fablazing-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,52 @@
1
+ """HTTP client for the v1 API. Exits 1 on API errors, 3 on quota."""
2
+ import httpx
3
+
4
+ from . import __version__
5
+ from .config import api_key, api_url
6
+ from .output import fail
7
+
8
+
9
+ def get(path: str, params: dict | None = None) -> dict:
10
+ key = api_key()
11
+ if not key:
12
+ fail(
13
+ "no API key configured. Run: fablazing auth set-key "
14
+ "(or set the FABLAZING_API_KEY environment variable)"
15
+ )
16
+
17
+ clean = {}
18
+ for name, value in (params or {}).items():
19
+ if value is None or value == []:
20
+ continue
21
+ clean[name] = value
22
+
23
+ url = f"{api_url()}{path}"
24
+ try:
25
+ response = httpx.get(
26
+ url,
27
+ params=clean,
28
+ headers={
29
+ "Authorization": f"Bearer {key}",
30
+ "User-Agent": f"fablazing-cli/{__version__}",
31
+ },
32
+ timeout=120,
33
+ )
34
+ except httpx.HTTPError as exc:
35
+ fail(f"cannot reach the API at {api_url()}: {exc}")
36
+
37
+ if response.status_code == 401:
38
+ fail("invalid or revoked API key (401). Run: fablazing auth set-key")
39
+ if response.status_code == 429:
40
+ detail = _detail(response) or "rate limited"
41
+ fail(f"{detail}", 3)
42
+ if response.status_code >= 400:
43
+ fail(f"API error {response.status_code}: {_detail(response)}")
44
+
45
+ return response.json()
46
+
47
+
48
+ def _detail(response: httpx.Response) -> str:
49
+ try:
50
+ return response.json().get("detail", "")
51
+ except ValueError:
52
+ return response.text[:200]
File without changes
@@ -0,0 +1,43 @@
1
+ """API key management."""
2
+ import typer
3
+
4
+ from .. import client
5
+ from ..config import api_key, api_url, config_path, load_config, save_config
6
+ from ..output import console, fail
7
+
8
+ app = typer.Typer(help="API key management")
9
+
10
+
11
+ @app.command("set-key")
12
+ def set_key(key: str = typer.Argument(None, help="Your fbl_live_... API key")):
13
+ """Store your API key (prompted securely if not passed as an argument)."""
14
+ if not key:
15
+ key = typer.prompt("API key", hide_input=True)
16
+ key = key.strip()
17
+ if not key.startswith("fbl_live_"):
18
+ fail("that doesn't look like a Fablazing API key (expected fbl_live_...)", 2)
19
+ cfg = load_config()
20
+ cfg["api_key"] = key
21
+ save_config(cfg)
22
+ console.print(f"Key saved to {config_path()}")
23
+
24
+
25
+ @app.command("set-url")
26
+ def set_url(url: str = typer.Argument(..., help="API base URL (for self-hosted/dev)")):
27
+ """Point the CLI at a different API base URL."""
28
+ cfg = load_config()
29
+ cfg["api_url"] = url.rstrip("/")
30
+ save_config(cfg)
31
+ console.print(f"API URL set to {cfg['api_url']}")
32
+
33
+
34
+ @app.command()
35
+ def status():
36
+ """Verify the configured key against the API."""
37
+ key = api_key()
38
+ if not key:
39
+ fail("no API key configured. Run: fablazing auth set-key")
40
+ masked = key[:15] + "..."
41
+ heroes = client.get("/heroes")
42
+ count = heroes.get("meta", {}).get("count", "?")
43
+ console.print(f"[green]OK[/green] key {masked} against {api_url()} ({count} heroes visible)")
@@ -0,0 +1,141 @@
1
+ """Card search, metadata, trends, synergies, popularity."""
2
+ import typer
3
+
4
+ from .. import client, resolve
5
+ from ..output import console, emit, pct, set_mode
6
+
7
+ app = typer.Typer(help="Card catalog and analytics")
8
+
9
+
10
+ @app.command()
11
+ def search(
12
+ query: str = typer.Argument(..., help="Part of a card name"),
13
+ limit: int = typer.Option(25, "--limit", min=1, max=100),
14
+ json_: bool = typer.Option(False, "--json"),
15
+ csv_: bool = typer.Option(False, "--csv"),
16
+ ):
17
+ """Search cards and equipment by name."""
18
+ set_mode(json_, csv_)
19
+ payload = client.get("/cards/search", {"q": query, "limit": limit})
20
+ rows = [
21
+ [
22
+ c.get("card_id", ""),
23
+ c.get("card_name", ""),
24
+ c.get("pitch_value", ""),
25
+ "equipment" if c.get("is_equipment") else "card",
26
+ c.get("class", ""),
27
+ f"${c['tcg_market_price']:.2f}" if c.get("tcg_market_price") else "-",
28
+ ]
29
+ for c in payload["data"]
30
+ ]
31
+ emit(payload, tables=[("Results", ["ID", "Name", "Pitch", "Kind", "Class", "Price"], rows)])
32
+
33
+
34
+ @app.command()
35
+ def show(
36
+ card: str = typer.Argument(..., help="Card id or name"),
37
+ json_: bool = typer.Option(False, "--json"),
38
+ csv_: bool = typer.Option(False, "--csv"),
39
+ ):
40
+ """Show one card's metadata."""
41
+ set_mode(json_, csv_)
42
+ card_id = resolve.card_id(card)
43
+ payload = client.get(f"/cards/{card_id}")
44
+ data = payload["data"]
45
+ rows = [
46
+ [field, str(data.get(field, ""))]
47
+ for field in (
48
+ "card_id", "card_name", "kind", "pitch_value", "cost", "power",
49
+ "defense", "class", "rarity", "tcg_market_price", "default_set_name",
50
+ )
51
+ if data.get(field) not in (None, "")
52
+ ]
53
+ emit(payload, tables=[(data.get("card_name", card_id), ["Field", "Value"], rows)])
54
+ if data.get("text"):
55
+ console.print(f"[dim]{data['text']}[/dim]")
56
+
57
+
58
+ @app.command()
59
+ def trends(
60
+ card: str = typer.Argument(..., help="Card id or name"),
61
+ format_type: str = typer.Option("cc", "--format"),
62
+ days: int = typer.Option(30, "--days", min=1, max=365),
63
+ json_: bool = typer.Option(False, "--json"),
64
+ csv_: bool = typer.Option(False, "--csv"),
65
+ ):
66
+ """Daily play-rate time series."""
67
+ set_mode(json_, csv_)
68
+ card_id = resolve.card_id(card)
69
+ payload = client.get(f"/cards/{card_id}/trends", {"format": format_type, "days": days})
70
+ data = payload["data"]
71
+ points = data.get("data_points") or data.get("trends") or []
72
+ rows = [
73
+ [
74
+ p.get("date", ""),
75
+ pct(p.get("deck_frequency")),
76
+ p.get("deck_count", ""),
77
+ p.get("match_count", ""),
78
+ ]
79
+ for p in points
80
+ ]
81
+ summary = []
82
+ if data.get("trend_direction"):
83
+ summary.append(
84
+ f"[bold]{card_id}[/bold] ({format_type}, {days}d): trend {data['trend_direction']}"
85
+ + (f", change {data.get('percent_change'):+.1f}%" if data.get("percent_change") is not None else "")
86
+ )
87
+ emit(payload, tables=[("Daily", ["Date", "Deck freq", "Decks", "Matches"], rows)], summary=summary)
88
+
89
+
90
+ @app.command()
91
+ def synergies(
92
+ card: str = typer.Argument(..., help="Card id or name"),
93
+ format_type: str = typer.Option("cc", "--format"),
94
+ limit: int = typer.Option(20, "--limit", min=1, max=50),
95
+ json_: bool = typer.Option(False, "--json"),
96
+ csv_: bool = typer.Option(False, "--csv"),
97
+ ):
98
+ """Co-occurrence synergy partners, with scores."""
99
+ set_mode(json_, csv_)
100
+ card_id = resolve.card_id(card)
101
+ payload = client.get(f"/cards/{card_id}/synergies", {"format": format_type, "limit": limit})
102
+ rows = [
103
+ [
104
+ s.get("card_id", ""),
105
+ s.get("card_name", ""),
106
+ f"{s.get('synergy_score', 0):.3f}",
107
+ s.get("co_occurrence_count", ""),
108
+ f"${s['tcg_market_price']:.2f}" if s.get("tcg_market_price") else "-",
109
+ ]
110
+ for s in payload["data"].get("synergies", [])
111
+ ]
112
+ emit(payload, tables=[(f"Synergies for {card_id}", ["ID", "Name", "Score", "Co-occur", "Price"], rows)])
113
+
114
+
115
+ @app.command()
116
+ def popular(
117
+ format_type: str = typer.Option("cc", "--format"),
118
+ days: int = typer.Option(7, "--days", min=1, max=365),
119
+ limit: int = typer.Option(25, "--limit", min=1, max=100),
120
+ json_: bool = typer.Option(False, "--json"),
121
+ csv_: bool = typer.Option(False, "--csv"),
122
+ ):
123
+ """Most-played cards over the window."""
124
+ set_mode(json_, csv_)
125
+ payload = client.get("/cards/popular", {"format": format_type, "days": days, "limit": limit})
126
+ rows = [
127
+ [
128
+ c.get("rank", ""),
129
+ c.get("card_id", ""),
130
+ c.get("card_name", ""),
131
+ pct(c.get("avg_deck_frequency")),
132
+ c.get("total_deck_count", ""),
133
+ c.get("total_matches", ""),
134
+ ]
135
+ for c in payload["data"].get("cards", [])
136
+ ]
137
+ emit(
138
+ payload,
139
+ tables=[("Popular cards", ["#", "ID", "Name", "Deck freq", "Decks", "Matches"], rows)],
140
+ summary=[f"[bold]Most played[/bold] - {format_type}, last {days}d"],
141
+ )
@@ -0,0 +1,91 @@
1
+ """Hero catalog + pre-computed matchup aggregates."""
2
+ import typer
3
+
4
+ from .. import client, resolve
5
+ from ..output import emit, pct, set_mode
6
+
7
+ app = typer.Typer(help="Heroes and their matchup tables")
8
+
9
+
10
+ @app.command("list")
11
+ def list_heroes(
12
+ hero_class: str = typer.Option(None, "--class", help="Filter by class, e.g. Mechanologist"),
13
+ json_: bool = typer.Option(False, "--json"),
14
+ csv_: bool = typer.Option(False, "--csv"),
15
+ ):
16
+ """List all heroes."""
17
+ set_mode(json_, csv_)
18
+ payload = client.get("/heroes", {"class": hero_class})
19
+ rows = [
20
+ [h["hero_id"], h.get("hero_name", ""), ", ".join(h.get("class", []) if isinstance(h.get("class"), list) else [str(h.get("class", ""))]), h.get("life", ""), h.get("intellect", "")]
21
+ for h in payload["data"]
22
+ ]
23
+ emit(payload, tables=[("Heroes", ["ID", "Name", "Class", "Life", "Int"], rows)])
24
+
25
+
26
+ @app.command()
27
+ def show(
28
+ hero: str = typer.Argument(..., help="Hero id or name"),
29
+ json_: bool = typer.Option(False, "--json"),
30
+ csv_: bool = typer.Option(False, "--csv"),
31
+ ):
32
+ """Show one hero's details."""
33
+ set_mode(json_, csv_)
34
+ hero_id = resolve.hero_id(hero)
35
+ payload = client.get(f"/heroes/{hero_id}")
36
+ data = payload["data"]
37
+ rows = [
38
+ [field, str(data.get(field, ""))]
39
+ for field in ("hero_id", "hero_name", "class", "life", "intellect", "rarity", "tcg_market_price")
40
+ if data.get(field) is not None
41
+ ]
42
+ emit(payload, tables=[(data.get("hero_name", hero_id), ["Field", "Value"], rows)])
43
+
44
+
45
+ @app.command()
46
+ def aggregate(
47
+ hero: str = typer.Argument(..., help="Hero id or name"),
48
+ format_type: str = typer.Option("cc", "--format"),
49
+ source: str = typer.Option("online", "--source", help="online | paper"),
50
+ period: str = typer.Option("14d", "--period", help="7d | 14d | 30d"),
51
+ json_: bool = typer.Option(False, "--json"),
52
+ csv_: bool = typer.Option(False, "--csv"),
53
+ ):
54
+ """Pre-computed matchup table: per-opponent record + play/draw splits."""
55
+ set_mode(json_, csv_)
56
+ hero_id = resolve.hero_id(hero)
57
+ payload = client.get(
58
+ f"/heroes/{hero_id}/aggregate",
59
+ {"format": format_type, "source": source, "period": period},
60
+ )
61
+ data = payload["data"]
62
+ names = resolve.hero_names()
63
+
64
+ matchups = sorted(
65
+ data.get("matchups", {}).items(),
66
+ key=lambda kv: kv[1].get("total_matches", 0),
67
+ reverse=True,
68
+ )
69
+ rows = [
70
+ [
71
+ names.get(opp_id, opp_id),
72
+ opp_id,
73
+ m.get("total_matches", 0),
74
+ pct(m.get("win_rate")),
75
+ pct(m.get("going_first_win_rate")),
76
+ pct(m.get("going_second_win_rate")),
77
+ ]
78
+ for opp_id, m in matchups
79
+ ]
80
+ summary = [
81
+ f"[bold]{names.get(hero_id, hero_id)}[/bold] - {format_type}/{source}/{period}: "
82
+ f"{data.get('total_matches', 0)} matches, "
83
+ f"overall WR {pct(data.get('overall_win_rate'))} "
84
+ f"(first {pct(data.get('going_first_win_rate'))}, "
85
+ f"second {pct(data.get('going_second_win_rate'))})"
86
+ ]
87
+ emit(
88
+ payload,
89
+ tables=[("Matchups", ["Opponent", "ID", "Matches", "WR", "First WR", "Second WR"], rows)],
90
+ summary=summary,
91
+ )
@@ -0,0 +1,121 @@
1
+ """The matchup command."""
2
+ from typing import List
3
+
4
+ import typer
5
+
6
+ from .. import client, resolve
7
+ from ..output import emit, pct, set_mode
8
+
9
+
10
+ def matchup(
11
+ hero1: str = typer.Argument(..., help="Deck 1 hero (id or name)"),
12
+ hero2: str = typer.Argument(..., help="Deck 2 hero (id or name)"),
13
+ format_type: str = typer.Option("cc", "--format"),
14
+ days: int = typer.Option(30, "--days", min=1, max=365),
15
+ start_date: str = typer.Option(None, "--start", help="YYYY-MM-DD (overrides --days)"),
16
+ end_date: str = typer.Option(None, "--end", help="YYYY-MM-DD"),
17
+ winner: int = typer.Option(None, "--winner", min=1, max=2, help="Only games won by deck 1 or 2"),
18
+ include1: List[str] = typer.Option([], "--include1", help="Deck 1 must run this card (repeatable; supports card:gte2)"),
19
+ include2: List[str] = typer.Option([], "--include2", help="Deck 2 must run this card"),
20
+ exclude1: List[str] = typer.Option([], "--exclude1", help="Deck 1 must NOT run this card"),
21
+ exclude2: List[str] = typer.Option([], "--exclude2", help="Deck 2 must NOT run this card"),
22
+ dynamics: bool = typer.Option(True, "--dynamics/--no-dynamics", help="Game length / fatigue stats"),
23
+ top: int = typer.Option(10, "--top", min=1, max=50, help="Cards per table"),
24
+ json_: bool = typer.Option(False, "--json"),
25
+ csv_: bool = typer.Option(False, "--csv"),
26
+ ):
27
+ """Head-to-head analysis: win rates, play/draw splits, card tables, tech
28
+ cards, and game dynamics."""
29
+ set_mode(json_, csv_)
30
+ h1 = resolve.hero_id(hero1)
31
+ h2 = resolve.hero_id(hero2)
32
+ params = {
33
+ "format": format_type,
34
+ "days": days,
35
+ "start_date": start_date,
36
+ "end_date": end_date,
37
+ "winner": winner,
38
+ "included_deck1": [resolve.card_id(c, allow_spec=True) for c in include1],
39
+ "included_deck2": [resolve.card_id(c, allow_spec=True) for c in include2],
40
+ "excluded_deck1": [resolve.card_id(c) for c in exclude1],
41
+ "excluded_deck2": [resolve.card_id(c) for c in exclude2],
42
+ "include_dynamics": dynamics,
43
+ }
44
+ payload = client.get(f"/matchups/{h1}/{h2}", params)
45
+ data = payload["data"]
46
+ names = resolve.hero_names()
47
+ name1, name2 = names.get(h1, h1), names.get(h2, h2)
48
+
49
+ total = data.get("total_matches", 0)
50
+ summary = [
51
+ f"[bold]{name1}[/bold] vs [bold]{name2}[/bold] - {format_type}, {total} matches"
52
+ ]
53
+ if total == 0:
54
+ emit(payload, summary=summary + ["No matches found for these filters."])
55
+ return
56
+
57
+ overview_rows = []
58
+ for deck_key, name in (("deck1", name1), ("deck2", name2)):
59
+ overview_rows.append([
60
+ name,
61
+ data.get(f"{deck_key}_wins", 0),
62
+ pct(data.get(f"{deck_key}_win_rate")),
63
+ f"{pct(data.get(f'{deck_key}_going_first_win_rate'))} ({data.get(f'{deck_key}_going_first_matches', 0)})",
64
+ f"{pct(data.get(f'{deck_key}_going_second_win_rate'))} ({data.get(f'{deck_key}_going_second_matches', 0)})",
65
+ ])
66
+
67
+ tables = [("Overview", ["Deck", "Wins", "WR", "First WR (n)", "Second WR (n)"], overview_rows)]
68
+
69
+ def card_rows(cards):
70
+ return [
71
+ [
72
+ c.get("card_id", ""),
73
+ pct(c.get("frequency")),
74
+ pct(c.get("win_rate")),
75
+ pct(c.get("play_frequency")),
76
+ pct(c.get("block_frequency")),
77
+ pct(c.get("pitch_frequency")),
78
+ f"{c.get('copies_avg', 0):.1f}",
79
+ ]
80
+ for c in (cards or [])[:top]
81
+ ]
82
+
83
+ card_columns = ["Card", "Freq", "WR", "Play", "Block", "Pitch", "Copies"]
84
+ for deck_key, name in (("deck1", name1), ("deck2", name2)):
85
+ frequent = data.get(f"{deck_key}_frequent_cards")
86
+ if frequent:
87
+ tables.append((f"{name} - top cards", card_columns, card_rows(frequent)))
88
+ tech = data.get(f"{deck_key}_tech_cards")
89
+ if tech:
90
+ tech_rows = [
91
+ [
92
+ c.get("card_id", ""),
93
+ pct(c.get("frequency")),
94
+ pct(c.get("win_rate")),
95
+ f"+{c.get('wilson_improvement', 0) * 100:.1f}%",
96
+ c.get("match_count", ""),
97
+ ]
98
+ for c in tech[:top]
99
+ ]
100
+ tables.append((f"{name} - tech cards (Wilson outperformers)", ["Card", "Freq", "WR", "Wilson delta", "Matches"], tech_rows))
101
+
102
+ game_length = data.get("game_length") or {}
103
+ turns = game_length.get("turns") or {}
104
+ if turns:
105
+ percentiles = turns.get("percentiles") or {}
106
+ spread = ""
107
+ if percentiles.get("p10") is not None and percentiles.get("p90") is not None:
108
+ spread = f", p10-p90 {percentiles['p10']}-{percentiles['p90']}"
109
+ summary.append(
110
+ f"Game length: avg {turns.get('avg', '?')} turns, "
111
+ f"median {turns.get('median', '?')}{spread}"
112
+ )
113
+ for deck_key, name in (("deck1", name1), ("deck2", name2)):
114
+ transform = data.get(f"{deck_key}_transform")
115
+ if transform and transform.get("can_transform"):
116
+ summary.append(
117
+ f"{name} transform rate: {pct(transform.get('transform_rate'))} "
118
+ f"({transform.get('transformed_matches', 0)}/{transform.get('assessed_matches', 0)} games)"
119
+ )
120
+
121
+ emit(payload, tables=tables, summary=summary)
@@ -0,0 +1,76 @@
1
+ """Meta snapshot + movers."""
2
+ import typer
3
+
4
+ from .. import client
5
+ from ..output import emit, pct, set_mode
6
+
7
+ app = typer.Typer(help="Meta breakdown and trending heroes")
8
+
9
+
10
+ @app.command()
11
+ def snapshot(
12
+ days: int = typer.Option(14, "--days", min=1, max=90),
13
+ format_type: str = typer.Option("cc", "--format"),
14
+ source: str = typer.Option("online", "--source", help="online | paper"),
15
+ tournament_type: str = typer.Option(None, "--type", help="Paper only, e.g. Calling"),
16
+ json_: bool = typer.Option(False, "--json"),
17
+ csv_: bool = typer.Option(False, "--csv"),
18
+ ):
19
+ """Hero popularity, win rate, and top matchups over the window."""
20
+ set_mode(json_, csv_)
21
+ payload = client.get(
22
+ "/meta/snapshot",
23
+ {"days": days, "format": format_type, "source": source, "tournament_type": tournament_type},
24
+ )
25
+ data = payload["data"]
26
+ rows = [
27
+ [
28
+ rank,
29
+ h.get("hero_name", h.get("hero_id", "")),
30
+ h.get("hero_id", ""),
31
+ f"{h.get('percentage', 0):.1f}%",
32
+ pct(h.get("win_rate")),
33
+ h.get("count", 0),
34
+ ]
35
+ for rank, h in enumerate(data.get("heroes", []), start=1)
36
+ ]
37
+ summary = [
38
+ f"[bold]Meta snapshot[/bold] - {format_type}/{source}, last {days}d, "
39
+ f"{data.get('total_matches', 0)} matches"
40
+ ]
41
+ emit(payload, tables=[("Heroes", ["#", "Hero", "ID", "Play %", "WR", "Games"], rows)], summary=summary)
42
+
43
+
44
+ @app.command()
45
+ def movers(
46
+ days: int = typer.Option(14, "--days", min=1, max=90),
47
+ format_type: str = typer.Option("cc", "--format"),
48
+ source: str = typer.Option("online", "--source"),
49
+ limit: int = typer.Option(10, "--limit", min=1, max=50),
50
+ sort_by: str = typer.Option("popularity", "--sort-by", help="popularity | winrate"),
51
+ json_: bool = typer.Option(False, "--json"),
52
+ csv_: bool = typer.Option(False, "--csv"),
53
+ ):
54
+ """Rising and falling heroes over the window."""
55
+ set_mode(json_, csv_)
56
+ payload = client.get(
57
+ "/meta/movers",
58
+ {"days": days, "format": format_type, "source": source, "limit": limit, "sort_by": sort_by},
59
+ )
60
+ arrows = {"up": "↑", "down": "↓", "stable": "→"}
61
+ rows = [
62
+ [
63
+ h.get("hero_name", h.get("hero_id", "")),
64
+ h.get("hero_id", ""),
65
+ arrows.get(h.get("trend_direction", ""), h.get("trend_direction", "")),
66
+ f"{h.get('percent_change', 0):+.1f}%",
67
+ f"{h.get('current_popularity', 0):.1f}%",
68
+ f"{h.get('avg_popularity', 0):.1f}%",
69
+ ]
70
+ for h in payload["data"].get("heroes", [])
71
+ ]
72
+ emit(
73
+ payload,
74
+ tables=[("Movers", ["Hero", "ID", "Trend", "Change", "Now", "Avg"], rows)],
75
+ summary=[f"[bold]Meta movers[/bold] - {format_type}/{source}, last {days}d, by {sort_by}"],
76
+ )
@@ -0,0 +1,120 @@
1
+ """Tournament events, final standings, decklists."""
2
+ import typer
3
+
4
+ from .. import client
5
+ from ..output import emit, set_mode
6
+
7
+ app = typer.Typer(help="Paper tournaments: events, standings, decklists")
8
+
9
+
10
+ @app.command("list")
11
+ def list_tournaments(
12
+ format_type: str = typer.Option(None, "--format"),
13
+ tournament_type: str = typer.Option(None, "--type", help="Battle Hardened, National, Pro Tour, Calling..."),
14
+ status: str = typer.Option(None, "--status"),
15
+ has_decklists: bool = typer.Option(None, "--has-decklists/--no-decklists"),
16
+ limit: int = typer.Option(25, "--limit", min=1, max=100),
17
+ offset: int = typer.Option(0, "--offset", min=0),
18
+ json_: bool = typer.Option(False, "--json"),
19
+ csv_: bool = typer.Option(False, "--csv"),
20
+ ):
21
+ """List tournaments, newest first."""
22
+ set_mode(json_, csv_)
23
+ payload = client.get(
24
+ "/tournaments",
25
+ {
26
+ "format": format_type,
27
+ "type": tournament_type,
28
+ "status": status,
29
+ "has_decklists": has_decklists,
30
+ "limit": limit,
31
+ "offset": offset,
32
+ },
33
+ )
34
+ rows = [
35
+ [
36
+ e.get("id", ""),
37
+ e.get("tournament_name", ""),
38
+ (e.get("event_date") or "")[:10],
39
+ e.get("format", ""),
40
+ e.get("type", ""),
41
+ e.get("total_players", ""),
42
+ (e.get("winner") or {}).get("player_name", "-") if isinstance(e.get("winner"), dict) else "-",
43
+ ]
44
+ for e in payload["data"]
45
+ ]
46
+ emit(
47
+ payload,
48
+ tables=[("Tournaments", ["ID", "Name", "Date", "Format", "Type", "Players", "Winner"], rows)],
49
+ summary=[f"{payload['meta'].get('total', len(rows))} tournaments (showing {len(rows)})"],
50
+ )
51
+
52
+
53
+ @app.command()
54
+ def show(
55
+ event_id: str = typer.Argument(..., help="Tournament id (from `tournaments list`)"),
56
+ json_: bool = typer.Option(False, "--json"),
57
+ csv_: bool = typer.Option(False, "--csv"),
58
+ ):
59
+ """Show one tournament's details."""
60
+ set_mode(json_, csv_)
61
+ payload = client.get(f"/tournaments/{event_id}")
62
+ data = payload["data"]
63
+ rows = [
64
+ [field, str(data.get(field, ""))]
65
+ for field in (
66
+ "tournament_name", "event_date", "format", "type", "status",
67
+ "total_players", "total_rounds", "has_decklists", "has_matches", "event_url",
68
+ )
69
+ if data.get(field) is not None
70
+ ]
71
+ emit(payload, tables=[(data.get("tournament_name", event_id), ["Field", "Value"], rows)])
72
+
73
+
74
+ @app.command()
75
+ def standings(
76
+ event_id: str = typer.Argument(...),
77
+ json_: bool = typer.Option(False, "--json"),
78
+ csv_: bool = typer.Option(False, "--csv"),
79
+ ):
80
+ """Winner + top 8 from official final standings."""
81
+ set_mode(json_, csv_)
82
+ payload = client.get(f"/tournaments/{event_id}/standings")
83
+ data = payload["data"]
84
+ rows = [
85
+ [p.get("rank", ""), p.get("player_name", ""), p.get("hero_name", ""), p.get("hero_id", "")]
86
+ for p in data.get("top_8", [])
87
+ ]
88
+ winner = data.get("winner") or {}
89
+ summary = [f"[bold]{data.get('tournament_name', '')}[/bold]"]
90
+ if winner:
91
+ summary.append(
92
+ f"Winner: [green]{winner.get('player_name', '?')}[/green] "
93
+ f"({winner.get('hero_name', '?')})"
94
+ )
95
+ emit(payload, tables=[("Top 8", ["Rank", "Player", "Hero", "Hero ID"], rows)], summary=summary)
96
+
97
+
98
+ @app.command()
99
+ def decklists(
100
+ event_id: str = typer.Argument(...),
101
+ json_: bool = typer.Option(False, "--json"),
102
+ csv_: bool = typer.Option(False, "--csv"),
103
+ ):
104
+ """Submitted decklists for a tournament."""
105
+ set_mode(json_, csv_)
106
+ payload = client.get(f"/tournaments/{event_id}/decklists")
107
+ rows = [
108
+ [
109
+ d.get("player_id", ""),
110
+ len(d.get("card_ids") or []),
111
+ len(d.get("equipment_ids") or []),
112
+ d.get("decklist_url", ""),
113
+ ]
114
+ for d in payload["data"]
115
+ ]
116
+ emit(
117
+ payload,
118
+ tables=[("Decklists", ["Player", "Cards", "Equipment", "URL"], rows)],
119
+ summary=[f"{payload['meta'].get('count', len(rows))} decklists (full card lists in --json)"],
120
+ )
@@ -0,0 +1,39 @@
1
+ """Config + cache paths. Key/URL resolution order: env var, then config file."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from platformdirs import user_cache_dir, user_config_dir
7
+
8
+ APP_NAME = "fablazing"
9
+ DEFAULT_API_URL = "https://fablazing.com/api/v1"
10
+
11
+
12
+ def config_path() -> Path:
13
+ return Path(user_config_dir(APP_NAME)) / "config.json"
14
+
15
+
16
+ def load_config() -> dict:
17
+ try:
18
+ return json.loads(config_path().read_text(encoding="utf-8"))
19
+ except (OSError, ValueError):
20
+ return {}
21
+
22
+
23
+ def save_config(cfg: dict) -> None:
24
+ path = config_path()
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
27
+
28
+
29
+ def api_key():
30
+ return os.environ.get("FABLAZING_API_KEY") or load_config().get("api_key")
31
+
32
+
33
+ def api_url() -> str:
34
+ url = os.environ.get("FABLAZING_API_URL") or load_config().get("api_url") or DEFAULT_API_URL
35
+ return url.rstrip("/")
36
+
37
+
38
+ def cache_path(name: str) -> Path:
39
+ return Path(user_cache_dir(APP_NAME)) / name
fablazing_cli/main.py ADDED
@@ -0,0 +1,41 @@
1
+ """Fablazing CLI entry point. `fablazing` and `fabz` both land here."""
2
+ import typer
3
+
4
+ from . import __version__
5
+ from .commands import auth, card, hero, meta, tournaments
6
+ from .commands.matchup import matchup
7
+ from .output import console
8
+ from .reference import REFERENCE
9
+
10
+ app = typer.Typer(
11
+ name="fablazing",
12
+ help=(
13
+ "Flesh and Blood analytics from your terminal: matchups, meta, cards, "
14
+ "and tournaments. Run `fablazing reference` for the full primer."
15
+ ),
16
+ no_args_is_help=True,
17
+ context_settings={"help_option_names": ["-h", "--help"]},
18
+ )
19
+
20
+ app.add_typer(auth.app, name="auth")
21
+ app.add_typer(hero.app, name="hero")
22
+ app.add_typer(meta.app, name="meta")
23
+ app.add_typer(card.app, name="card")
24
+ app.add_typer(tournaments.app, name="tournaments")
25
+ app.command()(matchup)
26
+
27
+
28
+ @app.command()
29
+ def reference():
30
+ """Print the full CLI + data-semantics primer (markdown)."""
31
+ print(REFERENCE)
32
+
33
+
34
+ @app.command()
35
+ def version():
36
+ """Print the CLI version."""
37
+ console.print(f"fablazing-cli {__version__}")
38
+
39
+
40
+ if __name__ == "__main__":
41
+ app()
@@ -0,0 +1,97 @@
1
+ """Rendering: rich tables on a tty, JSON when piped or with --json, CSV of
2
+ the primary table with --csv. JSON mode always gets the full API payload."""
3
+ import csv
4
+ import io
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ # Windows pipes default to cp1252, which crashes on non-ASCII card/hero
14
+ # names (e.g. "Jarl Vetreiði"). Force UTF-8 on both streams.
15
+ for _stream in (sys.stdout, sys.stderr):
16
+ if hasattr(_stream, "reconfigure"):
17
+ try:
18
+ _stream.reconfigure(encoding="utf-8", errors="replace")
19
+ except (OSError, ValueError):
20
+ pass
21
+
22
+ console = Console()
23
+ err_console = Console(stderr=True)
24
+
25
+ _forced_mode = None # None = auto (table on TTY, json otherwise)
26
+
27
+
28
+ def set_mode(json_flag: bool, csv_flag: bool) -> None:
29
+ global _forced_mode
30
+ if json_flag and csv_flag:
31
+ fail("--json and --csv are mutually exclusive", 2)
32
+ if json_flag:
33
+ _forced_mode = "json"
34
+ elif csv_flag:
35
+ _forced_mode = "csv"
36
+
37
+
38
+ def mode() -> str:
39
+ if _forced_mode:
40
+ return _forced_mode
41
+ env_mode = os.environ.get("FABLAZING_OUTPUT")
42
+ if env_mode in ("table", "json", "csv"):
43
+ return env_mode
44
+ return "table" if sys.stdout.isatty() else "json"
45
+
46
+
47
+ def fail(message: str, code: int = 1):
48
+ err_console.print(f"[red]error:[/red] {message}")
49
+ raise typer.Exit(code)
50
+
51
+
52
+ def pct(value, digits: int = 1) -> str:
53
+ """Format a 0-1 rate as a percentage string."""
54
+ if value is None:
55
+ return "-"
56
+ return f"{value * 100:.{digits}f}%"
57
+
58
+
59
+ def num(value) -> str:
60
+ return "-" if value is None else str(value)
61
+
62
+
63
+ def emit(payload, tables=None, summary=None) -> None:
64
+ """payload: full API response (JSON mode). tables: [(title, columns,
65
+ rows)]. summary: list of rich-markup lines printed above tables."""
66
+ current = mode()
67
+
68
+ if current == "json":
69
+ buffer = io.StringIO()
70
+ json.dump(payload, buffer, indent=2, default=str, ensure_ascii=False)
71
+ print(buffer.getvalue())
72
+ return
73
+
74
+ if current == "csv":
75
+ if not tables:
76
+ fail("this command has no tabular form; use --json", 2)
77
+ title, columns, rows = tables[0]
78
+ if len(tables) > 1:
79
+ err_console.print(
80
+ f"[yellow]note:[/yellow] --csv only emits the first table ({title}), use --json for everything"
81
+ )
82
+ writer = csv.writer(sys.stdout, lineterminator="\n")
83
+ writer.writerow(columns)
84
+ writer.writerows(rows)
85
+ return
86
+
87
+ for line in summary or []:
88
+ console.print(line)
89
+ for title, columns, rows in tables or []:
90
+ table = Table(title=title, title_justify="left")
91
+ for column in columns:
92
+ table.add_column(str(column))
93
+ for row in rows:
94
+ table.add_row(*[str(cell) for cell in row])
95
+ console.print(table)
96
+ if not tables and not summary:
97
+ console.print_json(json.dumps(payload, default=str))
@@ -0,0 +1,61 @@
1
+ """Text for the `reference` command."""
2
+
3
+ REFERENCE = """\
4
+ # Fablazing CLI reference
5
+
6
+ Terminal client for the Fablazing API: matchup stats, meta breakdowns, card
7
+ analytics and paper tournament results for competitive Flesh and Blood.
8
+ `fablazing` and `fabz` are the same binary.
9
+
10
+ ## Auth
11
+ - `fablazing auth set-key fbl_live_...` stores your key, or set the
12
+ FABLAZING_API_KEY env var.
13
+ - `fablazing auth status` checks it.
14
+ - Keys have a daily quota and a per-minute limit. Hitting either exits
15
+ with code 3.
16
+
17
+ ## IDs and formats
18
+ - Hero ids look like `EVO001` (set code + number).
19
+ - Card ids are snake_case with a pitch color suffix: `sink_below_red`.
20
+ The same card name can exist as red, yellow and blue. Those are
21
+ different cards and are counted separately everywhere.
22
+ - Names are accepted anywhere an id is. Ambiguous names error out and
23
+ list the matching ids (exit code 2).
24
+ - Formats: `cc` (default), `blitz`, `ll`, `sage`, `gage`.
25
+
26
+ ## Data notes
27
+ - win_rate fields are fractions between 0 and 1.
28
+ - In `matchup HERO1 HERO2`, deck1 is HERO1, so deck1_win_rate is HERO1's.
29
+ - going_first means that player took the first turn.
30
+ - Matches are deduplicated. Transforming heroes (Levia, Marionette, Maxx,
31
+ Teklovossen) are stored under their base hero id, with transform stats
32
+ in deck1_transform / deck2_transform.
33
+ - Tech cards are Wilson-scored against the matchup baseline, so a lucky
34
+ streak on a small sample doesn't make the list.
35
+ - Tournament standings and decklists come from official paper events.
36
+
37
+ ## Commands
38
+ - `fablazing matchup EVO001 "Jarl" --days 30`
39
+ Card filters: `--include1/--include2/--exclude1/--exclude2`, repeatable.
40
+ Copy counts on includes: `--include1 "sink_below_red:gte2"` (eq/gte/lte,
41
+ cards only, not equipment). `--no-dynamics` skips game length stats.
42
+ - `fablazing meta snapshot --format cc --days 14`
43
+ - `fablazing meta movers --days 14`
44
+ - `fablazing hero list` / `hero show EVO001`
45
+ - `fablazing hero aggregate EVO001 --period 14d --source online`
46
+ - `fablazing card search "sink"` / `card show sink_below_red`
47
+ - `fablazing card trends sink_below_red --days 30`
48
+ - `fablazing card synergies sink_below_red`
49
+ - `fablazing card popular --days 7`
50
+ - `fablazing tournaments list --type Calling`
51
+ - `fablazing tournaments show <id>` / `standings <id>` / `decklists <id>`
52
+ - `fablazing reference` prints this text.
53
+
54
+ ## Output
55
+ - Tables on a terminal, JSON when piped or with --json (the JSON is the
56
+ full API payload, a superset of the tables), CSV of the primary table
57
+ with --csv. Data goes to stdout, errors to stderr.
58
+
59
+ ## Exit codes
60
+ 0 ok, 1 api error, 2 usage error, 3 quota exceeded
61
+ """
@@ -0,0 +1,87 @@
1
+ """Resolve hero/card names to ids so commands accept either form.
2
+
3
+ Heroes come from a 24h disk cache of /heroes. Cards go through
4
+ /cards/search, but only when the input isn't already a card_id.
5
+ """
6
+ import json
7
+ import re
8
+ import time
9
+
10
+ from . import client
11
+ from .config import cache_path
12
+ from .output import fail
13
+
14
+ HERO_ID_RE = re.compile(r"^[A-Z]{2,4}\d{3}$")
15
+ CARD_ID_RE = re.compile(r"^[a-z0-9_]+$")
16
+ CARD_SPEC_RE = re.compile(r"^(?P<card>.+?):(?P<spec>(eq|gte|lte)\d+)$")
17
+ HERO_CACHE_TTL = 24 * 3600
18
+ _HEROES_CACHE_FILE = "heroes.json"
19
+
20
+
21
+ def hero_list() -> list:
22
+ path = cache_path(_HEROES_CACHE_FILE)
23
+ try:
24
+ cached = json.loads(path.read_text(encoding="utf-8"))
25
+ if time.time() - cached["at"] < HERO_CACHE_TTL:
26
+ return cached["heroes"]
27
+ except (OSError, ValueError, KeyError):
28
+ pass
29
+ heroes = client.get("/heroes")["data"]
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ path.write_text(
32
+ json.dumps({"at": time.time(), "heroes": heroes}), encoding="utf-8"
33
+ )
34
+ return heroes
35
+
36
+
37
+ def hero_names() -> dict:
38
+ """hero_id -> hero_name map from the cached hero list."""
39
+ return {h["hero_id"]: h.get("hero_name", h["hero_id"]) for h in hero_list()}
40
+
41
+
42
+ def hero_id(text: str) -> str:
43
+ candidate = text.strip()
44
+ if HERO_ID_RE.match(candidate.upper()):
45
+ return candidate.upper()
46
+
47
+ query = candidate.lower()
48
+ heroes = hero_list()
49
+ exact = [
50
+ h for h in heroes
51
+ if h.get("hero_name", "").lower() == query or h.get("snake_case") == query
52
+ ]
53
+ if len(exact) == 1:
54
+ return exact[0]["hero_id"]
55
+ partial = [h for h in heroes if query in h.get("hero_name", "").lower()]
56
+ if len(partial) == 1:
57
+ return partial[0]["hero_id"]
58
+ if not partial:
59
+ fail(f"no hero matches '{text}'", 2)
60
+ options = ", ".join(f"{h['hero_name']} ({h['hero_id']})" for h in partial[:8])
61
+ fail(f"ambiguous hero '{text}' - did you mean: {options}", 2)
62
+
63
+
64
+ def card_id(text: str, allow_spec: bool = False) -> str:
65
+ """Resolve a card name or id; optionally preserve a :gte2-style suffix."""
66
+ candidate = text.strip()
67
+ spec = ""
68
+ match = CARD_SPEC_RE.match(candidate)
69
+ if match and allow_spec:
70
+ candidate, spec = match.group("card"), ":" + match.group("spec")
71
+
72
+ if CARD_ID_RE.match(candidate):
73
+ return candidate + spec
74
+
75
+ results = client.get("/cards/search", {"q": candidate, "limit": 10})["data"]
76
+ exact = [c for c in results if c.get("card_name", "").lower() == candidate.lower()]
77
+ pool = exact or results
78
+ if len(pool) == 1:
79
+ return pool[0]["card_id"] + spec
80
+ if not pool:
81
+ fail(f"no card matches '{candidate}'", 2)
82
+ options = ", ".join(c["card_id"] for c in pool[:9])
83
+ fail(
84
+ f"ambiguous card '{candidate}' (pitch variants share names) - "
85
+ f"use a card_id: {options}",
86
+ 2,
87
+ )
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: fablazing-cli
3
+ Version: 0.1.0
4
+ Summary: Fablazing analytics from your terminal - Flesh and Blood matchups, meta, cards, and tournaments.
5
+ Project-URL: Homepage, https://fablazing.com
6
+ Author: Fablazing
7
+ License: MIT
8
+ Keywords: analytics,cli,fab,flesh-and-blood,tcg
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: platformdirs>=4.0
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: typer>=0.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # fablazing-cli
17
+
18
+ Terminal client for [Fablazing](https://fablazing.com): matchup stats, meta
19
+ breakdowns, card trends and paper tournament results for Flesh and Blood.
20
+
21
+ You need an API key (Builder plan).
22
+
23
+ ## Install
24
+
25
+ pipx install fablazing-cli
26
+
27
+ or plain `pip install fablazing-cli`. On Windows without pipx:
28
+
29
+ py -m pip install --user pipx
30
+ py -m pipx ensurepath
31
+
32
+ This installs `fablazing` and `fabz`, same thing.
33
+
34
+ ## Setup
35
+
36
+ fabz auth set-key fbl_live_xxx
37
+ fabz auth status
38
+
39
+ The key can also come from the `FABLAZING_API_KEY` env var.
40
+
41
+ ## Usage
42
+
43
+ fabz matchup "Dash I/O" Jarl --days 30
44
+ fabz meta snapshot
45
+ fabz meta movers
46
+ fabz hero aggregate EVO001 --period 14d
47
+ fabz card trends "Sink Below" --json
48
+ fabz card popular --days 7 --csv > popular.csv
49
+ fabz tournaments list --type Calling
50
+
51
+ Heroes and cards can be given by name or id. A name that matches several
52
+ cards (pitch variants share names) errors and lists the ids so you can pick
53
+ one. Copy count filters work on matchup includes:
54
+ `--include1 "sink_below_red:gte2"`.
55
+
56
+ ## Output
57
+
58
+ Tables on a terminal. JSON when piped or with `--json`, CSV with `--csv`.
59
+ Exit codes: 0 ok, 1 api error, 2 usage error, 3 quota exceeded.
60
+
61
+ ## Tab completion
62
+
63
+ fablazing --install-completion
64
+
65
+ ## Reference
66
+
67
+ `fablazing reference` prints the full command and data reference.
@@ -0,0 +1,18 @@
1
+ fablazing_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ fablazing_cli/client.py,sha256=QcX7KPVMVJTY9-eIebv_SPPaoEnZLTwNUWXAiGXZzD8,1500
3
+ fablazing_cli/config.py,sha256=fWMBakRegMF2VxCOdp56Zb7fJUHZzVsMEoaxgFYAs8s,1015
4
+ fablazing_cli/main.py,sha256=c2YVSmsHrVsHfSQb88l3FhGJs9j8gDlqvMAjf_7m5w8,1070
5
+ fablazing_cli/output.py,sha256=pclbVso68F-JJAtImDC8ed-M8qqAlO1KYMLtoQRt0-E,2968
6
+ fablazing_cli/reference.py,sha256=8Gm7UEJpJreeHVgCiFyWDy6wvTQ0Pz4zWShnOhnF2tg,2617
7
+ fablazing_cli/resolve.py,sha256=dwlbGOADRNakW4tYTbxQ6o6QTvYQ5chBkadOUPpmJBs,2859
8
+ fablazing_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ fablazing_cli/commands/auth.py,sha256=KgDNwnqxgtxU1zgKAsvY4PgkRvU8xS4zg9LVqgVfuS4,1451
10
+ fablazing_cli/commands/card.py,sha256=4FIqeOqDu-OYKiKab2vhFW8hw_gNze0F1xgXC_m-TqU,5052
11
+ fablazing_cli/commands/hero.py,sha256=SgZ0JZeZFphPGSYg4VTpnTikN4dSgk8VjaLJ74RCB40,3214
12
+ fablazing_cli/commands/matchup.py,sha256=5i5iapR0QRcQ54fBEwQYfvP5cg0EBBKmAQi0UGl66O8,5346
13
+ fablazing_cli/commands/meta.py,sha256=Po-m64OSMKUpo2i0st4N_TyCiAu9pmpzA-mAZ8U_JuM,2821
14
+ fablazing_cli/commands/tournaments.py,sha256=UqYtWrYdFZK3hfVln8PBLvMLNit3rosyGA8Q-McZtOw,4127
15
+ fablazing_cli-0.1.0.dist-info/METADATA,sha256=WEx4AM92FYdYaH1YO0SAGsAkl66UYUus8km-jRvTfKA,1814
16
+ fablazing_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
+ fablazing_cli-0.1.0.dist-info/entry_points.txt,sha256=w6VNYUimCKbqnOw-aT8JA2q0DxE-dQTZBfh40UL2vyc,83
18
+ fablazing_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ fablazing = fablazing_cli.main:app
3
+ fabz = fablazing_cli.main:app