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 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