Coverage for structured_tutorials / models / base.py: 100%
47 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-26 12:41 +0100
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-26 12:41 +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 update_environment: dict[str, str] = Field(
48 default_factory=dict, description="Update the environment for all subsequent commands."
49 )
50 environment: dict[str, str] = Field(
51 default_factory=dict, description="Additional environment variables for the process."
52 )
53 show_output: bool = Field(
54 default=True, description="Set to `False` to always hide the output of this command."
55 )
58class TestSpecificationMixin:
59 """Mixin for specifying tests."""
61 delay: Annotated[float, Field(ge=0)] = 0
62 retry: NonNegativeInt = 0
63 backoff_factor: NonNegativeFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
66class ConfigurationMixin:
67 """Mixin for configuration models."""
69 skip: bool = Field(default=False, description="Skip this part.")
70 update_context: dict[str, Any] = Field(default_factory=dict)
73class DocumentationConfigurationMixin:
74 """Mixin for documentation configuration models."""
76 text_before: str = Field(default="", description="Text before documenting this part.")
77 text_after: str = Field(default="", description="Text after documenting this part.")
80class FileMixin:
81 """Mixin for specifying a file (used in file part and for stdin of commands)."""
83 contents: str | None = Field(
84 default=None,
85 field_title_generator=template_field_title_generator,
86 description=f"Contents of the file. {TEMPLATE_DESCRIPTION}",
87 )
88 source: Path | None = Field(
89 default=None,
90 field_title_generator=template_field_title_generator,
91 description="The source path of the file to create. Unless `template` is `False`, the file is loaded "
92 "into memory and rendered as template.",
93 )
94 template: bool = Field(
95 default=True, description="Whether the file contents should be rendered in a template."
96 )
98 @field_validator("source", mode="after")
99 @classmethod
100 def validate_source(cls, value: Path) -> Path:
101 if value.is_absolute():
102 raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
103 return value
105 @model_validator(mode="after")
106 def validate_contents_or_source(self) -> Self:
107 if self.contents is None and self.source is None:
108 raise ValueError("Either contents or source is required.")
109 if self.contents is not None and self.source is not None:
110 raise ValueError("Only one of contents or source is allowed.")
111 return self