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 ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ EIA API Client Library
3
+
4
+ A Python client for interacting with the U.S. Energy Information Administration (EIA) API.
5
+ """
6
+
7
+ from .client import EIAClient, EIAError
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = ["EIAClient", "EIAError"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any