Repository: conda/conda

Issue:
Add flag to build environment.yml without build strings
https://gitter.im/conda/conda?at=59ef54ebe44c43700a70e9a4
https://twitter.com/drvinceknight/status/922837449092542464?ref_src=twsrc%5Etfw

> Due to hashes of packages being introduced in `envinronment.yml` I'm getting all sorts of issues with building envs from file. (Very new problem)
test.py error "prefix is not writeable" 
While attempting to use "pyinstaller" to create a standalone conda-index executable, the resulting binary failed in “conda/gateways/disk/test.py” prefix_is_writable because that method assumes the prefix is a python root directory, and in the case of the standalone, the “prefix” is the executable’s current directory. If I add append sys.executable to the end of the “find_first_existing” call parameter list, then it worked as expected.

Of course, this particular writability check seems unnecessary for conda-index, because the only writing is to the target, not the conda-meta directory.

Any change that would allow the creation of a standalone conda-index is appreciated.


Discussion:

Resolving patch:
diff --git a/conda/base/context.py b/conda/base/context.py
--- a/conda/base/context.py
+++ b/conda/base/context.py
@@ -11,7 +11,7 @@
 from .constants import (APP_NAME, DEFAULTS_CHANNEL_NAME, DEFAULT_CHANNELS, DEFAULT_CHANNEL_ALIAS,
                         ERROR_UPLOAD_URL, PLATFORM_DIRECTORIES, PathConflict, ROOT_ENV_NAME,
                         SEARCH_PATH, SafetyChecks)
-from .. import __version__ as CONDA_VERSION
+from .. import __version__ as CONDA_VERSION, CondaError
 from .._vendor.appdirs import user_data_dir
 from .._vendor.auxlib.collection import frozendict
 from .._vendor.auxlib.decorators import memoize, memoizedproperty
@@ -312,7 +312,13 @@ def root_dir(self):
     @property
     def root_writable(self):
         from ..gateways.disk.test import prefix_is_writable
-        return prefix_is_writable(self.root_prefix)
+        try:
+            return prefix_is_writable(self.root_prefix)
+        except CondaError:  # pragma: no cover
+            # With pyinstaller, conda code can sometimes be called even though sys.prefix isn't
+            # a conda environment with a conda-meta/ directory.  In this case, it's safe to return
+            # False, because conda shouldn't itself be mutating that environment. See #6243
+            return False
 
     @property
     def envs_dirs(self):
diff --git a/conda/resolve.py b/conda/resolve.py
--- a/conda/resolve.py
+++ b/conda/resolve.py
@@ -570,9 +570,13 @@ def generate_package_count(self, C, missing):
         return {self.push_MatchSpec(C, nm): 1 for nm in missing}
 
     def generate_version_metrics(self, C, specs, include0=False):
-        eqc = {}  # a C.minimize() objective: Dict[varname, coeff]
-        eqv = {}  # a C.minimize() objective: Dict[varname, coeff]
-        eqb = {}  # a C.minimize() objective: Dict[varname, coeff]
+        # each of these are weights saying how well packages match the specs
+        #    format for each: a C.minimize() objective: Dict[varname, coeff]
+        eqc = {}  # channel
+        eqv = {}  # version
+        eqb = {}  # build number
+        eqt = {}  # timestamp
+
         sdict = {}  # Dict[package_name, Dist]
 
         for s in specs:
@@ -594,18 +598,21 @@ def generate_version_metrics(self, C, specs, include0=False):
                 if targets and any(dist == t for t in targets):
                     continue
                 if pkey is None:
-                    ic = iv = ib = 0
+                    ic = iv = ib = it = 0
                 # valid package, channel priority
                 elif pkey[0] != version_key[0] or pkey[1] != version_key[1]:
                     ic += 1
-                    iv = ib = 0
+                    iv = ib = it = 0
                 # version
                 elif pkey[2] != version_key[2]:
                     iv += 1
