Source code for click_extra.version

# 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.
"""Introspect CLI metadata at runtime and print a colored `--version` string.

{class}`VersionOption` gathers the executed CLI's metadata (module and
package names, distribution version, author and license, environment profile,
and the live Git state) and renders them through a customizable, colorized
message template.

Git fields (`git_branch`, `git_short_hash`, ...) are resolved at runtime by
shelling out to `git`, with three fallbacks for `git`-less environments: a
pre-baked `__<field>__` dunder in the CLI module or its package (injected before
build by {mod}`click_extra.prebake`), then a committed `.git_archival.json`
populated by `git archive`, then the commit an installer records for a
distribution installed from a Git URL. The resolvers behind each field live in
{mod}`click_extra.version_facts`.
"""

from __future__ import annotations

import copy
import inspect
import logging
import os
import re
import shutil
import sys
from dataclasses import dataclass
from functools import cached_property
from gettext import gettext as _
from pathlib import Path
from string import Formatter

import click
from click import echo, get_current_context

# CLI_ECOSYSTEM_PACKAGES is bound under a private name so this module does not
# offer a second public spelling of it: its canonical home is click_extra._utils.
from ._utils import (
    CLI_ECOSYSTEM_PACKAGES as _CLI_ECOSYSTEM_PACKAGES,
)
from .color import invocation_color, is_a_tty
from .context import ACCESSIBLE, _LazyMetaDict, get
from .layout import cell_width, pad_to
from .parameters import ExtraOption
from .styling import Style
from .theme import get_current_theme
from .version_facts import (
    GIT_RESOLVERS,
    archival_field,
    default_facts,
    direct_url_field,
    distribution_of,
    dunder_name,
    find_archival_file,
    find_git_root,
    read_archival,
    read_direct_url,
    resolve_author,
    resolve_license,
    run_git,
)

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
    from importlib.metadata import PackageMetadata
    from types import FrameType, ModuleType
    from typing import Any, ClassVar, TypeAlias

    from click import Command
    from cloup.styling import IStyle

    Facts: TypeAlias = Mapping[str, str]
    """Label-to-value rows of a version screen, in the order they are drawn."""

logger = logging.getLogger(__name__)

MUTED = Style(fg="bright_black")
"""The recessive style of what a version message says beside its name and version.

The tagline and the fact labels of a {class}`VersionScreen` take it, and so do
the template fields that locate a build rather than identify it: the repository
path, the commit date, the build facts and the environment profile.
"""


