birdieapi 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.
birdieapi/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """BirdieAPI: Python client for the BirdieAPI golf data API."""
2
+ from ._core import (
3
+ APIConnectionError,
4
+ APIError,
5
+ AuthError,
6
+ InsufficientCreditsError,
7
+ NotFoundError,
8
+ RateLimitError,
9
+ to_dataframe,
10
+ )
11
+ from ._version import __version__
12
+ from .client import TOOLS, Client
13
+
14
+ __all__ = [
15
+ "APIConnectionError",
16
+ "APIError",
17
+ "AuthError",
18
+ "Client",
19
+ "InsufficientCreditsError",
20
+ "NotFoundError",
21
+ "RateLimitError",
22
+ "TOOLS",
23
+ "__version__",
24
+ "to_dataframe",
25
+ ]
birdieapi/_core.py ADDED
@@ -0,0 +1,307 @@
1
+ """Shared runtime for the BirdieAPI Python client.
2
+
3
+ Standard library only. The generated methods live in client.py and call
4
+ ``Client._call``; everything else (auth, retries, errors, credits headers) is here.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import socket
11
+ import ssl
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
16
+
17
+ from ._version import __version__
18
+
19
+ BRAND = "BirdieAPI"
20
+ ENV_VAR = "BIRDIEAPI_API_KEY"
21
+ DEFAULT_BASE_URL = "https://mcp.birdieapi.com"
22
+ SIGNUP_URL = "https://birdieapi.com/signup"
23
+ DEFAULT_TIMEOUT = 60.0
24
+ MAX_RETRIES = 3
25
+ MAX_RETRY_WAIT = 30.0
26
+
27
+ # A transport takes (method, url, headers, body, timeout) and returns
28
+ # (status, headers, body). Header names in the returned dict are lowercase.
29
+ Transport = Callable[
30
+ [str, str, Dict[str, str], Optional[bytes], float],
31
+ Tuple[int, Dict[str, str], bytes],
32
+ ]
33
+
34
+
35
+ class APIError(Exception):
36
+ """Any error response from the API, or a failed connection."""
37
+
38
+ def __init__(
39
+ self,
40
+ message: str,
41
+ status_code: Optional[int] = None,
42
+ body: Any = None,
43
+ ) -> None:
44
+ super().__init__(message)
45
+ self.message = message
46
+ self.status_code = status_code
47
+ self.body = body
48
+
49
+ def __str__(self) -> str:
50
+ if self.status_code:
51
+ return "%s (HTTP %s)" % (self.message, self.status_code)
52
+ return self.message
53
+
54
+
55
+ class AuthError(APIError):
56
+ """401: the API key is missing, invalid or revoked."""
57
+
58
+
59
+ class InsufficientCreditsError(APIError):
60
+ """402: the account does not have enough credits for this call."""
61
+
62
+
63
+ class NotFoundError(APIError):
64
+ """404: the game, player, team or other id does not exist."""
65
+
66
+
67
+ class RateLimitError(APIError):
68
+ """429: too many requests. ``retry_after`` is in seconds when the server sent it."""
69
+
70
+ def __init__(
71
+ self,
72
+ message: str,
73
+ status_code: Optional[int] = 429,
74
+ body: Any = None,
75
+ retry_after: Optional[float] = None,
76
+ ) -> None:
77
+ super().__init__(message, status_code, body)
78
+ self.retry_after = retry_after
79
+
80
+
81
+ class APIConnectionError(APIError):
82
+ """The request never got a response (DNS, TLS, timeout, reset)."""
83
+
84
+
85
+ def _ssl_context() -> Optional[ssl.SSLContext]:
86
+ """Use certifi's CA bundle when it is installed, else the system default.
87
+
88
+ Some python.org installs on macOS ship without root certificates. Installing
89
+ certifi (or running the "Install Certificates" script) fixes the resulting
90
+ CERTIFICATE_VERIFY_FAILED error.
91
+ """
92
+ try:
93
+ import certifi
94
+ except ImportError:
95
+ return None
96
+ return ssl.create_default_context(cafile=certifi.where())
97
+
98
+
99
+ def _urllib_transport(
100
+ method: str,
101
+ url: str,
102
+ headers: Dict[str, str],
103
+ body: Optional[bytes],
104
+ timeout: float,
105
+ ) -> Tuple[int, Dict[str, str], bytes]:
106
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
107
+ try:
108
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
109
+ return (
110
+ resp.status,
111
+ {k.lower(): v for k, v in resp.headers.items()},
112
+ resp.read(),
113
+ )
114
+ except urllib.error.HTTPError as exc:
115
+ try:
116
+ payload = exc.read()
117
+ finally:
118
+ exc.close()
119
+ return (
120
+ exc.code,
121
+ {k.lower(): v for k, v in (exc.headers or {}).items()},
122
+ payload,
123
+ )
124
+ except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as exc:
125
+ reason = getattr(exc, "reason", exc)
126
+ raise APIConnectionError("Could not reach the API: %s" % (reason,)) from exc
127
+
128
+
129
+ def _to_number(value: Optional[str]) -> Optional[Union[int, float]]:
130
+ if value is None:
131
+ return None
132
+ try:
133
+ return int(value)
134
+ except ValueError:
135
+ try:
136
+ return float(value)
137
+ except ValueError:
138
+ return None
139
+
140
+
141
+ def _error_message(parsed: Any, raw: bytes, status: int) -> str:
142
+ if isinstance(parsed, dict):
143
+ err = parsed.get("error")
144
+ if isinstance(err, str) and err:
145
+ return err
146
+ if isinstance(err, dict) and isinstance(err.get("message"), str):
147
+ return err["message"]
148
+ text = raw.decode("utf-8", "replace").strip()
149
+ if text:
150
+ return text[:300]
151
+ return "HTTP %s" % status
152
+
153
+
154
+ class BaseClient:
155
+ """Holds the key, base URL, retry policy and the last credit counters."""
156
+
157
+ def __init__(
158
+ self,
159
+ api_key: Optional[str] = None,
160
+ *,
161
+ base_url: Optional[str] = None,
162
+ timeout: float = DEFAULT_TIMEOUT,
163
+ max_retries: int = MAX_RETRIES,
164
+ transport: Optional[Transport] = None,
165
+ ) -> None:
166
+ key = api_key or os.environ.get(ENV_VAR)
167
+ if not key:
168
+ raise AuthError(
169
+ "No API key. Pass api_key=... or set %s. "
170
+ "A free key is at %s" % (ENV_VAR, SIGNUP_URL)
171
+ )
172
+ self.api_key = key
173
+ self.base_url = (base_url or os.environ.get(BRAND.upper() + "_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
174
+ self.timeout = timeout
175
+ self.max_retries = max(0, int(max_retries))
176
+ self._transport: Transport = transport or _urllib_transport
177
+ self._sleep: Callable[[float], None] = time.sleep
178
+ self.last_credits_used: Optional[Union[int, float]] = None
179
+ self.last_credits_remaining: Optional[Union[int, float]] = None
180
+
181
+ def __repr__(self) -> str:
182
+ return "<%s.Client base_url=%r>" % (BRAND, self.base_url)
183
+
184
+ def call(self, tool: str, arguments: Optional[Dict[str, Any]] = None) -> Any:
185
+ """Call any tool by name. Useful for tools newer than this package."""
186
+ return self._call(tool, arguments or {})
187
+
188
+ def _call(self, tool: str, arguments: Dict[str, Any]) -> Any:
189
+ payload = {k: v for k, v in arguments.items() if v is not None}
190
+ url = "%s/v1/%s" % (self.base_url, tool)
191
+ headers = {
192
+ "x-api-key": self.api_key,
193
+ "content-type": "application/json",
194
+ "accept": "application/json",
195
+ "user-agent": "%s-python/%s" % (BRAND.lower(), __version__),
196
+ }
197
+ body = json.dumps(payload).encode("utf-8")
198
+
199
+ attempt = 0
200
+ while True:
201
+ try:
202
+ status, resp_headers, raw = self._transport("POST", url, headers, body, self.timeout)
203
+ except APIConnectionError:
204
+ if attempt >= self.max_retries:
205
+ raise
206
+ self._sleep(self._backoff(attempt, None))
207
+ attempt += 1
208
+ continue
209
+
210
+ self._record_credits(resp_headers)
211
+ if status == 429 or status >= 500:
212
+ if attempt < self.max_retries:
213
+ self._sleep(self._backoff(attempt, self._retry_after(resp_headers)))
214
+ attempt += 1
215
+ continue
216
+ return self._handle(status, resp_headers, raw)
217
+
218
+ @staticmethod
219
+ def _retry_after(headers: Dict[str, str]) -> Optional[float]:
220
+ value = headers.get("retry-after")
221
+ if value is None:
222
+ return None
223
+ try:
224
+ return max(0.0, float(value))
225
+ except ValueError:
226
+ return None
227
+
228
+ @staticmethod
229
+ def _backoff(attempt: int, retry_after: Optional[float]) -> float:
230
+ if retry_after is not None:
231
+ return min(retry_after, MAX_RETRY_WAIT)
232
+ return min(0.5 * (2 ** attempt), MAX_RETRY_WAIT)
233
+
234
+ def _record_credits(self, headers: Dict[str, str]) -> None:
235
+ used = _to_number(headers.get("x-credits-used"))
236
+ remaining = _to_number(headers.get("x-credits-remaining"))
237
+ if used is not None:
238
+ self.last_credits_used = used
239
+ if remaining is not None:
240
+ self.last_credits_remaining = remaining
241
+
242
+ def _handle(self, status: int, headers: Dict[str, str], raw: bytes) -> Any:
243
+ try:
244
+ parsed = json.loads(raw.decode("utf-8")) if raw else None
245
+ except ValueError:
246
+ parsed = None
247
+
248
+ if 200 <= status < 300:
249
+ if parsed is None:
250
+ raise APIError("The API returned a response that is not JSON", status, raw[:300])
251
+ if isinstance(parsed, dict) and "data" in parsed:
252
+ return parsed["data"]
253
+ return parsed
254
+
255
+ message = _error_message(parsed, raw, status)
256
+ if status == 401:
257
+ raise AuthError(message, status, parsed)
258
+ if status == 402:
259
+ raise InsufficientCreditsError(message, status, parsed)
260
+ if status == 404:
261
+ raise NotFoundError(message, status, parsed)
262
+ if status == 429:
263
+ raise RateLimitError(message, status, parsed, self._retry_after(headers))
264
+ raise APIError(message, status, parsed)
265
+
266
+
267
+ def _rows(data: Any, key: Optional[str]) -> List[Any]:
268
+ if key is not None:
269
+ cur = data
270
+ for part in key.split("."):
271
+ if not isinstance(cur, dict) or part not in cur:
272
+ raise KeyError("No %r in the response. Keys: %s" % (key, sorted(cur) if isinstance(cur, dict) else type(cur).__name__))
273
+ cur = cur[part]
274
+ if isinstance(cur, list):
275
+ return cur
276
+ return [cur]
277
+ if isinstance(data, list):
278
+ return data
279
+ if isinstance(data, dict):
280
+ lists = {k: v for k, v in data.items() if isinstance(v, list)}
281
+ records = {k: v for k, v in lists.items() if v and all(isinstance(r, dict) for r in v)}
282
+ pool = records or lists
283
+ if pool:
284
+ return pool[max(pool, key=lambda k: len(pool[k]))]
285
+ for v in data.values():
286
+ if isinstance(v, dict) and any(isinstance(x, list) for x in v.values()):
287
+ return _rows(v, None)
288
+ return [data]
289
+ return [data]
290
+
291
+
292
+ def to_dataframe(data: Any, key: Optional[str] = None) -> Any:
293
+ """Turn a response into a pandas DataFrame.
294
+
295
+ ``data`` is what a client method returned. ``key`` names the list to use
296
+ (dots reach into nested dicts, for example ``"props"``). Without a key the
297
+ longest list of records in the response is used. Nested dicts are flattened
298
+ into dotted columns. pandas is imported here, so it stays optional.
299
+ """
300
+ try:
301
+ import pandas as pd
302
+ except ImportError as exc: # pragma: no cover
303
+ raise ImportError("to_dataframe needs pandas: pip install pandas") from exc
304
+ rows = _rows(data, key)
305
+ if rows and all(isinstance(r, dict) for r in rows):
306
+ return pd.json_normalize(rows)
307
+ return pd.DataFrame(rows)
birdieapi/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
birdieapi/client.py ADDED
@@ -0,0 +1,536 @@
1
+ """Generated by packages/python/generate.py from the BirdieAPI OpenAPI spec. Do not edit by hand."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Literal, Optional, Union
5
+
6
+ from ._core import BaseClient
7
+
8
+ #: Credits each tool costs, from the spec.
9
+ TOOLS: Dict[str, int] = {
10
+ "list_tournaments": 1,
11
+ "get_schedule": 1,
12
+ "get_leaderboard": 2,
13
+ "get_course": 2,
14
+ "search_players": 1,
15
+ "get_player": 5,
16
+ "get_player_results": 5,
17
+ "get_player_stats": 5,
18
+ "get_scorecard": 2,
19
+ "get_course_history": 5,
20
+ "get_head_to_head": 5,
21
+ "get_ratings": 2,
22
+ "get_leaders": 5,
23
+ "get_winner_odds": 5,
24
+ }
25
+
26
+
27
+ class Client(BaseClient):
28
+ """Client for the BirdieAPI API (golf).
29
+
30
+ Create one with ``Client()`` (reads BIRDIEAPI_API_KEY) or ``Client(api_key="...")``.
31
+ Every method returns the unwrapped ``data`` from the response.
32
+ ``last_credits_used`` and ``last_credits_remaining`` hold the counters from
33
+ the most recent response.
34
+ """
35
+
36
+ def list_tournaments(
37
+ self,
38
+ *,
39
+ season: Optional[Union[str, int, float]] = None,
40
+ status: Optional[Literal["scheduled", "live", "final"]] = None,
41
+ major: Optional[bool] = None,
42
+ limit: Optional[int] = None,
43
+ offset: Optional[int] = None,
44
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
45
+ ) -> Any:
46
+ """List tournaments.
47
+
48
+ Use to find tournaments. Lists a season's tournaments (ascending) or the latest ones
49
+ (descending) with dates, status, purse, whether it is a major, the winner and winning score,
50
+ field size and the courses. Filter by season, status and major. Costs 1 credit.
51
+
52
+ Costs 1 credit per call.
53
+
54
+ Args:
55
+ season (optional): Season year, e.g. 2025.
56
+ status (optional): Only upcoming, in-progress or completed tournaments. One of: scheduled,
57
+ live, final.
58
+ major (optional): Only the majors.
59
+ limit (optional): Max tournaments (default 60).
60
+ offset (optional): Page offset; use next_offset.
61
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
62
+
63
+ Returns:
64
+ The unwrapped ``data`` object from the response.
65
+ """
66
+ return self._call(
67
+ "list_tournaments",
68
+ {
69
+ "season": season,
70
+ "status": status,
71
+ "major": major,
72
+ "limit": limit,
73
+ "offset": offset,
74
+ "league": league,
75
+ },
76
+ )
77
+
78
+ def get_schedule(
79
+ self,
80
+ *,
81
+ days: Optional[int] = None,
82
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
83
+ ) -> Any:
84
+ """Schedule.
85
+
86
+ Use for what is on now and next: tournaments in play or starting in the next N days (default
87
+ 30), with the courses and the defending champion. Costs 1 credit.
88
+
89
+ Costs 1 credit per call.
90
+
91
+ Args:
92
+ days (optional): Days ahead (default 30, max 365).
93
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
94
+
95
+ Returns:
96
+ The unwrapped ``data`` object from the response.
97
+ """
98
+ return self._call(
99
+ "get_schedule",
100
+ {
101
+ "days": days,
102
+ "league": league,
103
+ },
104
+ )
105
+
106
+ def get_leaderboard(
107
+ self,
108
+ tournament_id: str,
109
+ *,
110
+ limit: Optional[int] = None,
111
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
112
+ ) -> Any:
113
+ """Leaderboard.
114
+
115
+ Use for a tournament's results: every player in the field in finishing order with position (T5),
116
+ status (cut, wd, dq), score to par, total strokes, each round's score and strokes versus the
117
+ field, earnings and season points, plus any playoff. Costs 2 credits.
118
+
119
+ Costs 2 credits per call.
120
+
121
+ Args:
122
+ tournament_id: Tournament id from list_tournaments or get_schedule.
123
+ limit (optional): Max players (default the whole field).
124
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
125
+
126
+ Returns:
127
+ The unwrapped ``data`` object from the response.
128
+ """
129
+ return self._call(
130
+ "get_leaderboard",
131
+ {
132
+ "tournament_id": tournament_id,
133
+ "limit": limit,
134
+ "league": league,
135
+ },
136
+ )
137
+
138
+ def get_course(
139
+ self,
140
+ tournament_id: str,
141
+ *,
142
+ course_id: Optional[str] = None,
143
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
144
+ ) -> Any:
145
+ """Course.
146
+
147
+ Use for a course as it was set up for a tournament: par and yards per hole, and how each hole
148
+ played (scoring average, to par, birdie and bogey rates, difficulty rank) from every round on
149
+ it. For a tournament on more than one course pass course_id. Costs 2 credits.
150
+
151
+ Costs 2 credits per call.
152
+
153
+ Args:
154
+ tournament_id: Tournament id from list_tournaments.
155
+ course_id (optional): Course id, for tournaments played on more than one course
156
+ (list_tournaments lists them).
157
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
158
+
159
+ Returns:
160
+ The unwrapped ``data`` object from the response.
161
+ """
162
+ return self._call(
163
+ "get_course",
164
+ {
165
+ "tournament_id": tournament_id,
166
+ "course_id": course_id,
167
+ "league": league,
168
+ },
169
+ )
170
+
171
+ def search_players(
172
+ self,
173
+ query: str,
174
+ *,
175
+ limit: Optional[int] = None,
176
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
177
+ ) -> Any:
178
+ """Search players.
179
+
180
+ Use to find a player's id by name (accents optional). Returns country, tournaments played, wins
181
+ and the seasons played. Costs 1 credit.
182
+
183
+ Costs 1 credit per call.
184
+
185
+ Args:
186
+ query: Part of the player's name, e.g. 'scheffler'.
187
+ limit (optional): Max players (default 20).
188
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
189
+
190
+ Returns:
191
+ The unwrapped ``data`` object from the response.
192
+ """
193
+ return self._call(
194
+ "search_players",
195
+ {
196
+ "query": query,
197
+ "limit": limit,
198
+ "league": league,
199
+ },
200
+ )
201
+
202
+ def get_player(
203
+ self,
204
+ player_id: str,
205
+ *,
206
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
207
+ ) -> Any:
208
+ """Player.
209
+
210
+ Use for a player's overview: profile, career totals (starts, wins, top tens, cuts, money,
211
+ scoring average, strokes versus the field), our current rating, a line per season and the last
212
+ ten results. Costs 5 credits.
213
+
214
+ Costs 5 credits per call.
215
+
216
+ Args:
217
+ player_id: Player id from search_players or get_leaderboard.
218
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
219
+
220
+ Returns:
221
+ The unwrapped ``data`` object from the response.
222
+ """
223
+ return self._call(
224
+ "get_player",
225
+ {
226
+ "player_id": player_id,
227
+ "league": league,
228
+ },
229
+ )
230
+
231
+ def get_player_results(
232
+ self,
233
+ player_id: str,
234
+ *,
235
+ season: Optional[Union[str, int, float]] = None,
236
+ limit: Optional[int] = None,
237
+ offset: Optional[int] = None,
238
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
239
+ ) -> Any:
240
+ """Player results.
241
+
242
+ Use for a player's tournament-by-tournament record: finish, score, earnings and each round with
243
+ the field's average and strokes versus the field (raw and field-strength adjusted). Newest
244
+ first; filter by season. Costs 5 credits.
245
+
246
+ Costs 5 credits per call.
247
+
248
+ Args:
249
+ player_id: Player id from search_players.
250
+ season (optional): Season year, e.g. 2025.
251
+ limit (optional): Max tournaments (default 50).
252
+ offset (optional): Page offset; use next_offset.
253
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
254
+
255
+ Returns:
256
+ The unwrapped ``data`` object from the response.
257
+ """
258
+ return self._call(
259
+ "get_player_results",
260
+ {
261
+ "player_id": player_id,
262
+ "season": season,
263
+ "limit": limit,
264
+ "offset": offset,
265
+ "league": league,
266
+ },
267
+ )
268
+
269
+ def get_player_stats(
270
+ self,
271
+ player_id: str,
272
+ season: Union[str, int, float],
273
+ *,
274
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
275
+ ) -> Any:
276
+ """Player stats.
277
+
278
+ Use for a player's season in numbers: results (starts, wins, top tens, cuts, money), scoring
279
+ average and strokes versus the field, hole scoring from every hole played (birdies and bogeys
280
+ per round, par 3/4/5 averages) and, for the PGA Tour and Korn Ferry Tour, strokes versus the
281
+ field by hole type (sg_par3, sg_par4, sg_par5; score-based, not shot data) and, where ESPN keeps
282
+ them, driving distance and accuracy, greens in regulation and putts per green. Costs 5 credits.
283
+
284
+ Costs 5 credits per call.
285
+
286
+ Args:
287
+ player_id: Player id from search_players.
288
+ season: Season year, e.g. 2025.
289
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
290
+
291
+ Returns:
292
+ The unwrapped ``data`` object from the response.
293
+ """
294
+ return self._call(
295
+ "get_player_stats",
296
+ {
297
+ "player_id": player_id,
298
+ "season": season,
299
+ "league": league,
300
+ },
301
+ )
302
+
303
+ def get_scorecard(
304
+ self,
305
+ tournament_id: str,
306
+ player_id: str,
307
+ *,
308
+ league: Optional[Literal["golf", "kornferry"]] = None,
309
+ ) -> Any:
310
+ """Scorecard.
311
+
312
+ Use for one player's tournament hole by hole: every round's strokes on each hole with par, yards
313
+ and the result (birdie, bogey), the course played, out and in, and strokes versus the field.
314
+ Costs 2 credits.
315
+
316
+ Costs 2 credits per call.
317
+
318
+ Args:
319
+ tournament_id: Tournament id from list_tournaments.
320
+ player_id: Player id from get_leaderboard or search_players.
321
+ league (optional): League. Defaults to golf. One of: golf, kornferry.
322
+
323
+ Returns:
324
+ The unwrapped ``data`` object from the response.
325
+ """
326
+ return self._call(
327
+ "get_scorecard",
328
+ {
329
+ "tournament_id": tournament_id,
330
+ "player_id": player_id,
331
+ "league": league,
332
+ },
333
+ )
334
+
335
+ def get_course_history(
336
+ self,
337
+ player_id: str,
338
+ *,
339
+ tournament_id: Optional[str] = None,
340
+ course_id: Optional[str] = None,
341
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
342
+ ) -> Any:
343
+ """Course history.
344
+
345
+ Use for course or event form: a player's record at one tournament across every year (pass any
346
+ edition's tournament_id) or at one course across every tournament played there (course_id), with
347
+ finishes, rounds and strokes versus the field, and a summary. Costs 5 credits.
348
+
349
+ Costs 5 credits per call.
350
+
351
+ Args:
352
+ player_id: Player id from search_players.
353
+ tournament_id (optional): Any year's id of the tournament; every edition is returned.
354
+ course_id (optional): A course id (list_tournaments lists each tournament's courses).
355
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
356
+
357
+ Returns:
358
+ The unwrapped ``data`` object from the response.
359
+ """
360
+ return self._call(
361
+ "get_course_history",
362
+ {
363
+ "player_id": player_id,
364
+ "tournament_id": tournament_id,
365
+ "course_id": course_id,
366
+ "league": league,
367
+ },
368
+ )
369
+
370
+ def get_head_to_head(
371
+ self,
372
+ player_a: str,
373
+ player_b: str,
374
+ *,
375
+ season: Optional[Union[str, int, float]] = None,
376
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
377
+ ) -> Any:
378
+ """Head to head.
379
+
380
+ Use to compare two players: every tournament both played with each one's finish, who finished
381
+ ahead (making the cut beats missing it), round-by-round strokes on the same course, and a
382
+ summary. Filter by season. Costs 5 credits.
383
+
384
+ Costs 5 credits per call.
385
+
386
+ Args:
387
+ player_a: First player id.
388
+ player_b: Second player id.
389
+ season (optional): Season year, e.g. 2025.
390
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
391
+
392
+ Returns:
393
+ The unwrapped ``data`` object from the response.
394
+ """
395
+ return self._call(
396
+ "get_head_to_head",
397
+ {
398
+ "player_a": player_a,
399
+ "player_b": player_b,
400
+ "season": season,
401
+ "league": league,
402
+ },
403
+ )
404
+
405
+ def get_ratings(
406
+ self,
407
+ *,
408
+ limit: Optional[int] = None,
409
+ as_of: Optional[str] = None,
410
+ min_rounds: Optional[int] = None,
411
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
412
+ ) -> Any:
413
+ """Ratings.
414
+
415
+ Use for who is playing best: our strokes-versus-field rating for every player active in the past
416
+ year, best first. A rating of 2.0 means two strokes a round better than an average player in the
417
+ same fields, adjusted for field strength and weighted toward recent rounds. Pass as_of for the
418
+ ratings on a past date. Costs 2 credits.
419
+
420
+ Costs 2 credits per call.
421
+
422
+ Args:
423
+ limit (optional): Max players (default 50).
424
+ as_of (optional): A date (YYYY-MM-DD): the ratings as they stood then. Default the latest.
425
+ min_rounds (optional): Minimum decay-weighted rounds behind a rating (default 20).
426
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
427
+
428
+ Returns:
429
+ The unwrapped ``data`` object from the response.
430
+ """
431
+ return self._call(
432
+ "get_ratings",
433
+ {
434
+ "limit": limit,
435
+ "as_of": as_of,
436
+ "min_rounds": min_rounds,
437
+ "league": league,
438
+ },
439
+ )
440
+
441
+ def get_leaders(
442
+ self,
443
+ stat: Literal["scoring_average", "adj_vs_field", "vs_field", "birdies_per_round", "bogey_avoidance", "par3_average", "par4_average", "par5_average", "sg_par3", "sg_par4", "sg_par5", "earnings", "cup_points", "wins", "top_tens", "driving_distance", "driving_accuracy", "greens_in_regulation", "putts_per_gir"],
444
+ season: Union[str, int, float],
445
+ *,
446
+ min: Optional[int] = None,
447
+ limit: Optional[int] = None,
448
+ league: Optional[Literal["golf", "lpga", "liv", "dpwt", "kornferry"]] = None,
449
+ ) -> Any:
450
+ """Leaders.
451
+
452
+ Use for a season's leaders in one stat, with each player's sample. Stats from rounds and holes:
453
+ scoring_average, vs_field, adj_vs_field, birdies_per_round, bogey_avoidance, par3_average,
454
+ par4_average, par5_average, and sg_par3, sg_par4, sg_par5 (strokes versus the field on par 3, 4
455
+ or 5 holes, field-strength adjusted; PGA Tour and Korn Ferry Tour only, score-based, not shot
456
+ data). From results: earnings, cup_points, wins, top_tens. From ESPN's tournament lines (PGA
457
+ Tour from 2011): driving_distance, driving_accuracy, greens_in_regulation, putts_per_gir. Costs
458
+ 5 credits.
459
+
460
+ Costs 5 credits per call.
461
+
462
+ Args:
463
+ stat: The stat to rank. One of: scoring_average, adj_vs_field, vs_field, birdies_per_round,
464
+ bogey_avoidance, par3_average, par4_average, par5_average, sg_par3, sg_par4, sg_par5,
465
+ earnings, cup_points, wins, top_tens, driving_distance, driving_accuracy,
466
+ greens_in_regulation, putts_per_gir.
467
+ season: Season year, e.g. 2025.
468
+ min (optional): Minimum sample: rounds (default 40), starts (1) or tournaments with a stat
469
+ line (8).
470
+ limit (optional): Max players (default 25).
471
+ league (optional): League. Defaults to golf. One of: golf, lpga, liv, dpwt, kornferry.
472
+
473
+ Returns:
474
+ The unwrapped ``data`` object from the response.
475
+ """
476
+ return self._call(
477
+ "get_leaders",
478
+ {
479
+ "stat": stat,
480
+ "season": season,
481
+ "min": min,
482
+ "limit": limit,
483
+ "league": league,
484
+ },
485
+ )
486
+
487
+ def get_winner_odds(
488
+ self,
489
+ *,
490
+ tournament_id: Optional[str] = None,
491
+ season: Optional[Union[str, int, float]] = None,
492
+ major: Optional[Literal["masters", "pga_championship", "us_open", "the_open"]] = None,
493
+ player_id: Optional[str] = None,
494
+ bookmaker: Optional[str] = None,
495
+ limit: Optional[int] = None,
496
+ ) -> Any:
497
+ """Winner odds.
498
+
499
+ Use for the pre-tournament winner prices of the four men's majors by sportsbook, and how they
500
+ moved: for each golfer and book the opening price (the first snapshot) and the latest price
501
+ before the first round, with the implied probability, from The Odds API (the archive for every
502
+ major from 2021 plus the 2020 U.S. Open, then refreshed daily while a major's market is open,
503
+ every six hours in tournament week). For past majors the latest price can be up to a week before
504
+ the first tee time; round, matchup and finishing-position markets are not held. Pass
505
+ tournament_id, or season (all four majors), or player_id (one golfer across the majors). Costs 5
506
+ credits.
507
+
508
+ Costs 5 credits per call.
509
+
510
+ Args:
511
+ tournament_id (optional): A major's tournament id from list_tournaments (major: true).
512
+ season (optional): ESPN's season year, e.g. 2025: all four majors of that season (the 2020
513
+ U.S. Open and the November 2020 Masters are season 2021).
514
+ major (optional): Only this major (with season, or alone for every year). One of: masters,
515
+ pga_championship, us_open, the_open.
516
+ player_id (optional): Player id from search_players: this golfer across the majors he was
517
+ priced for.
518
+ bookmaker (optional): Bookmaker key, e.g. draftkings, fanduel, betmgm, bovada, betonlineag,
519
+ espnbet, pinnacle, mybookieag.
520
+ limit (optional): Golfers per tournament, favourites first (default 20, max 200). Ignored
521
+ with player_id.
522
+
523
+ Returns:
524
+ The unwrapped ``data`` object from the response.
525
+ """
526
+ return self._call(
527
+ "get_winner_odds",
528
+ {
529
+ "tournament_id": tournament_id,
530
+ "season": season,
531
+ "major": major,
532
+ "player_id": player_id,
533
+ "bookmaker": bookmaker,
534
+ "limit": limit,
535
+ },
536
+ )
birdieapi/py.typed ADDED
File without changes
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.5
2
+ Name: birdieapi
3
+ Version: 0.1.0
4
+ Summary: Python client for the BirdieAPI golf data API. No dependencies.
5
+ Project-URL: Homepage, https://birdieapi.com
6
+ Project-URL: Documentation, https://birdieapi.com/docs
7
+ Project-URL: Repository, https://github.com/noahowsh/puckapi
8
+ Project-URL: Sign up, https://birdieapi.com/signup
9
+ Author-email: BirdieAPI <support@birdieapi.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-client,betting,birdieapi,golf,odds,player-props,sports-data
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: pandas
26
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # BirdieAPI for Python
32
+
33
+ Python client for the [BirdieAPI](https://birdieapi.com) golf data API. It has no required dependencies (standard library only) and works on Python 3.9 and newer. Pandas is optional.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install birdieapi
39
+ pip install "birdieapi[pandas]" # only if you want DataFrames
40
+ ```
41
+
42
+ ## Set your key
43
+
44
+ Get a free key at https://birdieapi.com/signup. Free accounts get 500 credits a month.
45
+
46
+ ```bash
47
+ export BIRDIEAPI_API_KEY=your_key_here
48
+ ```
49
+
50
+ ```python
51
+ from birdieapi import Client
52
+
53
+ client = Client() # reads BIRDIEAPI_API_KEY
54
+ client = Client(api_key="your_key") # or pass it in
55
+ ```
56
+
57
+ ## Examples
58
+
59
+ ### 1. Recent tournaments
60
+
61
+ `list_tournaments` filters by season and status.
62
+
63
+ ```python
64
+ from birdieapi import Client
65
+
66
+ client = Client()
67
+
68
+ events = client.list_tournaments(status="final", limit=3)["tournaments"]
69
+ for e in events:
70
+ print(e["tournament_id"], e["name"], e["winner_name"], e["winning_to_par"])
71
+ ```
72
+
73
+ ### 2. A leaderboard
74
+
75
+ Pass a tournament id from `list_tournaments`.
76
+
77
+ ```python
78
+ from birdieapi import Client, to_dataframe
79
+
80
+ client = Client()
81
+
82
+ board = client.get_leaderboard("401850916", limit=10)
83
+ print(to_dataframe(board).head(10))
84
+ ```
85
+
86
+ ### 3. A player's results
87
+
88
+ Find the player id with `search_players`, then pull his results for a season.
89
+
90
+ ```python
91
+ from birdieapi import Client
92
+
93
+ client = Client()
94
+
95
+ scheffler = client.search_players("Scheffler", limit=1)["players"][0]
96
+ results = client.get_player_results(scheffler["player_id"], season=2026, limit=5)
97
+ print(scheffler["name"], results)
98
+ ```
99
+
100
+ ## Credits
101
+
102
+ Every method returns the `data` object from the response. After each call, `client.last_credits_used` and `client.last_credits_remaining` hold the counters from the `X-Credits-Used` and `X-Credits-Remaining` headers. Methods that take `estimate_only=True` return the price of a call without running or charging it.
103
+
104
+ ## Pandas
105
+
106
+ ```python
107
+ from birdieapi import to_dataframe
108
+
109
+ df = to_dataframe(data) # finds the main list in the response
110
+ df = to_dataframe(data, key="rows") # or name the list yourself
111
+ ```
112
+
113
+ Nested fields become dotted columns. Install pandas separately or with `pip install "birdieapi[pandas]"`.
114
+
115
+ ## Errors
116
+
117
+ Every error is an `APIError` subclass, with the server's message and the HTTP status.
118
+
119
+ | Exception | Status | Meaning |
120
+ | --- | --- | --- |
121
+ | `AuthError` | 401 | Missing, invalid or revoked key |
122
+ | `InsufficientCreditsError` | 402 | Not enough credits for the call |
123
+ | `NotFoundError` | 404 | The id or name does not exist |
124
+ | `RateLimitError` | 429 | Too many requests; has `.retry_after` when the server sends it |
125
+ | `APIError` | 400, 5xx | Bad arguments or a server problem |
126
+ | `APIConnectionError` | none | No response (network or timeout) |
127
+
128
+ ```python
129
+ from birdieapi import Client, APIError, InsufficientCreditsError, RateLimitError
130
+
131
+ client = Client()
132
+ try:
133
+ data = client.list_tournaments()
134
+ except InsufficientCreditsError:
135
+ print("Out of credits, top up at https://birdieapi.com")
136
+ except RateLimitError as exc:
137
+ print("Slow down, retry after", exc.retry_after)
138
+ except APIError as exc:
139
+ print(exc.status_code, exc.message)
140
+ ```
141
+
142
+ The client retries 429 and 5xx responses and connection failures up to 3 times with backoff, and honors `Retry-After`. Set `Client(max_retries=0)` to turn that off.
143
+
144
+ If you see `CERTIFICATE_VERIFY_FAILED` on a python.org install for macOS, run the "Install Certificates.command" script that came with Python, or `pip install certifi` (the client uses it when present).
145
+
146
+ ## Options
147
+
148
+ ```python
149
+ Client(
150
+ api_key=None, # defaults to $BIRDIEAPI_API_KEY
151
+ base_url="https://mcp.birdieapi.com", # point at another host
152
+ timeout=60.0, # seconds per request
153
+ max_retries=3,
154
+ )
155
+ ```
156
+
157
+ `client.call("tool_name", {"arg": "value"})` calls any tool by name, which helps when the API adds a tool before this package does.
158
+
159
+ ## Tools
160
+
161
+ Method names match the API tool names. Arguments are keyword arguments; required ones can also be passed positionally. Each method's docstring lists its arguments (`help(client.list_tournaments)`).
162
+
163
+ | Method | Credits | What it does |
164
+ | --- | --- | --- |
165
+ | `list_tournaments` | 1 | List tournaments |
166
+ | `get_schedule` | 1 | Schedule |
167
+ | `get_leaderboard` | 2 | Leaderboard |
168
+ | `get_course` | 2 | Course |
169
+ | `search_players` | 1 | Search players |
170
+ | `get_player` | 5 | Player |
171
+ | `get_player_results` | 5 | Player results |
172
+ | `get_player_stats` | 5 | Player stats |
173
+ | `get_scorecard` | 2 | Scorecard |
174
+ | `get_course_history` | 5 | Course history |
175
+ | `get_head_to_head` | 5 | Head to head |
176
+ | `get_ratings` | 2 | Ratings |
177
+ | `get_leaders` | 5 | Leaders |
178
+ | `get_winner_odds` | 5 | Winner odds |
179
+
180
+ Full API reference: https://birdieapi.com/docs
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,9 @@
1
+ birdieapi/__init__.py,sha256=NiicMIy_okR7a3JvZ5_Y9IR3oeEWA5gdRwNp2ywT2vg,510
2
+ birdieapi/_core.py,sha256=4zyKuW-zKf3LnxwXYRF4kNV2JiomWxhNzCjIXHSVRQ8,10604
3
+ birdieapi/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
4
+ birdieapi/client.py,sha256=5OXc4bMy2iMOGaN_X9g93767RQc9nMGUcY0uVKPGOWc,19838
5
+ birdieapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ birdieapi-0.1.0.dist-info/METADATA,sha256=_AZuCwXF_iO10Sa6bBwVI_Rdl2aw8RLfweLOGyhRKzg,6038
7
+ birdieapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ birdieapi-0.1.0.dist-info/licenses/LICENSE,sha256=jZbcZfjQOO4cnpy0N68uhPi2HgElF8sPB80CmySOqnM,1066
9
+ birdieapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BirdieAPI
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.