Source code for nur.core.providers.nox

from __future__ import annotations

import ast
import builtins
import enum
import logging
import warnings
from typing import TYPE_CHECKING

from nur.core.models import Task

if TYPE_CHECKING:
    from pathlib import Path
    from typing import TypeIs

__all__ = ["NoxProvider", "parse_noxfile"]


log = logging.getLogger("nur")

_SOURCE_FILE = "noxfile.py"
# Modules whose ``session`` is ``nox.session`` or a drop-in wrapper that
# forwards ``name=`` to it (nox-uv).
_NOX_MODULES = frozenset({"nox", "nox_uv"})
# `nox.__all__`, which `from nox import *` binds.
_NOX_STAR = {
    "Session": "OTHER",
    "main": "OTHER",
    "needs_version": "OTHER",
    "options": "OTHER",
    "param": "OTHER",
    "parametrize": "PARAMETRIZE",
    "project": "OTHER",
    "session": "SESSION",
}
# Builtins that expose, edit, or run code in a namespace, so may rebind any
# name or replace `nox.session`.
_MUTATORS = frozenset({
    "vars",
    "globals",
    "locals",
    "exec",
    "eval",
    "setattr",
    "delattr",
    "__setattr__",
    "__delattr__",
})
# Statements that run straight through and whose only effect on nox bindings
# is the names they store. (`global`/`nonlocal` names are never trusted.)
_PLAIN = (
    ast.Assign
    | ast.AnnAssign
    | ast.AugAssign
    | ast.Delete
    | ast.Expr
    | ast.Pass
    | ast.FunctionDef
    | ast.AsyncFunctionDef
    | ast.ClassDef
    | ast.Global
    | ast.Nonlocal
)


class _Kind(enum.Enum):
    """What a module-level name is certainly bound to.

    Bindings map names to a kind. A name missing from the mapping may be
    unbound, so reading it at import time may raise ``NameError``.
    """

    MODULE = enum.auto()  # a nox module: `<name>.session` is the decorator
    SESSION = enum.auto()  # the `session` decorator itself
    UV_MODULE = enum.auto()  # the nox-uv module: `<name>.session` wraps nox's
    UV_SESSION = enum.auto()  # nox-uv's drop-in `session` decorator
    PARAMETRIZE = enum.auto()  # `nox.parametrize`, which keeps `__name__`
    OTHER = enum.auto()  # bound, but not to anything nox


def _mutates_namespace(tree: ast.Module) -> bool:
    """Return True if the file may replace ``nox.session`` or rebind names opaquely.

    Aliases share one module object and a re-import returns the same mutated
    module, so once ``.session`` may have been swapped (``nox.session = ...``,
    ``del nox.session``, ``setattr``/``delattr``) no decorator can be trusted.
    The same goes for a namespace edited as a mapping (``__dict__``,
    ``vars()``, ``globals()``, ``locals()``) or by running code in it
    (``exec``, ``eval``), including qualified or imported forms such as
    ``builtins.setattr``. Other attributes
    (``nox.options``, ``nox.needs_version``) do not count.

    Session bodies run only when nox calls the session, after the noxfile has
    loaded, so they are not scanned; ``getattr``/``vars`` there are common and
    harmless. Other function bodies are, since module code may call them.
    """
    stack: list[ast.AST] = [tree]
    while stack:
        node = stack.pop()
        if _mutates(node):
            return True
        if _is_session_function(node):
            stack += [*node.decorator_list, node.args, *filter(None, [node.returns])]
        else:
            stack.extend(ast.iter_child_nodes(node))
    return False


def _is_session_function(
    node: ast.AST,
) -> TypeIs[ast.FunctionDef | ast.AsyncFunctionDef]:
    """Return True for a ``def`` decorated with ``@...session`` (syntactically)."""
    if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
        return False
    for decorator in node.decorator_list:
        target = decorator.func if isinstance(decorator, ast.Call) else decorator
        if isinstance(target, ast.Attribute) and target.attr == "session":
            return True
        if isinstance(target, ast.Name) and target.id == "session":
            return True
    return False


