Metadata-Version: 2.4
Name: basef1
Version: 0.2.0
Summary: Python client for the Jolpica F1 API (Ergast-compatible JSON)
Project-URL: Homepage, https://github.com/jolpica/jolpica-f1
Project-URL: Documentation, https://github.com/jolpica/jolpica-f1/tree/main/docs
Author: Göktuğ Öcal
License: MIT
License-File: LICENSE
Keywords: api,basef1,ergast,f1,formula1,jolpica
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: docs
Requires-Dist: shibuya; extra == 'docs'
Requires-Dist: sphinx>=7; extra == 'docs'
Description-Content-Type: text/markdown

# BaseF1

Python package **`basef1`** — a small, synchronous client for the **[Jolpica F1 API](https://github.com/jolpica/jolpica-f1)**. The API serves Ergast-compatible JSON at `https://api.jolpi.ca/ergast/f1/` so you can query historical and current Formula 1 data (seasons, races, results, drivers, constructors, standings, laps, pit stops, and more) without hand-crafting URLs for the common cases.

This repository is a **third-party HTTP wrapper**. It is not the Jolpica server itself; see the disclaimer below.

**What’s new in 0.2.0:** usage counters and logging (`usage_snapshot()`, `stats()`, `request_log_maxlen`), richer Sphinx docs (`docs/examples.rst`, `docs/caching.rst`), and package version **0.2.0**.

---

## Disclaimer

- **Not affiliated** with the [Jolpica F1](https://github.com/jolpica/jolpica-f1) project, the former Ergast service, Formula 1, the FIA, or any team. This client is an independent convenience library.
- **Data and availability** come from `api.jolpi.ca`. Accuracy, completeness, schema changes, downtime, and [rate limits](https://github.com/jolpica/jolpica-f1/blob/main/docs/rate_limits.md) are controlled by the API operators, not by this package. Do not rely on this client for safety-critical or compliance-critical systems.
- **Trademarks** such as “Formula 1” and related marks belong to their respective owners. This project uses publicly documented HTTP endpoints only.
- **No warranty**: the software is provided “as is” (see [LICENSE](LICENSE)). You are responsible for complying with the API’s terms of use and for respectful request volumes (caching, backoff, and spacing out bulk scripts).

---

## Features

- **Sync HTTP** via [httpx](https://www.python-httpx.org/), with optional custom `httpx.Client` for tests or proxies.
- **Chainable queries** (`F1Query`) that mirror Jolpica’s path-based filters: season, round, circuit, constructor, driver, grid slot, finishing status, then a terminal resource (`races`, `results`, `driver_standings`, etc.).
- **Pagination helpers**: `limit` (1–100) and `offset` validated before requests.
- **`ParsedApiResponse`**: every terminal method returns parsed MRData with **`pagination`**, **`kind`**, and typed **`data`** (`RaceTable`, `SeasonTable`, …, or **`RawTable`** for unknown tables). Use **`ApiResponse`** only when you construct JSON yourself and call **`parse_api_response()`**.
- **Typed domain models** (`basef1.domain`): dataclass hierarchy rooted at **`JolpicaModel`** with **`to_dict()`**; nested **`Race`** graph (results, qualifying rows, laps, pit stops, sprint results, sessions).
- **Response cache**: **in-memory TTL by default** (`TTLCache`, 5-minute TTL, 256 entries) or **persistent SQLite** (`cache_backend="sqlite"`). Identical GETs reuse cached JSON and stay under rate limits. Pass **`cache=False`** to disable, or **`cache_ttl=`** / **`cache=`** to customize (see below).
- **Usage observability**: **`usage_snapshot()`** for logical vs HTTP request counts and cache hits; **`stats()`** for a printable summary; **`request_log_maxlen`** bounds the in-memory request log.
- **Explicit errors** via `JolpicaHTTPError` for non-2xx responses and malformed JSON.
- **Runnable examples** under [`examples/`](examples/) against the live API (use sparingly to avoid throttling).

---

## Requirements

- Python **3.10+**
- **httpx** (installed automatically with the package)

---

## Installation

From a clone of this repository:

```bash
pip install -e .
```

For local development (tests and Ruff):

```bash
pip install -e ".[dev]"
```

---

## Quick start

```python
from basef1 import BaseF1Client
from basef1.domain.tables import SeasonTable

with BaseF1Client() as client:
    envelope = client.get_seasons(limit=100)
    assert isinstance(envelope.data, SeasonTable)
    print(envelope.pagination.total_int, len(envelope.data.seasons))
    print(envelope.data.seasons[0].season)
```

Always prefer a **context manager** (`with BaseF1Client() as client:`) so the internal `httpx.Client` is closed when you are done. If you construct `BaseF1Client()` without `with`, call `client.close()` when finished (unless you passed your own `httpx.Client` and manage its lifecycle yourself).

---

## Core concepts

### Base URL and client

`BaseF1Client` defaults to `https://api.jolpi.ca/ergast/f1`. Override with `base_url=...` for mirrors or tests. `timeout` is forwarded to httpx when the library creates the client. **HTTP responses are cached in memory by default** (see Request caching); use **`cache=False`** when you need every request to hit the network (for example some tests).

### `query()` and path filters

`client.query()` returns a fresh **`F1Query`**. Fluent methods append URL segments; **order matters** and must follow the [Jolpica / Ergast MRD](http://ergast.com/mrd/) style paths (same as calling the REST API manually).

| Method | Path effect |
|--------|----------------|
| `season(year)` | `/{year}/` or `current` |
| `round(n)` | `/{round}/` — must come **immediately after** `season(...)` (year or `current`) |
| `circuit(id)` | `/circuits/{id}/` |
| `constructor(id)` | `/constructors/{id}/` |
| `driver(id)` | `/drivers/{id}/` |
| `grid(position)` | `/grid/{position}/` |
| `with_status(id)` | `/status/{id}/` (finishing status filter; avoids clashing with the `get_statuses()` resource) |

### Terminal resources (one GET each)

Call these at the end of the chain (each returns a `ParsedApiResponse`):

`seasons`, `races`, `results`, `qualifying`, `sprint`, `drivers`, `constructors`, `circuits`, `driver_standings`, `constructor_standings`, `laps`, `pitstops`, `statuses`.

**Shorthand on the client:** `client.get_seasons()` and `client.get_statuses()` are equivalent to `client.query().get_seasons()` and `client.query().get_statuses()`.

### Pagination

Every terminal accepts `limit=` and `offset=`. The API defaults to 30 rows per page; maximum `limit` is **100**. Invalid values raise `ValueError` before any network I/O.

### `ParsedApiResponse` (terminal methods)

| Member | Purpose |
|--------|---------|
| `.kind` | MRData table key (e.g. `RaceTable`) or `None` if missing |
| `.pagination` | `Pagination` with `limit`, `offset`, `total` (strings from API) and `total_int` where parseable |
| `.data` | Typed table model (`RaceTable`, `SeasonTable`, …) or **`RawTable`** when unknown |

### `ApiResponse` (raw JSON wrapper)

If you already have a Jolpica-shaped **`dict`**, wrap it with **`ApiResponse(raw=payload)`** and call **`parse()`** or **`parse_api_response(...)`** to obtain a **`ParsedApiResponse`**. For normal client usage you rarely construct **`ApiResponse`** yourself.

| Member | Purpose |
|--------|---------|
| `.raw` | Full parsed JSON `dict` |
| `.mrdata` | The `MRData` object (meta + one `*Table` data) |
| `.pagination` | Same as above |
| `.get_data()` | Primary data table **`dict`** under MRData (priority + `*Table` fallback); `{}` if none |
| `.parse()` | Builds **`ParsedApiResponse`** |

Exported constants for advanced use: `MRDATA_METADATA_KEYS`, `MRDATA_TABLE_KEYS`, `MRDATA_TABLE_PRIORITY`.

### Typed models and serialization

Terminal methods already return **`ParsedApiResponse`** — inspect **`envelope.kind`** and narrow **`envelope.data`** (for example `RaceTable`, `SeasonTable`, `StandingsTable`). Nested objects (`Driver`, `Circuit`, `RaceResult`, …) subclass **`JolpicaModel`** and implement **`to_dict()`** (omit `None` fields; JSON keys use Jolpica camelCase via dataclass field metadata where needed).

Typed models mirror documented fields; new API properties may require library updates.

#### Model taxonomy (MRData table to Python types)

| MRData `*Table` key | data class | Main row / nested types |
|---------------------|---------------|-------------------------|
| `RaceTable` | `RaceTable` | `races: list[Race]`; each `Race` has `circuit: Circuit | None`, session slots (`Session`), `results: list[RaceResult]`, `qualifying_results`, `laps`, `pit_stops` |
| | | `RaceResult` → `Driver`, `Constructor`, `TimeElement`, `FastestLap` → `AverageSpeed` |
| | | `QualifyingResult` → `Driver`, `Constructor`; `LapRow` → `LapTimingEntry`; `PitStopRow` |
| `SeasonTable` | `SeasonTable` | `seasons: list[Season]` |
| `DriverTable` | `DriverTable` | `drivers: list[Driver]` |
| `ConstructorTable` | `ConstructorTable` | `constructors: list[Constructor]` |
| `CircuitTable` | `CircuitTable` | `circuits: list[Circuit]`; each `Circuit` has `location: Location | None` |
| `StatusTable` | `StatusTable` | `statuses: list[StatusRow]` |
| `StandingsTable` | `StandingsTable` | `standings_lists: list[StandingsList]` → `driver_standings` / `constructor_standings` rows with nested `Driver` / `Constructor` |

Import concrete types from **`basef1.domain`** or the package root: `from basef1 import Race, Circuit, Location`.

### Request caching

By default the client keeps an in-memory **`TTLCache`** (300 second TTL via **`DEFAULT_CACHE_TTL_SECONDS`**, 256 entries, LRU eviction). Duplicate **same URL and query parameters** within the TTL window only performs **one** HTTP GET.

Disable caching entirely:

```python
with BaseF1Client(cache=False) as client:
    ...
```

Persistent local cache (SQLite, default file `~/.cache/basef1/http_cache.sqlite`):

```python
with BaseF1Client(cache_backend="sqlite") as client:
  ...

with BaseF1Client(
    cache_backend="sqlite",
    cache_path="/tmp/f1_cache.sqlite",
    cache_ttl=600.0,
) as client:
    ...
```

Customize in-memory TTL or inject your own cache (do not pass both `cache=` and `cache_ttl=`):

```python
from basef1 import BaseF1Client

with BaseF1Client(cache_ttl=600.0, cache_max_entries=512) as client:
    client.get_seasons(limit=30, offset=0)
    client.get_seasons(limit=30, offset=0)  # cache hit if within TTL

from basef1 import SQLiteCache, TTLCache

with BaseF1Client(cache=SQLiteCache(path="/tmp/f1.sqlite", ttl_seconds=300.0)) as client:
    ...

cache = TTLCache(max_entries=64, ttl_seconds=120.0)
with BaseF1Client(cache=cache) as client:
    ...
```

Caching stores a deep copy of JSON payloads inside the cache backend; your **`ParsedApiResponse`** models are separate parsed views.

### Usage statistics

- **`usage_snapshot()`** — returns logical vs HTTP GET counts, cache hits, whether caching is enabled, and a **`request_log`** tuple (`RequestLogEntry`: method, URL, query params, `cache` vs `network`).
- **`stats()`** — prints a human-readable summary (including approximate cache hit rate and up to 10 recent log lines). Pass **`file=`** to redirect output (for example ``io.StringIO()``).
- **`request_log_maxlen`** — caps how many requests are retained (default **`512`**); use **`None`** for unlimited.

```python
with BaseF1Client() as client:
    client.get_seasons(limit=30, offset=0)
    client.get_seasons(limit=30, offset=0)
    client.stats()
```

### Errors

- **`JolpicaHTTPError`**: HTTP status ≥ 400, or invalid JSON body, or transport failures (see `.status_code` and `.body`).
- **`ValueError`**: invalid `limit` / `offset`, or `round()` used without a valid preceding `season()`.

### Standings and Jolpica-specific rules

Driver and constructor standings require a **season** in the path for Jolpica. Other nuances (e.g. differences vs legacy Ergast) are documented upstream: [Ergast differences](https://github.com/jolpica/jolpica-f1/blob/main/docs/ergast_differences.md).

---

## Use case examples

These snippets assume `from basef1 import BaseF1Client` and `with BaseF1Client() as client:`.

**List every championship season (paginate with offset):**

```python
from basef1.domain.tables import SeasonTable

page = client.get_seasons(limit=100, offset=0)
assert isinstance(page.data, SeasonTable)
season_rows = page.data.seasons
```

**All races in a calendar year:**

```python
from basef1.domain.tables import RaceTable

env = client.query().season(2024).get_races(limit=100)
assert isinstance(env.data, RaceTable)
names = [r.race_name for r in env.data.races]
```

**Full result sheet for one round:**

```python
from basef1.domain.tables import RaceTable

env = client.query().season(2024).round(1).get_results(limit=100)
assert isinstance(env.data, RaceTable)
race = env.data.races[0] if env.data.races else None
results = race.results if race else []
```

**Races at a circuit, or for a constructor:**

```python
monza = client.query().circuit("monza").get_races(limit=50)
ferrari = client.query().constructor("ferrari").get_races(limit=50)
```

**One driver’s results for a season (season before driver in the chain):**

```python
resp = client.query().season(2024).driver("hamilton").get_results(limit=50)
```

**Qualifying and sprint entries for a season:**

```python
q = client.query().season(2024)
quali = q.get_qualifying(limit=100)
sprint = q.get_sprint(limit=100)
```

**Championship tables (season required):**

```python
q = client.query().season(2024)
drivers = q.get_driver_standings(limit=100)
teams = q.get_constructor_standings(limit=100)
```

**Lap timing and pit stops (season + round required):**

```python
q = client.query().season(2024).round(1)
laps = q.get_laps(limit=100)
stops = q.get_pitstops(limit=100)
```

**Finishing status catalogue vs filtering races by status id:**

```python
catalogue = client.get_statuses(limit=100)
filtered = client.query().with_status(1).get_races(limit=50)
```

**“Current” season and “next” / “last” round keywords:**

```python
next_race = client.query().season("current").round("next").get_races(limit=1)
last_results = client.query().season("current").round("last").get_results(limit=20)
```

**Custom `httpx.Client` (testing, retries, proxies):**

```python
import httpx
from basef1 import BaseF1Client

with httpx.Client(timeout=60.0) as http:
    client = BaseF1Client(client=http, cache=False)  # optional: disable cache in tests
    client.query().get_circuits(limit=5)
```

---

## Runnable scripts

The [`examples/`](examples/) directory contains small programs that hit the **live** API. Run them from the repository root after `pip install -e .`. **Do not** hammer all scripts in a tight loop or you may receive HTTP 429; add delays or run selectively.

| Script | Use case |
|--------|----------|
| [examples/01_global_seasons.py](examples/01_global_seasons.py) | Global seasons (`client.get_seasons()`) |
| [examples/02_global_lists.py](examples/02_global_lists.py) | Global races, drivers, constructors, circuits |
| [examples/03_season_races.py](examples/03_season_races.py) | Calendar for one season |
| [examples/04_season_round_results.py](examples/04_season_round_results.py) | Results for season + round |
| [examples/05_season_qualifying_and_sprint.py](examples/05_season_qualifying_and_sprint.py) | Qualifying and sprint |
| [examples/06_circuit_filter_races.py](examples/06_circuit_filter_races.py) | Circuit filter + races |
| [examples/07_constructor_filter_races.py](examples/07_constructor_filter_races.py) | Constructor filter + races |
| [examples/08_driver_season_results.py](examples/08_driver_season_results.py) | Season + driver + results |
| [examples/09_grid_filter_races.py](examples/09_grid_filter_races.py) | Grid slot filter + races |
| [examples/10_status_filter_races.py](examples/10_status_filter_races.py) | `with_status` + races |
| [examples/11_status_catalogue.py](examples/11_status_catalogue.py) | Status catalogue (`get_statuses()`) |
| [examples/12_standings.py](examples/12_standings.py) | Driver and constructor standings |
| [examples/13_laps_and_pitstops.py](examples/13_laps_and_pitstops.py) | Laps and pit stops |
| [examples/14_pagination_offset.py](examples/14_pagination_offset.py) | `limit` / `offset` |
| [examples/15_current_season_next_round.py](examples/15_current_season_next_round.py) | `current`, `next`, `last` |

```bash
python examples/01_global_seasons.py
```

Runnable scripts unpack **`ParsedApiResponse.data`** as typed tables (`SeasonTable`, `RaceTable`, …). See also Sphinx **`docs/examples.rst`** for more patterns.

---

## Documentation (Sphinx)

Build HTML docs locally:

```bash
pip install -e ".[docs]"
sphinx-build -b html docs docs/_build/html
```

Open **`docs/_build/html/index.html`** in a browser. Narrative topics live under **`docs/`** (installation, quick start, examples, caching, changelog).

---

## Development

```bash
pip install -e ".[dev]"
ruff check src tests examples
pytest
```

---

## Further reading

- [Jolpica F1 documentation](https://github.com/jolpica/jolpica-f1/tree/main/docs)
- [Rate limits](https://github.com/jolpica/jolpica-f1/blob/main/docs/rate_limits.md)
- [Differences vs Ergast](https://github.com/jolpica/jolpica-f1/blob/main/docs/ergast_differences.md)

---

## License

[MIT](LICENSE)