-                    ib = 0
-                # build number, timestamp
-                elif pkey[3] != version_key[3] or pkey[4] != version_key[4]:
+                    ib = it = 0
+                # build number
+                elif pkey[3] != version_key[3]:
                     ib += 1
+                    it = 0
+                elif pkey[4] != version_key[4]:
+                    it += 1
 
                 if ic or include0:
                     eqc[dist.full_name] = ic
@@ -613,9 +620,11 @@ def generate_version_metrics(self, C, specs, include0=False):
                     eqv[dist.full_name] = iv
                 if ib or include0:
                     eqb[dist.full_name] = ib
+                if it or include0:
+                    eqt[dist.full_name] = it
                 pkey = version_key
 
-        return eqc, eqv, eqb
+        return eqc, eqv, eqb, eqt
 
     def dependency_sort(self, must_have):
         # type: (Dict[package_name, Dist]) -> List[Dist]
@@ -865,10 +874,10 @@ def mysat(specs, add_if=False):
             log.debug('Package removal metric: %d', obj7)
 
         # Requested packages: maximize versions
-        eq_req_c, eq_req_v, eq_req_b = r2.generate_version_metrics(C, specr)
+        eq_req_c, eq_req_v, eq_req_b, eq_req_t = r2.generate_version_metrics(C, specr)
         solution, obj3a = C.minimize(eq_req_c, solution)
         solution, obj3 = C.minimize(eq_req_v, solution)
-        log.debug('Initial package version metric: %d/%d', obj3a, obj3)
+        log.debug('Initial package channel/version metric: %d/%d', obj3a, obj3)
 
         # Track features: minimize feature count
         eq_feature_count = r2.generate_feature_count(C)
@@ -897,11 +906,17 @@ def mysat(specs, add_if=False):
         log.debug('Dependency update count: %d', obj50)
 
         # Remaining packages: maximize versions, then builds
-        eq_c, eq_v, eq_b = r2.generate_version_metrics(C, speca)
+        eq_c, eq_v, eq_b, eq_t = r2.generate_version_metrics(C, speca)
         solution, obj5a = C.minimize(eq_c, solution)
         solution, obj5 = C.minimize(eq_v, solution)
         solution, obj6 = C.minimize(eq_b, solution)
-        log.debug('Additional package version/build metrics: %d/%d/%d', obj5a, obj5, obj6)
+        log.debug('Additional package channel/version/build metrics: %d/%d/%d',
+                  obj5a, obj5, obj6)
+
+        # Maximize timestamps
+        eq_t.update(eq_req_t)
+        solution, obj6t = C.minimize(eq_t, solution)
+        log.debug('Timestamp metric: %d', obj6t)
 
         # Prune unnecessary packages
         eq_c = r2.generate_package_count(C, specm)
diff --git a/conda_env/env.py b/conda_env/env.py
--- a/conda_env/env.py
+++ b/conda_env/env.py
@@ -51,9 +51,9 @@ def from_environment(name, prefix, no_builds=False, ignore_channels=False):
     pip_pkgs = sorted(installed - conda_pkgs)
 
     if no_builds:
-        dependencies = ['='.join(a.quad[0:3]) for a in sorted(conda_pkgs)]
+        dependencies = ['='.join((a.name, a.version)) for a in sorted(conda_pkgs)]
     else:
-        dependencies = ['='.join(a.quad[0:3]) for a in sorted(conda_pkgs)]
+        dependencies = ['='.join((a.name, a.version, a.build)) for a in sorted(conda_pkgs)]
     if len(pip_pkgs) > 0:
         dependencies.append({'pip': ['=='.join(a.rsplit('-', 2)[:2]) for a in pip_pkgs]})
     # conda uses ruamel_yaml which returns a ruamel_yaml.comments.CommentedSeq


Test patch:
diff --git a/conda/gateways/disk/test.py b/conda/gateways/disk/test.py
--- a/conda/gateways/disk/test.py
+++ b/conda/gateways/disk/test.py
@@ -4,6 +4,7 @@
 from logging import getLogger
 from os import W_OK, access
 from os.path import basename, dirname, isdir, isfile, join
