Skip to content

Commit cb32fca

Browse files
committed
Merge origin/master into feat/split-api-workspaces-25
Resolves the structural conflict in api/workspaces.py: PR #31's whole point is splitting the 1400-line monolith into services (HEAD has the thin route handlers that delegate to services.workspace_listing / services.workspace_tabs / services.cli_tabs / services.workspace_resolver), while master had #35's lint cleanup applied to the still-inline monolith. Took HEAD's structure wholesale via `git checkout --ours` then layered in master's print → _logger.exception pattern on the three thin route-handler error catches (list_workspaces / get_workspace / tabs). Plus two ruff E741 cleanups (1-char `l → ln` renames) in services/cli_tabs.py:63 and services/workspace_tabs.py:348 — these existed on PR #31's branch because the lint pass on master fixed the same pattern in api/workspaces.py but PR #31 extracted to services before that pass landed. Verified locally: - ruff check api/ utils/ scripts/export.py app.py services/ → All checks passed - unittest discover tests → 207 / 207 OK Known follow-up (NOT in this commit): mypy gate (newly enforced by #35) now flags 27 pre-existing errors in services/workspace_tabs.py (26) and services/workspace_resolver.py (1) — all "object has no attribute X" style, the upstream dict types are too loose. Out of scope for a merge resolution; flagging to Brad for a separate tightening pass on the services/ modules.
2 parents ca2d597 + 4210226 commit cb32fca

13 files changed

Lines changed: 73 additions & 68 deletions

File tree

.github/workflows/tests.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ jobs:
5555
# Codebase already has type hints across most of the surface (~70+ typed
5656
# functions). Mypy runs in lenient mode (--ignore-missing-imports for
5757
# untyped third-party deps; no strict-optional) so the gate isn't a wall
58-
# of false positives on first run. continue-on-error keeps findings as
59-
# warnings during the surface-cleanup phase; flip to required by removing
60-
# continue-on-error once the surface is clean.
58+
# of false positives. The transitional `continue-on-error: true` was
59+
# removed in #29 once mypy reached zero errors on this repo — type
60+
# failures now block merges.
6161
typecheck:
6262
name: Typecheck (mypy)
6363
runs-on: ubuntu-latest
@@ -75,9 +75,8 @@ jobs:
7575
python -m pip install 'flask>=3.0' 'fpdf2>=2.7' 'mypy>=1.10'
7676
7777
- name: Run mypy
78-
# Transitional only (maintainer consensus): keeps CI green until `mypy` exits
79-
# zero on this repo — then delete this line so type errors fail the job.
80-
continue-on-error: true
78+
# No `continue-on-error` — mypy now exits zero on this repo (closes #29),
79+
# so type errors must fail the job from here on.
8180
run: mypy --ignore-missing-imports --no-strict-optional --pretty .
8281

8382
# ── Secret scan: gitleaks ─────────────────────────────────────────────────

api/composers.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import json
8+
import logging
89
import os
910
import sqlite3
1011
from contextlib import closing
@@ -16,6 +17,7 @@
1617
from utils.workspace_descriptor import _read_json_file
1718

1819
bp = Blueprint("composers", __name__)
20+
_logger = logging.getLogger(__name__)
1921

2022

2123
@bp.route("/api/composers")
@@ -63,8 +65,8 @@ def list_composers():
6365
composers.sort(key=lambda c: to_epoch_ms(c.get("lastUpdatedAt")), reverse=True)
6466
return jsonify(composers)
6567

66-
except Exception as e:
67-
print(f"Failed to get composers: {e}")
68+
except Exception:
69+
_logger.exception("Failed to get composers")
6870
return jsonify({"error": "Failed to get composers"}), 500
6971

7072

@@ -118,6 +120,6 @@ def get_composer(composer_id):
118120

119121
return jsonify({"error": "Composer not found"}), 404
120122

121-
except Exception as e:
122-
print(f"Failed to get composer: {e}")
123+
except Exception:
124+
_logger.exception("Failed to get composer")
123125
return jsonify({"error": "Failed to get composer"}), 500

api/export_api.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import os
1010
import re
1111
import sqlite3
12-
import sys
1312
import zipfile
1413
from contextlib import closing
1514
from datetime import datetime
@@ -18,7 +17,7 @@
1817
from flask import Blueprint, Response, current_app, jsonify, request
1918

