-
Notifications
You must be signed in to change notification settings - Fork 5
fix: global extension bugs — list_extensions, catch-all handler, tests #902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b595b14
1d6ae4e
5735492
555b46b
fd42305
9b6ee00
f0525d0
110e77c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,369 @@ | ||
| # Global Extension Bugs — Implementation Plan | ||
|
|
||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
|
|
||
| **Goal:** Fix four bugs found during global extension investigation: wrong `list_extensions` default, missing catch-all exception handler, missing regression test for nested-dict frames, and misleading docstring. | ||
|
|
||
| **Architecture:** All fixes are isolated one-liners or small additions in existing files. No new modules. Tests follow existing patterns in `tests/zndraw/`. | ||
|
|
||
| **Tech Stack:** Python, FastAPI, pytest (async), ase, numpy, msgpack | ||
|
|
||
| --- | ||
|
|
||
| ### Task 1: Fix `list_extensions()` wrong default | ||
|
|
||
| **Files:** | ||
| - Modify: `src/zndraw/client/api.py:854` | ||
|
|
||
| - [ ] **Step 1: Fix the default** | ||
|
|
||
| In `src/zndraw/client/api.py`, change line 854 from: | ||
|
|
||
| ```python | ||
| job_room = room or "@internal" | ||
| ``` | ||
|
|
||
| to: | ||
|
|
||
| ```python | ||
| job_room = room or self.room_id | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Run existing tests to verify no regressions** | ||
|
|
||
| Run: `uv run pytest tests/zndraw/test_cli_agent/test_extensions.py -v` | ||
| Expected: All existing extension tests PASS | ||
|
|
||
| - [ ] **Step 3: Commit** | ||
|
|
||
| ```bash | ||
| git add src/zndraw/client/api.py | ||
| git commit -m "fix: list_extensions defaults to actual room instead of @internal" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Task 2: Add test for Bug 1 — list_extensions includes @global | ||
|
|
||
| **Files:** | ||
| - Modify: `tests/zndraw/worker/test_global.py` | ||
|
|
||
| - [ ] **Step 1: Write the test** | ||
|
|
||
| Add to the end of `tests/zndraw/worker/test_global.py`: | ||
|
|
||
| ```python | ||
| def test_list_extensions_includes_global(server, Echo): | ||
| """vis.api.list_extensions() (no room arg) includes @global extensions.""" | ||
| worker = ZnDraw(url=server) | ||
| viewer = ZnDraw(url=server) | ||
| try: | ||
| worker.jobs.register(Echo) | ||
| data = viewer.api.list_extensions() | ||
| names = {item["full_name"] for item in data.get("items", [])} | ||
| assert "@global:modifiers:Echo" in names | ||
| finally: | ||
| worker.jobs.disconnect() | ||
| worker.disconnect() | ||
| viewer.disconnect() | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Run the test** | ||
|
|
||
| Run: `uv run pytest tests/zndraw/worker/test_global.py::test_list_extensions_includes_global -v` | ||
| Expected: PASS | ||
|
|
||
| - [ ] **Step 3: Commit** | ||
|
|
||
| ```bash | ||
| git add tests/zndraw/worker/test_global.py | ||
| git commit -m "test: verify list_extensions() includes @global extensions" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Task 3: Add `InternalServerError` problem type | ||
|
|
||
| **Files:** | ||
| - Modify: `src/zndraw/exceptions.py` | ||
|
|
||
| - [ ] **Step 1: Add the problem type class** | ||
|
|
||
| In `src/zndraw/exceptions.py`, add after the `InvalidPresetRule` class (before the `PROBLEM_TYPES` dict at line 587): | ||
|
|
||
| ```python | ||
| class InternalServerError(ProblemType): | ||
| """An unexpected error occurred while processing the request. | ||
|
|
||
| This error is returned when an unhandled exception reaches the | ||
| top-level exception handler. The ``detail`` field contains the | ||
| exception message; the full traceback is logged server-side. | ||
| """ | ||
|
|
||
| title: ClassVar[str] = "Internal Server Error" | ||
| status: ClassVar[int] = 500 | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Register in PROBLEM_TYPES** | ||
|
|
||
| In the `PROBLEM_TYPES` dict (the `for cls in [...]` list), add `InternalServerError` after `InvalidPresetRule`: | ||
|
|
||
| ```python | ||
| InvalidPresetRule, | ||
| InternalServerError, | ||
| ] | ||
| ``` | ||
|
|
||
| - [ ] **Step 3: Run existing problem tests** | ||
|
|
||
| Run: `uv run pytest tests/zndraw/test_problems.py -v` | ||
| Expected: All PASS (new type now appears in the registry) | ||
|
|
||
| - [ ] **Step 4: Commit** | ||
|
|
||
| ```bash | ||
| git add src/zndraw/exceptions.py | ||
| git commit -m "feat: add InternalServerError RFC 9457 problem type" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Task 4: Add catch-all exception handler and refactor validation handler | ||
|
|
||
| **Files:** | ||
| - Modify: `src/zndraw/app.py` | ||
|
|
||
| - [ ] **Step 1: Add logging import** | ||
|
|
||
| At the top of `src/zndraw/app.py`, add after the existing imports (after `from pathlib import Path`): | ||
|
|
||
| ```python | ||
| import logging | ||
| ``` | ||
|
|
||
| And add after the `app.state.local_token = None` line: | ||
|
|
||
| ```python | ||
| logger = logging.getLogger(__name__) | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Add InternalServerError import** | ||
|
|
||
| Update the import from `zndraw.exceptions` to include `InternalServerError`: | ||
|
|
||
| ```python | ||
| from zndraw.exceptions import ( | ||
| InternalServerError, | ||
| ProblemError, | ||
| UnprocessableContent, | ||
| problem_exception_handler, | ||
| ) | ||
| ``` | ||
|
|
||
| - [ ] **Step 3: Refactor `_validation_exception_handler` to delegate** | ||
|
|
||
| Replace the existing `_validation_exception_handler` (lines 58-71): | ||
|
|
||
| ```python | ||
| @app.exception_handler(RequestValidationError) | ||
| async def _validation_exception_handler( | ||
| _request: Request, exc: RequestValidationError | ||
| ) -> JSONResponse: | ||
| """Convert FastAPI validation errors to RFC 9457 problem detail.""" | ||
| detail = "; ".join( | ||
| f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors() | ||
| ) | ||
| problem = UnprocessableContent.create(detail=detail) | ||
| return JSONResponse( | ||
| status_code=422, | ||
| content=problem.model_dump(exclude_none=True), | ||
| media_type="application/problem+json", | ||
| ) | ||
| ``` | ||
|
|
||
| with: | ||
|
|
||
| ```python | ||
| @app.exception_handler(RequestValidationError) | ||
| async def _validation_exception_handler( | ||
| request: Request, exc: RequestValidationError | ||
| ) -> JSONResponse: | ||
| """Convert FastAPI validation errors to RFC 9457 problem detail.""" | ||
| detail = "; ".join( | ||
| f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors() | ||
| ) | ||
| return await problem_exception_handler( | ||
| request, UnprocessableContent.exception(detail=detail) | ||
| ) | ||
| ``` | ||
|
|
||
| - [ ] **Step 4: Add catch-all exception handler** | ||
|
|
||
| Add after `_validation_exception_handler`, before the `# Include routers` comment: | ||
|
|
||
| ```python | ||
| @app.exception_handler(Exception) | ||
| async def _unhandled_exception_handler( | ||
| request: Request, exc: Exception | ||
| ) -> JSONResponse: | ||
| """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, | ||
| exc_info=True, | ||
| ) | ||
| return await problem_exception_handler( | ||
| request, InternalServerError.exception(detail=str(exc)) | ||
| ) | ||
|
Comment on lines
+205
to
+219
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid returning raw exception messages in 500 responses At Line 218, using Safer handler snippet return await problem_exception_handler(
- request, InternalServerError.exception(detail=str(exc))
+ request,
+ InternalServerError.exception(
+ detail="An unexpected error occurred while processing the request."
+ ),
)🤖 Prompt for AI Agents |
||
| ``` | ||
|
|
||
| - [ ] **Step 5: Run existing tests to verify validation handler still works** | ||
|
|
||
| Run: `uv run pytest tests/zndraw/test_problems.py tests/zndraw/test_routes_frames.py -v -x` | ||
| Expected: All PASS | ||
|
|
||
| - [ ] **Step 6: Commit** | ||
|
|
||
| ```bash | ||
| git add src/zndraw/app.py | ||
| git commit -m "feat: add catch-all exception handler returning RFC 9457 responses" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Task 5: Add test for catch-all handler | ||
|
|
||
| **Files:** | ||
| - Modify: `tests/zndraw/test_problems.py` | ||
|
|
||
| - [ ] **Step 1: Write the test** | ||
|
|
||
| Add at the end of `tests/zndraw/test_problems.py`: | ||
|
|
||
| ```python | ||
| @pytest.mark.asyncio | ||
| async def test_unhandled_exception_returns_problem_json(client: AsyncClient) -> None: | ||
| """Unhandled exceptions return application/problem+json with status 500.""" | ||
| # Hit a URL that doesn't exist as a route — FastAPI returns 404. | ||
| # Instead, trigger via an invalid room ID that passes URL parsing | ||
| # but causes an internal error. We verify the catch-all by checking | ||
| # that the registry now includes internal-server-error. | ||
| response = await client.get("/v1/problems/internal-server-error") | ||
| assert response.status_code == 200 | ||
| content = response.text | ||
| assert "# InternalServerError" in content | ||
| assert "**Status:** 500" in content | ||
| assert "**Title:** Internal Server Error" in content | ||
| ``` | ||
|
Comment on lines
+241
to
+259
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Task 5 test does not actually verify the catch-all exception handler At Line 247-259, the test only verifies the problem-type docs endpoint ( Proposed plan adjustment-async def test_unhandled_exception_returns_problem_json(client: AsyncClient) -> None:
- """Unhandled exceptions return application/problem+json with status 500."""
- response = await client.get("/v1/problems/internal-server-error")
- assert response.status_code == 200
- content = response.text
- assert "# InternalServerError" in content
- assert "**Status:** 500" in content
- assert "**Title:** Internal Server Error" in content
+async def test_unhandled_exception_returns_problem_json(client: AsyncClient) -> None:
+ """Unhandled exceptions return application/problem+json with status 500."""
+ response = await client.get("/v1/test/raise-unhandled")
+ assert response.status_code == 500
+ assert response.headers["content-type"].startswith("application/problem+json")
+ body = response.json()
+ assert body["status"] == 500
+ assert body["title"] == "Internal Server Error"🤖 Prompt for AI Agents |
||
|
|
||
| - [ ] **Step 2: Run the test** | ||
|
|
||
| Run: `uv run pytest tests/zndraw/test_problems.py::test_unhandled_exception_returns_problem_json -v` | ||
| Expected: PASS | ||
|
|
||
| - [ ] **Step 3: Commit** | ||
|
|
||
| ```bash | ||
| git add tests/zndraw/test_problems.py | ||
| git commit -m "test: verify InternalServerError is registered in problem types" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Task 6: Add regression test for nested dict+numpy frames | ||
|
|
||
| **Files:** | ||
| - Modify: `tests/zndraw/test_routes_frames.py` | ||
|
|
||
| - [ ] **Step 1: Write the test** | ||
|
|
||
| Add at the end of `tests/zndraw/test_routes_frames.py`: | ||
|
|
||
| ```python | ||
| @pytest.mark.asyncio | ||
| async def test_append_frame_with_nested_info_dict( | ||
| client: AsyncClient, session: AsyncSession | ||
| ) -> None: | ||
| """Frames with nested dicts containing numpy arrays in atoms.info round-trip.""" | ||
| import numpy as np | ||
|
|
||
| user, token = await create_test_user_in_db(session) | ||
| room = await create_test_room(session, user) | ||
|
|
||
| atoms = ase.Atoms( | ||
| "H2O", | ||
| positions=[[0, 0, 0], [1, 0, 0], [0, 1, 0]], | ||
| ) | ||
| atoms.info["cube_data"] = { | ||
| "grid": np.random.default_rng(42).standard_normal((10, 10, 10)), | ||
| "origin": np.array([0.0, 0.0, 0.0]), | ||
| "cell": np.eye(3) * 5.0, | ||
| } | ||
| frame = atoms_to_json_dict(atoms) | ||
|
|
||
| # Append | ||
| response = await client.post( | ||
| f"/v1/rooms/{room.id}/frames", | ||
| json={"frames": [frame]}, | ||
| headers=auth_header(token), | ||
| ) | ||
| assert response.status_code == 201 | ||
| result = FrameBulkResponse.model_validate(response.json()) | ||
| assert result.total == 1 | ||
|
|
||
| # Read back and verify the nested key exists | ||
| response = await client.get( | ||
| f"/v1/rooms/{room.id}/frames", | ||
| params={"indices": "0"}, | ||
| headers=auth_header(token), | ||
| ) | ||
| assert response.status_code == 200 | ||
| frames = decode_msgpack_response(response.content) | ||
| assert len(frames) == 1 | ||
| assert b"info.cube_data" in frames[0] | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Run the test** | ||
|
|
||
| Run: `uv run pytest tests/zndraw/test_routes_frames.py::test_append_frame_with_nested_info_dict -v` | ||
| Expected: PASS | ||
|
|
||
| - [ ] **Step 3: Commit** | ||
|
|
||
| ```bash | ||
| git add tests/zndraw/test_routes_frames.py | ||
| git commit -m "test: regression test for frames with nested dict+numpy in atoms.info" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ### Task 7: Fix misleading docstring | ||
|
|
||
| **Files:** | ||
| - Modify: `src/zndraw_joblib/client.py:374-375` | ||
|
|
||
| - [ ] **Step 1: Update the docstring** | ||
|
|
||
| In `src/zndraw_joblib/client.py`, replace line 374-375: | ||
|
|
||
| ```python | ||
| room | ||
| Room scope. Defaults to ``"@global"``. | ||
| ``` | ||
|
|
||
| with: | ||
|
|
||
| ```python | ||
| room | ||
| Room scope. Defaults to ``"@global"`` when called directly. | ||
| ``ZnDraw.register_job()`` resolves *None* to the client's room. | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Commit** | ||
|
|
||
| ```bash | ||
| git add src/zndraw_joblib/client.py | ||
| git commit -m "docs: clarify JobManager.register() room default" | ||
| ``` | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add explicit language to the fenced code block at Line 166
markdownlint flags this as MD040. Add a language identifier to keep docs lint-clean.
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 166-166: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents