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.
Files changed (23) hide show
  1. {izana_core-0.1.0 → izana_core-0.2.0}/PKG-INFO +2 -2
  2. {izana_core-0.1.0 → izana_core-0.2.0}/pyproject.toml +2 -2
  3. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/client/function.py +34 -14
  4. izana_core-0.2.0/src/izana_core/executor.py +242 -0
  5. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/ir.py +4 -6
  6. izana_core-0.2.0/tests/__init__.py +0 -0
  7. izana_core-0.2.0/tests/test_executor.py +159 -0
  8. {izana_core-0.1.0 → izana_core-0.2.0}/.gitignore +0 -0
  9. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/__init__.py +0 -0
  10. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/cache/__init__.py +0 -0
  11. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/cache/backend.py +0 -0
  12. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/cache/keys.py +0 -0
  13. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/client/__init__.py +0 -0
  14. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/graph_check.py +0 -0
  15. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/mwt.py +0 -0
  16. /izana_core-0.1.0/tests/__init__.py → /izana_core-0.2.0/src/izana_core/py.typed +0 -0
  17. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/registry.py +0 -0
  18. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/templates/ir/document.kdl.j2 +0 -0
  19. {izana_core-0.1.0 → izana_core-0.2.0}/src/izana_core/type_utils.py +0 -0
  20. {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_graph_check.py +0 -0
  21. {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_ir.py +0 -0
  22. {izana_core-0.1.0 → izana_core-0.2.0}/tests/test_keys.py +0 -0
  23. {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.1.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.1.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("context must be a non-empty string, ReactContext, or False.")
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
- ) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
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, context=resolved_context, affects=affects, merge=merge,
212
- private=private, route=route, methods=methods,
213
- websocket=websocket, auth=auth, rev=rev, cache=cache,
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, context=resolved_context, affects=affects, merge=merge,
220
- private=private, route=route, methods=methods,
221
- websocket=websocket, auth=auth, rev=rev, cache=cache,
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(f"{fn.__name__}_Output", result=(output_type, ...))
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() -> tuple[
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