izana-pywebview 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ # The dispatch/auth/validate/serialize/invalidation/merge logic lives in
2
+ # izana_core.executor — this adapter has no executor module of its own,
3
+ # only the js_api-shaped transport (bridge.py) over the shared core.
4
+ from izana_core.executor import (
5
+ ErrorCode,
6
+ IzanaError,
7
+ NotFound,
8
+ BadRequest,
9
+ ValidationFailed,
10
+ Unauthorized,
11
+ Forbidden,
12
+ NotImplementedYet,
13
+ InternalError,
14
+ compute_invalidation,
15
+ compute_merges,
16
+ execute_function,
17
+ )
18
+ from izana_pywebview.bridge import Bridge, execute_call, execute_context, push
19
+ from izana_pywebview.discovery import register_module
20
+
21
+ __all__ = [
22
+ "Bridge",
23
+ "execute_call",
24
+ "execute_context",
25
+ "push",
26
+ "register_module",
27
+ "execute_function",
28
+ "compute_invalidation",
29
+ "compute_merges",
30
+ "ErrorCode",
31
+ "IzanaError",
32
+ "NotFound",
33
+ "BadRequest",
34
+ "ValidationFailed",
35
+ "Unauthorized",
36
+ "Forbidden",
37
+ "NotImplementedYet",
38
+ "InternalError",
39
+ ]
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any, Callable
6
+
7
+ from izana_core.executor import (
8
+ IzanaError,
9
+ NotFound,
10
+ compute_invalidation,
11
+ compute_merges,
12
+ execute_function,
13
+ )
14
+ from izana_core.registry import get_context_groups, get_function
15
+
16
+
17
+ def _error_envelope(exc: IzanaError) -> dict[str, Any]:
18
+ # The flat, no-status shape: {"error": true, "code", "message", "details"}.
19
+ # izana-fastapi's router nests this under an "error" key because HTTP
20
+ # already carries the status line separately; a js_api bridge has no
21
+ # status line, so the envelope needs `error: true` to say "this is a
22
+ # failure" the way a status code otherwise would — the same shape
23
+ # izana-base's IzanaError already documents for a status-less perimeter.
24
+ body: dict[str, Any] = {
25
+ "error": True,
26
+ "code": exc.code.value,
27
+ "message": exc.message,
28
+ }
29
+ if exc.details:
30
+ body["details"] = exc.details
31
+ return body
32
+
33
+
34
+ async def _call(request: Any, fn_name: str, args: dict[str, Any]) -> dict[str, Any]:
35
+ fn_class = get_function(fn_name)
36
+ result = await execute_function(request, fn_name, args)
37
+ invalidate = compute_invalidation(fn_class, args)
38
+ merges = compute_merges(fn_class, args, result)
39
+ envelope: dict[str, Any] = {"result": result, "invalidate": invalidate}
40
+ if merges:
41
+ envelope["merge"] = merges
42
+ return envelope
43
+
44
+
45
+ def execute_call(request: Any, fn_name: str, args: dict[str, Any]) -> dict[str, Any]:
46
+ """`fn_name` dispatched, with its invalidation/merge envelope — or the
47
+ error envelope, since a js_api bridge has no exception-handler
48
+ middleware to catch `IzanaError` the way the FastAPI adapter's router
49
+ does; this function is that catch, done explicitly."""
50
+ try:
51
+ return asyncio.run(_call(request, fn_name, args))
52
+ except IzanaError as exc:
53
+ return _error_envelope(exc)
54
+
55
+
56
+ async def _bundle(
57
+ request: Any, fn_names: list[str], params: dict[str, Any]
58
+ ) -> dict[str, Any]:
59
+ return {fn: await execute_function(request, fn, params) for fn in fn_names}
60
+
61
+
62
+ def execute_context(
63
+ request: Any, context_name: str, params: dict[str, Any]
64
+ ) -> dict[str, Any]:
65
+ """Every function in `context_name`'s group, called and bundled by
66
+ name — the FastAPI adapter's `GET /ctx/{name}/` without the HTTP."""
67
+ fn_names = get_context_groups().get(context_name)
68
+ if not fn_names:
69
+ return _error_envelope(NotFound(f"context {context_name!r} not found"))
70
+ try:
71
+ return asyncio.run(_bundle(request, fn_names, params))
72
+ except IzanaError as exc:
73
+ return _error_envelope(exc)
74
+
75
+
76
+ def push(window: Any, context: str, params: dict[str, Any] | None = None) -> None:
77
+ """The reverse channel: tell the frontend kernel that `context` changed
78
+ without waiting for it to ask. Evaluates `window.__izana_push__(...)`
79
+ — the handler `pywebviewTransport()` installs — through pywebview's
80
+ `Window.evaluate_js`, so a background job's own publish invalidates the
81
+ same way a mutation's `affects=` does."""
82
+ window.evaluate_js(
83
+ f"window.__izana_push__({json.dumps(context)}, {json.dumps(params)})"
84
+ )
85
+
86
+
87
+ class Bridge:
88
+ """What pywebview gets as (part of) `js_api`: `izana_call`/`izana_fetch`,
89
+ thin over `execute_call`/`execute_context`. No verb name appears here —
90
+ dispatch is entirely registry-driven off `fn_name`/`context_name`.
91
+
92
+ `request_factory` is the one seam a consumer supplies: whatever
93
+ `request` means for that app is built fresh for every call, the same
94
+ way a WSGI/ASGI app builds a fresh `Request` per call — the executor
95
+ only ever reads `request` through `_enforce_auth`, so this adapter
96
+ itself carries no opinion about what `request` is.
97
+ """
98
+
99
+ def __init__(self, request_factory: Callable[[], Any]) -> None:
100
+ self._request_factory = request_factory
101
+
102
+ def izana_call(self, fn: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
103
+ return execute_call(self._request_factory(), fn, args or {})
104
+
105
+ def izana_fetch(
106
+ self, context: str, params: dict[str, Any] | None = None
107
+ ) -> dict[str, Any]:
108
+ return execute_context(self._request_factory(), context, params or {})
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import inspect
5
+ from typing import Any
6
+
7
+ from izana_core.client.function import ServerFunction
8
+ from izana_core.registry import get_function, register
9
+
10
+
11
+ def _members(module_path: str) -> list[tuple[str, Any]]:
12
+ module = importlib.import_module(module_path)
13
+ return [
14
+ (name, member)
15
+ for name, member in inspect.getmembers(module)
16
+ if not inspect.isclass(member) or member.__module__ == module.__name__
17
+ ]
18
+
19
+
20
+ def register_module(module_path: str) -> None:
21
+ """Import `module_path` and register every `@client`-declared function it
22
+ defines.
23
+
24
+ `@client` alone builds the `ServerFunction` subclass but does not call
25
+ `register()` — every adapter's discovery does that separately (the
26
+ izana-django adapter's `izana_module` does the same walk this does, off
27
+ Django's own vendored member-lister; this is that walk without the
28
+ Django dependency, for the one-process, one-entry-module shape a
29
+ pywebview app actually has).
30
+
31
+ Idempotent: re-registering the same class under the same name (a second
32
+ `Host()` in a test, a stale re-import) is a no-op, mirroring
33
+ `register()`'s own idempotence for a same-name/same-class re-visit.
34
+ """
35
+ for name, member in _members(module_path):
36
+ if (
37
+ isinstance(member, type)
38
+ and issubclass(member, ServerFunction)
39
+ and member is not ServerFunction
40
+ ):
41
+ fn_name = getattr(member, "name", None) or member.__name__
42
+ if get_function(fn_name) is member:
43
+ continue
44
+ register(member, fn_name)
izana_pywebview/ir.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from izana_core.ir import build_ir
6
+
7
+ from izana_pywebview.discovery import register_module
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> int:
11
+ args = list(sys.argv[1:] if argv is None else argv)
12
+ if len(args) != 1:
13
+ print("usage: python -m izana_pywebview.ir <module>", file=sys.stderr)
14
+ return 2
15
+
16
+ module_name = args[0]
17
+ try:
18
+ register_module(module_name)
19
+ except Exception as e:
20
+ print(f"failed to import {module_name!r}: {e}", file=sys.stderr)
21
+ return 1
22
+
23
+ sys.stdout.write(build_ir())
24
+ return 0
25
+
26
+
27
+ if __name__ == "__main__":
28
+ sys.exit(main())
File without changes
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: izana-pywebview
3
+ Version: 0.1.0
4
+ Summary: Izana PyWebView backend adapter — js_api RPC dispatch + context bundling, built on izana-core.
5
+ License-Expression: Elastic-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: izana-core>=0.2.0
8
+ Requires-Dist: pydantic>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,8 @@
1
+ izana_pywebview/__init__.py,sha256=mLvTfWKPtOZtTsQxPBa9el_WwPRZScth3OfsZd0AZhU,968
2
+ izana_pywebview/bridge.py,sha256=pE-5rMc5ejl64Pt6IzG-Z6rPQusHtCFPJeb9Bss70Lw,4262
3
+ izana_pywebview/discovery.py,sha256=6Am140quYlzOW_DQNUYzalmj8qz25XEdBl70tg8blIw,1632
4
+ izana_pywebview/ir.py,sha256=4iIe7hchv0j2H_4wt-HWldN8kcsqjGiElDrqm9FhfMI,650
5
+ izana_pywebview/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ izana_pywebview-0.1.0.dist-info/METADATA,sha256=h64GULOh0p7J18G8nQiICYPnYQ4Z2QJiIqcu2VHM4EM,346
7
+ izana_pywebview-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ izana_pywebview-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any