Coverage for structured_tutorials / models.py: 99%
178 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 14:01 +0100
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-19 14:01 +0100
1# Copyright (c) 2025 Mathias Ertl
2# Licensed under the MIT License. See LICENSE file for details.
4"""Basic tutorial structure."""
6import os
7import re
8from pathlib import Path
9from typing import Annotated, Any, Literal, Self
11from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator, model_validator
12from pydantic.fields import FieldInfo
13from pydantic_core.core_schema import ValidationInfo
14from yaml import safe_load
16PositiveInt = Annotated[int, Field(ge=0)]
17PositiveFloat = Annotated[float, Field(ge=0)]
19TEMPLATE_DESCRIPTION = "This value is rendered as a template with the current context."
22def default_tutorial_root_factory(data: dict[str, Any]) -> Path:
23 """Default factory for the tutorial_root variable."""
24 tutorial_root = data["path"].parent
25 assert isinstance(tutorial_root, Path)
26 return tutorial_root
29def template_field_title_generator(field_name: str, field_info: FieldInfo) -> str:
30 """Field title generator for template fields."""
31 return f"{field_name.title()} (template)"
34def validate_regex(value: Any) -> Any:
35 """Validate and compile a regular expression."""
36 if isinstance(value, str): 36 ↛ 38line 36 didn't jump to line 38 because the condition on line 36 was always true
37 return re.compile(value)
38 return value
41class CommandBaseModel(BaseModel):
42 """Base model for commands."""
44 model_config = ConfigDict(extra="forbid")
46 status_code: Annotated[int, Field(ge=0, le=255)] = 0
47 show_output: bool = Field(
48 default=True, description="Set to `False` to always hide the output of this command."
49 )
52class TestSpecificationMixin:
53 """Mixin for specifying tests."""
55 delay: Annotated[float, Field(ge=0)] = 0
56 retry: PositiveInt = 0
57 backoff_factor: PositiveFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
60class ConfigurationMixin:
61 """Mixin for configuration models."""
63 skip: bool = Field(default=False, description="Skip this part.")
66class CleanupCommandModel(CommandBaseModel):
67 """Model for cleanup commands."""
69 model_config = ConfigDict(extra="forbid")
71 command: str
74class TestCommandModel(TestSpecificationMixin, CommandBaseModel):
75 """Model for a test command for a normal command."""
77 model_config = ConfigDict(extra="forbid")
79 command: str
82class TestPortModel(TestSpecificationMixin, BaseModel):
83 """Model for testing connectivity after a command is run."""
85 model_config = ConfigDict(extra="forbid")
87 host: str
88 port: Annotated[int, Field(ge=0, le=65535)]
91class TestOutputModel(BaseModel):
92 """Test the output of the command."""
94 model_config = ConfigDict(extra="forbid")
95 stream: Literal["stdout", "stderr"] = "stdout"
96 regex: Annotated[re.Pattern[str], BeforeValidator(validate_regex)] = Field(
97 description="A regular expression to test."
98 )
101class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel):
102 """Model for runtime configuration when running a single command."""
104 model_config = ConfigDict(extra="forbid")
106 update_context: dict[str, Any] = Field(default_factory=dict)
107 cleanup: tuple[CleanupCommandModel, ...] = tuple()
108 test: tuple[TestCommandModel | TestPortModel | TestOutputModel, ...] = tuple()
111class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
112 """Model for documenting a single command."""
114 model_config = ConfigDict(extra="forbid")
116 output: str = ""
117 update_context: dict[str, Any] = Field(default_factory=dict)
120class CommandModel(BaseModel):
121 """Model for a single command."""
123 model_config = ConfigDict(extra="forbid")
125 command: str
126 run: CommandRuntimeConfigurationModel = CommandRuntimeConfigurationModel()
127 doc: CommandDocumentationConfigurationModel = CommandDocumentationConfigurationModel()
130class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
131 """Runtime configuration for an entire commands part."""
133 model_config = ConfigDict(extra="forbid")
136class CommandsDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
137 """Documentation configuration for an entire commands part."""
139 model_config = ConfigDict(extra="forbid")
142class CommandsPartModel(BaseModel):
143 """A tutorial part consisting of one or more commands."""
145 model_config = ConfigDict(extra="forbid", title="Command part")
147 type: Literal["commands"] = "commands"
148 commands: tuple[CommandModel, ...]
150 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel()
151 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel()
154class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
155 """Configure a file part when running the tutorial."""
157 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
160class FileDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
161 """Configure a file part when rendering it as documentation.
163 For the `language`, `caption`, `linenos`, `lineno_start`, `emphasize_lines` and `name` options, please
164 consult the [sphinx documentation](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block).
165 """
167 model_config = ConfigDict(extra="forbid", title="File part documentation configuration")
169 # sphinx options:
170 language: str = Field(default="", description="The language used for the code block directive.")
171 caption: str | Literal[False] = Field(
172 default="",
173 description=f"The caption. Defaults to the `destination` of this part. {TEMPLATE_DESCRIPTION}",
174 )
175 linenos: bool = False
176 lineno_start: PositiveInt | Literal[False] = False
177 emphasize_lines: str = ""
178 name: str = ""
179 ignore_spelling: bool = Field(
180 default=False,
181 description="If true, wrap the caption in `:spelling:ignore:` (see"
182 " [sphinxcontrib.spelling](https://sphinxcontrib-spelling.readthedocs.io/en/latest/)).",
183 )
186class FilePartModel(BaseModel):
187 """A tutorial part for creating a file.
189 Note that exactly one of `contents` or `source` is required.
190 """
192 model_config = ConfigDict(extra="forbid", title="File part")
194 type: Literal["file"] = "file"
195 contents: str | None = Field(
196 default=None,
197 field_title_generator=template_field_title_generator,
198 description=f"Contents of the file. {TEMPLATE_DESCRIPTION}",
199 )
200 source: Path | None = Field(
201 default=None,
202 field_title_generator=template_field_title_generator,
203 description="The source path of the file to create. Unless `template` is `False`, the file is loaded "
204 "into memory and rendered as template.",
205 )
206 destination: str = Field(
207 field_title_generator=template_field_title_generator,
208 description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}",
209 )
210 template: bool = Field(
211 default=True, description="Whether the file contents should be rendered in a template."
212 )
214 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
215 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel()
217 @field_validator("source", mode="after")
218 @classmethod
219 def validate_source(cls, value: Path) -> Path:
220 if value.is_absolute():
221 raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
222 return value
224 @model_validator(mode="after")
225 def validate_contents_or_source(self) -> Self:
226 if self.contents is None and self.source is None:
227 raise ValueError("Either contents or source is required.")
228 if self.contents is not None and self.source is not None:
229 raise ValueError("Only one of contents or source is allowed.")
230 return self
232 @model_validator(mode="after")
233 def validate_destination(self) -> Self:
234 if not self.source and self.destination.endswith(os.sep):
235 raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.")
236 return self
239class RuntimeConfigurationModel(BaseModel):
240 """Initial configuration for running the tutorial."""
242 model_config = ConfigDict(extra="forbid", title="Runtime Configuration")
244 context: dict[str, Any] = Field(
245 default_factory=dict, description="Key/value pairs for the initial context when rendering templates."
246 )
247 temporary_directory: bool = Field(
248 default=False, description="Switch to an empty temporary directory before running the tutorial."
249 )
250 git_export: bool = Field(
251 default=False,
252 description="Export a git archive to a temporary directory before running the tutorial.",
253 )
255 @model_validator(mode="after")
256 def set_default_context(self) -> Self:
257 self.context["doc"] = False
258 self.context["run"] = True
259 self.context["cwd"] = Path.cwd()
260 return self
263class DocumentationConfigurationModel(BaseModel):
264 """Initial configuration for rendering the tutorial as documentation."""
266 model_config = ConfigDict(extra="forbid", title="Documentation Configuration")
268 context: dict[str, Any] = Field(
269 default_factory=dict, description="Key/value pairs for the initial context when rendering templates."
270 )
271 alternative_names: dict[str, str] = Field(
272 default_factory=dict,
273 description="Names for alternative keys, used in tab titles. By default, the key itself is used.",
274 )
276 @model_validator(mode="after")
277 def set_default_context(self) -> Self:
278 self.context["run"] = False
279 self.context["doc"] = True
280 self.context.setdefault("user", "user")
281 self.context.setdefault("host", "host")
282 self.context.setdefault("cwd", "~")
283 self.context.setdefault(
284 "prompt_template",
285 "{{ user }}@{{ host }}:{{ cwd }}{% if user == 'root' %}#{% else %}${% endif %} ",
286 )
287 return self
290class PromptModel(BaseModel):
291 """Allows you to inspect the current state of the tutorial manually."""
293 model_config = ConfigDict(extra="forbid", title="Prompt Configuration")
294 prompt: str = Field(description=f"The prompt text. {TEMPLATE_DESCRIPTION}")
295 type: Literal["enter", "confirm"] = "enter"
296 default: bool = Field(
297 default=True, description="For type=`confirm`, the default if the user just presses enter."
298 )
299 error: str = Field(
300 default="State was not confirmed.",
301 description="For `type=confirm`, the error message if the user does not confirm the current state. "
302 "{TEMPLATE_DESCRIPTION} The context will also include the `response` variable, representing the user "
303 "response.",
304 )
307PartModels = CommandsPartModel | FilePartModel
310class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
311 """Configure an alternative part when running the tutorial."""
313 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
316class AlternativeDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
317 """Configure an alternative part when documenting the tutorial."""
319 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
322class AlternativeModel(BaseModel):
323 """A tutorial part that has several different alternatives.
325 When rendering documentation, alternatives are rendered in tabs. When running a tutorial, the runner has
326 to specify exactly one (or at most one, if `required=False`) of the alternatives that should be run.
328 An alternative can contain parts for files or commands.
329 """
331 model_config = ConfigDict(extra="forbid", title="Alternatives")
333 alternatives: dict[str, PartModels]
334 required: bool = Field(default=True, description="Whether one of the alternatives is required.")
335 doc: AlternativeDocumentationConfigurationModel = AlternativeDocumentationConfigurationModel()
336 run: AlternativeRuntimeConfigurationModel = AlternativeRuntimeConfigurationModel()
339class ConfigurationModel(BaseModel):
340 """Initial configuration of a tutorial."""
342 model_config = ConfigDict(extra="forbid", title="Tutorial Configuration")
344 run: RuntimeConfigurationModel = RuntimeConfigurationModel()
345 doc: DocumentationConfigurationModel = DocumentationConfigurationModel()
348class TutorialModel(BaseModel):
349 """Root structure for the entire tutorial."""
351 model_config = ConfigDict(extra="forbid", title="Tutorial")
353 # absolute path to YAML file
354 path: Path = Field(
355 description="Absolute path to the tutorial file. This field is populated automatically while loading the tutorial.", # noqa: E501
356 )
357 tutorial_root: Path = Field(
358 default_factory=default_tutorial_root_factory,
359 description="Directory from which relative file paths are resolved. Defaults to the path of the "
360 "tutorial file.",
361 ) # absolute path (input: relative to path)
362 parts: tuple[PartModels | PromptModel | AlternativeModel, ...] = Field(
363 description="The individual parts of this tutorial."
364 )
365 configuration: ConfigurationModel = Field(default=ConfigurationModel())
367 @field_validator("path", mode="after")
368 @classmethod
369 def validate_path(cls, value: Path, info: ValidationInfo) -> Path:
370 if not value.is_absolute():
371 raise ValueError(f"{value}: Must be an absolute path.")
372 return value
374 @field_validator("tutorial_root", mode="after")
375 @classmethod
376 def resolve_tutorial_root(cls, value: Path, info: ValidationInfo) -> Path:
377 if value.is_absolute():
378 raise ValueError(f"{value}: Must be a relative path (relative to the tutorial file).")
379 path: Path = info.data["path"]
381 return (path.parent / value).resolve()
383 @model_validator(mode="after")
384 def update_context(self) -> Self:
385 self.configuration.run.context["tutorial_path"] = self.path
386 self.configuration.run.context["tutorial_dir"] = self.path.parent
387 self.configuration.doc.context["tutorial_path"] = self.path
388 self.configuration.doc.context["tutorial_dir"] = self.path.parent
389 return self
391 @classmethod
392 def from_file(cls, path: Path) -> "TutorialModel":
393 """Load a tutorial from a YAML file."""
394 with open(path) as stream:
395 tutorial_data = safe_load(stream)
396 tutorial_data["path"] = path.resolve()
397 tutorial = TutorialModel.model_validate(tutorial_data, context={"path": path})
398 return tutorial