pitchapi 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.
Files changed (33) hide show
  1. pitchapi-0.1.0/.gitignore +11 -0
  2. pitchapi-0.1.0/LICENSE +21 -0
  3. pitchapi-0.1.0/PKG-INFO +178 -0
  4. pitchapi-0.1.0/README.md +148 -0
  5. pitchapi-0.1.0/pyproject.toml +114 -0
  6. pitchapi-0.1.0/src/pitchapi/__init__.py +44 -0
  7. pitchapi-0.1.0/src/pitchapi/_transport.py +101 -0
  8. pitchapi-0.1.0/src/pitchapi/client.py +184 -0
  9. pitchapi-0.1.0/src/pitchapi/errors.py +128 -0
  10. pitchapi-0.1.0/src/pitchapi/models/__init__.py +127 -0
  11. pitchapi-0.1.0/src/pitchapi/models/_convert.py +79 -0
  12. pitchapi-0.1.0/src/pitchapi/models/advanced.py +279 -0
  13. pitchapi-0.1.0/src/pitchapi/models/common.py +51 -0
  14. pitchapi-0.1.0/src/pitchapi/models/event.py +27 -0
  15. pitchapi-0.1.0/src/pitchapi/models/h2h.py +30 -0
  16. pitchapi-0.1.0/src/pitchapi/models/league.py +62 -0
  17. pitchapi-0.1.0/src/pitchapi/models/lineup.py +41 -0
  18. pitchapi-0.1.0/src/pitchapi/models/match.py +48 -0
  19. pitchapi-0.1.0/src/pitchapi/models/momentum.py +16 -0
  20. pitchapi-0.1.0/src/pitchapi/models/player.py +42 -0
  21. pitchapi-0.1.0/src/pitchapi/models/shot.py +54 -0
  22. pitchapi-0.1.0/src/pitchapi/models/stats.py +35 -0
  23. pitchapi-0.1.0/src/pitchapi/models/team.py +13 -0
  24. pitchapi-0.1.0/src/pitchapi/py.typed +0 -0
  25. pitchapi-0.1.0/src/pitchapi/resources/__init__.py +20 -0
  26. pitchapi-0.1.0/src/pitchapi/resources/_base.py +52 -0
  27. pitchapi-0.1.0/src/pitchapi/resources/date.py +40 -0
  28. pitchapi-0.1.0/src/pitchapi/resources/leagues.py +61 -0
  29. pitchapi-0.1.0/src/pitchapi/resources/matches.py +163 -0
  30. pitchapi-0.1.0/src/pitchapi/resources/players.py +21 -0
  31. pitchapi-0.1.0/src/pitchapi/resources/teams.py +17 -0
  32. pitchapi-0.1.0/tests/test_client.py +367 -0
  33. pitchapi-0.1.0/tests/test_convert.py +137 -0
