server-decorator-entity 2.0.19__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ .npmrc
2
+ dist
3
+ node_modules
4
+ .astro
5
+
6
+
7
+ # GitHub Actions
8
+ .env
9
+ *.log
10
+
11
+ # Package managers
12
+ npm-debug.log*
13
+ yarn-debug.log*
14
+ yarn-error.log*
15
+ package-lock.json # Use pnpm-lock.yaml instead
16
+ yarn.lock # Use pnpm-lock.yaml instead
17
+ # uv is dev-only; do not commit the lockfile
18
+ python/uv.lock
19
+
20
+ # iCloud-style duplicate files (D-D)
21
+ * 2
22
+ * 2.*
23
+ * 3
24
+ * 3.*
25
+
26
+ # Python (added during polyglot migration)
27
+ __pycache__/
28
+ *.py[cod]
29
+ .venv/
30
+ *.egg-info/
31
+ .pytest_cache/
32
+ .mypy_cache/
33
+ .ruff_cache/
34
+ # ps-release-workflow (gitignored state)
35
+ .claude/state/
36
+ .claude/worktrees/
37
+
38
+ # Rendered design HTML — local view artifacts only (global rule)
39
+ docs/superpowers/**/*.html
40
+ research/**/*.html
41
+
42
+ # Nim (F-006): `nim c` drops binaries beside sources; `nim r`/`nimble test` do not, but be safe.
43
+ nimcache/
44
+ nim/tests/t_*
45
+ !nim/tests/t_*.nim
46
+ nim/examples/emit_*
47
+ !nim/examples/emit_*.nim
48
+ nim/examples/user
49
+ nim/nimble.lock
50
+ # ^ the package has zero dependencies (plan constraint) — this lockfile must never be committed.
51
+
52
+ # graft's local graph cache — regenerable, not committed (run `graft build`).
53
+ graft/
54
+ # Rust build artifacts
55
+ target/
56
+ rust/target/
57
+
@@ -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,27 @@
1
+ # server-decorator-entity (Python)
2
+
3
+ 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).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install server-decorator-entity
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from typing import Annotated
15
+ from server_decorator_entity import restful, Id, Email, entity_spec
16
+
17
+ @restful(path="users")
18
+ class User:
19
+ id: Annotated[str, Id()]
20
+ email: Annotated[str, Email()]
21
+
22
+ spec = entity_spec(User) # -> EntitySpec IR, byte-identical to the Node emitter
23
+ ```
24
+
25
+ `entity_spec_canonical(User)` returns the IR as a canonical JSON string (codepoint-sorted keys,
26
+ `None`-dropping) — the exact bytes both languages' generators and the cross-language parity
27
+ check compare against.
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "server-decorator-entity"
3
+ version = "2.0.19"
4
+ description = "Declarative entity decorators emitting the language-neutral EntitySpec IR. Polyglot sibling of @montionugera/entity-spec."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "MIT"}
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.5", "mypy>=1.10", "jsonschema>=4.21"]
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/server_decorator_entity"]
19
+
20
+ [tool.ruff]
21
+ line-length = 100
22
+ target-version = "py310"
23
+
24
+ [tool.ruff.lint]
25
+ select = ["E", "F", "I", "B", "UP", "N", "ASYNC", "RUF"]
26
+
27
+ [tool.ruff.lint.per-file-ignores]
28
+ "tests/**/*.py" = ["S101"]
29
+
30
+ [tool.mypy]
31
+ python_version = "3.10"
32
+ strict = true
33
+ files = ["src/server_decorator_entity"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # python/packages/entity/scripts/check.sh — lint + type-check + unit tests.
3
+ # Assumes `uv` is installed; `pip install uv` if not.
4
+ set -euo pipefail
5
+
6
+ cd "$(dirname "$0")/.."
7
+
8
+ if command -v uv >/dev/null 2>&1; then
9
+ uv sync --extra dev >/dev/null
10
+ RUN="uv run"
11
+ else
12
+ echo "warning: uv not found; falling back to plain python+pytest. Install uv for the full dev experience." >&2
13
+ RUN=""
14
+ fi
15
+
16
+ $RUN ruff check src tests
17
+ $RUN ruff format --check src tests
18
+ $RUN mypy src
19
+
20
+ # Coverage gate (unit tests only; integration-marked tests are excluded).
21
+ # COV_MIN is a ratchet: achieved line+branch coverage (--cov-branch) rounded down,
22
+ # measured on Python 3.10, 3.12 and 3.14. Raise it, never lower it.
23
+ COV_MIN=${COV_MIN:-95}
24
+ export COVERAGE_FILE="${COVERAGE_FILE:-${TMPDIR:-/tmp}/server-decorator-entity.coverage}"
25
+ $RUN pytest -q tests/unit -m "not integration" \
26
+ --cov=src --cov-branch --cov-report=term-missing:skip-covered --cov-fail-under="$COV_MIN"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from server_decorator_entity import entity_spec_canonical
4
+ from server_decorator_entity.testing.user_fixture import UserFixture
5
+
6
+ sys.stdout.write(entity_spec_canonical(UserFixture))
@@ -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,8 @@
1
+ """Canonical EntitySpec JSON — contracts/rules/entity-spec-canonical.md."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def canonical_json(value: Any) -> str:
8
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
@@ -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)
@@ -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))
@@ -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,10 @@
1
+ from server_decorator_entity.canonical import canonical_json
2
+
3
+
4
+ def test_sorts_keys_no_whitespace() -> None:
5
+ result = canonical_json({"b": 1, "a": {"d": [2, 1], "c": "x"}})
6
+ assert result == '{"a":{"c":"x","d":[2,1]},"b":1}'
7
+
8
+
9
+ def test_non_ascii_not_escaped() -> None:
10
+ assert canonical_json({"a": "ราคา"}) == '{"a":"ราคา"}'
@@ -0,0 +1,29 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import jsonschema
5
+ import pytest
6
+
7
+ from server_decorator_entity import entity_spec, entity_spec_canonical
8
+ from server_decorator_entity.testing.user_fixture import UserFixture
9
+
10
+ # test_entity_spec.py -> unit -> tests -> entity -> packages -> python -> repo root
11
+ CONTRACTS = Path(__file__).resolve().parents[5] / "contracts"
12
+
13
+
14
+ def test_canonical_parity_with_contract_fixture() -> None:
15
+ expected = (CONTRACTS / "fixtures/entity-spec.valid-user.json").read_text().strip()
16
+ assert entity_spec_canonical(UserFixture) == expected
17
+
18
+
19
+ def test_validates_against_schema() -> None:
20
+ schema = json.loads((CONTRACTS / "schemas/entity-spec.schema.json").read_text())
21
+ jsonschema.validate(entity_spec(UserFixture), schema)
22
+
23
+
24
+ def test_undecorated_class_raises() -> None:
25
+ class Naked:
26
+ pass
27
+
28
+ with pytest.raises(TypeError, match="not decorated with @restful"):
29
+ entity_spec(Naked)
@@ -0,0 +1,129 @@
1
+ """Reviewer-confirmed hardening for entity_spec/@restful/@action (Task 9 fix round).
2
+
3
+ Findings:
4
+ 1. entity_spec() must not return the live memoized dict (mutation-proof copy).
5
+ 2. Undecorated subclass of a @restful base must not silently inherit the base's spec.
6
+ 3. Field collection must be own-class only (no MRO re-collection in subclasses).
7
+ 4. _camel()-computed wire names must be validated (format + collisions).
8
+ 5. @action on a staticmethod/classmethod must raise, in either decorator order.
9
+ """
10
+
11
+ from typing import Annotated
12
+
13
+ import pytest
14
+
15
+ from server_decorator_entity import Id, Str, action, entity_spec, entity_spec_canonical, restful
16
+ from server_decorator_entity.testing.user_fixture import UserFixture
17
+
18
+ # --- Finding 1: copy-on-read -------------------------------------------------
19
+
20
+
21
+ def test_entity_spec_returns_copy_not_live_reference() -> None:
22
+ spec1 = entity_spec(UserFixture)
23
+ spec1["entity"] = "Poisoned"
24
+ spec1["fields"].append({"name": "poison", "type": "str"})
25
+
26
+ spec2 = entity_spec(UserFixture)
27
+ assert spec2["entity"] == "User"
28
+ assert all(f.get("name") != "poison" for f in spec2["fields"])
29
+
30
+
31
+ def test_entity_spec_canonical_unaffected_by_prior_mutation_attempt() -> None:
32
+ spec = entity_spec(UserFixture)
33
+ spec["fields"] = []
34
+
35
+ # entity_spec_canonical reads the internal stored spec, not the poisoned copy.
36
+ assert '"fields":[]' not in entity_spec_canonical(UserFixture)
37
+
38
+
39
+ # --- Finding 2: undecorated subclass must not inherit base's spec via MRO ----
40
+
41
+
42
+ def test_undecorated_subclass_of_restful_base_raises() -> None:
43
+ @restful(path="bases")
44
+ class Base:
45
+ id: Annotated[str, Id()]
46
+
47
+ class Sub(Base):
48
+ pass
49
+
50
+ with pytest.raises(TypeError, match="not decorated with @restful"):
51
+ entity_spec(Sub)
52
+
53
+
54
+ # --- Finding 3: field collection is own-class only, not MRO-wide ------------
55
+
56
+
57
+ def test_subclass_fields_do_not_inherit_base_fields() -> None:
58
+ @restful(path="bases")
59
+ class Base:
60
+ id: Annotated[str, Id()]
61
+ name: Annotated[str, Str()]
62
+
63
+ @restful(path="subs")
64
+ class Sub(Base):
65
+ extra: Annotated[str, Str()]
66
+
67
+ spec = entity_spec(Sub)
68
+ assert [f["name"] for f in spec["fields"]] == ["extra"]
69
+
70
+
71
+ # --- Finding 4: wire-name validation -----------------------------------------
72
+
73
+
74
+ def test_leading_underscore_field_name_raises() -> None:
75
+ with pytest.raises(ValueError, match="_private"):
76
+
77
+ @restful(path="x")
78
+ class Bad:
79
+ _private: Annotated[str, Str()]
80
+
81
+
82
+ def test_colliding_wire_names_raise() -> None:
83
+ with pytest.raises(ValueError, match="collides"):
84
+
85
+ @restful(path="x")
86
+ class Bad:
87
+ foo_bar: Annotated[str, Str()]
88
+ foo__bar: Annotated[str, Str()]
89
+
90
+
91
+ # --- Finding 5: @action on staticmethod/classmethod raises, either order ----
92
+
93
+
94
+ def test_action_then_staticmethod_raises_immediately() -> None:
95
+ with pytest.raises(TypeError, match="static"):
96
+
97
+ class Bad:
98
+ @action("ping")
99
+ @staticmethod
100
+ def ping() -> None: ...
101
+
102
+
103
+ def test_staticmethod_then_action_raises_at_restful_time() -> None:
104
+ with pytest.raises(TypeError, match="static"):
105
+
106
+ @restful(path="x")
107
+ class Bad:
108
+ @staticmethod
109
+ @action("ping")
110
+ def ping() -> None: ...
111
+
112
+
113
+ def test_action_then_classmethod_raises_immediately() -> None:
114
+ with pytest.raises(TypeError, match="class"):
115
+
116
+ class Bad:
117
+ @action("ping")
118
+ @classmethod
119
+ def ping(cls) -> None: ...
120
+
121
+
122
+ def test_classmethod_then_action_raises_at_restful_time() -> None:
123
+ with pytest.raises(TypeError, match="class"):
124
+
125
+ @restful(path="x")
126
+ class Bad:
127
+ @classmethod
128
+ @action("ping")
129
+ def ping(cls) -> None: ...
@@ -0,0 +1,51 @@
1
+ """1.2.0 IR metadata: description, tags and security (mirror of Node's restful.spec.ts)."""
2
+
3
+ from typing import Annotated
4
+
5
+ import pytest
6
+
7
+ from server_decorator_entity import Id, Str, entity_spec, restful
8
+
9
+
10
+ def test_metadata_flows_into_spec() -> None:
11
+ @restful(
12
+ path="widgets",
13
+ description="A widget",
14
+ tags=["catalog"],
15
+ security={
16
+ "schemes": [{"name": "bearerAuth", "type": "http", "scheme": "bearer"}],
17
+ "ops": {"create": ["widget:write"]},
18
+ },
19
+ )
20
+ class Widget:
21
+ id: Annotated[str, Id()]
22
+ name: Annotated[str, Str(description="Display name")]
23
+
24
+ spec = entity_spec(Widget)
25
+ assert spec["description"] == "A widget"
26
+ assert spec["tags"] == ["catalog"]
27
+ assert spec["security"]["ops"]["create"] == ["widget:write"]
28
+ assert spec["fields"][1]["description"] == "Display name"
29
+
30
+
31
+ def test_security_op_must_be_declared() -> None:
32
+ with pytest.raises(ValueError, match=r"security\.ops\.create is not declared in ops"):
33
+
34
+ @restful(
35
+ path="widgets",
36
+ ops=["list"],
37
+ security={
38
+ "schemes": [{"name": "bearerAuth", "type": "http", "scheme": "bearer"}],
39
+ "ops": {"create": ["widget:write"]},
40
+ },
41
+ )
42
+ class Widget:
43
+ id: Annotated[str, Id()]
44
+
45
+
46
+ def test_unknown_scheme_type_rejected() -> None:
47
+ with pytest.raises(ValueError, match=r'scheme "x": type must be "http" or "apiKey"'):
48
+
49
+ @restful(path="widgets", security={"schemes": [{"name": "x", "type": "oauth2"}]})
50
+ class Widget:
51
+ id: Annotated[str, Id()]
@@ -0,0 +1,231 @@
1
+ """P1 gate-review fixes (C4, C5, escalated empty-enum, I-a, I-b1). See
2
+ contracts/rules/entity-spec-validation.md for the shared rulings both languages implement, and
3
+ node/packages/entity/src/validate.spec.ts for the Node twin these mirror.
4
+ """
5
+
6
+ from typing import Annotated
7
+
8
+ import pytest
9
+
10
+ from server_decorator_entity import (
11
+ EnumOf,
12
+ Float,
13
+ Id,
14
+ Int,
15
+ ListOf,
16
+ Obj,
17
+ Ref,
18
+ Str,
19
+ action,
20
+ entity_spec,
21
+ restful,
22
+ )
23
+
24
+
25
+ def test_empty_transports_raises() -> None:
26
+ with pytest.raises(ValueError, match="transports must not be empty"):
27
+
28
+ @restful(path="x1", transports=[])
29
+ class X1:
30
+ id: Annotated[str, Id()]
31
+
32
+
33
+ def test_empty_ops_raises() -> None:
34
+ with pytest.raises(ValueError, match="ops must not be empty"):
35
+
36
+ @restful(path="x2", ops=[])
37
+ class X2:
38
+ id: Annotated[str, Id()]
39
+
40
+
41
+ def test_empty_enum_raises() -> None:
42
+ with pytest.raises(ValueError, match="enum must have at least one value"):
43
+
44
+ @restful(path="x3")
45
+ class X3:
46
+ id: Annotated[str, Id()]
47
+ status: Annotated[str, EnumOf()]
48
+
49
+
50
+ def test_non_integer_min_raises() -> None:
51
+ with pytest.raises(ValueError, match=r"min/max must be integers"):
52
+
53
+ @restful(path="x4")
54
+ class X4:
55
+ id: Annotated[str, Id()]
56
+ b: Annotated[int, Int(min=2.5)] # type: ignore[arg-type]
57
+
58
+
59
+ def test_non_integer_max_raises() -> None:
60
+ with pytest.raises(ValueError, match=r"min/max must be integers"):
61
+
62
+ @restful(path="x5")
63
+ class X5:
64
+ id: Annotated[str, Id()]
65
+ b: Annotated[float, Float(max=2.5)]
66
+
67
+
68
+ def test_bool_min_raises_even_though_isinstance_int_is_true() -> None:
69
+ with pytest.raises(ValueError, match=r"min/max must be integers"):
70
+
71
+ @restful(path="x5b")
72
+ class X5b:
73
+ id: Annotated[str, Id()]
74
+ b: Annotated[int, Int(min=True)] # type: ignore[arg-type]
75
+
76
+
77
+ def test_format_and_pattern_together_raises() -> None:
78
+ with pytest.raises(ValueError, match="format and pattern are mutually exclusive"):
79
+
80
+ @restful(path="x6")
81
+ class X6:
82
+ id: Annotated[str, Id()]
83
+ e: Annotated[str, Str(format="email", pattern="^admin@")]
84
+
85
+
86
+ def test_reserved_wire_name_raises() -> None:
87
+ with pytest.raises(ValueError, match="reserved"):
88
+
89
+ @restful(path="x7")
90
+ class X7:
91
+ id: Annotated[str, Id()]
92
+ copy: Annotated[str, Str()]
93
+
94
+
95
+ def test_reserved_wire_name_in_nested_obj_raises() -> None:
96
+ with pytest.raises(ValueError, match="reserved"):
97
+
98
+ @restful(path="x8")
99
+ class X8:
100
+ id: Annotated[str, Id()]
101
+ profile: Annotated[dict, Obj([("schema", Str())])] # type: ignore[type-arg]
102
+
103
+
104
+ def test_reserved_wire_name_in_action_input_raises() -> None:
105
+ with pytest.raises(ValueError, match="reserved"):
106
+
107
+ class X9:
108
+ id: Annotated[str, Id()]
109
+
110
+ @action("go", input=[("json", Str())])
111
+ def go(self) -> None: ...
112
+
113
+ restful(path="x9")(X9)
114
+
115
+
116
+ def test_nested_list_of_list_names_every_level_with_the_parent_field_name() -> None:
117
+ @restful(path="x10")
118
+ class X10:
119
+ id: Annotated[str, Id()]
120
+ grid: Annotated[list, ListOf(ListOf(Str()))] # type: ignore[type-arg]
121
+
122
+ spec = entity_spec(X10)
123
+ grid_field = next(f for f in spec["fields"] if f["name"] == "grid")
124
+ assert grid_field["item"]["name"] == "grid"
125
+ assert grid_field["item"]["item"]["name"] == "grid"
126
+
127
+
128
+ def test_lookahead_pattern_raises() -> None:
129
+ with pytest.raises(ValueError, match=r"not RE2/rust-regex compatible"):
130
+
131
+ @restful(path="x12")
132
+ class X12:
133
+ id: Annotated[str, Id()]
134
+ e: Annotated[str, Str(pattern="^(?=.*[0-9]).+$")]
135
+
136
+
137
+ def test_negative_lookahead_pattern_raises() -> None:
138
+ with pytest.raises(ValueError, match="lookaround"):
139
+
140
+ @restful(path="x13")
141
+ class X13:
142
+ id: Annotated[str, Id()]
143
+ e: Annotated[str, Str(pattern="^(?!admin).+$")]
144
+
145
+
146
+ def test_lookbehind_pattern_raises() -> None:
147
+ with pytest.raises(ValueError, match="lookaround"):
148
+
149
+ @restful(path="x14")
150
+ class X14:
151
+ id: Annotated[str, Id()]
152
+ e: Annotated[str, Str(pattern="(?<=foo)bar")]
153
+
154
+
155
+ def test_backreference_pattern_raises() -> None:
156
+ with pytest.raises(ValueError, match="backreference"):
157
+
158
+ @restful(path="x15")
159
+ class X15:
160
+ id: Annotated[str, Id()]
161
+ e: Annotated[str, Str(pattern="(a)\\1")]
162
+
163
+
164
+ def test_normal_portable_pattern_still_works() -> None:
165
+ @restful(path="x16")
166
+ class X16:
167
+ id: Annotated[str, Id()]
168
+ e: Annotated[str, Str(pattern="^[a-z]+$")]
169
+
170
+ spec = entity_spec(X16)
171
+ e_field = next(f for f in spec["fields"] if f["name"] == "e")
172
+ assert e_field["pattern"] == "^[a-z]+$"
173
+
174
+
175
+ def test_doubled_backslash_before_digit_does_not_false_positive() -> None:
176
+ # Source pattern characters: ^ a \ \ 1 $ — an escaped backslash (\\) followed by a literal
177
+ # "1", NOT a backreference (\1). The raw Python string r"^a\\1$" contains exactly those six
178
+ # characters (a raw string literal does not collapse \\ to \).
179
+ pattern = r"^a\\1$"
180
+ assert pattern == "^a\\\\1$" # sanity: 6 chars — ^ a \ \ 1 $
181
+
182
+ @restful(path="x17")
183
+ class X17:
184
+ id: Annotated[str, Id()]
185
+ e: Annotated[str, Str(pattern=pattern)]
186
+
187
+ spec = entity_spec(X17)
188
+ e_field = next(f for f in spec["fields"] if f["name"] == "e")
189
+ assert e_field["pattern"] == pattern
190
+
191
+
192
+ def test_empty_string_pattern_is_preserved_in_ir() -> None:
193
+ # M4 parity bug: FieldMarker.to_ir used truthiness (`if self.pattern:`), which silently
194
+ # dropped an empty-string pattern from the IR. The TS twin (`str({ pattern: '' })`) spreads
195
+ # the option straight into FieldSpec and only filters `undefined` in canonicalJson, so it
196
+ # keeps `pattern: ''`. The schema (`contracts/schemas/entity-spec.schema.json`) declares
197
+ # `pattern` as a plain `{"type": "string"}` with no minLength, so `""` is schema-valid and
198
+ # must round-trip byte-identically across languages.
199
+ @restful(path="x18")
200
+ class X18:
201
+ id: Annotated[str, Id()]
202
+ e: Annotated[str, Str(pattern="")]
203
+
204
+ spec = entity_spec(X18)
205
+ e_field = next(f for f in spec["fields"] if f["name"] == "e")
206
+ assert "pattern" in e_field
207
+ assert e_field["pattern"] == ""
208
+
209
+
210
+ def test_empty_string_ref_is_preserved_in_ir() -> None:
211
+ # Same M4 parity bug, `ref` side: FieldMarker.to_ir used `if self.ref:`, dropping an
212
+ # empty-string ref. The TS twin (`ref('')`) keeps `ref: ''` for the same reason as above.
213
+ @restful(path="x19")
214
+ class X19:
215
+ id: Annotated[str, Id()]
216
+ r: Annotated[dict, Ref("")] # type: ignore[type-arg]
217
+
218
+ spec = entity_spec(X19)
219
+ r_field = next(f for f in spec["fields"] if f["name"] == "r")
220
+ assert "ref" in r_field
221
+ assert r_field["ref"] == ""
222
+
223
+
224
+ def test_valid_entity_unchanged_control_case() -> None:
225
+ @restful(path="x11")
226
+ class X11:
227
+ id: Annotated[str, Id()]
228
+ name: Annotated[str, Str(min=1, max=10)]
229
+
230
+ spec = entity_spec(X11)
231
+ assert [f["name"] for f in spec["fields"]] == ["id", "name"]