pyrox-client 0.2.3__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.3 → pyrox_client-0.2.4}/pyproject.toml +49 -13
- pyrox_client-0.2.4/src/pyrox/__init__.py +14 -0
- {pyrox_client-0.2.3 → pyrox_client-0.2.4}/src/pyrox/core.py +127 -7
- {pyrox_client-0.2.3 → 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.3/.gitignore +0 -5
- pyrox_client-0.2.3/PKG-INFO +0 -237
- pyrox_client-0.2.3/README.md +0 -215
- pyrox_client-0.2.3/img.png +0 -0
- pyrox_client-0.2.3/img_1.png +0 -0
- pyrox_client-0.2.3/src/pyrox/__init__.py +0 -14
- pyrox_client-0.2.3/src/pyrox/helpers.py +0 -0
- {pyrox_client-0.2.3 → 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,34 +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
|
-
"twine>=6.2.0",
|
|
22
17
|
]
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
reporting = [
|
|
21
|
+
"duckdb>=1.4.3",
|
|
22
|
+
"numpy>=1.26.4",
|
|
28
23
|
]
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
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"
|
|
32
41
|
|
|
33
42
|
[tool.hatch.build.targets.wheel]
|
|
34
43
|
packages = ["src/pyrox"]
|
|
35
44
|
|
|
45
|
+
exclude = [
|
|
46
|
+
"src/pyrox/api/**",
|
|
47
|
+
"src/pyrox/helpers.py",
|
|
48
|
+
]
|
|
49
|
+
|
|
36
50
|
[tool.hatch.build.targets.sdist]
|
|
37
51
|
include = [
|
|
38
|
-
"src/pyrox",
|
|
39
52
|
"README.md",
|
|
40
53
|
"pyproject.toml",
|
|
41
|
-
"
|
|
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",
|
|
42
70
|
]
|
|
43
71
|
|
|
44
72
|
[tool.hatch.version]
|
|
@@ -47,6 +75,8 @@ path = "src/pyrox/__init__.py"
|
|
|
47
75
|
[dependency-groups]
|
|
48
76
|
dev = [
|
|
49
77
|
"black>=25.11.0",
|
|
78
|
+
"duckdb>=1.4.3",
|
|
79
|
+
"fastparquet>=2024.5.0",
|
|
50
80
|
"isort>=6.1.0",
|
|
51
81
|
"pylint>=3.3.9",
|
|
52
82
|
"pytest>=8.4.2",
|
|
@@ -57,4 +87,10 @@ dev = [
|
|
|
57
87
|
"seaborn>=0.13.2",
|
|
58
88
|
"ipykernel>=7.1.0",
|
|
59
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",
|
|
60
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
|
|
@@ -206,22 +207,141 @@ class PyroxClient:
|
|
|
206
207
|
|
|
207
208
|
return races.sort_values(["season", "location"]).reset_index(drop=True)
|
|
208
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)
|
|
257
|
+
|
|
209
258
|
def _manifest_row(
|
|
210
259
|
self, season: int, location: str, year: Optional[int] = None
|
|
211
260
|
) -> pd.Series:
|
|
212
261
|
df = self._get_manifest()
|
|
213
262
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
217
266
|
if year is not None:
|
|
218
267
|
mask &= df["year"].eq(int(year))
|
|
219
268
|
if not mask.any():
|
|
220
|
-
raise
|
|
221
|
-
f"No manifest entry for season={season}, location='{location}'"
|
|
222
|
-
)
|
|
269
|
+
raise self._build_manifest_not_found_error(df, season, location, year)
|
|
223
270
|
return df.loc[mask].iloc[0]
|
|
224
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
|
+
|
|
225
345
|
def _s3_key_from_uri(self, s3_uri: str) -> str:
|
|
226
346
|
if s3_uri.startswith("s3://"):
|
|
227
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)
|
|
@@ -346,7 +466,7 @@ class PyroxClient:
|
|
|
346
466
|
|
|
347
467
|
try:
|
|
348
468
|
df = self._get_race_from_cdn(season, location, year, gender, division)
|
|
349
|
-
except
|
|
469
|
+
except FileNotFoundError as e:
|
|
350
470
|
raise FileNotFoundError(
|
|
351
471
|
f"Read failed for season{season}, location={location} - {e}"
|
|
352
472
|
) from e
|
|
@@ -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
|
-
|