@@ -0,0 +1,11 @@
1
+ # Python build & cache artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .venv/
11
+ venv/
pitchapi-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PitchAPI
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,178 @@
1
+ Metadata-Version: 2.5
2
+ Name: pitchapi
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the PitchAPI football-data API
5
+ Project-URL: Homepage, https://pitchapi.dev
6
+ Project-URL: Documentation, https://pitchapi.dev/#sdks
7
+ Project-URL: Changelog, https://pitchapi.dev/#changelog
8
+ Author: PitchAPI
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,football,opta,sdk,soccer,xg
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx>=0.24
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy<2,>=1.5; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
27
+ Requires-Dist: pytest>=7; extra == 'dev'
28
+ Requires-Dist: ruff<0.17,>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pitchapi — Python SDK
32
+
33
+ Typed Python client for the [PitchAPI](https://pitchapi.dev) football-data API.
34
+ Match results, shots, xG, lineups, momentum, events, player stats, head-to-head,
35
+ and Opta-derived advanced analytics.
36
+
37
+ - Sync **and** async clients over one shared core (`httpx`)
38
+ - Plain `dataclass` response models — a faithful mirror of the API's JSON, built
39
+ through a small `from_dict` converter (no Pydantic, one dependency)
40
+ - Typed exceptions mapped from the API's stable `error.code`
41
+ - Automatic retries on `429`/`5xx` with `Retry-After` honoured
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install pitchapi
47
+ ```
48
+
49
+ Requires Python 3.9+.
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ from pitchapi import PitchAPI
55
+
56
+ with PitchAPI(api_key="pk_live_...") as client:
57
+ day = client.date.get("2026-08-27")
58
+ for m in day.matches:
59
+ print(m.home_team.name, m.score_home, "-", m.score_away, m.away_team.name)
60
+
61
+ match = client.matches.get("m_4DP2fy")
62
+ shots = client.matches.shots(match.id)
63
+ adv = client.matches.advanced(match.id) # team rollups
64
+ net = client.matches.advanced_network(match.id) # pass networks
65
+ ```
66
+
67
+ The key can also come from the `PITCHAPI_API_KEY` environment variable, in which
68
+ case `PitchAPI()` needs no arguments.
69
+
70
+ ## Async
71
+
72
+ ```python
73
+ import asyncio
74
+ from pitchapi import AsyncPitchAPI
75
+
76
+ async def main():
77
+ async with AsyncPitchAPI() as client:
78
+ league = await client.leagues.get("l_0bfbkO")
79
+ matches = await client.leagues.matches(league.id, season="2025-2026")
80
+ print(len(matches.matches), "matches")
81
+
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ ## Namespaces
86
+
87
+ | Namespace | Methods |
88
+ |---|---|
89
+ | `client.date` | `get(date, status=None)` — `date` is a `str` (`YYYY-MM-DD`) or a `datetime.date` |
90
+ | `client.matches` | `get`, `shots`, `shot`, `events`, `lineups`, `momentum`, `stats`, `players`, `player`, `player_shots`, `h2h`, `advanced`, `advanced_network`, `advanced_players`, `advanced_player` |
91
+ | `client.leagues` | `list`, `get`, `matches(id, season=None, status=None)` |
92
+ | `client.teams` | `get` |
93
+ | `client.players` | `get` |
94
+
95
+ Every method returns a typed dataclass from `pitchapi.models`. Per-player match
96
+ `stats` is a raw `dict` because its shape varies by position.
97
+
98
+ ## Upcoming fixtures
99
+
100
+ Match listings return played matches by default. Pass `status` to reach
101
+ scheduled ones — `"upcoming"` for fixtures that have not kicked off, `"all"` for
102
+ both. An upcoming match carries a kickoff `time_utc` and a `status`, but its
103
+ scores are `None` until it is played.
104
+
105
+ ```python
106
+ for m in client.date.get("2026-09-01", status="upcoming").matches:
107
+ print(m.time_utc, m.home_team.name, "vs", m.away_team.name)
108
+
109
+ # Within a season. The season is resolved first, so asking for upcoming
110
+ # fixtures of a finished campaign is an empty list, not next season's.
111
+ client.leagues.matches("l_0bfbkO", season="2025-2026", status="all")
112
+ ```
113
+
114
+ Lineups for a fixture may be a pre-match prediction rather than the real XI.
115
+ `confirmed` is the flag to branch on; `lineup_type` carries the source's own
116
+ label for a prediction and is `None` once the lineup is confirmed.
117
+
118
+ ```python
119
+ lineups = client.matches.lineups("m_4DP2fy")
120
+ if lineups.home.confirmed:
121
+ print(lineups.home.formation, [p.name for p in lineups.home.starters])
122
+ else:
123
+ print("predicted only:", lineups.home.lineup_type)
124
+ ```
125
+
126
+ ## Errors
127
+
128
+ ```python
129
+ from pitchapi import NotFoundError, PlanUpgradeRequiredError, RateLimitError
130
+
131
+ try:
132
+ client.matches.advanced("m_unprocessed")
133
+ except NotFoundError as e:
134
+ # code is RESOURCE_NOT_FOUND or ANALYTICS_UNAVAILABLE
135
+ print(e.code, e.request_id)
136
+ except PlanUpgradeRequiredError:
137
+ ... # league is Pro-only
138
+ except RateLimitError as e:
139
+ print("retry after", e.retry_after, "s")
140
+ ```
141
+
142
+ All exceptions derive from `pitchapi.PitchAPIError` and carry `code`,
143
+ `status_code`, and `request_id` where available.
144
+
145
+ ## Configuration
146
+
147
+ ```python
148
+ PitchAPI(
149
+ api_key="pk_live_...",
150
+ base_url="https://api.pitchapi.dev", # override for self-hosting/tests
151
+ timeout=30.0,
152
+ max_retries=2, # 429/5xx + network errors; 0 disables
153
+ )
154
+ ```
155
+
156
+ ## Development
157
+
158
+ ```bash
159
+ pip install -e ".[dev]"
160
+ pytest # tests use httpx.MockTransport — no network
161
+ mypy # scoped to src/ by pyproject
162
+ ruff check .
163
+ ruff format --check .
164
+ ```
165
+
166
+ ## OpenAPI
167
+
168
+ A full OpenAPI 3.1 description of the API lives at
169
+ [`openapi/openapi.yaml`](../../openapi/openapi.yaml) in the repository root. The
170
+ dataclass models in this SDK mirror its schemas one-to-one.
171
+
172
+ ## License
173
+
174
+ This client library is released under the [MIT License](LICENSE).
175
+
176
+ The licence covers the SDK source only. Access to the PitchAPI service and the
177
+ football data it returns is governed separately by the PitchAPI terms of
178
+ service — an MIT-licensed client does not grant any right to the data.
@@ -0,0 +1,148 @@
1
+ # pitchapi — Python SDK
2
+
3
+ Typed Python client for the [PitchAPI](https://pitchapi.dev) football-data API.
4
+ Match results, shots, xG, lineups, momentum, events, player stats, head-to-head,
5
+ and Opta-derived advanced analytics.
6
+
7
+ - Sync **and** async clients over one shared core (`httpx`)
8
+ - Plain `dataclass` response models — a faithful mirror of the API's JSON, built
9
+ through a small `from_dict` converter (no Pydantic, one dependency)
10
+ - Typed exceptions mapped from the API's stable `error.code`
11
+ - Automatic retries on `429`/`5xx` with `Retry-After` honoured
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install pitchapi
17
+ ```
18
+
19
+ Requires Python 3.9+.
20
+
21
+ ## Quick start
22
+
23
+ ```python
24
+ from pitchapi import PitchAPI
25
+
26
+ with PitchAPI(api_key="pk_live_...") as client:
27
+ day = client.date.get("2026-08-27")
28
+ for m in day.matches:
29
+ print(m.home_team.name, m.score_home, "-", m.score_away, m.away_team.name)
30
+
31
+ match = client.matches.get("m_4DP2fy")
32
+ shots = client.matches.shots(match.id)
33
+ adv = client.matches.advanced(match.id) # team rollups
34
+ net = client.matches.advanced_network(match.id) # pass networks
35
+ ```
36
+
37
+ The key can also come from the `PITCHAPI_API_KEY` environment variable, in which
38
+ case `PitchAPI()` needs no arguments.
39
+
40
+ ## Async
41
+
42
+ ```python
43
+ import asyncio
44
+ from pitchapi import AsyncPitchAPI
45
+
46
+ async def main():
47
+ async with AsyncPitchAPI() as client:
48
+ league = await client.leagues.get("l_0bfbkO")
49
+ matches = await client.leagues.matches(league.id, season="2025-2026")
50
+ print(len(matches.matches), "matches")
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## Namespaces
56
+
57
+ | Namespace | Methods |
58
+ |---|---|
59
+ | `client.date` | `get(date, status=None)` — `date` is a `str` (`YYYY-MM-DD`) or a `datetime.date` |
60
+ | `client.matches` | `get`, `shots`, `shot`, `events`, `lineups`, `momentum`, `stats`, `players`, `player`, `player_shots`, `h2h`, `advanced`, `advanced_network`, `advanced_players`, `advanced_player` |
61
+ | `client.leagues` | `list`, `get`, `matches(id, season=None, status=None)` |
62
+ | `client.teams` | `get` |
63
+ | `client.players` | `get` |
64
+
65
+ Every method returns a typed dataclass from `pitchapi.models`. Per-player match
66
+ `stats` is a raw `dict` because its shape varies by position.
67
+
68
+ ## Upcoming fixtures
69
+
70
+ Match listings return played matches by default. Pass `status` to reach
71
+ scheduled ones — `"upcoming"` for fixtures that have not kicked off, `"all"` for
72
+ both. An upcoming match carries a kickoff `time_utc` and a `status`, but its
73
+ scores are `None` until it is played.
74
+
75
+ ```python
76
+ for m in client.date.get("2026-09-01", status="upcoming").matches:
77
+ print(m.time_utc, m.home_team.name, "vs", m.away_team.name)
78
+
79
+ # Within a season. The season is resolved first, so asking for upcoming
80
+ # fixtures of a finished campaign is an empty list, not next season's.
81
+ client.leagues.matches("l_0bfbkO", season="2025-2026", status="all")
82
+ ```
83
+
84
+ Lineups for a fixture may be a pre-match prediction rather than the real XI.
85
+ `confirmed` is the flag to branch on; `lineup_type` carries the source's own
86
+ label for a prediction and is `None` once the lineup is confirmed.
87
+
88
+ ```python
89
+ lineups = client.matches.lineups("m_4DP2fy")
90
+ if lineups.home.confirmed:
91
+ print(lineups.home.formation, [p.name for p in lineups.home.starters])
92
+ else:
93
+ print("predicted only:", lineups.home.lineup_type)
94
+ ```
95
+
96
+ ## Errors
97
+
98
+ ```python
99
+ from pitchapi import NotFoundError, PlanUpgradeRequiredError, RateLimitError
100
+
101
+ try:
102
+ client.matches.advanced("m_unprocessed")
103
+ except NotFoundError as e:
104
+ # code is RESOURCE_NOT_FOUND or ANALYTICS_UNAVAILABLE
105
+ print(e.code, e.request_id)
106
+ except PlanUpgradeRequiredError:
107
+ ... # league is Pro-only
108
+ except RateLimitError as e:
109
+ print("retry after", e.retry_after, "s")
110
+ ```
111
+
112
+ All exceptions derive from `pitchapi.PitchAPIError` and carry `code`,
113
+ `status_code`, and `request_id` where available.
114
+
115
+ ## Configuration
116
+
117
+ ```python
118
+ PitchAPI(
119
+ api_key="pk_live_...",
120
+ base_url="https://api.pitchapi.dev", # override for self-hosting/tests
121
+ timeout=30.0,
122
+ max_retries=2, # 429/5xx + network errors; 0 disables
123
+ )
124
+ ```
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ pip install -e ".[dev]"
130
+ pytest # tests use httpx.MockTransport — no network
131
+ mypy # scoped to src/ by pyproject
132
+ ruff check .
133
+ ruff format --check .
134
+ ```
135
+
136
+ ## OpenAPI
137
+
138
+ A full OpenAPI 3.1 description of the API lives at
139
+ [`openapi/openapi.yaml`](../../openapi/openapi.yaml) in the repository root. The
140
+ dataclass models in this SDK mirror its schemas one-to-one.
141
+
142
+ ## License
143
+
144
+ This client library is released under the [MIT License](LICENSE).
145
+
146
+ The licence covers the SDK source only. Access to the PitchAPI service and the
147
+ football data it returns is governed separately by the PitchAPI terms of
148
+ service — an MIT-licensed client does not grant any right to the data.
@@ -0,0 +1,114 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pitchapi"
7
+ # Single-sourced from src/pitchapi/__init__.py, which is also what the
8
+ # User-Agent reports — one place to bump, no way for the two to drift.
9
+ dynamic = ["version"]
10
+ description = "Python SDK for the PitchAPI football-data API"
11
+ readme = "README.md"
12
+ requires-python = ">=3.9"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ authors = [{ name = "PitchAPI" }]
16
+ keywords = ["football", "soccer", "opta", "xg", "api", "sdk"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = ["httpx>=0.24"]
30
+
31
+ [project.optional-dependencies]
32
+ # Upper bounds on the linters only: both change their default behaviour between
33
+ # minor releases, and CI should fail on a real regression rather than on an
34
+ # upstream rule that did not exist when the code was written.
35
+ dev = [
36
+ "pytest>=7",
37
+ "pytest-asyncio>=0.21",
38
+ "mypy>=1.5,<2",
39
+ "ruff>=0.6,<0.17",
40
+ ]
41
+
42
+ # Every URL here is rendered publicly on the PyPI project page. The source repo
43
+ # is private, so it is deliberately not listed: linking it would publish both the
44
+ # owner's account name and the repository name to anyone reading the page.
45
+ [project.urls]
46
+ Homepage = "https://pitchapi.dev"
47
+ Documentation = "https://pitchapi.dev/#sdks"
48
+ Changelog = "https://pitchapi.dev/#changelog"
49
+
50
+ [tool.hatch.version]
51
+ path = "src/pitchapi/__init__.py"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/pitchapi"]
55
+
56
+ # Listed explicitly rather than defaulting to "everything git does not ignore".
57
+ # The sdist is a published artifact that anyone can download and read, so a file
58
+ # added to this directory later — a release runbook, internal notes — must not
59
+ # ship by accident. It ships only if it appears below.
60
+ [tool.hatch.build.targets.sdist]
61
+ include = [
62
+ "src",
63
+ "tests",
64
+ "README.md",
65
+ "LICENSE",
66
+ "pyproject.toml",
67
+ ]
68
+
69
+ [tool.pytest.ini_options]
70
+ asyncio_mode = "auto"
71
+ testpaths = ["tests"]
72
+
73
+ [tool.ruff]
74
+ line-length = 100
75
+ target-version = "py39"
76
+
77
+ [tool.ruff.lint]
78
+ # Selected explicitly rather than relying on ruff's defaults, which grow between
79
+ # minor releases — an unpinned ruff would otherwise start failing code that has
80
+ # not changed. Two families are left out on purpose:
81
+ #
82
+ # UP / FA — pyupgrade and the future-annotations rules want to rewrite
83
+ # `Optional[X]` to `X | None` and add `from __future__ import annotations`
84
+ # to the model modules. Both are wrong here. The package supports 3.9, where
85
+ # `X | None` is a runtime TypeError outside an annotation; and
86
+ # models/_convert.py reads `dataclasses.fields(cls)[i].type` as a real type
87
+ # object, which only holds while the model modules keep their annotations
88
+ # un-postponed. See the design notes in that module.
89
+ select = ["E", "W", "F", "I", "B", "C4", "SIM", "RUF"]
90
+ # `__all__` is grouped by meaning (clients, then models, then errors) and
91
+ # mirrors the import block above it; alphabetical order would lose that.
92
+ ignore = ["RUF022"]
93
+
94
+ [tool.mypy]
95
+ # Strict mode covers the shipped package. The tests are deliberately loose —
96
+ # the httpx mock handlers take whatever shape each case needs — so `files`
97
+ # scopes a bare `mypy` to src rather than leaving that to the caller.
98
+ files = ["src"]
99
+ # No `python_version`: mypy no longer accepts a 3.9 target, so the version under
100
+ # test is whichever interpreter CI runs it on. The matrix covers 3.9 upward.
101
+ strict = true
102
+ warn_unused_ignores = false
103
+
104
+ [tool.pyright]
105
+ include = ["src", "tests"]
106
+ extraPaths = ["src"]
107
+
108
+ [tool.ruff.format]
109
+ # The formatter owns Python code and nothing else. Recent ruff versions also
110
+ # reformat fenced code in Markdown and docstrings by default, which flattens the
111
+ # indented reST literal blocks the module docstrings rely on and rewraps the
112
+ # hand-aligned trailing comments in README examples.
113
+ docstring-code-format = false
114
+ exclude = ["*.md"]
@@ -0,0 +1,44 @@
1
+ """PitchAPI — Python SDK for the PitchAPI football-data API.
2
+
3
+ Example:
4
+ from pitchapi import PitchAPI
5
+
6
+ with PitchAPI(api_key="pk_live_...") as client:
7
+ for m in client.date.get("2026-08-27").matches:
8
+ print(m.home_team.name, m.score_home, "-", m.score_away, m.away_team.name)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from . import models
16
+ from .client import AsyncPitchAPI, PitchAPI
17
+ from .errors import (
18
+ APIConnectionError,
19
+ AuthError,
20
+ InvalidParameterError,
21
+ NotFoundError,
22
+ PitchAPIError,
23
+ PlanUpgradeRequiredError,
24
+ RateLimitError,
25
+ ServerError,
26
+ SubscriptionSuspendedError,
27
+ )
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "PitchAPI",
32
+ "AsyncPitchAPI",
33
+ "models",
34
+ # errors
35
+ "PitchAPIError",
36
+ "APIConnectionError",
37
+ "InvalidParameterError",
38
+ "AuthError",
39
+ "SubscriptionSuspendedError",
40
+ "PlanUpgradeRequiredError",
41
+ "NotFoundError",
42
+ "RateLimitError",
43
+ "ServerError",
44
+ ]
@@ -0,0 +1,101 @@
1
+ """Transport-level helpers shared by the sync and async clients.
2
+
3
+ Keeping envelope handling, error mapping, and the retry policy here means the
4
+ two clients differ only in how they run the HTTP call itself, not in how they
5
+ interpret the result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from typing import Any, Optional
12
+
13
+ from . import __version__
14
+ from .errors import (
15
+ PitchAPIError,
16
+ RateLimitError,
17
+ ServerError,
18
+ exception_for_code,
19
+ )
20
+
21
+ DEFAULT_BASE_URL = "https://api.pitchapi.dev"
22
+ API_PREFIX = "/v1"
23
+
24
+ # Statuses that a GET may safely be replayed on. Everything else (400/401/403/
25
+ # 404) is a deterministic client error and is surfaced immediately.
26
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
27
+
28
+
29
+ def user_agent() -> str:
30
+ return f"pitchapi-python/{__version__}"
31
+
32
+
33
+ def build_headers(api_key: str, extra: Optional[dict[str, str]] = None) -> dict[str, str]:
34
+ headers = {
35
+ "X-API-KEY": api_key,
36
+ "Accept": "application/json",
37
+ "User-Agent": user_agent(),
38
+ }
39
+ if extra:
40
+ headers.update(extra)
41
+ return headers
42
+
43
+
44
+ def should_retry(status: int, attempt: int, max_retries: int) -> bool:
45
+ return attempt < max_retries and status in _RETRY_STATUSES
46
+
47
+
48
+ def backoff_seconds(attempt: int, retry_after: Optional[int]) -> float:
49
+ """Delay before the next attempt.
50
+
51
+ Honours a server-provided ``Retry-After`` exactly; otherwise falls back to
52
+ exponential backoff with full jitter so retried clients do not synchronise.
53
+ """
54
+ if retry_after is not None and retry_after >= 0:
55
+ return float(retry_after)
56
+ base = min(2.0**attempt, 8.0)
57
+ return random.uniform(0.0, base)
58
+
59
+
60
+ def parse_retry_after(value: Optional[str]) -> Optional[int]:
61
+ if not value:
62
+ return None
63
+ try:
64
+ return int(value)
65
+ except ValueError:
66
+ return None
67
+
68
+
69
+ def unwrap(status: int, payload: Any, request_id: Optional[str], retry_after: Optional[int]) -> Any:
70
+ """Return the ``data`` object on success, or raise the mapped exception.
71
+
72
+ ``payload`` is the already-decoded JSON body, or ``None`` when the body was
73
+ absent or not JSON (which is itself an error for this API).
74
+ """
75
+ if 200 <= status < 300:
76
+ if isinstance(payload, dict) and "data" in payload:
77
+ return payload["data"]
78
+ # A 2xx without the envelope should not happen; surface it rather than
79
+ # hand back a shape the models cannot parse.
80
+ raise PitchAPIError(
81
+ "malformed success response: missing 'data' envelope",
82
+ status_code=status,
83
+ request_id=request_id,
84
+ )
85
+
86
+ code: Optional[str] = None
87
+ message = f"HTTP {status}"
88
+ if isinstance(payload, dict):
89
+ err = payload.get("error")
90
+ if isinstance(err, dict):
91
+ code = err.get("code")
92
+ message = err.get("message", message)
93
+
94
+ exc_cls = exception_for_code(code, status)
95
+ if exc_cls is RateLimitError:
96
+ raise RateLimitError(
97
+ message, retry_after=retry_after, code=code, status_code=status, request_id=request_id
98
+ )
99
+ if exc_cls is ServerError and code is None:
100
+ message = f"server error (HTTP {status})"
101
+ raise exc_cls(message, code=code, status_code=status, request_id=request_id)