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}")
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)
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`.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
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"])
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.
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"""
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}")