tinyercot 0.1.0__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.
- tinyercot-0.1.0/PKG-INFO +121 -0
- tinyercot-0.1.0/README.md +107 -0
- tinyercot-0.1.0/pyproject.toml +25 -0
- tinyercot-0.1.0/setup.cfg +4 -0
- tinyercot-0.1.0/tinyercot/__init__.py +1 -0
- tinyercot-0.1.0/tinyercot/_client.py +134 -0
- tinyercot-0.1.0/tinyercot/_generated.py +4736 -0
- tinyercot-0.1.0/tinyercot.egg-info/PKG-INFO +121 -0
- tinyercot-0.1.0/tinyercot.egg-info/SOURCES.txt +10 -0
- tinyercot-0.1.0/tinyercot.egg-info/dependency_links.txt +1 -0
- tinyercot-0.1.0/tinyercot.egg-info/requires.txt +7 -0
- tinyercot-0.1.0/tinyercot.egg-info/top_level.txt +1 -0
tinyercot-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinyercot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tiny fully-typed ERCOT API
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: aiolimiter>=1.1.0
|
|
8
|
+
Requires-Dist: cachetools>=6.2.4
|
|
9
|
+
Requires-Dist: httpx>=0.28.1
|
|
10
|
+
Requires-Dist: httpx-retries>=0.4.5
|
|
11
|
+
Requires-Dist: pydantic>=2.10.0
|
|
12
|
+
Requires-Dist: tqdm>=4.67.1
|
|
13
|
+
Requires-Dist: pandas>=2.0
|
|
14
|
+
|
|
15
|
+
# tinyercot
|
|
16
|
+
|
|
17
|
+
Fully-typed Python client for the ERCOT Public API.
|
|
18
|
+
|
|
19
|
+
**Why tiny?** The entire hand-written codebase is ~420 lines. Everything else is auto-generated from ERCOT's OpenAPI spec.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv add tinyercot
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export ERCOT_USERNAME="your-username"
|
|
31
|
+
export ERCOT_PASSWORD="your-password"
|
|
32
|
+
export ERCOT_SUBSCRIPTION_KEY="your-subscription-key"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Single Page
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from datetime import date
|
|
41
|
+
import tinyercot
|
|
42
|
+
|
|
43
|
+
# Returns typed response with .data, .meta, .links
|
|
44
|
+
response = tinyercot.np4_190_cd.dam_stlmnt_pnt_prices(
|
|
45
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
46
|
+
settlementPoint="HB_HOUSTON",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Convert to pandas DataFrame
|
|
50
|
+
df = response.to_df()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Pagination (Sync)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# Iterator - yields typed rows from all pages
|
|
57
|
+
for row in tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_iter(
|
|
58
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
59
|
+
):
|
|
60
|
+
print(row.settlementPoint, row.settlementPointPrice)
|
|
61
|
+
|
|
62
|
+
# DataFrame - fetches all pages, returns single DataFrame
|
|
63
|
+
df = tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_df(
|
|
64
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Pagination (Async + Rate Limited)
|
|
69
|
+
|
|
70
|
+
Async methods automatically rate-limit to 20 req/min with retry on 429:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import asyncio
|
|
74
|
+
import tinyercot
|
|
75
|
+
|
|
76
|
+
async def main():
|
|
77
|
+
# Async iterator - rate-limited, non-blocking
|
|
78
|
+
async for row in tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_iter_async(
|
|
79
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
80
|
+
):
|
|
81
|
+
print(row.settlementPoint, row.settlementPointPrice)
|
|
82
|
+
|
|
83
|
+
# Async DataFrame - rate-limited, non-blocking
|
|
84
|
+
df = await tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_df_async(
|
|
85
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API Pattern
|
|
92
|
+
|
|
93
|
+
Every endpoint generates 5 methods:
|
|
94
|
+
|
|
95
|
+
| Method | Returns | Use Case |
|
|
96
|
+
|--------|---------|----------|
|
|
97
|
+
| `endpoint()` | `Response` | Single page |
|
|
98
|
+
| `endpoint_iter()` | `Iterator[Row]` | Stream all pages (sync) |
|
|
99
|
+
| `endpoint_df()` | `DataFrame` | All pages as DataFrame (sync) |
|
|
100
|
+
| `endpoint_iter_async()` | `AsyncIterator[Row]` | Stream all pages (async, rate-limited) |
|
|
101
|
+
| `endpoint_df_async()` | `DataFrame` | All pages as DataFrame (async, rate-limited) |
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
Create a `.env` file with your credentials, then run IPython with dev deps:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run --env-file .env --group dev -- ipython
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Regenerate
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv run python tools/generate_client.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
With fresh response field data (requires credentials):
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
uv run --env-file .env python tools/generate_client.py --refresh
|
|
121
|
+
```
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# tinyercot
|
|
2
|
+
|
|
3
|
+
Fully-typed Python client for the ERCOT Public API.
|
|
4
|
+
|
|
5
|
+
**Why tiny?** The entire hand-written codebase is ~420 lines. Everything else is auto-generated from ERCOT's OpenAPI spec.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv add tinyercot
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export ERCOT_USERNAME="your-username"
|
|
17
|
+
export ERCOT_PASSWORD="your-password"
|
|
18
|
+
export ERCOT_SUBSCRIPTION_KEY="your-subscription-key"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Single Page
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from datetime import date
|
|
27
|
+
import tinyercot
|
|
28
|
+
|
|
29
|
+
# Returns typed response with .data, .meta, .links
|
|
30
|
+
response = tinyercot.np4_190_cd.dam_stlmnt_pnt_prices(
|
|
31
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
32
|
+
settlementPoint="HB_HOUSTON",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Convert to pandas DataFrame
|
|
36
|
+
df = response.to_df()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Pagination (Sync)
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# Iterator - yields typed rows from all pages
|
|
43
|
+
for row in tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_iter(
|
|
44
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
45
|
+
):
|
|
46
|
+
print(row.settlementPoint, row.settlementPointPrice)
|
|
47
|
+
|
|
48
|
+
# DataFrame - fetches all pages, returns single DataFrame
|
|
49
|
+
df = tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_df(
|
|
50
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Pagination (Async + Rate Limited)
|
|
55
|
+
|
|
56
|
+
Async methods automatically rate-limit to 20 req/min with retry on 429:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
import tinyercot
|
|
61
|
+
|
|
62
|
+
async def main():
|
|
63
|
+
# Async iterator - rate-limited, non-blocking
|
|
64
|
+
async for row in tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_iter_async(
|
|
65
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
66
|
+
):
|
|
67
|
+
print(row.settlementPoint, row.settlementPointPrice)
|
|
68
|
+
|
|
69
|
+
# Async DataFrame - rate-limited, non-blocking
|
|
70
|
+
df = await tinyercot.np4_190_cd.dam_stlmnt_pnt_prices_df_async(
|
|
71
|
+
deliveryDateFrom=date(2025, 12, 29),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
asyncio.run(main())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## API Pattern
|
|
78
|
+
|
|
79
|
+
Every endpoint generates 5 methods:
|
|
80
|
+
|
|
81
|
+
| Method | Returns | Use Case |
|
|
82
|
+
|--------|---------|----------|
|
|
83
|
+
| `endpoint()` | `Response` | Single page |
|
|
84
|
+
| `endpoint_iter()` | `Iterator[Row]` | Stream all pages (sync) |
|
|
85
|
+
| `endpoint_df()` | `DataFrame` | All pages as DataFrame (sync) |
|
|
86
|
+
| `endpoint_iter_async()` | `AsyncIterator[Row]` | Stream all pages (async, rate-limited) |
|
|
87
|
+
| `endpoint_df_async()` | `DataFrame` | All pages as DataFrame (async, rate-limited) |
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
Create a `.env` file with your credentials, then run IPython with dev deps:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv run --env-file .env --group dev -- ipython
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Regenerate
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv run python tools/generate_client.py
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
With fresh response field data (requires credentials):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
uv run --env-file .env python tools/generate_client.py --refresh
|
|
107
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tinyercot"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tiny fully-typed ERCOT API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"aiolimiter>=1.1.0",
|
|
9
|
+
"cachetools>=6.2.4",
|
|
10
|
+
"httpx>=0.28.1",
|
|
11
|
+
"httpx-retries>=0.4.5",
|
|
12
|
+
"pydantic>=2.10.0",
|
|
13
|
+
"tqdm>=4.67.1",
|
|
14
|
+
"pandas>=2.0"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"ipython>=9.8.0",
|
|
20
|
+
"tqdm>=4.67.1",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
extend-exclude = ["tinyercot/_generated.py"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from tinyercot._generated import * # noqa: F403
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from typing import ClassVar, Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from aiolimiter import AsyncLimiter
|
|
7
|
+
from cachetools import TTLCache, cached
|
|
8
|
+
from httpx_retries import Retry, RetryTransport
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T", bound=BaseModel)
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://api.ercot.com/api/public-reports"
|
|
14
|
+
CLIENT_ID = "fec253ea-0d06-4272-a5e6-b478baeecd70"
|
|
15
|
+
SCOPE = f"openid+{CLIENT_ID}+offline_access"
|
|
16
|
+
AUTH_URL = (
|
|
17
|
+
f"https://ercotb2c.b2clogin.com/ercotb2c.onmicrosoft.com/"
|
|
18
|
+
f"B2C_1_PUBAPI-ROPC-FLOW/oauth2/v2.0/token"
|
|
19
|
+
f"?username={os.getenv('ERCOT_USERNAME')}&password={os.getenv('ERCOT_PASSWORD')}"
|
|
20
|
+
f"&grant_type=password&scope={SCOPE}&client_id={CLIENT_ID}&response_type=id_token"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_tok_cache: TTLCache = TTLCache(maxsize=1, ttl=3600)
|
|
24
|
+
_client = httpx.Client(
|
|
25
|
+
transport=RetryTransport(retry=Retry(total=3, backoff_factor=2))
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_rate_limiter = AsyncLimiter(20, 60)
|
|
29
|
+
_async_client: httpx.AsyncClient | None = None
|
|
30
|
+
|
|
31
|
+
_MAX_RETRIES = 5
|
|
32
|
+
_BASE_DELAY = 2.0 # seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_async_client() -> httpx.AsyncClient:
|
|
36
|
+
global _async_client
|
|
37
|
+
if _async_client is None:
|
|
38
|
+
_async_client = httpx.AsyncClient(timeout=30.0)
|
|
39
|
+
return _async_client
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cached(_tok_cache)
|
|
43
|
+
def _token() -> str:
|
|
44
|
+
return _client.post(AUTH_URL).json().get("id_token")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get(path: str, schema: dict | None = None, **params) -> dict: # pyright: ignore
|
|
48
|
+
"""Fetch data from API, optionally converting list-of-lists to list-of-dicts."""
|
|
49
|
+
response = _client.get(
|
|
50
|
+
f"{BASE_URL}/{path.lstrip('/')}",
|
|
51
|
+
headers={
|
|
52
|
+
"Ocp-Apim-Subscription-Key": os.environ["ERCOT_SUBSCRIPTION_KEY"],
|
|
53
|
+
"Authorization": f"Bearer {_token()}",
|
|
54
|
+
},
|
|
55
|
+
params={k: v for k, v in params.items() if v is not None},
|
|
56
|
+
).json()
|
|
57
|
+
|
|
58
|
+
if schema and "data" in response and response["data"]:
|
|
59
|
+
keys = list(schema.keys())
|
|
60
|
+
response["data"] = [dict(zip(keys, row)) for row in response["data"]]
|
|
61
|
+
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _aget(path: str, schema: dict | None = None, **params) -> dict: # pyright: ignore
|
|
66
|
+
"""Async fetch with rate limiting and retry on 429."""
|
|
67
|
+
client = _get_async_client()
|
|
68
|
+
url = f"{BASE_URL}/{path.lstrip('/')}"
|
|
69
|
+
headers = {
|
|
70
|
+
"Ocp-Apim-Subscription-Key": os.environ["ERCOT_SUBSCRIPTION_KEY"],
|
|
71
|
+
"Authorization": f"Bearer {_token()}",
|
|
72
|
+
}
|
|
73
|
+
filtered_params = {k: v for k, v in params.items() if v is not None}
|
|
74
|
+
|
|
75
|
+
for attempt in range(_MAX_RETRIES):
|
|
76
|
+
async with _rate_limiter:
|
|
77
|
+
resp = await client.get(
|
|
78
|
+
url, headers=headers, params=filtered_params
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if resp.status_code == 429:
|
|
82
|
+
# Exponential backoff: 2s, 4s, 8s, 16s, 32s
|
|
83
|
+
delay = _BASE_DELAY * (2**attempt)
|
|
84
|
+
await asyncio.sleep(delay)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
response = resp.json()
|
|
88
|
+
break
|
|
89
|
+
else:
|
|
90
|
+
# All retries exhausted
|
|
91
|
+
raise httpx.HTTPStatusError(
|
|
92
|
+
f"Rate limited after {_MAX_RETRIES} retries",
|
|
93
|
+
request=resp.request,
|
|
94
|
+
response=resp,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Convert list-of-lists to list-of-dicts if schema provided
|
|
98
|
+
if schema and "data" in response and response["data"]:
|
|
99
|
+
keys = list(schema.keys())
|
|
100
|
+
response["data"] = [dict(zip(keys, row)) for row in response["data"]]
|
|
101
|
+
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ErcotResponse(BaseModel, Generic[T]):
|
|
106
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
107
|
+
meta: dict = Field(default_factory=dict, alias="_meta")
|
|
108
|
+
report: dict = Field(default_factory=dict)
|
|
109
|
+
fields: list[dict] = Field(default_factory=list)
|
|
110
|
+
data: list[T] = Field(default_factory=list)
|
|
111
|
+
links: list[dict] | dict = Field(default_factory=dict, alias="_links")
|
|
112
|
+
_schema: ClassVar[dict] = {}
|
|
113
|
+
|
|
114
|
+
def to_df(self):
|
|
115
|
+
import pandas as pd
|
|
116
|
+
|
|
117
|
+
df = pd.DataFrame([r.model_dump() for r in self.data])
|
|
118
|
+
for col, dtype in self._schema.items():
|
|
119
|
+
if col not in df.columns:
|
|
120
|
+
continue
|
|
121
|
+
match dtype:
|
|
122
|
+
case "DATE":
|
|
123
|
+
df[col] = pd.to_datetime(df[col]).dt.date
|
|
124
|
+
case "DATETIME":
|
|
125
|
+
df[col] = pd.to_datetime(df[col])
|
|
126
|
+
case "DOUBLE":
|
|
127
|
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
128
|
+
case "INTEGER":
|
|
129
|
+
df[col] = pd.to_numeric(df[col], errors="coerce").astype(
|
|
130
|
+
"Int64"
|
|
131
|
+
)
|
|
132
|
+
case "BOOLEAN":
|
|
133
|
+
df[col] = df[col].astype(bool)
|
|
134
|
+
return df
|