izana-core 0.1.0__tar.gz → 0.2.0__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.
- {izana_core-0.1.0 → izana_core-0.2.0}/PKG-INFO +2 -2
- {izana_core-0.1.0 → izana_core-0.2.0}/pyproject.toml +2 -2
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/client/function.py +34 -14
- izana_core-0.2.0/src/izana_core/executor.py +242 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/ir.py +4 -6
- izana_core-0.2.0/tests/__init__.py +0 -0
- izana_core-0.2.0/tests/test_executor.py +159 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/.gitignore +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/__init__.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/cache/__init__.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/cache/backend.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/cache/keys.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/client/__init__.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/graph_check.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/mwt.py +0 -0
- /izana_core-0.1.0/tests/__init__.py → /izana_core-0.2.0/src/izana_core/py.typed +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/registry.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/templates/ir/document.kdl.j2 +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/type_utils.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_graph_check.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_ir.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_keys.py +0 -0
- {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_mwt.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: izana-core
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Izana Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter.
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Izana Python core — @client registry, dispatch executor, KDL IR, HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter.
|
|
5
5
|
License-Expression: Elastic-2.0
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: jinja2>=3.1
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "izana-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
license = "Elastic-2.0"
|
|
5
|
-
description = "Izana Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
|
|
5
|
+
description = "Izana Python core — @client registry, dispatch executor, KDL IR, HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
dependencies = [
|
|
8
8
|
"PyJWT>=2.0",
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
from __future__ import annotations
|
|
3
2
|
|
|
4
3
|
import asyncio
|
|
@@ -16,7 +15,6 @@ from typing import (
|
|
|
16
15
|
|
|
17
16
|
from pydantic import BaseModel
|
|
18
17
|
|
|
19
|
-
|
|
20
18
|
# View-path detection needs the framework's response base class, which only
|
|
21
19
|
# the backend adapter registers. While unset, every function is treated as RPC.
|
|
22
20
|
|
|
@@ -89,8 +87,7 @@ class ServerFunction(ABC, Generic[TInput, TOutput]):
|
|
|
89
87
|
return self.request.user
|
|
90
88
|
|
|
91
89
|
@abstractmethod
|
|
92
|
-
def call(self, input: TInput) -> TOutput:
|
|
93
|
-
...
|
|
90
|
+
def call(self, input: TInput) -> TOutput: ...
|
|
94
91
|
|
|
95
92
|
async def acall(self, input: TInput) -> TOutput:
|
|
96
93
|
return await asyncio.to_thread(self.call, input)
|
|
@@ -150,7 +147,9 @@ def _resolve_context(context: ContextMode) -> str | Literal[False]:
|
|
|
150
147
|
return context.name
|
|
151
148
|
if isinstance(context, str):
|
|
152
149
|
if not context.strip():
|
|
153
|
-
raise ValueError(
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"context must be a non-empty string, ReactContext, or False."
|
|
152
|
+
)
|
|
154
153
|
return context
|
|
155
154
|
raise ValueError(
|
|
156
155
|
f"context must be a ReactContext, a string, or False. Got {type(context).__name__}."
|
|
@@ -167,7 +166,7 @@ MergeMode = MergeTarget | list[MergeTarget] | None
|
|
|
167
166
|
|
|
168
167
|
|
|
169
168
|
def client(
|
|
170
|
-
fn: Callable = None,
|
|
169
|
+
fn: Callable[..., Any] | None = None,
|
|
171
170
|
*,
|
|
172
171
|
context: ContextMode = False,
|
|
173
172
|
affects: AffectsMode = None,
|
|
@@ -179,7 +178,10 @@ def client(
|
|
|
179
178
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
|
180
179
|
rev: int = 0,
|
|
181
180
|
cache: int | bool = True,
|
|
182
|
-
) ->
|
|
181
|
+
) -> (
|
|
182
|
+
type[ServerFunction[Any, Any]]
|
|
183
|
+
| Callable[[Callable[..., Any]], type[ServerFunction[Any, Any]]]
|
|
184
|
+
):
|
|
183
185
|
# Resolve context to name string
|
|
184
186
|
resolved_context = _resolve_context(context)
|
|
185
187
|
|
|
@@ -208,17 +210,33 @@ def client(
|
|
|
208
210
|
|
|
209
211
|
def decorator(fn: Callable) -> type[ServerFunction]:
|
|
210
212
|
return _create_server_function(
|
|
211
|
-
fn,
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
fn,
|
|
214
|
+
context=resolved_context,
|
|
215
|
+
affects=affects,
|
|
216
|
+
merge=merge,
|
|
217
|
+
private=private,
|
|
218
|
+
route=route,
|
|
219
|
+
methods=methods,
|
|
220
|
+
websocket=websocket,
|
|
221
|
+
auth=auth,
|
|
222
|
+
rev=rev,
|
|
223
|
+
cache=cache,
|
|
214
224
|
)
|
|
215
225
|
|
|
216
226
|
# Support both @client and @client(...)
|
|
217
227
|
if fn is not None:
|
|
218
228
|
return _create_server_function(
|
|
219
|
-
fn,
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
fn,
|
|
230
|
+
context=resolved_context,
|
|
231
|
+
affects=affects,
|
|
232
|
+
merge=merge,
|
|
233
|
+
private=private,
|
|
234
|
+
route=route,
|
|
235
|
+
methods=methods,
|
|
236
|
+
websocket=websocket,
|
|
237
|
+
auth=auth,
|
|
238
|
+
rev=rev,
|
|
239
|
+
cache=cache,
|
|
222
240
|
)
|
|
223
241
|
return decorator
|
|
224
242
|
|
|
@@ -333,7 +351,9 @@ def _create_server_function(
|
|
|
333
351
|
output_cls = output_type
|
|
334
352
|
is_primitive_output = False
|
|
335
353
|
else:
|
|
336
|
-
output_cls = create_model(
|
|
354
|
+
output_cls = create_model(
|
|
355
|
+
f"{fn.__name__}_Output", result=(output_type, ...)
|
|
356
|
+
)
|
|
337
357
|
is_primitive_output = True
|
|
338
358
|
|
|
339
359
|
# Store param names for unpacking validated input
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ValidationError
|
|
7
|
+
from pydantic_core import to_jsonable_python
|
|
8
|
+
|
|
9
|
+
from izana_core.registry import get_context_groups, get_function
|
|
10
|
+
from izana_core.type_utils import types_match_for_merge
|
|
11
|
+
|
|
12
|
+
# ─── Error taxonomy ─────────────────────────────────────────────────────────
|
|
13
|
+
#
|
|
14
|
+
# No `status_code` here: HTTP-ness is a transport concern, and only the
|
|
15
|
+
# adapters that have a status line (izana-fastapi) own that table
|
|
16
|
+
# (`status_of` in their router) — a js_api or Tauri-command bridge has no
|
|
17
|
+
# status line at all, so an error carried on the exception itself would be a
|
|
18
|
+
# field every non-HTTP adapter inherits and can never use.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ErrorCode(str, Enum):
|
|
22
|
+
NOT_FOUND = "NOT_FOUND"
|
|
23
|
+
BAD_REQUEST = "BAD_REQUEST"
|
|
24
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
25
|
+
UNAUTHORIZED = "UNAUTHORIZED"
|
|
26
|
+
FORBIDDEN = "FORBIDDEN"
|
|
27
|
+
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
|
|
28
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IzanaError(Exception):
|
|
32
|
+
|
|
33
|
+
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.message = message
|
|
38
|
+
self.details = details
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotFound(IzanaError):
|
|
42
|
+
code = ErrorCode.NOT_FOUND # noqa: E701
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BadRequest(IzanaError):
|
|
46
|
+
code = ErrorCode.BAD_REQUEST # noqa: E701
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ValidationFailed(IzanaError):
|
|
50
|
+
code = ErrorCode.VALIDATION_ERROR # noqa: E701
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Unauthorized(IzanaError):
|
|
54
|
+
code = ErrorCode.UNAUTHORIZED # noqa: E701
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Forbidden(IzanaError):
|
|
58
|
+
code = ErrorCode.FORBIDDEN # noqa: E701
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NotImplementedYet(IzanaError):
|
|
62
|
+
code = ErrorCode.NOT_IMPLEMENTED # noqa: E701
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InternalError(IzanaError):
|
|
66
|
+
code = ErrorCode.INTERNAL_ERROR # noqa: E701
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ─── Auth ───────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _user(request: Any) -> Any:
|
|
73
|
+
return getattr(getattr(request, "state", None), "user", None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_authenticated(user: Any) -> bool:
|
|
77
|
+
return bool(user) and getattr(user, "is_authenticated", True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _enforce_auth(request: Any, requirement: Any) -> None:
|
|
81
|
+
if requirement is None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
user = _user(request)
|
|
85
|
+
|
|
86
|
+
match requirement:
|
|
87
|
+
case True | "required":
|
|
88
|
+
if not _is_authenticated(user):
|
|
89
|
+
raise Unauthorized("Authentication required")
|
|
90
|
+
case "staff":
|
|
91
|
+
if not _is_authenticated(user):
|
|
92
|
+
raise Unauthorized("Authentication required")
|
|
93
|
+
if not getattr(user, "is_staff", False):
|
|
94
|
+
raise Forbidden("Staff access required")
|
|
95
|
+
case "superuser":
|
|
96
|
+
if not _is_authenticated(user):
|
|
97
|
+
raise Unauthorized("Authentication required")
|
|
98
|
+
if not getattr(user, "is_superuser", False):
|
|
99
|
+
raise Forbidden("Superuser access required")
|
|
100
|
+
case f if callable(f):
|
|
101
|
+
if not f(request):
|
|
102
|
+
raise Forbidden("Permission denied")
|
|
103
|
+
case other:
|
|
104
|
+
raise InternalError(f"Unknown auth requirement: {other!r}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ─── Input validation ───────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_input(input_cls: Any, input_data: Any) -> BaseModel | None:
|
|
111
|
+
if input_cls in (None, BaseModel) or not getattr(input_cls, "model_fields", None):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
fields = input_cls.model_fields
|
|
115
|
+
required = [name for name, f in fields.items() if f.is_required()]
|
|
116
|
+
|
|
117
|
+
if not input_data:
|
|
118
|
+
if required:
|
|
119
|
+
raise ValidationFailed(
|
|
120
|
+
"Input validation failed",
|
|
121
|
+
details={"fields": {name: ["Field required"] for name in required}},
|
|
122
|
+
)
|
|
123
|
+
return input_cls()
|
|
124
|
+
|
|
125
|
+
if not isinstance(input_data, dict):
|
|
126
|
+
raise BadRequest(f"Input must be an object, got {type(input_data).__name__}")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return input_cls(**input_data)
|
|
130
|
+
except ValidationError as e:
|
|
131
|
+
raise ValidationFailed(
|
|
132
|
+
"Input validation failed",
|
|
133
|
+
details={"errors": e.errors()},
|
|
134
|
+
) from e
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ─── Dispatch ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_function(fn_name: str) -> Any:
|
|
141
|
+
view_class = get_function(fn_name)
|
|
142
|
+
if view_class is None:
|
|
143
|
+
raise NotFound("Function not found")
|
|
144
|
+
if getattr(view_class, "_meta", {}).get("private"):
|
|
145
|
+
raise Forbidden("Function is not client-callable")
|
|
146
|
+
return view_class
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _serialize(result: Any) -> Any:
|
|
150
|
+
# A @client Output is always BaseModel / list / dict / enum / primitive,
|
|
151
|
+
# so pydantic_core's own encoder is total over it — no adapter-specific
|
|
152
|
+
# recursion needed here.
|
|
153
|
+
return to_jsonable_python(result)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def execute_function(
|
|
157
|
+
request: Any,
|
|
158
|
+
fn_name: str,
|
|
159
|
+
input_data: dict[str, Any] | None = None,
|
|
160
|
+
) -> Any:
|
|
161
|
+
view_class = _resolve_function(fn_name)
|
|
162
|
+
_enforce_auth(request, view_class._meta.get("auth"))
|
|
163
|
+
|
|
164
|
+
view = view_class(request)
|
|
165
|
+
validated = _validate_input(view.Input, input_data)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
result = await view.acall(validated)
|
|
169
|
+
except NotImplementedError as e:
|
|
170
|
+
raise NotImplementedYet(str(e) or "Not implemented") from e
|
|
171
|
+
except IzanaError:
|
|
172
|
+
raise
|
|
173
|
+
except Exception as e:
|
|
174
|
+
raise InternalError(str(e)) from e
|
|
175
|
+
|
|
176
|
+
return _serialize(result)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ─── Invalidation ───────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def compute_invalidation(
|
|
183
|
+
view_class: Any, input_data: dict[str, Any] | None
|
|
184
|
+
) -> list[Any]:
|
|
185
|
+
affects = getattr(view_class, "_meta", {}).get("affects") or []
|
|
186
|
+
return [_invalidation_target(target, input_data or {}) for target in affects]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def compute_merges(
|
|
190
|
+
view_class: Any, input_data: dict[str, Any] | None, result: Any
|
|
191
|
+
) -> list[dict[str, Any]]:
|
|
192
|
+
targets = getattr(view_class, "_meta", {}).get("merge") or []
|
|
193
|
+
if not targets:
|
|
194
|
+
return []
|
|
195
|
+
mutation_output = getattr(view_class, "Output", None)
|
|
196
|
+
out: list[dict[str, Any]] = []
|
|
197
|
+
for ctx_name in targets:
|
|
198
|
+
slot = _resolve_merge_slot(ctx_name, mutation_output)
|
|
199
|
+
if slot is None:
|
|
200
|
+
continue
|
|
201
|
+
entry: dict[str, Any] = {"context": ctx_name, "slot": slot, "value": result}
|
|
202
|
+
scoped = _scoped_params(ctx_name, input_data or {})
|
|
203
|
+
if scoped:
|
|
204
|
+
entry["params"] = scoped
|
|
205
|
+
out.append(entry)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_merge_slot(context_name: str, mutation_output: Any) -> str | None:
|
|
210
|
+
if mutation_output is None:
|
|
211
|
+
return None
|
|
212
|
+
matches: list[str] = []
|
|
213
|
+
for fn_name in get_context_groups().get(context_name, []):
|
|
214
|
+
fn_cls = get_function(fn_name)
|
|
215
|
+
if fn_cls is None:
|
|
216
|
+
continue
|
|
217
|
+
fn_output = getattr(fn_cls, "Output", None)
|
|
218
|
+
if fn_output is not None and types_match_for_merge(fn_output, mutation_output):
|
|
219
|
+
matches.append(fn_name)
|
|
220
|
+
return matches[0] if len(matches) == 1 else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _scoped_params(context_name: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
fn_names = get_context_groups().get(context_name, [])
|
|
225
|
+
declared: set[str] = set()
|
|
226
|
+
for fn_name in fn_names:
|
|
227
|
+
fn_cls = get_function(fn_name)
|
|
228
|
+
if fn_cls is None:
|
|
229
|
+
continue
|
|
230
|
+
input_cls = getattr(fn_cls, "Input", None)
|
|
231
|
+
if (
|
|
232
|
+
input_cls
|
|
233
|
+
and input_cls is not BaseModel
|
|
234
|
+
and hasattr(input_cls, "model_fields")
|
|
235
|
+
):
|
|
236
|
+
declared.update(input_cls.model_fields.keys())
|
|
237
|
+
return {k: v for k, v in input_data.items() if k in declared}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _invalidation_target(name: str, input_data: dict[str, Any]) -> Any:
|
|
241
|
+
scoped = _scoped_params(name, input_data)
|
|
242
|
+
return {"context": name, "params": scoped} if scoped else name
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
from __future__ import annotations
|
|
3
2
|
|
|
4
3
|
import re
|
|
@@ -18,7 +17,6 @@ from izana_core.registry import (
|
|
|
18
17
|
)
|
|
19
18
|
from izana_core.type_utils import extract_list_element, extract_optional
|
|
20
19
|
|
|
21
|
-
|
|
22
20
|
__all__ = ["build_ir", "wire_to_pascal"]
|
|
23
21
|
|
|
24
22
|
|
|
@@ -35,7 +33,7 @@ def wire_to_pascal(wire_name: str) -> str:
|
|
|
35
33
|
def _kdl(value: str) -> str:
|
|
36
34
|
escaped = (
|
|
37
35
|
value.replace("\\", "\\\\")
|
|
38
|
-
.replace("
|
|
36
|
+
.replace('"', '\\"')
|
|
39
37
|
.replace("\n", "\\n")
|
|
40
38
|
.replace("\r", "\\r")
|
|
41
39
|
.replace("\t", "\\t")
|
|
@@ -315,9 +313,9 @@ _CHANNEL_SLOTS = (
|
|
|
315
313
|
)
|
|
316
314
|
|
|
317
315
|
|
|
318
|
-
def _collect_channels() ->
|
|
319
|
-
list[dict[str, Any]], list[tuple[str, type[BaseModel]]]
|
|
320
|
-
|
|
316
|
+
def _collect_channels() -> (
|
|
317
|
+
tuple[list[dict[str, Any]], list[tuple[str, type[BaseModel]]]]
|
|
318
|
+
):
|
|
321
319
|
channel_classes = get_registry().get("channels", {})
|
|
322
320
|
records: list[dict[str, Any]] = []
|
|
323
321
|
models: list[tuple[str, type[BaseModel]]] = []
|
|
File without changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from izana_core.client.function import client
|
|
9
|
+
from izana_core.executor import (
|
|
10
|
+
Forbidden,
|
|
11
|
+
Unauthorized,
|
|
12
|
+
ValidationFailed,
|
|
13
|
+
compute_invalidation,
|
|
14
|
+
compute_merges,
|
|
15
|
+
execute_function,
|
|
16
|
+
)
|
|
17
|
+
from izana_core.registry import clear_registry, get_function, register
|
|
18
|
+
|
|
19
|
+
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
20
|
+
#
|
|
21
|
+
# Exercised directly against izana_core — no adapter in the loop. An adapter
|
|
22
|
+
# (izana-pywebview, izana-fastapi) only owes a transport over this; the
|
|
23
|
+
# dispatch/auth/validate/serialize/invalidation/merge contract itself is
|
|
24
|
+
# core's, and this is where it's proven independent of any one of them.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EchoOutput(BaseModel):
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ItemOutput(BaseModel):
|
|
32
|
+
id: int
|
|
33
|
+
name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class User:
|
|
37
|
+
def __init__(self, *, is_authenticated: bool, is_staff: bool = False):
|
|
38
|
+
self.is_authenticated = is_authenticated
|
|
39
|
+
self.is_staff = is_staff
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class State:
|
|
43
|
+
def __init__(self, user: object | None):
|
|
44
|
+
self.user = user
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Req:
|
|
48
|
+
def __init__(self, user: object | None = None):
|
|
49
|
+
self.state = State(user)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def registry():
|
|
54
|
+
clear_registry()
|
|
55
|
+
|
|
56
|
+
@client
|
|
57
|
+
def add(request, a: int, b: int) -> EchoOutput:
|
|
58
|
+
return EchoOutput(message=f"sum: {a + b}")
|
|
59
|
+
|
|
60
|
+
@client(auth=True)
|
|
61
|
+
def whoami(request) -> EchoOutput:
|
|
62
|
+
return EchoOutput(message="ok")
|
|
63
|
+
|
|
64
|
+
@client(auth="staff")
|
|
65
|
+
def staff_only(request) -> EchoOutput:
|
|
66
|
+
return EchoOutput(message="staff ok")
|
|
67
|
+
|
|
68
|
+
@client(affects="user")
|
|
69
|
+
def update_email(request, email: str) -> EchoOutput:
|
|
70
|
+
return EchoOutput(message=f"updated: {email}")
|
|
71
|
+
|
|
72
|
+
@client(merge="items")
|
|
73
|
+
def set_item_name(request, id: int, name: str) -> ItemOutput:
|
|
74
|
+
return ItemOutput(id=id, name=name)
|
|
75
|
+
|
|
76
|
+
@client(context="items")
|
|
77
|
+
def items_list(request) -> list[ItemOutput]:
|
|
78
|
+
return [ItemOutput(id=1, name="orig")]
|
|
79
|
+
|
|
80
|
+
register(add, "add")
|
|
81
|
+
register(whoami, "whoami")
|
|
82
|
+
register(staff_only, "staff_only")
|
|
83
|
+
register(update_email, "update_email")
|
|
84
|
+
register(set_item_name, "set_item_name")
|
|
85
|
+
register(items_list, "items_list")
|
|
86
|
+
|
|
87
|
+
yield None
|
|
88
|
+
|
|
89
|
+
clear_registry()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run(coro):
|
|
93
|
+
return asyncio.run(coro)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ─── Dispatch + serialize ────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DispatchTests:
|
|
100
|
+
def test_a_call_dispatches_validates_and_serializes(self, registry):
|
|
101
|
+
result = run(execute_function(Req(), "add", {"a": 2, "b": 3}))
|
|
102
|
+
assert result == {"message": "sum: 5"}
|
|
103
|
+
|
|
104
|
+
def test_a_list_of_basemodel_serializes_as_a_bare_array(self, registry):
|
|
105
|
+
result = run(execute_function(Req(), "items_list", {}))
|
|
106
|
+
assert result == [{"id": 1, "name": "orig"}]
|
|
107
|
+
|
|
108
|
+
def test_a_missing_required_field_is_a_validation_failure(self, registry):
|
|
109
|
+
with pytest.raises(ValidationFailed) as exc:
|
|
110
|
+
run(execute_function(Req(), "add", {}))
|
|
111
|
+
assert "a" in exc.value.details["fields"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ─── Auth ───────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AuthTests:
|
|
118
|
+
def test_an_unauthenticated_request_to_an_auth_required_function_refuses(
|
|
119
|
+
self, registry
|
|
120
|
+
):
|
|
121
|
+
with pytest.raises(Unauthorized):
|
|
122
|
+
run(execute_function(Req(user=None), "whoami", {}))
|
|
123
|
+
|
|
124
|
+
def test_an_authenticated_request_passes(self, registry):
|
|
125
|
+
result = run(
|
|
126
|
+
execute_function(Req(user=User(is_authenticated=True)), "whoami", {})
|
|
127
|
+
)
|
|
128
|
+
assert result == {"message": "ok"}
|
|
129
|
+
|
|
130
|
+
def test_a_non_staff_authenticated_user_is_forbidden_from_staff_only(
|
|
131
|
+
self, registry
|
|
132
|
+
):
|
|
133
|
+
with pytest.raises(Forbidden):
|
|
134
|
+
run(
|
|
135
|
+
execute_function(
|
|
136
|
+
Req(user=User(is_authenticated=True, is_staff=False)),
|
|
137
|
+
"staff_only",
|
|
138
|
+
{},
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ─── Invalidation + merge ────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class InvalidationAndMergeTests:
|
|
147
|
+
def test_affects_computes_the_invalidation_list(self, registry):
|
|
148
|
+
fn_class = get_function("update_email")
|
|
149
|
+
assert compute_invalidation(fn_class, {"email": "a@b.com"}) == ["user"]
|
|
150
|
+
|
|
151
|
+
def test_merge_computes_a_merge_entry_against_the_matching_context_reader(
|
|
152
|
+
self, registry
|
|
153
|
+
):
|
|
154
|
+
fn_class = get_function("set_item_name")
|
|
155
|
+
result = run(execute_function(Req(), "set_item_name", {"id": 1, "name": "x"}))
|
|
156
|
+
merges = compute_merges(fn_class, {"id": 1, "name": "x"}, result)
|
|
157
|
+
assert merges == [
|
|
158
|
+
{"context": "items", "slot": "items_list", "value": {"id": 1, "name": "x"}}
|
|
159
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|