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

79 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"""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, Tag, field_validator, model_validator 

10from pydantic_core.core_schema import ValidationInfo 

11from yaml import safe_load 

12 

13from structured_tutorials.models.base import default_tutorial_root_factory 

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

15from structured_tutorials.typing import Self 

16 

17 

18class DocumentationConfigurationModel(BaseModel): 

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

20 

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

22 

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

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

25 ) 

26 alternative_names: dict[str, str] = Field( 

27 default_factory=dict, 

28 description="Names for alternative keys, used in tab titles. By default, the key itself is used.", 

29 ) 

30 

31 @model_validator(mode="after") 

32 def set_default_context(self) -> Self: 

33 self.context["run"] = False 

34 self.context["doc"] = True 

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

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

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

38 self.context.setdefault( 

39 "prompt_template", 

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

41 ) 

42 return self 

43 

44 

45class RuntimeConfigurationModel(BaseModel): 

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

47 

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

49 

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

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

52 ) 

53 temporary_directory: bool = Field( 

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

55 ) 

56 git_export: bool = Field( 

57 default=False, 

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

59 ) 

60 

61 @model_validator(mode="after") 

62 def set_default_context(self) -> Self: 

63 self.context["doc"] = False 

64 self.context["run"] = True 

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

66 return self 

67 

68 

69class ConfigurationModel(BaseModel): 

70 """Initial configuration of a tutorial.""" 

71 

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

73 

74 run: RuntimeConfigurationModel = RuntimeConfigurationModel() 

75 doc: DocumentationConfigurationModel = DocumentationConfigurationModel() 

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

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

78 ) 

79 

80 

81class TutorialModel(BaseModel): 

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

83 

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

85 

86 # absolute path to YAML file 

87 path: Path = Field( 

88 description="Absolute path to the tutorial file. This field is populated automatically while loading the tutorial.", # noqa: E501 

89 ) 

90 tutorial_root: Path = Field( 

91 default_factory=default_tutorial_root_factory, 

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

93 "tutorial file.", 

94 ) # absolute path (input: relative to path) 

95 parts: tuple[ 

96 Annotated[ 

97 PartModels 

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

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

100 Discriminator(part_discriminator), 

101 ], 

102 ..., 

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

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

105 

106 @field_validator("path", mode="after") 

107 @classmethod 

108 def validate_path(cls, value: Path, info: ValidationInfo) -> Path: 

109 if not value.is_absolute(): 

110 raise ValueError(f"{value}: Must be an absolute path.") 

111 return value 

112 

113 @field_validator("tutorial_root", mode="after") 

114 @classmethod 

115 def resolve_tutorial_root(cls, value: Path, info: ValidationInfo) -> Path: 

116 if value.is_absolute(): 

117 raise ValueError(f"{value}: Must be a relative path (relative to the tutorial file).") 

118 path: Path = info.data["path"] 

119 

120 return (path.parent / value).resolve() 

121 

122 @model_validator(mode="after") 

123 def update_context(self) -> Self: 

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

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

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

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

128 return self 

129 

130 @model_validator(mode="after") 

131 def update_part_data(self) -> Self: 

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

133 part.index = part_no 

134 if not part.id: 

135 part.id = str(part_no) 

136 return self 

137 

138 @classmethod 

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

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

141 with open(path) as stream: 

142 tutorial_data = safe_load(stream) 

143 

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

145 if not isinstance(tutorial_data, dict): 

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

147 

148 tutorial_data["path"] = path.resolve() 

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

150 return tutorial