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

1# Copyright (c) 2025 Mathias Ertl 

2# Licensed under the MIT License. See LICENSE file for details. 

3 

4"""Base model classes.""" 

5 

6from pathlib import Path 

7from typing import Annotated, Any 

8 

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 

19 

20from structured_tutorials.typing import Self 

21 

22# Type for commands to execute 

23CommandType = str | tuple[str, ...] 

24 

25TEMPLATE_DESCRIPTION = "This value is rendered as a template with the current context." 

26 

27 

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 

33 

34 

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)" 

38 

39 

40class CommandBaseModel(BaseModel): 

41 """Base model for commands.""" 

42 

43 model_config = ConfigDict(extra="forbid") 

44 

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 ) 

49 

50 

51class TestSpecificationMixin: 

52 """Mixin for specifying tests.""" 

53 

54 delay: Annotated[float, Field(ge=0)] = 0 

55 retry: NonNegativeInt = 0 

56 backoff_factor: NonNegativeFloat = 0 # {backoff factor} * (2 ** ({number of previous retries})) 

57 

58 

59class ConfigurationMixin: 

60 """Mixin for configuration models.""" 

61 

62 skip: bool = Field(default=False, description="Skip this part.") 

63 update_context: dict[str, Any] = Field(default_factory=dict) 

64 

65 

66class FileMixin: 

67 """Mixin for specifying a file (used in file part and for stdin of commands).""" 

68 

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 ) 

83 

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 

90 

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