Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/frontend-tester.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,5 @@ After testing, provide a structured report:
```python
from zndraw import ZnDraw

vis = ZnDraw(room="<room_id>") # finds local server automatically
vis = ZnDraw(room="<room_id>") # finds local server automatically
```
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<owner_uuid>/<name>` returned by the server. Single-segment names are no longer supported.
Expand All @@ -71,7 +73,7 @@ vis = ZnDraw(
url="http://localhost:1234",
room="123e4567-e89b-12d3-a456-426614174000/my-room",
user="username",
password="password"
password="password",
)
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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")
```

Expand Down
14 changes: 4 additions & 10 deletions docs/superpowers/plans/2026-03-13-isosurface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
21 changes: 14 additions & 7 deletions docs/superpowers/plans/2026-03-18-storage-refactor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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, ...)
```

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions docs/superpowers/plans/2026-03-19-cli-auth-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 49 additions & 53 deletions docs/superpowers/plans/2026-03-20-global-extension-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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**
Expand Down
19 changes: 11 additions & 8 deletions docs/superpowers/plans/2026-03-25-pydantic-settings-phase1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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**
Expand Down
Loading
Loading