Coverage for structured_tutorials / models / base.py: 100%
41 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-21 19:08 +0100
« 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.
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 show_output: bool = Field(
47 default=True, description="Set to `False` to always hide the output of this command."
48 )
51class TestSpecificationMixin:
52 """Mixin for specifying tests."""
54 delay: Annotated[float, Field(ge=0)] = 0
55 retry: NonNegativeInt = 0
56 backoff_factor: NonNegativeFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
59class ConfigurationMixin:
60 """Mixin for configuration models."""
62 skip: bool = Field(default=False, description="Skip this part.")
63 update_context: dict[str, Any] = Field(default_factory=dict)
66class FileMixin:
67 """Mixin for specifying a file (used in file part and for stdin of commands)."""
69 contents: str | None = Field(
70 default=None,
71 field_title_generator=template_field_title_generator,
72 description=f"Contents of the file. {TEMPLATE_DESCRIPTION}",
73 )
74 source: Path | None = Field(
75 default=None,
76 field_title_generator=template_field_title_generator,
77 description="The source path of the file to create. Unless `template` is `False`, the file is loaded "
78 "into memory and rendered as template.",
79 )
80 template: bool = Field(
81 default=True, description="Whether the file contents should be rendered in a template."
82 )
84 @field_validator("source", mode="after")
85 @classmethod
86 def validate_source(cls, value: Path) -> Path:
87 if value.is_absolute():
88 raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
89 return value
91 @model_validator(mode="after")
92 def validate_contents_or_source(self) -> Self:
93 if self.contents is None and self.source is None:
94 raise ValueError("Either contents or source is required.")
95 if self.contents is not None and self.source is not None:
96 raise ValueError("Only one of contents or source is allowed.")
97 return self