Repository: wntrblm/nox
Pull request #878 (2024-10-28 15:56:37)

feat: expose main as nox.main
See https://github.com/wntrblm/nox/issues/848. This just makes using PEP 723 a bit nicer, but doesn't implement native support in nox yet.



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c684e5e..1e3bf8d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,12 @@ jobs:
             python-version: "3.12"
     steps:
       - uses: actions/checkout@v4
+      - name: Set up non-default Pythons
+        uses: actions/setup-python@v5
+        with:
+          python-version: |
+            3.9
+            3.10
       - name: Set up Python ${{ matrix.python-version }}
         uses: actions/setup-python@v5
         with:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index b561917..e39fc4f 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -1,11 +1,10 @@
 version: 2
 build:
-  os: ubuntu-20.04
+  os: ubuntu-22.04
   tools:
-    python: "3.10"
-
-python:
-  install:
-    - requirements: requirements-test.txt
-    - method: pip
-      path: .
+    python: "3.12"
+  commands:
+    - asdf plugin add uv
+    - asdf install uv latest
+    - asdf global uv latest
+    - uv run --group docs sphinx-build -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 51dfe91..afdfb36 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -386,6 +386,41 @@ You can also pass the notified session positional arguments:
 
 Note that this will only have the desired effect if selecting sessions to run via the ``--session/-s`` flag. If you simply run ``nox``, all selected sessions will be run.
 
+Requiring sessions
+------------------
+
+You can also request sessions be run before your session runs. This is done with the ``requires=`` keyword:
+
+
+.. code-block:: python
+
+    @nox.session
+    def tests(session):
+        session.install("pytest")
+        session.run("pytest")
+
+    @nox.session(requires=["tests"])
+    def coverage(session):
+        session.install("coverage")
+        session.run("coverage")
+
+The required sessions will be stably topologically sorted and run. Parametrized
+sessions are supported. You can also get the current Python version with
+``{python}``, though arbitrary parametrizations are not supported.
+
+
+.. code-block:: python
+
+    @nox.session(python=["3.10", "3.13"])
+    def tests(session):
+        session.install("pytest")
+        session.run("pytest")
+
+    @nox.session(python=["3.10", "3.13"], requires=["tests-{python}"])
+    def coverage(session):
+        session.install("coverage")
+        session.run("coverage")
+
 Testing against different and multiple Pythons
 ----------------------------------------------
 
@@ -559,6 +594,28 @@ the tags, so all three sessions:
     * flake8
 
 
+Running without the nox command
+-------------------------------
+
+With a few small additions to your noxfile, you can support running using only
+a generalized Python runner, such as ``pipx run noxfile.py``, ``uv run
+noxfile.py``, ``pdm run noxfile.py``, or ``hatch run noxfile.py``. You need to
+have the following comment in your noxfile:
+
+.. code-block:: python
+
+   # /// script
+   # dependencies = ["nox"]
+   # ///
+
+And the following block of code:
+
+.. code-block:: python
+
+   if __name__ == "__main__":
+       nox.main()
+
+
 Next steps
 ----------
 
diff --git a/nox/__init__.py b/nox/__init__.py
index 87d12fb..4664edc 100644
--- a/nox/__init__.py
+++ b/nox/__init__.py
@@ -15,6 +15,7 @@
 from __future__ import annotations
 
 from nox import project
+from nox.__main__ import main
 from nox._options import noxfile_options as options
 from nox._parametrize import Param as param
 from nox._parametrize import parametrize_decorator as parametrize
@@ -31,4 +32,5 @@ __all__ = [
     "parametrize",
     "project",
     "session",
+    "main",
 ]
diff --git a/nox/_decorators.py b/nox/_decorators.py
index e68ec34..f7e3174 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -71,6 +71,7 @@ class Func(FunctionDecorator):
         tags: Sequence[str] | None = None,
         *,
         default: bool = True,
+        requires: Sequence[str] | None = None,
     ) -> None:
         self.func = func
         self.python = python
@@ -81,6 +82,7 @@ class Func(FunctionDecorator):
         self.should_warn = dict(should_warn or {})
         self.tags = list(tags or [])
         self.default = default
+        self.requires = list(requires or [])
 
     def __call__(self, *args: Any, **kwargs: Any) -> Any:
         return self.func(*args, **kwargs)
@@ -98,8 +100,31 @@ class Func(FunctionDecorator):
             self.should_warn,
             self.tags,
             default=self.default,
+            requires=self._requires,
         )
 
+    @property
+    def requires(self) -> list[str]:
+        # Compute dynamically on lookup since ``self.python`` can be modified after
+        # creation (e.g. on an instance from ``self.copy``).
+        return list(map(self.format_dependency, self._requires))
+
+    @requires.setter
+    def requires(self, value: Sequence[str]) -> None:
+        self._requires = list(value)
+
+    def format_dependency(self, dependency: str) -> str:
+        if isinstance(self.python, (bool, str)) or self.python is None:
+            formatted = dependency.format(python=self.python, py=self.python)
+            if (
+                self.python is None or isinstance(self.python, bool)
+            ) and formatted != dependency:
+                msg = "Cannot parametrize requires with {python} when python is None or a bool."
+                raise ValueError(msg)
+            return formatted
+        msg = "The requires of a not-yet-parametrized session cannot be parametrized."  # pragma: no cover
+        raise TypeError(msg)  # pragma: no cover
+
 
 class Call(Func):
     """This represents a call of a function with a particular set of arguments."""
@@ -130,6 +155,7 @@ class Call(Func):
             func.should_warn,
             func.tags + param_spec.tags,
             default=func.default,
+            requires=func.requires,
         )
         self.call_spec = call_spec
         self.session_signature = session_signature
