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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sonnet_core