sonnet-core 0.1.0__py3-none-any.whl
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.
- sonnet_core/__init__.py +64 -0
- sonnet_core/enum_column.py +56 -0
- sonnet_core/id_generator.py +59 -0
- sonnet_core/json_schema.py +191 -0
- sonnet_core/model_builder.py +353 -0
- sonnet_core/model_converter.py +224 -0
- sonnet_core/schema_utils.py +82 -0
- sonnet_core/version_sort.py +138 -0
- sonnet_core-0.1.0.dist-info/METADATA +52 -0
- sonnet_core-0.1.0.dist-info/RECORD +12 -0
- sonnet_core-0.1.0.dist-info/WHEEL +5 -0
- sonnet_core-0.1.0.dist-info/top_level.txt +1 -0
sonnet_core/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""sonnet-core: framework-agnostic core library for Petrarca Labs services.
|
|
2
|
+
|
|
3
|
+
Pure data-layer and logic building blocks with no web/server framework
|
|
4
|
+
dependency. Public surface::
|
|
5
|
+
|
|
6
|
+
from sonnet_core import (
|
|
7
|
+
# ids
|
|
8
|
+
generate_id, to_base36,
|
|
9
|
+
# models
|
|
10
|
+
ModelBuilder, create_model, create_model_builder,
|
|
11
|
+
to_response_model, update_model_fields,
|
|
12
|
+
# schema
|
|
13
|
+
SchemaValidationError, ValidationResult, validate_instance, validate_schema,
|
|
14
|
+
combine_schemas, extract_schemas_from_model_infos,
|
|
15
|
+
# enums
|
|
16
|
+
str_enum_column,
|
|
17
|
+
# versioning
|
|
18
|
+
VALID_ALGORITHMS, DEFAULT_ALGORITHM, pick_latest, version_sort_key,
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from sonnet_core.enum_column import str_enum_column
|
|
23
|
+
from sonnet_core.id_generator import generate_id, to_base36
|
|
24
|
+
from sonnet_core.json_schema import (
|
|
25
|
+
SchemaValidationError,
|
|
26
|
+
ValidationResult,
|
|
27
|
+
validate_instance,
|
|
28
|
+
validate_schema,
|
|
29
|
+
)
|
|
30
|
+
from sonnet_core.model_builder import ModelBuilder, create_model, create_model_builder
|
|
31
|
+
from sonnet_core.model_converter import to_response_model, update_model_fields
|
|
32
|
+
from sonnet_core.schema_utils import combine_schemas, extract_schemas_from_model_infos
|
|
33
|
+
from sonnet_core.version_sort import (
|
|
34
|
+
DEFAULT_ALGORITHM,
|
|
35
|
+
VALID_ALGORITHMS,
|
|
36
|
+
pick_latest,
|
|
37
|
+
version_sort_key,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# ID generation
|
|
42
|
+
"generate_id",
|
|
43
|
+
"to_base36",
|
|
44
|
+
# Model builder / converter
|
|
45
|
+
"ModelBuilder",
|
|
46
|
+
"create_model",
|
|
47
|
+
"create_model_builder",
|
|
48
|
+
"to_response_model",
|
|
49
|
+
"update_model_fields",
|
|
50
|
+
# JSON schema
|
|
51
|
+
"SchemaValidationError",
|
|
52
|
+
"ValidationResult",
|
|
53
|
+
"validate_instance",
|
|
54
|
+
"validate_schema",
|
|
55
|
+
"combine_schemas",
|
|
56
|
+
"extract_schemas_from_model_infos",
|
|
57
|
+
# Enum column
|
|
58
|
+
"str_enum_column",
|
|
59
|
+
# Version ordering
|
|
60
|
+
"VALID_ALGORITHMS",
|
|
61
|
+
"DEFAULT_ALGORITHM",
|
|
62
|
+
"pick_latest",
|
|
63
|
+
"version_sort_key",
|
|
64
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Helper for mapping a StrEnum to a plain VARCHAR column with value coercion.
|
|
2
|
+
|
|
3
|
+
StrEnum fields are stored as their string *values* (not member names) in a
|
|
4
|
+
plain VARCHAR column -- no native Postgres enum type and no CHECK constraint,
|
|
5
|
+
so new enum values can be added without a migration.
|
|
6
|
+
|
|
7
|
+
The key detail: SQLAlchemy's ``Enum`` type looks up by member *name* by
|
|
8
|
+
default, but our StrEnums use lowercase values (``CREATED = "created"``).
|
|
9
|
+
``values_callable`` makes it store and load by value, and the result
|
|
10
|
+
processor coerces the stored string back into the enum instance on load --
|
|
11
|
+
so the ORM object holds a real enum, not a bare string. This prevents the
|
|
12
|
+
Pydantic V2 ``PydanticSerializationUnexpectedValue`` serializer warning that
|
|
13
|
+
occurs when a model holding a bare string is dumped against an enum-typed
|
|
14
|
+
field.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
import sqlalchemy as sa
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def str_enum_column(
|
|
23
|
+
enum_cls: type[StrEnum],
|
|
24
|
+
*,
|
|
25
|
+
nullable: bool = False,
|
|
26
|
+
server_default: str | None = None,
|
|
27
|
+
length: int = 32,
|
|
28
|
+
index: bool = False,
|
|
29
|
+
) -> sa.Column:
|
|
30
|
+
"""Return a Column storing a StrEnum as a constraint-free VARCHAR by value.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
enum_cls: The StrEnum subclass to map.
|
|
34
|
+
nullable: Whether the column allows NULL.
|
|
35
|
+
server_default: Optional server-side default (the enum *value* string).
|
|
36
|
+
length: VARCHAR length. Defaults to 32.
|
|
37
|
+
index: Whether to index the column.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A SQLAlchemy Column that round-trips str <-> enum and emits no CHECK
|
|
41
|
+
constraint (plain VARCHAR), so enum values can be added without a
|
|
42
|
+
schema migration.
|
|
43
|
+
"""
|
|
44
|
+
enum_type = sa.Enum(
|
|
45
|
+
enum_cls,
|
|
46
|
+
native_enum=False,
|
|
47
|
+
create_constraint=False,
|
|
48
|
+
length=length,
|
|
49
|
+
values_callable=lambda e: [member.value for member in e],
|
|
50
|
+
)
|
|
51
|
+
return sa.Column(
|
|
52
|
+
enum_type,
|
|
53
|
+
nullable=nullable,
|
|
54
|
+
server_default=server_default,
|
|
55
|
+
index=index,
|
|
56
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Utility functions for generating IDs."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import string
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_id(length: int = 16, prefix: str = "") -> str:
|
|
9
|
+
"""Generate a compact, time-sortable ID with an optional type prefix.
|
|
10
|
+
|
|
11
|
+
Produces a URL-safe, human-readable identifier that sorts roughly in
|
|
12
|
+
creation order (a millisecond timestamp is encoded first). With a prefix
|
|
13
|
+
it yields self-describing, Stripe-style typed IDs (e.g. "org_...").
|
|
14
|
+
|
|
15
|
+
The ID consists of:
|
|
16
|
+
- Optional prefix (e.g. "org_") -- not counted toward length
|
|
17
|
+
- Current timestamp in base36 (8-10 chars), giving time ordering
|
|
18
|
+
- Random string (remaining chars)
|
|
19
|
+
|
|
20
|
+
Note: uses non-cryptographic randomness. Suitable for entity keys, not
|
|
21
|
+
for unguessable security tokens.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
length: The length of the timestamp+random part (default: 16). The
|
|
25
|
+
prefix is prepended and does not count toward this length.
|
|
26
|
+
prefix: Optional prefix to prepend (e.g. "org_"). Produces
|
|
27
|
+
self-describing IDs like "org_mltkrwu9XPqQ8bf8".
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A string containing the generated ID with the optional prefix.
|
|
31
|
+
"""
|
|
32
|
+
# Get current timestamp in base36 (will be 8-10 chars)
|
|
33
|
+
timestamp = to_base36(int(time.time() * 1000))
|
|
34
|
+
|
|
35
|
+
# Generate random string for remaining characters
|
|
36
|
+
random_chars = string.ascii_letters + string.digits
|
|
37
|
+
random_part = "".join(random.choice(random_chars) for _ in range(length))
|
|
38
|
+
|
|
39
|
+
# Combine and ensure exactly the specified length, then prepend prefix
|
|
40
|
+
return prefix + (timestamp + random_part)[:length].ljust(length, "0")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def to_base36(number: int) -> str:
|
|
44
|
+
"""Convert a number to base36 representation.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
number: The number to convert
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A string containing the base36 representation
|
|
51
|
+
"""
|
|
52
|
+
alphabet = string.digits + string.ascii_lowercase
|
|
53
|
+
base36 = ""
|
|
54
|
+
|
|
55
|
+
while number:
|
|
56
|
+
number, i = divmod(number, 36)
|
|
57
|
+
base36 = alphabet[i] + base36
|
|
58
|
+
|
|
59
|
+
return base36 or "0"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""JSON Schema validation utilities.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around the ``jsonschema`` library. All JSON Schema
|
|
4
|
+
validation in the application flows through this module so that the
|
|
5
|
+
underlying library can be replaced without touching callers.
|
|
6
|
+
|
|
7
|
+
Two concerns are addressed:
|
|
8
|
+
|
|
9
|
+
1. **Meta-validation** -- verify that a dict is a valid JSON Schema
|
|
10
|
+
document conforming to Draft 2020-12.
|
|
11
|
+
2. **Instance validation** -- verify that a JSON-compatible value
|
|
12
|
+
conforms to a given JSON Schema document, with optional external
|
|
13
|
+
``$ref`` resolution via a caller-supplied callback.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from jsonschema import Draft202012Validator, SchemaError, ValidationError
|
|
23
|
+
from referencing import Registry, Resource
|
|
24
|
+
from referencing.exceptions import Unresolvable
|
|
25
|
+
from referencing.jsonschema import DRAFT202012
|
|
26
|
+
|
|
27
|
+
# -- Result types -----------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class SchemaValidationError:
|
|
32
|
+
"""Single validation error with location context."""
|
|
33
|
+
|
|
34
|
+
message: str
|
|
35
|
+
path: list[str] = field(default_factory=list)
|
|
36
|
+
schema_path: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ValidationResult:
|
|
41
|
+
"""Outcome of a validation call.
|
|
42
|
+
|
|
43
|
+
``valid`` is True when no errors were found. ``errors`` contains
|
|
44
|
+
structured error details when validation fails.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
valid: bool
|
|
48
|
+
errors: list[SchemaValidationError] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -- Public API -------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_schema(document: dict[str, Any]) -> ValidationResult:
|
|
55
|
+
"""Validate that *document* is a valid JSON Schema (Draft 2020-12).
|
|
56
|
+
|
|
57
|
+
Unknown keywords (e.g. ``x-ui-widget``) are allowed per spec -- they
|
|
58
|
+
are treated as annotations and do not cause validation failures.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
document: The schema document to validate.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
ValidationResult indicating success or listing errors.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
Draft202012Validator.check_schema(document)
|
|
68
|
+
except SchemaError as exc:
|
|
69
|
+
errors = [
|
|
70
|
+
SchemaValidationError(
|
|
71
|
+
message=exc.message,
|
|
72
|
+
path=[str(p) for p in exc.path],
|
|
73
|
+
schema_path=[str(p) for p in exc.schema_path],
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
return ValidationResult(valid=False, errors=errors)
|
|
77
|
+
return ValidationResult(valid=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_instance(
|
|
81
|
+
instance: Any,
|
|
82
|
+
schema: dict[str, Any],
|
|
83
|
+
resolve_ref: Callable[[str], dict[str, Any]] | None = None,
|
|
84
|
+
) -> ValidationResult:
|
|
85
|
+
"""Validate a JSON-compatible *instance* against a JSON Schema.
|
|
86
|
+
|
|
87
|
+
The schema is assumed to be valid (call :func:`validate_schema`
|
|
88
|
+
first if unsure). Collects all errors rather than failing on the
|
|
89
|
+
first one.
|
|
90
|
+
|
|
91
|
+
Local ``$ref`` references (``#/$defs/...``) are resolved
|
|
92
|
+
automatically by the underlying library. External ``$ref`` URIs
|
|
93
|
+
(e.g. ``urn:example:address``) require a *resolve_ref* callback that
|
|
94
|
+
maps a URI string to the referenced schema document. This keeps the
|
|
95
|
+
public API library-agnostic -- callers never import the underlying
|
|
96
|
+
``referencing`` package.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
instance: The value to validate (dict, list, scalar, ...).
|
|
100
|
+
schema: A valid JSON Schema document (Draft 2020-12).
|
|
101
|
+
resolve_ref: Optional callback that resolves an external ``$ref``
|
|
102
|
+
URI to a JSON Schema document (plain dict). When *None*,
|
|
103
|
+
external ``$ref`` URIs will produce a validation error.
|
|
104
|
+
The callback should raise ``KeyError`` if the URI cannot be
|
|
105
|
+
resolved; this is translated into a validation error.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
ValidationResult indicating success or listing all errors.
|
|
109
|
+
|
|
110
|
+
Example::
|
|
111
|
+
|
|
112
|
+
# Schema that references an external definition by URI.
|
|
113
|
+
schema = {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"name": {"type": "string"},
|
|
117
|
+
"address": {"$ref": "urn:example:address"},
|
|
118
|
+
},
|
|
119
|
+
"required": ["name", "address"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# A lookup function that returns the referenced schema dict.
|
|
123
|
+
# In practice this would call SchemaRegistryClient.get_schema().
|
|
124
|
+
def resolve_ref(uri: str) -> dict:
|
|
125
|
+
schemas = {
|
|
126
|
+
"urn:example:address": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"street": {"type": "string"},
|
|
130
|
+
"city": {"type": "string"},
|
|
131
|
+
},
|
|
132
|
+
"required": ["street", "city"],
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
return schemas[uri] # KeyError if unknown
|
|
136
|
+
|
|
137
|
+
result = validate_instance(
|
|
138
|
+
{"name": "Alice", "address": {"street": "1 Main St", "city": "Zurich"}},
|
|
139
|
+
schema,
|
|
140
|
+
resolve_ref=resolve_ref,
|
|
141
|
+
)
|
|
142
|
+
assert result.valid is True
|
|
143
|
+
"""
|
|
144
|
+
registry = _build_registry(resolve_ref) if resolve_ref is not None else Registry()
|
|
145
|
+
validator = Draft202012Validator(schema, registry=registry)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
raw_errors: list[ValidationError] = sorted(
|
|
149
|
+
validator.iter_errors(instance),
|
|
150
|
+
key=lambda e: list(e.path),
|
|
151
|
+
)
|
|
152
|
+
except Unresolvable as exc:
|
|
153
|
+
# An external $ref could not be resolved.
|
|
154
|
+
return ValidationResult(
|
|
155
|
+
valid=False,
|
|
156
|
+
errors=[SchemaValidationError(message=f"Unresolvable $ref: {exc}")],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if not raw_errors:
|
|
160
|
+
return ValidationResult(valid=True)
|
|
161
|
+
|
|
162
|
+
errors = [
|
|
163
|
+
SchemaValidationError(
|
|
164
|
+
message=err.message,
|
|
165
|
+
path=[str(p) for p in err.path],
|
|
166
|
+
schema_path=[str(p) for p in err.schema_path],
|
|
167
|
+
)
|
|
168
|
+
for err in raw_errors
|
|
169
|
+
]
|
|
170
|
+
return ValidationResult(valid=False, errors=errors)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- Internal helpers -------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _build_registry(resolve_ref: Callable[[str], dict[str, Any]]) -> Registry:
|
|
177
|
+
"""Build a ``referencing.Registry`` backed by *resolve_ref*.
|
|
178
|
+
|
|
179
|
+
The registry uses lazy retrieval: schemas are fetched on demand the
|
|
180
|
+
first time a ``$ref`` URI is encountered during validation. This
|
|
181
|
+
keeps the public API library-agnostic -- callers provide a plain
|
|
182
|
+
``Callable[[str], dict]`` and never import ``referencing`` directly.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def _retrieve(uri: str) -> Resource:
|
|
186
|
+
# Let KeyError propagate -- referencing catches it and raises
|
|
187
|
+
# Unresolvable, which we handle in validate_instance.
|
|
188
|
+
contents = resolve_ref(uri)
|
|
189
|
+
return Resource.from_contents(contents, default_specification=DRAFT202012)
|
|
190
|
+
|
|
191
|
+
return Registry(retrieve=_retrieve)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Model builder utilities for creating Pydantic models dynamically."""
|
|
2
|
+
|
|
3
|
+
import types
|
|
4
|
+
from typing import Any, Union, get_args, get_origin
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from pydantic import create_model as pydantic_create_model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
11
|
+
"""Unwrap ``T | None`` to ``T``.
|
|
12
|
+
|
|
13
|
+
If the annotation is a Union of exactly one concrete type and
|
|
14
|
+
``NoneType``, returns the concrete type. Otherwise returns the
|
|
15
|
+
annotation unchanged.
|
|
16
|
+
"""
|
|
17
|
+
origin = get_origin(annotation)
|
|
18
|
+
if origin is Union or origin is types.UnionType:
|
|
19
|
+
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
20
|
+
if len(args) == 1:
|
|
21
|
+
return args[0]
|
|
22
|
+
return annotation
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModelBuilder:
|
|
26
|
+
"""Builder class for creating models with a fluent API."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_model: type[BaseModel]):
|
|
29
|
+
"""Initialize the builder with a base model.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
base_model: The base model to inherit from
|
|
33
|
+
"""
|
|
34
|
+
self.base_model = base_model
|
|
35
|
+
self.model_name = None
|
|
36
|
+
self.included_fields = None
|
|
37
|
+
self.excluded_fields = None
|
|
38
|
+
self.required_fields: list[str] | None = None
|
|
39
|
+
self.model_config = None
|
|
40
|
+
self.field_overrides = {}
|
|
41
|
+
self.all_fields = True
|
|
42
|
+
self.api_model = False
|
|
43
|
+
|
|
44
|
+
def with_name(self, name: str) -> ModelBuilder:
|
|
45
|
+
"""Set the name for the model.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
name: Name for the new model
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Self for method chaining
|
|
52
|
+
"""
|
|
53
|
+
self.model_name = name
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def include(self, fields: list[str]) -> ModelBuilder:
|
|
57
|
+
"""Include only specified fields from the base model.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
fields: List of field names to include
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Self for method chaining
|
|
64
|
+
"""
|
|
65
|
+
self.included_fields = fields
|
|
66
|
+
self.excluded_fields = None
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def exclude(self, fields: list[str]) -> ModelBuilder:
|
|
70
|
+
"""Exclude specified fields from the base model.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
fields: List of field names to exclude
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Self for method chaining
|
|
77
|
+
"""
|
|
78
|
+
self.excluded_fields = fields
|
|
79
|
+
self.included_fields = None
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def require(self, fields: list[str]) -> ModelBuilder:
|
|
83
|
+
"""Mark fields as required (strip Optional and remove default).
|
|
84
|
+
|
|
85
|
+
Use this to promote nullable base-model fields to required in
|
|
86
|
+
derived API input models. The field's inner type is unwrapped
|
|
87
|
+
from ``T | None`` and the default is removed so Pydantic treats
|
|
88
|
+
it as required.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
fields: List of field names to make required.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Self for method chaining.
|
|
95
|
+
"""
|
|
96
|
+
self.required_fields = fields
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def with_config(self, config: dict[str, Any]) -> ModelBuilder:
|
|
100
|
+
"""Set model configuration.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
config: Dictionary with model configuration
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Self for method chaining
|
|
107
|
+
"""
|
|
108
|
+
self.model_config = config
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def with_all_fields(self, all_fields: bool) -> ModelBuilder:
|
|
112
|
+
"""Set whether to include all fields from the base model.
|
|
113
|
+
|
|
114
|
+
Only relevant if neither include nor exclude is specified.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
all_fields: Whether to include all fields from the base model
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Self for method chaining
|
|
121
|
+
"""
|
|
122
|
+
self.all_fields = all_fields
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def with_api_model(self, api_model: bool) -> ModelBuilder:
|
|
126
|
+
"""Set whether this is an API model.
|
|
127
|
+
|
|
128
|
+
If true, will add {"extra": "forbid"} to the model config
|
|
129
|
+
unless explicitly overridden by with_config.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
api_model: Whether this is an API model
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Self for method chaining
|
|
136
|
+
"""
|
|
137
|
+
self.api_model = api_model
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def override_field(self, field_name: str, annotation: Any, default: Any = ...) -> ModelBuilder:
|
|
141
|
+
"""Override a field's type and default value.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
field_name: Name of the field to override
|
|
145
|
+
annotation: Type annotation for the field
|
|
146
|
+
default: Default value for the field
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Self for method chaining
|
|
150
|
+
"""
|
|
151
|
+
self.field_overrides[field_name] = (annotation, default)
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def _get_field_default(self, field_info) -> Any:
|
|
155
|
+
"""Extract the default value from a field.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
field_info: Field information from the model
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Default value or ... if no default
|
|
162
|
+
"""
|
|
163
|
+
return field_info.default if field_info.default is not ... else ...
|
|
164
|
+
|
|
165
|
+
def _validate_required_fields(self, required: set[str]) -> None:
|
|
166
|
+
"""Raise ValueError if any required field name is not on the base model."""
|
|
167
|
+
for req in required:
|
|
168
|
+
if req not in self.base_model.model_fields:
|
|
169
|
+
msg = f"required_fields: '{req}' does not exist on {self.base_model.__name__}"
|
|
170
|
+
raise ValueError(msg)
|
|
171
|
+
|
|
172
|
+
def _resolve_field(
|
|
173
|
+
self,
|
|
174
|
+
field_name: str,
|
|
175
|
+
required: set[str],
|
|
176
|
+
fields: dict[str, tuple[Any, Any]],
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Add a single field to the output dict, applying required promotion if needed.
|
|
179
|
+
|
|
180
|
+
When not promoting to required, the full FieldInfo is passed so that
|
|
181
|
+
aliases, serialization aliases, and other field metadata are preserved
|
|
182
|
+
in the derived model.
|
|
183
|
+
"""
|
|
184
|
+
if field_name not in self.base_model.model_fields:
|
|
185
|
+
return
|
|
186
|
+
field_info = self.base_model.model_fields[field_name]
|
|
187
|
+
if field_name in required:
|
|
188
|
+
# Required promotion: strip Optional and remove default/FieldInfo.
|
|
189
|
+
# Aliases are intentionally dropped here -- required fields in
|
|
190
|
+
# Input models are typically populated by field name, not alias.
|
|
191
|
+
fields[field_name] = (_unwrap_optional(field_info.annotation), ...)
|
|
192
|
+
else:
|
|
193
|
+
# Preserve full FieldInfo so aliases and other metadata carry through.
|
|
194
|
+
fields[field_name] = (field_info.annotation, field_info)
|
|
195
|
+
|
|
196
|
+
def _get_included_fields(self) -> dict[str, tuple[Any, Any]]:
|
|
197
|
+
"""Get fields to include based on include/exclude settings.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dictionary of field definitions
|
|
201
|
+
"""
|
|
202
|
+
required = set(self.required_fields) if self.required_fields else set()
|
|
203
|
+
self._validate_required_fields(required)
|
|
204
|
+
|
|
205
|
+
fields: dict[str, tuple[Any, Any]] = {}
|
|
206
|
+
|
|
207
|
+
if self.included_fields is not None:
|
|
208
|
+
# Case 1: Only include specified fields
|
|
209
|
+
for field in self.included_fields:
|
|
210
|
+
self._resolve_field(field, required, fields)
|
|
211
|
+
elif self.excluded_fields is not None:
|
|
212
|
+
# Case 2: Include all fields except those explicitly excluded
|
|
213
|
+
for field in self.base_model.model_fields:
|
|
214
|
+
if field not in self.excluded_fields:
|
|
215
|
+
self._resolve_field(field, required, fields)
|
|
216
|
+
elif self.all_fields:
|
|
217
|
+
# Case 3: Include all fields
|
|
218
|
+
for field in self.base_model.model_fields:
|
|
219
|
+
self._resolve_field(field, required, fields)
|
|
220
|
+
|
|
221
|
+
return fields
|
|
222
|
+
|
|
223
|
+
def _create_model(self, fields: dict[str, tuple[Any, Any]]) -> type[BaseModel]:
|
|
224
|
+
"""Create a Pydantic model with the given fields.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
fields: Dictionary of field definitions
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
New Pydantic model
|
|
231
|
+
"""
|
|
232
|
+
model_config = self.model_config or {}
|
|
233
|
+
if self.api_model and "extra" not in model_config:
|
|
234
|
+
model_config["extra"] = "forbid"
|
|
235
|
+
model = pydantic_create_model(self.model_name, __module__=__name__, **fields)
|
|
236
|
+
model.model_config.update(model_config)
|
|
237
|
+
return model
|
|
238
|
+
|
|
239
|
+
def build(self) -> type[BaseModel]:
|
|
240
|
+
"""Build and return the model.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
A new Pydantic model with the selected fields
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: If model_name is not set
|
|
247
|
+
"""
|
|
248
|
+
if not self.model_name:
|
|
249
|
+
raise ValueError("Model name must be set using with_name() before building")
|
|
250
|
+
fields = self._get_included_fields()
|
|
251
|
+
fields.update(self.field_overrides)
|
|
252
|
+
return self._create_model(fields)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def create_model_builder(base_model: type[BaseModel]) -> ModelBuilder:
|
|
256
|
+
"""Create a model builder for the given base model.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
base_model: The base model to inherit from
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A ModelBuilder instance for fluent API usage
|
|
263
|
+
"""
|
|
264
|
+
return ModelBuilder(base_model)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def create_model(
|
|
268
|
+
base_model: type[BaseModel],
|
|
269
|
+
name: str,
|
|
270
|
+
fields: list[str] | None = None,
|
|
271
|
+
excluded_fields: list[str] | None = None,
|
|
272
|
+
required_fields: list[str] | None = None,
|
|
273
|
+
config: dict[str, Any] | None = None,
|
|
274
|
+
field_overrides: dict[str, tuple[Any, Any]] | None = None,
|
|
275
|
+
all_fields: bool = True,
|
|
276
|
+
api_model: bool = False,
|
|
277
|
+
**field_override_kwargs: Any,
|
|
278
|
+
) -> type[BaseModel]:
|
|
279
|
+
"""Create a model based on a base model with various customizations.
|
|
280
|
+
|
|
281
|
+
This is a unified function that can handle all model creation cases:
|
|
282
|
+
- Create a model with only specified fields (include)
|
|
283
|
+
- Create a model excluding specified fields (exclude)
|
|
284
|
+
- Create a complete model with all fields
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
base_model: The base model to inherit from
|
|
288
|
+
name: Name for the new model
|
|
289
|
+
fields: Optional list of field names to include (takes precedence over excluded_fields)
|
|
290
|
+
excluded_fields: Optional list of field names to exclude (only used if fields is None)
|
|
291
|
+
required_fields: Optional list of field names to make required. Strips
|
|
292
|
+
``T | None`` to ``T`` and removes the default so Pydantic treats
|
|
293
|
+
the field as mandatory. Useful for Input models where the base
|
|
294
|
+
model has all fields nullable.
|
|
295
|
+
config: Optional model configuration
|
|
296
|
+
field_overrides: Optional field overrides as {field_name: (annotation, default)}
|
|
297
|
+
all_fields: Whether to include all fields from the base model (only used if fields and excluded_fields are None)
|
|
298
|
+
api_model: Whether this is an API model (if true, adds {"extra": "forbid"} to config unless explicitly overridden)
|
|
299
|
+
**field_override_kwargs: Direct field overrides as keyword arguments
|
|
300
|
+
Format: field_name=(annotation, default)
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
A new Pydantic model with the selected fields
|
|
304
|
+
|
|
305
|
+
Examples:
|
|
306
|
+
# Create a model with only specific fields
|
|
307
|
+
OrganizationResponse = create_model(
|
|
308
|
+
OrganizationBase,
|
|
309
|
+
"OrganizationResponse",
|
|
310
|
+
fields=["id", "org_id", "name"],
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Create a model excluding specific fields
|
|
314
|
+
OrganizationInput = create_model(
|
|
315
|
+
OrganizationBase,
|
|
316
|
+
"OrganizationInput",
|
|
317
|
+
excluded_fields=["id", "org_id", "version"],
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Create an input model with required fields
|
|
321
|
+
UserInput = create_model(
|
|
322
|
+
UserBase,
|
|
323
|
+
"UserInput",
|
|
324
|
+
excluded_fields=["id", "user_id", "status"],
|
|
325
|
+
required_fields=["username"],
|
|
326
|
+
api_model=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Create an API model with extra fields forbidden
|
|
330
|
+
OrganizationUpdate = create_model(
|
|
331
|
+
OrganizationBase,
|
|
332
|
+
"OrganizationUpdate",
|
|
333
|
+
api_model=True,
|
|
334
|
+
)
|
|
335
|
+
"""
|
|
336
|
+
builder = create_model_builder(base_model).with_name(name).with_all_fields(all_fields)
|
|
337
|
+
if fields is not None:
|
|
338
|
+
builder = builder.include(fields)
|
|
339
|
+
elif excluded_fields is not None:
|
|
340
|
+
builder = builder.exclude(excluded_fields)
|
|
341
|
+
if required_fields:
|
|
342
|
+
builder = builder.require(required_fields)
|
|
343
|
+
if config:
|
|
344
|
+
builder = builder.with_config(config)
|
|
345
|
+
if field_overrides:
|
|
346
|
+
for field_name, (annotation, default) in field_overrides.items():
|
|
347
|
+
builder = builder.override_field(field_name, annotation, default)
|
|
348
|
+
for field_name, override in field_override_kwargs.items():
|
|
349
|
+
if isinstance(override, tuple) and len(override) == 2:
|
|
350
|
+
annotation, default = override
|
|
351
|
+
builder = builder.override_field(field_name, annotation, default)
|
|
352
|
+
builder = builder.with_api_model(api_model)
|
|
353
|
+
return builder.build()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Utility functions for converting between database and API models."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar, get_args, get_origin
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from sqlmodel import SQLModel
|
|
8
|
+
|
|
9
|
+
# Define generic type variables
|
|
10
|
+
T = TypeVar("T", bound=SQLModel) # Database model type
|
|
11
|
+
R = TypeVar("R", bound=BaseModel) # API response model type
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def update_model_fields(
|
|
15
|
+
model: SQLModel,
|
|
16
|
+
fields: dict[str, Any],
|
|
17
|
+
field_transformers: dict[str, Callable[[Any], Any]] | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Update model fields from a dictionary, applying optional transformations.
|
|
20
|
+
|
|
21
|
+
Only updates fields that are present in the ``fields`` dictionary.
|
|
22
|
+
This enables partial updates with the ``exclude_unset`` pattern.
|
|
23
|
+
|
|
24
|
+
Transformer functions are skipped when their corresponding value is
|
|
25
|
+
``None`` -- e.g. a password hasher is not called when no new password
|
|
26
|
+
is provided.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
model: The SQLModel instance to update.
|
|
30
|
+
fields: Dictionary of field names and values to update.
|
|
31
|
+
field_transformers: Optional mapping of field names to transformation
|
|
32
|
+
functions applied before assignment.
|
|
33
|
+
Example: ``{"password": password_service.hash_password}``
|
|
34
|
+
"""
|
|
35
|
+
field_transformers = field_transformers or {}
|
|
36
|
+
|
|
37
|
+
for field_name, value in fields.items():
|
|
38
|
+
if not hasattr(model, field_name):
|
|
39
|
+
continue
|
|
40
|
+
if field_name in field_transformers:
|
|
41
|
+
if value is None:
|
|
42
|
+
continue # skip transformer when no value supplied
|
|
43
|
+
value = field_transformers[field_name](value)
|
|
44
|
+
setattr(model, field_name, value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _convert_to_dict(db_model: Any) -> dict:
|
|
48
|
+
"""Convert a model instance to a dictionary."""
|
|
49
|
+
if db_model is None:
|
|
50
|
+
return {}
|
|
51
|
+
if isinstance(db_model, SQLModel):
|
|
52
|
+
return db_model.model_dump()
|
|
53
|
+
elif hasattr(db_model, "__dict__"):
|
|
54
|
+
return {k: v for k, v in db_model.__dict__.items() if not k.startswith("_")}
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _convert_collection(items: list | set | tuple, target_model: type[BaseModel]) -> list:
|
|
59
|
+
"""Convert a collection of items to a list of response models."""
|
|
60
|
+
return [to_response_model(item, target_model).model_dump() for item in items if item is not None]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _convert_single_item(item: Any, target_model: type[BaseModel]) -> dict:
|
|
64
|
+
"""Convert a single item to a response model."""
|
|
65
|
+
if item is None:
|
|
66
|
+
return {}
|
|
67
|
+
return to_response_model(item, target_model).model_dump()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_nested_attribute(obj: Any, attr_path: str) -> tuple[Any, str, Any]:
|
|
71
|
+
"""Get a nested attribute from an object.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tuple of (parent_object, final_attribute_name, attribute_value)
|
|
75
|
+
"""
|
|
76
|
+
if not attr_path or obj is None:
|
|
77
|
+
return None, "", None
|
|
78
|
+
parts = attr_path.split(".")
|
|
79
|
+
current_obj = obj
|
|
80
|
+
# For simple attributes
|
|
81
|
+
if len(parts) == 1:
|
|
82
|
+
return obj, parts[0], getattr(obj, parts[0], None)
|
|
83
|
+
# Navigate through the object hierarchy for nested attributes
|
|
84
|
+
for part in parts[:-1]:
|
|
85
|
+
if hasattr(current_obj, part):
|
|
86
|
+
current_obj = getattr(current_obj, part)
|
|
87
|
+
if current_obj is None:
|
|
88
|
+
return None, parts[-1], None
|
|
89
|
+
else:
|
|
90
|
+
return None, parts[-1], None
|
|
91
|
+
return current_obj, parts[-1], getattr(current_obj, parts[-1], None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _process_simple_mapping(db_model: Any, attr_name: str, target_model: type[BaseModel]) -> dict | None:
|
|
95
|
+
"""Process a simple (non-nested) attribute mapping."""
|
|
96
|
+
if not hasattr(db_model, attr_name):
|
|
97
|
+
return None
|
|
98
|
+
value = getattr(db_model, attr_name)
|
|
99
|
+
if value is None:
|
|
100
|
+
return None
|
|
101
|
+
if isinstance(value, list | set | tuple):
|
|
102
|
+
return _convert_collection(value, target_model)
|
|
103
|
+
return _convert_single_item(value, target_model)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _process_nested_mapping(
|
|
107
|
+
db_model: Any, response_model_class: type[BaseModel], attr_path: str, target_model: type[BaseModel]
|
|
108
|
+
) -> tuple[str, dict] | None:
|
|
109
|
+
"""Process a nested attribute mapping.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (parent_field_name, updated_parent_dict) or None if mapping can't be applied
|
|
113
|
+
"""
|
|
114
|
+
parent_obj, final_attr, value = _get_nested_attribute(db_model, attr_path)
|
|
115
|
+
if parent_obj is None or value is None:
|
|
116
|
+
return None
|
|
117
|
+
# Get the parent field name (first part of the path)
|
|
118
|
+
parent_field = attr_path.split(".")[0]
|
|
119
|
+
if parent_field not in response_model_class.model_fields:
|
|
120
|
+
return None
|
|
121
|
+
# Get the parent field type from the response model
|
|
122
|
+
parent_field_type = response_model_class.model_fields[parent_field].annotation
|
|
123
|
+
# Check if the parent field is a Pydantic model and has the nested attribute
|
|
124
|
+
if (
|
|
125
|
+
not isinstance(parent_field_type, type)
|
|
126
|
+
or not issubclass(parent_field_type, BaseModel)
|
|
127
|
+
or final_attr not in parent_field_type.model_fields
|
|
128
|
+
):
|
|
129
|
+
return None
|
|
130
|
+
# Get the parent value from the database model
|
|
131
|
+
parent_value = getattr(db_model, parent_field)
|
|
132
|
+
if parent_value is None:
|
|
133
|
+
return None
|
|
134
|
+
# Convert the parent to its response model
|
|
135
|
+
parent_response = to_response_model(parent_value, parent_field_type)
|
|
136
|
+
# Convert the nested value
|
|
137
|
+
nested_value = to_response_model(value, target_model)
|
|
138
|
+
# Update the parent model with the nested value
|
|
139
|
+
parent_dict = parent_response.model_dump()
|
|
140
|
+
parent_dict[final_attr] = nested_value.model_dump()
|
|
141
|
+
return parent_field, parent_dict
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _process_explicit_mappings(db_model: Any, response_model_class: type[BaseModel], mapping: dict[str, type[BaseModel]]) -> dict:
|
|
145
|
+
"""Process explicit mappings for attributes."""
|
|
146
|
+
result = {}
|
|
147
|
+
for attr_path, target_model in mapping.items():
|
|
148
|
+
# Skip mappings for attributes that don't exist in the response model
|
|
149
|
+
if attr_path not in response_model_class.model_fields:
|
|
150
|
+
continue
|
|
151
|
+
# Handle simple attribute paths
|
|
152
|
+
if "." not in attr_path:
|
|
153
|
+
converted = _process_simple_mapping(db_model, attr_path, target_model)
|
|
154
|
+
if converted is not None:
|
|
155
|
+
result[attr_path] = converted
|
|
156
|
+
else:
|
|
157
|
+
# Handle nested attribute paths
|
|
158
|
+
nested_result = _process_nested_mapping(db_model, response_model_class, attr_path, target_model)
|
|
159
|
+
if nested_result:
|
|
160
|
+
parent_field, parent_dict = nested_result
|
|
161
|
+
result[parent_field] = parent_dict
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _process_nested_models(db_model: Any, response_model_class: type[BaseModel]) -> dict:
|
|
166
|
+
"""Process nested models based on response model field types."""
|
|
167
|
+
result = {}
|
|
168
|
+
for field_name, field_info in response_model_class.model_fields.items():
|
|
169
|
+
field_type = field_info.annotation
|
|
170
|
+
# Skip if the db_model doesn't have this field or it's None
|
|
171
|
+
if not hasattr(db_model, field_name) or getattr(db_model, field_name) is None:
|
|
172
|
+
continue
|
|
173
|
+
value = getattr(db_model, field_name)
|
|
174
|
+
# Handle single nested model
|
|
175
|
+
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
|
|
176
|
+
if isinstance(value, list | set | tuple):
|
|
177
|
+
result[field_name] = _convert_collection(value, field_type)
|
|
178
|
+
else:
|
|
179
|
+
result[field_name] = _convert_single_item(value, field_type)
|
|
180
|
+
# Handle list of nested models
|
|
181
|
+
elif (
|
|
182
|
+
get_origin(field_type) is list
|
|
183
|
+
and len(get_args(field_type)) > 0
|
|
184
|
+
and isinstance(get_args(field_type)[0], type)
|
|
185
|
+
and issubclass(get_args(field_type)[0], BaseModel)
|
|
186
|
+
):
|
|
187
|
+
item_type = get_args(field_type)[0]
|
|
188
|
+
result[field_name] = _convert_collection(value, item_type)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def to_response_model[T: SQLModel, R: BaseModel](
|
|
193
|
+
db_model: T, response_model_class: type[R], mapping: dict[str, type[BaseModel]] | None = None
|
|
194
|
+
) -> R:
|
|
195
|
+
"""Convert a database model to an API response model.
|
|
196
|
+
|
|
197
|
+
This function handles nested objects and lists by recursively converting them
|
|
198
|
+
to the appropriate response model types. You can also specify explicit mappings
|
|
199
|
+
for nested objects.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
db_model: The database model instance to convert
|
|
203
|
+
response_model_class: The API response model class to convert to
|
|
204
|
+
mapping: Optional dictionary mapping attribute paths to response model classes.
|
|
205
|
+
Example: {"items": ItemResponse, "owner.details": DetailsResponse}
|
|
206
|
+
Note: Mapped attributes must exist in the response model class.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
An instance of the API response model
|
|
210
|
+
"""
|
|
211
|
+
if db_model is None:
|
|
212
|
+
return None
|
|
213
|
+
# Convert the database model to a dictionary
|
|
214
|
+
model_dict = _convert_to_dict(db_model)
|
|
215
|
+
# Process explicit mappings if provided
|
|
216
|
+
if mapping:
|
|
217
|
+
mapped_fields = _process_explicit_mappings(db_model, response_model_class, mapping)
|
|
218
|
+
model_dict.update(mapped_fields)
|
|
219
|
+
# Otherwise, check response model fields for nested objects
|
|
220
|
+
else:
|
|
221
|
+
nested_fields = _process_nested_models(db_model, response_model_class)
|
|
222
|
+
model_dict.update(nested_fields)
|
|
223
|
+
# Create the response model with model_validate
|
|
224
|
+
return response_model_class.model_validate(model_dict)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Schema Utilities - Common schema manipulation utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def combine_schemas(
|
|
7
|
+
schemas: list[dict[str, Any]],
|
|
8
|
+
title: str = "Data Model",
|
|
9
|
+
description: str = "Complete data model for API management including entities and their relationships.",
|
|
10
|
+
schema_id: str = "https://sonnet-server.com/schemas/data.schema.json",
|
|
11
|
+
) -> dict[str, Any]:
|
|
12
|
+
"""Combine multiple JSON schemas into a single enriched schema using $defs format.
|
|
13
|
+
|
|
14
|
+
This function creates an enriched JSON Schema Draft 2020-12 compliant schema
|
|
15
|
+
that nests all individual schemas under the $defs property.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
schemas: List of individual JSON schema objects to combine
|
|
19
|
+
title: Title for the combined schema (default: "Data Model")
|
|
20
|
+
description: Description for the combined schema
|
|
21
|
+
schema_id: $id value for the combined schema
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Combined schema in enriched $defs format
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> schemas = [{"title": "Product", "type": "object", ...}, {"title": "Company", "type": "object", ...}]
|
|
28
|
+
>>> combined = combine_schemas(schemas)
|
|
29
|
+
>>> combined["title"]
|
|
30
|
+
'Data Model'
|
|
31
|
+
>>> combined["$defs"]["Product"]["title"]
|
|
32
|
+
'Product'
|
|
33
|
+
"""
|
|
34
|
+
# Create enriched schema with $defs structure (JSON Schema Draft 2020-12)
|
|
35
|
+
enriched_schema = {
|
|
36
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
37
|
+
"$id": schema_id,
|
|
38
|
+
"title": title,
|
|
39
|
+
"description": description,
|
|
40
|
+
"$defs": {},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Add all schemas to $defs
|
|
44
|
+
for schema in schemas:
|
|
45
|
+
schema_title = schema.get("title")
|
|
46
|
+
if schema_title:
|
|
47
|
+
enriched_schema["$defs"][schema_title] = schema
|
|
48
|
+
|
|
49
|
+
return enriched_schema
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_schemas_from_model_infos(model_infos: list[Any]) -> list[dict[str, Any]]:
|
|
53
|
+
"""Extract JSON schemas from a list of model info objects.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
model_infos: List of model info objects
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of JSON schema dictionaries
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If no JSON schema is available for a model
|
|
63
|
+
"""
|
|
64
|
+
schemas = []
|
|
65
|
+
for model_info in model_infos:
|
|
66
|
+
# Try to get schema from common attributes
|
|
67
|
+
json_schema = getattr(model_info, "schema", None) or getattr(model_info, "json_schema", None)
|
|
68
|
+
if not json_schema:
|
|
69
|
+
raise ValueError(f"No JSON schema available for model: {getattr(model_info, 'name', 'unknown')}")
|
|
70
|
+
|
|
71
|
+
# Handle case where json_schema might be a string (JSON serialized)
|
|
72
|
+
if isinstance(json_schema, str):
|
|
73
|
+
import json
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
json_schema = json.loads(json_schema)
|
|
77
|
+
except json.JSONDecodeError as e:
|
|
78
|
+
raise ValueError(f"Invalid JSON schema string for model {getattr(model_info, 'name', 'unknown')}: {e}") from e
|
|
79
|
+
|
|
80
|
+
schemas.append(json_schema)
|
|
81
|
+
|
|
82
|
+
return schemas
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Version comparison and sorting utilities.
|
|
2
|
+
|
|
3
|
+
Provides a cross-cutting mechanism for determining "latest" among
|
|
4
|
+
multiple versions of the same logical resource (identified by URI or
|
|
5
|
+
name). Aligned with FHIR R5 CodeSystem.versionAlgorithm.
|
|
6
|
+
|
|
7
|
+
Supported algorithms:
|
|
8
|
+
natural -- natural sort (default). Splits on digit/non-digit
|
|
9
|
+
boundaries and compares segments numerically where
|
|
10
|
+
possible. Handles "2025", "10", "1.2.3" correctly.
|
|
11
|
+
semver -- semantic versioning (major.minor.patch). Pre-release
|
|
12
|
+
labels sort lower than release.
|
|
13
|
+
integer -- numeric cast. Non-numeric values sort to -inf.
|
|
14
|
+
date -- ISO date string comparison (lexicographic on ISO dates).
|
|
15
|
+
alpha -- case-insensitive alphabetical.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
from sonnet_core.version_sort import pick_latest
|
|
19
|
+
|
|
20
|
+
latest = pick_latest(rows, key=lambda r: r.content_version,
|
|
21
|
+
algorithm=r.version_algorithm)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from loguru import logger
|
|
27
|
+
|
|
28
|
+
VALID_ALGORITHMS = ("natural", "semver", "integer", "date", "alpha")
|
|
29
|
+
DEFAULT_ALGORITHM = "natural"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _natural_sort_key(version: str) -> list:
|
|
33
|
+
"""Split version string into numeric and non-numeric segments for natural comparison."""
|
|
34
|
+
parts = re.split(r"(\d+)", version)
|
|
35
|
+
return [int(p) if p.isdigit() else p.lower() for p in parts if p]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _semver_sort_key(version: str) -> tuple:
|
|
39
|
+
"""Parse a semver-like string into a comparable tuple.
|
|
40
|
+
|
|
41
|
+
Handles major.minor.patch with optional pre-release suffix.
|
|
42
|
+
Non-conforming strings fall back to natural sort key.
|
|
43
|
+
"""
|
|
44
|
+
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-(.+))?$", version)
|
|
45
|
+
if not match:
|
|
46
|
+
return (0, 0, 0, 0, _natural_sort_key(version))
|
|
47
|
+
major = int(match.group(1))
|
|
48
|
+
minor = int(match.group(2) or 0)
|
|
49
|
+
patch = int(match.group(3) or 0)
|
|
50
|
+
# Pre-release sorts lower than release (release = no suffix = high).
|
|
51
|
+
pre = match.group(4)
|
|
52
|
+
pre_rank = 0 if pre else 1
|
|
53
|
+
pre_key = _natural_sort_key(pre) if pre else []
|
|
54
|
+
return (major, minor, patch, pre_rank, pre_key)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _integer_sort_key(version: str) -> int:
|
|
58
|
+
"""Parse version as integer. Non-numeric values sort to lowest."""
|
|
59
|
+
try:
|
|
60
|
+
return int(version)
|
|
61
|
+
except ValueError, TypeError:
|
|
62
|
+
return -1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _date_sort_key(version: str) -> str:
|
|
66
|
+
"""ISO date strings are lexicographically sortable."""
|
|
67
|
+
return version
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _alpha_sort_key(version: str) -> str:
|
|
71
|
+
"""Case-insensitive alphabetical comparison."""
|
|
72
|
+
return version.lower()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def version_sort_key(version: str, algorithm: str = DEFAULT_ALGORITHM):
|
|
76
|
+
"""Return a sort key for the given version string and algorithm.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
version: The version string to produce a sort key for.
|
|
80
|
+
algorithm: One of the VALID_ALGORITHMS.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A comparable value suitable for sorted() or max().
|
|
84
|
+
"""
|
|
85
|
+
if algorithm == "natural":
|
|
86
|
+
return _natural_sort_key(version)
|
|
87
|
+
if algorithm == "semver":
|
|
88
|
+
return _semver_sort_key(version)
|
|
89
|
+
if algorithm == "integer":
|
|
90
|
+
return _integer_sort_key(version)
|
|
91
|
+
if algorithm == "date":
|
|
92
|
+
return _date_sort_key(version)
|
|
93
|
+
if algorithm == "alpha":
|
|
94
|
+
return _alpha_sort_key(version)
|
|
95
|
+
logger.warning("Unknown version_algorithm '{}', falling back to natural", algorithm)
|
|
96
|
+
return _natural_sort_key(version)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pick_latest(rows, *, version_key, algorithm_key=None, status_key=None, algorithm: str | None = None):
|
|
100
|
+
"""Pick the latest row from a list, preferring active status and highest version.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
rows: Iterable of row objects (must be non-empty).
|
|
104
|
+
version_key: Callable that extracts the version string from a row.
|
|
105
|
+
algorithm_key: Callable that extracts the version_algorithm from a row.
|
|
106
|
+
Ignored if ``algorithm`` is explicitly provided.
|
|
107
|
+
status_key: Callable that extracts the status string from a row.
|
|
108
|
+
When provided, active rows are preferred over non-active.
|
|
109
|
+
algorithm: Explicit algorithm override. When set, algorithm_key is ignored.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The single "latest" row.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If rows is empty.
|
|
116
|
+
"""
|
|
117
|
+
items = list(rows)
|
|
118
|
+
if not items:
|
|
119
|
+
raise ValueError("Cannot pick latest from empty list")
|
|
120
|
+
if len(items) == 1:
|
|
121
|
+
return items[0]
|
|
122
|
+
|
|
123
|
+
# Determine algorithm from the first row if not explicitly provided.
|
|
124
|
+
if algorithm is None and algorithm_key is not None:
|
|
125
|
+
algorithm = algorithm_key(items[0]) or DEFAULT_ALGORITHM
|
|
126
|
+
if algorithm is None:
|
|
127
|
+
algorithm = DEFAULT_ALGORITHM
|
|
128
|
+
|
|
129
|
+
def sort_key(row):
|
|
130
|
+
# Primary: active status preferred (1 = active, 0 = other).
|
|
131
|
+
active_rank = 0
|
|
132
|
+
if status_key is not None:
|
|
133
|
+
active_rank = 1 if status_key(row) == "active" else 0
|
|
134
|
+
# Secondary: version comparison.
|
|
135
|
+
ver = version_key(row) or ""
|
|
136
|
+
return (active_rank, version_sort_key(ver, algorithm))
|
|
137
|
+
|
|
138
|
+
return max(items, key=sort_key)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonnet-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Framework-agnostic core library for Petrarca Labs backend services (models, ids, schemas, state machines)
|
|
5
|
+
Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Programming Language :: Python
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Python: <4.0,>=3.14
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: loguru>=0.7.3
|
|
14
|
+
Requires-Dist: pydantic>=2.0
|
|
15
|
+
Requires-Dist: sqlmodel>=0.0.37
|
|
16
|
+
Requires-Dist: sqlalchemy>=2.0.48
|
|
17
|
+
Requires-Dist: jsonschema>=4.23.0
|
|
18
|
+
Requires-Dist: arrow>=1.4.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# sonnet-core
|
|
25
|
+
|
|
26
|
+
Framework-agnostic core library for Petrarca Labs backend services.
|
|
27
|
+
|
|
28
|
+
`sonnet-core` is the foundation layer beneath `sonnet-server`. It holds pure
|
|
29
|
+
data-layer and logic building blocks that carry **no web/server framework
|
|
30
|
+
dependency** (no FastAPI, uvicorn, Alembic, Typer, or Jinja2). It may depend on
|
|
31
|
+
data-modeling libraries (Pydantic, SQLAlchemy, SQLModel) and pure utilities
|
|
32
|
+
(jsonschema, arrow, loguru).
|
|
33
|
+
|
|
34
|
+
## Layering
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
sonnet-core (this package -- pure data/logic)
|
|
38
|
+
^
|
|
39
|
+
sonnet-server (web/server framework)
|
|
40
|
+
^
|
|
41
|
+
sonnet-auth / sonnet-graph / sonnet-storage
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Contents
|
|
45
|
+
|
|
46
|
+
- **ids** -- compact, time-sortable id generation (`generate_id`, `to_base36`).
|
|
47
|
+
- **models** -- Pydantic/SQLModel model building and conversion
|
|
48
|
+
(`create_model`, `ModelBuilder`, `to_response_model`, `update_model_fields`).
|
|
49
|
+
- **schema** -- JSON Schema validation helpers
|
|
50
|
+
(`validate_instance`, `validate_schema`, `ValidationResult`).
|
|
51
|
+
- **enums** -- constraint-free enum columns (`str_enum_column`).
|
|
52
|
+
- **versioning** -- version-string ordering (`pick_latest`).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sonnet_core/__init__.py,sha256=qebjNt95gJKHqzz_mL4864oWMnvv_H_wJM7qEunNDzg,1871
|
|
2
|
+
sonnet_core/enum_column.py,sha256=B49zcEtzlAlRqg67FyZQAuP8XuNEKzH5-Ash-2B5IV0,1944
|
|
3
|
+
sonnet_core/id_generator.py,sha256=xqXnnEPYDAHye3PwyivpDokwOeJsvljYta5dIHEwkiY,1985
|
|
4
|
+
sonnet_core/json_schema.py,sha256=nj5FWTIC9Ufz_DTZhNmaSebgd7lkQIuzd6bIMs3tzZA,6718
|
|
5
|
+
sonnet_core/model_builder.py,sha256=ZUefLdLLLblYCraRm6uUn1klfsYzOSMngkLlVmG8VOM,12391
|
|
6
|
+
sonnet_core/model_converter.py,sha256=0kojI1g6w7yLRdF6YpTnYMf3DPXXmdZliI2qcDj1As8,9193
|
|
7
|
+
sonnet_core/schema_utils.py,sha256=c0WWrFAgxPe64Jd5shVnLOY33115ektZQrU387gbGWw,2869
|
|
8
|
+
sonnet_core/version_sort.py,sha256=fkWJ5q_0WwWgCSfhVcU5PQslNnDh5IlVP1V9mkXpSFQ,4975
|
|
9
|
+
sonnet_core-0.1.0.dist-info/METADATA,sha256=D_UteTRlfnYFiX_b0hYd6W2mot32sK7nyJWLsgAa3pY,1924
|
|
10
|
+
sonnet_core-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
sonnet_core-0.1.0.dist-info/top_level.txt,sha256=msjeLzbQxJmq3Nouoz33OmHQKiiEO89uX8WOitDtzhk,12
|
|
12
|
+
sonnet_core-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sonnet_core
|