Repository: conda/conda

Issue:
[ENH] Compare environments
I find I am often in a situation where I want to know if my current Conda environment matches the environment specification of an `environment.yml` file or a `requirements.txt` file or some other specification of Conda packages.  Why?  Because as much as I love Conda environments, sometimes I'd like to know:

*"Can I just run this in my current environment without changing anything? Because if so, then I know others can also run this thing in this same environment that I know they have access to, rather than having to complicate my life and theirs with instructions on how to augment their current environment appropriately."*

So off the top of my head this would mean the ability to do something like:

```
conda compare environment.yml # report on delta between current environment and environment.yml
conda compare foo bar # where foo and bar are two named environments
```

But as it stands I end up writing some complicated `conda list -e | grep` statements by hand, after doing `cat environment.yml`.


Discussion:
This would be really great.

I often encounter problems with parallelization where some commands are executed on a Jupyterhub server and some on a cluster that it is connected to via ssh.

In order to run everything successfully, I need to ensure that the environments on both machines are the same. 

Right now I do it by just comparing the outputs of `conda list --export`.
I agree this would be nice to have - I came across a bug in pytest/py and it took me a while to figure out where the problem was - a side by side comparison like this would be really helpful - 

```
> conda compare root fooenv

# installed packages
                      root      fooenv
alabaster             0.7.10     
anaconda              5.0.1       
anaconda-client       1.6.5       
anaconda-navigator    1.6.9       
anaconda-project      0.8.0       
asn1crypto            0.22.0    0.22.0
astroid               1.5.3     
astropy               2.0.2       
attrs                           17.3.0
babel                 2.5.0       
```





I find it quite comfortable to use a diff tool of my choice, i.e.,
```bash
$diffTool <(conda list -n env1) <(conda list -n env2)
```
or for environment files
```bash
$diffTool env1.yml <(conda list -en env2)
```
with `$diffTool` being any of `diff`, `diff -y`, `sdiff`, `meld`, ...
That way use can even use a tool to do 3-way compares, like
```bash
meld base.yml <(conda list -en env1) <(conda list -en env2)
```
IMHO, a `conda compare` (if needed at all) could just be simple wrapper around a diff tool (e.g., given by a parameter `--diff-tool=sdiff`). So the only own functionality would just have to be parsing its arguments to determine whether they are YAML files or environment names/paths.
I was just using `diff` on the output of `conda list`-- and it was actually pretty unhelpful. I guess the problem is that the two environments are too different -- one had a bunch of extra packages, as well as maybe some different versions of the same package -- so the diff was pretty ugly.

I;d live a tool that gave a me a clean difference:

- these packages are in a and not b
- these packages are in b and not a
- these packages are in both but different versions

OK -- off to write that ----

-CHB


DONE.

Here is a hacked-together python script that compares two conda environments:

https://gist.github.com/ChrisBarker-NOAA/00106a2af2029259ba7496f865c39086

It would be great if similar functionality could be built in to conda.
PR for conda welcome Chris!

On Wed, Sep 19, 2018, 9:15 AM Chris Barker <notifications@github.com> wrote:

> DONE.
>
> Here is a hacked-together python script that compares two conda
> environments:
>
> https://gist.github.com/ChrisBarker-NOAA/00106a2af2029259ba7496f865c39086
>
> IT would be great if similar functionality could be built in to conda.
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/conda/conda/issues/3781#issuecomment-422703511>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AA_pdBt1j6UX6MuJ1GwP9b78LIXMy9Bbks5ucf0ogaJpZM4KmHBb>
> .
>

@mingwandroid: yeah, that would be nice. But would require familiarizing myself with the conda code first -- hopefully I'll have time some day, but don't hold your breath :-( 

But nice to know you're open to the idea -- if anyone else has the rountoits, feel free to get ideas from my code -- I was pleasantly surprised how easy it was to use a sets to get out what I wanted -- I'd hardly ever used sets for anything meaningful before ...
I had an issue on this topic. When I typed `conda info --envs`, I was given a list that included two different environments with the same name, except one was capitalized. I don't know how I got myself into this predicament. I ran @ChrisBarker-NOAA's tool on these two envs, and they were identical. Here's my stupid part: I tried to delete one, and it deleted both.
This was on a Mac. 


The Mac is weird. It has a case insensitive, but case preserving file system. 

This behaves oddly with *nix tools that are case sensitive. 

I’m not sure conda can prevent these oddities.
I would like to work on this issue. I encountered a similar issue to what the OP did and have written this script: https://github.com/sidhant007/DiffConda to compare between the current conda env and a particular yml config file.

OP's motivation was to be able to check if a condo environment is capable of running what is mentioned in an environment specification (a .yml file)

I think the use-case that @ChrisBarker-NOAA highlighted, i.e to be able to compare two different conda envs is less-interesting because if the user already has the two conda environments created then they will just switch to the one required, on the contrary the OP's aim was to avoid creating the second environment if the .yml file showed that is a subset of the current environment.

So for the first iteration (a basic version of conda compare), I would like to propose this:
1. `conda compare environment.yml` - Reports packages lacking in the current environment required by `environment.yml` and if there are any version mismatches.
2. `conda compare -n myenv environment.yml` - Same as above except in this we consider the environment as `myenv`

Implementation wise: It will be similar to how `conda list` is written, since this is also a command that won't change anything on the backend. We use [package match specifications](https://docs.conda.io/projects/conda-build/en/latest/resources/package-spec.html#package-match-specifications) to define whether a package in the current environment "matches" with the one mentioned in the `environment.yml`.

Are they are any suggestions/objections to such a feature?
Well, what you find interesting depends on what you need to do :-) -- in my case, I knew that one of the environments I had didn't work, but I did't know why. The problem is that if you build two  environments from the same spec at different times, you get a different result if some packages have been updated and aren't pinned. or if you had an environment and added packages one by one.

