Coverage for search_matches.py: 0.00%
205 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 12:24 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 12:24 +0000
1#!/usr/bin/env python3
2"""
3Match Search Tool
5A focused CLI tool for searching matches with comprehensive filters.
7Features:
8- Filter by Match Type (default: League)
9- Filter by League
10- Filter by Division/Conference
11- Filter by Age Group
12- Filter by Team (home or away)
13- Filter by Season (default: 2025-2026)
14- Beautiful rich table output
15- Environment-aware (local/dev/prod)
17Usage:
18 # Search with defaults (League matches, 2025-2026 season)
19 python scripts/utilities/search_matches.py
20 # Or from backend directory:
21 uv run python scripts/utilities/search_matches.py
23 # Search by team
24 python scripts/utilities/search_matches.py --team IFA
26 # Search by MLS ID (external match identifier)
27 python scripts/utilities/search_matches.py --mls-id 100502
29 # Search by age group and division
30 python scripts/utilities/search_matches.py --age-group U14 --division "Elite Division"
32 # Search by league
33 python scripts/utilities/search_matches.py --league Academy
35 # Search specific match type
36 python scripts/utilities/search_matches.py --match-type Friendly
38 # Search different season
39 python scripts/utilities/search_matches.py --season 2024-2025
41 # Combine filters
42 python scripts/utilities/search_matches.py --team IFA --age-group U14 --season 2025-2026
43"""
45import os
46import sys
47from pathlib import Path
49import typer
50from rich.console import Console
51from rich.panel import Panel
52from rich.table import Table
54# Add backend root directory to path for imports (scripts/utilities/ -> backend/)
55sys.path.insert(0, str(Path(__file__).parent.parent.parent))
57# Import DAO layer
58from dao.match_dao import MatchDAO, SupabaseConnection
60app = typer.Typer(help="Match Search Tool - Search matches with comprehensive filters")
61console = Console()
63# Defaults
64DEFAULT_MATCH_TYPE = "League"
65DEFAULT_SEASON = "2025-2026"
68def load_environment():
69 """Load environment variables from .env file based on APP_ENV"""
70 app_env = os.getenv("APP_ENV", "local")
71 console.print(f"[green]✓[/green] Environment: [cyan]{app_env}[/cyan]")
72 return app_env
75def get_dao() -> MatchDAO:
76 """Get DAO instance using proper data access layer"""
77 try:
78 connection = SupabaseConnection()
79 dao = MatchDAO(connection)
80 return dao
81 except Exception as e:
82 console.print(f"[red]Error connecting to database: {e}[/red]")
83 raise typer.Exit(1) from e
86def get_reference_data(dao: MatchDAO):
87 """Get reference data for validation and filtering using DAO"""
88 try:
89 match_types = {mt["name"]: mt["id"] for mt in dao.get_all_match_types()}
90 seasons = {s["name"]: s["id"] for s in dao.get_all_seasons()}
91 age_groups = {ag["name"]: ag["id"] for ag in dao.get_all_age_groups()}
92 divisions = {d["name"]: d["id"] for d in dao.get_all_divisions()}
93 leagues = {l["name"]: l["id"] for l in dao.get_all_leagues()}
94 teams = {t["name"]: t["id"] for t in dao.get_all_teams()}
96 return {
97 "match_types": match_types,
98 "seasons": seasons,
99 "age_groups": age_groups,
100 "divisions": divisions,
101 "leagues": leagues,
102 "teams": teams,
103 }
104 except Exception as e:
105 console.print(f"[red]Error loading reference data: {e}[/red]")
106 raise typer.Exit(1) from e
109@app.command()
110def search(
111 match_type: str | None = typer.Option(
112 DEFAULT_MATCH_TYPE, "--match-type", "-m", help=f"Match type (default: {DEFAULT_MATCH_TYPE})"
113 ),
114 season: str | None = typer.Option(DEFAULT_SEASON, "--season", "-s", help=f"Season (default: {DEFAULT_SEASON})"),
115 league: str | None = typer.Option(None, "--league", "-l", help="Filter by league"),
116 division: str | None = typer.Option(None, "--division", "-d", help="Filter by division/conference"),
117 age_group: str | None = typer.Option(None, "--age-group", "-a", help="Filter by age group (e.g., U14, U15)"),
118 team: str | None = typer.Option(None, "--team", "-t", help="Filter by team name (searches both home and away)"),
119 mls_id: str | None = typer.Option(
120 None, "--mls-id", help="Filter by MLS match ID (external identifier from mlssoccer.com)"
121 ),
122 limit: int = typer.Option(100, "--limit", help="Maximum number of matches to show"),
123 verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed information"),
124 show_filters: bool = typer.Option(True, "--show-filters/--no-filters", help="Show applied filters"),
125):
126 """
127 Search for matches with comprehensive filters.
129 Defaults:
130 - Match Type: League
131 - Season: 2025-2026
133 Examples:
134 # Default search (League matches, 2025-2026)
135 python scripts/utilities/search_matches.py
137 # Search by team
138 python scripts/utilities/search_matches.py --team IFA
140 # Search by MLS ID (external match identifier)
141 python scripts/utilities/search_matches.py --mls-id 100502
143 # Search by age group and division
144 python scripts/utilities/search_matches.py --age-group U14 --division "Elite Division"
146 # Search different season
147 python scripts/utilities/search_matches.py --season 2024-2025 --match-type Tournament
148 """
149 env = load_environment()
150 dao = get_dao()
152 # Load reference data
153 ref_data = get_reference_data(dao)
155 # Validate filters
156 if match_type and match_type not in ref_data["match_types"]:
157 console.print(f"[red]Error: Match type '{match_type}' not found.[/red]")
158 console.print(f"[yellow]Available match types: {', '.join(ref_data['match_types'].keys())}[/yellow]")
159 raise typer.Exit(1)
161 if season and season not in ref_data["seasons"]:
162 console.print(f"[red]Error: Season '{season}' not found.[/red]")
163 console.print(f"[yellow]Available seasons: {', '.join(ref_data['seasons'].keys())}[/yellow]")
164 raise typer.Exit(1)
166 if age_group and age_group not in ref_data["age_groups"]:
167 console.print(f"[red]Error: Age group '{age_group}' not found.[/red]")
168 console.print(f"[yellow]Available age groups: {', '.join(ref_data['age_groups'].keys())}[/yellow]")
169 raise typer.Exit(1)
171 if division and division not in ref_data["divisions"]:
172 console.print(f"[red]Error: Division '{division}' not found.[/red]")
173 console.print(f"[yellow]Available divisions: {', '.join(ref_data['divisions'].keys())}[/yellow]")
174 raise typer.Exit(1)
176 # Look up team ID if team name is provided (exact match)
177 team_id = None
178 if team:
179 team_obj = dao.get_team_by_name(team)
180 if not team_obj:
181 console.print(f"[red]Error: Team '{team}' not found.[/red]")
182 console.print("[yellow]Tip: Use 'list-teams' command to see available teams[/yellow]")
183 raise typer.Exit(1)
184 team_id = team_obj["id"]
186 # Get matches using DAO layer - all filtering done in database
187 matches_data = dao.get_all_matches(
188 season_id=ref_data["seasons"].get(season) if season else None,
189 age_group_id=ref_data["age_groups"].get(age_group) if age_group else None,
190 division_id=ref_data["divisions"].get(division) if division else None,
191 team_id=team_id, # Exact team ID lookup
192 match_type=match_type, # DAO handles match_type by name
193 )
195 # Apply client-side filters (league and mls_id not in DAO)
196 original_count = len(matches_data)
198 # Filter by MLS ID if provided
199 if mls_id:
200 matches_data = [m for m in matches_data if m.get("match_id") == mls_id]
202 if league:
203 if league not in ref_data["leagues"]:
204 console.print(f"[red]Error: League '{league}' not found.[/red]")
205 console.print(f"[yellow]Available leagues: {', '.join(ref_data['leagues'].keys())}[/yellow]")
206 raise typer.Exit(1)
208 def get_match_league(match):
209 """Extract the league name from the division"""
210 division = match.get("division")
211 if division and isinstance(division, dict):
212 leagues_data = division.get("leagues!divisions_league_id_fkey") or division.get("leagues")
213 if isinstance(leagues_data, dict):
214 return leagues_data.get("name")
215 return None
217 matches_data = [m for m in matches_data if get_match_league(m) == league]
219 # Apply limit AFTER filtering
220 matches_data = matches_data[:limit]
222 # Display results
223 if show_filters:
224 filters_applied = []
225 if match_type:
226 filters_applied.append(f"Match Type: [cyan]{match_type}[/cyan]")
227 if season:
228 filters_applied.append(f"Season: [cyan]{season}[/cyan]")
229 if league:
230 filters_applied.append(f"League: [cyan]{league}[/cyan]")
231 if division:
232 filters_applied.append(f"Division: [cyan]{division}[/cyan]")
233 if age_group:
234 filters_applied.append(f"Age Group: [cyan]{age_group}[/cyan]")
235 if team:
236 filters_applied.append(f"Team: [cyan]{team}[/cyan]")
237 if mls_id:
238 filters_applied.append(f"MLS ID: [cyan]{mls_id}[/cyan]")
240 if filters_applied:
241 console.print(
242 Panel(
243 "\n".join(filters_applied),
244 title="[bold]Applied Filters[/bold]",
245 border_style="blue",
246 )
247 )
248 console.print()
250 # Create results table
251 table = Table(title=f"Matches ({len(matches_data)} found)", show_header=True, header_style="bold magenta")
253 table.add_column("ID", style="cyan", width=5)
254 table.add_column("MLS ID", style="dim", width=12)
255 table.add_column("Date", style="yellow", width=10)
256 table.add_column("Home Team", style="green", width=20)
257 table.add_column("H.ID", style="dim", width=5)
258 table.add_column("Score", style="white", width=7, justify="center")
259 table.add_column("Away Team", style="blue", width=20)
260 table.add_column("A.ID", style="dim", width=5)
261 table.add_column("League", style="magenta", width=10)
262 if verbose:
263 table.add_column("Age Group", style="dim", width=8)
264 table.add_column("Division", style="dim", width=15)
265 table.add_column("Status", style="dim", width=10)
267 for match in matches_data:
268 # DAO returns flattened structure
269 home_team = match.get("home_team_name", "Unknown")
270 away_team = match.get("away_team_name", "Unknown")
271 home_team_id = match.get("home_team_id", "-")
272 away_team_id = match.get("away_team_id", "-")
273 home_score = match.get("home_score")
274 away_score = match.get("away_score")
275 mls_match_id = match.get("match_id", "-") or "-" # External match ID from MLSNext
277 # Extract league name from division
278 league_name = "-"
279 division = match.get("division")
280 if division and isinstance(division, dict):
281 leagues_data = division.get("leagues!divisions_league_id_fkey") or division.get("leagues")
282 if isinstance(leagues_data, dict):
283 league_name = leagues_data.get("name", "-")
285 # Format score
286 score = f"{home_score}-{away_score}" if home_score is not None and away_score is not None else "TBD"
288 row = [
289 str(match["id"]), # Primary key
290 mls_match_id, # External match ID from MLSNext website
291 match["match_date"],
292 home_team,
293 str(home_team_id),
294 score,
295 away_team,
296 str(away_team_id),
297 league_name,
298 ]
300 if verbose:
301 row.append(match.get("age_group_name", "-"))
302 row.append(match.get("division_name", "-") or "-")
303 row.append(match.get("match_status", "-"))
305 table.add_row(*row)
307 console.print(table)
309 # Summary
310 if original_count != len(matches_data):
311 console.print(f"\n[dim]Filtered from {original_count} to {len(matches_data)} matches[/dim]")
313 console.print(f"\n[green]Environment:[/green] {env}")
316@app.command()
317def list_options():
318 """List all available filter options (match types, seasons, leagues, etc.)"""
319 load_environment()
320 dao = get_dao()
321 ref_data = get_reference_data(dao)
323 console.print(Panel("[bold]Available Filter Options[/bold]", border_style="green"))
324 console.print()
326 # Match Types
327 console.print("[bold cyan]Match Types:[/bold cyan]")
328 for name in sorted(ref_data["match_types"].keys()):
329 default = " [yellow](default)[/yellow]" if name == DEFAULT_MATCH_TYPE else ""
330 console.print(f" • {name}{default}")
331 console.print()
333 # Seasons
334 console.print("[bold cyan]Seasons:[/bold cyan]")
335 for name in sorted(ref_data["seasons"].keys(), reverse=True):
336 default = " [yellow](default)[/yellow]" if name == DEFAULT_SEASON else ""
337 console.print(f" • {name}{default}")
338 console.print()
340 # Leagues
341 console.print("[bold cyan]Leagues:[/bold cyan]")
342 for name in sorted(ref_data["leagues"].keys()):
343 console.print(f" • {name}")
344 console.print()
346 # Age Groups
347 console.print("[bold cyan]Age Groups:[/bold cyan]")
348 for name in sorted(ref_data["age_groups"].keys()):
349 console.print(f" • {name}")
350 console.print()
352 # Divisions
353 console.print("[bold cyan]Divisions:[/bold cyan]")
354 for name in sorted(ref_data["divisions"].keys()):
355 console.print(f" • {name}")
358@app.command()
359def list_teams(
360 league: str | None = typer.Option(None, "--league", "-l", help="Filter teams by league"),
361 age_group: str | None = typer.Option(None, "--age-group", "-a", help="Filter teams by age group"),
362 search: str | None = typer.Option(None, "--search", "-s", help="Search teams by name (partial match)"),
363):
364 """List all teams with optional filtering"""
365 load_environment()
366 dao = get_dao()
367 ref_data = get_reference_data(dao)
369 # Validate filters
370 if league and league not in ref_data["leagues"]:
371 console.print(f"[red]Error: League '{league}' not found.[/red]")
372 console.print(f"[yellow]Available leagues: {', '.join(ref_data['leagues'].keys())}[/yellow]")
373 raise typer.Exit(1)
375 if age_group and age_group not in ref_data["age_groups"]:
376 console.print(f"[red]Error: Age group '{age_group}' not found.[/red]")
377 console.print(f"[yellow]Available age groups: {', '.join(ref_data['age_groups'].keys())}[/yellow]")
378 raise typer.Exit(1)
380 # Get all teams
381 all_teams = dao.get_all_teams()
383 # Apply filters
384 teams = all_teams
386 # Filter by search term (partial match)
387 if search:
388 teams = [t for t in teams if search.lower() in t["name"].lower()]
390 # Filter by league (check team's league_id)
391 if league:
392 league_id = ref_data["leagues"][league]
393 teams = [t for t in teams if t.get("league_id") == league_id]
395 # Note: Can't easily filter by age_group since teams can play in multiple age groups via team_mappings
396 # For now, skip age_group filtering
398 # Display results
399 table = Table(title=f"Teams ({len(teams)} found)", show_header=True, header_style="bold magenta")
401 table.add_column("Name", style="green", width=40)
402 table.add_column("City", style="yellow", width=20)
403 table.add_column("League", style="cyan", width=15)
405 for team in sorted(teams, key=lambda t: t["name"]):
406 league_name = "-"
407 if team.get("league_id"):
408 # Find league name from ref_data
409 for l_name, l_id in ref_data["leagues"].items():
410 if l_id == team["league_id"]:
411 league_name = l_name
412 break
414 table.add_row(team["name"], team.get("city", "-") or "-", league_name)
416 console.print(table)
418 if search or league:
419 console.print(f"\n[dim]Showing {len(teams)} of {len(all_teams)} total teams[/dim]")
422if __name__ == "__main__":
423 app()