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

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 FileMixin, 

18 template_field_title_generator, 

19) 

20from structured_tutorials.models.tests import TestCommandModel, TestOutputModel, TestPortModel 

21from structured_tutorials.typing import Self 

22 

23 

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" 

37 

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 

41 

42 

43class PartMixin: 

44 """Mixin used by all parts.""" 

45 

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

50 

51 

52class CleanupCommandModel(CommandBaseModel): 

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

54 

55 model_config = ConfigDict(extra="forbid") 

56 

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

58 

59 

60class StdinCommandModel(FileMixin, BaseModel): 

61 """Standard input for a command.""" 

62 

63 

64class CommandRuntimeConfigurationModel(ConfigurationMixin, CommandBaseModel): 

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

66 

67 model_config = ConfigDict(extra="forbid") 

68 

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 

73 

74 

75class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

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

77 

78 model_config = ConfigDict(extra="forbid") 

79 

80 output: str = "" 

81 

82 

83class CommandModel(BaseModel): 

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

85 

86 model_config = ConfigDict(extra="forbid") 

87 

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 ) 

95 

96 

97class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

99 

100 model_config = ConfigDict(extra="forbid") 

101 

102 

103class CommandsDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

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

105 

106 model_config = ConfigDict(extra="forbid") 

107 

108 

109class CommandsPartModel(PartMixin, BaseModel): 

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

111 

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

113 

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

115 commands: tuple[CommandModel, ...] 

116 

117 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel() 

118 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel() 

119 

120 

121class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

123 

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

125 

126 

127class FileDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

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

129 

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

133 

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

135 

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 ) 

151 

152 

153class FilePartModel(PartMixin, FileMixin, BaseModel): 

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

155 

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

157 """ 

158 

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

160 

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

162 

163 destination: str = Field( 

164 field_title_generator=template_field_title_generator, 

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

166 ) 

167 

168 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel() 

169 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel() 

170 

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 

176 

177 

178class PromptModel(PartMixin, BaseModel): 

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

180 

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

182 

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 ) 

195 

196 

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

198 

199 

200class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

202 

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

204 

205 

206class AlternativeDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

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

208 

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

210 

211 

212class AlternativeModel(PartMixin, BaseModel): 

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

214 

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. 

217 

218 An alternative can contain parts for files or commands. 

219 """ 

220 

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

222 

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