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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)