pyrox-client 0.2.2__tar.gz → 0.2.4__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 (28) hide show
  1. pyrox_client-0.2.4/.gitignore +25 -0
  2. pyrox_client-0.2.4/PKG-INFO +152 -0
  3. pyrox_client-0.2.4/README.md +132 -0
  4. {pyrox_client-0.2.2 → pyrox_client-0.2.4}/pyproject.toml +49 -12
  5. pyrox_client-0.2.4/src/pyrox/__init__.py +14 -0
  6. {pyrox_client-0.2.2 → pyrox_client-0.2.4}/src/pyrox/core.py +148 -14
  7. {pyrox_client-0.2.2 → pyrox_client-0.2.4}/src/pyrox/errors.py +23 -1
  8. pyrox_client-0.2.4/src/pyrox/reporting.py +1176 -0
  9. pyrox_client-0.2.2/.gitignore +0 -5
  10. pyrox_client-0.2.2/.venv-pyrox-test/lib/python3.13/site-packages/pandas/pyproject.toml +0 -819
  11. pyrox_client-0.2.2/.venv-pyrox-test/lib/python3.13/site-packages/pyarrow/tests/data/orc/README.md +0 -22
  12. pyrox_client-0.2.2/PKG-INFO +0 -236
  13. pyrox_client-0.2.2/README.md +0 -215
  14. pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_Dallas.png +0 -0
  15. pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_Hamburg.png +0 -0
  16. pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_London.png +0 -0
  17. pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_Paris.png +0 -0
  18. pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Barcelona.png +0 -0
  19. pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Berlin.png +0 -0
  20. pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Glasgow.png +0 -0
  21. pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Johannesburg.png +0 -0
  22. pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Malaga.png +0 -0
  23. pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Shanghai.png +0 -0
  24. pyrox_client-0.2.2/img.png +0 -0
  25. pyrox_client-0.2.2/img_1.png +0 -0
  26. pyrox_client-0.2.2/src/pyrox/__init__.py +0 -14
  27. pyrox_client-0.2.2/src/pyrox/helpers.py +0 -0
  28. {pyrox_client-0.2.2 → pyrox_client-0.2.4}/src/pyrox/constants.py +0 -0
@@ -0,0 +1,25 @@
1
+ *.pyc
2
+
3
+ /src/pyrox/__pycache__/
4
+ __pycache__/
5
+
6
+ site/
7
+ dist/
8
+
9
+ .env
10
+
11
+ .vscode/
12
+
13
+ .idea/
14
+
15
+ docs/issues/
16
+
17
+ ui/.env.capacitor
18
+ ui/.env
19
+ ui/node_modules/
20
+ ui/ios/DerivedData/
21
+
22
+ .venv*/
23
+ node_modules/
24
+ pyrox_duckdb
25
+ test.sql
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrox-client
3
+ Version: 0.2.4
4
+ Summary: HYROX race data client – retrieve full race results via Python
5
+ Author: Vlad Matei
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: fsspec>=2024.2.0
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: pandas>=2.3.3
10
+ Requires-Dist: pyarrow>=22.0.0
11
+ Provides-Extra: api
12
+ Requires-Dist: duckdb>=1.4.3; extra == 'api'
13
+ Requires-Dist: fastapi>=0.115.0; extra == 'api'
14
+ Requires-Dist: numpy>=1.26.4; extra == 'api'
15
+ Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'api'
16
+ Provides-Extra: reporting
17
+ Requires-Dist: duckdb>=1.4.3; extra == 'reporting'
18
+ Requires-Dist: numpy>=1.26.4; extra == 'reporting'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pyrox-client
22
+
23
+ Unofficial Python client for HYROX race results.
24
+
25
+ ![Unit Tests](https://github.com/vmatei2/pyrox-client/actions/workflows/tests.yml/badge.svg)
26
+ ![Integration Tests](https://github.com/vmatei2/pyrox-client/actions/workflows/integration.yml/badge.svg)
27
+ [![Docs](https://img.shields.io/badge/docs-mkdocs-0b5d4a)](https://vmatei2.github.io/pyrox-client/)
28
+ [![PyPI - Version](https://img.shields.io/pypi/v/pyrox-client.svg)](https://pypi.org/project/pyrox-client/)
29
+ [![Wheel](https://img.shields.io/pypi/wheel/pyrox-client.svg)](https://pypi.org/project/pyrox-client/)
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv pip install pyrox-client
35
+ ```
36
+
37
+ or
38
+
39
+ ```bash
40
+ pip install pyrox-client
41
+ ```
42
+
43
+ Reporting helpers that read the generated DuckDB database are optional:
44
+
45
+ ```bash
46
+ pip install "pyrox-client[reporting]"
47
+ ```
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ import pyrox
53
+
54
+ client = pyrox.PyroxClient()
55
+
56
+ # Discover races
57
+ races = client.list_races()
58
+ seasons = client.list_seasons()
59
+ locations = client.list_locations(season=8)
60
+ years = client.list_years(season=8, location="london")
61
+
62
+ # Fetch one race
63
+ london = client.get_race(season=7, location="london")
64
+
65
+ # Optional filters
66
+ london_male_open = client.get_race(
67
+ season=7,
68
+ location="london",
69
+ gender="male",
70
+ division="open",
71
+ )
72
+
73
+ # Total time filter in minutes
74
+ sub60 = client.get_race(season=7, location="london", total_time=60)
75
+ range_50_60 = client.get_race(season=7, location="london", total_time=(50, 60))
76
+
77
+ # Athlete lookup in a race
78
+ athlete = client.get_athlete_in_race(
79
+ season=7,
80
+ location="london",
81
+ athlete_name="surname, name",
82
+ )
83
+ ```
84
+
85
+ ## Core API
86
+
87
+ - `list_races(season: int | None = None, force_refresh: bool = False) -> pd.DataFrame`
88
+ - `list_seasons(force_refresh: bool = False) -> list[int]`
89
+ - `list_locations(season: int | None = None, force_refresh: bool = False) -> list[str]`
90
+ - `list_years(season: int | None = None, location: str | None = None, force_refresh: bool = False) -> list[int]`
91
+ - `get_race(season, location, year=None, gender=None, division=None, total_time=None, use_cache=True) -> pd.DataFrame`
92
+ - `get_season(season, locations=None, gender=None, division=None, max_workers=8, use_cache=True) -> pd.DataFrame`
93
+ - `get_athlete_in_race(season, location, athlete_name, year=None, gender=None, division=None, use_cache=True) -> pd.DataFrame`
94
+ - `clear_cache(pattern="*") -> None`
95
+ - `cache_info() -> dict`
96
+
97
+ ## Mistake Recovery
98
+
99
+ `RaceNotFound` includes manifest-backed suggestions when a race cannot be found:
100
+
101
+ ```python
102
+ from pyrox.errors import RaceNotFound
103
+
104
+ try:
105
+ client.get_race(season=8, location="londn")
106
+ except RaceNotFound as exc:
107
+ print(exc)
108
+ print(exc.suggestions)
109
+ ```
110
+
111
+ ## Reporting Helpers
112
+
113
+ The base install keeps the public client lightweight. Install the reporting extra
114
+ before using `ReportingClient`:
115
+
116
+ ```python
117
+ from pyrox.reporting import ReportingClient
118
+ ```
119
+
120
+ ## Documentation
121
+
122
+ - Live docs: https://vmatei2.github.io/pyrox-client/
123
+ - Client API: `docs/api.md`
124
+ - Error model: `docs/errors.md`
125
+ - Filters and usage notes: `docs/filters.md`
126
+
127
+ ## Repository Scope
128
+
129
+ The PyPI package is the `pyrox` client library.
130
+
131
+ This repository also contains a reporting service and UI (`pyrox_api_service/`, `ui/`) used for project workflows. Those are not part of the published `pyrox-client` wheel.
132
+
133
+ Reporting-service contract note:
134
+
135
+ - Athlete profile endpoints (`/api/athletes/profile` and `/api/athletes/{athlete_id}/profile`)
136
+ may include optional `personal_bests[*].percentile` values in `[0, 1]`.
137
+ - `average_times[*].percentile` may also be present with the same semantics.
138
+ - Missing percentile data is non-fatal and returned by omitting the `percentile` key
139
+ for that segment.
140
+ - Profile percentile cohorts are computed against historical results in the same
141
+ division and gender.
142
+
143
+ Maintainer-only operational docs:
144
+
145
+ - `docs/maintainers/README.md`
146
+ - `docs/maintainers/release.md`
147
+ - `docs/maintainers/reporting-service.md`
148
+
149
+ ## Disclaimer
150
+
151
+ Pyrox is an independent project and is not affiliated with, endorsed, or sponsored by HYROX.
152
+ HYROX and related marks are trademarks of their respective owners and are used only for descriptive purposes.
@@ -0,0 +1,132 @@
1
+ # pyrox-client
2
+
3
+ Unofficial Python client for HYROX race results.
4
+
5
+ ![Unit Tests](https://github.com/vmatei2/pyrox-client/actions/workflows/tests.yml/badge.svg)
6
+ ![Integration Tests](https://github.com/vmatei2/pyrox-client/actions/workflows/integration.yml/badge.svg)
7
+ [![Docs](https://img.shields.io/badge/docs-mkdocs-0b5d4a)](https://vmatei2.github.io/pyrox-client/)
8
+ [![PyPI - Version](https://img.shields.io/pypi/v/pyrox-client.svg)](https://pypi.org/project/pyrox-client/)
9
+ [![Wheel](https://img.shields.io/pypi/wheel/pyrox-client.svg)](https://pypi.org/project/pyrox-client/)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv pip install pyrox-client
15
+ ```
16
+
17
+ or
18
+
19
+ ```bash
20
+ pip install pyrox-client
21
+ ```
22
+
23
+ Reporting helpers that read the generated DuckDB database are optional:
24
+
25
+ ```bash
26
+ pip install "pyrox-client[reporting]"
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ import pyrox
33
+
34
+ client = pyrox.PyroxClient()
35
+
36
+ # Discover races
37
+ races = client.list_races()
38
+ seasons = client.list_seasons()
39
+ locations = client.list_locations(season=8)
40
+ years = client.list_years(season=8, location="london")
41
+
42
+ # Fetch one race
43
+ london = client.get_race(season=7, location="london")
44
+
45
+ # Optional filters
46
+ london_male_open = client.get_race(
47
+ season=7,
48
+ location="london",
49
+ gender="male",
50
+ division="open",
51
+ )
52
+
53
+ # Total time filter in minutes
54
+ sub60 = client.get_race(season=7, location="london", total_time=60)
55
+ range_50_60 = client.get_race(season=7, location="london", total_time=(50, 60))
56
+
57
+ # Athlete lookup in a race
58
+ athlete = client.get_athlete_in_race(
59
+ season=7,
60
+ location="london",
61
+ athlete_name="surname, name",
62
+ )
63
+ ```
64
+
65
+ ## Core API
66
+
67
+ - `list_races(season: int | None = None, force_refresh: bool = False) -> pd.DataFrame`
68
+ - `list_seasons(force_refresh: bool = False) -> list[int]`
69
+ - `list_locations(season: int | None = None, force_refresh: bool = False) -> list[str]`
70
+ - `list_years(season: int | None = None, location: str | None = None, force_refresh: bool = False) -> list[int]`
71
+ - `get_race(season, location, year=None, gender=None, division=None, total_time=None, use_cache=True) -> pd.DataFrame`
72
+ - `get_season(season, locations=None, gender=None, division=None, max_workers=8, use_cache=True) -> pd.DataFrame`
73
+ - `get_athlete_in_race(season, location, athlete_name, year=None, gender=None, division=None, use_cache=True) -> pd.DataFrame`
74
+ - `clear_cache(pattern="*") -> None`
75
+ - `cache_info() -> dict`
76
+
77
+ ## Mistake Recovery
78
+
79
+ `RaceNotFound` includes manifest-backed suggestions when a race cannot be found:
80
+
81
+ ```python
82
+ from pyrox.errors import RaceNotFound
83
+
84
+ try:
85
+ client.get_race(season=8, location="londn")
86
+ except RaceNotFound as exc:
87
+ print(exc)
88
+ print(exc.suggestions)
89
+ ```
90
+
91
+ ## Reporting Helpers
92
+
93
+ The base install keeps the public client lightweight. Install the reporting extra
94
+ before using `ReportingClient`:
95
+
96
+ ```python
97
+ from pyrox.reporting import ReportingClient
98
+ ```
99
+
100
+ ## Documentation
101
+
102
+ - Live docs: https://vmatei2.github.io/pyrox-client/
103
+ - Client API: `docs/api.md`
104
+ - Error model: `docs/errors.md`
105
+ - Filters and usage notes: `docs/filters.md`
106
+
107
+ ## Repository Scope
108
+
109
+ The PyPI package is the `pyrox` client library.
110
+
111
+ This repository also contains a reporting service and UI (`pyrox_api_service/`, `ui/`) used for project workflows. Those are not part of the published `pyrox-client` wheel.
112
+
113
+ Reporting-service contract note:
114
+
115
+ - Athlete profile endpoints (`/api/athletes/profile` and `/api/athletes/{athlete_id}/profile`)
116
+ may include optional `personal_bests[*].percentile` values in `[0, 1]`.
117
+ - `average_times[*].percentile` may also be present with the same semantics.
118
+ - Missing percentile data is non-fatal and returned by omitting the `percentile` key
119
+ for that segment.
120
+ - Profile percentile cohorts are computed against historical results in the same
121
+ division and gender.
122
+
123
+ Maintainer-only operational docs:
124
+
125
+ - `docs/maintainers/README.md`
126
+ - `docs/maintainers/release.md`
127
+ - `docs/maintainers/reporting-service.md`
128
+
129
+ ## Disclaimer
130
+
131
+ Pyrox is an independent project and is not affiliated with, endorsed, or sponsored by HYROX.
132
+ HYROX and related marks are trademarks of their respective owners and are used only for descriptive purposes.
@@ -11,33 +11,62 @@ readme = {file="README.md", content-type = "text/markdown" }
11
11
  requires-python = ">=3.12"
12
12
  dependencies = [
13
13
  "pandas>=2.3.3",
14
- "fastparquet>=2024.5.0",
15
14
  "fsspec>=2024.2.0",
16
- "s3fs>=2024.2.0",
17
- "platformdirs>=4.2.0",
18
15
  "httpx>=0.28.1",
19
16
  "pyarrow>=22.0.0", # stable versions with wheels, had issues with latest
20
- "statsmodels>=0.14.6",
21
17
  ]
22
18
 
23
- classifiers = [
24
- "Programming Language :: Python :: 3.12",
25
- "License :: OSI Approved :: MIT License",
26
- "Operating System :: OS Independent",
19
+ [project.optional-dependencies]
20
+ reporting = [
21
+ "duckdb>=1.4.3",
22
+ "numpy>=1.26.4",
27
23
  ]
28
24
 
29
- urls."Homepage" = "https://github.com/vmatei2/pyrox-client"
30
- urls."Issues" = "https://github.com/vmatei2/pyrox-client/issues"
25
+ # Repository-local service dependencies (FastAPI service lives in pyrox_api_service/).
26
+ api = [
27
+ "duckdb>=1.4.3",
28
+ "fastapi>=0.115.0",
29
+ "numpy>=1.26.4",
30
+ "uvicorn[standard]>=0.30.0",
31
+ ]
32
+
33
+ # classifiers = [
34
+ # "Programming Language :: Python :: 3.12",
35
+ # "License :: OSI Approved :: MIT License",
36
+ # "Operating System :: OS Independent",
37
+ # ]
38
+
39
+ # urls."Homepage" = "https://github.com/vmatei2/pyrox-client"
40
+ # urls."Issues" = "https://github.com/vmatei2/pyrox-client/issues"
31
41
 
32
42
  [tool.hatch.build.targets.wheel]
33
43
  packages = ["src/pyrox"]
34
44
 
45
+ exclude = [
46
+ "src/pyrox/api/**",
47
+ "src/pyrox/helpers.py",
48
+ ]
49
+
35
50
  [tool.hatch.build.targets.sdist]
36
51
  include = [
37
- "src/pyrox",
38
52
  "README.md",
39
53
  "pyproject.toml",
40
- "*.png"
54
+ "src/pyrox/**",
55
+ ]
56
+ exclude = [
57
+ "src/pyrox/api/**",
58
+ "src/pyrox/helpers.py",
59
+ "**/__pycache__/**",
60
+ "*.pyc",
61
+ "ui/**",
62
+ "pyrox_api_service/**",
63
+ "scripts/**",
64
+ "docs/**",
65
+ "example_notebooks/**",
66
+ ".venv*/**",
67
+ "node_modules/**",
68
+ "dist/**",
69
+ "pyrox_duckdb",
41
70
  ]
42
71
 
43
72
  [tool.hatch.version]
@@ -46,6 +75,8 @@ path = "src/pyrox/__init__.py"
46
75
  [dependency-groups]
47
76
  dev = [
48
77
  "black>=25.11.0",
78
+ "duckdb>=1.4.3",
79
+ "fastparquet>=2024.5.0",
49
80
  "isort>=6.1.0",
50
81
  "pylint>=3.3.9",
51
82
  "pytest>=8.4.2",
@@ -56,4 +87,10 @@ dev = [
56
87
  "seaborn>=0.13.2",
57
88
  "ipykernel>=7.1.0",
58
89
  "mkdocs>=1.6.1",
90
+ "python-dotenv>=1.0.0",
91
+ "s3fs>=2024.2.0",
92
+ "scikit-learn>=1.8.0",
93
+ "statsmodels>=0.14.6",
94
+ "twine>=6.2.0",
95
+ "bs4>=0.0.2",
59
96
  ]
@@ -0,0 +1,14 @@
1
+ """Public package exports for pyrox-client."""
2
+
3
+ from .core import PyroxClient
4
+ from .errors import AthleteNotFound, PyroxError, RaceNotFound
5
+
6
+ __version__ = "0.2.4"
7
+
8
+
9
+ __all__ = [
10
+ "PyroxClient",
11
+ "PyroxError",
12
+ "RaceNotFound",
13
+ "AthleteNotFound",
14
+ ]
@@ -9,6 +9,7 @@ import io
9
9
  import json
10
10
  import logging
11
11
  import time
12
+ from difflib import get_close_matches
12
13
  from concurrent.futures import ThreadPoolExecutor, as_completed
13
14
  from pathlib import Path
14
15
  from threading import RLock
@@ -195,29 +196,152 @@ class PyroxClient:
195
196
  if season is not None:
196
197
  df = df[df["season"] == int(season)]
197
198
 
198
- return (
199
- df[["season", "location"]]
200
- .drop_duplicates()
201
- .sort_values(["season", "location"])
202
- .reset_index(drop=True)
203
- )
199
+ if "file_last_modified" in df.columns:
200
+ races = (
201
+ df[["season", "location", "file_last_modified"]]
202
+ .groupby(["season", "location"], as_index=False)["file_last_modified"]
203
+ .max()
204
+ )
205
+ else:
206
+ races = df[["season", "location"]].drop_duplicates()
207
+
208
+ return races.sort_values(["season", "location"]).reset_index(drop=True)
209
+
210
+ def list_seasons(self, force_refresh: bool = False) -> list[int]:
211
+ """Return sorted season values available in the manifest."""
212
+ df = self._get_manifest(force_refresh=force_refresh)
213
+ return self._unique_int_values(df, "season")
214
+
215
+ def list_locations(
216
+ self,
217
+ season: Optional[int] = None,
218
+ force_refresh: bool = False,
219
+ ) -> list[str]:
220
+ """Return sorted location values available in the manifest."""
221
+ df = self._get_manifest(force_refresh=force_refresh)
222
+ if season is not None and "season" in df.columns:
223
+ df = df[df["season"].eq(int(season))]
224
+ return self._unique_str_values(df, "location")
225
+
226
+ def list_years(
227
+ self,
228
+ season: Optional[int] = None,
229
+ location: Optional[str] = None,
230
+ force_refresh: bool = False,
231
+ ) -> list[int]:
232
+ """Return sorted calendar years available in the manifest."""
233
+ df = self._get_manifest(force_refresh=force_refresh)
234
+ if season is not None and "season" in df.columns:
235
+ df = df[df["season"].eq(int(season))]
236
+ if location is not None and "location" in df.columns:
237
+ df = df[df["location"].astype(str).str.casefold().eq(location.casefold())]
238
+ return self._unique_int_values(df, "year")
239
+
240
+ @staticmethod
241
+ def _unique_int_values(df: pd.DataFrame, column: str) -> list[int]:
242
+ if column not in df.columns:
243
+ return []
244
+ values = pd.to_numeric(df[column], errors="coerce").dropna()
245
+ return sorted({int(value) for value in values.tolist()})
246
+
247
+ @staticmethod
248
+ def _unique_str_values(df: pd.DataFrame, column: str) -> list[str]:
249
+ if column not in df.columns:
250
+ return []
251
+ values_by_key: dict[str, str] = {}
252
+ for value in df[column].dropna().tolist():
253
+ text = str(value).strip()
254
+ if text:
255
+ values_by_key.setdefault(text.casefold(), text)
256
+ return sorted(values_by_key.values(), key=str.casefold)
204
257
 
205
258
  def _manifest_row(
206
259
  self, season: int, location: str, year: Optional[int] = None
207
260
  ) -> pd.Series:
208
261
  df = self._get_manifest()
209
262
 
210
- mask = df["season"].eq(int(season)) & df["location"].str.casefold().eq(
211
- location.casefold()
212
- )
263
+ season_mask = df["season"].eq(int(season))
264
+ location_mask = df["location"].astype(str).str.casefold().eq(location.casefold())
265
+ mask = season_mask & location_mask
213
266
  if year is not None:
214
267
  mask &= df["year"].eq(int(year))
215
268
  if not mask.any():
216
- raise RaceNotFound(
217
- f"No manifest entry for season={season}, location='{location}'"
218
- )
269
+ raise self._build_manifest_not_found_error(df, season, location, year)
219
270
  return df.loc[mask].iloc[0]
220
271
 
272
+ def _build_manifest_not_found_error(
273
+ self,
274
+ df: pd.DataFrame,
275
+ season: int,
276
+ location: str,
277
+ year: Optional[int],
278
+ ) -> RaceNotFound:
279
+ available_seasons = self._unique_int_values(df, "season")
280
+ season_df = (
281
+ df[df["season"].eq(int(season))]
282
+ if "season" in df.columns
283
+ else df.iloc[0:0]
284
+ )
285
+
286
+ message_parts = [f"No manifest entry for season={season}, location='{location}'"]
287
+ if year is not None:
288
+ message_parts[0] += f", year={year}"
289
+
290
+ if season_df.empty:
291
+ if available_seasons:
292
+ seasons_text = ", ".join(str(value) for value in available_seasons)
293
+ message_parts.append(f"Available seasons: {seasons_text}.")
294
+ return RaceNotFound(
295
+ " ".join(message_parts),
296
+ season=season,
297
+ location=location,
298
+ year=year,
299
+ available_seasons=available_seasons,
300
+ )
301
+
302
+ available_locations = self._unique_str_values(season_df, "location")
303
+ location_lookup = {value.casefold(): value for value in available_locations}
304
+ suggestions = [
305
+ location_lookup[value]
306
+ for value in get_close_matches(
307
+ location.casefold(),
308
+ list(location_lookup),
309
+ n=3,
310
+ cutoff=0.6,
311
+ )
312
+ ]
313
+
314
+ location_df = season_df[
315
+ season_df["location"].astype(str).str.casefold().eq(location.casefold())
316
+ ]
317
+ if location_df.empty:
318
+ if suggestions:
319
+ message_parts.append(f"Did you mean: {', '.join(suggestions)}?")
320
+ return RaceNotFound(
321
+ " ".join(message_parts),
322
+ season=season,
323
+ location=location,
324
+ year=year,
325
+ available_seasons=available_seasons,
326
+ available_locations=available_locations,
327
+ suggestions=suggestions,
328
+ )
329
+
330
+ available_years = self._unique_int_values(location_df, "year")
331
+ if year is not None and available_years:
332
+ years_text = ", ".join(str(value) for value in available_years)
333
+ message_parts.append(f"Available years for {location}: {years_text}.")
334
+
335
+ return RaceNotFound(
336
+ " ".join(message_parts),
337
+ season=season,
338
+ location=location,
339
+ year=year,
340
+ available_seasons=available_seasons,
341
+ available_locations=available_locations,
342
+ available_years=available_years,
343
+ )
344
+
221
345
  def _s3_key_from_uri(self, s3_uri: str) -> str:
222
346
  if s3_uri.startswith("s3://"):
223
347
  ## example output from splitting ['s3:', '', 'hyrox-results', 'processed', 'parquet/season=6/S6_2023_London__JGDMS4JI62E.parquet'] -- so taking 4th (due to cdn settings for bucket)
@@ -250,6 +374,15 @@ class PyroxClient:
250
374
  division: Optional[str] = None,
251
375
  ) -> pd.DataFrame:
252
376
  url = self._cdn_url_from_manifest(season, location, year)
377
+ return self._get_race_from_url(url, gender, division)
378
+
379
+
380
+ def _get_race_from_url(
381
+ self,
382
+ url: str,
383
+ gender: Optional[str] = None,
384
+ division: Optional[str] = None,
385
+ ) -> pd.DataFrame:
253
386
  filters = self._filters_for_race(gender, division)
254
387
  try:
255
388
  with fsspec.open(url, "rb") as f:
@@ -264,6 +397,7 @@ class PyroxClient:
264
397
  except Exception as e:
265
398
  raise FileNotFoundError(f"CDN read failed for {url}: {e}") from e
266
399
 
400
+
267
401
  def get_race(
268
402
  self,
269
403
  season: int,
@@ -332,7 +466,7 @@ class PyroxClient:
332
466
 
333
467
  try:
334
468
  df = self._get_race_from_cdn(season, location, year, gender, division)
335
- except (RaceNotFound, FileNotFoundError) as e:
469
+ except FileNotFoundError as e:
336
470
  raise FileNotFoundError(
337
471
  f"Read failed for season{season}, location={location} - {e}"
338
472
  ) from e
@@ -514,4 +648,4 @@ def mmss_to_minutes(s: pd.Series) -> pd.Series:
514
648
  s = s.astype(str).str.strip()
515
649
  # if it's MM:SS, promote to 0:MM:SS so pandas parses it
516
650
  s = s.where(s.str.count(":") == 2, "0:" + s)
517
- return pd.to_timedelta(s, errors="coerce").dt.total_seconds() / 60.0
651
+ return pd.to_timedelta(s, errors="coerce").dt.total_seconds() / 60.0
@@ -8,6 +8,8 @@ Expose a small, predictable hierarchy so users can:
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from typing import Optional
12
+
11
13
 
12
14
  __all__ = [
13
15
  "PyroxError",
@@ -29,6 +31,27 @@ class RaceNotFound(PyroxError, LookupError):
29
31
  Inherit from LookupError to feel natural in 'lookup' code paths.
30
32
  """
31
33
 
34
+ def __init__(
35
+ self,
36
+ message: str,
37
+ *,
38
+ season: Optional[int] = None,
39
+ location: Optional[str] = None,
40
+ year: Optional[int] = None,
41
+ available_seasons: Optional[list[int]] = None,
42
+ available_locations: Optional[list[str]] = None,
43
+ available_years: Optional[list[int]] = None,
44
+ suggestions: Optional[list[str]] = None,
45
+ ) -> None:
46
+ super().__init__(message)
47
+ self.season = season
48
+ self.location = location
49
+ self.year = year
50
+ self.available_seasons = available_seasons or []
51
+ self.available_locations = available_locations or []
52
+ self.available_years = available_years or []
53
+ self.suggestions = suggestions or []
54
+
32
55
 
33
56
  class AthleteNotFound(PyroxError, LookupError):
34
57
  """
@@ -37,4 +60,3 @@ class AthleteNotFound(PyroxError, LookupError):
37
60
  Inerit from LookupError to feel natural in 'lookup' code paths.
38
61
  """
39
62
 
40
-