pyrox-client 0.0.2__tar.gz → 0.0.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.0.4/.gitignore +3 -0
- pyrox_client-0.0.4/PKG-INFO +30 -0
- pyrox_client-0.0.4/README.md +13 -0
- {pyrox_client-0.0.2 → pyrox_client-0.0.4}/pyproject.toml +5 -2
- {pyrox_client-0.0.2 → pyrox_client-0.0.4}/src/pyrox/__init__.py +2 -1
- {pyrox_client-0.0.2 → pyrox_client-0.0.4}/src/pyrox/config.py +0 -1
- pyrox_client-0.0.4/src/pyrox/core.py +74 -0
- {pyrox_client-0.0.2 → pyrox_client-0.0.4}/src/pyrox/manifest.py +10 -9
- pyrox_client-0.0.2/PKG-INFO +0 -19
- pyrox_client-0.0.2/README.md +0 -2
- pyrox_client-0.0.2/src/pyrox/core.py +0 -102
- {pyrox_client-0.0.2 → pyrox_client-0.0.4}/src/pyrox/errors.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrox-client
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: HYROX race data client – retrieve full race results via Python
|
|
5
|
+
Author: Vlad Matei
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: fastparquet>=2024.5.0
|
|
9
|
+
Requires-Dist: fsspec>=2024.2.0
|
|
10
|
+
Requires-Dist: pandas>=2.2
|
|
11
|
+
Requires-Dist: platformdirs>=4.2.0
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
Requires-Dist: s3fs>=2024.2.0
|
|
14
|
+
Provides-Extra: duckdb
|
|
15
|
+
Requires-Dist: duckdb>=1.0.0; extra == 'duckdb'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
Package to retrieve Hyrox race data programmatically
|
|
19
|
+
|
|
20
|
+
At the moment, only 3 endpoints -- examples below
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
```commandline
|
|
25
|
+
s6races_info = list_races(season=6) # list available races of season 6
|
|
26
|
+
all_races_info = list_races() # without passing in a season parameter - see all available races in the data warehouse
|
|
27
|
+
s6races_subset = get_season(season=6, locations=['london', 'hamburg']) # get a subset of races from a specified season
|
|
28
|
+
london_season6 = get_race(season=6, location="london") # get a specific race
|
|
29
|
+
hamburg_season6 = get_race(season=6, location="hamburg", division="open", gender="m") # get a specific race, with extra-filtering applied
|
|
30
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Package to retrieve Hyrox race data programmatically
|
|
2
|
+
|
|
3
|
+
At the moment, only 3 endpoints -- examples below
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
```commandline
|
|
8
|
+
s6races_info = list_races(season=6) # list available races of season 6
|
|
9
|
+
all_races_info = list_races() # without passing in a season parameter - see all available races in the data warehouse
|
|
10
|
+
s6races_subset = get_season(season=6, locations=['london', 'hamburg']) # get a subset of races from a specified season
|
|
11
|
+
london_season6 = get_race(season=6, location="london") # get a specific race
|
|
12
|
+
hamburg_season6 = get_race(season=6, location="hamburg", division="open", gender="m") # get a specific race, with extra-filtering applied
|
|
13
|
+
```
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling>=1.20"]
|
|
2
|
+
requires = ["hatchling>=1.20", "hatch-vcs>=0.4"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyrox-client"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "HYROX race data client – retrieve full race results via Python"
|
|
9
9
|
authors = [{ name = "Vlad Matei" }]
|
|
10
10
|
readme = "README.md"
|
|
@@ -34,3 +34,6 @@ include = [
|
|
|
34
34
|
"README.md",
|
|
35
35
|
"pyproject.toml"
|
|
36
36
|
]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
path = "src/pyrox/__init__.py"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
3
|
+
from typing import Iterable, Optional
|
|
4
|
+
import os
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
DEFAULT_API_URL = "https://pyrox-api-proud-surf-3131.fly.dev"
|
|
9
|
+
DEFAULT_API_KEY = os.getenv("PYROX_API_KEY") # optional for now as not used on api side
|
|
10
|
+
# DEFAULT_API_URL = "http://localhost:8000" --> used for testing when running api in docker container
|
|
11
|
+
class ApiError(RuntimeError): ...
|
|
12
|
+
class RaceNotFound(RuntimeError): ...
|
|
13
|
+
|
|
14
|
+
def _client(base_url: str | None = None, api_key: str | None = None) -> httpx.Client:
|
|
15
|
+
headers = {}
|
|
16
|
+
if api_key:
|
|
17
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
18
|
+
return httpx.Client(base_url=base_url or DEFAULT_API_URL, headers=headers, timeout=30)
|
|
19
|
+
|
|
20
|
+
def list_races(season: int | None = None, base_url: str | None = None, api_key: str | None = DEFAULT_API_KEY) -> pd.DataFrame:
|
|
21
|
+
with _client(base_url, api_key) as c:
|
|
22
|
+
r = c.get("/v1/manifest")
|
|
23
|
+
if r.status_code != 200:
|
|
24
|
+
raise ApiError(f"manifest failed: {r.status_code} {r.text}")
|
|
25
|
+
rows = r.json() # [{season, location, path}, ...]
|
|
26
|
+
df = pd.DataFrame(rows)
|
|
27
|
+
if season is not None:
|
|
28
|
+
df = df[df["season"] == int(season)]
|
|
29
|
+
return df[["season", "location"]].drop_duplicates().sort_values(["season", "location"]).reset_index(drop=True)
|
|
30
|
+
|
|
31
|
+
def get_race(*, season: int, location: str, gender: Optional[str] = None, division: Optional[str] = None,
|
|
32
|
+
base_url: str | None = None, api_key: str | None = DEFAULT_API_KEY) -> pd.DataFrame:
|
|
33
|
+
params = {}
|
|
34
|
+
if gender: params["gender"] = gender
|
|
35
|
+
if division: params["division"] = division
|
|
36
|
+
with _client(base_url, api_key) as c:
|
|
37
|
+
r = c.get(f"/v1/race/{int(season)}/{location}", params=params)
|
|
38
|
+
if r.status_code == 404:
|
|
39
|
+
raise RaceNotFound(f"No race found for season={season}, location='{location}'")
|
|
40
|
+
if r.status_code != 200:
|
|
41
|
+
raise ApiError(f"race fetch failed: {r.status_code} {r.text}")
|
|
42
|
+
rows = r.json() # list[dict]
|
|
43
|
+
df = pd.DataFrame(rows)
|
|
44
|
+
return df
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_season(season: int, locations: Optional[Iterable[str]] = None,
|
|
48
|
+
max_workers: int = 8, base_url: str | None = None, api_key: str | None = DEFAULT_API_KEY,
|
|
49
|
+
gender: Optional[str] = None, division: Optional[str] = None) -> pd.DataFrame:
|
|
50
|
+
# discover available races first (avoids 404 spam)
|
|
51
|
+
m = list_races(season=season, base_url=base_url, api_key=api_key)
|
|
52
|
+
if locations:
|
|
53
|
+
want = {loc.casefold() for loc in locations}
|
|
54
|
+
m = m[m["location"].str.casefold().isin(want)]
|
|
55
|
+
if m.empty:
|
|
56
|
+
raise RaceNotFound(f"No races found for season={season}")
|
|
57
|
+
# function to get one data frame for a specifc race
|
|
58
|
+
def _one(loc: str) -> pd.DataFrame:
|
|
59
|
+
return get_race(season=season, location=loc, gender=gender, division=division, base_url=base_url, api_key=api_key)
|
|
60
|
+
|
|
61
|
+
frames: list[pd.DataFrame] = []
|
|
62
|
+
# split the work to get the dataframe across multiple workers
|
|
63
|
+
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
|
64
|
+
futs = [ex.submit(_one, loc) for loc in m["location"].tolist()]
|
|
65
|
+
for f in as_completed(futs):
|
|
66
|
+
frames.append(f.result())
|
|
67
|
+
# join and return the data!
|
|
68
|
+
if not frames:
|
|
69
|
+
return pd.DataFrame()
|
|
70
|
+
out = pd.concat(frames, ignore_index=True)
|
|
71
|
+
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
@@ -7,14 +7,15 @@ from pathlib import Path
|
|
|
7
7
|
import fsspec
|
|
8
8
|
import pandas as pd
|
|
9
9
|
|
|
10
|
-
from .config import get_config
|
|
11
|
-
from .errors import ManifestUnavailable
|
|
12
|
-
|
|
10
|
+
from pyrox.config import get_config
|
|
11
|
+
from pyrox.errors import ManifestUnavailable
|
|
12
|
+
import logging
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
logging.basicConfig(level=logging.INFO)
|
|
13
15
|
|
|
14
16
|
def _s3_open(path: str, mode: str = "rb", anon: bool = True):
|
|
15
17
|
"""
|
|
16
18
|
Unified opener for S3 (via fsspec/s3fs).
|
|
17
|
-
|
|
18
19
|
Parameters
|
|
19
20
|
----------
|
|
20
21
|
path : str
|
|
@@ -23,7 +24,6 @@ def _s3_open(path: str, mode: str = "rb", anon: bool = True):
|
|
|
23
24
|
File mode, usually "rb" for binary reads.
|
|
24
25
|
anon : bool
|
|
25
26
|
True => anonymous/public read (no AWS creds required).
|
|
26
|
-
|
|
27
27
|
Returns
|
|
28
28
|
-------
|
|
29
29
|
contextmanager
|
|
@@ -36,7 +36,6 @@ def _s3_open(path: str, mode: str = "rb", anon: bool = True):
|
|
|
36
36
|
def _cache_paths(name: str) -> Tuple[Path, Path]:
|
|
37
37
|
"""
|
|
38
38
|
Compute cache locations for the manifest CSV and its ETag sidecar.
|
|
39
|
-
|
|
40
39
|
We cache:
|
|
41
40
|
- the CSV bytes as ~/.cache/pyrox/manifest.latest.csv
|
|
42
41
|
- the ETag string as ~/.cache/pyrox/manifest.latest.csv.etag
|
|
@@ -122,8 +121,10 @@ def load_manifest(refresh: bool = True) -> pd.DataFrame:
|
|
|
122
121
|
# Some backends wrap ETag in quotes; we keep it as-is because
|
|
123
122
|
# we compare exact strings on both sides.
|
|
124
123
|
current_etag = info.get("ETag")
|
|
125
|
-
except Exception:
|
|
124
|
+
except Exception as e:
|
|
126
125
|
# HEAD can fail (e.g., transient network); we'll fall back to cache or GET.
|
|
126
|
+
|
|
127
|
+
logger.info(f"Have not been able to retrieve head_s3 from the manifest uri: {s3_manifest_uri}. Encountered exception: {e}. Setting etag as none and looking for cache.")
|
|
127
128
|
current_etag = None
|
|
128
129
|
|
|
129
130
|
# 2) If we have a cached file and either:
|
|
@@ -135,7 +136,6 @@ def load_manifest(refresh: bool = True) -> pd.DataFrame:
|
|
|
135
136
|
try:
|
|
136
137
|
return pd.read_csv(cache_file)
|
|
137
138
|
except Exception:
|
|
138
|
-
# Corrupted cache? Continue to re-download below.
|
|
139
139
|
pass
|
|
140
140
|
|
|
141
141
|
# 3) Re-download from S3 and update cache.
|
|
@@ -164,7 +164,6 @@ def load_manifest(refresh: bool = True) -> pd.DataFrame:
|
|
|
164
164
|
return df
|
|
165
165
|
|
|
166
166
|
except Exception as e:
|
|
167
|
-
# 4) If network failed, try to limp with the existing cache (even if ETag mismatch).
|
|
168
167
|
if cache_file.exists():
|
|
169
168
|
try:
|
|
170
169
|
return pd.read_csv(cache_file)
|
|
@@ -172,3 +171,5 @@ def load_manifest(refresh: bool = True) -> pd.DataFrame:
|
|
|
172
171
|
pass
|
|
173
172
|
# No working cache → bail with a clear error
|
|
174
173
|
raise ManifestUnavailable(f"Could not load manifest from {s3_manifest_uri}: {e}")
|
|
174
|
+
|
|
175
|
+
|
pyrox_client-0.0.2/PKG-INFO
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pyrox-client
|
|
3
|
-
Version: 0.0.2
|
|
4
|
-
Summary: HYROX race data client – retrieve full race results via Python
|
|
5
|
-
Author: Vlad Matei
|
|
6
|
-
License: MIT
|
|
7
|
-
Requires-Python: >=3.9
|
|
8
|
-
Requires-Dist: fastparquet>=2024.5.0
|
|
9
|
-
Requires-Dist: fsspec>=2024.2.0
|
|
10
|
-
Requires-Dist: pandas>=2.2
|
|
11
|
-
Requires-Dist: platformdirs>=4.2.0
|
|
12
|
-
Requires-Dist: requests>=2.31
|
|
13
|
-
Requires-Dist: s3fs>=2024.2.0
|
|
14
|
-
Provides-Extra: duckdb
|
|
15
|
-
Requires-Dist: duckdb>=1.0.0; extra == 'duckdb'
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
|
|
18
|
-
Package to retrieve Hyrox race data programatically.
|
|
19
|
-
|
pyrox_client-0.0.2/README.md
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import io
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
import pandas as pd
|
|
6
|
-
|
|
7
|
-
from .manifest import load_manifest, _s3_open # re-use your manifest loader + fsspec opener
|
|
8
|
-
from .config import get_config
|
|
9
|
-
from .errors import RaceNotFound, ParquetReadError
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def list_races(season: int | None = None) -> pd.DataFrame:
|
|
13
|
-
"""
|
|
14
|
-
Return available (season, location) pairs from the manifest.
|
|
15
|
-
"""
|
|
16
|
-
df = load_manifest(refresh=True)
|
|
17
|
-
if season is not None:
|
|
18
|
-
try:
|
|
19
|
-
df = df[df["season"] == int(season)]
|
|
20
|
-
except ValueError as e:
|
|
21
|
-
raise (ValueError(f"Input {season} is not of expected type. Please make sure you request an integer."))
|
|
22
|
-
return (
|
|
23
|
-
df[["season", "location"]]
|
|
24
|
-
.drop_duplicates()
|
|
25
|
-
.sort_values(["season", "location"])
|
|
26
|
-
.reset_index(drop=True)
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _local_parquet_cache_path(s3_key: str) -> Path:
|
|
31
|
-
"""
|
|
32
|
-
Map an S3 key to a deterministic local cache path under ~/.cache/pyrox/parquet/.
|
|
33
|
-
"""
|
|
34
|
-
cfg = get_config()
|
|
35
|
-
p = cfg.cache_dir / "parquet" / s3_key.replace("/", "__")
|
|
36
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
-
return p
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _read_parquet_from_s3(s3_uri: str) -> pd.DataFrame:
|
|
41
|
-
"""
|
|
42
|
-
Download the parquet bytes anonymously from S3 and read with fastparquet.
|
|
43
|
-
"""
|
|
44
|
-
try:
|
|
45
|
-
with _s3_open(s3_uri, "rb", anon=True) as f:
|
|
46
|
-
data = f.read()
|
|
47
|
-
return pd.read_parquet(io.BytesIO(data), engine="fastparquet")
|
|
48
|
-
except Exception as e:
|
|
49
|
-
raise ParquetReadError(f"Failed to read parquet from {s3_uri}: {e}")
|
|
50
|
-
# core.py
|
|
51
|
-
def _to_s3_uri(bucket: str, key: str) -> str:
|
|
52
|
-
"""Join bucket + key safely. If key is already a full s3:// URI, use it as-is."""
|
|
53
|
-
key = key.strip()
|
|
54
|
-
if key.startswith("s3://"):
|
|
55
|
-
return key
|
|
56
|
-
# allow bucket to be "s3://bucket" or just "bucket"
|
|
57
|
-
if not bucket.startswith("s3://"):
|
|
58
|
-
bucket = f"s3://{bucket}"
|
|
59
|
-
return f"{bucket.rstrip('/')}/{key.lstrip('/')}"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def get_race(*, season: int, location: str) -> pd.DataFrame:
|
|
63
|
-
"""
|
|
64
|
-
Resolve (season, location) -> s3_key via the manifest, then return the race DataFrame.
|
|
65
|
-
|
|
66
|
-
Uses a simple local file cache. If the cached parquet fails to read,
|
|
67
|
-
it is discarded and we re-fetch from S3.
|
|
68
|
-
"""
|
|
69
|
-
manifest = load_manifest(refresh=True)
|
|
70
|
-
|
|
71
|
-
# case-insensitive match on location
|
|
72
|
-
row = manifest[
|
|
73
|
-
(manifest["season"] == int(season)) &
|
|
74
|
-
(manifest["location"].str.casefold() == location.casefold())
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
if row.empty:
|
|
78
|
-
raise RaceNotFound(f"No race found for season={season}, location='{location}'. "
|
|
79
|
-
f"Try: pyrox.list_races({season})")
|
|
80
|
-
|
|
81
|
-
s3_key = row.iloc[0]["path"]
|
|
82
|
-
cfg = get_config()
|
|
83
|
-
s3_uri = _to_s3_uri(cfg.bucket, s3_key)
|
|
84
|
-
|
|
85
|
-
# check local cache first
|
|
86
|
-
local_path = _local_parquet_cache_path(s3_key)
|
|
87
|
-
if local_path.exists():
|
|
88
|
-
try:
|
|
89
|
-
return pd.read_parquet(local_path, engine="fastparquet")
|
|
90
|
-
except Exception:
|
|
91
|
-
local_path.unlink(missing_ok=True) # bad cache → remove and refetch
|
|
92
|
-
|
|
93
|
-
# fetch from S3 and cache
|
|
94
|
-
df = _read_parquet_from_s3(s3_uri)
|
|
95
|
-
try:
|
|
96
|
-
df.to_parquet(local_path, index=False, engine="fastparquet")
|
|
97
|
-
except Exception:
|
|
98
|
-
# cache failures should not fail the API
|
|
99
|
-
pass
|
|
100
|
-
return df
|
|
101
|
-
|
|
102
|
-
breakheere = 0
|
|
File without changes
|