Coverage for mt_cli.py: 0.00%

385 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-15 12:31 +0000

1#!/usr/bin/env python3 

2""" 

3Match Tracking CLI for MissingTable 

4 

5Chat with Claw during matches to post live events. 

6 

7Usage: 

8 mt login tom 

9 mt search --age U14 --days 30 

10 mt match start 1053 

11 mt match goal --team home --player "Matt" 

12 mt match message "Great pass by Carter" 

13 mt match status 

14 mt match halftime 

15 mt match secondhalf 

16 mt match end 

17""" 

18 

19from __future__ import annotations 

20 

21import json 

22import os 

23from datetime import UTC, datetime 

24from getpass import getpass 

25from pathlib import Path 

26 

27import typer 

28from pydantic import BaseModel 

29from rich.console import Console 

30from rich.panel import Panel 

31from rich.table import Table 

32 

33from api_client import APIError, AuthenticationError, MissingTableClient 

34from api_client.models import GoalEvent, LiveMatchClock, MessageEvent 

35 

36app = typer.Typer(help="MT Match Tracking CLI") 

37match_app = typer.Typer(help="Live match tracking commands") 

38app.add_typer(match_app, name="match") 

39console = Console() 

40 

41# Paths 

42REPO_ROOT = Path(__file__).parent.parent 

43BACKEND_DIR = Path(__file__).parent 

44MT_CONFIG_FILE = REPO_ROOT / ".mt-config" 

45STATE_FILE = BACKEND_DIR / ".mt-cli-state.json" 

46 

47 

48# --- Models --- 

49 

50 

51class CLIState(BaseModel): 

52 """Persistent state for CLI (login session + active match).""" 

53 

54 access_token: str | None = None 

55 refresh_token: str | None = None 

56 username: str | None = None 

57 match_id: int | None = None 

58 home_team_name: str | None = None 

59 away_team_name: str | None = None 

60 

61 

62# --- Config Helpers --- 

63 

64 

65def mt_config_get(key: str, default: str = "") -> str: 

66 """Read a key from .mt-config.""" 

67 if MT_CONFIG_FILE.exists(): 

68 with open(MT_CONFIG_FILE) as f: 

69 for line in f: 

70 line = line.strip() 

71 if line.startswith(f"{key}="): 

72 return line.split("=", 1)[1] 

73 return default 

74 

75 

76def get_current_env() -> str: 

77 """Get current environment (local or prod).""" 

78 config_val = mt_config_get("supabase_env") 

79 if config_val: 

80 return config_val 

81 return os.getenv("APP_ENV", "local") 

82 

83 

84def get_base_url() -> str: 

85 """Get API base URL for current environment.""" 

86 env = get_current_env() 

87 env_file = BACKEND_DIR / f".env.{env}" 

88 if not env_file.exists(): 

89 console.print(f"[red]Environment file not found: {env_file}[/red]") 

90 raise typer.Exit(1) 

91 

92 env_vars = {} 

93 with open(env_file) as f: 

94 for line in f: 

95 line = line.strip() 

96 if line and not line.startswith("#") and "=" in line: 

97 key, value = line.split("=", 1) 

98 env_vars[key] = value 

99 

100 if env == "local": 

101 return env_vars.get("BACKEND_URL", "http://localhost:8000") 

102 

103 base_url = env_vars.get("BACKEND_URL") 

104 if not base_url: 

105 console.print( 

106 "[red]BACKEND_URL not set in .env.prod[/red]\n" 

107 "[yellow]Add this line to backend/.env.prod:[/yellow]\n" 

108 "BACKEND_URL=https://your-prod-api.com" 

109 ) 

110 raise typer.Exit(1) 

111 return base_url 

112 

113 

114# --- State Management --- 

115 

116 

117def load_state() -> CLIState: 

118 """Load CLI state from file.""" 

119 if STATE_FILE.exists(): 

120 with open(STATE_FILE) as f: 

121 data = json.load(f) 

122 return CLIState(**data) 

123 return CLIState() 

124 

125 

126def save_state(state: CLIState) -> None: 