def _mutates(node: ast.AST) -> bool:
    if isinstance(node, ast.Attribute):
        stored = isinstance(node.ctx, ast.Store | ast.Del)
        return (node.attr == "session" and stored) or node.attr == "__dict__"
    if isinstance(node, ast.Call):
        func = node.func
        name = func.id if isinstance(func, ast.Name) else None
        if isinstance(func, ast.Attribute):
            name = func.attr  # e.g. `builtins.setattr(...)`
        return name in _MUTATORS or _computed_getattr(node)
    if isinstance(node, ast.Constant):
        return node.value == "__dict__"  # e.g. `getattr(nox, "__dict__")`.
    if isinstance(node, ast.ImportFrom) and node.module == "builtins":
        # `from builtins import setattr as s` hides the call.
        return any(alias.name in _MUTATORS | {"*"} for alias in node.names)
    return False


def _computed_getattr(node: ast.Call) -> bool:
    """Return True for ``getattr(obj, name)`` with a non-literal attribute name."""
    if len(node.args) < 2:  # noqa: PLR2004  # (object, name)
        return False
    func = node.func
    name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None)
    attr = node.args[1]
    return name in {"getattr", "__getattribute__"} and not (
        isinstance(attr, ast.Constant) and isinstance(attr.value, str)
    )


def _stores(*nodes: ast.AST | None) -> set[str]:
    """Return every name *nodes* may bind or delete.

    Conservative: stores inside comprehensions or lambdas count too, which can
    only invalidate a binding, never invent one.
    """
    names: set[str] = set()
    for node in nodes:
        if node is None:
            continue
        for child in ast.walk(node):
            if isinstance(child, ast.Name) and isinstance(
                child.ctx, ast.Store | ast.Del
            ):
                names.add(child.id)
    return names


def _bind(node: ast.stmt, bound: dict[str, _Kind]) -> dict[str, _Kind]:
    """Apply a plain statement's bindings to *bound*.

    Every name it may store or delete loses its nox kind (over-approximated,
    counting comprehension and lambda internals); only names it certainly binds
    in module scope become defined. A ``def`` or ``class`` binds its name and
    evaluates its header here, but its body is its own scope.
    """
    if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
        header = _stores(*node.decorator_list, node.args, node.returns)
        return _without(bound, header) | {node.name: _Kind.OTHER}
    if isinstance(node, ast.ClassDef):
        header = _stores(*node.decorator_list, *node.bases, *node.keywords)
        return _without(bound, header) | {node.name: _Kind.OTHER}
    result = _without(bound, _stores(node))
    for name in _certain_targets(node):
        result[name] = _Kind.OTHER
    return result


def _certain_targets(node: ast.stmt) -> set[str]:
    """Return the module names an assignment statement certainly binds."""
    if isinstance(node, ast.Assign):
        targets = node.targets
    elif isinstance(node, ast.AnnAssign | ast.AugAssign):
        targets = [node.target] if getattr(node, "value", None) is not None else []
    else:
        return set()
    names: set[str] = set()
    for target in targets:
        stack: list[ast.expr] = [target]
        while stack:
            current = stack.pop()
            if isinstance(current, ast.Name):
                names.add(current.id)
            elif isinstance(current, ast.Tuple | ast.List):
                stack.extend(current.elts)
            elif isinstance(current, ast.Starred):
                stack.append(current.value)
    return names


def _reads(*nodes: ast.AST | None) -> set[str]:
    """Return the names evaluating *nodes* reads in the enclosing scope.

    Names bound inside a comprehension are its own; a lambda's body runs only
    when called, though its defaults are evaluated here.
    """
    reads: set[str] = set()
    for node in nodes:
        if node is not None:
            _collect_reads(node, reads, local=frozenset())
    return reads


def _collect_reads(node: ast.AST, reads: set[str], *, local: frozenset[str]) -> None:
    if isinstance(node, ast.Lambda):
        for default in [*node.args.defaults, *node.args.kw_defaults]:
            if default is not None:
                _collect_reads(default, reads, local=local)
        return
    if isinstance(node, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp):
        # Each generator's iterable is read before its own target is bound (the
        # first one in the enclosing scope); its filters and everything after
        # see that target, and the element sees all of them.
        # A walrus inside binds the enclosing scope, so it is not local here.
        inner = local
        for generator in node.generators:
            _collect_reads(generator.iter, reads, local=inner)
            inner |= _stores(generator.target)
            for condition in generator.ifs:
                _collect_reads(condition, reads, local=inner)
        parts = [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt]
        for part in parts:
            _collect_reads(part, reads, local=inner)
        return
    if (
        isinstance(node, ast.Name)
        and isinstance(node.ctx, ast.Load)
        and node.id not in local
    ):
        reads.add(node.id)
    for child in ast.iter_child_nodes(node):
        _collect_reads(child, reads, local=local)


def _deleted_names(node: ast.stmt) -> set[str]:
    """Return the plain names a ``del`` statement deletes."""
    if not isinstance(node, ast.Delete):
        return set()
    return {
        child.id
        for target in node.targets
        for child in ast.walk(target)
        if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Del)
    }


def _annotated_reads(
    node: ast.FunctionDef | ast.AsyncFunctionDef | ast.AnnAssign,
    *,
    lazy_annotations: bool,
) -> set[str]:
    """Return the reads of a statement with annotations, skipping lazy ones."""
    if isinstance(node, ast.AnnAssign):
        if lazy_annotations:
            return _reads(node.target, node.value) - _certain_targets(node)
        return _reads(node)
    if lazy_annotations:
        defaults = [*node.args.defaults, *node.args.kw_defaults]
        return _reads(*node.decorator_list, *defaults)
    return _reads(*node.decorator_list, node.args, node.returns)


def _statement_reads(node: ast.stmt, *, lazy_annotations: bool) -> set[str]:
    """Return the names a statement reads when it runs (its header only).

    With ``from __future__ import annotations`` annotations are never
    evaluated, so they are not reads; otherwise they are (eagerly, before
    Python 3.14), which nur assumes since nox may run an older Python.
    """
    if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.AnnAssign):
        return _annotated_reads(node, lazy_annotations=lazy_annotations)
    if isinstance(node, ast.ClassDef):
        return _reads(*node.decorator_list, *node.bases, *node.keywords)
    if isinstance(node, ast.If):
        return _reads(node.test)
    if isinstance(node, ast.Try | ast.Import | ast.ImportFrom):
        return set()
    reads = _reads(node)
    if isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
        reads.add(node.target.id)  # `x += 1` reads `x` first.
    return reads


def _without(bound: dict[str, _Kind], names: set[str]) -> dict[str, _Kind]:
    return {name: kind for name, kind in bound.items() if name not in names}


def _merge(paths: list[dict[str, _Kind]]) -> dict[str, _Kind]:
    """Keep only the bindings every path agrees on.

    nox's and nox-uv's ``session`` decorators both register sessions, so a
    name bound to either on every path stays a session decorator, limited to
    the keywords nox accepts (``from nox_uv import session`` falling back to
    ``from nox import session``). Otherwise every kind must match.
    """
    first, *rest = paths
    merged: dict[str, _Kind] = {}
    for name, kind in first.items():
        kinds = {kind, *(path.get(name) for path in rest)}
        if len(kinds) == 1:
            merged[name] = kind
        elif kinds <= {_Kind.SESSION, _Kind.UV_SESSION}:
            merged[name] = _Kind.SESSION
    return merged


def _unknown_star_import(node: ast.stmt) -> bool:
    """Return True for ``from x import *`` of a module other than nox itself.

    nox-uv defines no ``__all__``, so what its star import binds varies.
    """
    return (
        isinstance(node, ast.ImportFrom)
        and any(alias.name == "*" for alias in node.names)
        and not (node.module == "nox" and not node.level)
    )


def _apply_import(
    node: ast.Import | ast.ImportFrom, bound: dict[str, _Kind]
) -> dict[str, _Kind]:
    result = dict(bound)
    if isinstance(node, ast.Import):
        for alias in node.names:
            # `import nox.command` binds the top-level `nox` name too.
            name = alias.asname or alias.name.partition(".")[0]
            target = alias.name if alias.asname else name
            result[name] = _MODULE_KINDS.get(target, _Kind.OTHER)
        return result
    from_nox = node.module in _NOX_MODULES and not node.level
    kinds = {"session": _Kind.SESSION}
    if node.module == "nox":
        kinds["parametrize"] = _Kind.PARAMETRIZE
    else:
        kinds["session"] = _Kind.UV_SESSION
    for alias in node.names:
        if alias.name == "*":  # Only `from nox import *` gets here.
            result.update({n: _Kind[kind] for n, kind in _NOX_STAR.items()})
            continue
        name = alias.asname or alias.name
        known = from_nox and alias.name in kinds
        result[name] = kinds[alias.name] if known else _Kind.OTHER
    return result


_MODULE_KINDS = {"nox": _Kind.MODULE, "nox_uv": _Kind.UV_MODULE}
_SESSION_OF = {_Kind.MODULE: _Kind.SESSION, _Kind.UV_MODULE: _Kind.UV_SESSION}
# Keyword arguments `nox.session` accepts; any other raises TypeError.
_NOX_SESSION_KWARGS = frozenset({
    "python",
    "py",
    "reuse_venv",
    "name",
    "venv_backend",
    "venv_params",
    "tags",
    "default",
    "requires",
    "download_python",
    "allow_parallel",
})
# nox-uv adds its own and forwards the rest to `nox.session`.
_UV_SESSION_KWARGS = _NOX_SESSION_KWARGS | {
    "uv_groups",
    "uv_extras",
    "uv_only_groups",
    "uv_all_extras",
    "uv_no_extras",
    "uv_all_groups",
    "uv_no_groups",
    "uv_no_install_project",
    "uv_sync_locked",
    "uv_quiet",
    "uv_packages",
    "uv_all_packages",
}


class _Unpredictable(Exception):  # noqa: N818  # control flow, not an error
    """The noxfile uses a construct whose effect on nox's registry is unknown."""


def _never_runs(node: ast.If, *, type_checking: bool, main_name: bool) -> bool:
    """Return True for an ``if`` whose body never runs when nox imports the file.

    That is ``if TYPE_CHECKING:`` or ``if __name__ == "__main__":`` with no
    ``else`` (nox imports the noxfile under another name). *type_checking*
    and *main_name* say the file never rebinds those names.
    """
    if node.orelse:
        return False
    test = node.test
    if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
        return type_checking
    if not (
        isinstance(test, ast.Compare)
        and len(test.ops) == 1
        and isinstance(test.ops[0], ast.Eq)
    ):
        return False
    sides = [test.left, test.comparators[0]]
    names = {side.id for side in sides if isinstance(side, ast.Name)}
    values = {side.value for side in sides if isinstance(side, ast.Constant)}
    return main_name and names == {"__name__"} and values == {"__main__"}


# Names a module has without binding them.
_IMPLICIT = frozenset(dir(builtins)) | {
    "__name__",
    "__file__",
    "__doc__",
    "__spec__",
    "__loader__",
    "__package__",
    "__builtins__",
    "__annotations__",
}


def _only_imported_from_typing(tree: ast.Module, name: str) -> bool:
    """Return True if *name* is never stored and only imported from ``typing``."""
    for node in ast.walk(tree):
        if not isinstance(node, ast.Import | ast.ImportFrom):
            continue
        for alias in node.names:
            if (alias.asname or alias.name.partition(".")[0]) != name:
                continue
            from_typing = (
                isinstance(node, ast.ImportFrom)
                and node.module in {"typing", "typing_extensions"}
                and not node.level
                and alias.name == name
            )
            if not from_typing:
                return False
    return name not in _stores(tree)


def _session_kwargs(node: ast.expr, bound: dict[str, _Kind]) -> frozenset[str] | None:
    """Return the keywords a session decorator accepts, or None if it isn't one.

    ``nox.session``/``session`` from nox and their nox-uv equivalents count.
    """
    kind: _Kind | None = None
    if isinstance(node, ast.Attribute) and node.attr == "session":
        if isinstance(node.value, ast.Name):
            module = bound.get(node.value.id)
            kind = _SESSION_OF.get(module) if module is not None else None
    elif isinstance(node, ast.Name):
        kind = bound.get(node.id)
    if kind is _Kind.SESSION:
        return _NOX_SESSION_KWARGS
    return _UV_SESSION_KWARGS if kind is _Kind.UV_SESSION else None


