Skip to content

Commit 964813f

Browse files
refactor: shared session load path for session and export APIs (#125)
* refactor: shared session load path for session and export APIs * fix: handle exclusion failures in session load try block * style: ruff format test_error_propagation.py
1 parent cd7d4a0 commit 964813f

7 files changed

Lines changed: 240 additions & 169 deletions

File tree

api/_session_handlers.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Shared resolve/load/exclude/error helpers for session API handlers."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from collections.abc import Callable
7+
from dataclasses import dataclass
8+
9+
from flask import current_app
10+
11+
from api._flask_types import FlaskReturn
12+
from api.error_codes import ErrorCode, error_response
13+
from models.session import SessionDict
14+
from models.stats import SessionStatsDict
15+
from utils.exclusion_rules import is_session_excluded
16+
from utils.session_cache import get_cached_session
17+
from utils.session_errors import SESSION_LOAD_ERRORS
18+
from utils.session_path import get_claude_projects_dir, safe_join
19+
from utils.session_stats import compute_stats
20+
21+
__all__ = [
22+
"SESSION_LOAD_ERRORS",
23+
"LoadedSession",
24+
"resolve_loaded_session",
25+
"compute_stats_or_error",
26+
]
27+
28+
29+
@dataclass(frozen=True)
30+
class LoadedSession:
31+
session: SessionDict
32+
filepath: str
33+
34+
35+
def resolve_loaded_session(
36+
project_name: str,
37+
session_id: str,
38+
*,
39+
missing_file_message: str | Callable[[str], str],
40+
parse_log_action: str = "Failed to parse session %s",
41+
) -> LoadedSession | FlaskReturn:
42+
"""Resolve path, load session, and apply exclusion rules.
43+
44+
Returns ``LoadedSession`` on success or an ``error_response`` tuple/Response.
45+
"""
46+
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
47+
try:
48+
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
49+
except ValueError:
50+
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
51+
52+
if not os.path.isfile(filepath):
53+
msg = (
54+
missing_file_message(session_id)
55+
if callable(missing_file_message)
56+
else missing_file_message
57+
)
58+
return error_response(ErrorCode.SESSION_NOT_FOUND, msg, 404)
59+
60+
try:
61+
session = get_cached_session(filepath)
62+
rules = current_app.config.get("EXCLUSION_RULES") or []
63+
if is_session_excluded(rules, session, project_name):
64+
return error_response(
65+
ErrorCode.SESSION_NOT_FOUND,
66+
"Session not found",
67+
404,
68+
)
69+
except SESSION_LOAD_ERRORS:
70+
current_app.logger.exception(parse_log_action, session_id)
71+
return error_response(
72+
ErrorCode.PARSE_ERROR,
73+
"Failed to parse session",
74+
500,
75+
)
76+
77+
return LoadedSession(session=session, filepath=filepath)
78+
79+
80+
def compute_stats_or_error(
81+
session: SessionDict,
82+
session_id: str,
83+
*,
84+
log_action: str,
85+
) -> SessionStatsDict | FlaskReturn:
86+
try:
87+
return compute_stats(session)
88+
except SESSION_LOAD_ERRORS:
89+
current_app.logger.exception(log_action, session_id)
90+
return error_response(
91+
ErrorCode.INTERNAL_ERROR,
92+
"Failed to compute session stats",
93+
500,
94+
)

api/export_api.py

Lines changed: 44 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,21 @@
22

33
import io
44
import json
5-
import os
65
import zipfile
76
from datetime import datetime
87
from typing import Any
98

109
from flask import Blueprint, current_app, request, send_file
1110

1211
from api._flask_types import FlaskReturn, json_response
12+
from api._session_handlers import (
13+
LoadedSession,
14+
compute_stats_or_error,
15+
resolve_loaded_session,
16+
)
1317
from api.error_codes import ErrorCode, error_response
1418
from models.export import ExportStateDict
15-
from utils.exclusion_rules import is_session_excluded
16-
from utils.export_engine import (
17-
EXPORT_ERRORS as _EXPORT_ERRORS,
18-
ExportFailure,
19-
ZipSink,
20-
run_bulk_export,
21-
)
19+
from utils.export_engine import ExportFailure, ZipSink, run_bulk_export
2220
from utils.export_state_store import (
2321
EXPORT_STATE_FILE,
2422
atomic_write_export_state,
@@ -27,9 +25,7 @@
2725
)
2826
from utils.json_exporter import session_to_json
2927
from utils.md_exporter import session_to_markdown
30-
from utils.session_cache import get_cached_session
31-
from utils.session_path import get_claude_projects_dir, list_projects, safe_join
32-
from utils.session_stats import compute_stats
28+
from utils.session_path import get_claude_projects_dir, list_projects
3329
from utils.slugify import slugify
3430

3531
export_bp = Blueprint("export", __name__)
@@ -222,67 +218,41 @@ def _on_export_error(sid: str, exc: Exception) -> None:
222218

223219
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
224220
def export_session(project_name: str, session_id: str) -> FlaskReturn:
225-
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
226-
try:
227-
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
228-
except ValueError:
229-
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
230-
231-
if not os.path.isfile(filepath):
232-
return error_response(
233-
ErrorCode.SESSION_NOT_FOUND,
234-
"Session not found",
235-
404,
236-
)
237-
238-
fmt = request.args.get("format", "md")
239-
try:
240-
session = get_cached_session(filepath)
241-
except _EXPORT_ERRORS:
242-
current_app.logger.exception("Failed to parse session %s for export", session_id)
243-
return error_response(
244-
ErrorCode.PARSE_ERROR,
245-
"Failed to parse session",
246-
500,
247-
)
248-
249-
rules = current_app.config.get("EXCLUSION_RULES") or []
250-
if is_session_excluded(rules, session, project_name):
251-
return error_response(
252-
ErrorCode.SESSION_NOT_FOUND,
253-
"Session not found",
254-
404,
255-
)
256-
257-
try:
258-
stats = compute_stats(session)
259-
except _EXPORT_ERRORS:
260-
current_app.logger.exception("Failed to compute stats for export %s", session_id)
261-
return error_response(
262-
ErrorCode.INTERNAL_ERROR,
263-
"Failed to compute session stats",
264-
500,
265-
)
266-
267-
title_slug = slugify(session["title"], default="session")
268-
269-
if fmt == "json":
270-
content = session_to_json(session, stats)
271-
buf = io.BytesIO(content.encode("utf-8"))
272-
buf.seek(0)
273-
return send_file(
274-
buf,
275-
mimetype="application/json",
276-
as_attachment=True,
277-
download_name=f"{title_slug}.json", # type: ignore[call-arg]
278-
)
279-
280-
md = session_to_markdown(session, stats)
281-
buf = io.BytesIO(md.encode("utf-8"))
282-
buf.seek(0)
283-
return send_file(
284-
buf,
285-
mimetype="text/markdown",
286-
as_attachment=True,
287-
download_name=f"{title_slug}.md", # type: ignore[call-arg]
221+
loaded = resolve_loaded_session(
222+
project_name,
223+
session_id,
224+
missing_file_message="Session not found",
225+
parse_log_action="Failed to parse session %s for export",
288226
)
227+
if isinstance(loaded, LoadedSession):
228+
fmt = request.args.get("format", "md")
229+
stats = compute_stats_or_error(
230+
loaded.session,
231+
session_id,
232+
log_action="Failed to compute stats for export %s",
233+
)
234+
if isinstance(stats, dict):
235+
title_slug = slugify(loaded.session["title"], default="session")
236+
237+
if fmt == "json":
238+
content = session_to_json(loaded.session, stats)
239+
buf = io.BytesIO(content.encode("utf-8"))
240+
buf.seek(0)
241+
return send_file(
242+
buf,
243+
mimetype="application/json",
244+
as_attachment=True,
245+
download_name=f"{title_slug}.json", # type: ignore[call-arg]
246+
)
247+
248+
md = session_to_markdown(loaded.session, stats)
249+
buf = io.BytesIO(md.encode("utf-8"))
250+
buf.seek(0)
251+
return send_file(
252+
buf,
253+
mimetype="text/markdown",
254+
as_attachment=True,
255+
download_name=f"{title_slug}.md", # type: ignore[call-arg]
256+
)
257+
return stats
258+
return loaded

api/sessions.py

Lines changed: 31 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,47 @@
11
"""Session detail and stats endpoints."""
22

3-
import json
4-
import os
5-
6-
from flask import Blueprint, current_app
3+
from flask import Blueprint
74

85
from api._flask_types import FlaskReturn, json_response
9-
from api.error_codes import ErrorCode, error_response
10-
from utils.exclusion_rules import is_session_excluded
11-
from utils.session_cache import get_cached_session
12-
from utils.session_path import get_claude_projects_dir, safe_join
13-
from utils.session_stats import compute_stats
6+
from api._session_handlers import (
7+
LoadedSession,
8+
compute_stats_or_error,
9+
resolve_loaded_session,
10+
)
1411

1512
sessions_bp = Blueprint("sessions", __name__)
1613

17-
_PARSE_ERRORS = (
18-
json.JSONDecodeError,
19-
KeyError,
20-
ValueError,
21-
OSError,
22-
FileNotFoundError,
23-
)
14+
15+
def _missing_session_message(session_id: str) -> str:
16+
return f"Session {session_id} not found"
2417

2518

2619
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>")
2720
def get_session(project_name: str, session_id: str) -> FlaskReturn:
28-
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
29-
try:
30-
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
31-
except ValueError:
32-
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
33-
34-
if not os.path.isfile(filepath):
35-
return error_response(
36-
ErrorCode.SESSION_NOT_FOUND,
37-
f"Session {session_id} not found",
38-
404,
39-
)
40-
41-
try:
42-
session = get_cached_session(filepath)
43-
rules = current_app.config.get("EXCLUSION_RULES") or []
44-
if is_session_excluded(rules, session, project_name):
45-
return error_response(
46-
ErrorCode.SESSION_NOT_FOUND,
47-
"Session not found",
48-
404,
49-
)
50-
return json_response(session)
51-
except _PARSE_ERRORS:
52-
current_app.logger.exception("Failed to parse session %s", session_id)
53-
return error_response(
54-
ErrorCode.PARSE_ERROR,
55-
"Failed to parse session",
56-
500,
57-
)
21+
loaded = resolve_loaded_session(
22+
project_name,
23+
session_id,
24+
missing_file_message=_missing_session_message,
25+
)
26+
if isinstance(loaded, LoadedSession):
27+
return json_response(loaded.session)
28+
return loaded
5829

5930

6031
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>/stats")
6132
def get_session_stats(project_name: str, session_id: str) -> FlaskReturn:
62-
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
63-
try:
64-
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
65-
except ValueError:
66-
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
67-
68-
if not os.path.isfile(filepath):
69-
return error_response(
70-
ErrorCode.SESSION_NOT_FOUND,
71-
f"Session {session_id} not found",
72-
404,
73-
)
74-
75-
try:
76-
session = get_cached_session(filepath)
77-
rules = current_app.config.get("EXCLUSION_RULES") or []
78-
if is_session_excluded(rules, session, project_name):
79-
return error_response(
80-
ErrorCode.SESSION_NOT_FOUND,
81-
"Session not found",
82-
404,
83-
)
84-
except _PARSE_ERRORS:
85-
current_app.logger.exception("Failed to parse session %s", session_id)
86-
return error_response(
87-
ErrorCode.PARSE_ERROR,
88-
"Failed to parse session",
89-
500,
90-
)
91-
92-
try:
93-
stats = compute_stats(session)
94-
return json_response(stats)
95-
except _PARSE_ERRORS:
96-
current_app.logger.exception("Failed to compute stats for %s", session_id)
97-
return error_response(
98-
ErrorCode.INTERNAL_ERROR,
99-
"Failed to compute session stats",
100-
500,
33+
loaded = resolve_loaded_session(
34+
project_name,
35+
session_id,
36+
missing_file_message=_missing_session_message,
37+
)
38+
if isinstance(loaded, LoadedSession):
39+
stats = compute_stats_or_error(
40+
loaded.session,
41+
session_id,
42+
log_action="Failed to compute stats for %s",
10143
)
44+
if isinstance(stats, dict):
45+
return json_response(stats)
46+
return stats
47+
return loaded

tests/test_api_routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_session_detail_parse_failure_returns_500_without_leak(client, monkeypat
6666
def _boom(*_args, **_kwargs):
6767
raise KeyError("internal_secret_field_id")
6868

69-
monkeypatch.setattr("api.sessions.get_cached_session", _boom)
69+
monkeypatch.setattr("api._session_handlers.get_cached_session", _boom)
7070
resp = client.get("/api/sessions/test-project/session_abc123")
7171
assert resp.status_code == 500
7272
body_text = resp.get_data(as_text=True)

0 commit comments

Comments
 (0)