Repository: wntrblm/nox (python)

Issue:
support micromamba as a backend
### How would this feature be useful?

I would like to be able to use `micromamba` along with `mamba`/`conda`/`venv`. It's a lot smaller/faster than `mamba` (that's why we use it in our Gitlab CI flows), and to my knowledge lacks no functionality that is required for `nox` backend purposes.

### Describe the solution you'd like

_No response_

### Describe alternatives you've considered

_No response_

### Anything else?

_No response_

Pull request description:
feat: add micromamba support
Adds micromamba support alongside the existing conda and mamba support. Micromamba is a version of mamba using C++ completely, not requiring Python. It's very useful for CI systems and anywhere a single binary is easier to work with.

Fix #680.


Patch:
diff --git a/docs/config.rst b/docs/config.rst
index 53ab693..9464b68 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -167,7 +167,7 @@ You can also specify that the virtualenv should *always* be reused instead of re
     def tests(session):
         pass
 
-You are not limited to virtualenv, there is a selection of backends you can choose from as venv, uv, conda, mamba, or virtualenv (default):
+You are not limited to virtualenv, there is a selection of backends you can choose from as venv, uv, conda, mamba, micromamba, or virtualenv (default):
 
 .. code-block:: python
 
@@ -176,8 +176,8 @@ You are not limited to virtualenv, there is a selection of backends you can choo
         pass
 
 You can chain together optional backends with ``|``, such as ``uv|virtualenv``
-or ``mamba|conda``, and the first available backend will be selected. You
-cannot put anything after a backend that can't be missing like ``venv`` or
+or ``micromamba|mamba|conda``, and the first available backend will be selected.
+You cannot put anything after a backend that can't be missing like ``venv`` or
 ``virtualenv``.
 
 Finally, custom backend parameters are supported:
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 4b3131f..e3420d8 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -397,12 +397,13 @@ Install packages with conda:
 
 .. code-block:: python
 
-    session.conda_install("pytest")
+    session.conda_install("pytest", channels=["conda-forge"])
 
 It is possible to install packages with pip into the conda environment, but
 it's a best practice only install pip packages with the ``--no-deps`` option.
-This prevents pip from breaking the conda environment by installing
-incompatible versions of packages already installed with conda.
+This prevents pip from breaking the conda environment by installing incompatible
+versions of packages already installed with conda. You should always specify
+channels for consistency; default channels can vary (and ``micromamba`` has none).
 
 .. code-block:: python
 
@@ -412,6 +413,9 @@ incompatible versions of packages already installed with conda.
 ``"mamba"`` is also allowed as a choice for ``venv_backend``, which will
 use/require `mamba <https://github.com/mamba-org/mamba>`_ instead of conda.
 
+``"micromamba"`` is also allowed as a choice for ``venv_backend``, which will
+use/require `micromamba <https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html#>`_
+instead of conda.
 
 Parametrization
 ---------------
diff --git a/docs/usage.rst b/docs/usage.rst
index 887b4d1..069f123 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -126,7 +126,7 @@ Then running ``nox --session tests`` will actually run all parametrized versions
 Changing the sessions default backend
 -------------------------------------
 
-By default Nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``uv``, ``conda``, ``mamba``, and ``venv`` as well as no backend (passthrough to whatever python environment Nox is running on). You can change the default behaviour by using ``-db <backend>`` or ``--default-venv-backend <backend>``. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'venv')``.
+By default Nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``uv``, ``conda``, ``mamba``, ``micromamba``, and ``venv`` as well as no backend (passthrough to whatever python environment Nox is running on). You can change the default behaviour by using ``-db <backend>`` or ``--default-venv-backend <backend>``. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'venv')``.
 
 
 .. tabs::
@@ -142,9 +142,9 @@ By default Nox uses ``virtualenv`` as the virtual environment backend for the se
 
 .. note::
 
-   The ``uv``, ``conda``, and ``mamba`` backends require their respective
-   programs be pre-installed. ``uv`` is distributed as a Python package
-   and can be installed with the ``nox[uv]`` extra.
+   The ``uv``, ``conda``, ``mamba``, and ``micromamba`` backends require their
+   respective programs be pre-installed. ``uv`` is distributed as a Python
+   package and can be installed with the ``nox[uv]`` extra.
 
 You can also set this option with the ``NOX_DEFAULT_VENV_BACKEND`` environment variable, or in the Noxfile with ``nox.options.default_venv_backend``. In case more than one is provided, the command line argument overrides the environment variable, which in turn overrides the Noxfile configuration.
 
@@ -156,7 +156,7 @@ Note that using this option does not change the backend for sessions where ``ven
    as ``uv pip`` is used to install programs instead. If you need to manually
    interact with pip, you should install it with ``session.install("pip")``.
 
