Skip to content

Commit 72f0f96

Browse files
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
1 parent 4bbb456 commit 72f0f96

24 files changed

Lines changed: 569 additions & 170 deletions

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,20 @@ 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: requirements-dev.txt
71+
72+
- name: Install dev dependencies
73+
run: pip install -r requirements-dev.txt
74+
75+
- name: Run mypy (strict)
76+
run: mypy

api/_flask_types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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+
FlaskReturn = Union[Response, tuple[Response, int]]
8+
9+
10+
def json_ok(*args: Any, **kwargs: Any) -> Response:
11+
"""Typed wrapper around :func:`flask.jsonify`."""
12+
return cast(Response, jsonify(*args, **kwargs))

api/export_api.py

Lines changed: 26 additions & 22 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_ok
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_ok(
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_ok({"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_ok({"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:
@@ -227,7 +231,7 @@ def bulk_export():
227231

228232
if count == 0:
229233
return (
230-
jsonify(
234+
json_ok(
231235
{
232236
"error": "Nothing to export",
233237
"since": since,
@@ -251,12 +255,12 @@ def bulk_export():
251255
buf,
252256
mimetype="application/zip",
253257
as_attachment=True,
254-
download_name=f"claude-code-export{suffix}-{date_tag}.zip",
258+
download_name=f"claude-code-export{suffix}-{date_tag}.zip", # type: ignore[call-arg]
255259
)
256260

257261

258262
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
259-
def export_session(project_name, session_id):
263+
def export_session(project_name: str, session_id: str) -> FlaskReturn:
260264
import os
261265
from utils.session_path import safe_join
262266

@@ -267,16 +271,16 @@ def export_session(project_name, session_id):
267271
try:
268272
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
269273
except ValueError:
270-
return jsonify({"error": "Invalid path"}), 400
274+
return json_ok({"error": "Invalid path"}), 400
271275

272276
if not os.path.isfile(filepath):
273-
return jsonify({"error": "Session not found"}), 404
277+
return json_ok({"error": "Session not found"}), 404
274278

275279
fmt = request.args.get("format", "md")
276280
session = parse_session(filepath)
277281
rules = current_app.config.get("EXCLUSION_RULES") or []
278282
if is_session_excluded(rules, session, project_name):
279-
return jsonify({"error": "Session not found"}), 404
283+
return json_ok({"error": "Session not found"}), 404
280284
stats = compute_stats(session)
281285
title_slug = slugify(session["title"], default="session")
282286

@@ -288,7 +292,7 @@ def export_session(project_name, session_id):
288292
buf,
289293
mimetype="application/json",
290294
as_attachment=True,
291-
download_name=f"{title_slug}.json",
295+
download_name=f"{title_slug}.json", # type: ignore[call-arg]
292296
)
293297

294298
md = session_to_markdown(session, stats)
@@ -298,5 +302,5 @@ def export_session(project_name, session_id):
298302
buf,
299303
mimetype="text/markdown",
300304
as_attachment=True,
301-
download_name=f"{title_slug}.md",
305+
download_name=f"{title_slug}.md", # type: ignore[call-arg]
302306
)

api/projects.py

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

3+
from typing import cast
4+
35
from flask import Blueprint, current_app, jsonify
46

7+
from api._flask_types import FlaskReturn, json_ok
8+
from models.project import ProjectSessionRowDict
59
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
610
from utils.exclusion_rules import is_session_excluded
711

812
projects_bp = Blueprint("projects", __name__)
913

1014

1115
@projects_bp.route("/api/projects")
12-
def get_projects():
16+
def get_projects() -> FlaskReturn:
1317
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
1418
projects = list_projects(base)
1519

@@ -36,21 +40,21 @@ def get_projects():
3640
if latest_ts:
3741
project["last_modified"] = latest_ts
3842

39-
return jsonify(projects)
43+
return json_ok(projects)
4044

4145

4246
@projects_bp.route("/api/projects/<path:project_name>/sessions")
43-
def get_project_sessions(project_name):
47+
def get_project_sessions(project_name: str) -> FlaskReturn:
4448
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4549
try:
4650
project_dir = safe_join(base, project_name)
4751
except ValueError:
48-
return jsonify([]), 400
52+
return json_ok([]), 400
4953
sessions = list_sessions(project_dir)
5054
# Add summary preview for each session
5155
from utils.jsonl_parser import parse_session
5256
rules = current_app.config.get("EXCLUSION_RULES") or []
53-
result = []
57+
result: list[ProjectSessionRowDict] = []
5458
for s in sessions:
5559
try:
5660
parsed = parse_session(s["path"])
@@ -60,20 +64,25 @@ def get_project_sessions(project_name):
6064
continue
6165
if is_session_excluded(rules, parsed, project_name):
6266
continue
63-
result.append({
67+
models = meta.get("models_used", [])
68+
result.append(cast(ProjectSessionRowDict, {
6469
**s,
6570
"title": parsed["title"],
66-
"models": meta["models_used"],
71+
"models": sorted(models) if isinstance(models, set) else list(models),
6772
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
6873
"tool_calls": meta["total_tool_calls"],
6974
"first_timestamp": meta["first_timestamp"],
7075
"last_timestamp": meta["last_timestamp"],
71-
})
76+
}))
7277
except Exception:
7378
# Full detail (class, message, traceback) to the server log via
7479
# logger.exception. The per-session card carries only `error: True`
7580
# — the class-name+message string was a leak (issue #25). The
7681
# operator looks at the server log for triage.
7782
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)
83+
result.append(cast(ProjectSessionRowDict, {
84+
**s,
85+
"title": "Error parsing session",
86+
"error": True,
87+
}))
88+
return json_ok(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_ok
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_ok([])
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_ok({"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_ok(results)

api/sessions.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import os
44

5-
from flask import Blueprint, current_app, jsonify, abort
5+
from flask import Blueprint, current_app, jsonify
6+
7+
from api._flask_types import FlaskReturn, json_ok
68

79
from utils.session_path import get_claude_projects_dir, safe_join
810
from utils.jsonl_parser import parse_session
@@ -13,49 +15,49 @@
1315

1416

1517
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>")
16-
def get_session(project_name, session_id):
18+
def get_session(project_name: str, session_id: str) -> FlaskReturn:
1719
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
1820
try:
1921
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
2022
except ValueError:
21-
return jsonify({"error": "Invalid path"}), 400
23+
return json_ok({"error": "Invalid path"}), 400
2224

2325
if not os.path.isfile(filepath):
24-
return jsonify({"error": f"Session {session_id} not found"}), 404
26+
return json_ok({"error": f"Session {session_id} not found"}), 404
2527

2628
try:
2729
session = parse_session(filepath)
2830
rules = current_app.config.get("EXCLUSION_RULES") or []
2931
if is_session_excluded(rules, session, project_name):
30-
return jsonify({"error": "Session not found"}), 404
31-
return jsonify(session)
32+
return json_ok({"error": "Session not found"}), 404
33+
return json_ok(session)
3234
except Exception:
3335
# Full traceback (class name, message, stack) goes to the server log
3436
# via logger.exception. The HTTP body returns a stable, generic
3537
# message — never the class name or `e` itself, which would leak
3638
# internal field names, file paths, and user values to any client
3739
# (issue #25).
3840
current_app.logger.exception("Failed to parse session %s", session_id)
39-
return jsonify({"error": "Failed to parse session"}), 500
41+
return json_ok({"error": "Failed to parse session"}), 500
4042

4143

4244
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>/stats")
43-
def get_session_stats(project_name, session_id):
45+
def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
4446
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4547
try:
4648
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
4749
except ValueError:
48-
return jsonify({"error": "Invalid path"}), 400
50+
return json_ok({"error": "Invalid path"}), 400
4951

5052
if not os.path.isfile(filepath):
51-
return jsonify({"error": f"Session {session_id} not found"}), 404
53+
return json_ok({"error": f"Session {session_id} not found"}), 404
5254

5355
try:
5456
session = parse_session(filepath)
5557
stats = compute_stats(session)
56-
return jsonify(stats)
58+
return json_ok(stats)
5759
except Exception:
5860
# Same pattern as get_session above — full detail to the server log,
5961
# generic message in the HTTP body (issue #25).
6062
current_app.logger.exception("Failed to compute stats for %s", session_id)
61-
return jsonify({"error": "Failed to compute session stats"}), 500
63+
return json_ok({"error": "Failed to compute session stats"}), 500

0 commit comments

Comments
 (0)