Coverage for structured_tutorials / models / tutorial.py: 100%
79 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 22:55 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 22:55 +0100
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, Tag, field_validator, model_validator
10from pydantic_core.core_schema import ValidationInfo
11from yaml import safe_load
13from structured_tutorials.models.base import default_tutorial_root_factory
14from structured_tutorials.models.parts import AlternativeModel, PartModels, PromptModel, part_discriminator
15from structured_tutorials.typing import Self
18class DocumentationConfigurationModel(BaseModel):
19 """Initial configuration for rendering the tutorial as documentation."""
21 model_config = ConfigDict(extra="forbid", title="Documentation Configuration")
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 )
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
45class RuntimeConfigurationModel(BaseModel):
46 """Initial configuration for running the tutorial."""
48 model_config = ConfigDict(extra="forbid", title="Runtime Configuration")
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 )
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
69class ConfigurationModel(BaseModel):
70 """Initial configuration of a tutorial."""
72 model_config = ConfigDict(extra="forbid", title="Tutorial Configuration")
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 )
81class TutorialModel(BaseModel):
82 """Root structure for the entire tutorial."""
84 model_config = ConfigDict(extra="forbid", title="Tutorial")
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())
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
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"]
120 return (path.parent / value).resolve()
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
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
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)
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.")
148 tutorial_data["path"] = path.resolve()
149 tutorial = TutorialModel.model_validate(tutorial_data, context={"path": path})
150 return tutorial