# 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.
"""Sphinx rendering of CLI based on Click Extra.
Holds the `click:source` and `click:run` directives, their options, and the
`:screenshot:` captures and `:mirror:` regions they maintain.
{mod}`click_extra.sphinx.click_runner` runs their blocks, and
{mod}`click_extra.sphinx.click_reference` builds the `click:tree` and
`click:config` reference directives on top of them.
```{seealso}
These directives are based on [Pallets' Sphinx Themes](https://github.com/pallets/pallets-sphinx-themes/blob/main/src/pallets_sphinx_themes/themes/click/domain.py),
[released under a BSD-3-Clause license](https://github.com/pallets/pallets-sphinx-themes/blob/main/LICENSE.txt).
Compared to the latter, it:
- Adds support for MyST syntax.
- Adds rendering of ANSI codes in CLI results.
- Has better error handling and reporting which helps you pinpoint the failing
code in your documentation.
- Has no `println` function: a block prints a blank line natively.
```
```{todo}
Collapse `ContentOffsetMixin.abs_content_offset` of `click_extra.sphinx._base`
onto its rST branch (`content_offset` verbatim) once a `myst-parser` release
carries the open rework
[`#1175`](https://github.com/executablebooks/MyST-Parser/pull/1175), which
makes `content_offset` document-relative.
```
"""
from __future__ import annotations
import re
from collections.abc import Sequence
from functools import cached_property
from pathlib import Path
from docutils import nodes
from sphinx.directives import directives
from sphinx.directives.code import CodeBlock
from sphinx.util import parselinenos
from ..capture.presets import (
OPAQUE,
PRESETS,
Chrome,
Cursor,
CursorShape,
TerminalPreset,
)
from ..capture.recording import (
DEFAULT_QUANTUM,
DEFAULT_RECORDING_BLANK,
DEFAULT_RECORDING_HOLD,
DEFAULT_SUBMIT,
Frame,
quantize,
type_line,
)
from ..capture.screenshot import (
AUTO_CURSOR,
DEFAULT_COLUMNS,
append_prompt,
parse_columns,
prompt_line,
render,
)
from ..capture.snippet import highlight_code, resolve_style, style_palette
from ..capture.svg import animation_metadata, parse_hold, parse_pause
from ..color import TerminalBackground
from ..layout import number_frames, number_lines
from ..markdown.blocks import (
region_markers,
rewrite_fenced_regions,
split_options,
update_blocks,
)
from ..spinner import Spinner
from ._base import ContentOffsetMixin, make_cleanup, parse_into_section
from .click_runner import PROMPT_SIGIL, ClickRunner, _resolve_run_capture
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable
from typing import Any, ClassVar
from sphinx.application import Sphinx
from sphinx.util.typing import OptionSpec
from ..capture.presets import TerminalPalette
from ..capture.screenshot import TColumns
from ..capture.svg import THold
RST_INDENT = " " * 3
"""The indentation used for rST code blocks lines."""
DEFAULT_SCREENSHOT_DIR = "assets"
"""Directory a `click:run` `:screenshot:` capture is written to by default.
Relative to the documentation source root. Overridden by the
`click_extra_screenshot_dir` `conf.py` value, see {data}`SCREENSHOT_DIR_CONFIG`.
"""
RUN_CAPTURE_CONFIG = "click_extra_run_capture"
"""Name of the `conf.py` value selecting the stream-capture mode for the CLIs that
`click:run` and `click:tree` execute.
Maps to the `capture` parameter of Click's {class}`~click.testing.CliRunner`,
`"sys"` or `"fd"`. Defaults to `"fd"`, which also captures what a subprocess or a
C extension writes to the standard file descriptors: see
{class}`~click_extra.sphinx.click_runner.ClickRunner` for what it leaves out.
Windows has no file-descriptor capture, so `"fd"` degrades to `"sys"` there.
"""
SCREENSHOT_DIR_CONFIG = "click_extra_screenshot_dir"
"""Name of the `conf.py` value locating the directory `click:run` writes captures to.
A path relative to the documentation source directory, holding the SVG a
`click:run` block names with its `:screenshot:` option. Defaults to
{data}`DEFAULT_SCREENSHOT_DIR`, matching where a Sphinx project conventionally
keeps the images its pages embed, and where a README pointing at the repository
finds them.
"""
SCREENSHOT_PRESET_CONFIG = "click_extra_screenshot_preset"
"""Name of the `conf.py` value naming the terminal every capture is drawn as.
One of {data}`~click_extra.capture.presets.PRESETS`, applied to each
`click:run` block whose `:screenshot:` does not name a preset of its own. Empty
by default, which keeps the renderer's neutral window: a project wanting all of
its captures to look like the same desktop states it once here instead of on
every block.
"""
SCREENSHOT_SYNTAX_STYLE_CONFIG = "click_extra_screenshot_syntax_style"
"""Name of the `conf.py` value coloring every capture drawn from source code.
One of the [Pygments styles](https://pygments.org/styles/), applied to each
source block whose `:screenshot:` does not name a
`:screenshot-syntax-style:` of its own. Empty by default, which takes the style
each chrome is drawn for, see
{data}`~click_extra.capture.snippet.DEFAULT_SYNTAX_STYLES`. Unused by a block picturing
what a command printed, whose colors that command already chose.
"""
SCREENSHOT_WATERMARK_CONFIG = "click_extra_screenshot_watermark"
"""Name of the `conf.py` value crediting every capture a `click:run` writes.
Empty by default, which draws no credit line: unlike a capture of the
`screenshot` command, a block's image sits on a page that already says what drew
it. A project wanting a credit states its text here, or per block with
`:screenshot-watermark:`.
"""
SCREENSHOT_MARKER_START = region_markers("screenshot")[0]
"""Opening marker of a `click:run` `:mirror:` region.
Written on its own line, directly below the fence, and paired with
{data}`SCREENSHOT_MARKER_END`. The region holds the Markdown link to the capture
the block's `:screenshot:` option names, so the image shows wherever the raw
Markdown is read: on GitHub, on PyPI, in an editor's preview.
Same `<!-- name --> / <!-- name-end -->` grammar as the `python:render`
`:mirror:` regions, under a name saying what this one holds. Unlike those, the
region's content is derived from the option alone, never from executing
anything: it goes stale only when the capture is renamed, which is why no
build-time pass regenerates it in memory.
"""
SCREENSHOT_MARKER_END = region_markers("screenshot")[1]
"""Closing marker of a `:mirror:` region. See {data}`SCREENSHOT_MARKER_START`."""
_CAPTURE_FENCE_OPEN_RE = re.compile(
r"^[ \t]*`{3,}\{(?:click:(?:run|source)|python:(?:run|source))\}[ \t]*\S*[ \t]*$"
)
"""Match the opening line of a MyST fence whose `:mirror:` shows a capture.
`:mirror:` writes Markdown back into a Markdown host, so it is scoped to the
MyST fence form: an rST directive has no Markdown region to hold.
`python:render` is deliberately absent, being the one directive whose
`:mirror:` already means something else: it mirrors the *markup* the block
generated, and {func}`~click_extra.sphinx.python.update_mirror_blocks` owns
that region. Two refreshers writing one region would each undo the other on
alternate runs.
"""
[docs]
def register_config(app: Sphinx) -> None:
"""Declare the `conf.py` values the `click:*` directives read.
Called by {func}`click_extra.sphinx.setup` beside the opt-in flag gating the
directives, and whether or not the project opts in: a `conf.py` naming one of
these values is then valid on every project loading the extension.
"""
# Stream-capture mode for the CLIs click:run and click:tree execute, see
# ClickRunner.
app.add_config_value(RUN_CAPTURE_CONFIG, "fd", "env", types=[str])
# Where a `click:run` `:screenshot:` capture is written.
app.add_config_value(
SCREENSHOT_DIR_CONFIG, DEFAULT_SCREENSHOT_DIR, "env", types=[str]
)
# Terminal every capture is drawn as, unless a block says otherwise.
app.add_config_value(SCREENSHOT_PRESET_CONFIG, "", "env", types=[str])
# Pygments style every source capture is colored with, see snippet.py.
app.add_config_value(SCREENSHOT_SYNTAX_STYLE_CONFIG, "", "env", types=[str])
# Credit line every capture carries, off unless a project asks for one.
app.add_config_value(SCREENSHOT_WATERMARK_CONFIG, "", "env", types=[str])
def _screenshot_opacity(argument: str) -> float:
"""Read the `:screenshot-opacity:` option into how solid the window is."""
opacity = float(argument)
if not 0.0 <= opacity <= OPAQUE:
raise ValueError(f"{argument} is not an opacity, between 0 and {OPAQUE}.")
return opacity
def _screenshot_cursor(argument: str | None) -> CursorShape | None:
"""Read the `:screenshot-cursor:` option into a shape, or into the preset's.
A bare `:screenshot-cursor:` reaches here as `None`, which docutils passes
for an option written without a value. That, an empty one, and
{data}`~click_extra.capture.screenshot.AUTO_CURSOR` all leave the shape to the
terminal preset the block is drawn as.
"""
shape = (argument or "").strip().lower()
if not shape or shape == AUTO_CURSOR:
return None
try:
return CursorShape(shape)
except ValueError:
known = ", ".join(member.value for member in CursorShape)
raise ValueError(
f"{argument} is not a cursor shape: name one of {known}, or {AUTO_CURSOR}."
) from None
def _positive_number(argument: str) -> float:
"""Read an option that only a number above zero makes sense for.
A frame duration, a typing pace, a replay speed and a rounding grid all take
one.
"""
number = float(argument)
if number <= 0:
raise ValueError(f"{argument} is not a positive number.")
return number
def _screenshot_preset(argument: str) -> TerminalPreset:
"""Read the `:screenshot-preset:` option into the terminal it names."""
return PRESETS[directives.choice(argument, tuple(sorted(PRESETS)))]
def _screenshot_background(argument: str) -> TerminalBackground:
"""Read the `:screenshot-background:` option into its enum member.
Rejecting an unknown chrome here, at the option level, is what turns a typo
into a build error naming the choices, instead of a capture silently drawn
on the default.
"""
choices = tuple(member.value for member in TerminalBackground)
return TerminalBackground(directives.choice(argument, choices))
def _code_fence(lines: Iterable[str]) -> str:
"""Return a backtick fence longer than any backtick run opening one of `lines`.
A MyST fence closes on the first bare backtick run at least as long as its
opener. A fixed three-backtick fence therefore ends early around content
holding a fence of its own, like a command's Markdown help. The fence is
never shorter than three backticks.
"""
longest = max(
(len(line) - len(line.lstrip("`")) for line in map(str.lstrip, lines)),
default=0,
)
return "`" * max(3, longest + 1)
def _write_capture(path: Path, svg: str) -> None:
"""Write `svg` to `path`, unless the file holds it already.
The rule of {func}`click_extra.markdown.blocks.rewrite_file`, for a file that
may not exist yet. Writing the same bytes again would still move the
modification time of the file, which a tool watching the documentation tree
reads as a change.
"""
if path.exists() and path.read_text(encoding="UTF-8") == svg:
return
path.write_text(svg, encoding="UTF-8")
[docs]
class ClickDirective(ContentOffsetMixin):
"""Base class of every `click:*` directive."""
has_content = True
required_arguments = 0
optional_arguments = 1
"""The optional argument overrides the default Pygments language to use."""
final_argument_whitespace = False
option_spec: ClassVar[OptionSpec] = CodeBlock.option_spec | {
"language": directives.unchanged_required,
"show-source": directives.flag,
"hide-source": directives.flag,
"show-results": directives.flag,
"hide-results": directives.flag,
"show-prompt": directives.flag,
"hide-prompt": directives.flag,
"emphasize-result-lines": CodeBlock.option_spec["emphasize-lines"],
"mirror": directives.flag,
"screenshot": directives.unchanged_required,
"screenshot-animate": directives.unchanged_required,
"screenshot-backdrop": directives.unchanged_required,
"screenshot-background": _screenshot_background,
"screenshot-blank": parse_pause,
"screenshot-blink": parse_pause,
"screenshot-border": directives.unchanged_required,
"screenshot-border-width": directives.nonnegative_int,
"screenshot-closing-prompt": directives.flag,
"screenshot-columns": parse_columns,
"screenshot-cursor": _screenshot_cursor,
"screenshot-emphasize-lines": directives.unchanged_required,
"screenshot-hold": parse_hold,
"screenshot-interval": _positive_number,
"screenshot-line-numbers": directives.flag,
"screenshot-margin": directives.nonnegative_int,
"screenshot-opacity": _screenshot_opacity,
"screenshot-padding": directives.nonnegative_int,
"screenshot-preset": _screenshot_preset,
"screenshot-prompt": directives.unchanged,
"screenshot-quantum": _positive_number,
"screenshot-radius": directives.nonnegative_int,
"screenshot-record": directives.unchanged_required,
"screenshot-shadow": directives.unchanged_required,
"screenshot-speed": _positive_number,
"screenshot-submit": _positive_number,
"screenshot-syntax-style": directives.unchanged_required,
"screenshot-title": directives.unchanged_required,
"screenshot-typing": _positive_number,
"screenshot-watermark": directives.unchanged_required,
"screenshot-watermark-color": directives.unchanged_required,
}
"""Options supported by this directive.
Support the [same options](https://github.com/sphinx-doc/sphinx/blob/cc7c6f4/sphinx/directives/code.py#L108-L117)
as `sphinx.directives.code.CodeBlock`, and some specific to Click
directives.
The standard `emphasize-lines` option applies to the source block only. Use
`emphasize-result-lines` to highlight specific lines in the captured output
block, with the same syntax (like `:emphasize-result-lines: 1,3-5`).
Every directive carries the `:screenshot:` family, because every one of them
shows something worth committing as an image. What lands in that image is
whatever the block itself draws: a run's captured output, and a source
block's own code, highlighted by {mod}`click_extra.capture.snippet`.
`:screenshot:` and `:mirror:` are deliberately independent. `:screenshot:
<name>` only *writes* `<name>.svg` under the `click_extra_screenshot_dir`,
leaving the page's code block alone: inside Sphinx that block beats an
image, being selectable, searchable and theme-aware. `:mirror:` is what puts
the image on the page, by keeping a Markdown link to it in the source `.md`
between the same marker pair the `python:render` `:mirror:` flag uses, so
the capture shows on GitHub and PyPI as well.
So `:screenshot:` alone maintains an asset some other surface embeds, and
the two together also show it here. Both are refreshed offline by
`click-extra refresh-directives`.
"""
default_language: str
"""Default highlighting language to use to render the code block.
[All Pygments' languages short names](https://pygments.org/languages/) are
recognized.
"""
source_language: str | None = None
"""Language the source block is highlighted in, when it is not `language`.
`None` for a directive showing code of the language it names: the source
block of a `click:source` follows its `:language:` option. A directive that
*runs* a block names the language of its results instead, so it states the
language of the code producing them here, `python` for every runner of this
package.
"""
screenshots_source: bool = False
"""Whether a `:screenshot:` on this directive pictures the block's own code.
`False` for a directive that runs something: what is worth committing there
is the output, and the code producing it is on the page already. `True` for
one that only declares code, which has no output to picture and whose
subject is the code itself. See
{meth}`~click_extra.sphinx.click.ClickDirective.screenshot_lines`.
"""
show_source_by_default: bool = True
"""Whether to render the source code of the example in the code block."""
show_results_by_default: bool = True
"""Whether to render the results of the example in the code block."""
show_prompt_by_default: bool = True
"""Whether to draw the invocation above the results it produced."""
runner_method: str
"""The name of the method to call on the per-document runner.
A {class}`~click_extra.sphinx.click_runner.ClickRunner` for the `click:*`
directives.
"""
runner_attr: ClassVar[str] = "click_runner"
"""Name of the attribute holding the runner on the doctree.
Subclasses (like `PythonDirective`)
override this so the Click and Python runners don't collide on the same
document.
"""
runner_factory: ClassVar[type] = ClickRunner
"""Class to instantiate for the per-document runner.
Called with the `capture` mode the `click_extra_run_capture` `conf.py` value
resolves to, see {attr}`runner`.
"""
@property
def runner(self):
"""Get or create the per-document runner.
Creates one runner per document, keyed by {attr}`runner_attr`.
"""
runner = getattr(self.state.document, self.runner_attr, None)
if runner is None:
runner = self.runner_factory(
capture=_resolve_run_capture(
getattr(self.env.config, RUN_CAPTURE_CONFIG),
),
)
setattr(self.state.document, self.runner_attr, runner)
return runner
[docs]
@cached_property
def language(self) -> str:
"""Short name of the Pygments lexer used to highlight the code block.
Returns, in order of precedence, the language specified in the `:language:`
directive options, the first argument of the directive (if any), or the default
set in the directive class.
"""
if "language" in self.options:
return self.options["language"] # type: ignore[no-any-return]
if self.arguments:
return str(self.arguments[0])
return self.default_language
[docs]
def code_block_options(self, target: str = "source") -> list[str]:
"""Render the options supported by Sphinx' native `code-block` directive.
`target` selects which block these options will be attached to:
`"source"` for the directive's input source code, `"results"` for the
captured output. `emphasize-lines` routes to the source block;
`emphasize-result-lines` is rewritten as `emphasize-lines` on the
results block, so authors can highlight different lines in each.
"""
options = []
for option_id in CodeBlock.option_spec:
if option_id == "emphasize-lines":
if target == "source" and "emphasize-lines" in self.options:
options.append(f":emphasize-lines: {self.options[option_id]}")
elif target == "results" and "emphasize-result-lines" in self.options:
options.append(
f":emphasize-lines: {self.options['emphasize-result-lines']}"
)
continue
if option_id in self.options:
value = self.options[option_id]
line = f":{option_id}:"
if value:
line += f" {value}"
options.append(line)
return options
def _toggle(self, subject: str, default: bool) -> bool:
"""Resolve a `show-<subject>` / `hide-<subject>` option pair.
The last one given wins, in the order the options were written. With
neither, `default` answers.
"""
value = default
for option_id in self.options:
if option_id == f"show-{subject}":
value = True
elif option_id == f"hide-{subject}":
value = False
return value
[docs]
@cached_property
def show_source(self) -> bool:
"""Whether to show the source code of the example in the code block.
The last occurrence of either `show-source` or `hide-source` options
wins. If neither is set, the default is taken from `show_source_by_default`.
"""
return self._toggle("source", self.show_source_by_default)
[docs]
@cached_property
def show_results(self) -> bool:
"""Whether to show the results of running the example in the code block.
The last occurrence of either `show-results` or `hide-results` options
wins. If neither is set, the default is taken from `show_results_by_default`.
"""
return self._toggle("results", self.show_results_by_default)
[docs]
@cached_property
def show_prompt(self) -> bool:
"""Whether to draw the invocation above the output it produced.
The last occurrence of either `show-prompt` or `hide-prompt` options
wins. If neither is set, the default is taken from
`show_prompt_by_default`.
The prompt is one line rendered by
{func}`~click_extra.execution.format_cli_prompt`, prepended to the
captured output. It is therefore part of the results, not a block of its
own: hiding it drops it from the results code block *and* from the SVG a
`:screenshot:` writes, which is drawn from the same lines. Reach for it
when the invocation is noise the surrounding prose already carries, or
when a capture is wanted as bare output.
"""
return self._toggle("prompt", self.show_prompt_by_default)
[docs]
def evaluate(self, expression: str, subject: str) -> Any:
"""Evaluate `expression` in the runner namespace of the document.
The namespace is the one the earlier blocks of the page filled, so an
expression names what a `click:source` above built. `subject` opens the
error, naming what asked for the evaluation: a directive, like
`click:tree`, or one of its options, like `click:run: :screenshot-animate:`.
:raises RuntimeError: when the expression cannot be evaluated.
"""
try:
return eval(expression, self.runner.namespace)
except Exception as exc:
raise RuntimeError(
f"{subject}: failed to evaluate {expression!r}: {exc}",
) from exc
[docs]
def render_code_block(
self,
lines: Iterable[str],
language: str,
target: str = "source",
) -> list[str]:
"""Render the code block with the source code or results.
`target` is forwarded to {meth}`code_block_options` so the
`emphasize-lines` / `emphasize-result-lines` split routes the right
highlighting to each block.
"""
block: list[str] = []
lines = list(lines)
if not lines:
return block
# Initiate the code block with its MyST or rST syntax. The MyST fence
# outgrows any fence the lines hold, which would otherwise close it.
fence = _code_fence(lines)
code_directive = (
f"{fence}{{code-block}}" if self.is_myst_syntax else ".. code-block::"
)
block.append(f"{code_directive} {language}")
# Re-attach each option to the code block. An rST block indents its
# options and body under the directive.
indent = "" if self.is_myst_syntax else RST_INDENT
block.extend(indent + line for line in self.code_block_options(target))
# Both rST and MyST need a blank line before the body of the block else the
# first line will be interpreted as a directive option or argument.
block.append("")
block.extend(indent + line for line in lines)
# In MyST, we need to close the code block.
if self.is_myst_syntax:
block.append(fence)
return block
[docs]
@cached_property
def screenshot(self) -> str | None:
"""Name of the SVG capture this block renders to, without its extension.
Set by the `:screenshot:` option. `None` when the block renders its
results as a code block, which is the default.
"""
return self.options.get("screenshot") # type: ignore[no-any-return]
[docs]
@cached_property
def screenshot_background(self) -> TerminalBackground:
"""Chrome the capture is drawn on, set by `:screenshot-background:`.
Defaults to the dark chrome a terminal and this package's default theme
both look like. A block rendering a light-background theme says so, or
its capture washes out: see
{class}`~click_extra.color.TerminalBackground`.
"""
return self.options.get( # type: ignore[no-any-return]
"screenshot-background", TerminalBackground.DARK
)
[docs]
@cached_property
def screenshot_columns(self) -> TColumns:
"""Width the capture is laid out at, set by `:screenshot-columns:`.
Defaults to the fixed width a terminal capture is taken at. `auto` sizes
the image to the longest line the block printed, which is what a run
holding a line Click does not wrap needs to keep on one line: a prompt,
a wide table, a machine-readable dump.
"""
return self.options.get( # type: ignore[no-any-return]
"screenshot-columns", DEFAULT_COLUMNS
)
[docs]
def resolve_animation(
self,
expression: str,
option: str,
) -> tuple[tuple[str, ...], float | tuple[float, ...]]:
"""Frames the capture animates, set by `:screenshot-animate:`.
The option is a Python expression, evaluated in the same per-document
namespace the block's own source runs in, so a `click:source` block
above can build the subject and this one picture it. It yields either:
- a {class}`~click_extra.spinner.Spinner`, whose
{meth}`~click_extra.spinner.Spinner.frame_lines` and `interval` are
taken as they stand, which is the whole declaration for a spinner;
- a sequence of {class}`~click_extra.capture.recording.Frame`, as a recording
hands back, each carrying the screen it held and for how long. Those
durations are wall-clock, so they are rounded onto a grid before they
reach the picture, see `:screenshot-quantum:`;
- any other sequence of strings, one captured text per frame, whose
`:screenshot-interval:` says how long each is shown.
`:screenshot-interval:` overrides a spinner's own when both are stated,
and times a recording flatly when it would rather not keep the pace it
was recorded at.
```{caution}
An animated block pictures its *frames*, not its results. The block
still runs, and its results still render on the page as any other
block's do, but they are not what the image shows.
```
A declared subject keeps the committed asset deterministic outright: the
same expression composes the same lines on every build. A recording
cannot, its frames being timed by the wall clock, so it is written once:
{meth}`~click_extra.sphinx.click.ClickDirective.write_screenshot` leaves
an asset that carries an `@recording` line alone.
:return: the frames and how long each is shown, or `None` when the block
draws a still.
:raises RuntimeError: when the expression cannot be evaluated.
:raises TypeError: when it yields neither a spinner nor strings.
:raises ValueError: when a bare sequence of frames states no interval.
"""
interval = self.options.get("screenshot-interval")
subject = self.evaluate(expression, f"click:run: {option}")
if isinstance(subject, Spinner):
return (subject.frame_lines(), interval or subject.interval)
if isinstance(subject, str) or not isinstance(subject, Sequence):
raise TypeError(
f"click:run: {option}: {expression!r} yielded neither "
f"a Spinner nor a sequence of frames (got "
f"{type(subject).__name__}).",
)
recorded = tuple(subject)
if not recorded:
raise ValueError(
f"click:run: {option}: {expression!r} yielded no frame to draw.",
)
if all(isinstance(frame, Frame) for frame in recorded):
# Rounded here rather than left to the page, so forgetting to do it
# cannot be what dirties the working tree on the next build.
timed = quantize(
recorded,
self.options.get("screenshot-quantum", DEFAULT_QUANTUM),
)
texts = tuple(frame.text for frame in timed)
if interval is not None:
return (texts, interval)
return (texts, tuple(frame.duration for frame in timed))
if not all(isinstance(frame, str) for frame in recorded):
raise TypeError(
f"click:run: {option}: {expression!r} yielded a "
"sequence holding neither a frame's text nor a recorded Frame.",
)
if interval is None:
raise ValueError(
f"click:run: {option}: needs a :screenshot-interval: "
"when it does not name a spinner or a recording carrying one.",
)
return (recorded, interval)
[docs]
@cached_property
def screenshot_animation(
self,
) -> tuple[tuple[str, ...], float | tuple[float, ...]] | None:
"""Frames a *declared* animation draws, set by `:screenshot-animate:`.
Declared frames are composed rather than timed, so they come out the
same on every build and the asset is regenerated like any other, which
is what keeps it from drifting away from the code. See
{meth}`resolve_animation` for what the expression may yield, and
`:screenshot-record:` for the case that cannot be regenerated.
:return: the frames and how long each is shown, or `None` when the block
draws a still.
"""
expression = self.options.get("screenshot-animate")
if expression is None:
return None
return self.resolve_animation(expression, ":screenshot-animate:")
@property
def emphasis_spec(self) -> str | None:
"""The lines the capture bands, as the block spells them.
Only `:screenshot-emphasize-lines:` on a directive whose image is its
*output*: the block's three emphasis options then mark three different
contents, and folding any two together would band the wrong one.
A block picturing its own code has one content, so `:emphasize-lines:`
marks the same lines in the page's code block and in the image, and
saying it twice would be the surprise. `:screenshot-emphasize-lines:`
still overrides it where the two should differ.
"""
spec = self.options.get("screenshot-emphasize-lines")
if spec or not self.screenshots_source:
return spec # type: ignore[no-any-return]
return self.options.get("emphasize-lines") # type: ignore[no-any-return]
[docs]
def screenshot_emphasis(self, total: int) -> tuple[int, ...]:
"""Lines the capture bands, set by `:screenshot-emphasize-lines:`.
Takes the same specification `:emphasize-lines:` does, `1,3-5` and the
rest, and counts from one the same way. It is read here rather than at
option-parsing time because the specification may name a range open at
one end, which only means something once the capture's height is known.
:param total: how many lines the capture is drawing.
:return: the lines to band, counted from one.
:raises ValueError: when the specification names no readable line.
"""
spec = self.emphasis_spec
if not spec:
return ()
try:
wanted = parselinenos(spec, total)
except ValueError as exc:
raise ValueError(
f"click:run: :screenshot-emphasize-lines: {spec!r}: {exc}",
) from exc
return tuple(index + 1 for index in wanted)
[docs]
def numbered(self, frames: tuple[str, ...]) -> tuple[str, ...]:
"""Number every frame's lines when `:screenshot-line-numbers:` asks.
Counted within each frame rather than once across the animation: a frame
is a screen, and a screen's gutter numbers the rows it is showing. An
animation whose rows accumulate therefore numbers more of them as it
goes. The gutter is as wide on every frame as on the tallest, so no row
moves when the count gains a digit, see
{func}`~click_extra.layout.number_frames`.
:param frames: the animation's frames.
:return: the same frames, each with a gutter, or unchanged.
"""
if "screenshot-line-numbers" not in self.options:
return frames
return number_frames(frames)
[docs]
def screenshot_hold(self, fallback: THold) -> THold:
"""Seconds the last frame stays up, set by `:screenshot-hold:`.
:param fallback: what to hold for when the block states nothing.
:return: the pause, in seconds, or the `auto` sentinel.
"""
return self.options.get( # type: ignore[no-any-return]
"screenshot-hold", fallback
)
[docs]
@cached_property
def screenshot_syntax_style(self) -> str:
"""Pygments style a source capture is colored with.
Set by `:screenshot-syntax-style:`, falling back to the
`click_extra_screenshot_syntax_style` `conf.py` value, then to the one
the chrome is drawn for. Unused by a directive picturing its output,
whose colors the command it ran already chose.
:return: the style's name.
:raises ValueError: when the name is not a style Pygments knows.
"""
return resolve_style(
self.options.get("screenshot-syntax-style")
or getattr(self.env.config, SCREENSHOT_SYNTAX_STYLE_CONFIG)
or None,
self.screenshot_background,
)
[docs]
@cached_property
def screenshot_palette(self) -> TerminalPalette | None:
"""Colors the capture resolves against, beyond what the chrome names.
`None` for a capture of a terminal, which is every run: its colors are
the terminal's own, and the chrome already answers for them. A capture
of *code* takes them from its syntax style instead, background included,
so the picture looks like that style does in an editor.
"""
if not self.screenshots_source:
return None
return style_palette(
self.screenshot_syntax_style,
self.screenshot_background,
preset=self.screenshot_frame.get("preset"),
)
[docs]
def screenshot_lines(self, results: Iterable[str]) -> list[str]:
"""The lines the still capture draws, before its gutter is added.
The captured output, with one substitution: a block is run by a
documentation build rather than by the terminal it pictures, so it
prompts with this platform's sigil where the drawn terminal has its own.
A block picturing its own code draws that instead, colored by
{func}`~click_extra.capture.snippet.highlight_code` into the same ANSI a command
would have printed. It has no prompt to substitute, and `results` is
whatever its runner returned, which for a source block is nothing.
:param results: what the block's runner returned.
:return: the lines to draw.
:raises ValueError: when the block names a language or style Pygments
does not know.
"""
if self.screenshots_source:
return highlight_code(
"\n".join(self.content),
language=self.language,
style=self.screenshot_syntax_style,
).split("\n")
lines = list(results)
preset = self.screenshot_frame.get("preset")
if (
self.show_prompt
and preset is not None
and lines
and lines[0].startswith(f"{PROMPT_SIGIL} ")
):
# Gated on `show_prompt` so a `:hide-prompt:` block whose first
# *output* line happens to open with "$ " is not mistaken for an
# invocation.
lines[0] = f"{preset.prompt} {lines[0].removeprefix(f'{PROMPT_SIGIL} ')}"
return lines
[docs]
@cached_property
def screenshot_frame(self) -> dict[str, Any]:
"""Window the capture is drawn in, set by the `:screenshot-*:` options.
Each entry is left out when its option is: the renderer then picks what
the chrome asks for, which is what every block on these pages wants.
`:screenshot-backdrop:`, `:screenshot-border:` and
`:screenshot-shadow:` take a CSS color (or `none` to paint neither),
`:screenshot-border-width:`, `:screenshot-margin:`,
`:screenshot-padding:` and `:screenshot-radius:` take pixels,
`:screenshot-opacity:` how solid the window's body is,
`:screenshot-title:` the caption drawn in the window's own chrome, and
`:screenshot-watermark:` a credit line for the image's corner, which no
capture carries here unless asked for.
`:screenshot-line-numbers:` is a flag, numbering every line the block
rendered, its prompt first. `:screenshot-cursor:` draws a terminal
cursor, in the shape it names or in the preset's own, and
`:screenshot-blink:` says how long one blink takes. See
{func}`~click_extra.capture.svg.render_svg`.
A block naming no preset falls back to the one the
`click_extra_screenshot_preset` `conf.py` value names, so a project
drawing all of its captures as the same terminal states it once.
"""
decoration = {
name.replace("-", "_"): self.options[f"screenshot-{name}"]
for name in (
"backdrop",
"border",
"border-width",
"margin",
"opacity",
"padding",
"radius",
"shadow",
"watermark",
"watermark-color",
)
if f"screenshot-{name}" in self.options
}
# Stated even when nothing asks for one, the renderer crediting
# click-extra by default: the page around a block already says what
# drew its image.
decoration.setdefault(
"watermark",
getattr(self.env.config, SCREENSHOT_WATERMARK_CONFIG),
)
frame: dict[str, Any] = {
name: self.options[f"screenshot-{name}"]
for name in ("preset", "title")
if f"screenshot-{name}" in self.options
}
frame["chrome"] = Chrome(**decoration)
if "screenshot-cursor" in self.options:
frame["cursor"] = Cursor(
shape=self.options["screenshot-cursor"],
blink=self.options.get("screenshot-blink", Cursor().blink),
)
default_preset = getattr(self.env.config, SCREENSHOT_PRESET_CONFIG)
if default_preset and "preset" not in frame:
if default_preset not in PRESETS:
raise ValueError(
f"{SCREENSHOT_PRESET_CONFIG} names no terminal Click "
f"Extra knows: {default_preset!r} is not one of "
f"{', '.join(sorted(PRESETS))}."
)
frame["preset"] = PRESETS[default_preset]
return frame
[docs]
@cached_property
def invocation(self) -> str:
"""The command line the capture draws above itself, styled as a prompt.
Set by `:screenshot-prompt:`, which states the line a reader would type
rather than anything the block ran: a `click:run` block invokes a Python
callable, so there is no argv to recover one from. Styled exactly as a
still capture styles its own, see
{func}`~click_extra.capture.screenshot.prompt_line`.
```{note}
Animations only. A still block already prints its invocation, so the
option would draw it twice; the animated paths are the ones holding
what a command *drew* and nothing about the command line that drew it.
```
:return: the styled line, empty when the block states none.
"""
spec = self.options.get("screenshot-prompt")
if not spec:
return ""
return prompt_line(
(),
prompt=spec,
background=self.screenshot_background,
preset=self.screenshot_frame.get("preset"),
)
[docs]
def closed(self, frames: tuple[str, ...]) -> tuple[str, ...]:
"""Put the shell's prompt under the last frame, when asked.
`:screenshot-closing-prompt:` draws the sigil on the row a finished
command leaves the shell waiting on, see
{func}`~click_extra.capture.screenshot.append_prompt`. The last frame alone
carries it: the shell has not come back while the command is still
drawing.
:param frames: the animation's frames, in order.
:return: the same frames, the last one closed, or unchanged.
"""
if "screenshot-closing-prompt" not in self.options or not frames:
return frames
return (
*frames[:-1],
append_prompt(
frames[-1],
background=self.screenshot_background,
preset=self.screenshot_frame.get("preset"),
),
)
[docs]
def opened(
self,
frames: tuple[str, ...],
interval: float | tuple[float, ...],
) -> tuple[tuple[str, ...], float | tuple[float, ...]]:
"""Draw the invocation over an animation, and type it when asked.
A block records what a command *drew* and never the command line that
drew it, so an animation opens on output arriving from nowhere.
`:screenshot-prompt:` puts the line back over every frame, and
`:screenshot-typing:` opens on it being typed, one character a frame,
see {func}`~click_extra.capture.recording.type_line`.
:param frames: the animation's frames, in order.
:param interval: how long each is shown, one number for all alike.
:return: the frames and their timing, both grown by the opening.
"""
line = self.invocation
if not line:
return (tuple(frames), interval)
frames = tuple(f"{line}\n{frame}" for frame in frames)
typing = self.options.get("screenshot-typing")
if not typing:
return (frames, interval)
opening = type_line(
line,
typing=typing,
submit=self.options.get("screenshot-submit", DEFAULT_SUBMIT),
)
# A typed frame lasts a keystroke and the rest last whatever they were
# given, so one number covering every frame alike no longer can: it is
# spread over the frames it was standing for before the two are joined.
if isinstance(interval, int | float):
interval = (float(interval),) * len(frames)
return (
tuple(frame.text for frame in opening) + frames,
tuple(frame.duration for frame in opening) + tuple(interval),
)
[docs]
def render_animation(
self,
frames: tuple[str, ...],
interval: float | tuple[float, ...],
*,
hold: THold,
blank: float,
) -> str:
"""Draw an animated capture of `frames`, whatever produced them.
The two animated paths agree on everything but what closes a cycle: a
recording ends somewhere and pauses on that ending, while a declared
animation turns in place and ends nowhere, so it pauses for nothing
unless the block asks. Both fallbacks are overridden by
`:screenshot-hold:` and `:screenshot-blank:`.
:param frames: the animation's frames, in order.
:param interval: how long each of them is shown.
:param hold: seconds the last frame stays up when the block states none.
:param blank: seconds of empty screen closing the cycle when the block
states none.
:return: the SVG document.
"""
frames, interval = self.opened(self.closed(frames), interval)
return render(
columns=self.screenshot_columns,
unique_id=self.screenshot,
background=self.screenshot_background,
frames=self.numbered(frames),
emphasize=self.screenshot_emphasis(
max(frame.count("\n") + 1 for frame in frames)
),
interval=interval,
hold=self.screenshot_hold(hold),
blank=self.options.get("screenshot-blank", blank),
speed=self.options.get("screenshot-speed", 1.0),
**self.screenshot_frame,
)
[docs]
def write_screenshot(self, results: Iterable[str]) -> None:
"""Write the captured output as an SVG beside the documentation.
The file lands in the directory the `click_extra_screenshot_dir`
`conf.py` value names, under the source root, so a README pointing at
the repository embeds the very output this page renders live.
This is a side effect, not a rendering: the page keeps its results code
block, which stays selectable, searchable and theme-aware where an image
would not be. Use `:mirror:` to put the image on the page as well.
Writing during the build keeps the committed asset in step with the CLI
without anyone remembering to refresh it. `unique_id` is pinned to the
asset's name, so an unchanged CLI draws the same bytes, and the file is
then left alone, its modification time included. Only the generator's
release stamps and any output that varies by machine can change.
```{note}
That refresh only happens when the document carrying the block is
re-parsed: Sphinx's environment cache skips unchanged sources. A
change on the *package* side (a new config format widening a
`--config` default, say) leaves every capture stale until a rebuild
with a fresh environment (`sphinx-build -E`).
```
"""
assert self.screenshot
path = (
Path(self.env.srcdir)
/ getattr(self.env.config, SCREENSHOT_DIR_CONFIG)
/ f"{self.screenshot}.svg"
)
path.parent.mkdir(parents=True, exist_ok=True)
recording = self.options.get("screenshot-record")
if recording is not None:
if path.exists() and animation_metadata(path.read_text(encoding="UTF-8")):
# Kept as it was recorded. A recording of an animation cannot be
# reproduced: which spinner glyph pairs with which screen is
# decided by the scheduler, so the same command records a
# different set of frames every other run. Rewriting on each
# build would dirty the working tree for nothing anyone did, so
# the first recording is the one that stands.
#
# The expression is not even evaluated here, which is what keeps
# the command it records off every later build's clock. To take
# a fresh recording, delete the file and build again.
return
frames, interval = self.resolve_animation(recording, ":screenshot-record:")
_write_capture(
path,
self.render_animation(
frames,
interval,
hold=DEFAULT_RECORDING_HOLD,
blank=DEFAULT_RECORDING_BLANK,
),
)
return
animation = self.screenshot_animation
if animation is not None:
frames, interval = animation
_write_capture(
path,
self.render_animation(frames, interval, hold=0.0, blank=0.0),
)
return
lines = self.screenshot_lines(results)
if lines:
lines = self.closed(("\n".join(lines),))[0].split("\n")
if "screenshot-line-numbers" in self.options and lines:
# Numbered whole, so line 1 is the first line the picture shows.
lines = number_lines("\n".join(lines)).splitlines()
_write_capture(
path,
render(
"\n".join(lines),
emphasize=self.screenshot_emphasis(len(lines)),
columns=self.screenshot_columns,
unique_id=self.screenshot,
background=self.screenshot_background,
palette=self.screenshot_palette,
**self.screenshot_frame,
),
)
[docs]
def run(self) -> list[nodes.Node]:
"""Execute the directive and render its source and results."""
assert hasattr(self.runner, self.runner_method), (
f"{self.runner!r} does not have a method named {self.runner_method!r}."
)
runner_func = getattr(self.runner, self.runner_method)
results = runner_func(self)
# Materialize the committed capture before deciding what to render: the
# asset is wanted even by a block hiding its own results.
if self.screenshot:
self.write_screenshot(results)
# If neither source code nor results are requested, we don't render anything.
if not self.show_source and not self.show_results:
return []
lines = []
if self.show_source:
language = self.source_language or self.language
lines.extend(self.render_code_block(self.content, language, "source"))
if self.show_results:
lines.extend(self.render_code_block(results, self.language, "results"))
# Convert code block lines to a Docutils node tree.
return parse_into_section(self, lines)
[docs]
class SourceDirective(ClickDirective):
"""Directive to declare a Click CLI source code.
This directive is used to declare a Click CLI example in the
documentation. It renders the source code of the example in a
Python code block.
"""
default_language = "python"
show_source_by_default = True
show_results_by_default = False
runner_method = "execute_source"
screenshots_source = True
[docs]
class RunDirective(ClickDirective):
"""Directive to run a Click CLI example.
This directive is used to run a Click CLI example in the
documentation. It renders the results of running the example in a
shell session code block supporting ANSI colors.
"""
default_language = "ansi-shell-session"
show_source_by_default = False
show_results_by_default = True
runner_method = "run_cli"
source_language = SourceDirective.default_language
[docs]
def rewrite_screenshot_regions(
text: str,
directory: str = DEFAULT_SCREENSHOT_DIR,
) -> str:
"""Return `text` with every capture block's `:mirror:` region refreshed.
Only a top-level `click:run`, `click:source`, `python:run` or
`python:source` fence carrying both `:screenshot:` and `:mirror:` gets a
region, holding the Markdown link to the capture. The walk, the insertion and
the idempotency are those of
{func}`click_extra.markdown.blocks.rewrite_fenced_regions`.
The image itself is written by the directive at build time. This only
maintains the Markdown pointing at it. The `click-extra refresh-directives`
command calls it on each page, and {func}`update_screenshot_blocks` on each
file it is given.
"""
def link(inner: list[str]) -> list[str] | None:
options, _content = split_options(inner)
name = options.get("screenshot")
if not name or "mirror" not in options:
return None
return [f""]
return rewrite_fenced_regions(text, "screenshot", _CAPTURE_FENCE_OPEN_RE, link)
[docs]
def update_screenshot_blocks(
paths: Iterable[Path],
*,
check: bool = False,
directory: str = DEFAULT_SCREENSHOT_DIR,
) -> list[Path]:
"""Refresh every capture block's `:mirror:` region in the given sources.
See {func}`click_extra.markdown.blocks.update_blocks` for the walk, write, and
`check`-mode contract. Unlike the `python:render` `:mirror:` refresher, this
executes nothing: a region's content is derived from the block's
`:screenshot:` name.
:param paths: Markdown files, or directories recursed for `*.md`.
:param check: report what would change without writing.
:param directory: where the captures live, relative to each document.
:return: the files whose regions were (or, under `check`, would be) updated.
"""
def rewrite(text: str, path: Path) -> str:
return rewrite_screenshot_regions(text, directory)
return update_blocks(paths, rewrite, check=check)
cleanup_runner = make_cleanup(ClickDirective.runner_attr)
"""Drop the {class}`~click_extra.sphinx.click_runner.ClickRunner` from the doctree.
Connected to `doctree-read`, so it runs once the document is read.
"""