Coverage for structured_tutorials / models.py: 99%

178 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-19 14:01 +0100

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Basic tutorial structure.""" 

5 

6import os 

7import re 

8from pathlib import Path 

9from typing import Annotated, Any, Literal, Self 

10 

11from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator, model_validator 

12from pydantic.fields import FieldInfo 

13from pydantic_core.core_schema import ValidationInfo 

14from yaml import safe_load 

15 

16PositiveInt = Annotated[int, Field(ge=0)] 

17PositiveFloat = Annotated[float, Field(ge=0)] 

18 

19TEMPLATE_DESCRIPTION = "This value is rendered as a template with the current context." 

20 

21 

22def default_tutorial_root_factory(data: dict[str, Any]) -> Path: 

23 """Default factory for the tutorial_root variable.""" 

24 tutorial_root = data["path"].parent 

25 assert isinstance(tutorial_root, Path) 

26 return tutorial_root 

27 

28 

29def template_field_title_generator(field_name: str, field_info: FieldInfo) -> str: 

30 """Field title generator for template fields.""" 

31 return f"{field_name.title()} (template)" 

32 

33 

34def validate_regex(value: Any) -> Any: 

35 """Validate and compile a regular expression.""" 

36 if isinstance(value, str): 36 ↛ 38line 36 didn't jump to line 38 because the condition on line 36 was always true

37 return re.compile(value) 

38 return value 

39 

40 

41class CommandBaseModel(BaseModel): 

42 """Base model for commands.""" 

43 

44 model_config = ConfigDict(extra="forbid") 

45 

46 status_code: Annotated[int, Field(ge=0, le=255)] = 0 

47 show_output: bool = Field( 

48 default=True, description="Set to `False` to always hide the output of this command." 

49 ) 

50 

51 

52class TestSpecificationMixin: 

53 """Mixin for specifying tests.""" 

54 

55 delay: Annotated[float, Field(ge=0)] = 0 

56 retry: PositiveInt = 0 

57 backoff_factor: PositiveFloat = 0 # {backoff factor} * (2 ** ({number of previous retries})) 

58 

59 

60class ConfigurationMixin: 

61 """Mixin for configuration models.""" 

62 

63 skip: bool = Field(default=False, description="Skip this part.") 

64 

65 

66class CleanupCommandModel(CommandBaseModel): 

67 """Model for cleanup commands.""" 

68 

69 model_config = ConfigDict(extra="forbid") 

70 

71 command: str 

72 

73 

74class TestCommandModel(TestSpecificationMixin, CommandBaseModel): 

75 """Model for a test command for a normal command.""" 

76 

77 model_config = ConfigDict(extra="forbid") 

78 

79 command: str 

80 

81 

82class TestPortModel(TestSpecificationMixin, BaseModel): 

83 """Model for testing connectivity after a command is run.""" 

84 

85 model_config = ConfigDict(extra="forbid") 

86 

87 host: str 

88 port: Annotated[int, Field(ge=0, le=65535)] 

89 

90 

91class TestOutputModel(BaseModel): 

92 """Test the output of the command.""" 

93 

94 model_config = ConfigDict(extra="forbid") 

95 stream: Literal["stdout", "stderr"] = "stdout" 

96 regex: Annotated[re.Pattern[str], BeforeValidator(validate_regex)] = Field( 

97 description="A regular expression to test." 

98 ) 

99 

100 

101class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel): 

102 """Model for runtime configuration when running a single command.""" 

103 

104 model_config = ConfigDict(extra="forbid") 

105 

106 update_context: dict[str, Any] = Field(default_factory=dict) 

107 cleanup: tuple[CleanupCommandModel, ...] = tuple() 

108 test: tuple[TestCommandModel | TestPortModel | TestOutputModel, ...] = tuple() 

109 

110 

111class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

112 """Model for documenting a single command.""" 

113 

114 model_config = ConfigDict(extra="forbid") 

115 

116 output: str = "" 

117 update_context: dict[str, Any] = Field(default_factory=dict) 

118 

119 

120class CommandModel(BaseModel): 

121 """Model for a single command.""" 

122 

123 model_config = ConfigDict(extra="forbid") 

124 

125 command: str 

126 run: CommandRuntimeConfigurationModel = CommandRuntimeConfigurationModel() 

127 doc: CommandDocumentationConfigurationModel = CommandDocumentationConfigurationModel() 

128 

129 

130class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

131 """Runtime configuration for an entire commands part.""" 

132 

133 model_config = ConfigDict(extra="forbid") 

134 

135 

136class CommandsDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

137 """Documentation configuration for an entire commands part.""" 

138 

139 model_config = ConfigDict(extra="forbid") 

140 

141 

142class CommandsPartModel(BaseModel): 

143 """A tutorial part consisting of one or more commands.""" 

144 

145 model_config = ConfigDict(extra="forbid", title="Command part") 

146 

147 type: Literal["commands"] = "commands" 

148 commands: tuple[CommandModel, ...] 

149 

150 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel() 

151 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel() 

152 

153 

154class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

155 """Configure a file part when running the tutorial.""" 

156 

157 model_config = ConfigDict(extra="forbid", title="File part runtime configuration") 

158 

159 

160class FileDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

161 """Configure a file part when rendering it as documentation. 

