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.
- izana_pywebview/__init__.py +39 -0
- izana_pywebview/bridge.py +108 -0
- izana_pywebview/discovery.py +44 -0
- izana_pywebview/ir.py +28 -0
- izana_pywebview/py.typed +0 -0
- izana_pywebview-0.1.0.dist-info/METADATA +10 -0
- izana_pywebview-0.1.0.dist-info/RECORD +8 -0
- izana_pywebview-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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())
|
izana_pywebview/py.typed
ADDED
|
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,,
|