Source code for sphinxcontrib.typer

r"""
::


    ███████╗██████╗ ██╗  ██╗██╗███╗   ██╗██╗  ██╗
    ██╔════╝██╔══██╗██║  ██║██║████╗  ██║╚██╗██╔╝
    ███████╗██████╔╝███████║██║██╔██╗ ██║ ╚███╔╝
    ╚════██║██╔═══╝ ██╔══██║██║██║╚██╗██║ ██╔██╗
    ███████║██║     ██║  ██║██║██║ ╚████║██╔╝ ██╗
    ╚══════╝╚═╝     ╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝╚═╝  ╚═╝

    ████████╗██╗   ██╗██████╗ ███████╗██████╗
    ╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝██╔══██╗
       ██║    ╚████╔╝ ██████╔╝█████╗  ██████╔╝
       ██║     ╚██╔╝  ██╔═══╝ ██╔══╝  ██╔══██╗
       ██║      ██║   ██║     ███████╗██║  ██║
       ╚═╝      ╚═╝   ╚═╝     ╚══════╝╚═╝  ╚═╝

"""

import base64
import contextlib
import hashlib
import inspect
import io
import os
import re
import traceback
import typing as t
from contextlib import contextmanager
from enum import Enum
from html import escape as html_escape
from importlib import import_module
from importlib.util import find_spec
from pathlib import Path
from pprint import pformat

import click
from docutils import nodes
from docutils.parsers import rst
from docutils.parsers.rst import directives
from rich import terminal_theme as rich_theme
from rich.console import Console
from rich.theme import Theme
from sphinx import application
from sphinx.addnodes import pending_xref
from sphinx.util import logging
from sphinx.util.nodes import make_refnode

from typer import rich_utils as typer_rich_utils
from typer.core import MarkupMode, TyperGroup
from typer.main import Typer
from typer.main import get_command as get_typer_command
from typer.models import Context as TyperContext
from typer.models import TyperInfo

VERSION = (0, 8, 1)

__title__ = "SphinxContrib Typer"
__version__ = ".".join(str(i) for i in VERSION)
__author__ = "Brian Kohan"
__license__ = "MIT"
__copyright__ = "Copyright 2023-2026 Brian Kohan"


SELENIUM_DEFAULT_WINDOW_WIDTH = 1920
SELENIUM_DEFAULT_WINDOW_HEIGHT = 2048


def get_function(function: t.Union[str, t.Callable[..., t.Any]]):
    if callable(function):
        return function
    if isinstance(function, str):
        parts = function.split(".")
        return getattr(import_module(".".join(parts[0:-1])), parts[-1])


def _filter_commands(ctx: click.Context, cmd_filter: t.List[str]):
    return [ctx.command.get_command(ctx, cmd_name) for cmd_name in cmd_filter]


def _add_dependency(env, command):
    cb = getattr(command, "callback", None)
    cb = getattr(cb, "__wrapped__", cb)
    if cb:
        env.note_dependency(inspect.getfile(cb))


def _command_path(ctx: t.Optional[click.Context]):
    parts = []
    while ctx:
        parts.append(ctx.info_name)
        ctx = ctx.parent
    return ":".join(reversed(parts))


class RenderTarget(str, Enum):
    HTML = "html"
    SVG = "svg"
    TEXT = "text"

    def __str__(self) -> str:
        return self.value


