Coverage for structured_tutorials / models / parts.py: 100%
90 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +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 FileMixin,
18 template_field_title_generator,
19)
20from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel
21from structured_tutorials.typing import Self
24def 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"
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
43class PartMixin:
44 """Mixin used by all parts."""
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.")
52class CleanupCommandModel(CommandBaseModel):
53 """Command to clean up artifacts created by the current part."""
55 model_config = ConfigDict(extra="forbid")
57 command: CommandType = Field(description="Command that cleans up artifacts created by the main command.")
60class StdinCommandModel(FileMixin, BaseModel):
61 """Standard input for a command."""
64class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel):
65 """Model for runtime configuration when running a single command."""
67 model_config = ConfigDict(extra="forbid")
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
75class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
76 """Model for documenting a single command."""
78 model_config = ConfigDict(extra="forbid")
80 output: str = ""
83class CommandModel(BaseModel):
84 """A single command to run in this part."""
86 model_config = ConfigDict(extra="forbid")
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 )
97class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
98 """Runtime configuration for an entire commands part."""
100 model_config = ConfigDict(extra="forbid")
103class CommandsDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
104 """Documentation configuration for an entire commands part."""
106 model_config = ConfigDict(extra="forbid")
109class CommandsPartModel(PartMixin, BaseModel):
110 """A tutorial part consisting of one or more commands."""
112 model_config = ConfigDict(extra="forbid", title="Command part")
114 type: Literal["commands"] = "commands"
115 commands: tuple[CommandModel, ...]
117 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel()
118 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel()
121class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
122 """Configure a file part when running the tutorial."""
124 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
127class FileDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
128 """Configure a file part when rendering it as documentation.
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 """
134 model_config = ConfigDict(extra="forbid", title="File part documentation configuration")
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 )
153class FilePartModel(PartMixin, FileMixin, BaseModel):
154 """A tutorial part for creating a file.
156 Note that exactly one of `contents` or `source` is required.
157 """
159 model_config = ConfigDict(extra="forbid", title="File part")
161 type: Literal["file"] = "file"
163 destination: str = Field(
164 field_title_generator=template_field_title_generator,
165 description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}",
166 )
168 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
169 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel()
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
178class PromptModel(PartMixin, BaseModel):
179 """Allows you to inspect the current state of the tutorial manually."""
181 model_config = ConfigDict(extra="forbid", title="Prompt Configuration")
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 )
197PartModels = Annotated[CommandsPartModel, Tag("commands")] | Annotated[FilePartModel, Tag("file")]
200class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
201 """Configure an alternative part when running the tutorial."""
203 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
206class AlternativeDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
207 """Configure an alternative part when documenting the tutorial."""
209 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
212class AlternativeModel(PartMixin, BaseModel):
213 """A tutorial part that has several different alternatives.
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.
218 An alternative can contain parts for files or commands.
219 """
221 model_config = ConfigDict(extra="forbid", title="Alternatives")
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()