Repository: conda/conda-build

Issue:
empty host loses imposed deps from build run_exports
These libraries are expected to be present when compiling, but if there are no other host dependencies, conda-build discards the host section completely and creates no environment for it.  Conda-build needs to resolve all run_exports prior to env creation, and create the host env even when the only deps are those that are imposed by run_exports in the build section (i.e. "strong" run_exports)


Discussion:
Faced this issue while figuring out why the new bad-linkage detection logic was giving me false negatives.

On macOS:
```
WARNING (mysql-connector-c,bin/perror): Needed DSO /usr/lib/libc++.1.dylib not found in any CDT/compiler package, nor the whitelist?!
   INFO (mysql-connector-c,bin/perror): Needed DSO /usr/lib/libSystem.B.dylib found in the whitelist
WARNING (mysql-connector-c,bin/my_print_defaults): Needed DSO /usr/lib/libc++.1.dylib not found in any CDT/compiler package, nor the whitelist?!
   INFO (mysql-connector-c,bin/my_print_defaults): Needed DSO /usr/lib/libSystem.B.dylib found in the whitelist
WARNING (mysql-connector-c,lib/libmysqlclient.18.dylib): Needed DSO /usr/lib/libc++.1.dylib not found in any CDT/compiler package, nor the whitelist?!
   INFO (mysql-connector-c,lib/libmysqlclient.18.dylib): Needed DSO /usr/lib/libSystem.B.dylib found in the whitelist
```

But otool seemed happy:
```
Load command 10
          cmd LC_LOAD_DYLIB
      cmdsize 48
         name @rpath/libc++.1.dylib (offset 24)
   time stamp 2 Wed Dec 31 18:00:02 1969
      current version 1.0.0
compatibility version 1.0.0
Load command 11
          cmd LC_LOAD_DYLIB
      cmdsize 56
         name /usr/lib/libSystem.B.dylib (offset 24)
   time stamp 2 Wed Dec 31 18:00:02 1969
      current version 1197.1.1
compatibility version 1.0.0
Load command 12
          cmd LC_RPATH
      cmdsize 272
         path /Users/nwani/m3/envs/mysql/lib (offset 12)
Load command 13
      cmd LC_FUNCTION_STARTS
  cmdsize 16
  dataoff 4133408
 datasize 3544
Load command 14
      cmd LC_DATA_IN_CODE
  cmdsize 16
  dataoff 4136952
 datasize 424
Load command 15
          cmd LC_RPATH
      cmdsize 32
         path @loader_path/ (offset 12)
```

Similarly on ppc64le:
```
 INFO (mysql-connector-c,bin/my_print_defaults): Needed DSO powerpc64le-conda_cos7-linux-gnu/sysroot/lib/libpthread.so.0 found in CDT/compiler package defaults::gcc_impl_linux-ppc64le-7.2.0-h3f999cd_2
ERROR (mysql-connector-c,bin/my_print_defaults): /lib64/libgcc_s.so.1 not found in sysroot, is this binary repackaging? .. do you need to use install_name_tool/patchelf?
 INFO (mysql-connector-c,bin/my_print_defaults): Needed DSO powerpc64le-conda_cos7-linux-gnu/sysroot/lib/libc.so.6 found in CDT/compiler package defaults::gcc_impl_linux-ppc64le-7.2.0-h3f999cd_2
 INFO (mysql-connector-c,bin/my_print_defaults): Needed DSO powerpc64le-conda_cos7-linux-gnu/sysroot/lib/libstdc++.so.6 found in CDT/compiler package defaults::libstdcxx-ng-7.2.0-h7a57d05_2
 INFO (mysql-connector-c,bin/perror): Needed DSO powerpc64le-conda_cos7-linux-gnu/sysroot/lib/libpthread.so.0 found in CDT/compiler package defaults::gcc_impl_linux-ppc64le-7.2.0-h3f999cd_2
```

