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.
@@ -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,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
- classifiers = [
25
- "Programming Language :: Python :: 3.12",
26
- "License :: OSI Approved :: MIT License",
27
- "Operating System :: OS Independent",
19
+ [project.optional-dependencies]
20
+ reporting = [
21
+ "duckdb>=1.4.3",
22
+ "numpy>=1.26.4",
28
23
  ]
29
24
 
30
- urls."Homepage" = "https://github.com/vmatei2/pyrox-client"
31
- 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"
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
- "*.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",
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
- mask = df["season"].eq(int(season)) & df["location"].str.casefold().eq(
215
- location.casefold()
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 RaceNotFound(
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 (RaceNotFound, FileNotFoundError) as e:
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
-