diff --git a/nox/_resolver.py b/nox/_resolver.py
new file mode 100644
index 0000000..c5c2a8e
--- /dev/null
+++ b/nox/_resolver.py
@@ -0,0 +1,203 @@
+# Copyright 2022 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import itertools
+from collections import OrderedDict
+from typing import Hashable, Iterable, Iterator, Mapping, TypeVar
+
+Node = TypeVar("Node", bound=Hashable)
+
+
+class CycleError(ValueError):
+    """An exception indicating that a cycle was encountered in a graph."""
+
+
+def lazy_stable_topo_sort(
+    dependencies: Mapping[Node, Iterable[Node]],
+    root: Node,
+    drop_root: bool = True,
+) -> Iterator[Node]:
+    """Returns the "lazy, stable" topological sort of a dependency graph.
+
+    The sort returned will be a topological sort of the subgraph containing only
+    ``root`` and its (recursive) dependencies. ``root`` will not be included in the
+    output sort if ``drop_root`` is ``True``.
+
+    The sort returned is "lazy" in the sense that a node will not appear any earlier in
+    the output sort than is necessitated by its dependents.
+
+    The sort returned is "stable" in the sense that the relative order of two nodes in
+    ``dependencies[node]`` is preserved in the output sort, except when doing so would
+    prevent the output sort from being either topological or lazy. The order of nodes in
+    ``dependencies[node]`` allows the caller to exert a preference on the order of the
+    output sort.
+
+    For example, consider:
+
+    >>> list(
+    ...     lazy_stable_topo_sort(
+    ...         dependencies = {
+    ...             "a": ["c", "b"],
+    ...             "b": [],
+    ...             "c": [],
+    ...             "d": ["e"],
+    ...             "e": ["c"],
+    ...             "root": ["a", "d"],
+    ...         },
+    ...         "root",
+    ...         drop_root=False,
+    ...     )
+    ... )
+    ["c", "b", "a", "e", "d", "root"]
+
+    Notice that:
+
+        1.  This is a topological sort of the dependency graph. That is, nodes only
+            occur in the sort after all of their dependencies occur.
+
+        2.  Had we also included a node ``"f": ["b"]`` but kept ``dependencies["root"]``
+            the same, the output would not have changed. This is because ``"f"`` was not
+            requested directly by including it in ``dependencies["root"]`` or
+            transitively as a (recursive) dependency of a node in
+            ``dependencies["root"]``.
+
+        3.  ``"e"`` occurs no earlier than was required by its dependents ``{"d"}``.
+            This is an example of the sort being "lazy". If ``"e"`` had occurred in the
+            output any earlier---for example, just before ``"a"``---the sort would not
+            have been lazy, but (in this example) the output would still have been a
+            topological sort.
+
+        4.  Because the topological order between ``"a"`` and ``"d"`` is undefined and
+            because it is possible to do so without making the output sort non-lazy,
+            ``"a"`` and ``"d"`` are kept in the relative order that they have in
+            ``dependencies["root"]``. This is an example of the sort being stable
+            between pairs in ``dependencies[node]`` whenever possible. If ``"a"``'s
+            dependency list was instead ``["d"]``, however, the relative order between
+            ``"a"`` and ``"d"`` in ``dependencies["root"]`` would have been ignored to
+            satisfy this dependency.
+
+            Similarly, ``"b"`` and ``"c"`` are kept in the relative order that they have
+            in ``dependencies["a"]``. If ``"c"``'s dependency list was instead
+            ``["b"]``, however, the relative order between ``"b"`` and ``"c"`` in
+            ``dependencies["a"]`` would have been ignored to satisfy this dependency.
+
+    This implementation of this function is recursive and thus should not be used on
+    large dependency graphs, but it is suitable for noxfile-sized dependency graphs.
+
+    Args:
+        dependencies (Mapping[~nox._resolver.Node, Iterable[~nox._resolver.Node]]):
+            A mapping from each node in the graph to the (ordered) list of nodes that it
+            depends on. Using a mapping type with O(1) lookup (e.g. `dict`) is strongly
+            suggested.
+        root (~nox._resolver.Node):
+            The root node to start the sort at. If ``drop_root`` is not ``True``,
+            ``root`` will be the last element of the output.
+        drop_root (bool):
+            If ``True``, ``root`` will be not be included in the output sort. Defaults
+            to ``True``.
+
+
+    Returns:
+        Iterator[~nox._resolver.Node]: The "lazy, stable" topological sort of the
+        subgraph containing ``root`` and its dependencies.
+
+    Raises:
+        ~nox._resolver.CycleError: If a dependency cycle is encountered.
+    """
+
+    visited = {node: False for node in dependencies}
+
+    def prepended_by_dependencies(
+        node: Node,
+        walk: OrderedDict[Node, None] | None = None,
+    ) -> Iterator[Node]:
+        """Yields a node's dependencies depth-first, followed by the node itself.
+
+        A dependency will be skipped if has already been yielded by another call of
+        ``prepended_by_dependencies``. Since ``prepended_by_dependencies`` is recursive,
+        this means that each node will only be yielded once, and only the deepest
+        occurrence of a node will be yielded.
+
+        Args:
+            node (~nox._resolver.Node):
+                A node in the dependency graph.
+            walk (OrderedDict[~nox._resolver.Node, None] | None):
+                An ``OrderedDict`` whose keys are the nodes traversed when walking a
+                path leading up to ``node`` on the reversed-edge dependency graph.
+                Defaults to ``OrderedDict()``.
+
+        Yields:
+            ~nox._resolver.Node: ``node``'s direct dependencies, each
+            prepended by their own direct dependencies, and so forth recursively,
+            depth-first, followed by ``node``.
+
+        Raises:
+            ValueError: If a dependency cycle is encountered.
+        """
+        nonlocal visited
+        # We would like for ``walk`` to be an ordered set so that we get (a) O(1) ``node
+        # in walk`` and (b) so that we can use the order to report to the user what the
+        # dependency cycle is, if one is encountered. The standard library does not have
+        # an ordered set type, so we instead use the keys of an ``OrderedDict[Node,
+        # None]`` as an ordered set.
+        walk = walk or OrderedDict()
+        walk = extend_walk(walk, node)
+        if not visited[node]:
+            visited[node] = True
+            # Recurse for each node in dependencies[node] in order so that we adhere to
+            # the ``dependencies[node]`` order preference if doing so is possible.
+            yield from itertools.chain.from_iterable(
+                prepended_by_dependencies(dependency, walk)
+                for dependency in dependencies[node]
+            )
+            yield node
+        else:
+            return
+
+    def extend_walk(
+        walk: OrderedDict[Node, None], node: Node
+    ) -> OrderedDict[Node, None]:
+        """Extend a walk by a node, checking for dependency cycles.
+
+        Args:
+            walk (OrderedDict[~nox._resolver.Node, None]):
+                See ``prepended_by_dependencies``.
+            nodes (~nox._resolver.Node):
+                A node to extend the walk with.
+
+        Returns:
+            OrderedDict[~nox._resolver.Node, None]: ``walk``, extended by
+            ``node``.
+
+        Raises:
+            ValueError: If extending ``walk`` by ``node`` introduces a cycle into the
+                represented walk on the dependency graph.
+        """
+        walk = walk.copy()
+        if node in walk:
+            # Dependency cycle found.
+            walk_list = list(walk)
+            cycle = walk_list[walk_list.index(node) :] + [node]
+            raise CycleError("Nodes are in a dependency cycle", tuple(cycle))
+        walk[node] = None
+        return walk
+
+    sort = prepended_by_dependencies(root)
+    if drop_root:
+        return filter(
+            lambda node: not (node == root and hash(node) == hash(root)), sort
+        )
+    return sort
diff --git a/nox/manifest.py b/nox/manifest.py
index 093d80e..0fb8ea3 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -17,11 +17,13 @@ from __future__ import annotations
 import argparse
 import ast
 import itertools
+import operator
 from collections import OrderedDict
 from collections.abc import Iterable, Iterator, Sequence
-from typing import Any, Mapping
+from typing import Any, Mapping, cast
 
 from nox._decorators import Call, Func