class RenderTheme(str, Enum):
    LIGHT = "light"
    MONOKAI = "monokai"
    DIMMED_MONOKAI = "dimmed_monokai"
    NIGHT_OWLISH = "night_owlish"
    DARK = "dark"
    RED_SANDS = "red_sands"
    BLUE_WAVES = "blue_waves"

    def __str__(self) -> str:
        return self.value

    @property
    def terminal_theme(self) -> rich_theme.TerminalTheme:
        return {
            RenderTheme.LIGHT: rich_theme.DEFAULT_TERMINAL_THEME,
            RenderTheme.MONOKAI: rich_theme.MONOKAI,
            RenderTheme.DIMMED_MONOKAI: rich_theme.DIMMED_MONOKAI,
            RenderTheme.NIGHT_OWLISH: rich_theme.NIGHT_OWLISH,
            RenderTheme.DARK: rich_theme.SVG_EXPORT_THEME,
            RenderTheme.RED_SANDS: rich_theme.TerminalTheme(
                (132, 42, 38),  # background
                (210, 193, 159),  # text
                [
                    (210, 193, 159),  #
                    (0, 0, 0),  # required
                    (77, 218, 77),  # option on short name
                    (227, 189, 57),  # Usage/metavar
                    (210, 193, 159),  #
                    (0, 18, 140),  # option off
                    (75, 214, 225),  # option on/command names
                    (210, 193, 159),  #
                ],
            ),
            RenderTheme.BLUE_WAVES: rich_theme.TerminalTheme(
                (20, 118, 247),  # background
                (250, 240, 250),  # text
                [
                    (250, 240, 250),  #
                    (0, 0, 0),  # required
                    (0, 255, 0),  # option on short name
                    (227, 189, 57),  # Usage/metavar
                    (250, 240, 250),  #
                    (2, 2, 214),  # option off
                    (146, 226, 252),  # option on/command names
                    (250, 240, 250),  #
                ],
            ),
        }[self]


Command = t.Union[click.Command, click.Group]

"""
Callbacks that return a dict of kwargs to pass to various renderer functions
must all have the RenderCallback function signature:
"""
RenderCallback = t.Callable[
    [
        "TyperDirective",  # directive - the TyperDirective instance
        str,  # name - the name of the command
        Command,  # command - the command instance
        click.Context,  # ctx - the click.Context instance
        t.Optional[click.Context],  # parent - the parent click.Context instance
    ],
    t.Dict[str, t.Any],
]

"""
Custom render options can be provided at a python path that resolves to the
following type. Either a dictionary of kwargs to pass to the relevant function
or a callable that returns a dictionary of kwargs to pass to the relevant function
"""
RenderOptions = t.Union[t.Dict[str, t.Any], RenderCallback]


