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

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 """Model for runtime configuration when running a single command.""" 

67 

68 model_config = ConfigDict(extra="forbid") 

69 

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 

74 

75 

76class CommandDocumentationConfigurationModel(ConfigurationMixin, BaseModel): 

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

78 

79 model_config = ConfigDict(extra="forbid") 

80 

81 output: str = "" 

82 

83 

84class CommandModel(BaseModel): 

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

86 

87 model_config = ConfigDict(extra="forbid") 

88 

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 ) 

96 

97 

98class CommandsRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

100 

101 model_config = ConfigDict(extra="forbid") 

102 

103 

104class CommandsDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel): 

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

106 

107 model_config = ConfigDict(extra="forbid") 

108 

109 

110class CommandsPartModel(PartMixin, BaseModel): 

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

112 

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

114 

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

116 commands: tuple[CommandModel, ...] 

117 

118 run: CommandsRuntimeConfigurationModel = CommandsRuntimeConfigurationModel() 

119 doc: CommandsDocumentationConfigurationModel = CommandsDocumentationConfigurationModel() 

120 

121 

122class FileRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

124 

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

126 

127 

128class FileDocumentationConfigurationModel(ConfigurationMixin, DocumentationConfigurationMixin, BaseModel): 

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

130 

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

134 

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

136 

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 ) 

152 

153 

154class FilePartModel(PartMixin, FileMixin, BaseModel): 

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

156 

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

158 """ 

159 

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

161 

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

163 

164 destination: str = Field( 

165 field_title_generator=template_field_title_generator, 

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

167 ) 

168 

169 doc: FileDocumentationConfigurationModel = FileDocumentationConfigurationModel() 

170 run: FileRuntimeConfigurationModel = FileRuntimeConfigurationModel() 

171 

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 

177 

178 

179class PromptModel(PartMixin, BaseModel): 

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

181 

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

183 

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 ) 

196 

197 

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

199 

200 

201class AlternativeRuntimeConfigurationModel(ConfigurationMixin, BaseModel): 

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

203 

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

205 

206 

207class AlternativeDocumentationConfigurationModel( 

208 ConfigurationMixin, DocumentationConfigurationMixin, BaseModel 

209): 

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

211 

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

213 

214 

215class AlternativeModel(PartMixin, BaseModel): 

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

217 

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. 

220 

221 An alternative can contain parts for files or commands. 

222 """ 

223 

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

225 

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