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.
@@ -0,0 +1,3 @@
1
+ *.pyc
2
+
3
+ /src/pyrox/__pycache__/
@@ -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
- version = "0.0.2"
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"
@@ -1,5 +1,6 @@
1
1
  from .core import get_race, list_races
2
2
  from .config import set_bucket, set_manifest_path, get_config
3
3
 
4
+ __version__ = "0.0.4"
5
+
4
6
  __all__ = ["get_race", "list_races", "set_bucket", "set_manifest_path", "get_config"]
5
- __version__ = "0.0.2"
@@ -21,7 +21,6 @@ _config = PyroxConfig()
21
21
  def set_bucket(uri:str) -> None:
22
22
  _config.bucket = uri.rsplit("/")
23
23
 
24
-
25
24
  def set_manifest_path(path: str) -> None:
26
25
  _config.manifest_path = path
27
26
 
@@ -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
+
@@ -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
-
@@ -1,2 +0,0 @@
1
- Package to retrieve Hyrox race data programatically.
2
-
@@ -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