+from uuid import uuid4
 
 from .create import create_link
 from .delete import rm_rf, try_rmdir_all_empty
@@ -12,6 +13,7 @@
 from .update import touch
 from ..._vendor.auxlib.decorators import memoize
 from ...base.constants import PREFIX_MAGIC_FILE
+from ...common.compat import text_type
 from ...common.path import expand, get_python_short_path
 from ...models.enums import LinkType
 
@@ -91,7 +93,7 @@ def hardlink_supported(source_file, dest_dir):
     # file system configuration, a symbolic link may be created
     # instead. If a symbolic link is created instead of a hard link,
     # return False.
-    test_file = join(dest_dir, '.tmp.' + basename(source_file))
+    test_file = join(dest_dir, '.tmp.%s.%s' % (basename(source_file), text_type(uuid4())[:8]))
     assert isfile(source_file), source_file
     assert isdir(dest_dir), dest_dir
     if lexists(test_file):
diff --git a/tests/conda_env/test_cli.py b/tests/conda_env/test_cli.py
--- a/tests/conda_env/test_cli.py
+++ b/tests/conda_env/test_cli.py
@@ -16,6 +16,7 @@
 from conda.install import rm_rf
 from conda_env.cli.main import create_parser
 from conda_env.exceptions import SpecNotFound
+from conda_env.yaml import load as yaml_load
 
 environment_1 = '''
 name: env-1
@@ -240,7 +241,7 @@ def test_create_remove_env(self):
         self.assertFalse(env_is_created(test_env_name_3))
 
 
-    def test_export(self):
+    def test_env_export(self):
         """
             Test conda env export
         """
@@ -254,11 +255,20 @@ def test_export(self):
             env_yaml.write(snowflake)
             env_yaml.flush()
             env_yaml.close()
+
             run_env_command(Commands.ENV_REMOVE, test_env_name_2)
             self.assertFalse(env_is_created(test_env_name_2))
             run_env_command(Commands.ENV_CREATE, env_yaml.name)
             self.assertTrue(env_is_created(test_env_name_2))
 
+            # regression test for #6220
+            snowflake, e, = run_env_command(Commands.ENV_EXPORT, test_env_name_2, '--no-builds')
+            assert not e.strip()
+            env_description = yaml_load(snowflake)
+            assert len(env_description['dependencies'])
+            for spec_str in env_description['dependencies']:
+                assert spec_str.count('=') == 1
+
     def test_list(self):
         """
             Test conda list -e and conda create from txt
diff --git a/tests/test_resolve.py b/tests/test_resolve.py
--- a/tests/test_resolve.py
+++ b/tests/test_resolve.py
@@ -158,7 +158,7 @@ def test_generate_eq_1():
     reduced_index = r.get_reduced_index(['anaconda'])
     r2 = Resolve(reduced_index, True, True)
     C = r2.gen_clauses()
-    eqc, eqv, eqb = r2.generate_version_metrics(C, list(r2.groups.keys()))
+    eqc, eqv, eqb, eqt = r2.generate_version_metrics(C, list(r2.groups.keys()))
     # Should satisfy the following criteria:
     # - lower versions of the same package should should have higher
     #   coefficients.
@@ -170,6 +170,7 @@ def test_generate_eq_1():
     eqc = {Dist(key).to_filename(): value for key, value in iteritems(eqc)}
     eqv = {Dist(key).to_filename(): value for key, value in iteritems(eqv)}
     eqb = {Dist(key).to_filename(): value for key, value in iteritems(eqb)}
+    eqt = {Dist(key).to_filename(): value for key, value in iteritems(eqt)}
     assert eqc == {}
     assert eqv == {
         'accelerate-1.0.0-np15py26_p0.tar.bz2': 2,
@@ -483,6 +484,9 @@ def test_generate_eq_1():
         'zeromq-2.2.0-0.tar.bz2': 1,
     }
 
+    # No timestamps in the current data set
+    assert eqt == {}
+
 
 def test_unsat():
     # scipy 0.12.0b1 is not built for numpy 1.5, only 1.6 and 1.7
@@ -499,6 +503,48 @@ def test_nonexistent():
     assert raises(ResolvePackageNotFound, lambda: r.install(['numpy 1.5']))
 
 
+def test_timestamps_and_deps():
+    # If timestamp maximization is performed too early in the solve optimization,
+    # it will force unnecessary changes to dependencies. Timestamp maximization needs
+    # to be done at low priority so that conda is free to consider packages with the
+    # same version and build that are most compatible with the installed environment.
+    index2 = {Dist(key): value for key, value in iteritems(index)}
+    index2[Dist('mypackage-1.0-hash12_0.tar.bz2')] = IndexRecord(**{
+        'build': 'hash27_0',
+        'build_number': 0,
+        'depends': ['libpng 1.2.*'],
+        'name': 'mypackage',
+        'requires': ['libpng 1.2.*'],
+        'version': '1.0',
+        'timestamp': 1,
+    })
+    index2[Dist('mypackage-1.0-hash15_0.tar.bz2')] = IndexRecord(**{
+        'build': 'hash15_0',
+        'build_number': 0,
+        'depends': ['libpng 1.5.*'],
+        'name': 'mypackage',
+        'requires': ['libpng 1.5.*'],
+        'version': '1.0',
+        'timestamp': 0,
+    })
+    r = Resolve(index2)
+    installed1 = r.install(['libpng 1.2.*', 'mypackage'])
+    print([k.dist_name for k in installed1])
+    assert any(k.name == 'libpng' and k.version.startswith('1.2') for k in installed1)
+    assert any(k.name == 'mypackage' and k.build == 'hash12_0' for k in installed1)
+    installed2 = r.install(['libpng 1.5.*', 'mypackage'])
+    assert any(k.name == 'libpng' and k.version.startswith('1.5') for k in installed2)
+    assert any(k.name == 'mypackage' and k.build == 'hash15_0' for k in installed2)
+    # this is testing that previously installed reqs are not disrupted by newer timestamps.
+    #   regression test of sorts for https://github.com/conda/conda/issues/6271
+    installed3 = r.install(['mypackage'], r.install(['libpng 1.2.*']))
+    assert installed1 == installed3
+    installed4 = r.install(['mypackage'], r.install(['libpng 1.5.*']))
+    assert installed2 == installed4
+    # unspecified python version should maximize libpng (v1.5), even though it has a lower timestamp
+    installed5 = r.install(['mypackage'])
+    assert installed2 == installed5
+
 def test_nonexistent_deps():
     index2 = index.copy()
     index2['mypackage-1.0-py33_0.tar.bz2'] = IndexRecord(**{
@@ -1169,7 +1215,7 @@ def test_channel_priority_2():
         dists = this_r.get_reduced_index(spec)
         r2 = Resolve(dists, True, True, channels=channels)
         C = r2.gen_clauses()
-        eqc, eqv, eqb = r2.generate_version_metrics(C, list(r2.groups.keys()))
+        eqc, eqv, eqb, eqt = r2.generate_version_metrics(C, list(r2.groups.keys()))
         eqc = {str(Dist(key)): value for key, value in iteritems(eqc)}
         assert eqc == {
             'channel-3::openssl-1.0.1c-0': 1,
@@ -1266,7 +1312,7 @@ def test_channel_priority_2():
         dists = this_r.get_reduced_index(spec)
         r2 = Resolve(dists, True, True, channels=channels)
         C = r2.gen_clauses()
-        eqc, eqv, eqb = r2.generate_version_metrics(C, list(r2.groups.keys()))
+        eqc, eqv, eqb, eqt = r2.generate_version_metrics(C, list(r2.groups.keys()))
         eqc = {str(Dist(key)): value for key, value in iteritems(eqc)}
         assert eqc == {
             'channel-1::accelerate-1.0.0-np16py27_p0': 2,