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

Issue:
[BUG] Strict utilization of channels
It is possible to write an environment.yml file without the `channels:` key, but this is not the best practice that we want to promote for conda-project. Secondly, even with the `channels:` key the globally configured channels are still respected.

Anaconda-project solves this by using `--override-channels` [when the env is created](https://github.com/Anaconda-Platform/anaconda-project/commit/26b9c9eca77c0b2a386ed596abb9ad590269d578) there by ignoring the condarc `channels:` and forcing strict adherence to what is defined for the project.

Conda lock [does the right thing here](https://github.com/conda-incubator/conda-lock/blob/388db9f0b20a2f440ced73bb24afe6995506c784/conda_lock/invoke_conda.py#L176-L177) and sets override-channels and fails when `channels:` is missing so it would be good to piggyback on that



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index 6d051dc8..060b62e8 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -39,7 +39,7 @@ jobs:
       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"]
+        conda-version: [4.8, 4.9, "4.10", "4.11", "4.12", "4.13"]
         exclude:
           # Python 3.10
           - conda-version: 4.8
@@ -79,6 +79,18 @@ jobs:
           else
             source $HOME/miniconda/bin/activate root && conda env update -f etc/test-environment.yml -p $HOME/miniconda
           fi
+      - name: Install Libmamba
+        shell: bash
+        run: |
+          if [ ${{ matrix.conda-version }} == "4.12" ] || [ ${{ matrix.conda-version }} == "4.13" ]; then
+            if [ $RUNNER_OS == 'Windows' ]; then
+              source $HOME/miniconda/Scripts/activate root && \
+              conda install conda-libmamba-solver -p $HOME/miniconda
+            else
+              source $HOME/miniconda/bin/activate root && \
+              conda install conda-libmamba-solver -p $HOME/miniconda
+            fi
+          fi
       - name: py.test
         shell: bash
         run: |
diff --git a/conda_project/cli/commands.py b/conda_project/cli/commands.py
index ac23a2f3..ccf12f13 100644
--- a/conda_project/cli/commands.py
+++ b/conda_project/cli/commands.py
@@ -16,22 +16,89 @@ def handle_errors(func: Callable[[Namespace], Any]) -> Callable[[Namespace], int
     @wraps(func)
     def wrapper(args: Namespace) -> int:
         try:
-            func(args)
-            return 0
+            ret = func(args)
+            if ret:
+                return 0
+            else:
+                return 1
         except CondaProjectError as e:
-            print(e, file=sys.stderr)
+            print(f"{e.__class__.__name__}: {e}", file=sys.stderr)
             return 1
 
     return wrapper
 
 
 @handle_errors
-def prepare(args: Namespace) -> None:
+def create(args: Namespace) -> bool:
+    project = CondaProject.create(
+        args.directory,
+        args.name,
+        args.dependencies,
+        args.channel,
+        args.platforms.split(","),
+        [] if args.conda_configs is None else args.conda_configs.split(","),
+        not args.no_lock,
+        verbose=True,
+    )
+
+    if args.prepare:
+        project.default_environment.prepare(verbose=True)
+
+    return True
+
+
+@handle_errors
+def lock(args: Namespace) -> bool:
+    project = CondaProject(args.directory)
+
+    if args.environment:
+        to_lock = [project.environments[args.environment]]
+    else:
+        to_lock = project.environments.values()
+
+    for env in to_lock:
+        env.lock(force=args.force, verbose=True)
+
+    return True
+
+
+@handle_errors
+def check(args: Namespace) -> bool:
+    project = CondaProject(args.directory)
+    return project.check(verbose=True)
+
+
+@handle_errors
+def prepare(args: Namespace) -> bool:
     project = CondaProject(args.directory)
-    project.prepare(force=args.force, verbose=True)
+
+    if args.all:
+        for _, env in project.environments:
+            env.prepare(force=args.force, verbose=True)
+    else:
+        env = (
+            project.environments[args.environment]
+            if args.environment
+            else project.default_environment
+        )
+        env.prepare(force=args.force, verbose=True)
+
+    return True
 
 
 @handle_errors
-def clean(args: Namespace) -> None:
+def clean(args: Namespace) -> bool:
     project = CondaProject(args.directory)
-    project.clean(verbose=True)
+
+    if args.all:
+        for env in project.environments.values():
+            env.clean(verbose=True)
+    else:
+        env = (
+            project.environments[args.environment]
+            if args.environment
+            else project.default_environment
+        )
+        env.clean(verbose=True)
+
+    return True
diff --git a/conda_project/cli/main.py b/conda_project/cli/main.py
index 4d263e9e..2e071e37 100644
--- a/conda_project/cli/main.py
+++ b/conda_project/cli/main.py
@@ -8,6 +8,7 @@
 
 from conda_project import __version__
 
+from ..project import DEFAULT_PLATFORMS
 from . import commands
 
 if typing.TYPE_CHECKING:
@@ -40,12 +41,135 @@ def cli() -> ArgumentParser:
 
     subparsers = p.add_subparsers(metavar="command", required=True)
 
+    _create_create_parser(subparsers, common)
+    _create_lock_parser(subparsers, common)
+    _create_check_parser(subparsers, common)
     _create_prepare_parser(subparsers, common)
     _create_clean_parser(subparsers, common)
 
     return p
 
 
+def _create_create_parser(
+    subparsers: "_SubParsersAction", parent_parser: ArgumentParser
+) -> None:
+    """Add a subparser for the "create" subcommand.
+
+    Args:
+        subparsers: The existing subparsers corresponding to the "command" meta-variable.
+        parent_parser: The parent parser, which is used to pass common arguments into the subcommands.
+
+    """
+    desc = "Create a new project"
+
+    p = subparsers.add_parser(
+        "create", description=desc, help=desc, parents=[parent_parser]
+    )
+    p.add_argument(
+        "-n", "--name", help="Name for the project.", action="store", default=None
+    )
+    p.add_argument(
+        "-c",
+        "--channel",
+        help=(
+            "Additional channel to search for packages. The default channel is 'defaults'. "
+            "Multiple channels are added with repeated use of this argument."
+        ),
+        action="append",
+    )
+    p.add_argument(
+        "--platforms",
+        help=(
+            f"Comma separated list of platforms for which to lock dependencies. "
+            f"The default is {','.join(DEFAULT_PLATFORMS)}"
+        ),
+        action="store",
+        default=",".join(DEFAULT_PLATFORMS),
+    )
+    p.add_argument(
+        "--conda-configs",
+        help=(
+            "Comma separated list of Conda configuration parameters to write into the "
+            ".condarc file in the project directory. The format for each config is key=value. "
+            "For example --conda-configs experimental_solver=libmamba,channel_priority=strict"
+        ),
+        action="store",
+        default=None,
+    )
+    p.add_argument(
+        "--no-lock", help="Do not create the conda-lock.yml file", action="store_true"
+    )
+    p.add_argument(
+        "--prepare",
+        help="Create the local Conda environment for the current platform.",
+        action="store_true",
+    )
+    p.add_argument(
+        "dependencies",
+        help=(
+            "Packages to add to the environment.yml. The format for each package is '<name>[<op><version>]' "
+            "where <op> can be =, <, >, <=, or >=."
+        ),
+        action="store",
+        nargs="*",
+        metavar="PACKAGE_SPECIFICATION",
+    )
+
+    p.set_defaults(func=commands.create)
+
+
+def _create_lock_parser(
+    subparsers: "_SubParsersAction", parent_parser: ArgumentParser
+) -> None:
+    """Add a subparser for the "lock" subcommand.
+
+    Args:
+        subparsers: The existing subparsers corresponding to the "command" meta-variable.
+        parent_parser: The parent parser, which is used to pass common arguments into the subcommands.
+
+    """
+    desc = "Lock all Conda environments or a specific one by creating .conda-lock.yml files."
+
+    p = subparsers.add_parser(
+        "lock", description=desc, help=desc, parents=[parent_parser]
+    )
+    p.add_argument(
+        "environment",
+        help="Optional: Lock the selected environment. If no environment name is selected "
+        "all environments are locked.",
+        nargs="?",
+    )
+    p.add_argument(
+        "--force",
+        help="Remove and recreate existing .conda-lock.yml files.",
+        action="store_true",
+    )
+
+    p.set_defaults(func=commands.lock)
+
+
+def _create_check_parser(
+    subparsers: "_SubParsersAction", parent_parser: ArgumentParser
+) -> None:
+    """Add a subparser for the "lock" subcommand.
+
+    Args:
+        subparsers: The existing subparsers corresponding to the "command" meta-variable.
+        parent_parser: The parent parser, which is used to pass common arguments into the subcommands.
+
+    """
+    desc = "Check the project for inconsistencies or errors. This will check that .conda-lock.yml files "
+    "have been created for each environment and are up-to-date with the source environment specifications. "
+    "If the project is fully locked this command will not print anything and return status code 0. If any "
+    "environment is not fully locked details are printed to stderr and the command returns status code 1."
+
+    p = subparsers.add_parser(
+        "check", description=desc, help=desc, parents=[parent_parser]
+    )
+
+    p.set_defaults(func=commands.check)
+
+
 def _create_prepare_parser(
     subparsers: "_SubParsersAction", parent_parser: ArgumentParser
 ) -> None:
@@ -61,6 +185,24 @@ def _create_prepare_parser(
     p = subparsers.add_parser(
         "prepare", description=desc, help=desc, parents=[parent_parser]
     )
+    group = p.add_mutually_exclusive_group(required=False)
+    group.add_argument(
+        "environment",
+        help="Prepare the selected environment. If no environment name is selected "
+        "the first environment defined in the conda-project.yml file is prepared.",
+        nargs="?",
+    )
+    group.add_argument(
+        "--all", help="Check or prepare all defined environments.", action="store_true"
+    )
+    p.add_argument(
+        "--check-only",
+        help="Check that the prepared Conda environment exists and is up-to-date with the "
+        "source environment and files and lockfile and then exit. If the environment is up-to-date "
+        "nothing is printed and the command exists with 0. If the environment is missing or out-of-date "
+        "details are printed to stderr and the command exits with 1.",
+        action="store_true",
+    )
     p.add_argument(
         "--force",
         help="Remove and recreate an existing environment.",
@@ -85,6 +227,15 @@ def _create_clean_parser(
     p = subparsers.add_parser(
         "clean", description=desc, help=desc, parents=[parent_parser]
     )
+    p.add_argument(
+        "environment",
+        help="Remove environment prefix for selected environment. If no environment name is selected "
+        "the first environment defined in the conda-project.yml file is removed.",
+        nargs="?",
+    )
+    p.add_argument(
+        "--all", help="Prepare all defined environments.", action="store_true"
+    )
 
     p.set_defaults(func=commands.clean)
 
diff --git a/conda_project/conda.py b/conda_project/conda.py
index b5e1711b..24e03e09 100644
--- a/conda_project/conda.py
+++ b/conda_project/conda.py
@@ -3,9 +3,12 @@
 # SPDX-License-Identifier: BSD-3-Clause
 from __future__ import annotations
 
+import json
 import os
 import subprocess
+from logging import Logger
 from pathlib import Path
+from typing import List, Optional
 
 from .exceptions import CondaProjectError
 
@@ -13,10 +16,15 @@
 
 
 def call_conda(
-    args: list[str], condarc_path: Path = None, verbose: bool = False
+    args: List[str],
+    condarc_path: Optional[Path] = None,
+    verbose: bool = False,
+    logger: Optional[Logger] = None,
 ) -> subprocess.CompletedProcess:
     env = os.environ.copy()
     if condarc_path is not None:
+        if logger is not None:
+            logger.info(f"setting CONDARC env variable to {condarc_path}")
         env["CONDARC"] = str(condarc_path)
 
     cmd = [CONDA_EXE] + args
@@ -26,6 +34,8 @@ def call_conda(
     else:
         stdout = subprocess.PIPE
 
+    if logger is not None:
+        logger.info(f'running conda command: {" ".join(cmd)}')
     proc = subprocess.run(
         cmd, env=env, stdout=stdout, stderr=subprocess.PIPE, encoding="utf-8"
     )
@@ -35,3 +45,14 @@ def call_conda(
         raise CondaProjectError(f"Failed to run:\n  {print_cmd}\n{proc.stderr.strip()}")
 
     return proc
+
+
+def conda_info():
+    proc = call_conda(["info", "--json"])
+    parsed = json.loads(proc.stdout)
+    return parsed
+
+
+def current_platform():
+    info = conda_info()
+    return info.get("platform")
diff --git a/conda_project/project.py b/conda_project/project.py
index 5f57f3b6..1842c384 100644
--- a/conda_project/project.py
+++ b/conda_project/project.py
@@ -3,12 +3,324 @@
 # SPDX-License-Identifier: BSD-3-Clause
 from __future__ import annotations
 
+import json
+import logging
+import os
+import shutil
+import sys
+import tempfile
+import warnings
+from collections import OrderedDict
+from contextlib import nullcontext, redirect_stderr
+from io import StringIO
 from pathlib import Path
+from subprocess import SubprocessError
+from typing import List, Optional, Tuple, Union
 
-from .conda import call_conda
+from conda_lock.conda_lock import (
+    default_virtual_package_repodata,
+    make_lock_files,
+    make_lock_spec,
+    parse_conda_lock_file,
+    render_lockfile_for_platform,
+)
+from pydantic import BaseModel, create_model
+
+from .conda import CONDA_EXE, call_conda, current_platform
 from .exceptions import CondaProjectError
+from .project_file import (
+    ENVIRONMENT_YAML_FILENAMES,
+    PROJECT_YAML_FILENAMES,
+    CondaProjectYaml,
+    EnvironmentYaml,
+    yaml,
+)
+from .utils import Spinner, env_variable, find_file
+
+_TEMPFILE_DELETE = False if sys.platform.startswith("win") else True
+
+DEFAULT_PLATFORMS = set(["osx-64", "win-64", "linux-64", current_platform()])
+
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=os.environ.get("CONDA_PROJECT_LOGLEVEL", "WARNING"))
+
+
+class Environment(BaseModel):
+    name: str
+    sources: Tuple[Path, ...]
+    prefix: Path
+    lockfile: Path
+    condarc: Path
+
+    class Config:
+        allow_mutation = False
+        extra = "forbid"
+
+    @property
+    def _overrides(self):
+        specified_channels = []
+        specified_platforms = set()
+        for fn in self.sources:
+            env = EnvironmentYaml.parse_yaml(fn)
+            for channel in env.channels or []:
+                if channel not in specified_channels:
+                    specified_channels.append(channel)
+            if env.platforms is not None:
+                specified_platforms.update(env.platforms)
+
+        channel_overrides = None
+        if not specified_channels:
+            env_files = ",".join([source.name for source in self.sources])
+            msg = f"there are no 'channels:' key in {env_files} assuming 'defaults'."
+            warnings.warn(msg)
+            channel_overrides = ["defaults"]
+
+        platform_overrides = None
+        if not specified_platforms:
+            platform_overrides = list(DEFAULT_PLATFORMS)
+
+        return channel_overrides, platform_overrides
+
+    @property
+    def is_locked(self) -> bool:
+        """
+        bool: Returns True if the lockfile is consistent with the source files, False otherwise.
+        """
+        channel_overrides, platform_overrides = self._overrides
+        if self.lockfile.exists():
+            lock = parse_conda_lock_file(self.lockfile)
+            spec = make_lock_spec(
+                src_files=list(self.sources),
+                channel_overrides=channel_overrides,
+                platform_overrides=platform_overrides,
+                virtual_package_repo=default_virtual_package_repodata(),
+            )
+            all_up_to_date = all(
+                p in lock.metadata.platforms
+                and spec.content_hash_for_platform(p) == lock.metadata.content_hash[p]
+                for p in spec.platforms
+            )
+            return all_up_to_date
+        else:
+            return False
+
+    @property
+    def is_prepared(self) -> bool:
+        """
+        bool: Returns True if the Conda environment exists and is consistent with
+              the environment source and lock files, False otherwise. If is_locked is
+              False is_prepared is False.
+        """
+        if (self.prefix / "conda-meta" / "history").exists():
+            if self.is_locked:
+                installed_pkgs = call_conda(
+                    ["list", "-p", str(self.prefix), "--explicit"]
+                ).stdout.splitlines()[3:]
+
+                lock = parse_conda_lock_file(self.lockfile)
+                rendered = render_lockfile_for_platform(
+                    lockfile=lock,
+                    platform=current_platform(),
+                    kind="explicit",
+                    include_dev_dependencies=False,
+                    extras=None,
+                )
+                locked_pkgs = [p.split("#")[0] for p in rendered[3:]]
+
+                return installed_pkgs == locked_pkgs
+
+        return False
+
+    def lock(
+        self,
+        force: bool = False,
+        verbose: bool = False,
+    ) -> None:
+        """Generate locked package lists for the supplied or default platforms
+
+        Utilizes conda-lock to build the .conda-lock.yml file.
+
+        Args:
+            force:       Rebuild the .conda-lock.yml file even if no changes were made
+                         to the dependencies.
+            verbose:     A verbose flag passed into the `conda lock` command.
+
+        """
+        if self.is_locked and not force:
+            if verbose:
+                print(
+                    f"The lockfile {self.lockfile.name} already exists and is up-to-date.\n"
+                    f"Run 'conda project lock --force {self.name} to recreate it from source specification."
+                )
+            return
+
+        # Setup temporary file for conda-lock to write to.
+        # If a package is removed from the environment source
+        # after the lockfile has been created conda-lock updates
+        # the hash in the lockfile but does not remove the unspecified
+        # package (and necessary orphaned dependencies) from the lockfile.
+        # To avoid this scenario lockfiles are written to a temporary location
+        # and copied back to the self.lockfile path if successful.
+        tempdir = Path(tempfile.mkdtemp())
+        lockfile = tempdir / self.lockfile.name
+
+        channel_overrides, platform_overrides = self._overrides
+
+        specified_channels = []
+        for fn in self.sources:
+            env = EnvironmentYaml.parse_yaml(fn)
+            for channel in env.channels or []:
+                if channel not in specified_channels:
+                    specified_channels.append(channel)
+
+        with redirect_stderr(StringIO()) as _:
+            with env_variable("CONDARC", str(self.condarc)):
+                if verbose:
+                    context = Spinner(prefix=f"Locking dependencies for {self.name}")
+                else:
+                    context = nullcontext()
+
+                with context:
+                    try:
+                        make_lock_files(
+                            conda=CONDA_EXE,
+                            src_files=list(self.sources),
+                            lockfile_path=lockfile,
+                            kinds=["lock"],
+                            platform_overrides=platform_overrides,
+                            channel_overrides=channel_overrides,
+                        )
+                        shutil.copy(lockfile, self.lockfile)
+                    except SubprocessError as e:
+                        output = json.loads(e.output)
+                        msg = output["message"].replace(
+                            "target environment",
+                            f"supplied channels: {channel_overrides or specified_channels}",
+                        )
+                        msg = "Project failed to lock\n" + msg
+                        raise CondaProjectError(msg)
+                    finally:
+                        shutil.rmtree(tempdir)
+
+        lock = parse_conda_lock_file(self.lockfile)
+        msg = f"Locked dependencies for {', '.join(lock.metadata.platforms)} platforms"
+        logger.info(msg)
+
+    def prepare(
+        self,
+        force: bool = False,
+        verbose: bool = False,
+    ) -> Path:
+        """Prepare the conda environment.
+
+        Creates a new conda environment and installs the packages from the environment.yaml file.
+        Environments are always created from the conda-lock.yml file. The conda-lock.yml
+        will be created if it does not already exist.
+
+        Args:
+            force: If True, will force creation of a new conda environment.
+            verbose: A verbose flag passed into the `conda create` command.
+
+        Raises:
+            CondaProjectError: If no suitable environment file can be found.
+
+        Returns:
+            The path to the created environment.
+
+        """
+        if not self.is_locked:
+            if verbose and self.lockfile.exists():
+                print(f"The lockfile {self.lockfile} is out-of-date, re-locking...")
+            self.lock(verbose=verbose)
+
+        if self.is_prepared:
+            if not force:
+                logger.info(f"environment already exists at {self.prefix}")
+                if verbose:
+                    print(
+                        f"The environment already exists and is up-to-date.\n"
+                        f"run 'conda project prepare --force {self.name} to recreate it from the locked dependencies."
+                    )
+                return self.prefix
+        elif (self.prefix / "conda-meta" / "history").exists() and not self.is_prepared:
+            if not force:
+                if verbose:
+                    print(
+                        f"The environment exists but does not match the locked dependencies.\n"
+                        f"Run 'conda project prepare --force {self.name}' to recreate the environment from the "
+                        f"locked dependencies."
+                    )
+                return self.prefix
+
+        lock = parse_conda_lock_file(self.lockfile)
+        if current_platform() not in lock.metadata.platforms:
+            msg = (
+                f"Your current platform, {current_platform()}, is not in the supported locked platforms.\n"
+                f"You may need to edit your environment source files and run 'conda project lock' again."
+            )
+            raise CondaProjectError(msg)
+
+        rendered = render_lockfile_for_platform(
+            lockfile=lock,
+            platform=current_platform(),
+            kind="explicit",
+            include_dev_dependencies=False,
+            extras=None,
+        )
 
-ENVIRONMENT_YAML_FILENAMES = ("environment.yml", "environment.yaml")
+        with tempfile.NamedTemporaryFile(mode="w", delete=_TEMPFILE_DELETE) as f:
+            f.write("\n".join(rendered))
+            f.flush()
+
+            args = [
+                "create",
+                "-y",
+                *("--file", f.name),
+                *("-p", str(self.prefix)),
+            ]
+            if force:
+                args.append("--force")
+
+            _ = call_conda(
+                args, condarc_path=self.condarc, verbose=verbose, logger=logger
+            )
+
+        with (self.prefix / ".gitignore").open("wt") as f:
+            f.write("*")
+
+        msg = f"environment created at {self.prefix}"
+        logger.info(msg)
+        if verbose:
+            print(msg)
+
+        return self.prefix
+
+    def clean(
+        self,
+        verbose: bool = False,
+    ) -> None:
+        """Remove the conda environment."""
+
+        _ = call_conda(
+            ["env", "remove", "-p", str(self.prefix)],
+            condarc_path=self.condarc,
+            verbose=verbose,
+            logger=logger,
+        )
+
+
+class BaseEnvironments(BaseModel):
+    def __getitem__(self, key: str) -> Environment:
+        return getattr(self, key)
+
+    def keys(self):
+        return self.__dict__.keys()
+
+    def values(self):
+        return self.__dict__.values()
+
+    class Config:
+        allow_mutation = False
 
 
 class CondaProject:
@@ -18,6 +330,7 @@ class CondaProject:
         directory: The project base directory. Defaults to the current working directory.
         condarc: A path to the local `.condarc` file. Defaults to `<directory>/.condarc`.
         environment_file: A path to the environment file.
+        lock_file: A path to the conda-lock file.
 
     Args:
         directory: The project base directory.
@@ -27,69 +340,181 @@ class CondaProject:
 
     """
 
-    def __init__(self, directory: Path | str = "."):
+    def __init__(self, directory: Union[Path, str] = "."):
         self.directory = Path(directory).resolve()
+        logger.info(f"created Project instance at {self.directory}")
+
+        self.project_yaml_path = find_file(self.directory, PROJECT_YAML_FILENAMES)
+        if self.project_yaml_path is not None:
+            self._project_file = CondaProjectYaml.parse_yaml(self.project_yaml_path)
+        else:
+            options = " or ".join(PROJECT_YAML_FILENAMES)
+            logger.info(
+                f"No {options} file was found. Checking for environment YAML files."
+            )
+
+            environment_yaml_path = find_file(
+                self.directory, ENVIRONMENT_YAML_FILENAMES
+            )
+            if environment_yaml_path is None:
+                options = " or ".join(ENVIRONMENT_YAML_FILENAMES)
+                raise CondaProjectError(f"No Conda {options} file was found.")
+
+            self._project_file = CondaProjectYaml(
+                name=self.directory.name,
+                environments=OrderedDict(
+                    [("default", [environment_yaml_path.relative_to(self.directory)])]
+                ),
+            )
+
         self.condarc = self.directory / ".condarc"
-        self.environment_file = self._find_environment_file()
 
-    def _find_environment_file(self) -> Path:
-        """Find an environment file in the project directory.
+    @classmethod
+    def create(
+        cls,
+        directory: Union[Path, str] = ".",
+        name: Optional[str] = None,
+        dependencies: Optional[List[str]] = None,
+        channels: Optional[List[str]] = None,
+        platforms: Optional[List[str]] = None,
+        conda_configs: Optional[List[str]] = None,
+        lock_dependencies: bool = True,
+        verbose: bool = False,
+    ) -> CondaProject:
+        """Create a new project
 
-        Raises:
-            CondaProjectError: If no suitable environment file can be found.
+        Creates the environment.yml file from the specified dependencies,
+        channels, and platforms. Further a local .condarc can also be
+        created in the directory.
+
+        Args:
+            directory:         The path to use as the project directory. The directory
+                               will be created if it doesn't exist.
+            name:              Name of the project. The default is the basename of the project
+                               directory.
+            dependencies:      List of package dependencies to include in the environment.yml in
+                               MatchSpec format.
+            channels:          List of channels to search for dependencies. The default value is
+                               ['defaults']
+            platforms:         List of platforms over which to lock the dependencies. The default is
+                               osx-64, linux-64, win-64 and your current platform if it is not already
+                               included.
+            conda_configs:     List of Conda configuration parameters to include in the .condarc file
+                               written to the project directory.
+            lock_dependencies: Create the conda-lock.yml file for the requested dependencies.
+                               Default is True.
+            force:             Force creation of project and environment files if they already
+                               exist. The default value is False.
+            verbose:           Print information to stdout. The default value is False.
+
+        Returns:
+            CondaProject instance for the project directory.
 
         """
-        for filename in ENVIRONMENT_YAML_FILENAMES:
-            path = self.directory / filename
-            if path.exists():
-                return path
-        raise CondaProjectError(
-            f"No Conda environment.yml or environment.yaml file was found in {self.directory}."
+
+        directory = Path(directory).resolve()
+        if not directory.exists():
+            directory.mkdir(parents=True)
+
+        existing_project_file = find_file(directory, PROJECT_YAML_FILENAMES)
+        if existing_project_file is not None:
+            if verbose:
+                print(f"Existing project file found at {existing_project_file}.")
+            return cls(directory)
+
+        if name is None:
+            name = directory.name
+
+        environment_yaml = EnvironmentYaml(
+            channels=channels or ["defaults"],
+            dependencies=dependencies or [],
+            platforms=platforms or list(DEFAULT_PLATFORMS),
         )
 
+        environment_yaml_path = directory / "environment.yml"
+        environment_yaml.yaml(directory / "environment.yml")
+
+        project_yaml = CondaProjectYaml(
+            name=name,
+            environments=OrderedDict(
+                [("default", [environment_yaml_path.relative_to(directory)])]
+            ),
+        )
+
+        project_yaml.yaml(directory / "conda-project.yml")
+
+        condarc = {}
+        for config in conda_configs or []:
+            k, v = config.split("=")
+            condarc[k] = v
+        yaml.dump(condarc, directory / ".condarc")
+
+        project = cls(directory)
+
+        if lock_dependencies:
+            project.default_environment.lock(verbose=verbose)
+
+        if verbose:
+            print(f"Project created at {directory}")
+
+        return project
+
     @property
-    def default_env(self) -> Path:
-        """A path to the default conda environment."""
-        return self.directory / "envs" / "default"
+    def environments(self) -> BaseEnvironments:
+        envs = OrderedDict()
+        for env_name, sources in self._project_file.environments.items():
+            envs[env_name] = Environment(
+                name=env_name,
+                sources=[self.directory / str(s) for s in sources],
+                prefix=self.directory / "envs" / env_name,
+                lockfile=self.directory / f"{env_name}.conda-lock.yml",
+                condarc=self.condarc,
+            )
+        Environments = create_model(
+            "Environments",
+            **{k: (Environment, ...) for k in envs},
+            __base__=BaseEnvironments,
+        )
+        return Environments(**envs)
 
-    def prepare(self, force: bool = False, verbose: bool = False) -> Path:
-        """Prepare the default conda environment.
+    @property
+    def default_environment(self) -> Environment:
+        name = next(iter(self._project_file.environments))
+        return self.environments[name]
 
-        Creates a new conda environment and installs the packages from the environment.yaml file.
+    def check(self, verbose=False) -> bool:
+        """Check the project for inconsistencies or errors.
 
-        Args:
-            force: If True, will force creation of a new conda environment.
-            verbose: A verbose flag passed into the `conda create` command.
+        This will check that .conda-lock.yml files exist for each environment
+        and that they are up-to-date against the environment specification.
 
         Returns:
-            A path to the created environment.
+            Boolean: True if all environments are locked and update to date,
+                     False if any environment is not locked or out-of-date.
 
         """
-        default_env = self.default_env
-        conda_meta = default_env / "conda-meta" / "history"
-        if conda_meta.exists() and not force:
-            return default_env
-
-        args = [
-            "env",
-            "create",
-            *("-f", str(self.environment_file)),
-            *("-p", str(default_env)),
-        ]
-        if force:
-            args.append("--force")
+        return_status = []
 
-        _ = call_conda(
-            args,
-            condarc_path=self.condarc,
-            verbose=verbose,
-        )
-        return default_env
+        for env in self.environments.values():
+            if not env.lockfile.exists():
+                if verbose:
+                    print(f"The environment {env.name} is not locked.", file=sys.stderr)
+                    print(
+                        f"Run 'conda project lock {env.name}' to create.",
+                        file=sys.stderr,
+                    )
+                return_status.append(False)
+            elif not env.is_locked:
+                if verbose:
+                    print(
+                        f"The lockfile for environment {env.name} is out-of-date.",
+                        file=sys.stderr,
+                    )
+                    print(
+                        f"Run 'conda project lock {env.name}' to fix.", file=sys.stderr
+                    )
+                return_status.append(False)
+            else:
+                return_status.append(True)
 
-    def clean(self, verbose: bool = False) -> None:
-        """Remove the default conda environment."""
-        _ = call_conda(
-            ["env", "remove", "-p", str(self.default_env)],
-            condarc_path=self.condarc,
-            verbose=verbose,
-        )
+        return all(return_status)
diff --git a/conda_project/project_file.py b/conda_project/project_file.py
new file mode 100644
index 00000000..738e9c4f
--- /dev/null
+++ b/conda_project/project_file.py
@@ -0,0 +1,74 @@
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+
+import json
+from pathlib import Path
+from typing import Dict, List, Optional, OrderedDict, TextIO, Union
+
+from pydantic import BaseModel, ValidationError, validator
+from ruamel.yaml import YAML
+
+from .exceptions import CondaProjectError
+
+PROJECT_YAML_FILENAMES = ("conda-project.yml", "conda-project.yaml")
+ENVIRONMENT_YAML_FILENAMES = ("environment.yml", "environment.yaml")
+
+yaml = YAML(typ="rt")
+yaml.default_flow_style = False
+yaml.block_seq_indent = 2
+yaml.indent = 2
+
+
+def _cleandict(d: Dict) -> Dict:
+    return {k: v for k, v in d.items() if v is not None}
+
+
+class BaseYaml(BaseModel):
+    def yaml(self, stream: Union[TextIO, Path]):
+        # Passing through self.json() allows json_encoders
+        # to serialize objects and the _cleandict hook avoids
+        # writing empty keys to the yaml file.
+        encoded = json.loads(self.json(), object_hook=_cleandict)
+        return yaml.dump(encoded, stream)
+
+    @classmethod
+    def parse_yaml(cls, fn: Union[str, Path]):
+        d = yaml.load(fn)
+        if d is None:
+            msg = (
+                f"Failed to read {fn} as {cls.__name__}. The file appears to be empty."
+            )
+            raise CondaProjectError(msg)
+        try:
+            return cls(**d)
+        except ValidationError as e:
+            msg = f"Failed to read {fn} as {cls.__name__}\n{str(e)}"
+            raise CondaProjectError(msg)
+
+    class Config:
+        json_encoders = {Path: lambda v: v.as_posix()}
+        extra = "forbid"
+
+
+class CondaProjectYaml(BaseYaml):
+    name: str
+    environments: OrderedDict[str, List[Path]]
+
+
+class EnvironmentYaml(BaseYaml):
+    name: Optional[str] = None
+    channels: Optional[List[str]] = None
+    dependencies: List[Union[str, Dict[str, List[str]]]] = []
+    variables: Optional[Dict[str, str]] = None
+    prefix: Optional[Path] = None
+    platforms: Optional[List[str]] = None
+
+    @validator("dependencies")
+    def only_pip_key_allowed(cls, v):
+        for item in v:
+            if isinstance(item, dict):
+                if not item.keys() == {"pip"}:
+                    raise ValueError(
+                        f'The dependencies key contains an invalid map {item}. Only "pip:" is allowed.'
+                    )
+        return v
diff --git a/conda_project/utils.py b/conda_project/utils.py
new file mode 100644
index 00000000..c300e83a
--- /dev/null
+++ b/conda_project/utils.py
@@ -0,0 +1,106 @@
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+
+import itertools
+import os
+import sys
+import threading
+import time
+from collections.abc import Generator
+from contextlib import contextmanager
+from inspect import Traceback
+from pathlib import Path
+from typing import Optional, Type
+
+from .exceptions import CondaProjectError
+
+
+@contextmanager
+def env_variable(key: str, value: str) -> Generator:
+    """Temporarily set environment variable in a context manager."""
+    old = os.environ.get(key, None)
+    os.environ[key] = value
+
+    yield
+
+    if old is None:
+        os.environ.pop(key, None)
+    else:
+        os.environ[key] = old
+
+
+class Spinner:
+    """Multithreaded CLI spinner context manager
+
+    Attributes:
+        prefix: Text to display at the start of the line
+
+    Args:
+        prefix: Text to display at the start of the line
+
+    """
+
+    def __init__(self, prefix: str):
+        self.prefix = prefix
+        self._event = threading.Event()
+        self._thread = threading.Thread(target=self._spin)
+
+    def _spin(self) -> None:
+        spinner = itertools.cycle(["◜", "◠", "◝", "◞", "◡", "◟"])
+
+        while not self._event.is_set():
+            sys.stdout.write("\r")
+            sys.stdout.write("\033[K")
+            sys.stdout.write(f"{self.prefix}: {next(spinner)} ")
+            sys.stdout.flush()
+            time.sleep(0.10)
+
+    def start(self) -> None:
+        self._thread.start()
+
+    def stop(self) -> None:
+        self._event.set()
+        self._thread.join()
+        sys.stdout.write("\r")
+        sys.stdout.write("\033[K")
+        sys.stdout.write(f"{self.prefix}: done\n")
+        sys.stdout.flush()
+
+    def __enter__(self) -> None:
+        self.start()
+
+    def __exit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc_value: Optional[BaseException],
+        exc_tb: Optional[Traceback],
+    ) -> None:
+        self.stop()
+
+
+def find_file(directory: Path, options: tuple) -> Optional[Path]:
+    """Search for a file in a directory from a tuple of variants.
+
+    Returns:
+        The path to the file if found else None
+
+    Raises:
+        CondaProjectError if more than one of the options is found
+
+    """
+    found = []
+
+    for filename in options:
+        path = directory / filename
+        if path.exists():
+            found.append(path.resolve())
+
+    if len(found) == 1:
+        return found[0]
+    elif len(found) > 1:
+        _found_files = "\n".join([str(p) for p in found])
+        raise CondaProjectError(
+            f"Multiple variants of the same file were found.\n{_found_files}\nConsider using one of them."
+        )
+    else:
+        return None
diff --git a/environment.yml b/environment.yml
index b7e1eccc..04150ce2 100644
--- a/environment.yml
+++ b/environment.yml
@@ -9,3 +9,6 @@ dependencies:
   - pylint
   - pytest-cov
   - pre-commit
+  - conda-lock>=1
+  - pydantic
+  - ruamel.yaml
diff --git a/setup.cfg b/setup.cfg
index cec1443c..fd068a28 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,8 +1,11 @@
 [flake8]
 max-line-length = 120
-ignore = E122,E123,E126,E127,E128,E731,E722
+ignore = E122,E123,E126,E127,E128,E731,E722,W503
 exclude = build,conda_project/_version.py,tests,conda.recipe,.git,versioneer.py,benchmarks,.asv
 
+[isort]
+profile=black
+
 [tool:pytest]
 norecursedirs= .* *.egg* build dist conda.recipe examples env
 addopts =
diff --git a/setup.py b/setup.py
index 18232311..7ea84626 100644
--- a/setup.py
+++ b/setup.py
@@ -8,9 +8,7 @@
 with open("README.md", "r") as fh:
     long_description = fh.read()
 
-requirements = [
-    # package requirements go here
-]
+requirements = ["conda-lock>=1", "ruamel.yaml", "pydantic"]
 
 setup(
     name="conda-project",


Test patch:
diff --git a/etc/test-environment.yml b/etc/test-environment.yml
index c0543ebf..73b8112e 100644
--- a/etc/test-environment.yml
+++ b/etc/test-environment.yml
@@ -1,6 +1,10 @@
 name: test-conda-project
 channels:
   - defaults
+  - conda-forge
 dependencies:
+  - conda-lock>=1
+  - ruamel.yaml
+  - pydantic
   - pytest
   - pytest-cov
diff --git a/tests/conftest.py b/tests/conftest.py
index 80fbfda8..18158afb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,21 +4,25 @@
 from __future__ import annotations
 
 from pathlib import Path
+from typing import Optional
 
 import pytest
 
 
-@pytest.fixture(params=["environment.yml", "environment.yaml"])
+@pytest.fixture(params=[".yml", ".yaml"])
 def project_directory_factory(tmp_path, request):
     """A fixture returning a factory function used to create a temporary project directory."""
 
-    def _create_project_directory(
-        env_yaml: str = "", files: dict[str, str] | None = None
+    def create_project_directory(
+        env_yaml: Optional[str] = None,
+        project_yaml: Optional[str] = None,
+        files: dict[str, str] | None = None,
     ) -> Path:
         """Create a temporary project directory, optionally containing some files.
 
         Args:
-            env_yaml: The contents of the environment file to be included in the project directory.
+            env_yaml: The contents of the environment file to be included in the project directory. Optional
+            project_yaml: The contents of the conda-project file to be included in the project directory. Optional
             files: Additional files to be included in the project directory. The key is the filename,
                 and the value is a string of contents to write to the file.
 
@@ -26,17 +30,26 @@ def _create_project_directory(
             A path to the temporary project directory.
 
         """
-        env_filename = request.param
-        env_file = tmp_path / env_filename
-        with env_file.open("w") as f:
-            f.write(env_yaml)
+        suffix = request.param
+        if env_yaml is not None:
+            env_file = (tmp_path / "environment").with_suffix(suffix)
+            with env_file.open("w") as f:
+                f.write(env_yaml)
+
+        if project_yaml is not None:
+            project_file = (tmp_path / "conda-project").with_suffix(suffix)
+            with project_file.open("w") as f:
+                f.write(project_yaml)
 
         files = files or {}
         for fn, contents in files.items():
             path = tmp_path / fn
+            path.parent.mkdir(parents=True, exist_ok=True)
             with path.open("wt") as f:
                 f.write(contents)
 
         return tmp_path
 
-    return _create_project_directory
+    create_project_directory._suffix = request.param
+
+    return create_project_directory
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 49800f77..bf050306 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2,17 +2,20 @@
 # Copyright (C) 2022 Anaconda, Inc
 # SPDX-License-Identifier: BSD-3-Clause
 from functools import partial
+from textwrap import dedent
 
 import pytest
 
 from conda_project.cli.main import cli, main, parse_and_run
 
-COMMANDS = ["prepare", "clean"]
+PROJECT_COMMANDS = ("create", "check")
+ENVIRONMENT_COMMANDS = ("clean", "prepare", "lock")
+ALL_COMMANDS = PROJECT_COMMANDS + ENVIRONMENT_COMMANDS
 
 
 def test_known_commands():
     parser = cli()
-    assert parser._positionals._actions[2].choices.keys() == set(COMMANDS)
+    assert parser._positionals._actions[2].choices.keys() == set(ALL_COMMANDS)
 
 
 def test_no_command(capsys, monkeypatch):
@@ -24,8 +27,8 @@ def test_no_command(capsys, monkeypatch):
     assert "conda-project [-h] [-V] command" in out
 
 
-def test_no_env_yaml(tmpdir, monkeypatch, capsys):
-    monkeypatch.chdir(tmpdir)
+def test_no_env_yaml(tmp_path, monkeypatch, capsys):
+    monkeypatch.chdir(tmp_path)
 
     monkeypatch.setattr("sys.argv", ["conda-project", "prepare"])
     assert main() == 1
@@ -36,13 +39,13 @@ def test_no_env_yaml(tmpdir, monkeypatch, capsys):
 
 def test_unknown_command(capsys):
     with pytest.raises(SystemExit):
-        assert parse_and_run(["nope"]) is None
+        assert parse_and_run(["__nope__"]) is None
 
     err = capsys.readouterr().err
-    assert "invalid choice: 'nope'" in err
+    assert "invalid choice: '__nope__'" in err
 
 
-@pytest.mark.parametrize("command", COMMANDS)
+@pytest.mark.parametrize("command", ALL_COMMANDS)
 def test_command_args(command, monkeypatch, capsys):
     def mocked_command(command, args):
         print(f"I am {command}")
@@ -61,12 +64,185 @@ def mocked_command(command, args):
     assert "" == err
 
 
-@pytest.mark.parametrize("command", COMMANDS)
-def test_cli_verbose(command, monkeypatch):
-    def mocked_action(*args, **kwargs):
+@pytest.mark.parametrize("command", ENVIRONMENT_COMMANDS)
+def test_cli_verbose_env(command, monkeypatch, project_directory_factory):
+    if command == "create":
+        project_path = project_directory_factory()
+    else:
+        env_yaml = "dependencies: []\n"
+        project_path = project_directory_factory(env_yaml=env_yaml)
+
+    def mocked_action(*_, **kwargs):
+        assert kwargs.get("verbose", False)
+
+    monkeypatch.setattr(f"conda_project.project.Environment.{command}", mocked_action)
+
+    ret = parse_and_run([command, "--directory", str(project_path)])
+    assert ret == 0
+
+
+@pytest.mark.parametrize("command", PROJECT_COMMANDS)
+def test_cli_verbose_project(command, monkeypatch, project_directory_factory):
+    if command == "create":
+        project_path = project_directory_factory()
+    else:
+        env_yaml = "dependencies: []\n"
+        project_path = project_directory_factory(env_yaml=env_yaml)
+
+    def mocked_action(*_, **kwargs):
         assert kwargs.get("verbose", False)
 
     monkeypatch.setattr(f"conda_project.project.CondaProject.{command}", mocked_action)
 
-    ret = parse_and_run([command])
+    _ = parse_and_run([command, "--directory", str(project_path)])
+
+
+def test_create_with_prepare(tmp_path):
+    ret = parse_and_run(["create", "--directory", str(tmp_path), "--prepare"])
+
+    assert ret == 0
+
+    assert (tmp_path / "envs" / "default" / "conda-meta" / "history").exists()
+
+
+@pytest.mark.parametrize("command", ENVIRONMENT_COMMANDS)
+def test_command_with_environment_name(command, monkeypatch, project_directory_factory):
+    env1 = env2 = "dependencies: []\n"
+    project_yaml = dedent(
+        f"""\
+        name: multi-envs
+        environments:
+          env1: [env1{project_directory_factory._suffix}]
+          env2: [env2{project_directory_factory._suffix}]
+        """
+    )
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env1,
+            f"env2{project_directory_factory._suffix}": env2,
+        },
+    )
+
+    def mocked_action(self, *args, **kwargs):
+        assert self.name == "env1"
+
+    monkeypatch.setattr(f"conda_project.project.Environment.{command}", mocked_action)
+
+    ret = parse_and_run([command, "--directory", str(project_path), "env1"])
+    assert ret == 0
+
+
+def test_prepare_and_clean_all_environments(monkeypatch, project_directory_factory):
+    env1 = env2 = "dependencies: []\n"
+    project_yaml = dedent(
+        f"""\
+        name: multi-envs
+        environments:
+          env1: [env1{project_directory_factory._suffix}]
+          env2: [env2{project_directory_factory._suffix}]
+        """
+    )
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env1,
+            f"env2{project_directory_factory._suffix}": env2,
+        },
+    )
+
+    def mocked_action(self, *args, **kwargs):
+        assert self.name in ["env1", "env2"]
+
+    monkeypatch.setattr("conda_project.project.Environment.prepare", mocked_action)
+    monkeypatch.setattr("conda_project.project.Environment.clean", mocked_action)
+
+    ret = parse_and_run(["prepare", "--directory", str(project_path), "--all"])
+    assert ret == 0
+
+    ret = parse_and_run(["clean", "--directory", str(project_path), "--all"])
+    assert ret == 0
+
+
+def test_lock_all_environments(monkeypatch, project_directory_factory):
+    env1 = env2 = "dependencies: []\n"
+    project_yaml = dedent(
+        f"""\
+        name: multi-envs
+        environments:
+          env1: [env1{project_directory_factory._suffix}]
+          env2: [env2{project_directory_factory._suffix}]
+        """
+    )
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env1,
+            f"env2{project_directory_factory._suffix}": env2,
+        },
+    )
+
+    def mocked_action(self, *args, **kwargs):
+        assert self.name in ["env1", "env2"]
+
+    monkeypatch.setattr("conda_project.project.Environment.lock", mocked_action)
+
+    ret = parse_and_run(["lock", "--directory", str(project_path)])
     assert ret == 0
+
+
+@pytest.mark.slow
+def test_check_multi_env(project_directory_factory, capsys):
+    env1 = env2 = "dependencies: []\n"
+    project_yaml = dedent(
+        f"""\
+        name: multi-envs
+        environments:
+          env1: [env1{project_directory_factory._suffix}]
+          env2: [env2{project_directory_factory._suffix}]
+        """
+    )
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env1,
+            f"env2{project_directory_factory._suffix}": env2,
+        },
+    )
+
+    ret = parse_and_run(["check", "--directory", str(project_path)])
+    assert ret == 1
+
+    stderr = capsys.readouterr().err
+    assert "The environment env1 is not locked" in stderr
+    assert "The environment env2 is not locked" in stderr
+
+    ret = parse_and_run(["lock", "--directory", str(project_path)])
+    assert ret == 0
+
+    ret = parse_and_run(["check", "--directory", str(project_path)])
+    assert ret == 0
+
+    env1 = "dependencies: [python=3.8]\n"
+    with (project_path / f"env1{project_directory_factory._suffix}").open("w") as f:
+        f.write(env1)
+
+    ret = parse_and_run(["check", "--directory", str(project_path)])
+    assert ret == 1
+
+    stderr = capsys.readouterr().err
+    assert stderr
+    assert "The lockfile for environment env1 is out-of-date" in stderr
+    assert "The lockfile for environment env2" not in stderr
+
+    env2 = "dependencies: [python=3.8]\n"
+    with (project_path / f"env2{project_directory_factory._suffix}").open("w") as f:
+        f.write(env2)
+
+    ret = parse_and_run(["check", "--directory", str(project_path)])
+    assert ret == 1
+
+    stderr = capsys.readouterr().err
+    assert stderr
+    assert "The lockfile for environment env1 is out-of-date" in stderr
+    assert "The lockfile for environment env2 is out-of-date" in stderr
diff --git a/tests/test_conda.py b/tests/test_conda.py
index 6dd128f8..73930a90 100644
--- a/tests/test_conda.py
+++ b/tests/test_conda.py
@@ -3,18 +3,19 @@
 # SPDX-License-Identifier: BSD-3-Clause
 import pytest
 
-from conda_project.conda import call_conda
+from conda_project.conda import call_conda, conda_info, current_platform
 from conda_project.exceptions import CondaProjectError
 
 
-def test_local_condarc(tmpdir):
+def test_local_condarc(tmp_path):
     condarc = "channels: [__conda-project-test]\n"
-    condarc_file = tmpdir.join(".condarc")
-    condarc_file.write(condarc)
+    condarc_file = tmp_path / ".condarc"
+    with condarc_file.open("wt") as f:
+        f.write(condarc)
 
     proc = call_conda(
         ["config", "--show", "channels"],
-        condarc_path=condarc_file.strpath,
+        condarc_path=condarc_file,
         verbose=False,
     )
     channels = proc.stdout.splitlines()
@@ -34,3 +35,15 @@ def test_conda_output():
 
     proc = call_conda(["info"], verbose=True)
     assert not proc.stdout
+
+
+def test_conda_info():
+    info = conda_info()
+    assert "platform" in info
+    assert "conda_version" in info
+
+
+def test_current_platform(monkeypatch):
+    monkeypatch.setenv("CONDA_SUBDIR", "monkey-64")
+    platform = current_platform()
+    assert platform == "monkey-64"
diff --git a/tests/test_project.py b/tests/test_project.py
index bd79cfea..77ad5029 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -1,78 +1,1108 @@
 # -*- coding: utf-8 -*-
 # Copyright (C) 2022 Anaconda, Inc
 # SPDX-License-Identifier: BSD-3-Clause
+import json
+import logging
 import os
+from pathlib import Path
+from textwrap import dedent
 
 import pytest
+from ruamel.yaml import YAML
 
+from conda_project.conda import call_conda
 from conda_project.exceptions import CondaProjectError
-from conda_project.project import CondaProject
+from conda_project.project import DEFAULT_PLATFORMS, CondaProject
 
 
-def test_conda_project_init_no_env_yml(tmpdir):
+def is_libmamba_installed():
+    proc = call_conda(["list", "-n", "base", "conda-libmamba-solver", "--json"])
+    result = json.loads(proc.stdout)
+    if result:
+        return result[0]["name"] == "conda-libmamba-solver"
+    else:
+        return False
+
+
+def test_project_create_new_directory(tmp_path, capsys):
+    project_directory = tmp_path / "new-project"
+    assert not os.path.exists(project_directory)
+
+    p = CondaProject.create(project_directory, lock_dependencies=False, verbose=True)
+
+    assert os.path.exists(project_directory)
+    assert p.project_yaml_path.exists()
+    assert p.default_environment.sources[0].exists()
+    assert not p.default_environment.is_locked
+
+    assert p.condarc.exists()
+    with p.condarc.open() as f:
+        condarc = YAML().load(f)
+    assert condarc == {}
+
+    out, _ = capsys.readouterr()
+    assert f"Project created at {project_directory}\n" == out
+
+
+def test_project_create_twice(tmp_path, capsys):
+    _ = CondaProject.create(tmp_path, lock_dependencies=False)
+    p = CondaProject.create(tmp_path, lock_dependencies=False, verbose=True)
+
+    out, _ = capsys.readouterr()
+    assert f"Existing project file found at {p.project_yaml_path}.\n" == out
+
+
+def test_project_create_default_platforms(tmp_path):
+    p = CondaProject.create(tmp_path, lock_dependencies=False)
+
+    with p.default_environment.sources[0].open() as f:
+        env = YAML().load(f)
+
+    assert env["platforms"] == list(DEFAULT_PLATFORMS)
+
+
+def test_project_create_specific_platforms(tmp_path):
+    p = CondaProject.create(tmp_path, platforms=["linux-64"], lock_dependencies=False)
+
+    with p.default_environment.sources[0].open() as f:
+        env = YAML().load(f)
+
+    assert env["platforms"] == ["linux-64"]
+
+
+def test_project_create_specific_channels(tmp_path):
+    p = CondaProject.create(
+        tmp_path,
+        dependencies=["python=3.8", "numpy"],
+        channels=["conda-forge", "defaults"],
+        lock_dependencies=False,
+    )
+
+    with p.default_environment.sources[0].open() as f:
+        env = YAML().load(f)
+
+    assert env["dependencies"] == ["python=3.8", "numpy"]
+    assert env["channels"] == ["conda-forge", "defaults"]
+
+
+def test_project_create_default_channel(tmp_path):
+    p = CondaProject.create(
+        tmp_path, dependencies=["python=3.8", "numpy"], lock_dependencies=False
+    )
+
+    with p.default_environment.sources[0].open() as f:
+        env = YAML().load(f)
+
+    assert env["dependencies"] == ["python=3.8", "numpy"]
+    assert env["channels"] == ["defaults"]
+
+
+def test_project_create_conda_configs(tmp_path):
+    p = CondaProject.create(
+        tmp_path,
+        dependencies=["python=3.8", "numpy"],
+        conda_configs=["experimental_solver=libmamba"],
+        lock_dependencies=False,
+    )
+
+    for path in [p.condarc, p.default_environment.condarc]:
+        with path.open() as f:
+            condarc = YAML().load(f)
+
+        assert condarc["experimental_solver"] == "libmamba"
+
+
+@pytest.mark.slow
+def test_project_create_and_lock(tmp_path):
+    p = CondaProject.create(
+        tmp_path, dependencies=["python=3.8"], lock_dependencies=True
+    )
+    assert p.default_environment.lockfile.exists()
+    assert p.default_environment.lockfile == tmp_path / "default.conda-lock.yml"
+
+
+def test_conda_project_init_empty_dir(tmp_path, caplog):
+    caplog.set_level(logging.INFO)
+
     with pytest.raises(CondaProjectError) as excinfo:
-        CondaProject(tmpdir)
+        CondaProject(tmp_path)
     assert "No Conda environment.yml or environment.yaml file was found" in str(
         excinfo.value
     )
 
+    assert "No conda-project.yml or conda-project.yaml file was found" in caplog.text
+
+
+def test_conda_project_init_with_env_yaml(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+    project = CondaProject(project_path)
+
+    assert project.default_environment == project.environments["default"]
+
+    assert (
+        project.default_environment.lockfile
+        == project.directory / "default.conda-lock.yml"
+    )
+    assert project.default_environment.sources == (
+        (project.directory / "environment").with_suffix(
+            project_directory_factory._suffix
+        ),
+    )
+    assert project.default_environment.prefix == project.directory / "envs" / "default"
+
 
 def test_project_init_expands_cwd(monkeypatch, project_directory_factory):
-    project_path = project_directory_factory()
+    project_path = project_directory_factory(env_yaml="")
     monkeypatch.chdir(project_path)
 
     project = CondaProject()
     assert project.directory.samefile(project_path)
-    assert project.environment_file.exists()
 
 
 def test_project_init_path(project_directory_factory):
-    project_path = project_directory_factory()
+    project_path = project_directory_factory(env_yaml="")
 
     project = CondaProject(project_path)
     assert project.directory.samefile(project_path)
-    assert project.environment_file
+
+
+def test_prepare_with_gitignore(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+    project = CondaProject(project_path)
+
+    env_dir = project.default_environment.prepare()
+    assert (env_dir / ".gitignore").exists()
+    with (env_dir / ".gitignore").open("rt") as f:
+        assert f.read().strip() == "*"
 
 
 def test_prepare_no_dependencies(project_directory_factory):
-    env_yaml = """name: test
-dependencies: []
-"""
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
     project_path = project_directory_factory(env_yaml=env_yaml)
     project = CondaProject(project_path)
-    assert project.directory.samefile(project_path)
 
-    env_dir = project.prepare()
+    env_dir = project.default_environment.prepare()
     assert env_dir.samefile(project_path / "envs" / "default")
 
     conda_history = env_dir / "conda-meta" / "history"
     assert conda_history.exists()
+    assert project.default_environment.is_prepared
+
+
+@pytest.mark.slow
+def test_is_prepared(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: [python=3.8]
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+    project = CondaProject(project_path)
+
+    _ = project.default_environment.prepare()
+    assert project.default_environment.is_prepared
+
+    updated_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+          - requests
+        """
+    )
+
+    with (project.default_environment.sources[0]).open("wt") as f:
+        f.write(updated_yaml)
+
+    assert not project.default_environment.is_locked
+    assert not project.default_environment.is_prepared
+
+    _ = project.default_environment.prepare(force=False)
+    assert project.default_environment.is_locked
+    assert not project.default_environment.is_prepared
+
+    _ = project.default_environment.prepare(force=True)
+    assert project.default_environment.is_prepared
+
+
+@pytest.mark.slow
+def test_is_prepared_live_env_changed(project_directory_factory, capsys):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: [python=3.8]
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+    project = CondaProject(project_path)
+
+    _ = project.default_environment.prepare()
+    assert project.default_environment.is_locked
+    assert project.default_environment.is_prepared
+
+    _ = call_conda(
+        ["install", "-p", str(project.default_environment.prefix), "requests", "-y"]
+    )
+
+    assert project.default_environment.is_locked
+    assert not project.default_environment.is_prepared
+
+    _ = project.default_environment.prepare(force=False, verbose=True)
+    assert not project.default_environment.is_prepared
+
+    stdout = capsys.readouterr().out
+    assert "The environment exists but does not match the locked dependencies" in stdout
+
+
+@pytest.mark.slow
+def test_is_prepared_source_changed(project_directory_factory, capsys):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: [python=3.8]
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+    project = CondaProject(project_path)
+
+    _ = project.default_environment.prepare()
+    assert project.default_environment.is_locked
+    assert project.default_environment.is_prepared
+
+    _ = call_conda(
+        ["install", "-p", str(project.default_environment.prefix), "requests", "-y"]
+    )
+
+    assert project.default_environment.is_locked
+    assert not project.default_environment.is_prepared
+
+    _ = project.default_environment.prepare(force=False, verbose=True)
+    assert not project.default_environment.is_prepared
+
+    stdout = capsys.readouterr().out
+    assert "The environment exists but does not match the locked dependencies" in stdout
+
+
+def test_prepare_env_exists(project_directory_factory, capsys):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+    project = CondaProject(project_path)
+
+    env_dir = project.default_environment.prepare(verbose=True)
+
+    stdout = capsys.readouterr().out
+    assert f"environment created at {env_dir}" in stdout
+
+    _ = project.default_environment.prepare(verbose=True)
+
+    stdout = capsys.readouterr().out
+    assert "The environment already exists" in stdout
 
 
 @pytest.mark.slow
 def test_prepare_and_clean(project_directory_factory):
-    env_yaml = """name: test
-dependencies:
-  - python=3.8
-"""
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+        """
+    )
     project_path = project_directory_factory(env_yaml=env_yaml)
 
     project = CondaProject(project_path)
-    env_dir = project.prepare()
+    env_dir = project.default_environment.prepare()
     assert env_dir.samefile(project_path / "envs" / "default")
 
     conda_history = env_dir / "conda-meta" / "history"
     assert conda_history.exists()
 
     with conda_history.open() as f:
-        assert "# update specs: ['python=3.8']" in f.read()
+        assert "create -y --file" in f.read()
     conda_history_mtime = os.path.getmtime(conda_history)
 
-    project.prepare()
+    project.default_environment.prepare()
     assert conda_history_mtime == os.path.getmtime(conda_history)
 
-    project.prepare(force=True)
+    project.default_environment.prepare(force=True)
     assert conda_history_mtime < os.path.getmtime(conda_history)
 
-    project.clean()
+    project.default_environment.clean()
     assert not conda_history.exists()
+    assert not project.default_environment.is_prepared
+
+
+@pytest.mark.slow
+def test_lock(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    lockfile = project_path / "default.conda-lock.yml"
+    assert lockfile == project.default_environment.lockfile
+    assert lockfile.exists()
+    assert project.default_environment.is_locked
+
+
+def test_lock_no_channels(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+
+    with pytest.warns(UserWarning, match=r"there are no 'channels:' key.*"):
+        project.default_environment.lock(verbose=True)
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+
+    assert [c["url"] for c in lock["metadata"]["channels"]] == ["defaults"]
+
+
+def test_lock_with_channels(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        channels: [defusco, conda-forge, defaults]
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+
+    assert [c["url"] for c in lock["metadata"]["channels"]] == [
+        "defusco",
+        "conda-forge",
+        "defaults",
+    ]
+
+
+def test_lock_no_platforms(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+
+    assert lock["metadata"]["platforms"] == list(DEFAULT_PLATFORMS)
+
+
+def test_lock_with_platforms(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        platforms: [linux-64, osx-64]
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock(verbose=True)
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+
+    assert lock["metadata"]["platforms"] == ["linux-64", "osx-64"]
+
+
+def test_lock_wrong_platform(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        platforms: [dummy-platform]
+        """
+    )
+
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+    assert project.default_environment.is_locked
+
+    with pytest.raises(CondaProjectError) as e:
+        project.default_environment.prepare()
+    assert "not in the supported locked platforms" in str(e.value)
+
+
+def test_force_relock(project_directory_factory, capsys):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock(verbose=True)
+    stdout = capsys.readouterr().out
+    assert project.default_environment.is_locked
+
+    lockfile_mtime = os.path.getmtime(project.default_environment.lockfile)
+    project.default_environment.lock(verbose=True)
+    stdout = capsys.readouterr().out
+    assert (
+        "The lockfile default.conda-lock.yml already exists and is up-to-date."
+        in stdout
+    )
+    assert lockfile_mtime == os.path.getmtime(project.default_environment.lockfile)
+
+    project.default_environment.lock(force=True)
+    assert lockfile_mtime < os.path.getmtime(project.default_environment.lockfile)
+
+
+def test_lock_outdated(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies: []
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock(verbose=True)
+    assert project.default_environment.is_locked
+
+    updated_env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+        """
+    )
+    with (project.default_environment.sources[0]).open("wt") as f:
+        f.write(updated_env_yaml)
+
+    assert not project.default_environment.is_locked
+
+
+@pytest.mark.slow
+def test_relock_add_packages(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    assert project.default_environment.lockfile.exists()
+    lockfile_mtime = os.path.getmtime(project.default_environment.lockfile)
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+    assert "python" in [p["name"] for p in lock["package"]]
+    assert "requests" not in [p["name"] for p in lock["package"]]
+
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+          - requests
+        """
+    )
+    with project.default_environment.sources[0].open("w") as f:
+        f.write(env_yaml)
+
+    assert not project.default_environment.is_locked
+
+    project.default_environment.lock()
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+    assert "python" in [p["name"] for p in lock["package"]]
+    assert "requests" in [p["name"] for p in lock["package"]]
+
+    assert lockfile_mtime < os.path.getmtime(project.default_environment.lockfile)
+
+
+@pytest.mark.slow
+def test_relock_remove_packages(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+          - requests
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    assert project.default_environment.lockfile.exists()
+    lockfile_mtime = os.path.getmtime(project.default_environment.lockfile)
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+    assert "python" in [p["name"] for p in lock["package"]]
+    assert "requests" in [p["name"] for p in lock["package"]]
+
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+        """
+    )
+    with project.default_environment.sources[0].open("w") as f:
+        f.write(env_yaml)
+
+    project.default_environment.lock()
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+    assert "python" in [p["name"] for p in lock["package"]]
+    assert "requests" not in [p["name"] for p in lock["package"]]
+
+    assert lockfile_mtime < os.path.getmtime(project.default_environment.lockfile)
+
+
+@pytest.mark.slow
+def test_relock_failed(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+          - requests
+        """
+    )
+    project_path = project_directory_factory(env_yaml=env_yaml)
+
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    assert project.default_environment.lockfile.exists()
+    lockfile_mtime = os.path.getmtime(project.default_environment.lockfile)
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+    assert "python" in [p["name"] for p in lock["package"]]
+    assert "requests" in [p["name"] for p in lock["package"]]
+
+    env_yaml = dedent(
+        """\
+        name: test
+        dependencies:
+          - python=3.8
+          - __bad-package
+        """
+    )
+    with project.default_environment.sources[0].open("w") as f:
+        f.write(env_yaml)
+
+    with pytest.raises(CondaProjectError):
+        project.default_environment.lock()
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+    assert "python" in [p["name"] for p in lock["package"]]
+    assert "requests" in [p["name"] for p in lock["package"]]
+    assert "__bad-package" not in [p["name"] for p in lock["package"]]
+
+    assert lockfile_mtime == os.path.getmtime(project.default_environment.lockfile)
+
+
+def test_project_named_environment(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          standard: [environment{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        env_yaml=env_yaml, project_yaml=project_yaml
+    )
+    project = CondaProject(project_path)
+
+    assert project.environments.keys() == {"standard"}
+    assert (
+        project.environments["standard"]
+        .sources[0]
+        .samefile(
+            (project_path / "environment").with_suffix(
+                project_directory_factory._suffix
+            )
+        )
+    )
+    assert project.default_environment == project.environments["standard"]
+
+
+def test_project_hyphen_named_environment(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          my-env: [environment{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        env_yaml=env_yaml, project_yaml=project_yaml
+    )
+    project = CondaProject(project_path)
+
+    assert project.environments.keys() == {"my-env"}
+    assert (
+        project.environments["my-env"]
+        .sources[0]
+        .samefile(
+            (project_path / "environment").with_suffix(
+                project_directory_factory._suffix
+            )
+        )
+    )
+    assert project.default_environment == project.environments["my-env"]
+
+
+def test_prepare_named_environment(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          standard: [environment{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        env_yaml=env_yaml, project_yaml=project_yaml
+    )
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+    env_dir = project.default_environment.prepare()
+
+    assert project.environments["standard"].lockfile.samefile(
+        project_path / "standard.conda-lock.yml"
+    )
+    assert project.environments["standard"].prefix.samefile(
+        project_path / "envs" / "standard"
+    )
+
+    assert env_dir.samefile(project_path / "envs" / "standard")
+
+    conda_history = env_dir / "conda-meta" / "history"
+    assert conda_history.exists()
+
+
+def test_project_environments_immutable(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          default: [env{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={f"env{project_directory_factory._suffix}": env_yaml},
+    )
+    project = CondaProject(project_path)
+
+    with pytest.raises(TypeError):
+        project.default_environment.sources[0] = ("empty",)
+
+    with pytest.raises(TypeError):
+        project.environments["new"] = project.default_environment
+
+    with pytest.raises(TypeError):
+        project.environments["default"] = project.default_environment
+
+    with pytest.raises(TypeError):
+        project.environments.default = project.default_environment
+
+
+def test_project_multiple_envs(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          bbb: [env1{project_directory_factory._suffix}]
+          default: [env2{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env_yaml,
+            f"env2{project_directory_factory._suffix}": env_yaml,
+        },
+    )
+    project = CondaProject(project_path)
+
+    assert project.environments.keys() == {"bbb", "default"}
+    assert project.default_environment.name == "bbb"
+
+
+def test_lock_prepare_clean_default_with_multiple_envs(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          bbb: [env1{project_directory_factory._suffix}]
+          default: [env2{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env_yaml,
+            f"env2{project_directory_factory._suffix}": env_yaml,
+        },
+    )
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+    project.default_environment.prepare()
+
+    assert project.default_environment.lockfile.samefile(
+        project_path / "bbb.conda-lock.yml"
+    )
+    assert project.default_environment.lockfile.exists()
+
+    assert project.default_environment.prefix.samefile(project_path / "envs" / "bbb")
+    assert (project.default_environment.prefix / "conda-meta" / "history").exists()
+
+    project.default_environment.clean()
+    assert not project.default_environment.prefix.exists()
+
+
+def test_lock_prepare_clean_named_with_multiple_envs(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          bbb: [env1{project_directory_factory._suffix}]
+          default: [env2{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env_yaml,
+            f"env2{project_directory_factory._suffix}": env_yaml,
+        },
+    )
+    project = CondaProject(project_path)
+    project.environments["default"].lock()
+    project.environments["default"].prepare()
+
+    assert project.environments["default"].lockfile.samefile(
+        project_path / "default.conda-lock.yml"
+    )
+    assert project.environments["default"].lockfile.exists()
+
+    assert project.environments["default"].prefix.samefile(
+        project_path / "envs" / "default"
+    )
+    assert (project.environments["default"].prefix / "conda-meta" / "history").exists()
+
+    project.environments["default"].clean()
+    assert not project.environments["default"].prefix.exists()
+
+
+def test_lock_prepare_clean_multiple_envs(project_directory_factory):
+    env_yaml = "dependencies: []\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          bbb: [env1{project_directory_factory._suffix}]
+          default: [env2{project_directory_factory._suffix}]
+        """
+    )
+
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env_yaml,
+            f"env2{project_directory_factory._suffix}": env_yaml,
+        },
+    )
+    project = CondaProject(project_path)
+
+    project.environments["bbb"].lock()
+    project.environments["bbb"].prepare()
+
+    assert project.environments["bbb"].lockfile.samefile(
+        project_path / "bbb.conda-lock.yml"
+    )
+    assert project.environments["bbb"].lockfile.exists()
+
+    assert project.environments["bbb"].prefix.samefile(project_path / "envs" / "bbb")
+    assert (project.environments["bbb"].prefix / "conda-meta" / "history").exists()
+
+    project.environments["default"].lock()
+    project.environments["default"].prepare()
+
+    assert project.environments["default"].lockfile.samefile(
+        project_path / "default.conda-lock.yml"
+    )
+    assert project.environments["default"].lockfile.exists()
+
+    assert project.environments["default"].prefix.samefile(
+        project_path / "envs" / "default"
+    )
+    assert (project.environments["default"].prefix / "conda-meta" / "history").exists()
+
+    project.environments["bbb"].clean()
+    assert not project.environments["bbb"].prefix.exists()
+
+    project.environments["default"].clean()
+    assert not project.environments["default"].prefix.exists()
+
+
+@pytest.mark.slow
+def test_project_lock_env_multiple_sources(project_directory_factory):
+    environment_yml = "dependencies: [python]\n"
+
+    extras_yml = "dependencies: [requests]\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          default:
+            - environment{project_directory_factory._suffix}
+            - extras{project_directory_factory._suffix}
+        """
+    )
+
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"environment{project_directory_factory._suffix}": environment_yml,
+            f"extras{project_directory_factory._suffix}": extras_yml,
+        },
+    )
+    project = CondaProject(project_path)
+    project.default_environment.lock()
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+
+    assert lock["metadata"]["sources"] == [
+        str(project_path / f"environment{project_directory_factory._suffix}"),
+        str(project_path / f"extras{project_directory_factory._suffix}"),
+    ]
+
+    assert "requests" in [p["name"] for p in lock["package"]]
+    assert "python" in [p["name"] for p in lock["package"]]
+
+
+@pytest.mark.slow
+def test_project_lock_env_multiple_sources_different_directories(
+    project_directory_factory,
+):
+    environment_yml = "dependencies: [python]\n"
+
+    extras_yml = "dependencies: [requests]\n"
+
+    project_yaml = dedent(
+        f"""\
+        name: test
+        environments:
+          default:
+            - ./environment{project_directory_factory._suffix}
+            - ../extras{project_directory_factory._suffix}
+        """
+    )
+
+    project_path = project_directory_factory(
+        files={
+            f"project/conda-project{project_directory_factory._suffix}": project_yaml,
+            f"project/environment{project_directory_factory._suffix}": environment_yml,
+            f"extras{project_directory_factory._suffix}": extras_yml,
+        },
+    )
+    project = CondaProject(project_path / "project")
+
+    assert project.default_environment.sources[0].samefile(
+        project_path / "project" / f"environment{project_directory_factory._suffix}"
+    )
+    assert project.default_environment.sources[1].samefile(
+        project_path / f"extras{project_directory_factory._suffix}"
+    )
+
+    project.default_environment.lock()
+
+    with project.default_environment.lockfile.open() as f:
+        lock = YAML().load(f)
+
+    assert Path(lock["metadata"]["sources"][0]).samefile(
+        project_path / "project" / f"environment{project_directory_factory._suffix}"
+    )
+    assert Path(lock["metadata"]["sources"][1]).samefile(
+        project_path / f"extras{project_directory_factory._suffix}"
+    )
+
+    assert "requests" in [p["name"] for p in lock["package"]]
+    assert "python" in [p["name"] for p in lock["package"]]
+
+
+@pytest.mark.skipif(
+    not is_libmamba_installed(), reason="Libmamba solver not installed."
+)
+def test_failed_to_solve_libmamba(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: fail
+        channels:
+          - conda-forge
+
+        dependencies:
+          - ensureconda
+          - conda-token
+        """
+    )
+
+    condarc = "experimental_solver: libmamba"
+
+    project_path = project_directory_factory(
+        env_yaml=env_yaml, files={".condarc": condarc}
+    )
+    project = CondaProject(project_path)
+
+    with pytest.raises(CondaProjectError) as exinfo:
+        project.default_environment.lock()
+
+    assert "The following packages are missing from the supplied channels" in str(
+        exinfo.value
+    )
+
+
+@pytest.mark.skipif(
+    not is_libmamba_installed(), reason="Libmamba solver not installed."
+)
+def test_failed_to_solve_classic(project_directory_factory):
+    env_yaml = dedent(
+        """\
+        name: fail
+        channels:
+          - conda-forge
+
+        dependencies:
+          - ensureconda
+          - conda-token
+        """
+    )
+    condarc = "experimental_solver: classic"
+
+    project_path = project_directory_factory(
+        env_yaml=env_yaml, files={".condarc": condarc}
+    )
+    project = CondaProject(project_path)
+
+    with pytest.raises(CondaProjectError) as exinfo:
+        project.default_environment.lock()
+
+    assert "The following packages are not available from current channels:" in str(
+        exinfo.value
+    )
+
+
+def test_check_multi_env(project_directory_factory):
+    env1 = env2 = "dependencies: []\n"
+    project_yaml = dedent(
+        f"""\
+        name: multi-envs
+        environments:
+          env1: [env1{project_directory_factory._suffix}]
+          env2: [env2{project_directory_factory._suffix}]
+        """
+    )
+    project_path = project_directory_factory(
+        project_yaml=project_yaml,
+        files={
+            f"env1{project_directory_factory._suffix}": env1,
+            f"env2{project_directory_factory._suffix}": env2,
+        },
+    )
+
+    project = CondaProject(project_path)
+    assert not project.check()
+
+    project.environments["env1"].lock()
+    assert not project.check()
+
+    project.environments["env2"].lock()
+    assert project.check()
+
+    env1 = "dependencies: [python=3.8]\n"
+    with (project_path / f"env1{project_directory_factory._suffix}").open("w") as f:
+        f.write(env1)
+
+    assert not project.check()
diff --git a/tests/test_project_file.py b/tests/test_project_file.py
new file mode 100644
index 00000000..35e030e0
--- /dev/null
+++ b/tests/test_project_file.py
@@ -0,0 +1,138 @@
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+
+from io import StringIO
+from pathlib import Path
+from textwrap import dedent
+from typing import Dict, List, Optional, Union
+
+import pytest
+
+from conda_project.exceptions import CondaProjectError
+from conda_project.project_file import BaseYaml, CondaProjectYaml, EnvironmentYaml
+
+
+def test_empty_environment():
+    env_dict = {"dependencies": []}
+
+    env = EnvironmentYaml(**env_dict)
+    assert env.name is None
+    assert env.dependencies == []
+    assert env.channels is None
+    assert env.variables is None
+    assert env.prefix is None
+
+
+def test_unsupported_key_in_dependencies():
+    env_dict = {"name": "unsupported", "dependencies": ["python", {"npm": ["foo"]}]}
+
+    with pytest.raises(ValueError):
+        _ = EnvironmentYaml(**env_dict)
+
+
+def test_to_yaml_with_indent():
+    class Yaml(BaseYaml):
+        foo: str
+        stuff: List[str]
+
+    yml = Yaml(foo="bar", stuff=["thing1", "thing2"])
+    stream = StringIO()
+    yml.yaml(stream)
+    assert stream.getvalue() == "foo: bar\nstuff:\n  - thing1\n  - thing2\n"
+
+
+def test_yaml_dump_skip_empty_keys():
+    class Yaml(BaseYaml):
+        filled: str
+        empty: Optional[str] = None
+        nested: Dict[str, Union[None, List[str]]] = {}
+
+    yml = Yaml(filled="foo", nested={"a": ["b"], "c": None, "d": []})
+    stream = StringIO()
+    yml.yaml(stream)
+    assert stream.getvalue() == "filled: foo\nnested:\n  a:\n    - b\n  d: []\n"
+
+
+def test_bad_yaml_file():
+    class YamlFile(BaseYaml):
+        attribute: str
+
+    yml = "attribute: correct\nmore_attributes: wrong"
+
+    with pytest.raises(CondaProjectError) as exinfo:
+        _ = YamlFile.parse_yaml(yml)
+
+    assert "validation error for YamlFile" in str(exinfo.value)
+
+
+def test_miss_spelled_env_yaml_file():
+    environment_yaml = dedent(
+        """\
+        name: misspelled
+        channel:
+            - defaults
+
+        dependencies: []
+        """
+    )
+
+    with pytest.raises(CondaProjectError) as exinfo:
+        _ = EnvironmentYaml.parse_yaml(environment_yaml)
+
+    assert "validation error for EnvironmentYaml" in str(exinfo.value)
+
+
+def test_empty_project_yaml_file():
+    environment_yaml = ""
+
+    with pytest.raises(CondaProjectError) as exinfo:
+        _ = CondaProjectYaml.parse_yaml(environment_yaml)
+
+    assert "The file appears to be empty." in str(exinfo.value)
+
+
+def test_project_file_with_one_env():
+    project_dict = {
+        "name": "one-env",
+        "environments": {"default": ["./environment.yml"]},
+    }
+
+    project_file = CondaProjectYaml(**project_dict)
+    assert project_file.name == "one-env"
+    assert project_file.environments["default"] == [Path("./environment.yml")]
+
+
+def test_project_yaml_round_trip():
+    project_file_input = dedent(
+        """\
+        name: my-project
+        # comment
+        environments:
+          default:
+            - ./environment.yml
+            - ../dev.yaml
+          another:
+            - another-env.yml
+        """
+    )
+
+    project_file = CondaProjectYaml.parse_yaml(project_file_input)
+
+    stream = StringIO()
+    project_file.yaml(stream)
+
+    written_contents = stream.getvalue()
+
+    expected_contents = dedent(
+        """\
+        name: my-project
+        environments:
+          default:
+            - environment.yml
+            - ../dev.yaml
+          another:
+            - another-env.yml
+        """
+    )
+
+    assert written_contents == expected_contents
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..52d20774
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2022 Anaconda, Inc
+# SPDX-License-Identifier: BSD-3-Clause
+
+import os
+
+import pytest
+
+from conda_project.exceptions import CondaProjectError
+from conda_project.utils import env_variable, find_file
+
+
+def test_env_var_context():
+    foo = "_conda_project_foo"
+    assert foo not in os.environ
+
+    with env_variable(foo, "bar"):
+        assert os.getenv(foo) == "bar"
+
+    assert foo not in os.environ
+
+
+def test_replace_env_var_context():
+    foo = "_conda_project_foo"
+    os.environ[foo] = "bar"
+
+    with env_variable(foo, "baz"):
+        assert os.getenv(foo) == "baz"
+
+    assert os.getenv(foo) == "bar"
+
+
+def test_find_file(tmp_path):
+    yaml = tmp_path / "file.yaml"
+    yml = tmp_path / "file.yml"
+
+    yaml.touch()
+    yml.touch()
+
+    assert find_file(tmp_path, ("missing.yml",)) is None
+    assert find_file(tmp_path, ("file.yml",)) == yml.resolve()
+    assert find_file(tmp_path, ("file.yaml",)) == yaml.resolve()
+
+    with pytest.raises(CondaProjectError):
+        find_file(tmp_path, ("file.yaml", "file.yml"))