# 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.
"""Resolvers for the facts a `--version` message reports.
Each function answers one fact on its own, with no option or command around it:
the live Git state ({data}`GIT_RESOLVERS`, {func}`run_git`), the build host
({data}`BUILD_RESOLVERS`), the `.git_archival.json` and `direct_url.json`
fallbacks, the core metadata of a distribution ({func}`resolve_author`,
{func}`resolve_license`), and the interpreter and platform ({func}`default_facts`,
{func}`env_summary`). {class}`~click_extra.version.VersionOption` chains them
into its template fields, and `click-extra prebake all` calls them to bake the
values into a build.
"""
from __future__ import annotations
import json
import logging
import os
import re
from datetime import UTC, datetime
from ._utils import walk_up
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping
from importlib.metadata import PackageMetadata
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _installed(name: str) -> bool:
"""Whether an installed distribution goes by *name*."""
# Deferred import: keeps importlib.metadata off the startup path of every CLI.
from importlib import metadata
try:
metadata.distribution(name)
except metadata.PackageNotFoundError:
return False
return True
[docs]
def distribution_of(package_name: str | None) -> str | None:
"""Resolve an import package name to the installed distribution providing it.
An import (top-level module) name may differ from the distribution name
(`PIL` vs `Pillow`, `jwt` vs `PyJWT`). A name already matching an installed
distribution is returned as-is; otherwise it is resolved through
{func}`importlib.metadata.packages_distributions`. Ambiguous mappings (one
import name to several distributions) return `None`: pass `package_name`
explicitly to disambiguate.
Returns `None` when nothing installed provides the name, which is what makes
it usable as a test of whether a module belongs to a real distribution.
"""
if not package_name:
logger.debug("No package name provided.")
return None
if _installed(package_name):
return package_name
# Deferred import: keeps importlib.metadata off the startup path of every CLI.
from importlib import metadata
# The given name matches no installed distribution: resolve it as an import
# (top-level module) name.
distributions = metadata.packages_distributions().get(package_name, [])
if len(distributions) == 1:
return distributions[0]
if len(distributions) > 1:
logger.debug(
f"{package_name!r} maps to multiple installed distributions "
f"({', '.join(distributions)}); pass 'package_name' to disambiguate."
)
return None
logger.debug(f"{package_name!r} package not found or not installed.")
return None
GIT_FIELDS: dict[str, tuple[str, ...]] = {
"git_branch": ("rev-parse", "--abbrev-ref", "HEAD"),
"git_long_hash": ("rev-parse", "HEAD"),
"git_short_hash": ("rev-parse", "--short", "HEAD"),
"git_date": ("show", "-s", "--format=%ci", "HEAD"),
"git_tag": ("describe", "--tags", "--exact-match", "HEAD"),
}
"""Git fields whose live value *is* the stripped output of one static `git`
subcommand, mapped to that subcommand's args.
`git_tag_sha`, `git_distance` and `git_dirty` are excluded: their
resolution is not a single static `git` invocation whose stripped output is
the value. `git_tag_sha` dereferences the tag (`git rev-list -1 <tag>`),
`git_distance` parses `git describe` and `git_dirty` maps the porcelain
status to a label. See {func}`resolve_git_tag_sha`, {func}`resolve_git_distance`
and {func}`resolve_git_dirty`.
For the resolver of *every* pre-bakeable git field (these five plus the three
computed ones), keyed uniformly by field ID, see {data}`GIT_RESOLVERS`.
"""
GIT_TIMEOUT: float = 5
"""Seconds {func}`run_git` lets a `git` command run before it gives up on it.
A local read answers in milliseconds. The bound is for a repository that blocks,
like one on a network share that stopped answering.
"""
[docs]
def run_git(
*args: str,
cwd: Path | None = None,
allow_empty: bool = False,
check: bool = False,
timeout: float = GIT_TIMEOUT,
) -> str | None:
"""Run a `git` command and return its output, without the final newline.
A command that fails returns `None`: no `git` on the `PATH`, a non-zero exit
status, like the one of a `cwd` outside a repository, or a command still
running after `timeout` seconds. Set `check` to raise instead: an
{exc}`OSError` when `git` does not start, a
{exc}`subprocess.CalledProcessError` on a non-zero exit status, and a
{exc}`subprocess.TimeoutExpired` when the time runs out.
By default an empty output returns `None` too, like a failure. Set
`allow_empty` to keep the empty string, which some commands use
meaningfully: `git status --porcelain` prints nothing for a clean work tree,
and that is distinct from the command failing.
The output decodes as UTF-8, and a byte that is no UTF-8 becomes a
replacement character: Git prints a ref name as the bytes it stores, which a
repository can hold in any encoding.
:param cwd: the folder to run in. The current working directory when `None`.
"""
# Deferred import: keeps subprocess off the startup path of every CLI.
import subprocess
try:
result = subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
encoding="UTF-8",
errors="replace",
check=True,
timeout=timeout,
)
except (OSError, subprocess.SubprocessError):
if check:
raise
return None
# Only the newline Git ends its output with goes: the other whitespace may
# belong to the value, like the empty last field of a tab-separated line.
output = result.stdout.removesuffix("\n")
if output or allow_empty:
return output
return None
[docs]
def resolve_git_dirty(cwd: Path | None = None) -> str | None:
"""Report the work-tree state as `"dirty"`, `"clean"` or `None`.
Returns `"dirty"` when `git status --porcelain` reports uncommitted
changes, `"clean"` when it reports none, and `None` when the state
cannot be determined (not a Git repository, or `git` is unavailable).
The empty output of a clean work tree is meaningful here, so the command is
run with `allow_empty` to tell it apart from a failure.
"""
status = run_git("status", "--porcelain", cwd=cwd, allow_empty=True)
if status is None:
return None
return "dirty" if status else "clean"
def _describe_distance(described: str) -> str | None:
"""The distance in a `git describe` line, `<tag>-<distance>-g<hash>`, or `None`.
`None` for a bare `<tag>`, which `git describe` prints for a tagged commit
without `--long`. The pattern compiles on the first call, on a path few runs
take, rather than at import.
"""
match = re.search(r"-(\d+)-g[0-9a-f]+$", described)
return match.group(1) if match else None
[docs]
def resolve_git_distance(cwd: Path | None = None) -> str | None:
"""Count commits since the most recent tag, as a string, or `None`.
Parses `git describe --tags --long`, whose output has the form
`<tag>-<distance>-g<short_hash>`. Returns `None` when no tag is
reachable, the directory is not a Git repository, or `git` is
unavailable.
"""
described = run_git("describe", "--tags", "--long", cwd=cwd)
return None if described is None else _describe_distance(described)
[docs]
def resolve_git_tag_sha(cwd: Path | None = None) -> str | None:
"""Resolve the commit SHA the tag at `HEAD` points at, or `None`.
Runs `git describe --tags --exact-match HEAD` to find the tag, then
`git rev-list -1 <tag>` to dereference it to a commit SHA. Returns
`None` when `HEAD` is not at a tagged commit, the directory is not a
Git repository, or `git` is unavailable.
"""
tag = run_git(*GIT_FIELDS["git_tag"], cwd=cwd)
if not tag:
return None
return run_git("rev-list", "-1", tag, cwd=cwd)
def _direct_git_resolver(
field_id: str,
) -> Callable[[Path | None], str | None]:
"""Build a `cwd`-taking resolver for a direct {data}`GIT_FIELDS` field.
The returned callable runs the field's static `git` subcommand and
returns its stripped output. Defined as a named factory (rather than an
inline `lambda`) so each resolver binds its own `field_id`.
"""
args = GIT_FIELDS[field_id]
def resolver(cwd: Path | None = None) -> str | None:
return run_git(*args, cwd=cwd)
return resolver
GIT_RESOLVERS: dict[str, Callable[[Path | None], str | None]] = {
**{field_id: _direct_git_resolver(field_id) for field_id in GIT_FIELDS},
"git_tag_sha": resolve_git_tag_sha,
"git_distance": resolve_git_distance,
"git_dirty": resolve_git_dirty,
}
"""Canonical live resolver for every pre-bakeable `git_*` field.
Maps each field ID to a callable that takes an optional working directory and
returns the field's value by shelling out to `git` (or `None` when it
cannot be resolved). This is the single source of truth for *how each git field
is computed live*, shared by two consumers:
- {class}`~click_extra.version.VersionOption`'s runtime accessors, which wrap
each resolver with the pre-baked-dunder and `.git_archival.json` fallbacks.
- the `click-extra prebake all` command, which calls every resolver to bake
values into source files at build time.
Keeping it here means adding a new git field is a one-line edit in this module,
with no matching change needed in the CLI.
"""
SOURCE_DATE_EPOCH = "SOURCE_DATE_EPOCH"
"""Environment variable a reproducible build sets to pin every timestamp it writes.
See the [reproducible-builds.org specification](https://reproducible-builds.org/docs/source-date-epoch/).
"""
[docs]
def build_moment() -> datetime:
"""The instant a build happens, in UTC.
Reads `SOURCE_DATE_EPOCH` when the build sets it, so two runs of a
reproducible build stamp the same instant. Falls back to the current time.
"""
epoch = os.environ.get(SOURCE_DATE_EPOCH)
if epoch:
return datetime.fromtimestamp(int(epoch), tz=UTC)
return datetime.now(tz=UTC)
[docs]
def resolve_build_time() -> str:
"""The moment the distribution is built, as an RFC 3339 UTC timestamp.
See {func}`build_moment` for where the instant comes from.
"""
return build_moment().isoformat(timespec="seconds").replace("+00:00", "Z")
[docs]
def resolve_build_os() -> str:
"""The operating system the build runs on."""
# Deferred import: keeps extra_platforms off the startup path of every CLI.
from extra_platforms import current_platform
return current_platform().name
[docs]
def resolve_build_target() -> str:
"""The platform a distribution built here installs on.
This is the wheel platform tag (`macosx-15.0-arm64`, `linux-x86_64`,
`win-amd64`), which is Python's answer to the target triple shadow-rs bakes
into a Rust binary. It states an ABI floor the two fields beside it cannot:
`macosx-15.0-arm64` says the binary needs macOS 15, where
{func}`~click_extra.version_facts.resolve_build_os` only says it was built
on macOS.
"""
# Deferred import: keeps sysconfig off the startup path of every CLI.
import sysconfig
return sysconfig.get_platform()
[docs]
def resolve_build_target_arch() -> str:
"""The CPU architecture the build runs on."""
# Deferred import: keeps extra_platforms off the startup path of every CLI.
from extra_platforms import current_architecture
return current_architecture().name
BUILD_RESOLVERS: dict[str, Callable[[], str]] = {
"build_time": resolve_build_time,
"build_os": resolve_build_os,
"build_target": resolve_build_target,
"build_target_arch": resolve_build_target_arch,
}
"""Canonical resolver for every pre-bakeable `build_*` field.
These describe the machine and moment a distribution was *built*, which
{data}`GIT_RESOLVERS` and {func}`env_summary` both leave unanswered: git states
what source went in, {func}`env_summary` states where the binary is running now,
and neither one identifies the host that produced it. That gap is what a
cross-built binary turns into a support question, and what shadow-rs answers for
Rust with its `BUILD_TIME`, `BUILD_OS` and `BUILD_TARGET` constants.
A build fact has no live fallback, unlike a git one: nothing at runtime can
recover the host a binary was compiled on, so `click-extra prebake all` is the
only thing that ever writes these. A field left unbaked stays empty, and its
{class}`~click_extra.version.VersionOption` accessor answers `None`.
Adding a field here is a one-line edit, with no matching change in the CLI.
"""
[docs]
def dunder_name(field_id: str) -> str:
"""The module-level name a pre-baked template field is stored under.
`git_branch` is baked as `__git_branch__`: the one spelling
{class}`~click_extra.version.VersionOption` reads back, and
`click-extra prebake` writes. Keeping the contract in one place is what lets
the two sides stay agreed.
"""
return f"__{field_id}__"
[docs]
def find_git_root(start: Path) -> Path | None:
"""Walk up from *start* to the first folder holding a `.git` entry.
The entry is a folder in a plain clone, and a file in a worktree or a
submodule. Returns that folder, *start* included, or `None`.
"""
for path in walk_up(start):
if (path / ".git").exists():
return path
return None
[docs]
def find_archival_file(start: Path) -> Path | None:
"""Walk up from *start* to find a `.git_archival.json` file.
Returns the first match in *start* or any of its parents, or `None`.
"""
for path in walk_up(start):
candidate = path / ".git_archival.json"
if candidate.is_file():
return candidate
return None
[docs]
def read_archival(path: Path) -> dict[str, str]:
"""Parse a `.git_archival.json` file into a string mapping.
Returns an empty mapping when the file is missing, unreadable, or not a
valid JSON object.
"""
try:
data = json.loads(path.read_text(encoding="UTF-8"))
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(key): str(value) for key, value in data.items()}
[docs]
def archival_field(data: Mapping[str, str], field_id: str) -> str | None:
"""Resolve a `git_*` field from parsed `.git_archival.json` data.
*data* follows the [setuptools-scm archival schema](https://setuptools-scm.readthedocs.io/latest/usage/#git-archives):
`node` (full hash), `node-date`, `describe-name` and `ref-names`.
The same file is read by setuptools-scm and Dunamai, so a single committed
`.git_archival.json` serves all three.
Returns `None` when the field is absent, empty, or still holds an
unsubstituted `$Format:…$` placeholder. That last case is what a plain
checkout contains: `git archive` performs the substitution, so values are
real only inside an exported archive (including GitHub's source tarballs).
There is no entry for `git_dirty`: an archive has no work tree, so its
state is unknowable.
"""
def value(key: str) -> str | None:
raw = data.get(key, "").strip()
if not raw or "$Format" in raw:
return None
return raw
def refs() -> list[str]:
"""The refs pointing at the archived commit, like `HEAD -> main`."""
names = value("ref-names")
return names.split(", ") if names else []
match field_id:
case "git_long_hash":
return value("node")
case "git_short_hash":
node = value("node")
return node[:7] if node else None
case "git_date":
return value("node-date")
case "git_branch":
# "HEAD -> main" names the checked-out branch.
return next(
(ref.partition(" -> ")[2] for ref in refs() if " -> " in ref), None
)
case "git_tag":
return next(
(
ref.removeprefix("tag: ")
for ref in refs()
if ref.startswith("tag: ")
),
None,
)
case "git_tag_sha":
# A tag among the refs points at the archived commit itself.
return value("node") if archival_field(data, "git_tag") else None
case "git_distance":
described = value("describe-name")
if described is None:
return None
# A bare "<tag>" means distance zero.
return _describe_distance(described) or "0"
return None
[docs]
def read_direct_url(distribution: str | None) -> dict[str, Any]:
"""Parse the `direct_url.json` record of an installed distribution.
[PEP 610](https://peps.python.org/pep-0610/) has installers write this record
beside the metadata of a distribution installed from a URL, like
`uvx --from git+https://...` or `pip install git+https://...`.
Returns an empty mapping when the distribution is not installed, carries no
record, or holds a record that is not a JSON object.
"""
if not distribution:
return {}
# Deferred import: keeps importlib.metadata off the startup path of every CLI.
from importlib import metadata
try:
text = metadata.distribution(distribution).read_text("direct_url.json")
except metadata.PackageNotFoundError:
return {}
try:
data = json.loads(text) if text else None
except ValueError:
return {}
return data if isinstance(data, dict) else {}
[docs]
def direct_url_field(data: Mapping[str, Any], field_id: str) -> str | None:
"""Resolve a `git_*` field from a parsed `direct_url.json` record.
Only an install from a Git URL records a commit: the full hash of the checkout
the installer built, in `vcs_info.commit_id`. It answers `git_long_hash`, and
its first 7 characters `git_short_hash`.
Every other field answers `None`. The `vcs_info.requested_revision` the record
may also hold names a branch, a tag or a commit, with nothing to tell which.
"""
vcs_info = data.get("vcs_info")
if not isinstance(vcs_info, dict) or vcs_info.get("vcs") != "git":
return None
commit = vcs_info.get("commit_id")
if not isinstance(commit, str) or not commit:
return None
if field_id == "git_long_hash":
return commit
if field_id == "git_short_hash":
return commit[:7]
return None
[docs]
def resolve_distribution(names: Iterable[str]) -> str | None:
"""Return the first installed distribution among *names*, or `None`.
Probes each candidate name in order with {func}`importlib.metadata.distribution`
and returns the first that resolves to an installed distribution. Used to
pick a distribution from a set of plausible spellings (like the program name
with `-` / `_` variants) before reading its metadata.
"""
return next((name for name in names if name and _installed(name)), None)
[docs]
def resolve_author(meta: PackageMetadata | None) -> str | None:
"""Return the author(s) from *meta*'s core metadata, or `None`.
Prefers the `Author` field, then the `Maintainer` field, then the
display name parsed out of the `Author-email` / `Maintainer-email`
fields (`Name <email>`). Returns `None` when *meta* is `None` or no
author can be determined.
"""
if not meta:
return None
# Plain-name fields, in order of preference.
name = meta_value(meta, "Author", "Maintainer")
if name:
return name
# `Name <email>` combined fields: keep only the display names, falling
# back to the raw value when no name part is present.
contact = meta_value(meta, "Author-email", "Maintainer-email")
if contact:
# Deferred import: keeps email.utils off the startup path of every CLI.
from email.utils import getaddresses
names = [n for n, _addr in getaddresses([contact]) if n]
return ", ".join(names) if names else contact
return None
[docs]
def resolve_license(meta: PackageMetadata | None) -> str | None:
"""Return the license from *meta*'s core metadata, or `None`.
Prefers the SPDX `License-Expression` field ([core metadata 2.4+](https://packaging.python.org/en/latest/specifications/core-metadata/#license-expression)).
Falls back to the human-readable name of the first `License ::` trove
classifier, then to the free-form `License` field (which may hold the
full license text). Returns `None` when *meta* is `None` or no license
can be determined.
"""
if not meta:
return None
# SPDX expression (core metadata 2.4+), the modern canonical field.
expression = meta_value(meta, "License-Expression")
if expression:
return expression
# `License :: OSI Approved :: GNU GPL v3 (GPLv3)` → `GNU GPL v3 (GPLv3)`.
for classifier in meta.get_all("Classifier") or []:
text = str(classifier)
if text.startswith("License ::"):
return text.rsplit("::", maxsplit=1)[-1].strip()
# Free-form legacy field (may hold the full license text).
return meta_value(meta, "License")
[docs]
def env_summary() -> str:
"""One-line interpreter and platform summary.
Reads `Python 3.14.7, macOS ARM64 (AArch64)` on an Apple Silicon Mac: the
same two facts {func}`default_facts` puts on the version screen, joined for a
CLI that would rather spend one line of its plain `--version` on them than
draw a screen at all:
`@version_option(message="{prog_name} {version} (" + env_summary() + ")")`.
"""
facts = default_facts()
return f"Python {facts['Python']}, {facts['Platform']}"
[docs]
def dependency_versions() -> str:
"""The Click and Cloup releases this install is sitting on.
Worth a row on a screen whose main job is to be pasted into a bug report: Click
Extra subclasses both, so which of the three is at fault is the first question
any such report raises. Not a default fact, since a CLI built on Click Extra may
reasonably consider that its own business rather than its user's.
Read from the installed distributions: Click deprecates its own `__version__`
attribute.
"""
# Deferred import: keeps importlib.metadata off the startup path of every CLI.
from importlib import metadata
return ", ".join(
f"{name.capitalize()} {metadata.version(name)}" for name in ("click", "cloup")
)
[docs]
def default_facts() -> dict[str, str]:
"""The facts every version screen carries, as an ordered label-to-value map.
A mapping rather than a sequence of pairs so a CLI can adjust one row without
restating the rest, `dict` preserving insertion order and replacement keeping a
key where it already sat:
```{code-block} python
default_facts() | {"Platform": my_own_label} # replaces, in place
default_facts() | {"Docs": DOCS_URL} # appends, at the end
```
"""
# Deferred import: keeps platform off the startup path of every CLI.
import platform
return {
"Python": platform.python_version(),
"Platform": platform_label(),
}