statewire 0.6.0__tar.gz → 0.6.2__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.
- {statewire-0.6.0 → statewire-0.6.2}/PKG-INFO +1 -1
- {statewire-0.6.0 → statewire-0.6.2}/pyproject.toml +1 -1
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/api.py +59 -1
- statewire-0.6.2/tests/test_route_auth.py +106 -0
- {statewire-0.6.0 → statewire-0.6.2}/.gitignore +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/README.md +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/examples/__init__.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/examples/demo_app.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/__init__.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/client.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/langgraph.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/ops.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/state.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/src/statewire/testing.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/client_helpers.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/statewire_helpers.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_authorize.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_client.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_client_ws.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_commands.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_context.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_idle.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_langgraph.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_lifespan.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_meta.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_observer.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_state_proxy.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_stream.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_testing.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_writer_lease.py +0 -0
- {statewire-0.6.0 → statewire-0.6.2}/tests/test_ws.py +0 -0
|
@@ -61,6 +61,12 @@ stream of that client id with ``fin {"reason": "superseded"}``. HTTP
|
|
|
61
61
|
commands must carry the current token in ``Statewire-Lease`` (else 423);
|
|
62
62
|
over WS, holding the connection is the lease.
|
|
63
63
|
|
|
64
|
+
Subclass ``@route`` handlers are gated behind ``authorize`` automatically:
|
|
65
|
+
the request is authorized before the handler body runs (an ``HTTPException``
|
|
66
|
+
propagates as its status; a WebSocket handshake is denied with close code
|
|
67
|
+
1008), and a handler that declares an ``identity`` parameter receives the
|
|
68
|
+
authorized identity in it.
|
|
69
|
+
|
|
64
70
|
Expected usage::
|
|
65
71
|
|
|
66
72
|
class Thread(Statewire):
|
|
@@ -81,12 +87,14 @@ one atomic envelope, flushed when control returns to the event loop.
|
|
|
81
87
|
|
|
82
88
|
import asyncio
|
|
83
89
|
import contextlib
|
|
90
|
+
import functools
|
|
84
91
|
import inspect
|
|
85
92
|
import json
|
|
86
93
|
import logging
|
|
87
94
|
import secrets
|
|
88
95
|
import sys
|
|
89
96
|
import time
|
|
97
|
+
import typing
|
|
90
98
|
from dataclasses import dataclass, field
|
|
91
99
|
from typing import Any, AsyncIterator, Awaitable, Callable, Coroutine
|
|
92
100
|
|
|
@@ -444,6 +452,54 @@ def _reserved_paths() -> set[str]:
|
|
|
444
452
|
return {spec.path for fn in vars(Statewire).values() for spec in routes_of(fn)}
|
|
445
453
|
|
|
446
454
|
|
|
455
|
+
def _authorized_route(fn: Any) -> Any:
|
|
456
|
+
"""Gate a subclass ``@route`` handler behind ``authorize``: the identity is
|
|
457
|
+
injected into an ``identity`` parameter when the handler declares one."""
|
|
458
|
+
is_ws = any(spec.method == "WEBSOCKET" for spec in routes_of(fn))
|
|
459
|
+
conn_name = "websocket" if is_ws else "request"
|
|
460
|
+
sig = inspect.signature(fn)
|
|
461
|
+
injects_identity = "identity" in sig.parameters
|
|
462
|
+
has_conn = conn_name in sig.parameters
|
|
463
|
+
|
|
464
|
+
@functools.wraps(fn)
|
|
465
|
+
async def wrapped(self: "Statewire", **kwargs: Any) -> Any:
|
|
466
|
+
connection = kwargs[conn_name] if has_conn else kwargs.pop(conn_name)
|
|
467
|
+
if is_ws:
|
|
468
|
+
try:
|
|
469
|
+
identity = await self.authorize(connection)
|
|
470
|
+
except HTTPException as exc:
|
|
471
|
+
detail = exc.detail if isinstance(exc.detail, str) else "unauthorized"
|
|
472
|
+
raise WebSocketException(code=1008, reason=detail) from exc
|
|
473
|
+
else:
|
|
474
|
+
identity = await self.authorize(connection)
|
|
475
|
+
if injects_identity:
|
|
476
|
+
kwargs["identity"] = identity
|
|
477
|
+
return await fn(self, **kwargs)
|
|
478
|
+
|
|
479
|
+
# Resolve hints against fn's own module so pinned's signature-driven mount
|
|
480
|
+
# sees real annotations, and expose a FastAPI-injectable connection param.
|
|
481
|
+
conn_type = WebSocket if is_ws else Request
|
|
482
|
+
hints = typing.get_type_hints(fn)
|
|
483
|
+
hints.setdefault(conn_name, conn_type)
|
|
484
|
+
params = [
|
|
485
|
+
p.replace(annotation=hints.get(p.name, p.annotation))
|
|
486
|
+
for p in sig.parameters.values()
|
|
487
|
+
if p.name != "identity"
|
|
488
|
+
]
|
|
489
|
+
if not has_conn:
|
|
490
|
+
params.insert(
|
|
491
|
+
1,
|
|
492
|
+
inspect.Parameter(
|
|
493
|
+
conn_name,
|
|
494
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
495
|
+
annotation=conn_type,
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
wrapped.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined]
|
|
499
|
+
wrapped.__annotations__ = {k: v for k, v in hints.items() if k != "identity"}
|
|
500
|
+
return wrapped
|
|
501
|
+
|
|
502
|
+
|
|
447
503
|
class Statewire(PinnedAPI):
|
|
448
504
|
stream_queue_size: int = 256
|
|
449
505
|
heartbeat_interval: float = 15.0
|
|
@@ -458,13 +514,15 @@ class Statewire(PinnedAPI):
|
|
|
458
514
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
459
515
|
super().__init_subclass__(**kwargs)
|
|
460
516
|
reserved = _reserved_paths()
|
|
461
|
-
for fn in vars(cls).
|
|
517
|
+
for name, fn in list(vars(cls).items()):
|
|
462
518
|
for spec in routes_of(fn):
|
|
463
519
|
if spec.path in reserved:
|
|
464
520
|
raise TypeError(
|
|
465
521
|
f"{cls.__name__}.{fn.__name__} declares @route on reserved "
|
|
466
522
|
f"path {spec.path!r}; it is a Statewire protocol endpoint"
|
|
467
523
|
)
|
|
524
|
+
if routes_of(fn):
|
|
525
|
+
setattr(cls, name, _authorized_route(fn))
|
|
468
526
|
# Full-MRO scan so plain mixins (no Statewire base) contribute their
|
|
469
527
|
# @command handlers; the most derived registration of a method wins.
|
|
470
528
|
merged: dict[str, str] = {}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Contract: subclass ``@route`` handlers are gated behind ``authorize``.
|
|
2
|
+
|
|
3
|
+
- an unauthorized request answers with the authorize HTTPException's status
|
|
4
|
+
and the handler never runs,
|
|
5
|
+
- a handler declaring ``identity`` receives the authorized identity,
|
|
6
|
+
- a handler without ``identity`` is still gated,
|
|
7
|
+
- FastAPI body/query parameters on the handler keep working through the wrap,
|
|
8
|
+
- a WebSocket subclass route denies the handshake when authorize rejects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from statewire_helpers import WSStream, statewire_client
|
|
12
|
+
from fastapi import HTTPException, Request
|
|
13
|
+
from pinned.routing import route
|
|
14
|
+
|
|
15
|
+
from statewire import Statewire
|
|
16
|
+
|
|
17
|
+
handled: list[str] = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Domain(Statewire):
|
|
21
|
+
async def lifespan(self):
|
|
22
|
+
self.state = {"count": 0}
|
|
23
|
+
yield
|
|
24
|
+
|
|
25
|
+
async def authorize(self, request) -> str:
|
|
26
|
+
if request.headers.get("x-token") != "secret":
|
|
27
|
+
raise HTTPException(status_code=401, detail="unauthorized")
|
|
28
|
+
return request.headers.get("x-user")
|
|
29
|
+
|
|
30
|
+
@route.get("/whoami")
|
|
31
|
+
async def whoami(self, identity):
|
|
32
|
+
return {"identity": identity}
|
|
33
|
+
|
|
34
|
+
@route.post("/bump")
|
|
35
|
+
async def bump(self, payload: dict, by: int = 1):
|
|
36
|
+
handled.append("bump")
|
|
37
|
+
return {"count": self.state["count"] + by, "echo": payload}
|
|
38
|
+
|
|
39
|
+
@route.get("/peek")
|
|
40
|
+
async def peek(self, request: Request, identity):
|
|
41
|
+
return {"identity": identity, "path": request.url.path}
|
|
42
|
+
|
|
43
|
+
@route.websocket("/tap")
|
|
44
|
+
async def tap(self, websocket, identity):
|
|
45
|
+
await websocket.accept()
|
|
46
|
+
await websocket.send_json({"tapped": identity})
|
|
47
|
+
await websocket.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_unauthorized_route_answers_the_authorize_status():
|
|
51
|
+
async with statewire_client(Domain) as (app, client):
|
|
52
|
+
before = len(handled)
|
|
53
|
+
response = await client.post("/threads/t1/bump", json={"k": "v"})
|
|
54
|
+
assert response.status_code == 401
|
|
55
|
+
assert len(handled) == before
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_identity_parameter_receives_the_authorized_identity():
|
|
59
|
+
async with statewire_client(Domain) as (app, client):
|
|
60
|
+
response = await client.get(
|
|
61
|
+
"/threads/t1/whoami", headers={"x-token": "secret", "x-user": "alice"}
|
|
62
|
+
)
|
|
63
|
+
assert response.status_code == 200
|
|
64
|
+
assert response.json() == {"identity": "alice"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def test_gated_handler_without_identity_still_runs_authorized():
|
|
68
|
+
async with statewire_client(Domain) as (app, client):
|
|
69
|
+
response = await client.post(
|
|
70
|
+
"/threads/t1/bump", json={"k": "v"}, headers={"x-token": "secret"}
|
|
71
|
+
)
|
|
72
|
+
assert response.status_code == 200
|
|
73
|
+
assert response.json() == {"count": 1, "echo": {"k": "v"}}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_body_and_query_params_survive_the_wrap():
|
|
77
|
+
async with statewire_client(Domain) as (app, client):
|
|
78
|
+
response = await client.post(
|
|
79
|
+
"/threads/t1/bump",
|
|
80
|
+
params={"by": 5},
|
|
81
|
+
json={"k": "v"},
|
|
82
|
+
headers={"x-token": "secret"},
|
|
83
|
+
)
|
|
84
|
+
assert response.status_code == 200
|
|
85
|
+
assert response.json() == {"count": 5, "echo": {"k": "v"}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_declared_request_param_reaches_the_handler():
|
|
89
|
+
async with statewire_client(Domain) as (app, client):
|
|
90
|
+
response = await client.get(
|
|
91
|
+
"/threads/t1/peek", headers={"x-token": "secret", "x-user": "bob"}
|
|
92
|
+
)
|
|
93
|
+
assert response.status_code == 200
|
|
94
|
+
assert response.json() == {"identity": "bob", "path": "/threads/t1/peek"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_websocket_route_denies_the_handshake_when_unauthorized():
|
|
98
|
+
async with statewire_client(Domain) as (app, client):
|
|
99
|
+
async with WSStream(app, "/threads/t1/tap") as ws:
|
|
100
|
+
assert ws.accepted is False
|
|
101
|
+
assert ws.close_code == 1008
|
|
102
|
+
async with WSStream(
|
|
103
|
+
app, "/threads/t1/tap", headers={"x-token": "secret", "x-user": "carol"}
|
|
104
|
+
) as ws:
|
|
105
|
+
assert ws.accepted is True
|
|
106
|
+
assert (await ws.next_frame()) == {"tapped": "carol"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|