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.
- ufcalendar-0.1.0/.gitignore +86 -0
- ufcalendar-0.1.0/LICENSE +21 -0
- ufcalendar-0.1.0/PKG-INFO +101 -0
- ufcalendar-0.1.0/README.md +76 -0
- ufcalendar-0.1.0/pyproject.toml +37 -0
- ufcalendar-0.1.0/tests/test_client.py +82 -0
- ufcalendar-0.1.0/ufcalendar/__init__.py +19 -0
- ufcalendar-0.1.0/ufcalendar/client.py +264 -0
|
@@ -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
|
ufcalendar-0.1.0/LICENSE
ADDED
|
@@ -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")
|