Skip to content

Commit

Permalink
feat: support session dependencies (requires) (#631)
Browse files Browse the repository at this point in the history
* Support session dependencies

* Workaround tests that fail because they violate type annotations

* Test dependency resolver

* Test session requires

* fix: support None

Signed-off-by: Henry Schreiner <[email protected]>

* ci: add a couple of Python versions always

Signed-off-by: Henry Schreiner <[email protected]>

* chore: make coverage report 100% again

Signed-off-by: Henry Schreiner <[email protected]>

* fix: support parametrized sessions with modern parametrization

Signed-off-by: Henry Schreiner <[email protected]>

* chore: cleanup after rebase

Signed-off-by: Henry Schreiner <[email protected]>

* tests: add test for modern parametrize

Signed-off-by: Henry Schreiner <[email protected]>

---------

Signed-off-by: Henry Schreiner <[email protected]>
Co-authored-by: Henry Schreiner <[email protected]>
  • Loading branch information
gschaffner and henryiii authored Oct 29, 2024
1 parent 7dd02a8 commit 9cb5661
Show file tree
Hide file tree
Showing 14 changed files with 809 additions and 15 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions docs/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------------------

Expand Down
26 changes: 26 additions & 0 deletions nox/_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def __init__(
tags: Sequence[str] | None = None,
*,
default: bool = True,
requires: Sequence[str] | None = None,
) -> None:
self.func = func
self.python = python
Expand All @@ -81,6 +82,7 @@ def __init__(
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)
Expand All @@ -98,8 +100,31 @@ def copy(self, name: str | None = None) -> Func:
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."""
Expand Down Expand Up @@ -130,6 +155,7 @@ def __init__(self, func: Func, param_spec: Param) -> None:
func.should_warn,
func.tags + param_spec.tags,
default=func.default,
requires=func.requires,
)
self.call_spec = call_spec
self.session_signature = session_signature
Expand Down
203 changes: 203 additions & 0 deletions nox/_resolver.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 9cb5661

Please sign in to comment.