Coverage for structured_tutorials / models / base.py: 100%
46 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:17 +0200
« 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.
4"""Base model classes."""
6from pathlib import Path
7from typing import Annotated, Any, Generic, TypeVar, overload
9from pydantic import (
10 BaseModel,
11 ConfigDict,
12 Field,
13 NonNegativeFloat,
14 NonNegativeInt,
15 field_validator,
16 model_validator,
17)
18from pydantic.fields import FieldInfo
20from structured_tutorials.typing import Self
22# Type for commands to execute
23CommandType = str | tuple[str, ...]
24V = TypeVar("V")
26TEMPLATE_DESCRIPTION = "This value is rendered as a template with the current context."
29def template_field_title_generator(field_name: str, field_info: FieldInfo) -> str:
30 """Field title generator for template fields."""
31 return f"{field_name.title()} (template)"
34class CommandBaseModel(BaseModel):
35 """Base model for commands."""
37 model_config = ConfigDict(extra="forbid")
39 status_code: Annotated[int, Field(ge=0, le=255)] = 0
40 clear_environment: bool = Field(default=False, description="Clear the environment.")
41 update_environment: dict[str, str] = Field(
42 default_factory=dict, description="Update the environment for all subsequent commands."
43 )
44 environment: dict[str, str] = Field(
45 default_factory=dict, description="Additional environment variables for the process."
46 )
47 show_output: bool = Field(
48 default=True, description="Set to `False` to always hide the output of this command."
49 )
52class TestSpecificationMixin:
53 """Mixin for specifying tests."""
55 delay: Annotated[float, Field(ge=0)] = 0
56 retry: NonNegativeInt = 0
57 backoff_factor: NonNegativeFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
60class ConfigurationMixin:
61 """Mixin for configuration models."""
63 update_context: dict[str, Any] = Field(default_factory=dict)
66class DocumentationConfigurationMixin:
67 """Mixin for documentation configuration models."""
69 text_before: str = Field(default="", description="Text before documenting this part.")
70 text_after: str = Field(default="", description="Text after documenting this part.")
73class DictRootModelMixin(Generic[V]):
74 """Mixin for dict-based root models."""
76 root: dict[str, V]
78 @overload
79 def get(self, key: str, default: V) -> V: ...
81 @overload
82 def get(self, key: str) -> V | None: ...
84 def get(self, key: str, default: V | None = None) -> V | None:
85 return self.root.get(key, default)
88class FileMixin:
89 """Mixin for specifying a file (used in file part and for stdin of commands)."""
91 contents: str | None = Field(
92 default=None,
93 field_title_generator=template_field_title_generator,
94 description=f"Contents of the file. {TEMPLATE_DESCRIPTION}",
95 )
96 source: Path | None = Field(
97 default=None,
98 field_title_generator=template_field_title_generator,
99 description="The source path of the file to create. Unless `template` is `False`, the file is loaded "
100 "into memory and rendered as template.",
101 )
102 template: bool = Field(
103 default=True, description="Whether the file contents should be rendered in a template."
104 )
106 @field_validator("source", mode="after")
107 @classmethod
108 def validate_source(cls, value: Path) -> Path:
109 if value.is_absolute():
110 raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
111 return value
113 @model_validator(mode="after")
114 def validate_contents_or_source(self) -> Self:
115 if self.contents is None and self.source is None:
116 raise ValueError("Either contents or source is required.")
117 if self.contents is not None and self.source is not None:
118 raise ValueError("Only one of contents or source is allowed.")
119 return self