Source code for click_extra.prebake

# Copyright Kevin Deldycke <kevin@deldycke.com> 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.
"""Bake build-time metadata into Python source files before compilation.

Compiled binaries (Nuitka, PyInstaller) and ``git``-less runtimes (Docker
images, archive checkouts) cannot resolve version or Git metadata at runtime
the way :class:`click_extra.version.VersionOption` does. The values must
instead be written into the source *before* the build, by rewriting the
relevant dunder assignments (``__version__``, ``__git_short_hash__``, ...) in
place with :mod:`ast`.

This mirrors `shadow-rs <https://github.com/baoyachi/shadow-rs>`_, which
injects build-time constants (``BRANCH``, ``SHORT_COMMIT``, ``COMMIT_HASH``,
``COMMIT_DATE``, ``TAG``, ...) into Rust binaries at compile time.

.. todo::
    Add the following build-time template fields, mirroring the constants
    shadow-rs injects:

    - ``{build_time}``: when the distribution was built (shadow-rs exposes it
      as ``BUILD_TIME``, with RFC 2822 and RFC 3339 variants ``BUILD_TIME_2822``
      / ``BUILD_TIME_3339``).
    - ``{build_os}`` / ``{build_target}`` / ``{build_target_arch}``: the OS,
      target triple and architecture the build ran on. These describe the
      *build* host, unlike ``{env_info}`` which reports the *runtime* Python,
      OS and architecture, so both are worth keeping for cross-built binaries.
"""

from __future__ import annotations

import ast
import logging
import sys
from pathlib import Path

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore[import-not-found]

logger = logging.getLogger(__name__)


def _find_dunder_str(source: str, name: str) -> ast.Constant | None:
    """Find a top-level dunder string constant in parsed source.

    Locates the first top-level ``name = "..."`` assignment and returns
    the :class:`ast.Constant` node for the string value. Returns
    ``None`` if no matching assignment is found.
    """
    tree = ast.parse(source)
    for node in ast.iter_child_nodes(tree):
        if (
            isinstance(node, ast.Assign)
            and len(node.targets) == 1
            and isinstance(node.targets[0], ast.Name)
            and node.targets[0].id == name
            and isinstance(node.value, ast.Constant)
            and isinstance(node.value.value, str)
        ):
            return node.value
    return None


def _rewrite_str_literal(
    file_path: Path,
    source: str,
    node: ast.Constant,
    new_value: str,
) -> None:
    """Replace a string literal's content in a source file.

    Uses the AST node's line/column positions to swap the text between
    the opening and closing quotes, preserving quoting style and all
    surrounding content.
    """
    col_offset = node.col_offset
    end_lineno = node.end_lineno
    col_end = node.end_col_offset
    assert col_offset is not None
    assert end_lineno is not None and col_end is not None
    lines = source.splitlines(keepends=True)
    line = lines[end_lineno - 1]
    # Replace everything between the opening and closing quotes.
    new_line = line[: col_offset + 1] + new_value + line[col_end - 1 :]
    lines[end_lineno - 1] = new_line
    file_path.write_text("".join(lines), encoding="utf-8")


[docs] def prebake_version( file_path: Path, local_version: str, ) -> str | None: """Pre-bake a ``__version__`` string with a `PEP 440 local version identifier <https://peps.python.org/pep-0440/#local-version-identifiers>`_. Reads *file_path*, finds the ``__version__`` assignment via :mod:`ast`, and, if the version contains ``.dev`` and does not already contain ``+``, appends ``+<local_version>``. This is the compile-time complement to the runtime :attr:`click_extra.version.VersionOption.version` property: Nuitka/PyInstaller binaries cannot run ``git`` at runtime, so the hash must be baked into ``__version__`` in the source file **before** compilation. Returns the new version string on success, or ``None`` if no change was made (release version, already pre-baked, or no ``__version__`` found). """ source = file_path.read_text(encoding="utf-8") node = _find_dunder_str(source, "__version__") if node is None: logger.warning("No __version__ found in %s", file_path) return None version = node.value assert isinstance(version, str) if ".dev" not in version: logger.info( "Release version %r in %s β€” skipping.", version, file_path, ) return None if "+" in version: logger.info( "Version %r in %s already has a local identifier β€” skipping.", version, file_path, ) return None new_version = f"{version}+{local_version}" _rewrite_str_literal(file_path, source, node, new_version) logger.info( "Pre-baked %s: %r β†’ %r", file_path, version, new_version, ) return new_version
[docs] def prebake_dunder( file_path: Path, name: str, value: str, ) -> str | None: """Replace an empty dunder variable's value in a Python source file. Reads *file_path*, finds a top-level ``name = ""`` assignment via :mod:`ast`, and, if the current value is an empty string, replaces it with *value*. Placeholders must use empty strings (``__field__ = ""``, not ``None``). The AST matcher only recognizes string literals, and the empty string acts as a falsy sentinel that stays type-consistent with baked values (always ``str``). This is the generic counterpart to :func:`prebake_version`: where ``prebake_version`` appends a PEP 440 local identifier to ``__version__``, this function does a full replacement of any dunder variable that starts empty. Typical use case: injecting a release commit SHA into ``__git_tag_sha__ = ""`` at build time. Returns the new value on success, or ``None`` if no change was made (variable not found, or already has a non-empty value). """ source = file_path.read_text(encoding="utf-8") node = _find_dunder_str(source, name) if node is None: logger.warning("No %s found in %s", name, file_path) return None current = node.value if current: logger.info( "%s in %s already has value %r β€” skipping.", name, file_path, current, ) return None _rewrite_str_literal(file_path, source, node, value) logger.info( "Pre-baked %s in %s: %r β†’ %r", name, file_path, current, value, ) return value
[docs] def discover_package_init_files() -> list[Path]: """Discover ``__init__.py`` files from ``[project.scripts]``. Reads the ``pyproject.toml`` in the current working directory, extracts ``[project.scripts]`` entry points, and returns the unique ``__init__.py`` paths for each top-level package. Only returns paths that exist on disk. Returns an empty list if ``pyproject.toml`` is missing or has no ``[project.scripts]``. """ pyproject_path = Path("pyproject.toml") if not pyproject_path.exists(): logger.warning("No pyproject.toml found in current directory.") return [] data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) scripts = data.get("project", {}).get("scripts", {}) if not scripts: logger.warning("No [project.scripts] entries found in pyproject.toml.") return [] seen: set[Path] = set() paths: list[Path] = [] for script in scripts.values(): # "repomatic.__main__:main" β†’ "repomatic". module_id = script.split(":")[0] package_dir = module_id.split(".")[0] init_path = Path(package_dir) / "__init__.py" if init_path in seen: continue seen.add(init_path) if init_path.exists(): paths.append(init_path) else: logger.warning("Package init not found: %s", init_path) return paths