# 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
import json
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.
.. 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.
"""
name = "Conda"
homepage_url = "https://conda.org"
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
:py: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)
if output:
for package in json.loads(output):
yield self.package(
id=package["name"],
installed_version=package["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
)
if not output:
return
actions = json.loads(output).get("actions")
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
:py: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")
if output:
# 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 json.loads(output).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(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")