def _explicit_name(
    call: ast.Call, default: str | None, accepted: frozenset[str]
) -> str | None:
    """Return the name a ``@nox.session(...)`` call registers, if it is static.

    nox registers ``name or func.__name__``. A ``name=`` that is not a literal,
    a ``**mapping`` that may carry one, or positional arguments make the real
    name unknowable without evaluation, and a keyword not in *accepted* makes
    the call raise ``TypeError``, so nothing registers.
    """
    if call.args or any(keyword.arg not in accepted for keyword in call.keywords):
        return None
    for keyword in call.keywords:
        if keyword.arg == "name":
            value = keyword.value
            if not isinstance(value, ast.Constant):
                return None
            if not value.value:  # nox registers `name or func.__name__`
                return default
            return value.value if isinstance(value.value, str) else None
    return default


def _session_names(func: ast.FunctionDef, bound: dict[str, _Kind]) -> list[str]:
    """Return every name *func* is registered under as a nox session.

    Stacked ``@nox.session`` decorators each register an alias, applied
    bottom-up. Any name a decorator expression stores is treated as rebound
    for all of them. nox falls back to the wrapped callable's ``__name__``,
    which a lower decorator other than ``@nox.session``/``@nox.parametrize``
    may change, so above one only an explicit ``name=`` is trusted. A
    decorator whose name cannot be read statically contributes nothing rather
    than a name ``nox -s`` would reject.
    """
    seen = _without(bound, _stores(*func.decorator_list))
    if any(_decorator_fails(decorator, seen) for decorator in func.decorator_list):
        return []  # Python evaluates every decorator before applying any.
    names: list[str] = []
    default: str | None = func.name
    for decorator in reversed(func.decorator_list):
        call = decorator if isinstance(decorator, ast.Call) else None
        accepted = _session_kwargs(call.func if call else decorator, seen)
        if accepted is not None and call is None:
            if default is not None:
                names.append(default)
        elif accepted is not None and call is not None:
            name = _explicit_name(call, default, accepted)
            if name is not None:
                names.append(name)
        elif not _is_parametrize(decorator, seen):
            default = None  # An unknown decorator may rename the function.
    return names


def _decorator_fails(
    decorator: ast.expr, bound: dict[str, _Kind], *, certain: bool = False
) -> bool:
    """Return True if a nox decorator expression may (or, if *certain*, must) raise.

    A session call with positional arguments or an unaccepted keyword raises,
    and one with ``**mapping`` may; so does ``parametrize`` used bare or
    without its required arguments.
    """
    call = decorator if isinstance(decorator, ast.Call) else None
    target = call.func if call is not None else decorator
    accepted = _session_kwargs(target, bound)
    if accepted is not None and call is not None:
        keywords = [kw.arg for kw in call.keywords if kw.arg is not None or not certain]
        return bool(call.args) or any(kw not in accepted for kw in keywords)
    if accepted is not None:
        return False
    if _is_parametrize_ref(target, bound):
        return call is None or not _valid_parametrize_call(call)
    return False


def _is_parametrize(node: ast.expr, bound: dict[str, _Kind]) -> bool:
    """Return True for ``@nox.parametrize(...)``, which keeps ``__name__``.

    It must be called with its two required arguments (``arg_names`` and
    ``arg_values_list``); ``@nox.parametrize()`` raises ``TypeError``.
    """
    if not isinstance(node, ast.Call) or not _valid_parametrize_call(node):
        return False
    return _is_parametrize_ref(node.func, bound)


def _is_parametrize_ref(func: ast.expr, bound: dict[str, _Kind]) -> bool:
    """Return True if *func* names nox's ``parametrize``."""
    if isinstance(func, ast.Name):
        return bound.get(func.id) is _Kind.PARAMETRIZE
    return (
        isinstance(func, ast.Attribute)
        and func.attr == "parametrize"
        and isinstance(func.value, ast.Name)
        and bound.get(func.value.id) is _Kind.MODULE
    )


