schemez 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {schemez-0.2.2 → schemez-0.2.4}/PKG-INFO +1 -1
  2. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/__init__.py +1 -1
  3. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/pydantic_types.py +12 -2
  4. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/schema.py +12 -2
  5. schemez-0.2.4/src/schemez/schemadef/schemadef.py +260 -0
  6. schemez-0.2.4/tests/test_enum_support.py +192 -0
  7. schemez-0.2.4/tests/test_schema_field.py +467 -0
  8. schemez-0.2.2/src/schemez/schemadef/schemadef.py +0 -120
  9. {schemez-0.2.2 → schemez-0.2.4}/.copier-answers.yml +0 -0
  10. {schemez-0.2.2 → schemez-0.2.4}/.github/FUNDING.yml +0 -0
  11. {schemez-0.2.2 → schemez-0.2.4}/.github/copilot-instructions.md +0 -0
  12. {schemez-0.2.2 → schemez-0.2.4}/.github/dependabot.yml +0 -0
  13. {schemez-0.2.2 → schemez-0.2.4}/.github/workflows/build.yml +0 -0
  14. {schemez-0.2.2 → schemez-0.2.4}/.github/workflows/documentation.yml +0 -0
  15. {schemez-0.2.2 → schemez-0.2.4}/.gitignore +0 -0
  16. {schemez-0.2.2 → schemez-0.2.4}/.pre-commit-config.yaml +0 -0
  17. {schemez-0.2.2 → schemez-0.2.4}/LICENSE +0 -0
  18. {schemez-0.2.2 → schemez-0.2.4}/README.md +0 -0
  19. {schemez-0.2.2 → schemez-0.2.4}/docs/.empty +0 -0
  20. {schemez-0.2.2 → schemez-0.2.4}/duties.py +0 -0
  21. {schemez-0.2.2 → schemez-0.2.4}/mkdocs.yml +0 -0
  22. {schemez-0.2.2 → schemez-0.2.4}/overrides/_dummy.txt +0 -0
  23. {schemez-0.2.2 → schemez-0.2.4}/pyproject.toml +0 -0
  24. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/code.py +0 -0
  25. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/convert.py +0 -0
  26. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/docstrings.py +0 -0
  27. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/helpers.py +0 -0
  28. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/py.typed +0 -0
  29. {schemez-0.2.2 → schemez-0.2.4}/src/schemez/schemadef/__init__.py +0 -0
  30. {schemez-0.2.2 → schemez-0.2.4}/tests/__init__.py +0 -0
  31. {schemez-0.2.2 → schemez-0.2.4}/tests/conftest.py +0 -0
  32. {schemez-0.2.2 → schemez-0.2.4}/tests/test_schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: schemez
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Pydantic shim for config stuff
5
5
  Project-URL: Documentation, https://phil65.github.io/schemez/
6
6
  Project-URL: Source, https://github.com/phil65/schemez
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.2"
1
+ __version__ = "0.2.4"
2
2
 
3
3
 
4
4
  from schemez.schema import Schema
@@ -2,9 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Annotated
5
+ from typing import Annotated, Any
6
6
 
7
- from pydantic import Field
7
+ from pydantic import BaseModel, Field
8
8
 
9
9
 
10
10
  ModelIdentifier = Annotated[
@@ -40,3 +40,13 @@ MimeType = Annotated[
40
40
  description="Standard MIME type identifying file formats and content types",
41
41
  ),
42
42
  ]
43
+
44
+
45
+ def get_field_type(model: type[BaseModel], field_name: str) -> dict[str, Any]:
46
+ """Extract field_type metadata from a model field."""
47
+ field_info = model.model_fields[field_name]
48
+ metadata = {}
49
+ if field_info.json_schema_extra and isinstance(field_info.json_schema_extra, dict):
50
+ metadata.update(field_info.json_schema_extra)
51
+
52
+ return metadata
@@ -216,11 +216,21 @@ class Schema(BaseModel):
216
216
 
217
217
  return get_ctor_basemodel(target_cls)
218
218
 
219
- def model_dump_yaml(self) -> str:
219
+ def model_dump_yaml(
220
+ self,
221
+ exclude_none: bool = True,
222
+ exclude_defaults: bool = False,
223
+ exclude_unset: bool = False,
224
+ ) -> str:
220
225
  """Dump configuration to YAML string."""
221
226
  import yamling
222
227
 
223
- return yamling.dump_yaml(self.model_dump(exclude_none=True))
228
+ text = self.model_dump(
229
+ exclude_none=exclude_none,
230
+ exclude_defaults=exclude_defaults,
231
+ exclude_unset=exclude_unset,
232
+ )
233
+ return yamling.dump_yaml(text)
224
234
 
225
235
  def save(self, path: StrPath, overwrite: bool = False) -> None:
