modwire-siren 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.
Files changed (37) hide show
  1. modwire_siren/__init__.py +16 -0
  2. modwire_siren/_version.py +24 -0
  3. modwire_siren/composition.py +28 -0
  4. modwire_siren/contracts/__init__.py +0 -0
  5. modwire_siren/contracts/action.py +28 -0
  6. modwire_siren/contracts/base.py +5 -0
  7. modwire_siren/contracts/entity.py +39 -0
  8. modwire_siren/contracts/link.py +12 -0
  9. modwire_siren/contracts/operation.py +19 -0
  10. modwire_siren/contracts/resource.py +17 -0
  11. modwire_siren/facade.py +17 -0
  12. modwire_siren/factories/__init__.py +0 -0
  13. modwire_siren/factories/action.py +30 -0
  14. modwire_siren/factories/entity.py +38 -0
  15. modwire_siren/factories/field.py +28 -0
  16. modwire_siren/factories/link.py +62 -0
  17. modwire_siren/factories/resource.py +31 -0
  18. modwire_siren/integrations/__init__.py +0 -0
  19. modwire_siren/integrations/ninja_extra.py +80 -0
  20. modwire_siren/openapi/__init__.py +0 -0
  21. modwire_siren/openapi/catalog.py +62 -0
  22. modwire_siren/openapi/contracts.py +19 -0
  23. modwire_siren/openapi/error.py +4 -0
  24. modwire_siren/openapi/factory.py +16 -0
  25. modwire_siren/openapi/href.py +26 -0
  26. modwire_siren/openapi/method.py +12 -0
  27. modwire_siren/openapi/operation.py +73 -0
  28. modwire_siren/openapi/resolver.py +56 -0
  29. modwire_siren/openapi/resource.py +34 -0
  30. modwire_siren/policies/__init__.py +0 -0
  31. modwire_siren/policies/field_type.py +24 -0
  32. modwire_siren/py.typed +1 -0
  33. modwire_siren/serialization.py +15 -0
  34. modwire_siren/standards.py +21 -0
  35. modwire_siren-0.1.0.dist-info/METADATA +135 -0
  36. modwire_siren-0.1.0.dist-info/RECORD +37 -0
  37. modwire_siren-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,16 @@
