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

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 

24 

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" 

38 

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 

42 

43 

44class PartMixin: 

45 """Mixin used by all parts.""" 

46 

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

51 

52 

53class CleanupCommandModel(CommandBaseModel): 

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

55 

56 model_config = ConfigDict(extra="forbid") 

57 

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

59 

60 

61class StdinCommandModel(FileMixin, BaseModel): 

62 """Standard input for a command.""" 

63 

64 

65class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel): 

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

67 

68 model_config = ConfigDict(extra="forbid") 

69 

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 

78 

79 

80class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

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

82 

83 model_config = ConfigDict(extra="forbid") 

84 

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

86 

87 

88class CommandModel(BaseModel): 

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

90 

91 model_config = ConfigDict(extra="forbid") 

92 

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 ) 

100 

101 

102class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

104 

105 model_config = ConfigDict(extra="forbid") 

106 

107 

108class CommandsDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel): 

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

110 

111 model_config = ConfigDict(extra="forbid") 

112 

113 

114class CommandsPartModel(PartMixin, BaseModel): 

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

116 

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

118 

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

120 commands: tuple[CommandModel, ...] 

121 

122 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel() 

123 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel() 

124 

125 

126class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

128 

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

130 

131 

132class FileDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel): 

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

134 

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

138 

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

140 

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 ) 

156 

157 

158class FilePartModel(PartMixin, FileMixin, BaseModel): 

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

160 

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

162 """ 

163 

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

165 

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

167 

168 destination: str = Field( 

169 field_title_generator=template_field_title_generator, 

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

171 ) 

172 

173 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel() 

174 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel() 

175 

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 

181 

182 

183class PromptModel(PartMixin, BaseModel): 

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

185 

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

187 

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 ) 

200 

201 

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

203 

204 

205class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

207 

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

209 

210 

211class AlternativeDocumentationConfigurationModel( 

212 ConfigurationMixin, DocumentationConfigurationMixin, BaseModel 

213): 

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

215 

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

217 

218 

219class AlternativeModel(PartMixin, BaseModel): 

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

221 

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. 

224 

225 An alternative can contain parts for files or commands. 

226 """ 

227 

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

229 

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