Coverage for structured_tutorials / models / parts.py: 100%
90 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-26 12:41 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-26 12:41 +0100
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
25def part_discriminator(value: Any) -> str | None:
26 """Discriminator for parts."""
27 if isinstance(value, dict):
28 if typ := value.get("type"):
29 return typ # type: ignore[no-any-return]
30 if "commands" in value:
31 return "commands"
32 if "destination" in value:
33 return "file"
34 if "prompt" in value:
35 return "prompt"
36 if "alternatives" in value: # pragma: no branch # all alternatives covered
37 return "alternatives"
39 elif isinstance(value, PartMixin): # pragma: no cover # not really sure how to trigger this
40 return value.type
41 return None # pragma: no cover # not really sure how to trigger this
44class PartMixin:
45 """Mixin used by all parts."""
47 type: str
48 id: str = Field(default="", description="ID that can be used to reference the specific part.")
49 index: int = Field(default=0, description="Index of the part in the tutorial.")
50 name: str = Field(default="", description="Human-readable name of the part.")
53class CleanupCommandModel(CommandBaseModel):
54 """Command to clean up artifacts created by the current part."""
56 model_config = ConfigDict(extra="forbid")
58 command: CommandType = Field(description="Command that cleans up artifacts created by the main command.")
61class StdinCommandModel(FileMixin, BaseModel):
62 """Standard input for a command."""
65class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel):
66 """Runtime configuration for a single command."""
68 model_config = ConfigDict(extra="forbid")
70 chdir: Path | None = Field(
71 default=None,
72 description=f"Change working directory to this path. This change affects all subsequent commands."
73 f" {TEMPLATE_DESCRIPTION}",
74 )
75 cleanup: tuple[CleanupCommandModel, ...] = tuple()
76 test: tuple[TestCommandModel | TestPortModel | TestOutputModel, ...] = tuple()
77 stdin: StdinCommandModel | None = None
80class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
81 """Documentation configuration for a single command."""
83 model_config = ConfigDict(extra="forbid")
85 output: str = Field(default="", description="The output to show when rendering the command.")
88class CommandModel(BaseModel):
89 """A single command to run in this part."""
91 model_config = ConfigDict(extra="forbid")
93 command: CommandType = Field(description="The command to run.")
94 run: CommandRuntimeConfigurationModel = Field(
95 default=CommandRuntimeConfigurationModel(), description="The runtime configuration."
96 )
97 doc: CommandDocumentationConfigurationModel = Field(
98 default=CommandDocumentationConfigurationModel(), description="The documentation configuration."
99 )
102class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
103 """Runtime configuration for an entire commands part."""
105 model_config = ConfigDict(extra="forbid")
108class CommandsDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel):
109 """Documentation configuration for an entire commands part."""
111 model_config = ConfigDict(extra="forbid")
114class CommandsPartModel(PartMixin, BaseModel):
115 """A tutorial part consisting of one or more commands."""
117 model_config = ConfigDict(extra="forbid", title="Command part")
119 type: Literal["commands"] = "commands"
120 commands: tuple[CommandModel, ...]
122 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel()
123 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel()
126class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
127 """Configure a file part when running the tutorial."""
129 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
132class FileDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel):
133 """Configure a file part when rendering it as documentation.
135 For the `language`, `caption`, `linenos`, `lineno_start`, `emphasize_lines` and `name` options, please
136 consult the [sphinx documentation](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block).
137 """
139 model_config = ConfigDict(extra="forbid", title="File part documentation configuration")
141 # sphinx options:
142 language: str = Field(default="", description="The language used for the code block directive.")
143 caption: str | Literal[False] = Field(
144 default="",
145 description=f"The caption. Defaults to the `destination` of this part. {TEMPLATE_DESCRIPTION}",
146 )
147 linenos: bool = False
148 lineno_start: NonNegativeInt | Literal[False] = False
149 emphasize_lines: str = ""
150 name: str = ""
151 ignore_spelling: bool = Field(
152 default=False,
153 description="If true, wrap the caption in `:spelling:ignore:` (see"
154 " [sphinxcontrib.spelling](https://sphinxcontrib-spelling.readthedocs.io/en/latest/)).",
155 )
158class FilePartModel(PartMixin, FileMixin, BaseModel):
159 """A tutorial part for creating a file.
161 Note that exactly one of `contents` or `source` is required.
162 """
164 model_config = ConfigDict(extra="forbid", title="File part")
166 type: Literal["file"] = "file"
168 destination: str = Field(
169 field_title_generator=template_field_title_generator,
170 description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}",
171 )
173 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
174 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel()
176 @model_validator(mode="after")
177 def validate_destination(self) -> Self:
178 if not self.source and self.destination.endswith(os.sep):
179 raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.")
180 return self
183class PromptModel(PartMixin, BaseModel):
184 """Allows you to inspect the current state of the tutorial manually."""
186 model_config = ConfigDict(extra="forbid", title="Prompt Configuration")
188 type: Literal["prompt"] = "prompt"
189 prompt: str = Field(description=f"The prompt text. {TEMPLATE_DESCRIPTION}")
190 response: Literal["enter", "confirm"] = "enter"
191 default: bool = Field(
192 default=True, description="For type=`confirm`, the default if the user just presses enter."
193 )
194 error: str = Field(
195 default="State was not confirmed.",
196 description="For `type=confirm`, the error message if the user does not confirm the current state. "
197 "{TEMPLATE_DESCRIPTION} The context will also include the `response` variable, representing the user "
198 "response.",
199 )
202PartModels = Annotated[CommandsPartModel, Tag("commands")] | Annotated[FilePartModel, Tag("file")]
205class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
206 """Configure an alternative part when running the tutorial."""
208 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
211class AlternativeDocumentationConfigurationModel(
212 ConfigurationMixin, DocumentationConfigurationMixin, BaseModel
213):
214 """Configure an alternative part when documenting the tutorial."""
216 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
219class AlternativeModel(PartMixin, BaseModel):
220 """A tutorial part that has several different alternatives.
222 When rendering documentation, alternatives are rendered in tabs. When running a tutorial, the runner has
223 to specify exactly one (or at most one, if `required=False`) of the alternatives that should be run.
225 An alternative can contain parts for files or commands.
226 """
228 model_config = ConfigDict(extra="forbid", title="Alternatives")
230 type: Literal["alternatives"] = "alternatives"
231 alternatives: dict[str, Annotated[PartModels, Discriminator(part_discriminator)]]
232 required: bool = Field(default=True, description="Whether one of the alternatives is required.")
233 doc: AlternativeDocumentationConfigurationModel = AlternativeDocumentationConfigurationModel()
234 run: AlternativeRuntimeConfigurationModel = AlternativeRuntimeConfigurationModel()