From 9f38b1ea9cf9a1af0e2b0482f86e75b10b19f008 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:08:34 +0000 Subject: [PATCH] Add debug CLI implementation plan --- docs/plans/2026-02-22-debug-cli-plan.md | 1214 +++++++++++++++++++++++ 1 file changed, 1214 insertions(+) create mode 100644 docs/plans/2026-02-22-debug-cli-plan.md diff --git a/docs/plans/2026-02-22-debug-cli-plan.md b/docs/plans/2026-02-22-debug-cli-plan.md new file mode 100644 index 0000000..aeba139 --- /dev/null +++ b/docs/plans/2026-02-22-debug-cli-plan.md @@ -0,0 +1,1214 @@ +# Debug CLI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a `debug` command group to the CLI that mirrors all web UI interactions, with switchable direct/HTTP execution modes and user impersonation. + +**Architecture:** New `cli/` package with one module per domain (listings, decisions, pois, tasks, districts). A shared `_context.py` handles user identity resolution, HTTP client with self-signed JWT, and output formatting. The `debug` group is registered in `main.py`. + +**Tech Stack:** Click (existing), httpx (existing), PyJWT (existing), no new dependencies. + +--- + +### Task 1: Create `cli/` package with shared context + +**Files:** +- Create: `cli/__init__.py` +- Create: `cli/_context.py` + +**Step 1: Create `cli/__init__.py`** + +```python +"""Debug CLI package — mirrors web UI interactions for debugging.""" +``` + +**Step 2: Create `cli/_context.py` with CliContext, user resolution, JWT minting, and output helpers** + +```python +"""Shared context for the debug CLI.""" +import json +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt + +from database import engine +from repositories.user_repository import UserRepository + + +@dataclass +class CliContext: + """Shared state passed through Click context.""" + user_email: str + use_http: bool + json_output: bool + api_base_url: str + + +def resolve_user_id(email: str) -> int: + """Get or create a database user by email. Returns the DB user ID.""" + user_repo = UserRepository(engine) + db_user = user_repo.get_user_by_email(email) + if db_user is None: + db_user = user_repo.create_user(email) + if db_user.id is None: + raise RuntimeError(f"Failed to resolve user ID for {email}") + return db_user.id + + +def mint_jwt(email: str) -> str: + """Create a self-signed JWT for HTTP mode (passkey-style HS256 token).""" + secret = os.getenv("JWT_SECRET", "change-me-in-production") + issuer = os.getenv("JWT_ISSUER", "wrongmove") + algorithm = os.getenv("JWT_ALGORITHM", "HS256") + payload = { + "sub": email, + "email": email, + "name": email, + "iss": issuer, + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + return jwt.encode(payload, secret, algorithm=algorithm) + + +def get_http_headers(email: str) -> dict[str, str]: + """Build HTTP headers with Authorization bearer token.""" + token = mint_jwt(email) + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def output(data: Any, json_mode: bool) -> None: + """Print data in JSON or human-readable format.""" + if json_mode: + print(json.dumps(data, indent=2, default=str)) + elif isinstance(data, list): + if not data: + print("No results.") + return + # Print as simple table if list of dicts + if isinstance(data[0], dict): + keys = list(data[0].keys()) + print(" ".join(f"{k:<20}" for k in keys)) + print(" ".join("-" * 20 for _ in keys)) + for row in data: + print(" ".join(f"{str(row.get(k, '')):<20}" for k in keys)) + else: + for item in data: + print(f" {item}") + elif isinstance(data, dict): + for k, v in data.items(): + print(f" {k}: {v}") + else: + print(data) + + +def error_output(message: str, json_mode: bool) -> None: + """Print an error message.""" + if json_mode: + print(json.dumps({"error": message}), file=sys.stderr) + else: + print(f"Error: {message}", file=sys.stderr) +``` + +**Step 3: Commit** + +```bash +git add cli/__init__.py cli/_context.py +git commit -m "Add cli/ package with shared debug context" +``` + +--- + +### Task 2: Create `cli/districts.py` + +This is the simplest subcommand — good to wire up the full pattern first. + +**Files:** +- Create: `cli/districts.py` + +**Step 1: Create `cli/districts.py`** + +```python +"""Debug CLI — district commands.""" +import click +import httpx + +from cli._context import CliContext, get_http_headers, output, error_output +from services import district_service + + +@click.group("districts") +def districts_group() -> None: + """District management commands.""" + pass + + +@districts_group.command("list") +@click.pass_context +def list_districts(ctx: click.Context) -> None: + """List all available districts.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/get_districts", + headers=get_http_headers(cli_ctx.user_email), + ) + resp.raise_for_status() + data = resp.json() + else: + data = district_service.get_all_districts() + + if cli_ctx.json_output: + output(data, json_mode=True) + else: + print(f"Available districts ({len(data)}):") + for name in sorted(data.keys()): + print(f" {name}: {data[name]}") + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) +``` + +**Step 2: Commit** + +```bash +git add cli/districts.py +git commit -m "Add debug CLI districts subcommand" +``` + +--- + +### Task 3: Register `debug` group in `main.py` + +**Files:** +- Modify: `main.py` (add imports and debug group at bottom, before `if __name__`) + +**Step 1: Add the debug group and register all subcommand groups** + +Add to `main.py` after the existing commands and before `if __name__ == "__main__":`: + +```python +from cli._context import CliContext +from cli.districts import districts_group + + +@cli.group("debug") +@click.option("--user-email", "-u", required=True, help="Email of user to impersonate") +@click.option("--http", "use_http", is_flag=True, default=False, help="Use HTTP requests instead of direct service calls") +@click.option("--json", "json_output", is_flag=True, default=False, help="Output in JSON format") +@click.option("--api-url", default="http://localhost:8000", help="API base URL for HTTP mode") +@click.pass_context +def debug(ctx: click.Context, user_email: str, use_http: bool, json_output: bool, api_url: str) -> None: + """Debug CLI — mirrors web UI interactions with superuser access.""" + ctx.ensure_object(dict) + ctx.obj["cli_ctx"] = CliContext( + user_email=user_email, + use_http=use_http, + json_output=json_output, + api_base_url=api_url, + ) + + +debug.add_command(districts_group) +``` + +**Step 2: Write test for debug group registration** + +Create `tests/unit/test_debug_cli.py`: + +```python +"""Tests for the debug CLI.""" +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from main import cli + + +class TestDebugGroup: + """Tests for the debug command group.""" + + @patch("main.engine", new_callable=MagicMock) + def test_debug_help_shows_subcommands(self, mock_engine: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["debug", "--help"]) + assert result.exit_code == 0 + assert "districts" in result.output + + @patch("main.engine", new_callable=MagicMock) + def test_debug_requires_user_email(self, mock_engine: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["debug", "districts", "list"]) + assert result.exit_code != 0 + assert "user-email" in result.output.lower() or "Missing" in result.output + + +class TestDistrictsCommand: + """Tests for debug districts subcommand.""" + + @patch("cli.districts.district_service.get_all_districts") + @patch("main.engine", new_callable=MagicMock) + def test_list_districts_direct( + self, mock_engine: MagicMock, mock_get: MagicMock + ) -> None: + mock_get.return_value = {"London": "R1", "Camden": "R2"} + runner = CliRunner() + result = runner.invoke( + cli, ["debug", "-u", "test@example.com", "districts", "list"] + ) + assert result.exit_code == 0 + assert "London" in result.output + + @patch("cli.districts.district_service.get_all_districts") + @patch("main.engine", new_callable=MagicMock) + def test_list_districts_json( + self, mock_engine: MagicMock, mock_get: MagicMock + ) -> None: + mock_get.return_value = {"London": "R1"} + runner = CliRunner() + result = runner.invoke( + cli, ["debug", "-u", "test@example.com", "--json", "districts", "list"] + ) + assert result.exit_code == 0 + import json + data = json.loads(result.output) + assert "London" in data +``` + +**Step 3: Run tests** + +Run: `pytest tests/unit/test_debug_cli.py -v` +Expected: All pass + +**Step 4: Commit** + +```bash +git add main.py tests/unit/test_debug_cli.py +git commit -m "Register debug group in main.py with districts subcommand" +``` + +--- + +### Task 4: Create `cli/decisions.py` + +**Files:** +- Create: `cli/decisions.py` + +**Step 1: Create `cli/decisions.py`** + +```python +"""Debug CLI — decision commands (like/dislike).""" +import click +import httpx + +from cli._context import CliContext, get_http_headers, output, error_output, resolve_user_id +from database import engine +from repositories.decision_repository import DecisionRepository +from services import decision_service + + +@click.group("decisions") +def decisions_group() -> None: + """Like/dislike decision commands.""" + pass + + +@decisions_group.command("set") +@click.argument("listing_id", type=int) +@click.argument("decision", type=click.Choice(["liked", "disliked"])) +@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"])) +@click.pass_context +def set_decision(ctx: click.Context, listing_id: int, decision: str, listing_type: str) -> None: + """Set a like/dislike decision for a listing.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.put( + f"{cli_ctx.api_base_url}/api/decisions/{listing_id}", + headers=get_http_headers(cli_ctx.user_email), + json={"decision": decision, "listing_type": listing_type}, + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = DecisionRepository(engine) + result = decision_service.set_decision(repo, user_id, listing_id, listing_type, decision) + data = { + "listing_id": result.listing_id, + "listing_type": result.listing_type, + "decision": result.decision, + "created_at": str(result.created_at), + "updated_at": str(result.updated_at), + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@decisions_group.command("list") +@click.pass_context +def list_decisions(ctx: click.Context) -> None: + """List all decisions for the user.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/decisions", + headers=get_http_headers(cli_ctx.user_email), + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = DecisionRepository(engine) + decisions = decision_service.get_user_decisions(repo, user_id) + data = [ + { + "listing_id": d.listing_id, + "listing_type": d.listing_type, + "decision": d.decision, + "created_at": str(d.created_at), + "updated_at": str(d.updated_at), + } + for d in decisions + ] + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@decisions_group.command("remove") +@click.argument("listing_id", type=int) +@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"])) +@click.pass_context +def remove_decision(ctx: click.Context, listing_id: int, listing_type: str) -> None: + """Remove a decision (un-like/un-dislike).""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.delete( + f"{cli_ctx.api_base_url}/api/decisions/{listing_id}", + headers=get_http_headers(cli_ctx.user_email), + params={"listing_type": listing_type}, + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = DecisionRepository(engine) + deleted = decision_service.remove_decision(repo, user_id, listing_id, listing_type) + if not deleted: + error_output("Decision not found", cli_ctx.json_output) + return + data = {"success": True} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) +``` + +**Step 2: Register in `main.py`** + +Add import `from cli.decisions import decisions_group` and `debug.add_command(decisions_group)`. + +**Step 3: Add tests to `tests/unit/test_debug_cli.py`** + +```python +class TestDecisionsCommand: + """Tests for debug decisions subcommand.""" + + @patch("cli.decisions.decision_service.set_decision") + @patch("cli.decisions.resolve_user_id", return_value=1) + @patch("main.engine", new_callable=MagicMock) + def test_set_decision_direct( + self, mock_engine: MagicMock, mock_resolve: MagicMock, mock_set: MagicMock + ) -> None: + from datetime import datetime + mock_set.return_value = MagicMock( + listing_id=123, listing_type="RENT", decision="liked", + created_at=datetime(2025, 1, 1), updated_at=datetime(2025, 1, 1), + ) + runner = CliRunner() + result = runner.invoke( + cli, ["debug", "-u", "test@example.com", "decisions", "set", "123", "liked", "-t", "RENT"] + ) + assert result.exit_code == 0 + mock_set.assert_called_once() + + @patch("cli.decisions.decision_service.get_user_decisions") + @patch("cli.decisions.resolve_user_id", return_value=1) + @patch("main.engine", new_callable=MagicMock) + def test_list_decisions_direct( + self, mock_engine: MagicMock, mock_resolve: MagicMock, mock_list: MagicMock + ) -> None: + mock_list.return_value = [] + runner = CliRunner() + result = runner.invoke( + cli, ["debug", "-u", "test@example.com", "decisions", "list"] + ) + assert result.exit_code == 0 +``` + +**Step 4: Run tests** + +Run: `pytest tests/unit/test_debug_cli.py -v` +Expected: All pass + +**Step 5: Commit** + +```bash +git add cli/decisions.py main.py tests/unit/test_debug_cli.py +git commit -m "Add debug CLI decisions subcommand (set, list, remove)" +``` + +--- + +### Task 5: Create `cli/pois.py` + +**Files:** +- Create: `cli/pois.py` + +**Step 1: Create `cli/pois.py`** + +```python +"""Debug CLI — POI commands.""" +import click +import httpx + +from cli._context import CliContext, get_http_headers, output, error_output, resolve_user_id +from database import engine +from models.listing import ListingType +from repositories.poi_repository import POIRepository +from services import poi_service, task_service + + +@click.group("pois") +def pois_group() -> None: + """Point of Interest management commands.""" + pass + + +@pois_group.command("list") +@click.pass_context +def list_pois(ctx: click.Context) -> None: + """List all POIs for the user.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/poi", + headers=get_http_headers(cli_ctx.user_email), + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = POIRepository(engine) + pois = poi_service.get_user_pois(repo, user_id) + data = [ + { + "id": p.id, + "name": p.name, + "address": p.address, + "latitude": p.latitude, + "longitude": p.longitude, + "created_at": str(p.created_at), + } + for p in pois + ] + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@pois_group.command("create") +@click.option("--name", required=True, help="POI name") +@click.option("--address", required=True, help="Address") +@click.option("--lat", required=True, type=float, help="Latitude") +@click.option("--lon", required=True, type=float, help="Longitude") +@click.pass_context +def create_poi(ctx: click.Context, name: str, address: str, lat: float, lon: float) -> None: + """Create a new POI.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.post( + f"{cli_ctx.api_base_url}/api/poi", + headers=get_http_headers(cli_ctx.user_email), + json={"name": name, "address": address, "latitude": lat, "longitude": lon}, + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = POIRepository(engine) + result = poi_service.create_poi(repo, user_id, name, address, lat, lon) + data = { + "id": result.poi.id, + "name": result.poi.name, + "address": result.poi.address, + "latitude": result.poi.latitude, + "longitude": result.poi.longitude, + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@pois_group.command("update") +@click.argument("poi_id", type=int) +@click.option("--name", default=None, help="New name") +@click.option("--address", default=None, help="New address") +@click.option("--lat", default=None, type=float, help="New latitude") +@click.option("--lon", default=None, type=float, help="New longitude") +@click.pass_context +def update_poi(ctx: click.Context, poi_id: int, name: str | None, address: str | None, lat: float | None, lon: float | None) -> None: + """Update an existing POI.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + body: dict = {} + if name is not None: + body["name"] = name + if address is not None: + body["address"] = address + if lat is not None: + body["latitude"] = lat + if lon is not None: + body["longitude"] = lon + resp = httpx.put( + f"{cli_ctx.api_base_url}/api/poi/{poi_id}", + headers=get_http_headers(cli_ctx.user_email), + json=body, + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = POIRepository(engine) + result = poi_service.update_poi(repo, poi_id, user_id, name, address, lat, lon) + if result is None: + error_output("POI not found", cli_ctx.json_output) + return + data = { + "id": result.poi.id, + "name": result.poi.name, + "address": result.poi.address, + "latitude": result.poi.latitude, + "longitude": result.poi.longitude, + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@pois_group.command("delete") +@click.argument("poi_id", type=int) +@click.pass_context +def delete_poi(ctx: click.Context, poi_id: int) -> None: + """Delete a POI.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.delete( + f"{cli_ctx.api_base_url}/api/poi/{poi_id}", + headers=get_http_headers(cli_ctx.user_email), + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = POIRepository(engine) + deleted = poi_service.delete_poi(repo, poi_id, user_id) + if not deleted: + error_output("POI not found", cli_ctx.json_output) + return + data = {"success": True} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@pois_group.command("calculate") +@click.argument("poi_id", type=int) +@click.option("--travel-modes", required=True, help="Comma-separated: WALK,BICYCLE,TRANSIT") +@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"])) +@click.pass_context +def calculate_distances(ctx: click.Context, poi_id: int, travel_modes: str, listing_type: str) -> None: + """Trigger distance calculation for a POI.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + modes = [m.strip().upper() for m in travel_modes.split(",")] + lt = ListingType[listing_type] + + try: + if cli_ctx.use_http: + resp = httpx.post( + f"{cli_ctx.api_base_url}/api/poi/{poi_id}/calculate", + headers=get_http_headers(cli_ctx.user_email), + json={"travel_modes": modes, "listing_type": listing_type}, + ) + resp.raise_for_status() + data = resp.json() + else: + result = poi_service.trigger_calculation( + poi_id=poi_id, + travel_modes=modes, + listing_type=lt, + user_email=cli_ctx.user_email, + ) + if result.task_id: + task_service.add_task_for_user(cli_ctx.user_email, result.task_id) + data = {"task_id": result.task_id or "", "message": result.message} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@pois_group.command("distances") +@click.argument("listing_id", type=int) +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.pass_context +def get_distances(ctx: click.Context, listing_id: int, listing_type: str) -> None: + """Get POI distances for a specific listing.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + lt = ListingType[listing_type] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/poi/distances", + headers=get_http_headers(cli_ctx.user_email), + params={"listing_id": listing_id, "listing_type": listing_type}, + ) + resp.raise_for_status() + data = resp.json() + else: + user_id = resolve_user_id(cli_ctx.user_email) + repo = POIRepository(engine) + distances = poi_service.get_distances_for_listing(repo, listing_id, lt, user_id) + data = [ + { + "poi_id": d.poi_id, + "travel_mode": d.travel_mode, + "duration_seconds": d.duration_seconds, + "distance_meters": d.distance_meters, + } + for d in distances + ] + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) +``` + +**Step 2: Register in `main.py`** + +Add import `from cli.pois import pois_group` and `debug.add_command(pois_group)`. + +**Step 3: Add tests** + +```python +class TestPOIsCommand: + """Tests for debug pois subcommand.""" + + @patch("cli.pois.poi_service.get_user_pois") + @patch("cli.pois.resolve_user_id", return_value=1) + @patch("main.engine", new_callable=MagicMock) + def test_list_pois_empty( + self, mock_engine: MagicMock, mock_resolve: MagicMock, mock_pois: MagicMock + ) -> None: + mock_pois.return_value = [] + runner = CliRunner() + result = runner.invoke( + cli, ["debug", "-u", "test@example.com", "pois", "list"] + ) + assert result.exit_code == 0 + assert "No results" in result.output +``` + +**Step 4: Run tests and commit** + +```bash +pytest tests/unit/test_debug_cli.py -v +git add cli/pois.py main.py tests/unit/test_debug_cli.py +git commit -m "Add debug CLI POI subcommand (list, create, update, delete, calculate, distances)" +``` + +--- + +### Task 6: Create `cli/tasks.py` + +**Files:** +- Create: `cli/tasks.py` + +**Step 1: Create `cli/tasks.py`** + +```python +"""Debug CLI — task management commands.""" +import click +import httpx + +from cli._context import CliContext, get_http_headers, output, error_output +from services import task_service + + +@click.group("tasks") +def tasks_group() -> None: + """Background task management commands.""" + pass + + +@tasks_group.command("status") +@click.argument("task_id") +@click.pass_context +def task_status(ctx: click.Context, task_id: str) -> None: + """Get the status of a background task.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/task_status", + headers=get_http_headers(cli_ctx.user_email), + params={"task_id": task_id}, + ) + resp.raise_for_status() + data = resp.json() + else: + status = task_service.get_task_status(task_id) + data = { + "task_id": status.task_id, + "status": status.status, + "progress": status.progress, + "processed": status.processed, + "total": status.total, + "message": status.message, + "error": status.error, + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@tasks_group.command("list") +@click.pass_context +def list_tasks(ctx: click.Context) -> None: + """List all task IDs for the user.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/tasks_for_user", + headers=get_http_headers(cli_ctx.user_email), + ) + resp.raise_for_status() + data = resp.json() + else: + data = task_service.get_user_tasks(cli_ctx.user_email) + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@tasks_group.command("cancel") +@click.argument("task_id") +@click.pass_context +def cancel_task(ctx: click.Context, task_id: str) -> None: + """Cancel a running task.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.post( + f"{cli_ctx.api_base_url}/api/cancel_task", + headers=get_http_headers(cli_ctx.user_email), + params={"task_id": task_id}, + ) + resp.raise_for_status() + data = resp.json() + else: + task_service.cancel_task(task_id, user_email=cli_ctx.user_email) + data = {"success": True, "message": "Task cancelled"} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@tasks_group.command("clear") +@click.pass_context +def clear_tasks(ctx: click.Context) -> None: + """Clear all tasks for the user.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.post( + f"{cli_ctx.api_base_url}/api/clear_all_tasks", + headers=get_http_headers(cli_ctx.user_email), + ) + resp.raise_for_status() + data = resp.json() + else: + count = task_service.clear_all_tasks(cli_ctx.user_email) + data = {"success": True, "count": count, "message": f"Cleared {count} tasks"} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) +``` + +**Step 2: Register in `main.py`** + +Add import `from cli.tasks import tasks_group` and `debug.add_command(tasks_group)`. + +**Step 3: Commit** + +```bash +git add cli/tasks.py main.py +git commit -m "Add debug CLI tasks subcommand (status, list, cancel, clear)" +``` + +--- + +### Task 7: Create `cli/listings.py` + +**Files:** +- Create: `cli/listings.py` + +**Step 1: Create `cli/listings.py`** + +```python +"""Debug CLI — listing commands.""" +import asyncio +import json + +import click +import httpx + +from cli._context import CliContext, get_http_headers, output, error_output, resolve_user_id +from database import engine +from models.listing import ListingType, QueryParameters +from repositories.listing_repository import ListingRepository +from services import listing_service, export_service, task_service + + +@click.group("listings") +def listings_group() -> None: + """Listing browsing and management commands.""" + pass + + +@listings_group.command("list") +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.option("--limit", default=10, type=int, help="Max listings to return") +@click.pass_context +def list_listings(ctx: click.Context, listing_type: str, limit: int) -> None: + """List listings from the database.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/listing", + headers=get_http_headers(cli_ctx.user_email), + params={"limit": limit}, + ) + resp.raise_for_status() + data = resp.json() + else: + repository = ListingRepository(engine=engine) + result = asyncio.run(listing_service.get_listings(repository, limit=limit)) + data = { + "total_count": result.total_count, + "listings": [ + { + "id": l.id, + "price": l.price, + "bedrooms": l.number_of_bedrooms, + "sqm": l.square_meters, + "url": l.url, + } + for l in result.listings + ], + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@listings_group.command("detail") +@click.argument("listing_id", type=int) +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.pass_context +def listing_detail(ctx: click.Context, listing_id: int, listing_type: str) -> None: + """Get detailed information for a single listing.""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + + try: + if cli_ctx.use_http: + resp = httpx.get( + f"{cli_ctx.api_base_url}/api/listing/{listing_id}/detail", + headers=get_http_headers(cli_ctx.user_email), + params={"listing_type": listing_type}, + ) + resp.raise_for_status() + data = resp.json() + else: + repository = ListingRepository(engine=engine) + lt = ListingType(listing_type) + listings = asyncio.run(repository.get_listings(only_ids=[listing_id], listing_type=lt)) + if not listings: + error_output("Listing not found", cli_ctx.json_output) + return + listing = listings[0] + data = { + "id": listing.id, + "price": listing.price, + "bedrooms": listing.number_of_bedrooms, + "sqm": listing.square_meters, + "agency": listing.agency, + "url": listing.url, + "council_tax_band": listing.council_tax_band, + } + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@listings_group.command("stream") +@click.option("--type", "-t", "listing_type", default="RENT", type=click.Choice(["RENT", "BUY"])) +@click.option("--batch-size", default=50, type=int) +@click.option("--limit", default=None, type=int) +@click.option("--min-bedrooms", default=1, type=int) +@click.option("--max-bedrooms", default=999, type=int) +@click.option("--min-price", default=0, type=int) +@click.option("--max-price", default=10_000_000, type=int) +@click.pass_context +def stream_listings( + ctx: click.Context, + listing_type: str, + batch_size: int, + limit: int | None, + min_bedrooms: int, + max_bedrooms: int, + min_price: int, + max_price: int, +) -> None: + """Stream listings as NDJSON (mirrors the streaming API endpoint).""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + lt = ListingType[listing_type] + qp = QueryParameters( + listing_type=lt, + min_bedrooms=min_bedrooms, + max_bedrooms=max_bedrooms, + min_price=min_price, + max_price=max_price, + ) + + try: + if cli_ctx.use_http: + params: dict = { + "listing_type": listing_type, + "batch_size": batch_size, + "min_bedrooms": min_bedrooms, + "max_bedrooms": max_bedrooms, + "min_price": min_price, + "max_price": max_price, + } + if limit is not None: + params["limit"] = limit + with httpx.stream( + "GET", + f"{cli_ctx.api_base_url}/api/listing_geojson/stream", + headers=get_http_headers(cli_ctx.user_email), + params=params, + ) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if line.strip(): + print(line) + else: + result = asyncio.run( + export_service.export_to_geojson(repository=ListingRepository(engine=engine), query_parameters=qp, limit=limit) + ) + if cli_ctx.json_output: + output(result.data, json_mode=True) + else: + features = result.data.get("features", []) if result.data else [] + print(f"Streamed {len(features)} features") + for f in features[:5]: + props = f.get("properties", {}) + print(f" [{props.get('id')}] {props.get('displayAddress', 'N/A')} - £{props.get('price', '?')}") + if len(features) > 5: + print(f" ... and {len(features) - 5} more") + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) + + +@listings_group.command("refresh") +@click.option("--type", "-t", "listing_type", required=True, type=click.Choice(["RENT", "BUY"])) +@click.option("--min-bedrooms", default=1, type=int) +@click.option("--max-bedrooms", default=10, type=int) +@click.option("--min-price", default=0, type=int) +@click.option("--max-price", default=999_999, type=int) +@click.pass_context +def refresh_listings( + ctx: click.Context, + listing_type: str, + min_bedrooms: int, + max_bedrooms: int, + min_price: int, + max_price: int, +) -> None: + """Trigger a listing refresh (scrape from Rightmove).""" + cli_ctx: CliContext = ctx.obj["cli_ctx"] + lt = ListingType[listing_type] + qp = QueryParameters( + listing_type=lt, + min_bedrooms=min_bedrooms, + max_bedrooms=max_bedrooms, + min_price=min_price, + max_price=max_price, + ) + + try: + if cli_ctx.use_http: + resp = httpx.post( + f"{cli_ctx.api_base_url}/api/refresh_listings", + headers=get_http_headers(cli_ctx.user_email), + params={ + "listing_type": listing_type, + "min_bedrooms": min_bedrooms, + "max_bedrooms": max_bedrooms, + "min_price": min_price, + "max_price": max_price, + }, + ) + resp.raise_for_status() + data = resp.json() + else: + repository = ListingRepository(engine=engine) + result = asyncio.run( + listing_service.refresh_listings( + repository, qp, async_mode=False, + ) + ) + data = {"message": result.message, "new_count": result.new_listings_count} + + output(data, cli_ctx.json_output) + except httpx.HTTPStatusError as e: + error_output(f"HTTP {e.response.status_code}: {e.response.text}", cli_ctx.json_output) + except Exception as e: + error_output(str(e), cli_ctx.json_output) +``` + +**Step 2: Register in `main.py`** + +Add import `from cli.listings import listings_group` and `debug.add_command(listings_group)`. + +**Step 3: Commit** + +```bash +git add cli/listings.py main.py +git commit -m "Add debug CLI listings subcommand (list, detail, stream, refresh)" +``` + +--- + +### Task 8: Final wiring, run full test suite, commit + +**Step 1: Verify `main.py` imports are complete** + +The final imports section and registration at the bottom of `main.py` should look like: + +```python +from cli._context import CliContext +from cli.districts import districts_group +from cli.decisions import decisions_group +from cli.pois import pois_group +from cli.tasks import tasks_group +from cli.listings import listings_group + +# ... debug group definition ... + +debug.add_command(districts_group) +debug.add_command(decisions_group) +debug.add_command(pois_group) +debug.add_command(tasks_group) +debug.add_command(listings_group) +``` + +**Step 2: Run full test suite** + +Run: `pytest tests/ -v --tb=short` +Expected: All existing tests still pass, plus new debug CLI tests + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "Complete debug CLI with all subcommands" +```