But ldd seemed happy:
```
(mysql) [nwani@host-10-25-0-21 ~]$ chrpath -l $(which perror)
/home/nwani/m3/envs/mysql/bin/perror: RPATH=$ORIGIN/../lib

(mysql) [nwani@host-10-25-0-21 ~]$ ldd $(which perror)
	linux-vdso64.so.1 =>  (0x00003fff8d8e0000)
	libpthread.so.0 => /lib64/libpthread.so.0 (0x00003fff8d4b0000)
	libstdc++.so.6 => /home/nwani/m3/envs/mysql/bin/../lib/libstdc++.so.6 (0x00003fff8d300000)
	libgcc_s.so.1 => /home/nwani/m3/envs/mysql/bin/../lib/libgcc_s.so.1 (0x00003fff8d2d0000)
	libc.so.6 => /lib64/libc.so.6 (0x00003fff8d0e0000)
	/lib64/ld64.so.2 (0x0000000077a60000)
	libm.so.6 => /lib64/libm.so.6 (0x00003fff8cff0000)
```
`pyldd` is designed to deliberately ignore the build prefix and look for libs in the host prefix only (when not merging host and build prefixes), it also explicitly errors when it finds the DSOs but they're only transitive dependencies because all auto-loaded DSOs *must* come from direct run dependencies..

.. so to say that those tools are 'happy' isn't *really* the case, rather the checks they perform are less comprehensive because they do not have any idea about `conda`/`conda-build` concepts.

Resolving patch:
diff --git a/conda_build/render.py b/conda_build/render.py
--- a/conda_build/render.py
+++ b/conda_build/render.py
@@ -28,7 +28,6 @@
 from .conda_interface import conda_43
 from .conda_interface import specs_from_url
 from .conda_interface import memoized
-from .conda_interface import VersionOrder
 
 from conda_build import exceptions, utils, environ
 from conda_build.metadata import MetaData, combine_top_level_metadata_with_output
@@ -359,6 +358,8 @@ def add_upstream_pins(m, permit_unsatisfiable_variants, exclude_pattern):
         extra_run_specs = set(extra_run_specs_from_build.get('strong', []))
         if not m.uses_new_style_compiler_activation:
             extra_run_specs.update(extra_run_specs_from_build.get('weak', []))
+        else:
+            host_deps = set(extra_run_specs_from_build.get('strong', []))
 
     run_deps = extra_run_specs | set(utils.ensure_list(requirements.get('run')))
 
@@ -370,78 +371,38 @@ def add_upstream_pins(m, permit_unsatisfiable_variants, exclude_pattern):
     return build_unsat, host_unsat
 
 
-class _Constraint(object):
-    def __init__(self, lower_bound=None, upper_bound=None, exact=None):
-        self.lower_bound = VersionOrder(lower_bound) if lower_bound else None
-        self.upper_bound = VersionOrder(upper_bound) if upper_bound else None
-        self.exact = exact
-
-    def __str__(self):
-        pin = ""
-        if self.exact:
-            pin = self.exact
-        elif self.lower_bound:
-            pin = ">=" + self.lower_bound.norm_version + (
-                ',<' + self.upper_bound.norm_version) if self.upper_bound else ""
-        elif self.upper_bound:
-            pin = "<" + self.upper_bound.norm_version
-        return pin
-
-
-def _minimize_constraints(one, two):
-    if one.lower_bound and two.lower_bound:
-        lower_bound = max(VersionOrder(one.lower_bound), VersionOrder(two.lower_bound))
-    else:
-        lower_bound = one.lower_bound if one.lower_bound else two.lower_bound
-    if one.upper_bound and two.upper_bound:
-        upper_bound = min(VersionOrder(one.upper_bound), VersionOrder(two.upper_bound))
-    else:
-        upper_bound = one.upper_bound if one.upper_bound else two.upper_bound
-    if one.exact == two.exact or any(x.exact is None for x in (one, two)):
-        exact = one.exact if one.exact else two.exact
-    elif one.exact != two.exact:
-        raise ValueError("Incompatible exact pins: {} and {}".format(one, two))
-    return _Constraint(lower_bound=lower_bound, upper_bound=upper_bound, exact=exact)
-
-
-def _translate_equal_or_star_to_bounded(spec_text):
-    version = re.search(r'(?:==)([\w\.]+)|([\w\.]+)(?:\*)', spec_text)
-    if version:
-        version = version.group(1) if version.group(1) else version.group(2)
-        spec_text = version.rstrip('.')
-        spec_text = utils.apply_pin_expressions(spec_text, max_pin='x.x.x.x.x.x.x.x.x.x.x')
-    return spec_text
-
-
-def _simplify_to_tightest_constraint(metadata):
+def _simplify_to_exact_constraints(metadata):
+    """
+    For metapackages that are pinned exactly, we want to bypass all dependencies that may
+    be less exact.
+    """
     requirements = metadata.meta.get('requirements', {})
