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

github_output: str
github_ref_name: str
github_repository: str
github_token: str | None
runner_debug: bool
branch
112    @property
113    def branch(self):
114        return self.github_ref_name
verbosity
116    @property
117    def verbosity(self):
118        return 1 if self.runner_debug else 0
repo
120    @cached_property
121    def repo(self):
122        return self.gh.get_repo(self.github_repository)
gh
124    @cached_property
125    def gh(self):
126        return Github(
127            auth=self._gh_auth,
128        )
def set_output(self, name: str, value: str | tuple[type[~_T], ~_T]):
135    def set_output(self, name: str, value: str | tuple[type[_T], _T]):
136        match value:
137            case str():
138                pass
139            case (data_type, data):
140                ta = TypeAdapter(data_type)
141                value = ta.dump_json(data).decode()
142
143        with open(self.github_output, "a") as f:
144            print(f"{name}={value}", file=f)
model_config = {'extra': 'allow', 'arbitrary_types_allowed': True, 'validate_default': True, 'case_sensitive': False, 'env_prefix': '', '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, 'json_file': None, 'json_file_encoding': None, 'yaml_file': None, 'yaml_file_encoding': 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:
147def get_pages_url(repo: Repository) -> str:
148    endpoint = f"{repo.url}/pages"
149    try:
150        _, data = repo._requester.requestJsonAndCheck("GET", endpoint)
151    except Exception as e:
152        e.add_note(f"  Endpoint: {endpoint}")
153        if isinstance(e, UnknownObjectException):
154            e.add_note(
155                "Note: check if GitHub Pages is enabled in this repo"
156                + " (https://hexdoc.hexxy.media/docs/guides/deployment/github-pages)"
157            )
158        raise
159    return str(data["html_url"])
class CIMatrixItem(hexdoc.model.base.HexdocModel):
162class CIMatrixItem(HexdocModel):
163    value: str
164    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):
167class AnnotationKwargs(TypedDict, total=False):
168    title: str
169    """Custom title"""
170    file: str
171    """Filename"""
172    col: int
173    """Column number, starting at 1"""
174    endColumn: int
175    """End column number"""
176    line: int
177    """Line number, starting at 1"""
178    endLine: int
179    """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'>):
182def add_notice(message: str, **kwargs: Unpack[AnnotationKwargs]):
183    return add_annotation("notice", message, **kwargs)
def add_warning(message: str, **kwargs: *<class 'AnnotationKwargs'>):
186def add_warning(message: str, **kwargs: Unpack[AnnotationKwargs]):
187    return add_annotation("warning", message, **kwargs)
def add_error(message: str, **kwargs: *<class 'AnnotationKwargs'>):
190def add_error(message: str, **kwargs: Unpack[AnnotationKwargs]):
191    return add_annotation("error", message, **kwargs)
def add_annotation( type: Literal['notice', 'warning', 'error'], message: str, **kwargs: *<class 'AnnotationKwargs'>):
194def add_annotation(
195    type: Literal["notice", "warning", "error"],
196    message: str,
197    **kwargs: Unpack[AnnotationKwargs],
198):
199    if kwargs:
200        kwargs_str = " " + ",".join(f"{k}={v}" for k, v in kwargs.items())
201    else:
202        kwargs_str = ""
203
204    print(f"::{type}{kwargs_str}::{message}")