Coverage for structured_tutorials / models / tutorial.py: 100%

91 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"""Module containing main tutorial model and global configuration models.""" 

5 

6from pathlib import Path 

7from typing import Annotated, Any 

8 

9from pydantic import BaseModel, ConfigDict, Discriminator, Field, RootModel, Tag, model_validator 

10from pydantic_core.core_schema import ValidationInfo 

11from yaml import safe_load 

12 

13from structured_tutorials.models.base import DictRootModelMixin 

14from structured_tutorials.models.parts import AlternativeModel, PartModels, PromptModel, part_discriminator 

15from structured_tutorials.typing import Self 

16 

17 

18class DocumentationAlternativeConfigurationModel(BaseModel): 

19 """Additional documentation configuration for alternatives.""" 

20 

21 model_config = ConfigDict(extra="forbid", title="Additional configuration for alternatives.") 

22 

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

24 default_factory=dict, description="Key/value pairs for the initial context for an alternative." 

25 ) 

26 name: str = Field(default="", description="Name of the alternative (used e.g. in tab titles).") 

27 

28 

29class RuntimeAlternativeConfigurationModel(BaseModel): 

30 """Additional runtime configuration for alternatives.""" 

31 

32 model_config = ConfigDict(extra="forbid", title="Additional configuration for alternatives.") 

33 

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

35 default_factory=dict, description="Key/value pairs for the initial context for an alternative." 

36 ) 

37 environment: dict[str, str | None] = Field( 

38 default_factory=dict, description="Additional environment variables for all commands." 

39 ) 

40 required_executables: tuple[str, ...] = Field(default=tuple(), description="Required executables.") 

41 

42 

43class DocumentationAlternativesConfigurationModel( 

44 DictRootModelMixin[DocumentationAlternativeConfigurationModel], 

45 RootModel[dict[str, DocumentationAlternativeConfigurationModel]], 

46): 

47 """Configuration for alternatives when rendering documentation.""" 

48 

49 pass 

50 

51 

52class RuntimeAlternativesConfigurationModel( 

53 DictRootModelMixin[RuntimeAlternativeConfigurationModel], 

54 RootModel[dict[str, RuntimeAlternativeConfigurationModel]], 

55): 

56 """Configuration for alternatives at runtime.""" 

57 

58 pass 

59 

60 

61class DocumentationConfigurationModel(BaseModel): 

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

63 

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

65 

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

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

68 ) 

69 alternatives: DocumentationAlternativesConfigurationModel = Field( 

70 default_factory=lambda: DocumentationAlternativesConfigurationModel({}) 

71 ) 

72 

73 @model_validator(mode="after") 

74 def set_default_context(self) -> Self: 

75 self.context["run"] = False 

76 self.context["doc"] = True 

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

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

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

80 self.context.setdefault( 

81 "prompt_template", 

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

83 ) 

84 return self 

85 

86 

87class RunnerConfig(BaseModel): 

88 """Configuration for the runner itself.""" 

89 

90 path: str = "structured_tutorials.runners.local.LocalTutorialRunner" 

91 options: dict[str, Any] = Field(default_factory=dict) 

92 

93 

94class RuntimeConfigurationModel(BaseModel): 

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

96 

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

98 

99 runner: RunnerConfig = RunnerConfig() 

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

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

102 ) 

103 temporary_directory: bool = Field( 

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

105 ) 

106 git_export: bool = Field( 

107 default=False, 

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

109 ) 

110 environment: dict[str, str | None] = Field( 

111 default_factory=dict, 

112 description="Additional environment variables for all commands." 

113 "Set a value to `None` to clear it from the global environment.", 

114 ) 

115 clear_environment: bool = Field(default=False, description="Clear the environment for all commands.") 

116 alternatives: RuntimeAlternativesConfigurationModel = Field( 

117 default_factory=lambda: RuntimeAlternativesConfigurationModel({}) 

118 ) 

119 required_executables: tuple[str, ...] = Field(default=tuple(), description="Required executables.") 

120 

121 @model_validator(mode="after") 

122 def set_default_context(self) -> Self: 

123 self.context["doc"] = False 

124 self.context["run"] = True 

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

126 return self 

127 

128 

129class ConfigurationModel(BaseModel): 

130 """Initial configuration of a tutorial.""" 

131 

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

133 

134 run: RuntimeConfigurationModel = RuntimeConfigurationModel() 

135 doc: DocumentationConfigurationModel = DocumentationConfigurationModel() 

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

137 default_factory=dict, description="Initial context for both documentation and runtime." 

138 ) 

139 

140 

141class TutorialModel(BaseModel): 

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

143 

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

145 

146 root: Path = Field( 

147 json_schema_extra={"required": False}, 

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

149 "tutorial file.", 

150 ) # absolute path 

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

152 parts: tuple[ 

153 Annotated[ 

154 PartModels 

155 | Annotated[PromptModel, Tag("prompt")] 

156 | Annotated[AlternativeModel, Tag("alternatives")], 

157 Discriminator(part_discriminator), 

158 ], 

159 ..., 

160 ] = Field(description="The individual parts of this tutorial.") 

161 

162 @classmethod 

163 def model_json_schema(cls, **kwargs: Any) -> dict[str, Any]: # type: ignore[override] # pragma: no cover 

164 schema = super().model_json_schema(**kwargs) 

165 schema["required"].remove("root") 

166 return schema 

167 

168 @model_validator(mode="after") 

169 def update_context(self, info: ValidationInfo) -> Self: 

170 if isinstance(info.context, dict): 

171 if path := info.context.get("path"): # pragma: no branch 

172 assert isinstance(path, Path) 

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

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

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

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

177 

178 if not self.root.is_absolute(): 

179 self.root = (path.parent / self.root).resolve() 

180 return self 

181 

182 @model_validator(mode="after") 

183 def update_part_data(self) -> Self: 

184 for part_no, part in enumerate(self.parts): 

185 part.index = part_no 

186 if not part.id: 

187 part.id = str(part_no) 

188 return self 

189 

190 @classmethod 

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

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

193 with open(path) as stream: 

194 tutorial_data = safe_load(stream) 

195 

196 # e.g. an empty YAML file will return None 

197 if not isinstance(tutorial_data, dict): 

198 raise ValueError("File does not contain a mapping at top level.") 

199 

200 tutorial_data.setdefault("root", path.resolve().parent) 

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

202 return tutorial