Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Offline compatibility checks

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.11", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python }}
- run: uv sync --frozen --group dev
- run: uv run python tools/generate_client.py --check
- run: uv run pytest -q
# Keep the byte-frozen runtime and unrelated examples out of formatting.
- run: uv run ruff check tools/generate_client.py tinyercot/catalog.py tests
- run: uv run ruff format --check tools/generate_client.py tinyercot/catalog.py tests
- run: uv build
- run: uv export --frozen --no-dev --no-emit-project --output-file /tmp/tinyercot-requirements.txt
- run: uv venv /tmp/tinyercot-wheel
- run: uv pip sync --python /tmp/tinyercot-wheel/bin/python /tmp/tinyercot-requirements.txt
- run: uv pip install --python /tmp/tinyercot-wheel/bin/python --no-deps dist/*.whl
- run: /tmp/tinyercot-wheel/bin/python -I tests/wheel_smoke.py
140 changes: 32 additions & 108 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,112 +1,36 @@
# CLAUDE.md
# Repository guidance

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
TinyERCOT preserves its legacy public Python API. The opt-in `tinyercot.catalog`
module supplies offline metadata only. Read `docs/public-foundation.md` before
adding current data capabilities.

## Project Overview
## Local checks

**tinyercot** is a fully-typed Python client for the ERCOT Public API. The entire hand-written codebase is ~230 lines—everything else is auto-generated from ERCOT's OpenAPI spec and response field definitions.

## Commands

### Development Environment

Start an IPython session with dev dependencies and environment variables:
```bash
uv run --env-file .env --group dev -- ipython
```

### Code Generation

Regenerate the client from the ERCOT OpenAPI spec:
```bash
uv run python tools/generate_client.py
```

Regenerate with fresh response field data (requires ERCOT credentials):
```bash
uv run --env-file .env python tools/generate_client.py --refresh
```

Generate a pandas-based client (returns DataFrames instead of TypedDicts):
```bash
uv run python tools/generate_client.py --pandas
```

## Architecture

### Core Components

1. **`tinyercot/_client.py`** (~58 lines)
- OAuth 2.0 authentication with ERCOT's B2C endpoint
- Token caching (1-hour TTL) via `cachetools`
- HTTP client with automatic retries via `httpx-retries`
- `_get()` function for authenticated API calls
- `_to_df()` function for pandas DataFrame conversion with proper type coercion

2. **`tools/generate_client.py`** (~238 lines)
- Fetches ERCOT's OpenAPI spec to extract all endpoints and parameters
- Fetches response field schemas from ERCOT's products API (cached in `api_response_fields.json`)
- Generates `tinyercot/_generated.py` with fully-typed classes and methods
- Maps OpenAPI types → Python types for query parameters
- Maps ERCOT dataTypes → Python types for response fields

3. **`tinyercot/_generated.py`** (AUTO-GENERATED)
- One class per EMIL ID (e.g., `np4_190_cd`)
- Each class contains methods for endpoint suffixes
- Each method has:
- `TypedDict` for params (query parameters)
- `TypedDict` for response rows (individual data records)
- `TypedDict` for full response (meta, links, data)
- **Never edit this file manually**—always regenerate via `generate_client.py`

4. **`api_response_fields.json`**
- Cache of response field schemas for all ERCOT endpoints
- Maps `{emil_id}/{suffix}` → `{field_name: dataType}`
- Regenerate with `--refresh` flag (rate-limited to 1 req/sec)

### Code Generation Flow

```
ERCOT OpenAPI Spec → parse_openapi() → endpoints + tags
↓
ERCOT Products API → fetch_response_fields() → api_response_fields.json
↓
generate() → tinyercot/_generated.py
```

### Type Mapping

**Query Parameters** (from OpenAPI schema):
- `string` → `str`
- `integer` → `int`
- `number` → `Decimal`
- `boolean` → `bool`
- `format: "yyyy-MM-dd"` → `datetime.date`
- `format: "yyyy-MM-ddTH24:mm:ss"` → `datetime.datetime`

**Response Fields** (from ERCOT dataType):
- `VARCHAR` → `str`
- `INTEGER` → `int`
- `DOUBLE` → `Decimal`
- `BOOLEAN` → `bool`
- `DATE` → `datetime.date`
- `DATETIME` → `datetime.datetime`
- `TIME` → `datetime.time`

## Environment Variables

Required in `.env`:
```
ERCOT_USERNAME=your-username
ERCOT_PASSWORD=your-password
ERCOT_SUBSCRIPTION_KEY=your-subscription-key
```

Pass to commands via `uv run --env-file .env`.

## Key Design Principles

1. **Minimize hand-written code**: Use code generation to reduce maintenance burden
2. **Full typing**: All responses and parameters are fully typed via `TypedDict`
3. **Smart caching**: Response fields are cached to avoid slow API calls during generation
4. **Flexible output**: Support both raw JSON (`TypedDict`) and pandas `DataFrame` outputs
uv sync --frozen --group dev
uv run python tools/generate_client.py --check
uv run pytest -q
uv run ruff check tools/generate_client.py tinyercot/catalog.py tests
uv run ruff format --check tools/generate_client.py tinyercot/catalog.py tests
uv build
```

Tests use synthetic credentials and mocked transport. Do not load `.env` for
these checks. The generator uses pinned local inputs and needs no credentials.
It has no `--refresh`, `--cache-products`, or `--pandas` option.

## Architecture and compatibility

- `tinyercot/_client.py` contains the legacy auth, transport, and `ErcotResponse`.
- `tinyercot/_generated.py` contains 35 product classes and 102 endpoint families.
Each family has Pydantic Row and Response models plus five static methods.
- `tools/generate_client.py` reproduces the generated file from pinned inputs.
Use `--check` for a comparison or `--output /tmp/legacy.py` for a review copy.
- `api_response_fields.json` is the frozen legacy field cache. It does not verify
current schemas. `products.json` is historical evidence, not a generation input.
- `tinyercot/catalog.py` reads bundled public metadata without data requests.

Do not edit the generated file manually. Keep the three legacy runtime files
unchanged for this milestone. New clients, errors, schema policies, file
adapters, and temporal behavior must use a separate opt-in surface.
Secure, Certified, EWS, and private participant/customer records are excluded.
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
include api_response_fields.json uv.lock
recursive-include tools *.py *.json
recursive-include tests *.py *.json
recursive-include docs *.md
50 changes: 37 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# tinyercot

Fully-typed Python client for the ERCOT Public API.
Python client with 102 legacy typed ERCOT Public API endpoint families.

**Why tiny?** The entire hand-written codebase is ~420 lines. Everything else is auto-generated from ERCOT's OpenAPI spec.
Legacy imports, signatures, and behavior stay fixed. Current server compatibility
is not verified. The opt-in offline catalog describes observed public sources
and access boundaries; it adds no data retrieval.

## Install

Expand Down Expand Up @@ -59,6 +61,7 @@ Async methods automatically rate-limit to 20 req/min with retry on 429:
import asyncio
import tinyercot


async def main():
# Async iterator - rate-limited, non-blocking
async for row in tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_iter_async(
Expand All @@ -71,6 +74,7 @@ async def main():
deliveryDateFrom=date(2025, 12, 29),
)


asyncio.run(main())
```

Expand All @@ -86,22 +90,42 @@ Every endpoint generates 5 methods:
| `endpoint_iter_async()` | `AsyncIterator[Row]` | Stream all pages (async, rate-limited) |
| `endpoint_df_async()` | `DataFrame` | All pages as DataFrame (async, rate-limited) |

## Development
## Offline public catalog

Create a `.env` file with your credentials, then run IPython with dev deps:

```bash
uv run --env-file .env --group dev -- ipython
```python
from tinyercot.catalog import Access, classify_access, operations, sources

# No credentials or network requests are needed for this metadata.
observed = operations(service="public-reports")
assert len(observed) == 249 # 242 data paths and 7 service operations.
assert all(op.support == "metadata_only" for op in observed)
assert classify_access("Certified") is Access.RESTRICTED
boundaries = sources()
```

## Regenerate
The 2026-09-05 snapshot records 243 public data paths across two APIs.
It does not claim typed current coverage. Forty paths lack cached row fields;
all other cached rows remain unverified against current responses.
Secure, Certified, EWS, private participant records, and customer data are
restricted and excluded. Public API data requests still need an ERCOT account.
See [scope, provenance, and adapter boundaries](docs/public-foundation.md).

```bash
uv run python tools/generate_client.py
```
## Development and legacy generation

With fresh response field data (requires credentials):
Generation uses hash-pinned local inputs. It never fetches an upstream URL.

```bash
uv run --env-file .env python tools/generate_client.py --refresh
uv sync --frozen --group dev
uv run python tools/generate_client.py --check
uv run pytest -q
uv run ruff check tools/generate_client.py tinyercot/catalog.py tests
uv run ruff format --check tools/generate_client.py tinyercot/catalog.py tests
uv build
```

To reproduce the legacy file, run `uv run python tools/generate_client.py`.
Use `--output /tmp/legacy.py` to write a review copy. Paths do not depend on the
working directory. `--check` never writes. Missing inputs or changed hashes fail
before output is written. The former authenticated `--refresh` and
`--cache-products` developer commands are removed. Metadata refresh and current
API generation need a separate reviewed tool; neither is part of this milestone.
130 changes: 130 additions & 0 deletions docs/public-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Public data foundation

This milestone freezes the Python contract at
`d1daad25df42d3fff41f88b907d99ef325b970e0`. It adds offline metadata under
`tinyercot.catalog`. It adds no current data client, raw fetch, MIS download,
archive adapter, live polling, authentication flow, or historical backfill.
No authenticated ERCOT request or historical bulk extraction occurred.

## Compatibility boundary

`tinyercot/__init__.py`, `_client.py`, and `_generated.py` keep their exact base
bytes. The 35 product exports, `configure`, 510 method signatures, 102 Row models,
102 Response models, and base envelope remain fixed. Numeric suffixes retain
their leading underscores. Tests check function categories, module and nested
model identities, field order, requiredness, aliases, defaults, model settings,
and JSON schema digests. The fixture was checked against the independent audit.

All 102 method families have mocked sync/async path, filter, pagination, and
DataFrame tests. Other tests preserve exception propagation, token cache rules,
None filter omission, cached positional field order, extra-value truncation,
missing-page-count behavior, and DataFrame dtypes. A JSON 401 still produces an
empty legacy response. This is a compatibility test, not the intended policy
for a future client. Legacy token and DataFrame behavior is unchanged.

Python compatibility cannot undo a server-side change. The audit found 39
changed legacy query contracts and one order-only change. The new catalog
records these states separately. None of the 102 legacy paths establishes
successful current decoding. Retirement does not remove Python symbols.

## Pinned inputs

`tools/inputs/legacy-provenance.json` records source URL, capture time, original
source SHA-256, projected input SHA-256, cache SHA-256, and output SHA-256.
The original OpenAPI export is ERCOT's
[legacy GitHub specification](https://raw.githubusercontent.com/ercot/api-specs/main/pubapi/pubapi-apim-api.json),
captured on 2026-09-05. The URL is provenance, not a generator input.
The original file hash identifies its content even though the URL moves.
Its version label is `1.0`; that label does not identify a revision.
No upstream Git revision was established, so the manifest stores null.

`legacy-openapi.json` is a compact projection, with one path per line.
It retains `openapi`, `info.version`, `tags`, and data GET summaries and query
parameter `name`, `in`, and `schema`, in source order. It excludes root, version,
and templated paths. It omits unused response examples and repeated envelope
definitions. The generator reads no network resource and imports no HTTP client.

The existing `api_response_fields.json` remains unchanged. It is evidence for
legacy reproduction only. Its original capture time, current field nullability,
and current schema validity are unknown. It must not authorize current typed
models. Generation checks exact pinned metadata, ordered fields and queries,
valid Python names, member collisions, supported legacy types/formats, and the
final output digest. It cannot emit an empty Row model from missing evidence.
Unknown formats and response types fail. The old `mm:ss` string policy remains
explicit. Changed inputs need a separate reviewed contract; editing the moving
URL or refreshing this cache cannot upgrade the legacy API.

`tests/fixtures/legacy-contract.json` stores all method identities and signature
digests, ordered field essentials, model identities/settings, and full JSON
schema digests. Signature digests hash `str(inspect.signature(function))`.
Schema digests hash UTF-8 JSON with sorted keys and compact separators.
This avoids repeating each long signature five times and nested schemas in
every response. The source projection and legacy code retain readable contracts.
The locked Pydantic version defines the baseline schema serialization.

Do not update these pins to accept unexplained drift. A later refresh tool must
write separate candidate metadata with source hashes, explicit validation, and
bounded requests. It must not overwrite a valid cache after a failed request.
The legacy generator deliberately has no refresh command.

## Offline catalog contract

`operations()` returns immutable observations of 257 operations: 249 under
`public-reports` and eight under `public-data` (ESR). Of these, 242 and one,
respectively, are data GET paths. Each API also has seven generic operations,
including separate archive and bundle POST downloads. The catalog preserves
verbs and request/success media types. It does not implement request bodies,
path substitution, pagination, decoding, or transport.

The catalog derives from the public ERCOT
[Public Reports export](https://apiexplorer.ercot.com/developer/apis/pubapi-apim-api?export=true&api-version=2022-04-01-preview)
and [ESR export](https://apiexplorer.ercot.com/developer/apis/esrapi-apim-api?export=true&api-version=2022-04-01-preview).
`tools/inputs/public-provenance.json` records their capture times and hashes,
the catalog hash, audit input hashes, and the projection recipe. These are audit
observations from 2026-09-05, not a rolling inventory. The date does not establish
publication time, first available history, or present source availability.

Every operation has `support="metadata_only"`. `legacy_path` means only that a
legacy method exposes that path. For data endpoints, `row_schema` is either
`cached_unverified` (203 paths) or `missing` (40 paths). Generic operations use
`not_applicable` because they do not describe data rows. **No state grants typed
or raw current retrieval.** `query_drift` is `contract`, `order_only`, `unchanged`,
or None when no legacy comparison applies.

`sources()` returns eight selected delivery and access boundaries. It is not the
full EMIL catalog. `classify_access()` recognizes only Public, Secure, and
Certified labels, ignoring case and surrounding whitespace. Unknown, absent,
and conflicting labels remain unknown. Active and Greybox do not establish
public access. `Access.UNAVAILABLE` records explicit source unavailability,
such as the [2001 hourly load gap](https://www.ercot.com/gridinfo/load/load_hist).

Public does not mean anonymous API access. ERCOT's
[authentication guide](https://developer.ercot.com/applications/pubapi/user-guide/registration-and-authentication/)
requires an account, ID token, and subscription key for API data requests.
Offline metadata does not read credentials, authenticate, or make requests.
Restricted entries describe exclusions; they do not route through public auth.
These include [Secure network products](https://www.ercot.com/mp/data-products/data-product-details?id=np4-500-sg),
[Certified settlements](https://www.ercot.com/mp/data-products/data-product-details?id=np9-170-sg),
and [EWS participant services](https://developer.ercot.com/applications/ews/Services%20Organization/).
Private telemetry, bids, COP, awards, settlements, and customer records remain
excluded. Public aggregates and delayed disclosures are separate products.

## Additive adapter path and deferred evidence

A future current API client needs a separate opt-in namespace and runtime.
It must bind current query and source-backed response contracts together.
All current row schemas still need validation; missing ones stay unsupported.
It must add status checks and error types without changing legacy exceptions.

MIS/archive and live sources need separate adapters. The selected public
[MIS price archive](https://www.ercot.com/mp/data-products/data-product-details?id=np4-180-er)
and [dashboard](https://www.ercot.com/gridmktinfo/dashboards) references identify
delivery paths only. Listing years do not prove complete interval history.
Rolling feeds do not establish historical access. File receipts, bounded
transfers, archive validation, temporal/DST rules, freshness, and revision/as-of
semantics are deferred. No adapters or placeholder clients are added here.

Full EMIL enumeration, current row fixtures, authenticated probes, a dependency
compatibility matrix, installed-wheel typing, and `py.typed` are also deferred.
There are no authenticated tests in this milestone. Any future authenticated
tests must be opt-in and skipped by default. No forecaster work is included.
Loading
Loading