1
+ from ._version import __version__
2
+ from .composition import ModwireSirenFactory
3
+ from .contracts.entity import SirenEntityRequest
4
+ from .facade import ModwireSiren
5
+ from .integrations.ninja_extra import NinjaExtraSirenController, SirenEntityDecorator
6
+ from .openapi.error import OpenApiError
7
+
8
+ __all__ = [
9
+ "ModwireSiren",
10
+ "ModwireSirenFactory",
11
+ "NinjaExtraSirenController",
12
+ "OpenApiError",
13
+ "SirenEntityDecorator",
14
+ "SirenEntityRequest",
15
+ "__version__",
16
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,28 @@
1
+ from typing import Any
2
+
3
+ from .facade import ModwireSiren
4
+ from .factories.action import SirenActionFactory
5
+ from .factories.entity import SirenEntityFactory
6
+ from .factories.field import OpenApiSirenFieldFactory
7
+ from .factories.link import OpenApiSirenLinkFactory
8
+ from .factories.resource import OpenApiSirenResourceHrefResolver
9
+ from .openapi.factory import OpenApiCatalogFactory
10
+ from .openapi.href import OpenApiHrefResolver
11
+ from .policies.field_type import OpenApiSirenFieldTypeResolver
12
+ from .serialization import PydanticSirenSerializer
13
+
14
+
15
+ class ModwireSirenFactory:
16
+ """Build the standard OpenAPI-backed Siren façade."""
17
+
18
+ @classmethod
19
+ def standard(cls, schema: dict[str, Any], base_url: str) -> ModwireSiren:
20
+ """Create a ready-to-use façade from an OpenAPI schema and API base URL."""
21
+ catalog = OpenApiCatalogFactory.create(schema)
22
+ hrefs = OpenApiHrefResolver(base_url)
23
+ resource_hrefs = OpenApiSirenResourceHrefResolver(hrefs)
24
+ fields = OpenApiSirenFieldFactory(OpenApiSirenFieldTypeResolver())
25
+ actions = SirenActionFactory(catalog, hrefs, fields)
26
+ links = OpenApiSirenLinkFactory(catalog, resource_hrefs)
27
+ entities = SirenEntityFactory(catalog, resource_hrefs, links, actions)
28
+ return ModwireSiren(entities, PydanticSirenSerializer())
File without changes
@@ -0,0 +1,28 @@
1
+ from typing import Annotated, Any
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from .base import SirenContract
6
+
7
+
8
+ class SirenField(SirenContract):
9
+ name: str
10
+ type: str
11
+ required: bool
12
+ title: str
13
+ options: tuple[dict[str, Any], ...]
14
+ schema_definition: Annotated[dict[str, Any], Field(validation_alias="schema", serialization_alias="schema")]
15
+
16
+
17
+ class SirenAction(SirenContract):
18
+ name: str
19
+ href: str
20
+ method: str
21
+ title: str
22
+ media_type: Annotated[str, Field(serialization_alias="type")]
23
+ fields: tuple[SirenField, ...]
24
+
25
+ @field_validator("method")
26
+ @classmethod
27
+ def normalize_method(cls, value: str) -> str:
28
+ return value.upper()
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class SirenContract(BaseModel):
5
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from pydantic import Field, model_serializer
6
+
7
+ from .action import SirenAction
8
+ from .base import SirenContract
9
+ from .link import SirenLink
10
+
11
+
12
+ class SirenEntity(SirenContract):
13
+ classes: Annotated[tuple[str, ...], Field(serialization_alias="class")]
14
+ properties: dict[str, Any]
15
+ links: tuple[SirenLink, ...]
16
+ actions: tuple[SirenAction, ...]
17
+ entities: tuple[SirenEmbeddedEntity, ...]
18
+
19
+
20
+ class SirenEmbeddedEntity(SirenContract):
21
+ rel: tuple[str, ...]
22
+ entity: SirenEntity
23
+
24
+ @model_serializer
25
+ def serialize(self) -> dict[str, Any]:
26
+ return {"rel": self.rel, **self.entity.model_dump(mode="json", by_alias=True)}
27
+
28
+
29
+ class SirenEntityRequest(SirenContract):
30
+ """Describe the resource data and allowed operations projected into one entity."""
31
+
32
+ resource_name: str
33
+ properties: dict[str, Any]
34
+ operation_ids: tuple[str, ...]
35
+ path_values: dict[str, Any]
36
+ entities: tuple[SirenEmbeddedEntity, ...]
37
+
38
+
39
+ SirenEntity.model_rebuild()
@@ -0,0 +1,12 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import Field
4
+
5
+ from .base import SirenContract
6
+
7
+
8
+ class SirenLink(SirenContract):
9
+ rel: tuple[str, ...]
10
+ href: str
11
+ title: str
12
+ media_type: Annotated[str, Field(serialization_alias="type")]
@@ -0,0 +1,19 @@
1
+ from typing import Annotated, Any
2
+
3
+ from pydantic import Field
4
+
5
+ from .base import SirenContract
6
+
7
+
8
+ class OpenApiField(SirenContract):
9
+ name: str
10
+ schema_definition: Annotated[dict[str, Any], Field(validation_alias="schema")]
11
+ required: bool
12
+
13
+
14
+ class OpenApiOperation(SirenContract):
15
+ operation_id: str
16
+ method: str
17
+ path: str
18
+ title: str
19
+ fields: tuple[OpenApiField, ...]
@@ -0,0 +1,17 @@
1
+ from .base import SirenContract
2
+
3
+
4
+ class SirenRelation(SirenContract):
5
+ field: str
6
+ rel: str
7
+ resource: str
8
+ many: bool
9
+
10
+
11
+ class SirenResource(SirenContract):
12
+ name: str
13
+ path: str
14
+ resource_class: str
15
+ identifier: str
16
+ path_parameters: dict[str, str]
17
+ relations: tuple[SirenRelation, ...]
@@ -0,0 +1,17 @@
1
+ from typing import Any
2
+
3
+ from .contracts.entity import SirenEntityRequest
4
+ from .factories.entity import SirenEntityFactory
5
+ from .serialization import SirenSerializer
6
+
7
+
8
+ class ModwireSiren:
9
+ """Project validated entity requests into serialized Siren documents."""
10
+
11
+ def __init__(self, entities: SirenEntityFactory, serializer: SirenSerializer):
12
+ self._entities = entities
13
+ self._serializer = serializer
14
+
15
+ def document(self, request: SirenEntityRequest) -> dict[str, Any]:
16
+ """Build and serialize one Siren entity document."""
17
+ return self._serializer.serialize(self._entities.create(request))
File without changes
@@ -0,0 +1,30 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+ from ..contracts.action import SirenAction
5
+ from ..openapi.catalog import SirenResourceCatalog
6
+ from ..openapi.href import SirenHrefResolver
7
+ from ..standards import SirenMediaType
8
+ from .field import SirenFieldFactory
9
+
10
+
11
+ class SirenActionFactory:
12
+ def __init__(
13
+ self,
14
+ catalog: SirenResourceCatalog,
15
+ hrefs: SirenHrefResolver,
16
+ fields: SirenFieldFactory,
17
+ ):
18
+ self._catalog = catalog
19
+ self._hrefs = hrefs
20
+ self._fields = fields
21
+
22
+ def create(self, operation_id: str, path_values: Mapping[str, Any]) -> SirenAction:
23
+ operation = self._catalog.operation(operation_id)
24
+ return SirenAction(
25
+ **operation.model_dump(include={"method", "title"}),
26
+ name=operation.operation_id,
27
+ href=self._hrefs.resolve(operation.path, path_values),
28
+ media_type=SirenMediaType.ACTION,
29
+ fields=tuple(self._fields.create(field) for field in operation.fields),
30
+ )
@@ -0,0 +1,38 @@
1
+ from ..contracts.entity import SirenEntity, SirenEntityRequest
2
+ from ..openapi.catalog import SirenResourceCatalog
3
+ from ..openapi.error import OpenApiError
4
+ from .action import SirenActionFactory
5
+ from .link import SirenLinkFactory
6
+ from .resource import SirenResourceHrefResolver
7
+
8
+
9
+ class SirenEntityFactory:
10
+ def __init__(
11
+ self,
12
+ catalog: SirenResourceCatalog,
13
+ hrefs: SirenResourceHrefResolver,
14
+ links: SirenLinkFactory,
15
+ actions: SirenActionFactory,
16
+ ):
17
+ self._catalog = catalog
18
+ self._hrefs = hrefs
19
+ self._links = links
20
+ self._actions = actions
21
+
22
+ def create(self, request: SirenEntityRequest) -> SirenEntity:
23
+ resource = self._catalog.resource(request.resource_name)
24
+ operations = tuple(self._catalog.operation(operation_id) for operation_id in request.operation_ids)
25
+ foreign = tuple(operation.operation_id for operation in operations if operation.path != resource.path)
26
+ if foreign:
27
+ raise OpenApiError(f"Operations {list(foreign)} do not belong to resource {resource.name!r}")
28
+ values = request.properties | request.path_values
29
+ operation_values = self._hrefs.path_values(resource, values)
30
+ return SirenEntity(
31
+ classes=(resource.resource_class,),
32
+ properties=request.properties,
33
+ links=self._links.create(resource, values),
34
+ actions=tuple(
35
+ self._actions.create(operation.operation_id, operation_values) for operation in operations
36
+ ),
37
+ entities=request.entities,
38
+ )
@@ -0,0 +1,28 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from ..contracts.action import SirenField
4
+ from ..contracts.operation import OpenApiField
5
+ from ..policies.field_type import SirenFieldTypeResolver
6
+ from ..standards import SirenFieldType
7
+
8
+
9
+ class SirenFieldFactory(ABC):
10
+ @abstractmethod
11
+ def create(self, field: OpenApiField) -> SirenField:
12
+ raise NotImplementedError
13
+
14
+
15
+ class OpenApiSirenFieldFactory(SirenFieldFactory):
16
+ def __init__(self, types: SirenFieldTypeResolver):
17
+ self._types = types
18
+
19
+ def create(self, field: OpenApiField) -> SirenField:
20
+ field_type = self._types.resolve(field.schema_definition)
21
+ return SirenField(
22
+ name=field.name,
23
+ type=field_type,
24
+ required=field.required,
25
+ title=field.schema_definition.get("title", field.name),
26
+ options=tuple({"value": value, "title": str(value)} for value in field.schema_definition.get("enum", [])),
27
+ schema=field.schema_definition if field_type is SirenFieldType.JSON else {},
28
+ )
@@ -0,0 +1,62 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Iterable, Mapping
3
+ from typing import Any
4
+
5
+ from ..contracts.link import SirenLink
6
+ from ..contracts.resource import SirenRelation, SirenResource
7
+ from ..openapi.catalog import SirenResourceCatalog
8
+ from ..openapi.error import OpenApiError
9
+ from ..standards import SirenMediaType, SirenRelationName
10
+ from .resource import SirenResourceHrefResolver
11
+
12
+
13
+ class SirenLinkFactory(ABC):
14
+ @abstractmethod
15
+ def create(self, resource: SirenResource, properties: Mapping[str, Any]) -> tuple[SirenLink, ...]:
16
+ raise NotImplementedError
17
+
18
+
19
+ class OpenApiSirenLinkFactory(SirenLinkFactory):
20
+ def __init__(self, catalog: SirenResourceCatalog, hrefs: SirenResourceHrefResolver):
21
+ self._catalog = catalog
22
+ self._hrefs = hrefs
23
+
24
+ def create(self, resource: SirenResource, properties: Mapping[str, Any]) -> tuple[SirenLink, ...]:
25
+ return (self._self_link(resource, properties), *self._relation_links(resource, properties))
26
+
27
+ def _self_link(self, resource: SirenResource, properties: Mapping[str, Any]) -> SirenLink:
28
+ return SirenLink(
29
+ rel=(SirenRelationName.SELF,),
30
+ href=self._hrefs.href(resource, properties),
31
+ title=resource.resource_class,
32
+ media_type=SirenMediaType.ENTITY,
33
+ )
34
+
35
+ def _relation_links(self, resource: SirenResource, properties: Mapping[str, Any]) -> tuple[SirenLink, ...]:
36
+ return tuple(
37
+ self._relation_link(relation, value)
38
+ for relation in resource.relations
39
+ if relation.field in properties
40
+ for value in self._relation_values(relation, properties[relation.field])
41
+ )
42
+
43
+ def _relation_link(self, relation: SirenRelation, value: Any) -> SirenLink:
44
+ target = self._catalog.resource(relation.resource)
45
+ return SirenLink(
46
+ rel=(relation.rel,),
47
+ href=self._hrefs.href(target, {target.identifier: value}),
48
+ title=relation.rel,
49
+ media_type=SirenMediaType.ENTITY,
50
+ )
51
+
52
+ def _relation_values(self, relation: SirenRelation, value: Any) -> tuple[Any, ...]:
53
+ if value is None:
54
+ raise OpenApiError(f"Relation {relation.rel!r} requires a non-null value")
55
+ if relation.many:
56
+ if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable):
57
+ raise OpenApiError(f"Relation {relation.rel!r} requires an iterable value")
58
+ values = tuple(value)
59
+ if any(item is None for item in values):
60
+ raise OpenApiError(f"Relation {relation.rel!r} requires non-null values")
61
+ return values
62
+ return (value,)
@@ -0,0 +1,31 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Mapping
3
+ from typing import Any
4
+
5
+ from ..contracts.resource import SirenResource
6
+ from ..openapi.error import OpenApiError
7
+ from ..openapi.href import SirenHrefResolver
8
+
9
+
10
+ class SirenResourceHrefResolver(ABC):
11
+ @abstractmethod
12
+ def href(self, resource: SirenResource, values: Mapping[str, Any]) -> str:
13
+ raise NotImplementedError
14
+
15
+ @abstractmethod
16
+ def path_values(self, resource: SirenResource, values: Mapping[str, Any]) -> dict[str, Any]:
17
+ raise NotImplementedError
18
+
19
+
20
+ class OpenApiSirenResourceHrefResolver(SirenResourceHrefResolver):
21
+ def __init__(self, hrefs: SirenHrefResolver):
22
+ self._hrefs = hrefs
23
+
24
+ def href(self, resource: SirenResource, values: Mapping[str, Any]) -> str:
25
+ return self._hrefs.resolve(resource.path, self.path_values(resource, values))
26
+
27
+ def path_values(self, resource: SirenResource, values: Mapping[str, Any]) -> dict[str, Any]:
28
+ missing = set(resource.path_parameters.values()) - set(values)
29
+ if missing:
30
+ raise OpenApiError(f"Missing properties {sorted(missing)} for resource {resource.name!r}")
31
+ return {parameter: values[property_name] for parameter, property_name in resource.path_parameters.items()}
File without changes
@@ -0,0 +1,80 @@
1
+ import inspect
2
+ from collections.abc import Awaitable, Callable, Mapping
3
+ from functools import wraps
4
+ from typing import Any, TypeVar, cast
5
+
6
+ from ..contracts.entity import SirenEmbeddedEntity, SirenEntityRequest
7
+ from ..facade import ModwireSiren
8
+
9
+ Result = Mapping[str, Any]
10
+ F = TypeVar("F", bound=Callable[..., Result | Awaitable[Result]])
11
+
12
+
13
+ class NinjaExtraSirenController:
14
+ """Framework-light base for Ninja Extra controllers that emit Siren documents."""
15
+
16
+ def __init__(self, siren: ModwireSiren):
17
+ self._siren = siren
18
+
19
+ def siren_document(
20
+ self,
21
+ resource_name: str,
22
+ properties: Mapping[str, Any],
23
+ operation_ids: tuple[str, ...],
24
+ path_values: Mapping[str, Any],
25
+ entities: tuple[SirenEmbeddedEntity, ...] = (),
26
+ ) -> dict[str, Any]:
27
+ return self._siren.document(
28
+ SirenEntityRequest(
29
+ resource_name=resource_name,
30
+ properties=dict(properties),
31
+ operation_ids=operation_ids,
32
+ path_values=dict(path_values),
33
+ entities=entities,
34
+ )
35
+ )
36
+
37
+
38
+ class SirenEntityDecorator:
39
+ """Turn a controller method's property mapping into a Siren entity document."""
40
+
41
+ def __init__(self, resource_name: str, *, operations: tuple[str, ...]):
42
+ if not resource_name.strip():
43
+ raise ValueError("Siren resource name must not be blank")
44
+ self._resource_name = resource_name
45
+ self._operations = operations
46
+
47
+ def __call__(self, function: F) -> F:
48
+ signature = inspect.signature(function)
49
+ resource_name = self._resource_name
50
+ operations = self._operations
51
+
52
+ def path_values(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
53
+ arguments = signature.bind(*args, **kwargs).arguments
54
+ return {name: value for name, value in arguments.items() if name not in {"self", "request"}}
55
+
56
+ if inspect.iscoroutinefunction(function):
57
+
58
+ @wraps(function)
59
+ async def async_wrapper(self: NinjaExtraSirenController, *args: Any, **kwargs: Any) -> dict[str, Any]:
60
+ properties = await function(self, *args, **kwargs)
61
+ return self.siren_document(
62
+ resource_name,
63
+ properties,
64
+ operations,
65
+ path_values((self, *args), kwargs),
66
+ )
67
+
68
+ return cast(F, async_wrapper)
69
+
70
+ @wraps(function)
71
+ def wrapper(self: NinjaExtraSirenController, *args: Any, **kwargs: Any) -> dict[str, Any]:
72
+ properties = function(self, *args, **kwargs)
73
+ return self.siren_document(
74
+ resource_name,
75
+ properties,
76
+ operations,
77
+ path_values((self, *args), kwargs),
78
+ )
79
+
80
+ return cast(F, wrapper)
File without changes
@@ -0,0 +1,62 @@
1
+ import re
2
+ from abc import ABC, abstractmethod
3
+ from collections.abc import Iterable
4
+
5
+ from ..contracts.operation import OpenApiOperation
6
+ from ..contracts.resource import SirenResource
7
+ from .error import OpenApiError
8
+
9
+
10
+ class SirenResourceCatalog(ABC):
11
+ @abstractmethod
12
+ def operation(self, operation_id: str) -> OpenApiOperation:
13
+ raise NotImplementedError
14
+
15
+ @abstractmethod
16
+ def resource(self, name: str) -> SirenResource:
17
+ raise NotImplementedError
18
+
19
+
20
+ class OpenApiCatalog(SirenResourceCatalog):
21
+ def __init__(self, operations: tuple[OpenApiOperation, ...], resources: tuple[SirenResource, ...]):
22
+ self._reject_duplicates("OpenAPI operationId", (item.operation_id for item in operations))
23
+ self._reject_duplicates("Siren resource name", (item.name for item in resources))
24
+ self._operations = {item.operation_id: item for item in operations}
25
+ self._resources = {item.name: item for item in resources}
26
+ self._validate_resources()
27
+
28
+ @staticmethod
29
+ def _reject_duplicates(label: str, values: Iterable[str]) -> None:
30
+ seen: set[str] = set()
31
+ duplicates: set[str] = set()
32
+ for value in values:
33
+ if value in seen:
34
+ duplicates.add(value)
35
+ seen.add(value)
36
+ if duplicates:
37
+ raise OpenApiError(f"Duplicate {label}: {sorted(duplicates)}")
38
+
39
+ def operation(self, operation_id: str) -> OpenApiOperation:
40
+ try:
41
+ return self._operations[operation_id]
42
+ except KeyError as error:
43
+ raise OpenApiError(f"Unknown OpenAPI operation: {operation_id}") from error
44
+
45
+ def resource(self, name: str) -> SirenResource:
46
+ try:
47
+ return self._resources[name]
48
+ except KeyError as error:
49
+ raise OpenApiError(f"Unknown Siren resource: {name}") from error
50
+
51
+ def _validate_resources(self) -> None:
52
+ for resource in self._resources.values():
53
+ placeholders = set(re.findall(r"{([^}]+)}", resource.path))
54
+ declared = set(resource.path_parameters)
55
+ if placeholders != declared:
56
+ raise OpenApiError(
57
+ f"Resource {resource.name!r} path parameters must explicitly map "
58
+ f"{sorted(placeholders)}; received {sorted(declared)}"
59
+ )
60
+ unknown = {relation.resource for relation in resource.relations} - set(self._resources)
61
+ if unknown:
62
+ raise OpenApiError(f"Resource {resource.name!r} references unknown resources: {sorted(unknown)}")
@@ -0,0 +1,19 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import Field
4
+
5
+ from ..contracts.base import SirenContract
6
+
7
+
8
+ class OpenApiRelationExtension(SirenContract):
9
+ rel: str
10
+ resource: str
11
+ many: bool
12
+
13
+
14
+ class OpenApiResourceExtension(SirenContract):
15
+ name: str
16
+ resource_class: Annotated[str, Field(validation_alias="class")]
17
+ identifier: str
18
+ path_parameters: Annotated[dict[str, str], Field(validation_alias="path-parameters")]
19
+ relations: dict[str, OpenApiRelationExtension]
@@ -0,0 +1,4 @@
1
+ class OpenApiError(ValueError):
2
+ """Report invalid or incomplete OpenAPI data used for Siren projection."""
3
+
4
+ pass
@@ -0,0 +1,16 @@
1
+ from typing import Any
2
+
3
+ from .catalog import OpenApiCatalog
4
+ from .operation import OpenApiOperationReader
5
+ from .resolver import ComponentSchemaResolver
6
+ from .resource import OpenApiResourceReader
7
+
8
+
9
+ class OpenApiCatalogFactory:
10
+ @classmethod
11
+ def create(cls, schema: dict[str, Any]) -> OpenApiCatalog:
12
+ components = schema.get("components", {}).get("schemas", {})
13
+ paths = schema.get("paths", {})
14
+ operations = OpenApiOperationReader(ComponentSchemaResolver(components)).read(paths)
15
+ resources = OpenApiResourceReader().read(paths)
16
+ return OpenApiCatalog(operations, resources)
@@ -0,0 +1,26 @@
1
+ import re
2
+ from abc import ABC, abstractmethod
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+ from urllib.parse import quote, urljoin
6
+
7
+ from .error import OpenApiError
8
+
9
+
10
+ class SirenHrefResolver(ABC):
11
+ @abstractmethod
12
+ def resolve(self, path: str, values: Mapping[str, Any]) -> str:
13
+ raise NotImplementedError
14
+
15
+
16
+ class OpenApiHrefResolver(SirenHrefResolver):
17
+ def __init__(self, base_url: str):
18
+ self._base_url = base_url.rstrip("/") + "/"
19
+
20
+ def resolve(self, path: str, values: Mapping[str, Any]) -> str:
21
+ resolved = path
22
+ for name in re.findall(r"{([^}]+)}", path):
23
+ if name not in values:
24
+ raise OpenApiError(f"Missing path value {name!r} for {path}")
25
+ resolved = resolved.replace(f"{{{name}}}", quote(str(values[name]), safe=""))
26
+ return urljoin(self._base_url, resolved.lstrip("/"))
@@ -0,0 +1,12 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class OpenApiHttpMethod(StrEnum):
5
+ DELETE = "delete"
6
+ GET = "get"
7
+ HEAD = "head"
8
+ OPTIONS = "options"
9
+ PATCH = "patch"
10
+ POST = "post"
11
+ PUT = "put"
12
+ TRACE = "trace"
@@ -0,0 +1,73 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from ..contracts.operation import OpenApiField, OpenApiOperation
5
+ from ..standards import SirenMediaType
6
+ from .error import OpenApiError
7
+ from .method import OpenApiHttpMethod
8
+ from .resolver import OpenApiSchemaResolver
9
+
10
+
11
+ class OpenApiOperationSource(ABC):
12
+ @abstractmethod
13
+ def read(self, paths: dict[str, Any]) -> tuple[OpenApiOperation, ...]:
14
+ raise NotImplementedError
15
+
16
+
17
+ class OpenApiOperationReader(OpenApiOperationSource):
18
+ def __init__(self, schemas: OpenApiSchemaResolver):
19
+ self._schemas = schemas
20
+
21
+ def read(self, paths: dict[str, Any]) -> tuple[OpenApiOperation, ...]:
22
+ return tuple(
23
+ self._operation(path, method, specification, path_item.get("parameters", ()))
24
+ for path, path_item in paths.items()
25
+ for method, specification in path_item.items()
26
+ if method.lower() in OpenApiHttpMethod and isinstance(specification, dict)
27
+ )
28
+
29
+ def _operation(
30
+ self,
31
+ path: str,
32
+ method: str,
33
+ specification: dict[str, Any],
34
+ path_parameters: list[dict[str, Any]] | tuple[dict[str, Any], ...],
35
+ ) -> OpenApiOperation:
36
+ operation_id = specification.get("operationId")
37
+ if not operation_id:
38
+ raise OpenApiError(f"Siren requires operationId for {method.upper()} {path}")
39
+ return OpenApiOperation(
40
+ operation_id=operation_id,
41
+ method=method.upper(),
42
+ path=path,
43
+ title=specification.get("summary", operation_id),
44
+ fields=self._fields(specification, path_parameters),
45
+ )
46
+
47
+ def _fields(
48
+ self,
49
+ specification: dict[str, Any],
50
+ path_parameters: list[dict[str, Any]] | tuple[dict[str, Any], ...],
51
+ ) -> tuple[OpenApiField, ...]:
52
+ parameters = {
53
+ (parameter.get("name"), parameter.get("in")): parameter
54
+ for parameter in (*path_parameters, *specification.get("parameters", ()))
55
+ }
56
+ query = tuple(
57
+ OpenApiField(
58
+ name=parameter["name"],
59
+ schema=self._schemas.resolve(parameter.get("schema", {})),
60
+ required=parameter.get("required", False),
61
+ )
62
+ for parameter in parameters.values()
63
+ if parameter.get("in") == "query"
64
+ )
65
+ content = specification.get("requestBody", {}).get("content", {})
66
+ media = content.get(SirenMediaType.ACTION) or next(iter(content.values()), {})
67
+ schema = self._schemas.resolve(media.get("schema", {}))
68
+ required = set(schema.get("required", []))
69
+ body = tuple(
70
+ OpenApiField(name=name, schema=self._schemas.resolve(value), required=name in required)
71
+ for name, value in schema.get("properties", {}).items()
72
+ )
73
+ return query + body
@@ -0,0 +1,56 @@
1
+ from abc import ABC, abstractmethod
2
+ from copy import deepcopy
3
+ from typing import Any
4
+
5
+ from .error import OpenApiError
6
+
7
+
8
+ class OpenApiSchemaResolver(ABC):
9
+ @abstractmethod
10
+ def resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
11
+ raise NotImplementedError
12
+
13
+
14
+ class ComponentSchemaResolver(OpenApiSchemaResolver):
15
+ def __init__(self, components: dict[str, Any]):
16
+ self._components = deepcopy(components)
17
+
18
+ def resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
19
+ return self._resolve(schema, ())
20
+
21
+ def _resolve(self, schema: dict[str, Any], resolving: tuple[str, ...]) -> dict[str, Any]:
22
+ resolved = deepcopy(schema)
23
+ reference = resolved.pop("$ref", None)
24
+ if reference:
25
+ component_name = reference.rsplit("/", 1)[-1]
26
+ if component_name not in self._components:
27
+ raise OpenApiError(f"Unknown OpenAPI schema reference: {reference}")
28
+ if component_name in resolving:
29
+ chain = " -> ".join((*resolving, component_name))
30
+ raise OpenApiError(f"Circular OpenAPI schema reference: {chain}")
31
+ resolved = deepcopy(self._components[component_name]) | resolved
32
+ resolving = (*resolving, component_name)
33
+ merged: dict[str, Any] = {}
34
+ for part in resolved.pop("allOf", ()):
35
+ merged = self._merge(merged, self._resolve(part, resolving))
36
+ resolved = self._merge(merged, resolved)
37
+ for keyword in ("anyOf", "oneOf", "prefixItems"):
38
+ if keyword in resolved:
39
+ resolved[keyword] = [self._resolve(item, resolving) for item in resolved[keyword]]
40
+ for keyword in ("items", "additionalProperties"):
41
+ if isinstance(resolved.get(keyword), dict):
42
+ resolved[keyword] = self._resolve(resolved[keyword], resolving)
43
+ if "properties" in resolved:
44
+ resolved["properties"] = {
45
+ name: self._resolve(value, resolving) for name, value in resolved["properties"].items()
46
+ }
47
+ return resolved
48
+
49
+ @staticmethod
50
+ def _merge(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
51
+ merged = left | right
52
+ if "properties" in left or "properties" in right:
53
+ merged["properties"] = left.get("properties", {}) | right.get("properties", {})
54
+ if "required" in left or "required" in right:
55
+ merged["required"] = list(dict.fromkeys((*left.get("required", ()), *right.get("required", ()))))
56
+ return merged
@@ -0,0 +1,34 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from ..contracts.resource import SirenRelation, SirenResource
5
+ from ..standards import SirenOpenApiExtension
6
+ from .contracts import OpenApiResourceExtension
7
+
8
+
9
+ class OpenApiResourceSource(ABC):
10
+ @abstractmethod
11
+ def read(self, paths: dict[str, Any]) -> tuple[SirenResource, ...]:
12
+ raise NotImplementedError
13
+
14
+
15
+ class OpenApiResourceReader(OpenApiResourceSource):
16
+ def read(self, paths: dict[str, Any]) -> tuple[SirenResource, ...]:
17
+ return tuple(
18
+ self._resource(path, raw_extension)
19
+ for path, path_item in paths.items()
20
+ if (raw_extension := path_item.get(SirenOpenApiExtension.RESOURCE))
21
+ )
22
+
23
+ def _resource(self, path: str, raw_extension: dict[str, Any]) -> SirenResource:
24
+ extension = OpenApiResourceExtension.model_validate(raw_extension)
25
+ return SirenResource(
26
+ name=extension.name,
27
+ path=path,
28
+ resource_class=extension.resource_class,
29
+ identifier=extension.identifier,
30
+ path_parameters=extension.path_parameters,
31
+ relations=tuple(
32
+ SirenRelation(field=field, **relation.model_dump()) for field, relation in extension.relations.items()
33
+ ),
34
+ )
File without changes
@@ -0,0 +1,24 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import ClassVar
3
+
4
+ from ..standards import SirenFieldType
5
+
6
+
7
+ class SirenFieldTypeResolver(ABC):
8
+ @abstractmethod
9
+ def resolve(self, schema: dict) -> SirenFieldType:
10
+ raise NotImplementedError
11
+
12
+
13
+ class OpenApiSirenFieldTypeResolver(SirenFieldTypeResolver):
14
+ TYPE_MAP: ClassVar = {
15
+ "boolean": SirenFieldType.CHECKBOX,
16
+ "integer": SirenFieldType.NUMBER,
17
+ "string": SirenFieldType.TEXT,
18
+ }
19
+
20
+ def resolve(self, schema: dict) -> SirenFieldType:
21
+ kind = schema.get("type", "string")
22
+ if kind in {"array", "object"} or "anyOf" in schema:
23
+ return SirenFieldType.JSON
24
+ return self.TYPE_MAP.get(kind, SirenFieldType.TEXT)
modwire_siren/py.typed ADDED
@@ -0,0 +1 @@
1
+ # typed
@@ -0,0 +1,15 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ from .contracts.entity import SirenEntity
5
+
6
+
7
+ class SirenSerializer(ABC):
8
+ @abstractmethod
9
+ def serialize(self, entity: SirenEntity) -> dict[str, Any]:
10
+ raise NotImplementedError
11
+
12
+
13
+ class PydanticSirenSerializer(SirenSerializer):
14
+ def serialize(self, entity: SirenEntity) -> dict[str, Any]:
15
+ return entity.model_dump(mode="json", by_alias=True)
@@ -0,0 +1,21 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class SirenMediaType(StrEnum):
5
+ ACTION = "application/json"
6
+ ENTITY = "application/vnd.siren+json"
7
+
8
+
9
+ class SirenRelationName(StrEnum):
10
+ SELF = "self"
11
+
12
+
13
+ class SirenFieldType(StrEnum):
14
+ CHECKBOX = "checkbox"
15
+ JSON = "json"
16
+ NUMBER = "number"
17
+ TEXT = "text"
18
+
19
+
20
+ class SirenOpenApiExtension(StrEnum):
21
+ RESOURCE = "x-siren-resource"
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: modwire-siren
3
+ Version: 0.1.0
4
+ Summary: Siren hypermedia and OpenAPI integration for Modwire.
5
+ Project-URL: Repository, https://github.com/9orky/modwire-siren
6
+ Project-URL: Issues, https://github.com/9orky/modwire-siren/issues
7
+ Author: 9orky
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: pydantic>=2.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # modwire-siren
20
+
21
+ Typed Siren documents projected from OpenAPI without application-owned route maps.
22
+
23
+ Every external boundary is behind a package interface: catalog, href resolution, field policy,
24
+ field creation, link creation, resource hrefs, and serialization. `ModwireSirenFactory` is the
25
+ standard composition root; `ModwireSiren` is the small public façade.
26
+
27
+ <!-- generated:public-api:start -->
28
+ ## Public API
29
+
30
+ The supported root imports below are generated from `modwire_siren.__all__`.
31
+
32
+ | Symbol | Purpose | Primary API |
33
+ | --- | --- | --- |
34
+ | `ModwireSiren` | Project validated entity requests into serialized Siren documents. | `document(request: modwire_siren.contracts.entity.SirenEntityRequest) -> dict[str, typing.Any]` |
35
+ | `ModwireSirenFactory` | Build the standard OpenAPI-backed Siren façade. | `standard(schema: dict[str, typing.Any], base_url: str) -> modwire_siren.facade.ModwireSiren` |
36
+ | `NinjaExtraSirenController` | Framework-light base for Ninja Extra controllers that emit Siren documents. | `siren_document(resource_name: str, properties: collections.abc.Mapping[str, typing.Any], operation_ids: tuple[str, ...], path_values: collections.abc.Mapping[str, typing.Any], entities: tuple[modwire_siren.contracts.entity.SirenEmbeddedEntity, ...] = ()) -> dict[str, typing.Any]` |
37
+ | `OpenApiError` | Report invalid or incomplete OpenAPI data used for Siren projection. | — |
38
+ | `SirenEntityDecorator` | Turn a controller method's property mapping into a Siren entity document. | — |
39
+ | `SirenEntityRequest` | Describe the resource data and allowed operations projected into one entity. | — |
40
+ | `__version__` | Installed distribution version. | — |
41
+
42
+ ## Executable example
43
+
44
+ Source: [`build_document.py`](examples/build_document.py). This file is executed by the test suite.
45
+
46
+ ```python
47
+ from modwire_siren import ModwireSirenFactory, SirenEntityRequest
48
+
49
+ openapi_schema = {
50
+ "openapi": "3.1.0",
51
+ "paths": {
52
+ "/records/{record_slug}": {
53
+ "x-siren-resource": {
54
+ "name": "record",
55
+ "class": "record",
56
+ "identifier": "slug",
57
+ "path-parameters": {"record_slug": "slug"},
58
+ "relations": {},
59
+ },
60
+ "get": {"operationId": "get_record", "summary": "Get record"},
61
+ }
62
+ },
63
+ }
64
+
65
+ siren = ModwireSirenFactory.standard(openapi_schema, "https://api.example.com/")
66
+ document = siren.document(
67
+ SirenEntityRequest(
68
+ resource_name="record",
69
+ properties={"slug": "architecture/aggregate", "title": "Architecture"},
70
+ operation_ids=("get_record",),
71
+ path_values={},
72
+ entities=(),
73
+ )
74
+ )
75
+ ```
76
+ <!-- generated:public-api:end -->
77
+
78
+ ## OpenAPI contract
79
+
80
+ ```yaml
81
+ paths:
82
+ /records/{record_slug}:
83
+ x-siren-resource:
84
+ name: record
85
+ class: record
86
+ identifier: slug
87
+ path-parameters:
88
+ record_slug: slug
89
+ relations:
90
+ section_slug:
91
+ rel: section
92
+ resource: section
93
+ many: false
94
+ patch:
95
+ operationId: revise_record
96
+ summary: Revise record
97
+ ```
98
+
99
+ The strict `OpenApiResourceExtension` validates the extension. Unknown resources, incomplete path
100
+ mappings, absent operation IDs, and unknown schema references fail while building the catalog.
101
+
102
+ The Pydantic Siren contracts own wire aliases such as `class`, `type`, and `schema`.
103
+ `PydanticSirenSerializer` implements the `SirenSerializer` interface with one model dump; it does
104
+ not redeclare the wire schema.
105
+
106
+ ## Django Ninja Extra
107
+
108
+ The controller adapter does not import Django or Ninja Extra, so the core package keeps no framework
109
+ dependency. It composes directly with Ninja Extra's controller and route decorators:
110
+
111
+ ```python
112
+ from ninja_extra import ControllerBase, api_controller, route
113
+ from modwire_siren import ModwireSiren, NinjaExtraSirenController, SirenEntityDecorator
114
+
115
+ @api_controller("/records")
116
+ class RecordController(ControllerBase, NinjaExtraSirenController):
117
+ def __init__(self, records: RecordService, siren: ModwireSiren):
118
+ NinjaExtraSirenController.__init__(self, siren)
119
+ self.records = records
120
+
121
+ @route.get("/{record_slug}", operation_id="get_record")
122
+ @SirenEntityDecorator("record", operations=("revise_record",))
123
+ def get_record(self, record_slug: str):
124
+ return self.records.get(record_slug)
125
+ ```
126
+
127
+ The method returns only resource properties. `@SirenEntityDecorator(...)` retains its signature for
128
+ Ninja's parameter inspection, supplies route arguments as path values, supports sync and async
129
+ handlers, and projects the result through the standard `ModwireSiren` composition root.
130
+
131
+ ## Development and release
132
+
133
+ Run `uv sync --all-groups` and `make verify`. Releases use strict SemVer tags and PyPI Trusted
134
+ Publishing configured for repository `9orky/modwire-siren`, workflow `release.yml`, and environment
135
+ `pypi`.
@@ -0,0 +1,37 @@
1
+ modwire_siren/__init__.py,sha256=J-p2o0e1M4belucGsiXkKRF71NrJ8OWQYz4mO9XG6Ms,475
2
+ modwire_siren/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ modwire_siren/composition.py,sha256=1rAw--WSHZxdOLsgOKPCrXRNazHxBcl8NlgTTNy_KNI,1317
4
+ modwire_siren/facade.py,sha256=0EFpJclK4vVSCU6D7Ptr5gQ4pl24yx8H9PrVcDw3R5k,627
5
+ modwire_siren/py.typed,sha256=PkRcTGuZkArgW1q-rCE9SkWNSwwdgEXZnIl5CY27NgY,7
6
+ modwire_siren/serialization.py,sha256=Ib0YkbaitVgg2KDA1_iOMMQl6qPok1rr6BT2Cs54l6k,425
7
+ modwire_siren/standards.py,sha256=U1sEaB-WlC7S9jcm4Ih4BEYWG8b8Y8uNsIJIfO5amlE,377
8
+ modwire_siren/contracts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ modwire_siren/contracts/action.py,sha256=Q26YC2xRHATDK_nQyH_pda-EFZoAZc7X7DWaDqbOYSA,686
10
+ modwire_siren/contracts/base.py,sha256=bNXaMJwu7yr-BjfJVDnmDp2zwGRHPNvj21bsqMR1Y5c,149
11
+ modwire_siren/contracts/entity.py,sha256=YSGwTnzPolFRYkp9ilbtVYdfHsesPUrUz51Q8dp1Jfg,1042
12
+ modwire_siren/contracts/link.py,sha256=_w9bxNjQgUrEPSlknIJKtu5Zvid3gtdGDt7Z34jmSMQ,244
13
+ modwire_siren/contracts/operation.py,sha256=rn-aBdXM8bfXF6KuVy7kaJb14HWp2rP-b0dkUNCARGA,393
14
+ modwire_siren/contracts/resource.py,sha256=JxxiZM6a3lk0Bvf9cq5dxN3YXXXZ1fhTf_6BKV091K4,318
15
+ modwire_siren/factories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ modwire_siren/factories/action.py,sha256=7gGptM6_KTuPXaomqmLkCEFKOCh3sEouql9mTm6_VWk,1028
17
+ modwire_siren/factories/entity.py,sha256=lkDR9b7cZ4bUeUP-gjpGVTQ85VYW06ajWjiHnE6m4cw,1592
18
+ modwire_siren/factories/field.py,sha256=Bc5_t6_7g0-PWZzKzYmfjUqokFsBlVKWqIhD0_K2tfY,1033
19
+ modwire_siren/factories/link.py,sha256=evJv4eXAJajr6wzBeoJgJKTb1YUMA15stoLCVkC4qjU,2685
20
+ modwire_siren/factories/resource.py,sha256=8VvMZe-DeQ7eHgcg5Txe61l5A9I6Kelwp_zLnezJppg,1274
21
+ modwire_siren/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ modwire_siren/integrations/ninja_extra.py,sha256=5_X5GV5_6UpayiDSAzLo0QpCzNjvjXHIRzoJtxnHaEE,2857
23
+ modwire_siren/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ modwire_siren/openapi/catalog.py,sha256=OXMUZctNO-rFGL3jQ0mBoLyXbdcbGx5fgVdZEOOJE1I,2553
25
+ modwire_siren/openapi/contracts.py,sha256=B3y76D179HgXq-6vNGAt5IWJ_dCt75CQejLa9L8tXl4,488
26
+ modwire_siren/openapi/error.py,sha256=J3ly-HXuICvUqD6tmWk1KEdmqvIEFE_K5_nei2xQDA0,121
27
+ modwire_siren/openapi/factory.py,sha256=L-v_Gc9vz-AgDZVfpYORUbgAGeJTIDjQM_kkVUuQHms,618
28
+ modwire_siren/openapi/href.py,sha256=znjHK0FfvAYgaFB8w_2095u6Sa2OogBAY-4k6Bhb8Rs,880
29
+ modwire_siren/openapi/method.py,sha256=R2cLl4PolGQNYlFBhXL_U7eh-YElaskk9K0rAvxUnmg,215
30
+ modwire_siren/openapi/operation.py,sha256=NDnB0ZKRAm6HBMn7SXna9PvC6byL-BgaEdNe9Tx3e4s,2855
31
+ modwire_siren/openapi/resolver.py,sha256=UYfrYr4MoFTUEVzCULe1w-i-JNjabEvHxJviQ1BqTao,2530
32
+ modwire_siren/openapi/resource.py,sha256=nJtfsjE_BXwvmNtNdgXQhYuAhucgL2PEj-89dL3yeio,1285
33
+ modwire_siren/policies/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ modwire_siren/policies/field_type.py,sha256=LgTl7u54uIAOJ3yGqfIFuN6pAJ-DiSnigKnVXtgHs6c,731
35
+ modwire_siren-0.1.0.dist-info/METADATA,sha256=iUPdMTJD0KTvnZt5rgFcKNsmjfPFYEoXLbM_APqXNVM,5496
36
+ modwire_siren-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
37
+ modwire_siren-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any