tinyercot 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- tinyercot/_client.py +134 -0
- tinyercot/_generated.py +4736 -0
- tinyercot-0.1.0.dist-info/METADATA +121 -0
- tinyercot-0.1.0.dist-info/RECORD +7 -0
- tinyercot-0.1.0.dist-info/WHEEL +5 -0
- tinyercot-0.1.0.dist-info/top_level.txt +1 -0
tinyercot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from tinyercot._generated import * # noqa: F403
|
tinyercot/_client.py
ADDED
|
@@ -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
|