-    default_constraint = _Constraint(None, None, None)
     # collect deps on a per-section basis
     for section in 'build', 'host', 'run':
         deps = requirements.get(section, [])
-        deps_dict = defaultdict(_Constraint)
+        deps_dict = defaultdict(list)
         for dep in deps:
             spec_parts = utils.ensure_valid_spec(dep).split()
             name = spec_parts[0]
-            if len(spec_parts) == 1:
-                constraint = _Constraint(None, None, None)
-            elif len(spec_parts) == 3:
-                constraint = _Constraint(None, None, " ".join(spec_parts[1:]))
+            if len(spec_parts) > 1:
+                deps_dict[name].append(spec_parts[1:])
+            else:
+                deps_dict[name].append([])
+
+        deps_list = []
+        for name, values in deps_dict.items():
+            exact_pins = [dep for dep in values if len(dep) > 1]
+            if len(values) == 1 and not any(values):
+                deps_list.append(name)
+            elif exact_pins:
+                if not all(pin == exact_pins[0] for pin in exact_pins):
+                    raise ValueError("Conflicting exact pins: {}".format(exact_pins))
+                else:
+                    deps_list.append(' '.join([name] + exact_pins[0]))
             else:
-                version_spec = _translate_equal_or_star_to_bounded(spec_parts[1])
-                lower_bound = re.search(r'>=?(.*?(?=,|\Z))', version_spec)
-                if lower_bound:
-                    lower_bound = lower_bound.group(1)
-                upper_bound = re.search(r'<=?(.*?(?=,|\Z))', version_spec)
-                if upper_bound:
-                    upper_bound = upper_bound.group(1)
-                constraint = _Constraint(lower_bound, upper_bound, None)
-            deps_dict[name] = _minimize_constraints(deps_dict.get(name, default_constraint),
-                                                    constraint)
-        requirements[section] = [' '.join((spec, str(constraint))).rstrip() for spec, constraint in
-                                          deps_dict.items()]
-    if requirements:
-        metadata.meta['requirements'] = requirements
+                deps_list.extend(' '.join([name] + dep) for dep in values if dep)
+        requirements[section] = deps_list
+    metadata.meta['requirements'] = requirements
 
 
 def finalize_metadata(m, permit_unsatisfiable_variants=False):
@@ -566,7 +527,7 @@ def finalize_metadata(m, permit_unsatisfiable_variants=False):
         if not rendered_metadata.meta.get('build'):
             rendered_metadata.meta['build'] = {}
 
-        _simplify_to_tightest_constraint(rendered_metadata)
+        _simplify_to_exact_constraints(rendered_metadata)
 
         if build_unsat or host_unsat:
             rendered_metadata.final = False


Test patch:
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
@@ -900,8 +900,8 @@ def test_run_exports(testing_metadata, testing_config, testing_workdir):
     testing_metadata.meta['requirements']['build'] = ['test_has_run_exports']
     api.output_yaml(testing_metadata, 'meta.yaml')
     m = api.render(testing_workdir, config=testing_config)[0][0]
-    assert 'strong_pinned_package >=1.0,<1.1.0a0' in m.meta['requirements']['run']
-    assert 'weak_pinned_package >=1.0,<1.1.0a0' in m.meta['requirements']['run']
+    assert 'strong_pinned_package 1.0.*' in m.meta['requirements']['run']
+    assert 'weak_pinned_package 1.0.*' in m.meta['requirements']['run']
 
     #    2. host present.  Use run_exports from host, ignore 'weak' ones from build.  All are
     #           weak by default.
@@ -910,7 +910,7 @@ def test_run_exports(testing_metadata, testing_config, testing_workdir):
     testing_metadata.meta['requirements']['host'] = ['python']
     api.output_yaml(testing_metadata, 'host_present_weak/meta.yaml')
     m = api.render(os.path.join(testing_workdir, 'host_present_weak'), config=testing_config)[0][0]
-    assert 'weak_pinned_package >=2.0,<2.1.0a0' not in m.meta['requirements'].get('run', [])
+    assert 'weak_pinned_package 2.0.*' not in m.meta['requirements'].get('run', [])
 
     #    3. host present, and deps in build have "strong" run_exports section.  use host, add
     #           in "strong" from build.