127 """Save CLI state to file.""" 

128 with open(STATE_FILE, "w") as f: 

129 json.dump(state.model_dump(), f, indent=2) 

130 

131 

132def get_client() -> tuple[MissingTableClient, CLIState]: 

133 """Get an authenticated MissingTableClient and current state.""" 

134 state = load_state() 

135 if not state.access_token: 

136 console.print( 

137 "[red]Not logged in[/red]\n" 

138 "[yellow]Login first:[/yellow] mt login <username>" 

139 ) 

140 raise typer.Exit(1) 

141 

142 client = MissingTableClient( 

143 base_url=get_base_url(), 

144 access_token=state.access_token, 

145 ) 

146 return client, state 

147 

148 

149def require_active_match(state: CLIState) -> int: 

150 """Return the active match_id from state, or exit with an error.""" 

151 if not state.match_id: 

152 console.print( 

153 "[red]No active match[/red]\n" 

154 "[yellow]Start a match first:[/yellow] mt match start <match_id>" 

155 ) 

156 raise typer.Exit(1) 

157 return state.match_id 

158 

159 

160# --- Helpers --- 

161 

162 

163def _load_env_vars() -> dict[str, str]: 

164 """Load env vars for the current environment.""" 

165 env = get_current_env() 

166 env_file = BACKEND_DIR / f".env.{env}" 

167 if not env_file.exists(): 

168 return {} 

169 env_vars = {} 

170 with open(env_file) as f: 

171 for line in f: 

172 line = line.strip() 

173 if line and not line.startswith("#") and "=" in line: 

174 key, value = line.split("=", 1) 

175 env_vars[key] = value 

176 return env_vars 

177 

178 

179def _resolve_team(live: dict, team_arg: str) -> tuple[int, str]: 

180 """Resolve a team argument to (team_id, team_name). 

181 

182 Accepts "home", "away", or a case-insensitive substring of a team name. 

183 """ 

184 home_id = live["home_team_id"] 

185 away_id = live["away_team_id"] 

186 home_name = live.get("home_team_name", "Home") 

187 away_name = live.get("away_team_name", "Away") 

188 

189 lower = team_arg.lower() 

190 

191 if lower == "home": 

192 return home_id, home_name 

193 if lower == "away": 

194 return away_id, away_name 

195 

196 # Try case-insensitive substring match against team names 

197 home_match = lower in home_name.lower() 

198 away_match = lower in away_name.lower() 

199 

200 if home_match and away_match: 

201 console.print( 

202 f"[red]'{team_arg}' matches both teams:[/red] {home_name} and {away_name}\n" 

203 "[yellow]Use 'home' or 'away' to disambiguate[/yellow]" 

204 ) 

205 raise typer.Exit(1) 

206 

207 if home_match: 

208 return home_id, home_name 

209 if away_match: 

210 return away_id, away_name 

211 

212 console.print( 

213 f"[red]'{team_arg}' doesn't match either team[/red]\n" 

214 f" Home: {home_name}\n" 

215 f" Away: {away_name}\n" 

216 "[yellow]Use 'home', 'away', or part of a team name[/yellow]" 

217 ) 

218 raise typer.Exit(1) 

219 

220 

221def _resolve_player( 

222 client: MissingTableClient, team_id: int, season_id: int | None, player_arg: str 

223) -> tuple[int | None, str | None]: 

224 """Resolve a player argument to (player_id, display_name). 

225 

226 Accepts a jersey number or a player name (case-insensitive substring). 

227 Returns (None, None) if roster lookup fails gracefully. 

228 """ 

229 # Try as jersey number first 

230 try: 

231 jersey = int(player_arg) 

232 except ValueError: 

233 jersey = None 

234 

235 # Fetch roster 

236 roster = [] 

237 if season_id: 

238 try: 

239 result = client.get_team_roster(team_id, season_id=season_id) 

240 roster = result.get("roster", []) 

241 except Exception as e: 

242 console.print(f"[yellow]Could not fetch roster: {e}[/yellow]") 

243 

244 if jersey is not None: 

