Repository: conda-incubator/conda-project (language: python)

Issue:
[BUG] Update env to match what is specified in the yaml file
# The need

1. User creates `environment.yml`
2. User runs `conda project prepare` to make the env from scratch
3. User modifies `environment.yml` where dependencies are either added or removed
4. `conda project prepare` should always modify the previously installed env to match what the `environment.yml` says.

Under the hood I would like to use `conda env update --prune ...` for this, but there is a open issue: https://github.com/conda/conda/issues/7279. What happens is that `conda env update --prune` will install new packages that have been added to the yaml file, but will not uninstall packages that have been removed.

https://github.com/conda/conda/pull/9614 may be a workable solution.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
new file mode 100644
index 00000000..2109d464
--- /dev/null
+++ b/.github/workflows/main.yaml
@@ -0,0 +1,108 @@
+name: Build and test
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+jobs:
+  package:
+    name: Build package
+    runs-on: "ubuntu-latest"
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+      - uses: conda-incubator/setup-miniconda@v2
+        with:
+          miniconda-version: "latest"
+          activate-environment: build-conda-token
+          environment-file: etc/build-environment.yml
+          python-version: 3.8
+          auto-activate-base: false
+      - name: Conda Build
+        shell: bash -l {0}
+        run: |
+          conda build conda.recipe --no-test
+          mv $CONDA_PREFIX/conda-bld .
+  test:
+    name: Test (Conda ${{ matrix.conda-version }}, Python ${{ matrix.python-version }}, ${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ["ubuntu-latest", "macos-latest", "windows-latest"]
+        python-version: [3.7, 3.8, 3.9, "3.10"]
+        conda-version: [4.8, 4.9, "4.10", "4.11", "4.12"]
+        exclude:
+          # Python 3.10
+          - conda-version: 4.8
+            python-version: "3.10"
+          - conda-version: 4.9
+            python-version: "3.10"
+          - conda-version: "4.11"
+            python-version: "3.10"
+
+          # Python 3.9
+          - conda-version: 4.8
+            python-version: 3.9
+    steps:
+      - uses: actions/checkout@v2
+      - name: Download conda standalone
+        shell: bash
+        run: |
+          if [ $RUNNER_OS == 'Linux' ]; then
+            curl https://repo.anaconda.com/pkgs/misc/conda-execs/conda-latest-linux-64.exe -o conda.exe
+          elif [ $RUNNER_OS == 'Windows' ]; then
+            curl https://repo.anaconda.com/pkgs/misc/conda-execs/conda-latest-win-64.exe -o conda.exe
+          elif [ $RUNNER_OS == 'macOS' ]; then
+            curl https://repo.anaconda.com/pkgs/misc/conda-execs/conda-latest-osx-64.exe -o conda.exe
+          fi
+          chmod +x conda.exe
+          ./conda.exe config --set auto_update_conda false
+          ./conda.exe info
+      - name: Setup miniconda 
+        shell: bash
+        run: |
+          ./conda.exe create -p $HOME/miniconda conda=${{ matrix.conda-version }} python=${{ matrix.python-version }}
+          if [ $RUNNER_OS == 'Windows' ]; then
+            source $HOME/miniconda/Scripts/activate root && conda env update -f etc/test-environment.yml -p $HOME/miniconda
+          else
+            source $HOME/miniconda/bin/activate root && conda env update -f etc/test-environment.yml -p $HOME/miniconda
+          fi
+      - name: py.test
+        shell: bash
+        run: |
+          if [ $RUNNER_OS == 'Windows' ]; then
+            source $HOME/miniconda/Scripts/activate root && conda --version && py.test -xv
+          else
+            source $HOME/miniconda/bin/activate root && conda --version && py.test -xv
+          fi
+  upload:
+    needs: test
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push'
+    steps:
+    - name: Retrieve the source code
+      uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+    - name: Download the build artifacts
+      uses: actions/download-artifact@v2
+      with:
+        name: package-${{ github.sha }}
+        path: conda-bld
+    - name: Upload to anaconda.org
+      env:
+        ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }}
+        GITHUB_REF: ${{ github.ref }}
+      run: |
+        source $CONDA/bin/activate
+        conda install -y anaconda-client
+        [[ "$GITHUB_REF" =~ ^refs/tags/ ]] || export LABEL="--label dev"
+        anaconda --verbose --token $ANACONDA_TOKEN upload --user defusco $LABEL conda-bld/*/*.tar.bz2 --force
+    - name: Clean up older artifacts
+      uses: glassechidna/artifact-cleaner@master
+      with:
+        minimumAge: 86400
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 244c5c5b..cab7b8fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,6 +115,9 @@ venv.bak/
 .spyderproject
 .spyproject
 
+# VSCode project settings
+.vscode/
+
 # Rope project settings
 .ropeproject
 
diff --git a/README.md b/README.md
index 591ca787..0a5d8291 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,33 @@
 # Conda Project
 
 Tool for encapsulating, running, and reproducing projects with Conda environments
+
+This package is intended as a successor to [Anaconda Project](https://github.com/Anaconda-Platform/anaconda-project).
+Please continue to use Anaconda Project until it has been delcared deprecated and Conda Project has
+stabilized.
+
+## Setup for development
+
+To setup conda-project for development, first install [Miniconda](https://docs.conda.io/en/latest/miniconda.html),
+then
+
+1. Clone this repository.
+1. Create a development environment using the `environment.yml` file in this repository
+    ```
+    conda env create -p ./env
+1. Install `conda-project` as editable in the env by activating the environment first
+    ```
+    conda activate ./env
+    pip install -e .
+    ```
+1. To run tests you can either activate the env and run pytest
+    ```
+    conda activate ./env
+    pytest
+    ```
+
+    or use `conda run`
+
+    ```
+    conda run -p ./env pytest
+    ```
diff --git a/conda_project/__init__.py b/conda_project/__init__.py
index e44e9d15..77d4f9fe 100644
--- a/conda_project/__init__.py
+++ b/conda_project/__init__.py
@@ -1,3 +1,9 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
 from ._version import get_versions
 __version__ = get_versions()['version']
 del get_versions
+
+from .project import CondaProject
+from .exceptions import CondaProjectError
diff --git a/conda_project/__main__.py b/conda_project/__main__.py
index 0596b629..3b142c7b 100644
--- a/conda_project/__main__.py
+++ b/conda_project/__main__.py
@@ -1,3 +1,6 @@
-from conda_project import cli
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+from .cli.main import main
 
-cli.cli()
+main()
diff --git a/conda_project/cli.py b/conda_project/cli.py
deleted file mode 100644
index e2ebb5d7..00000000
--- a/conda_project/cli.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from argparse import ArgumentParser
-from conda_project import __version__
-
-def cli(args=None):
-    p = ArgumentParser(
-        description="Tool for encapsulating, running, and reproducing projects with Conda environments",
-        conflict_handler='resolve'
-    )
-    p.add_argument(
-        '-V', '--version',
-        action='version',
-        help='Show the conda-prefix-replacement version number and exit.',
-        version="conda_project %s" % __version__,
-    )
-
-    args, unknown = p.parse_known_args(args)
-
-    # do something with the args
-    print("CLI template - fix me up!")
-
-    # No return value means no error.
-    # Return a value of 1 or higher to signify an error.
-    # See https://docs.python.org/3/library/sys.html#sys.exit
-
-
-if __name__ == '__main__':
-    import sys
-    cli(sys.argv[1:])
diff --git a/conda_project/cli/__init__.py b/conda_project/cli/__init__.py
new file mode 100644
index 00000000..e03a1512
--- /dev/null
+++ b/conda_project/cli/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
\ No newline at end of file
diff --git a/conda_project/cli/commands.py b/conda_project/cli/commands.py
new file mode 100644
index 00000000..fc35a513
--- /dev/null
+++ b/conda_project/cli/commands.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+import sys
+from functools import wraps
+
+from ..project import CondaProject
+from ..exceptions import CondaProjectError
+
+
+def handle_errors(func):
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        try:
+            func(*args, **kwargs)
+            return 0
+        except CondaProjectError as e:
+            print(e, file=sys.stderr)
+            return 1
+    return wrapper
+
+
+@handle_errors
+def prepare(args):
+    project = CondaProject(args.directory)
+    project.prepare(args.force, verbose=True)
+
+
+@handle_errors
+def clean(args):
+    project = CondaProject(args.directory)
+    project.clean(verbose=True)
diff --git a/conda_project/cli/main.py b/conda_project/cli/main.py
new file mode 100644
index 00000000..6ec4a96e
--- /dev/null
+++ b/conda_project/cli/main.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+from argparse import ArgumentParser
+from conda_project import __version__
+
+from . import commands
+
+
+def cli():
+    common = ArgumentParser(add_help=False)
+    common.add_argument(
+        '--directory',
+        metavar='PROJECT_DIR',
+        default='.',
+        help="Project directory (defaults to current directory)"
+    )
+
+    p = ArgumentParser(
+        description="Tool for encapsulating, running, and reproducing projects with Conda environments",
+        conflict_handler='resolve',
+    )
+    p.add_argument(
+        '-V', '--version',
+        action='version',
+        help='Show the conda-prefix-replacement version number and exit.',
+        version="conda_project %s" % __version__,
+    )
+
+    subparsers = p.add_subparsers(metavar='command', required=True)
+
+    create_prepare_parser(subparsers, common)
+    create_clean_parser(subparsers, common)
+
+    return p
+
+
+def parse_and_run(args=None):
+    p = cli()
+    args, _ = p.parse_known_args(args)
+    return args.func(args)
+
+
+def create_prepare_parser(subparsers, parent_parser):
+    desc = 'Prepare the Conda environments'
+
+    p = subparsers.add_parser(
+        'prepare',
+        description=desc,
+        help=desc,
+        parents=[parent_parser]
+    )
+    p.add_argument(
+        '--force',
+        help='Remove and recreate an existing environment.',
+        action='store_true'
+    )
+
+    p.set_defaults(func=commands.prepare)
+
+
+def create_clean_parser(subparsers, parent_parser):
+    desc = 'Clean the Conda environments'
+
+    p = subparsers.add_parser(
+        'clean',
+        description=desc,
+        help=desc,
+        parents=[parent_parser]
+    )
+
+    p.set_defaults(func=commands.clean)
+
+
+def main():
+    import sys
+
+    if len(sys.argv) == 1:
+        parse_and_run(('-h',))
+
+    retcode = parse_and_run(sys.argv[1:])
+    return retcode
diff --git a/conda_project/conda.py b/conda_project/conda.py
new file mode 100644
index 00000000..20a8a386
--- /dev/null
+++ b/conda_project/conda.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+import os
+import subprocess
+
+from .exceptions import CondaProjectError
+
+CONDA_EXE = os.environ.get("CONDA_EXE", "conda")
+
+
+def call_conda(args, condarc_path=None, verbose=False):
+    env = os.environ.copy()
+    if condarc_path is not None:
+        env['CONDARC'] = condarc_path
+
+    cmd = [CONDA_EXE] + args
+
+    if verbose:
+        stdout = None
+    else:
+        stdout = subprocess.PIPE
+
+    proc = subprocess.run(
+        cmd,
+        env=env,
+        stdout=stdout,
+        stderr=subprocess.PIPE,
+        encoding='utf-8'
+    )
+
+    if proc.returncode != 0:
+        print_cmd = ' '.join(cmd)
+        raise CondaProjectError(f'Failed to run:\n  {print_cmd}\n{proc.stderr.strip()}')
+
+    return proc
diff --git a/conda_project/exceptions.py b/conda_project/exceptions.py
new file mode 100644
index 00000000..26345c13
--- /dev/null
+++ b/conda_project/exceptions.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+
+class CondaProjectError(Exception):
+    pass
diff --git a/conda_project/project.py b/conda_project/project.py
new file mode 100644
index 00000000..8a35a325
--- /dev/null
+++ b/conda_project/project.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+import os
+
+from .conda import call_conda
+from .exceptions import CondaProjectError
+
+ENVIRONMENT_YAML_FILENAMES = ('environment.yml', 'environment.yaml')
+
+
+class CondaProject:
+    def __init__(self, directory='.'):
+        self.directory = os.path.normcase(os.path.abspath(directory))
+
+        self.condarc = os.path.join(self.directory, '.condarc')
+
+        for fn in ENVIRONMENT_YAML_FILENAMES:
+            fn = os.path.join(self.directory, fn)
+            if os.path.exists(fn):
+                self.environment_file = fn
+                break
+        else:
+            raise CondaProjectError(f'No Conda environment.yml or environment.yaml file was found in {self.directory}.')
+
+    def default_env(self):
+        return os.path.join(self.directory, 'envs', 'default')
+
+    def prepare(self, force=False, verbose=False):
+        default_env = self.default_env()
+        conda_meta = os.path.join(default_env, 'conda-meta', 'history')
+        force = '--force' if force else ''
+        if os.path.exists(conda_meta) and not force:
+            return default_env
+        else:
+            _ = call_conda(
+                ['env', 'create', '-f', self.environment_file, '-p', default_env, force],
+                condarc_path=self.condarc,
+                verbose=verbose
+            )
+            return default_env
+
+    def clean(self, verbose=False):
+        _ = call_conda(
+            ['env', 'remove', '-p', self.default_env()],
+            condarc_path=self.condarc,
+            verbose=verbose
+        )
diff --git a/environment.yml b/environment.yml
new file mode 100644
index 00000000..6649de75
--- /dev/null
+++ b/environment.yml
@@ -0,0 +1,8 @@
+channels:
+  - defaults
+dependencies:
+  - python=3.8
+  - flake8
+  - pytest
+  - pylint
+  - pytest-cov
diff --git a/etc/build-environment.yml b/etc/build-environment.yml
new file mode 100644
index 00000000..7317abe1
--- /dev/null
+++ b/etc/build-environment.yml
@@ -0,0 +1,5 @@
+name: build-conda-project
+dependencies:
+  - conda
+  - conda-build
+  - conda-verify
\ No newline at end of file
diff --git a/examples/.gitignore b/examples/.gitignore
new file mode 100644
index 00000000..37819f73
--- /dev/null
+++ b/examples/.gitignore
@@ -0,0 +1,1 @@
+envs/
diff --git a/examples/minimal/.condarc b/examples/minimal/.condarc
new file mode 100644
index 00000000..67c0ef37
--- /dev/null
+++ b/examples/minimal/.condarc
@@ -0,0 +1,1 @@
+experimental_solver: libmamba
diff --git a/examples/minimal/environment.yml b/examples/minimal/environment.yml
new file mode 100644
index 00000000..6606abcb
--- /dev/null
+++ b/examples/minimal/environment.yml
@@ -0,0 +1,4 @@
+name: minimal
+
+dependencies:
+  - python=3.9
diff --git a/setup.cfg b/setup.cfg
index eb3bbc5a..0dba9308 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -4,17 +4,19 @@ ignore = E122,E123,E126,E127,E128,E731,E722
 exclude = build,conda_project/_version.py,tests,conda.recipe,.git,versioneer.py,benchmarks,.asv
 
 [tool:pytest]
-norecursedirs= .* *.egg* build dist conda.recipe
+norecursedirs= .* *.egg* build dist conda.recipe examples env
 addopts =
-    --junitxml=junit.xml
+    -vv
     --ignore setup.py
     --ignore run_test.py
     --cov-report term-missing
+    --cov conda_project
     --tb native
     --strict-markers
     --durations=20
 markers =
     serial: execute test serially (to avoid race conditions)
+    slow: tests can take a long time (deselect with '-m "not slow"')
 
 [versioneer]
 VCS = git
diff --git a/setup.py b/setup.py
index 4ca5cfbd..a2d28495 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,7 @@
     packages=find_packages(),
     entry_points={
         'console_scripts': [
-            'conda_project=conda_project.cli:cli'
+            'conda-project=conda_project.cli.main:main'
         ]
     },
     python_requires=">=3.7",


Test patch:
diff --git a/etc/test-environment.yml b/etc/test-environment.yml
new file mode 100644
index 00000000..c0543ebf
--- /dev/null
+++ b/etc/test-environment.yml
@@ -0,0 +1,6 @@
+name: test-conda-project
+channels:
+  - defaults
+dependencies:
+  - pytest
+  - pytest-cov
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29b..e03a1512 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..eaaff052
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,21 @@
+import pytest
+from functools import partial
+from pathlib import Path
+
+
+@pytest.fixture(params=['environment.yml', 'environment.yaml'])
+def project_directory(tmpdir, request):
+    def __project_directory(env_fn, env_yaml='', files=None):
+        env_file = tmpdir.join(env_fn)
+        env_file.write(env_yaml)
+
+        if files is not None:
+            for fn, contents in files.items():
+                path = Path(tmpdir) / fn
+                path.parent.mkdir(parents=True, exist_ok=True)
+                with open(path, 'wt') as f:
+                    f.write(contents)
+
+        return tmpdir
+
+    return partial(__project_directory, env_fn=request.param)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 05aa239b..7182ccb2 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,4 +1,70 @@
-from conda_project import cli
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+import pytest
+from functools import partial
 
-def test_cli_template():
-    assert cli.cli() is None
+from conda_project.cli.main import cli, main, parse_and_run
+
+COMMANDS = ['prepare', 'clean']
+
+
+def test_known_commands():
+    parser = cli()
+    assert parser._positionals._actions[2].choices.keys() == set(COMMANDS)
+
+
+def test_no_command(capsys, monkeypatch):
+    monkeypatch.setattr("sys.argv", ["conda-project"])
+    with pytest.raises(SystemExit):
+        assert main() is None
+
+    out = capsys.readouterr().out
+    assert 'conda-project [-h] [-V] command' in out
+
+
+def test_no_env_yaml(tmpdir, monkeypatch, capsys):
+    monkeypatch.chdir(tmpdir)
+
+    monkeypatch.setattr("sys.argv", ["conda-project", "prepare"])
+    assert main() == 1
+
+    err = capsys.readouterr().err
+    assert 'No Conda environment.yml or environment.yaml file was found' in err
+
+
+def test_unknown_command(capsys):
+    with pytest.raises(SystemExit):
+        assert parse_and_run(['nope']) is None
+
+    err = capsys.readouterr().err
+    assert "invalid choice: 'nope'" in err
+
+
+@pytest.mark.parametrize('command', COMMANDS)
+def test_command_args(command, monkeypatch, capsys):
+    def mocked_command(command, args):
+        print(f'I am {command}')
+        assert args.directory == 'project-dir'
+        return 42
+
+    monkeypatch.setattr(f'conda_project.cli.commands.{command}',
+                        partial(mocked_command, command))
+
+    ret = parse_and_run([command, '--directory', 'project-dir'])
+    assert ret == 42
+
+    out, err = capsys.readouterr()
+    assert f"I am {command}\n" == out
+    assert "" == err
+
+
+@pytest.mark.parametrize('command', COMMANDS)
+def test_cli_verbose(command, monkeypatch):
+    def mocked_action(*args, **kwargs):
+        assert kwargs.get('verbose', False)
+
+    monkeypatch.setattr(f'conda_project.project.CondaProject.{command}', mocked_action)
+
+    ret = parse_and_run([command])
+    assert ret == 0
diff --git a/tests/test_conda.py b/tests/test_conda.py
new file mode 100644
index 00000000..88465c31
--- /dev/null
+++ b/tests/test_conda.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+import pytest
+
+from conda_project.conda import call_conda
+from conda_project.exceptions import CondaProjectError
+
+
+def test_local_condarc(tmpdir):
+    condarc = "channels: [__conda-project-test]\n"
+    condarc_file = tmpdir.join('.condarc')
+    condarc_file.write(condarc)
+
+    proc = call_conda(['config', '--show', 'channels'],
+                      condarc_path=condarc_file.strpath, verbose=False)
+    channels = proc.stdout.splitlines()
+    assert channels[1] == '  - __conda-project-test'
+
+
+def test_conda_error():
+    with pytest.raises(CondaProjectError) as excinfo:
+        _ = call_conda(['not-a-command'], verbose=False)
+
+    assert 'Failed to run' in str(excinfo.value)
+
+
+def test_conda_output():
+    proc = call_conda(['info'], verbose=False)
+    assert 'active environment' in proc.stdout
+
+    proc = call_conda(['info'], verbose=True)
+    assert not proc.stdout
diff --git a/tests/test_project.py b/tests/test_project.py
new file mode 100644
index 00000000..032dac1b
--- /dev/null
+++ b/tests/test_project.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+import os
+import pytest
+
+from conda_project.project import CondaProject
+from conda_project.exceptions import CondaProjectError
+
+
+def test_conda_project_init_no_env_yml(tmpdir):
+    with pytest.raises(CondaProjectError) as excinfo:
+        CondaProject(tmpdir)
+    assert 'No Conda environment.yml or environment.yaml file was found' in str(excinfo.value)
+
+
+def test_project_init_expands_cwd(monkeypatch, project_directory):
+    tmpdir = project_directory()
+    monkeypatch.chdir(tmpdir)
+
+    project = CondaProject()
+    assert project.directory == tmpdir
+    assert project.environment_file
+
+
+def test_project_init_path(project_directory):
+    tmpdir = project_directory()
+
+    project = CondaProject(tmpdir)
+    assert project.environment_file
+
+
+def test_prepare_no_dependencies(project_directory):
+    env_yaml = """name: test
+dependencies: []
+"""
+    tmpdir = project_directory(env_yaml=env_yaml)
+    project = CondaProject(tmpdir)
+    assert os.path.samefile(project.directory, tmpdir)
+
+    env_dir = project.prepare()
+    assert os.path.samefile(env_dir, os.path.join(tmpdir, 'envs', 'default'))
+
+    conda_history = os.path.join(env_dir, 'conda-meta', 'history')
+    assert os.path.exists(conda_history)
+
+
+@pytest.mark.slow
+def test_prepare_and_clean(project_directory):
+    env_yaml = """name: test
+dependencies:
+  - python=3.8
+"""
+    tmpdir = project_directory(env_yaml=env_yaml)
+
+    project = CondaProject(tmpdir)
+    env_dir = project.prepare()
+    assert os.path.samefile(env_dir, os.path.join(tmpdir, 'envs', 'default'))
+
+    conda_history = os.path.join(env_dir, 'conda-meta', 'history')
+    assert os.path.exists(conda_history)
+    with open(conda_history) as f:
+        assert "# update specs: ['python=3.8']" in f.read()
+    conda_history_mtime = os.path.getmtime(conda_history)
+
+    project.prepare()
+    assert conda_history_mtime == os.path.getmtime(conda_history)
+
+    project.prepare(force=True)
+    assert conda_history_mtime < os.path.getmtime(conda_history)
+
+    project.clean()
+    assert not os.path.exists(conda_history)