def _valid_parametrize_call(call: ast.Call) -> bool:
    keywords = {keyword.arg for keyword in call.keywords if keyword.arg is not None}
    if len(keywords) < len(call.keywords) or any(
        isinstance(arg, ast.Starred) for arg in call.args
    ):
        return False  # `*args`/`**kwargs` hide what is passed.
    accepted = ["arg_names", "arg_values_list", "ids"]
    positional = set(accepted[: len(call.args)])
    return (
        len(call.args) <= len(accepted)
        and not positional & keywords
        and keywords <= set(accepted)
        and {"arg_names", "arg_values_list"} <= positional | keywords
    )


def _first_line(func: ast.FunctionDef) -> str | None:
    lines = (ast.get_docstring(func) or "").strip().splitlines()
    return lines[0].strip() if lines else None


class _Walker:
    """Follow a noxfile's straight-line module code, tracking nox bindings.

    Blocks may contain imports, plain statements, ``if`` statements, and
    ``try`` statements (not ``except*``) whose parts are themselves such
    blocks. A ``raise`` ends its block, but only under an ``if`` whose test
    uses a name or call (a runtime guard): one in a ``try``, a ``class`` body,
    or under a literal-only test makes the file unpredictable. Sessions
    register only from top-level ``def`` statements. Anything else raises
    ``_Unpredictable``.
    """

    def __init__(self, tree: ast.Module) -> None:
        self.sessions: dict[str, str | None] = {}
        self._type_checking_safe = _only_imported_from_typing(tree, "TYPE_CHECKING")
        self._lazy_annotations = any(
            isinstance(node, ast.ImportFrom)
            and node.module == "__future__"
            and any(alias.name == "annotations" for alias in node.names)
            for node in tree.body
        )
        # `__name__` is never imported from typing, so this means "never bound".
        self._main_name = _only_imported_from_typing(tree, "__name__")
        # A `global`/`nonlocal` anywhere (e.g. in a function called at import)
        # can rebind a module-level name out of statement order.
        self._unstable = {
            name
            for node in ast.walk(tree)
            if isinstance(node, ast.Global | ast.Nonlocal)
            for name in node.names
        }

    def block(
        self, body: list[ast.stmt], bound: dict[str, _Kind], *, top: bool
    ) -> dict[str, _Kind] | None:
        """Return the bindings after *body*, or None if it always raises."""
        state: dict[str, _Kind] | None = bound
        for node in body:
            if state is None:
                break  # Unreachable after a raise.
            reads = _statement_reads(node, lazy_annotations=self._lazy_annotations)
            if not reads <= state.keys() | _IMPLICIT:
                return None  # Reading a name not yet bound raises NameError.
            if not _deleted_names(node) <= state.keys():
                return None  # `del` of an unbound name (even a builtin) fails.
            state = self._statement(node, state, top=top)
            if state is not None:
                state = _without(state, self._unstable)
        return state

    def _statement(
        self, node: ast.stmt, bound: dict[str, _Kind], *, top: bool
    ) -> dict[str, _Kind] | None:
        if isinstance(node, ast.Import | ast.ImportFrom):
            return self._import(node, bound, top=top)
        if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
            return self._function(node, bound, top=top)
        if isinstance(node, ast.ClassDef):
            return self._class(node, bound)
        if isinstance(node, _PLAIN | ast.Raise):
            # A `raise` ends this path; other plain statements only bind names.
            return None if isinstance(node, ast.Raise) else _bind(node, bound)
        if isinstance(node, ast.If):
            return self._if(node, bound)
        if isinstance(node, ast.Try):
            return self._try(node, bound)
        raise _Unpredictable  # Loops, `with`, `match`, `assert`, `except*`, ...

    def _class(
        self, node: ast.ClassDef, bound: dict[str, _Kind]
    ) -> dict[str, _Kind] | None:
        if _deletes_within(node):
            # A class-scope `del` only sees the class namespace, which the
            # module-level bindings nur passes in do not separate out.
            raise _Unpredictable
        if self.block(node.body, bound, top=False) is None:
            # A class body runs at import, under the same rules as module code;
            # one that always raises means the import always fails.
            return None
        return _bind(node, bound)

    def _function(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
        bound: dict[str, _Kind],
        *,
        top: bool,
    ) -> dict[str, _Kind] | None:
        seen = _without(bound, _stores(*node.decorator_list))
        if any(_decorator_fails(d, seen, certain=True) for d in node.decorator_list):
            # e.g. `@nox.session(typo=True)` raises while the file loads, so
            # nothing after it registers either.
            return None
        if isinstance(node, ast.FunctionDef) and top:
            # A later definition under the same name replaces the earlier one,
            # as in nox's own registry, while keeping the first one's position.
            description = _first_line(node)
            for name in _session_names(node, bound):
                self.sessions[name] = description
        return _bind(node, bound)

    def _import(
        self, node: ast.Import | ast.ImportFrom, bound: dict[str, _Kind], *, top: bool
    ) -> dict[str, _Kind]:
        del top  # Imports bind the same way at any depth.
        if _unknown_star_import(node):
            raise _Unpredictable  # It may rebind any name.
        return _apply_import(node, bound)

    def _if(self, node: ast.If, bound: dict[str, _Kind]) -> dict[str, _Kind] | None:
        type_checking = self._type_checking_safe and "TYPE_CHECKING" in bound
        if _never_runs(node, type_checking=type_checking, main_name=self._main_name):
            return bound
        bound = _without(bound, _stores(node.test))
        branches = [self.block(b, bound, top=False) for b in (node.body, node.orelse)]
        if None in branches and _has_literal_condition(node.test):
            # `if True: missing` always fails; nur does not evaluate conditions,
            # so it cannot tell whether the failing branch is the one taken.
            raise _Unpredictable
        return _merge_continuing(branches)

    def _try(self, node: ast.Try, bound: dict[str, _Kind]) -> dict[str, _Kind] | None:
        if _raises_within(node):
            # Whether a handler catches an explicit raise depends on classes nur
            # does not model, so a `try` with one is unpredictable.
            raise _Unpredictable
        body = self.block(node.body, bound, top=False)
        if body is None:
            # The body certainly fails (say, a NameError); whether a handler
            # catches that depends on its class, which nur does not model.
            raise _Unpredictable
        completed = self.block(node.orelse, body, top=False)
        # A handler may start after any statement of the body ran, unless the
        # body cannot raise at all (only `pass` and literal expressions).
        start = _without(bound, _stores(*node.body))
        paths = [completed]
        handlers = [] if _cannot_raise(node.body) else node.handlers
        for handler in handlers:
            names = _stores(handler.type) | ({handler.name} if handler.name else set())
            entry = _without(start, names)
            if handler.name:
                entry[handler.name] = _Kind.OTHER
            after = self.block(handler.body, entry, top=False)
            # Python deletes an `except ... as name` target when it exits.
            paths.append(None if after is None else _without(after, names))
        state = _merge_continuing(paths)
        if state is None:
            return None
        return self.block(node.finalbody, state, top=False)


