Source code for rules_requirements.labels

# SPDX-License-Identifier: AGPL-3.0-or-later
"""One spelling per build target.

The model names targets (``verified_by``, ``config.variants``, the lock) and
evidence files results under them (``bazel-testlogs`` paths, ``rr wrap
--target``, records). The same target has many spellings — ``//p``,
``//p:p``, ``@@//p:p``, ``@splanc//p:p`` from inside splanc, and the
canonical ``@@rules_requirements+//p:n`` (Bazel 8) or
``@@rules_requirements~//p:n`` (Bazel 7) of a module repo — and a claim must
match its evidence whatever spelling either side used, or two spellings of
one target would read as two targets (and could be claimed by two
requirements). :func:`normalize_label` maps every spelling to one:

* the main repository is ``//p:n`` (``@@//``, ``@//`` and ``@<main_repo>//``
  are dropped);
* another module repository is ``@<apparent name>//p:n`` (the canonical
  ``~``/``+`` decoration is dropped, so ``bazel-testlogs/external/<repo>~/``
  paths match a model written with apparent names);
* ``//p`` is ``//p:p`` and ``@r`` is ``@r//:r``;
* the pseudo-targets ``suite:<testsuite name>`` (JUnit outside a testlogs
  tree) and ``record:<stem>`` (records without a target) pass through.

Anything else (``:n``, ``p:n``, a pattern such as ``//p/...``, an empty
string) raises :class:`BadTarget`, reported as ``bad-target``.

Repositories created by module extensions keep their canonical name, always
written ``@@`` (``@@rules_python~~pip~pypi//...``): a test target there needs
an alias in a module repository to be claimed under a stable name.
"""

from __future__ import annotations

import re

PSEUDO_PREFIXES = ("suite:", "record:")

# A module's canonical repo name: ``name+`` (Bazel 8), ``name~`` (Bazel 7.1+)
# or ``name~<version>`` (Bazel 7.0). Extension repos (``a~ext~b``,
# ``a++ext+b``) carry more than one decoration and are left alone.
_MODULE_CANONICAL = re.compile(r"^([A-Za-z][A-Za-z0-9._-]*)(?:\+|~|~[0-9][0-9A-Za-z._-]*)$")
_REPO = re.compile(r"^[A-Za-z0-9_.~+-]*$")
_APPARENT_REPO = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
# Characters Bazel rejects in package and target names, plus '#' (it separates
# a case key's target from its path) and the pattern wildcard '*'.
_BAD_CHARS = re.compile(r"[\s#*\"'\\\x00-\x1f\x7f]")


[docs] class BadTarget(ValueError): # noqa: N818 - named after the bad-target rule """``label`` is not a build label or pseudo-target (rule ``bad-target``)."""
[docs] def is_pseudo(label: str) -> bool: """Whether ``label`` is a ``suite:`` / ``record:`` pseudo-target.""" return label.startswith(PSEUDO_PREFIXES)
[docs] def is_repo_name(name: str) -> bool: """Whether ``name`` can be a ``config.main_repo`` (an apparent repo name).""" return bool(_APPARENT_REPO.match(name))
[docs] def normalize_label(label: str, main_repo: str = "") -> str: """The canonical spelling of ``label``; raises :class:`BadTarget`. ``main_repo`` is the apparent name the main repository has in other modules (``config.main_repo``), so ``@<main_repo>//p:n`` is ``//p:n``. """ if not isinstance(label, str): raise BadTarget(f"{label!r} is not a label") text = label.strip() if not text: raise BadTarget("empty label") if is_pseudo(text): prefix, _, name = text.partition(":") if not name.strip() or "#" in name or "\n" in name: raise BadTarget(f"{label!r}: a {prefix}: pseudo-target needs a name without '#' or newlines") return text repo = "" rest = text if text.startswith("@"): at = 2 if text.startswith("@@") else 1 repo, sep, tail = text[at:].partition("//") if not sep: # "@r" is "@r//:r" if not repo: raise BadTarget(f"{label!r}: a label needs '//'") tail = ":" + repo rest = "//" + tail if not _REPO.match(repo): raise BadTarget(f"{label!r}: bad repository name {repo!r}") m = _MODULE_CANONICAL.match(repo) if m: repo = m.group(1) if repo == main_repo: repo = "" if not rest.startswith("//"): raise BadTarget(f"{label!r}: not an absolute label (write //package:name)") body = rest[2:] pkg, colon, name = body.partition(":") if not colon: if not pkg: raise BadTarget(f"{label!r}: names no target") name = pkg.rsplit("/", 1)[-1] if not name: raise BadTarget(f"{label!r}: empty target name") if pkg.startswith("/") or pkg.endswith("/") or "//" in pkg: raise BadTarget(f"{label!r}: malformed package {pkg!r}") for part in pkg.split("/") if pkg else (): if part in (".", "..", "...") or _BAD_CHARS.search(part): raise BadTarget(f"{label!r}: {part!r} is not a package name (a label names one target, not a pattern)") if ":" in name or _BAD_CHARS.search(name) or name.startswith("/") or name.endswith("/") or "//" in name: raise BadTarget(f"{label!r}: {name!r} is not a target name") if any(part in (".", "..") for part in name.split("/")): raise BadTarget(f"{label!r}: {name!r} is not a target name") # A name still decorated is canonical (apparent names cannot hold ~ or +). sigil = "@@" if ("~" in repo or "+" in repo) else "@" return f"{sigil + repo if repo else ''}//{pkg}:{name}"
[docs] def try_normalize(label: str, main_repo: str = "") -> str | None: """:func:`normalize_label`, or ``None`` for a bad label.""" try: return normalize_label(label, main_repo) except BadTarget: return None
[docs] def read_known_targets(text: str) -> tuple[list[str], list[str]]: """The labels listed one per line (``bazel query 'tests(//...)'`` output), as written, and the lines that are not labels. Blank lines and ``#`` comments are skipped.""" known: list[str] = [] bad: list[str] = [] for raw in text.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue (known if try_normalize(line) is not None else bad).append(line) return known, bad