# 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.
"""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.
"""
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)
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
def unstyled(text: str) -> str:
"""Identity style, for a segment left with no color of its own."""
return text
@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)
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()
),
)
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)
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
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 + "}"
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,
)
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
)
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
@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
@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)
@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
@cached_property
def module_name(self) -> str:
"""Returns the full module name or `__main__`."""
return self.module.__name__
@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)
@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
@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)
@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
@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)
@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)
@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
@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()
@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)
)
@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")
@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")
@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")
@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")
@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")
@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")
@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")
@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")
@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")
@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")
@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")
@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
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
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
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})
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}}}")
)
)
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()
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)