Source code for repomatic.metadata.project

# 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.

"""Python-project reading of {class}`~repomatic.metadata.core.Metadata`.

Everything derived from `pyproject.toml` and the Sphinx configuration:
project identity, entry points, version state, and tool parameters.
"""

from __future__ import annotations

import ast
import logging
import re
from functools import cached_property
from pathlib import Path

import tomlrt
from packaging.version import Version
from pyproject_metadata import ConfigurationError, StandardMetadata

from ..config import (
    Config,
    load_repomatic_config,
)
from ..git_ops import (
    get_latest_tag_version,
    get_release_version_from_commits,
)
from ..pyproject import (
    is_python_package as _is_python_package,
    is_python_project as _is_python_project,
)
from ..release.binary import NUITKA_BUILD_TARGETS
from ..tooling.tool_registry import MYPY_VERSION_MIN

TYPE_CHECKING = False
if TYPE_CHECKING:
    from typing import Any, Final, Literal

    from ..github.matrix import Matrix


_SCRIPT_NAME_RE: Final[re.Pattern[str]] = re.compile(r"[A-Za-z0-9._-]+")
"""Allowed characters in a `[project.scripts]` entry name.

Matches the rule PyPI enforces on uploaded wheels and the validation
[uv-build performs](https://github.com/astral-sh/uv/pull/19495) before
writing wheel metadata. Names are also required to be non-empty and to
contain at least one non-dot character; both extra checks live next to
the regex in {meth}`ProjectMetadata.script_entries`.
"""


def _known_build_targets(names: list[str], kind: str) -> set[str]:
    """Keep the recognized Nuitka build targets among *names*.

    Shared by {attr}`ProjectMetadata.dev_targets` and
    {attr}`ProjectMetadata.unstable_targets`, which read two different config lists
    against the same roster and would otherwise drift on how they treat a name
    that roster does not carry.

    :param names: Target names, as configured.
    :param kind: What the list configures, for the warning naming the strays.
    :return: The subset of *names* present in
        {data}`~repomatic.release.binary.NUITKA_BUILD_TARGETS`.
    """
    targets = set(names)
    unknown = targets - set(NUITKA_BUILD_TARGETS)
    if unknown:
        logging.warning(f"Unrecognized {kind} targets: {unknown}")
    return targets & set(NUITKA_BUILD_TARGETS)


[docs] def is_version_bump_allowed(part: Literal["minor", "major"]) -> bool: """Check if a version bump of the specified part is allowed. This prevents double version increments within a development cycle. A bump is blocked if the version has already been bumped (but not released) since the last tagged release. For example: - Last release: `v5.0.1`, current: `5.0.2` β†’ minor bump allowed - Last release: `v5.0.1`, current: `5.1.0` β†’ minor bump NOT allowed (bumped) - Last release: `v5.0.1`, current: `6.0.0` β†’ major bump NOT allowed (bumped) ```{note} When tags are not available (e.g., due to race conditions between workflows), this function falls back to parsing version from recent commit messages. ``` :param part: The version part to check (`minor` or `major`). :return: `True` if the bump should proceed, `False` if it should be skipped. """ # Validate part argument early. if part not in ("minor", "major"): raise ValueError(f"Invalid version part: {part!r}. Must be 'minor' or 'major'.") current_version_str = ProjectMetadata.get_current_version() if not current_version_str: logging.warning("Cannot determine current version. Allowing bump.") return True # Try to get the latest release version from tags first. latest_release = get_latest_tag_version() # Fallback to commit message parsing if tag not found. # This handles race conditions where the release workflow hasn't pushed the tag yet. if not latest_release: logging.info("No tags found, falling back to commit message parsing.") latest_release = get_release_version_from_commits() if not latest_release: logging.warning("No release version found from tags or commits. Allowing bump.") return True current = Version(current_version_str) logging.info(f"Current version: {current}, Latest release: {latest_release}") if part == "major": # Block if major version is already ahead of the latest release. if current.major > latest_release.major: logging.info( "Major version already bumped " f"({current.major} > {latest_release.major}). Skipping bump." ) return False elif part == "minor": # Block if major is ahead, or if minor is ahead within the same major. if current.major > latest_release.major: logging.info( "Major version already bumped " f"({current.major} > {latest_release.major}). Skipping minor bump." ) return False if ( current.major == latest_release.major and current.minor > latest_release.minor ): logging.info( "Minor version already bumped " f"({current.minor} > {latest_release.minor}). Skipping bump." ) return False logging.info(f"Version bump for {part} is allowed.") return True
[docs] class ProjectMetadata: """What `pyproject.toml` and the Sphinx configuration declare. 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. pyproject_path: Path sphinx_conf_path: Path @cached_property def new_commits_matrix(self) -> Matrix | None: """See {class}`~repomatic.metadata.git.GitMetadata`.""" @cached_property def release_commits_matrix(self) -> Matrix | None: """See {class}`~repomatic.metadata.git.GitMetadata`."""
[docs] @cached_property def is_python_project(self) -> bool: """Returns `True` if repository is a Python project. Presence of a `pyproject.toml` file that respects the standards is enough to consider the project as a Python one. Delegates to {func}`repomatic.pyproject.is_python_project` so the detection rule has a single source of truth. """ return _is_python_project(pyproject_data=self.pyproject_toml)
[docs] @cached_property def is_python_package(self) -> bool: """Returns `True` if the repository builds a distributable package. Strictly narrower than {attr}`is_python_project`: a uv virtual project declares a `[project]` table to carry its dependencies, then opts out of being built with `[tool.uv] package = false`. Delegates to {func}`repomatic.pyproject.is_python_package`, the same predicate {attr}`~repomatic.registry.RepoScope.PACKAGE_ONLY` resolves against, so the release lane and the checks that police it agree on who publishes. Prefer this over the truthiness of {attr}`package_name` when gating anything about publishing. `package_name` only reports what `[project] name` says, which a virtual project still declares. """ return _is_python_package(pyproject_data=self.pyproject_toml)
[docs] @cached_property def pyproject_toml(self) -> dict[str, Any]: """Returns the raw parsed content of `pyproject.toml`. Returns an empty dict if the file does not exist. """ if self.pyproject_path.exists() and self.pyproject_path.is_file(): data: dict[str, Any] = tomlrt.loads( self.pyproject_path.read_text(encoding="UTF-8") ) return data return {}
[docs] @cached_property def pyproject(self) -> StandardMetadata | None: """Returns metadata stored in the `pyproject.toml` file. Returns `None` if the `pyproject.toml` does not exists or does not respects the PEP standards. ```{warning} Some third-party apps have their configuration saved into `pyproject.toml` file, but that does not means the project is a Python one. For that, the `pyproject.toml` needs to respect the PEPs. ``` """ toml = self.pyproject_toml if toml: try: return StandardMetadata.from_pyproject(toml) except ConfigurationError: pass return None
[docs] @cached_property def config(self) -> Config: """Returns the `[tool.repomatic]` section from `pyproject.toml`. Merges user configuration with defaults from `Config`. """ return load_repomatic_config(self.pyproject_toml)
[docs] @cached_property def nuitka_entry_points(self) -> list[str]: """Entry points selected for Nuitka binary compilation. Reads `[tool.repomatic].nuitka.entry-points` from `pyproject.toml`. When empty (the default), deduplicates by callable target: keeps the first entry point for each unique `module:callable` pair, so alias entry points (like both `mpm` and `meta-package-manager` pointing to the same function) don't produce duplicate binaries. Unrecognized CLI IDs are logged as warnings and discarded. """ all_cli_ids = [cli_id for cli_id, _, _ in self.script_entries] if not all_cli_ids: return [] raw = self.config.nuitka_entry_points if not raw: # Default: first entry point per unique callable target. seen_targets: set[str] = set() unique: list[str] = [] for cli_id, module_id, callable_id in self.script_entries: target = f"{module_id}:{callable_id}" if target not in seen_targets: seen_targets.add(target) unique.append(cli_id) return unique selected = [] for cli_id in raw: if cli_id in all_cli_ids: selected.append(cli_id) else: logging.warning( f"Unrecognized nuitka entry point {cli_id!r}; valid: {all_cli_ids}" ) return selected or all_cli_ids[:1]
[docs] @cached_property def dev_targets(self) -> set[str]: """Nuitka build targets compiled on ordinary (non-release) pushes. Reads `[tool.repomatic].nuitka.dev-targets` from `pyproject.toml`. An empty list disables dev builds entirely. See {attr}`~repomatic.config.Config.nuitka_dev_targets` for the default and the canary rationale. Unrecognized target names are logged as warnings and discarded. """ return _known_build_targets(self.config.nuitka_dev_targets, "dev")
[docs] @cached_property def unstable_targets(self) -> set[str]: """Nuitka build targets allowed to fail without blocking the release. Reads `[tool.repomatic].nuitka.unstable-targets` from `pyproject.toml`. Defaults to an empty set. Unrecognized target names are logged as warnings and discarded. """ return _known_build_targets(self.config.nuitka_unstable_targets, "unstable")
[docs] @cached_property def package_name(self) -> str | None: """Returns package name as published on PyPI.""" if self.pyproject and self.pyproject.canonical_name: return self.pyproject.canonical_name return None
[docs] @cached_property def project_description(self) -> str | None: """Returns project description from pyproject.toml.""" if self.pyproject and self.pyproject.description: return self.pyproject.description return None
[docs] @cached_property def script_entries(self) -> list[tuple[str, str, str]]: """Returns a list of tuples containing the script name, its module and callable. Results are derived from the script entries of `pyproject.toml`. So that: ```{code-block} toml [project.scripts] mdedup = "mail_deduplicate.cli:mdedup" mpm = "meta_package_manager.__main__:main" ``` Will yields the following list: ```{code-block} python ( ("mdedup", "mail_deduplicate.cli", "mdedup"), ("mpm", "meta_package_manager.__main__", "main"), ..., ) ``` Each entry is validated against PEP 621 and PyPI conventions: - The script *name* (the dict key) must be non-empty, contain at least one non-dot character, and match `[A-Za-z0-9._-]+`. This mirrors the rule PyPI enforces on uploaded wheels and the check [uv-build performs](https://github.com/astral-sh/uv/pull/19495); rejecting names like `../escape`, `nested/script` or `.` here keeps them from flowing into the binary file path template `{{cli_id}}-{{current_version}}-{{target}}.{{extension}}` and from there into shell-quoted artifact names, `chmod`, and attestation commands in the release workflow. - The script *value* must split on `:` into exactly two non-empty parts (`module:object`). Malformed values raise a descriptive `ValueError` instead of crashing with an unpacking error. """ entries = [] if self.pyproject: for cli_id, script in self.pyproject.scripts.items(): if not _SCRIPT_NAME_RE.fullmatch(cli_id) or all( c == "." for c in cli_id ): raise ValueError( f"Invalid [project.scripts] name {cli_id!r}: must" " contain at least one non-dot character and match" " [A-Za-z0-9._-]+." ) parts = script.split(":") if len(parts) != 2 or not all(parts): raise ValueError( f"Invalid [project.scripts] value {script!r} for" f" {cli_id!r}: expected the form 'module:object'." ) module_id, callable_id = parts entries.append((cli_id, module_id, callable_id)) # Double check we do not have duplicate entries. all_cli_ids = [cli_id for cli_id, _, _ in entries] assert len(set(all_cli_ids)) == len(all_cli_ids) return entries
[docs] @cached_property def requires_python_floor(self) -> tuple[int, int] | None: """The project's `requires-python` lower bound, as `(major, minor)`. The one reduction of the specifier to a floor, shared by {attr}`mypy_params` and the `lint-repo` Python-consistency check so the two cannot disagree on which operators count as a bound. :return: The first `>=`/`>` bound's release pair, or `None` when the project declares no `requires-python` or no lower bound. """ if not self.pyproject or not self.pyproject.requires_python: return None for spec in self.pyproject.requires_python: if spec.operator in (">=", ">"): release = Version(spec.version).release return (release[0], release[1]) return None
[docs] @cached_property def mypy_params(self) -> list[str] | None: """Generates `mypy` parameters. Mypy needs to be fed with this parameter: `--python-version 3.x`. Extracts the minimum Python version from the project's `requires-python` specifier. Only takes `major.minor` into account. """ min_version = self.requires_python_floor if not min_version: return None # Compare to Mypy's lowest supported version of Python dialect. major, minor = max(MYPY_VERSION_MIN, min_version) return ["--python-version", f"{major}.{minor}"]
[docs] @staticmethod def get_current_version() -> str | None: """Returns the current version as managed by bump-my-version. Same as calling the CLI: ```{code-block} shell-session $ bump-my-version show current_version ``` Reads `current_version` from the first TOML file found in the current working directory: `.bumpversion.toml` (top-level table) or `pyproject.toml` (`[tool.bumpversion]`). """ cwd = Path.cwd() for filename, section_path in ( (".bumpversion.toml", ()), ("pyproject.toml", ("tool", "bumpversion")), ): path = cwd / filename if not path.exists(): continue try: data = tomlrt.loads(path.read_text(encoding="UTF-8")).to_dict() except tomlrt.TOMLParseError: continue section = data for key in section_path: section = section.get(key, {}) if not isinstance(section, dict): section = {} break version = section.get("current_version") if version is not None: return str(version) return None
[docs] @cached_property def current_version(self) -> str | None: """Returns the current version. Current version is fetched from the `bump-my-version` configuration file. During a release, two commits are bundled into a single push event: 1. `[changelog] Release vX.Y.Z` β€” freezes the version to the release number 2. `[changelog] Post-release bump vX.Y.Z β†’ vX.Y.Z` β€” bumps to the next dev version In this situation, the current version returned is the one from the most recent commit (the post-release bump), which represents the next development version. Use `released_version` to get the version from the release commit. """ version = None if self.new_commits_matrix: details = self.new_commits_matrix.include if details: version = details[0].get("current_version") else: version = self.get_current_version() return version
[docs] @cached_property def released_version(self) -> str | None: """Returns the version of the release commit. During a release push event, this extracts the version from the `[changelog] Release vX.Y.Z` commit, which is distinct from `current_version` (the post-release bump version). This is used for tagging, PyPI publishing, and GitHub release creation. Returns `None` if no release commit is found in the current event. """ version = None if self.release_commits_matrix: details = self.release_commits_matrix.include if details: # This script is only designed for at most 1 release in the list of new # commits. assert len(details) == 1 version = details[0].get("current_version") return version
[docs] @cached_property def is_sphinx(self) -> bool: """Returns `True` if the Sphinx config file is present.""" # The Sphinx config file is present, that's enough for us. return self.sphinx_conf_path.exists() and self.sphinx_conf_path.is_file()
[docs] @cached_property def minor_bump_allowed(self) -> bool: """Check if a minor version bump is allowed. This prevents double version increments within a development cycle. """ return is_version_bump_allowed("minor")
[docs] @cached_property def major_bump_allowed(self) -> bool: """Check if a major version bump is allowed. This prevents double version increments within a development cycle. """ return is_version_bump_allowed("major")
def _has_sphinx_extension(self, extension_name: str) -> bool: """Check if a Sphinx extension is listed in `conf.py`'s `extensions`. Parses the Sphinx configuration file as an AST and looks for an `extensions = [...]` assignment containing `extension_name`. """ if not self.is_sphinx: return False for node in ast.parse(self.sphinx_conf_path.read_bytes()).body: if isinstance(node, ast.Assign) and isinstance( node.value, ast.List | ast.Tuple ): extension_found = "extensions" in ( t.id # type: ignore[attr-defined] for t in node.targets ) if extension_found: elements = ( e.value for e in node.value.elts if isinstance(e, ast.Constant) ) if extension_name in elements: return True return False
[docs] @cached_property def active_autodoc(self) -> bool: """Returns `True` if Sphinx autodoc is active.""" return self._has_sphinx_extension("sphinx.ext.autodoc")
[docs] @cached_property def uses_myst(self) -> bool: """Returns `True` if MyST-Parser is active in Sphinx.""" return self._has_sphinx_extension("myst_parser")