refactor: minor tweaks#47
Conversation
1. Typed request models for server routes (explicit > implicit) - New `server/requests.py` with Pydantic models for all mutating endpoints - `parse_body()` helper validates JSON against typed models - Eliminates cast() chains and manual field extraction in _routes.py - ValidationError returns 422 (not silent 400 on missing fields) 2. Audit silent exception suppressions (errors should never pass silently) - Replace all `contextlib.suppress(Exception)` with try/except + logger.debug - Narrow exception types where possible (KaganError, OSError) - 4 files: mcp/toolsets/tasks.py, sessions.py, server/_routes.py, tui/task_editor_modal.py 3. Extract shared web utilities (one obvious way to do it) - Move asBool, normalizeLauncher, quoteShell to lib/utils.ts - Remove 3 duplicate definitions across agent-control, settings-panel, task-detail-page - Add 15 unit tests for extracted functions 4. Consolidate TUI test fixtures (readability counts) - Add board_with_task shared fixture to tests/helpers/fixtures.py - Replace 7 duplicate board fixtures across TUI test files - ~90 lines of test boilerplate removed Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Greptile SummaryThis PR applies four focused Zen-of-Python refactors across the server, web, and TUI test layers: typed Pydantic request models replace manual
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| src/kagan/server/requests.py | New file: typed Pydantic request models for all mutating routes. RunTaskRequest.agent_backend: str = "" is a silent breaking change vs the old null-tolerant behavior; _validate_criteria is duplicated across two models; FollowUpRequest._strip_text emits a misleading error for wrong-type input. |
| src/kagan/server/_helpers.py | Adds parse_body[T] generic helper and ValidationError → 422 branch in _error_response. Clean and consistent with existing error-handling patterns. |
| src/kagan/server/_routes.py | All mutating routes migrated from manual dict extraction to typed parse_body calls. The set_settings endpoint intentionally retains inline request.json() parsing (freeform key-value map). The model_fields_set loop for partial task updates is correct. Contextlib suppressions replaced with explicit logger.debug except/try blocks. |
| packages/web/src/lib/utils.ts | Extracts asBool, normalizeLauncher, quoteShell, and LAUNCHER_BACKENDS from three components into the shared utility module. Implementations are identical to the originals; export surface is clean. |
| packages/web/src/lib/tests/utils.test.ts | 15 new unit tests covering all three extracted utilities across edge-case inputs (null/undefined, casing, whitespace, shell metacharacters). Comprehensive coverage. |
| tests/helpers/fixtures.py | New board_with_task fixture consolidates repeated setup across 7 TUI test files. It includes settings_update({"ui.tui_tutorial_seen": "true"}) which was not present in all original per-file fixtures — a deliberate but behavior-changing addition worth verifying in tests that care about tutorial state. |
| src/kagan/mcp/toolsets/sessions.py | Replaces contextlib.suppress(Exception) with explicit try/except (KaganError, OSError) + logger.debug for worktree fetch. Exception scope is appropriately narrowed. |
| src/kagan/tui/screens/task_editor_modal.py | Auto-save contextlib.suppress(Exception) converted to explicit except Exception with logger.debug. Broad catch is retained deliberately (auto-save failure should not surface to user) but is now observable via debug logging. |
Sequence Diagram
sequenceDiagram
participant Client
participant Route as _routes.py
participant Helper as _helpers.py (parse_body)
participant Model as requests.py (Pydantic Model)
participant Core as ctx.client
Client->>Route: POST /api/tasks {body}
Route->>Helper: parse_body(request, CreateTaskRequest)
Helper->>Helper: await request.json()
Helper->>Model: model.model_validate(payload)
alt Validation passes
Model-->>Helper: typed CreateTaskRequest instance
Helper-->>Route: body
Route->>Core: tasks.create(body.title, ...)
Core-->>Route: task
Route-->>Client: 200 {ok: true, data: task}
else Missing required field (e.g. title)
Model-->>Helper: raises ValidationError
Helper-->>Route: raises ValidationError
Route->>Helper: _error_response(ValidationError)
Helper-->>Client: 422 {ok: false, error: "..."}
else Body is not a JSON object
Helper-->>Route: raises ValueError
Route->>Helper: _error_response(ValueError)
Helper-->>Client: 400 {ok: false, error: "..."}
end
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/kagan/server/requests.py
Line: 55
Comment:
**`agent_backend: str` rejects `null` from callers**
The old route used `payload.get("agent_backend", "")`, which treated an explicit `{"agent_backend": null}` JSON value as falsy and fell back to the settings default. Pydantic now rejects `null` for a bare `str` field with a 422 — a behavioral regression for any client that omits the key with a null rather than absent it.
To preserve the original semantics (absent _or_ null → fall back to settings), declare it as `str | None`:
```suggestion
agent_backend: str | None = None
```
The existing fallback logic in `_routes.py` (`if not agent_backend`) already handles `None` correctly, so no route change is needed.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/kagan/server/requests.py
Line: 76-81
Comment:
**Misleading error message for wrong-type input**
`_strip_text` is a `mode="before"` validator, so it runs before Pydantic's own type coercion. If a caller sends `{"text": 42}` (an integer), `not isinstance(v, str)` fires first and raises `ValueError("text is required")` — which is factually incorrect. The field was _provided_; it was just the wrong type.
Consider separating the two failure modes:
```suggestion
@field_validator("text", mode="before")
@classmethod
def _strip_text(cls, v: object) -> str:
if not isinstance(v, str):
raise ValueError("text must be a string")
if not v.strip():
raise ValueError("text is required")
return v.strip()
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/kagan/server/requests.py
Line: 21-47
Comment:
**Duplicated `_validate_criteria` validator**
`CreateTaskRequest` and `UpdateTaskRequest` define byte-for-byte identical `_validate_criteria` methods. A shared base class removes the duplication and makes future changes (e.g. verifying list items are actually strings) apply in one place:
```python
class _TaskFieldsMixin(BaseModel):
acceptance_criteria: list[str] | None = None
@field_validator("acceptance_criteria", mode="before")
@classmethod
def _validate_criteria(cls, v: object) -> list[str] | None:
if v is None:
return None
if not isinstance(v, list):
raise ValueError("acceptance_criteria must be a list of strings")
return v
class CreateTaskRequest(_TaskFieldsMixin):
title: str
...
class UpdateTaskRequest(_TaskFieldsMixin):
title: str | None = None
...
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "refactor: Zen of Python refinements acro..." | Re-trigger Greptile
|
|
||
|
|
||
| class RunTaskRequest(BaseModel): | ||
| agent_backend: str = "" |
There was a problem hiding this comment.
agent_backend: str rejects null from callers
The old route used payload.get("agent_backend", ""), which treated an explicit {"agent_backend": null} JSON value as falsy and fell back to the settings default. Pydantic now rejects null for a bare str field with a 422 — a behavioral regression for any client that omits the key with a null rather than absent it.
To preserve the original semantics (absent or null → fall back to settings), declare it as str | None:
| agent_backend: str = "" | |
| agent_backend: str | None = None |
The existing fallback logic in _routes.py (if not agent_backend) already handles None correctly, so no route change is needed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/kagan/server/requests.py
Line: 55
Comment:
**`agent_backend: str` rejects `null` from callers**
The old route used `payload.get("agent_backend", "")`, which treated an explicit `{"agent_backend": null}` JSON value as falsy and fell back to the settings default. Pydantic now rejects `null` for a bare `str` field with a 422 — a behavioral regression for any client that omits the key with a null rather than absent it.
To preserve the original semantics (absent _or_ null → fall back to settings), declare it as `str | None`:
```suggestion
agent_backend: str | None = None
```
The existing fallback logic in `_routes.py` (`if not agent_backend`) already handles `None` correctly, so no route change is needed.
How can I resolve this? If you propose a fix, please make it concise.| @field_validator("text", mode="before") | ||
| @classmethod | ||
| def _strip_text(cls, v: object) -> str: | ||
| if not isinstance(v, str) or not v.strip(): | ||
| raise ValueError("text is required") | ||
| return v.strip() |
There was a problem hiding this comment.
Misleading error message for wrong-type input
_strip_text is a mode="before" validator, so it runs before Pydantic's own type coercion. If a caller sends {"text": 42} (an integer), not isinstance(v, str) fires first and raises ValueError("text is required") — which is factually incorrect. The field was provided; it was just the wrong type.
Consider separating the two failure modes:
| @field_validator("text", mode="before") | |
| @classmethod | |
| def _strip_text(cls, v: object) -> str: | |
| if not isinstance(v, str) or not v.strip(): | |
| raise ValueError("text is required") | |
| return v.strip() | |
| @field_validator("text", mode="before") | |
| @classmethod | |
| def _strip_text(cls, v: object) -> str: | |
| if not isinstance(v, str): | |
| raise ValueError("text must be a string") | |
| if not v.strip(): | |
| raise ValueError("text is required") | |
| return v.strip() |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/kagan/server/requests.py
Line: 76-81
Comment:
**Misleading error message for wrong-type input**
`_strip_text` is a `mode="before"` validator, so it runs before Pydantic's own type coercion. If a caller sends `{"text": 42}` (an integer), `not isinstance(v, str)` fires first and raises `ValueError("text is required")` — which is factually incorrect. The field was _provided_; it was just the wrong type.
Consider separating the two failure modes:
```suggestion
@field_validator("text", mode="before")
@classmethod
def _strip_text(cls, v: object) -> str:
if not isinstance(v, str):
raise ValueError("text must be a string")
if not v.strip():
raise ValueError("text is required")
return v.strip()
```
How can I resolve this? If you propose a fix, please make it concise.| @field_validator("acceptance_criteria", mode="before") | ||
| @classmethod | ||
| def _validate_criteria(cls, v: object) -> list[str] | None: | ||
| if v is None: | ||
| return None | ||
| if not isinstance(v, list): | ||
| raise ValueError("acceptance_criteria must be a list of strings") | ||
| return v | ||
|
|
||
|
|
||
| class UpdateTaskRequest(BaseModel): | ||
| title: str | None = None | ||
| description: str | None = None | ||
| priority: str | int | None = None | ||
| base_branch: str | None = None | ||
| acceptance_criteria: list[str] | None = None | ||
| agent_backend: str | None = None | ||
| launcher: str | None = None | ||
|
|
||
| @field_validator("acceptance_criteria", mode="before") | ||
| @classmethod | ||
| def _validate_criteria(cls, v: object) -> list[str] | None: | ||
| if v is None: | ||
| return None | ||
| if not isinstance(v, list): | ||
| raise ValueError("acceptance_criteria must be a list of strings") | ||
| return v |
There was a problem hiding this comment.
Duplicated
_validate_criteria validator
CreateTaskRequest and UpdateTaskRequest define byte-for-byte identical _validate_criteria methods. A shared base class removes the duplication and makes future changes (e.g. verifying list items are actually strings) apply in one place:
class _TaskFieldsMixin(BaseModel):
acceptance_criteria: list[str] | None = None
@field_validator("acceptance_criteria", mode="before")
@classmethod
def _validate_criteria(cls, v: object) -> list[str] | None:
if v is None:
return None
if not isinstance(v, list):
raise ValueError("acceptance_criteria must be a list of strings")
return v
class CreateTaskRequest(_TaskFieldsMixin):
title: str
...
class UpdateTaskRequest(_TaskFieldsMixin):
title: str | None = None
...Prompt To Fix With AI
This is a comment left during a code review.
Path: src/kagan/server/requests.py
Line: 21-47
Comment:
**Duplicated `_validate_criteria` validator**
`CreateTaskRequest` and `UpdateTaskRequest` define byte-for-byte identical `_validate_criteria` methods. A shared base class removes the duplication and makes future changes (e.g. verifying list items are actually strings) apply in one place:
```python
class _TaskFieldsMixin(BaseModel):
acceptance_criteria: list[str] | None = None
@field_validator("acceptance_criteria", mode="before")
@classmethod
def _validate_criteria(cls, v: object) -> list[str] | None:
if v is None:
return None
if not isinstance(v, list):
raise ValueError("acceptance_criteria must be a list of strings")
return v
class CreateTaskRequest(_TaskFieldsMixin):
title: str
...
class UpdateTaskRequest(_TaskFieldsMixin):
title: str | None = None
...
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
- RunTaskRequest.agent_backend: str -> str | None (accept null from callers) - FollowUpRequest._strip_text: separate TypeError from ValueError - Extract shared _CriteriaMixin to deduplicate acceptance_criteria validator Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
Four Zen of Python-guided refinements addressing the highest-impact code quality gaps found in a thorough codebase review:
server/requests.py): Pydantic models replace manualdict[str, Any]extraction andcast()chains in all mutating API routes. Missing fields now return 422 with structured validation errors instead of opaque KeyError 400s.contextlib.suppress(Exception)blocks converted to explicittry/exceptwithlogger.debug, narrowing toKaganError | OSErrorwhere possible. Zero silent suppressions remain.asBool,normalizeLauncher,quoteShellextracted from 3 components tolib/utils.tswith 15 unit tests. Three duplicate definitions removed.board_with_taskfixture replaces 7 duplicate board fixtures across TUI test files. ~90 lines of boilerplate removed.Refinement #5 (chat SSE hook unification) is deferred to a follow-up PR due to higher complexity.
Test plan