Coverage for structured_tutorials / models / parts.py: 100%
90 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"""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 """Model for runtime configuration when running a single command."""
68 model_config = ConfigDict(extra="forbid")
70 chdir: Path | None = Field(default=None, description="Change working directory to this path.")
71 cleanup: tuple[CleanupCommandModel, ...] = tuple()
72 test: tuple[TestCommandModel | TestPortModel | TestOutputModel, ...] = tuple()
73 stdin: StdinCommandModel | None = None
76class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel):
77 """Model for documenting a single command."""
79 model_config = ConfigDict(extra="forbid")
81 output: str = ""
84class CommandModel(BaseModel):
85 """A single command to run in this part."""
87 model_config = ConfigDict(extra="forbid")
89 command: CommandType = Field(description="The command to run.")
90 run: CommandRuntimeConfigurationModel = Field(
91 default=CommandRuntimeConfigurationModel(), description="The runtime configuration."
92 )
93 doc: CommandDocumentationConfigurationModel = Field(
94 default=CommandDocumentationConfigurationModel(), description="The documentation configuration."
95 )
98class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
99 """Runtime configuration for an entire commands part."""
101 model_config = ConfigDict(extra="forbid")
104class CommandsDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel):
105 """Documentation configuration for an entire commands part."""
107 model_config = ConfigDict(extra="forbid")
110class CommandsPartModel(PartMixin, BaseModel):
111 """A tutorial part consisting of one or more commands."""
113 model_config = ConfigDict(extra="forbid", title="Command part")
115 type: Literal["commands"] = "commands"
116 commands: tuple[CommandModel, ...]
118 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel()
119 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel()
122class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
123 """Configure a file part when running the tutorial."""
125 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
128class FileDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel):
129 """Configure a file part when rendering it as documentation.
131 For the `language`, `caption`, `linenos`, `lineno_start`, `emphasize_lines` and `name` options, please
132 consult the [sphinx documentation](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block).
133 """
135 model_config = ConfigDict(extra="forbid", title="File part documentation configuration")
137 # sphinx options:
138 language: str = Field(default="", description="The language used for the code block directive.")
139 caption: str | Literal[False] = Field(
140 default="",
141 description=f"The caption. Defaults to the `destination` of this part. {TEMPLATE_DESCRIPTION}",
142 )
143 linenos: bool = False
144 lineno_start: NonNegativeInt | Literal[False] = False
145 emphasize_lines: str = ""
146 name: str = ""
147 ignore_spelling: bool = Field(
148 default=False,
149 description="If true, wrap the caption in `:spelling:ignore:` (see"
150 " [sphinxcontrib.spelling](https://sphinxcontrib-spelling.readthedocs.io/en/latest/)).",
151 )
154class FilePartModel(PartMixin, FileMixin, BaseModel):
155 """A tutorial part for creating a file.
157 Note that exactly one of `contents` or `source` is required.
158 """
160 model_config = ConfigDict(extra="forbid", title="File part")
162 type: Literal["file"] = "file"
164 destination: str = Field(
165 field_title_generator=template_field_title_generator,
166 description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}",
167 )
169 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel()
170 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel()
172 @model_validator(mode="after")
173 def validate_destination(self) -> Self:
174 if not self.source and self.destination.endswith(os.sep):
175 raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.")
176 return self
179class PromptModel(PartMixin, BaseModel):
180 """Allows you to inspect the current state of the tutorial manually."""
182 model_config = ConfigDict(extra="forbid", title="Prompt Configuration")
184 type: Literal["prompt"] = "prompt"
185 prompt: str = Field(description=f"The prompt text. {TEMPLATE_DESCRIPTION}")
186 response: Literal["enter", "confirm"] = "enter"
187 default: bool = Field(
188 default=True, description="For type=`confirm`, the default if the user just presses enter."
189 )
190 error: str = Field(
191 default="State was not confirmed.",
192 description="For `type=confirm`, the error message if the user does not confirm the current state. "
193 "{TEMPLATE_DESCRIPTION} The context will also include the `response` variable, representing the user "
194 "response.",
195 )
198PartModels = Annotated[CommandsPartModel, Tag("commands")] | Annotated[FilePartModel, Tag("file")]
201class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel):
202 """Configure an alternative part when running the tutorial."""
204 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
207class AlternativeDocumentationConfigurationModel(
208 ConfigurationMixin, DocumentationConfigurationMixin, BaseModel
209):
210 """Configure an alternative part when documenting the tutorial."""
212 model_config = ConfigDict(extra="forbid", title="File part runtime configuration")
215class AlternativeModel(PartMixin, BaseModel):
216 """A tutorial part that has several different alternatives.
218 When rendering documentation, alternatives are rendered in tabs. When running a tutorial, the runner has
219 to specify exactly one (or at most one, if `required=False`) of the alternatives that should be run.
221 An alternative can contain parts for files or commands.
222 """
224 model_config = ConfigDict(extra="forbid", title="Alternatives")
226 type: Literal["alternatives"] = "alternatives"
227 alternatives: dict[str, Annotated[PartModels, Discriminator(part_discriminator)]]
228 required: bool = Field(default=True, description="Whether one of the alternatives is required.")
229 doc: AlternativeDocumentationConfigurationModel = AlternativeDocumentationConfigurationModel()
230 run: AlternativeRuntimeConfigurationModel = AlternativeRuntimeConfigurationModel()