Skip to content

Commit 7420358

Browse files
Add search limit validation, typed models, and mypy strict CI (#38)
* Add search limit validation, typed models, and mypy strict CI - Return 400 with a clear JSON error when /api/search limit is invalid instead of crashing with ValueError on bad input (abc, 1.5, etc.) - Add models/ TypedDict shapes and annotate parser/API boundaries - Enable mypy --strict on api, utils, and models; add CI mypy job - Add tests/test_search.py for limit validation cases * Fix PR review: stats exclusion, export_session errors, mypy 2.1, CI cache * Fix mypy CI errors in json_ok and export_api send_file stubs * fix(session_path): continue JSONL scan when display name peek fails * Fix: Single quote on Attribute * fix(ui): early theme apply and safer search/sidebar click handlers * fix(api): address PR review — json_error, typed session rows, test limits * fix(ci): repair mypy config and CI invocations for mypy 1.15
1 parent 4bbb456 commit 7420358

29 files changed

Lines changed: 725 additions & 216 deletions

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,25 @@ jobs:
5757

5858
- name: Run tests
5959
run: pytest --tb=short -q
60+
61+
mypy:
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@v4
65+
66+
- uses: actions/setup-python@v5
67+
with:
68+
python-version: "3.12"
69+
cache: pip
70+
cache-dependency-path: |
71+
requirements.txt
72+
requirements-dev.txt
73+
74+
- name: Install dev dependencies
75+
run: pip install -r requirements-dev.txt
76+
77+
- name: Run mypy (strict, production packages)
78+
run: mypy -p api -p utils -p models
79+
80+
- name: Run mypy on tests (non-strict smoke)
81+
run: mypy tests --config-file mypy-tests.ini --follow-imports skip

api/_flask_types.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Shared Flask handler return types for mypy."""
2+
3+
from typing import Any, Union, cast
4+
5+
from flask import Response, jsonify
6+
7+
# Narrow scope: handlers here return Response or (Response, status) only.
8+
# Widen if adding (Response, int, headers) or plain-text tuples.
9+
FlaskReturn = Union[Response, tuple[Response, int]]
10+
11+
12+
def json_response(*args: Any, **kwargs: Any) -> Response:
13+
"""Typed wrapper around :func:`flask.jsonify` for JSON bodies."""
14+
return cast(Response, jsonify(*args, **kwargs))
15+
16+
17+
def json_error(payload: str | dict[str, Any], status: int) -> tuple[Response, int]:
18+
"""JSON error body with explicit HTTP status (avoids trailing `, 404` at call sites)."""
19+
body: dict[str, Any] = {"error": payload} if isinstance(payload, str) else payload
20+
return jsonify(body), status

api/export_api.py

Lines changed: 51 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
import os
66
import zipfile
77
from datetime import datetime
8+
from typing import Any
89

9-
from flask import Blueprint, current_app, jsonify, request, send_file
10+
from flask import Blueprint, current_app, request, send_file
11+
12+
from api._flask_types import FlaskReturn, json_error, json_response
13+
from models.export import ExportStateDict
1014

1115
from utils.export_state_store import (
1216
EXPORT_STATE_FILE,
@@ -33,24 +37,24 @@
3337
_STATE_FILE = EXPORT_STATE_FILE
3438

3539

36-
def _state_lock():
40+
def _state_lock() -> Any:
3741
return export_state_lock(_STATE_FILE)
3842

3943

40-
def _load_state_from_disk() -> dict:
44+
def _load_state_from_disk() -> ExportStateDict:
4145
return load_export_state_from_disk(_STATE_FILE)
4246

4347

44-
def _atomic_write_state(state: dict) -> None:
48+
def _atomic_write_state(state: ExportStateDict) -> None:
4549
atomic_write_export_state(state, _STATE_FILE)
4650

4751

48-
def _read_state() -> dict:
52+
def _read_state() -> ExportStateDict:
4953
with _state_lock():
5054
return _load_state_from_disk()
5155

5256

53-
def _write_state(sessions_map: dict, count: int) -> None:
57+
def _write_state(sessions_map: dict[str, float], count: int) -> None:
5458
"""Persist merge of *sessions_map* and update last-export metadata (*count* = this run only)."""
5559
with _state_lock():
5660
state = _load_state_from_disk()
@@ -61,10 +65,10 @@ def _write_state(sessions_map: dict, count: int) -> None:
6165

6266

6367
@export_bp.route("/api/export/state")
64-
def get_export_state():
68+
def get_export_state() -> FlaskReturn:
6569
state = _read_state()
6670
n = state.get("exportedCount", 0)
67-
return jsonify(
71+
return json_response(
6872
{
6973
"last_export_time": state.get("lastExportTime"),
7074
# Sessions exported in the last completed bulk export (not a lifetime total).
@@ -75,16 +79,16 @@ def get_export_state():
7579

7680

7781
@export_bp.route("/api/export", methods=["POST"])
78-
def bulk_export():
82+
def bulk_export() -> FlaskReturn:
7983
body = request.get_json(silent=True)
8084
if body is None:
8185
body = {}
8286
if not isinstance(body, dict):
83-
return jsonify({"error": "Invalid request body"}), 400
87+
return json_error("Invalid request body", 400)
8488

8589
since = body.get("since", "all")
8690
if since not in ("all", "last", "incremental"):
87-
return jsonify({"error": "Invalid since mode", "since": since}), 400
91+
return json_error({"error": "Invalid since mode", "since": since}, 400)
8892

8993
base = (
9094
current_app.config.get("CLAUDE_PROJECTS_DIR")
@@ -94,14 +98,14 @@ def bulk_export():
9498
rules = current_app.config.get("EXCLUSION_RULES") or []
9599

96100
state = _read_state()
97-
last_export_sessions: dict = (
101+
last_export_sessions: dict[str, float] = (
98102
state.get("sessions", {}) if since == "incremental" else {}
99103
)
100104

101105
buf = io.BytesIO()
102106
count = 0
103-
manifest = []
104-
new_sessions_map: dict = {}
107+
manifest: list[dict[str, Any]] = []
108+
new_sessions_map: dict[str, float] = {}
105109
latest_day = None
106110

107111
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
@@ -226,15 +230,7 @@ def bulk_export():
226230
_write_state(new_sessions_map, count)
227231

228232
if count == 0:
229-
return (
230-
jsonify(
231-
{
232-
"error": "Nothing to export",
233-
"since": since,
234-
}
235-
),
236-
422,
237-
)
233+
return json_error({"error": "Nothing to export", "since": since}, 422)
238234

239235
buf.seek(0)
240236
date_tag = datetime.now().strftime("%Y-%m-%d")
@@ -251,12 +247,12 @@ def bulk_export():
251247
buf,
252248
mimetype="application/zip",
253249
as_attachment=True,
254-
download_name=f"claude-code-export{suffix}-{date_tag}.zip",
250+
download_name=f"claude-code-export{suffix}-{date_tag}.zip", # type: ignore[call-arg]
255251
)
256252

257253

258254
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
259-
def export_session(project_name, session_id):
255+
def export_session(project_name: str, session_id: str) -> FlaskReturn:
260256
import os
261257
from utils.session_path import safe_join
262258

@@ -267,36 +263,42 @@ def export_session(project_name, session_id):
267263
try:
268264
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
269265
except ValueError:
270-
return jsonify({"error": "Invalid path"}), 400
266+
return json_error("Invalid path", 400)
271267

272268
if not os.path.isfile(filepath):
273-
return jsonify({"error": "Session not found"}), 404
269+
return json_error("Session not found", 404)
274270

275271
fmt = request.args.get("format", "md")
276-
session = parse_session(filepath)
277-
rules = current_app.config.get("EXCLUSION_RULES") or []
278-
if is_session_excluded(rules, session, project_name):
279-
return jsonify({"error": "Session not found"}), 404
280-
stats = compute_stats(session)
281-
title_slug = slugify(session["title"], default="session")
282-
283-
if fmt == "json":
284-
content = session_to_json(session, stats)
285-
buf = io.BytesIO(content.encode("utf-8"))
272+
try:
273+
session = parse_session(filepath)
274+
rules = current_app.config.get("EXCLUSION_RULES") or []
275+
if is_session_excluded(rules, session, project_name):
276+
return json_error("Session not found", 404)
277+
stats = compute_stats(session)
278+
title_slug = slugify(session["title"], default="session")
279+
280+
if fmt == "json":
281+
content = session_to_json(session, stats)
282+
buf = io.BytesIO(content.encode("utf-8"))
283+
buf.seek(0)
284+
return send_file(
285+
buf,
286+
mimetype="application/json",
287+
as_attachment=True,
288+
download_name=f"{title_slug}.json", # type: ignore[call-arg]
289+
)
290+
291+
md = session_to_markdown(session, stats)
292+
buf = io.BytesIO(md.encode("utf-8"))
286293
buf.seek(0)
287294
return send_file(
288295
buf,
289-
mimetype="application/json",
296+
mimetype="text/markdown",
290297
as_attachment=True,
291-
download_name=f"{title_slug}.json",
298+
download_name=f"{title_slug}.md", # type: ignore[call-arg]
292299
)
293-
294-
md = session_to_markdown(session, stats)
295-
buf = io.BytesIO(md.encode("utf-8"))
296-
buf.seek(0)
297-
return send_file(
298-
buf,
299-
mimetype="text/markdown",
300-
as_attachment=True,
301-
download_name=f"{title_slug}.md",
302-
)
300+
except Exception:
301+
current_app.logger.exception(
302+
"Failed to export session %s/%s", project_name, session_id
303+
)
304+
return json_error("Internal server error exporting session", 500)

api/projects.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,46 @@
11
"""Project listing endpoints."""
22

3-
from flask import Blueprint, current_app, jsonify
3+
from flask import Blueprint, current_app
44

5+
from api._flask_types import FlaskReturn, json_error, json_response
6+
from models.project import ProjectSessionRowDict, SessionListItemDict
7+
from models.session import SessionDict
58
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
69
from utils.exclusion_rules import is_session_excluded
710

811
projects_bp = Blueprint("projects", __name__)
912

1013

14+
def _session_row_ok(s: SessionListItemDict, parsed: SessionDict) -> ProjectSessionRowDict:
15+
meta = parsed["metadata"]
16+
models = meta.get("models_used", [])
17+
return {
18+
"id": s["id"],
19+
"path": s["path"],
20+
"size_bytes": s["size_bytes"],
21+
"modified": s["modified"],
22+
"title": parsed["title"],
23+
"models": sorted(models) if isinstance(models, set) else list(models),
24+
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
25+
"tool_calls": meta["total_tool_calls"],
26+
"first_timestamp": meta["first_timestamp"],
27+
"last_timestamp": meta["last_timestamp"],
28+
}
29+
30+
31+
def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
32+
return {
33+
"id": s["id"],
34+
"path": s["path"],
35+
"size_bytes": s["size_bytes"],
36+
"modified": s["modified"],
37+
"title": "Error parsing session",
38+
"error": True,
39+
}
40+
41+
1142
@projects_bp.route("/api/projects")
12-
def get_projects():
43+
def get_projects() -> FlaskReturn:
1344
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
1445
projects = list_projects(base)
1546

@@ -36,44 +67,35 @@ def get_projects():
3667
if latest_ts:
3768
project["last_modified"] = latest_ts
3869

39-
return jsonify(projects)
70+
return json_response(projects)
4071

4172

4273
@projects_bp.route("/api/projects/<path:project_name>/sessions")
43-
def get_project_sessions(project_name):
74+
def get_project_sessions(project_name: str) -> FlaskReturn:
4475
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4576
try:
4677
project_dir = safe_join(base, project_name)
4778
except ValueError:
48-
return jsonify([]), 400
79+
return json_response([]), 400
4980
sessions = list_sessions(project_dir)
5081
# Add summary preview for each session
5182
from utils.jsonl_parser import parse_session
5283
rules = current_app.config.get("EXCLUSION_RULES") or []
53-
result = []
84+
result: list[ProjectSessionRowDict] = []
5485
for s in sessions:
5586
try:
5687
parsed = parse_session(s["path"])
57-
meta = parsed["metadata"]
5888
# Skip untitled sessions (no real conversation)
5989
if parsed["title"] == "Untitled Session":
6090
continue
6191
if is_session_excluded(rules, parsed, project_name):
6292
continue
63-
result.append({
64-
**s,
65-
"title": parsed["title"],
66-
"models": meta["models_used"],
67-
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
68-
"tool_calls": meta["total_tool_calls"],
69-
"first_timestamp": meta["first_timestamp"],
70-
"last_timestamp": meta["last_timestamp"],
71-
})
93+
result.append(_session_row_ok(s, parsed))
7294
except Exception:
7395
# Full detail (class, message, traceback) to the server log via
7496
# logger.exception. The per-session card carries only `error: True`
7597
# — the class-name+message string was a leak (issue #25). The
7698
# operator looks at the server log for triage.
7799
current_app.logger.exception("Failed to parse session %s", s["id"])
78-
result.append({**s, "title": "Error parsing session", "error": True})
79-
return jsonify(result)
100+
result.append(_session_row_error(s))
101+
return json_response(result)

api/search.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,45 @@
44

55
from flask import Blueprint, current_app, jsonify, request
66

7+
from api._flask_types import FlaskReturn, json_error, json_response
8+
from models.search import SearchHitDict
79
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
810
from utils.jsonl_parser import parse_session
911
from utils.exclusion_rules import is_session_excluded
1012

1113
search_bp = Blueprint("search", __name__)
1214

15+
_DEFAULT_LIMIT = 50
16+
17+
18+
def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
19+
"""Parse a positive integer limit from a query string value."""
20+
if raw is None or raw.strip() == "":
21+
return default
22+
try:
23+
value = int(raw)
24+
except ValueError:
25+
raise ValueError("Invalid limit: must be a positive integer") from None
26+
if value < 1:
27+
raise ValueError("Invalid limit: must be a positive integer")
28+
return value
29+
1330

1431
@search_bp.route("/api/search")
15-
def search():
32+
def search() -> FlaskReturn:
1633
query = request.args.get("q", "").strip().lower()
1734
if not query:
18-
return jsonify([])
35+
return json_response([])
1936

20-
max_results = int(request.args.get("limit", 50))
37+
try:
38+
max_results = _parse_limit(request.args.get("limit"))
39+
except ValueError as e:
40+
return json_error(str(e), 400)
2141
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
2242
projects = list_projects(base)
2343

2444
rules = current_app.config.get("EXCLUSION_RULES") or []
25-
results = []
45+
results: list[SearchHitDict] = []
2646
for project in projects:
2747
sessions = list_sessions(project["path"])
2848
for sess_info in sessions:
@@ -56,4 +76,4 @@ def search():
5676
if len(results) >= max_results:
5777
break
5878

59-
return jsonify(results)
79+
return json_response(results)

0 commit comments

Comments
 (0)