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.
- structured_tutorials/__init__.py +9 -0
- structured_tutorials/cli.py +83 -0
- structured_tutorials/errors.py +40 -0
- structured_tutorials/models/__init__.py +14 -0
- structured_tutorials/models/base.py +97 -0
- structured_tutorials/models/parts.py +227 -0
- structured_tutorials/models/tests.py +40 -0
- structured_tutorials/models/tutorial.py +150 -0
- structured_tutorials/models/validators.py +14 -0
- structured_tutorials/output.py +139 -0
- structured_tutorials/runners/__init__.py +2 -0
- structured_tutorials/runners/base.py +115 -0
- structured_tutorials/runners/local.py +254 -0
- structured_tutorials/sphinx/__init__.py +35 -0
- structured_tutorials/sphinx/directives.py +79 -0
- structured_tutorials/sphinx/utils.py +202 -0
- structured_tutorials/templates/alternative_part.rst.template +5 -0
- structured_tutorials/templates/file_part.rst.template +8 -0
- structured_tutorials/textwrap.py +96 -0
- structured_tutorials/typing.py +15 -0
- structured_tutorials/utils.py +78 -0
- structured_tutorials-0.1.0.dist-info/METADATA +114 -0
- structured_tutorials-0.1.0.dist-info/RECORD +26 -0
- structured_tutorials-0.1.0.dist-info/WHEEL +4 -0
- structured_tutorials-0.1.0.dist-info/entry_points.txt +2 -0
- structured_tutorials-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|