class TyperDirective(rst.Directive):
    """
    A directive that renders a Typer app or Click command help text as either
    an html, text literal or svg image node depending on the builder and
    configuraton.

    Ex usage.

    .. code-block:: rst

        .. typer:: import.path.to.typer.app:subcommand
            :prog: script_name
    """

    logger = logging.getLogger("sphinxcontrib.typer")

    has_content = False
    required_arguments = 1
    option_spec = {
        "prog": directives.unchanged_required,
        "make-sections": directives.flag,
        "show-nested": directives.flag,
        "markup-mode": directives.unchanged,
        "width": directives.nonnegative_int,
        "theme": RenderTheme,
        "svg-kwargs": directives.unchanged,
        "text-kwargs": directives.unchanged,
        "html-kwargs": directives.unchanged,
        "console-kwargs": directives.unchanged,
        "preferred": RenderTarget,
        "builders": directives.unchanged,
        "iframe-height": directives.nonnegative_int,
        "convert-png": directives.unchanged,
    }

    # resolved options
    prog_name: str
    nested: bool
    make_sections: bool
    width: int
    iframe_height: t.Optional[int] = None
    typer_convert_png: bool = False

    console: Console
    parent: click.Context

    theme: RenderTheme = RenderTheme.LIGHT
    preferred: t.Optional[RenderTarget] = None

    markup_mode: MarkupMode

    # the console_kwargs option can be a dict or a callable that returns a dict, the callable
    # must conform to the RenderOptions signature
    console_kwargs: RenderOptions
    html_kwargs: RenderOptions
    svg_kwargs: RenderOptions
    text_kwargs: RenderOptions

    target: RenderTarget

    builder_targets = {
        **{
            builder: [RenderTarget.SVG, RenderTarget.HTML, RenderTarget.TEXT]
            for builder in [
                "html",
                "dirhtml",
                "singlehtml",
                "htmlhelp",
                "qthelp",
                "devhelp",
            ]
        },
        "epub": [RenderTarget.HTML, RenderTarget.SVG, RenderTarget.TEXT],
        **{
            builder: [RenderTarget.SVG, RenderTarget.TEXT]
            for builder in ["latex", "latexpdf", "texinfo"]
        },
        **{builder: [RenderTarget.TEXT] for builder in ["text", "gettext"]},
    }

    @property
    def builder(self) -> str:
        return self.env.app.builder.name

    def uuid(self, normal_cmd: str) -> str:
        """
        Get a repeatable unique hash id for a given directive instance and command.

        This is used to generate repeatable unique filenames for any build artifacts
        like svg -> pdf conversions.

        :param normal_cmd: The normalized command name
        """
        # Contextual information
        source = self.state_machine.get_source_and_line()[0]
        line_number = self.state_machine.get_source_and_line()[1]
        source = os.path.relpath(source, self.env.app.builder.srcdir)
        return hashlib.sha256(
            f"{source}.{line_number}[{normal_cmd}]".encode("utf-8")
        ).hexdigest()[:8]

    def import_object(
        self,
        obj_path: t.Optional[str],
        accessor: t.Callable[[t.Any, str, t.Any], t.Any] = lambda obj, attr, _: getattr(
            obj, attr
        ),
    ) -> t.Any:
        """
        Imports an arbitrary object from a python string path.
        Delimiters can be '.', '::' or ':'.

        :param obj_path: The python path to the object, if False, returns None
        """
        if not obj_path:
            return None
        parts = re.split(r"::|[.:]", obj_path)
        tries = 1
        try:
            while True:
                # walk up the import path until we find something importable
                # then walk down the path fetching all the attributes
                # this allows import strings to reach into nested class
                # attributes
                try:
                    tries += 1
                    try_path = ".".join(parts[0 : -(tries - 1)])
                    obj = import_module(try_path)
                    file_spec = getattr(find_spec(try_path), "origin", None)
                    if file_spec:
                        self.env.note_dependency(file_spec)
                    for attr in parts[-(tries - 1) :]:
                        obj = accessor(obj, attr, try_path)
                    break
                except (ImportError, ModuleNotFoundError):
                    if tries >= len(parts):
                        raise

        except (Exception, SystemExit) as exc:
            err_msg = f'Failed to import "{obj_path}"'
            if isinstance(exc, SystemExit):
                err_msg += "The module appeared to call sys.exit()."
            else:
                err_msg += "The following exception was raised:\n{}".format(
                    traceback.format_exc()
                )

            raise self.severe(err_msg)

        return obj

    def load_root_command(self, typer_path: str) -> t.Union[click.Command, click.Group]:
        """
        Load the module.

        :param typer_path: The python path to the Typer app instance.
        """

        def resolve_root_command(obj):
            if isinstance(obj, (click.Command, click.Group)):
                return obj

            # use lenient duck typing check incase obj is a proxy for a Typer instance
            if isinstance(obj, Typer) or isinstance(
                getattr(obj, "info", None), TyperInfo
            ):
                return get_typer_command(obj)

            if callable(obj):
                ret = obj()
                if isinstance(ret, Typer) or isinstance(
                    getattr(obj, "info", None), TyperInfo
                ):
                    return get_typer_command(obj)
                if isinstance(ret, (click.Command, click.Group)):
                    return ret

            raise self.error(
                f'"{typer_path}" of type {type(obj)} is not Typer, click.Command or '
                "click.Group."
            )

        def access_command(
            obj, attr, imprt_path
        ) -> t.Union[click.Command, click.Group]:
            attr_obj = None
            try:
                attr_obj = getattr(obj, attr)
                return resolve_root_command(attr_obj)
            except Exception:
                try:
                    self.parent = TyperContext(
                        resolve_root_command(obj),
                        # we can't trust the name attribute for the first
                        # command - but it is probably the best bet for
                        # subsequent commands - so if this is a nested
                        # import pull out the name attribute if it exists
                        # otherwise we use the last successful import path
                        # part because it is probably the module with main
                        info_name=(
                            (
                                getattr(obj, "name", "")
                                if getattr(self, "parent", None)
                                else ""
                            )
                            or imprt_path.split(".")[-1]
                        ),
                        parent=getattr(self, "parent", None),
                    )
                    cmds = _filter_commands(self.parent, [attr])
                    if cmds:
                        return cmds[0]
                except (IndexError, rst.DirectiveError):
                    if attr_obj:
                        return attr_obj
                raise

        return resolve_root_command(
            self.import_object(typer_path, accessor=access_command)
        )

    def get_html(self, **options):
        return self.console.export_html(
            **{"theme": self.theme.terminal_theme, **options, "clear": False}
        )

    def get_svg(self, **options):
        return self.console.export_svg(
            **{"theme": self.theme.terminal_theme, **options, "clear": False}
        )

    def get_text(self, **options):
        return self.console.export_text(**{**options, "clear": False})

    def generate_nodes(
        self,
        name: str,
        command: click.Command,
        parent: t.Optional[click.Context],
    ) -> t.List[nodes.section]:
        """
        Generate the relevant Sphinx nodes.

        Generate node help for `click.Group` or `click.Command`.

        :param command: Instance of `click.Group` or `click.Command`
        :param parent: Instance of `typer.models.Context`, or None
        :returns: A list of nested docutil nodes
        """
        ctx = TyperContext(
            command,
            info_name=name,
            parent=parent,
            terminal_width=self.width,
            max_content_width=self.width,
        )

        _add_dependency(self.env, command)

        if command.hidden:
            return []

        normal_cmd = section_title = _command_path(ctx).replace(":", " ")
        section_id = nodes.make_id(section_title)
        if not getattr(self, "parent", None):
            section_title = section_title.split(" ")[-1]

        section = (
            nodes.section(
                "",
                nodes.title(text=section_title),
                ids=[section_id],
                names=[nodes.fully_normalize_name(section_title)],
            )
            if self.make_sections
            else nodes.container()
        )
        self.env.domaindata["std"].setdefault("typer", {})[section_id] = (
            self.env.docname,
            section_id,
            normal_cmd,
        )

        # Summary
        def resolve_options(
            options: RenderOptions, parameter: str
        ) -> t.Dict[str, t.Any]:
            if callable(options):
                options = options(self, name, command, ctx, parent)
            if isinstance(options, dict):
                return options
            raise self.severe(
                f"Invalid {parameter}, must be a dict or callable, got {type(options)}"
            )

        def get_console(stderr: bool = False) -> Console:
            self.console = Console(
                **{
                    "theme": Theme(
                        {
                            "option": typer_rich_utils.STYLE_OPTION,
                            "switch": typer_rich_utils.STYLE_SWITCH,
                            "negative_option": typer_rich_utils.STYLE_NEGATIVE_OPTION,
                            "negative_switch": typer_rich_utils.STYLE_NEGATIVE_SWITCH,
                            "metavar": typer_rich_utils.STYLE_METAVAR,
                            "metavar_sep": typer_rich_utils.STYLE_METAVAR_SEPARATOR,
                            "usage": typer_rich_utils.STYLE_USAGE,
                        },
                    ),
                    "highlighter": typer_rich_utils.highlighter,
                    "color_system": None
                    if self.target is RenderTarget.TEXT
                    else typer_rich_utils.COLOR_SYSTEM,
                    "force_terminal": typer_rich_utils.FORCE_TERMINAL,
                    "width": self.width or typer_rich_utils.MAX_WIDTH,
                    "stderr": stderr,
                    # overrides any defaults above
                    **resolve_options(self.console_kwargs, "console-kwargs"),
                    "record": True,
                }
            )
            return self.console

        # todo
        # typer provides no official way to alter the console that prints out the help
        # command so we have to monkey patch it - revisit in future if this changes!
        # we also monkey patch get_help incase its a click command
        orig_getter = typer_rich_utils._get_rich_console
        orig_format_help = command.format_help
        command.rich_markup_mode = getattr(
            self, "markup_mode", getattr(command, "rich_markup_mode", "markdown")
        )
        command.format_help = TyperGroup.format_help.__get__(command, command.__class__)
        typer_rich_utils._get_rich_console = get_console
        with contextlib.redirect_stdout(io.StringIO()):
            command.get_help(ctx)
        typer_rich_utils._get_rich_console = orig_getter
        command.format_help = orig_format_help
        ##############################################################################

        export_options = resolve_options(
            getattr(self, f"{self.target}_kwargs", {}), f"{self.target}-kwargs"
        )

        rendered = getattr(self, f"get_{self.target}")(
            **({"title": section_title} if self.target is RenderTarget.SVG else {}),
            **export_options,
        )

        def to_path(name: str, ext: str) -> Path:
            return (
                Path(self.env.app.builder.outdir)
                / f"{name.replace(':', '_').replace(' ', '_')}_{self.uuid(name)}.{ext}"
            )

        # Image URIs must be relative to the document's directory, not srcdir,
        # so that Sphinx can locate the file when the directive appears in a
        # document nested inside a subdirectory (e.g. via autodoc).
        # See https://github.com/sphinx-contrib/typer/issues/58
        doc_dir = Path(self.env.srcdir) / Path(self.env.docname).parent

        if self.typer_convert_png:
            png_path = to_path(normal_cmd, "png")
            get_function(self.env.app.config.typer_convert_png)(
                self, rendered, png_path
            )
            section += nodes.image(
                uri=os.path.relpath(png_path, doc_dir),
                alt=section_title,
            )
        elif self.target == RenderTarget.HTML:
            section += nodes.raw(
                "",
                get_function(self.env.app.config.typer_render_html)(
                    self, normal_cmd, rendered
                ),
                format="html",
            )
        elif self.target == RenderTarget.SVG:
            if "html" in self.builder:
                section += nodes.raw("", rendered, format="html")
            else:
                svg_path = to_path(normal_cmd, "svg")
                pdf_path = to_path(normal_cmd, "pdf")
                svg_path.write_text(rendered)
                get_function(self.env.app.config.typer_svg2pdf)(
                    self, rendered, pdf_path
                )
                section += nodes.image(
                    uri=os.path.relpath(pdf_path, doc_dir),
                    alt=section_title,
                )

        elif self.target == RenderTarget.TEXT:
            section += nodes.literal_block("", rendered)
        else:
            raise self.severe(f"Invalid typer render target: {self.target}")

        # recurse through subcommands if we should
        if isinstance(command, click.MultiCommand):
            commands = _filter_commands(ctx, command.list_commands(ctx))
            for command in commands:
                if self.nested:
                    section.extend(
                        self.generate_nodes(command.name, command, parent=ctx)
                    )
                else:
                    _add_dependency(self.env, command)
        return [section]

    def run(self) -> t.Iterable[nodes.section]:
        self.env = self.state.document.settings.env

        command = self.load_root_command(self.arguments[0])

        self.make_sections = "make-sections" in self.options
        self.nested = "show-nested" in self.options
        self.prog_name = self.options.get("prog", "")
        if "markup-mode" in self.options:
            self.markup_mode = self.options["markup-mode"]

        if not self.prog_name:
            try:
                self.prog_name = (
                    command.callback.__module__.split(".")[-1]
                    if hasattr(command, "callback") and not hasattr(self, "parent")
                    else re.split(r"::|[.:]", self.arguments[0])[-1]
                )
            except Exception as err:
                raise self.severe(
                    "Unable to determine program name, please specify using :prog:"
                ) from err

        self.prog_name = self.prog_name.strip()

        self.width = self.options.get("width", 65)
        self.iframe_height = self.options.get("iframe-height", None)

        # if no builders supplied but convert-png is set,
        # force png for all builders, otherwise require the builder
        # to be in the list of typer_convert_png builders
        self.typer_convert_png = "convert-png" in self.options
        if self.typer_convert_png:
            builders = self.options["convert-png"].strip()
            self.typer_convert_png = self.builder in builders if builders else True

        for trg in ["console", *list(RenderTarget)]:
            setattr(
                self,
                f"{trg}_kwargs",
                self.import_object(self.options.get(f"{trg}-kwargs", None)) or {},
            )

        self.preferred = self.options.get("preferred", None)
        self.theme = self.options.get("theme", self.theme)

        builder_targets = {}
        for builder_target in self.options.get("builders", "").split(":"):
            if builder_target.strip():
                builder, targets = builder_target.split("=")[0:2]
                builder_targets[builder.strip()] = [
                    RenderTarget(target.strip()) for target in targets.split(",")
                ]

        builder_targets = {**self.builder_targets, **builder_targets}

        if self.typer_convert_png:
            self.target = (
                self.preferred
                or (builder_targets.get(self.builder, []) or [RenderTarget.SVG])[0]
            )
        elif self.builder not in builder_targets:
            self.target = self.preferred or RenderTarget.TEXT
            self.logger.debug(
                "Unable to resolve render target for builder: %s - using: %s",
                self.builder,
                self.target,
            )
        else:
            supported = builder_targets[self.builder]
            self.target = (
                self.preferred if self.preferred in supported else supported[0]
            )

        parent = getattr(self, "parent", None)
        if parent and self.options.get("prog", None):
            # we unset this because we're not at the root command and this gets
            # messed up for whatever reason
            # https://github.com/sphinx-contrib/typer/issues/24
            parent.info_name = ""
        return self.generate_nodes(self.prog_name, command, parent)