2019
from utils.workspace_path import resolve_workspace_path
21-
from utils.path_helpers import normalize_file_path, get_workspace_folder_paths, to_epoch_ms
20+
from utils.path_helpers import get_workspace_folder_paths, to_epoch_ms
2221
from utils.text_extract import extract_text_from_bubble
2322
from utils.tool_parser import parse_tool_call
2423
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
@@ -390,15 +389,15 @@ def export_chats():
390389
status_str = f" ({tool_status})" if tool_status else ""
391390
md += f"> **Tool: {tool_summary}**{status_str}\n"
392391
if t.get("input"):
393-
md += f">\n> **INPUT:**\n> ```\n"
392+
md += ">\n> **INPUT:**\n> ```\n"
394393
for iline in str(t["input"]).split("\n"):
395394
md += f"> {iline}\n"
396-
md += f"> ```\n"
395+
md += "> ```\n"
397396
if t.get("output"):
398-
md += f">\n> **OUTPUT:**\n> ```\n"
397+
md += ">\n> **OUTPUT:**\n> ```\n"
399398
for oline in str(t["output"]).split("\n"):
400399
md += f"> {oline}\n"
401-
md += f"> ```\n"
400+
md += "> ```\n"
402401
md += "\n"
403402
md += "---\n\n"
404403

api/logs.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import json
7+
import logging
78
import os
89
import re
910
import sqlite3
@@ -16,6 +17,7 @@
1617
from utils.path_helpers import to_epoch_ms
1718

1819
bp = Blueprint("logs", __name__)
20+
_logger = logging.getLogger(__name__)
1921

2022

2123
def _extract_chat_id_from_bubble_key(key: str) -> str | None:
@@ -69,8 +71,8 @@ def get_logs():
6971
"type": "chat",
7072
"messageCount": len(bubbles),
7173
})
72-
except Exception as e:
73-
print(f"Error reading global storage: {e}")
74+
except Exception:
75+
_logger.exception("Error reading global storage")
7476

7577
# Per-workspace (legacy)
7678
try:
@@ -133,9 +135,9 @@ def get_logs():
133135
except Exception:
134136
pass
135137

136-
logs.sort(key=lambda l: l.get("timestamp") or 0, reverse=True)
138+
logs.sort(key=lambda log: log.get("timestamp") or 0, reverse=True)
137139
return jsonify({"logs": logs})
138140

139-
except Exception as e:
140-
print(f"Failed to get logs: {e}")
141+
except Exception:
142+
_logger.exception("Failed to get logs")
141143
return jsonify({"error": "Failed to get logs", "logs": []}), 500

api/search.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import json
7+
import logging
78
import os
89
import re
910
import sqlite3
@@ -15,11 +16,12 @@
1516

1617
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
1718
from utils.workspace_path import resolve_workspace_path, get_cli_chats_path
18-
from utils.path_helpers import normalize_file_path, get_workspace_folder_paths, to_epoch_ms
19+
from utils.path_helpers import to_epoch_ms
1920
from utils.text_extract import extract_text_from_bubble
2021
from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles
2122

2223
bp = Blueprint("search", __name__)
24+
_logger = logging.getLogger(__name__)
2325

2426

2527
def _json_dump_safe(value) -> str:
@@ -51,7 +53,7 @@ def _build_exclusion_searchable(
5153
metadata_parts: list[str] | None = None,
5254
) -> str:
5355
"""Build broad searchable text so exclusion rules cover visible output."""
54-
combined = []
56+
combined: list[str] = []
5557
if content_parts:
5658
combined.extend(p for p in content_parts if p)
5759
if metadata_parts:
@@ -231,7 +233,7 @@ def search():
231233
# Derive title from first bubble
232234
for text in bubble_texts:
233235
if text:
234-
first_lines = [l for l in text.split("\n") if l.strip()]
236+
first_lines = [ln for ln in text.split("\n") if ln.strip()]
235237
if first_lines:
236238
title = first_lines[0][:100]
237239
break
@@ -250,8 +252,8 @@ def search():
250252
except Exception:
251253
pass
252254

253-
except Exception as e:
254-
print(f"Error searching global storage: {e}")
255+
except Exception:
256+
_logger.exception("Error searching global storage")
255257
finally:
256258
if conn is not None:
257259
conn.close()
@@ -435,8 +437,8 @@ def search():
435437
"type": "cli_agent",
436438
"source": "cli",
437439
})
438-
except Exception as e:
439-
print(f"Error searching CLI sessions: {e}")
440+
except Exception:
441+
_logger.exception("Error searching CLI sessions")
440442