+from nox._resolver import CycleError, lazy_stable_topo_sort
 from nox.sessions import Session, SessionRunner
 
 WARN_PYTHONS_IGNORED = "python_ignored"
@@ -104,6 +106,32 @@ class Manifest:
         for session in self._all_sessions:
             yield session, session in self._queue
 
+    @property
+    def all_sessions_by_signature(self) -> dict[str, SessionRunner]:
+        return {
+            signature: session
+            for session in self._all_sessions
+            for signature in session.signatures
+        }
+
+    @property
+    def parametrized_sessions_by_name(self) -> dict[str, list[SessionRunner]]:
+        """Returns a mapping from names to all sessions that are parameterizations of
+        the ``@session`` with each name.
+
+        The sessions in each returned list will occur in the same order as they occur in
+        ``self._all_sessions``.
+        """
+        parametrized_sessions = filter(operator.attrgetter("multi"), self._all_sessions)
+        key = operator.attrgetter("name")
+        # Note that ``sorted`` uses a stable sorting algorithm.
+        return {
+            name: list(sessions_parametrizing_name)
+            for name, sessions_parametrizing_name in itertools.groupby(
+                sorted(parametrized_sessions, key=key), key
+            )
+        }
+
     def add_session(self, session: SessionRunner) -> None:
         """Add the given session to the manifest.
 
@@ -192,6 +220,57 @@ class Manifest:
         """
         self._queue = [x for x in self._queue if set(x.tags).intersection(tags)]
 
+    def add_dependencies(self) -> None:
+        """Add direct and recursive dependencies to the queue.
+
+        Raises:
+            KeyError: If any depended-on sessions are not found.
+            ~nox._resolver.CycleError: If a dependency cycle is encountered.
+        """
+        sessions_by_id = self.all_sessions_by_signature
+
+        # For each session that was parametrized from a list of Pythons, create a fake
+        # parent session that depends on it.
+        parent_sessions: set[SessionRunner] = set()
+        for (
+            parent_name,
+            parametrized_sessions,
+        ) in self.parametrized_sessions_by_name.items():
+            parent_func = _null_session_func.copy()
+            parent_func.requires = [
+                session.signatures[0] for session in parametrized_sessions
+            ]
+            parent_session = SessionRunner(
+                parent_name, [], parent_func, self._config, self, False
+            )
+            parent_sessions.add(parent_session)
+            sessions_by_id[parent_name] = parent_session
+
+        # Construct the dependency graph. Note that this is done lazily with iterators
+        # so that we won't raise if a session that doesn't actually need to run declares
+        # missing/improper dependencies.
+        dependency_graph = {
+            session: session.get_direct_dependencies(sessions_by_id)
+            for session in sessions_by_id.values()
+        }
+
+        # Resolve the dependency graph.
+        root = cast(SessionRunner, object())  # sentinel
+        try:
+            resolved_graph = list(
+                lazy_stable_topo_sort({**dependency_graph, root: self._queue}, root)
+            )
+        except CycleError as exc:
+            raise CycleError(
+                "Sessions are in a dependency cycle: "
+                + " -> ".join(session.name for session in exc.args[1])
+            ) from exc
+
+        # Remove fake parent sessions from the resolved graph.
+        self._queue = [
+            session for session in resolved_graph if session not in parent_sessions
+        ]
+
     def make_session(
         self, name: str, func: Func, multi: bool = False
     ) -> list[SessionRunner]:
@@ -259,7 +338,7 @@ class Manifest:
             if func.python:
                 long_names.append(f"{name}-{func.python}")
 
-            return [SessionRunner(name, long_names, func, self._config, self)]
+            return [SessionRunner(name, long_names, func, self._config, self, multi)]
 
         # Since this function is parametrized, we need to add a distinct
         # session for each permutation.
@@ -274,13 +353,15 @@ class Manifest:
                 # Ensure that specifying session-python will run all parameterizations.
                 long_names.append(f"{name}-{func.python}")
 
-            sessions.append(SessionRunner(name, long_names, call, self._config, self))
+            sessions.append(
+                SessionRunner(name, long_names, call, self._config, self, multi)
+            )
 
         # Edge case: If the parameters made it such that there were no valid
         # calls, add an empty, do-nothing session.
         if not calls:
             sessions.append(
-                SessionRunner(name, [], _null_session_func, self._config, self)
+                SessionRunner(name, [], _null_session_func, self._config, self, multi)
             )
 
         # Return the list of sessions.
diff --git a/nox/registry.py b/nox/registry.py
index 9d900e8..d107a3f 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -44,6 +44,7 @@ def session_decorator(
     tags: Sequence[str] | None = ...,
     *,
     default: bool = ...,
+    requires: Sequence[str] | None = ...,
 ) -> Callable[[F], F]: ...
 
 
@@ -58,6 +59,7 @@ def session_decorator(
     tags: Sequence[str] | None = None,
     *,
     default: bool = True,
+    requires: Sequence[str] | None = None,
 ) -> F | Callable[[F], F]:
     """Designate the decorated function as a session."""
     # If `func` is provided, then this is the decorator call with the function
@@ -78,6 +80,7 @@ def session_decorator(
             venv_params=venv_params,
             tags=tags,
             default=default,
+            requires=requires,
         )
 
     if py is not None and python is not None:
