Source code for click_extra.context

# 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.
"""Click context plumbing: the :class:`Context` subclass plus the central
registry of every ``ctx.meta`` key Click Extra writes or reads.

Click's :attr:`click.Context.meta` is a per-invocation dict that Click shares
across the parent/child context hierarchy. Click Extra uses it to pass
per-invocation state (the picked theme, the resolved table format, the loaded
configuration, etc.) between eager callbacks and the rest of the CLI without
mutating module-level globals. Per-invocation context storage is what keeps
back-to-back invocations of the same CLI (Sphinx builds, test runners, REPLs)
from leaking state into each other.

This module is part of Click Extra's **public API**. Inside any
``@command``- or ``@group``-decorated function, request the active context
with :func:`click.pass_context` (or call :func:`click.get_current_context`)
and read the entries you need:

.. code-block:: python

    from click_extra import command, context, echo, pass_context


    @command
    @pass_context
    def cli(ctx):
        echo(f"Theme: {ctx.meta[context.THEME]}")
        echo(f"Jobs:  {ctx.meta[context.JOBS]}")

Each constant below documents who writes the entry, when, and what shape the
value takes. The raw string values are stable and downstream code may also
read ``ctx.meta["click_extra.<field>"]`` directly: the constants exist so
internal call sites and downstream code can converge on a single spelling.
"""

from __future__ import annotations

import functools
import os
from typing import Any, ParamSpec, TypeVar, cast

import click
import cloup

from .color import resolve_color_env
from .highlight import HelpFormatter

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable
    from typing import Concatenate, Final

P = ParamSpec("P")
R = TypeVar("R")


POSIXLY_CORRECT_ENVVAR: Final[str] = "POSIXLY_CORRECT"
"""Environment variable that requests strict POSIX argument parsing.

When this variable is present in the environment (regardless of its value,
matching GNU ``getopt`` semantics), :class:`Context` forces
``allow_interspersed_args`` to ``False`` so option parsing stops at the first
positional argument.
"""


