Source code for repomatic.metadata.matrix

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Job-matrix construction of {class}`~repomatic.metadata.core.Metadata`.

Builds the Nuitka build matrix and the test matrices from the other
concerns' facts, and applies the repository's `[tool.repomatic]`
test-matrix configuration to them.
"""

from __future__ import annotations

import logging
from functools import cached_property
from pathlib import Path

from ..github.actions import (
    WorkflowEvent,
)
from ..github.matrix import (
    JOB_STATE_KEY,
    OS_AXIS,
    PYTHON_VERSION_AXIS,
    Matrix,
    stale_axis_values,
)
from ..matrix_axes import (
    PRERELEASE_LABEL_SUFFIX,
    SINGLE_RUNNER_PYTHON_VERSIONS,
    TEST_PYTHON_FULL,
    TEST_PYTHON_PR,
    TEST_RUNNERS_FULL,
    TEST_RUNNERS_PR,
    UNSTABLE_PYTHON_VERSIONS,
)
from ..release.binary import (
    NUITKA_BUILD_TARGETS,
    binary_name,
)

TYPE_CHECKING = False
if TYPE_CHECKING:
    from ..config import Config


[docs] class MatrixMetadata: """The Nuitka build matrix and the test matrices. A concern mixin of {class}`~repomatic.metadata.core.Metadata`: never instantiated on its own, and reads sibling concerns through `self`. """ if TYPE_CHECKING: # Sibling-concern surface read through `self`: each stub mirrors # the descriptor another mixin (or the assembled `Metadata` # class) defines, so every concern type-checks on its own. @cached_property def config(self) -> Config: """See {class}`~repomatic.metadata.project.ProjectMetadata`.""" @cached_property def current_commit_matrix(self) -> Matrix | None: """See {class}`~repomatic.metadata.git.GitMetadata`.""" @cached_property def dev_targets(self) -> set[str]: """See {class}`~repomatic.metadata.project.ProjectMetadata`.""" @cached_property def event_type(self) -> WorkflowEvent | None: """See {class}`~repomatic.metadata.env.EnvironmentMetadata`.""" @cached_property def nuitka_entry_points(self) -> list[str]: """See {class}`~repomatic.metadata.project.ProjectMetadata`.""" @cached_property def release_commits_matrix(self) -> Matrix | None: """See {class}`~repomatic.metadata.git.GitMetadata`.""" @cached_property def script_entries(self) -> list[tuple[str, str, str]]: """See {class}`~repomatic.metadata.project.ProjectMetadata`.""" @cached_property def unstable_targets(self) -> set[str]: """See {class}`~repomatic.metadata.project.ProjectMetadata`."""
[docs] @cached_property def nuitka_matrix(self) -> Matrix | None: """Pre-compute a matrix for Nuitka compilation workflows. Crosses three axes: - one commit per release commit (during a release) or per new commit (otherwise) - every `[project.scripts]` entry point - every build target of {data}`~repomatic.release.binary.NUITKA_BUILD_TARGETS` (runner, platform, architecture, binary extension, and the glibc floor or minimum-OS version that target enforces), narrowed to the `[tool.repomatic] nuitka.dev-targets` canary subset on an ordinary push (see {attr}`dev_targets`); release commits, `schedule` and `workflow_dispatch` runs keep the full roster Each axis contributes an `include` entry carrying the extra parameters the compile job needs, keyed on the axis value that selects it: the target's runner and floors, the entry point's module and callable, and the commit's short SHA and version. A final pass adds one `include` entry per `(os, entry_point, commit)` triple naming the `bin_name` the compiled artifact takes, since that name depends on all three at once. The matrix closes with `{"state": "stable"}`, which the release workflow reads to decide whether a failing job blocks the release. ```{note} Every value comes from {data}`~repomatic.release.binary.NUITKA_BUILD_TARGETS` and the project's own `pyproject.toml`, so no literal is repeated here: run `repomatic metadata nuitka_matrix` against a project to see the matrix it computes, or `repomatic show-test-matrix` for the test one. ``` ```{todo} Drop the per-entry-point `--python-flag=-m` workaround computed below, and compile a `__main__.py` entry point through Nuitka's own `--main-entry-point`, once [Nuitka#3879](https://github.com/Nuitka/Nuitka/issues/3879) ships. ``` """ # Only produce a matrix if the project is providing CLI entry points. if not self.script_entries: return None # Allow projects to opt out of Nuitka compilation via pyproject.toml. if not self.config.nuitka_enabled: logging.info( "[tool.repomatic] nuitka.enabled is disabled." " Skipping binary compilation." ) return None # On an ordinary push, compile only the canary subset: the full fleet # exists to refresh the rolling dev pre-release (a draft), and its # compile jobs contend for the account-wide runner cap on every code # push. Release commits, the weekly `schedule` trigger, # `workflow_dispatch` and local runs keep the full roster. build_targets = NUITKA_BUILD_TARGETS if self.event_type is WorkflowEvent.push and not self.release_commits_matrix: build_targets = { target_id: target_data for target_id, target_data in NUITKA_BUILD_TARGETS.items() if target_id in self.dev_targets } if not build_targets: logging.info( "[tool.repomatic] nuitka.dev-targets selects no target." " Skipping binary compilation for this push." ) return None matrix = Matrix() # Register all runners on which we want to run Nuitka builds. matrix.add_variation( OS_AXIS, tuple(target.runner for target in build_targets.values()) ) # Augment each "os" entry with platform-specific data. for build_target in build_targets.values(): matrix.add_includes(build_target.as_matrix_entry()) # `[tool.nuitka]` is not assembled here: `repomatic run nuitka` resolves # it at build time (the tool runner translates the section to CLI flags). # Only the per-entry-point --python-flag=-m workaround is computed below. # Filter entry points to those selected for Nuitka compilation. selected = set(self.nuitka_entry_points) for cli_id, module_id, callable_id in self.script_entries: if cli_id not in selected: continue # Derive CLI module path from its ID. Nuitka 4.1's # `--main-entry-point` flag is unusable on its own: it skips # populating `_main_paths` in `nuitka.importing.Importing` and # crashes with `NuitkaCodeDeficit: Error, cannot locate modules # before import mechanism is setup` inside # `setStandardLibraryModules`. Falling back to a positional module # path keeps `_main_paths` initialized via # `addMainScriptDirectory`. module_path = Path(f"{module_id.replace('.', '/')}.py") # That positional path is resolved from the repository root, which # a src-layout project does not expose: `mypkg.__main__` lives at # `src/mypkg/__main__.py`, not `mypkg/__main__.py`. Skip the entry # point instead of failing, so a project laid out that way still # gets its metadata (only its binaries are unavailable). if not module_path.exists(): logging.warning( f"Skipping Nuitka entry point {cli_id!r}: no module file " f"at {module_path}." ) continue # CLI ID is supposed to be unique, we'll use that as a key. matrix.add_variation("entry_point", [cli_id]) # When the entry point is a `__main__.py` inside a package, # Nuitka expects the package directory (not the file) along # with `--python-flag=-m`. Passing the file directly # produces a binary that silently exits without output. python_flags = "" if module_path.name == "__main__.py": package_dir = module_path.parent init_file = package_dir / "__init__.py" if init_file.exists(): module_path = package_dir python_flags = "--python-flag=-m" matrix.add_includes({ "entry_point": cli_id, "cli_id": cli_id, "module_id": module_id, "callable_id": callable_id, "module_path": str(module_path), "nuitka_python_flags": python_flags, }) # Every selected entry point was skipped above. The `bin_name` template # below interpolates `cli_id`, so carrying on would raise instead of # reporting "nothing to compile". if "entry_point" not in matrix.variations: logging.warning( "No Nuitka entry point resolves to a module file." " Skipping binary compilation." ) return None # For releases, only build binaries for the release (freeze) commits. The # post-release bump commit doesn't need binaries β€” only the freeze commit # gets tagged and attached to the GitHub release. This halves the number of # expensive Nuitka builds during the release cycle (6 instead of 12). # For non-release pushes, only build for the HEAD commit. Binary # compilation is expensive (6 OS/arch combinations Γ— Nuitka), and the # workflow concurrency rule already cancels older runs for non-release # pushes β€” building every commit in a multi-commit push is wasteful. # Package builds (build-package job) still use new_commits_matrix # since they're cheap. build_commit_matrix = self.release_commits_matrix or self.current_commit_matrix assert build_commit_matrix # Extend the matrix with a new dimension: a list of commits. matrix.add_variation("commit", build_commit_matrix["commit"]) matrix.add_includes(*build_commit_matrix.include) # Augment each variation set of the matrix with the binary name Nuitka # produces. Iterate over all matrix variation sets so we have all the # metadata needed to generate a name unique to these variations. for variations in matrix.solve(): # We re-attach the binary name with an include directive, so we need a # copy of the main variants it corresponds to. bin_name_include = {k: variations[k] for k in matrix.variations} bin_name_include["bin_name"] = binary_name( variations["cli_id"], variations["target"], variations["current_version"], ) matrix.add_includes(bin_name_include) # All jobs are stable by default, unless marked otherwise by specific # configuration. Unstable targets outside the selected subset are # dropped: an include whose "os" matches no matrix combination would # be added by GitHub as a new, half-formed combination. matrix.add_includes({JOB_STATE_KEY: "stable"}) for unstable_target in self.unstable_targets: if unstable_target not in build_targets: continue matrix.add_includes({ JOB_STATE_KEY: "unstable", OS_AXIS: NUITKA_BUILD_TARGETS[unstable_target].runner, }) return matrix
def _apply_test_matrix_config(self, matrix: Matrix, full: bool = False) -> None: """Apply per-project `[tool.repomatic.test-matrix]` config to a matrix. :param matrix: The matrix to modify in-place. :param full: If `True`, also apply `variations` (extra dimension values) and `unstable` (continue-on-error markings). Both are added to the full matrix only, not the PR matrix, to keep PR CI fast and stable. """ # Replacements first, then removals: both modify axis values in-place. for var_id, mapping in self.config.test_matrix.replace.items(): for old, new in mapping.items(): matrix.replace_variation_value(var_id, old, new) for var_id, values in self.config.test_matrix.remove.items(): for value in values: matrix.remove_variation_value(var_id, value) if full: for var_id, values in self.config.test_matrix.variations.items(): matrix.add_variation(var_id, values) # Mark matching combinations continue-on-error via a `state` # include. Full matrix only: in the PR matrix a non-base axis key # (e.g. click-version) would be added to every job and hijack it. for combination in self.config.test_matrix.unstable: matrix.add_includes({**combination, JOB_STATE_KEY: "unstable"}) if self.config.test_matrix.exclude: matrix.add_excludes(*self.config.test_matrix.exclude) if self.config.test_matrix.include: matrix.add_includes(*self.config.test_matrix.include) # Drop excludes that became no-ops after replace/remove changed the # axes, so GitHub Actions does not reject the matrix. No-op user # excludes that are likely typos are surfaced separately by the # lint-repo check (see Metadata.stale_test_matrix_excludes). matrix.prune() @cached_property def _test_matrix_base(self) -> Matrix: """Full test matrix in axes form, before any `full-include` flattening. The cross-product of OS images and Python versions plus per-project variations, with includes and excludes applied. {attr}`test_matrix` flattens this to an explicit job list when `full-include` rows are configured; the stale-exclude lint check reads its axis values from here, so it keeps working whichever form {attr}`test_matrix` emits. """ matrix = Matrix() matrix.add_variation(OS_AXIS, TEST_RUNNERS_FULL) matrix.add_variation(PYTHON_VERSION_AXIS, TEST_PYTHON_FULL) removed_os = self.config.test_matrix.remove.get(OS_AXIS, ()) # Python 3.10 has no native ARM64 Windows build. Skip this guard when # the project removes windows-11-arm, so it does not linger as a no-op # exclude that prune would warn about. if "windows-11-arm" not in removed_os: matrix.add_excludes({ OS_AXIS: "windows-11-arm", PYTHON_VERSION_AXIS: "3.10", }) matrix.add_includes({JOB_STATE_KEY: "stable"}) # `python-label` is display-only: it spells the version the way pyenv and # actions/setup-python name a prerelease, so the job title says why the # cell is continue-on-error. It rides alongside the version rather than # replacing it because uv rejects the `-dev` form, and because downstream # `test-matrix` directives key on the bare version. See # {data}`~repomatic.matrix_axes.PRERELEASE_LABEL_SUFFIX`. for version in sorted(UNSTABLE_PYTHON_VERSIONS): matrix.add_includes({ JOB_STATE_KEY: "unstable", PYTHON_VERSION_AXIS: version, "python-label": f"{version}{PRERELEASE_LABEL_SUFFIX}", }) # Released build flavors (free-threaded) are a variant of an # already-broadly-covered base version, so they smoke-test stable on a # single runner rather than the full spread. Each is a standalone # include pinned to its runner (it introduces a python-version absent # from the axis, so it joins one runner instead of multiplying across # the os axis); skip it when that runner was removed. for version, keep_os in sorted(SINGLE_RUNNER_PYTHON_VERSIONS.items()): if keep_os not in removed_os: matrix.add_includes({ OS_AXIS: keep_os, PYTHON_VERSION_AXIS: version, JOB_STATE_KEY: "stable", }) self._apply_test_matrix_config(matrix, full=True) return matrix
[docs] @cached_property def test_matrix(self) -> Matrix: """Full test matrix for non-PR events. Combines all runner OS images and Python versions, excluding known incompatible combinations. Marks development Python versions as unstable so CI can use `continue-on-error`, and adds released build flavors (free-threaded) as stable single-runner smoke tests. Per-project config from `[tool.repomatic.test-matrix]` is applied last. When `[tool.repomatic.test-matrix] full-include` rows are configured, the matrix is emitted as a flat job list (`{"include": [...]}`) so each row is a standalone combination GitHub runs verbatim, rather than one that augments a base combo sharing its `os` and `python-version`. """ base = self._test_matrix_base full_include = self.config.test_matrix.full_include if not full_include: return base # Single-key default includes (like {click-version: released}) that the # base matrix grants every cross-product job. Collect them first so they # backfill both the solved base jobs and the full-include rows below. defaults = { key: value for directive in base.include if len(directive) == 1 for key, value in directive.items() } defaults.setdefault(JOB_STATE_KEY, "stable") # Solve the base cross-product to explicit jobs, then append each # full-include row as its own standalone job. Emitting this flat list # sidesteps GitHub's include augment-or-add ambiguity for rows sharing # an os/python with the shipped-config jobs. base.solve() also appends # free-threaded probe jobs from standalone includes, which GitHub never # augments with the defaults; backfill every cell so none emits an empty # click-version/cloup-version that GitHub expands to "". rows = [{**defaults, **cell} for cell in base.solve()] rows.extend({**defaults, **cell} for cell in full_include) flat = Matrix() flat.add_includes(*rows) return flat
[docs] @cached_property def test_matrix_pr(self) -> Matrix: """Reduced test matrix for pull requests. Skips experimental Python versions and redundant architecture variants to reduce CI load on PRs. Per-project config excludes and includes from `[tool.repomatic.test-matrix]` are applied, but variations are not (to keep the PR matrix small). """ matrix = Matrix() matrix.add_variation(OS_AXIS, TEST_RUNNERS_PR) matrix.add_variation(PYTHON_VERSION_AXIS, TEST_PYTHON_PR) matrix.add_includes({JOB_STATE_KEY: "stable"}) self._apply_test_matrix_config(matrix, full=False) return matrix
[docs] @cached_property def stale_test_matrix_excludes( self, ) -> list[tuple[dict[str, str], dict[str, str]]]: """User `test-matrix.exclude` entries matching no full-matrix axis value. An exclude naming a value absent from every axis (like a renamed runner) can never match a combination, so `Matrix.prune()` drops it silently and its exclusion intent is lost. This drift is common after an upstream runner rename (such as `macos-15-intel` becoming `macos-26-intel`). The `lint-repo` check surfaces these so the drift fails loudly instead of silently. The axes come from {attr}`_test_matrix_base`, never from the emitted {attr}`test_matrix`: a `full-include` matrix emits as a flat job list whose `all_variations()` is empty, which would misreport every key of an entry as stale. Carrying the absent values in the result is what keeps the lint check from re-deriving them against the wrong matrix. :return: `(entry, absent_values)` pairs in config order: each offending exclude with the key/value pairs no axis carries. """ axes = self._test_matrix_base.all_variations() return [ (entry, bad) for entry in self.config.test_matrix.exclude if (bad := stale_axis_values(entry, axes)) ]