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.
- pyrox_client-0.2.4/.gitignore +25 -0
- pyrox_client-0.2.4/PKG-INFO +152 -0
- pyrox_client-0.2.4/README.md +132 -0
- {pyrox_client-0.2.2 → pyrox_client-0.2.4}/pyproject.toml +49 -12
- pyrox_client-0.2.4/src/pyrox/__init__.py +14 -0
- {pyrox_client-0.2.2 → pyrox_client-0.2.4}/src/pyrox/core.py +148 -14
- {pyrox_client-0.2.2 → pyrox_client-0.2.4}/src/pyrox/errors.py +23 -1
- pyrox_client-0.2.4/src/pyrox/reporting.py +1176 -0
- pyrox_client-0.2.2/.gitignore +0 -5
- pyrox_client-0.2.2/.venv-pyrox-test/lib/python3.13/site-packages/pandas/pyproject.toml +0 -819
- pyrox_client-0.2.2/.venv-pyrox-test/lib/python3.13/site-packages/pyarrow/tests/data/orc/README.md +0 -22
- pyrox_client-0.2.2/PKG-INFO +0 -236
- pyrox_client-0.2.2/README.md +0 -215
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_Dallas.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_Hamburg.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_London.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2024_Paris.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Barcelona.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Berlin.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Glasgow.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Johannesburg.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Malaga.png +0 -0
- pyrox_client-0.2.2/example_notebooks/event_dists/event_2025_Shanghai.png +0 -0
- pyrox_client-0.2.2/img.png +0 -0
- pyrox_client-0.2.2/img_1.png +0 -0
- pyrox_client-0.2.2/src/pyrox/__init__.py +0 -14
- pyrox_client-0.2.2/src/pyrox/helpers.py +0 -0
- {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
|
+

|
|
26
|
+

|
|
27
|
+
[](https://vmatei2.github.io/pyrox-client/)
|
|
28
|
+
[](https://pypi.org/project/pyrox-client/)
|
|
29
|
+
[](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
|
+

|
|
6
|
+

|
|
7
|
+
[](https://vmatei2.github.io/pyrox-client/)
|
|
8
|
+
[](https://pypi.org/project/pyrox-client/)
|
|
9
|
+
[](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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
reporting = [
|
|
21
|
+
"duckdb>=1.4.3",
|
|
22
|
+
"numpy>=1.26.4",
|
|
27
23
|
]
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
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
|
-
|