226
236
  """Save configuration to a YAML file.
@@ -0,0 +1,260 @@
1
+ """Models for schema fields and definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from pydantic import BaseModel, Field, create_model
9
+
10
+ from schemez import Schema, helpers
11
+
12
+
13
+ class SchemaField(Schema):
14
+ """Field definition for inline response types.
15
+
16
+ Defines a single field in an inline response definition, including:
17
+ - Data type specification
18
+ - Optional description
19
+ - Validation constraints
20
+ - Enum values (when type is 'enum')
21
+
22
+ Used by InlineSchemaDef to structure response fields.
23
+ """
24
+
25
+ type: str
26
+ """Data type of the response field"""
27
+
28
+ description: str | None = None
29
+ """Optional description of what this field represents"""
30
+
31
+ values: list[Any] | None = None
32
+ """Values for enum type fields"""
33
+
34
+ # Common validation constraints
35
+ default: Any | None = None
36
+ """Default value for the field"""
37
+
38
+ title: str | None = None
39
+ """Title for the field in generated JSON Schema"""
40
+
41
+ pattern: str | None = None
42
+ """Regex pattern for string validation"""
43
+
44
+ min_length: int | None = None
45
+ """Minimum length for collections"""
46
+
47
+ max_length: int | None = None
48
+ """Maximum length for collections"""
49
+
50
+ gt: float | None = None
51
+ """Greater than (exclusive) validation for numbers"""
52
+
53
+ ge: float | None = None
54
+ """Greater than or equal (inclusive) validation for numbers"""
55
+
56
+ lt: float | None = None
57
+ """Less than (exclusive) validation for numbers"""
58
+
59
+ le: float | None = None
60
+ """Less than or equal (inclusive) validation for numbers"""
61
+
62
+ multiple_of: float | None = None
63
+ """Number must be a multiple of this value"""
64
+
65
+ literal_value: Any | None = None
66
+ """Value for Literal type constraint, makes field accept only this specific value"""
67
+
68
+ examples: list[Any] | None = None
69
+ """Examples for this field in JSON Schema"""
70
+
71
+ optional: bool = False
72
+ """Whether this field is optional (None value allowed)"""
73
+
74
+ json_schema_extra: dict[str, Any] | None = None
75
+ """Additional JSON Schema information"""
76
+
77
+ field_config: dict[str, Any] | None = None
78
+ """Configuration for Pydantic model fields"""
79
+
80
+ # Extensibility for future or custom constraints
81
+ constraints: dict[str, Any] = Field(default_factory=dict)
82
+ """Additional constraints not covered by explicit fields"""
83
+
84
+
85
+ class BaseSchemaDef(Schema):
86
+ """Base class for response definitions."""
87
+
88
+ type: str = Field(init=False)
89
+
90
+ description: str | None = None
91
+ """A description for this response definition."""
92
+
93
+
94
+ class InlineSchemaDef(BaseSchemaDef):
95
+ """Inline definition of schema.
96
+
97
+ Allows defining response types directly in the configuration using:
98
+ - Field definitions with types and descriptions
99
+ - Optional validation constraints
100
+ - Custom field descriptions
101
+
102
+ Example:
103
+ schemas:
104
+ BasicResult:
105
+ type: inline
106
+ fields:
107
+ success: {type: bool, description: "Operation success"}
108
+ message: {type: str, description: "Result details"}
109
+ """
110
+
111
+ type: Literal["inline"] = Field("inline", init=False)
112
+ """Inline response definition."""
113
+
114
+ fields: dict[str, SchemaField]
115
+ """A dictionary containing all fields."""
116
+
117
+ def get_schema(self) -> type[BaseModel]: # type: ignore
118
+ """Create Pydantic model from inline definition."""
119
+ fields = {}
120
+ for name, field in self.fields.items():
121
+ # Initialize constraint dictionary
122
+ field_constraints = {}
123
+
124
+ # Handle enum type
125
+ if field.type == "enum":
126
+ if not field.values:
127
+ msg = f"Field '{name}' has type 'enum' but no values defined"
128
+ raise ValueError(msg)
129
+
130
+ # Create dynamic Enum class
131
+ enum_name = f"{name.capitalize()}Enum"
132
+
133
+ # Create enum members dictionary
134
+ enum_members = {}
135
+ for i, value in enumerate(field.values):
136
+ if isinstance(value, str) and value.isidentifier():
137
+ # If value is a valid Python identifier, use it as is
138
+ key = value
139
+ else:
140
+ # Otherwise, create a synthetic name
141
+ key = f"VALUE_{i}"
142
+ enum_members[key] = value
143
+
144
+ # Create the enum class
145
+ enum_class = Enum(enum_name, enum_members)
146
+ python_type: Any = enum_class
147
+
148
+ # Handle enum default value specially
149
+ if field.default is not None:
150
+ # Store default value as the enum value string
151
+ # Pydantic v2 will convert it to the enum instance
152
+ if field.default in list(field.values):
153
+ field_constraints["default"] = field.default
154
+ else:
155
+ msg = (
156
+ f"Default value {field.default!r} not found "
157
+ f"in enum values for field {name!r}"
158
+ )
159
+ raise ValueError(msg)
160
+ else:
161
+ python_type = helpers.resolve_type_string(field.type)
162
+ if not python_type:
163
+ msg = f"Unsupported field type: {field.type}"
164
+ raise ValueError(msg)
165
+
166
+ # Handle literal constraint if provided
167
+ if field.literal_value is not None:
168
+ from typing import Literal as LiteralType
169
+
170
+ python_type = LiteralType[field.literal_value]
171
+
172
+ # Handle optional fields (allowing None)
173
+ if field.optional:
174
+ python_type = python_type | None # type: ignore
175
+
176
+ # Add standard Pydantic constraints
177
+ # Collect all constraint values
178
+ for constraint in [
179
+ "default",
180
+ "title",
181
+ "min_length",
182
+ "max_length",
183
+ "pattern",
184
+ "min_length",
185
+ "max_length",
186
+ "gt",
187
+ "ge",
188
+ "lt",
189
+ "le",
190
+ "multiple_of",
191
+ ]:
192
+ value = getattr(field, constraint, None)
193
+ if value is not None:
194
+ field_constraints[constraint] = value
195
+
196
+ # Handle examples separately (Pydantic v2 way)
197
+ if field.examples:
198
+ if field.json_schema_extra is None:
199
+ field.json_schema_extra = {}
200
+ field.json_schema_extra["examples"] = field.examples
201
+
202
+ # Add json_schema_extra if provided
203
+ if field.json_schema_extra:
204
+ field_constraints["json_schema_extra"] = field.json_schema_extra
205
+
206
+ # Add any additional constraints
207
+ field_constraints.update(field.constraints)
208
+
209
+ field_info = Field(description=field.description, **field_constraints)
210
+ fields[name] = (python_type, field_info)
211
+
212
+ cls_name = self.description or "ResponseType"
213
+ return create_model(
214
+ cls_name,
215
+ **fields,
216
+ __base__=BaseModel,
217
+ __doc__=self.description,
218
+ ) # type: ignore[call-overload]
219
+
220
+
221
+ class ImportedSchemaDef(BaseSchemaDef):
222
+ """Response definition that imports an existing Pydantic model.
223
+
224
+ Allows using externally defined Pydantic models as response types.
225
+ Benefits:
226
+ - Reuse existing model definitions
227
+ - Full Python type support
228
+ - Complex validation logic
229
+ - IDE support for imported types
230
+
231
+ Example:
232
+ responses:
233
+ AnalysisResult:
234
+ type: import
235
+ import_path: myapp.models.AnalysisResult
236
+ """
237
+
238
+ type: Literal["import"] = Field("import", init=False)
239
+ """Import-path based response definition."""
240
+
241
+ import_path: str
242
+ """The path to the pydantic model to use as the response type."""
243
+
244
+ # mypy is confused about "type"
245
+ # TODO: convert BaseModel to Schema?
246
+ def get_schema(self) -> type[BaseModel]: # type: ignore
247
+ """Import and return the model class."""
248
+ try:
249
+ model_class = helpers.import_class(self.import_path)
250
+ if not issubclass(model_class, BaseModel):
251
+ msg = f"{self.import_path} must be a Pydantic model"
252
+ raise TypeError(msg) # noqa: TRY301
253
+ except Exception as e:
254
+ msg = f"Failed to import response type {self.import_path}"
255
+ raise ValueError(msg) from e
256
+ else:
257
+ return model_class
258
+
259
+
260
+ SchemaDef = Annotated[InlineSchemaDef | ImportedSchemaDef, Field(discriminator="type")]
@@ -0,0 +1,192 @@
1
+ """Test enum support in SchemaField."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from pydantic import ValidationError
8
+ import pytest
9
+
10
+ from schemez.schemadef.schemadef import InlineSchemaDef, SchemaField
11
+
12
+
13
+ def test_explicit_constraints():
14
+ """Test that explicit constraints work correctly."""
15
+ fields = {
16
+ "name": SchemaField(
17
+ type="str",
18
+ min_length=3,
19
+ max_length=50,
20
+ pattern=r"^[a-zA-Z0-9_]+$",
21
+ ),
22
+ "age": SchemaField(type="int", description="User age", ge=18, lt=120),
23
+ "score": SchemaField(
24
+ type="float",
25
+ description="Score value",
26
+ ge=0.0,
27
+ le=100.0,
28
+ multiple_of=0.5,
29
+ ),
30
+ "tags": SchemaField(type="set[str]", min_length=1, max_length=10),
31
+ }
32
+ schema_def = InlineSchemaDef(description="Test Schema Constraints", fields=fields)
33
+ model = schema_def.get_schema()
34
+ valid_instance = model(
35
+ name="user123", # type: ignore
36
+ age=30, # type: ignore
37
+ score=95.5, # type: ignore
38
+ tags=["developer", "python"], # type: ignore
39
+ )
40
+ assert valid_instance.name == "user123" # type: ignore
41
+ assert valid_instance.age == 30 # type: ignore # noqa: PLR2004
42
+ assert valid_instance.score == 95.5 # type: ignore # noqa: PLR2004
43
+ assert valid_instance.tags == {"developer", "python"} # type: ignore
44
+
45
+ # Test min_length constraint
46
+ with pytest.raises(ValidationError):
47
+ model(
48
+ name="ab", # Too short # type: ignore
49
+ age=30, # type: ignore
50
+ score=95.5, # type: ignore
51
+ tags=["developer"], # type: ignore
52
+ )
53
+
54
+ # Test pattern constraint
55
+ with pytest.raises(ValidationError):
56
+ model(
57
+ name="user-name", # Invalid character # type: ignore
58
+ age=30, # type: ignore
59
+ score=95.5, # type: ignore
60
+ tags=["developer"], # type: ignore
61
+ )
62
+
63
+ # Test ge constraint
64
+ with pytest.raises(ValidationError):
65
+ model(
66
+ name="user123", # type: ignore
67
+ age=17, # Too young # type: ignore
68
+ score=95.5, # type: ignore
69
+ tags=["developer"], # type: ignore
70
+ )
71
+
72
+ # Test multiple_of constraint
73
+ with pytest.raises(ValidationError):
74
+ model(
75
+ name="user123", # type: ignore
76
+ age=30, # type: ignore
77
+ score=95.7, # Not a multiple of 0.5 # type: ignore
78
+ tags=["developer"], # type: ignore
79
+ )
80
+
81
+ # Test that set automatically enforces uniqueness
82
+ instance = model(
83
+ name="user123", # type: ignore
84
+ age=30, # type: ignore
85
+ score=95.5, # type: ignore
86
+ # Duplicates get automatically removed
87
+ tags=["developer", "developer", "python"], # type: ignore
88
+ )
89
+ assert len(instance.tags) == 2 # type: ignore # noqa: PLR2004
90
+ assert "developer" in instance.tags # type: ignore
91
+ assert "python" in instance.tags # type: ignore
92
+
93
+
94
+ def test_enum_type():
95
+ """Test that enum type creates an Enum type."""
96
+ fields = {
97
+ "color": SchemaField(
98
+ type="enum",
99
+ description="Color selection",
100
+ values=["red", "green", "blue"],
101
+ )
102
+ }
103
+ schema_def = InlineSchemaDef(description="Test Schema", fields=fields)
104
+
105
+ model = schema_def.get_schema()
106
+
107
+ # Check that the model has the color field with an Enum type
108
+ color_field = model.model_fields["color"]
109
+ assert color_field.description == "Color selection"
110
+
111
+ # The field should be an Enum type
112
+ color_value = model(color="red").color # type: ignore
113
+ assert isinstance(color_value, Enum)
114
+ assert color_value.value == "red"
115
+
116
+ # Check all values work
117
+ assert model(color="green").color.value == "green" # type: ignore
118
+ assert model(color="blue").color.value == "blue" # type: ignore
119
+
120
+ # Try with invalid value
121
+ with pytest.raises(ValidationError):
122
+ model(color="purple") # type: ignore
123
+
124
+
125
+ def test_enum_with_numeric_values():
126
+ """Test that enum type works with numeric values."""
127
+ fields = {
128
+ "priority": SchemaField(
129
+ type="enum",
130
+ description="Task priority",
131
+ values=[1, 2, 3, 5, 8],
132
+ )
133
+ }
134
+ schema_def = InlineSchemaDef(description="Test Schema", fields=fields)
135
+ model = schema_def.get_schema()
136
+
137
+ # Create a valid instance
138
+ instance = model(priority=5) # type: ignore
139
+ assert instance.priority.value == 5 # type: ignore # noqa: PLR2004
140
+ # Try with invalid value
141
+ with pytest.raises(ValidationError):
142
+ model(priority=4) # type: ignore
143
+
144
+
145
+ def test_mixed_enum_values():
146
+ """Test that enum type works with mixed value types."""
147
+ fields = {
148
+ "value": SchemaField(
149
+ type="enum",
150
+ description="Mixed values",
151
+ values=[1, "text", True],
152
+ )
153
+ }
154
+ schema_def = InlineSchemaDef(description="Test Schema", fields=fields)
155
+ model = schema_def.get_schema()
156
+
157
+ # Create valid instances
158
+ assert model(value=1).value.value == 1 # type: ignore
159
+ assert model(value="text").value.value == "text" # type: ignore
160
+ assert model(value=True).value.value # type: ignore
161
+
162
+ # Try with invalid value
163
+ with pytest.raises(ValidationError):
164
+ model(value="invalid") # type: ignore
165
+
166
+
167
+ def test_missing_enum_values():
168
+ """Test that enum type raises error when values are missing."""
169
+ fields = {"status": SchemaField(type="enum", description="Status with no vals")}
170
+ schema_def = InlineSchemaDef(description="Test Schema", fields=fields)
171
+ # Should raise an error when creating the schema
172
+ with pytest.raises(ValueError, match="has type 'enum' but no values defined"):
173
+ schema_def.get_schema()
174
+
175
+
176
+ def test_enum_with_default():
177
+ """Test that enum type works with default value."""
178
+ fields = {
179
+ "status": SchemaField(
180
+ type="enum",
181
+ description="Status with default",
182
+ values=["pending", "active", "completed"],
183
+ default="pending",
184
+ )
185
+ }
186
+ schema_def = InlineSchemaDef(description="Test Schema", fields=fields)
187
+
188
+ model = schema_def.get_schema()
189
+
190
+ # Test default value
191
+ instance = model()
192
+ assert instance.status == "pending" # type: ignore
@@ -0,0 +1,467 @@
1
+ """Test SchemaField functionality comprehensively."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from pydantic import ValidationError
8
+ import pytest
9
+
10
+ from schemez.schemadef.schemadef import InlineSchemaDef, SchemaField
11
+
12
+
13
+ def test_basic_types():
14
+ """Test that basic types are resolved correctly."""
15
+ schema_def = InlineSchemaDef(
16
+ description="Test Schema with Basic Types",
17
+ fields={
18
+ "string_field": SchemaField(type="str", description="A string field"),
19
+ "int_field": SchemaField(type="int", description="An integer field"),
20
+ "float_field": SchemaField(type="float", description="A float field"),
21
+ "bool_field": SchemaField(type="bool", description="A boolean field"),
22
+ "dict_field": SchemaField(type="dict", description="A dictionary field"),
23
+ "list_field": SchemaField(type="list", description="A list field"),
24
+ },
25
+ )
26
+
27
+ model = schema_def.get_schema()
28
+
29
+ # Check field types
30
+ assert model.model_fields["string_field"].annotation is str
31
+ assert model.model_fields["int_field"].annotation is int
32
+ assert model.model_fields["float_field"].annotation is float
33
+ assert model.model_fields["bool_field"].annotation is bool
34
+ assert model.model_fields["dict_field"].annotation is dict
35
+ assert model.model_fields["list_field"].annotation is list
36
+
37
+ # Check descriptions
38
+ assert model.model_fields["string_field"].description == "A string field"
39
+
40
+
41
+ def test_generic_types():
42
+ """Test that generic types like list[str] are resolved correctly."""
43
+ schema_def = InlineSchemaDef(
44
+ description="Test Schema with Generic Types",
45
+ fields={
46
+ "str_list": SchemaField(type="list[str]", description="List of strings"),
47
+ "int_list": SchemaField(type="list[int]", description="List of integers"),
48
+ "str_dict": SchemaField(
49
+ type="dict[str, int]", description="String to int mapping"
50
+ ),
51
+ },
52
+ )
53
+
54
+ model = schema_def.get_schema()
55
+
56
+ # Create valid instances
57
+ instance = model(
58
+ str_list=["a", "b", "c"], # type: ignore
59
+ int_list=[1, 2, 3], # type: ignore
60
+ str_dict={"a": 1, "b": 2}, # type: ignore
61
+ )
62
+
63
+ assert instance.str_list == ["a", "b", "c"] # type: ignore
64
+ assert instance.int_list == [1, 2, 3] # type: ignore
65
+ assert instance.str_dict == {"a": 1, "b": 2} # type: ignore
66
+
67
+ # Test type validation
68
+ with pytest.raises(ValidationError):
69
+ model(str_list=[1, 2, 3]) # type: ignore
70
+
71
+ with pytest.raises(ValidationError):
72
+ model(int_list=["a", "b", "c"]) # type: ignore
73
+
74
+ with pytest.raises(ValidationError):
75
+ model(str_dict={1: "a"}) # type: ignore
76
+
77
+
78
+ def test_string_constraints():
79
+ """Test string validation constraints."""
80
+ schema_def = InlineSchemaDef(
81
+ description="Test Schema with String Constraints",
82
+ fields={
83
+ "username": SchemaField(
84
+ type="str",
85
+ description="Username",
86
+ min_length=3,
87
+ max_length=20,
88
+ pattern=r"^[a-z0-9_]+$",
89
+ ),
90
+ },
91
+ )
92
+
93
+ model = schema_def.get_schema()
94
+
95
+ # Valid username
96
+ assert model(username="user_123").username == "user_123" # type: ignore
97
+
98
+ # Too short
99
+ with pytest.raises(ValidationError):
100
+ model(username="ab") # type: ignore
101
+
102
+ # Too long
103
+ with pytest.raises(ValidationError):
104
+ model(username="a" * 21) # type: ignore
105
+
106
+ # Invalid pattern
107
+ with pytest.raises(ValidationError):
108
+ model(username="User-Name") # type: ignore
109
+
110
+
111
+ def test_numeric_constraints():
112
+ """Test numeric validation constraints."""
113
+ schema_def = InlineSchemaDef(
114
+ description="Test Schema with Numeric Constraints",
115
+ fields={
116
+ "age": SchemaField(
117
+ type="int",
118
+ description="Age",
119
+ ge=18,
120
+ lt=120,
121
+ ),
122
+ "score": SchemaField(
123
+ type="float",
124
+ description="Score",
125
+ gt=0.0,
126
+ le=100.0,
127
+ multiple_of=0.5,
128
+ ),
129
+ },
130
+ )
131
+
132
+ model = schema_def.get_schema()
133
+
134
+ # Valid values
135
+ instance = model(age=18, score=99.5) # type: ignore
136
+ assert instance.age == 18 # type: ignore # noqa: PLR2004
137
+ assert instance.score == 99.5 # type: ignore # noqa: PLR2004
138
+
139
+ # Test ge constraint
140
+ with pytest.raises(ValidationError):
141
+ model(age=17, score=50.0) # type: ignore
142
+
143
+ # Test lt constraint
144
+ with pytest.raises(ValidationError):
145
+ model(age=120, score=50.0) # type: ignore
146
+
147
+ # Test gt constraint
148
+ with pytest.raises(ValidationError):
149
+ model(age=18, score=0.0) # type: ignore
150
+
151
+ # Test le constraint
152
+ with pytest.raises(ValidationError):
153
+ model(age=18, score=100.5) # type: ignore
154
+
155
+ # Test multiple_of constraint
156
+ with pytest.raises(ValidationError):
157
+ model(age=18, score=50.1) # type: ignore
158
+
159
+
160
+ def test_collection_constraints():
161
+ """Test collection validation constraints."""
162
+ schema_def = InlineSchemaDef(
163
+ description="Test Schema with Collection Constraints",
164
+ fields={
165
+ "tags": SchemaField(
166
+ type="list[str]",
167
+ description="Tags",
168
+ min_length=1,
169
+ max_length=5,
170
+ ),
171
+ "unique_tags": SchemaField(
172
+ type="set[str]",
173
+ description="Tags with unique values",
174
+ min_length=1,
175
+ max_length=5,
176
+ ),
177
+ },
178
+ )
179
+
180
+ model = schema_def.get_schema()
181
+
182
+ # Valid - provide both required fields
183
+ instance = model(
184
+ tags=["one", "two"], # type: ignore
185
+ unique_tags={"one", "two"}, # type: ignore
186
+ )
187
+ assert instance.tags == ["one", "two"] # type: ignore
188
+ assert instance.unique_tags == {"one", "two"} # type: ignore
189
+
190
+ # Too few items - need to provide both fields
191
+ with pytest.raises(ValidationError):
192
+ model(
193
+ tags=[], # type: ignore
194
+ unique_tags={"one", "two"}, # type: ignore
195
+ )
196
+
197
+ with pytest.raises(ValidationError):
198
+ model(
199
+ tags=["one", "two"], # type: ignore
200
+ unique_tags=set(), # type: ignore
201
+ )
202
+
203
+ # Too many items
204
+ with pytest.raises(ValidationError):
205
+ model(
206
+ tags=["one", "two", "three", "four", "five", "six"], # type: ignore
207
+ unique_tags={"one", "two"}, # type: ignore
208
+ )
209
+
210
+ with pytest.raises(ValidationError):
211
+ model(
212
+ tags=["one", "two"], # type: ignore
213
+ unique_tags={"one", "two", "three", "four", "five", "six"}, # type: ignore
214
+ )
215
+
216
+ # Non-unique items are allowed in list but automatically deduplicated in set
217
+ instance = model(
218
+ tags=["one", "one", "two"], # type: ignore
219
+ unique_tags={"one", "two"}, # type: ignore
220
+ )
221
+ assert instance.tags == ["one", "one", "two"] # type: ignore
222
+
223
+ # Attempting to pass non-unique items to a set field will automatically deduplicate
224
+ instance = model(
225
+ tags=["one", "two"], # type: ignore
226
+ unique_tags=["one", "one", "two"], # type: ignore # Will be converted to set
227
+ )
228
+ assert instance.unique_tags == {"one", "two"} # type: ignore
229
+
230
+
231
+ def test_default_values():
232
+ """Test default values for fields."""
233
+ schema_def = InlineSchemaDef(
234
+ description="Test Schema with Default Values",
235
+ fields={
236
+ "name": SchemaField(
237
+ type="str",
238
+ description="Name",
239
+ default="Anonymous",
240
+ ),
241
+ "active": SchemaField(
242
+ type="bool",
243
+ description="Active status",
244
+ default=True,
245
+ ),
246
+ "count": SchemaField(
247
+ type="int",
248
+ description="Count",
249
+ default=0,
250
+ ),
251
+ },
252
+ )
253
+
254
+ model = schema_def.get_schema()
255
+
256
+ # Test defaults when not provided
257
+ instance = model()
258
+ assert instance.name == "Anonymous" # type: ignore
259
+ assert instance.active is True # type: ignore
260
+ assert instance.count == 0 # type: ignore
261
+
262
+ # Test overriding defaults
263
+ instance = model(name="User", active=False, count=10) # type: ignore
264
+ assert instance.name == "User" # type: ignore
265
+ assert instance.active is False # type: ignore
266
+ assert instance.count == 10 # type: ignore # noqa: PLR2004
267
+
268
+
269
+ def test_const_constraint():
270
+ """Test const constraint."""
271
+ schema_def = InlineSchemaDef(
272
+ description="Test Schema with Const Constraint",
273
+ fields={
274
+ "version": SchemaField(
275
+ type="str",
276
+ description="API Version",
277
+ literal_value="1.0",
278
+ ),
279
+ },
280
+ )
281
+
282
+ model = schema_def.get_schema()
283
+
284
+ # Valid
285
+ assert model(version="1.0").version == "1.0" # type: ignore
286
+
287
+ # Invalid
288
+ with pytest.raises(ValidationError):
289
+ model(version="2.0") # type: ignore
290
+
291
+
292
+ def test_enum_type():
293
+ """Test enum type field."""
294
+ schema_def = InlineSchemaDef(
295
+ description="Test Schema with Enum",
296
+ fields={
297
+ "status": SchemaField(
298
+ type="enum",
299
+ description="Status",
300
+ values=["pending", "active", "completed"],
301
+ ),
302
+ },
303
+ )
304
+
305
+ model = schema_def.get_schema()
306
+
307
+ # The field should use an Enum type
308
+ status_field = model.model_fields["status"]
309
+ assert issubclass(status_field.annotation, Enum) # type: ignore
310
+
311
+ # Valid values
312
+ status = model(status="pending").status # type: ignore
313
+ assert isinstance(status, Enum)
314
+ assert status.value == "pending"
315
+
316
+ # Invalid value
317
+ with pytest.raises(ValidationError):
318
+ model(status="invalid") # type: ignore
319
+
320
+
321
+ def test_enum_with_default():
322
+ """Test enum with default value."""
323
+ schema_def = InlineSchemaDef(
324
+ description="Test Schema with Enum Default",
325
+ fields={
326
+ "role": SchemaField(
327
+ type="enum",
328
+ description="User Role",
329
+ values=["admin", "user", "guest"],
330
+ default="user",
331
+ ),
332
+ },
333
+ )
334
+
335
+ model = schema_def.get_schema()
336
+
337
+ # Test default
338
+ instance = model()
339
+ assert instance.role == "user" # type: ignore
340
+
341
+ # Override default
342
+ instance = model(role="admin") # type: ignore
343
+ assert instance.role.value == "admin" # type: ignore
344
+
345
+
346
+ def test_nullable_constraint():
347
+ """Test nullable constraint."""
348
+ schema_def = InlineSchemaDef(
349
+ description="Test Schema with Nullable Fields",
350
+ fields={
351
+ "name": SchemaField(
352
+ type="str",
353
+ description="Optional name",
354
+ optional=True,
355
+ ),
356
+ },
357
+ )
358
+
359
+ model = schema_def.get_schema()
360
+
361
+ # Test null value
362
+ instance = model(name=None) # type: ignore
363
+ assert instance.name is None # type: ignore
364
+
365
+
366
+ def test_additional_constraints():
367
+ """Test additional constraints via the constraints dict."""
368
+ schema_def = InlineSchemaDef(
369
+ description="Test Schema with Additional Constraints",
370
+ fields={
371
+ "custom_field": SchemaField(
372
+ type="str",
373
+ description="Field with custom constraint",
374
+ examples=["example1", "example2"],
375
+ ),
376
+ },
377
+ )
378
+
379
+ model = schema_def.get_schema()
380
+ assert model(custom_field="test").custom_field == "test" # type: ignore
381
+
382
+
383
+ def test_title_constraint():
384
+ """Test title constraint for JSON Schema generation."""
385
+ schema_def = InlineSchemaDef(
386
+ description="Test Schema with Title",
387
+ fields={
388
+ "user_id": SchemaField(
389
+ type="str",
390
+ description="User ID",
391
+ title="User Identifier",
392
+ ),
393
+ },
394
+ )
395
+
396
+ model = schema_def.get_schema()
397
+
398
+ # Check that the title was set
399
+ field = model.model_fields["user_id"]
400
+ assert field.title == "User Identifier"
401
+
402
+
403
+ def test_complex_nested_structure():
404
+ """Test complex nested structure with various constraints."""
405
+ schema_def = InlineSchemaDef(
406
+ description="Complex Nested Schema",
407
+ fields={
408
+ "user": SchemaField(
409
+ type="dict[str, Any]",
410
+ description="User information",
411
+ json_schema_extra={
412
+ "required": ["name", "email"],
413
+ },
414
+ ),
415
+ "settings": SchemaField(
416
+ type="dict[str, bool]",
417
+ description="User settings",
418
+ default={},
419
+ ),
420
+ "metadata": SchemaField(
421
+ type="dict",
422
+ description="Additional metadata",
423
+ optional=True,
424
+ ),
425
+ },
426
+ )
427
+
428
+ model = schema_def.get_schema()
429
+
430
+ # Test valid complex structure
431
+ instance = model(
432
+ user={"name": "Test User", "email": "test@example.com", "age": 30}, # type: ignore
433
+ settings={"notifications": True, "darkMode": False}, # type: ignore
434
+ metadata={"created": "2023-01-01", "source": "API"}, # type: ignore
435
+ )
436
+
437
+ assert instance.user["name"] == "Test User" # type: ignore
438
+ assert instance.settings["notifications"] is True # type: ignore
439
+ assert instance.metadata["source"] == "API" # type: ignore
440
+
441
+ # Test with null metadata
442
+ instance = model(
443
+ user={"name": "Test User", "email": "test@example.com"}, # type: ignore
444
+ settings={"notifications": True}, # type: ignore
445
+ metadata=None, # type: ignore
446
+ )
447
+ # Test optional field with None value
448
+ assert instance.metadata is None # type: ignore
449
+
450
+ # Test with default empty settings
451
+ instance = model(
452
+ user={"name": "Test User", "email": "test@example.com"}, # type: ignore
453
+ metadata=None, # type: ignore
454
+ )
455
+ assert instance.settings == {} # type: ignore
456
+
457
+
458
+ if __name__ == "__main__":
459
+ test_basic_types()
460
+ test_generic_types()
461
+ test_string_constraints()
462
+ test_numeric_constraints()
463
+ test_collection_constraints()
464
+ test_default_values()
465
+ test_const_constraint()
466
+ test_enum_type()
467
+ test_enum_with_default()
@@ -1,120 +0,0 @@
1
- """Models for schema fields and definitions."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Annotated, Any, Literal
6
-
7
- from pydantic import BaseModel, Field, create_model
8
-
9
- from schemez import Schema, helpers
10
-
11
-
12
- class SchemaField(Schema):
13
- """Field definition for inline response types.
14
-
15
- Defines a single field in an inline response definition, including:
16
- - Data type specification
17
- - Optional description
18
- - Validation constraints
19
-
20
- Used by InlineSchemaDef to structure response fields.
21
- """
22
-
23
- type: str
24
- """Data type of the response field"""
25
-
26
- description: str | None = None
27
- """Optional description of what this field represents"""
28
-
29
- constraints: dict[str, Any] = Field(default_factory=dict)
30
- """Optional validation constraints for the field"""
31
-
32
-
33
- class BaseSchemaDef(Schema):
34
- """Base class for response definitions."""
35
-
36
- type: str = Field(init=False)
37
-
38
- description: str | None = None
39
- """A description for this response definition."""
40
-
41
-
42
- class InlineSchemaDef(BaseSchemaDef):
43
- """Inline definition of schema.
44
-
45
- Allows defining response types directly in the configuration using:
46
- - Field definitions with types and descriptions
47
- - Optional validation constraints
48
- - Custom field descriptions
49
-
50
- Example:
51
- schemas:
52
- BasicResult:
53
- type: inline
54
- fields:
55
- success: {type: bool, description: "Operation success"}
56
- message: {type: str, description: "Result details"}
57
- """
58
-
59
- type: Literal["inline"] = Field("inline", init=False)
60
- """Inline response definition."""
61
-
62
- fields: dict[str, SchemaField]
63
- """A dictionary containing all fields."""
64
-
65
- def get_schema(self) -> type[Schema]: # type: ignore
66
- """Create Pydantic model from inline definition."""
67
- fields = {}
68
- for name, field in self.fields.items():
69
- python_type = helpers.resolve_type_string(field.type)
70
- if not python_type:
71
- msg = f"Unsupported field type: {field.type}"
72
- raise ValueError(msg)
73
-
74
- field_info = Field(description=field.description, **(field.constraints))
75
- fields[name] = (python_type, field_info)
76
-
77
- cls_name = self.description or "ResponseType"
78
- return create_model(cls_name, **fields, __base__=Schema, __doc__=self.description) # type: ignore[call-overload]
79
-
80
-
81
- class ImportedSchemaDef(BaseSchemaDef):
82
- """Response definition that imports an existing Pydantic model.
83
-
84
- Allows using externally defined Pydantic models as response types.
85
- Benefits:
86
- - Reuse existing model definitions
87
- - Full Python type support
88
- - Complex validation logic
89
- - IDE support for imported types
90
-
91
- Example:
92
- responses:
93
- AnalysisResult:
94
- type: import
95
- import_path: myapp.models.AnalysisResult
96
- """
97
-
98
- type: Literal["import"] = Field("import", init=False)
99
- """Import-path based response definition."""
100
-
101
- import_path: str
102
- """The path to the pydantic model to use as the response type."""
103
-
104
- # mypy is confused about "type"
105
- # TODO: convert BaseModel to Schema?
106
- def get_schema(self) -> type[BaseModel]: # type: ignore
107
- """Import and return the model class."""
108
- try:
109
- model_class = helpers.import_class(self.import_path)
110
- if not issubclass(model_class, BaseModel):
111
- msg = f"{self.import_path} must be a Pydantic model"
112
- raise TypeError(msg) # noqa: TRY301
113
- except Exception as e:
114
- msg = f"Failed to import response type {self.import_path}"
115
- raise ValueError(msg) from e
116
- else:
117
- return model_class
118
-
119
-
120
- SchemaDef = Annotated[InlineSchemaDef | ImportedSchemaDef, Field(discriminator="type")]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes