Skip to content

Commit f194541

Browse files
Enable strict mypy and add typed search models (#100) (#103)
* feat: initial implementation * fix: test failing ssues * fix: review findings * fix: outside diff finding * fix: after rebasing * fix: Changed record_source_failure(self, exc: BaseException, …) → exc: Exception * fix: reviewer's findings * fix: coderabbitai's full review findings * fix: typecheck failure. * update gitignore file * fix: unify bubble KV loaders and converge DisplayBubble shapes Route all bubbleId:* loads through workspace_db._parse_bubble_kv_row and load_bubble_map (including workspace tabs). Introduce DisplayBubble/BubbleMetadata and utils/display_bubble builders shared by tabs, CLI, and IDE markdown export. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fe3c728 commit f194541

45 files changed

Lines changed: 990 additions & 715 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/tests.yml

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,9 @@ jobs:
132132
run: dist\CursorChatBrowser\CursorChatBrowser.exe --help
133133

134134
# ── Typecheck: mypy ───────────────────────────────────────────────────────
135-
# Codebase already has type hints across most of the surface (~70+ typed
136-
# functions). Mypy runs with --ignore-missing-imports for untyped
137-
# third-party deps; strict-optional is enabled (mypy default). The
138-
# transitional `continue-on-error: true` was removed in #29 once mypy
139-
# reached zero errors on this repo — type failures now block merges.
135+
# strict = true in pyproject.toml (issue #100). Per-module overrides skip
136+
# scripts/export.py and tests/ until those surfaces are fully annotated.
137+
# Type failures block merges — no continue-on-error.
140138
typecheck:
141139
name: Typecheck (mypy)
142140
runs-on: ubuntu-latest
@@ -151,15 +149,14 @@ jobs:
151149
- name: Install runtime deps + mypy
152150
# Install from the pinned lock file for deterministic resolution,
153151
# then add mypy (dev-only; not in requirements-lock.txt).
152+
# Flask 3.1+ ships inline types — do not install types-Flask (conflicts).
154153
run: |
155154
python -m pip install --upgrade pip
156155
python -m pip install -r requirements-lock.txt
157156
python -m pip install 'mypy>=1.10,<2'
158157
159158
- name: Run mypy
160-
# No `continue-on-error` — mypy now exits zero on this repo (closes #29),
161-
# so type errors must fail the job from here on.
162-
run: mypy --ignore-missing-imports --pretty .
159+
run: mypy .
163160

164161
# ── Secret scan: gitleaks ─────────────────────────────────────────────────
165162
# Catches accidentally committed credentials. Runs over full git history

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ __pycache__/
99
venv/
1010
.venv/
1111
env/
12+
.mypy-ci-test/
1213

1314
# Packaging
1415
*.egg-info/

CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
## [0.1.0] - 2026-06-04
11-
1210
### Added
11+
- **Strict mypy**`strict = true` in `pyproject.toml`; core TypedDict models
12+
(`SearchResult`, `ConversationSummary`) and full annotations on API routes and
13+
`utils/` (#100)
1314
- **Summary disk cache (Phase 3)** — project list and tab summaries cached under
1415
`~/.cache/cursor-chat-browser/`, invalidated when global or per-workspace DB
1516
mtimes change; bypass with `?nocache=1` or `CURSOR_CHAT_BROWSER_NOCACHE=1` (#84)
@@ -40,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4041
- Unit tests for `determine_project_for_conversation` fallback chain (#87, #89)
4142

4243
### Changed
44+
- CI typecheck job runs `mypy .` using pyproject config (strict production code;
45+
per-module overrides for `scripts/export.py` and `tests.*`)
4346
- **List-path performance** — skip full `messageRequestContext` scan unless
4447
invalid workspace aliases are needed; filter `composerData` in SQL; skip
4548
`Composer.from_dict` on list/summary paths; cache `composer_id_to_ws` mapping (#84)

api/composers.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
import os
1010
import sqlite3
1111
from contextlib import closing
12+
from typing import Any
1213

13-
from flask import Blueprint, jsonify
14+
from flask import Blueprint, Response
15+
16+
from api.flask_config import json_response
1417

1518
from utils.workspace_path import resolve_workspace_path
1619
from utils.path_helpers import to_epoch_ms
@@ -20,13 +23,13 @@
2023
_logger = logging.getLogger(__name__)
2124

2225

23-
def _read_json_file(path: str):
26+
def _read_json_file(path: str) -> Any:
2427
with open(path, "r", encoding="utf-8") as f:
2528
return json.load(f)
2629

2730

2831
@bp.route("/api/composers")
29-
def list_composers():
32+
def list_composers() -> tuple[Response, int] | Response:
3033
try:
3134
workspace_path = resolve_workspace_path()
3235
composers = []
@@ -112,15 +115,13 @@ def list_composers():
112115
)
113116

114117
composers.sort(key=lambda pair: to_epoch_ms(pair[0].last_updated_at), reverse=True)
115-
return jsonify([c for _, c in composers])
118+
return json_response([c for _, c in composers])
116119

117120
except Exception:
118121
_logger.exception("Failed to get composers")
119-
return jsonify({"error": "Failed to get composers"}), 500
120-
121-
122+
return json_response({"error": "Failed to get composers"}, 500)
122123
@bp.route("/api/composers/<composer_id>")
123-
def get_composer(composer_id):
124+
def get_composer(composer_id: str) -> tuple[Response, int] | Response:
124125
try:
125126
workspace_path = resolve_workspace_path()
126127

@@ -182,7 +183,7 @@ def get_composer(composer_id):
182183
# the composer (CodeRabbit on PR #30).
183184
payload = dict(local.raw)
184185
payload["conversation"] = payload.get("conversation") or []
185-
return jsonify(payload)
186+
return json_response(payload)
186187
except SchemaError as e:
187188
_logger.warning(
188189
"Schema drift in %s: %s (%s)",
@@ -218,15 +219,14 @@ def get_composer(composer_id):
218219
e,
219220
type(e).__name__,
220221
)
221-
return jsonify({"error": "Composer schema drift"}), 404
222+
return json_response({"error": "Composer schema drift"}, 404)
222223
payload = dict(composer.raw)
223224
payload["conversation"] = payload.get("conversation") or []
224-
return jsonify(payload)
225+
return json_response(payload)
225226
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
226227
pass
227228

228-
return jsonify({"error": "Composer not found"}), 404
229-
229+
return json_response({"error": "Composer not found"}, 404)
230230
except Exception:
231231
_logger.exception("Failed to get composer")
232-
return jsonify({"error": "Failed to get composer"}), 500
232+
return json_response({"error": "Failed to get composer"}, 500)

api/config_api.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import subprocess
1212
import sys
1313

14-
from flask import Blueprint, jsonify, request
14+
from flask import Blueprint, Response, request
15+
16+
from api.flask_config import json_response
1517

1618
from utils.path_validation import WorkspacePathError, validate_workspace_path
1719
from utils.workspace_path import set_workspace_path_override
@@ -21,7 +23,7 @@
2123

2224

2325
@bp.route("/api/detect-environment")
24-
def detect_environment():
26+
def detect_environment() -> Response:
2527
try:
2628
is_wsl = False
2729
is_remote = bool(
@@ -39,7 +41,7 @@ def detect_environment():
3941
except Exception:
4042
pass
4143

42-
return jsonify({
44+
return json_response({
4345
"os": sys.platform,
4446
"isWSL": is_wsl,
4547
"isRemote": is_remote,
@@ -52,23 +54,23 @@ def detect_environment():
5254
type(e).__name__,
5355
exc_info=True,
5456
)
55-
return jsonify({"os": "unknown", "isWSL": False, "isRemote": False})
57+
return json_response({"os": "unknown", "isWSL": False, "isRemote": False})
5658

5759

5860
@bp.route("/api/validate-path", methods=["POST"])
59-
def validate_path():
61+
def validate_path() -> tuple[Response, int] | Response:
6062
"""Same path rules as POST /api/set-workspace: realpath, markers (issue #15)."""
6163
try:
6264
body = request.get_json(silent=True) or {}
6365
if not isinstance(body, dict):
64-
return jsonify(
66+
return json_response(
6567
{"valid": False, "error": "invalid JSON body", "workspaceCount": 0}
6668
)
6769
raw = body.get("path", "")
6870
try:
6971
canonical = validate_workspace_path(raw)
7072
except WorkspacePathError as e:
71-
return jsonify({"valid": False, "error": str(e), "workspaceCount": 0})
73+
return json_response({"valid": False, "error": str(e), "workspaceCount": 0})
7274

7375
workspace_count = 0
7476
for name in os.listdir(canonical):
@@ -78,7 +80,7 @@ def validate_path():
7880
if os.path.isfile(db):
7981
workspace_count += 1
8082

81-
return jsonify(
83+
return json_response(
8284
{
8385
"valid": workspace_count > 0,
8486
"workspaceCount": workspace_count,
@@ -93,19 +95,17 @@ def validate_path():
9395
type(e).__name__,
9496
exc_info=True,
9597
)
96-
return jsonify({"valid": False, "error": "Failed to validate path"}), 500
97-
98-
98+
return json_response({"valid": False, "error": "Failed to validate path"}, 500)
9999
@bp.route("/api/set-workspace", methods=["POST"])
100-
def set_workspace():
100+
def set_workspace() -> tuple[Response, int] | Response:
101101
# Reject non-dict JSON bodies (array / string / number / null). Without
102102
# this, get_json returns the value directly, the truthy fallback `or {}`
103103
# is bypassed, and `body.get("path", "")` raises AttributeError — which
104104
# the outer Exception handler then mis-reports as a 500 server error
105105
# instead of a 400 client error. (CodeRabbit on PR #16.)
106106
body = request.get_json(silent=True)
107107
if not isinstance(body, dict):
108-
return jsonify({"error": "request body must be a JSON object"}), 400
108+
return json_response({"error": "request body must be a JSON object"}, 400)
109109
raw = body.get("path", "")
110110
# Validate the supplied path BEFORE storing the override (issue #15).
111111
# validate_workspace_path collapses `..` traversal AND resolves symlinks
@@ -115,18 +115,18 @@ def set_workspace():
115115
try:
116116
canonical = validate_workspace_path(raw)
117117
except WorkspacePathError as e:
118-
return jsonify({"error": str(e)}), 400
118+
return json_response({"error": str(e)}, 400)
119119
except Exception: # noqa: BLE001 — only here as a fallback
120-
return jsonify({"error": "Failed to validate workspace path"}), 500
120+
return json_response({"error": "Failed to validate workspace path"}, 500)
121121
try:
122122
set_workspace_path_override(canonical)
123123
except Exception: # noqa: BLE001 — keep the response shape structured JSON
124-
return jsonify({"error": "Failed to set workspace path"}), 500
125-
return jsonify({"success": True, "path": canonical})
124+
return json_response({"error": "Failed to set workspace path"}, 500)
125+
return json_response({"success": True, "path": canonical})
126126

127127

128128
@bp.route("/api/get-username")
129-
def get_username():
129+
def get_username() -> Response:
130130
try:
131131
username = "YOUR_USERNAME"
132132

@@ -144,7 +144,7 @@ def get_username():
144144
import getpass
145145
username = getpass.getuser()
146146

147-
return jsonify({"username": username})
147+
return json_response({"username": username})
148148

149149
except Exception as e:
150150
_logger.warning(
@@ -153,4 +153,4 @@ def get_username():
153153
type(e).__name__,
154154
exc_info=True,
155155
)
156-
return jsonify({"username": "YOUR_USERNAME"})
156+
return json_response({"username": "YOUR_USERNAME"})

api/export_api.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
import zipfile
1313
from datetime import datetime
1414
from pathlib import Path
15+
from typing import Any, cast
1516

16-
from flask import Blueprint, Response, jsonify, request
17+
from flask import Blueprint, Response, request
1718

18-
from api.flask_config import exclusion_rules
19+
from api.flask_config import exclusion_rules, json_response
1920

2021
from utils.workspace_path import resolve_workspace_path
2122
from utils.path_helpers import to_epoch_ms
@@ -38,13 +39,13 @@ def _get_state_dir() -> str:
3839
return os.path.join(str(Path.home()), ".cursor-chat-browser")
3940

4041

41-
def _get_export_state() -> dict:
42+
def _get_export_state() -> dict[str, Any]:
4243
"""Read the export state file."""
4344
state_path = os.path.join(_get_state_dir(), "export_state.json")
4445
if os.path.isfile(state_path):
4546
try:
4647
with open(state_path, "r", encoding="utf-8") as f:
47-
return json.load(f)
48+
return cast(dict[str, Any], json.load(f))
4849
except (json.JSONDecodeError, ValueError, OSError) as e:
4950
_logger.warning(
5051
"Could not read export state from %s: %s",
@@ -54,7 +55,7 @@ def _get_export_state() -> dict:
5455
return {}
5556

5657

57-
def _save_export_state(count: int):
58+
def _save_export_state(count: int) -> None:
5859
"""Save export state after an export."""
5960
state_dir = _get_state_dir()
6061
os.makedirs(state_dir, exist_ok=True)
@@ -68,14 +69,14 @@ def _save_export_state(count: int):
6869

6970

7071
@bp.route("/api/export/state")
71-
def get_export_state():
72+
def get_export_state() -> Response:
7273
"""Return the last export timestamp."""
7374
state = _get_export_state()
74-
return jsonify(state)
75+
return json_response(state)
7576

7677

7778
@bp.route("/api/export", methods=["POST"])
78-
def export_chats():
79+
def export_chats() -> tuple[Response, int] | Response:
7980
"""Export chats as a zip archive.
8081
8182
Exclusion rules (``EXCLUSION_RULES`` app config key) are evaluated against
@@ -112,14 +113,13 @@ def export_chats():
112113
ws_id_to_slug[e["name"]] = slug(display)
113114

114115
today = datetime.now().strftime("%Y-%m-%d")
115-
exported = []
116+
exported: list[dict[str, Any]] = []
116117
rules = exclusion_rules()
117118

118119
# ── Database reading via service layer ────────────────────────────────
119120
with open_global_db(workspace_path) as (global_db, _):
120121
if global_db is None:
121-
return jsonify({"error": "Cursor global storage not found"}), 404
122-
122+
return json_response({"error": "Cursor global storage not found"}, 404)
123123
bubble_map = load_bubble_map(global_db)
124124
code_block_diff_map = load_code_block_diff_map(global_db)
125125

@@ -199,10 +199,9 @@ def export_chats():
199199

200200
count = len(exported)
201201
if count == 0:
202-
return jsonify({"error": "No conversations to export" + (
202+
return json_response({"error": "No conversations to export" + (
203203
" since last export" if since == "last" else ""
204-
)}), 404
205-
204+
)}, 404)
206205
buf = io.BytesIO()
207206
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
208207
for entry in exported:
@@ -228,4 +227,4 @@ def export_chats():
228227
type(e).__name__,
229228
exc_info=True,
230229
)
231-
return jsonify({"error": "Export failed"}), 500
230+
return json_response({"error": "Export failed"}, 500)

api/flask_config.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,31 @@
22

33
from __future__ import annotations
44

5-
from flask import current_app
5+
from typing import Any, overload
66

7+
from flask import Response, current_app, jsonify
78

8-
def exclusion_rules() -> list:
9+
10+
def exclusion_rules() -> list[list[Any]]:
911
"""Return loaded exclusion rules from app config (empty list when unset)."""
1012
return current_app.config.get("EXCLUSION_RULES") or []
13+
14+
15+
@overload
16+
def json_response(data: Any) -> Response: ...
17+
18+
19+
@overload
20+
def json_response(data: Any, status: int) -> tuple[Response, int]: ...
21+
22+
23+
def json_response(
24+
data: Any,
25+
status: int | None = None,
26+
) -> Response | tuple[Response, int]:
27+
"""Typed wrapper around :func:`flask.jsonify` for strict mypy."""
28+
response = jsonify(data)
29+
assert isinstance(response, Response)
30+
if status is None:
31+
return response
32+
return response, status

0 commit comments

Comments
 (0)