Edit on GitHub

hexdoc.cli.ci

Commands designed to run in a GitHub Actions workflow.

  1"""
  2Commands designed to run in a GitHub Actions workflow.
  3"""
  4
  5# pyright: reportPrivateUsage=false
  6
  7import os
  8import shutil
  9import subprocess
 10from functools import cached_property
 11from pathlib import Path
 12from typing import Literal, TypedDict, TypeVar, Unpack
 13
 14from github import Auth, Github, UnknownObjectException
 15from github.Repository import Repository
 16from pydantic import TypeAdapter
 17from typer import Typer
 18
 19from hexdoc.cli.utils.args import PropsOption, ReleaseOption
 20from hexdoc.model import HexdocModel, HexdocSettings
 21from hexdoc.utils import setup_logging
 22
 23app = Typer(name="ci")
 24
 25
 26@app.command()
 27def build(
 28    *,
 29    props_file: PropsOption,
 30    release: ReleaseOption,
 31    run_hatch_build: bool = True,
 32):
 33    from . import app as hexdoc_app
 34
 35    env = CIEnvironment.model_getenv()
 36    setup_logging(env.verbosity, ci=True)
 37
 38    # FIXME: scuffed. why are we setting environment variables here :/
 39    for key in [
 40        "MOCK_GITHUB_PAGES_URL",  # highest priority so tests work correctly
 41        "HEXDOC_SITE_URL",  # TODO: this should be supported in more places
 42        "GITHUB_PAGES_URL",
 43    ]:
 44        if pages_url := os.getenv(key):
 45            break
 46    else:
 47        pages_url = get_pages_url(env.repo)
 48    os.environ["GITHUB_PAGES_URL"] = pages_url
 49
 50    site_path = hexdoc_app.build(
 51        Path("_site/src/docs"),
 52        clean=True,
 53        branch=env.branch,
 54        props_file=props_file,
 55        release=release,
 56    )
 57
 58    if run_hatch_build:
 59        site_dist = site_path / "dist"
 60        if site_dist.is_dir():
 61            shutil.rmtree(site_dist)
 62
 63        subprocess.run(["hatch", "build", "--clean"], check=True)
 64        shutil.copytree("dist", site_dist)
 65
 66    env.set_output("pages-url", pages_url)
 67
 68
 69@app.command()
 70def merge(
 71    *,
 72    props_file: PropsOption,
 73    release: ReleaseOption,
 74):
 75    from . import app as hexdoc_app
 76
 77    env = CIEnvironment.model_getenv()
 78    setup_logging(env.verbosity, ci=True)
 79    hexdoc_app.merge(props_file=props_file, release=release)
 80
 81
 82@app.command(deprecated=True)
 83def export(
 84    *,
 85    props_file: PropsOption,
 86    release: ReleaseOption,
 87):
 88    build(props_file=props_file, release=release)
 89
 90
 91@app.command(deprecated=True)
 92def render(
 93    lang: str,
 94    *,
 95    props_file: PropsOption,
 96    release: ReleaseOption,
 97):
 98    build(props_file=props_file, release=release)
 99