441443
# Sort by timestamp descending
442444
def _ts(r):
@@ -451,6 +453,6 @@ def _ts(r):
451453

452454
return jsonify({"results": results})
453455

454-
except Exception as e:
455-
print(f"Search failed: {e}")
456+
except Exception:
457+
_logger.exception("Search failed")
456458
return jsonify({"error": "Search failed", "results": []}), 500

api/workspaces.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import logging
1011
import os
1112
from datetime import datetime, timezone
1213

@@ -30,6 +31,7 @@
3031
from services.workspace_tabs import assemble_workspace_tabs
3132

3233
bp = Blueprint("workspaces", __name__)
34+
_logger = logging.getLogger(__name__)
3335

3436

3537
# ---------------------------------------------------------------------------
@@ -43,8 +45,8 @@ def list_workspaces():
4345
rules = current_app.config.get("EXCLUSION_RULES") or []
4446
projects = list_workspace_projects(workspace_path, rules)
4547
return jsonify(projects)
46-
except Exception as e:
47-
print(f"Failed to get workspaces: {e}")
48+
except Exception:
49+
_logger.exception("Failed to get workspaces")
4850
return jsonify({"error": "Failed to get workspaces"}), 500
4951

5052

@@ -120,8 +122,8 @@ def get_workspace(workspace_id):
120122
"lastModified": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(),
121123
})
122124

123-
except Exception as e:
124-
print(f"Failed to get workspace: {e}")
125+
except Exception:
126+
_logger.exception("Failed to get workspace")
125127
return jsonify({"error": "Failed to get workspace"}), 500
126128

127129

@@ -138,7 +140,7 @@ def get_workspace_tabs(workspace_id):
138140
rules = current_app.config.get("EXCLUSION_RULES") or []
139141
payload, status = assemble_workspace_tabs(workspace_id, workspace_path, rules)
140142
return jsonify(payload), status
141-
except Exception as e:
142-
print(f"Failed to get workspace tabs: {e}")
143+
except Exception:
144+
_logger.exception("Failed to get workspace tabs")
143145
return jsonify({"error": "Failed to get workspace tabs"}), 500
144146

app.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ def favicon():
9090

9191
if __name__ == "__main__":
9292
import argparse
93-
import sys
9493

9594
parser = argparse.ArgumentParser(description="Cursor Chat Browser (Python)")
9695
parser.add_argument("--port", type=int, default=3000)

scripts/__init__.py

Whitespace-only changes.

scripts/export.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,23 @@
2222
if str(_project_root) not in sys.path:
2323
sys.path.insert(0, str(_project_root))
2424

