33from pathlib import Path
44from typing import Annotated , Any , Literal , Self
55
6- from pydantic import BaseModel , Field , field_validator , model_validator
6+ from pydantic import BaseModel , ConfigDict , Field , field_validator , model_validator
77from pydantic_core .core_schema import ValidationInfo
88from yaml import safe_load
99
@@ -19,12 +19,16 @@ def default_cwd_factory(data: dict[str, Any]) -> Path:
1919class CommandBaseModel (BaseModel ):
2020 """Base model for commands."""
2121
22+ model_config = ConfigDict (extra = "forbid" )
23+
2224 status_code : Annotated [int , Field (ge = 0 , le = 255 )] = 0
2325
2426
2527class TestSpecificationMixin :
2628 """Mixin for specifying tests."""
2729
30+ model_config = ConfigDict (extra = "forbid" )
31+
2832 delay : Annotated [float , Field (ge = 0 )] = 0
2933 retry : PositiveInt = 0
3034 backoff_factor : PositiveFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
@@ -33,25 +37,33 @@ class TestSpecificationMixin:
3337class CleanupCommandModel (CommandBaseModel ):
3438 """Model for cleanup commands."""
3539
40+ model_config = ConfigDict (extra = "forbid" )
41+
3642 command : str
3743
3844
3945class TestCommandModel (TestSpecificationMixin , CommandBaseModel ):
4046 """Model for a test command for a normal command."""
4147
48+ model_config = ConfigDict (extra = "forbid" )
49+
4250 command : str
4351
4452
4553class TestPortModel (TestSpecificationMixin , BaseModel ):
4654 """Model for testing connectivity after a command is run."""
4755
56+ model_config = ConfigDict (extra = "forbid" )
57+
4858 host : str
4959 port : Annotated [int , Field (ge = 0 , le = 65535 )]
5060
5161
5262class CommandRuntimeConfigurationModel (CommandBaseModel ):
5363 """Model for runtime configuration when running a single command."""
5464
65+ model_config = ConfigDict (extra = "forbid" )
66+
5567 update_context : dict [str , Any ] = Field (default_factory = dict )
5668 cleanup : tuple [CleanupCommandModel , ...] = tuple ()
5769 test : tuple [TestCommandModel | TestPortModel , ...] = tuple ()
@@ -60,13 +72,17 @@ class CommandRuntimeConfigurationModel(CommandBaseModel):
6072class CommandDocumentationConfigurationModel (BaseModel ):
6173 """Model for documenting a single command."""
6274
75+ model_config = ConfigDict (extra = "forbid" )
76+
6377 output : str = ""
6478 update_context : dict [str , Any ] = Field (default_factory = dict )
6579
6680
6781class CommandModel (BaseModel ):
6882 """Model for a single command."""
6983
84+ model_config = ConfigDict (extra = "forbid" )
85+
7086 command : str
7187 run : CommandRuntimeConfigurationModel = CommandRuntimeConfigurationModel ()
7288 doc : CommandDocumentationConfigurationModel = CommandDocumentationConfigurationModel ()
@@ -75,19 +91,35 @@ class CommandModel(BaseModel):
7591class CommandsPartModel (BaseModel ):
7692 """Model for a set of commands."""
7793
94+ model_config = ConfigDict (extra = "forbid" )
95+
7896 type : Literal ["commands" ] = "commands"
7997 commands : tuple [CommandModel , ...]
8098
8199
100+ class FileDocumentationConfigurationModel (BaseModel ):
101+ # sphinx options:
102+ language : str = ""
103+ caption : str | Literal [False ] = ""
104+ linenos : bool = False
105+ lineno_start : PositiveInt | Literal [False ] = False
106+ emphasize_lines : str = ""
107+ name : str = ""
108+
109+
82110class FilePartModel (BaseModel ):
83111 """Model for a file to be copied."""
84112
113+ model_config = ConfigDict (extra = "forbid" )
114+
85115 type : Literal ["file" ] = "file"
86116 contents : str | None = None
87117 source : Path | None = None
88118 destination : Path
89119 template : bool = True
90120
121+ doc : FileDocumentationConfigurationModel = FileDocumentationConfigurationModel ()
122+
91123 @field_validator ("source" , mode = "after" )
92124 @classmethod
93125 def validate_source (cls , value : Path ) -> Path :
@@ -99,12 +131,16 @@ def validate_source(cls, value: Path) -> Path:
99131 def validate_contents_or_source (self ) -> Self :
100132 if self .contents is None and self .source is None :
101133 raise ValueError ("Either contents or source is required." )
134+ if self .contents is not None and self .source is not None :
135+ raise ValueError ("Only one of contents or source is allowed." )
102136 return self
103137
104138
105139class RuntimeConfigurationModel (BaseModel ):
106140 """Model for configuration at runtime."""
107141
142+ model_config = ConfigDict (extra = "forbid" )
143+
108144 context : dict [str , Any ] = Field (default_factory = dict )
109145
110146 @model_validator (mode = "after" )
@@ -117,6 +153,8 @@ def set_default_context(self) -> Self:
117153class DocumentationConfigurationModel (BaseModel ):
118154 """Model for configuration of the documentation."""
119155
156+ model_config = ConfigDict (extra = "forbid" )
157+
120158 context : dict [str , Any ] = Field (default_factory = dict )
121159
122160 @model_validator (mode = "after" )
@@ -136,13 +174,17 @@ def set_default_context(self) -> Self:
136174class ConfigurationModel (BaseModel ):
137175 """Model for the initial configuration of a tutorial."""
138176
177+ model_config = ConfigDict (extra = "forbid" )
178+
139179 run : RuntimeConfigurationModel = RuntimeConfigurationModel ()
140180 doc : DocumentationConfigurationModel = DocumentationConfigurationModel ()
141181
142182
143183class TutorialModel (BaseModel ):
144184 """Model representing the entire tutorial."""
145185
186+ model_config = ConfigDict (extra = "forbid" )
187+
146188 path : Path # absolute path
147189 cwd : Path = Field (default_factory = default_cwd_factory ) # absolute path (input: relative to path)
148190 parts : tuple [CommandsPartModel | FilePartModel , ...]
0 commit comments