162 

163 For the `language`, `caption`, `linenos`, `lineno_start`, `emphasize_lines` and `name` options, please 

164 consult the [sphinx documentation](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block). 

165 """ 

166 

167 model_config = ConfigDict(extra="forbid", title="File part documentation configuration") 

168 

169 # sphinx options: 

170 language: str = Field(default="", description="The language used for the code block directive.") 

171 caption: str | Literal[False] = Field( 

172 default="", 

173 description=f"The caption. Defaults to the `destination` of this part. {TEMPLATE_DESCRIPTION}", 

174 ) 

175 linenos: bool = False 

176 lineno_start: PositiveInt | Literal[False] = False 

177 emphasize_lines: str = "" 

178 name: str = "" 

179 ignore_spelling: bool = Field( 

180 default=False, 

181 description="If true, wrap the caption in `:spelling:ignore:` (see" 

182 " [sphinxcontrib.spelling](https://sphinxcontrib-spelling.readthedocs.io/en/latest/)).", 

183 ) 

184 

185 

186class FilePartModel(BaseModel): 

187 """A tutorial part for creating a file. 

188 

189 Note that exactly one of `contents` or `source` is required. 

190 """ 

191 

192 model_config = ConfigDict(extra="forbid", title="File part") 

193 

194 type: Literal["file"] = "file" 

195 contents: str | None = Field( 

196 default=None, 

197 field_title_generator=template_field_title_generator, 

198 description=f"Contents of the file. {TEMPLATE_DESCRIPTION}", 

199 ) 

200 source: Path | None = Field( 

201 default=None, 

202 field_title_generator=template_field_title_generator, 

203 description="The source path of the file to create. Unless `template` is `False`, the file is loaded " 

204 "into memory and rendered as template.", 

205 ) 

206 destination: str = Field( 

207 field_title_generator=template_field_title_generator, 

208 description=f"The destination path of the file. {TEMPLATE_DESCRIPTION}", 

209 ) 

210 template: bool = Field( 

211 default=True, description="Whether the file contents should be rendered in a template." 

212 ) 

213 

214 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel() 

215 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel() 

216 

217 @field_validator("source", mode="after") 

218 @classmethod 

219 def validate_source(cls, value: Path) -> Path: 

220 if value.is_absolute(): 

221 raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).") 

222 return value 

223 

224 @model_validator(mode="after") 

225 def validate_contents_or_source(self) -> Self: 

226 if self.contents is None and self.source is None: 

227 raise ValueError("Either contents or source is required.") 

228 if self.contents is not None and self.source is not None: 

229 raise ValueError("Only one of contents or source is allowed.") 

230 return self 

231 

232 @model_validator(mode="after") 

233 def validate_destination(self) -> Self: 

234 if not self.source and self.destination.endswith(os.sep): 

235 raise ValueError(f"{self.destination}: Destination must not be a directory if contents is given.") 

236 return self 

237 

238 

239class RuntimeConfigurationModel(BaseModel): 

240 """Initial configuration for running the tutorial.""" 

241 

242 model_config = ConfigDict(extra="forbid", title="Runtime Configuration") 

243 

244 context: dict[str, Any] = Field( 

245 default_factory=dict, description="Key/value pairs for the initial context when rendering templates." 

246 ) 

247 temporary_directory: bool = Field( 

248 default=False, description="Switch to an empty temporary directory before running the tutorial." 

249 ) 

250 git_export: bool = Field( 

251 default=False, 

252 description="Export a git archive to a temporary directory before running the tutorial.", 

253 ) 

254 

255 @model_validator(mode="after") 

256 def set_default_context(self) -> Self: 

257 self.context["doc"] = False 

258 self.context["run"] = True 

259 self.context["cwd"] = Path.cwd() 

260 return self 

261 

262 

263class DocumentationConfigurationModel(BaseModel): 

264 """Initial configuration for rendering the tutorial as documentation.""" 

265 

266 model_config = ConfigDict(extra="forbid", title="Documentation Configuration") 

267 

268 context: dict[str, Any] = Field( 

269 default_factory=dict, description="Key/value pairs for the initial context when rendering templates." 

270 ) 

271 alternative_names: dict[str, str] = Field( 

272 default_factory=dict, 

273 description="Names for alternative keys, used in tab titles. By default, the key itself is used.", 

274 ) 

275 

276 @model_validator(mode="after") 

277 def set_default_context(self) -> Self: 

278 self.context["run"] = False 

279 self.context["doc"] = True 

280 self.context.setdefault("user", "user") 

281 self.context.setdefault("host", "host") 

282 self.context.setdefault("cwd", "~") 

283 self.context.setdefault( 

284 "prompt_template", 

285 "{{ user }}@{{ host }}:{{ cwd }}{% if user == 'root' %}#{% else %}${% endif %} ", 

286 ) 

287 return self 

288 

289 

290class PromptModel(BaseModel): 

291 """Allows you to inspect the current state of the tutorial manually.""" 

292 

293 model_config = ConfigDict(extra="forbid", title="Prompt Configuration") 

294 prompt: str = Field(description=f"The prompt text. {TEMPLATE_DESCRIPTION}") 

295 type: Literal["enter", "confirm"] = "enter" 

296 default: bool = Field( 

297 default=True, description="For type=`confirm`, the default if the user just presses enter." 

298 ) 

299 error: str = Field( 

300 default="State was not confirmed.", 

301 description="For `type=confirm`, the error message if the user does not confirm the current state. " 

302 "{TEMPLATE_DESCRIPTION} The context will also include the `response` variable, representing the user " 

303 "response.", 

304 ) 

305 

306 

307PartModels = CommandsPartModel | FilePartModel 

308 

309 

310class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

311 """Configure an alternative part when running the tutorial.""" 

312 

313 model_config = ConfigDict(extra="forbid", title="File part runtime configuration") 

314 

315 

316class AlternativeDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

317 """Configure an alternative part when documenting the tutorial.""" 

318 

319 model_config = ConfigDict(extra="forbid", title="File part runtime configuration") 

320 

321 

322class AlternativeModel(BaseModel): 

323 """A tutorial part that has several different alternatives. 

