fablazing-cli 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (20) hide show
  1. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/CHANGELOG.md +10 -0
  2. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/PKG-INFO +1 -1
  3. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/pyproject.toml +1 -1
  4. fablazing_cli-0.3.0/src/fablazing_cli/__init__.py +1 -0
  5. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/card.py +66 -16
  6. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/hero.py +44 -0
  7. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/matchup.py +5 -0
  8. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/meta.py +6 -6
  9. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/tournaments.py +13 -13
  10. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/main.py +1 -2
  11. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/reference.py +10 -0
  12. fablazing_cli-0.2.2/src/fablazing_cli/__init__.py +0 -1
  13. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/.gitignore +0 -0
  14. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/README.md +0 -0
  15. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/client.py +0 -0
  16. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/__init__.py +0 -0
  17. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/auth.py +0 -0
  18. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/config.py +0 -0
  19. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/output.py +0 -0
  20. {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/resolve.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - `fabz hero cards`: most-used deck cards for a hero vs the whole field
6
+ (frequency, win rate, Wilson lower bound, copies) - new API endpoint
7
+ - `fabz card pov`: a card's win-rate impact for a hero, overall and per
8
+ opponent - new API endpoint
9
+ - 95% confidence intervals on matchup and hero-cards win rates
10
+ - Tabular API endpoints accept ?output=csv (+ ?api_key= for spreadsheet
11
+ IMPORTDATA use)
12
+
3
13
  ## 0.2.2
4
14
 
5
15
  - Reference: name the exact `meets_threshold` field on tech candidates and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fablazing-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Fablazing analytics from your terminal - Flesh and Blood matchups, meta, cards, and tournaments.
5
5
  Project-URL: Homepage, https://fablazing.com
6
6
  Author: Fablazing
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fablazing-cli"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Fablazing analytics from your terminal - Flesh and Blood matchups, meta, cards, and tournaments."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -1,4 +1,4 @@
1
- """Card search, metadata, trends, synergies, popularity."""
1
+ """card search / show / trends / synergies / popular"""
2
2
  import typer
3
3
 
4
4
  from .. import client, resolve
@@ -11,11 +11,11 @@ app = typer.Typer(help="Card catalog and analytics")
11
11
  def search(
12
12
  query: str = typer.Argument(..., help="Part of a card name"),
13
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"),
14
+ as_json: bool = typer.Option(False, "--json"),
15
+ as_csv: bool = typer.Option(False, "--csv"),
16
16
  ):
17
17
  """Search cards and equipment by name."""
18
- set_mode(json_, csv_)
18
+ set_mode(as_json, as_csv)
19
19
  payload = client.get("/cards/search", {"q": query, "limit": limit})
20
20
  rows = [
21
21
  [
@@ -34,11 +34,11 @@ def search(
34
34
  @app.command()
35
35
  def show(
36
36
  card: str = typer.Argument(..., help="Card id or name"),
37
- json_: bool = typer.Option(False, "--json"),
38
- csv_: bool = typer.Option(False, "--csv"),
37
+ as_json: bool = typer.Option(False, "--json"),
38
+ as_csv: bool = typer.Option(False, "--csv"),
39
39
  ):
40
40
  """Show one card's metadata."""
41
- set_mode(json_, csv_)
41
+ set_mode(as_json, as_csv)
42
42
  card_id = resolve.card_id(card)
43
43
  payload = client.get(f"/cards/{card_id}")
44
44
  data = payload["data"]
@@ -60,11 +60,11 @@ def trends(
60
60
  card: str = typer.Argument(..., help="Card id or name"),
61
61
  format_type: str = typer.Option("cc", "--format"),
62
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"),
63
+ as_json: bool = typer.Option(False, "--json"),
64
+ as_csv: bool = typer.Option(False, "--csv"),
65
65
  ):
66
66
  """Daily play-rate time series."""
67
- set_mode(json_, csv_)
67
+ set_mode(as_json, as_csv)
68
68
  card_id = resolve.card_id(card)
69
69
  payload = client.get(f"/cards/{card_id}/trends", {"format": format_type, "days": days})
70
70
  data = payload["data"]
@@ -92,11 +92,11 @@ def synergies(
92
92
  card: str = typer.Argument(..., help="Card id or name"),
93
93
  format_type: str = typer.Option("cc", "--format"),
94
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"),
95
+ as_json: bool = typer.Option(False, "--json"),
96
+ as_csv: bool = typer.Option(False, "--csv"),
97
97
  ):
98
98
  """Co-occurrence synergy partners, with scores."""
99
- set_mode(json_, csv_)
99
+ set_mode(as_json, as_csv)
100
100
  card_id = resolve.card_id(card)
101
101
  payload = client.get(f"/cards/{card_id}/synergies", {"format": format_type, "limit": limit})
102
102
  rows = [
@@ -112,16 +112,66 @@ def synergies(
112
112
  emit(payload, tables=[(f"Synergies for {card_id}", ["ID", "Name", "Score", "Co-occur", "Price"], rows)])
113
113
 
114
114
 
115
+ @app.command()
116
+ def pov(
117
+ hero: str = typer.Argument(..., help="Hero id or name", autocompletion=resolve.complete_hero),
118
+ card: str = typer.Argument(..., help="Card id or name"),
119
+ format_type: str = typer.Option("cc", "--format"),
120
+ days: int = typer.Option(30, "--days", min=1, max=90),
121
+ top: int = typer.Option(12, "--top", min=1, max=50, help="Matchup rows"),
122
+ as_json: bool = typer.Option(False, "--json"),
123
+ as_csv: bool = typer.Option(False, "--csv"),
124
+ ):
125
+ """Card point of view: does running this card change the hero's win
126
+ rate, overall and against which opponents."""
127
+ set_mode(as_json, as_csv)
128
+ hero_id = resolve.hero_id(hero)
129
+ card_id = resolve.card_id(card)
130
+ payload = client.get(
131
+ f"/card-pov/{hero_id}/{card_id}", {"format": format_type, "days": days}
132
+ )
133
+ data = payload["data"]
134
+ overall = data.get("overall_card_impact") or {}
135
+ summary = [
136
+ f"[bold]{card_id}[/bold] on [bold]{hero_id}[/bold] - {format_type}, {days}d, "
137
+ f"{data.get('total_matches_analyzed', overall.get('total_matches', '?'))} matches"
138
+ ]
139
+ if overall:
140
+ summary.append(
141
+ f"presence {pct(overall.get('presence_rate'))} | "
142
+ f"WR with {pct(overall.get('card_win_rate'))} vs without {pct(overall.get('without_card_win_rate'))} "
143
+ f"(impact {overall.get('presence_impact', 0) * 100:+.1f}%)"
144
+ )
145
+ impacts = data.get("matchup_impacts") or []
146
+ rows = [
147
+ [
148
+ m.get("opponent_hero_name", m.get("opponent_hero_id", "")),
149
+ m.get("total_matches", ""),
150
+ pct(m.get("card_presence_rate")),
151
+ pct(m.get("base_win_rate")),
152
+ pct(m.get("card_win_rate")),
153
+ pct(m.get("without_card_win_rate")),
154
+ f"{m.get('presence_impact', 0) * 100:+.1f}%",
155
+ ]
156
+ for m in impacts[:top]
157
+ ]
158
+ emit(
159
+ payload,
160
+ tables=[("Per-matchup impact", ["Opponent", "N", "Presence", "Base WR", "With", "Without", "Impact"], rows)],
161
+ summary=summary,
162
+ )
163
+
164
+
115
165
  @app.command()
116
166
  def popular(
117
167
  format_type: str = typer.Option("cc", "--format"),
118
168
  days: int = typer.Option(7, "--days", min=1, max=365),
119
169
  limit: int = typer.Option(25, "--limit", min=1, max=100),
120
- json_: bool = typer.Option(False, "--json"),
121
- csv_: bool = typer.Option(False, "--csv"),
170
+ as_json: bool = typer.Option(False, "--json"),
171
+ as_csv: bool = typer.Option(False, "--csv"),
122
172
  ):
123
173
  """Most-played cards over the window."""
124
- set_mode(json_, csv_)
174
+ set_mode(as_json, as_csv)
125
175
  payload = client.get("/cards/popular", {"format": format_type, "days": days, "limit": limit})
126
176
  rows = [
127
177
  [
@@ -42,6 +42,50 @@ def show(
42
42
  emit(payload, tables=[(data.get("hero_name", hero_id), ["Field", "Value"], rows)])
43
43
 
44
44
 
45
+ @app.command()
46
+ def cards(
47
+ hero: str = typer.Argument(..., help="Hero id or name", autocompletion=resolve.complete_hero),
48
+ format_type: str = typer.Option("cc", "--format"),
49
+ days: int = typer.Option(30, "--days", min=1, max=90),
50
+ min_freq: float = typer.Option(0.02, "--min-freq", help="Minimum deck frequency"),
51
+ limit: int = typer.Option(30, "--limit", min=1, max=300),
52
+ json_: bool = typer.Option(False, "--json"),
53
+ csv_: bool = typer.Option(False, "--csv"),
54
+ ):
55
+ """Most-used deck cards for a hero across the whole field: frequency,
56
+ win rate, Wilson lower bound, average copies."""
57
+ set_mode(json_, csv_)
58
+ hero_id = resolve.hero_id(hero)
59
+ payload = client.get(
60
+ f"/heroes/{hero_id}/cards",
61
+ {"format": format_type, "days": days, "min_frequency": min_freq, "limit": limit},
62
+ )
63
+ data = payload["data"]
64
+ rows = [
65
+ [
66
+ c.get("card_id", ""),
67
+ pct(c.get("frequency")),
68
+ c.get("matches", ""),
69
+ pct(c.get("win_rate")),
70
+ pct(c.get("wilson_lower_bound")),
71
+ f"{c.get('copies_avg', 0):.1f}",
72
+ f"${c['tcg_market_price']:.2f}" if c.get("tcg_market_price") else "-",
73
+ ]
74
+ for c in data.get("cards", [])
75
+ ]
76
+ ci = data.get("overall_win_rate_ci") or [None, None]
77
+ summary = [
78
+ f"[bold]{resolve.hero_names().get(hero_id, hero_id)}[/bold] vs the field - "
79
+ f"{format_type}, {days}d: {data.get('total_games', 0)} games, "
80
+ f"WR {pct(data.get('overall_win_rate'))} (95% CI {pct(ci[0])}-{pct(ci[1])})"
81
+ ]
82
+ emit(
83
+ payload,
84
+ tables=[("Cards", ["Card", "Freq", "N", "WR", "Wilson LB", "Copies", "Price"], rows)],
85
+ summary=summary,
86
+ )
87
+
88
+
45
89
  @app.command()
46
90
  def aggregate(
47
91
  hero: str = typer.Argument(..., help="Hero id or name", autocompletion=resolve.complete_hero),
@@ -50,6 +50,11 @@ def matchup(
50
50
  summary = [
51
51
  f"[bold]{name1}[/bold] vs [bold]{name2}[/bold] - {format_type}, {total} matches"
52
52
  ]
53
+ ci1, ci2 = data.get("deck1_win_rate_ci"), data.get("deck2_win_rate_ci")
54
+ if ci1 and ci2:
55
+ summary.append(
56
+ f"95% CI: {name1} {pct(ci1[0])}-{pct(ci1[1])}, {name2} {pct(ci2[0])}-{pct(ci2[1])}"
57
+ )
53
58
  if total == 0:
54
59
  emit(payload, summary=summary + ["No matches found for these filters."])
55
60
  return
@@ -58,17 +58,17 @@ def movers(
58
58
  {"days": days, "format": format_type, "source": source, "limit": limit, "sort_by": sort_by},
59
59
  )
60
60
  arrows = {"up": "↑", "down": "↓", "stable": "→"}
61
- rows = [
62
- [
61
+ rows = []
62
+ for h in payload["data"].get("heroes", []):
63
+ trend = h.get("trend_direction", "")
64
+ rows.append([
63
65
  h.get("hero_name", h.get("hero_id", "")),
64
66
  h.get("hero_id", ""),
65
- arrows.get(h.get("trend_direction", ""), h.get("trend_direction", "")),
67
+ arrows.get(trend, trend),
66
68
  f"{h.get('percent_change', 0):+.1f}%",
67
69
  f"{h.get('current_popularity', 0):.1f}%",
68
70
  f"{h.get('avg_popularity', 0):.1f}%",
69
- ]
70
- for h in payload["data"].get("heroes", [])
71
- ]
71
+ ])
72
72
  emit(
73
73
  payload,
74
74
  tables=[("Movers", ["Hero", "ID", "Trend", "Change", "Now", "Avg"], rows)],
@@ -1,4 +1,4 @@
1
- """Tournament events, final standings, decklists."""
1
+ """tournament events, standings, decklists"""
2
2
  import typer
3
3
 
4
4
  from .. import client
@@ -15,11 +15,11 @@ def list_tournaments(
15
15
  has_decklists: bool = typer.Option(None, "--has-decklists/--no-decklists"),
16
16
  limit: int = typer.Option(25, "--limit", min=1, max=100),
17
17
  offset: int = typer.Option(0, "--offset", min=0),
18
- json_: bool = typer.Option(False, "--json"),
19
- csv_: bool = typer.Option(False, "--csv"),
18
+ as_json: bool = typer.Option(False, "--json"),
19
+ as_csv: bool = typer.Option(False, "--csv"),
20
20
  ):
21
21
  """List tournaments, newest first."""
22
- set_mode(json_, csv_)
22
+ set_mode(as_json, as_csv)
23
23
  payload = client.get(
24
24
  "/tournaments",
25
25
  {
@@ -53,11 +53,11 @@ def list_tournaments(
53
53
  @app.command()
54
54
  def show(
55
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"),
56
+ as_json: bool = typer.Option(False, "--json"),
57
+ as_csv: bool = typer.Option(False, "--csv"),
58
58
  ):
59
59
  """Show one tournament's details."""
60
- set_mode(json_, csv_)
60
+ set_mode(as_json, as_csv)
61
61
  payload = client.get(f"/tournaments/{event_id}")
62
62
  data = payload["data"]
63
63
  rows = [
@@ -74,11 +74,11 @@ def show(
74
74
  @app.command()
75
75
  def standings(
76
76
  event_id: str = typer.Argument(...),
77
- json_: bool = typer.Option(False, "--json"),
78
- csv_: bool = typer.Option(False, "--csv"),
77
+ as_json: bool = typer.Option(False, "--json"),
78
+ as_csv: bool = typer.Option(False, "--csv"),
79
79
  ):
80
80
  """Winner + top 8 from official final standings."""
81
- set_mode(json_, csv_)
81
+ set_mode(as_json, as_csv)
82
82
  payload = client.get(f"/tournaments/{event_id}/standings")
83
83
  data = payload["data"]
84
84
  rows = [
@@ -98,11 +98,11 @@ def standings(
98
98
  @app.command()
99
99
  def decklists(
100
100
  event_id: str = typer.Argument(...),
101
- json_: bool = typer.Option(False, "--json"),
102
- csv_: bool = typer.Option(False, "--csv"),
101
+ as_json: bool = typer.Option(False, "--json"),
102
+ as_csv: bool = typer.Option(False, "--csv"),
103
103
  ):
104
104
  """Submitted decklists for a tournament."""
105
- set_mode(json_, csv_)
105
+ set_mode(as_json, as_csv)
106
106
  payload = client.get(f"/tournaments/{event_id}/decklists")
107
107
  rows = [
108
108
  [
@@ -27,8 +27,7 @@ def _main():
27
27
 
28
28
 
29
29
  def _completion_tip():
30
- """One-time hint about tab completion. Skipped during shell completion
31
- itself and when not on a terminal."""
30
+ # one-time nudge; skip when running as a shell completer or piped
32
31
  if any(k.endswith("_COMPLETE") for k in os.environ):
33
32
  return
34
33
  cfg = load_config()
@@ -56,6 +56,11 @@ analytics and paper tournament results for competitive Flesh and Blood.
56
56
  pairing of the top-N heroes, in one call (never loop matchup for this)
57
57
  - `fablazing hero list` / `hero show EVO001`
58
58
  - `fablazing hero aggregate EVO001 --period 14d --source online`
59
+ - `fablazing hero cards MON119 --days 30` - most-used deck cards for a
60
+ hero vs the whole field, with per-card WR and Wilson lower bound
61
+ (deck cards only, no equipment; live aggregation cached up to 4h)
62
+ - `fablazing card pov MON119 "Dread Screamer"` - does running the card
63
+ change the hero's win rate, overall and per opponent (cached up to 4h)
59
64
  - `fablazing card search "sink"` / `card show sink_below_red`
60
65
  - `fablazing card trends sink_below_red --days 30`
61
66
  - `fablazing card synergies sink_below_red`
@@ -68,6 +73,11 @@ analytics and paper tournament results for competitive Flesh and Blood.
68
73
  - Tables on a terminal, JSON when piped or with --json (the JSON is the
69
74
  full API payload, a superset of the tables), CSV of the primary table
70
75
  with --csv. Data goes to stdout, errors to stderr.
76
+ - Matchup and hero-cards responses include 95% Wilson confidence
77
+ intervals (`*_win_rate_ci`) next to the win rates.
78
+ - Spreadsheets can skip the CLI: tabular API endpoints accept
79
+ `?output=csv&api_key=fbl_live_...` for Google Sheets IMPORTDATA
80
+ (careful: the key is then visible in the sheet).
71
81
 
72
82
  ## Exit codes
73
83
  0 ok, 1 api error, 2 usage error, 3 quota exceeded
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"
File without changes
File without changes