-Backends that could be missing (``uv``, ``conda``, and ``mamba``) can have a fallback using ``|``, such as ``uv|virtualenv`` or ``mamba|conda``. This will use the first item that is available on the users system.
+Backends that could be missing (``uv``, ``conda``, ``mamba``, and ``micromamba``) can have a fallback using ``|``, such as ``uv|virtualenv`` or ``micromamba|mamba|conda``. This will use the first item that is available on the users system.
 
 If you need to check to see which backend was selected, you can access it via
 ``session.venv_backend`` in your noxfile.
@@ -166,7 +166,7 @@ If you need to check to see which backend was selected, you can access it via
 Forcing the sessions backend
 ----------------------------
 
-You might work in a different environment than a project's default continuous integration settings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current Nox execution by using ``-fb <backend>`` or ``--force-venv-backend <backend>``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and Noxfile configuration. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'venv')``.
+You might work in a different environment than a project's default continuous integration settings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current Nox execution by using ``-fb <backend>`` or ``--force-venv-backend <backend>``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and Noxfile configuration. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'micromamba', 'venv')``.
 
 .. code-block:: console
 
diff --git a/nox/sessions.py b/nox/sessions.py
index 432db0c..1544c3f 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -554,7 +554,9 @@ class Session:
 
         You can specify a conda channel using `channel=`; a falsey value will
         not change the current channels. You can specify a list of channels if
-        needed.
+        needed. It is highly recommended to specify this; micromamba does not
+        set default channels, and default channels vary for conda. Note that
+        "defaults" is also not permissivly licenced like "conda-forge" is.
 
         Additional keyword args are the same as for :meth:`run`.
 
@@ -565,7 +567,7 @@ class Session:
         prefix_args: tuple[str, ...] = ()
         if isinstance(venv, CondaEnv):
             prefix_args = ("--prefix", venv.location)
-        elif not isinstance(venv, PassthroughEnv):  # pragma: no cover
+        elif not isinstance(venv, PassthroughEnv):
             raise ValueError(
                 "A session without a conda environment can not install dependencies"
                 " from conda."
@@ -574,7 +576,9 @@ class Session:
         if not args:
             raise ValueError("At least one argument required to install().")
 
-        if self._runner.global_config.no_install and venv._reused:
+        if self._runner.global_config.no_install and (
+            isinstance(venv, PassthroughEnv) or venv._reused
+        ):
             return
 
         # Escape args that should be (conda-specific; pip install does not need this)
@@ -648,6 +652,8 @@ class Session:
                 "A session without a virtualenv can not install dependencies."
             )
         if isinstance(venv, PassthroughEnv):
