ufcalendar 0.1.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.
@@ -0,0 +1,86 @@
1
+ # Node
2
+ node_modules/
3
+ .pnpm-store/
4
+ .pnpm-debug.log*
5
+ .next/
6
+ .open-next/
7
+ out/
8
+ .vercel/
9
+ *.tsbuildinfo
10
+ apps/web/next-env.d.ts
11
+ # Serwist build output (generated into public/ by `next build`)
12
+ apps/web/public/sw*
13
+ apps/web/public/swe-worker*
14
+
15
+ # Python
16
+ __pycache__/
17
+ *.py[cod]
18
+ *$py.class
19
+ .venv/
20
+ .uv-cache/
21
+ .pytest_cache/
22
+
23
+ # Scrapy
24
+ .scrapy/
25
+
26
+ # Logs (root-only so we don't accidentally swallow source dirs named "logs/")
27
+ *.log
28
+ /logs/
29
+
30
+ # Env
31
+ .env
32
+ .env.local
33
+ .env.*.local
34
+ !.env.example
35
+
36
+ # OS
37
+ .DS_Store
38
+ Thumbs.db
39
+
40
+ # IDE
41
+ .vscode/
42
+ .idea/
43
+ *.swp
44
+ .claude/
45
+
46
+ # SEO audit output directories — easy to regenerate via /claude-seo:seo-audit
47
+ seo-audit-*/
48
+
49
+ # Browser network captures — debug artifacts that can contain auth headers/cookies
50
+ *.har
51
+
52
+ # Google OAuth secrets — live in ~/.config/ufcalendar/seo-google/, never in the repo
53
+ client_secret*.json
54
+ oauth-token*.json
55
+
56
+ # AI-search eval runs (results/grades are large and reproducible)
57
+ apps/web/scripts/search-eval/runs/
58
+
59
+ # Demo-video render artifacts (/demo-video output: frames, webm captures, mp4 exports)
60
+ marketing/
61
+
62
+ # ML track: the SOURCE is tracked (it builds the prod rescore image — see
63
+ # ml/Dockerfile); only the regenerable bulk is ignored. Blanket-ignoring ml/
64
+ # untracked predict.py/explain_card.py etc., which are load-bearing for the
65
+ # ml-rescore container.
66
+ ml/.venv/
67
+ ml/__pycache__/
68
+ ml/data/
69
+ ml/models/
70
+ # Rendered parlay/pick artifact pages — the JSON record next to them IS the
71
+ # dated experiment log (tracked); the HTML is just a re-renderable view.
72
+ ml/predictions/*.html
73
+
74
+ # be-writer skill: exemplar library is copyrighted source text — S3 only,
75
+ # fetched on demand (see .claude/skills/be-writer/SKILL.md), never committed.
76
+ .claude/skills/be-writer/exemplars/
77
+
78
+ # Harvest artifacts (raw scrape output). Durable home is the bronze S3
79
+ # layer; the generated apps/web/lib/fight-dna-data.ts is what's committed.
80
+ scraper/data/
81
+ apps/api/dist/
82
+
83
+ # Fighter-credentials research corpus: per-fighter JSONs live in S3
84
+ # (s3://ufcalendar-raw-203012547507/fighter-credentials/json/), not git.
85
+ # PROGRESS.md is force-added and stays tracked as the runbook.
86
+ docs/research/fighter-credentials/backfill/*.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UFCalendar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.5
2
+ Name: ufcalendar
3
+ Version: 0.1.0
4
+ Summary: Python client for the UFCalendar Fight API: UFC, PFL, OKTAGON and BKFC events, fight cards, results, per-round stats, fighter careers and UFC rankings history since 2013.
5
+ Project-URL: Homepage, https://www.ufcalendar.com/developers
6
+ Project-URL: Documentation, https://api.ufcalendar.com/docs
7
+ Project-URL: UFC API, https://www.ufcalendar.com/developers/ufc-api
8
+ Project-URL: OpenAPI spec, https://api.ufcalendar.com/openapi.json
9
+ Project-URL: Source, https://github.com/UFCalendar/ufcalendar-python
10
+ Project-URL: Issues, https://github.com/UFCalendar/ufcalendar-python/issues
11
+ Author-email: UFCalendar <api@ufcalendar.com>
12
+ License: MIT
13
+ License-File: LICENSE
14
+ Keywords: api,bkfc,fight-data,mma,mma-api,pfl,rankings,sports-data,ufc,ufc-api
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: requests>=2.28
24
+ Description-Content-Type: text/markdown
25
+
26
+ # ufcalendar — Python client for the UFCalendar Fight API
27
+
28
+ The [UFCalendar Fight API](https://www.ufcalendar.com/developers) is a REST API for MMA data: **UFC, PFL, OKTAGON and BKFC** events, full fight cards, results within minutes, per-fight and round-by-round statistics, complete fighter careers, and the only **UFC rankings API with point-in-time history back to 2013**. This package is a thin `requests` wrapper over it — one method per endpoint, cursor pagination handled for you.
29
+
30
+ ```bash
31
+ pip install ufcalendar
32
+ ```
33
+
34
+ Get a key (free 1-day trial, 100 requests, no card) at https://www.ufcalendar.com/account/api?trial=1. Paid plans from $19/month, hard caps, no overage.
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ from ufcalendar import FightAPI
40
+
41
+ api = FightAPI("ufcalendar_...") # or export UFCAL_API_KEY=...
42
+
43
+ # Upcoming UFC schedule, soonest first
44
+ for ev in api.events(org="ufc", limit=5):
45
+ print(ev["starts_at"], ev["title"], "PPV" if ev["is_ppv"] else "")
46
+
47
+ # Full card + results of the newest completed UFC event
48
+ latest = next(api.events(org="ufc", status="completed", limit=1))
49
+ card = api.event(latest["slug"])
50
+ for f in card["fights"]:
51
+ r = f.get("result")
52
+ if r:
53
+ print(f["fighter_a"]["name"], "vs", f["fighter_b"]["name"], "->", r["method"], f"R{r['round']} {r['time']}")
54
+
55
+ # Round-by-round statistics for one bout
56
+ rounds = api.fight_rounds(card["fights"][0]["id"])
57
+
58
+ # UFC rankings on any date since February 2013 (rank 0 = champion)
59
+ board = api.rankings("ufc", date="2016-11-14")
60
+ lw = next(d for d in board["divisions"] if d["division"] == "lightweight")
61
+ print(board["snapshot_date"], [e["name"] for e in lw["entries"][:5]])
62
+
63
+ # A fighter's complete multi-promotion career
64
+ history = api.fighter_history("islam-makhachev")
65
+ ```
66
+
67
+ ## What's covered
68
+
69
+ | Method | Endpoint |
70
+ |---|---|
71
+ | `events(org, status, from_date, to_date, order)` | `GET /v1/events` (paginated) |
72
+ | `event(slug)` / `event_changes(slug)` | `GET /v1/events/{slug}` / `…/changes` |
73
+ | `fight(id)` / `fight_stats(id)` / `fight_rounds(id)` | `GET /v1/fights/{id}` / `…/stats` / `…/rounds` |
74
+ | `fighters(q, org, country)` / `fighter(slug)` | `GET /v1/fighters` / `…/{slug}` |
75
+ | `fighter_history` / `fighter_stats` / `fighter_rankings` / `fighter_power_index` | `GET /v1/fighters/{slug}/…` |
76
+ | `rankings(org, date)` / `division_rankings(org, division)` / `champions()` | `GET /v1/rankings/…` / `/v1/champions` |
77
+ | `power_index(org)` / `predictions_upcoming()` | `GET /v1/power-index/{org}` / `/v1/predictions/upcoming` |
78
+ | `broadcast_rights(org, country)` / `venue(id)` / `search(q)` / `usage()` | misc |
79
+ | `create_webhook_endpoint(url, events)` … | `POST /v1/webhook-endpoints` (Pro+) |
80
+ | `calendar_ics_url(org)` | `GET /v1/calendar/{org}.ics` |
81
+
82
+ Full reference: https://api.ufcalendar.com/docs · OpenAPI 3.1: https://api.ufcalendar.com/openapi.json
83
+
84
+ ## Webhooks instead of polling (Pro and up)
85
+
86
+ ```python
87
+ ep = api.create_webhook_endpoint("https://example.com/hooks/ufcal", ["fight.result", "card.changed"])
88
+ print(ep["secret"]) # shown once; verify X-UFCalendar-Signature with it
89
+ ```
90
+
91
+ ## Errors and rate limits
92
+
93
+ Every error raises `FightAPIError` with `.status`, `.code`, `.message`, `.request_id`. After each call `api.last_rate_limit` holds the `X-RateLimit-*` headers.
94
+
95
+ ## Notes
96
+
97
+ - No betting odds are served, by design.
98
+ - Fighter `images` are Wikimedia Commons / Creative Commons files: display the `license` and `artist` fields as a credit.
99
+ - Not affiliated with UFC, Zuffa, TKO or any promotion. Terms: https://www.ufcalendar.com/developers/terms
100
+
101
+ MIT licensed.
@@ -0,0 +1,76 @@
1
+ # ufcalendar — Python client for the UFCalendar Fight API
2
+
3
+ The [UFCalendar Fight API](https://www.ufcalendar.com/developers) is a REST API for MMA data: **UFC, PFL, OKTAGON and BKFC** events, full fight cards, results within minutes, per-fight and round-by-round statistics, complete fighter careers, and the only **UFC rankings API with point-in-time history back to 2013**. This package is a thin `requests` wrapper over it — one method per endpoint, cursor pagination handled for you.
4
+
5
+ ```bash
6
+ pip install ufcalendar
7
+ ```
8
+
9
+ Get a key (free 1-day trial, 100 requests, no card) at https://www.ufcalendar.com/account/api?trial=1. Paid plans from $19/month, hard caps, no overage.
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from ufcalendar import FightAPI
15
+
16
+ api = FightAPI("ufcalendar_...") # or export UFCAL_API_KEY=...
17
+
18
+ # Upcoming UFC schedule, soonest first
19
+ for ev in api.events(org="ufc", limit=5):
20
+ print(ev["starts_at"], ev["title"], "PPV" if ev["is_ppv"] else "")
21
+
22
+ # Full card + results of the newest completed UFC event
23
+ latest = next(api.events(org="ufc", status="completed", limit=1))
24
+ card = api.event(latest["slug"])
25
+ for f in card["fights"]:
26
+ r = f.get("result")
27
+ if r:
28
+ print(f["fighter_a"]["name"], "vs", f["fighter_b"]["name"], "->", r["method"], f"R{r['round']} {r['time']}")
29
+
30
+ # Round-by-round statistics for one bout
31
+ rounds = api.fight_rounds(card["fights"][0]["id"])
32
+
33
+ # UFC rankings on any date since February 2013 (rank 0 = champion)
34
+ board = api.rankings("ufc", date="2016-11-14")
35
+ lw = next(d for d in board["divisions"] if d["division"] == "lightweight")
36
+ print(board["snapshot_date"], [e["name"] for e in lw["entries"][:5]])
37
+
38
+ # A fighter's complete multi-promotion career
39
+ history = api.fighter_history("islam-makhachev")
40
+ ```
41
+
42
+ ## What's covered
43
+
44
+ | Method | Endpoint |
45
+ |---|---|
46
+ | `events(org, status, from_date, to_date, order)` | `GET /v1/events` (paginated) |
47
+ | `event(slug)` / `event_changes(slug)` | `GET /v1/events/{slug}` / `…/changes` |
48
+ | `fight(id)` / `fight_stats(id)` / `fight_rounds(id)` | `GET /v1/fights/{id}` / `…/stats` / `…/rounds` |
49
+ | `fighters(q, org, country)` / `fighter(slug)` | `GET /v1/fighters` / `…/{slug}` |
50
+ | `fighter_history` / `fighter_stats` / `fighter_rankings` / `fighter_power_index` | `GET /v1/fighters/{slug}/…` |
51
+ | `rankings(org, date)` / `division_rankings(org, division)` / `champions()` | `GET /v1/rankings/…` / `/v1/champions` |
52
+ | `power_index(org)` / `predictions_upcoming()` | `GET /v1/power-index/{org}` / `/v1/predictions/upcoming` |
53
+ | `broadcast_rights(org, country)` / `venue(id)` / `search(q)` / `usage()` | misc |
54
+ | `create_webhook_endpoint(url, events)` … | `POST /v1/webhook-endpoints` (Pro+) |
55
+ | `calendar_ics_url(org)` | `GET /v1/calendar/{org}.ics` |
56
+
57
+ Full reference: https://api.ufcalendar.com/docs · OpenAPI 3.1: https://api.ufcalendar.com/openapi.json
58
+
59
+ ## Webhooks instead of polling (Pro and up)
60
+
61
+ ```python
62
+ ep = api.create_webhook_endpoint("https://example.com/hooks/ufcal", ["fight.result", "card.changed"])
63
+ print(ep["secret"]) # shown once; verify X-UFCalendar-Signature with it
64
+ ```
65
+
66
+ ## Errors and rate limits
67
+
68
+ Every error raises `FightAPIError` with `.status`, `.code`, `.message`, `.request_id`. After each call `api.last_rate_limit` holds the `X-RateLimit-*` headers.
69
+
70
+ ## Notes
71
+
72
+ - No betting odds are served, by design.
73
+ - Fighter `images` are Wikimedia Commons / Creative Commons files: display the `license` and `artist` fields as a credit.
74
+ - Not affiliated with UFC, Zuffa, TKO or any promotion. Terms: https://www.ufcalendar.com/developers/terms
75
+
76
+ MIT licensed.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ufcalendar"
7
+ version = "0.1.0"
8
+ description = "Python client for the UFCalendar Fight API: UFC, PFL, OKTAGON and BKFC events, fight cards, results, per-round stats, fighter careers and UFC rankings history since 2013."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "UFCalendar", email = "api@ufcalendar.com" }]
13
+ keywords = ["ufc", "mma", "api", "ufc-api", "mma-api", "sports-data", "fight-data", "rankings", "bkfc", "pfl"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Internet :: WWW/HTTP",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ dependencies = ["requests>=2.28"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://www.ufcalendar.com/developers"
27
+ Documentation = "https://api.ufcalendar.com/docs"
28
+ "UFC API" = "https://www.ufcalendar.com/developers/ufc-api"
29
+ "OpenAPI spec" = "https://api.ufcalendar.com/openapi.json"
30
+ Source = "https://github.com/UFCalendar/ufcalendar-python"
31
+ Issues = "https://github.com/UFCalendar/ufcalendar-python/issues"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["ufcalendar"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["ufcalendar", "README.md", "LICENSE", "pyproject.toml", "tests"]
@@ -0,0 +1,82 @@
1
+ """Offline tests: the client's request shapes and error handling, no network."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ import requests
7
+
8
+ from ufcalendar import FightAPI, FightAPIError
9
+
10
+
11
+ class _Resp:
12
+ def __init__(self, status, body, headers=None):
13
+ self.status_code = status
14
+ self._body = body
15
+ self.headers = headers or {}
16
+ self.text = json.dumps(body)
17
+
18
+ def json(self):
19
+ return self._body
20
+
21
+
22
+ class _Session:
23
+ def __init__(self, responses):
24
+ self.responses = list(responses)
25
+ self.calls = []
26
+
27
+ def request(self, method, url, **kw):
28
+ self.calls.append((method, url, kw.get("params"), kw.get("json")))
29
+ return self.responses.pop(0)
30
+
31
+
32
+ def test_requires_key(monkeypatch):
33
+ monkeypatch.delenv("UFCAL_API_KEY", raising=False)
34
+ with pytest.raises(ValueError):
35
+ FightAPI()
36
+
37
+
38
+ def test_events_paginates_and_drops_none_params():
39
+ s = _Session([
40
+ _Resp(200, {"data": [{"slug": "a"}], "meta": {"pagination": {"next_cursor": "c2"}}}, {"X-RateLimit-Remaining": "99"}),
41
+ _Resp(200, {"data": [{"slug": "b"}], "meta": {"pagination": {"next_cursor": None}}}),
42
+ ])
43
+ api = FightAPI("ufcalendar_test", session=s)
44
+ rows = list(api.events(org="ufc", status=None))
45
+ assert [r["slug"] for r in rows] == ["a", "b"]
46
+ assert s.calls[0][1].endswith("/v1/events")
47
+ assert s.calls[0][2] == {"org": "ufc", "limit": 100}
48
+ assert s.calls[1][2]["cursor"] == "c2"
49
+ assert api.last_rate_limit["remaining"] is None # last call carried no header
50
+
51
+
52
+ def test_rankings_date_param():
53
+ s = _Session([_Resp(200, {"data": {"snapshot_date": "2016-11-07", "divisions": []}})])
54
+ api = FightAPI("ufcalendar_test", session=s)
55
+ board = api.rankings("ufc", date="2016-11-14")
56
+ assert board["snapshot_date"] == "2016-11-07"
57
+ assert s.calls[0][1].endswith("/v1/rankings/ufc")
58
+ assert s.calls[0][2] == {"date": "2016-11-14"}
59
+
60
+
61
+ def test_error_envelope():
62
+ s = _Session([_Resp(403, {"error": {"code": "subscription_required", "message": "no plan", "request_id": "req_1"}})])
63
+ api = FightAPI("ufcalendar_test", session=s)
64
+ with pytest.raises(FightAPIError) as ei:
65
+ api.champions()
66
+ assert ei.value.status == 403
67
+ assert ei.value.code == "subscription_required"
68
+ assert ei.value.request_id == "req_1"
69
+
70
+
71
+ def test_webhook_create_body():
72
+ s = _Session([_Resp(201, {"data": {"id": 1, "secret": "whsec"}})])
73
+ api = FightAPI("ufcalendar_test", session=s)
74
+ ep = api.create_webhook_endpoint("https://example.com/h", ["fight.result"])
75
+ assert ep["secret"] == "whsec"
76
+ assert s.calls[0][0] == "POST"
77
+ assert s.calls[0][3] == {"url": "https://example.com/h", "events": ["fight.result"]}
78
+
79
+
80
+ def test_ics_url_carries_key():
81
+ api = FightAPI("ufcalendar_test", session=_Session([]))
82
+ assert api.calendar_ics_url("ufc") == "https://api.ufcalendar.com/v1/calendar/ufc.ics?key=ufcalendar_test"
@@ -0,0 +1,19 @@
1
+ """ufcalendar — Python client for the UFCalendar Fight API.
2
+
3
+ from ufcalendar import FightAPI
4
+
5
+ api = FightAPI("ufcalendar_...") # or UFCAL_API_KEY env var
6
+ for event in api.events(org="ufc"): # upcoming UFC cards, soonest first
7
+ print(event["starts_at"], event["title"])
8
+
9
+ board = api.rankings("ufc", date="2016-11-14") # rankings point-in-time since 2013
10
+ print(board["snapshot_date"], board["divisions"][0]["entries"][0])
11
+
12
+ Docs: https://api.ufcalendar.com/docs · pricing + free 1-day trial:
13
+ https://www.ufcalendar.com/developers
14
+ """
15
+
16
+ from .client import FightAPI, FightAPIError
17
+
18
+ __all__ = ["FightAPI", "FightAPIError"]
19
+ __version__ = "0.1.0"
@@ -0,0 +1,264 @@
1
+ """Thin, dependency-light client for https://api.ufcalendar.com/v1.
2
+
3
+ Every method returns the parsed ``data`` value of the JSON envelope
4
+ (``{"data": ..., "meta": ...}``); list endpoints are generators that follow
5
+ cursor pagination for you. Errors raise :class:`FightAPIError` carrying the
6
+ API's ``code``, ``message`` and ``request_id`` (quote the request id when
7
+ you write to api@ufcalendar.com).
8
+
9
+ The API serves no betting odds, by design. Fighter ``images`` are Wikimedia
10
+ Commons / Creative Commons files — the ``license`` and ``artist`` fields you
11
+ receive must be displayed as a credit.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Any, Dict, Iterator, List, Optional, Sequence
18
+
19
+ import requests
20
+
21
+ DEFAULT_BASE_URL = "https://api.ufcalendar.com/v1"
22
+ _TIMEOUT = 30
23
+
24
+
25
+ class FightAPIError(Exception):
26
+ """An error response from the Fight API."""
27
+
28
+ def __init__(self, status: int, code: str, message: str, request_id: Optional[str] = None):
29
+ super().__init__(f"{status} {code}: {message}" + (f" (request_id={request_id})" if request_id else ""))
30
+ self.status = status
31
+ self.code = code
32
+ self.message = message
33
+ self.request_id = request_id
34
+
35
+
36
+ class FightAPI:
37
+ """Client for the UFCalendar Fight API.
38
+
39
+ :param api_key: ``ufcalendar_…`` key from https://www.ufcalendar.com/account/api.
40
+ Falls back to the ``UFCAL_API_KEY`` environment variable.
41
+ :param base_url: override for testing; defaults to the production ``/v1``.
42
+ :param session: optional :class:`requests.Session` to reuse connections.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ api_key: Optional[str] = None,
48
+ *,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ session: Optional[requests.Session] = None,
51
+ timeout: float = _TIMEOUT,
52
+ ):
53
+ key = api_key or os.environ.get("UFCAL_API_KEY")
54
+ if not key:
55
+ raise ValueError(
56
+ "No API key. Pass api_key=... or set UFCAL_API_KEY. "
57
+ "Keys (and the free 1-day trial) live at https://www.ufcalendar.com/account/api"
58
+ )
59
+ self.api_key = key
60
+ self.base_url = base_url.rstrip("/")
61
+ self._session = session or requests.Session()
62
+ self._timeout = timeout
63
+ self.last_rate_limit: Dict[str, Optional[str]] = {"limit": None, "remaining": None, "reset": None}
64
+
65
+ # ------------------------------------------------------------------ core
66
+
67
+ def _request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None, json: Any = None) -> Dict[str, Any]:
68
+ url = f"{self.base_url}/{path.lstrip('/')}"
69
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
70
+ resp = self._session.request(
71
+ method,
72
+ url,
73
+ params=clean,
74
+ json=json,
75
+ headers={
76
+ "Authorization": f"Bearer {self.api_key}",
77
+ "Accept": "application/json",
78
+ "User-Agent": "ufcalendar-python/0.1.0",
79
+ },
80
+ timeout=self._timeout,
81
+ allow_redirects=True,
82
+ )
83
+ self.last_rate_limit = {
84
+ "limit": resp.headers.get("X-RateLimit-Limit"),
85
+ "remaining": resp.headers.get("X-RateLimit-Remaining"),
86
+ "reset": resp.headers.get("X-RateLimit-Reset"),
87
+ }
88
+ if resp.status_code == 204:
89
+ return {}
90
+ try:
91
+ body = resp.json()
92
+ except ValueError:
93
+ body = {}
94
+ if resp.status_code >= 400:
95
+ err = body.get("error") if isinstance(body, dict) else None
96
+ if isinstance(err, dict):
97
+ raise FightAPIError(resp.status_code, str(err.get("code", "error")), str(err.get("message", resp.text[:200])), err.get("request_id"))
98
+ raise FightAPIError(resp.status_code, "http_error", resp.text[:200], resp.headers.get("x-request-id"))
99
+ return body
100
+
101
+ def get(self, path: str, **params: Any) -> Any:
102
+ """Raw GET returning the ``data`` value. Escape hatch for new endpoints."""
103
+ return self._request("GET", path, params).get("data")
104
+
105
+ def _paginate(self, path: str, params: Dict[str, Any], limit: Optional[int]) -> Iterator[Dict[str, Any]]:
106
+ params = dict(params)
107
+ params.setdefault("limit", 100)
108
+ seen = 0
109
+ while True:
110
+ body = self._request("GET", path, params)
111
+ for row in body.get("data") or []:
112
+ yield row
113
+ seen += 1
114
+ if limit is not None and seen >= limit:
115
+ return
116
+ cursor = ((body.get("meta") or {}).get("pagination") or {}).get("next_cursor")
117
+ if not cursor:
118
+ return
119
+ params["cursor"] = cursor
120
+
121
+ # ------------------------------------------------------------------ orgs
122
+
123
+ def orgs(self) -> List[Dict[str, Any]]:
124
+ """Launch orgs with capability flags (stats / rounds / rankings / broadcasts / predictions)."""
125
+ return self.get("orgs")
126
+
127
+ def org(self, slug: str) -> Dict[str, Any]:
128
+ return self.get(f"orgs/{slug}")
129
+
130
+ # ---------------------------------------------------------------- events
131
+
132
+ def events(
133
+ self,
134
+ org: Optional[str] = None,
135
+ *,
136
+ status: Optional[str] = None,
137
+ from_date: Optional[str] = None,
138
+ to_date: Optional[str] = None,
139
+ order: Optional[str] = None,
140
+ limit: Optional[int] = None,
141
+ ) -> Iterator[Dict[str, Any]]:
142
+ """Schedule + results. Bare call = upcoming calendar, soonest first.
143
+
144
+ ``status="completed"`` (or ``order="desc"``) browses the archive newest-first.
145
+ ``from_date`` / ``to_date`` are ``YYYY-MM-DD``.
146
+ """
147
+ return self._paginate(
148
+ "events",
149
+ {"org": org, "status": status, "from": from_date, "to": to_date, "order": order},
150
+ limit,
151
+ )
152
+
153
+ def event(self, id_or_slug: str) -> Dict[str, Any]:
154
+ """One event with its full fight card, venue and broadcasts."""
155
+ return self.get(f"events/{id_or_slug}")
156
+
157
+ def event_changes(self, id_or_slug: str) -> List[Dict[str, Any]]:
158
+ """Card-change diff log (fight added/removed, opponent swapped, date moved)."""
159
+ return self.get(f"events/{id_or_slug}/changes")
160
+
161
+ # ---------------------------------------------------------------- fights
162
+
163
+ def fight(self, fight_id: int) -> Dict[str, Any]:
164
+ return self.get(f"fights/{fight_id}")
165
+
166
+ def fight_stats(self, fight_id: int) -> List[Dict[str, Any]]:
167
+ """Per-fight totals for both corners."""
168
+ return self.get(f"fights/{fight_id}/stats")
169
+
170
+ def fight_rounds(self, fight_id: int) -> List[Dict[str, Any]]:
171
+ """Round-by-round stat lines for both corners."""
172
+ return self.get(f"fights/{fight_id}/rounds")
173
+
174
+ # -------------------------------------------------------------- fighters
175
+
176
+ def fighters(
177
+ self,
178
+ q: Optional[str] = None,
179
+ *,
180
+ org: Optional[str] = None,
181
+ country: Optional[str] = None,
182
+ limit: Optional[int] = None,
183
+ ) -> Iterator[Dict[str, Any]]:
184
+ return self._paginate("fighters", {"q": q, "org": org, "country": country}, limit)
185
+
186
+ def fighter(self, id_or_slug: str) -> Dict[str, Any]:
187
+ """Bio, records, career stats, Power Index and CC-licensed images."""
188
+ return self.get(f"fighters/{id_or_slug}")
189
+
190
+ def fighter_history(self, id_or_slug: str) -> List[Dict[str, Any]]:
191
+ """Complete multi-promotion career timeline."""
192
+ return self.get(f"fighters/{id_or_slug}/history")
193
+
194
+ def fighter_stats(self, id_or_slug: str) -> List[Dict[str, Any]]:
195
+ """Career statistics per scope (``pro-mma``, ``ufc-only`` …)."""
196
+ return self.get(f"fighters/{id_or_slug}/stats")
197
+
198
+ def fighter_rankings(self, id_or_slug: str) -> List[Dict[str, Any]]:
199
+ """Every official ranking row the fighter ever held, newest first."""
200
+ return self.get(f"fighters/{id_or_slug}/rankings")
201
+
202
+ def fighter_power_index(self, id_or_slug: str) -> Any:
203
+ return self.get(f"fighters/{id_or_slug}/power-index")
204
+
205
+ # -------------------------------------------------------------- rankings
206
+
207
+ def rankings(self, org: str = "ufc", *, date: Optional[str] = None, board: Optional[str] = None) -> Dict[str, Any]:
208
+ """Official board, point-in-time. ``date="YYYY-MM-DD"`` returns the board
209
+ valid on that day (UFC history back to 2013; rank 0 = champion)."""
210
+ return self.get(f"rankings/{org}", date=date, board=board)
211
+
212
+ def division_rankings(self, org: str, division: str, *, date: Optional[str] = None) -> Dict[str, Any]:
213
+ return self.get(f"rankings/{org}/{division}", date=date)
214
+
215
+ def champions(self) -> Any:
216
+ """Current champions across every launch org."""
217
+ return self.get("champions")
218
+
219
+ def power_index(self, org: str = "ufc") -> Any:
220
+ return self.get(f"power-index/{org}")
221
+
222
+ # ----------------------------------------------------------------- misc
223
+
224
+ def predictions_upcoming(self) -> Any:
225
+ """Model win probabilities for upcoming UFC bouts."""
226
+ return self.get("predictions/upcoming")
227
+
228
+ def broadcast_rights(self, org: str = "ufc", *, country: Optional[str] = None) -> List[Dict[str, Any]]:
229
+ """Who airs the promotion, per ISO-2 country."""
230
+ return self.get(f"broadcast-rights/{org}", country=country)
231
+
232
+ def venue(self, venue_id: int) -> Dict[str, Any]:
233
+ return self.get(f"venues/{venue_id}")
234
+
235
+ def search(self, q: str) -> Any:
236
+ return self.get("search", q=q)
237
+
238
+ def usage(self) -> Dict[str, Any]:
239
+ """Your key's month-to-date quota usage."""
240
+ return self.get("usage")
241
+
242
+ def calendar_ics_url(self, org: str = "ufc") -> str:
243
+ """Subscribable ICS feed URL for calendar apps (authenticates via ``?key=``)."""
244
+ return f"{self.base_url}/calendar/{org}.ics?key={self.api_key}"
245
+
246
+ # ------------------------------------------------------------- webhooks
247
+
248
+ def webhook_endpoints(self) -> List[Dict[str, Any]]:
249
+ return self.get("webhook-endpoints")
250
+
251
+ def create_webhook_endpoint(self, url: str, events: Optional[Sequence[str]] = None) -> Dict[str, Any]:
252
+ """Register a signed webhook (Pro and up). ``events`` ⊆
253
+ ``event.announced``, ``fight.result``, ``card.changed``, ``event.completed``.
254
+ The signing secret is returned ONCE in the response."""
255
+ body: Dict[str, Any] = {"url": url}
256
+ if events:
257
+ body["events"] = list(events)
258
+ return self._request("POST", "webhook-endpoints", json=body).get("data")
259
+
260
+ def delete_webhook_endpoint(self, endpoint_id: int) -> None:
261
+ self._request("DELETE", f"webhook-endpoints/{endpoint_id}")
262
+
263
+ def rotate_webhook_secret(self, endpoint_id: int) -> Dict[str, Any]:
264
+ return self._request("POST", f"webhook-endpoints/{endpoint_id}/rotate-secret").get("data")