structured-tutorials 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ try:
7
+ __version__ = version("structured-tutorials")
8
+ except PackageNotFoundError: # pragma: no cover
9
+ __version__ = "not-installed"
@@ -0,0 +1,83 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Main CLI entrypoint."""
5
+
6
+ import argparse
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from structured_tutorials import __version__
14
+ from structured_tutorials.errors import InvalidAlternativesSelectedError
15
+ from structured_tutorials.models import TutorialModel
16
+ from structured_tutorials.output import error, setup_logging
17
+ from structured_tutorials.runners.local import LocalTutorialRunner
18
+
19
+
20
+ def main(argv: Sequence[str] | None = None) -> None:
21
+ """Main entry function for the command-line."""
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("path", type=Path)
24
+ parser.add_argument("--version", action="version", version=__version__)
25
+ parser.add_argument("-a", "--alternative", dest="alternatives", action="append", default=[])
26
+ parser.add_argument("--no-colors", action="store_true", default=False)
27
+ parser.add_argument(
28
+ "-n",
29
+ "--non-interactive",
30
+ dest="interactive",
31
+ action="store_false",
32
+ default=True,
33
+ help="Never prompt for any user input.",
34
+ )
35
+ parser.add_argument(
36
+ "--log-level",
37
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
38
+ default="INFO",
39
+ help="Override root log level",
40
+ )
41
+ parser.add_argument(
42
+ "--hide-commands",
43
+ dest="show_commands",
44
+ action="store_false",
45
+ default=True,
46
+ help="Do not show commands that are run by the tutorial.",
47
+ )
48
+ parser.add_argument(
49
+ "--hide-command-output",
50
+ dest="show_command_output",
51
+ action="store_false",
52
+ default=True,
53
+ help="Do not show the output of commands that are run on the terminal.",
54
+ )
55
+ args = parser.parse_args(argv)
56
+
57
+ setup_logging(level=args.log_level, no_colors=args.no_colors, show_commands=args.show_commands)
58
+
59
+ try:
60
+ tutorial = TutorialModel.from_file(args.path)
61
+ except yaml.YAMLError as exc: # an invalid YAML file
62
+ error(f"{args.path}: Invalid YAML file:")
63
+ print(exc, file=sys.stderr)
64
+ sys.exit(1)
65
+ except ValueError as ex: # thrown by Pydantic model loading
66
+ error(f"{args.path}: File is not a valid Tutorial:")
67
+ print(ex, file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ runner = LocalTutorialRunner(
71
+ tutorial,
72
+ alternatives=tuple(args.alternatives),
73
+ show_command_output=args.show_command_output,
74
+ interactive=args.interactive,
75
+ )
76
+
77
+ try:
78
+ runner.validate_alternatives()
79
+ except InvalidAlternativesSelectedError as ex:
80
+ error(str(ex))
81
+ sys.exit(1)
82
+
83
+ runner.run()
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Collection of errors thrown by this project."""
5
+
6
+
7
+ class StructuredTutorialError(Exception):
8
+ """Base class for all exceptions thrown by this project."""
9
+
10
+
11
+ class InvalidAlternativesSelectedError(StructuredTutorialError):
12
+ """Exception raised when an invalid alternative is selected."""
13
+
14
+
15
+ class PartError(StructuredTutorialError):
16
+ """Base class for all errors happening in a specific part."""
17
+
18
+
19
+ class CommandsPartError(PartError):
20
+ """Base class for all errors happening in a specific commands part."""
21
+
22
+
23
+ class CommandTestError(CommandsPartError):
24
+ """Base class for exceptions when a test for a command fails."""
25
+
26
+
27
+ class CommandOutputTestError(CommandTestError):
28
+ """Exception raised when an output test fails."""
29
+
30
+
31
+ class FilePartError(PartError):
32
+ """Exception raised for errors in file parts."""
33
+
34
+
35
+ class DestinationIsADirectoryError(FilePartError):
36
+ """Exception raised when a destination is a directory."""
37
+
38
+
39
+ class PromptNotConfirmedError(PartError):
40
+ """Exception raised when a user does not confirm the current state in a prompt part."""
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ from structured_tutorials.models.parts import AlternativeModel, CommandsPartModel, FilePartModel, PromptModel
5
+ from structured_tutorials.models.tutorial import TutorialModel
6
+
7
+ __all__ = [
8
+ "AlternativeModel",
9
+ "AlternativeModel",
10
+ "CommandsPartModel",
11
+ "FilePartModel",
12
+ "PromptModel",
13
+ "TutorialModel",
14
+ ]
@@ -0,0 +1,97 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Base model classes."""
5
+
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ NonNegativeFloat,
14
+ NonNegativeInt,
15
+ field_validator,
16
+ model_validator,
17
+ )
18
+ from pydantic.fields import FieldInfo
19
+
20
+ from structured_tutorials.typing import Self
21
+
22
+ # Type for commands to execute
23
+ CommandType = str | tuple[str, ...]
24
+
25
+ TEMPLATE_DESCRIPTION = "This value is rendered as a template with the current context."
26
+
27
+
28
+ def default_tutorial_root_factory(data: dict[str, Any]) -> Path:
29
+ """Default factory for the tutorial_root variable."""
30
+ tutorial_root = data["path"].parent
31
+ assert isinstance(tutorial_root, Path)
32
+ return tutorial_root
33
+
34
+
35
+ def template_field_title_generator(field_name: str, field_info: FieldInfo) -> str:
36
+ """Field title generator for template fields."""
37
+ return f"{field_name.title()} (template)"
38
+
39
+
40
+ class CommandBaseModel(BaseModel):
41
+ """Base model for commands."""
42
+
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ status_code: Annotated[int, Field(ge=0, le=255)] = 0
46
+ show_output: bool = Field(
47
+ default=True, description="Set to `False` to always hide the output of this command."
48
+ )
49
+
50
+
51
+ class TestSpecificationMixin:
52
+ """Mixin for specifying tests."""
53
+
54
+ delay: Annotated[float, Field(ge=0)] = 0
55
+ retry: NonNegativeInt = 0
56
+ backoff_factor: NonNegativeFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
57
+
58
+
59
+ class ConfigurationMixin:
60
+ """Mixin for configuration models."""
61
+
62
+ skip: bool = Field(default=False, description="Skip this part.")
63
+ update_context: dict[str, Any] = Field(default_factory=dict)
64
+
65
+
66
+ class FileMixin:
67
+ """Mixin for specifying a file (used in file part and for stdin of commands)."""
68
+
69
+ contents: str | None = Field(
70
+ default=None,
71
+ field_title_generator=template_field_title_generator,
72
+ description=f"Contents of the file. {TEMPLATE_DESCRIPTION}",
73
+ )
74
+ source: Path | None = Field(
75
+ default=None,
76
+ field_title_generator=template_field_title_generator,
77
+ description="The source path of the file to create. Unless `template` is `False`, the file is loaded "
78
+ "into memory and rendered as template.",
79
+ )
80
+ template: bool = Field(
81
+ default=True, description="Whether the file contents should be rendered in a template."
82
+ )
83
+
84
+ @field_validator("source", mode="after")
85
+ @classmethod
86
+ def validate_source(cls, value: Path) -> Path:
87
+ if value.is_absolute():
88
+ raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
89
+ return value
90
+
91
+ @model_validator(mode="after")
92
+ def validate_contents_or_source(self) -> Self:
93
+ if self.contents is None and self.source is None:
94
+ raise ValueError("Either contents or source is required.")
95
+ if self.contents is not None and self.source is not None:
96
+ raise ValueError("Only one of contents or source is allowed.")
97
+ return self
@@ -0,0 +1,227 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Basic tutorial structure."""
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Annotated, Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Discriminator, Field, NonNegativeInt, Tag, model_validator
11
+
12
+ from structured_tutorials.models.base import (
13
+ TEMPLATE_DESCRIPTION,
14
+ CommandBaseModel,
15
+ CommandType,
16
+ ConfigurationMixin,
17
+ FileMixin,
18
+ template_field_title_generator,
19
+ )
20
+ from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel
21
+ from structured_tutorials.typing import Self
22
+
23
+
24
+ def part_discriminator(value: Any) -> str | None:
25
+ """Discriminator for parts."""
26
+ if isinstance(value, dict):
27
+ if typ := value.get("type"):
28
+ return typ # type: ignore[no-any-return]
29
+ if "commands" in value:
30
+ return "commands"
31
+ if "destination" in value:
32
+ return "file"
33
+ if "prompt" in value:
34
+ return "prompt"
35
+ if "alternatives" in value: # pragma: no branch # all alternatives covered
36
+ return "alternatives"
37
+
38
+ elif isinstance(value, PartMixin): # pragma: no cover # not really sure how to trigger this
39
+ return value.type
40
+ return None # pragma: no cover # not really sure how to trigger this
41
+
42
+
43
+ class PartMixin:
44
+ """Mixin used by all parts."""
45
+
46
+ type: str
47
+ id: str = Field(default="", description="ID that can be used to reference the specific part.")
48
+ index: int = Field(default=0, description="Index of the part in the tutorial.")
49
+ name: str = Field(default="", description="Human-readable name of the part.")
50
+
51
+
52
+ class CleanupCommandModel(CommandBaseModel):
53
+ """Command to clean up artifacts created by the current part."""
54
+
55
+ model_config = ConfigDict(extra="forbid")
56
+
57
+ command: CommandType = Field(description="Command that cleans up artifacts created by the main command.")
58
+
59
+
60
+ class StdinCommandModel(FileMixin, BaseModel):
61
+ """Standard input for a command."""
62
+
63
+
64
+ class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel):
65
+ """Model for runtime configuration when running a single command."""
66
+
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+ chdir: Path | None = Field(default=None, description="Change working directory to this path.")
70
+ cleanup: tuple[CleanupCommandModel, ...] = tuple()
71
+ test: tuple[TestCommandModel | TestPortModel | TestOutputModel, ...] = tuple()
72
+ stdin: StdinCommandModel | None = None
73
+
74
+
75
+ class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
76
+ """Model for documenting a single command."""
77
+
78
+ model_config = ConfigDict(extra="forbid")
79
+
80
+ output: str = ""
81
+
82
+
83
+ class CommandModel(BaseModel):
84
+ """A single command to run in this part."""
85
+
86
+ model_config = ConfigDict(extra="forbid")
87
+
88
+ command: CommandType = Field(description="The command to run.")
89
+ run: CommandRuntimeConfigurationModel = Field(
90
+ default=CommandRuntimeConfigurationModel(), description="The runtime configuration."
91
+ )
92
+ doc: CommandDocumentationConfigurationModel = Field(
93
+ default=CommandDocumentationConfigurationModel(), description="The documentation configuration."
94
+ )
95
+
96
+
97
+ class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
98
+ """Runtime configuration for an entire commands part."""
99
+
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+
103
+ class CommandsDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
104
+ """Documentation configuration for an entire commands part."""
105
+
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+
109
+ class CommandsPartModel(PartMixin, BaseModel):
110
+ """A tutorial part consisting of one or more commands."""
111
+
112
+ model_config = ConfigDict(extra="forbid", title="Command part")
113
+
114
+ type: Literal["commands"] = "commands"
115
+ commands: tuple[CommandModel, ...]
116
+
117
+ run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel()
118
+ doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel()
119
+
120
+
121
+ class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
122
+ """Configure a file part when running the tutorial."""
123
+
124
+ model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
125
+
126
+
127
+ class FileDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
128
+ """Configure a file part when rendering it as documentation.
129
+
130
+ For the `language`, `caption`, `linenos`, `lineno_start`, `emphasize_lines` and `name` options, please
131
+ consult the [sphinx documentation](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block).
132
+ """
133
+
134
+ model_config = ConfigDict(extra="forbid", title="File part documentation configuration")
135
+
136
+ # sphinx options:
137
+ language: str = Field(default="", description="The language used for the code block directive.")
138
+ caption: str | Literal[False] = Field(
139
+ default="",
140
+ description=f"The caption. Defaults to the `destination` of this part. {TEMPLATE_DESCRIPTION}",
141
+ )
142
+ linenos: bool = False
143
+ lineno_start: NonNegativeInt | Literal[False] = False
144
+ emphasize_lines: str = ""
145
+ name: str = ""
146
+ ignore_spelling: bool = Field(
147
+ default=False,
148
+ description="If true, wrap the caption in `:spelling:ignore:` (see"
149
+ " [sphinxcontrib.spelling](https://sphinxcontrib-spelling.readthedocs.io/en/latest/)).",
150
+ )
151
+
152
+
153
+ class FilePartModel(PartMixin, FileMixin, BaseModel):
154
+ """A tutorial part for creating a file.
155
+
156
+ Note that exactly one of `contents` or `source` is required.
157
+ """
158
+
159
+ model_config = ConfigDict(extra="forbid", title="File part")
160
+
161
+ type: Literal["file"] = "file"
162
+
163
+ destination: str = Field(
164
+ field_title_generator=template_field_title_generator,
165
+ description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}",
166
+ )
167
+
168
+ doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
169
+ run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel()
170
+
171
+ @model_validator(mode="after")
172
+ def validate_destination(self) -> Self:
173
+ if not self.source and self.destination.endswith(os.sep):
174
+ raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.")
175
+ return self
176
+
177
+
178
+ class PromptModel(PartMixin, BaseModel):
179
+ """Allows you to inspect the current state of the tutorial manually."""
180
+
181
+ model_config = ConfigDict(extra="forbid", title="Prompt Configuration")
182
+
183
+ type: Literal["prompt"] = "prompt"
184
+ prompt: str = Field(description=f"The prompt text. {TEMPLATE_DESCRIPTION}")
185
+ response: Literal["enter", "confirm"] = "enter"
186
+ default: bool = Field(
187
+ default=True, description="For type=`confirm`, the default if the user just presses enter."
188
+ )
189
+ error: str = Field(
190
+ default="State was not confirmed.",
191
+ description="For `type=confirm`, the error message if the user does not confirm the current state. "
192
+ "{TEMPLATE_DESCRIPTION} The context will also include the `response` variable, representing the user "
193
+ "response.",
194
+ )
195
+
196
+
197
+ PartModels = Annotated[CommandsPartModel, Tag("commands")] | Annotated[FilePartModel, Tag("file")]
198
+
199
+
200
+ class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
201
+ """Configure an alternative part when running the tutorial."""
202
+
203
+ model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
204
+
205
+
206
+ class AlternativeDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
207
+ """Configure an alternative part when documenting the tutorial."""
208
+
209
+ model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
210
+
211
+
212
+ class AlternativeModel(PartMixin, BaseModel):
213
+ """A tutorial part that has several different alternatives.
214
+
215
+ When rendering documentation, alternatives are rendered in tabs. When running a tutorial, the runner has
216
+ to specify exactly one (or at most one, if `required=False`) of the alternatives that should be run.
217
+
218
+ An alternative can contain parts for files or commands.
219
+ """
220
+
221
+ model_config = ConfigDict(extra="forbid", title="Alternatives")
222
+
223
+ type: Literal["alternatives"] = "alternatives"
224
+ alternatives: dict[str, Annotated[PartModels, Discriminator(part_discriminator)]]
225
+ required: bool = Field(default=True, description="Whether one of the alternatives is required.")
226
+ doc: AlternativeDocumentationConfigurationModel = AlternativeDocumentationConfigurationModel()
227
+ run: AlternativeRuntimeConfigurationModel = AlternativeRuntimeConfigurationModel()
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Test specifications for commands."""
5
+
6
+ import re
7
+ from typing import Annotated, Literal
8
+
9
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
10
+
11
+ from structured_tutorials.models.base import CommandBaseModel, CommandType, TestSpecificationMixin
12
+ from structured_tutorials.models.validators import validate_regex
13
+
14
+
15
+ class TestCommandModel(TestSpecificationMixin, CommandBaseModel):
16
+ """Test a command by running another command."""
17
+
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+ command: CommandType = Field(description="The command to run.")
21
+
22
+
23
+ class TestPortModel(TestSpecificationMixin, BaseModel):
24
+ """Test a command by checking if a port is open."""
25
+
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ host: str = Field(description="The host to connect to.")
29
+ port: Annotated[int, Field(ge=0, le=65535)] = Field(description="The port to connect to.")
30
+
31
+
32
+ class TestOutputModel(BaseModel):
33
+ """Test a command by checking the output of a command."""
34
+
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ stream: Literal["stdout", "stderr"] = Field(default="stdout", description="The output stream to use.")
38
+ regex: Annotated[re.Pattern[bytes], BeforeValidator(validate_regex)] = Field(
39
+ description="A regular expression to test."
40
+ )
@@ -0,0 +1,150 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Module containing main tutorial model and global configuration models."""
5
+
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag, field_validator, model_validator
10
+ from pydantic_core.core_schema import ValidationInfo
11
+ from yaml import safe_load
12
+
13
+ from structured_tutorials.models.base import default_tutorial_root_factory
14
+ from structured_tutorials.models.parts import AlternativeModel, PartModels, PromptModel, part_discriminator
15
+ from structured_tutorials.typing import Self
16
+
17
+
18
+ class DocumentationConfigurationModel(BaseModel):
19
+ """Initial configuration for rendering the tutorial as documentation."""
20
+
21
+ model_config = ConfigDict(extra="forbid", title="Documentation Configuration")
22
+
23
+ context: dict[str, Any] = Field(
24
+ default_factory=dict, description="Key/value pairs for the initial context when rendering templates."
25
+ )
26
+ alternative_names: dict[str, str] = Field(
27
+ default_factory=dict,
28
+ description="Names for alternative keys, used in tab titles. By default, the key itself is used.",
29
+ )
30
+
31
+ @model_validator(mode="after")
32
+ def set_default_context(self) -> Self:
33
+ self.context["run"] = False
34
+ self.context["doc"] = True
35
+ self.context.setdefault("user", "user")
36
+ self.context.setdefault("host", "host")
37
+ self.context.setdefault("cwd", "~")
38
+ self.context.setdefault(
39
+ "prompt_template",
40
+ "{{ user }}@{{ host }}:{{ cwd }}{% if user == 'root' %}#{% else %}${% endif %} ",
41
+ )
42
+ return self
43
+
44
+
45
+ class RuntimeConfigurationModel(BaseModel):
46
+ """Initial configuration for running the tutorial."""
47
+
48
+ model_config = ConfigDict(extra="forbid", title="Runtime Configuration")
49
+
50
+ context: dict[str, Any] = Field(
51
+ default_factory=dict, description="Key/value pairs for the initial context when rendering templates."
52
+ )
53
+ temporary_directory: bool = Field(
54
+ default=False, description="Switch to an empty temporary directory before running the tutorial."
55
+ )
56
+ git_export: bool = Field(
57
+ default=False,
58
+ description="Export a git archive to a temporary directory before running the tutorial.",
59
+ )
60
+
61
+ @model_validator(mode="after")
62
+ def set_default_context(self) -> Self:
63
+ self.context["doc"] = False
64
+ self.context["run"] = True
65
+ self.context["cwd"] = Path.cwd()
66
+ return self
67
+
68
+
69
+ class ConfigurationModel(BaseModel):
70
+ """Initial configuration of a tutorial."""
71
+
72
+ model_config = ConfigDict(extra="forbid", title="Tutorial Configuration")
73
+
74
+ run: RuntimeConfigurationModel = RuntimeConfigurationModel()
75
+ doc: DocumentationConfigurationModel = DocumentationConfigurationModel()
76
+ context: dict[str, Any] = Field(
77
+ default_factory=dict, description="Initial context for both documentation and runtime."
78
+ )
79
+
80
+
81
+ class TutorialModel(BaseModel):
82
+ """Root structure for the entire tutorial."""
83
+
84
+ model_config = ConfigDict(extra="forbid", title="Tutorial")
85
+
86
+ # absolute path to YAML file
87
+ path: Path = Field(
88
+ description="Absolute path to the tutorial file. This field is populated automatically while loading the tutorial.", # noqa: E501
89
+ )
90
+ tutorial_root: Path = Field(
91
+ default_factory=default_tutorial_root_factory,
92
+ description="Directory from which relative file paths are resolved. Defaults to the path of the "
93
+ "tutorial file.",
94
+ ) # absolute path (input: relative to path)
95
+ parts: tuple[
96
+ Annotated[
97
+ PartModels
98
+ | Annotated[PromptModel, Tag("prompt")]
99
+ | Annotated[AlternativeModel, Tag("alternatives")],
100
+ Discriminator(part_discriminator),
101
+ ],
102
+ ...,
103
+ ] = Field(description="The individual parts of this tutorial.")
104
+ configuration: ConfigurationModel = Field(default=ConfigurationModel())
105
+
106
+ @field_validator("path", mode="after")
107
+ @classmethod
108
+ def validate_path(cls, value: Path, info: ValidationInfo) -> Path:
109
+ if not value.is_absolute():
110
+ raise ValueError(f"{value}: Must be an absolute path.")
111
+ return value
112
+
113
+ @field_validator("tutorial_root", mode="after")
114
+ @classmethod
115
+ def resolve_tutorial_root(cls, value: Path, info: ValidationInfo) -> Path:
116
+ if value.is_absolute():
117
+ raise ValueError(f"{value}: Must be a relative path (relative to the tutorial file).")
118
+ path: Path = info.data["path"]
119
+
120
+ return (path.parent / value).resolve()
121
+
122
+ @model_validator(mode="after")
123
+ def update_context(self) -> Self:
124
+ self.configuration.run.context["tutorial_path"] = self.path
125
+ self.configuration.run.context["tutorial_dir"] = self.path.parent
126
+ self.configuration.doc.context["tutorial_path"] = self.path
127
+ self.configuration.doc.context["tutorial_dir"] = self.path.parent
128
+ return self
129
+
130
+ @model_validator(mode="after")
131
+ def update_part_data(self) -> Self:
132
+ for part_no, part in enumerate(self.parts):
133
+ part.index = part_no
134
+ if not part.id:
135
+ part.id = str(part_no)
136
+ return self
137
+
138
+ @classmethod
139
+ def from_file(cls, path: Path) -> "TutorialModel":
140
+ """Load a tutorial from a YAML file."""
141
+ with open(path) as stream:
142
+ tutorial_data = safe_load(stream)
143
+
144
+ # e.g. an empty YAML file will return None
145
+ if not isinstance(tutorial_data, dict):
146
+ raise ValueError("File does not contain a mapping at top level.")
147
+
148
+ tutorial_data["path"] = path.resolve()
149
+ tutorial = TutorialModel.model_validate(tutorial_data, context={"path": path})
150
+ return tutorial
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2025 Mathias Ertl
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ """Validators for various models."""
5
+
6
+ import re
7
+ from typing import Any
8
+
9
+
10
+ def validate_regex(value: Any) -> Any:
11
+ """Validate and compile a regular expression."""
12
+ if isinstance(value, str):
13
+ return re.compile(value.encode())
14
+ return value # pragma: no cover