+            if self._runner.global_config.no_install:
+                return
             raise ValueError(
                 f"Session {self.name} does not have a virtual environment, so use of"
                 " session.install() is no longer allowed since it would modify the"
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index b561054..bf93f9f 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -236,7 +236,7 @@ class CondaEnv(ProcessEnv):
     """
 
     is_sandboxed = True
-    allowed_globals = ("conda", "mamba")
+    allowed_globals = ("conda", "mamba", "micromamba")
 
     def __init__(
         self,
@@ -305,6 +305,12 @@ class CondaEnv(ProcessEnv):
             return False
 
         cmd = [self.conda_cmd, "create", "--yes", "--prefix", self.location]
+        if self.conda_cmd == "micromamba" and not any(
+            v.startswith(("--channel=", "-c")) or v == "--channel"
+            for v in self.venv_params
+        ):
+            # Micromamba doesn't have any default channels
+            cmd.append("--channel=conda-forge")
 
         cmd.extend(self.venv_params)
 
@@ -314,7 +320,9 @@ class CondaEnv(ProcessEnv):
         python_dep = f"python={self.interpreter}" if self.interpreter else "python"
         cmd.append(python_dep)
 
-        logger.info(f"Creating conda env in {self.location_name} with {python_dep}")
+        logger.info(
+            f"Creating {self.conda_cmd} env in {self.location_name} with {python_dep}"
+        )
         nox.command.run(cmd, silent=True, log=nox.options.verbose or False)
 
         return True
@@ -589,6 +597,7 @@ class VirtualEnv(ProcessEnv):
 ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = {
     "conda": functools.partial(CondaEnv, conda_cmd="conda"),
     "mamba": functools.partial(CondaEnv, conda_cmd="mamba"),
+    "micromamba": functools.partial(CondaEnv, conda_cmd="micromamba"),
     "virtualenv": functools.partial(VirtualEnv, venv_backend="virtualenv"),
     "venv": functools.partial(VirtualEnv, venv_backend="venv"),
     "uv": functools.partial(VirtualEnv, venv_backend="uv"),
@@ -601,5 +610,6 @@ ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = {
 OPTIONAL_VENVS = {
     "conda": shutil.which("conda") is not None,
     "mamba": shutil.which("mamba") is not None,
+    "micromamba": shutil.which("micromamba") is not None,
     "uv": HAS_UV,
 }
diff --git a/noxfile.py b/noxfile.py
index a151e5b..7f3e4bb 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -34,6 +34,10 @@ nox.options.default_venv_backend = "uv|virtualenv"
 nox.options.sessions = ["tests", "cover", "lint", "docs"]
 if shutil.which("conda"):
     nox.options.sessions.append("conda_tests")
+if shutil.which("mamba"):
+    nox.options.sessions.append("mamba_tests")
+if shutil.which("micromamba"):
+    nox.options.sessions.append("micromamba_tests")
 
 
 # Because there is a dependency conflict between argcomplete and the latest tox
@@ -79,12 +83,31 @@ def tests(session: nox.Session, tox_version: str) -> None:
             con.execute("DELETE FROM file WHERE SUBSTR(path, 2, 1) == ':'")
 
 
-@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"], venv_backend="conda")
+@nox.session(venv_backend="conda")
 def conda_tests(session: nox.Session) -> None:
-    """Run test suite with pytest."""
-    session.create_tmp()  # Fixes permission errors on Windows
+    """Run test suite set up with conda."""
+    session.conda_install(
+        "--file", "requirements-conda-test.txt", channel="conda-forge"
+    )
+    session.install("-e", ".", "--no-deps")
+    session.run("pytest", *session.posargs)
+
+
+@nox.session(venv_backend="mamba")
+def mamba_tests(session: nox.Session) -> None:
+    """Run test suite set up with mamba."""
+    session.conda_install(
+        "--file", "requirements-conda-test.txt", channel="conda-forge"
+    )
+    session.install("-e", ".", "--no-deps")
+    session.run("pytest", *session.posargs)
+
+
+@nox.session(venv_backend="micromamba")
+def micromamba_tests(session: nox.Session) -> None:
+    """Run test suite set up with micromamba."""
     session.conda_install(
-        "--file", "requirements-conda-test.txt", "--channel", "conda-forge"
+        "--file", "requirements-conda-test.txt", channel="conda-forge"
     )
     session.install("-e", ".", "--no-deps")
     session.run("pytest", *session.posargs)


Test patch:
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index e6006ea..e720c61 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -374,6 +374,20 @@ class TestSession:
         exc_args = exc_info.value.args
         assert exc_args == ("At least one argument required to run_install().",)
 
+    def test_run_no_install_passthrough(self):
+        session, runner = self.make_session_and_runner()
+        runner.venv = nox.virtualenv.PassthroughEnv()
+        runner.global_config.no_install = True
+
+        session.install("numpy")
+        session.conda_install("numpy")
+
+    def test_run_no_conda_install(self):
+        session, runner = self.make_session_and_runner()
+
+        with pytest.raises(ValueError, match="A session without a conda"):
+            session.conda_install("numpy")
+
     def test_run_install_success(self):
         session, _ = self.make_session_and_runner()
 
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 340c2f9..f1dfb82 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -483,6 +483,51 @@ def test_reuse_conda_environment(make_one):
     assert reused
 
 
+# This mocks micromamba so that it doesn't need to be installed.
+@has_conda
+def test_micromamba_environment(make_one, monkeypatch):
+    conda_path = shutil.which("conda")
+    which = shutil.which
+    monkeypatch.setattr(
+        shutil, "which", lambda x: conda_path if x == "micromamba" else which(x)
+    )
+    venv, _ = make_one(reuse_existing=True, venv_backend="micromamba")
+    run = mock.Mock()
+    monkeypatch.setattr(nox.command, "run", run)
+    venv.create()
+    run.assert_called_once()
+    # .args requires Python 3.8+
+    ((args,), _) = run.call_args
+    assert args[0] == "micromamba"
+    assert "--channel=conda-forge" in args
+
+
+# This mocks micromamba so that it doesn't need to be installed.
+@pytest.mark.parametrize(
+    "params",
+    [["--channel=default"], ["-cdefault"], ["-c", "default"], ["--channel", "default"]],
+)
+@has_conda
+def test_micromamba_channel_environment(make_one, monkeypatch, params):
+    conda_path = shutil.which("conda")
+    which = shutil.which
+    monkeypatch.setattr(
+        shutil, "which", lambda x: conda_path if x == "micromamba" else which(x)
+    )
+    venv, _ = make_one(reuse_existing=True, venv_backend="micromamba")
+    run = mock.Mock()
+    monkeypatch.setattr(nox.command, "run", run)
+    venv.venv_params = params
+    venv.create()
+    run.assert_called_once()
+    # .args requires Python 3.8+
+    ((args,), _) = run.call_args
+    assert args[0] == "micromamba"
+    for p in params:
+        assert p in args
+    assert "--channel=conda-forge" not in args
+
+
 @pytest.mark.parametrize(
     ("frm", "to", "result"),
     [