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.
Files changed (32) hide show
  1. {statewire-0.6.0 → statewire-0.6.2}/PKG-INFO +1 -1
  2. {statewire-0.6.0 → statewire-0.6.2}/pyproject.toml +1 -1
  3. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/api.py +59 -1
  4. statewire-0.6.2/tests/test_route_auth.py +106 -0
  5. {statewire-0.6.0 → statewire-0.6.2}/.gitignore +0 -0
  6. {statewire-0.6.0 → statewire-0.6.2}/README.md +0 -0
  7. {statewire-0.6.0 → statewire-0.6.2}/examples/__init__.py +0 -0
  8. {statewire-0.6.0 → statewire-0.6.2}/examples/demo_app.py +0 -0
  9. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/__init__.py +0 -0
  10. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/client.py +0 -0
  11. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/langgraph.py +0 -0
  12. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/ops.py +0 -0
  13. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/state.py +0 -0
  14. {statewire-0.6.0 → statewire-0.6.2}/src/statewire/testing.py +0 -0
  15. {statewire-0.6.0 → statewire-0.6.2}/tests/client_helpers.py +0 -0
  16. {statewire-0.6.0 → statewire-0.6.2}/tests/statewire_helpers.py +0 -0
  17. {statewire-0.6.0 → statewire-0.6.2}/tests/test_authorize.py +0 -0
  18. {statewire-0.6.0 → statewire-0.6.2}/tests/test_client.py +0 -0
  19. {statewire-0.6.0 → statewire-0.6.2}/tests/test_client_ws.py +0 -0
  20. {statewire-0.6.0 → statewire-0.6.2}/tests/test_commands.py +0 -0
  21. {statewire-0.6.0 → statewire-0.6.2}/tests/test_context.py +0 -0
  22. {statewire-0.6.0 → statewire-0.6.2}/tests/test_idle.py +0 -0
  23. {statewire-0.6.0 → statewire-0.6.2}/tests/test_langgraph.py +0 -0
  24. {statewire-0.6.0 → statewire-0.6.2}/tests/test_lifespan.py +0 -0
  25. {statewire-0.6.0 → statewire-0.6.2}/tests/test_meta.py +0 -0
  26. {statewire-0.6.0 → statewire-0.6.2}/tests/test_observer.py +0 -0
  27. {statewire-0.6.0 → statewire-0.6.2}/tests/test_state_proxy.py +0 -0
  28. {statewire-0.6.0 → statewire-0.6.2}/tests/test_statewire_hostable.py +0 -0
  29. {statewire-0.6.0 → statewire-0.6.2}/tests/test_stream.py +0 -0
  30. {statewire-0.6.0 → statewire-0.6.2}/tests/test_testing.py +0 -0
  31. {statewire-0.6.0 → statewire-0.6.2}/tests/test_writer_lease.py +0 -0
  32. {statewire-0.6.0 → statewire-0.6.2}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statewire
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Replicate one JSON object over an SSE op stream + a command endpoint
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statewire"
3
- version = "0.6.0"
3
+ version = "0.6.2"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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).values():
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