[docs] class Context(cloup.Context): """Like ``cloup._context.Context``, but with the ability to populate the context's ``meta`` property at instantiation. Also defaults ``color`` to ``True`` for root contexts (those without a parent), so help screens are always colorized, even when piped. Click's own default is ``None`` (auto-detect via TTY), which strips colors in non-interactive contexts. Parent-to-child color inheritance is handled by Click itself at ``Context.__init__`` time, so no property override is needed. When the ``POSIXLY_CORRECT`` environment variable is set, this context forces ``allow_interspersed_args`` to ``False`` so option parsing stops at the first positional argument, as GNU getopt-based tools do. See :data:`~click_extra.context.POSIXLY_CORRECT_ENVVAR`. .. todo:: Propose addition of ``meta`` keyword upstream to Click. """ formatter_class = HelpFormatter """Use our own formatter to colorize the help screen.""" def __init__(self, *args, meta: dict[str, Any] | None = None, **kwargs) -> None: """Like parent's context but with an extra ``meta`` keyword-argument. Also pre-seed ``color`` from the color environment variables for a parentless context when the user did not provide it, and force ``allow_interspersed_args`` to ``False`` when ``POSIXLY_CORRECT`` is set in the environment. """ super().__init__(*args, **kwargs) # Click defaults root ``ctx.color`` to ``None`` (GNU ``auto``: keep ANSI on a # TTY, strip it when piped). For a parentless context, pre-seed it from the # color environment variables so the eager help and version screens, which # can render before ``--color`` resolves, still honor ``FORCE_COLOR`` / # ``NO_COLOR``. With no recognized variable the value stays ``None`` (auto), # and the ``ColorOption`` callback later layers the command line, configuration # and ``--accessible`` on top. if not self.parent and self.color is None: self.color = resolve_color_env() # Honor the POSIX conformance switch: when POSIXLY_CORRECT is present in the # environment, stop parsing options at the first positional argument, the way # GNU getopt-based tools do. Presence alone is enough, regardless of value. # This can only tighten parsing (True -> False), never loosen it. if POSIXLY_CORRECT_ENVVAR in os.environ: self.allow_interspersed_args = False # Update the context's meta property with the one provided by user. if meta: self._meta.update(meta)
META_NAMESPACE: Final[str] = "click_extra." """Prefix shared by every ``ctx.meta`` key Click Extra writes. Reserved for entries the framework owns. Downstream consumers picking their own ``ctx.meta`` keys are encouraged to use a different prefix to avoid colliding with current or future Click Extra entries. """ class _LazyMetaDict(dict): """Dict subclass that lazily resolves fields on first access. Installed as ``ctx._meta`` so that ``ctx.meta["click_extra.<field>"]`` transparently evaluates the corresponding ``@cached_property`` on the source object only when the key is actually read. """ def __init__( self, base: dict[str, Any], source: object, fields: tuple[str, ...], ) -> None: super().__init__(base) self._source = source self._lazy_keys = {f"{META_NAMESPACE}{f}": f for f in fields} def _resolve(self, key: str) -> Any: """Resolve a lazy key, cache the result, and return it.""" value = getattr(self._source, self._lazy_keys[key]) # Store as a regular entry so subsequent reads are plain dict lookups. dict.__setitem__(self, key, value) return value def __getitem__(self, key: str) -> Any: if key in self._lazy_keys and not dict.__contains__(self, key): return self._resolve(key) return super().__getitem__(key) def __contains__(self, key: object) -> bool: return key in self._lazy_keys or super().__contains__(key) def get(self, key: str, default: Any = None) -> Any: if key in self._lazy_keys: if dict.__contains__(self, key): return super().__getitem__(key) return self._resolve(key) return super().get(key, default) # --- Argument capture --------------------------------------------------------- RAW_ARGS: Final[str] = "click_extra.raw_args" """The raw, pre-parsed ``argv`` slice fed to the current command. Written by :class:`click_extra.commands.Command.make_context` so that :class:`click_extra.parameters.ShowParamsOption` can re-parse the original arguments for the ``--show-params`` table without re-running the callbacks. Consumers normalize the parser's ``UNSET`` sentinel back to ``None`` on read, matching what ``click.Command.parse_args`` does for ``ctx.params``. """ # Developer note: why RAW_ARGS exists, and what to actually propose upstream. # # What --show-params needs is the *forward* resolution: for every parameter, the # value it resolves to and its provenance (ParameterSource), computable at an # arbitrary moment. Click does not expose this: # - The parsed command-line values (`opts`) are a local in # `click.Command.parse_args`, discarded when it returns. They are never # stored on the Context, despite the convenience of pretending otherwise. # - --show-params is eager, so its callback fires mid-processing, before the # non-eager parameters land in `ctx.params`. Reading `ctx.params` there would # see a partial picture. # # Workaround: Command/Group.make_context stashes a copy of the raw # argv under RAW_ARGS; parameters.render_params_table rebuilds the parser, # re-parses those args to recover `opts`, and calls `consume_value()` (not # `handle_parse_result()`) per parameter so eager callbacks are not re-fired. # The same re-parse backs `click-extra wrap --show-params`, which introspects a # foreign CLI that is never actually executed (no live parse to borrow from). # # Fragilities to keep in mind: # - The re-parse replays the parser but skips Click's post-parse cleanup, so it # drifts whenever that cleanup changes. Click 8.4 introduced the UNSET # sentinel (returned for parameters with no default); parse_args rewrites it # to None, the re-parse did not, and the sentinel leaked into the value and # default columns. It is now normalized back to None at the consumer sites # (render_params_table and format_param_row). # - It depends on `Command.make_parser()` handing back # `click.parser._OptionParser`, private since the Click 8.2 parser rework. # # Upstream proposal (revisit later: tracked in docs/upstream.md under # "Normalized arguments", linked to click#1279). Three different features get # muddled under the "raw_args" label: # 1. Preserve the raw input argv on the Context. Trivial; what we do here. # 2. Expose the parsed `opts`, or better, a per-parameter resolved # (value, ParameterSource), on the Context. Modest; this is what # --show-params actually consumes. # 3. Reconstruct a *normalized* argv from a Context (the inverse direction). # Hard and underdefined; this is click#1279. # click#1279 was filed for pip-tools and is feature 3: davidism scoped it with # ~a dozen normalization rules (option ordering, prompt/env inclusion, default # exclusion, ambiguous file paths) and flagged it underdefined; it has stalled # since 2023. click-extra needs feature 2, the easier forward direction, so the # right move is a new, narrowly-scoped issue/PR for a public Context accessor # returning a parameter's resolved (value, ParameterSource) after parsing # without re-firing eager callbacks, referencing click#1279 as related, not home. # # Note: "just reuse the `opts` lying around" does not work as stated. `opts` is # not on the Context; capturing it means reimplementing parse_args or wrapping # make_parser, it would still need the UNSET normalization above, and it would # not help the foreign-CLI wrap path, which has no live parse to capture from. # --- Configuration loading ---------------------------------------------------- CONF_SOURCE: Final[str] = "click_extra.conf_source" """Resolved path or URL of the configuration file that was loaded. Written by :class:`click_extra.config.option.ConfigOption.load_conf` after a configuration file is found and parsed. ``None`` if no file matched. """ CONF_FULL: Final[str] = "click_extra.conf_full" """Full parsed configuration document (the whole file, every section). Written by :class:`click_extra.config.option.ConfigOption.load_conf`. Read by :class:`click_extra.commands.Group` (for subcommand inheritance) and by :func:`click_extra.cli_wrapper.invoke_target` (to forward the loaded config to wrapped CLIs). """ TOOL_CONFIG: Final[str] = "click_extra.tool_config" """The app-specific config section, deserialized through ``config_schema``. Written by :class:`~click_extra.config.option.ConfigOption`'s ``_apply_config_schema`` method only when a schema callable is configured. Read via :func:`click_extra.config.schema.get_tool_config`. """ # --- Verbosity / logging ------------------------------------------------------ VERBOSITY_LEVEL: Final[str] = "click_extra.verbosity_level" """The reconciled :class:`~click_extra.logging.LogLevel` chosen for the run. Written by ``_VerbosityOption.apply_verbosity``, which reconciles every verbosity-related option (``--verbosity``, ``--verbose``/``-v`` and ``--quiet``/``-q``) into a single level. Read by the same method to detect whether the reconciled level changed since a sibling option last fired. """ VERBOSITY: Final[str] = "click_extra.verbosity" """Raw value of ``--verbosity LEVEL`` as the user passed it. Written by :meth:`click_extra.logging.VerbosityOption.set_level`. Stored alongside :data:`VERBOSITY_LEVEL` so downstream code can tell whether the final level came from ``--verbosity`` or from ``-v``/``-q`` repetitions. """ VERBOSE: Final[str] = "click_extra.verbose" """Raw repetition count of ``--verbose``/``-v``. Written by :meth:`click_extra.logging.VerboseOption.set_level`. Combined with :data:`QUIET` into the signed ``verbose - quiet`` counter that ``_VerbosityOption.resolve_level`` shifts the base level by. """ QUIET: Final[str] = "click_extra.quiet" """Raw repetition count of ``--quiet``/``-q``. Written by :meth:`click_extra.logging.QuietOption.set_level`. The quiet counterpart of :data:`VERBOSE`: each ``-q`` subtracts one step from the ``verbose - quiet`` net applied on top of the base verbosity level. """ # --- Timing ------------------------------------------------------------------- START_TIME: Final[str] = "click_extra.start_time" """``time.perf_counter()`` snapshot taken when ``--time`` is enabled. Written by :class:`click_extra.execution.TimerOption.init_timer`. """ # --- Parallelism -------------------------------------------------------------- JOBS: Final[str] = "click_extra.jobs" """Effective parallel job count after clamping (always >= 1). Written by :class:`click_extra.execution.JobsOption.validate_jobs`. Click Extra itself does not act on this value: it is a contract for downstream commands that drive their own concurrency. """ # --- Table rendering ---------------------------------------------------------- TABLE_FORMAT: Final[str] = "click_extra.table_format" """The :class:`~click_extra.table.TableFormat` chosen via ``--table-format``. Written by :class:`click_extra.table.TableFormatOption.init_formatter`. Read by :class:`click_extra.table.SortByOption` to thread the same format through ``ctx.print_table``. """ SORT_BY: Final[str] = "click_extra.sort_by" """Tuple of column IDs picked via ``--sort-by`` (in priority order). Written by :class:`click_extra.table.SortByOption.init_sort`. """ COLUMNS: Final[str] = "click_extra.columns" """Tuple of column IDs selected via ``--columns`` (in display order). Written by :class:`click_extra.table.ColumnsOption.init_columns`. Read by table-rendering consumers (like :class:`click_extra.parameters.ShowParamsOption`) to project and reorder columns before emitting the table. Empty / unset means no projection: render every column in its canonical order. """ # --- Theming ------------------------------------------------------------------ THEME: Final[str] = "click_extra.theme.active" """The :class:`~click_extra.theme.HelpTheme` active for this invocation. Written by :class:`click_extra.theme.ThemeOption.set_theme`. Read via :func:`click_extra.theme.get_current_theme`, which falls back to ``click_extra.theme.default_theme`` when no key is set. """ THEME_OVERRIDES: Final[str] = "click_extra.theme.overrides" """Per-invocation theme registry overlay loaded from the user's config file. Written by :class:`click_extra.config.option.ConfigOption` when it sees ``[tool.<cli>.themes.<name>]`` tables: each table is built into a :class:`~click_extra.theme.HelpTheme` (cascading on top of an existing theme when *name* matches one already in :data:`~click_extra.theme.theme_registry`). Read by :func:`click_extra.theme.get_theme_registry` so ``--theme`` can pick the new themes without leaking them into sibling invocations sharing the same process. """ # --- Telemetry ---------------------------------------------------------------- TELEMETRY: Final[str] = "click_extra.telemetry" """``True`` if the user opted into telemetry, ``False`` otherwise. Written by :class:`click_extra.telemetry.TelemetryOption.set_telemetry` after reconciling ``--telemetry`` / ``--no-telemetry`` with the standard ``DO_NOT_TRACK`` environment variable. Downstream code reads this to decide whether to emit usage data. """ # --- Progress ----------------------------------------------------------------- PROGRESS: Final[str] = "click_extra.progress" """``True`` when the CLI may display progress spinners, ``False`` otherwise. Written by :class:`click_extra.spinner.ProgressOption.set_progress` from the ``--progress`` / ``--no-progress`` flag (which ``--accessible`` lowers to ``False``). Downstream code reads it to decide whether to start a :class:`~click_extra.spinner.Spinner`. Deliberately independent of color: a spinner is an interactivity concern, so it is gated on the terminal (TTY / ``TERM=dumb``, handled by the spinner) and on explicit intent (``--no-progress`` / ``--accessible``), never on ``--no-color`` / ``NO_COLOR``. See :class:`~click_extra.spinner.ProgressOption` for the rationale. """ # --- Accessibility ------------------------------------------------------------ ACCESSIBLE: Final[str] = "click_extra.accessible" """``True`` when the user requested screen-reader-friendly output. Written by :meth:`~click_extra.AccessibleOption.set_accessible` after reconciling the ``--accessible`` flag with the ``ACCESSIBLE`` environment variable. Read by output helpers that must degrade a cursor-driven element to a linear stream: :func:`~click_extra.clear` becomes a no-op and :func:`~click_extra.echo_via_pager` writes its text straight to stdout instead of spawning a pager. This is the *readable* counterpart to the ``--color`` / ``--progress`` / ``--table-format`` defaults that ``--accessible`` also lowers: those are consumed through their own resolved values (``ctx.color``, :data:`PROGRESS`, the table format), while this flag exposes the accessibility intent itself. """ # --- Exit code ---------------------------------------------------------------- ZERO_EXIT: Final[str] = "click_extra.zero_exit" """``True`` when the user asked the CLI to always return a zero exit code. Written by :class:`click_extra.execution.ZeroExitOption.set_zero_exit`. Click Extra itself does not act on this value: it is a contract for downstream commands that suppress their non-zero "problems found" exit code and return ``0`` as long as the run itself succeeded. """ # --- Helpers ------------------------------------------------------------------
[docs] def pass_context(func: Callable[Concatenate[Context, P], R]) -> Callable[P, R]: """Mark a callback as wanting the active :class:`Context` as its first argument. Click's own :func:`click.pass_context` is typed for the base :class:`click.Context`. A handler annotated with click-extra's enhanced :class:`Context` (to reach its extra helpers like ``ctx.print_table``) therefore fails static type checking: function parameters are contravariant, so ``Callable[[Context], R]`` is not assignable where a ``Callable[[click.Context], R]`` is expected. This drop-in is typed for the enhanced :class:`Context` and still accepts handlers typed for the base ``click.Context`` (a wider first parameter is allowed), so both type-check. At runtime it forwards the active context unchanged, exactly like :func:`click.pass_context`. """ @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(cast(Context, click.get_current_context()), *args, **kwargs) return wrapper
[docs] def get(ctx: click.Context, key: str, default: Any = None) -> Any: """Read ``key`` from the current context's shared ``meta`` dict. Equivalent to ``ctx.meta.get(key, default)``. Click's ``meta`` is shared across the parent/child hierarchy, so reading from the local context is sufficient: there is no need to walk up to the root manually. """ return ctx.meta.get(key, default)
[docs] def set( ctx: click.Context, key: str, value: Any, ) -> None: """Write ``value`` under ``key`` in the current context's shared ``meta`` dict. Equivalent to ``ctx.meta[key] = value``. Provided as the symmetric writer for :func:`get` so that callers can route both sides of a ``meta`` access through this module. """ ctx.meta[key] = value