izana-pywebview 0.1.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_pywebview-0.1.0/.gitignore +35 -0
- izana_pywebview-0.1.0/PKG-INFO +10 -0
- izana_pywebview-0.1.0/README.md +144 -0
- izana_pywebview-0.1.0/pyproject.toml +31 -0
- izana_pywebview-0.1.0/src/izana_pywebview/__init__.py +39 -0
- izana_pywebview-0.1.0/src/izana_pywebview/bridge.py +108 -0
- izana_pywebview-0.1.0/src/izana_pywebview/discovery.py +44 -0
- izana_pywebview-0.1.0/src/izana_pywebview/ir.py +28 -0
- izana_pywebview-0.1.0/src/izana_pywebview/py.typed +0 -0
- izana_pywebview-0.1.0/tests/__init__.py +0 -0
- izana_pywebview-0.1.0/tests/test_dispatch.py +267 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
*.db
|
|
7
|
+
uv.lock
|
|
8
|
+
|
|
9
|
+
# Node
|
|
10
|
+
node_modules/
|
|
11
|
+
dist/
|
|
12
|
+
package-lock.json
|
|
13
|
+
|
|
14
|
+
# Rust — every crate's build dir, anywhere in the tree
|
|
15
|
+
target/
|
|
16
|
+
**/target/
|
|
17
|
+
|
|
18
|
+
# Playwright
|
|
19
|
+
/test-results/
|
|
20
|
+
/playwright-report/
|
|
21
|
+
/blob-report/
|
|
22
|
+
examples/django-react-site/harness/test-results/
|
|
23
|
+
|
|
24
|
+
# IDE
|
|
25
|
+
.idea/
|
|
26
|
+
.vscode/
|
|
27
|
+
|
|
28
|
+
# Build artifacts
|
|
29
|
+
protocol/izana-generate/bin/izana-generate-*
|
|
30
|
+
|
|
31
|
+
# Env
|
|
32
|
+
.env
|
|
33
|
+
.env.*
|
|
34
|
+
*.pem
|
|
35
|
+
*.key
|
|
@@ -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,144 @@
|
|
|
1
|
+
# izana-pywebview
|
|
2
|
+
|
|
3
|
+
PyWebView backend adapter for the Izana protocol. Desktop apps don't have a
|
|
4
|
+
transport already listening on `127.0.0.1` — `window.pywebview.api` *is* the
|
|
5
|
+
transport, so this adapter is the executor wired straight to it: two
|
|
6
|
+
methods, `izana_call` and `izana_fetch`, both thin over `execute_call`/
|
|
7
|
+
`execute_context`.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
Same dispatch/validation/invalidation/merge surface as izana-fastapi, minus
|
|
12
|
+
HTTP: no status codes, no routes, no middleware. What's here instead:
|
|
13
|
+
|
|
14
|
+
- **`izana_core.executor`** (not in this package) — `execute_function`, the
|
|
15
|
+
error taxonomy (`IzanaError`/`ErrorCode`), `compute_invalidation`,
|
|
16
|
+
`compute_merges`. Shared with izana-fastapi; the only thing HTTP adds is
|
|
17
|
+
the code→status table, and that lives in izana-fastapi's router.
|
|
18
|
+
- **`bridge.py`** — `execute_call`/`execute_context` (the FastAPI adapter's
|
|
19
|
+
`POST /call/` and `GET /ctx/{name}/`, without the HTTP), `Bridge`, the
|
|
20
|
+
class a `pywebview.create_window(..., js_api=...)` call actually wants,
|
|
21
|
+
and `push`, the reverse channel (below).
|
|
22
|
+
- **`discovery.py`** — `register_module(module_path)`. `@client` alone
|
|
23
|
+
doesn't register a function (see below); a pywebview app is one process
|
|
24
|
+
with one entry module, so this is the one-module version of the Django
|
|
25
|
+
adapter's app-walking discovery, without the Django dependency.
|
|
26
|
+
- **`ir.py`** — `python -m izana_pywebview.ir <module>`, for codegen.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add izana-pywebview
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# host.py
|
|
38
|
+
import webview
|
|
39
|
+
|
|
40
|
+
from izana_pywebview import Bridge, register_module
|
|
41
|
+
|
|
42
|
+
import myapp.clients # noqa: F401 — not required for registration; see below
|
|
43
|
+
|
|
44
|
+
register_module("myapp.clients")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Host:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self.state = ... # whatever `request` should carry for your app
|
|
50
|
+
self._bridge = Bridge(request_factory=lambda: self.state)
|
|
51
|
+
|
|
52
|
+
def izana_call(self, fn, args=None):
|
|
53
|
+
return self._bridge.izana_call(fn, args)
|
|
54
|
+
|
|
55
|
+
def izana_fetch(self, context, params=None):
|
|
56
|
+
return self._bridge.izana_fetch(context, params)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
host = Host()
|
|
60
|
+
window = webview.create_window("My App", "index.html", js_api=host)
|
|
61
|
+
webview.start()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`Host` only needs to expose `izana_call`/`izana_fetch` on the object it
|
|
65
|
+
hands to `js_api=`; everything else on `Host` is the app's own window
|
|
66
|
+
plumbing (dialogs, `set_window`), never Izana's concern.
|
|
67
|
+
|
|
68
|
+
## Why discovery is explicit
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from izana_core.client.function import client
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@client
|
|
75
|
+
def echo(request, text: str) -> EchoOutput: ...
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`@client` builds the `ServerFunction` subclass the registry wants, but
|
|
79
|
+
*building* it isn't *registering* it — nothing calls `register()` as a side
|
|
80
|
+
effect of decoration. izana-fastapi asks every project to call `register()`
|
|
81
|
+
by hand (or from a startup hook); `register_module(module_path)` here does
|
|
82
|
+
that walk for you: import the module, find every `ServerFunction` subclass
|
|
83
|
+
among its members, register it under its function name. Call it once, before
|
|
84
|
+
the window opens.
|
|
85
|
+
|
|
86
|
+
## `@client` parameters
|
|
87
|
+
|
|
88
|
+
Same as every adapter — see izana-fastapi's README for the full list
|
|
89
|
+
(`context`, `affects`, `auth`, `rev`, …). This adapter reads `_meta` the
|
|
90
|
+
same way; nothing here is pywebview-specific about the decorator itself.
|
|
91
|
+
|
|
92
|
+
## Auth
|
|
93
|
+
|
|
94
|
+
There's no HTTP session for `request.state.user` to ride in on. Whatever
|
|
95
|
+
`request_factory` returns *is* `request` — if a verb needs `auth=`, populate
|
|
96
|
+
`.state.user` on whatever object your `request_factory` builds, the same
|
|
97
|
+
contract izana-fastapi's middleware fulfills for HTTP.
|
|
98
|
+
|
|
99
|
+
## Error envelope
|
|
100
|
+
|
|
101
|
+
A js_api call has no status line, so the envelope carries `code` alone:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"error": true, "code": "NOT_FOUND", "message": "..."}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This is the flat shape `IzanaError.fromEnvelope` (izana-base) already
|
|
108
|
+
documents for a status-less perimeter (its own docstring calls it the
|
|
109
|
+
"Django" shape) — the kernel's `IzanaError` on the frontend parses it
|
|
110
|
+
without change.
|
|
111
|
+
|
|
112
|
+
## Pushing from Python
|
|
113
|
+
|
|
114
|
+
A mutation's `affects=` invalidates when the frontend calls it. A background
|
|
115
|
+
job has no call to ride on, so it publishes itself:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from izana_pywebview import push
|
|
119
|
+
|
|
120
|
+
def on_job_finished(window):
|
|
121
|
+
push(window, "jobs") # refetch every `context="jobs"` reader
|
|
122
|
+
push(window, "job", {"id": 7}) # or just the readers scoped to id=7
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`push` evaluates `window.__izana_push__(context, params)` through
|
|
126
|
+
`Window.evaluate_js`; `pywebviewTransport()` installs that handler and
|
|
127
|
+
routes it into the kernel's `invalidate`.
|
|
128
|
+
|
|
129
|
+
## Generate the frontend
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python -m izana_pywebview.ir myapp.clients
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Imports the module (triggering discovery — `register_module`, not a bare
|
|
136
|
+
`importlib.import_module`, is what `ir.py` calls), then prints the Izana KDL
|
|
137
|
+
IR to stdout, same as every other adapter's `ir.py`.
|
|
138
|
+
|
|
139
|
+
## Running tests
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
uv sync --extra dev
|
|
143
|
+
uv run pytest
|
|
144
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "izana-pywebview"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
license = "Elastic-2.0"
|
|
5
|
+
description = "Izana PyWebView backend adapter — js_api RPC dispatch + context bundling, built on izana-core."
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"izana-core>=0.2.0",
|
|
9
|
+
"pydantic>=2.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = [
|
|
14
|
+
"pytest>=8.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/izana_pywebview"]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
izana-core = { path = "../../cores/izana-python", editable = true }
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
pythonpath = ["src"]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
python_classes = ["*Tests", "*Test", "Test*"]
|
|
31
|
+
python_functions = ["test_*"]
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from izana_core.client.function import client
|
|
9
|
+
from izana_core.registry import clear_registry, get_function, register
|
|
10
|
+
|
|
11
|
+
from izana_pywebview import Bridge, execute_call, execute_context, push
|
|
12
|
+
|
|
13
|
+
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EchoOutput(BaseModel):
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SumOutput(BaseModel):
|
|
21
|
+
total: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserOutput(BaseModel):
|
|
25
|
+
email: str
|
|
26
|
+
authenticated: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ItemOutput(BaseModel):
|
|
30
|
+
id: int
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoRequest:
|
|
35
|
+
"""An anonymous request: no `.state`, so `_user` degrades to `None`
|
|
36
|
+
through the same `getattr` chain a real bridge request hits when
|
|
37
|
+
nobody's logged in — a pywebview bridge has no auth middleware to
|
|
38
|
+
populate `.state.user`, so this is every un-authenticated call's actual
|
|
39
|
+
shape, not a test-only stand-in."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
REQUEST = NoRequest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def registry():
|
|
47
|
+
clear_registry()
|
|
48
|
+
|
|
49
|
+
@client
|
|
50
|
+
def echo(request, text: str) -> EchoOutput:
|
|
51
|
+
return EchoOutput(message=f"echo: {text}")
|
|
52
|
+
|
|
53
|
+
@client
|
|
54
|
+
def add(request, a: int, b: int) -> SumOutput:
|
|
55
|
+
return SumOutput(total=a + b)
|
|
56
|
+
|
|
57
|
+
@client(context="user")
|
|
58
|
+
def current_user(request) -> UserOutput:
|
|
59
|
+
return UserOutput(email="anon@example.com", authenticated=False)
|
|
60
|
+
|
|
61
|
+
@client(context="user")
|
|
62
|
+
def user_count(request) -> SumOutput:
|
|
63
|
+
return SumOutput(total=42)
|
|
64
|
+
|
|
65
|
+
@client(affects="user")
|
|
66
|
+
def update_email(request, email: str) -> EchoOutput:
|
|
67
|
+
return EchoOutput(message=f"updated: {email}")
|
|
68
|
+
|
|
69
|
+
@client(auth=True)
|
|
70
|
+
def whoami(request) -> UserOutput:
|
|
71
|
+
return UserOutput(email="real@example.com", authenticated=True)
|
|
72
|
+
|
|
73
|
+
@client
|
|
74
|
+
def list_items(request) -> list[ItemOutput]:
|
|
75
|
+
return [ItemOutput(id=1, name="a"), ItemOutput(id=2, name="b")]
|
|
76
|
+
|
|
77
|
+
@client(merge="items")
|
|
78
|
+
def set_item_name(request, id: int, name: str) -> ItemOutput:
|
|
79
|
+
return ItemOutput(id=id, name=name)
|
|
80
|
+
|
|
81
|
+
@client(context="items")
|
|
82
|
+
def items_list(request) -> list[ItemOutput]:
|
|
83
|
+
return [ItemOutput(id=1, name="orig")]
|
|
84
|
+
|
|
85
|
+
register(echo, "echo")
|
|
86
|
+
register(add, "add")
|
|
87
|
+
register(current_user, "current_user")
|
|
88
|
+
register(user_count, "user_count")
|
|
89
|
+
register(update_email, "update_email")
|
|
90
|
+
register(whoami, "whoami")
|
|
91
|
+
register(list_items, "list_items")
|
|
92
|
+
register(set_item_name, "set_item_name")
|
|
93
|
+
register(items_list, "items_list")
|
|
94
|
+
|
|
95
|
+
yield None
|
|
96
|
+
|
|
97
|
+
clear_registry()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ─── RPC dispatch ───────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FunctionCallTests:
|
|
104
|
+
def test_simple_call_returns_result(self, registry):
|
|
105
|
+
envelope = execute_call(REQUEST, "echo", {"text": "hi"})
|
|
106
|
+
assert envelope["result"]["message"] == "echo: hi"
|
|
107
|
+
assert envelope["invalidate"] == []
|
|
108
|
+
|
|
109
|
+
def test_call_with_typed_input(self, registry):
|
|
110
|
+
envelope = execute_call(REQUEST, "add", {"a": 2, "b": 3})
|
|
111
|
+
assert envelope["result"]["total"] == 5
|
|
112
|
+
|
|
113
|
+
def test_unknown_function_is_a_not_found_envelope(self, registry):
|
|
114
|
+
envelope = execute_call(REQUEST, "ghost", {})
|
|
115
|
+
assert envelope["error"] is True
|
|
116
|
+
assert envelope["code"] == "NOT_FOUND"
|
|
117
|
+
|
|
118
|
+
def test_validation_error_is_a_validation_envelope(self, registry):
|
|
119
|
+
envelope = execute_call(REQUEST, "add", {"a": "not-int", "b": 3})
|
|
120
|
+
assert envelope["error"] is True
|
|
121
|
+
assert envelope["code"] == "VALIDATION_ERROR"
|
|
122
|
+
|
|
123
|
+
def test_missing_required_input_is_a_validation_envelope(self, registry):
|
|
124
|
+
envelope = execute_call(REQUEST, "add", {})
|
|
125
|
+
assert envelope["code"] == "VALIDATION_ERROR"
|
|
126
|
+
assert "a" in envelope["details"]["fields"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ─── Context bundling ───────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ContextFetchTests:
|
|
133
|
+
def test_context_returns_bundled_results(self, registry):
|
|
134
|
+
bundled = execute_context(REQUEST, "user", {})
|
|
135
|
+
assert bundled["current_user"]["email"] == "anon@example.com"
|
|
136
|
+
assert bundled["user_count"]["total"] == 42
|
|
137
|
+
|
|
138
|
+
def test_unknown_context_is_a_not_found_envelope(self, registry):
|
|
139
|
+
envelope = execute_context(REQUEST, "ghost", {})
|
|
140
|
+
assert envelope["error"] is True
|
|
141
|
+
assert envelope["code"] == "NOT_FOUND"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ─── Auth gating ────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AuthTests:
|
|
148
|
+
def test_anonymous_request_to_auth_required_is_unauthorized(self, registry):
|
|
149
|
+
envelope = execute_call(REQUEST, "whoami", {})
|
|
150
|
+
assert envelope["error"] is True
|
|
151
|
+
assert envelope["code"] == "UNAUTHORIZED"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ─── Invalidation ───────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class InvalidationTests:
|
|
158
|
+
def test_mutation_emits_invalidate_list(self, registry):
|
|
159
|
+
envelope = execute_call(REQUEST, "update_email", {"email": "new@example.com"})
|
|
160
|
+
assert "user" in envelope["invalidate"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ─── Structured-output shapes ────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class StructuredOutputTests:
|
|
167
|
+
def test_list_of_basemodel_returns_bare_array(self, registry):
|
|
168
|
+
envelope = execute_call(REQUEST, "list_items", {})
|
|
169
|
+
assert envelope["result"] == [
|
|
170
|
+
{"id": 1, "name": "a"},
|
|
171
|
+
{"id": 2, "name": "b"},
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ─── Merge protocol ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class MergeTests:
|
|
179
|
+
def test_merge_target_emits_merge_entry(self, registry):
|
|
180
|
+
envelope = execute_call(REQUEST, "set_item_name", {"id": 42, "name": "renamed"})
|
|
181
|
+
assert envelope["merge"] == [
|
|
182
|
+
{
|
|
183
|
+
"context": "items",
|
|
184
|
+
"slot": "items_list",
|
|
185
|
+
"value": {"id": 42, "name": "renamed"},
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
assert envelope["invalidate"] == []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ─── The js_api surface ──────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class BridgeTests:
|
|
195
|
+
def test_bridge_delegates_izana_call_and_izana_fetch(self, registry):
|
|
196
|
+
bridge = Bridge(request_factory=lambda: REQUEST)
|
|
197
|
+
assert bridge.izana_call("echo", {"text": "via bridge"})["result"][
|
|
198
|
+
"message"
|
|
199
|
+
] == ("echo: via bridge")
|
|
200
|
+
assert bridge.izana_fetch("user")["current_user"]["email"] == "anon@example.com"
|
|
201
|
+
|
|
202
|
+
def test_bridge_request_factory_is_called_fresh_per_call(self, registry):
|
|
203
|
+
seen: list[object] = []
|
|
204
|
+
|
|
205
|
+
@client
|
|
206
|
+
def note_request(request) -> EchoOutput:
|
|
207
|
+
seen.append(request)
|
|
208
|
+
return EchoOutput(message="noted")
|
|
209
|
+
|
|
210
|
+
register(note_request, "note_request")
|
|
211
|
+
bridge = Bridge(request_factory=lambda: object())
|
|
212
|
+
bridge.izana_call("note_request", {})
|
|
213
|
+
bridge.izana_call("note_request", {})
|
|
214
|
+
assert len(seen) == 2
|
|
215
|
+
assert seen[0] is not seen[1]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ─── Reverse channel ─────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Window:
|
|
222
|
+
def __init__(self) -> None:
|
|
223
|
+
self.scripts: list[str] = []
|
|
224
|
+
|
|
225
|
+
def evaluate_js(self, script: str) -> None:
|
|
226
|
+
self.scripts.append(script)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class PushTests:
|
|
230
|
+
def test_push_evaluates_the_kernel_handler_with_context_and_params(self):
|
|
231
|
+
window = Window()
|
|
232
|
+
push(window, "jobs")
|
|
233
|
+
push(window, "job", {"id": 7})
|
|
234
|
+
assert window.scripts == [
|
|
235
|
+
'window.__izana_push__("jobs", null)',
|
|
236
|
+
'window.__izana_push__("job", {"id": 7})',
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ─── Discovery ───────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class DiscoveryTests:
|
|
244
|
+
def test_register_module_registers_every_client_function_in_it(
|
|
245
|
+
self, tmp_path, monkeypatch
|
|
246
|
+
):
|
|
247
|
+
clear_registry()
|
|
248
|
+
monkeypatch.syspath_prepend(str(tmp_path))
|
|
249
|
+
(tmp_path / "sample_clients.py").write_text(
|
|
250
|
+
"from izana_core.client.function import client\n"
|
|
251
|
+
"from pydantic import BaseModel\n\n\n"
|
|
252
|
+
"class Out(BaseModel):\n"
|
|
253
|
+
" value: int\n\n\n"
|
|
254
|
+
"@client\n"
|
|
255
|
+
"def sample(request) -> Out:\n"
|
|
256
|
+
" return Out(value=1)\n"
|
|
257
|
+
)
|
|
258
|
+
from izana_pywebview import register_module
|
|
259
|
+
|
|
260
|
+
register_module("sample_clients")
|
|
261
|
+
assert get_function("sample") is not None
|
|
262
|
+
|
|
263
|
+
register_module("sample_clients") # a re-visit is a no-op, not an error
|
|
264
|
+
assert get_function("sample") is not None
|
|
265
|
+
|
|
266
|
+
clear_registry()
|
|
267
|
+
del sys.modules["sample_clients"]
|