25-
from utils.exclusion_rules import (
25+
# noqa: E402 — these imports must come after the sys.path.insert above so the
26+
# script can be run directly as `python scripts/export.py` from anywhere.
27+
from utils.exclusion_rules import ( # noqa: E402
2628
resolve_exclusion_rules_path,
2729
load_rules,
2830
build_searchable_text,
2931
is_excluded_by_rules,
3032
)
31-
from utils.path_helpers import get_workspace_folder_paths as _shared_get_workspace_folder_paths
32-
from utils.tool_parser import parse_tool_call
33-
from utils.workspace_path import get_cli_chats_path
34-
from utils.cli_chat_reader import (
33+
from utils.path_helpers import get_workspace_folder_paths as _shared_get_workspace_folder_paths # noqa: E402
34+
from utils.tool_parser import parse_tool_call # noqa: E402
35+
from utils.workspace_path import get_cli_chats_path # noqa: E402
36+
from utils.cli_chat_reader import ( # noqa: E402
3537
list_cli_projects,
3638
traverse_blobs,
3739
messages_to_bubbles,
38-
aggregate_session_stats,
3940
)
40-
from utils.cursor_md_exporter import cursor_cli_session_to_markdown
41+
from utils.cursor_md_exporter import cursor_cli_session_to_markdown # noqa: E402
4142

4243
_logger = logging.getLogger(__name__)
4344

@@ -52,7 +53,7 @@ def _json_dump_safe(value) -> str:
5253

5354
def _load_manifest_entries(manifest_path: str) -> dict:
5455
"""Load manifest entries keyed by log_id from a JSONL file."""
55-
existing = {}
56+
existing: dict = {}
5657
if not os.path.isfile(manifest_path):
5758
return existing
5859
try:
@@ -347,9 +348,9 @@ def main():
347348
layouts = ctx.get("projectLayouts")
348349
if isinstance(layouts, list):
349350
project_layouts_map.setdefault(cid, [])
350-
for l in layouts:
351+
for layout in layouts:
351352
try:
352-
o = json.loads(l) if isinstance(l, str) else l
353+
o = json.loads(layout) if isinstance(layout, str) else layout
353354
if isinstance(o, dict) and o.get("rootPath"):
354355
project_layouts_map[cid].append(o["rootPath"])
355356
except Exception:
@@ -682,7 +683,7 @@ def assign_workspace(cd, cid):
682683
created_ms = to_epoch_ms(cd.get("createdAt")) or ts
683684
fm_lines = ["---"]
684685
fm_lines.append(f"log_id: {composer_id}")
685-
fm_lines.append(f"log_type: chat")
686+
fm_lines.append("log_type: chat")
686687
fm_lines.append(f'title: "{title.replace(chr(34), chr(92)+chr(34))}"')
687688
fm_lines.append(f"created_at: {datetime.fromtimestamp(created_ms / 1000).isoformat()}")
688689
fm_lines.append(f"updated_at: {datetime.fromtimestamp(updated_at / 1000).isoformat() if updated_at else datetime.now().isoformat()}")
@@ -930,25 +931,25 @@ def assign_workspace(cd, cid):
930931
zip_name = f"cursor-export-{today}.zip"
931932
zip_path = os.path.join(out_dir, zip_name)
932933
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
933-
for e in exported:
934-
zf.writestr(e["rel_path"], e["content"])
934+
for entry in exported:
935+
zf.writestr(entry["rel_path"], entry["content"])
935936
print(f"Exported {count} chat(s) to {zip_path}")
936937
else:
937938
# Write individual Markdown files to disk
938-
for e in exported:
939-
os.makedirs(os.path.dirname(e["out_path"]), exist_ok=True)
940-
with open(e["out_path"], "w", encoding="utf-8") as f:
941-
f.write(e["content"])
939+
for entry in exported:
940+
os.makedirs(os.path.dirname(entry["out_path"]), exist_ok=True)
941+
with open(entry["out_path"], "w", encoding="utf-8") as f:
942+
f.write(entry["content"])
942943

943944
# Manifest in output directory
944945
manifest_path = os.path.join(out_dir, "manifest.jsonl")
945946
existing = _load_manifest_entries(manifest_path)
946947

947-
for e in exported:
948-
existing[e["id"]] = {
949-
"log_id": e["id"],
950-
"path": os.path.relpath(e["out_path"], out_dir),
951-
"updated_at": datetime.fromtimestamp(e["updatedAt"] / 1000).isoformat() if e["updatedAt"] else datetime.now().isoformat(),
948+
for entry in exported:
949+
existing[entry["id"]] = {
950+
"log_id": entry["id"],
951+
"path": os.path.relpath(entry["out_path"], out_dir),
952+
"updated_at": datetime.fromtimestamp(entry["updatedAt"] / 1000).isoformat() if entry["updatedAt"] else datetime.now().isoformat(),
952953
}
953954

954955
if existing:
@@ -957,11 +958,11 @@ def assign_workspace(cd, cid):
957958
# Canonical manifest in user state dir so tracking survives changing --out paths
958959
global_manifest_path = os.path.join(state_dir, "manifest.jsonl")
959960
global_existing = _load_manifest_entries(global_manifest_path)
960-
for e in exported:
961-
global_existing[e["id"]] = {
962-
"log_id": e["id"],
963-
"path": e["out_path"],
964-
"updated_at": datetime.fromtimestamp(e["updatedAt"] / 1000).isoformat() if e["updatedAt"] else datetime.now().isoformat(),
961+
for entry in exported:
962+
global_existing[entry["id"]] = {
963+
"log_id": entry["id"],
964+
"path": entry["out_path"],
965+
"updated_at": datetime.fromtimestamp(entry["updatedAt"] / 1000).isoformat() if entry["updatedAt"] else datetime.now().isoformat(),
965966
}
966967
if global_existing:
967968
_write_manifest_entries(global_manifest_path, global_existing)

services/cli_tabs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def _get_cli_workspace_tabs(workspace_id: str):
6060
if not title or title.startswith("New Agent"):
6161
for b in bubbles:
6262
if b["type"] == "user" and b.get("text"):
63-
first_lines = [l for l in b["text"].split("\n") if l.strip()]
63+
first_lines = [ln for ln in b["text"].split("\n") if ln.strip()]
6464
if first_lines:
6565
title = first_lines[0][:100]
6666
if len(title) == 100:

0 commit comments

Comments
 (0)