[docs] def is_main_module(module_name: str) -> bool: """Is *module_name* a `__main__` entry point, of a package or of a script? An entry point is where the interpreter started: `python -m package`, a console script, or a compiled binary. It is never plumbing a stack walk lands on after running out of user frames, so it is exempt from `CLI_ECOSYSTEM_PACKAGES` even when it sits under one of those packages, as `click_extra.__main__` does in Click Extra's own binary. """ return module_name == "__main__" or module_name.endswith(".__main__")
def _is_plumbing(module_name: str) -> bool: """Whether *module_name* is plumbing between a stack walk and the CLI. A module of `CLI_ECOSYSTEM_PACKAGES` is, unless it is an entry point {func}`is_main_module` recognizes. """ top_level = module_name.partition(".")[0] return top_level in _CLI_ECOSYSTEM_PACKAGES and not is_main_module(module_name)
[docs] def theme_slot(slot: str) -> IStyle: """A style reading its palette slot off the active theme, on every call. The lookup happens at call time, so the message follows `--theme`, `CLICK_EXTRA_THEME` and the background-sniffing `auto` alike, and drops its color under the monochrome `manpage` palette. A style captured at import would paint every theme with one palette: the bright white program name of `dark` is white on white on a light terminal. {func}`~click_extra.theme.get_current_theme` answers with the colorless theme outside an invocation, and with a full palette inside one, so no slot can come back missing. """ def apply(text: str) -> str: return getattr(get_current_theme(), slot)(text) # type: ignore[no-any-return] return apply
[docs] def unstyled(text: str) -> str: """Identity style, for a segment left with no color of its own.""" return text
[docs] @dataclass(frozen=True) class VersionScreen: """A logo, and the facts to seat beside it, as `--version` should draw them. Owns the layout only. The artwork arrives already rendered, as a string or the lines of one, so a CLI is free to draw its mark however it likes, in ASCII line art, half-blocks or anything else, without this class knowing. Hand one to {class}`VersionOption` through its `screen` argument, or to a whole CLI through `default_params(screen=…)`. """ logo: str | Sequence[str] | Callable[[], str | Sequence[str]] """The mark, pre-rendered, or a callable producing it. A string splits on newlines. A callable defers the rendering to the first `--version` that draws the screen, as {attr}`facts` does for its values: a mark painted by code, from an image say, then costs nothing to the invocations that never ask for it. """ tagline: str = "" """One line under the program name. Omitted, with its blank line, when empty.""" facts: Facts | Callable[[], Facts] = default_facts """Label-to-value rows under the tagline, or a callable producing them. A callable defers the work to render time, which matters when a value costs something to compute: a CLI counting plugins should not pay for that on every invocation just to have the number ready in case `--version` is asked for. """ gutter: str = " " """Blank columns between the mark and the facts, and between label and value.""" def _artwork(self) -> list[str]: """The lines of the mark, as its renderer produced them.""" logo = self.logo() if callable(self.logo) else self.logo return logo.split("\n") if isinstance(logo, str) else list(logo) def _block(self) -> tuple[tuple[str, ...], int]: """The mark's lines padded out to its width, and that width. Draws the mark once, a callable `logo` included, for both answers. """ raw = self._artwork() width = max(map(cell_width, raw), default=0) return tuple(pad_to(line, width) for line in raw), width @property def lines(self) -> tuple[str, ...]: """The mark's lines, every one padded out to {attr}`width`. Padding here rather than asking for it is what lets a caller hand over whatever its renderer produced. Trailing blanks are invisible on a line by itself and ragged the moment anything is placed beside it, and a caller cannot repair that afterwards: {func}`~click_extra.layout.pad_to` is what measures a styled line right and closes the style it left open. """ return self._block()[0] @property def width(self) -> int: """Columns the mark occupies, taken from its widest line.""" return max(map(cell_width, self._artwork()), default=0)
[docs] def rows( self, prog_name: str, version: str, styles: Mapping[str, IStyle | None] ) -> tuple[tuple[str, str], ...]: """The column of facts, as (plain, styled) pairs. Both forms are built together because the styled one cannot be measured: its escape sequences take columns that never reach the screen, and the plain twin is what {meth}`render` sizes the layout against. The program name and version take the same styles the plain message gives them, so the two renderings of `--version` cannot drift apart on color. """ facts = self.facts() if callable(self.facts) else self.facts # One column per the longest label, so the values line up without anyone # having to declare a width that a later row could outgrow. label_width = max((len(label) for label in facts), default=0) def paint(field: str, text: str) -> str: style = styles.get(field) return style(text) if style else text header = [ ( f"{prog_name}, version {version}", paint("prog_name", prog_name) + ", version " + paint("version", version), ), ] if self.tagline: header.append((self.tagline, MUTED(self.tagline))) if facts: header.append(("", "")) return ( *header, *( ( f"{label:<{label_width}}{self.gutter}{value}", MUTED(f"{label:<{label_width}}") + self.gutter + value, ) for label, value in facts.items() ), )
[docs] def render( self, prog_name: str, version: str, styles: Mapping[str, IStyle | None] ) -> str | None: """Compose the mark and the facts into the screen, or decline to. The facts are centred against the mark's height, and either column may be the taller of the two: a line missing from one side simply renders blank. Returns `None` when the terminal is too narrow to seat the two columns side by side, leaving the caller to fall back rather than emit a wrapped mess. The threshold is measured off the facts actually built, since their widest row grows with whatever a CLI chose to report. A non-interactive stream reports `shutil`'s 80-column default, wide enough that a redirected-but-forced-color run still gets the screen it asked for. """ rows = self.rows(prog_name, version, styles) logo, width = self._block() needed = width + len(self.gutter) + max(len(plain) for plain, _ in rows) if needed > shutil.get_terminal_size().columns: return None offset = max(0, (len(logo) - len(rows)) // 2) blank = " " * width lines = [] for index in range(max(len(logo), offset + len(rows))): left = logo[index] if index < len(logo) else blank row = index - offset right = rows[row][1] if 0 <= row < len(rows) else "" lines.append(f"{left}{self.gutter}{right}".rstrip()) # Open on a blank line: `--version` is often the tail of a noisier command (a # `uv run` resolving, a wrapper announcing itself), and the mark reads as part # of that noise when it starts flush against it. return "\n" + "\n".join(lines)
[docs] def colors_reach_output() -> bool: """Will ANSI codes survive all the way to the user's terminal? Resolves Click Extra's color tri-state, deferring to the output stream's TTY status on its `auto` default, exactly as `click.echo` does when it decides whether to strip the codes itself. ```{note} The stream probed is {data}`sys.stdout`, not Click's own resolution of it. `click.echo` reaches stdout through a private cached wrapper, which fixes the output encoding and delegates `isatty()` to the stream beneath it, so both answer alike. Click deprecates the public accessor of that wrapper, `click.get_text_stream()`. ``` """ color = invocation_color() if color is None: return is_a_tty(sys.stdout) return color
[docs] def template_segments( template: str, ) -> Iterator[tuple[str, str | None, str | None]]: """Split a version message template into its literal and field segments. Yields one `(literal, field_id, field)` triple per field of `template`, in order: the literal text before the field, the identifier the field resolves on the option (`module` for `{module.__name__}`, the part before any index or attribute), and the field as written, braces and format spec included. A doubled brace of a literal comes back single. Literal text left after the last field comes in a final triple carrying no field. """ for literal, field_name, format_spec, conversion in Formatter().parse(template): if field_name is None: yield literal, None, None continue field = "{" + field_name if conversion: field += f"!{conversion}" if format_spec: field += f":{format_spec}" yield literal, re.split(r"[.\[]", field_name, maxsplit=1)[0], field + "}"
[docs] class VersionOption(ExtraOption): """Gathers CLI metadata and prints a colored version string. ```{note} This is **not a drop-in replacement** for the [standard `@click.version_option()` decorator](https://github.com/pallets/click/blob/cdab890/src/click/decorators.py#L421-L524). It addresses the following Click issues: - [click#2324](https://github.com/pallets/click/issues/2324), to allow its use with the declarative `params=` argument. - [click#2331](https://github.com/pallets/click/issues/2331), by distinguishing the module from the package. - [click#1756](https://github.com/pallets/click/issues/1756), by allowing path and Python version. ``` """ message: str = _("{prog_name}, version {version}") """Default message template used to render the version string.""" template_fields: tuple[str, ...] = ( "module", "module_name", "module_file", "module_version", "package_name", "package_version", "author", "license", "exec_name", "version", "git_repo_path", "git_branch", "git_long_hash", "git_short_hash", "git_date", "git_tag", "git_tag_sha", "git_distance", "git_dirty", "build_time", "build_os", "build_target", "build_target_arch", "prog_name", ) """List of field IDs recognized by the message template.""" default_styles: ClassVar[dict[str, IStyle]] = { "module_name": theme_slot("invoked_command"), "module_version": theme_slot("success"), "package_name": theme_slot("invoked_command"), "package_version": theme_slot("success"), "exec_name": theme_slot("invoked_command"), "version": theme_slot("success"), "git_repo_path": MUTED, "git_branch": Style(fg="cyan"), "git_long_hash": Style(fg="yellow"), "git_short_hash": Style(fg="yellow"), "git_date": MUTED, "git_tag": Style(fg="cyan"), "git_tag_sha": Style(fg="yellow"), "git_distance": theme_slot("success"), "git_dirty": Style(fg="red"), "build_time": MUTED, "build_os": MUTED, "build_target": MUTED, "build_target_arch": MUTED, "prog_name": theme_slot("invoked_command"), } """Default style for each template field. Fields absent from this mapping render with no style of their own and fall back to `message_style` (or no color when that is unset). User-provided `styles` are merged over these defaults. The name and version fields read their style off the active palette through {func}`~click_extra.version.theme_slot` rather than naming a color, so the message follows `--theme`. Under the default `dark` palette, `invoked_command` is bright white bold and `success` is green. """ def __init__( self, param_decls: Sequence[str] | None = None, *, message: str | None = None, fields: Mapping[str, Any] | None = None, styles: Mapping[str, IStyle | None] | None = None, message_style: IStyle | None = None, screen: VersionScreen | None = None, is_flag: bool = True, expose_value: bool = False, is_eager: bool = True, help: str = _("Show the version and exit."), **kwargs: Any, ) -> None: """Preconfigured as a `--version` option flag. :param message: the message template to print, in [format string syntax](https://docs.python.org/3/library/string.html#format-string-syntax). Defaults to ``{prog_name}, version {version}``. :param fields: mapping of template field name to a forced value, overriding the value auto-computed for that field. Keys must be members of `template_fields` (for example ``{"version": "1.2.3"}``). :param styles: mapping of template field name to its `Style`, merged over `default_styles`. Pass `None` as a value to clear a field's default style. Keys must be members of `template_fields`. :param message_style: fallback style for the message literals and for any field that has no style of its own. :param screen: a {class}`~click_extra.version.VersionScreen` to draw instead of the one-line message, whenever the terminal can take it. Left unset, `--version` prints the one-line message. :raises TypeError: when a key of `fields` or `styles` names no template field. """ if not param_decls: param_decls = ("--version",) if message is not None: self.message = message self.message_style = message_style self.screen = screen self._field_overrides: frozenset[str] = frozenset() """Fields the caller pinned, which no later resolution may reconsider. An override lands in the instance dict under the name of the `cached_property` it shadows, so nothing distinguishes the two by inspection. {meth}`reset_resolution` and {meth}`__deepcopy__` need the difference: they drop a resolved field and must keep a pinned one. """ # A field value override shadows the cached_property of the same name. for field_id, field_value in (fields or {}).items(): self.pin_field(field_id, field_value) # Per-field styles: class defaults overridden by user-provided styles. style_overrides = dict(styles or {}) self._reject_unknown(style_overrides) self.styles: dict[str, IStyle | None] = { **self.default_styles, **style_overrides, } kwargs.setdefault("callback", self.print_and_exit) super().__init__( param_decls=param_decls, is_flag=is_flag, expose_value=expose_value, is_eager=is_eager, help=help, **kwargs, )
[docs] def pin_field(self, field_id: str, value: Any) -> None: """Pin a template field to a caller-supplied value. The value shadows the `cached_property` that would otherwise resolve the field, and is recorded so {meth}`reset_resolution` can tell the two apart. Every caller that pins a field goes through here: a bare `setattr` sets the value without the record, and the next reset then discards it. :param field_id: Name of the template field to pin. :param value: Value the field reports from now on. :raises TypeError: when `field_id` names no template field. """ self._reject_unknown((field_id,)) setattr(self, field_id, value) self._field_overrides = self._field_overrides | {field_id}
def _reject_unknown(self, field_ids: Iterable[str]) -> None: """Raise on the first of *field_ids* that names no template field. :raises TypeError: naming the unknown field, and the known ones. """ for field_id in field_ids: if field_id not in self.template_fields: msg = ( f"Unknown version field {field_id!r}." f" Must be one of {self.template_fields}." ) raise TypeError(msg) def _is_resolved(self, key: str) -> bool: """Whether the instance dict entry *key* holds a memoized resolution. A pinned field lands under the same name as the `cached_property` it shadows, but holds the caller's value: see {attr}`_field_overrides`. """ return key not in self._field_overrides and isinstance( getattr(type(self), key, None), cached_property )
[docs] def reset_resolution(self) -> None: """Drop every memoized field, so the next read resolves from scratch. The resolution chain starts on a stack walk, and the option is built once at decoration time, so the first read in a process fixes the answer for every later one. That is what a CLI wants: one invocation, one process, one walk. A documentation build breaks the assumption. It renders many commands in one process, and a render that carries no CLI frame (writing roff for a command tree, say) resolves the chain from a stack the walk cannot read. The wrong answer then stands for the rest of the build, and a later `--version` example publishes it. Clearing the memo hands the next reader its own walk, at the cost of repeating one. A field the caller pinned through `fields=` is not a resolution and survives: see {attr}`_field_overrides`. """ for key in [key for key in self.__dict__ if self._is_resolved(key)]: del self.__dict__[key]
def __deepcopy__(self, memo: dict[int, Any]) -> VersionOption: """Copy the option, dropping every memoized field value. {mod}`~click_extra.multicall` deep-copies a group's parameters to build each personality, and the memo cannot travel along: the `module` entry holds a module object `copy.deepcopy` cannot reconstruct, and a copy is a new option anyway, free to resolve its fields against its own invocations. The configuration state is carried over: message template, styles, screen, and the fields the caller pinned, which keep the caller's own objects. """ cls = type(self) clone = cls.__new__(cls) memo[id(self)] = clone for key, value in self.__dict__.items(): if self._is_resolved(key): continue if key not in self._field_overrides: value = copy.deepcopy(value, memo) setattr(clone, key, value) return clone
[docs] @staticmethod def cli_frame() -> FrameType: """Returns the frame in which the CLI is implemented. Walks the execution stack from the innermost frame outwards, to find the package in which the user's CLI is implemented. The walk reads the module and the function name of each frame off the frame itself, with no source lookup: `--version` is eager, so every CLI pays for this walk at startup. Returns the frame itself. When every frame belongs to the Click ecosystem and none is an entry point, returns the outermost frame, which has no caller: {attr}`module` then defers to the module of the root command's callback. """ # No runner exists until its module is imported, which test tooling does # and a CLI does not: importing it here would load it for every CLI. runner_class = getattr(sys.modules.get("click.testing"), "CliRunner", None) # Keep a list of all frames inspected for debugging. frame_chain: list[tuple[str | None, str]] = [] candidate: FrameType | None = None frame = sys._getframe() while True: frame_name = frame.f_globals.get("__name__") func_name = frame.f_code.co_name frame_chain.append((frame_name, func_name)) # The invoke() of a CliRunner, which tests drive a CLI with. The runner # of Click Extra subclasses the one of Click, so the walk goes on while # it meets one, to stop on the outermost. if ( runner_class is not None and func_name == "invoke" and isinstance(frame.f_locals.get("self"), runner_class) ): candidate = frame # The frame past the outermost invoke() of a runner. elif candidate is not None: return candidate # A frame outside the Click ecosystem, or an entry point: the frame in # which the user's CLI is implemented. The ones in between are # plumbing, like the frames `@cached_property` adds. elif not (frame_name and _is_plumbing(frame_name)): return frame if frame.f_back is None: break frame = frame.f_back # The heuristics failed: every frame belongs to the Click ecosystem, and # none is an entry point. Fall back to the outermost frame in the stack, the # last one the walk visited. count_size = len(str(len(frame_chain))) for counter, (p_name, f_name) in enumerate(frame_chain): logger.debug(f"Frame {counter:<{count_size}} # {p_name}:{f_name}") logger.debug( "cli_frame heuristics exhausted, falling back to outermost frame: " f"{frame.f_globals.get('__name__')}:{frame.f_code.co_name}" ) return frame
[docs] @staticmethod def command_module(ctx: click.Context) -> ModuleType | None: """Returns the module implementing the root command's callback. The stack walk in {meth}`cli_frame` can only find the CLI when the CLI's own module is on the stack. That holds for a normal invocation, and for a `CliRunner` driven from the test module that declares the command, but not when a runner invokes a command it merely *imported*: the walk then finds no user frame at all and stops on the runner's own `invoke()`, inside the Click ecosystem. The Sphinx `click:run` directive is that shape: without this, a documented `--version` would render the version of the ecosystem package owning the runner in place of the CLI's own. A launcher is the other shape. A console script or a compiled binary whose entry point is a command object, like `orchard.cli:cli`, leaves no frame of that module on the stack: the walk ends on the launcher itself. A command's callback names its defining module directly and needs no stack, so it settles the case the walk cannot see. Returns `None` when the command has no callback (a bare {class}`click.Group`) or its module is not imported, leaving the walk's own answer in place. """ callback = ctx.find_root().command.callback module_name = getattr(callback, "__module__", None) if not module_name: return None return sys.modules.get(module_name)
[docs] @cached_property def module(self) -> ModuleType: """Returns the module in which the CLI resides.""" frame = self.cli_frame() # The globals of a frame name its module, for the cost of a dictionary # lookup. `inspect.getmodule()` matches a frame by file name instead, and # scans every loaded module on its first call: it only gets asked about # code whose globals name no loaded module, like code run by `exec()`. name = frame.f_globals.get("__name__", "") module = sys.modules.get(name) or inspect.getmodule(frame) if not module: raise RuntimeError(f"Cannot find module of {frame!r}") # The walk can stop on a frame that only leads to the CLI. Defer then to # the module of the root command's callback, which needs no stack. # # A packageless `__main__` is such a frame when it launches a command # object of another module: a console script in a `bin/` folder, or the # runner Nuitka compiles for `--main-entry-point`. A standalone script # declares its command in that same `__main__`, and gets it back. # # Standard library code is such a frame too, as no CLI lives there. The # walk stops on it when a thread or an executor starts the command, like # `threading.Thread(target=cli.main)`. # # Ecosystem plumbing that no installed distribution provides is another # case: the walk ran out of user frames and stopped on the runner that # invoked an imported command. Both halves of that test matter. A runner # living in a real distribution (`click.testing`, `click_extra.testing`) # keeps its own module, which is what a CLI declared in the module driving # the runner relies on. Among runners, only one under a subpackage that # resolves to nothing (`click_extra.sphinx`, which the `click:run` # directive invokes through) reaches the fallback. # # Ecosystem plumbing with no caller is deferred whatever its distribution: # the walk ran out of frames and fell back to the outermost one. That is # the `main()` of a CLI started with no frame under it, like the target of # `_thread.start_new_thread()`, an `atexit` hook or a call from a C host. # # An entry point with a package is never deferred. The distribution test # cannot see it: a Nuitka binary ships no metadata at all, so # `distribution_of()` answers `None` for the CLI's own package too. # Click Extra's own binary resolves to `click_extra.__main__`, and trading # that for the callback's module costs it the {func}`is_main_module` # exemption `_package_dunder()` needs to read the pre-baked `__version__` # off the parent package. launcher = module.__name__ == "__main__" and not module.__package__ stdlib = module.__name__.partition(".")[0] in sys.stdlib_module_names plumbing = _is_plumbing(module.__name__) and ( frame.f_back is None or distribution_of(module.__package__) is None ) if launcher or stdlib or plumbing: ctx = click.get_current_context(silent=True) if ctx is not None: from_command = self.command_module(ctx) if from_command is not None: return from_command return module
[docs] @cached_property def module_name(self) -> str: """Returns the full module name or `__main__`.""" return self.module.__name__
[docs] @cached_property def module_file(self) -> str | None: """Returns the module's file full path. `None` for a module with no file, like the `__main__` of `python -c`. """ return getattr(self.module, "__file__", None)
[docs] @cached_property def module_version(self) -> str | None: """Returns the string found in the local `__version__` variable. ```{hint} `__version__` is a convention from early Python packaging. It is not a standard variable and is not defined in the packaging PEPs. Prefer the `package_version` property below, which uses the standard library `importlib.metadata` API. Click's own `version_option` reads the package metadata only ([pallets/click#2598](https://github.com/pallets/click/issues/2598)). Click Extra reads `__version__` too, for the scripts and codebases that set it. ``` """ # First, try to get __version__ from the detected module. version = getattr(self.module, "__version__", None) # If not found, read it off the globals of the command's callback, for a # command defined in another context (like Sphinx documentation blocks, or # standalone scripts). if version is None: ctx = get_current_context(silent=True) callback = ctx.command.callback if ctx is not None else None version = getattr(callback, "__globals__", {}).get("__version__") # If still not found, read it off the package, whose `__init__.py` holds # it for a CLI that sits in a sibling module. if version is None: version = self._package_dunder("__version__") if version is not None and not isinstance(version, str): raise ValueError( f"Module version {version!r} expected to be a string or None." ) return version
[docs] @cached_property def package_name(self) -> str | None: """Returns the package name.""" return self.module.__package__
def _package_dunder(self, name: str) -> Any: """Read *name* off the package of the CLI module, or `None`. `__version__` and the dunders `click-extra prebake all` writes live in the `__init__.py` of the package, while the CLI usually sits in a module beside it, like `__main__` or `cli`. The package is read only when the walk found the CLI's own module: an entry point {func}`is_main_module` recognizes, or any module outside `CLI_ECOSYSTEM_PACKAGES`. A walk that stops on the `invoke()` of a `CliRunner` lands in the ecosystem, and reading the package there would report Click Extra's own values for the CLI under test. """ if not self.package_name or _is_plumbing(self.module_name): return None parent = sys.modules.get(self.package_name) return getattr(parent, name, None) if parent else None @cached_property def _distribution_name(self) -> str | None: """{attr}`package_name`, an *import* name, resolved to the installed distribution name {mod}`importlib.metadata` looks up. See {func}`~click_extra.version_facts.distribution_of` for the resolution rules. """ return distribution_of(self.package_name)
[docs] @cached_property def package_version(self) -> str | None: """Returns the package version if installed. Resolved from the distribution name (see `_distribution_name`) via {func}`importlib.metadata.version`. Returns `None` if the package is not installed or cannot be resolved. """ # Deferred import: keeps importlib.metadata off the startup path of every CLI. from importlib import metadata name = self._distribution_name return metadata.version(name) if name else None
@cached_property def _package_metadata(self) -> PackageMetadata | None: """Returns the distribution's core metadata, or `None`. Reads the [core metadata](https://packaging.python.org/en/latest/specifications/core-metadata/) (`Author`, `License-Expression`, classifiers, ...) of the resolved distribution (see `_distribution_name`). Returns `None` when the package is not installed or cannot be resolved. """ # Deferred import: keeps importlib.metadata off the startup path of every CLI. from importlib import metadata name = self._distribution_name return metadata.metadata(name) if name else None
[docs] @cached_property def author(self) -> str | None: """Returns the package author(s) from its core metadata. See {func}`~click_extra.version_facts.resolve_author` for the fields read, in order. `None` if no author can be determined. """ return resolve_author(self._package_metadata)
[docs] @cached_property def license(self) -> str | None: """Returns the package license from its core metadata. See {func}`~click_extra.version_facts.resolve_license` for the fields read, in order. `None` if no license can be determined. """ return resolve_license(self._package_metadata)
[docs] @cached_property def exec_name(self) -> str: """User-friendly name of the executed CLI. Returns the module name. But if the module is a `__main__` entry point, returns the package name: `python -m` runs it as `__main__`, while a console script or a compiled binary imports it as `package.__main__`. If not packaged, the CLI is assumed to be a simple standalone script, and the returned name is the script's file name (including its extension). Code with no file, like a CLI run by `python -c`, keeps the `__main__` module name. """ # The CLI has its own module. if not is_main_module(self.module_name): return self.module_name # The CLI module is a `__main__` entry-point, so returns its package name. if self.package_name: return self.package_name # The CLI is not packaged: it is a standalone script. Fallback to its # filename. if self.module_file: return os.path.basename(self.module_file) return self.module_name
[docs] @cached_property def version(self) -> str | None: """Return the version of the CLI. Returns the module version if a `__version__` variable is set alongside the CLI in its module. Else returns the package version if the CLI is implemented in a package, using [importlib.metadata.version()](https://docs.python.org/3/library/importlib.metadata.html?highlight=metadata#distribution-versions). For development versions (containing `.dev`), automatically appends the Git short hash as a [PEP 440 local version identifier](https://peps.python.org/pep-0440/#local-version-identifiers), producing versions like `1.2.3.dev0+abc1234`. This helps identify the exact commit a dev build was produced from. When {attr}`git_short_hash` finds no commit, the plain dev version is returned. Versions that already contain a `+` (a pre-baked local version identifier, typically set at build time by CI pipelines) are returned as-is to avoid producing invalid double-suffixed versions like `1.2.3.dev0+abc1234+xyz5678`. """ ver = self.module_version or self.package_version if ver and ".dev" in ver and "+" not in ver: git_hash = self.git_short_hash if git_hash: return f"{ver}+{git_hash}" return ver or None
@property def _search_start(self) -> Path: """Where the walks for a Git repository and an archival file start. The directory of the CLI module, or the working directory for a module with no file, like the `__main__` of `python -c`. Read on each call rather than cached: the working directory is free to change between two. """ if self.module_file: return Path(self.module_file).parent return Path.cwd()
[docs] @cached_property def git_repo_path(self) -> Path | None: """Find the Git repository root directory. Walks up from `_search_start` to the first directory holding a `.git` entry: see {func}`~click_extra.version_facts.find_git_root`. """ return find_git_root(self._search_start)
def _get_prebaked(self, field_id: str) -> str | None: """Read a pre-baked `__<field_id>__` dunder of the CLI. Checks the CLI module, then its package, where `click-extra prebake all` writes the dunders: see {meth}`_package_dunder`. Returns the first non-empty string, otherwise `None`. """ name = dunder_name(field_id) for value in (getattr(self.module, name, None), self._package_dunder(name)): # `isinstance` first so mypy narrows `value` from `Any` to `str` # for the return; the `and value` keeps only non-empty strings. if isinstance(value, str) and value: return value return None @cached_property def _archival_data(self) -> dict[str, str]: """Parsed `.git_archival.json` for the CLI, or an empty mapping. Found by walking up from the CLI module's directory (falling back to the current working directory). Only populated inside an archive produced by `git archive` (including GitHub's source tarballs), where git substitutes the `$Format:…$` placeholders. A normal checkout holds the raw placeholders and yields nothing here, so live `git` calls take precedence and this is consulted only as a fallback. """ path = find_archival_file(self._search_start) return read_archival(path) if path else {} @cached_property def _direct_url_data(self) -> dict[str, Any]: """Parsed `direct_url.json` record of the CLI's distribution, or an empty mapping. Only a distribution installed from a URL carries one. A wheel that `uvx --from git+https://...` builds from a Git source ships no `.git` directory, no substituted `.git_archival.json`, and no pre-baked dunder unless the build ran `click-extra prebake`: this record is the one place left that names its commit. """ return read_direct_url(self._distribution_name) def _resolve_uniform_git_field(self, field_id: str) -> str | None: """Resolve a `git_*` field through the shared precedence. A pre-baked `__<field_id>__` dunder first, then the live value from {data}`~click_extra.version_facts.GIT_RESOLVERS` (run inside {attr}`git_repo_path`), then the `.git_archival.json` fallback, then the `direct_url.json` record of an install from a Git URL. Both fallbacks answer `None` for a field they do not carry. Valid for every field in {data}`~click_extra.version_facts.GIT_RESOLVERS` but {attr}`git_tag_sha`, whose live value derives from a tag that may itself be pre-baked. """ live = None if self.git_repo_path: live = GIT_RESOLVERS[field_id](self.git_repo_path) return ( self._get_prebaked(field_id) or live or archival_field(self._archival_data, field_id) or direct_url_field(self._direct_url_data, field_id) )
[docs] @cached_property def git_branch(self) -> str | None: """Returns the current Git branch name. Checks for a pre-baked `__git_branch__` dunder first, then `git rev-parse --abbrev-ref HEAD`, then `.git_archival.json`. """ return self._resolve_uniform_git_field("git_branch")
[docs] @cached_property def git_long_hash(self) -> str | None: """Returns the full Git commit hash. Checks for a pre-baked `__git_long_hash__` dunder first, then `git rev-parse HEAD`, then `.git_archival.json`, then the commit recorded for an install from a Git URL. """ return self._resolve_uniform_git_field("git_long_hash")
[docs] @cached_property def git_short_hash(self) -> str | None: """Returns the short Git commit hash. Checks for a pre-baked `__git_short_hash__` dunder first, then `git rev-parse --short HEAD`, then `.git_archival.json`, then the commit recorded for an install from a Git URL. Both fallbacks derive it from the first 7 characters of the full hash. ```{hint} The short hash is usually the first 7 characters of the full hash, but this is not guaranteed to be the case. But it is at least guaranteed to be unique within the repository, and a [minimum of 4 characters](https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreabbrev). ``` """ return self._resolve_uniform_git_field("git_short_hash")
[docs] @cached_property def git_date(self) -> str | None: """Returns the commit date in ISO format: `YYYY-MM-DD HH:MM:SS +ZZZZ`. Checks for a pre-baked `__git_date__` dunder first, then `git show -s --format=%ci HEAD`, then `.git_archival.json` (whose `node-date` is strict ISO 8601, like `2021-01-01T12:00:00+00:00`). """ return self._resolve_uniform_git_field("git_date")
[docs] @cached_property def git_tag(self) -> str | None: """Returns the Git tag pointing at HEAD, if any. Checks for a pre-baked `__git_tag__` dunder first, then `git describe --tags --exact-match HEAD`, then `.git_archival.json`. Returns `None` if HEAD is not at a tagged commit. """ return self._resolve_uniform_git_field("git_tag")
[docs] @cached_property def git_tag_sha(self) -> str | None: """Returns the commit SHA that the current tag points at. Checks for a pre-baked `__git_tag_sha__` dunder first, then `git rev-list -1` on the tag returned by {attr}`git_tag`, then `.git_archival.json`. Returns `None` if HEAD is not at a tag. """ prebaked = self._get_prebaked("git_tag_sha") if prebaked: return prebaked tag = self.git_tag if tag and self.git_repo_path: live = run_git("rev-list", "-1", tag, cwd=self.git_repo_path) if live: return live return archival_field(self._archival_data, "git_tag_sha")
[docs] @cached_property def git_distance(self) -> str | None: """Number of commits since the most recent tag, or `None`. Checks for a pre-baked `__git_distance__` dunder first, then parses `git describe --tags --long`, then falls back to `.git_archival.json`. `None` when no tag is reachable or Git is unavailable. """ return self._resolve_uniform_git_field("git_distance")
[docs] @cached_property def git_dirty(self) -> str | None: """Work-tree state: `"dirty"`, `"clean"` or `None`. Checks for a pre-baked `__git_dirty__` dunder first, then runs `git status --porcelain`. `None` when not in a Git repository or Git is unavailable. There is no `.git_archival.json` fallback: an archive has no work tree, so its state is unknowable. """ return self._resolve_uniform_git_field("git_dirty")
[docs] @cached_property def build_time(self) -> str | None: """When the distribution was built, as an RFC 3339 UTC timestamp. Reads the pre-baked `__build_time__` dunder, the only source of a build fact: see {data}`~click_extra.version_facts.BUILD_RESOLVERS` and {func}`~click_extra.version_facts.resolve_build_time`. """ return self._get_prebaked("build_time")
[docs] @cached_property def build_os(self) -> str | None: """The operating system the build ran on. Reads the pre-baked `__build_os__` dunder. See {func}`~click_extra.version_facts.resolve_build_os`. """ return self._get_prebaked("build_os")
[docs] @cached_property def build_target(self) -> str | None: """The platform a distribution built here installs on. Reads the pre-baked `__build_target__` dunder. See {func}`~click_extra.version_facts.resolve_build_target`. """ return self._get_prebaked("build_target")
[docs] @cached_property def build_target_arch(self) -> str | None: """The CPU architecture the build ran on. Reads the pre-baked `__build_target_arch__` dunder. See {func}`~click_extra.version_facts.resolve_build_target_arch`. """ return self._get_prebaked("build_target_arch")
@property def prog_name(self) -> str | None: """Return the name of the CLI, from Click's point of view. Get the [info_name](https://click.palletsprojects.com/en/stable/api/#click.Context.info_name) of the [root](https://click.palletsprojects.com/en/stable/api/#click.Context.find_root) command. ```{note} Unlike its siblings, this field is resolved on every access instead of being cached on the instance: it is the one template field whose value legitimately varies between invocations of the same option instance sharing a process. {mod}`~click_extra.multicall` dispatch relies on that, running one CLI under many names in sequence, and a `prog_name` passed to `main()` varies it without any multicall at all. A cached value would pin the first name seen forever. ``` """ if "_prog_name_override" in self.__dict__: override: str | None = self.__dict__["_prog_name_override"] return override return get_current_context().find_root().info_name @prog_name.setter def prog_name(self, value: str | None) -> None: """Pin a forced value, the way `fields={"prog_name": …}` does. Field overrides are applied with `setattr()`, and no instance `__dict__` entry can shadow a property: this setter takes the value instead. """ self.__dict__["_prog_name_override"] = value
[docs] def field_style(self, field_id: str | None = None) -> IStyle: """Style painting the *field_id* segment of a rendered message. A field carrying no style of its own falls back to `message_style`, and one left unset by the caller too renders bare. Call with no `field_id` for the style of the template's literal segments, which is `message_style` alone. """ style = self.styles.get(field_id) if field_id else None return style or self.message_style or unstyled
[docs] def colored_template(self, template: str | None = None) -> str: """Insert ANSI styles to a message template. Accepts a custom `template` as parameter, otherwise uses the default message defined on the Option instance. The template is linearized so the ANSI codes apply to its string segments: an ANSI directive cannot be nested within another, unlike a markup tag of HTML. """ if template is None: template = self.message default_style = self.field_style() # A copy of the template, where literal and field segments are colored. colored_template = "" # Literals accumulate until the next field, so a run of them gets a single # style span. Their braces are re-escaped: the copy is a template too. literal_accu = "" for literal, field_id, field in template_segments(template): literal_accu += literal.replace("{", "{{").replace("}", "}}") if field is None: continue if literal_accu: colored_template += default_style(literal_accu) literal_accu = "" colored_template += self.field_style(field_id)(field) if literal_accu: colored_template += default_style(literal_accu) return colored_template
[docs] def render_message(self, template: str | None = None) -> str: """Render the version string from the provided template. Accepts a custom `template` as parameter, otherwise uses the default `self.colored_template()` produced by the instance. A CLI carrying a {class}`~click_extra.version.VersionScreen` gets that drawn instead, whenever three conditions hold. Failing any one of them falls back to the plain template unchanged, which is a deliberate guarantee rather than a default: that form is the one every machine reader parses. - **Color reaches the output.** A flat-shaded mark carries its shape in its colors, and color is the one lever a caller already has. A redirected `--version`, or one run under `--no-color` or [`NO_COLOR`](https://no-color.org), is asking for something parseable. - **The terminal is wide enough** to seat the facts beside the mark without wrapping them. - **Accessible mode is off.** A mark read out character by character is noise to a screen reader, so `--accessible` keeps the plain message. """ if template is None and self.screen is not None: ctx = click.get_current_context(silent=True) accessible = bool(ctx is not None and get(ctx, ACCESSIBLE, False)) if not accessible and colors_reach_output(): screen = self.screen.render( str(self.prog_name or ""), str(self.version or ""), self.styles, ) if screen is not None: return screen if template is None: template = self.colored_template() # Only resolve fields that actually appear in the template, so unused # properties, the git calls above all, are never evaluated. used_fields = { field_id for _literal, field_id, _field in template_segments(template) if field_id is not None } return template.format(**{v: getattr(self, v) for v in used_fields})
[docs] def print_debug_message(self) -> None: """Render in debug logs all template fields in color.""" if not logger.isEnabledFor(logging.DEBUG): return # Double the braces: the label renders as a literal `{field_id}`, naming the # placeholder a template would write, rather than expanding it. labels = {field_id: f"{{{{{field_id}}}}}" for field_id in self.template_fields} max_len = max(map(len, labels.values())) logger.debug("Version string template variables:") for field_id, label in labels.items(): logger.debug( self.render_message( self.colored_template(f"{label:<{max_len}}: {{{field_id}}}") ) )
[docs] def print_and_exit( self, ctx: click.Context, param: click.Parameter, value: bool, ) -> None: """Print the version string and exits. Also stores all version string elements in the Context's `meta` `dict`. """ # Install a lazy dict so that version fields in ctx.meta are only # evaluated when actually accessed, sparing every invocation the git calls # and the metadata lookups. ctx._meta = _LazyMetaDict(ctx._meta, self, self.template_fields) # Eagerly resolve `module` now: cli_frame() relies on stack # inspection that only produces the correct result during the eager # callback. Once cached, all dependent properties (module_name, # package_name, etc.) will use this cached value regardless of when # they are accessed. self.module # noqa: B018 # Always log all template fields at DEBUG level, even if --version is # not called. This provides valuable execution context in bug reports. # The debug check inside the method ensures fields are only resolved # (and thus the lazy dict entries materialized) when DEBUG is active. self.print_debug_message() if not value or ctx.resilient_parsing: # Do not print the version and continue normal CLI execution. return echo(self.render_message(), color=ctx.color) ctx.exit()
[docs] def reset_version_resolution(command: Command) -> None: """Reset every {class}`VersionOption` in a command tree. Walks the tree once, cycle-safe, and calls {meth}`VersionOption.reset_resolution` on each option it finds. Written for a documentation build, which renders many commands in one process where a CLI renders one. See that method for what a stale resolution costs a published page. ```{note} The walk reads the subcommands each group has registered, rather than going through {func}`~click_extra.param_tools.iter_subcommand_contexts` like the renderers do. A lazy subcommand not imported yet holds no resolution to reset, and the context that function builds for each subcommand runs its `--version` callback, which resolves the very chain this function clears. ``` """ seen: set[int] = set() def walk(cmd: Command) -> None: if id(cmd) in seen: return seen.add(id(cmd)) for param in cmd.params: if isinstance(param, VersionOption): param.reset_resolution() for sub in getattr(cmd, "commands", {}).values(): walk(sub) walk(command)