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.
- server_decorator_entity-2.0.19/.gitignore +57 -0
- server_decorator_entity-2.0.19/PKG-INFO +41 -0
- server_decorator_entity-2.0.19/README.md +27 -0
- server_decorator_entity-2.0.19/pyproject.toml +36 -0
- server_decorator_entity-2.0.19/scripts/check.sh +26 -0
- server_decorator_entity-2.0.19/scripts/emit_fixture.py +6 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/__init__.py +43 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/action.py +45 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/canonical.py +8 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/markers.py +119 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/py.typed +0 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/restful.py +128 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/testing/__init__.py +0 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/testing/user_fixture.py +63 -0
- server_decorator_entity-2.0.19/src/server_decorator_entity/validate.py +155 -0
- server_decorator_entity-2.0.19/tests/unit/test_canonical.py +10 -0
- server_decorator_entity-2.0.19/tests/unit/test_entity_spec.py +29 -0
- server_decorator_entity-2.0.19/tests/unit/test_entity_spec_hardening.py +129 -0
- server_decorator_entity-2.0.19/tests/unit/test_metadata.py +51 -0
- server_decorator_entity-2.0.19/tests/unit/test_validate.py +231 -0
|
@@ -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,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,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"]
|