Skip to content

Commit 1ad80d3

Browse files
committed
properly enforce some data constraints
1 parent 4dd0d30 commit 1ad80d3

File tree

3 files changed

+77
-8
lines changed

3 files changed

+77
-8
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ show_error_codes = true
3535
plugins = ["pydantic.mypy"]
3636

3737
[tool.pytest.ini_options]
38+
collect_imported_tests = false
3839
addopts = [
3940
"--cov",
4041
"--cov-report=html",

structured_tutorials/models.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Basic tutorial structure."""
22

33
from pathlib import Path
4-
from typing import Any, Literal, Self
4+
from typing import Annotated, Any, Literal, Self
55

66
from pydantic import BaseModel, Field, field_validator, model_validator
77
from pydantic_core.core_schema import ValidationInfo
88
from yaml import safe_load
99

10+
PositiveInt = Annotated[int, Field(ge=0)]
11+
PositiveFloat = Annotated[float, Field(ge=0)]
12+
1013

1114
def default_cwd_factory(data: dict[str, Any]) -> Path:
1215
"""Default factory for the path variable."""
@@ -16,15 +19,15 @@ def default_cwd_factory(data: dict[str, Any]) -> Path:
1619
class CommandBaseModel(BaseModel):
1720
"""Base model for commands."""
1821

19-
status_code: int = 0
22+
status_code: Annotated[int, Field(ge=0, le=255)] = 0
2023

2124

2225
class TestSpecificationMixin:
2326
"""Mixin for specifying tests."""
2427

25-
delay: int = 0
26-
retry: int = 0
27-
backoff_factor: float = 0 # {backoff factor} * (2 ** ({number of previous retries}))
28+
delay: Annotated[float, Field(ge=0)] = 0
29+
retry: PositiveInt = 0
30+
backoff_factor: PositiveFloat = 0 # {backoff factor} * (2 ** ({number of previous retries}))
2831

2932

3033
class CleanupCommandModel(CommandBaseModel):
@@ -43,7 +46,7 @@ class TestPortModel(TestSpecificationMixin, BaseModel):
4346
"""Model for testing connectivity after a command is run."""
4447

4548
host: str
46-
port: int
49+
port: Annotated[int, Field(ge=0, le=65535)]
4750

4851

4952
class CommandRuntimeConfigurationModel(CommandBaseModel):
@@ -85,6 +88,13 @@ class FilePartModel(BaseModel):
8588
destination: Path
8689
template: bool = True
8790

91+
@field_validator("source", mode="after")
92+
@classmethod
93+
def validate_source(cls, value: Path) -> Path:
94+
if value.is_absolute():
95+
raise ValueError(f"{value}: Must be a relative path (relative to the current cwd).")
96+
return value
97+
8898
@model_validator(mode="after")
8999
def validate_contents_or_source(self) -> Self:
90100
if self.contents is None and self.source is None:
@@ -140,7 +150,7 @@ class TutorialModel(BaseModel):
140150

141151
@field_validator("path", mode="after")
142152
@classmethod
143-
def resolve_path(cls, value: Path, info: ValidationInfo) -> Path:
153+
def validate_path(cls, value: Path, info: ValidationInfo) -> Path:
144154
if not value.is_absolute():
145155
raise ValueError(f"{value}: Must be an absolute path.")
146156
return value

tests/test_models.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@
44
from typing import Any
55

66
import pytest
7+
from pydantic import BaseModel
78

8-
from structured_tutorials.models import FilePartModel, TutorialModel
9+
from structured_tutorials.models import (
10+
CommandBaseModel,
11+
FilePartModel,
12+
TestCommandModel,
13+
TestPortModel,
14+
TutorialModel,
15+
)
916

1017

1118
def test_from_file(tutorial_paths: Path) -> None:
@@ -44,7 +51,58 @@ def test_absolute_cwd() -> None:
4451
TutorialModel.model_validate({"path": "/foo/test.yaml", "cwd": "/foo", "parts": []})
4552

4653

54+
def test_file_part_with_absolute_source() -> None:
55+
"""Test that file parts have an absolute source (if set)."""
56+
with pytest.raises(ValueError, match=r"/foo: Must be a relative path"):
57+
FilePartModel.model_validate({"source": "/foo", "destination": "foo.txt"})
58+
59+
4760
def test_file_part_with_no_source_and_no_contents() -> None:
4861
"""Test that file parts have either contents or a source."""
4962
with pytest.raises(ValueError, match=r"Either contents or source is required\."):
5063
FilePartModel.model_validate({"destination": "foo.txt"})
64+
65+
66+
@pytest.mark.parametrize(
67+
("model", "data", "error"),
68+
(
69+
(CommandBaseModel, {"status_code": -1}, r"status_code\s*Input should be greater than or equal to 0"),
70+
(CommandBaseModel, {"status_code": 256}, r"status_code\s*Input should be less than or equal to 255"),
71+
(TestCommandModel, {"delay": -1}, r"delay\s*Input should be greater than or equal to 0"),
72+
(TestCommandModel, {"retry": -1}, r"retry\s*Input should be greater than or equal to 0"),
73+
(
74+
TestCommandModel,
75+
{"backoff_factor": -1},
76+
r"backoff_factor\s*Input should be greater than or equal to 0",
77+
),
78+
(
79+
TestPortModel,
80+
{"host": "example.com", "port": 443, "delay": -1},
81+
r"delay\s*Input should be greater than or equal to 0",
82+
),
83+
(
84+
TestPortModel,
85+
{"host": "example.com", "port": 443, "retry": -1},
86+
r"retry\s*Input should be greater than or equal to 0",
87+
),
88+
(
89+
TestPortModel,
90+
{"host": "example.com", "port": 443, "backoff_factor": -1},
91+
r"backoff_factor\s*Input should be greater than or equal to 0",
92+
),
93+
(
94+
TestPortModel,
95+
{"host": "example.com", "port": -1},
96+
r"port\s*Input should be greater than or equal to 0",
97+
),
98+
(
99+
TestPortModel,
100+
{"host": "example.com", "port": 65536},
101+
r"port\s*Input should be less than or equal to 65535",
102+
),
103+
),
104+
)
105+
def test_annotated_field_constraints(model: type[BaseModel], data: dict[str, Any], error: str) -> None:
106+
"""Test annotated field constraints."""
107+
with pytest.raises(ValueError, match=error):
108+
model.model_validate(data)

0 commit comments

Comments
 (0)