[docs] def typer_get_iframe_height( directive: TyperDirective, normal_cmd: str, html_page: str ) -> int: """ The default iframe height calculation function. The iframe height resolution proceeds as follows: 1) Return the global iframe-height parameter if one was supplied as a parameter on the directive. 2) Check for a cached height value. 3) Attempt to use Selenium to dynamically determine the height of the iframe. Padding will be added from the config.typer_iframe_height_padding configuration value. The resulting height is then cached if that path is not None. If the attempt to use Selenium fails (it is not installed) a warning is issued and a default height of 600 is returned. :param directive: The TyperDirective instance :param normal_cmd: The normalized name of the command. (Subcommands are delimited by :) :param html_page: The full html document that will be rendered in the iframe """ if directive.iframe_height is not None: return directive.iframe_height if not hasattr(directive.env, "iframe_heights"): directive.env.iframe_heights = {} if height := directive.env.iframe_heights.get(normal_cmd, None): return height with get_function(directive.env.app.config.typer_get_web_driver)( directive ) as driver: # use base64 to avoid issues with special characters driver.get( f"data:text/html;base64," f"{base64.b64encode(html_page.encode('utf-8')).decode()}" ) height = ( int( driver.execute_script( "return document.documentElement.getBoundingClientRect().height" ) ) + directive.env.app.config.typer_iframe_height_padding ) directive.env.iframe_heights[normal_cmd] = height return height
[docs] def typer_render_html( directive: TyperDirective, normal_cmd: str, html_page: str ) -> str: """ The default html rendering function. This function returns the html console output wrapped in an iframe. The height of the iframe is dynamically determined by calling the configured typer_get_iframe_height function. :param directive: The TyperDirective instance :param normal_cmd: The normalized name of the command. (Subcommands are delimited by :) :param html_page: The html page rendered by console.export_html """ height = get_function(directive.env.app.config.typer_get_iframe_height)( directive, normal_cmd, html_page ) return ( f'<iframe style="border: none;" width="100%" height="' f'{height}px"' f' srcdoc="{html_escape(html_page)}"></iframe>' )
[docs] def typer_svg2pdf(directive: TyperDirective, svg_contents: str, pdf_path: str): """ The default typer_svg2pdf function. This function uses the cairosvg package to convert svg to pdf. .. note:: You will likely need to install fonts locally on your machine for the output of these conversions to look correct. The default font used by the svg export from rich is `FiraCode <https://github.com/tonsky/FiraCode/wiki/Installing>`_. :param directive: The TyperDirective instance :param svg_contents: The svg contents to convert to pdf :param pdf_path: The path to write the pdf to """ try: import cairosvg cairosvg.svg2pdf(bytestring=svg_contents, write_to=str(pdf_path)) except ImportError: directive.severe("cairosvg must be installed to render SVG in pdfs")
[docs] @contextmanager def typer_get_web_driver( directive: TyperDirective, width: int = SELENIUM_DEFAULT_WINDOW_WIDTH, height: int = SELENIUM_DEFAULT_WINDOW_HEIGHT, ) -> t.Any: """ The default get_web_driver function. This function yields a selenium web driver instance. It requires selenium to be installed. To override this function with a custom function see the ``typer_get_web_driver`` configuration parameter. .. note:: This must be implemented as a context manager that yields the webdriver instance and cleans it up on exit! :param directive: The TyperDirective instance """ import platform try: from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions except ImportError: raise directive.severe( "This feature requires selenium and webdriver-manager to be installed." ) # Set up headless browser options def opts(options=ChromeOptions()): options.add_argument("--headless") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-gpu") options.add_argument(f"--window-size={width}x{height}") return options def chrome(): from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager try: return webdriver.Chrome(options=opts()) except Exception: return webdriver.Chrome( service=Service(ChromeDriverManager().install()), options=opts() ) def chromium(): from selenium.webdriver.chrome.service import Service as ChromiumService from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.core.os_manager import ChromeType return webdriver.Chrome( service=ChromiumService( ChromeDriverManager(chrome_type=ChromeType.CHROMIUM).install() ), options=opts(), ) def firefox(): from selenium import webdriver from selenium.webdriver.firefox.options import Options from selenium.webdriver.firefox.service import Service as FirefoxService from webdriver_manager.firefox import GeckoDriverManager return webdriver.Firefox( service=FirefoxService(GeckoDriverManager().install()), options=opts(Options()), ) def edge(): from selenium.webdriver.edge.options import Options from selenium.webdriver.edge.service import Service as EdgeService from webdriver_manager.microsoft import EdgeChromiumDriverManager options = Options() options.use_chromium = True return webdriver.Edge( service=EdgeService(EdgeChromiumDriverManager().install()), options=opts(options), ) services = [ chrome, edge if platform.system().lower() == "windows" else chromium, firefox, ] driver = None for service in services: try: driver = service() break # use the first one that works! except Exception as err: directive.debug(f"Unable to initialize webdriver {service.__name__}: {err}") if driver: yield driver driver.quit() else: raise directive.severe("Unable to initialize any webdriver.")
[docs] def typer_convert_png( directive: TyperDirective, rendered: str, png_path: t.Union[str, Path], selenium_width: int = SELENIUM_DEFAULT_WINDOW_WIDTH, selenium_height: int = SELENIUM_DEFAULT_WINDOW_HEIGHT, ): """ The default typer_convert_png function. This function writes a png file to the given path by taking a selenium screen shot. It requires selenium to be installed. To override this function with a custom function see the ``typer_convert_png`` configuration parameter. :param directive: The TyperDirective instance :param rendered: The rendered command help. May be html, svg, or text. :param png_path: The path to write the png to :param selenium_width: The width of the selenium window - must be larger than the png to avoid cropping, default auto determine :param selenium_height: The height of the selenium window - must be larger than the png to avoid cropping, default auto determine """ import tempfile from io import BytesIO from PIL import Image from selenium.webdriver.common.by import By tag = "code" with get_function(directive.env.app.config.typer_get_web_driver)( directive ) as driver: with tempfile.NamedTemporaryFile(suffix=".html") as tmp: if directive.target is RenderTarget.TEXT: tag = "pre" rendered = f"<html><body><pre>{rendered}</pre></body></html>" elif directive.target is RenderTarget.SVG: tag = "svg" rendered = f"<html><body>{rendered}</body></html>" tmp.write(rendered.encode("utf-8")) tmp.flush() driver.get(f"file://{tmp.name}") png = driver.get_screenshot_as_png() # Find the element you want a screenshot of element = driver.find_element(By.CSS_SELECTOR, tag) pixel_ratio = driver.execute_script("return window.devicePixelRatio") # Get the element's location and size location = element.location size = element.size if size["width"] > selenium_width or size["height"] > selenium_height: # if our window is too small, resize it with some padding and try again return typer_convert_png( directive, rendered, png_path, size["width"] + 100, size["height"] + 100, ) # Open the screenshot and crop it to the element im = Image.open(BytesIO(png)) left = location["x"] * pixel_ratio top = location["y"] * pixel_ratio if directive.target is RenderTarget.TEXT: # getting the width of the text is actually a bit tricky script = """ const pre = arguments[0]; const textContent = pre.textContent || pre.innerText; const temporarySpan = document.createElement('span'); document.body.appendChild(temporarySpan); // Copy styles to match formatting const preStyle = window.getComputedStyle(pre); temporarySpan.style.fontFamily = preStyle.fontFamily; temporarySpan.style.fontSize = preStyle.fontSize; temporarySpan.style.whiteSpace = 'pre'; temporarySpan.textContent = textContent; return temporarySpan.offsetWidth; """ width = driver.execute_script(script, element) right = left + width * pixel_ratio else: right = left + size["width"] * pixel_ratio bottom = top + size["height"] * pixel_ratio im = im.crop((left, top, right, bottom)) # Defines crop points im.save(str(png_path)) # Saves the screenshot
_link_regex = re.compile(r"([^<]+)(?:<(.+?)>)?") def _link_and_text(text): return _link_regex.search(text).groups() def resolve_typer_reference(app, env, node, contnode): if node["reftype"] != "typer": return target_id = node["reftarget"] if target_id in env.domaindata["std"].get("typer", {}): docname, labelid, sectionname = env.domaindata["std"]["typer"][target_id] refnode = make_refnode( env.app.builder, node["refdoc"], docname, labelid, nodes.Text(node["reftitle"] or sectionname.strip()), target_id, ) return refnode else: lineno = node.line or getattr(node.parent, "line", 0) error_message = env.get_doctree(node["refdoc"]).reporter.error( f"Unresolved :typer: reference: '{target_id}' in document '{node['refdoc']}'. " f"Expected one of: {pformat(list(env.domaindata['std'].get('typer', {}).keys()), indent=2)}", line=lineno, ) msgid = node.document.set_id(error_message, node.parent) problematic = nodes.problematic(node.rawsource, node.rawsource, refid=msgid) prbid = node.document.set_id(problematic) error_message.add_backref(prbid) return problematic def typer_ref_role(name, rawtext, text, lineno, inliner, options={}, content=[]): env = inliner.document.settings.env title, link = _link_and_text(text) title = title.strip() if link: link = link.strip() target_id = nodes.make_id(link or title) if target_id in env.domaindata["std"].get("typer", {}): docname, labelid, sectionname = env.domaindata["std"]["typer"][target_id] refnode = make_refnode( env.app.builder, env.docname, docname, labelid, nodes.Text(sectionname.strip() if not link else title), target_id, ) return [refnode], [] else: pending = pending_xref( rawtext, refdomain="std", reftype="typer", reftarget=target_id, modname=None, classname=None, refexplicit=True, refwarn=True, reftitle=title if link else None, refdoc=env.docname, ) pending += nodes.Text(text) return [pending], [] def setup(app: application.Sphinx) -> t.Dict[str, t.Any]: # Need autodoc to support mocking modules app.add_directive("typer", TyperDirective) app.add_role("typer", typer_ref_role) app.connect("missing-reference", resolve_typer_reference) app.add_config_value( "typer_render_html", "sphinxcontrib.typer.typer_render_html", "env" ) app.add_config_value( "typer_get_iframe_height", "sphinxcontrib.typer.typer_get_iframe_height", "env" ) app.add_config_value("typer_svg2pdf", "sphinxcontrib.typer.typer_svg2pdf", "env") app.add_config_value("typer_iframe_height_padding", 30, "env") app.add_config_value( "typer_convert_png", "sphinxcontrib.typer.typer_convert_png", "env" ) app.add_config_value( "typer_get_web_driver", "sphinxcontrib.typer.typer_get_web_driver", "env" ) return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, }