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

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

53 

54 

55class TestSpecificationMixin: 

56 """Mixin for specifying tests.""" 

57 

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

59 retry: NonNegativeInt = 0 

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

61 

62 

63class ConfigurationMixin: 

64 """Mixin for configuration models.""" 

65 

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

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

68 

69 

70class DocumentationConfigurationMixin: 

71 """Mixin for documentation configuration models.""" 

72 

73 text_before: str = Field(default="", description="Text before documenting this part.") 

74 text_after: str = Field(default="", description="Text after documenting this part.") 

75 

76 

77class FileMixin: 

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

79 

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 ) 

94 

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 

101 

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