Coverage for structured_tutorials / models / tutorial.py: 100%
91 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:17 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:17 +0200
1# Copyright (c) 2025 Mathias Ertl
2# Licensed under the MIT License. See LICENSE file for details.
4"""Module containing main tutorial model and global configuration models."""
6from pathlib import Path
7from typing import Annotated, Any
9from pydantic import BaseModel, ConfigDict, Discriminator, Field, RootModel, Tag, model_validator
10from pydantic_core.core_schema import ValidationInfo
11from yaml import safe_load
13from structured_tutorials.models.base import DictRootModelMixin
14from structured_tutorials.models.parts import AlternativeModel, PartModels, PromptModel, part_discriminator
15from structured_tutorials.typing import Self
18class DocumentationAlternativeConfigurationModel(BaseModel):
19 """Additional documentation configuration for alternatives."""
21 model_config = ConfigDict(extra="forbid", title="Additional configuration for alternatives.")
23 context: dict[str, Any] = Field(
24 default_factory=dict, description="Key/value pairs for the initial context for an alternative."
25 )
26 name: str = Field(default="", description="Name of the alternative (used e.g. in tab titles).")
29class RuntimeAlternativeConfigurationModel(BaseModel):
30 """Additional runtime configuration for alternatives."""
32 model_config = ConfigDict(extra="forbid", title="Additional configuration for alternatives.")
34 context: dict[str, Any] = Field(
35 default_factory=dict, description="Key/value pairs for the initial context for an alternative."
36 )
37 environment: dict[str, str | None] = Field(
38 default_factory=dict, description="Additional environment variables for all commands."
39 )
40 required_executables: tuple[str, ...] = Field(default=tuple(), description="Required executables.")
43class DocumentationAlternativesConfigurationModel(
44 DictRootModelMixin[DocumentationAlternativeConfigurationModel],
45 RootModel[dict[str, DocumentationAlternativeConfigurationModel]],
46):
47 """Configuration for alternatives when rendering documentation."""
49 pass
52class RuntimeAlternativesConfigurationModel(
53 DictRootModelMixin[RuntimeAlternativeConfigurationModel],
54 RootModel[dict[str, RuntimeAlternativeConfigurationModel]],
55):
56 """Configuration for alternatives at runtime."""
58 pass
61class DocumentationConfigurationModel(BaseModel):
62 """Initial configuration for rendering the tutorial as documentation."""
64 model_config = ConfigDict(extra="forbid", title="Documentation Configuration")
66 context: dict[str, Any] = Field(
67 default_factory=dict, description="Key/value pairs for the initial context when rendering templates."
68 )
69 alternatives: DocumentationAlternativesConfigurationModel = Field(
70 default_factory=lambda: DocumentationAlternativesConfigurationModel({})
71 )
73 @model_validator(mode="after")
74 def set_default_context(self) -> Self:
75 self.context["run"] = False
76 self.context["doc"] = True
77 self.context.setdefault("user", "user")
78 self.context.setdefault("host", "host")
79 self.context.setdefault("cwd", "~")
80 self.context.setdefault(
81 "prompt_template",
82 "{{ user }}@{{ host }}:{{ cwd }}{% if user == 'root' %}#{% else %}${% endif %} ",
83 )
84 return self
87class RunnerConfig(BaseModel):
88 """Configuration for the runner itself."""
90 path: str = "structured_tutorials.runners.local.LocalTutorialRunner"
91 options: dict[str, Any] = Field(default_factory=dict)
94class RuntimeConfigurationModel(BaseModel):
95 """Initial configuration for running the tutorial."""
97 model_config = ConfigDict(extra="forbid", title="Runtime Configuration")
99 runner: RunnerConfig = RunnerConfig()
100 context: dict[str, Any] = Field(
101 default_factory=dict, description="Key/value pairs for the initial context when rendering templates."
102 )
103 temporary_directory: bool = Field(
104 default=False, description="Switch to an empty temporary directory before running the tutorial."
105 )
106 git_export: bool = Field(
107 default=False,
108 description="Export a git archive to a temporary directory before running the tutorial.",
109 )
110 environment: dict[str, str | None] = Field(
111 default_factory=dict,
112 description="Additional environment variables for all commands."
113 "Set a value to `None` to clear it from the global environment.",
114 )
115 clear_environment: bool = Field(default=False, description="Clear the environment for all commands.")
116 alternatives: RuntimeAlternativesConfigurationModel = Field(
117 default_factory=lambda: RuntimeAlternativesConfigurationModel({})
118 )
119 required_executables: tuple[str, ...] = Field(default=tuple(), description="Required executables.")
121 @model_validator(mode="after")
122 def set_default_context(self) -> Self:
123 self.context["doc"] = False
124 self.context["run"] = True
125 self.context["cwd"] = Path.cwd()
126 return self
129class ConfigurationModel(BaseModel):
130 """Initial configuration of a tutorial."""
132 model_config = ConfigDict(extra="forbid", title="Tutorial Configuration")
134 run: RuntimeConfigurationModel = RuntimeConfigurationModel()
135 doc: DocumentationConfigurationModel = DocumentationConfigurationModel()
136 context: dict[str, Any] = Field(
137 default_factory=dict, description="Initial context for both documentation and runtime."
138 )
141class TutorialModel(BaseModel):
142 """Root structure for the entire tutorial."""
144 model_config = ConfigDict(extra="forbid", title="Tutorial")
146 root: Path = Field(
147 json_schema_extra={"required": False},
148 description="Directory from which relative file paths are resolved. Defaults to the path of the "
149 "tutorial file.",
150 ) # absolute path
151 configuration: ConfigurationModel = Field(default=ConfigurationModel())
152 parts: tuple[
153 Annotated[
154 PartModels
155 | Annotated[PromptModel, Tag("prompt")]
156 | Annotated[AlternativeModel, Tag("alternatives")],
157 Discriminator(part_discriminator),
158 ],
159 ...,
160 ] = Field(description="The individual parts of this tutorial.")
162 @classmethod
163 def model_json_schema(cls, **kwargs: Any) -> dict[str, Any]: # type: ignore[override] # pragma: no cover
164 schema = super().model_json_schema(**kwargs)
165 schema["required"].remove("root")
166 return schema
168 @model_validator(mode="after")
169 def update_context(self, info: ValidationInfo) -> Self:
170 if isinstance(info.context, dict):
171 if path := info.context.get("path"): # pragma: no branch
172 assert isinstance(path, Path)
173 self.configuration.run.context["tutorial_path"] = path
174 self.configuration.run.context["tutorial_dir"] = path.parent
175 self.configuration.doc.context["tutorial_path"] = path
176 self.configuration.doc.context["tutorial_dir"] = path.parent
178 if not self.root.is_absolute():
179 self.root = (path.parent / self.root).resolve()
180 return self
182 @model_validator(mode="after")
183 def update_part_data(self) -> Self:
184 for part_no, part in enumerate(self.parts):
185 part.index = part_no
186 if not part.id:
187 part.id = str(part_no)
188 return self
190 @classmethod
191 def from_file(cls, path: Path) -> "TutorialModel":
192 """Load a tutorial from a YAML file."""
193 with open(path) as stream:
194 tutorial_data = safe_load(stream)
196 # e.g. an empty YAML file will return None
197 if not isinstance(tutorial_data, dict):
198 raise ValueError("File does not contain a mapping at top level.")
200 tutorial_data.setdefault("root", path.resolve().parent)
201 tutorial = TutorialModel.model_validate(tutorial_data, context={"path": path})
202 return tutorial