@@ -90,6 +93,7 @@ def session_decorator(
         python = py
 
     final_name = name or func.__name__
+
     fn = Func(
         func,
         python,
@@ -99,8 +103,9 @@ def session_decorator(
         venv_params,
         tags=tags,
         default=default,
+        requires=requires,
     )
-    _REGISTRY[final_name] = fn
+    _REGISTRY[name or func.__name__] = fn
     return fn
 
 
diff --git a/nox/sessions.py b/nox/sessions.py
index 05c0b6a..3a51d75 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -29,6 +29,7 @@ from collections.abc import (
     Callable,
     Generator,
     Iterable,
+    Iterator,
     Mapping,
     Sequence,
 )
@@ -902,6 +903,7 @@ class SessionRunner:
         func: Func,
         global_config: argparse.Namespace,
         manifest: Manifest,
+        multi: bool = False,
     ) -> None:
         self.name = name
         self.signatures = signatures
@@ -910,6 +912,14 @@ class SessionRunner:
         self.manifest = manifest
         self.venv: ProcessEnv | None = None
         self.posargs: list[str] = global_config.posargs[:]
+        self.result: Result | None = None
+        self.multi = multi
+
+        if getattr(func, "parametrize", None):
+            self.multi = True
+
+    def __repr__(self) -> str:
+        return f"<SessionRunner {self.name}: {self.signatures!r} {self.multi}>"
 
     @property
     def description(self) -> str | None:
@@ -934,6 +944,43 @@ class SessionRunner:
     def envdir(self) -> str:
         return _normalize_path(self.global_config.envdir, self.friendly_name)
 
+    def get_direct_dependencies(
+        self, sessions_by_id: Mapping[str, SessionRunner] | None = None
+    ) -> Iterator[SessionRunner]:
+        """Yields the sessions of the session's direct dependencies.
+
+        Args:
+            sessions_by_id (Mapping[str, ~nox.sessions.SessionRunner] | None): An
+                optional mapping from both dependency signatures and names to
+                corresponding ``SessionRunner``s. If this is not provided,
+                ``self.manifest.all_sessions_by_signature`` will be used to find the
+                sessions corresponding to signatures in ``self.func.requires``, and
+                non-signature names (i.e. names of sessions that were parameterized with
+                multiple Pythons) in ``self.func.requires`` will be resolved via
+                ``self.manifest.parametrized_sessions_by_name``.
+
+        Returns:
+            Iterator[~nox.session.SessionRunner]
+
+        Raises:
+            KeyError: If a dependency's session could not be found.
+        """
+        try:
+            if sessions_by_id is None:
+                sessions_by_signature = self.manifest.all_sessions_by_signature
+                parametrized_sessions_by_name = (
+                    self.manifest.parametrized_sessions_by_name
+                )
+                for requirement in self.func.requires:
+                    if requirement in sessions_by_signature:
+                        yield sessions_by_signature[requirement]
+                    else:
+                        yield from parametrized_sessions_by_name[requirement]
+            else:
+                yield from map(sessions_by_id.__getitem__, self.func.requires)
+        except KeyError as exc:
+            raise KeyError(f"Session not found: {exc.args[0]}") from exc
+
     def _create_venv(self) -> None:
         reuse_existing = self.reuse_existing_venv()
 
@@ -1023,6 +1070,18 @@ class SessionRunner:
     def execute(self) -> Result:
         logger.warning(f"Running session {self.friendly_name}")
 
+        for dependency in self.get_direct_dependencies():
+            if not dependency.result:
+                self.result = Result(
+                    self,
+                    Status.ABORTED,
+                    reason=(
+                        f"Prerequisite session {dependency.friendly_name} was not"
+                        " successful"
+                    ),
+                )
+                return self.result
+
         try:
             cwd = os.path.realpath(os.path.dirname(self.global_config.noxfile))
 
@@ -1033,22 +1092,25 @@ class SessionRunner:
                 self.func(session)
 
             # Nothing went wrong; return a success.
-            return Result(self, Status.SUCCESS)
+            self.result = Result(self, Status.SUCCESS)
 
         except nox.virtualenv.InterpreterNotFound as exc:
             if self.global_config.error_on_missing_interpreters:
-                return Result(self, Status.FAILED, reason=str(exc))
-            logger.warning("Missing interpreters will error by default on CI systems.")
-            return Result(self, Status.SKIPPED, reason=str(exc))
+                self.result = Result(self, Status.FAILED, reason=str(exc))
+            else:
+                logger.warning(
+                    "Missing interpreters will error by default on CI systems."
+                )
+                self.result = Result(self, Status.SKIPPED, reason=str(exc))
 
         except _SessionQuit as exc:
-            return Result(self, Status.ABORTED, reason=str(exc))
+            self.result = Result(self, Status.ABORTED, reason=str(exc))
 
         except _SessionSkip as exc:
-            return Result(self, Status.SKIPPED, reason=str(exc))
+            self.result = Result(self, Status.SKIPPED, reason=str(exc))
 
         except nox.command.CommandFailed:
-            return Result(self, Status.FAILED)
+            self.result = Result(self, Status.FAILED)
 
         except KeyboardInterrupt:
             logger.error(f"Session {self.friendly_name} interrupted.")
@@ -1056,7 +1118,9 @@ class SessionRunner:
 
         except Exception as exc:
             logger.exception(f"Session {self.friendly_name} raised exception {exc!r}")
-            return Result(self, Status.FAILED)
+            self.result = Result(self, Status.FAILED)
+
+        return self.result
 
 
 class Result:
diff --git a/nox/tasks.py b/nox/tasks.py
index 74fb884..e257641 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -27,6 +27,7 @@ from colorlog.escape_codes import parse_colors
 
 import nox
 from nox import _options, registry
+from nox._resolver import CycleError
 from nox._version import InvalidVersionSpecifier, VersionCheckFailed, check_nox_version
 from nox.logger import logger
 from nox.manifest import WARN_PYTHONS_IGNORED, Manifest
@@ -223,6 +224,14 @@ def filter_manifest(manifest: Manifest, global_config: Namespace) -> Manifest |
         logger.error("No sessions selected after filtering by keyword.")
         return 3
 
+    # Add dependencies.
+    try:
+        manifest.add_dependencies()
+    except (KeyError, CycleError) as exc:
+        logger.error("Error while resolving session dependencies.")
+        logger.error(exc.args[0])
+        return 3
+
     # Return the modified manifest.
     return manifest
 
diff --git a/noxfile.py b/noxfile.py
index d1ad2f3..81cf3c5 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -27,17 +27,16 @@ import nox
 
 ON_WINDOWS_CI = "CI" in os.environ and platform.system() == "Windows"
 
-nox.needs_version = ">=2024.3.2"
+nox.needs_version = ">=2024.4.15"
 nox.options.default_venv_backend = "uv|virtualenv"
 
-# Skip 'conda_tests' if user doesn't have conda installed
-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")
+PYPROJECT = nox.project.load_toml("pyproject.toml")
+
+ALL_PYTHONS = [
+    c.split()[-1]
+    for c in PYPROJECT["project"]["classifiers"]
+    if c.startswith("Programming Language :: Python :: 3.")
+]
 
 
 @nox.session
@@ -45,7 +44,7 @@ if shutil.which("micromamba"):
     "python, tox_version",
     [
         (python, tox_version)
-        for python in ("3.8", "3.9", "3.10", "3.11", "3.12", "3.13")
+        for python in ALL_PYTHONS
         for tox_version in ("latest", "<4")
     ],
 )
@@ -57,7 +56,7 @@ def tests(session: nox.Session, tox_version: str) -> None:
     )
 
     session.create_tmp()  # Fixes permission errors on Windows
-    session.install("-r", "requirements-test.txt")
+    session.install(*PYPROJECT["dependency-groups"]["test"], "uv")
     session.install("-e.[tox_to_nox]")
     if tox_version != "latest":
         session.install(f"tox{tox_version}")
@@ -79,37 +78,37 @@ def tests(session: nox.Session, tox_version: str) -> None:
             con.execute("DELETE FROM file WHERE SUBSTR(path, 2, 1) == ':'")
 
 
-@nox.session(venv_backend="conda")
+@nox.session(venv_backend="conda", default=shutil.which("conda"))
 def conda_tests(session: nox.Session) -> None:
     """Run test suite set up with conda."""
     session.conda_install(
         "--file", "requirements-conda-test.txt", channel="conda-forge"
     )
-    session.install("-e", ".", "--no-deps")
+    session.install("-e.", "--no-deps")
     session.run("pytest", *session.posargs)
 
 
