sonnet-core 0.1.0__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.
- sonnet_core-0.1.0/PKG-INFO +52 -0
- sonnet_core-0.1.0/README.md +29 -0
- sonnet_core-0.1.0/pyproject.toml +50 -0
- sonnet_core-0.1.0/setup.cfg +4 -0
- sonnet_core-0.1.0/src/sonnet_core/__init__.py +64 -0
- sonnet_core-0.1.0/src/sonnet_core/enum_column.py +56 -0
- sonnet_core-0.1.0/src/sonnet_core/id_generator.py +59 -0
- sonnet_core-0.1.0/src/sonnet_core/json_schema.py +191 -0
- sonnet_core-0.1.0/src/sonnet_core/model_builder.py +353 -0
- sonnet_core-0.1.0/src/sonnet_core/model_converter.py +224 -0
- sonnet_core-0.1.0/src/sonnet_core/schema_utils.py +82 -0
- sonnet_core-0.1.0/src/sonnet_core/version_sort.py +138 -0
- sonnet_core-0.1.0/src/sonnet_core.egg-info/PKG-INFO +52 -0
- sonnet_core-0.1.0/src/sonnet_core.egg-info/SOURCES.txt +15 -0
- sonnet_core-0.1.0/src/sonnet_core.egg-info/dependency_links.txt +1 -0
- sonnet_core-0.1.0/src/sonnet_core.egg-info/requires.txt +11 -0
- sonnet_core-0.1.0/src/sonnet_core.egg-info/top_level.txt +1 -0
|
@@ -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,29 @@
|
|
|
1
|
+
# sonnet-core
|
|
2
|
+
|
|
3
|
+
Framework-agnostic core library for Petrarca Labs backend services.
|
|
4
|
+
|
|
5
|
+
`sonnet-core` is the foundation layer beneath `sonnet-server`. It holds pure
|
|
6
|
+
data-layer and logic building blocks that carry **no web/server framework
|
|
7
|
+
dependency** (no FastAPI, uvicorn, Alembic, Typer, or Jinja2). It may depend on
|
|
8
|
+
data-modeling libraries (Pydantic, SQLAlchemy, SQLModel) and pure utilities
|
|
9
|
+
(jsonschema, arrow, loguru).
|
|
10
|
+
|
|
11
|
+
## Layering
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
sonnet-core (this package -- pure data/logic)
|
|
15
|
+
^
|
|
16
|
+
sonnet-server (web/server framework)
|
|
17
|
+
^
|
|
18
|
+
sonnet-auth / sonnet-graph / sonnet-storage
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Contents
|
|
22
|
+
|
|
23
|
+
- **ids** -- compact, time-sortable id generation (`generate_id`, `to_base36`).
|
|
24
|
+
- **models** -- Pydantic/SQLModel model building and conversion
|
|
25
|
+
(`create_model`, `ModelBuilder`, `to_response_model`, `update_model_fields`).
|
|
26
|
+
- **schema** -- JSON Schema validation helpers
|
|
27
|
+
(`validate_instance`, `validate_schema`, `ValidationResult`).
|
|
28
|
+
- **enums** -- constraint-free enum columns (`str_enum_column`).
|
|
29
|
+
- **versioning** -- version-string ordering (`pick_latest`).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonnet-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Wolfgang Miller", email = "wolfgang.miller@petrarca-labs.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Framework-agnostic core library for Petrarca Labs backend services (models, ids, schemas, state machines)"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "Apache-2.0"
|
|
14
|
+
requires-python = ">=3.14,<4.0"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"loguru>=0.7.3",
|
|
23
|
+
"pydantic>=2.0",
|
|
24
|
+
"sqlmodel>=0.0.37",
|
|
25
|
+
"sqlalchemy>=2.0.48",
|
|
26
|
+
"jsonschema>=4.23.0",
|
|
27
|
+
"arrow>=1.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"ruff>=0.3.0",
|
|
33
|
+
"pytest>=7.0.0",
|
|
34
|
+
"pytest-cov>=4.0.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
markers = [
|
|
42
|
+
"unit: marks tests as unit tests (default)",
|
|
43
|
+
"integration: marks tests as integration tests",
|
|
44
|
+
]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
addopts = [
|
|
47
|
+
"-m unit",
|
|
48
|
+
"--strict-markers",
|
|
49
|
+
"--import-mode=importlib",
|
|
50
|
+
]
|
|
@@ -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)
|