324 

325 When rendering documentation, alternatives are rendered in tabs. When running a tutorial, the runner has 

326 to specify exactly one (or at most one, if `required=False`) of the alternatives that should be run. 

327 

328 An alternative can contain parts for files or commands. 

329 """ 

330 

331 model_config = ConfigDict(extra="forbid", title="Alternatives") 

332 

333 alternatives: dict[str, PartModels] 

334 required: bool = Field(default=True, description="Whether one of the alternatives is required.") 

335 doc: AlternativeDocumentationConfigurationModel = AlternativeDocumentationConfigurationModel() 

336 run: AlternativeRuntimeConfigurationModel = AlternativeRuntimeConfigurationModel() 

337 

338 

339class ConfigurationModel(BaseModel): 

340 """Initial configuration of a tutorial.""" 

341 

342 model_config = ConfigDict(extra="forbid", title="Tutorial Configuration") 

343 

344 run: RuntimeConfigurationModel = RuntimeConfigurationModel() 

345 doc: DocumentationConfigurationModel = DocumentationConfigurationModel() 

346 

347 

348class TutorialModel(BaseModel): 

349 """Root structure for the entire tutorial.""" 

350 

351 model_config = ConfigDict(extra="forbid", title="Tutorial") 

352 

353 # absolute path to YAML file 

354 path: Path = Field( 

355 description="Absolute path to the tutorial file. This field is populated automatically while loading the tutorial.", # noqa: E501 

356 ) 

357 tutorial_root: Path = Field( 

358 default_factory=default_tutorial_root_factory, 

359 description="Directory from which relative file paths are resolved. Defaults to the path of the " 

360 "tutorial file.", 

361 ) # absolute path (input: relative to path) 

362 parts: tuple[PartModels | PromptModel | AlternativeModel, ...] = Field( 

363 description="The individual parts of this tutorial." 

364 ) 

365 configuration: ConfigurationModel = Field(default=ConfigurationModel()) 

366 

367 @field_validator("path", mode="after") 

368 @classmethod 

369 def validate_path(cls, value: Path, info: ValidationInfo) -> Path: 

370 if not value.is_absolute(): 

371 raise ValueError(f"{value}: Must be an absolute path.") 

372 return value 

373 

374 @field_validator("tutorial_root", mode="after") 

375 @classmethod 

376 def resolve_tutorial_root(cls, value: Path, info: ValidationInfo) -> Path: 

377 if value.is_absolute(): 

378 raise ValueError(f"{value}: Must be a relative path (relative to the tutorial file).") 

379 path: Path = info.data["path"] 

380 

381 return (path.parent / value).resolve() 

382 

383 @model_validator(mode="after") 

384 def update_context(self) -> Self: 

385 self.configuration.run.context["tutorial_path"] = self.path 

386 self.configuration.run.context["tutorial_dir"] = self.path.parent 

387 self.configuration.doc.context["tutorial_path"] = self.path 

388 self.configuration.doc.context["tutorial_dir"] = self.path.parent 

389 return self 

390 

391 @classmethod 

392 def from_file(cls, path: Path) -> "TutorialModel": 

393 """Load a tutorial from a YAML file.""" 

394 with open(path) as stream: 

395 tutorial_data = safe_load(stream) 

396 tutorial_data["path"] = path.resolve() 

397 tutorial = TutorialModel.model_validate(tutorial_data, context={"path": path}) 

398 return tutorial