python-eia 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.
- eia/__init__.py +10 -0
- eia/client.py +923 -0
- python_eia-0.1.0.dist-info/METADATA +59 -0
- python_eia-0.1.0.dist-info/RECORD +5 -0
- python_eia-0.1.0.dist-info/WHEEL +4 -0
eia/__init__.py
ADDED
eia/client.py
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import logging
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlparse, parse_qs
|
|
6
|
+
from typing import (
|
|
7
|
+
List,
|
|
8
|
+
Dict,
|
|
9
|
+
Optional,
|
|
10
|
+
Union,
|
|
11
|
+
Any,
|
|
12
|
+
Literal,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Generic,
|
|
15
|
+
Type,
|
|
16
|
+
Protocol,
|
|
17
|
+
runtime_checkable,
|
|
18
|
+
)
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
# Configure logging
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EIAError(Exception):
|
|
31
|
+
"""Custom exception for EIA API errors."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
status_code: Optional[int] = None,
|
|
37
|
+
api_error_code: Optional[int] = None,
|
|
38
|
+
):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
self.api_error_code = api_error_code
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
parts = [super().__str__()]
|
|
45
|
+
if self.status_code:
|
|
46
|
+
parts.append(f"HTTP Status Code: {self.status_code}")
|
|
47
|
+
if self.api_error_code:
|
|
48
|
+
parts.append(f"API Error Code: {self.api_error_code}")
|
|
49
|
+
return " | ".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class FacetValue:
|
|
54
|
+
"""Represents a single value for a facet with its metadata."""
|
|
55
|
+
|
|
56
|
+
id: str
|
|
57
|
+
name: str
|
|
58
|
+
description: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class FrequencyInfo:
|
|
63
|
+
"""Metadata for a specific data frequency."""
|
|
64
|
+
|
|
65
|
+
id: str
|
|
66
|
+
description: str
|
|
67
|
+
query: str
|
|
68
|
+
format: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class DataColumnInfo:
|
|
73
|
+
"""Metadata for a specific data column."""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
aggregation_method: Optional[str] = None
|
|
77
|
+
alias: Optional[str] = None
|
|
78
|
+
units: Optional[str] = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class FacetInfo:
|
|
83
|
+
"""Metadata for a specific facet."""
|
|
84
|
+
|
|
85
|
+
id: str
|
|
86
|
+
description: Optional[str] = None
|
|
87
|
+
# Store the route slug for potential API calls
|
|
88
|
+
_route_slug: Optional[str] = field(default=None, repr=False)
|
|
89
|
+
_client: Optional["EIAClient"] = field(default=None, repr=False)
|
|
90
|
+
|
|
91
|
+
def get_values(self) -> List[FacetValue]:
|
|
92
|
+
"""Fetches and returns all possible values for this facet."""
|
|
93
|
+
if not self._client or not self._route_slug:
|
|
94
|
+
raise ValueError("Client and route slug must be set to fetch facet values.")
|
|
95
|
+
|
|
96
|
+
response = self._client.get_facet_values(self._route_slug, self.id)
|
|
97
|
+
return [
|
|
98
|
+
FacetValue(
|
|
99
|
+
id=value["id"],
|
|
100
|
+
name=value.get("name", value["id"]),
|
|
101
|
+
description=value.get("description"),
|
|
102
|
+
)
|
|
103
|
+
for value in response.get("facets", [])
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@runtime_checkable
|
|
108
|
+
class FacetContainerProtocol(Protocol):
|
|
109
|
+
"""Protocol defining the interface for facet containers."""
|
|
110
|
+
|
|
111
|
+
_facets: Dict[str, FacetInfo]
|
|
112
|
+
|
|
113
|
+
def __getattr__(self, name: str) -> FacetInfo: ...
|
|
114
|
+
def __getitem__(self, key: str) -> FacetInfo: ...
|
|
115
|
+
def keys(self) -> List[str]: ...
|
|
116
|
+
def items(self) -> List[tuple[str, FacetInfo]]: ...
|
|
117
|
+
def values(self) -> List[FacetInfo]: ...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BaseFacetContainer:
|
|
121
|
+
"""Base container class for facets to allow attribute-based access."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, facets: Dict[str, FacetInfo]) -> None:
|
|
124
|
+
self._facets = facets
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, name: str) -> FacetInfo:
|
|
127
|
+
"""
|
|
128
|
+
Access facets as attributes using underscores instead of hyphens.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
name: The facet name to access
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The FacetInfo object for the requested facet
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
AttributeError: If no facet with the given name exists
|
|
138
|
+
"""
|
|
139
|
+
# Try with underscores and hyphens
|
|
140
|
+
if name in self._facets:
|
|
141
|
+
return self._facets[name]
|
|
142
|
+
|
|
143
|
+
# Try with hyphenated version
|
|
144
|
+
hyphenated = name.replace("_", "-")
|
|
145
|
+
if hyphenated in self._facets:
|
|
146
|
+
return self._facets[hyphenated]
|
|
147
|
+
|
|
148
|
+
raise AttributeError(f"No facet named '{name}' exists")
|
|
149
|
+
|
|
150
|
+
def __getitem__(self, key: str) -> FacetInfo:
|
|
151
|
+
"""Dictionary-style access for backward compatibility."""
|
|
152
|
+
try:
|
|
153
|
+
return self.__getattr__(key)
|
|
154
|
+
except AttributeError as e:
|
|
155
|
+
raise KeyError(str(e))
|
|
156
|
+
|
|
157
|
+
def __dir__(self) -> List[str]:
|
|
158
|
+
"""Customize dir() output to show available facets for autocomplete."""
|
|
159
|
+
# Get the facet names in both formats
|
|
160
|
+
facet_names = []
|
|
161
|
+
for name in self._facets:
|
|
162
|
+
facet_names.append(name)
|
|
163
|
+
if "-" in name:
|
|
164
|
+
facet_names.append(name.replace("-", "_"))
|
|
165
|
+
return sorted(set(facet_names + super().__dir__()))
|
|
166
|
+
|
|
167
|
+
def keys(self) -> List[str]:
|
|
168
|
+
"""Return facet keys."""
|
|
169
|
+
return list(self._facets.keys())
|
|
170
|
+
|
|
171
|
+
def items(self) -> List[tuple[str, FacetInfo]]:
|
|
172
|
+
"""Return facet items."""
|
|
173
|
+
return list(self._facets.items())
|
|
174
|
+
|
|
175
|
+
def values(self) -> List[FacetInfo]:
|
|
176
|
+
"""Return facet values."""
|
|
177
|
+
return list(self._facets.values())
|
|
178
|
+
|
|
179
|
+
def __repr__(self) -> str:
|
|
180
|
+
"""Provide a helpful representation of available facets."""
|
|
181
|
+
facets_str = ", ".join(self._facets.keys())
|
|
182
|
+
return f"FacetContainer(facets=[{facets_str}])"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_facet_container(
|
|
186
|
+
facets: Dict[str, FacetInfo],
|
|
187
|
+
) -> Type[FacetContainerProtocol]:
|
|
188
|
+
"""
|
|
189
|
+
Creates a typed FacetContainer class with attributes for the given facets.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
facets: Dictionary of facet information
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
A new FacetContainer subclass with typed attributes for the given facets
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def make_getter(facet_name: str):
|
|
199
|
+
"""Create a getter function for the property that properly captures the facet name."""
|
|
200
|
+
return lambda self: self._facets[facet_name]
|
|
201
|
+
|
|
202
|
+
class_attrs = {
|
|
203
|
+
# Convert class docstring to show available facets
|
|
204
|
+
"__doc__": "Container for facets with attribute access.\n\nAvailable facets:\n"
|
|
205
|
+
+ "\n".join(
|
|
206
|
+
f" {k}: {v.description or 'No description'}" for k, v in facets.items()
|
|
207
|
+
),
|
|
208
|
+
# Store the facets dict
|
|
209
|
+
"_facets": facets,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Add each facet as a typed property
|
|
213
|
+
for name, info in facets.items():
|
|
214
|
+
# Create both hyphenated and underscore versions of the property
|
|
215
|
+
underscore_name = name.replace("-", "_")
|
|
216
|
+
class_attrs[underscore_name] = property(
|
|
217
|
+
fget=make_getter(name), doc=f"{info.description or 'No description'}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# If the name contains hyphens, also add the hyphenated version as an alias
|
|
221
|
+
if "-" in name:
|
|
222
|
+
class_attrs[name] = property(
|
|
223
|
+
fget=make_getter(name), doc=f"{info.description or 'No description'}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Create the class with proper name and bases
|
|
227
|
+
return type("TypedFacetContainer", (BaseFacetContainer,), class_attrs)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class FacetContainer(BaseFacetContainer):
|
|
231
|
+
"""Container for facets with dynamic attribute access."""
|
|
232
|
+
|
|
233
|
+
def __new__(cls, facets: Dict[str, FacetInfo]) -> "FacetContainer":
|
|
234
|
+
"""
|
|
235
|
+
Create a new FacetContainer instance with typed attributes.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
facets: Dictionary mapping facet IDs to their FacetInfo objects
|
|
239
|
+
"""
|
|
240
|
+
# Create a new typed subclass for these specific facets
|
|
241
|
+
container_class = create_facet_container(facets)
|
|
242
|
+
# Create an instance of the typed container class
|
|
243
|
+
instance = object.__new__(container_class)
|
|
244
|
+
instance._facets = facets
|
|
245
|
+
return instance
|
|
246
|
+
|
|
247
|
+
def __init__(self, facets: Dict[str, FacetInfo]) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Initialize with a dictionary of facets.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
facets: Dictionary mapping facet IDs to their FacetInfo objects
|
|
253
|
+
"""
|
|
254
|
+
# __init__ is called after __new__, but we already initialized _facets in __new__
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Data:
|
|
259
|
+
"""Represents a data endpoint in the EIA API with its metadata and query capabilities."""
|
|
260
|
+
|
|
261
|
+
def __init__(self, client: "EIAClient", route: str, metadata: Dict[str, Any]):
|
|
262
|
+
self._client = client
|
|
263
|
+
self._route = route
|
|
264
|
+
self._metadata = metadata
|
|
265
|
+
self.id = metadata.get("id", route.split("/")[-1])
|
|
266
|
+
self.name = metadata.get("name", "")
|
|
267
|
+
self.description = metadata.get("description", "")
|
|
268
|
+
|
|
269
|
+
# Initialize attributes to store fetched data and metadata
|
|
270
|
+
self.dataframe: Optional[pd.DataFrame] = None
|
|
271
|
+
self.last_response_metadata: Optional[Dict[str, Any]] = None
|
|
272
|
+
|
|
273
|
+
self.frequencies: List[FrequencyInfo] = [
|
|
274
|
+
FrequencyInfo(
|
|
275
|
+
id=freq["id"],
|
|
276
|
+
description=freq.get("description", ""),
|
|
277
|
+
query=freq.get("query", ""),
|
|
278
|
+
format=freq.get("format", ""),
|
|
279
|
+
)
|
|
280
|
+
for freq in metadata.get("frequency", [])
|
|
281
|
+
if isinstance(freq, dict) and "id" in freq
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
facet_dict = {
|
|
285
|
+
facet_data["id"]: FacetInfo(
|
|
286
|
+
id=facet_data["id"],
|
|
287
|
+
description=facet_data.get("description"),
|
|
288
|
+
_route_slug=route, # Pass route slug
|
|
289
|
+
_client=client, # Pass client instance
|
|
290
|
+
)
|
|
291
|
+
for facet_data in metadata.get("facets", [])
|
|
292
|
+
if isinstance(facet_data, dict) and "id" in facet_data
|
|
293
|
+
}
|
|
294
|
+
# Use FacetContainer for attribute-based access
|
|
295
|
+
self.facets = FacetContainer(facet_dict)
|
|
296
|
+
|
|
297
|
+
self.data_columns: Dict[str, DataColumnInfo] = {}
|
|
298
|
+
for col_id, col_data in metadata.get("data", {}).items():
|
|
299
|
+
if isinstance(col_data, dict):
|
|
300
|
+
self.data_columns[col_id] = DataColumnInfo(
|
|
301
|
+
id=col_id,
|
|
302
|
+
aggregation_method=col_data.get("aggregation-method"),
|
|
303
|
+
alias=col_data.get("alias"),
|
|
304
|
+
units=col_data.get("units"),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
self.start_period = metadata.get("startPeriod")
|
|
308
|
+
self.end_period = metadata.get("endPeriod")
|
|
309
|
+
self.default_date_format = metadata.get("defaultDateFormat")
|
|
310
|
+
self.default_frequency = metadata.get("defaultFrequency")
|
|
311
|
+
|
|
312
|
+
def __dir__(self) -> List[str]:
|
|
313
|
+
"""Customize dir() output for better IDE support."""
|
|
314
|
+
return sorted(list(self.__dict__.keys()) + ["get"])
|
|
315
|
+
|
|
316
|
+
def get(
|
|
317
|
+
self,
|
|
318
|
+
data_columns: Optional[List[str]] = None,
|
|
319
|
+
facets: Optional[Dict[str, Union[str, List[str]]]] = None,
|
|
320
|
+
frequency: Optional[str] = None,
|
|
321
|
+
start: Optional[str] = None,
|
|
322
|
+
end: Optional[str] = None,
|
|
323
|
+
sort: Optional[List[Dict[str, str]]] = None,
|
|
324
|
+
length: Optional[int] = None,
|
|
325
|
+
offset: Optional[int] = None,
|
|
326
|
+
output_format: Optional[Literal["json", "xml"]] = "json",
|
|
327
|
+
paginate: bool = True,
|
|
328
|
+
) -> pd.DataFrame:
|
|
329
|
+
"""
|
|
330
|
+
Retrieves data from this endpoint, stores it and metadata internally,
|
|
331
|
+
and returns the data as a pandas DataFrame. Handles pagination automatically by default.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
data_columns: List of data column IDs to retrieve. If None, all available columns are fetched.
|
|
335
|
+
facets: Dictionary of facet filters, e.g., {'stateid': 'CA', 'sectorid': ['RES', 'COM']}
|
|
336
|
+
frequency: Data frequency ID (e.g., 'daily', 'monthly')
|
|
337
|
+
start: Start date/period
|
|
338
|
+
end: End date/period
|
|
339
|
+
sort: List of sort specifications
|
|
340
|
+
length: Maximum number of rows to return *if paginate=False*. Ignored if paginate=True.
|
|
341
|
+
offset: Starting row offset for the first request.
|
|
342
|
+
output_format: Response format ('json' or 'xml'). Must be 'json' for DataFrame conversion.
|
|
343
|
+
paginate: Whether to automatically paginate through results (default: True).
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
A pandas DataFrame containing the requested data
|
|
347
|
+
"""
|
|
348
|
+
# Use column IDs if data_columns is None
|
|
349
|
+
column_ids_to_fetch = (
|
|
350
|
+
data_columns if data_columns is not None else list(self.data_columns.keys())
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Ensure output is json if we want a DataFrame
|
|
354
|
+
if output_format != "json":
|
|
355
|
+
logging.warning(
|
|
356
|
+
"output_format must be 'json' to return a DataFrame. Forcing to JSON."
|
|
357
|
+
)
|
|
358
|
+
output_format = "json"
|
|
359
|
+
|
|
360
|
+
all_data = []
|
|
361
|
+
current_offset = offset if offset is not None else 0
|
|
362
|
+
first_request = True
|
|
363
|
+
API_PAGE_LIMIT = 5000 # Standard EIA API limit for JSON
|
|
364
|
+
|
|
365
|
+
while True:
|
|
366
|
+
request_length = None if paginate else length
|
|
367
|
+
|
|
368
|
+
logging.info(
|
|
369
|
+
f"Fetching data for route: {self._route} - Offset: {current_offset}, Length: {request_length or 'All (Pagination)'}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
response_data = self._client.get_data(
|
|
373
|
+
route=self._route,
|
|
374
|
+
data_columns=column_ids_to_fetch,
|
|
375
|
+
facets=facets,
|
|
376
|
+
frequency=frequency,
|
|
377
|
+
start=start,
|
|
378
|
+
end=end,
|
|
379
|
+
sort=sort,
|
|
380
|
+
length=request_length, # Use calculated length
|
|
381
|
+
offset=current_offset, # Use current offset
|
|
382
|
+
output_format=output_format,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if first_request:
|
|
386
|
+
# Store metadata from the first response
|
|
387
|
+
self.last_response_metadata = {
|
|
388
|
+
k: v for k, v in response_data.items() if k != "data"
|
|
389
|
+
}
|
|
390
|
+
first_request = False
|
|
391
|
+
|
|
392
|
+
# Extract data chunk
|
|
393
|
+
data_chunk = response_data.get("data", [])
|
|
394
|
+
|
|
395
|
+
if not data_chunk: # No more data
|
|
396
|
+
logging.info("Received empty data chunk, stopping pagination.")
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
all_data.extend(data_chunk)
|
|
400
|
+
logging.info(f"Received {len(data_chunk)} rows.")
|
|
401
|
+
|
|
402
|
+
# Stop if not paginating or if last page received
|
|
403
|
+
if not paginate or len(data_chunk) < API_PAGE_LIMIT:
|
|
404
|
+
if not paginate:
|
|
405
|
+
logging.info("Pagination disabled, stopping after one request.")
|
|
406
|
+
else:
|
|
407
|
+
logging.info("Received less than page limit, assuming end of data.")
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
# Prepare for next page
|
|
411
|
+
current_offset += API_PAGE_LIMIT
|
|
412
|
+
|
|
413
|
+
# Convert combined data to DataFrame
|
|
414
|
+
df = pd.DataFrame(all_data)
|
|
415
|
+
|
|
416
|
+
# --- Automatic Preprocessing ---
|
|
417
|
+
# Convert 'period' column to datetime if it exists
|
|
418
|
+
if "period" in df.columns:
|
|
419
|
+
try:
|
|
420
|
+
if frequency is not None and "local" not in frequency.lower():
|
|
421
|
+
df["period"] = pd.to_datetime(
|
|
422
|
+
df["period"], errors="coerce", utc=True
|
|
423
|
+
)
|
|
424
|
+
logging.info(
|
|
425
|
+
"Automatically converted 'period' column to datetime with UTC."
|
|
426
|
+
)
|
|
427
|
+
else:
|
|
428
|
+
df["period"] = pd.to_datetime(df["period"], errors="coerce")
|
|
429
|
+
logging.info("Automatically converted 'period' column to datetime.")
|
|
430
|
+
except Exception as e:
|
|
431
|
+
logging.warning(
|
|
432
|
+
f"Could not automatically convert 'period' column to datetime: {e}"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Convert 'value' column to numeric if it exists
|
|
436
|
+
if "value" in df.columns:
|
|
437
|
+
try:
|
|
438
|
+
# Attempt conversion, coercing errors to NaN
|
|
439
|
+
df["value"] = pd.to_numeric(df["value"], errors="coerce")
|
|
440
|
+
logging.info("Automatically converted 'value' column to numeric.")
|
|
441
|
+
except Exception as e:
|
|
442
|
+
logging.warning(
|
|
443
|
+
f"Could not automatically convert 'value' column to numeric: {e}"
|
|
444
|
+
)
|
|
445
|
+
# --- End Automatic Preprocessing ---
|
|
446
|
+
|
|
447
|
+
# Store DataFrame
|
|
448
|
+
self.dataframe = df
|
|
449
|
+
|
|
450
|
+
return df
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class Route:
|
|
454
|
+
"""
|
|
455
|
+
Represents a route in the EIA API that can contain either nested routes or data.
|
|
456
|
+
|
|
457
|
+
Attributes:
|
|
458
|
+
routes (Dict[str, 'Route']): Available child routes if this is a route container
|
|
459
|
+
data (Data): Data object if this endpoint contains data
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
def __init__(
|
|
463
|
+
self,
|
|
464
|
+
client: "EIAClient",
|
|
465
|
+
slug: str,
|
|
466
|
+
):
|
|
467
|
+
"""
|
|
468
|
+
Initialize a route.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
client: The EIA client instance
|
|
472
|
+
slug: The route path (e.g., 'electricity/retail-sales')
|
|
473
|
+
"""
|
|
474
|
+
self._client = client
|
|
475
|
+
self._slug = slug.strip("/")
|
|
476
|
+
self._metadata: Optional[Dict[str, Any]] = None
|
|
477
|
+
self._routes: Dict[str, "Route"] = {}
|
|
478
|
+
self._data: Optional[Data] = None
|
|
479
|
+
|
|
480
|
+
def _ensure_metadata(self) -> None:
|
|
481
|
+
"""Lazily loads metadata when needed."""
|
|
482
|
+
if self._metadata is None:
|
|
483
|
+
self._metadata = self._client.get_metadata(self._slug)
|
|
484
|
+
response_data = self._metadata
|
|
485
|
+
|
|
486
|
+
# Check if routes exist in the response dictionary
|
|
487
|
+
if "routes" in response_data:
|
|
488
|
+
for route in response_data["routes"]:
|
|
489
|
+
route_id = route["id"]
|
|
490
|
+
attr_name = route_id.replace("-", "_")
|
|
491
|
+
# Create placeholder Route objects without fetching their metadata
|
|
492
|
+
if attr_name not in self._routes:
|
|
493
|
+
self._routes[attr_name] = Route(
|
|
494
|
+
self._client,
|
|
495
|
+
f"{self._slug}/{route_id}",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# If response doesn't contain routes, it means this endpoint has data
|
|
499
|
+
if "routes" not in response_data:
|
|
500
|
+
self._data = Data(self._client, self._slug, response_data)
|
|
501
|
+
|
|
502
|
+
def __getattr__(self, name: str) -> Union["Route", Any]:
|
|
503
|
+
"""
|
|
504
|
+
Access child routes or data attributes.
|
|
505
|
+
Always returns a Route object for navigation.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
name: The attribute name to access
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
A Route object for the requested path
|
|
512
|
+
|
|
513
|
+
Raises:
|
|
514
|
+
AttributeError: If the route doesn't exist
|
|
515
|
+
"""
|
|
516
|
+
# Ensure metadata is loaded first
|
|
517
|
+
self._ensure_metadata()
|
|
518
|
+
|
|
519
|
+
# Try with underscores and hyphens
|
|
520
|
+
if name in self._routes:
|
|
521
|
+
return self._routes[name]
|
|
522
|
+
|
|
523
|
+
# Try with hyphenated version
|
|
524
|
+
hyphenated = name.replace("_", "-")
|
|
525
|
+
if hyphenated in self._routes:
|
|
526
|
+
return self._routes[hyphenated]
|
|
527
|
+
|
|
528
|
+
# If nothing is found, raise AttributeError
|
|
529
|
+
raise AttributeError(
|
|
530
|
+
f"'{type(self).__name__}' object for route '{self._slug}' has no attribute '{name}'"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def __getitem__(self, key: str) -> "Route":
|
|
534
|
+
"""
|
|
535
|
+
Access child routes using dictionary-style access.
|
|
536
|
+
Allows both underscore and hyphen formats.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
key: The route key to access
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
A Route object for the requested path
|
|
543
|
+
|
|
544
|
+
Raises:
|
|
545
|
+
KeyError: If the route doesn't exist
|
|
546
|
+
"""
|
|
547
|
+
try:
|
|
548
|
+
return self.__getattr__(key)
|
|
549
|
+
except AttributeError as e:
|
|
550
|
+
raise KeyError(str(e))
|
|
551
|
+
|
|
552
|
+
def keys(self) -> List[str]:
|
|
553
|
+
"""Returns available route keys."""
|
|
554
|
+
self._ensure_metadata()
|
|
555
|
+
return list(self._routes.keys())
|
|
556
|
+
|
|
557
|
+
def __iter__(self):
|
|
558
|
+
"""Allows iteration over available routes."""
|
|
559
|
+
return iter(self.keys())
|
|
560
|
+
|
|
561
|
+
def __dir__(self) -> List[str]:
|
|
562
|
+
"""
|
|
563
|
+
Customize dir() output to show available routes.
|
|
564
|
+
This helps with IDE autocompletion.
|
|
565
|
+
"""
|
|
566
|
+
self._ensure_metadata()
|
|
567
|
+
attributes = set(super().__dir__())
|
|
568
|
+
attributes.update(self._routes.keys())
|
|
569
|
+
if self._data is not None:
|
|
570
|
+
attributes.add("data")
|
|
571
|
+
return sorted(attributes)
|
|
572
|
+
|
|
573
|
+
@property
|
|
574
|
+
def routes(self) -> Dict[str, "Route"]:
|
|
575
|
+
"""Returns available child routes."""
|
|
576
|
+
self._ensure_metadata()
|
|
577
|
+
return self._routes
|
|
578
|
+
|
|
579
|
+
@property
|
|
580
|
+
def data(self) -> Data:
|
|
581
|
+
"""Returns the Data object if this endpoint contains data."""
|
|
582
|
+
self._ensure_metadata()
|
|
583
|
+
if self._data is not None:
|
|
584
|
+
return self._data
|
|
585
|
+
raise AttributeError(
|
|
586
|
+
f"Route '{self._slug}' does not contain data. It has child routes: {list(self._routes.keys())}"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class EIAClient:
|
|
591
|
+
"""A client for interacting with the U.S. Energy Information Administration (EIA) API v2."""
|
|
592
|
+
|
|
593
|
+
BASE_URL = "https://api.eia.gov/v2/"
|
|
594
|
+
# Regex to parse structured URL parameters like sort[0][column]
|
|
595
|
+
_param_regex = re.compile(
|
|
596
|
+
r"^([a-zA-Z_\-]+)(?:\[(\d+)\])?(?:\[([a-zA-Z_\-]+)\])?(?:\[\])?$"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def __init__(
|
|
600
|
+
self, api_key: Optional[str] = None, session: Optional[requests.Session] = None
|
|
601
|
+
):
|
|
602
|
+
"""
|
|
603
|
+
Initializes the EIAClient.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
api_key: Your EIA API key. If None, it will try to read from the EIA_API_KEY environment variable.
|
|
607
|
+
session: An optional requests.Session object for persistent connections.
|
|
608
|
+
"""
|
|
609
|
+
resolved_api_key = api_key or os.environ.get("EIA_API_KEY")
|
|
610
|
+
if not resolved_api_key:
|
|
611
|
+
raise ValueError(
|
|
612
|
+
"API key is required. Provide it directly or set the EIA_API_KEY environment variable."
|
|
613
|
+
)
|
|
614
|
+
self.api_key = resolved_api_key
|
|
615
|
+
self.session = session or requests.Session()
|
|
616
|
+
self.session.headers.update({"User-Agent": "Python EIAClient"})
|
|
617
|
+
logging.info("EIAClient initialized.")
|
|
618
|
+
|
|
619
|
+
def route(self, slug: str) -> Route:
|
|
620
|
+
"""
|
|
621
|
+
Access an API route by its slug.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
slug: The route path (e.g., 'electricity/retail-sales')
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
A Route object representing the requested endpoint
|
|
628
|
+
"""
|
|
629
|
+
return Route(self, slug)
|
|
630
|
+
|
|
631
|
+
def _build_url(self, route: str) -> str:
|
|
632
|
+
"""Constructs the full API URL for a given route."""
|
|
633
|
+
route = route.strip("/")
|
|
634
|
+
return f"{self.BASE_URL}{route}"
|
|
635
|
+
|
|
636
|
+
def _prepare_params(
|
|
637
|
+
self, params: Optional[Dict[str, Any]] = None
|
|
638
|
+
) -> Dict[str, Any]:
|
|
639
|
+
"""Prepares parameters, adding the API key."""
|
|
640
|
+
final_params = params.copy() if params else {}
|
|
641
|
+
final_params["api_key"] = self.api_key
|
|
642
|
+
return final_params
|
|
643
|
+
|
|
644
|
+
def _format_list_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
645
|
+
"""Formats list-based parameters for URL encoding."""
|
|
646
|
+
formatted_params = {}
|
|
647
|
+
list_params_to_process = {}
|
|
648
|
+
|
|
649
|
+
for key, value in params.items():
|
|
650
|
+
if key == "data" and isinstance(value, list):
|
|
651
|
+
list_params_to_process[key] = value
|
|
652
|
+
elif key == "facets" and isinstance(value, dict):
|
|
653
|
+
list_params_to_process[key] = value
|
|
654
|
+
elif key == "sort" and isinstance(value, list):
|
|
655
|
+
list_params_to_process[key] = value
|
|
656
|
+
else:
|
|
657
|
+
formatted_params[key] = value
|
|
658
|
+
|
|
659
|
+
if "data" in list_params_to_process:
|
|
660
|
+
for i, col in enumerate(list_params_to_process["data"]):
|
|
661
|
+
formatted_params[f"data[]"] = col
|
|
662
|
+
|
|
663
|
+
if "facets" in list_params_to_process:
|
|
664
|
+
facet_dict = list_params_to_process["facets"]
|
|
665
|
+
for facet_id, values in facet_dict.items():
|
|
666
|
+
if isinstance(values, list):
|
|
667
|
+
for val in values:
|
|
668
|
+
formatted_params[f"facets[{facet_id}][]"] = val
|
|
669
|
+
else:
|
|
670
|
+
formatted_params[f"facets[{facet_id}][]"] = values
|
|
671
|
+
|
|
672
|
+
if "sort" in list_params_to_process:
|
|
673
|
+
sort_list = list_params_to_process["sort"]
|
|
674
|
+
for i, sort_item in enumerate(sort_list):
|
|
675
|
+
if isinstance(sort_item, dict) and "column" in sort_item:
|
|
676
|
+
formatted_params[f"sort[{i}][column]"] = sort_item["column"]
|
|
677
|
+
if "direction" in sort_item:
|
|
678
|
+
formatted_params[f"sort[{i}][direction]"] = sort_item[
|
|
679
|
+
"direction"
|
|
680
|
+
]
|
|
681
|
+
|
|
682
|
+
return formatted_params
|
|
683
|
+
|
|
684
|
+
def _send_request(
|
|
685
|
+
self,
|
|
686
|
+
method: str,
|
|
687
|
+
route: str,
|
|
688
|
+
params: Optional[Dict[str, Any]] = None,
|
|
689
|
+
data: Optional[Dict[str, Any]] = None,
|
|
690
|
+
) -> Dict[str, Any]:
|
|
691
|
+
"""Sends an HTTP request to the EIA API."""
|
|
692
|
+
full_url = self._build_url(route)
|
|
693
|
+
base_params = self._prepare_params({})
|
|
694
|
+
request_params = params.copy() if params else {}
|
|
695
|
+
request_params.update(base_params)
|
|
696
|
+
formatted_url_params = self._format_list_params(request_params)
|
|
697
|
+
|
|
698
|
+
logging.debug(
|
|
699
|
+
f"Sending {method} request to {full_url} with params: {formatted_url_params}"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
response = self.session.request(
|
|
704
|
+
method=method,
|
|
705
|
+
url=full_url,
|
|
706
|
+
params=formatted_url_params,
|
|
707
|
+
json=data,
|
|
708
|
+
)
|
|
709
|
+
response.raise_for_status()
|
|
710
|
+
json_response = response.json()
|
|
711
|
+
|
|
712
|
+
if "error" in json_response:
|
|
713
|
+
error_msg = json_response["error"]
|
|
714
|
+
error_code = json_response.get("code")
|
|
715
|
+
logging.error(f"API Error ({error_code}): {error_msg}")
|
|
716
|
+
raise EIAError(
|
|
717
|
+
error_msg,
|
|
718
|
+
status_code=response.status_code,
|
|
719
|
+
api_error_code=error_code,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
if "warning" in json_response:
|
|
723
|
+
warning_msg = json_response.get("description", json_response["warning"])
|
|
724
|
+
logging.warning(f"API Warning: {warning_msg}")
|
|
725
|
+
|
|
726
|
+
return json_response
|
|
727
|
+
|
|
728
|
+
except requests.exceptions.HTTPError as e:
|
|
729
|
+
logging.error(f"HTTP Error: {e.response.status_code} - {e.response.text}")
|
|
730
|
+
raise EIAError(
|
|
731
|
+
f"HTTP Error: {e.response.status_code}",
|
|
732
|
+
status_code=e.response.status_code,
|
|
733
|
+
) from e
|
|
734
|
+
except requests.exceptions.RequestException as e:
|
|
735
|
+
logging.error(f"Request Exception: {e}")
|
|
736
|
+
raise EIAError(f"Request Failed: {e}") from e
|
|
737
|
+
except ValueError as e:
|
|
738
|
+
logging.error(f"Failed to decode JSON response: {e}")
|
|
739
|
+
raise EIAError("Invalid JSON response received from API.") from e
|
|
740
|
+
|
|
741
|
+
def get_metadata(self, route: str) -> Dict[str, Any]:
|
|
742
|
+
"""Retrieves metadata for a given API route."""
|
|
743
|
+
if route.endswith("/data"):
|
|
744
|
+
route = route[: -len("/data")]
|
|
745
|
+
route = route.strip("/")
|
|
746
|
+
logging.info(f"Fetching metadata for route: {route}")
|
|
747
|
+
response_data = self._send_request("GET", route)
|
|
748
|
+
return response_data.get("response", {})
|
|
749
|
+
|
|
750
|
+
def get_facet_values(self, route: str, facet_id: str) -> Dict[str, Any]:
|
|
751
|
+
"""Retrieves available values for a specific facet."""
|
|
752
|
+
if route.endswith("/data"):
|
|
753
|
+
route = route[: -len("/data")]
|
|
754
|
+
route = route.strip("/")
|
|
755
|
+
facet_route = f"{route}/facet/{facet_id}"
|
|
756
|
+
logging.info(f"Fetching facet values for facet '{facet_id}' in route: {route}")
|
|
757
|
+
response_data = self._send_request("GET", facet_route)
|
|
758
|
+
return response_data.get("response", {})
|
|
759
|
+
|
|
760
|
+
def get_data(
|
|
761
|
+
self,
|
|
762
|
+
route: str,
|
|
763
|
+
data_columns: List[str],
|
|
764
|
+
facets: Optional[Dict[str, Union[str, List[str]]]] = None,
|
|
765
|
+
frequency: Optional[str] = None,
|
|
766
|
+
start: Optional[str] = None,
|
|
767
|
+
end: Optional[str] = None,
|
|
768
|
+
sort: Optional[List[Dict[str, str]]] = None,
|
|
769
|
+
length: Optional[int] = None,
|
|
770
|
+
offset: Optional[int] = None,
|
|
771
|
+
output_format: Optional[Literal["json", "xml"]] = "json",
|
|
772
|
+
) -> Dict[str, Any]:
|
|
773
|
+
"""Retrieves data points from the EIA API."""
|
|
774
|
+
route = route.strip("/")
|
|
775
|
+
if not route.endswith("/data"):
|
|
776
|
+
data_route = f"{route}/data"
|
|
777
|
+
else:
|
|
778
|
+
data_route = route
|
|
779
|
+
|
|
780
|
+
logging.info(f"Fetching data for route: {data_route}")
|
|
781
|
+
|
|
782
|
+
params: Dict[str, Any] = {"data": data_columns}
|
|
783
|
+
|
|
784
|
+
# Convert FacetContainer to dict if needed
|
|
785
|
+
if facets and hasattr(facets, "_facets"):
|
|
786
|
+
facets = facets._facets
|
|
787
|
+
|
|
788
|
+
if facets:
|
|
789
|
+
params["facets"] = facets
|
|
790
|
+
if frequency:
|
|
791
|
+
params["frequency"] = frequency
|
|
792
|
+
if start:
|
|
793
|
+
params["start"] = start
|
|
794
|
+
if end:
|
|
795
|
+
params["end"] = end
|
|
796
|
+
if sort:
|
|
797
|
+
params["sort"] = sort
|
|
798
|
+
if length is not None:
|
|
799
|
+
params["length"] = length
|
|
800
|
+
if offset is not None:
|
|
801
|
+
params["offset"] = offset
|
|
802
|
+
if output_format and output_format != "json":
|
|
803
|
+
params["out"] = output_format
|
|
804
|
+
|
|
805
|
+
response_data = self._send_request("GET", data_route, params=params)
|
|
806
|
+
|
|
807
|
+
if output_format == "xml" and isinstance(response_data, str):
|
|
808
|
+
logging.warning(
|
|
809
|
+
"Received XML response as string. Consider adding XML parsing."
|
|
810
|
+
)
|
|
811
|
+
return {"raw_xml": response_data}
|
|
812
|
+
|
|
813
|
+
return response_data.get("response", {})
|
|
814
|
+
|
|
815
|
+
def get_data_endpoint(self, route_string: str) -> Data:
|
|
816
|
+
"""
|
|
817
|
+
Directly retrieves the Data object for a known, complete data route string.
|
|
818
|
+
|
|
819
|
+
This allows bypassing the chained route navigation if the exact data route is known.
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
route_string: The full route path to the data endpoint
|
|
823
|
+
(e.g., 'electricity/rto/fuel-type-data').
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
The Data object for the specified route.
|
|
827
|
+
|
|
828
|
+
Raises:
|
|
829
|
+
EIAError: If the route does not exist or does not contain data.
|
|
830
|
+
"""
|
|
831
|
+
route_string = route_string.strip("/")
|
|
832
|
+
logging.info(f"Directly accessing data endpoint metadata for: {route_string}")
|
|
833
|
+
|
|
834
|
+
# Fetch metadata for the route
|
|
835
|
+
metadata = self.get_metadata(route_string)
|
|
836
|
+
|
|
837
|
+
# Check if the route actually contains data (basic check)
|
|
838
|
+
if "data" not in metadata or "facets" not in metadata:
|
|
839
|
+
# Attempt to find the actual data sub-route if one exists
|
|
840
|
+
possible_data_route = f"{route_string}/data"
|
|
841
|
+
try:
|
|
842
|
+
logging.debug(
|
|
843
|
+
f"Route '{route_string}' might not be a data endpoint, trying '{possible_data_route}'"
|
|
844
|
+
)
|
|
845
|
+
metadata = self.get_metadata(
|
|
846
|
+
possible_data_route
|
|
847
|
+
) # Check if /data exists
|
|
848
|
+
route_string = possible_data_route # Use the /data route if it exists
|
|
849
|
+
if "data" not in metadata or "facets" not in metadata:
|
|
850
|
+
raise EIAError(
|
|
851
|
+
f"Route '{route_string}' does not appear to be a valid data endpoint."
|
|
852
|
+
)
|
|
853
|
+
except EIAError:
|
|
854
|
+
# If /data doesn't exist or is not a data endpoint either, raise error
|
|
855
|
+
raise EIAError(
|
|
856
|
+
f"Route '{route_string}' does not appear to be a valid data endpoint and '{possible_data_route}' was not found."
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Instantiate and return the Data object
|
|
860
|
+
return Data(self, route_string, metadata)
|
|
861
|
+
|
|
862
|
+
def get_data_from_url(self, url: str) -> Dict[str, Any]:
|
|
863
|
+
"""
|
|
864
|
+
Executes an EIA API v2 data request directly from a provided URL.
|
|
865
|
+
|
|
866
|
+
Parses the URL to extract the route and parameters, then sends the request.
|
|
867
|
+
This method respects the offset and length parameters in the URL and does
|
|
868
|
+
*not* perform automatic pagination.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
url: The full EIA API v2 data URL (must start with EIAClient.BASE_URL).
|
|
872
|
+
|
|
873
|
+
Returns:
|
|
874
|
+
The raw dictionary response from the API for the given URL.
|
|
875
|
+
|
|
876
|
+
Raises:
|
|
877
|
+
ValueError: If the URL is invalid or does not match the EIA API base URL.
|
|
878
|
+
EIAError: For API-related errors during the request.
|
|
879
|
+
"""
|
|
880
|
+
if not url.startswith(self.BASE_URL):
|
|
881
|
+
raise ValueError(
|
|
882
|
+
f"URL must start with the EIA API base URL: {self.BASE_URL}"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
parsed_url = urlparse(url)
|
|
886
|
+
route_path = parsed_url.path.replace(
|
|
887
|
+
self.BASE_URL.replace("https://api.eia.gov", ""), "", 1
|
|
888
|
+
).strip("/")
|
|
889
|
+
raw_params = parse_qs(parsed_url.query)
|
|
890
|
+
|
|
891
|
+
# Remove api_key from params if present, as it's handled by the client
|
|
892
|
+
raw_params.pop("api_key", None)
|
|
893
|
+
|
|
894
|
+
logging.info(
|
|
895
|
+
f"Executing request from URL. Route: {route_path}, Raw Params: {raw_params}"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
# Send the request using the parsed parameters
|
|
899
|
+
# Note: We bypass the high-level get_data and use _send_request directly
|
|
900
|
+
# because the parameter structure from parse_qs needs specific handling
|
|
901
|
+
# and we want to return the raw dict, not force a DataFrame.
|
|
902
|
+
# The _format_list_params method is designed for the structure used by Data.get,
|
|
903
|
+
# not the direct URL query string structure.
|
|
904
|
+
|
|
905
|
+
# Convert parsed params (values are lists) back to simple strings where appropriate
|
|
906
|
+
# EIA API typically doesn't expect list format for simple params like frequency, offset, length
|
|
907
|
+
processed_params = {}
|
|
908
|
+
for key, value_list in raw_params.items():
|
|
909
|
+
if (
|
|
910
|
+
isinstance(value_list, list)
|
|
911
|
+
and len(value_list) == 1
|
|
912
|
+
and not key.endswith("[]")
|
|
913
|
+
):
|
|
914
|
+
# If it's a list of one item and key doesn't suggest a list, take the single item
|
|
915
|
+
processed_params[key] = value_list[0]
|
|
916
|
+
else:
|
|
917
|
+
# Otherwise, keep the list (for facets, data, sort etc.)
|
|
918
|
+
processed_params[key] = value_list
|
|
919
|
+
|
|
920
|
+
# We pass the processed_params directly to _send_request which expects a flat dict
|
|
921
|
+
# It will add the api_key itself.
|
|
922
|
+
# The requests library handles the URL encoding of these params including the [] notation.
|
|
923
|
+
return self._send_request("GET", route_path, params=processed_params)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-eia
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client for the U.S. Energy Information Administration (EIA) API v2
|
|
5
|
+
Project-URL: Homepage, https://github.com/datons/python-eia
|
|
6
|
+
Project-URL: Repository, https://github.com/datons/python-eia.git
|
|
7
|
+
Project-URL: Issues, https://github.com/datons/python-eia/issues
|
|
8
|
+
Author-email: Jesus Lopez <jesus.lopez@datons.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Requires-Python: >=3.7
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: urllib3>=2.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black>=22.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: flake8>=3.9; extra == 'dev'
|
|
28
|
+
Requires-Dist: isort>=5.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=0.900; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=2.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=6.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: types-requests>=2.25.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Python EIA Client
|
|
36
|
+
|
|
37
|
+
A minimalist Python client for the U.S. Energy Information Administration (EIA) API v2.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install python-eia
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API Key
|
|
46
|
+
|
|
47
|
+
You must request an API key from the [EIA website](https://www.eia.gov/opendata/register.php).
|
|
48
|
+
|
|
49
|
+
Set your API key in one of two ways:
|
|
50
|
+
- Add it to a `.env` file as `EIA_API_KEY=your_token`
|
|
51
|
+
- Or pass it directly as a parameter: `EIAClient(api_key="your_token")`
|
|
52
|
+
|
|
53
|
+
## Usage Example
|
|
54
|
+
|
|
55
|
+
See [examples/1_Generic/steps/1_Download.ipynb](examples/1_Generic/steps/1_Download.ipynb) for usage instructions and examples.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT License
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
eia/__init__.py,sha256=twAaCUfzQE4vBKQ8HIH1G_VKNiWau_yYI9ou054babM,223
|
|
2
|
+
eia/client.py,sha256=O-EEYVVNSr2yGWE94-KnNjc3tD1TAgtlhwwq6gnkkOM,33514
|
|
3
|
+
python_eia-0.1.0.dist-info/METADATA,sha256=PEA93c9o1e0A_Yxz0AD-b7phE5JZ2E4R93ulqOOloZc,2051
|
|
4
|
+
python_eia-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
python_eia-0.1.0.dist-info/RECORD,,
|