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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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