Source code for meta_package_manager.managers.conda

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

from extra_platforms import LINUX_LIKE, MACOS, WINDOWS

from ..capabilities import search_capabilities
from ..manager import PackageManager

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ..package import Package


[docs] class Conda(PackageManager): """Conda cross-language package and environment manager. Reads go through conda's `--json` mode: installed packages come from `conda list --json` and search from `conda search "*query*" --json`. conda has no dedicated outdated command, so the upgrade the solver would perform is simulated with `conda update --all --dry-run --json` and its `UNLINK` (current) and `LINK` (candidate) sets are diffed by name: a name in both is an in-place upgrade, while a `LINK`-only entry is a freshly pulled dependency and is not reported. ```{note} Every operation targets conda's *currently active* environment, which is `base` when none is activated. mpm neither activates nor switches environments: it inspects and mutates whatever environment conda resolves from the inherited `CONDA_PREFIX` / `CONDA_DEFAULT_ENV`, exactly as a bare `conda` call in the same shell would. Per-environment targeting is not supported yet. ``` ```{note} The `>=4.6.0` floor is the release where `update --dry-run --json` settled on an `actions` mapping whose `LINK` / `UNLINK` values are package dicts, the shape the outdated diff parses. Much older conda wrapped `actions` in a list and emitted bare `channel::name-version-build` strings instead. ``` """ name = "Conda" homepage_url = "https://conda.org" logo = "anaconda" platforms = LINUX_LIKE, MACOS, WINDOWS requirement = ">=4.6.0" """`4.6.0` is a conservative floor. By this release ``conda update --dry-run --json` reports `actions` as a single mapping whose `LINK`` / `UNLINK` values are lists of package dicts: the exact shape {meth}`outdated` parses. Much older conda wrapped `actions` in a list and emitted bare `channel::name-version-build` strings instead of dicts, which the parser below does not handle. The `--json` output of `list` and `search` predates this floor by years. See [4.6.0 release](https://github.com/conda/conda/releases/tag/4.6.0). """ version_regexes = (r"conda\s+(?P<version>\S+)",) """ ```{code-block} shell-session $ conda --version conda 24.5.0 ``` """ @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ conda list --json [ { "base_url": "https://repo.anaconda.com/pkgs/main", "build_number": 0, "build_string": "py312hca03da5_0", "channel": "pkgs/main", "dist_name": "pip-24.0-py312hca03da5_0", "name": "pip", "platform": "osx-arm64", "version": "24.0" }, { "base_url": "https://repo.anaconda.com/pkgs/main", "build_number": 0, "build_string": "py312_0", "channel": "pkgs/main", "dist_name": "pytz-2024.1-py312_0", "name": "pytz", "platform": "osx-arm64", "version": "2024.1" } ] ``` """ output = self.run_cli("list", "--json", must_succeed=True) yield from self.parse_json_items( output, fields={"package_id": "name", "installed_version": "version"}, ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. There is no dedicated `conda outdated` command, so the upgrade the solver *would* perform is simulated with `--dry-run` and the `UNLINK` (current) / `LINK` (candidate) sets are diffed by name. A package appearing in both is an in-place upgrade; one appearing in only `LINK` is a freshly-pulled dependency and one in only `UNLINK` is a removal, so neither is reported. ```{code-block} shell-session $ conda update --all --dry-run --json { "actions": { "FETCH": [], "LINK": [ { "base_url": "https://repo.anaconda.com/pkgs/main", "build_number": 0, "build_string": "py312_0", "channel": "pkgs/main", "dist_name": "pytz-2024.2-py312_0", "name": "pytz", "platform": "osx-arm64", "version": "2024.2" } ], "UNLINK": [ { "base_url": "https://repo.anaconda.com/pkgs/main", "build_number": 0, "build_string": "py312_0", "channel": "pkgs/main", "dist_name": "pytz-2024.1-py312_0", "name": "pytz", "platform": "osx-arm64", "version": "2024.1" } ], "PREFIX": "/opt/conda" }, "dry_run": true, "prefix": "/opt/conda", "success": true } ``` When the environment is already current, conda omits the `actions` key entirely: ```{code-block} shell-session $ conda update --all --dry-run --json { "message": "All requested packages already installed.", "success": true } ``` """ output = self.run_cli( "update", "--all", "--dry-run", "--json", must_succeed=True ) data = self.parse_json(output) actions = data.get("actions") if data else None if not actions: return installed = {pkg["name"]: pkg["version"] for pkg in actions.get("UNLINK", ())} for pkg in actions.get("LINK", ()): # Only a name present in both sets is an in-place upgrade. A LINK-only # entry is a new dependency the upgrade would pull in, not an outdated # package. if pkg["name"] in installed: yield self.package( id=pkg["name"], installed_version=installed[pkg["name"]], latest_version=pkg["version"], )
[docs] @search_capabilities(extended_support=False, exact_support=False) def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. ```{caution} Search does not support extended or exact matching. The query is wrapped in `*` wildcards to get the broadest substring match conda offers, and {meth}`meta_package_manager.manager.PackageManager.refiltered_search` narrows the results down. conda exposes no package description in its search output, so extended matching has nothing to match against. ``` conda returns every available build of every matching package, grouped by name and sorted by ascending version, so the last record of each group carries the latest version. ```{code-block} shell-session $ conda search "*pytz*" --json { "pytz": [ { "arch": null, "build": "py27_0", "build_number": 0, "channel": "https://repo.anaconda.com/pkgs/main/osx-arm64", "name": "pytz", "version": "2013b" }, { "arch": null, "build": "py312_0", "build_number": 0, "channel": "https://repo.anaconda.com/pkgs/main/osx-arm64", "name": "pytz", "version": "2024.1" } ] } ``` """ output = self.run_cli("search", f"*{query}*", "--json") data = self.parse_json(output) if data: # A query matching nothing is reported as a non-zero exit carrying an # error payload ({"error": ..., "exception_name": # "PackagesNotFoundError"}) rather than an empty object, so skip any # value that is not a build list. for package_id, builds in data.items(): if isinstance(builds, list) and builds: yield self.package( id=package_id, latest_version=builds[-1]["version"], )
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package, optionally pinned to a version. conda accepts a `MatchSpec` so the version is appended with `=`. ```{code-block} shell-session $ conda install --yes pytz ## Package Plan ## environment location: /opt/conda added / updated specs: - pytz Preparing transaction: done Verifying transaction: done Executing transaction: done ``` ```{code-block} shell-session $ conda install --yes pytz=2024.1 ``` """ spec = package_id if version is None else f"{package_id}={version}" return self.run_cli("install", "--yes", spec)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generate the CLI to upgrade all packages. ```{code-block} shell-session $ conda update --all --yes ``` """ return self.build_cli("update", "--all", "--yes")
[docs] def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generate the CLI to upgrade a single package, optionally to a version. ```{code-block} shell-session $ conda update --yes pytz ``` ```{code-block} shell-session $ conda update --yes pytz=2024.2 ``` """ spec = package_id if version is None else f"{package_id}={version}" return self.build_cli("update", "--yes", spec)
[docs] def remove(self, package_id: str) -> str: """Remove one package. ```{code-block} shell-session $ conda remove --yes pytz ## Package Plan ## environment location: /opt/conda removed specs: - pytz Preparing transaction: done Verifying transaction: done Executing transaction: done ``` """ return self.run_cli("remove", "--yes", package_id)
[docs] def cleanup_cache(self) -> None: """Removes tarballs, unused packages and index caches. ```{code-block} shell-session $ conda clean --all --yes Will remove 42 (123.4 MB) tarball(s). Will remove 1 index cache(s). Will remove 7 (45.6 MB) package(s). ``` """ self.run_cli("clean", "--all", "--yes")