def _deletes_within(node: ast.ClassDef) -> bool:
    """Return True if a class body runs a ``del`` (outside nested functions)."""
    stack: list[ast.AST] = list(node.body)
    while stack:
        current = stack.pop()
        if isinstance(current, ast.Delete):
            return True
        if not isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef):
            stack.extend(ast.iter_child_nodes(current))
    return False


def _cannot_raise(body: list[ast.stmt]) -> bool:
    """Return True for a block of only ``pass`` and literal expressions."""
    return all(
        isinstance(stmt, ast.Pass)
        or (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
        for stmt in body
    )


def _raises_within(node: ast.AST) -> bool:
    """Return True if *node* holds a ``raise`` that runs at import time.

    Function and lambda bodies only run when called, so they are skipped.
    """
    stack: list[ast.AST] = [node]
    while stack:
        current = stack.pop()
        if isinstance(current, ast.Raise):
            return True
        if current is not node and isinstance(
            current, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda
        ):
            continue
        stack.extend(ast.iter_child_nodes(current))
    return False


def _literal_only(expr: ast.expr) -> bool:
    """Return True for an expression built without names, calls, or attributes."""
    return not any(
        isinstance(child, ast.Name | ast.Call | ast.Attribute)
        for child in ast.walk(expr)
    )


def _has_literal_condition(test: ast.expr) -> bool:
    """Return True if a condition's truth may hinge on a literal part.

    ``if flag or True:`` is always true, so a raise under it always runs;
    nur does not evaluate conditions, so a test, or an ``and``/``or``/``not``
    operand, whose truth is fixed (see ``_known_truth``) counts, as does a
    literal-only comparison.
    """
    if any(isinstance(n, ast.Compare) and _literal_only(n) for n in ast.walk(test)):
        return True
    return any(_known_truth(operand) for operand in _truth_operands(test))


def _truth_operands(test: ast.expr) -> list[ast.expr]:
    """Return *test* and the ``and``/``or``/``not`` operands its truth rests on."""
    operands = [test]
    if isinstance(test, ast.BoolOp):
        for value in test.values:
            operands += _truth_operands(value)
    elif isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
        operands += _truth_operands(test.operand)
    return operands


def _known_truth(expr: ast.expr) -> bool:
    """Return True if *expr*'s truth is fixed: a literal, display, or lambda.

    ``(flag,)`` is always true, whatever ``flag`` is.
    """
    displays = ast.Tuple | ast.List | ast.Set | ast.Dict | ast.JoinedStr | ast.Lambda
    return _literal_only(expr) or isinstance(expr, displays)


def _merge_continuing(paths: list[dict[str, _Kind] | None]) -> dict[str, _Kind] | None:
    """Merge the paths that continue; None when every path raises."""
    continuing = [path for path in paths if path is not None]
    return _merge(continuing) if continuing else None


def _scan(tree: ast.Module) -> dict[str, str | None] | None:
    """Return the sessions a straight-line noxfile registers, or None.

    None means nox's registry cannot be predicted without running the file: it
    uses an unsupported construct, or its module code always raises.
    """
    walker = _Walker(tree)
    try:
        if walker.block(tree.body, {}, top=True) is None:
            return None
    except _Unpredictable:
        return None
    return walker.sessions


[docs] def parse_noxfile(text: str, source_file: str = _SOURCE_FILE) -> list[Task]: """Extract nox sessions from *text* by AST parsing, never importing it. ``nox --list`` imports and executes ``noxfile.py`` to build its registry, so discovery reads ``@nox.session``-decorated functions from the syntax tree instead. It only trusts straight-line noxfiles: module code may use imports, plain statements (assignment, ``del``, expression, ``pass``, ``def``, ``class``), and ``if``/``try`` blocks made of those, where a ``raise`` ends its branch. Sessions register only from top-level ``def`` statements. Anything else (loops, ``with``, ``match``, ``assert``, ``except*``, a star import from another module) makes nox's registry depend on control flow nur does not follow, so nothing is listed rather than a session nox may not register. ``python=[...]`` and ``@nox.parametrize`` variants are surfaced under their base name (``nox -s <name>`` still runs every variant). """ # The noxfile's own SyntaxWarnings (e.g. invalid escapes) are nox's to # report when it runs; nur only reads names, so keep them off every listing. with warnings.catch_warnings(action="ignore", category=SyntaxWarning): tree = ast.parse(text, filename=source_file) # Some trees parse but cannot compile (a repeated keyword argument, a # module-level `return`); nox fails to import those, so list nothing. # Compiling builds a code object without executing any of it. compile(tree, source_file, "exec", dont_inherit=True) if _mutates_namespace(tree): return [] sessions = _scan(tree) if sessions is None: return [] return [ Task( name=name, prefix="nox", argv_base=("nox", "-s", name), description=description, source_file=source_file, passthrough_prefix=("--",), ) for name, description in sessions.items() ]
class NoxProvider: prefix = "nox" def detect(self, cwd: Path) -> bool: return (cwd / _SOURCE_FILE).is_file() def discover(self, cwd: Path) -> list[Task]: try: # Strict UTF-8, as nox itself reads the file before importing it, so # a file nox would reject (e.g. with a BOM) lists nothing. text = (cwd / _SOURCE_FILE).read_text(encoding="utf-8") return parse_noxfile(text) except ( OSError, UnicodeDecodeError, SyntaxError, ValueError, RecursionError, ) as exc: log.warning("nur: skipping %s (%s)", _SOURCE_FILE, exc) return []