-@nox.session(venv_backend="mamba")
+@nox.session(venv_backend="mamba", default=shutil.which("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.install("-e.", "--no-deps")
     session.run("pytest", *session.posargs)
 
 
-@nox.session(venv_backend="micromamba")
+@nox.session(venv_backend="micromamba", default=shutil.which("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"
     )
-    session.install("-e", ".", "--no-deps")
+    session.install("-e.", "--no-deps")
     session.run("pytest", *session.posargs)
 
 
-@nox.session
+@nox.session(default=False)
 def cover(session: nox.Session) -> None:
     """Coverage analysis."""
     if ON_WINDOWS_CI:
@@ -135,7 +134,7 @@ def lint(session: nox.Session) -> None:
     )
 
 
-@nox.session
+@nox.session(default=False)
 def docs(session: nox.Session) -> None:
     """Build the documentation."""
     output_dir = os.path.join(session.create_tmp(), "output")
@@ -143,8 +142,8 @@ def docs(session: nox.Session) -> None:
         functools.partial(os.path.join, output_dir), ["doctrees", "html"]
     )
     shutil.rmtree(output_dir, ignore_errors=True)
-    session.install("-r", "requirements-test.txt")
-    session.install(".")
+    session.install(*PYPROJECT["dependency-groups"]["docs"])
+    session.install("-e.")
     session.cd("docs")
     sphinx_args = ["-b", "html", "-W", "-d", doctrees, ".", html]
 
@@ -181,15 +180,10 @@ def _check_python_version(session: nox.Session) -> None:
 
 @nox.session(
     python=[
-        "3.8",
-        "3.9",
-        "3.10",
-        "3.11",
-        "3.12",
-        "3.13",
-        "pypy3.9",
+        *ALL_PYTHONS,
         "pypy3.10",
-    ]
+    ],
+    default=False,
 )
 def github_actions_default_tests(session: nox.Session) -> None:
     """Check default versions installed by the nox GHA Action"""
@@ -199,16 +193,12 @@ def github_actions_default_tests(session: nox.Session) -> None:
 
 @nox.session(
     python=[
-        "3.8",
-        "3.9",
-        "3.10",
-        "3.11",
-        "3.12",
-        "3.13",
+        *ALL_PYTHONS,
         "pypy3.8",
         "pypy3.9",
         "pypy3.10",
-    ]
+    ],
+    default=False,
 )
 def github_actions_all_tests(session: nox.Session) -> None:
     """Check all versions installed by the nox GHA Action"""
