diff --git a/.claude/agents/frontend-tester.md b/.claude/agents/frontend-tester.md index 9c47d4395..4533e568f 100644 --- a/.claude/agents/frontend-tester.md +++ b/.claude/agents/frontend-tester.md @@ -109,5 +109,5 @@ After testing, provide a structured report: ```python from zndraw import ZnDraw -vis = ZnDraw(room="") # finds local server automatically +vis = ZnDraw(room="") # finds local server automatically ``` diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9d8a934ce..63fdf72bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,17 +20,17 @@ repos: - id: sort-simple-yaml - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell - rev: v2.4.2 + rev: v2.4.3 hooks: - id: codespell additional_dependencies: ["tomli"] - repo: https://github.com/biomejs/pre-commit - rev: v2.4.10 # Use the sha / tag you want to point at + rev: v2.5.15 # Use the sha / tag you want to point at hooks: - id: biome-format # not using check becasue there are lots of things that need fixed additional_dependencies: ["@biomejs/biome@1.9.4"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.16.10 hooks: - id: ruff args: [--fix] diff --git a/README.md b/README.md index 74df4a121..08e7fab02 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ ZnDraw supports multiple clients connecting to the same visualization. Each visu ```python from zndraw import ZnDraw -vis = ZnDraw(url="http://localhost:1234", room="123e4567-e89b-12d3-a456-426614174000/my-room") +vis = ZnDraw( + url="http://localhost:1234", room="123e4567-e89b-12d3-a456-426614174000/my-room" +) ``` > `room=` accepts the composed form `/` returned by the server. Single-segment names are no longer supported. @@ -71,7 +73,7 @@ vis = ZnDraw( url="http://localhost:1234", room="123e4567-e89b-12d3-a456-426614174000/my-room", user="username", - password="password" + password="password", ) ``` @@ -106,12 +108,12 @@ subset = vis[10:20] Control various aspects of the visualization: ```python -vis.selection # Currently selected atoms -vis.step # Current frame index -vis.figures # Plotly figures -vis.bookmarks # Saved frame annotations +vis.selection # Currently selected atoms +vis.step # Current frame index +vis.figures # Plotly figures +vis.bookmarks # Saved frame annotations vis.geometries # 3D geometry overlays (dict-like) -vis.sessions # Session configuration +vis.sessions # Session configuration ``` ## Geometries @@ -162,6 +164,7 @@ Create custom tools accessible via the ZnDraw UI: from molify import smiles2atoms from zndraw import ZnDraw, Extension, Category + class AddMolecule(Extension): category = Category.MODIFIER smiles: str @@ -170,6 +173,7 @@ class AddMolecule(Extension): vis.append(smiles2atoms(self.smiles)) vis.step = len(vis) - 1 + vis = ZnDraw() vis.register_job(AddMolecule) # room-scoped (default) vis.wait() @@ -188,6 +192,7 @@ Register filesystem providers so users can load files from the UI: ```python import fsspec + vis.register_fs(fsspec.filesystem("file"), name="local") ``` diff --git a/docs/superpowers/plans/2026-03-13-isosurface.md b/docs/superpowers/plans/2026-03-13-isosurface.md index a16367332..e7082426b 100644 --- a/docs/superpowers/plans/2026-03-13-isosurface.md +++ b/docs/superpowers/plans/2026-03-13-isosurface.md @@ -272,7 +272,7 @@ Add to `geometries` dict (after the `"PropertyInspector": PropertyInspector,` li Add `"Isosurface"` to the `__all__` list (alphabetically, after `"InteractionSettings"`): ```python - "Isosurface", +("Isosurface",) ``` - [ ] **Step 5: Run tests to verify they pass** @@ -824,9 +824,7 @@ async def test_isosurface_pyscf_h2( "cell": cc.box * bohr_to_ang, } frame = { - b"info.orbital_homo": msgpack.packb( - cube_data, default=msgpack_numpy.encode - ), + b"info.orbital_homo": msgpack.packb(cube_data, default=msgpack_numpy.encode), } user, token = await create_test_user_in_db(iso_session) @@ -979,9 +977,7 @@ async def get_isosurface( _REQUIRED_KEYS = {"grid", "origin", "cell"} if not isinstance(cube_dict, dict): - raise UnprocessableContent.exception( - f"Key '{cube_key}' is not a dict" - ) + raise UnprocessableContent.exception(f"Key '{cube_key}' is not a dict") missing = _REQUIRED_KEYS - cube_dict.keys() if missing: raise UnprocessableContent.exception( @@ -990,9 +986,7 @@ async def get_isosurface( grid = np.asarray(cube_dict["grid"]) if grid.ndim != 3: - raise UnprocessableContent.exception( - f"Grid must be 3D, got {grid.ndim}D" - ) + raise UnprocessableContent.exception(f"Grid must be 3D, got {grid.ndim}D") origin = np.asarray(cube_dict["origin"], dtype=np.float64) cell = np.asarray(cube_dict["cell"], dtype=np.float64) diff --git a/docs/superpowers/plans/2026-03-18-storage-refactor.md b/docs/superpowers/plans/2026-03-18-storage-refactor.md index 71a8f2c47..862cb70d8 100644 --- a/docs/superpowers/plans/2026-03-18-storage-refactor.md +++ b/docs/superpowers/plans/2026-03-18-storage-refactor.md @@ -189,9 +189,12 @@ class FrameStorage: async def has_mount(self, room_id: str) -> bool: """Check if a room has a provider-backed frame count.""" - return await self._redis.exists( # type: ignore[misc] - RedisKey.provider_frame_count(room_id) - ) > 0 + return ( + await self._redis.exists( # type: ignore[misc] + RedisKey.provider_frame_count(room_id) + ) + > 0 + ) async def set_frame_count(self, room_id: str, count: int) -> None: """Store provider frame count in Redis.""" @@ -266,6 +269,7 @@ The new dependency section (replacing lines 32-34 and 56-72): from zndraw.storage import FrameStorage # ... (remove AsebytesStorage and StorageRouter imports) + def get_frame_storage(request: Request) -> FrameStorage: """Get frame storage registry from app.state.""" return request.app.state.frame_storage @@ -312,6 +316,7 @@ Find the section (around lines 239-248) that creates `AsebytesStorage` and `Stor ```python # Before: from zndraw.storage.router import StorageRouter + default_storage = AsebytesStorage(uri=settings.storage) app.state.frame_storage = StorageRouter( default=default_storage, @@ -320,6 +325,7 @@ app.state.frame_storage = StorageRouter( # After: from zndraw.storage import FrameStorage + app.state.frame_storage = FrameStorage( uri=settings.storage, redis=app.state.redis, @@ -355,9 +361,11 @@ Change the type annotation and all method bodies: # TYPE_CHECKING import changes: # Before: from zndraw.storage import AsebytesStorage + # After: from zndraw.storage import FrameStorage + # Class changes: class StorageResultBackend: """Adapt ``FrameStorage`` to the ``ResultBackend`` protocol. @@ -545,7 +553,7 @@ Replace `storage: StorageDep` with `storage: FrameStorageDep`. Add `_: RequireWr await storage.delete_range(room_id, index, index + 1) # After: -await storage[room_id][index:index + 1].delete() +await storage[room_id][index : index + 1].delete() ``` - [ ] **Step 9: Commit** @@ -586,9 +594,7 @@ try: except (IndexError, KeyError): cube_raw = None if cube_raw is None: - raise UnprocessableContent.exception( - f"Key '{cube_key}' not found in frame {index}" - ) + raise UnprocessableContent.exception(f"Key '{cube_key}' not found in frame {index}") cube_dict = msgpack.unpackb(cube_raw, ...) ``` @@ -889,6 +895,7 @@ app.dependency_overrides[get_frame_storage] = lambda: storage For tests without Redis fixture, create a minimal mock: ```python from unittest.mock import AsyncMock + mock_redis = AsyncMock() mock_redis.get = AsyncMock(return_value=None) mock_redis.exists = AsyncMock(return_value=0) diff --git a/docs/superpowers/plans/2026-03-19-cli-auth-consistency.md b/docs/superpowers/plans/2026-03-19-cli-auth-consistency.md index 1369becd4..08a702b85 100644 --- a/docs/superpowers/plans/2026-03-19-cli-auth-consistency.md +++ b/docs/superpowers/plans/2026-03-19-cli-auth-consistency.md @@ -444,9 +444,7 @@ Add at `src/zndraw/cli_agent/connection.py:39` after `RoomOpt`: ```python UserOpt = Annotated[ str | None, - typer.Option( - "--user", envvar="ZNDRAW_USER", help="User email for authentication" - ), + typer.Option("--user", envvar="ZNDRAW_USER", help="User email for authentication"), ] PasswordOpt = Annotated[ str | None, diff --git a/docs/superpowers/plans/2026-03-20-global-extension-registration.md b/docs/superpowers/plans/2026-03-20-global-extension-registration.md index b81563ab3..2d7764e78 100644 --- a/docs/superpowers/plans/2026-03-20-global-extension-registration.md +++ b/docs/superpowers/plans/2026-03-20-global-extension-registration.md @@ -94,45 +94,43 @@ Expected: FAIL — `register_job` passes `"@global"` to `_resolve_room` which tr In `src/zndraw/client/core.py`, replace the current `register_job` method (lines 592-603) with: ```python - def register_job( - self, - cls: type, - *, - room: Literal["@global"] | str | None = None, - public: Annotated[ - bool | None, - typing_extensions.deprecated( - "Use room='@global' instead of public=True" - ), - ] = None, - ) -> None: - """Register an extension as a job. Connects the socket if needed. - - Parameters - ---------- - cls - Extension subclass to register. - room - Room scope. Use ``"@global"`` for global registration (admin-only). - Defaults to ``self.room``. - public - .. deprecated:: - Use ``room='@global'`` instead. - """ - if public and room is not None: - raise ValueError("Cannot specify both 'room' and 'public'") - if public: - warnings.warn( - "public=True is deprecated, use room='@global' instead", - DeprecationWarning, - stacklevel=2, - ) - room = GLOBAL_ROOM - elif room != GLOBAL_ROOM: - room = self._resolve_room(room) - - self._ensure_socket_connected() - self.jobs.register(cls, room=room) +def register_job( + self, + cls: type, + *, + room: Literal["@global"] | str | None = None, + public: Annotated[ + bool | None, + typing_extensions.deprecated("Use room='@global' instead of public=True"), + ] = None, +) -> None: + """Register an extension as a job. Connects the socket if needed. + + Parameters + ---------- + cls + Extension subclass to register. + room + Room scope. Use ``"@global"`` for global registration (admin-only). + Defaults to ``self.room``. + public + .. deprecated:: + Use ``room='@global'`` instead. + """ + if public and room is not None: + raise ValueError("Cannot specify both 'room' and 'public'") + if public: + warnings.warn( + "public=True is deprecated, use room='@global' instead", + DeprecationWarning, + stacklevel=2, + ) + room = GLOBAL_ROOM + elif room != GLOBAL_ROOM: + room = self._resolve_room(room) + + self._ensure_socket_connected() + self.jobs.register(cls, room=room) ``` Add necessary imports at the top of the file. `warnings` is already imported. Add: @@ -201,20 +199,18 @@ Expected: FAIL — `AttributeError: 'ZnDraw' object has no attribute 'register_e In `src/zndraw/client/core.py`, add after the `register_job` method: ```python - @typing_extensions.deprecated( - "Use register_job(cls, room='@global') for global, " - "or register_job(cls) for room-scoped" - ) - def register_extension( - self, cls: type, *, public: bool = False, **kwargs: Any - ) -> None: - """Register an extension. - - .. deprecated:: - Use :meth:`register_job` instead. - """ - room = "@global" if public else kwargs.get("room") - self.register_job(cls, room=room) +@typing_extensions.deprecated( + "Use register_job(cls, room='@global') for global, " + "or register_job(cls) for room-scoped" +) +def register_extension(self, cls: type, *, public: bool = False, **kwargs: Any) -> None: + """Register an extension. + + .. deprecated:: + Use :meth:`register_job` instead. + """ + room = "@global" if public else kwargs.get("room") + self.register_job(cls, room=room) ``` - [ ] **Step 4: Run test to verify it passes** diff --git a/docs/superpowers/plans/2026-03-25-pydantic-settings-phase1.md b/docs/superpowers/plans/2026-03-25-pydantic-settings-phase1.md index fd53fedb0..5b1bb7204 100644 --- a/docs/superpowers/plans/2026-03-25-pydantic-settings-phase1.md +++ b/docs/superpowers/plans/2026-03-25-pydantic-settings-phase1.md @@ -246,14 +246,17 @@ Read `tests/test_cli.py` focusing on tests that assert env var writes: ```python # Before: -port: Annotated[int | None, typer.Option("--port", ..., envvar="ZNDRAW_PORT")] = None, -host: Annotated[str, typer.Option(help="...", envvar="ZNDRAW_HOST")] = "127.0.0.1", +port: Annotated[int | None, typer.Option("--port", ..., envvar="ZNDRAW_PORT")] = (None,) +host: Annotated[str, typer.Option(help="...", envvar="ZNDRAW_HOST")] = ("127.0.0.1",) # After: -port: Annotated[int | None, typer.Option("--port", - help="Server port [env: ZNDRAW_SERVER_PORT].")] = None, -host: Annotated[str | None, typer.Option( - help="Server hostname or IP address [env: ZNDRAW_SERVER_HOST].")] = None, +port: Annotated[ + int | None, typer.Option("--port", help="Server port [env: ZNDRAW_SERVER_PORT].") +] = (None,) +host: Annotated[ + str | None, + typer.Option(help="Server hostname or IP address [env: ZNDRAW_SERVER_HOST]."), +] = (None,) ``` **Behavioral change:** `host` default changes from `"127.0.0.1"` to `None`. When `None`, `Settings.host` default `"0.0.0.0"` takes over. This is intentional — Settings is the single source of truth. @@ -277,9 +280,9 @@ settings = Settings(**overrides) ```python # Before: -help="Database URL (overrides ZNDRAW_DATABASE_URL)" +help = "Database URL (overrides ZNDRAW_DATABASE_URL)" # After: -help="Database URL [env: ZNDRAW_SERVER_DATABASE_URL]." +help = "Database URL [env: ZNDRAW_SERVER_DATABASE_URL]." ``` - [ ] **Step 6: Rewrite `test_cli.py` tests that assert `os.environ` writes** diff --git a/docs/superpowers/plans/2026-03-25-pydantic-settings-phase2.md b/docs/superpowers/plans/2026-03-25-pydantic-settings-phase2.md index 46063b717..b8ab8e68c 100644 --- a/docs/superpowers/plans/2026-03-25-pydantic-settings-phase2.md +++ b/docs/superpowers/plans/2026-03-25-pydantic-settings-phase2.md @@ -53,6 +53,7 @@ ```python # tests/test_state_file.py """Tests for unified StateFile (replaces PID files + tokens.json).""" + from __future__ import annotations import json @@ -290,6 +291,7 @@ tokens.json with a single ~/.zndraw/state.json (mode 0600). All writes use tempfile + os.rename for atomicity. """ + from __future__ import annotations import contextlib @@ -679,6 +681,7 @@ git commit -m "feat: add StateFile migration from old PID files and tokens.json" ```python # tests/test_state_file_source.py """Tests for StateFileSource (pydantic-settings custom source).""" + from __future__ import annotations from datetime import UTC, datetime @@ -772,12 +775,18 @@ def test_url_skips_unresponsive_local(state_file): def test_url_prefers_localhost_over_remote(state_file): """Localhost servers are preferred over remote servers.""" - state_file.add_server("https://remote.example.com", _remote_entry( - last_used=datetime(2026, 3, 25, 15, 0, tzinfo=UTC), # more recent - )) - state_file.add_server("http://localhost:8000", _local_entry( - last_used=datetime(2026, 3, 25, 10, 0, tzinfo=UTC), # less recent - )) + state_file.add_server( + "https://remote.example.com", + _remote_entry( + last_used=datetime(2026, 3, 25, 15, 0, tzinfo=UTC), # more recent + ), + ) + state_file.add_server( + "http://localhost:8000", + _local_entry( + last_used=datetime(2026, 3, 25, 10, 0, tzinfo=UTC), # less recent + ), + ) source = _make_source(state_file) with ( @@ -806,12 +815,20 @@ def test_url_falls_back_to_remote(state_file): def test_url_most_recent_local_wins(state_file): """Among multiple local servers, most recently used wins.""" - state_file.add_server("http://localhost:8000", _local_entry( - pid=100, last_used=datetime(2026, 3, 25, 10, 0, tzinfo=UTC), - )) - state_file.add_server("http://localhost:9000", _local_entry( - pid=200, last_used=datetime(2026, 3, 25, 14, 0, tzinfo=UTC), - )) + state_file.add_server( + "http://localhost:8000", + _local_entry( + pid=100, + last_used=datetime(2026, 3, 25, 10, 0, tzinfo=UTC), + ), + ) + state_file.add_server( + "http://localhost:9000", + _local_entry( + pid=200, + last_used=datetime(2026, 3, 25, 14, 0, tzinfo=UTC), + ), + ) source = _make_source(state_file) with ( @@ -939,6 +956,7 @@ Expected: FAIL — `ModuleNotFoundError: No module named 'zndraw.settings_source Resolves URL via health-check-based server discovery and token via local_token (localhost) or stored access_token (remote). """ + from __future__ import annotations import logging @@ -1108,6 +1126,7 @@ class StateFileSource(PydanticBaseSettingsSource): Sources (highest to lowest priority): init args > env vars (ZNDRAW_*) > pyproject.toml [tool.zndraw] > StateFileSource """ + from __future__ import annotations from pydantic import SecretStr @@ -1192,6 +1211,7 @@ git commit -m "feat: add StateFileSource for URL discovery and token resolution" ```python # tests/test_client_settings.py """Tests for ClientSettings source chain: init > env > pyproject.toml > state file.""" + from __future__ import annotations from unittest.mock import patch @@ -1202,7 +1222,13 @@ import pytest @pytest.fixture(autouse=True) def _clean_env(monkeypatch): """Remove ZNDRAW_* client env vars to isolate tests.""" - for key in ("ZNDRAW_URL", "ZNDRAW_ROOM", "ZNDRAW_USER", "ZNDRAW_PASSWORD", "ZNDRAW_TOKEN"): + for key in ( + "ZNDRAW_URL", + "ZNDRAW_ROOM", + "ZNDRAW_USER", + "ZNDRAW_PASSWORD", + "ZNDRAW_TOKEN", + ): monkeypatch.delenv(key, raising=False) @@ -1337,6 +1363,7 @@ git commit -m "test: add ClientSettings source chain tests" ```python # tests/test_local_token_auth.py """Tests for local admin token authentication.""" + from __future__ import annotations import pytest @@ -1550,9 +1577,13 @@ for url, entry in data.servers.items(): state.remove_server(url) continue if _is_url_healthy(url): - typer.echo(f"Found existing server (PID: {entry.pid}, URL: {url}, Version: {entry.version})") + typer.echo( + f"Found existing server (PID: {entry.pid}, URL: {url}, Version: {entry.version})" + ) if entry.version and entry.version != __version__: - typer.echo(f"Warning: Server version ({entry.version}) differs from CLI version ({__version__})") + typer.echo( + f"Warning: Server version ({entry.version}) differs from CLI version ({__version__})" + ) return url, None, url # reuse existing — return URL for cleanup ``` @@ -1701,6 +1732,7 @@ Provides credential validation and auth helper functions. Token resolution chain (stored token, local_token) is handled by the pydantic-settings source chain (StateFileSource). """ + from __future__ import annotations import httpx @@ -1785,85 +1817,83 @@ def guest_login(base_url: str) -> str: In `src/zndraw/client/core.py`, replace `__post_init__` (lines 129-190): ```python - def __post_init__(self) -> None: - """Initialize the client (REST-only, socket connects lazily).""" - import atexit +def __post_init__(self) -> None: + """Initialize the client (REST-only, socket connects lazily).""" + import atexit - from zndraw.auth_utils import guest_login, login_with_credentials - from zndraw.client.settings import ClientSettings + from zndraw.auth_utils import guest_login, login_with_credentials + from zndraw.client.settings import ClientSettings - # Normalize password to SecretStr - if isinstance(self.password, str): - self.password = SecretStr(self.password) + # Normalize password to SecretStr + if isinstance(self.password, str): + self.password = SecretStr(self.password) - # Resolve via pydantic-settings chain - overrides = { - k: v - for k, v in { - "url": self.url, - "room": self.room, - "user": self.user, - "password": self.password, - "token": self.token, - }.items() - if v is not None - } - resolved = ClientSettings(**overrides) + # Resolve via pydantic-settings chain + overrides = { + k: v + for k, v in { + "url": self.url, + "room": self.room, + "user": self.user, + "password": self.password, + "token": self.token, + }.items() + if v is not None + } + resolved = ClientSettings(**overrides) - # url is required — if still None after full chain, error out - if resolved.url is None: - raise ConnectionError( - "No ZnDraw server found. Pass url=, set ZNDRAW_URL, " - "add [tool.zndraw] url to pyproject.toml, or start a local server." - ) - self.url = resolved.url - self.room = resolved.room or str(uuid.uuid4()) - - # Token resolution: settings chain > user/password login > guest - if resolved.token is not None: - self.token = resolved.token - elif resolved.user and resolved.password: - self.token = login_with_credentials( - self.url, resolved.user, resolved.password - ) - else: - self.token = guest_login(self.url) + # url is required — if still None after full chain, error out + if resolved.url is None: + raise ConnectionError( + "No ZnDraw server found. Pass url=, set ZNDRAW_URL, " + "add [tool.zndraw] url to pyproject.toml, or start a local server." + ) + self.url = resolved.url + self.room = resolved.room or str(uuid.uuid4()) + + # Token resolution: settings chain > user/password login > guest + if resolved.token is not None: + self.token = resolved.token + elif resolved.user and resolved.password: + self.token = login_with_credentials(self.url, resolved.user, resolved.password) + else: + self.token = guest_login(self.url) - # Create API manager - self.api = APIManager(url=self.url, room_id=self.room, token=self.token) + # Create API manager + self.api = APIManager(url=self.url, room_id=self.room, token=self.token) - # Populate self.user for guest/stored-token sessions - if self.user is None and self.token is not None: - resp = self.api.http.get( - "/v1/auth/users/me", - headers={"Authorization": f"Bearer {self.token}"}, - ) - if resp.status_code == 200: - self.user = resp.json().get("email") - - # Create socket manager (no connection yet — connects lazily) - self.socket = SocketManager(zndraw=self) - - # Create job manager (zero-cost until first register()) - self._jobs = JobManager( - api=self.api, - tsio=self.socket.tsio, - execute=self._execute_task if self.auto_pickup else None, - heartbeat_interval=self.heartbeat_interval, - polling_interval=self.polling_interval, + # Populate self.user for guest/stored-token sessions + if self.user is None and self.token is not None: + resp = self.api.http.get( + "/v1/auth/users/me", + headers={"Authorization": f"Bearer {self.token}"}, ) + if resp.status_code == 200: + self.user = resp.json().get("email") + + # Create socket manager (no connection yet — connects lazily) + self.socket = SocketManager(zndraw=self) + + # Create job manager (zero-cost until first register()) + self._jobs = JobManager( + api=self.api, + tsio=self.socket.tsio, + execute=self._execute_task if self.auto_pickup else None, + heartbeat_interval=self.heartbeat_interval, + polling_interval=self.polling_interval, + ) - # Verify/create room via REST and seed frame count cache - try: - info = self.api.get_room_info() - self.cached_length = info.get("frame_count", 0) - except KeyError: - if not self.create_if_missing: - raise - self.api.create_room(copy_from=self.copy_from) - self.cached_length = 0 - - atexit.register(self.disconnect) + # Verify/create room via REST and seed frame count cache + try: + info = self.api.get_room_info() + self.cached_length = info.get("frame_count", 0) + except KeyError: + if not self.create_if_missing: + raise + self.api.create_room(copy_from=self.copy_from) + self.cached_length = 0 + + atexit.register(self.disconnect) ``` Also **delete** the `_resolve_url` static method (lines ~493-517). @@ -1873,69 +1903,70 @@ Also **delete** the `_resolve_url` static method (lines ~493-517). These class methods (lines ~519-574) also use `_resolve_url` and the old `resolve_token`. Update both: ```python - @classmethod - def list_rooms( - cls, - url: str | None = None, - *, - token: str | None = None, - search: str | None = None, - ) -> list[dict[str, Any]]: - """List all rooms on the server. - - Parameters - ---------- - url - Server URL. If None, auto-discovers via state file. - token - JWT token. If None, uses stored token or creates guest session. - search - Optional search filter. - """ - from zndraw.auth_utils import guest_login - from zndraw.client.settings import ClientSettings +@classmethod +def list_rooms( + cls, + url: str | None = None, + *, + token: str | None = None, + search: str | None = None, +) -> list[dict[str, Any]]: + """List all rooms on the server. - overrides = {k: v for k, v in {"url": url, "token": token}.items() if v is not None} - resolved = ClientSettings(**overrides) - if resolved.url is None: - raise ConnectionError( - "No ZnDraw server found. Pass url= or start a local server." - ) - resolved_token = resolved.token or guest_login(resolved.url) - api = APIManager(url=resolved.url, room_id="", token=resolved_token) - try: - return api.list_rooms(search=search) - finally: - api.close() + Parameters + ---------- + url + Server URL. If None, auto-discovers via state file. + token + JWT token. If None, uses stored token or creates guest session. + search + Optional search filter. + """ + from zndraw.auth_utils import guest_login + from zndraw.client.settings import ClientSettings - @classmethod - def login( - cls, - url: str | None = None, - username: str = "", - password: str = "", - ) -> str: - """Authenticate and return a JWT token. - - Parameters - ---------- - url - Server URL. If None, auto-discovers via state file. - username - User email. - password - User password. - """ - from zndraw.auth_utils import login_with_credentials - from zndraw.client.settings import ClientSettings + overrides = {k: v for k, v in {"url": url, "token": token}.items() if v is not None} + resolved = ClientSettings(**overrides) + if resolved.url is None: + raise ConnectionError( + "No ZnDraw server found. Pass url= or start a local server." + ) + resolved_token = resolved.token or guest_login(resolved.url) + api = APIManager(url=resolved.url, room_id="", token=resolved_token) + try: + return api.list_rooms(search=search) + finally: + api.close() - overrides = {k: v for k, v in {"url": url}.items() if v is not None} - resolved = ClientSettings(**overrides) - if resolved.url is None: - raise ConnectionError( - "No ZnDraw server found. Pass url= or start a local server." - ) - return login_with_credentials(resolved.url, username, password) + +@classmethod +def login( + cls, + url: str | None = None, + username: str = "", + password: str = "", +) -> str: + """Authenticate and return a JWT token. + + Parameters + ---------- + url + Server URL. If None, auto-discovers via state file. + username + User email. + password + User password. + """ + from zndraw.auth_utils import login_with_credentials + from zndraw.client.settings import ClientSettings + + overrides = {k: v for k, v in {"url": url}.items() if v is not None} + resolved = ClientSettings(**overrides) + if resolved.url is None: + raise ConnectionError( + "No ZnDraw server found. Pass url= or start a local server." + ) + return login_with_credentials(resolved.url, username, password) ``` - [ ] **Step 3: Update tests** @@ -1945,6 +1976,7 @@ Update `tests/test_resolve_token.py` to test the simplified auth_utils: ```python # tests/test_resolve_token.py """Tests for auth_utils credential validation and login helpers.""" + from __future__ import annotations from unittest.mock import MagicMock, patch @@ -1993,7 +2025,9 @@ def test_login_with_credentials_success(): mock_client.post.return_value = mock_resp with patch("zndraw.auth_utils.httpx.Client", return_value=mock_client): - result = login_with_credentials("http://localhost:8000", "user@test.com", "pass") + result = login_with_credentials( + "http://localhost:8000", "user@test.com", "pass" + ) assert result == "login.jwt" @@ -2065,11 +2099,21 @@ Remove `envvar=` from all Typer option type aliases: # Before: UrlOpt = Annotated[str | None, typer.Option("--url", envvar="ZNDRAW_URL", help="...")] # After: -UrlOpt = Annotated[str | None, typer.Option("--url", help="ZnDraw server URL [env: ZNDRAW_URL].")] -TokenOpt = Annotated[str | None, typer.Option("--token", help="Auth token [env: ZNDRAW_TOKEN].")] -RoomOpt = Annotated[str | None, typer.Option("--room", help="Room ID [env: ZNDRAW_ROOM].")] -UserOpt = Annotated[str | None, typer.Option("--user", help="User email [env: ZNDRAW_USER].")] -PasswordOpt = Annotated[str | None, typer.Option("--password", help="Password [env: ZNDRAW_PASSWORD].")] +UrlOpt = Annotated[ + str | None, typer.Option("--url", help="ZnDraw server URL [env: ZNDRAW_URL].") +] +TokenOpt = Annotated[ + str | None, typer.Option("--token", help="Auth token [env: ZNDRAW_TOKEN].") +] +RoomOpt = Annotated[ + str | None, typer.Option("--room", help="Room ID [env: ZNDRAW_ROOM].") +] +UserOpt = Annotated[ + str | None, typer.Option("--user", help="User email [env: ZNDRAW_USER].") +] +PasswordOpt = Annotated[ + str | None, typer.Option("--password", help="Password [env: ZNDRAW_PASSWORD].") +] ``` - [ ] **Step 2: Replace resolve_url and resolve_token with ClientSettings** @@ -2089,7 +2133,12 @@ def get_connection( overrides = { k: v - for k, v in {"url": url, "token": token, "user": user, "password": password}.items() + for k, v in { + "url": url, + "token": token, + "user": user, + "password": password, + }.items() if v is not None } @@ -2136,8 +2185,11 @@ def get_zndraw( from zndraw import ZnDraw return ZnDraw( - url=url, room=room, token=token, - user=user, password=password, + url=url, + room=room, + token=token, + user=user, + password=password, create_if_missing=False, ) ``` @@ -2175,7 +2227,9 @@ Update `login()`: @auth_app.command("login") def login( url: UrlOpt = None, - code: bool = typer.Option(False, "--code", help="Print URL only, don't open browser"), + code: bool = typer.Option( + False, "--code", help="Print URL only, don't open browser" + ), ) -> None: """Login via browser approval (device-code flow).""" with cli_error_handler(): @@ -2288,7 +2342,12 @@ def status( overrides = { k: v - for k, v in {"url": url, "token": token, "user": user, "password": password}.items() + for k, v in { + "url": url, + "token": token, + "user": user, + "password": password, + }.items() if v is not None } settings = ClientSettings(**overrides) @@ -2437,6 +2496,7 @@ The retained file should look like: Retained utilities: process checking and server readiness polling. Server registry and token storage moved to zndraw.state_file. """ + from __future__ import annotations import logging diff --git a/docs/superpowers/plans/2026-04-01-worker-auth-jwt.md b/docs/superpowers/plans/2026-04-01-worker-auth-jwt.md index 848b802ca..43ad17f90 100644 --- a/docs/superpowers/plans/2026-04-01-worker-auth-jwt.md +++ b/docs/superpowers/plans/2026-04-01-worker-auth-jwt.md @@ -72,6 +72,7 @@ In `src/zndraw/database.py`, remove the `WORKER_EMAIL` module constant. Update ` ```python import uuid + async def ensure_internal_worker( session: AsyncSession, email: str, @@ -311,15 +312,13 @@ class InternalExecutor(Protocol): In `src/zndraw_joblib/registry.py`, update the closure (lines 63-72): ```python - def _make_task_fn( - cls: type[Extension] = ext_cls, ex: InternalExecutor = executor - ): - async def _execute( - task_id: str, room_id: str, payload: dict[str, Any], token: str - ) -> None: - await ex(cls, payload, room_id, task_id, token) +def _make_task_fn(cls: type[Extension] = ext_cls, ex: InternalExecutor = executor): + async def _execute( + task_id: str, room_id: str, payload: dict[str, Any], token: str + ) -> None: + await ex(cls, payload, room_id, task_id, token) - return _execute + return _execute ``` - [ ] **Step 4: Verify import works** @@ -430,21 +429,21 @@ from zndraw_joblib.dependencies import get_worker_token Then in the lifespan, after `await broker.startup()` (around line 307): ```python - # Wire WorkerTokenDep — mints JWTs for the internal worker user - from fastapi_users.authentication import JWTStrategy +# Wire WorkerTokenDep — mints JWTs for the internal worker user +from fastapi_users.authentication import JWTStrategy + + +async def _mint_worker_token() -> str: + async with app.state.session_maker() as session: + worker = await lookup_worker_user(session, settings.internal_worker_email) + strategy = JWTStrategy( + secret=auth_settings.secret_key.get_secret_value(), + lifetime_seconds=auth_settings.token_lifetime_seconds, + ) + return await strategy.write_token(worker) - async def _mint_worker_token() -> str: - async with app.state.session_maker() as session: - worker = await lookup_worker_user( - session, settings.internal_worker_email - ) - strategy = JWTStrategy( - secret=auth_settings.secret_key.get_secret_value(), - lifetime_seconds=auth_settings.token_lifetime_seconds, - ) - return await strategy.write_token(worker) - app.dependency_overrides[get_worker_token] = _mint_worker_token +app.dependency_overrides[get_worker_token] = _mint_worker_token ``` - [ ] **Step 4: Verify imports** @@ -701,9 +700,7 @@ Add to `tests/zndraw/test_worker_auth.py`: ```python @pytest.mark.anyio -async def test_worker_token_dep_mints_valid_jwt( - client: AsyncClient, settings -) -> None: +async def test_worker_token_dep_mints_valid_jwt(client: AsyncClient, settings) -> None: """The WorkerTokenDep should mint a JWT for the internal worker user.""" from zndraw_joblib.dependencies import get_worker_token diff --git a/docs/superpowers/plans/2026-04-17-internal-providers.md b/docs/superpowers/plans/2026-04-17-internal-providers.md index 3fa044085..3aa8e2361 100644 --- a/docs/superpowers/plans/2026-04-17-internal-providers.md +++ b/docs/superpowers/plans/2026-04-17-internal-providers.md @@ -650,9 +650,7 @@ def test_list_providers_includes_internal(client, async_session_factory): resp = client.get("/v1/joblib/rooms/room-42/providers") assert resp.status_code == 200 items = resp.json()["items"] - assert any( - p["full_name"] == "@internal:filesystem:FilesystemRead" for p in items - ) + assert any(p["full_name"] == "@internal:filesystem:FilesystemRead" for p in items) # @global also sees only its own scope (not @internal) resp = client.get("/v1/joblib/rooms/@global/providers") @@ -699,8 +697,7 @@ def test_get_provider_info_internal_visible_from_room(client, async_session_fact asyncio.run(seed()) resp = client.get( - "/v1/joblib/rooms/room-42/providers/" - "@internal:filesystem:FilesystemRead/info" + "/v1/joblib/rooms/room-42/providers/@internal:filesystem:FilesystemRead/info" ) assert resp.status_code == 200 assert resp.json()["schema"] == {"path": {"type": "string"}} @@ -843,10 +840,8 @@ Expected: FAIL — status is 204 (superuser can currently delete anything). In `/Users/fzills/tools/zndraw-fastapi/src/zndraw_joblib/router.py`, modify `delete_provider` (around line 1218). After the `ProviderNotFound` check and before the user-ownership check, add: ```python - if provider.room_id == "@internal": - raise Forbidden.exception( - detail="@internal providers cannot be deleted" - ) +if provider.room_id == "@internal": + raise Forbidden.exception(detail="@internal providers cannot be deleted") ``` - [ ] **Step 5.4: Run the test — must pass.** @@ -1446,9 +1441,7 @@ def test_default_internal_filesystem_listed(server): try: providers = _list_providers(vis) internal = [ - p - for p in providers - if p.full_name == "@internal:filesystem:FilesystemRead" + p for p in providers if p.full_name == "@internal:filesystem:FilesystemRead" ] assert len(internal) == 1 finally: diff --git a/docs/superpowers/plans/2026-04-20-pr920-review-fixes.md b/docs/superpowers/plans/2026-04-20-pr920-review-fixes.md index 73b464569..c683cfb06 100644 --- a/docs/superpowers/plans/2026-04-20-pr920-review-fixes.md +++ b/docs/superpowers/plans/2026-04-20-pr920-review-fixes.md @@ -194,9 +194,7 @@ def test_global_scope_cannot_resolve_room_provider(client_factory): assert resp.status_code == 201 # admin, calling with @global scope, must not resolve a room-42 provider. - resp = admin.get( - "/v1/joblib/rooms/@global/providers/room-42:filesystem:local" - ) + resp = admin.get("/v1/joblib/rooms/@global/providers/room-42:filesystem:local") assert resp.status_code == 404, resp.text @@ -291,11 +289,7 @@ async def _resolve_provider( provider_room_id, category, name = parts # Visibility check — mirror _room_provider_filter. - allowed = ( - {"@global"} - if room_id == "@global" - else {"@global", "@internal", room_id} - ) + allowed = {"@global"} if room_id == "@global" else {"@global", "@internal", room_id} if provider_room_id not in allowed: raise ProviderNotFound.exception( detail=f"Provider '{provider_name}' not accessible from room '{room_id}'" @@ -533,8 +527,10 @@ You will likely need to add a `monkeypatch_app_state` fixture to the relevant `c @pytest.fixture def monkeypatch_app_state(app, monkeypatch): """Temporarily override a key on app.state.""" + def _set(key: str, value): monkeypatch.setattr(app.state, key, value, raising=False) + return _set ``` @@ -696,11 +692,10 @@ def test_legitimate_json_with_error_type_keys_is_not_mis_flagged( async def _seed() -> None: await result_backend.store(cache_key, payload, 60) + asyncio.run(_seed()) - resp = alice.get( - f"/v1/joblib/rooms/room-42/providers/{provider_full_name}?path=/" - ) + resp = alice.get(f"/v1/joblib/rooms/room-42/providers/{provider_full_name}?path=/") assert resp.status_code == 200, resp.text assert resp.json() == {"type": "object", "error": None, "ok": True} ``` @@ -738,6 +733,7 @@ def _run() -> None: provider_cls.__name__, ) from zndraw_joblib.exceptions import ProviderExecutionFailed + problem = ProviderExecutionFailed.create( detail=f"{type(err).__name__}: {err}", ) @@ -881,9 +877,7 @@ if result is not None: status_code=status_code, headers=headers, ) - return Response( - content=result, media_type=provider.content_type, headers=headers - ) + return Response(content=result, media_type=provider.content_type, headers=headers) ``` Remove the existing content-sniffing blocks entirely. @@ -892,9 +886,7 @@ Remove the existing content-sniffing blocks entirely. Add to `tests/zndraw_joblib/test_providers.py`: ```python -def test_provider_error_path_returns_problem_detail( - client_factory, result_backend -): +def test_provider_error_path_returns_problem_detail(client_factory, result_backend): """When an executor posts an error, read_provider returns RFC 9457 problem+json with the status from the payload (not a hard-coded 400).""" import asyncio @@ -917,6 +909,7 @@ def test_provider_error_path_returns_problem_detail( async def _seed() -> None: await result_backend.store(cache_key, payload, 60) await result_backend.store(f"{cache_key}:status", b"error", 60) + asyncio.run(_seed()) resp = alice.get( @@ -1041,17 +1034,13 @@ def test_internal_filesystem_requires_superuser_by_default( resp = alice.get("/v1/joblib/rooms/room-42/providers") assert resp.status_code == 200 items = resp.json()["items"] - assert not any( - p["full_name"].startswith("@internal:filesystem:") for p in items - ) + assert not any(p["full_name"].startswith("@internal:filesystem:") for p in items) # Info endpoint gate — add an assertion here only after Step 6 below # lands and you have confirmed the info endpoint's path via grep. -def test_internal_filesystem_superuser_can_read( - client_factory, async_session_factory -): +def test_internal_filesystem_superuser_can_read(client_factory, async_session_factory): """Superusers bypass the gate.""" import asyncio import uuid @@ -1088,9 +1077,7 @@ def test_internal_filesystem_superuser_can_read( admin = client_factory("admin-su", is_superuser=True) resp = admin.get("/v1/joblib/rooms/room-42/providers") items = resp.json()["items"] - assert any( - p["full_name"] == "@internal:filesystem:FilesystemRead" for p in items - ) + assert any(p["full_name"] == "@internal:filesystem:FilesystemRead" for p in items) ``` - [ ] **Step 3: Run the failing tests** @@ -1145,7 +1132,8 @@ In the `_list_providers` endpoint (grep for `@router.get("/rooms/{room_id}/provi ```python if settings.filebrowser_require_superuser and not _current_user.is_superuser: items = [ - p for p in items + p + for p in items if not (p.room_id == "@internal" and p.category == "filesystem") ] ``` @@ -1161,9 +1149,7 @@ Expected: both PASS. Add to `tests/zndraw_joblib/test_providers.py`: ```python -def test_internal_filesystem_gate_disabled( - client_factory, async_session_factory, app -): +def test_internal_filesystem_gate_disabled(client_factory, async_session_factory, app): """With filebrowser_require_superuser=False, non-superusers can access.""" import asyncio import uuid @@ -1202,9 +1188,7 @@ def test_internal_filesystem_gate_disabled( alice = client_factory("alice-gated-off", is_superuser=False) resp = alice.get("/v1/joblib/rooms/room-42/providers") items = resp.json()["items"] - assert any( - p["full_name"] == "@internal:filesystem:FilesystemRead" for p in items - ) + assert any(p["full_name"] == "@internal:filesystem:FilesystemRead" for p in items) # Reset for other tests app.state.settings.filebrowser_require_superuser = True @@ -1325,16 +1309,20 @@ def test_filebrowser_disabled_hides_default_provider(server_factory): Rename `test_filebrowser_path_none_removes_stale_rows` → `test_filebrowser_disabled_removes_stale_rows`. Update the env-var payload in each of its two boots: ```python # first boot — enabled -server_factory({ - "ZNDRAW_SERVER_FILEBROWSER_ENABLED": "true", - "ZNDRAW_SERVER_FILEBROWSER_PATH": ".", - "ZNDRAW_SERVER_DATABASE_URL": db_url, -}) +server_factory( + { + "ZNDRAW_SERVER_FILEBROWSER_ENABLED": "true", + "ZNDRAW_SERVER_FILEBROWSER_PATH": ".", + "ZNDRAW_SERVER_DATABASE_URL": db_url, + } +) # second boot — disabled -server_factory({ - "ZNDRAW_SERVER_FILEBROWSER_ENABLED": "false", - "ZNDRAW_SERVER_DATABASE_URL": db_url, -}) +server_factory( + { + "ZNDRAW_SERVER_FILEBROWSER_ENABLED": "false", + "ZNDRAW_SERVER_DATABASE_URL": db_url, + } +) ``` - [ ] **Step 5: Write a regression test — `guest_password="none"` stays literal** diff --git a/docs/superpowers/plans/2026-04-21-rooms-feed.md b/docs/superpowers/plans/2026-04-21-rooms-feed.md index d9a4f8cfc..6180b4b65 100644 --- a/docs/superpowers/plans/2026-04-21-rooms-feed.md +++ b/docs/superpowers/plans/2026-04-21-rooms-feed.md @@ -55,9 +55,7 @@ Append to `tests/zndraw/test_socketio_rooms.py`: ```python @pytest.mark.asyncio -async def test_rooms_feed_auto_join( - server: str, http_client: AsyncClient -) -> None: +async def test_rooms_feed_auto_join(server: str, http_client: AsyncClient) -> None: """Every authenticated socket must auto-join rooms:feed on connect, so room_update events from any public room are delivered without an explicit join. @@ -86,9 +84,9 @@ async def test_rooms_feed_auto_join( # Give the event loop a beat to deliver the broadcast. await asyncio.sleep(0.5) - assert any( - e.id == new_room_id for e in received - ), f"Client B received no room_update for {new_room_id}; got {received}" + assert any(e.id == new_room_id for e in received), ( + f"Client B received no room_update for {new_room_id}; got {received}" + ) await sio_b.disconnect() ``` @@ -203,9 +201,7 @@ async def test_broadcast_private_room_targets_each_member( room = Room(id="priv", is_public=False) session.add(room) - session.add( - RoomMembership(room_id="priv", user_id=member_a, role=MemberRole.OWNER) - ) + session.add(RoomMembership(room_id="priv", user_id=member_a, role=MemberRole.OWNER)) session.add( RoomMembership(room_id="priv", user_id=member_b, role=MemberRole.MEMBER) ) @@ -215,9 +211,7 @@ async def test_broadcast_private_room_targets_each_member( await broadcast_room_update(sio, session, frame_storage, room) rooms_targeted = sorted(call["room"] for call in sio.emitted) - assert rooms_targeted == sorted( - [f"user:{member_a}", f"user:{member_b}"] - ) + assert rooms_targeted == sorted([f"user:{member_a}", f"user:{member_b}"]) assert "rooms:feed" not in rooms_targeted @@ -270,9 +264,7 @@ async def broadcast_room_update( await sio.emit(event, room="rooms:feed") return result = await session.exec( - select(RoomMembership.user_id).where( - RoomMembership.room_id == room.id - ) + select(RoomMembership.user_id).where(RoomMembership.room_id == room.id) ) for uid in result.all(): await sio.emit(event, room=f"user:{uid}") @@ -535,12 +527,8 @@ async def test_same_room_frame_append_updates_sidebar( tsio_a = wrap(sio_a) tsio_b = wrap(sio_b) - await tsio_a.call( - RoomJoin(room_id=room_id), response_model=RoomJoinResponse - ) - await tsio_b.call( - RoomJoin(room_id=room_id), response_model=RoomJoinResponse - ) + await tsio_a.call(RoomJoin(room_id=room_id), response_model=RoomJoinResponse) + await tsio_b.call(RoomJoin(room_id=room_id), response_model=RoomJoinResponse) # Drain any room_update emissions that predate the frame append. await asyncio.sleep(0.3) diff --git a/docs/superpowers/plans/2026-05-26-room-id-wire-contract-followups.md b/docs/superpowers/plans/2026-05-26-room-id-wire-contract-followups.md index a592804b5..7b79d250b 100644 --- a/docs/superpowers/plans/2026-05-26-room-id-wire-contract-followups.md +++ b/docs/superpowers/plans/2026-05-26-room-id-wire-contract-followups.md @@ -150,9 +150,7 @@ async def test_frame_selection_update_carries_room_address( assert response.status_code == 200, response.text emits = [e for e in mock_sio.emitted if e["event"] == "frame_selection_update"] - assert len(emits) == 1, ( - f"expected 1 frame_selection_update, got {len(emits)}" - ) + assert len(emits) == 1, f"expected 1 frame_selection_update, got {len(emits)}" captured = emits[0] data = captured["data"] assert "room_id" in data, ( @@ -422,11 +420,9 @@ There are three callsites to convert. For each, replace the inline `Emission(Job In `register_job` (`router.py:421-431`), replace the block: ```python - await session.commit() - emission = await build_room_scoped_emission( - session, JobsInvalidate, room_id - ) - await emit(tsio, {emission}) +await session.commit() +emission = await build_room_scoped_emission(session, JobsInvalidate, room_id) +await emit(tsio, {emission}) ``` In `register_provider` (`router.py:1205-1217`): @@ -443,13 +439,11 @@ In `register_provider` (`router.py:1205-1217`): In `delete_provider` (`router.py:1432-1444`): ```python - room_id = provider.room_id - await session.delete(provider) - await session.commit() - emission = await build_room_scoped_emission( - session, ProvidersInvalidate, room_id - ) - await emit(tsio, {emission}) +room_id = provider.room_id +await session.delete(provider) +await session.commit() +emission = await build_room_scoped_emission(session, ProvidersInvalidate, room_id) +await emit(tsio, {emission}) ``` In `upload_provider_result` (`router.py:1505-1520`): @@ -908,39 +902,37 @@ The existing sweep drives 6 endpoints. Add the missing families. The sweep's fin In `tests/zndraw/test_broadcast_contract.py`, between the existing "joblib family" block and the `rs_event_names = ...` line, insert: ```python - # step family — FrameUpdate - r = await client.put(f"{base}/step", json={"step": 0}, headers=headers) - assert r.status_code == 200, r.text - - # edit lock family — LockUpdate - r = await client.put( - f"{base}/edit-lock", json={"action": "acquire"}, headers=headers - ) - assert r.status_code == 200, r.text - - # geometry family — GeometryInvalidate - r = await client.put( - f"{base}/geometries/g1", - json={"geometry": {"type": "Sphere", "data": {}}}, - headers=headers, - ) - assert r.status_code in (200, 201), r.text +# step family — FrameUpdate +r = await client.put(f"{base}/step", json={"step": 0}, headers=headers) +assert r.status_code == 200, r.text + +# edit lock family — LockUpdate +r = await client.put(f"{base}/edit-lock", json={"action": "acquire"}, headers=headers) +assert r.status_code == 200, r.text + +# geometry family — GeometryInvalidate +r = await client.put( + f"{base}/geometries/g1", + json={"geometry": {"type": "Sphere", "data": {}}}, + headers=headers, +) +assert r.status_code in (200, 201), r.text - # selection-groups family — SelectionGroupsInvalidate - r = await client.put( - f"{base}/selection-groups/sg1", - json={"selection": {}}, - headers=headers, - ) - assert r.status_code in (200, 201), r.text +# selection-groups family — SelectionGroupsInvalidate +r = await client.put( + f"{base}/selection-groups/sg1", + json={"selection": {}}, + headers=headers, +) +assert r.status_code in (200, 201), r.text - # frame-selection family — FrameSelectionUpdate (added in Task 2) - r = await client.put( - f"{base}/frame-selection", - json={"indices": [0]}, - headers=headers, - ) - assert r.status_code == 200, r.text +# frame-selection family — FrameSelectionUpdate (added in Task 2) +r = await client.put( + f"{base}/frame-selection", + json={"indices": [0]}, + headers=headers, +) +assert r.status_code == 200, r.text ``` Note: the exact endpoint shapes must match the routes. Verify by reading each route's request schema before running. diff --git a/docs/superpowers/specs/2026-03-13-isosurface-design.md b/docs/superpowers/specs/2026-03-13-isosurface-design.md index 0d8bf0d80..4369e32e2 100644 --- a/docs/superpowers/specs/2026-03-13-isosurface-design.md +++ b/docs/superpowers/specs/2026-03-13-isosurface-design.md @@ -53,10 +53,14 @@ Each `Isosurface` instance represents ONE surface at ONE isovalue. The isovalue ```python vis.geometries["homo+"] = Isosurface( - cube_key="info.orbital_homo", isovalue=0.02, color="#2244CC", + cube_key="info.orbital_homo", + isovalue=0.02, + color="#2244CC", ) vis.geometries["homo-"] = Isosurface( - cube_key="info.orbital_homo", isovalue=-0.02, color="#CC4422", + cube_key="info.orbital_homo", + isovalue=-0.02, + color="#CC4422", ) ``` @@ -346,9 +350,9 @@ cc = cubegen.Cube(mol) # Build atoms with volumetric data atoms = ase.Atoms(...) atoms.info["orbital_homo"] = { - "grid": orb_on_grid, # (nx, ny, nz) float + "grid": orb_on_grid, # (nx, ny, nz) float "origin": cc.boxorig * 0.529, # Bohr → Angstrom - "cell": cc.box * 0.529, # Bohr → Angstrom + "cell": cc.box * 0.529, # Bohr → Angstrom } # Visualize diff --git a/docs/superpowers/specs/2026-03-17-storage-refactor-design.md b/docs/superpowers/specs/2026-03-17-storage-refactor-design.md index 6caf30ad2..28ded6141 100644 --- a/docs/superpowers/specs/2026-03-17-storage-refactor-design.md +++ b/docs/superpowers/specs/2026-03-17-storage-refactor-design.md @@ -39,20 +39,20 @@ efficient backend operations. ```python # PREFERRED — subscript/view API (pandas-like) -frame = await io[index] # single row -frames = await io[start:stop].to_list() # range of rows -frames = await io[[0, 5, 9]].to_list() # sparse rows -value = await io[b"cube_key"][index] # single column value -values = await io[b"energy"].to_list() # full column -await io[index].set(data) # write row -await io[start:stop].delete() # delete range -await io[index].update(partial) # merge into row +frame = await io[index] # single row +frames = await io[start:stop].to_list() # range of rows +frames = await io[[0, 5, 9]].to_list() # sparse rows +value = await io[b"cube_key"][index] # single column value +values = await io[b"energy"].to_list() # full column +await io[index].set(data) # write row +await io[start:stop].delete() # delete range +await io[index].update(partial) # merge into row # ACCEPTABLE — .get() when you need a filtered dict back -frame = await io.get(index, keys=[...]) # partial row as dict +frame = await io.get(index, keys=[...]) # partial row as dict # FORBIDDEN — private backend access -rows = await io._backend.get_many(indices) # NEVER +rows = await io._backend.get_many(indices) # NEVER ``` **When to use `.get(index, keys=[...])`:** Only when the caller needs a @@ -182,28 +182,20 @@ class FrameStorage: length = await io.len() if length > 0: return length - cached = await self._redis.get( - RedisKey.provider_frame_count(room_id) - ) + cached = await self._redis.get(RedisKey.provider_frame_count(room_id)) return int(cached) if cached else 0 async def has_mount(self, room_id: str) -> bool: """Check if a room has a provider-backed frame count.""" - return await self._redis.exists( - RedisKey.provider_frame_count(room_id) - ) > 0 + return await self._redis.exists(RedisKey.provider_frame_count(room_id)) > 0 async def set_frame_count(self, room_id: str, count: int) -> None: """Store provider frame count in Redis.""" - await self._redis.set( - RedisKey.provider_frame_count(room_id), count - ) + await self._redis.set(RedisKey.provider_frame_count(room_id), count) async def clear_frame_count(self, room_id: str) -> None: """Remove provider frame count from Redis.""" - await self._redis.delete( - RedisKey.provider_frame_count(room_id) - ) + await self._redis.delete(RedisKey.provider_frame_count(room_id)) # -- Lifecycle ------------------------------------------------------------- @@ -236,6 +228,7 @@ async def require_writable_room( if await storage.has_mount(room_id): raise RoomReadOnly.exception("Room is provider-backed (read-only)") + RequireWritableDep = Annotated[None, Depends(require_writable_room)] ``` diff --git a/docs/superpowers/specs/2026-03-19-cli-auth-consistency-design.md b/docs/superpowers/specs/2026-03-19-cli-auth-consistency-design.md index 43715c43e..17588a8c0 100644 --- a/docs/superpowers/specs/2026-03-19-cli-auth-consistency-design.md +++ b/docs/superpowers/specs/2026-03-19-cli-auth-consistency-design.md @@ -55,7 +55,9 @@ UserOpt = Annotated[ ] PasswordOpt = Annotated[ str | None, - typer.Option("--password", envvar="ZNDRAW_PASSWORD", help="Password for authentication"), + typer.Option( + "--password", envvar="ZNDRAW_PASSWORD", help="Password for authentication" + ), ] ``` diff --git a/docs/superpowers/specs/2026-03-20-connection-dialog-snippet-design.md b/docs/superpowers/specs/2026-03-20-connection-dialog-snippet-design.md index a2b10b817..a7123aba3 100644 --- a/docs/superpowers/specs/2026-03-20-connection-dialog-snippet-design.md +++ b/docs/superpowers/specs/2026-03-20-connection-dialog-snippet-design.md @@ -23,8 +23,8 @@ Remove `user=` from the snippet entirely. Without explicit credentials, the Pyth from zndraw import ZnDraw vis = ZnDraw( - url="{window.location.origin}/", - room="{roomId}", + url="{window.location.origin}/", + room="{roomId}", ) ``` diff --git a/docs/superpowers/specs/2026-03-20-global-extension-registration-design.md b/docs/superpowers/specs/2026-03-20-global-extension-registration-design.md index 31d92476b..82c497a59 100644 --- a/docs/superpowers/specs/2026-03-20-global-extension-registration-design.md +++ b/docs/superpowers/specs/2026-03-20-global-extension-registration-design.md @@ -76,7 +76,9 @@ def register_job(self, cls, *, room=None, public=None): ### Deprecated `register_extension` method ```python -@deprecated("Use register_job(cls, room='@global') for global, or register_job(cls) for room-scoped") +@deprecated( + "Use register_job(cls, room='@global') for global, or register_job(cls) for room-scoped" +) def register_extension(self, cls: type, *, public: bool = False, **kwargs) -> None: room = GLOBAL_ROOM if public else kwargs.get("room") self.register_job(cls, room=room) diff --git a/docs/superpowers/specs/2026-03-25-pydantic-settings-unification-design.md b/docs/superpowers/specs/2026-03-25-pydantic-settings-unification-design.md index 6dc811b8b..513c9c538 100644 --- a/docs/superpowers/specs/2026-03-25-pydantic-settings-unification-design.md +++ b/docs/superpowers/specs/2026-03-25-pydantic-settings-unification-design.md @@ -102,6 +102,7 @@ Each class overrides `settings_customise_sources()`: from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings.main import PydanticBaseSettingsSource + class Settings(BaseSettings): model_config = SettingsConfigDict( env_prefix="ZNDRAW_SERVER_", @@ -124,6 +125,7 @@ class Settings(BaseSettings): env_settings, PyprojectTomlConfigSettingsSource(settings_cls), ) + # ... fields unchanged ... ``` @@ -458,12 +460,12 @@ Typer handles **only** CLI inputs and help text. Pydantic-settings is the single @app.command() def main( # CLI overrides — defaults are None, no envvar= - host: Annotated[str | None, typer.Option( - help="Server host [env: ZNDRAW_SERVER_HOST]." - )] = None, - port: Annotated[int | None, typer.Option( - help="Server port [env: ZNDRAW_SERVER_PORT]." - )] = None, + host: Annotated[ + str | None, typer.Option(help="Server host [env: ZNDRAW_SERVER_HOST].") + ] = None, + port: Annotated[ + int | None, typer.Option(help="Server port [env: ZNDRAW_SERVER_PORT].") + ] = None, # Extra CLI args NOT in settings detached: bool = typer.Option(False, help="Run in background"), ): @@ -490,15 +492,23 @@ def main( ```python # Before (cli_agent/connection.py) def get_connection(url, token, user, password): - base_url = resolve_url(url) # manual PID file logic - resolved_token = resolve_token(...) # manual 3-tier fallback + base_url = resolve_url(url) # manual PID file logic + resolved_token = resolve_token(...) # manual 3-tier fallback return Connection(base_url, resolved_token) + # After def get_connection(url, token, user, password): - overrides = {k: v for k, v in { - "url": url, "token": token, "user": user, "password": password, - }.items() if v is not None} + overrides = { + k: v + for k, v in { + "url": url, + "token": token, + "user": user, + "password": password, + }.items() + if v is not None + } settings = ClientSettings(**overrides) return Connection(settings.url, settings.token) ``` diff --git a/docs/superpowers/specs/2026-04-01-global-extension-bugs-design.md b/docs/superpowers/specs/2026-04-01-global-extension-bugs-design.md index 6c827d2a4..101fd3a30 100644 --- a/docs/superpowers/specs/2026-04-01-global-extension-bugs-design.md +++ b/docs/superpowers/specs/2026-04-01-global-extension-bugs-design.md @@ -90,7 +90,9 @@ async def _unhandled_exception_handler( """Catch-all for unhandled exceptions — log and return RFC 9457.""" logger.error( "Unhandled %s on %s %s", - type(exc).__name__, request.method, request.url.path, + type(exc).__name__, + request.method, + request.url.path, exc_info=True, ) return await problem_exception_handler( diff --git a/docs/superpowers/specs/2026-04-01-worker-auth-jwt-design.md b/docs/superpowers/specs/2026-04-01-worker-auth-jwt-design.md index 208b25401..97ce56205 100644 --- a/docs/superpowers/specs/2026-04-01-worker-auth-jwt-design.md +++ b/docs/superpowers/specs/2026-04-01-worker-auth-jwt-design.md @@ -65,6 +65,7 @@ async def get_worker_token(request: Request, session: SessionDep) -> str: ) return await strategy.write_token(user) + WorkerTokenDep = Annotated[str, Depends(get_worker_token)] ``` diff --git a/docs/superpowers/specs/2026-04-08-sqlmodel-session-exec-migration-design.md b/docs/superpowers/specs/2026-04-08-sqlmodel-session-exec-migration-design.md index a205f2c92..28c1d44f7 100644 --- a/docs/superpowers/specs/2026-04-08-sqlmodel-session-exec-migration-design.md +++ b/docs/superpowers/specs/2026-04-08-sqlmodel-session-exec-migration-design.md @@ -115,13 +115,21 @@ There is exactly one site in the codebase (`src/zndraw_joblib/router.py:733`) th ```python # Before -stmt = update(Task).where(Task.id == task_id, Task.status == TaskStatus.PENDING).values(...) +stmt = ( + update(Task) + .where(Task.id == task_id, Task.status == TaskStatus.PENDING) + .values(...) +) cursor_result = await session.execute(stmt) if cursor_result.rowcount == 1: ... # After -stmt = update(Task).where(Task.id == task_id, Task.status == TaskStatus.PENDING).values(...) +stmt = ( + update(Task) + .where(Task.id == task_id, Task.status == TaskStatus.PENDING) + .values(...) +) cursor_result = await session.exec(stmt) if cursor_result.rowcount == 1: ... diff --git a/docs/superpowers/specs/2026-04-20-pr920-review-fixes-design.md b/docs/superpowers/specs/2026-04-20-pr920-review-fixes-design.md index d106e4543..384ec8fd4 100644 --- a/docs/superpowers/specs/2026-04-20-pr920-review-fixes-design.md +++ b/docs/superpowers/specs/2026-04-20-pr920-review-fixes-design.md @@ -196,6 +196,7 @@ class StorageResultBackend: async def store(self, key: str, data: bytes, ttl: int) -> None: io = self._storage[self._k(key)] ... + # apply _k in get/delete too ``` diff --git a/docs/superpowers/specs/2026-04-21-room-group-scope-design.md b/docs/superpowers/specs/2026-04-21-room-group-scope-design.md index 6502419c2..b685e6eb9 100644 --- a/docs/superpowers/specs/2026-04-21-room-group-scope-design.md +++ b/docs/superpowers/specs/2026-04-21-room-group-scope-design.md @@ -54,15 +54,15 @@ ownership semantics, and eliminates the anonymous code path. ```python class Visibility(str, Enum): - PRIVATE = "private" # only the owning user (user-owned rooms) - GROUP = "group" # only members of the owning group (group-owned rooms) - PUBLIC = "public" # anyone (including guests) can view + PRIVATE = "private" # only the owning user (user-owned rooms) + GROUP = "group" # only members of the owning group (group-owned rooms) + PUBLIC = "public" # anyone (including guests) can view class GroupRole(str, Enum): - VIEWER = "viewer" # read-only for group rooms - MEMBER = "member" # read + edit for group rooms - ADMIN = "admin" # member + manage membership + manage group rooms + VIEWER = "viewer" # read-only for group rooms + MEMBER = "member" # read + edit for group rooms + ADMIN = "admin" # member + manage membership + manage group rooms class ShareAccess(str, Enum): @@ -85,10 +85,14 @@ class Room(SQLModel, table=True): ) id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) description: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime()) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime() + ) created_by_id: UUID | None = Field(default=None, index=True) # audit, immutable - owner_user_id: UUID | None = Field(default=None, foreign_key="user.id", index=True) - owner_group_id: UUID | None = Field(default=None, foreign_key="group.id", index=True) + owner_user_id: UUID | None = Field(default=None, foreign_key="user.id", index=True) + owner_group_id: UUID | None = Field( + default=None, foreign_key="group.id", index=True + ) visibility: Visibility = Field(default=Visibility.PUBLIC) step: int = Field(default=0) frame_selection: str | None = Field(default=None) @@ -99,7 +103,9 @@ class Group(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str = Field(unique=True, index=True) description: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime()) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime() + ) created_by_id: UUID = Field(foreign_key="user.id", index=True) @@ -107,9 +113,11 @@ class GroupMembership(SQLModel, table=True): __table_args__ = (UniqueConstraint("group_id", "user_id"),) id: int | None = Field(default=None, primary_key=True) group_id: UUID = Field(foreign_key="group.id", index=True) - user_id: UUID = Field(foreign_key="user.id", index=True) + user_id: UUID = Field(foreign_key="user.id", index=True) role: GroupRole = Field(default=GroupRole.VIEWER) - joined_at: datetime = Field(default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime()) + joined_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime() + ) class RoomShareLink(SQLModel, table=True): @@ -118,7 +126,9 @@ class RoomShareLink(SQLModel, table=True): token: str = Field(unique=True, index=True) # url-safe, ~32 bytes access: ShareAccess = Field(default=ShareAccess.VIEW) created_by_id: UUID = Field(foreign_key="user.id") - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime()) + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), sa_type=UTCDateTime() + ) expires_at: datetime | None = Field(default=None, sa_type=UTCDateTime()) revoked_at: datetime | None = Field(default=None, sa_type=UTCDateTime()) ``` diff --git a/docs/superpowers/specs/2026-04-21-rooms-feed-design.md b/docs/superpowers/specs/2026-04-21-rooms-feed-design.md index 4bcd8397f..d4caafb1f 100644 --- a/docs/superpowers/specs/2026-04-21-rooms-feed-design.md +++ b/docs/superpowers/specs/2026-04-21-rooms-feed-design.md @@ -74,9 +74,7 @@ async def broadcast_room_update( else: member_ids = ( await session.exec( - select(RoomMembership.user_id).where( - RoomMembership.room_id == room.id - ) + select(RoomMembership.user_id).where(RoomMembership.room_id == room.id) ) ).all() for uid in member_ids: diff --git a/docs/superpowers/specs/2026-05-20-room-namespace-refactor-design.md b/docs/superpowers/specs/2026-05-20-room-namespace-refactor-design.md index 276ce6069..900996a1d 100644 --- a/docs/superpowers/specs/2026-05-20-room-namespace-refactor-design.md +++ b/docs/superpowers/specs/2026-05-20-room-namespace-refactor-design.md @@ -235,15 +235,18 @@ class RoomJoin(BaseModel): room_name: str client_type: Literal["frontend", "pyclient"] = "frontend" -class RoomLeave(BaseModel): # was lines 28-31 + +class RoomLeave(BaseModel): # was lines 28-31 owner_id: UUID room_name: str -class TypingStart(BaseModel): # was lines 38-41 + +class TypingStart(BaseModel): # was lines 38-41 owner_id: UUID room_name: str -class TypingStop(BaseModel): # was lines 44-47 + +class TypingStop(BaseModel): # was lines 44-47 owner_id: UUID room_name: str ``` diff --git a/skills/zndraw/SKILL.md b/skills/zndraw/SKILL.md index 96d855235..3bc001f70 100644 --- a/skills/zndraw/SKILL.md +++ b/skills/zndraw/SKILL.md @@ -200,6 +200,7 @@ Use ` --help` for full options. Key patterns: ```python from zndraw import ZnDraw from zndraw.geometries import Isosurface + vis = ZnDraw(room="ROOM") vis.geometries["homo"] = Isosurface(cube_key="info.orbital_homo", isovalue=0.02) ``` @@ -255,62 +256,63 @@ Use `uv run python -c "..."` with the **`ZnDraw` Python client**. It connects to ```python from zndraw import ZnDraw, Extension, Category + vis = ZnDraw(room="ROOM") # After `zndraw-cli auth login`, stored token is auto-discovered — no --token needed # Or with explicit url and token: ZnDraw(url=..., room=..., token="JWT_TOKEN") # Frames — MutableSequence[ase.Atoms] -len(vis) # frame count -vis[0] # frame as ase.Atoms -vis[vis.step] # current frame -vis.step = 42 # jump to frame -vis.append(atoms) # append frame -del vis[0] # delete frame -vis[:] # all frames as list[ase.Atoms] +len(vis) # frame count +vis[0] # frame as ase.Atoms +vis[vis.step] # current frame +vis.step = 42 # jump to frame +vis.append(atoms) # append frame +del vis[0] # delete frame +vis[:] # all frames as list[ase.Atoms] # Selections, bookmarks, figures -vis.selection = [0, 1, 2] # set selected atom indices -vis.bookmarks[42] = "label" # MutableMapping[int, str] -vis.figures["energy"] = fig # MutableMapping[str, plotly.Figure] +vis.selection = [0, 1, 2] # set selected atom indices +vis.bookmarks[42] = "label" # MutableMapping[int, str] +vis.figures["energy"] = fig # MutableMapping[str, plotly.Figure] # Chat -vis.chat.send("message") # send chat message -vis.chat[0] # read message (Sequence) +vis.chat.send("message") # send chat message +vis.chat[0] # read message (Sequence) # Room state -vis.locked = True # lock/unlock room +vis.locked = True # lock/unlock room # Run extensions — returns TaskHandle task = vis.run("@internal:modifiers:Delete") task = vis.run("@internal:modifiers:AddFromSMILES", smiles="CCO") -task.wait(timeout=30) # block until completed/failed -task.status # "pending" | "running" | "completed" | "failed" -task.id # task ID string +task.wait(timeout=30) # block until completed/failed +task.status # "pending" | "running" | "completed" | "failed" +task.id # task ID string # Discovery -list(vis.extensions) # all extension names -vis.extensions["@internal:modifiers:Delete"]["schema"] # parameter schema +list(vis.extensions) # all extension names +vis.extensions["@internal:modifiers:Delete"]["schema"] # parameter schema # Task handles -vis.tasks[task_id] # TaskHandle (with .wait(), .status, .id) -vis.tasks("running") # filtered view +vis.tasks[task_id] # TaskHandle (with .wait(), .status, .id) +vis.tasks("running") # filtered view # Sessions — room-scoped Mapping of active browser sessions -vis.sessions # Mapping[str, Session] (all users in room) -list(vis.sessions) # list of session SIDs +vis.sessions # Mapping[str, Session] (all users in room) +list(vis.sessions) # list of session SIDs # Screenshots (requires own browser session — see "Screenshots, GIFs" section) -sids = list(vis.sessions) # list active session SIDs -session = vis.sessions[sids[0]] # get a session by SID -img = session.screenshot() # capture screenshot (own sessions only) -img.data # PNG bytes -img.save("frame.png") # save to file +sids = list(vis.sessions) # list active session SIDs +session = vis.sessions[sids[0]] # get a session by SID +img = session.screenshot() # capture screenshot (own sessions only) +img.data # PNG bytes +img.save("frame.png") # save to file # Visual Presets — MutableMapping[str, Preset] -list(vis.presets) # list preset names -vis.presets.apply("matt") # apply preset to room geometries -vis.presets.apply("@default") # reset all geometries to factory defaults -vis.presets.load(Path("f.json")) # load preset from JSON file +list(vis.presets) # list preset names +vis.presets.apply("matt") # apply preset to room geometries +vis.presets.apply("@default") # reset all geometries to factory defaults +vis.presets.load(Path("f.json")) # load preset from JSON file vis.presets.export("pub", Path("out.json")) # export to file vis.presets["custom"] = Preset(name="custom", rules=[...]) del vis.presets["custom"] @@ -319,8 +321,10 @@ del vis.presets["custom"] vis.get(slice(None), keys=["info.energy"]) # Classmethods (no room needed) -ZnDraw.list_rooms() # or with url="http://localhost:8000" -token = ZnDraw.login(username="...", password="...") # or with url="http://localhost:8000", +ZnDraw.list_rooms() # or with url="http://localhost:8000" +token = ZnDraw.login( + username="...", password="..." +) # or with url="http://localhost:8000", ``` ### Bookmark frames matching a condition diff --git a/src/zndraw_joblib/client.py b/src/zndraw_joblib/client.py index 405cc0e1f..ab143e499 100644 --- a/src/zndraw_joblib/client.py +++ b/src/zndraw_joblib/client.py @@ -807,7 +807,7 @@ def _log_to_room(self, room_id: str, message: str) -> None: json={"content": message}, headers=self.api.get_headers(), ) - except Exception: # noqa: BLE001 + except Exception: logger.debug("Failed to log message to room %s", room_id, exc_info=True) # -- SIO event handlers --------------------------------------------------- diff --git a/tests/zndraw/worker/test_resilience.py b/tests/zndraw/worker/test_resilience.py index 12bf9f71d..cea888454 100644 --- a/tests/zndraw/worker/test_resilience.py +++ b/tests/zndraw/worker/test_resilience.py @@ -106,11 +106,11 @@ def _listen_loop(): # just disconnect the socket (disconnect will fail on HTTP DELETE, that's ok) try: worker.jobs.disconnect() - except Exception: # noqa: BLE001 + except Exception: logger.debug("Expected: jobs.disconnect() failed (stale token)", exc_info=True) try: worker.disconnect() - except Exception: # noqa: BLE001 + except Exception: logger.debug("Expected: disconnect() failed (stale token)", exc_info=True)