245 # Look up by jersey number 

246 player = next((p for p in roster if p.get("jersey_number") == jersey), None) 

247 if not player: 

248 if roster: 

249 console.print(f"[yellow]No player #{jersey} on roster — recording goal without player[/yellow]") 

250 return None, f"#{jersey}" 

251 

252 display = player.get("display_name") or player.get("first_name") or f"#{jersey}" 

253 return player.get("id"), display 

254 

255 # Look up by name (case-insensitive substring) 

256 if roster: 

257 matches = [] 

258 for p in roster: 

259 name = p.get("display_name") or p.get("first_name") or "" 

260 if player_arg.lower() in name.lower(): 

261 matches.append(p) 

262 

263 if len(matches) == 1: 

264 p = matches[0] 

265 display = p.get("display_name") or p.get("first_name") or f"#{p.get('jersey_number')}" 

266 return p.get("id"), display 

267 

268 if len(matches) > 1: 

269 console.print(f"[yellow]'{player_arg}' matches multiple players:[/yellow]") 

270 for p in matches: 

271 name = p.get("display_name") or p.get("first_name") or "?" 

272 console.print(f" #{p.get('jersey_number')} {name}") 

273 console.print("[yellow]Use the jersey number instead[/yellow]") 

274 raise typer.Exit(1) 

275 

276 # No roster match — use as free-text name 

277 return None, player_arg 

278 

279 

280def _parse_ts(value: str | None) -> datetime | None: 

281 """Parse an ISO timestamp string to datetime.""" 

282 if not value: 

283 return None 

284 return datetime.fromisoformat(value.replace("Z", "+00:00")) 

285 

286 

287def _match_clock(live: dict) -> tuple[str, str]: 

288 """Calculate current period and match minute from live state. 

289 

290 Returns (period, minute_display) e.g. ("1st Half", "23'") or ("Halftime", "40'"). 

291 """ 

292 kickoff_time = _parse_ts(live.get("kickoff_time")) 

293 halftime_start = _parse_ts(live.get("halftime_start")) 

294 second_half_start = _parse_ts(live.get("second_half_start")) 

295 match_end_time = _parse_ts(live.get("match_end_time")) 

296 half_duration = live.get("half_duration") or 45 

297 

298 if not kickoff_time: 

299 return "Pre-match", "-" 

300 

301 if match_end_time: 

302 return "Full time", f"{half_duration * 2}'" 

303 

304 now = datetime.now(UTC) 

305 

306 if second_half_start: 

307 elapsed = int((now - second_half_start).total_seconds() / 60) + 1 

308 minute = half_duration + elapsed 

309 full_time = half_duration * 2 

310 if minute > full_time: 

311 return "2nd Half", f"{full_time}+{minute - full_time}'" 

312 return "2nd Half", f"{minute}'" 

313 

314 if halftime_start: 

315 return "Halftime", f"{half_duration}'" 

316 

317 elapsed = int((now - kickoff_time).total_seconds() / 60) + 1 

318 if elapsed > half_duration: 

319 return "1st Half", f"{half_duration}+{elapsed - half_duration}'" 

320 return "1st Half", f"{elapsed}'" 

321 

322 

323# --- Top-level Commands --- 

324 

325 

326@app.command() 

327def login(username: str = typer.Argument("tom", help="Username to login with (default: tom)")): 

328 """Login to the MT API.""" 

329 # Try to find password from env file: TEST_USER_PASSWORD_<USERNAME> 

330 env_vars = _load_env_vars() 

331 env_key = f"TEST_USER_PASSWORD_{username.upper().replace('-', '_')}" 

332 password = env_vars.get(env_key) 

333 

334 if password: 

335 console.print(f"[dim]Using password from {env_key}[/dim]") 

336 else: 

337 password = getpass(f"Password for {username}: ") 

338 

339 base_url = get_base_url() 

340 client = MissingTableClient(base_url=base_url) 

341 

342 try: 

343 result = client.login(username, password) 

344 except AuthenticationError as e: 

345 console.print(f"[red]Login failed: {e}[/red]") 

