# 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
from ..version import parse_version
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
[docs]
class Mamba(PackageManager):
"""Conda-compatible package manager, reimplemented in C++.
Reaches the same channels [`conda`](conda.md) does, resolving with libsolv
behind a command line of its own rather than delegating to conda. It is
wrapped on the same grounds as [`nala`](nala.md) over `apt`: same archives,
separate implementation. Since `2.0` it shares no code with conda at all,
the Python executable of the `1.x` line having been replaced by a
dynamically linked build of micromamba.
```{important}
Every operation targets mamba's *currently active* environment, which is
`base` when none is activated. `mpm` neither activates nor switches
environments: it inspects and mutates whatever mamba resolves from the
inherited `CONDA_PREFIX` / `CONDA_DEFAULT_ENV`, exactly as a bare `mamba`
call in the same shell would, and exactly as the conda wrapper does.
Per-environment targeting is not supported yet.
```
```{caution}
Sharing that prefix with conda is why the two are serialized against each
other. mamba takes a real lock on the environment and on every package
cache directory for the length of a transaction, and conda honors none of
them: its own locking covers the repodata cache alone. Running them at once
corrupts rather than blocks, which upstream has closed as not planned
([conda/conda#13037](https://github.com/conda/conda/issues/13037)).
```
```{note}
No `sync`. Nothing in the command set refreshes the index on its own, the
closest being `clean --index-cache`, which only forces a refetch on the
next operation. The conda wrapper implements none either, so this is parity
rather than a gap.
```
```{warning}
`mamba upgrade` does not exist: unlike conda, mamba never aliased it, and
calling it exits non-zero on an unexpected argument. Upgrades go through
`update`.
```
Documentation: [mamba user guide](https://mamba.readthedocs.io/en/latest/user_guide/mamba.html).
"""
name = "Mamba"
homepage_url = "https://mamba.readthedocs.io"
logo = "anaconda"
platforms = LINUX_LIKE, MACOS, WINDOWS
requirement = ">=2.0.0"
"""The release that made mamba a standalone C++ program.
Chosen over the newer `2.9.0`, where the inventory settled on one shape, so
the floor does not exclude every release a user is realistically running:
`2.9.0` is days old. {meth}`installed` reads both shapes instead.
"""
version_regexes = (r"^(?P<version>\d+\.\d+\.\d+\S*)$",)
r"""Search the bare version mamba reports.
```{code-block} shell-session
$ mamba --version
2.9.0
```
Anchored at both ends because the output is the version and nothing else:
mamba prints no program name to key on, unlike conda's `conda 24.5.0`. The
patterns are applied in multiline mode, so the anchors bind to the line.
"""
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ mamba list --json
{
"log_history": [],
"packages": [
{
"base_url": "https://conda.anaconda.org/conda-forge",
"build_number": 2,
"build_string": "h23cfdf5_2",
"channel": "conda-forge",
"dist_name": "libiconv-1.18-h23cfdf5_2",
"md5": "4d5a7445f0b25b6a3ddbb56e790f5251",
"name": "libiconv",
"platform": "osx-arm64",
"url": "https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda",
"version": "1.18"
}
]
}
```
```{caution}
Two payload shapes are accepted, and reading the wrong one is silent.
`2.9.0` wrapped this array in an envelope carrying a `log_history`
sibling, where every release before it printed the array bare, the way
conda still does. Neither shape raises on the other's reader: it simply
finds no list and reports an empty inventory, so the array is located
by shape rather than by a fixed path.
That same envelope is why success is read from the exit code and the
presence of `packages`, never from the output parsing. Pointed at a
prefix that does not exist, `2.9.0` exits non-zero with an empty
`stderr` and tens of kilobytes of trace on `stdout`, as JSON that
parses perfectly and holds no packages.
```
"""
output = self.run_cli("list", "--json", must_succeed=True)
data = self.parse_json(output)
packages = data.get("packages") if isinstance(data, dict) else data
if not isinstance(packages, list):
return
for pkg in packages:
if isinstance(pkg, dict) and pkg.get("name"):
yield self.package(
id=pkg["name"],
installed_version=pkg.get("version"),
)
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
mamba inherits conda's lack of a dedicated outdated command, and its
dry-run reports the same `actions` mapping, so the upgrade the solver
*would* perform is simulated and its `UNLINK` (current) and `LINK`
(candidate) sets are diffed by name. A package in both is an in-place
upgrade; one in only `LINK` is a freshly pulled dependency and one in
only `UNLINK` is a removal, so neither is reported.
```{code-block} shell-session
$ mamba update --all --dry-run --json
{
"actions": {
"LINK": [
{
"build": "h23cfdf5_2",
"fn": "libiconv-1.18-h23cfdf5_2.conda",
"name": "libiconv",
"version": "1.18"
}
],
"PREFIX": "/opt/conda",
"UNLINK": [
{
"build": "h23cfdf5_1",
"fn": "libiconv-1.17-h23cfdf5_1.conda",
"name": "libiconv",
"version": "1.17"
}
]
},
"dry_run": true,
"log_history": [],
"prefix": "/opt/conda",
"success": true
}
```
When the environment is already current, the `actions` key is omitted
exactly as conda omits it:
```{code-block} shell-session
$ mamba update --all --dry-run --json
{
"dry_run": true,
"log_history": [],
"message": "All requested packages already installed",
"prefix": "/opt/conda",
"success": true
}
```
```{note}
`repoquery` was assessed for this and cannot answer it: it reads
channels and installed metadata but never solves, so it cannot say what
a transaction would do. Entries here also carry libmamba's wider field
set instead of conda's, and no `FETCH` key, neither of which is read.
```
"""
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", ()):
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=True)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
```{caution}
Search does not support extended matching, mamba exposing no package
description to match against. Exact matching is native: a bare query
resolves against the package name alone, where wrapping it in `*`
widens it to a substring match.
```
```{code-block} shell-session
$ mamba search "*zstd*" --json
{
"log_history": [],
"query": {
"query": "*zstd*",
"type": "search"
},
"result": {
"msg": "",
"pkgs": [
{
"build": "py313h7208f8c_0",
"name": "backports.zstd",
"version": "1.6.0"
}
],
"status": "OK"
}
}
```
```{caution}
Results are a flat list under `result.pkgs`, not conda's mapping of
name to builds, so they are grouped here by name. The newest build of
each group is picked by comparing versions rather than by position:
mamba sorts descending where conda sorts ascending, and that order was
a string comparison before `2.6.0`, which misplaces `1.10` against
`1.9`. Comparing parsed versions is right whatever the release does.
A query matching nothing exits zero with an empty `pkgs`, unlike
conda's non-zero `PackagesNotFoundError` payload.
```
"""
output = self.run_cli("search", query if exact else f"*{query}*", "--json")
data = self.parse_json(output)
result = data.get("result") if isinstance(data, dict) else None
packages = result.get("pkgs") if isinstance(result, dict) else None
if not isinstance(packages, list):
return
groups: dict[str, list[dict]] = {}
for pkg in packages:
if isinstance(pkg, dict) and pkg.get("name") and pkg.get("version"):
groups.setdefault(pkg["name"], []).append(pkg)
for package_id, builds in groups.items():
newest = max(builds, key=lambda build: parse_version(build["version"]))
yield self.package(id=package_id, latest_version=newest["version"])
[docs]
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package, optionally pinned to a version.
mamba accepts a `MatchSpec`, so the version is appended with `=`.
```{code-block} shell-session
$ mamba install --yes zstd
```
"""
if version:
package_id = f"{package_id}={version}"
return self.run_cli("install", "--yes", package_id)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages.
```{code-block} shell-session
$ mamba 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, ...]:
"""Generates the CLI to upgrade the package provided as parameter.
```{code-block} shell-session
$ mamba update --yes zstd
```
"""
if version:
package_id = f"{package_id}={version}"
return self.build_cli("update", "--yes", package_id)
[docs]
def remove(self, package_id: str) -> str:
"""Removes a package.
```{code-block} shell-session
$ mamba remove --yes zstd
```
"""
return self.run_cli("remove", "--yes", package_id)
[docs]
def cleanup_cache(self) -> None:
"""Removes things we don't need anymore.
```{code-block} shell-session
$ mamba clean --all --yes
```
"""
self.run_cli("clean", "--all", "--yes")
[docs]
class Micromamba(Mamba):
"""mamba's statically linked build, shipped as a single self-contained binary.
The same program as [`mamba`](mamba.md), built the other way: upstream's
build declares *"mamba is a dynamic build of micromamba"* and compiles both
executables from one source list. Every operation, parser and forced
argument is therefore inherited unchanged.
```{note}
The two are separate managers rather than one manager naming both binaries,
because they resolve *different* root prefixes: `mamba` takes the conda
installation it ships inside, while `micromamba` takes `~/micromamba` or
its XDG data directory. `mpm` resolves a manager to the first of its CLI
names found while walking the search path, so a host carrying both would
silently report whichever came first on `PATH` and hide the other's
packages entirely.
```
```{note}
Their command sets differ by exactly one entry: micromamba adds
`self-update`, which the dynamic build rejects. It maps to no `mpm`
operation, so nothing here uses it.
```
"""
id = "micromamba"
name = "Micromamba"
homepage_url = "https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html"
cli_names = ("micromamba",)