Repository: conda/conda-build

Issue:
Check that test/requires packages are available before building
I have some packages whose builds fail on certain platforms because packages required for the tests are not available. Currently, the failure is only reported after the package has been completely built. If possible, it would be nice to check if the test dependencies are satisfiable before running the build.



Discussion:
+1 - have encountered this a fair bit myself.  I will investigate.

Are there any updates on this issue? My package has a ton of dependencies and it is really a big hassle to figure this out manually
At least a proper exception would be nice
I believe we added a proper exception in https://github.com/conda/conda-build/commit/ffde7b7eec94941b4124555cff2a0e1d6f129305

Still, it would be much nicer to check the satisfiability of the test env at the same time that we check the satisfiability of the build env, to fail earlier.  @mandeep let's take a look at this.

Resolving patch:
diff --git a/conda_build/build.py b/conda_build/build.py
--- a/conda_build/build.py
+++ b/conda_build/build.py
@@ -888,6 +888,8 @@ def build(m, post=None, need_source_download=True, need_reparse_in_env=False, bu
         # installed files at packaging-time.
         host_ms_deps = None
         build_ms_deps = None
+        test_run_ms_deps = m.get_value('test/requires', []) + m.get_value('requirements/run', [])
+
         if m.config.host_subdir != m.config.build_subdir:
             if VersionOrder(conda_version) < VersionOrder('4.3.2'):
                 raise RuntimeError("Non-native subdir support only in conda >= 4.3.2")
@@ -922,6 +924,28 @@ def build(m, post=None, need_source_download=True, need_reparse_in_env=False, bu
                                                     max_env_retry=m.config.max_env_retry,
                                                     output_folder=m.config.output_folder,
                                                     channel_urls=tuple(m.config.channel_urls))
+
+        try:
+            # make sure test deps are available before taking time to create build env
+            environ.get_install_actions(m.config.test_prefix,
+                                        tuple(test_run_ms_deps), 'test',
+                                        subdir=m.config.build_subdir,
+                                        debug=m.config.debug,
+                                        verbose=m.config.verbose,
+                                        locking=m.config.locking,
+                                        bldpkgs_dirs=tuple(m.config.bldpkgs_dirs),
+                                        timeout=m.config.timeout,
+                                        disable_pip=m.config.disable_pip,
+                                        max_env_retry=m.config.max_env_retry,
+                                        output_folder=m.config.output_folder,
+                                        channel_urls=tuple(m.config.channel_urls))
+        except DependencyNeedsBuildingError as e:
+            # subpackages are not actually missing.  We just haven't built them yet.
+            missing_deps = set(e.packages) - set(out.name()
+                        for _, out in m.get_output_metadata_set(permit_undefined_jinja=True))
+            if missing_deps:
+                e.packages = missing_deps
+                raise e
         if (not m.config.dirty or not os.path.isdir(m.config.build_prefix) or
                 not os.listdir(m.config.build_prefix)):
             environ.create_env(m.config.build_prefix, build_actions, env='build', config=m.config,
@@ -1081,6 +1105,20 @@ def build(m, post=None, need_source_download=True, need_reparse_in_env=False, bu
                     output_d['files'] = (utils.prefix_files(prefix=m.config.host_prefix) -
                                          initial_files)
 
+                meta_dir = (m.meta_path or
+                            m.meta.get('extra', {}).get('parent_recipe', {}).get('path'))
+                if meta_dir and meta_dir.endswith('.yaml'):
+                    meta_dir = os.path.dirname(meta_dir)
+                # ensure that packaging scripts are copied over into the workdir
+                if 'script' in output_d and meta_dir:
+                    utils.copy_into(os.path.join(meta_dir, output_d['script']), m.config.work_dir)
+
+                # same thing, for test scripts
+                test_script = output_d.get('test', {}).get('script')
+                if test_script and meta_dir:
+                    utils.copy_into(os.path.join(meta_dir, test_script),
+                                    os.path.join(m.config.work_dir, test_script))
+
                 assert output_d.get('type') != 'conda' or m.final, (
                     "output metadata for {} is not finalized".format(m.dist()))
                 pkg_path = bldpkg_path(m)
diff --git a/conda_build/exceptions.py b/conda_build/exceptions.py
--- a/conda_build/exceptions.py
+++ b/conda_build/exceptions.py
@@ -1,5 +1,4 @@
 import textwrap
-from conda_build import conda_interface
 SEPARATOR = "-" * 70
 
 indent = lambda s: textwrap.fill(textwrap.dedent(s))
@@ -61,8 +60,8 @@ def __init__(self, error, script, *args):
 
 class DependencyNeedsBuildingError(CondaBuildException):
     def __init__(self, conda_exception=None, packages=None, subdir=None, *args, **kwargs):
+        self.subdir = subdir
         if packages:
-            self.message = "Unsatisfiable dependencies: {}".format(packages)
             self.packages = packages
         else:
             self.packages = packages or []
@@ -72,10 +71,6 @@ def __init__(self, conda_exception=None, packages=None, subdir=None, *args, **kw
                 pkg = line.lstrip('  - ').split(' -> ')[-1]
                 pkg = pkg.strip().split(' ')[0]
                 self.packages.append(pkg)
-            conda_exception_text = str(conda_exception)
-            if subdir:
-                conda_exception_text = conda_exception_text.replace(conda_interface.subdir, subdir)
-            self.message = conda_exception_text
         if not self.packages:
             raise RuntimeError("failed to parse packages from exception:"
                                " {}".format(str(conda_exception)))
@@ -83,6 +78,10 @@ def __init__(self, conda_exception=None, packages=None, subdir=None, *args, **kw
     def __str__(self):
         return self.message
 
+    @property
+    def message(self):
+        return "Unsatisfiable dependencies for platform {}: {}".format(self.subdir, self.packages)
+
 
 class RecipeError(CondaBuildException):
     pass
diff --git a/conda_build/metadata.py b/conda_build/metadata.py
--- a/conda_build/metadata.py
+++ b/conda_build/metadata.py
@@ -1535,16 +1535,6 @@ def get_output_metadata(self, output):
             build['run_exports'] = output['run_exports']
             output_metadata.meta['build'] = build
 
-        # ensure that packaging scripts are copied over into the workdir
-        if 'script' in output:
-            utils.copy_into(os.path.join(self.path, output['script']), self.config.work_dir)
-
-        # same thing, for test scripts
-        test_script = output.get('test', {}).get('script')
-        if test_script:
-            utils.copy_into(os.path.join(self.path, test_script),
-                            os.path.join(self.config.work_dir, test_script))
-
         return output_metadata
 
     def get_output_metadata_set(self, permit_undefined_jinja=False,


Test patch:
diff --git a/tests/test-recipes/fail/check_runtime_dependencies/meta.yaml b/tests/test-recipes/fail/check_runtime_dependencies/meta.yaml
new file mode 100644
--- /dev/null
+++ b/tests/test-recipes/fail/check_runtime_dependencies/meta.yaml
@@ -0,0 +1,18 @@
+package:
+  name: conda-build-test-depedencies
+  version: 1.0
+
+build:
+  string: abc
+
+requirements:
+  build:
+    - python
+  run:
+    - python
+    - some-nonexistent-package1
+
+test:
+  requires:
+    - pytest
+    - pytest-cov
\ No newline at end of file
diff --git a/tests/test-recipes/fail/check_test_dependencies/meta.yaml b/tests/test-recipes/fail/check_test_dependencies/meta.yaml
new file mode 100644
--- /dev/null
+++ b/tests/test-recipes/fail/check_test_dependencies/meta.yaml
@@ -0,0 +1,18 @@
+package:
+  name: conda-build-test-depedencies
+  version: 1.0
+
+build:
+  string: abc
+
+requirements:
+  build:
+    - python
+  run:
+    - python
+
+test:
+  requires:
+    - pytest
+    - pytest-cov
+    - pytest-package-does-not-exist
\ No newline at end of file
diff --git a/tests/test_api_build.py b/tests/test_api_build.py
--- a/tests/test_api_build.py
+++ b/tests/test_api_build.py
@@ -1030,3 +1030,23 @@ def test_no_locking(testing_config):
     api.update_index(os.path.join(testing_config.croot, testing_config.subdir),
                      config=testing_config)
     api.build(recipe, config=testing_config, locking=False)
+
+
+def test_test_dependencies(testing_workdir, testing_config):
+    recipe = os.path.join(fail_dir, 'check_test_dependencies')
+
+    with pytest.raises(exceptions.DependencyNeedsBuildingError) as e:
+        api.build(recipe, config=testing_config)
+
+    assert 'Unsatisfiable dependencies for platform ' in str(e.value)
+    assert 'pytest-package-does-not-exist' in str(e.value)
+
+
+def test_runtime_dependencies(testing_workdir, testing_config):
+    recipe = os.path.join(fail_dir, 'check_runtime_dependencies')
+
+    with pytest.raises(exceptions.DependencyNeedsBuildingError) as e:
+        api.build(recipe, config=testing_config)
+
+    assert 'Unsatisfiable dependencies for platform ' in str(e.value)
+    assert 'some-nonexistent-package1' in str(e.value)