346 raise typer.Exit(1) from None 

347 finally: 

348 client.close() 

349 

350 # Preserve any existing match state 

351 state = load_state() 

352 state.access_token = result.get("access_token") 

353 state.refresh_token = result.get("refresh_token") 

354 state.username = username 

355 save_state(state) 

356 

357 user = result.get("user", {}) 

358 role = user.get("role", "unknown") 

359 console.print(f"[green]Logged in as {username} ({role})[/green]") 

360 console.print(f"[dim]Environment: {get_current_env()}[/dim]") 

361 

362 

363@app.command() 

364def logout(): 

365 """Logout and clear stored credentials.""" 

366 state = load_state() 

367 state.access_token = None 

368 state.refresh_token = None 

369 state.username = None 

370 save_state(state) 

371 console.print("[green]Logged out[/green]") 

372 

373 

374@app.command() 

375def config(): 

376 """Show current configuration.""" 

377 env = get_current_env() 

378 base_url = get_base_url() 

379 state = load_state() 

380 

381 table = Table(title="MT CLI Configuration", show_header=False) 

382 table.add_column("Setting", style="cyan") 

383 table.add_column("Value", style="white") 

384 

385 table.add_row("Environment", env) 

386 table.add_row("API URL", base_url) 

387 table.add_row("Logged in as", state.username or "Not logged in") 

388 if state.match_id: 

389 match_label = f"#{state.match_id}" 

390 if state.home_team_name and state.away_team_name: 

391 match_label += f" ({state.home_team_name} vs {state.away_team_name})" 

392 table.add_row("Active match", match_label) 

393 

394 console.print(table) 

395 console.print(f"\n[dim]Config file: {MT_CONFIG_FILE}[/dim]") 

396 console.print(f"[dim]State file: {STATE_FILE}[/dim]") 

397 

398 

399@app.command() 

400def search( 

401 age_group: str = typer.Option(None, "--age", "-a", help="Filter by age group (e.g., 'U13', 'U14')"), 

402 team: str = typer.Option(None, "--team", "-t", help="Filter by team name (substring match)"), 

403 days: int = typer.Option(7, "--days", "-d", help="Number of days to search (default: 7)"), 

404): 

405 """Search for upcoming matches by age group, team, and date range.""" 

406 client, _ = get_client() 

407 

408 now = datetime.now(UTC) 

409 start = datetime(now.year, now.month, now.day, tzinfo=UTC) 

410 end = datetime.fromtimestamp(start.timestamp() + (days * 86400), tz=UTC) 

411 

412 start_str = start.strftime("%Y-%m-%d") 

413 end_str = end.strftime("%Y-%m-%d") 

414 

415 matches = client.get_games(start_date=start_str, end_date=end_str) 

416 

417 # Filter by age group and team if provided 

418 filtered = [] 

419 for match in matches: 

420 home_name = match.get("home_team_name", "Unknown") 

421 away_name = match.get("away_team_name", "Unknown") 

422 age_name = match.get("age_group_name", "Unknown") 

423 

424 if age_group: 

425 if age_group.lower() not in age_name.lower(): 

426 continue 

427 

428 if team: 

429 if team.lower() not in home_name.lower() and team.lower() not in away_name.lower(): 

430 continue 

431 

432 filtered.append(match) 

433 

434 if not filtered: 

435 console.print("[yellow]No matches found matching your criteria.[/yellow]") 

436 return 

437 

438 table = Table(title=f"Upcoming Matches (Next {days} days)") 

439 table.add_column("ID", style="cyan", no_wrap=True) 

440 table.add_column("Date", style="magenta") 

441 table.add_column("Home", style="white") 

442 table.add_column("Away", style="white") 

443 table.add_column("Age", style="yellow") 

444 

445 for m in filtered: 

446 match_id = str(m.get("id", "?")) 

447 date_str = m.get("match_date", "?") 

448 home = m.get("home_team_name", "Unknown") 

449 away = m.get("away_team_name", "Unknown") 

450 age = m.get("age_group_name", "?") 

451 

