server-decorator-entity 2.0.19__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.
- server_decorator_entity/__init__.py +43 -0
- server_decorator_entity/action.py +45 -0
- server_decorator_entity/canonical.py +8 -0
- server_decorator_entity/markers.py +119 -0
- server_decorator_entity/py.typed +0 -0
- server_decorator_entity/restful.py +128 -0
- server_decorator_entity/testing/__init__.py +0 -0
- server_decorator_entity/testing/user_fixture.py +63 -0
- server_decorator_entity/validate.py +155 -0
- server_decorator_entity-2.0.19.dist-info/METADATA +41 -0
- server_decorator_entity-2.0.19.dist-info/RECORD +12 -0
- server_decorator_entity-2.0.19.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from server_decorator_entity.action import action
|
|
2
|
+
from server_decorator_entity.canonical import canonical_json
|
|
3
|
+
from server_decorator_entity.markers import (
|
|
4
|
+
Bool,
|
|
5
|
+
Datetime,
|
|
6
|
+
Email,
|
|
7
|
+
EnumOf,
|
|
8
|
+
FieldMarker,
|
|
9
|
+
Float,
|
|
10
|
+
Id,
|
|
11
|
+
Int,
|
|
12
|
+
ListOf,
|
|
13
|
+
Obj,
|
|
14
|
+
Ref,
|
|
15
|
+
Str,
|
|
16
|
+
Uri,
|
|
17
|
+
Uuid,
|
|
18
|
+
)
|
|
19
|
+
from server_decorator_entity.restful import entity_spec, entity_spec_canonical, restful
|
|
20
|
+
from server_decorator_entity.validate import RESERVED_WIRE_NAMES
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"RESERVED_WIRE_NAMES",
|
|
24
|
+
"Bool",
|
|
25
|
+
"Datetime",
|
|
26
|
+
"Email",
|
|
27
|
+
"EnumOf",
|
|
28
|
+
"FieldMarker",
|
|
29
|
+
"Float",
|
|
30
|
+
"Id",
|
|
31
|
+
"Int",
|
|
32
|
+
"ListOf",
|
|
33
|
+
"Obj",
|
|
34
|
+
"Ref",
|
|
35
|
+
"Str",
|
|
36
|
+
"Uri",
|
|
37
|
+
"Uuid",
|
|
38
|
+
"action",
|
|
39
|
+
"canonical_json",
|
|
40
|
+
"entity_spec",
|
|
41
|
+
"entity_spec_canonical",
|
|
42
|
+
"restful",
|
|
43
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
|
+
|
|
4
|
+
from server_decorator_entity.markers import FieldMarker
|
|
5
|
+
|
|
6
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
7
|
+
|
|
8
|
+
_ATTR = "__entity_action__"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def action(
|
|
12
|
+
name: str,
|
|
13
|
+
*,
|
|
14
|
+
description: str | None = None,
|
|
15
|
+
state_field: str | None = None,
|
|
16
|
+
from_state: str | None = None,
|
|
17
|
+
to_state: str | None = None,
|
|
18
|
+
input: list[tuple[str, FieldMarker]] | None = None,
|
|
19
|
+
transactional: bool = True,
|
|
20
|
+
) -> Callable[[F], F]:
|
|
21
|
+
def mark(fn: F) -> F:
|
|
22
|
+
if isinstance(fn, (staticmethod, classmethod)):
|
|
23
|
+
kind = "static" if isinstance(fn, staticmethod) else "class"
|
|
24
|
+
underlying = fn.__func__
|
|
25
|
+
raise TypeError(
|
|
26
|
+
f"@action must decorate an instance method (got {kind} method "
|
|
27
|
+
f"'{underlying.__name__}')"
|
|
28
|
+
)
|
|
29
|
+
spec: dict[str, Any] = {"name": name}
|
|
30
|
+
if description is not None:
|
|
31
|
+
spec["description"] = description
|
|
32
|
+
if from_state is not None or to_state is not None:
|
|
33
|
+
spec["stateField"] = state_field or "status"
|
|
34
|
+
if from_state is not None:
|
|
35
|
+
spec["from"] = from_state
|
|
36
|
+
if to_state is not None:
|
|
37
|
+
spec["to"] = to_state
|
|
38
|
+
if input:
|
|
39
|
+
spec["input"] = [m.to_ir(n) for n, m in input]
|
|
40
|
+
if not transactional:
|
|
41
|
+
spec["transactional"] = False
|
|
42
|
+
setattr(fn, _ATTR, spec)
|
|
43
|
+
return fn
|
|
44
|
+
|
|
45
|
+
return mark
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Field markers used inside Annotated[...] on @restful classes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from server_decorator_entity.validate import (
|
|
7
|
+
assert_enum_nonempty,
|
|
8
|
+
assert_format_pattern_exclusive,
|
|
9
|
+
assert_integer_or_none,
|
|
10
|
+
assert_portable_pattern,
|
|
11
|
+
assert_wire_name_not_reserved,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class FieldMarker:
|
|
17
|
+
type: str
|
|
18
|
+
description: str | None = None
|
|
19
|
+
role: str | None = None
|
|
20
|
+
format: str | None = None
|
|
21
|
+
enum: tuple[str, ...] | None = None
|
|
22
|
+
min: int | None = None
|
|
23
|
+
max: int | None = None
|
|
24
|
+
pattern: str | None = None
|
|
25
|
+
optional: bool = False
|
|
26
|
+
nullable: bool = False
|
|
27
|
+
readonly: bool = False
|
|
28
|
+
writeonly: bool = False
|
|
29
|
+
filterable: bool = False
|
|
30
|
+
item: "FieldMarker | None" = None
|
|
31
|
+
ref: str | None = None
|
|
32
|
+
fields: tuple[tuple[str, "FieldMarker"], ...] | None = None # (name, marker) pairs for obj
|
|
33
|
+
|
|
34
|
+
def to_ir(self, name: str) -> dict[str, Any]:
|
|
35
|
+
label = f'field "{name}"'
|
|
36
|
+
assert_wire_name_not_reserved(name, label)
|
|
37
|
+
assert_format_pattern_exclusive(self.format, self.pattern, label)
|
|
38
|
+
assert_portable_pattern(self.pattern, label)
|
|
39
|
+
assert_enum_nonempty(self.enum, label)
|
|
40
|
+
assert_integer_or_none(self.min, f"{label}.min")
|
|
41
|
+
assert_integer_or_none(self.max, f"{label}.max")
|
|
42
|
+
|
|
43
|
+
ir: dict[str, Any] = {"name": name, "type": self.type}
|
|
44
|
+
if self.role:
|
|
45
|
+
ir["role"] = self.role
|
|
46
|
+
if self.format:
|
|
47
|
+
ir["format"] = self.format
|
|
48
|
+
if self.enum:
|
|
49
|
+
ir["enum"] = list(self.enum)
|
|
50
|
+
if self.min is not None:
|
|
51
|
+
ir["min"] = self.min
|
|
52
|
+
if self.max is not None:
|
|
53
|
+
ir["max"] = self.max
|
|
54
|
+
if self.pattern is not None:
|
|
55
|
+
ir["pattern"] = self.pattern
|
|
56
|
+
if self.description is not None:
|
|
57
|
+
ir["description"] = self.description
|
|
58
|
+
for flag in ("optional", "nullable", "readonly", "writeonly", "filterable"):
|
|
59
|
+
if getattr(self, flag):
|
|
60
|
+
ir[flag] = True
|
|
61
|
+
if self.item:
|
|
62
|
+
ir["item"] = self.item.to_ir(name)
|
|
63
|
+
if self.ref is not None:
|
|
64
|
+
ir["ref"] = self.ref
|
|
65
|
+
if self.fields is not None:
|
|
66
|
+
ir["fields"] = [m.to_ir(n) for n, m in self.fields]
|
|
67
|
+
return ir
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def Id() -> FieldMarker: # noqa: N802
|
|
71
|
+
return FieldMarker(type="str", role="id", readonly=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def Str(**kw: Any) -> FieldMarker: # noqa: N802
|
|
75
|
+
return FieldMarker(type="str", **kw)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def Email(**kw: Any) -> FieldMarker: # noqa: N802
|
|
79
|
+
return FieldMarker(type="str", format="email", **kw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def Uuid(**kw: Any) -> FieldMarker: # noqa: N802
|
|
83
|
+
return FieldMarker(type="str", format="uuid", **kw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def Uri(**kw: Any) -> FieldMarker: # noqa: N802
|
|
87
|
+
return FieldMarker(type="str", format="uri", **kw)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def Int(**kw: Any) -> FieldMarker: # noqa: N802
|
|
91
|
+
return FieldMarker(type="int", **kw)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def Float(**kw: Any) -> FieldMarker: # noqa: N802
|
|
95
|
+
return FieldMarker(type="float", **kw)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def Bool(**kw: Any) -> FieldMarker: # noqa: N802
|
|
99
|
+
return FieldMarker(type="bool", **kw)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def Datetime(**kw: Any) -> FieldMarker: # noqa: N802
|
|
103
|
+
return FieldMarker(type="datetime", **kw)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def EnumOf(*values: str, **kw: Any) -> FieldMarker: # noqa: N802
|
|
107
|
+
return FieldMarker(type="enum", enum=values, **kw)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def Ref(entity: str, **kw: Any) -> FieldMarker: # noqa: N802
|
|
111
|
+
return FieldMarker(type="ref", ref=entity, **kw)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ListOf(item: FieldMarker, **kw: Any) -> FieldMarker: # noqa: N802
|
|
115
|
+
return FieldMarker(type="list", item=item, **kw)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def Obj(fields: list[tuple[str, FieldMarker]], **kw: Any) -> FieldMarker: # noqa: N802
|
|
119
|
+
return FieldMarker(type="obj", fields=tuple(fields), **kw)
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import inspect
|
|
3
|
+
import re
|
|
4
|
+
import typing
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from server_decorator_entity.action import _ATTR
|
|
9
|
+
from server_decorator_entity.canonical import canonical_json
|
|
10
|
+
from server_decorator_entity.markers import FieldMarker
|
|
11
|
+
from server_decorator_entity.validate import assert_valid_security
|
|
12
|
+
|
|
13
|
+
C = TypeVar("C", bound=type)
|
|
14
|
+
|
|
15
|
+
_SPEC_ATTR = "__entity_spec__"
|
|
16
|
+
_DEFAULT_OPS = ["list", "get", "create", "update", "delete"]
|
|
17
|
+
_WIRE_NAME_RE = re.compile(r"^[a-z][A-Za-z0-9]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _camel(name: str) -> str:
|
|
21
|
+
head, *rest = name.split("_")
|
|
22
|
+
return head + "".join(s.capitalize() for s in rest)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def restful(
|
|
26
|
+
*,
|
|
27
|
+
path: str,
|
|
28
|
+
transports: list[str] | None = None,
|
|
29
|
+
ops: list[str] | None = None,
|
|
30
|
+
pagination: str = "offset",
|
|
31
|
+
description: str | None = None,
|
|
32
|
+
tags: list[str] | None = None,
|
|
33
|
+
security: dict[str, Any] | None = None,
|
|
34
|
+
) -> Callable[[C], C]:
|
|
35
|
+
def wrap(cls: C) -> C:
|
|
36
|
+
# Own-class declarations only (N2: no MRO walk) — a @restful subclass must not
|
|
37
|
+
# re-collect its base's fields. get_type_hints() still resolves the (possibly
|
|
38
|
+
# string/forward-ref) annotation values; own_annotations only supplies which
|
|
39
|
+
# names — and their declaration order — belong to this class.
|
|
40
|
+
own_annotations: dict[str, Any] = inspect.get_annotations(cls)
|
|
41
|
+
hints = typing.get_type_hints(cls, include_extras=True)
|
|
42
|
+
fields: list[dict[str, Any]] = []
|
|
43
|
+
seen_wire_names: set[str] = set()
|
|
44
|
+
for attr_name in own_annotations:
|
|
45
|
+
hint = hints.get(attr_name)
|
|
46
|
+
marker = next(
|
|
47
|
+
(m for m in getattr(hint, "__metadata__", ()) if isinstance(m, FieldMarker)), None
|
|
48
|
+
)
|
|
49
|
+
if marker is None:
|
|
50
|
+
continue
|
|
51
|
+
wire_name = _camel(attr_name)
|
|
52
|
+
if not _WIRE_NAME_RE.match(wire_name):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"{cls.__name__}.{attr_name}: computed wire name {wire_name!r} does not "
|
|
55
|
+
f"match ^[a-z][A-Za-z0-9]*$"
|
|
56
|
+
)
|
|
57
|
+
if wire_name in seen_wire_names:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"{cls.__name__}.{attr_name}: wire name {wire_name!r} collides with "
|
|
60
|
+
f"another field"
|
|
61
|
+
)
|
|
62
|
+
seen_wire_names.add(wire_name)
|
|
63
|
+
fields.append(marker.to_ir(wire_name))
|
|
64
|
+
|
|
65
|
+
# `or` is truthiness-coalescing, not None-coalescing — an explicit `transports=[]`/
|
|
66
|
+
# `ops=[]` must fail fast here (not be silently replaced by the default), matching TS's
|
|
67
|
+
# `??`-then-validate approach (C4). Only an omitted (`None`) argument gets the default.
|
|
68
|
+
resolved_transports = ["rest"] if transports is None else transports
|
|
69
|
+
if not resolved_transports:
|
|
70
|
+
raise ValueError(f"{cls.__name__}: transports must not be empty")
|
|
71
|
+
resolved_ops = list(_DEFAULT_OPS) if ops is None else ops
|
|
72
|
+
if not resolved_ops:
|
|
73
|
+
raise ValueError(f"{cls.__name__}: ops must not be empty")
|
|
74
|
+
|
|
75
|
+
actions: list[dict[str, Any]] = []
|
|
76
|
+
for member in vars(cls).values():
|
|
77
|
+
if isinstance(member, (staticmethod, classmethod)):
|
|
78
|
+
underlying = member.__func__
|
|
79
|
+
if hasattr(underlying, _ATTR):
|
|
80
|
+
kind = "static" if isinstance(member, staticmethod) else "class"
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f"@action must decorate an instance method (got {kind} method "
|
|
83
|
+
f"'{underlying.__name__}')"
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
if callable(member) and hasattr(member, _ATTR):
|
|
87
|
+
actions.append(getattr(member, _ATTR))
|
|
88
|
+
spec: dict[str, Any] = {
|
|
89
|
+
"specVersion": "1.2.0",
|
|
90
|
+
"entity": cls.__name__,
|
|
91
|
+
"path": path,
|
|
92
|
+
"transports": resolved_transports,
|
|
93
|
+
"ops": resolved_ops,
|
|
94
|
+
"pagination": pagination,
|
|
95
|
+
"fields": fields,
|
|
96
|
+
}
|
|
97
|
+
if actions:
|
|
98
|
+
spec["actions"] = actions
|
|
99
|
+
assert_valid_security(security, resolved_ops, [a["name"] for a in actions], cls.__name__)
|
|
100
|
+
# Keep absent keys absent so canonical JSON is byte-unchanged for 1.1-era declarations.
|
|
101
|
+
if description is not None:
|
|
102
|
+
spec["description"] = description
|
|
103
|
+
if tags is not None:
|
|
104
|
+
spec["tags"] = tags
|
|
105
|
+
if security is not None:
|
|
106
|
+
spec["security"] = security
|
|
107
|
+
setattr(cls, _SPEC_ATTR, spec)
|
|
108
|
+
return cls
|
|
109
|
+
|
|
110
|
+
return wrap
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _stored_spec(cls: type) -> dict[str, Any]:
|
|
114
|
+
# Own-attribute only (cls.__dict__, never getattr's MRO walk) — an undecorated
|
|
115
|
+
# subclass of a @restful base must not silently inherit the base's spec.
|
|
116
|
+
spec = cls.__dict__.get(_SPEC_ATTR)
|
|
117
|
+
if spec is None:
|
|
118
|
+
raise TypeError(f"{cls.__name__} is not decorated with @restful")
|
|
119
|
+
return spec # type: ignore[no-any-return]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def entity_spec(cls: type) -> dict[str, Any]:
|
|
123
|
+
# Deep copy so callers can't poison the memoized spec (parity with Node's deep-freeze).
|
|
124
|
+
return copy.deepcopy(_stored_spec(cls))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def entity_spec_canonical(cls: type) -> str:
|
|
128
|
+
return canonical_json(_stored_spec(cls))
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from typing import Annotated, Any
|
|
2
|
+
|
|
3
|
+
from server_decorator_entity import (
|
|
4
|
+
Bool,
|
|
5
|
+
Datetime,
|
|
6
|
+
Email,
|
|
7
|
+
EnumOf,
|
|
8
|
+
Float,
|
|
9
|
+
Id,
|
|
10
|
+
Int,
|
|
11
|
+
ListOf,
|
|
12
|
+
Obj,
|
|
13
|
+
Str,
|
|
14
|
+
action,
|
|
15
|
+
restful,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@restful(
|
|
20
|
+
path="users",
|
|
21
|
+
transports=["rest", "rpc", "sse", "ws"],
|
|
22
|
+
description="A registered user account",
|
|
23
|
+
tags=["identity"],
|
|
24
|
+
security={
|
|
25
|
+
"schemes": [
|
|
26
|
+
{"name": "bearerAuth", "type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
|
|
27
|
+
],
|
|
28
|
+
"ops": {"create": ["user:write"], "update": ["user:write"], "delete": ["user:admin"]},
|
|
29
|
+
"actions": {"activate": ["user:admin"]},
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
class User:
|
|
33
|
+
id: Annotated[str, Id()]
|
|
34
|
+
email: Annotated[str, Email(filterable=True, description="Primary contact address")]
|
|
35
|
+
name: Annotated[str, Str(min=2, max=80)]
|
|
36
|
+
status: Annotated[str, EnumOf("inactive", "active", "banned", filterable=True)]
|
|
37
|
+
login_count: Annotated[int, Int(min=0, readonly=True)]
|
|
38
|
+
tags: Annotated[list[str], ListOf(Str())]
|
|
39
|
+
profile: Annotated[
|
|
40
|
+
dict[str, Any] | None, Obj([("bio", Str(max=500, optional=True))], optional=True)
|
|
41
|
+
]
|
|
42
|
+
birthday: Annotated[str | None, Datetime(optional=True, nullable=True)]
|
|
43
|
+
# I-d (P1 gate-review): writable, non-readonly fields to exercise DTO derivation rules the
|
|
44
|
+
# original 16-case contract never touched (JSON-type strictness on float/bool, writeonly
|
|
45
|
+
# exclusion from read).
|
|
46
|
+
score: Annotated[float | None, Float(min=0, optional=True)]
|
|
47
|
+
active: Annotated[bool | None, Bool(optional=True)]
|
|
48
|
+
secret_note: Annotated[str | None, Str(writeonly=True, max=100, optional=True)]
|
|
49
|
+
|
|
50
|
+
@action(
|
|
51
|
+
"activate",
|
|
52
|
+
description="Move a dormant account back to active",
|
|
53
|
+
from_state="inactive",
|
|
54
|
+
to_state="active",
|
|
55
|
+
input=[("reason", Str(max=200, optional=True))],
|
|
56
|
+
)
|
|
57
|
+
def activate(self) -> None: ...
|
|
58
|
+
|
|
59
|
+
@action("ping")
|
|
60
|
+
def ping(self) -> None: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
UserFixture = User
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Decoration-time IR validation shared across field markers and @restful.
|
|
2
|
+
|
|
3
|
+
Mirrors node/packages/entity/src/validate.ts — see contracts/rules/entity-spec-validation.md
|
|
4
|
+
for the shared invariants (reserved wire names, format+pattern exclusivity, integer min/max,
|
|
5
|
+
non-empty enum/ops/transports) both language packages enforce identically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# Public pydantic.BaseModel attribute names (pydantic v2). A wire name colliding with one of
|
|
11
|
+
# these makes gen-pydantic's create_model() raise "shadows an attribute in parent BaseModel" —
|
|
12
|
+
# reject at decoration time in BOTH languages instead of letting it crash gen-pydantic's model
|
|
13
|
+
# build. See contracts/rules/entity-spec-validation.md "Reserved wire names".
|
|
14
|
+
RESERVED_WIRE_NAMES: frozenset[str] = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"construct",
|
|
17
|
+
"copy",
|
|
18
|
+
"dict",
|
|
19
|
+
"from_orm",
|
|
20
|
+
"json",
|
|
21
|
+
"model_computed_fields",
|
|
22
|
+
"model_config",
|
|
23
|
+
"model_construct",
|
|
24
|
+
"model_copy",
|
|
25
|
+
"model_dump",
|
|
26
|
+
"model_dump_json",
|
|
27
|
+
"model_extra",
|
|
28
|
+
"model_fields",
|
|
29
|
+
"model_fields_set",
|
|
30
|
+
"model_json_schema",
|
|
31
|
+
"model_parametrized_name",
|
|
32
|
+
"model_post_init",
|
|
33
|
+
"model_rebuild",
|
|
34
|
+
"model_validate",
|
|
35
|
+
"model_validate_json",
|
|
36
|
+
"model_validate_strings",
|
|
37
|
+
"parse_file",
|
|
38
|
+
"parse_obj",
|
|
39
|
+
"parse_raw",
|
|
40
|
+
"schema",
|
|
41
|
+
"schema_json",
|
|
42
|
+
"update_forward_refs",
|
|
43
|
+
"validate",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def assert_integer_or_none(value: Any, label: str) -> None:
|
|
49
|
+
if value is None:
|
|
50
|
+
return
|
|
51
|
+
# bool is a subclass of int in Python — isinstance(True, int) is True — so it must be
|
|
52
|
+
# excluded explicitly, not just checked for isinstance(value, int).
|
|
53
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
54
|
+
raise ValueError(f"{label}: min/max must be integers (got {value!r})")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def assert_wire_name_not_reserved(name: str, label: str) -> None:
|
|
58
|
+
if name in RESERVED_WIRE_NAMES:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"{label}: wire name {name!r} is reserved (shadows a pydantic BaseModel attribute)"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def assert_format_pattern_exclusive(fmt: Any, pattern: Any, label: str) -> None:
|
|
65
|
+
if fmt is not None and pattern is not None:
|
|
66
|
+
raise ValueError(f"{label}: format and pattern are mutually exclusive")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def assert_enum_nonempty(enum: Any, label: str) -> None:
|
|
70
|
+
if enum is not None and len(enum) == 0:
|
|
71
|
+
raise ValueError(f"{label}: enum must have at least one value")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_LOOKAROUND_MARKERS = ("(?=", "(?!", "(?<=", "(?<!")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _has_backreference(pattern: str) -> bool:
|
|
78
|
+
"""A `\\` run of odd length immediately before a digit 1-9 means that digit is escaped — a
|
|
79
|
+
backreference (`\\1`). An even-length run means the backslashes are themselves escaped
|
|
80
|
+
(`\\\\1` is a literal backslash followed by the literal digit `1`), so it's NOT a
|
|
81
|
+
backreference. `\\0` is never a backreference (it's either a literal `0` or, in some engines,
|
|
82
|
+
a null escape — not a capture-group reference), so only digits 1-9 are scanned. This is a
|
|
83
|
+
conservative lexical scan, not a regex parser — see
|
|
84
|
+
contracts/rules/entity-spec-validation.md for what it does and does not catch.
|
|
85
|
+
"""
|
|
86
|
+
for i, c in enumerate(pattern):
|
|
87
|
+
if c in "123456789":
|
|
88
|
+
backslashes = 0
|
|
89
|
+
j = i - 1
|
|
90
|
+
while j >= 0 and pattern[j] == "\\":
|
|
91
|
+
backslashes += 1
|
|
92
|
+
j -= 1
|
|
93
|
+
if backslashes % 2 == 1:
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def assert_portable_pattern(pattern: Any, label: str) -> None:
|
|
99
|
+
"""pydantic v2's Rust-backed regex engine (used by gen-pydantic) does not support lookaround
|
|
100
|
+
or backreferences — both are valid Python `re` / JS RegExp syntax, so a pattern using either
|
|
101
|
+
builds fine in gen-zod but crashes gen-pydantic's entire model build. Reject non-portable
|
|
102
|
+
patterns at DECORATION time (before either generator ever sees the IR), in both languages —
|
|
103
|
+
see contracts/rules/entity-spec-validation.md.
|
|
104
|
+
"""
|
|
105
|
+
if pattern is None:
|
|
106
|
+
return
|
|
107
|
+
for marker in _LOOKAROUND_MARKERS:
|
|
108
|
+
if marker in pattern:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"{label}: pattern {pattern!r} is not RE2/rust-regex compatible (contains "
|
|
111
|
+
f"lookaround {marker!r}) — see contracts/rules/entity-spec-validation.md"
|
|
112
|
+
)
|
|
113
|
+
if _has_backreference(pattern):
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"{label}: pattern {pattern!r} is not RE2/rust-regex compatible (contains a "
|
|
116
|
+
f"backreference) — see contracts/rules/entity-spec-validation.md"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"RESERVED_WIRE_NAMES",
|
|
122
|
+
"assert_enum_nonempty",
|
|
123
|
+
"assert_format_pattern_exclusive",
|
|
124
|
+
"assert_integer_or_none",
|
|
125
|
+
"assert_portable_pattern",
|
|
126
|
+
"assert_wire_name_not_reserved",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def assert_valid_security(
|
|
131
|
+
security: dict[str, Any] | None,
|
|
132
|
+
ops: list[str],
|
|
133
|
+
action_names: list[str],
|
|
134
|
+
entity: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Mirror of Node's validateSecurity() in packages/entity/src/validate.ts."""
|
|
137
|
+
if security is None:
|
|
138
|
+
return
|
|
139
|
+
schemes = security.get("schemes", [])
|
|
140
|
+
if not schemes:
|
|
141
|
+
raise ValueError(f"{entity}: security.schemes must not be empty")
|
|
142
|
+
for s in schemes:
|
|
143
|
+
name = s.get("name")
|
|
144
|
+
if s.get("type") not in ("http", "apiKey"):
|
|
145
|
+
raise ValueError(f'{entity}: scheme "{name}": type must be "http" or "apiKey"')
|
|
146
|
+
if s["type"] == "http" and "scheme" not in s:
|
|
147
|
+
raise ValueError(f'{entity}: scheme "{name}": http requires "scheme"')
|
|
148
|
+
if s["type"] == "apiKey" and ("in" not in s or "paramName" not in s):
|
|
149
|
+
raise ValueError(f'{entity}: scheme "{name}": apiKey requires "in" and "paramName"')
|
|
150
|
+
for op in security.get("ops", {}):
|
|
151
|
+
if op not in ops:
|
|
152
|
+
raise ValueError(f"{entity}: security.ops.{op} is not declared in ops")
|
|
153
|
+
for action_name in security.get("actions", {}):
|
|
154
|
+
if action_name not in action_names:
|
|
155
|
+
raise ValueError(f"{entity}: security.actions.{action_name} is not a declared action")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: server-decorator-entity
|
|
3
|
+
Version: 2.0.19
|
|
4
|
+
Summary: Declarative entity decorators emitting the language-neutral EntitySpec IR. Polyglot sibling of @montionugera/entity-spec.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: jsonschema>=4.21; extra == 'dev'
|
|
9
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# server-decorator-entity (Python)
|
|
16
|
+
|
|
17
|
+
Declarative entity decorators (`@restful`, `@action`, field markers) emitting the language-neutral EntitySpec IR — polyglot sibling of `@montionugera/entity-spec`, sharing the wire-format contracts in [`../../../contracts`](../../../contracts).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install server-decorator-entity
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from typing import Annotated
|
|
29
|
+
from server_decorator_entity import restful, Id, Email, entity_spec
|
|
30
|
+
|
|
31
|
+
@restful(path="users")
|
|
32
|
+
class User:
|
|
33
|
+
id: Annotated[str, Id()]
|
|
34
|
+
email: Annotated[str, Email()]
|
|
35
|
+
|
|
36
|
+
spec = entity_spec(User) # -> EntitySpec IR, byte-identical to the Node emitter
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`entity_spec_canonical(User)` returns the IR as a canonical JSON string (codepoint-sorted keys,
|
|
40
|
+
`None`-dropping) — the exact bytes both languages' generators and the cross-language parity
|
|
41
|
+
check compare against.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
server_decorator_entity/__init__.py,sha256=tqlDxK1waungYVvAcXEHl54Idra4dNcIR1NLIlxlzmE,781
|
|
2
|
+
server_decorator_entity/action.py,sha256=_ixpHHM4J8KGPx8rIpyRtUcIN5yKqd2dNCu7YFfqMCw,1462
|
|
3
|
+
server_decorator_entity/canonical.py,sha256=lFX82wVG8-1qpeuYA57dwOif4kxQyK1MT2omiY6CjB4,243
|
|
4
|
+
server_decorator_entity/markers.py,sha256=acEC9Z7oomjP_D384_j4wr5XhGiOvlc6X-66qWOrSHM,3705
|
|
5
|
+
server_decorator_entity/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
server_decorator_entity/restful.py,sha256=WQvmEevGQKOj4e1N9aPcsLGaoNebzUiNRrbj3nLYC0s,5207
|
|
7
|
+
server_decorator_entity/validate.py,sha256=FUB7bdRrXsetE7ZOp3eSa8FadoDJR_XGblNsMqZ128M,6076
|
|
8
|
+
server_decorator_entity/testing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
server_decorator_entity/testing/user_fixture.py,sha256=XW1P4XxMguShaUumOnXFLGUCxZeNvUyc1lq1Vv3iEao,1948
|
|
10
|
+
server_decorator_entity-2.0.19.dist-info/METADATA,sha256=Elvck7S19qcJhLaok4hBYBKGCfW6Ji-NqHK6hAFO1uk,1376
|
|
11
|
+
server_decorator_entity-2.0.19.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
server_decorator_entity-2.0.19.dist-info/RECORD,,
|