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