100
101# utils
102
103_T = TypeVar("_T")
104
105
106class CIEnvironment(HexdocSettings):
107    github_output: str
108    github_ref_name: str
109    github_repository: str
110    github_token: str | None = None
111    runner_debug: bool = False
112
113    @property
114    def branch(self):
115        return self.github_ref_name
116
117    @property
118    def verbosity(self):
119        return 1 if self.runner_debug else 0
120
121    @cached_property
122    def repo(self):
123        return self.gh.get_repo(self.github_repository)
124
125    @cached_property
126    def gh(self):
127        return Github(
128            auth=self._gh_auth,
129        )
130
131    @property
132    def _gh_auth(self):
133        if self.github_token:
134            return Auth.Token(self.github_token)
135
136    def set_output(self, name: str, value: str | tuple[type[_T], _T]):
137        match value:
138            case str():
139                pass
140            case (data_type, data):
141                ta = TypeAdapter(data_type)
142                value = ta.dump_json(data).decode()
143
144        with open(self.github_output, "a") as f:
145            print(f"{name}={value}", file=f)
146
147
148def get_pages_url(repo: Repository) -> str:
149    endpoint = f"{repo.url}/pages"
150    try:
151        _, data = repo._requester.requestJsonAndCheck("GET", endpoint)
152    except Exception as e:
153        e.add_note(f"  Endpoint: {endpoint}")
154        if isinstance(e, UnknownObjectException):
155            e.add_note(
156                "Note: check if GitHub Pages is enabled in this repo"
157                + " (https://hexdoc.hexxy.media/docs/guides/deployment/github-pages)"
158            )
159        raise
160    return str(data["html_url"])
161
162
163class CIMatrixItem(HexdocModel):
164    value: str
165    continue_on_error: bool
166
167
168class AnnotationKwargs(TypedDict, total=False):
169    title: str
170    """Custom title"""
171    file: str
172    """Filename"""
173    col: int
174    """Column number, starting at 1"""
175    endColumn: int
176    """End column number"""
177    line: int
178    """Line number, starting at 1"""
179    endLine: int
180    """End line number"""
181
182
183def add_notice(message: str, **kwargs: Unpack[AnnotationKwargs]):
184    return add_annotation("notice", message, **kwargs)
185
186
187def add_warning(message: str, **kwargs: Unpack[AnnotationKwargs]):
188    return add_annotation("warning", message, **kwargs)
189
190
191def add_error(message: str, **kwargs: Unpack[AnnotationKwargs]):
192    return add_annotation("error", message, **kwargs)
193
194
195def add_annotation(
196    type: Literal["notice", "warning", "error"],
197    message: str,
198    **kwargs: Unpack[AnnotationKwargs],
199):
200    if kwargs:
201        kwargs_str = " " + ",".join(f"{k}={v}" for k, v in kwargs.items())
202    else:
203        kwargs_str = ""
204
205    print(f"::{type}{kwargs_str}::{message}")
app = <typer.main.Typer object>
@app.command()
def build( *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>], run_hatch_build: bool = True):
27@app.command()
28def build(
29    *,
30    props_file: PropsOption,
31    release: ReleaseOption,
32    run_hatch_build: bool = True,
33):
34    from . import app as hexdoc_app
35
36    env = CIEnvironment.model_getenv()
37    setup_logging(env.verbosity, ci=True)
38
39    # FIXME: scuffed. why are we setting environment variables here :/
40    for key in [
41        "MOCK_GITHUB_PAGES_URL",  # highest priority so tests work correctly
42        "HEXDOC_SITE_URL",  # TODO: this should be supported in more places
43        "GITHUB_PAGES_URL",
44    ]:
45        if pages_url := os.getenv(key):
46            break
47    else:
48        pages_url = get_pages_url(env.repo)
49    os.environ["GITHUB_PAGES_URL"] = pages_url
50
51    site_path = hexdoc_app.build(
52        Path("_site/src/docs"),
53        clean=True,
54        branch=env.branch,
55        props_file=props_file,
56        release=release,
57    )
58
59    if run_hatch_build:
60        site_dist = site_path / "dist"
61        if site_dist.is_dir():
62            shutil.rmtree(site_dist)
63
64        subprocess.run(["hatch", "build", "--clean"], check=True)
65        shutil.copytree("dist", site_dist)
66
67    env.set_output("pages-url", pages_url)
@app.command()
def merge( *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>]):
70@app.command()
71def merge(
72    *,
73    props_file: PropsOption,
74    release: ReleaseOption,
75):
76    from . import app as hexdoc_app
77
78    env = CIEnvironment.model_getenv()
79    setup_logging(env.verbosity, ci=True)
80    hexdoc_app.merge(props_file=props_file, release=release)
@app.command(deprecated=True)
def export( *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>]):
83@app.command(deprecated=True)
84def export(
85    *,
86    props_file: PropsOption,
87    release: ReleaseOption,
88):
89    build(props_file=props_file, release=release)
@app.command(deprecated=True)
def render( lang: str, *, props_file: typing.Annotated[pathlib.Path, <typer.models.OptionInfo object>], release: typing.Annotated[bool, <typer.models.OptionInfo object>]):
92@app.command(deprecated=True)
93def render(
94    lang: str,
95    *,
96    props_file: PropsOption,
97    release: ReleaseOption,
98):
99    build(props_file=props_file, release=release)
class CIEnvironment(hexdoc.model.base.HexdocSettings):
107class CIEnvironment(HexdocSettings):
108    github_output: str
109    github_ref_name: str
110    github_repository: str
111    github_token: str | None = None
112    runner_debug: bool = False
113
114    @property
115    def branch(self):
116        return self.github_ref_name
117
118    @property
119    def verbosity(self):
120        return 1 if self.runner_debug else 0
121
122    @cached_property
123    def repo(self):
124        return self.gh.get_repo(self.github_repository)
125
126    @cached_property
127    def gh(self):
128        return Github(
129            auth=self._gh_auth,
130        )
131
132    @property
133    def _gh_auth(self):
134        if self.github_token:
135            return Auth.Token(self.github_token)
136
137    def set_output(self, name: str, value: str | tuple[type[_T], _T]):
138        match value:
139            case str():
140                pass
141            case (data_type, data):
142                ta = TypeAdapter(data_type)
143                value = ta.dump_json(data).decode()
144
145        with open(self.github_output, "a") as f:
146            print(f"{name}={value}", file=f)

