Skip to content

Commit 4c0aa13

Browse files
committed
Update: modify 4 file(s)
1 parent f0af639 commit 4c0aa13

4 files changed

Lines changed: 184 additions & 83 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ uv.lock
1010

1111
.kiro/*
1212
.kiro/**/*
13+
14+
build/

docs/docs/Guides/StructuredModel_Export.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ A typical workflow is to create a model with default comparators, export the con
1616
from stickler import StructuredModel, ComparableField
1717

1818
# Step 1: Create model with defaults
19+
# Note: default=... means "required" in Pydantic
20+
# Use default=None for optional fields
1921
class Product(StructuredModel):
20-
name: str = ComparableField(default=...)
21-
price: float = ComparableField(default=...)
22-
description: str = ComparableField(default=...)
22+
name: str = ComparableField(default=...) # Required field
23+
price: float = ComparableField(default=...) # Required field
24+
description: str = ComparableField(default=None) # Optional field
2325

2426
# Step 2: Export to get default configuration
2527
config = Product.to_stickler_config()
@@ -81,11 +83,13 @@ ReconstructedProduct = StructuredModel.from_json_schema(schema)
8183
"properties": {
8284
"name": {
8385
"type": "string",
86+
"x-aws-stickler-comparator": "LevenshteinComparator",
8487
"x-aws-stickler-threshold": 0.8,
8588
"x-aws-stickler-weight": 2.0
8689
},
8790
"price": {
8891
"type": "number",
92+
"x-aws-stickler-comparator": "NumericComparator",
8993
"x-aws-stickler-threshold": 0.95
9094
}
9195
},
@@ -120,12 +124,14 @@ ReconstructedProduct = StructuredModel.model_from_json(config)
120124
"fields": {
121125
"name": {
122126
"type": "str",
127+
"comparator": "LevenshteinComparator",
123128
"threshold": 0.8,
124129
"weight": 2.0,
125130
"required": true
126131
},
127132
"price": {
128133
"type": "float",
134+
"comparator": "NumericComparator",
129135
"threshold": 0.95,
130136
"required": true
131137
}
@@ -178,24 +184,32 @@ config = Customer.to_stickler_config()
178184

179185
## Lists of StructuredModels
180186

181-
Lists are exported with their element schemas:
187+
Lists are exported with their element schemas.
188+
189+
**Important:** When comparing `List[StructuredModel]` fields, Stickler uses the element model's `match_threshold` class attribute for Hungarian matching. You cannot specify a custom threshold or comparator on the list field itself.
182190

183191
```python
184192
from typing import List
185193

194+
class LineItem(StructuredModel):
195+
match_threshold = 0.8 # Used for matching list elements
196+
product: str = ComparableField(default=...)
197+
quantity: int = ComparableField(default=...)
198+
186199
class Order(StructuredModel):
187200
order_id: str = ComparableField(threshold=1.0, default=...)
188-
products: List[Product] = ComparableField(default=...)
201+
# The list field uses LineItem.match_threshold for matching
202+
products: List[LineItem] = ComparableField(default=...)
189203

190204
# JSON Schema export
191205
schema = Order.to_json_schema()
192206
# schema["properties"]["products"]["type"] == "array"
193-
# schema["properties"]["products"]["items"] contains Product schema
207+
# schema["properties"]["products"]["items"] contains LineItem schema
194208

195209
# Stickler config export
196210
config = Order.to_stickler_config()
197211
# config["fields"]["products"]["type"] == "list_structured_model"
198-
# config["fields"]["products"]["fields"] contains Product fields
212+
# config["fields"]["products"]["fields"] contains LineItem fields
199213
```
200214

201215
## Round-trip Examples
@@ -391,4 +405,4 @@ git commit -m "Add Product model schema v1"
391405

392406
- [StructuredModel Dynamic Creation](StructuredModel_Dynamic_Creation.md) - Import methods
393407
- [StructuredModel Advanced Functionality](StructuredModel_Advanced_Functionality.md) - Comparison features
394-
- [JSON Schema Extensions Reference](../README.md#json-schema-extensions-x-aws-stickler--complete-reference) - Full extension documentation
408+
- [JSON Schema Extensions](../../index.md) - Full extension documentation in main README

src/stickler/structured_object_evaluator/models/json_schema_field_converter.py

Lines changed: 87 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from typing import Any, Dict, List, Tuple, Type
88

9+
from pydantic.fields import FieldInfo
10+
911
from .comparable_field import ComparableField
1012
from .comparator_registry import create_comparator
1113

@@ -450,7 +452,7 @@ def _handle_array_type(
450452
return field_type, field
451453

452454

453-
def field_to_property(self, field_type: Type, field_info) -> Dict[str, Any]:
455+
def field_to_property(self, field_type: Type, field_info: FieldInfo) -> Dict[str, Any]:
454456
"""Convert Pydantic field to JSON Schema property.
455457
456458
Extracts comparison metadata from the field's json_schema_extra attribute
@@ -462,37 +464,26 @@ def field_to_property(self, field_type: Type, field_info) -> Dict[str, Any]:
462464
463465
Returns:
464466
JSON Schema property dict with x-aws-stickler-* extensions
465-
466-
Example:
467-
>>> converter = JsonSchemaFieldConverter(schema={}, field_path="")
468-
>>> field_info = model.model_fields["name"]
469-
>>> property_schema = converter.field_to_property(str, field_info)
470-
>>> print(property_schema["x-aws-stickler-comparator"])
471-
'LevenshteinComparator'
472467
"""
473-
# Use reverse mapping to avoid duplicating type definitions
474468
json_type = PYTHON_TYPE_TO_JSON_TYPE.get(field_type, "string")
475-
476469
property_schema = {"type": json_type}
477470

478-
# Extract metadata from field
471+
# Extract metadata and build extensions using consolidated helper
479472
metadata = self._extract_field_metadata(field_info)
473+
extensions = self._build_comparison_extensions(metadata, format="json_schema")
474+
property_schema.update(extensions)
480475

481-
# Add x-aws-stickler-* extensions only if metadata exists
482-
if metadata.get("comparator"):
483-
property_schema["x-aws-stickler-comparator"] = metadata["comparator"].__class__.__name__
484-
if "threshold" in metadata:
485-
property_schema["x-aws-stickler-threshold"] = metadata["threshold"]
486-
if "weight" in metadata:
487-
property_schema["x-aws-stickler-weight"] = metadata["weight"]
488-
if metadata.get("clip_under_threshold") is not None:
489-
property_schema["x-aws-stickler-clip-under-threshold"] = metadata["clip_under_threshold"]
490-
if metadata.get("aggregate"):
491-
property_schema["x-aws-stickler-aggregate"] = metadata["aggregate"]
476+
# Add Pydantic field params
477+
if field_info.description:
478+
property_schema["description"] = field_info.description
479+
if field_info.alias:
480+
property_schema["alias"] = field_info.alias
481+
if field_info.examples:
482+
property_schema["examples"] = field_info.examples
492483

493484
return property_schema
494485

495-
def field_to_stickler_config(self, field_type: Type, field_info) -> Dict[str, Any]:
486+
def field_to_stickler_config(self, field_type: Type, field_info: FieldInfo) -> Dict[str, Any]:
496487
"""Convert Pydantic field to Stickler config format.
497488
498489
Extracts comparison metadata and formats it as custom Stickler configuration
@@ -504,54 +495,81 @@ def field_to_stickler_config(self, field_type: Type, field_info) -> Dict[str, An
504495
505496
Returns:
506497
Stickler field config dict with type, comparator, threshold, etc.
507-
508-
Example:
509-
>>> converter = JsonSchemaFieldConverter(schema={}, field_path="")
510-
>>> field_info = model.model_fields["name"]
511-
>>> config = converter.field_to_stickler_config(str, field_info)
512-
>>> print(config["type"])
513-
'str'
514498
"""
515-
# Use reverse mapping for Stickler type strings
516499
stickler_type = PYTHON_TYPE_TO_STICKLER_TYPE.get(field_type, "str")
517-
518500
field_config = {"type": stickler_type}
519501

520-
# Extract metadata
502+
# Extract metadata and build extensions using consolidated helper
521503
metadata = self._extract_field_metadata(field_info)
504+
extensions = self._build_comparison_extensions(metadata, format="stickler_config")
505+
field_config.update(extensions)
522506

523-
# Add comparison config only if metadata exists
524-
if metadata.get("comparator"):
525-
field_config["comparator"] = metadata["comparator"].__class__.__name__
526-
if "threshold" in metadata:
527-
field_config["threshold"] = metadata["threshold"]
528-
if "weight" in metadata:
529-
field_config["weight"] = metadata["weight"]
530-
if metadata.get("clip_under_threshold") is not None:
531-
field_config["clip_under_threshold"] = metadata["clip_under_threshold"]
532-
if metadata.get("aggregate"):
533-
field_config["aggregate"] = metadata["aggregate"]
534-
535-
# Add Pydantic field params - use is_required() for Pydantic compatibility
507+
# Add Pydantic field params
536508
field_config["required"] = field_info.is_required()
537509
if not field_info.is_required():
538510
field_config["default"] = field_info.default
539511
if field_info.description:
540512
field_config["description"] = field_info.description
513+
if field_info.alias:
514+
field_config["alias"] = field_info.alias
515+
if field_info.examples:
516+
field_config["examples"] = field_info.examples
541517

542518
return field_config
543519

544-
def _extract_field_metadata(self, field_info) -> Dict[str, Any]:
520+
def _build_comparison_extensions(
521+
self,
522+
metadata: Dict[str, Any],
523+
format: str = "json_schema"
524+
) -> Dict[str, Any]:
525+
"""Build comparison extensions in specified format.
526+
527+
Consolidates duplicate logic from field_to_property() and field_to_stickler_config().
528+
529+
Args:
530+
metadata: Extracted field metadata from _extract_field_metadata()
531+
format: Output format - "json_schema" or "stickler_config"
532+
533+
Returns:
534+
Dictionary with comparison extensions in the specified format
535+
"""
536+
extensions = {}
537+
prefix = "x-aws-stickler-" if format == "json_schema" else ""
538+
539+
# Export comparator class name and configuration
540+
if metadata.get("comparator"):
541+
comparator = metadata["comparator"]
542+
extensions[f"{prefix}comparator"] = comparator.__class__.__name__
543+
544+
# Export comparator configuration (e.g., tolerance, case_sensitive)
545+
if hasattr(comparator, "config") and comparator.config:
546+
config_key = f"{prefix}comparator-config" if format == "json_schema" else "comparator_config"
547+
extensions[config_key] = comparator.config
548+
549+
# Export comparison parameters
550+
if "threshold" in metadata:
551+
extensions[f"{prefix}threshold"] = metadata["threshold"]
552+
if "weight" in metadata:
553+
extensions[f"{prefix}weight"] = metadata["weight"]
554+
if metadata.get("clip_under_threshold") is not None:
555+
clip_key = f"{prefix}clip-under-threshold" if format == "json_schema" else "clip_under_threshold"
556+
extensions[clip_key] = metadata["clip_under_threshold"]
557+
if metadata.get("aggregate") is not None:
558+
extensions[f"{prefix}aggregate"] = metadata["aggregate"]
559+
560+
return extensions
561+
562+
def _extract_field_metadata(self, field_info: FieldInfo) -> Dict[str, Any]:
545563
"""Extract comparison metadata from field's json_schema_extra.
546564
547-
The metadata is stored as attributes on the json_schema_extra function
548-
by ComparableField() to work around Pydantic's __slots__ restriction.
565+
Only includes attributes that are explicitly set (no default values).
549566
550567
Args:
551568
field_info: Pydantic FieldInfo object
552569
553570
Returns:
554-
Dictionary with comparator, threshold, weight, etc.
571+
Dictionary with explicitly set comparator, threshold, weight, etc.
572+
Empty dict if no metadata found.
555573
"""
556574
if not hasattr(field_info, "json_schema_extra"):
557575
return {}
@@ -560,11 +578,22 @@ def _extract_field_metadata(self, field_info) -> Dict[str, Any]:
560578
if not callable(json_func):
561579
return {}
562580

563-
# Extract stored attributes from the function object
564-
return {
565-
"comparator": getattr(json_func, "_comparator_instance", None),
566-
"threshold": getattr(json_func, "_threshold", 0.5),
567-
"weight": getattr(json_func, "_weight", 1.0),
568-
"clip_under_threshold": getattr(json_func, "_clip_under_threshold", True),
569-
"aggregate": getattr(json_func, "_aggregate", False),
570-
}
581+
# Only include attributes that are explicitly set
582+
metadata = {}
583+
584+
if hasattr(json_func, "_comparator_instance"):
585+
metadata["comparator"] = json_func._comparator_instance
586+
587+
if hasattr(json_func, "_threshold"):
588+
metadata["threshold"] = json_func._threshold
589+
590+
if hasattr(json_func, "_weight"):
591+
metadata["weight"] = json_func._weight
592+
593+
if hasattr(json_func, "_clip_under_threshold"):
594+
metadata["clip_under_threshold"] = json_func._clip_under_threshold
595+
596+
if hasattr(json_func, "_aggregate"):
597+
metadata["aggregate"] = json_func._aggregate
598+
599+
return metadata

0 commit comments

Comments
 (0)