Anyway, what people find interesting aside, the ability to compare environments is useful, and it would be good if it could be done from a environment file OR a live environment with the same code.

But what is being asked for now is another step: testing whether a given application can run in a given environment, which would require, as stated, using package match specifications, which means checking against a requirements file, not an environemnt.yaml.

checking: "does anything need to be changed in this environment to fit this spec" has got to be in conda already.

I'm pretty that in the general case, there is no robust way to say whether an application that can run in one environment will run in another one without knowing its requirements (unless the environments are identical) -- there's no way to know what particular version it might need otherwise. So:

> OP's motivation was to be able to check if a condo environment is capable of running what is mentioned in an environment specification (a .yml file)

that depends on the environment specification -- is in in "match" form, which could work, or a full, everything pinned version, which would not work in the general case.

-CHB










Resolving patch:
diff --git a/conda/cli/conda_argparse.py b/conda/cli/conda_argparse.py
--- a/conda/cli/conda_argparse.py
+++ b/conda/cli/conda_argparse.py
@@ -55,6 +55,7 @@ def generate_parser():
     sub_parsers.required = True
 
     configure_parser_clean(sub_parsers)
+    configure_parser_compare(sub_parsers)
     configure_parser_config(sub_parsers)
     configure_parser_create(sub_parsers)
     configure_parser_help(sub_parsers)
@@ -860,6 +861,39 @@ def configure_parser_list(sub_parsers):
     )
     p.set_defaults(func='.main_list.execute')
 
+def configure_parser_compare(sub_parsers):
+    descr = "Compare packages between conda environments."
+
+    # Note, the formatting of this is designed to work well with help2man
+    examples = dedent("""
+    Examples:
+
+    Compare packages in the current environment with respect to 'environment.yml':
+
+        conda compare environment.yml
+
+    Compare packages installed into the environment 'myenv' with respect to 'environment.yml':
+
+        conda compare -n myenv environment.yml
+
+    """)
+    p = sub_parsers.add_parser(
+        'compare',
+        description=descr,
+        help=descr,
+        formatter_class=RawDescriptionHelpFormatter,
+        epilog=examples,
+        add_help=False,
+    )
+    add_parser_help(p)
+    add_parser_json(p)
+    add_parser_prefix(p)
+    p.add_argument(
+        'file',
+        action="store",
+        help="Path to the environment file that is to be compared against",
+    )
+    p.set_defaults(func='.main_compare.execute')
 
 def configure_parser_package(sub_parsers):
     descr = "Low-level conda package utility. (EXPERIMENTAL)"
diff --git a/conda/cli/main_compare.py b/conda/cli/main_compare.py
new file mode 100644
--- /dev/null
+++ b/conda/cli/main_compare.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2012 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+import os
+
+from .common import stdout_json
+from ..base.context import context
+from ..common.compat import text_type
+from ..core.prefix_data import PrefixData
+from ..gateways.connection.session import CONDA_SESSION_SCHEMES
+from ..gateways.disk.test import is_conda_environment
+from .._vendor.auxlib.path import expand
+from conda_env import exceptions, specs
+from ..models.match_spec import MatchSpec
+
+log = logging.getLogger(__name__)
+
+def get_packages(prefix):
+    if not os.path.isdir(prefix):
+        from ..exceptions import EnvironmentLocationNotFound
+        raise EnvironmentLocationNotFound(prefix)
+
+    return sorted(PrefixData(prefix, pip_interop_enabled=True).iter_records(),
+                  key=lambda x: x.name)
+
+def _get_name_tuple(pkg):
+    return pkg.name, pkg
+
+def _to_str(pkg):
+    return "%s==%s=%s" % (pkg.name, pkg.version, pkg.build)
+
+def compare_packages(active_pkgs, specification_pkgs):
+    output = []
+    res = 0
+    ok = True
+    for pkg in specification_pkgs:
+        pkg_spec = MatchSpec(pkg)
+        name = pkg_spec.name
+        if name in active_pkgs:
+            if not pkg_spec.match(active_pkgs[name]):
+                ok = False
+                output.append("{} found but mismatch. Specification pkg: {}, Running pkg: {}"
+                              .format(name, pkg, _to_str(active_pkgs[name])))
+        else:
+            ok = False
+            output.append("{} not found".format(name))
+    if ok:
+        output.append("Success. All the packages in the \
+specification file are present in the environment \
+with matching version and build string.")
+    else:
+        res = 1
+    return res, output
+
+def execute(args, parser):
+    prefix = context.target_prefix
+    if not is_conda_environment(prefix):
+        from ..exceptions import EnvironmentLocationNotFound
+        raise EnvironmentLocationNotFound(prefix)
+
+    try:
+        url_scheme = args.file.split("://", 1)[0]
+        if url_scheme in CONDA_SESSION_SCHEMES:
+            filename = args.file
+        else:
+            filename = expand(args.file)
+
+        spec = specs.detect(name=args.name, filename=filename, directory=os.getcwd())
+        env = spec.environment
+
+        if args.prefix is None and args.name is None:
+            args.name = env.name
+    except exceptions.SpecNotFound:
+        raise
+
+    active_pkgs = dict(map(_get_name_tuple, get_packages(prefix)))
+    specification_pkgs = []
+    if 'conda' in env.dependencies:
+        specification_pkgs = specification_pkgs + env.dependencies['conda']
+    if 'pip' in env.dependencies:
+        specification_pkgs = specification_pkgs + env.dependencies['pip']
+
+    exitcode, output = compare_packages(active_pkgs, specification_pkgs)
+
+    if context.json:
+        stdout_json(output)
+    else:
+        print('\n'.join(map(text_type, output)))
+
+    return exitcode


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -112,6 +112,16 @@ def test_list(self, mockable_context_envs_dirs):
 
         assert mockable_context_envs_dirs.call_count > 0
 
+    @pytest.mark.usefixtures("empty_env_tmpdir")
+    @patch("conda.base.context.mockable_context_envs_dirs")
+    def test_compare(self, mockable_context_envs_dirs):
+        mockable_context_envs_dirs.return_value = (self.tmpdir,)
+        stdout, stderr, rc = run_inprocess_conda_command('conda compare --name nonexistent tempfile.rc --json')
+        assert json.loads(stdout.strip())['exception_name'] == 'EnvironmentLocationNotFound'
+        assert stderr == ''
+        assert rc > 0
+        assert mockable_context_envs_dirs.call_count > 0
+
     @pytest.mark.integration
     def test_search_0(self):
         with captured():
diff --git a/tests/test_create.py b/tests/test_create.py
--- a/tests/test_create.py
+++ b/tests/test_create.py
@@ -196,6 +196,7 @@ def FORCE_temp_prefix(name=None, use_restricted_unicode=False):
 
 
 class Commands:
+    COMPARE = "compare"
     CONFIG = "config"
     CLEAN = "clean"
     CREATE = "create"
@@ -246,7 +247,7 @@ def run_command(command, prefix, *arguments, **kwargs):
     if command is Commands.CONFIG:
         arguments.append('--file')
         arguments.append(join(prefix, 'condarc'))
-    if command in (Commands.LIST, Commands.CREATE, Commands.INSTALL,
+    if command in (Commands.LIST, Commands.COMPARE, Commands.CREATE, Commands.INSTALL,
                    Commands.REMOVE, Commands.UPDATE, Commands.RUN):
         arguments.insert(0, '-p')
         arguments.insert(1, prefix)
@@ -913,6 +914,39 @@ def test_list_with_pip_wheel(self):
             assert not isdir(prefix)
             assert prefix not in PrefixData._cache_
 
+    def test_compare_success(self):
+        with make_temp_env("python=3.6", "flask=1.0.2", "bzip2=1.0.8") as prefix:
+            env_file = join(prefix, 'env.yml')
+            touch(env_file)
+            with open(env_file, "w") as f:
+                f.write(
+"""name: dummy
+channels:
+  - defaults
+dependencies:
+  - bzip2=1.0.8
+  - flask>=1.0.1,<=1.0.4""")
+            output, _, _ = run_command(Commands.COMPARE, prefix, env_file, "--json")
+            assert "Success" in output
+            rmtree(prefix, ignore_errors=True)
+
+    def test_compare_fail(self):
+        with make_temp_env("python=3.6", "flask=1.0.2", "bzip2=1.0.8") as prefix:
+            env_file = join(prefix, 'env.yml')
+            touch(env_file)
+            with open(env_file, "w") as f:
+                f.write(
+"""name: dummy
+channels:
+  - defaults
+dependencies:
+  - yaml
+  - flask=1.0.3""")
+            output, _, _ = run_command(Commands.COMPARE, prefix, env_file, "--json")
+            assert "yaml not found" in output
+            assert "flask found but mismatch. Specification pkg: flask=1.0.3, Running pkg: flask==1.0.2=py36_1" in output
+            rmtree(prefix, ignore_errors=True)
+
     def test_install_tarball_from_local_channel(self):
         # Regression test for #2812
         # install from local channel