Base class for settings, allowing values to be overridden by environment variables.

This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose), Heroku and any 12 factor app design.

All the below attributes can be set via model_config.

Args: _case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity. Defaults to None. _nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. Defaults to False. _env_prefix: Prefix for all environment variables. Defaults to None. _env_prefix_target: Targets to which _env_prefix is applied. Default: variable. _env_file: The env file(s) to load settings values from. Defaults to Path(''), which means that the value from model_config['env_file'] should be used. You can also pass None to indicate that environment variables should not be loaded from an env file. _env_file_encoding: The env file encoding, e.g. 'latin-1'. Defaults to None. _env_ignore_empty: Ignore environment variables where the value is an empty string. Default to False. _env_nested_delimiter: The nested env values delimiter. Defaults to None. _env_nested_max_split: The nested env values maximum nesting. Defaults to None, which means no limit. _env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.) into None type(None). Defaults to None type(None), which means no parsing should occur. _env_parse_enums: Parse enum field names to values. Defaults to None., which means no parsing should occur. _cli_prog_name: The CLI program name to display in help text. Defaults to None if _cli_parse_args is None. Otherwise, defaults to sys.argv[0]. _cli_parse_args: The list of CLI arguments to parse. Defaults to None. If set to True, defaults to sys.argv[1:]. _cli_settings_source: Override the default CLI settings source with a user defined instance. Defaults to None. _cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into None type(None). Defaults to _env_parse_none_str value if set. Otherwise, defaults to "null" if _cli_avoid_json is False, and "None" if _cli_avoid_json is True. _cli_hide_none_type: Hide None values in CLI help text. Defaults to False. _cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to False. _cli_enforce_required: Enforce required fields at the CLI. Defaults to False. _cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. Defaults to False. _cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. Defaults to True. _cli_prefix: The root parser command line arguments prefix. Defaults to "". _cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'. _cli_implicit_flags: Controls how bool fields are exposed as CLI flags.

    - False (default): no implicit flags are generated; booleans must be set explicitly (e.g. --flag=true).
    - True / 'dual': optional boolean fields generate both positive and negative forms (--flag and --no-flag).
    - 'toggle': required boolean fields remain in 'dual' mode, while optional boolean fields generate a single
      flag aligned with the default value (if default=False, expose --flag; if default=True, expose --no-flag).
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
_cli_kebab_case: CLI args use kebab case. Defaults to `False`.
_cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
_build_sources: Pre-initialized sources and init kwargs to use for building instantiation values.
    Defaults to `None`.
