Coverage for structured_tutorials / models / parts.py: 100%
97 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"""Basic tutorial structure."""
6import os
7from pathlib import Path
8from typing import Annotated, Any, Literal
10from pydantic import BaseModel, ConfigDict, Discriminator, Field, NonNegativeInt, Tag, model_validator
12from structured_tutorials.models.base import (
13 TEMPLATE_DESCRIPTION,
14 CommandBaseModel,
15 CommandType,
16 ConfigurationMixin,
17 DocumentationConfigurationMixin,
18 FileMixin,
19 template_field_title_generator,
20)
21from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel
22from structured_tutorials.typing import Self
24OPTIONS_TYPE = Annotated[
25 dict[str, Any], Field(description="Custom options for the selected runtime backend.")
26]
27CHDIR_TYPE = Path | Literal[False] | None
30def part_discriminator(value: Any) -> str | None:
31 """Discriminator for parts."""
32 if isinstance(value, dict):
33 if typ := value.get("type"):
34 return typ # type: ignore[no-any-return]
35 if "commands" in value:
36 return "commands"
37 if "destination" in value:
38 return "file"
39 if "prompt" in value:
40 return "prompt"
41 if "alternatives" in value: # pragma: no branch # all alternatives covered
42 return "alternatives"
44 elif isinstance(value, PartMixin): # pragma: no cover # not really sure how to trigger this
45 return value.type
46 return None # pragma: no cover # not really sure how to trigger this
49class PartMixin:
50 """Mixin used by all parts."""
52 type: str
53 id: str = Field(default="", description="ID that can be used to reference the specific part.")
54 index: int = Field(default=0, description="Index of the part in the tutorial.")
55 name: str = Field(default="", description="Human-readable name of the part.")
58class CleanupCommandModel(CommandBaseModel):
59 """Command to clean up artifacts created by the current part."""
61 model_config = ConfigDict(extra="forbid")
63 command: CommandType = Field(description="Command that cleans up artifacts created by the main command.")
66class StdinCommandModel(FileMixin, BaseModel):
67 """Standard input for a command."""
70class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel):
71 """Runtime configuration for a single command."""
73 model_config = ConfigDict(extra="forbid")
75 cleanup: tuple[CleanupCommandModel, ...] = tuple()
76 test: tuple[TestCommandModel | TestPortModel | TestOutputModel, ...] = tuple()
77 stdin: StdinCommandModel | None = None
78 options: OPTIONS_TYPE = Field(default_factory=dict)
81class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
82 """Documentation configuration for a single command."""
84 model_config = ConfigDict(extra="forbid")
86 output: str = Field(default="", description="The output to show when rendering the command.")
89class CommandModel(BaseModel):
90 """A single command to run in this part."""
92 model_config = ConfigDict(extra="forbid")
94 command: CommandType = Field(description="The command to run.")
95 chdir: CHDIR_TYPE = Field(
96 default=None,
97 description=f"Change working directory to this path *after* running this command. "
98 f"This change affects all subsequent commands. {TEMPLATE_DESCRIPTION}",
99 )
100 run: CommandRuntimeConfigurationModel | Literal[False] = Field(
101 default=CommandRuntimeConfigurationModel(),
102 description="The runtime configuration (or `False` to skip at runtime).",
103 )
104 doc: CommandDocumentationConfigurationModel | Literal[False] = Field(
105 default=CommandDocumentationConfigurationModel(),
106 description="The documentation configuration (or `False` to skip at runtime).",
107 )
110class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
111 """Runtime configuration for an entire commands part."""
113 model_config = ConfigDict(extra="forbid")
115 options: OPTIONS_TYPE = Field(default_factory=dict)
118class CommandsDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel):
119 """Documentation configuration for an entire commands part."""
121 model_config = ConfigDict(extra="forbid")
124class CommandsPartModel(PartMixin, BaseModel):
125 """A tutorial part consisting of one or more commands."""
127 model_config = ConfigDict(extra="forbid", title="Command part")
129 type: Literal["commands"] = "commands"
130 commands: tuple[CommandModel, ...]
132 run: CommandsRuntimeConfigurationModel | Literal[False] = CommandsRuntimeConfigurationModel()
133 doc: CommandsDocumentationConfigurationModel | Literal[False] = CommandsDocumentationConfigurationModel()
136class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
137 """Configure a file part when running the tutorial."""
139 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
141 options: OPTIONS_TYPE = Field(default_factory=dict)
144class FileDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel):
145 """Configure a file part when rendering it as documentation.
147 For the `language`, `caption`, `linenos`, `lineno_start`, `emphasize_lines` and `name` options, please
148 consult the [sphinx documentation](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block).
149 """
151 model_config = ConfigDict(extra="forbid", title="File part documentation configuration")
153 # sphinx options:
154 language: str = Field(default="", description="The language used for the code block directive.")
155 caption: str | Literal[False] = Field(
156 default="",
157 description=f"The caption. Defaults to the `destination` of this part. {TEMPLATE_DESCRIPTION}",
158 )
159 linenos: bool = False
160 lineno_start: NonNegativeInt | Literal[False] = False
161 emphasize_lines: str = ""
162 name: str = ""
163 ignore_spelling: bool = Field(
164 default=False,
165 description="If true, wrap the caption in `:spelling:ignore:` (see"
166 " [sphinxcontrib.spelling](https://sphinxcontrib-spelling.readthedocs.io/en/latest/)).",
167 )
170class FilePartModel(PartMixin, FileMixin, BaseModel):
171 """A tutorial part for creating a file.
173 Note that exactly one of `contents` or `source` is required.
174 """
176 model_config = ConfigDict(extra="forbid", title="File part")
178 type: Literal["file"] = "file"
180 destination: str = Field(
181 field_title_generator=template_field_title_generator,
182 description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}",
183 )
185 doc: FileDocumentationConfigurationModel | Literal[False] = FileDocumentationConfigurationModel()
186 run: FileRuntimeConfigurationModel | Literal[False] = FileRuntimeConfigurationModel()
188 @model_validator(mode="after")
189 def validate_destination(self) -> Self:
190 if not self.source and self.destination.endswith(os.sep):
191 raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.")
192 return self
195class PromptModel(PartMixin, BaseModel):
196 """Allows you to inspect the current state of the tutorial manually."""
198 model_config = ConfigDict(extra="forbid", title="Prompt Configuration")
200 type: Literal["prompt"] = "prompt"
201 prompt: str = Field(description=f"The prompt text. {TEMPLATE_DESCRIPTION}")
202 response: Literal["enter", "confirm"] = "enter"
203 default: bool = Field(
204 default=True, description="For type=`confirm`, the default if the user just presses enter."
205 )
206 error: str = Field(
207 default="State was not confirmed.",
208 description="For `type=confirm`, the error message if the user does not confirm the current state. "
209 "{TEMPLATE_DESCRIPTION} The context will also include the `response` variable, representing the user "
210 "response.",
211 )
214PartModels = Annotated[CommandsPartModel, Tag("commands")] | Annotated[FilePartModel, Tag("file")]
217class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
218 """Configure an alternative part when running the tutorial."""
220 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
222 options: OPTIONS_TYPE = Field(default_factory=dict)
225class AlternativeDocumentationConfigurationModel(
226 ConfigurationMixin, DocumentationConfigurationMixin, BaseModel
227):
228 """Configure an alternative part when documenting the tutorial."""
230 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
233class AlternativeModel(PartMixin, BaseModel):
234 """A tutorial part that has several different alternatives.
236 When rendering documentation, alternatives are rendered in tabs. When running a tutorial, the runner has
237 to specify exactly one (or at most one, if `required=False`) of the alternatives that should be run.
239 An alternative can contain parts for files or commands.
240 """
242 model_config = ConfigDict(extra="forbid", title="Alternatives")
244 type: Literal["alternatives"] = "alternatives"
245 alternatives: dict[str, Annotated[PartModels, Discriminator(part_discriminator)]]
246 required: bool = Field(default=True, description="Whether one of the alternatives is required.")
247 chdir: CHDIR_TYPE = Field(
248 default=None,
249 description=f"Change working directory to this path *after* this Alternatives block. "
250 f"This change affects all subsequent commands. {TEMPLATE_DESCRIPTION}",
251 )
252 doc: AlternativeDocumentationConfigurationModel | Literal[False] = (
253 AlternativeDocumentationConfigurationModel()
254 )
255 run: AlternativeRuntimeConfigurationModel | Literal[False] = AlternativeRuntimeConfigurationModel()