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.
@@ -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"]