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