452 table.add_row(match_id, date_str, home, away, age) 

453 

454 console.print(table) 

455 console.print(f"\n[dim]Found {len(filtered)} match(es)[/dim]") 

456 console.print("[green]Use 'mt match start <match_id>' to start tracking[/green]") 

457 

458 

459# --- Match Subcommands --- 

460 

461 

462@match_app.command() 

463def start( 

464 match_id: int = typer.Argument(..., help="Match ID to track"), 

465 half: int = typer.Option(40, "--half", help="Half duration in minutes (default: 40)"), 

466): 

467 """Start tracking a match and kick off the first half.""" 

468 client, state = get_client() 

469 

470 console.print(f"[dim]Fetching match {match_id}...[/dim]") 

471 try: 

472 match = client.get_game(match_id) 

473 except AuthenticationError: 

474 console.print( 

475 "[red]Session expired[/red]\n" 

476 "[yellow]Login again:[/yellow] mt login <username>" 

477 ) 

478 raise typer.Exit(1) from None 

479 except APIError as e: 

480 console.print(f"[red]{e}[/red]") 

481 raise typer.Exit(1) from None 

482 

483 # Start the match clock (sets status to live, records kickoff time) 

484 clock = LiveMatchClock(action="start_first_half", half_duration=half) 

485 client.update_match_clock(match_id, clock) 

486 

487 # Save match_id and team names to state 

488 home_name = match.get("home_team_name") or "Unknown" 

489 away_name = match.get("away_team_name") or "Unknown" 

490 state.match_id = match_id 

491 state.home_team_name = home_name 

492 state.away_team_name = away_name 

493 save_state(state) 

494 

495 table = Table(title=f"Match #{match_id}", show_header=False) 

496 table.add_column("Field", style="cyan") 

497 table.add_column("Value", style="white") 

498 

499 table.add_row("Home", home_name) 

500 table.add_row("Away", away_name) 

501 table.add_row("Date", match.get("match_date", "Unknown")) 

502 table.add_row("Half Duration", f"{half} min/half = {half * 2} min total") 

503 table.add_row("Status", "live") 

504 

505 console.print(table) 

506 console.print(f"[green]Match {match_id} kicked off![/green]") 

507 console.print(f"[dim]Environment: {get_current_env()}[/dim]") 

508 

509 

510@match_app.command() 

511def goal( 

512 team: str = typer.Option(..., "--team", "-t", help="Team: 'home', 'away', or team name"), 

513 player: str = typer.Option(None, "--player", "-p", help="Player: jersey number or name (optional)"), 

514): 

515 """Record a goal.""" 

516 client, state = get_client() 

517 match_id = require_active_match(state) 

518 

519 live = client.get_live_match_state(match_id) 

520 team_id, team_name = _resolve_team(live, team) 

521 

522 # Resolve player if provided 

523 player_id = None 

524 player_display = None 

525 if player: 

526 player_id, player_display = _resolve_player( 

527 client, team_id, live.get("season_id"), player 

528 ) 

529 

530 # Build and post goal event 

531 goal_event = GoalEvent( 

532 team_id=team_id, 

533 player_id=player_id, 

534 player_name=player_display, 

535 ) 

536 client.post_goal(match_id, goal_event) 

537 

538 # Calculate current minute for display 

539 _, minute = _match_clock(live) 

540 

541 scorer = f" - {player_display}" if player_display else "" 

542 console.print( 

543 Panel( 

544 f"[bold]{team_name}[/bold]{scorer} ({minute})", 

545 title="Goal!", 

546 border_style="green", 

547 ) 

548 ) 

549 

550 

551@match_app.command() 

552def message( 

553 text: str = typer.Argument(..., help="Message text"), 

554): 

555 """Post a chat message to the match.""" 

556 client, state = get_client() 

557 match_id = require_active_match(state) 

558 

559 msg = MessageEvent(message=text) 

560 client.post_message(match_id, msg) 

561 

562 console.print(f"[dim]{text}[/dim]") 

563 

564 

565@match_app.command() 

