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

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 

7from pathlib import Path 

8from typing import Annotated, Any, Literal 

9 

10from pydantic import BaseModel, ConfigDict, Discriminator, Field, NonNegativeInt, Tag, model_validator 

11 

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 

23 

24OPTIONS_TYPE = Annotated[ 

25 dict[str, Any], Field(description="Custom options for the selected runtime backend.") 

26] 

27CHDIR_TYPE = Path | Literal[False] | None 

28 

29 

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" 

43 

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 

47 

48 

49class PartMixin: 

50 """Mixin used by all parts.""" 

51 

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.") 

56 

57 

58class CleanupCommandModel(CommandBaseModel): 

59 """Command to clean up artifacts created by the current part.""" 

60 

61 model_config = ConfigDict(extra="forbid") 

62 

63 command: CommandType = Field(description="Command that cleans up artifacts created by the main command.") 

64 

65 

66class StdinCommandModel(FileMixin, BaseModel): 

67 """Standard input for a command.""" 

68 

69 

70class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel): 

71 """Runtime configuration for a single command.""" 

72 

73 model_config = ConfigDict(extra="forbid") 

74 

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) 

79 

80 

81class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

82 """Documentation configuration for a single command.""" 

83 

84 model_config = ConfigDict(extra="forbid") 

85 

86 output: str = Field(default="", description="The output to show when rendering the command.") 

87 

88 

89class CommandModel(BaseModel): 

90 """A single command to run in this part.""" 

91 

92 model_config = ConfigDict(extra="forbid") 

93 

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 ) 

108 

109 

110class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

112 

113 model_config = ConfigDict(extra="forbid") 

114 

115 options: OPTIONS_TYPE = Field(default_factory=dict) 

116 

117 

118class CommandsDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel): 

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

120 

121 model_config = ConfigDict(extra="forbid") 

122 

123 

124class CommandsPartModel(PartMixin, BaseModel): 

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

126 

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

128 

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

130 commands: tuple[CommandModel, ...] 

131 

132 run: CommandsRuntimeConfigurationModel | Literal[False] = CommandsRuntimeConfigurationModel() 

133 doc: CommandsDocumentationConfigurationModel | Literal[False] = CommandsDocumentationConfigurationModel() 

134 

135 

136class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

138 

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

140 

141 options: OPTIONS_TYPE = Field(default_factory=dict) 

142 

143 

144class FileDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel): 

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

146 

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 """ 

150 

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

152 

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 ) 

168 

169 

170class FilePartModel(PartMixin, FileMixin, BaseModel): 

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

172 

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

174 """ 

175 

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

177 

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

179 

180 destination: str = Field( 

181 field_title_generator=template_field_title_generator, 

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

183 ) 

184 

185 doc: FileDocumentationConfigurationModel | Literal[False] = FileDocumentationConfigurationModel() 

186 run: FileRuntimeConfigurationModel | Literal[False] = FileRuntimeConfigurationModel() 

187 

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 

193 

194 

195class PromptModel(PartMixin, BaseModel): 

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

197 

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

199 

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 ) 

212 

213 

214PartModels = Annotated[CommandsPartModel, Tag("commands")] | Annotated[FilePartModel, Tag("file")] 

215 

216 

217class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

219 

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

221 

222 options: OPTIONS_TYPE = Field(default_factory=dict) 

223 

224 

225class AlternativeDocumentationConfigurationModel( 

226 ConfigurationMixin, DocumentationConfigurationMixin, BaseModel 

227): 

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

229 

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

231 

232 

233class AlternativeModel(PartMixin, BaseModel): 

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

235 

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. 

238 

239 An alternative can contain parts for files or commands. 

240 """ 

241 

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

243 

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()