Coverage for structured_tutorials / models / base.py: 100%
46 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-22 22:55 +0100
« 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.
4"""Base model classes."""
6from pathlib import Path
7from typing import Annotated, Any
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, ...]
25TEMPLATE_DESCRIPTION = "This value is rendered as a template with the current context."
28def default_tutorial_root_factory(data: dict[str, Any]) -> Path:
29 """Default factory for the tutorial_root variable."""
30 tutorial_root = data["path"].parent
31 assert isinstance(tutorial_root, Path)
32 return tutorial_root
35def template_field_title_generator(field_name: str, field_info: FieldInfo) -> str:
36 """Field title generator for template fields."""
37 return f"{field_name.title()} (template)"
40class CommandBaseModel(BaseModel):
41 """Base model for commands."""
43 model_config = ConfigDict(extra="forbid")
45 status_code: Annotated[int, Field(ge=0, le=255)] = 0
46 clear_environment: bool = Field(default=False, description="Clear the environment.")
47 environment: dict[str, Any] = Field(
48 default_factory=dict, description="Additional environment variables for the process."
49 )
50 show_output: bool = Field(
51 default=True, description="Set to `False` to always hide the output of this command."
52 )
55class TestSpecificationMixin:
56 """Mixin for specifying tests."""
58 delay: Annotated[float, Field(ge=0)] = 0
59 retry: NonNegativeInt = 0
60 backoff_factor: NonNegativeFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
63class ConfigurationMixin:
64 """Mixin for configuration models."""
66 skip: bool = Field(default=False, description="Skip this part.")
67 update_context: dict[str, Any] = Field(default_factory=dict)
70class DocumentationConfigurationMixin:
71 """Mixin for documentation configuration models."""
73 text_before: str = Field(default="", description="Text before documenting this part.")
74 text_after: str = Field(default="", description="Text after documenting this part.")
77class FileMixin:
78 """Mixin for specifying a file (used in file part and for stdin of commands)."""
80 contents: str | None = Field(
81 default=None,
82 field_title_generator=template_field_title_generator,
83 description=f"Contents of the file. {TEMPLATE_DESCRIPTION}",
84 )
85 source: Path | None = Field(
86 default=None,
87 field_title_generator=template_field_title_generator,
88 description="The source path of the file to create. Unless `template` is `False`, the file is loaded "
89 "into memory and rendered as template.",
90 )
91 template: bool = Field(
92 default=True, description="Whether the file contents should be rendered in a template."
93 )
95 @field_validator("source", mode="after")
96 @classmethod
97 def validate_source(cls, value: Path) -> Path:
98 if value.is_absolute():
99 raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
100 return value
102 @model_validator(mode="after")
103 def validate_contents_or_source(self) -> Self:
104 if self.contents is None and self.source is None:
105 raise ValueError("Either contents or source is required.")
106 if self.contents is not None and self.source is not None:
107 raise ValueError("Only one of contents or source is allowed.")
108 return self