566def status( 

567 match_id_arg: int = typer.Argument(None, metavar="MATCH_ID", help="Match ID (uses active match if omitted)"), 

568): 

569 """Show live match status.""" 

570 client, state = get_client() 

571 match_id = match_id_arg if match_id_arg is not None else require_active_match(state) 

572 

573 live = client.get_live_match_state(match_id) 

574 

575 home_name = live.get("home_team_name", "Home") 

576 away_name = live.get("away_team_name", "Away") 

577 period, minute = _match_clock(live) 

578 

579 home_score = live.get("home_score", 0) 

580 away_score = live.get("away_score", 0) 

581 

582 table = Table(title=f"Match #{match_id}", show_header=False) 

583 table.add_column("Field", style="cyan", width=15) 

584 table.add_column("Value", style="white") 

585 

586 table.add_row("Home", f"{home_name}{home_score}") 

587 table.add_row("Away", f"{away_name}{away_score}") 

588 table.add_row("Status", live.get("match_status", "Unknown")) 

589 table.add_row("Period", period) 

590 table.add_row("Minute", minute) 

591 table.add_row("Half Duration", f"{live.get('half_duration', '-')} min") 

592 

593 console.print(table) 

594 

595 # Fetch full event list and filter out status-change noise so every goal is shown. 

596 # The /live endpoint returns events newest-first; using [-N:] on that list would 

597 # show only the oldest entries, hiding recent goals (the original bug). 

598 events_to_show: list[dict] = [] 

599 try: 

600 all_events = client.get_match_events(match_id) 

601 # all_events is newest-first; reverse to chronological order. 

602 # Exclude status_change events (kickoff, halftime, etc.) from the CLI display — 

603 # those are already surfaced via the Period/Status rows above. 

604 _SKIP_TYPES = {"status_change"} 

605 events_to_show = [e for e in reversed(all_events) if e.get("event_type") not in _SKIP_TYPES] 

606 except Exception: 

607 # Graceful fallback: use whatever recent_events the /live response included, 

608 # but fix the ordering bug by taking the first (newest) entries, not the last. 

609 raw = live.get("recent_events") or [] 

610 events_to_show = list(reversed(raw[:10])) 

611 

612 if events_to_show: 

613 console.print("\n[bold]Match Events:[/bold]") 

614 for event in events_to_show: 

615 event_type = event.get("event_type", "") 

616 msg = event.get("message", "") 

617 minute = event.get("match_minute") 

618 extra = event.get("extra_time") 

619 if minute is not None: 

620 minute_str = f"{minute}+{extra}'" if extra else f"{minute}'" 

621 console.print(f" [dim]{minute_str}[/dim] {msg}") 

622 else: 

623 console.print(f" [dim]{event_type}:[/dim] {msg}") 

624 

625 

626@match_app.command() 

627def halftime(): 

628 """End the first half (start halftime).""" 

629 client, state = get_client() 

630 match_id = require_active_match(state) 

631 

632 clock = LiveMatchClock(action="start_halftime") 

633 client.update_match_clock(match_id, clock) 

634 console.print(f"[green]Match {match_id} — Halftime[/green]") 

635 

636 

637@match_app.command() 

638def secondhalf(): 

639 """Start the second half.""" 

640 client, state = get_client() 

641 match_id = require_active_match(state) 

642 

643 clock = LiveMatchClock(action="start_second_half") 

644 client.update_match_clock(match_id, clock) 

645 console.print(f"[green]Match {match_id} — Second half kicked off[/green]") 

646 

647 

648@match_app.command() 

649def end(): 

650 """End the match (full time) and clear active match.""" 

651 client, state = get_client() 

652 match_id = require_active_match(state) 

653 

654 clock = LiveMatchClock(action="end_match") 

655 client.update_match_clock(match_id, clock) 

656 

657 console.print(f"[green]Match {match_id} — Full time[/green]") 

658 

659 # Clear match state 

660 state.match_id = None 

661 state.home_team_name = None 

662 state.away_team_name = None 

663 save_state(state) 

664 

665 

666if __name__ == "__main__": 

667 app()