github_output: str
github_ref_name: str
github_repository: str
github_token: str | None
runner_debug: bool
branch
114    @property
115    def branch(self):
116        return self.github_ref_name
verbosity
118    @property
119    def verbosity(self):
120        return 1 if self.runner_debug else 0
repo
122    @cached_property
123    def repo(self):
124        return self.gh.get_repo(self.github_repository)
gh
126    @cached_property
127    def gh(self):
128        return Github(
129            auth=self._gh_auth,
130        )
def set_output(self, name: str, value: str | tuple[type[~_T], ~_T]):
137    def set_output(self, name: str, value: str | tuple[type[_T], _T]):
138        match value:
139            case str():
140                pass
141            case (data_type, data):
142                ta = TypeAdapter(data_type)
143                value = ta.dump_json(data).decode()
144
145        with open(self.github_output, "a") as f:
146            print(f"{name}={value}", file=f)
model_config = {'extra': 'allow', 'arbitrary_types_allowed': True, 'validate_default': True, 'case_sensitive': False, 'env_prefix': '', 'env_prefix_target': 'variable', 'nested_model_default_partial_update': False, 'env_file': '.env', 'env_file_encoding': None, 'env_ignore_empty': False, 'env_nested_delimiter': None, 'env_nested_max_split': None, 'env_parse_none_str': None, 'env_parse_enums': None, 'cli_prog_name': None, 'cli_parse_args': None, 'cli_parse_none_str': None, 'cli_hide_none_type': False, 'cli_avoid_json': False, 'cli_enforce_required': False, 'cli_use_class_docs_for_groups': False, 'cli_exit_on_error': True, 'cli_prefix': '', 'cli_flag_prefix_char': '-', 'cli_implicit_flags': False, 'cli_ignore_unknown_args': False, 'cli_kebab_case': False, 'cli_shortcuts': None, 'json_file': None, 'json_file_encoding': None, 'yaml_file': None, 'yaml_file_encoding': None, 'yaml_config_section': None, 'toml_file': None, 'secrets_dir': None, 'protected_namespaces': ('model_validate', 'model_dump', 'settings_customise_sources'), 'enable_decoding': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

def get_pages_url(repo: github.Repository.Repository) -> str:
149def get_pages_url(repo: Repository) -> str:
150    endpoint = f"{repo.url}/pages"
151    try:
152        _, data = repo._requester.requestJsonAndCheck("GET", endpoint)
153    except Exception as e:
154        e.add_note(f"  Endpoint: {endpoint}")
155        if isinstance(e, UnknownObjectException):
156            e.add_note(
157                "Note: check if GitHub Pages is enabled in this repo"
158                + " (https://hexdoc.hexxy.media/docs/guides/deployment/github-pages)"
159            )
160        raise
161    return str(data["html_url"])
class CIMatrixItem(hexdoc.model.base.HexdocModel):
164class CIMatrixItem(HexdocModel):
165    value: str
166    continue_on_error: bool

Base class for all Pydantic models in hexdoc.

Sets the default model config, and overrides __init__ to allow using the init_context context manager to set validation context for constructors.

value: str
continue_on_error: bool
class AnnotationKwargs(typing.TypedDict):
169class AnnotationKwargs(TypedDict, total=False):
170    title: str
171    """Custom title"""
172    file: str
173    """Filename"""
174    col: int
175    """Column number, starting at 1"""
176    endColumn: int
177    """End column number"""
178    line: int
179    """Line number, starting at 1"""
180    endLine: int
181    """End line number"""
title: str

Custom title

file: str

Filename

col: int

Column number, starting at 1

endColumn: int

End column number

line: int

Line number, starting at 1

endLine: int

End line number

def add_notice(message: str, **kwargs: *<class 'AnnotationKwargs'>):
184def add_notice(message: str, **kwargs: Unpack[AnnotationKwargs]):
185    return add_annotation("notice", message, **kwargs)
def add_warning(message: str, **kwargs: *<class 'AnnotationKwargs'>):
188def add_warning(message: str, **kwargs: Unpack[AnnotationKwargs]):
189    return add_annotation("warning", message, **kwargs)
def add_error(message: str, **kwargs: *<class 'AnnotationKwargs'>):
192def add_error(message: str, **kwargs: Unpack[AnnotationKwargs]):
193    return add_annotation("error", message, **kwargs)
def add_annotation( type: Literal['notice', 'warning', 'error'], message: str, **kwargs: *<class 'AnnotationKwargs'>):
196def add_annotation(
197    type: Literal["notice", "warning", "error"],
198    message: str,
199    **kwargs: Unpack[AnnotationKwargs],
200):
201    if kwargs:
202        kwargs_str = " " + ",".join(f"{k}={v}" for k, v in kwargs.items())
203    else:
204        kwargs_str = ""
205
206    print(f"::{type}{kwargs_str}::{message}")