@@ -920,11 +920,11 @@ def test_run_exports(testing_metadata, testing_config, testing_workdir):
     m = api.render(os.path.join(testing_workdir, 'host_present_strong'),
                    config=testing_config)[0][0]
     assert 'strong_pinned_package 1.0 0' in m.meta['requirements']['host']
-    assert 'strong_pinned_package >=1.0,<1.1.0a0' in m.meta['requirements']['run']
+    assert 'strong_pinned_package 1.0.*' in m.meta['requirements']['run']
     # weak one from test_has_run_exports should be excluded, since it is a build dep
-    assert 'weak_pinned_package >=1.0,<1.1.0a0' not in m.meta['requirements']['run']
+    assert 'weak_pinned_package 1.0.*' not in m.meta['requirements']['run']
     # weak one from test_has_run_exports_implicit_weak should be present, since it is a host dep
-    assert 'weak_pinned_package >=2.0,<2.1.0a0' in m.meta['requirements']['run']
+    assert 'weak_pinned_package 2.0.*' in m.meta['requirements']['run']
 
 
 def test_ignore_run_exports(testing_metadata, testing_config):
diff --git a/tests/test_render.py b/tests/test_render.py
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -16,27 +16,13 @@ def test_output_with_noarch_python_says_noarch(testing_metadata):
 
 
 def test_reduce_duplicate_specs(testing_metadata):
-    reqs = {'build': ['exact', 'exact 1.2.3 1', 'exact >1.0,<2',
-                      'bounded >=1.0,<2.0', 'bounded >=1.5', 'bounded <=1.8',
-                      'equals ==1.3', 'star  1.2*', 'star 1*', 'stardot 1.2.*', 'stardot 1.*'],
-            'host': ['exact', 'exact 1.2.3 1',
-                     'bounded >=1.0,<2.0', 'bounded >=1.5', 'bounded <=1.8',
-                     'equals >=1.3,<1.4.0a0', 'star >=1.2,<1.3.0a0', 'stardot >=1.2,<1.3.0a0'],
-            'run': ['exact', 'exact 1.2.3 1', 'bounded >=1.0,<2.0',
-                    'bounded >=1.5', 'bounded <=1.8',
-                    'equals >=1.3,<1.4.0a0', 'star >=1.2,<1.3.0a0', 'stardot >=1.2,<1.3.0a0'],
+    reqs = {'build': ['exact', 'exact 1.2.3 1', 'exact >1.0,<2'],
+            'host': ['exact', 'exact 1.2.3 1']
     }
     testing_metadata.meta['requirements'] = reqs
-    render._simplify_to_tightest_constraint(testing_metadata)
+    render._simplify_to_exact_constraints(testing_metadata)
     assert (testing_metadata.meta['requirements']['build'] ==
-            testing_metadata.meta['requirements']['host'] ==
-            testing_metadata.meta['requirements']['run'])
+            testing_metadata.meta['requirements']['host'])
     simplified_deps = testing_metadata.meta['requirements']
-    assert len(simplified_deps['build']) == 5
+    assert len(simplified_deps['build']) == 1
     assert 'exact 1.2.3 1' in simplified_deps['build']
-    # these get translated from the star syntax into bounded
-    assert 'star >=1.2,<1.3.0a0' in simplified_deps['build']
-    assert 'stardot >=1.2,<1.3.0a0' in simplified_deps['build']
-    # currently we lose the >= in the upper bound.  I don't think that's going to be a huge issue,
-    #     but we'll see.  I don't really want to track the bound - just the bound type (upper/lower)
-    assert 'bounded >=1.5,<1.8' in simplified_deps['build']
diff --git a/tests/test_subpackages.py b/tests/test_subpackages.py
--- a/tests/test_subpackages.py
+++ b/tests/test_subpackages.py
@@ -74,7 +74,7 @@ def test_run_exports_in_subpackage(testing_metadata):
     p2 = testing_metadata.copy()
     p2.meta['requirements']['host'] = ['has_run_exports']
     p2_final = finalize_metadata(p2)
-    assert 'bzip2 >=1.0,<1.1.0a0' in p2_final.meta['requirements']['run']
+    assert 'bzip2 1.0.*' in p2_final.meta['requirements']['run']
 
 
 def test_subpackage_variant_override(testing_config):