Coverage for mt_cli.py: 0.00%
385 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 00:07 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 00:07 +0000
1#!/usr/bin/env python3
2"""
3Match Tracking CLI for MissingTable
5Chat with Claw during matches to post live events.
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"""
19from __future__ import annotations
21import json
22import os
23from datetime import UTC, datetime
24from getpass import getpass
25from pathlib import Path
27import typer
28from pydantic import BaseModel
29from rich.console import Console
30from rich.panel import Panel
31from rich.table import Table
33from api_client import APIError, AuthenticationError, MissingTableClient
34from api_client.models import GoalEvent, LiveMatchClock, MessageEvent
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()
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"
48# --- Models ---
51class CLIState(BaseModel):
52 """Persistent state for CLI (login session + active match)."""
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
62# --- Config Helpers ---
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
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")
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)
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
100 if env == "local":
101 return env_vars.get("BACKEND_URL", "http://localhost:8000")
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
114# --- State Management ---
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()
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)
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)
142 client = MissingTableClient(
143 base_url=get_base_url(),
144 access_token=state.access_token,
145 )
146 return client, state
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
160# --- Helpers ---
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
179def _resolve_team(live: dict, team_arg: str) -> tuple[int, str]:
180 """Resolve a team argument to (team_id, team_name).
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")
189 lower = team_arg.lower()
191 if lower == "home":
192 return home_id, home_name
193 if lower == "away":
194 return away_id, away_name
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()
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)
207 if home_match:
208 return home_id, home_name
209 if away_match:
210 return away_id, away_name
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)
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).
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
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]")
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}"
252 display = player.get("display_name") or player.get("first_name") or f"#{jersey}"
253 return player.get("id"), display
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)
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
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)
276 # No roster match — use as free-text name
277 return None, player_arg
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"))
287def _match_clock(live: dict) -> tuple[str, str]:
288 """Calculate current period and match minute from live state.
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
298 if not kickoff_time:
299 return "Pre-match", "-"
301 if match_end_time:
302 return "Full time", f"{half_duration * 2}'"
304 now = datetime.now(UTC)
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}'"
314 if halftime_start:
315 return "Halftime", f"{half_duration}'"
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}'"
323# --- Top-level Commands ---
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)
334 if password:
335 console.print(f"[dim]Using password from {env_key}[/dim]")
336 else:
337 password = getpass(f"Password for {username}: ")
339 base_url = get_base_url()
340 client = MissingTableClient(base_url=base_url)
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()
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)
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]")
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]")
374@app.command()
375def config():
376 """Show current configuration."""
377 env = get_current_env()
378 base_url = get_base_url()
379 state = load_state()
381 table = Table(title="MT CLI Configuration", show_header=False)
382 table.add_column("Setting", style="cyan")
383 table.add_column("Value", style="white")
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)
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]")
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()
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)
412 start_str = start.strftime("%Y-%m-%d")
413 end_str = end.strftime("%Y-%m-%d")
415 matches = client.get_games(start_date=start_str, end_date=end_str)
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")
424 if age_group:
425 if age_group.lower() not in age_name.lower():
426 continue
428 if team:
429 if team.lower() not in home_name.lower() and team.lower() not in away_name.lower():
430 continue
432 filtered.append(match)
434 if not filtered:
435 console.print("[yellow]No matches found matching your criteria.[/yellow]")
436 return
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")
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", "?")
452 table.add_row(match_id, date_str, home, away, age)
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]")
459# --- Match Subcommands ---
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()
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
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)
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)
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")
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")
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]")
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)
519 live = client.get_live_match_state(match_id)
520 team_id, team_name = _resolve_team(live, team)
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 )
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)
538 # Calculate current minute for display
539 _, minute = _match_clock(live)
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 )
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)
559 msg = MessageEvent(message=text)
560 client.post_message(match_id, msg)
562 console.print(f"[dim]{text}[/dim]")
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)
573 live = client.get_live_match_state(match_id)
575 home_name = live.get("home_team_name", "Home")
576 away_name = live.get("away_team_name", "Away")
577 period, minute = _match_clock(live)
579 home_score = live.get("home_score", 0)
580 away_score = live.get("away_score", 0)
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")
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")
593 console.print(table)
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]))
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}")
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)
632 clock = LiveMatchClock(action="start_halftime")
633 client.update_match_clock(match_id, clock)
634 console.print(f"[green]Match {match_id} — Halftime[/green]")
637@match_app.command()
638def secondhalf():
639 """Start the second half."""
640 client, state = get_client()
641 match_id = require_active_match(state)
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]")
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)
654 clock = LiveMatchClock(action="end_match")
655 client.update_match_clock(match_id, clock)
657 console.print(f"[green]Match {match_id} — Full time[/green]")
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)
666if __name__ == "__main__":
667 app()