diff --git a/pyproject.toml b/pyproject.toml
index 3781be7..c0d100b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,8 +16,7 @@ keywords = [
 ]
 license = { text = "Apache-2.0" }
 authors = [
-  { name = "Alethea Katherine Flowers" },
-  { email = "me@thea.codes" },
+  { name = "Alethea Katherine Flowers", email = "me@thea.codes" },
 ]
 requires-python = ">=3.8"
 classifiers = [
@@ -116,3 +115,21 @@ enable_error_code = [ "ignore-without-code", "redundant-expr", "truthy-bool" ]
 [[tool.mypy.overrides]]
 module = [ "argcomplete", "colorlog.*", "py", "tox.*" ]
 ignore_missing_imports = true
+
+[dependency-groups]
+test = [
+  "coverage[toml]>=7.2",
+  "pytest>=6.0",
+  "pytest-cov",
+]
+docs = [
+  "myst-parser",
+  "sphinx>=3.0",
+  "sphinx-autobuild",
+  "sphinx-tabs",
+  "witchhazel",
+]
+dev = [
+  { include-group = "docs" },
+  { include-group = "test" },
+]
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index 5d33b8c..0000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,3 +0,0 @@
--e .
--r requirements-test.txt
--r requirements-conda-test.txt


Test patch:
diff --git a/requirements-test.txt b/requirements-test.txt
deleted file mode 100644
index b4240b7..0000000
--- a/requirements-test.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-coverage[toml]>=7.2
-flask
-myst-parser
-pytest>=6.0
-pytest-cov
-sphinx>=3.0
-sphinx-autobuild
-sphinx-tabs
-uv; python_version>='3.8'
-witchhazel
diff --git a/tests/resources/noxfile_requires.py b/tests/resources/noxfile_requires.py
new file mode 100644
index 0000000..a6acfad
--- /dev/null
+++ b/tests/resources/noxfile_requires.py
@@ -0,0 +1,129 @@
+# Copyright 2022 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import nox
+
+
+@nox.session(requires=["c", "b"])
+def a(session):
+    print(session.name)
+
+
+@nox.session()
+def b(session):
+    print(session.name)
+
+
+@nox.session()
+def c(session):
+    print(session.name)
+
+
+@nox.session(requires=["e"])
+def d(session):
+    print(session.name)
+
+
+@nox.session(requires=["c"])
+def e(session):
+    print(session.name)
+
+
+@nox.session(requires=["b", "g"])
+def f(session):
+    print(session.name)
+
+
+@nox.session(requires=["b", "h"])
+def g(session):
+    print(session.name)
+
+
+@nox.session(requires=["c"])
+def h(session):
+    print(session.name)
+
+
+@nox.session(requires=["j"])
+def i(session):
+    print(session.name)
+
+
+@nox.session(requires=["i"])
+def j(session):
+    print(session.name)
+
+
+@nox.session(python=["3.9", "3.10"])
+def k(session):
+    print(session.name)
+
+
+@nox.session(requires=["k"])
+def m(session):
+    print(session.name)
+
+
+@nox.session(python="3.10", requires=["k-{python}"])
+def n(session):
+    print(session.name)
+
+
+@nox.session(requires=["does_not_exist"])
+def o(session):
+    print(session.name)
+
+
+@nox.session(python=["3.9", "3.10"])
+def p(session):
+    print(session.name)
+
+
+@nox.session(python=None, requires=["p-{python}"])
+def q(session):
+    print(session.name)
+
+
+@nox.session
+def r(session):
+    print(session.name)
+    raise Exception("Fail!")
+
+
+@nox.session(requires=["r"])
+def s(session):
+    print(session.name)
+
+
+@nox.session(requires=["r"])
+def t(session):
+    print(session.name)
+
+
+@nox.parametrize("django", ["1.9", "2.0"])
+@nox.session
+def u(session, django):
+    print(session.name)
+
+
+@nox.session(requires=["u(django='1.9')", "u(django='2.0')"])
+def v(session):
+    print(session.name)
+
+
+@nox.session(requires=["u"])
+def w(session):
+    print(session.name)
diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py
index a09c6e1..d5d0fa4 100644
--- a/tests/test__parametrize.py
+++ b/tests/test__parametrize.py
@@ -167,6 +167,7 @@ def test_parametrize_decorator_multiple_and_stack():
 def test_generate_calls_simple():
     f = mock.Mock(should_warn={}, tags=[])
     f.__name__ = "f"
+    f.requires = None
     f.some_prop = 42
 
     arg_names = ("abc",)
@@ -199,6 +200,7 @@ def test_generate_calls_simple():
 def test_generate_calls_multiple_args():
     f = mock.Mock(should_warn=None, tags=[])
     f.__name__ = "f"
+    f.requires = None
 
     arg_names = ("foo", "abc")
     call_specs = [
@@ -225,6 +227,7 @@ def test_generate_calls_multiple_args():
 def test_generate_calls_ids():
     f = mock.Mock(should_warn={}, tags=[])
     f.__name__ = "f"
+    f.requires = None
 
     arg_names = ("foo",)
     call_specs = [
@@ -245,7 +248,7 @@ def test_generate_calls_ids():
 
 
 def test_generate_calls_tags():
-    f = mock.Mock(should_warn={}, tags=[])
+    f = mock.Mock(should_warn={}, tags=[], requires=[])
     f.__name__ = "f"
 
     arg_names = ("foo",)
@@ -264,7 +267,7 @@ def test_generate_calls_tags():
 
 
 def test_generate_calls_merge_tags():
-    f = mock.Mock(should_warn={}, tags=["tag1", "tag2"])
+    f = mock.Mock(should_warn={}, tags=["tag1", "tag2"], requires=[])
     f.__name__ = "f"
 
     arg_names = ("foo",)
diff --git a/tests/test__resolver.py b/tests/test__resolver.py
new file mode 100644
index 0000000..33eaf9d
--- /dev/null
+++ b/tests/test__resolver.py
@@ -0,0 +1,165 @@
+# Copyright 2022 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import pytest
+
+from nox._resolver import CycleError, lazy_stable_topo_sort
+
+
+@pytest.mark.parametrize(
+    ("dependencies", "expected"),
+    [
+        # Assert typical example with the following features:
+        # 1.  Topological sort.
+        # 2.  Ignores nodes outside the subgraph of ``dependencies[root]`` and its
+        #     (recursive) dependencies. (``"f"`` and ``"g"``).
+        # 3.  Lazy (``"e"`` does not occur any earlier than it needs to for ``"d"``).
+        # 4.  Obeys ``dependencies[node]`` order preference when possible (``"a"`` and
+        #     ``"d"``, ``"c"`` and ``"b"``).
+        (
+            {
+                "a": ("c", "b"),
+                "b": (),
+                "c": (),
+                "d": ("e",),
+                "e": ("c",),
+                "f": ("b", "g"),
+                "g": (),
+                "0": ("a", "d"),
+            },
+            ("c", "b", "a", "e", "d"),
+        ),
+        # 1.  Topological sort.
+        # 2.  Ignores (``"f"`` and ``"g"``).
+        # 3.  Lazy (``"b"``).
+        # 4.  Obeys order preference (``"d"`` and ``"a"``).
+        # 4.  Obeys order preference (``"c"`` and ``"b"``).
+        (
+            {
+                "a": ("c", "b"),
+                "b": (),
+                "c": (),
+                "d": ("e",),
+                "e": ("c",),
+                "f": ("b", "g"),
+                "g": (),
+                "0": ("d", "a"),
+            },
+            ("c", "e", "d", "b", "a"),
+        ),
+        # 1.  Topological sort.
+        # 2.  Ignores (``"f"`` and ``"g"``).
+        # 4.  Ignores order preference (``"a"`` and ``"d"``) when it is impossible to
+        #     both satisfy a pair preference and produce a producing a topological and
+        #     lazy sort.
+        (
+            {
+                "a": ("d",),
+                "b": (),
+                "c": (),
+                "d": ("e",),
+                "e": ("c",),
+                "f": ("b", "g"),
+                "g": (),
+                "0": ("a", "d"),
+            },
+            ("c", "e", "d", "a"),
+        ),
+        # 1.  Topological sort.
+        # 2.  Ignores (``"f"`` and ``"g"``).
+        # 3.  Lazy (``"e"``).
+        # 4.  Obeys order preference (``"a"`` and ``"d"``).
+        # 4.  Ignores order preference (``"c"`` and ``"b"``).
+        (
+            {
+                "a": ("c", "b"),
+                "b": (),
+                "c": ("b",),
+                "d": ("e",),
+                "e": ("c",),
+                "f": ("b",),
+                "0": ("a", "d"),
+            },
+            ("b", "c", "a", "e", "d"),
+        ),
+        # 1.  Topological sort.
+        # 2.  Ignores (``"f"``).
+        # 3.  Lazy (``"c"``, ``"h"``, ``"a"``, and ``"e"``).
+        # 4.  Obeys order preference (``"g"``, ``"a"``, and ``"d"``).
+        # 4.  Obeys order preference (``"b"`` and ``"g"``).
+        # 4.  Obeys order preference (``"b"`` and ``"h"``).
+        # 4.  Ignores order preference (``"c"`` and ``"b"``). Note that this is despite
+        #     the fact that the topological order between ``"b"`` and ``"c"`` is
+        #     undefined. In the tests above, we only saw a pair order preference ignored
+        #     because the topological order between that pair was defined. Here,
+        #     however, the ``dependencies["a"]`` order preference between ``"b"`` and
+        #     ``"c"`` is ignored because obeying this order preference cannot be done
+        #     without making the sort non-lazy (here, calling ``"c"`` earlier than is
+        #     required by one of its dependents (``"h"``) would be non-lazy).
+        (
+            {
+                "a": ("c", "b"),
+                "b": (),
+                "c": (),
+                "d": ("e",),
+                "e": ("c",),
+                "f": ("b", "g"),
+                "g": ("b", "h"),
+                "h": ("c",),
+                "0": ("g", "a", "d"),
+            },
+            ("b", "c", "h", "g", "a", "e", "d"),
+        ),
+    ],
+)
+def test_lazy_stable_topo_sort(dependencies, expected):
+    actual = tuple(lazy_stable_topo_sort(dependencies, "0"))
+    actual_with_root = tuple(lazy_stable_topo_sort(dependencies, "0", drop_root=False))
+    assert actual == actual_with_root[:-1] == expected
+
+
+@pytest.mark.parametrize(
+    ("dependencies", "expected_cycle"),
+    [
+        # Note that these cycles are inherent to the dependency graph; they cannot be
+        # resolved by ignoring the order preference for a pair in ``dependencies[node]``
+        # for some ``node``.
+        (
+            {
+                "a": ("b",),
+                "b": ("a",),
+                "0": ("a",),
+            },
+            ("a", "b", "a"),
+        ),
+        (
+            {
+                "a": ("c", "b"),
+                "b": (),
+                "c": ("a", "b"),
+                "0": ("a",),
+            },
+            ("a", "c", "a"),
+        ),
+    ],
+)
+def test_lazy_stable_topo_sort_CycleError(dependencies, expected_cycle):
+    with pytest.raises(CycleError) as exc_info:
+        tuple(lazy_stable_topo_sort(dependencies, "0"))
+    # While the exact cycle reported is not unique and is an implementation detail, this
+    # still serves as a regression test for unexpected changes in the implementation's
+    # behavior.
+    assert exc_info.value.args[1] == expected_cycle
diff --git a/tests/test_main.py b/tests/test_main.py
index f87da9b..668a74c 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -24,7 +24,6 @@ from unittest import mock
 import pytest
 
 import nox
-import nox.__main__
 import nox._options
 import nox.registry
 import nox.sessions
@@ -46,7 +45,7 @@ def test_main_no_args(monkeypatch):
 
         # Call the function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -84,7 +83,7 @@ def test_main_long_form_args():
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -118,7 +117,7 @@ def test_main_no_venv(monkeypatch, capsys):
     )
 
     with mock.patch("sys.exit") as sys_exit:
-        nox.__main__.main()
+        nox.main()
         stdout, stderr = capsys.readouterr()
         assert stdout == "Noms, cheddar so good!\n"
         assert (
@@ -140,7 +139,7 @@ def test_main_no_venv_error():
         "--no-venv",
     ]
     with pytest.raises(ValueError, match="You can not use"):
-        nox.__main__.main()
+        nox.main()
 
 
 def test_main_short_form_args(monkeypatch):
@@ -166,7 +165,7 @@ def test_main_short_form_args(monkeypatch):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -187,7 +186,7 @@ def test_main_explicit_sessions(monkeypatch):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -205,7 +204,7 @@ def test_main_explicit_sessions_with_spaces_in_names(monkeypatch):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -246,7 +245,7 @@ def test_main_list_option_from_nox_env_var(monkeypatch, var, option, env, values
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -274,7 +273,7 @@ def test_default_venv_backend_option(monkeypatch, options, env, expected):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -290,7 +289,7 @@ def test_main_positional_args(capsys, monkeypatch):
     with mock.patch.object(sys, "exit", fake_exit), pytest.raises(
         ValueError, match="asdf!"
     ):
-        nox.__main__.main()
+        nox.main()
     _, stderr = capsys.readouterr()
     assert "Unknown argument(s) '1 2 3'" in stderr
     fake_exit.assert_called_once_with(2)
@@ -300,7 +299,7 @@ def test_main_positional_args(capsys, monkeypatch):
     with mock.patch.object(sys, "exit", fake_exit), pytest.raises(
         ValueError, match="asdf!"
     ):
-        nox.__main__.main()
+        nox.main()
     _, stderr = capsys.readouterr()
     assert "Unknown argument(s) '1 2 3'" in stderr
     fake_exit.assert_called_once_with(2)
@@ -313,7 +312,7 @@ def test_main_positional_with_double_hyphen(monkeypatch):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -331,7 +330,7 @@ def test_main_positional_flag_like_with_double_hyphen(monkeypatch):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(0)
         assert execute.called
 
@@ -346,7 +345,7 @@ def test_main_version(capsys, monkeypatch):
     with contextlib.ExitStack() as stack:
         execute = stack.enter_context(mock.patch("nox.workflow.execute"))
         exit_mock = stack.enter_context(mock.patch("sys.exit"))
-        nox.__main__.main()
+        nox.main()
         _, err = capsys.readouterr()
         assert VERSION in err
         exit_mock.assert_not_called()
@@ -359,7 +358,7 @@ def test_main_help(capsys, monkeypatch):
     with contextlib.ExitStack() as stack:
         execute = stack.enter_context(mock.patch("nox.workflow.execute"))
         exit_mock = stack.enter_context(mock.patch("sys.exit"))
-        nox.__main__.main()
+        nox.main()
         out, _ = capsys.readouterr()
         assert "help" in out
         exit_mock.assert_not_called()
@@ -371,7 +370,7 @@ def test_main_failure(monkeypatch):
     with mock.patch("nox.workflow.execute") as execute:
         execute.return_value = 1
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_once_with(1)
 
 
@@ -389,7 +388,7 @@ def test_main_nested_config(capsys, monkeypatch):
     )
 
     with mock.patch("sys.exit") as sys_exit:
-        nox.__main__.main()
+        nox.main()
         stdout, stderr = capsys.readouterr()
         assert stdout == "Noms, cheddar so good!\n"
         assert "Session snack(cheese='cheddar') was successful." in stderr
@@ -410,7 +409,7 @@ def test_main_session_with_names(capsys, monkeypatch):
     )
 
     with mock.patch("sys.exit") as sys_exit:
-        nox.__main__.main()
+        nox.main()
         stdout, stderr = capsys.readouterr()
         assert stdout == "Noms, cheddar so good!\n"
         assert "Session cheese list(cheese='cheddar') was successful." in stderr
@@ -423,7 +422,7 @@ def run_nox(capsys, monkeypatch):
         monkeypatch.setattr(sys, "argv", ["nox", *args])
 
         with mock.patch("sys.exit") as sys_exit:
-            nox.__main__.main()
+            nox.main()
             stdout, stderr = capsys.readouterr()
             returncode = sys_exit.call_args[0][0]
 
@@ -489,6 +488,65 @@ def test_main_with_bad_session_names(run_nox, session):
     assert session in stderr
 
 
+@pytest.mark.parametrize(
+    ("sessions", "expected_order"),
+    [
+        (("g", "a", "d"), ("b", "c", "h", "g", "a", "e", "d")),
+        (("m",), ("k-3.9", "k-3.10", "m")),
+        (("n",), ("k-3.10", "n")),
+        (("v",), ("u(django='1.9')", "u(django='2.0')", "v")),
+        (("w",), ("u(django='1.9')", "u(django='2.0')", "w")),
+    ],
+)
+def test_main_requires(run_nox, sessions, expected_order):
+    noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+    returncode, stdout, _ = run_nox(f"--noxfile={noxfile}", "--sessions", *sessions)
+    assert returncode == 0
+    assert tuple(stdout.rstrip("\n").split("\n")) == expected_order
+
+
+def test_main_requires_cycle(run_nox):
+    noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+    returncode, _, stderr = run_nox(f"--noxfile={noxfile}", "--session=i")
+    assert returncode != 0
+    # While the exact cycle reported is not unique and is an implementation detail, this
+    # still serves as a regression test for unexpected changes in the implementation's
+    # behavior.
+    assert "Sessions are in a dependency cycle: i -> j -> i" in stderr
+
+
+def test_main_requires_missing_session(run_nox):
+    noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+    returncode, _, stderr = run_nox(f"--noxfile={noxfile}", "--session=o")
+    assert returncode != 0
+    assert "Session not found: does_not_exist" in stderr
+
+
+def test_main_requires_bad_python_parametrization(run_nox):
+    noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+    with pytest.raises(
+        ValueError,
+        match="Cannot parametrize requires",
+    ):
+        returncode, _, _ = run_nox(f"--noxfile={noxfile}", "--session=q")
+        assert returncode != 0
+
+
+@pytest.mark.parametrize("session", ("s", "t"))
+def test_main_requires_chain_fail(run_nox, session):
+    noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+    returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}")
+    assert returncode != 0
+    assert "Prerequisite session r was not successful" in stderr
+
+
+@pytest.mark.parametrize("session", ("w", "u"))
+def test_main_requries_modern_param(run_nox, session):
+    noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+    returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}")
+    assert returncode == 0
+
+
 def test_main_noxfile_options(monkeypatch, generate_noxfile_options):
     noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
     monkeypatch.setattr(
@@ -508,7 +566,7 @@ def test_main_noxfile_options(monkeypatch, generate_noxfile_options):
         honor_list_request.return_value = 0
 
         with mock.patch("sys.exit"):
-            nox.__main__.main()
+            nox.main()
 
         assert honor_list_request.called
 
@@ -538,7 +596,7 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch, generate_noxfile_opt
         honor_list_request.return_value = 0
 
         with mock.patch("sys.exit"):
-            nox.__main__.main()
+            nox.main()
 
         assert honor_list_request.called
 
@@ -560,7 +618,7 @@ def test_main_noxfile_options_sessions(monkeypatch, generate_noxfile_options):
         honor_list_request.return_value = 0
 
         with mock.patch("sys.exit"):
-            nox.__main__.main()
+            nox.main()
 
         assert honor_list_request.called
 
@@ -611,7 +669,7 @@ def test_main_noxfile_options_with_pythons_override(
     )
 
     with mock.patch("sys.exit") as sys_exit:
-        nox.__main__.main()
+        nox.main()
         _, stderr = capsys.readouterr()
         sys_exit.assert_called_once_with(0)
 
@@ -638,7 +696,7 @@ def test_main_noxfile_options_with_sessions_override(
     )
 
     with mock.patch("sys.exit") as sys_exit:
-        nox.__main__.main()
+        nox.main()
         _, stderr = capsys.readouterr()
         sys_exit.assert_called_once_with(0)
 
@@ -662,7 +720,7 @@ def test_main_color_from_isatty(monkeypatch, isatty_value, expected):
 
             # Call the main function.
             with mock.patch.object(sys, "exit"):
-                nox.__main__.main()
+                nox.main()
 
             config = execute.call_args[1]["global_config"]
             assert config.color == expected
@@ -685,7 +743,7 @@ def test_main_color_options(monkeypatch, color_opt, expected):
 
         # Call the main function.
         with mock.patch.object(sys, "exit"):
-            nox.__main__.main()
+            nox.main()
 
         config = execute.call_args[1]["global_config"]
         assert config.color == expected
@@ -698,7 +756,7 @@ def test_main_color_conflict(capsys, monkeypatch):
 
         # Call the main function.
         with mock.patch.object(sys, "exit") as exit:
-            nox.__main__.main()
+            nox.main()
             exit.assert_called_with(1)
 
     _, err = capsys.readouterr()
@@ -710,7 +768,7 @@ def test_main_force_python(monkeypatch):
     monkeypatch.setattr(sys, "argv", ["nox", "--force-python=3.11"])
     with mock.patch("nox.workflow.execute", return_value=0) as execute:
         with mock.patch.object(sys, "exit"):
-            nox.__main__.main()
+            nox.main()
         config = execute.call_args[1]["global_config"]
     assert config.pythons == config.extra_pythons == ["3.11"]
 
@@ -719,7 +777,7 @@ def test_main_reuse_existing_virtualenvs_no_install(monkeypatch):
     monkeypatch.setattr(sys, "argv", ["nox", "-R"])
     with mock.patch("nox.workflow.execute", return_value=0) as execute:
         with mock.patch.object(sys, "exit"):
-            nox.__main__.main()
+            nox.main()
         config = execute.call_args[1]["global_config"]
     assert (
         config.reuse_existing_virtualenvs
@@ -772,7 +830,7 @@ def test_main_noxfile_options_with_ci_override(
         "nox.tasks.honor_list_request", return_value=0
     ) as honor_list_request:
         with mock.patch("sys.exit"):
-            nox.__main__.main()
+            nox.main()
         config = honor_list_request.call_args[1]["global_config"]
     assert config.error_on_missing_interpreters == expected_final_value
 
@@ -790,7 +848,7 @@ def test_main_reuse_venv_cli_flags(monkeypatch, generate_noxfile_options, reuse_
     monkeypatch.setattr(sys, "argv", ["nox", "--reuse-venv", reuse_venv])
     with mock.patch("nox.workflow.execute", return_value=0) as execute:
         with mock.patch.object(sys, "exit"):
-            nox.__main__.main()
+            nox.main()
         config = execute.call_args[1]["global_config"]
     assert (
         not config.reuse_existing_virtualenvs
@@ -857,6 +915,6 @@ def test_main_noxfile_options_reuse_venv_compat_check(
         "nox.tasks.honor_list_request", return_value=0
     ) as honor_list_request:
         with mock.patch("sys.exit"):
-            nox.__main__.main()
+            nox.main()
         config = honor_list_request.call_args[1]["global_config"]
     assert config.reuse_venv == expected
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 23ef9f5..6f55988 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -965,6 +965,7 @@ class TestSessionRunner:
         func.python = None
         func.venv_backend = None
         func.reuse_venv = False
+        func.requires = []
         return nox.sessions.SessionRunner(
             name="test",
             signatures=["test(1, 2)"],
@@ -1168,6 +1169,7 @@ class TestSessionRunner:
         def func(session):
             session.error("meep")
 
+        func.requires = []
         runner.func = func
 
         result = runner.execute()
@@ -1180,6 +1182,7 @@ class TestSessionRunner:
         def func(session):
             session.skip("meep")
 
+        func.requires = []
         runner.func = func
 
         result = runner.execute()
@@ -1237,6 +1240,7 @@ class TestSessionRunner:
         def func(session):
             raise nox.command.CommandFailed()
 
+        func.requires = []
         runner.func = func
 
         result = runner.execute()
@@ -1249,6 +1253,7 @@ class TestSessionRunner:
         def func(session):
             raise KeyboardInterrupt()
 
+        func.requires = []
         runner.func = func
 
         with pytest.raises(KeyboardInterrupt):
@@ -1260,6 +1265,7 @@ class TestSessionRunner:
         def func(session):
             raise ValueError("meep")
 
+        func.requires = []
         runner.func = func
 
         result = runner.execute()
@@ -1277,6 +1283,7 @@ class TestSessionRunner:
                 f' os.environ["NOX_CURRENT_SESSION"] == {session.name!r} else 0)',
             )
 
+        func.requires = []
         runner.func = func
 
         result = runner.execute()
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index fdfad62..67b8e9b 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -41,6 +41,7 @@ session_func.venv_backend = None
 session_func.should_warn = {}
 session_func.tags = []
 session_func.default = True
+session_func.requires = []
 
 
 def session_func_with_python():
@@ -50,6 +51,7 @@ def session_func_with_python():
 session_func_with_python.python = "3.8"
 session_func_with_python.venv_backend = None
 session_func_with_python.default = True
+session_func_with_python.requires = []
 
 
 def session_func_venv_pythons_warning():