From 5b47e0428025f4972fdf31567ec1d310fbf26dc1 Mon Sep 17 00:00:00 2001 From: Karl Date: Sun, 13 Sep 2026 14:59:44 -0400 Subject: [PATCH] fix(ei): return the named collection EI methods promise (#107) Every Energy Intelligence method typed `List[Dict[str, Any]]` returned `response["data"]`, but the EI controllers put their records under a *named* key inside `data`. `client.ei.rig_counts.by_basin()` returned `{"report_date": ..., "basins": [...]}` where the signature and the docstring example promised the basin list, so the documented `for basin in basins: basin["count"]` iterated dict keys. Verified live against https://api.oilpriceapi.com on 2026-09-13 with a Scale-tier key: 27 methods across seven EI resources are wrong the same way, in both the sync resources and `async_resources.py`. - `unwrap_ei_collection` / `unwrap_ei_object` / `ei_data` in `oilpriceapi/resources/ei/_envelopes.py` are now the single place that knows the envelope shape. The per-method `if "data" in response: return response["data"]` is gone, and `unwrap_well_permit_search_response` keeps its name and its error message but delegates to the shared helper instead of carrying a second copy. - A success body missing the named collection raises `OilPriceAPIError(code="MALFORMED_RESPONSE")` instead of handing back the envelope. An empty collection stays an empty list. - `ei.well_permits.get()` / `ei.frac_focus.get()` return the record rather than its `{"well_permit": ...}` wrapper. - `ei.forecasts.prices()` / `.production()` are typed `Dict[str, Any]`: both return a mapping keyed by commodity / series code, never a list. - `ei.well_permits.latest()` / `ei.frac_focus.latest()` deliberately keep returning the envelope so their pagination and freshness counters stay reachable; their docstrings now say so. - Docstring examples use the field names the API actually returns. Tests drive the real client transport (respx over httpx), not a stubbed resource, because the defect lives between the HTTP body and the return value. Valid, empty, missing-collection, malformed-row, 401/403/429 and async parity are covered, plus a source-level guard that every async EI method's return expression matches its sync twin character for character. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --- CHANGELOG.md | 49 +- oilpriceapi/async_resources.py | 299 +++--- oilpriceapi/resources/ei/__init__.py | 6 +- oilpriceapi/resources/ei/_envelopes.py | 122 +++ .../resources/ei/drilling_productivity.py | 116 +-- oilpriceapi/resources/ei/forecasts.py | 90 +- oilpriceapi/resources/ei/frac_focus.py | 157 +-- oilpriceapi/resources/ei/oil_inventories.py | 66 +- oilpriceapi/resources/ei/opec_production.py | 77 +- oilpriceapi/resources/ei/rig_counts.py | 101 +- oilpriceapi/resources/ei/well_permits.py | 134 ++- pyproject.toml | 3 + tests/unit/test_ei_envelopes.py | 910 ++++++++++++++++++ 13 files changed, 1587 insertions(+), 543 deletions(-) create mode 100644 oilpriceapi/resources/ei/_envelopes.py create mode 100644 tests/unit/test_ei_envelopes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f9b4c..a87df91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this file. +## [Unreleased] + +### Fixed + +- **Energy Intelligence collection methods now return the collection they + promise (#107).** Every EI method typed `List[Dict[str, Any]]` returned + `response["data"]` -- but the EI controllers put their records under a *named* + key inside `data`. `client.ei.rig_counts.by_basin()` returned + `{"report_date": ..., "basins": [...]}` where the signature and the docstring + example promised the basin list, so the documented + `for basin in basins: basin["count"]` iterated dict *keys*. The same defect + ran through `by_state` (`states`), `historical` (`records`), OPEC + `by_country`/`historical`/`top_producers`, oil-inventory + `by_product`/`historical`, drilling-productivity + `duc_wells`/`by_basin`/`historical`/`trends`, forecast `historical`, and + every well-permit and frac-focus collection -- 27 methods, sync and async. + Each now returns the named list, and a success body missing that list raises + `OilPriceAPIError(code="MALFORMED_RESPONSE")` instead of handing back the + envelope. An empty collection is still an empty list. +- **`ei.well_permits.get()` and `ei.frac_focus.get()` return the record, not its + wrapper.** Production nests these under `data.well_permit` / + `data.frac_focus_disclosure`, so the documented `permit["operator"]` raised + `KeyError`. +- **`ei.forecasts.prices()` and `ei.forecasts.production()` are typed + `Dict[str, Any]`.** Both return a mapping keyed by commodity / series code, + never a list; the `List[Dict[str, Any]]` annotation was wrong from the start. + No behaviour change. +- **Docstring examples across the EI resources now use the field names the API + actually returns** (`region`/`count`, not the invented `name`/`rig_count`), + verified live on 2026-09-13. + +### Changed + +- The per-method `if "data" in response: return response["data"]` repeated + through every EI resource is replaced by one shared helper + (`oilpriceapi/resources/ei/_envelopes.py`). + `unwrap_well_permit_search_response` keeps its name and its error message and + now delegates to it, so there is one unwrapping implementation rather than + two. + +**Behaviour change for callers who adapted to the bug:** code reading +`by_basin()["basins"]`, `well_permits.list()["well_permits"]` or +`well_permits.get(id)["well_permit"]` must drop that subscript. Code following +the documented signature was broken before and works now. `ei.well_permits.latest()` +and `ei.frac_focus.latest()` deliberately keep returning the envelope object so +their pagination and freshness counters stay reachable. + ## [1.14.0] - 2026-09-13 ### Fixed @@ -112,8 +159,6 @@ pass the value you want explicitly. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - ## [1.12.6] - 2026-08-11 ### Changed diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index b7f27dc..27846e7 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -18,6 +18,7 @@ search_commodity_catalog, ) from .resources._futures_slug import normalize_futures_slug +from .resources.ei._envelopes import ei_data, unwrap_ei_collection, unwrap_ei_object from .resources.ei.well_permits import unwrap_well_permit_search_response from .resources.subscriptions import SubscriptionEventsPage @@ -935,45 +936,39 @@ def __init__(self, client): async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request(method="GET", path="/v1/ei/rig_counts", params=params) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request(method="GET", path=f"/v1/ei/rig_counts/{id}") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def latest(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/rig_counts/latest") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def by_basin(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/rig_counts/by_basin", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="basins", subject="rig-count by-basin" + ) async def by_state(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/rig_counts/by_state", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="states", subject="rig-count by-state" + ) async def historical(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/rig_counts/historical", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="records", subject="rig-count historical" + ) class AsyncEIOilInventoriesResource: @@ -982,49 +977,39 @@ def __init__(self, client): async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request(method="GET", path="/v1/ei/oil_inventories", params=params) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request(method="GET", path=f"/v1/ei/oil_inventories/{id}") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def latest(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/oil_inventories/latest") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def summary(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/oil_inventories/summary") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def by_product(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/oil_inventories/by_product", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="products", subject="oil-inventory by-product" + ) async def historical(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/oil_inventories/historical", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="records", subject="oil-inventory historical" + ) async def cushing(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/oil_inventories/cushing") - if "data" in response: - return response["data"] - return response + return ei_data(response) class AsyncEIOpecProductionResource: @@ -1033,51 +1018,43 @@ def __init__(self, client): async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request(method="GET", path="/v1/ei/opec_productions", params=params) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request(method="GET", path=f"/v1/ei/opec_productions/{id}") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def latest(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/opec_productions/latest") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def total(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/opec_productions/total") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def by_country(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/opec_productions/by_country", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="countries", subject="OPEC by-country" + ) async def historical(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/opec_productions/historical", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="records", subject="OPEC historical" + ) async def top_producers(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/opec_productions/top_producers", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="producers", subject="OPEC top-producers" + ) class AsyncEIDrillingProductivityResource: @@ -1088,65 +1065,65 @@ async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities", params=params ) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request( method="GET", path=f"/v1/ei/drilling_productivities/{id}" ) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def latest(self) -> Dict[str, Any]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities/latest" ) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def summary(self) -> Dict[str, Any]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities/summary" ) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def duc_wells(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities/duc_wells", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="by_basin", + subject="drilling-productivity DUC wells", + ) async def by_basin(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities/by_basin", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="months", + subject="drilling-productivity by-basin", + ) async def historical(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities/historical", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="records", + subject="drilling-productivity historical", + ) async def trends(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/drilling_productivities/trends", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="trends", + subject="drilling-productivity trends", + ) class AsyncEIForecastsResource: @@ -1155,59 +1132,45 @@ def __init__(self, client): async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request(method="GET", path="/v1/ei/forecasts", params=params) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request(method="GET", path=f"/v1/ei/forecasts/{id}") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def latest(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/forecasts/latest") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def summary(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/forecasts/summary") - if "data" in response: - return response["data"] - return response + return ei_data(response) - async def prices(self, **params) -> List[Dict[str, Any]]: + async def prices(self, **params) -> Dict[str, Any]: response = await self.client.request( method="GET", path="/v1/ei/forecasts/prices", params=params ) - if "data" in response: - return response["data"] - return response + return ei_data(response) - async def production(self, **params) -> List[Dict[str, Any]]: + async def production(self, **params) -> Dict[str, Any]: response = await self.client.request( method="GET", path="/v1/ei/forecasts/production", params=params ) - if "data" in response: - return response["data"] - return response + return ei_data(response) async def historical(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/forecasts/historical", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="actuals", subject="forecast historical" + ) async def compare(self, **params) -> Dict[str, Any]: response = await self.client.request( method="GET", path="/v1/ei/forecasts/compare", params=params ) - if "data" in response: - return response["data"] - return response + return ei_data(response) class AsyncEIWellPermitsResource: @@ -1216,51 +1179,51 @@ def __init__(self, client): async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request(method="GET", path="/v1/ei/well-permits", params=params) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="well_permits", subject="well-permit list" + ) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request(method="GET", path=f"/v1/ei/well-permits/{id}") - if "data" in response: - return response["data"] - return response + return unwrap_ei_object( + response, key="well_permit", subject="well-permit record" + ) async def latest(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/well-permits/latest") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def summary(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/well-permits/summary") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def by_state(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/well-permits/by-state", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="well_permits", subject="well-permit by-state" + ) async def by_operator(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/well-permits/by-operator", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="well_permits", + subject="well-permit by-operator", + ) async def by_formation(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/well-permits/by-formation", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="well_permits", + subject="well-permit by-formation", + ) async def search( self, @@ -1281,76 +1244,86 @@ def __init__(self, client): async def list(self, **params) -> List[Dict[str, Any]]: response = await self.client.request(method="GET", path="/v1/ei/frac-focus", params=params) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus list", + ) async def get(self, id: str) -> Dict[str, Any]: response = await self.client.request(method="GET", path=f"/v1/ei/frac-focus/{id}") - if "data" in response: - return response["data"] - return response + return unwrap_ei_object( + response, + key="frac_focus_disclosure", + subject="frac-focus record", + ) async def latest(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/frac-focus/latest") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def summary(self) -> Dict[str, Any]: response = await self.client.request(method="GET", path="/v1/ei/frac-focus/summary") - if "data" in response: - return response["data"] - return response + return ei_data(response) async def by_state(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/frac-focus/by-state", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus by-state", + ) async def by_operator(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/frac-focus/by-operator", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus by-operator", + ) async def by_chemical(self, **params) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path="/v1/ei/frac-focus/by-chemical", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus by-chemical", + ) async def search(self, query: str, **params) -> List[Dict[str, Any]]: params["query"] = query response = await self.client.request( method="GET", path="/v1/ei/frac-focus/search", params=params ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus search", + ) async def chemicals(self, id: str) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path=f"/v1/ei/frac-focus/{id}/chemicals" ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="chemicals", subject="frac-focus chemicals" + ) async def for_well(self, api_number: str) -> List[Dict[str, Any]]: response = await self.client.request( method="GET", path=f"/v1/ei/frac-focus/for-well/{api_number}" ) - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus for-well", + ) class AsyncEnergyIntelligenceResource: @@ -1368,9 +1341,7 @@ async def well_timeline(self, api_number: str) -> Dict[str, Any]: response = await self.client.request( method="GET", path=f"/v1/ei/wells/{api_number}/timeline" ) - if "data" in response: - return response["data"] - return response + return ei_data(response) class AsyncWebhooksResource: diff --git a/oilpriceapi/resources/ei/__init__.py b/oilpriceapi/resources/ei/__init__.py index 3e72d3c..637df12 100644 --- a/oilpriceapi/resources/ei/__init__.py +++ b/oilpriceapi/resources/ei/__init__.py @@ -6,6 +6,7 @@ from typing import Any, Dict +from ._envelopes import ei_data from .drilling_productivity import EIDrillingProductivityResource from .forecasts import EIForecastsResource from .frac_focus import EIFracFocusResource @@ -54,10 +55,7 @@ def well_timeline(self, api_number: str) -> Dict[str, Any]: path=f"/v1/ei/wells/{api_number}/timeline" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) __all__ = [ diff --git a/oilpriceapi/resources/ei/_envelopes.py b/oilpriceapi/resources/ei/_envelopes.py new file mode 100644 index 0000000..f6f25ca --- /dev/null +++ b/oilpriceapi/resources/ei/_envelopes.py @@ -0,0 +1,122 @@ +"""Shared Energy Intelligence response-envelope handling (#107). + +The EI controllers use two outer envelopes: + +* ``{"data": ..., "meta": {...}}`` — rig counts, oil inventories, OPEC + production, drilling productivity, forecasts. +* ``{"status": "success", "data": {...}}`` — well permits, frac focus. + +Inside ``data``, a *collection* endpoint does not return a bare list. It +returns an object whose named key holds the list, alongside the parameters +the server echoed back (``report_date``, ``region``, ``series_code``, …). +``/v1/ei/rig_counts/by_basin`` returns ``{report_date, basins:[...]}``; the +list lives under ``basins``. + +This module is the single place that knows that. It replaces the +``if "data" in response: return response["data"]`` line that was repeated in +every EI method and that silently handed the caller the envelope object where +the signature promised a list. +""" + +from typing import Any, Dict, List, Optional + +from ...exceptions import OilPriceAPIError + +__all__ = ["ei_data", "unwrap_ei_collection", "unwrap_ei_object"] + + +def ei_data(response: Any) -> Any: + """Strip the outer ``data`` envelope when there is one. + + Used by the EI methods whose payload genuinely *is* the object or list + sitting directly under ``data``. Anything that is not a mapping is handed + back untouched, so a bare-list response still works. + """ + if isinstance(response, dict) and "data" in response: + return response["data"] + return response + + +def _locate_collection(response: Any, collection: str) -> Optional[Any]: + """Find the named collection, tolerating every shape the API has shipped.""" + if isinstance(response, list): + return response + if not isinstance(response, dict): + return None + + # A pre-envelope response that names the collection at the top level. + if "data" not in response and collection in response: + return response[collection] + + data = ei_data(response) + if isinstance(data, list): + return data + if isinstance(data, dict) and collection in data: + return data[collection] + return None + + +def unwrap_ei_collection( + response: Any, + *, + collection: str, + subject: str, +) -> List[Dict[str, Any]]: + """Return the named collection as a list of records. + + Args: + response: The decoded response body. + collection: The key the server puts the list under, e.g. ``"basins"``. + subject: Human-readable name of the endpoint, used in the error. + + Returns: + The list of records. An empty collection is an empty list. + + Raises: + OilPriceAPIError: If the collection is missing, is not a list, or + contains something other than records. A malformed success body is + reported, never silently turned into an empty result. + """ + rows = _locate_collection(response, collection) + + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + raise OilPriceAPIError( + "Malformed %s response: expected a %s list" % (subject, collection), + code="MALFORMED_RESPONSE", + raw_body=response, + ) + return rows + + +def unwrap_ei_object( + response: Any, + *, + subject: str, + key: Optional[str] = None, +) -> Dict[str, Any]: + """Return a single record from an EI response. + + Args: + response: The decoded response body. + subject: Human-readable name of the endpoint, used in the error. + key: The key the record is nested under, when the endpoint nests it + (``well_permit``, ``frac_focus_disclosure``). When the key is + absent from the payload the payload itself is returned, so the + older flat shape keeps working. + + Raises: + OilPriceAPIError: If the payload is not a record. + """ + data = ei_data(response) + + if key is not None and isinstance(data, dict) and key in data: + data = data[key] + + if not isinstance(data, dict): + raise OilPriceAPIError( + "Malformed %s response: expected a %s object" + % (subject, key or "record"), + code="MALFORMED_RESPONSE", + raw_body=response, + ) + return data diff --git a/oilpriceapi/resources/ei/drilling_productivity.py b/oilpriceapi/resources/ei/drilling_productivity.py index c807373..07a0c7d 100644 --- a/oilpriceapi/resources/ei/drilling_productivity.py +++ b/oilpriceapi/resources/ei/drilling_productivity.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List +from ._envelopes import ei_data, unwrap_ei_collection + class EIDrillingProductivityResource: """Resource for Energy Intelligence drilling productivity data.""" @@ -25,12 +27,13 @@ def list(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of drilling productivity records + List of report summaries with ``id``, ``report_month``, + ``summary`` and ``status``. Example: - >>> productivity = client.ei.drilling_productivity.list() - >>> for record in productivity: - ... print(f"{record['basin']}: {record['productivity']} bpd/rig") + >>> reports = client.ei.drilling_productivity.list() + >>> for report in reports: + ... print(f"{report['report_month']}: {report['status']}") """ response = self.client.request( method="GET", @@ -38,10 +41,7 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def get(self, id: str) -> Dict[str, Any]: """Get a specific drilling productivity record by ID. @@ -50,61 +50,56 @@ def get(self, id: str) -> Dict[str, Any]: id: Drilling productivity record ID Returns: - Drilling productivity record details + Report object with ``id``, ``report_month``, ``source``, + ``last_updated``, ``total_duc`` and ``basins``. Example: - >>> record = client.ei.drilling_productivity.get("123") - >>> print(f"Productivity: {record['productivity']} bpd/rig") + >>> report = client.ei.drilling_productivity.get("123") + >>> print(f"Total DUC: {report['total_duc']}") """ response = self.client.request( method="GET", path=f"/v1/ei/drilling_productivities/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def latest(self) -> Dict[str, Any]: """Get latest drilling productivity data. Returns: - Latest drilling productivity summary + Report object with ``id``, ``report_month``, ``source``, + ``last_updated``, ``total_duc`` and ``basins``. Example: >>> latest = client.ei.drilling_productivity.latest() - >>> print(f"Average productivity: {latest['average']} bpd/rig") + >>> print(f"Total DUC: {latest['total_duc']}") """ response = self.client.request( method="GET", path="/v1/ei/drilling_productivities/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def summary(self) -> Dict[str, Any]: """Get drilling productivity summary. Returns: - Summary statistics for drilling productivity + Summary object with ``report_month``, ``total_duc_wells``, + ``average_oil_productivity``, ``average_gas_productivity``, + ``basins`` and ``headline``. Example: >>> summary = client.ei.drilling_productivity.summary() - >>> print(f"Total production: {summary['total_production']} bpd") + >>> print(f"Total DUC wells: {summary['total_duc_wells']}") """ response = self.client.request( method="GET", path="/v1/ei/drilling_productivities/summary" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def duc_wells(self, **params) -> List[Dict[str, Any]]: """Get DUC (Drilled but Uncompleted) wells data. @@ -113,12 +108,14 @@ def duc_wells(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of DUC well records + The ``by_basin`` list from ``data``. Each record has + ``basin``, ``basin_name``, ``duc_count``, ``region`` and + ``type``. Example: >>> ducs = client.ei.drilling_productivity.duc_wells() >>> for duc in ducs: - ... print(f"{duc['basin']}: {duc['count']} DUCs") + ... print(f"{duc['basin_name']}: {duc['duc_count']} DUCs") """ response = self.client.request( method="GET", @@ -126,10 +123,11 @@ def duc_wells(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="by_basin", + subject="drilling-productivity DUC wells", + ) def by_basin(self, **params) -> List[Dict[str, Any]]: """Get drilling productivity by basin. @@ -138,12 +136,15 @@ def by_basin(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of basin productivity records + The ``months`` list from ``data`` — one entry per report month, + each with ``report_month`` and a ``basins`` list of per-basin + records. (``data['basins']`` is the echoed filter, not the + collection.) Example: - >>> basins = client.ei.drilling_productivity.by_basin() - >>> for basin in basins: - ... print(f"{basin['name']}: {basin['productivity']} bpd/rig") + >>> months = client.ei.drilling_productivity.by_basin() + >>> for month in months: + ... print(f"{month['report_month']}: {len(month['basins'])} basins") """ response = self.client.request( method="GET", @@ -151,10 +152,11 @@ def by_basin(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="months", + subject="drilling-productivity by-basin", + ) def historical(self, **params) -> List[Dict[str, Any]]: """Get historical drilling productivity data. @@ -163,12 +165,14 @@ def historical(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of historical productivity records + The ``records`` list from ``data``. Each record has + ``report_month``, ``duc_count``, ``new_well_oil_per_rig`` and + ``new_well_gas_per_rig``. Example: - >>> history = client.ei.drilling_productivity.historical() + >>> history = client.ei.drilling_productivity.historical(basin="permian") >>> for record in history: - ... print(f"{record['date']}: {record['productivity']} bpd/rig") + ... print(f"{record['report_month']}: {record['duc_count']} DUCs") """ response = self.client.request( method="GET", @@ -176,10 +180,11 @@ def historical(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="records", + subject="drilling-productivity historical", + ) def trends(self, **params) -> List[Dict[str, Any]]: """Get drilling productivity trends. @@ -188,12 +193,14 @@ def trends(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of trend data points + The ``trends`` list from ``data``. Each record has ``basin``, + ``current_duc``, ``previous_duc``, ``duc_change``, + ``duc_trend``, ``productivity_oil`` and ``productivity_gas``. Example: >>> trends = client.ei.drilling_productivity.trends() >>> for point in trends: - ... print(f"{point['date']}: {point['trend']}") + ... print(f"{point['basin']}: {point['duc_trend']}") """ response = self.client.request( method="GET", @@ -201,7 +208,8 @@ def trends(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="trends", + subject="drilling-productivity trends", + ) diff --git a/oilpriceapi/resources/ei/forecasts.py b/oilpriceapi/resources/ei/forecasts.py index 6c8a1b7..b16e97a 100644 --- a/oilpriceapi/resources/ei/forecasts.py +++ b/oilpriceapi/resources/ei/forecasts.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List +from ._envelopes import ei_data, unwrap_ei_collection + class EIForecastsResource: """Resource for Energy Intelligence forecast data.""" @@ -38,10 +40,7 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def get(self, id: str) -> Dict[str, Any]: """Get a specific forecast record by ID. @@ -61,64 +60,61 @@ def get(self, id: str) -> Dict[str, Any]: path=f"/v1/ei/forecasts/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def latest(self) -> Dict[str, Any]: """Get latest forecast data. Returns: - Latest forecast summary + Report object with ``id``, ``report_date``, ``source``, + ``last_updated``, ``summary`` and ``forecasts`` (keyed by + ``prices``, ``production`` and ``supply_demand``). Example: >>> latest = client.ei.forecasts.latest() - >>> print(f"Next month forecast: ${latest['forecast_price']}") + >>> print(latest['forecasts'].keys()) """ response = self.client.request( method="GET", path="/v1/ei/forecasts/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def summary(self) -> Dict[str, Any]: """Get forecast summary. Returns: - Summary statistics for forecasts + Summary object with ``report_month``, ``forecasts`` and + ``headline``. Example: >>> summary = client.ei.forecasts.summary() - >>> print(f"Average forecast: ${summary['average']}") + >>> print(summary['headline']) """ response = self.client.request( method="GET", path="/v1/ei/forecasts/summary" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) - def prices(self, **params) -> List[Dict[str, Any]]: + def prices(self, **params) -> Dict[str, Any]: """Get price forecasts. Args: **params: Optional query parameters for filtering Returns: - List of price forecast records + Object with ``report_month`` and ``commodities`` — a mapping + keyed by commodity (``brent``, ``wti``, ``natural_gas``), each + holding that commodity's forecast series. This endpoint returns + a mapping, not a list. Example: >>> prices = client.ei.forecasts.prices() - >>> for price in prices: - ... print(f"{price['date']}: ${price['forecast']}") + >>> for commodity, series in prices['commodities'].items(): + ... print(f"{commodity}: {len(series)} periods") """ response = self.client.request( method="GET", @@ -126,24 +122,23 @@ def prices(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) - def production(self, **params) -> List[Dict[str, Any]]: + def production(self, **params) -> Dict[str, Any]: """Get production forecasts. Args: **params: Optional query parameters for filtering Returns: - List of production forecast records + Object with ``report_month`` and ``series`` — a mapping keyed by + series code, each holding that series' forecast. This endpoint + returns a mapping, not a list. Example: >>> production = client.ei.forecasts.production() - >>> for record in production: - ... print(f"{record['date']}: {record['forecast']} bpd") + >>> for code, series in production['series'].items(): + ... print(f"{code}: {len(series)} periods") """ response = self.client.request( method="GET", @@ -151,10 +146,7 @@ def production(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def historical(self, **params) -> List[Dict[str, Any]]: """Get historical forecast data. @@ -163,12 +155,13 @@ def historical(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of historical forecast records + The ``actuals`` list from ``data``. Each record has + ``report_month``, ``period`` and ``value``. Example: - >>> history = client.ei.forecasts.historical() + >>> history = client.ei.forecasts.historical(series_code="BREPUUS") >>> for record in history: - ... print(f"{record['date']}: ${record['forecast']}") + ... print(f"{record['period']}: {record['value']}") """ response = self.client.request( method="GET", @@ -176,10 +169,9 @@ def historical(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="actuals", subject="forecast historical" + ) def compare(self, **params) -> Dict[str, Any]: """Compare forecast vs actual data. @@ -188,11 +180,12 @@ def compare(self, **params) -> Dict[str, Any]: **params: Optional query parameters for filtering Returns: - Comparison data with accuracy metrics + Object with ``series_code``, ``month1``, ``month2`` and + ``comparison``. Example: - >>> comparison = client.ei.forecasts.compare() - >>> print(f"Accuracy: {comparison['accuracy']}%") + >>> comparison = client.ei.forecasts.compare(series_code="BREPUUS") + >>> print(comparison['comparison']) """ response = self.client.request( method="GET", @@ -200,7 +193,4 @@ def compare(self, **params) -> Dict[str, Any]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) diff --git a/oilpriceapi/resources/ei/frac_focus.py b/oilpriceapi/resources/ei/frac_focus.py index 81d0a68..1cf2358 100644 --- a/oilpriceapi/resources/ei/frac_focus.py +++ b/oilpriceapi/resources/ei/frac_focus.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List +from ._envelopes import ei_data, unwrap_ei_collection, unwrap_ei_object + class EIFracFocusResource: """Resource for Energy Intelligence FracFocus data.""" @@ -25,11 +27,15 @@ def list(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of FracFocus records + The ``frac_focus_disclosures`` list from ``data``. Each record + has ``upload_key``, ``api_number``, ``state_code``, ``county``, + ``operator``, ``well_name``, ``location``, ``job``, ``water``, + ``chemicals`` and ``provenance``. Pagination lives in + ``data['meta']`` and is not returned here. Example: - >>> frac_data = client.ei.frac_focus.list() - >>> for record in frac_data: + >>> records = client.ei.frac_focus.list() + >>> for record in records: ... print(f"{record['operator']}: {record['well_name']}") """ response = self.client.request( @@ -38,10 +44,11 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus list", + ) def get(self, id: str) -> Dict[str, Any]: """Get a specific FracFocus record by ID. @@ -50,10 +57,13 @@ def get(self, id: str) -> Dict[str, Any]: id: FracFocus record ID Returns: - FracFocus record details + The disclosure record from ``data['frac_focus_disclosure']``, + with ``upload_key``, ``api_number``, ``well_name``, + ``operator``, ``location``, ``job``, ``water``, ``chemicals``, + ``additives`` and ``provenance``. Example: - >>> record = client.ei.frac_focus.get("123") + >>> record = client.ei.frac_focus.get("ec7d19aa-2004-4e39-bd88-e660230cf74e") >>> print(f"Operator: {record['operator']}") """ response = self.client.request( @@ -61,50 +71,50 @@ def get(self, id: str) -> Dict[str, Any]: path=f"/v1/ei/frac-focus/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_object( + response, + key="frac_focus_disclosure", + subject="frac-focus record", + ) def latest(self) -> Dict[str, Any]: """Get latest FracFocus data. Returns: - Latest FracFocus summary + The latest-disclosures envelope: an object with + ``frac_focus_disclosures`` (the list of records) and ``meta`` + (pagination). This endpoint returns the envelope, not a bare + list, so the pagination counters stay reachable. Example: >>> latest = client.ei.frac_focus.latest() - >>> print(f"Recent jobs: {latest['count']}") + >>> print(f"Recent jobs: {len(latest['frac_focus_disclosures'])}") """ response = self.client.request( method="GET", path="/v1/ei/frac-focus/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def summary(self) -> Dict[str, Any]: """Get FracFocus summary. Returns: - Summary statistics for FracFocus data + Object with ``period_days``, ``total_disclosures``, ``by_state``, + ``top_operators``, ``water_usage``, ``monthly_trend`` and + ``last_updated``. Example: >>> summary = client.ei.frac_focus.summary() - >>> print(f"Total jobs: {summary['total']}") + >>> print(f"Total jobs: {summary['total_disclosures']}") """ response = self.client.request( method="GET", path="/v1/ei/frac-focus/summary" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def by_state(self, **params) -> List[Dict[str, Any]]: """Get FracFocus data by state. @@ -113,12 +123,12 @@ def by_state(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of state FracFocus records + The ``frac_focus_disclosures`` list from ``data``. Example: - >>> states = client.ei.frac_focus.by_state() - >>> for state in states: - ... print(f"{state['name']}: {state['job_count']}") + >>> records = client.ei.frac_focus.by_state(state="TX") + >>> for record in records: + ... print(f"{record['county']}: {record['well_name']}") """ response = self.client.request( method="GET", @@ -126,10 +136,11 @@ def by_state(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus by-state", + ) def by_operator(self, **params) -> List[Dict[str, Any]]: """Get FracFocus data by operator. @@ -138,12 +149,12 @@ def by_operator(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of operator FracFocus records + The ``frac_focus_disclosures`` list from ``data``. Example: - >>> operators = client.ei.frac_focus.by_operator() - >>> for operator in operators: - ... print(f"{operator['name']}: {operator['job_count']}") + >>> records = client.ei.frac_focus.by_operator(operator="Chesapeake") + >>> for record in records: + ... print(f"{record['well_name']}: {record['state_code']}") """ response = self.client.request( method="GET", @@ -151,10 +162,11 @@ def by_operator(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus by-operator", + ) def by_chemical(self, **params) -> List[Dict[str, Any]]: """Get FracFocus data by chemical. @@ -163,12 +175,12 @@ def by_chemical(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of chemical usage records + The ``frac_focus_disclosures`` list from ``data``. Example: - >>> chemicals = client.ei.frac_focus.by_chemical() - >>> for chemical in chemicals: - ... print(f"{chemical['name']}: {chemical['usage_count']}") + >>> records = client.ei.frac_focus.by_chemical(cas="7732-18-5") + >>> for record in records: + ... print(f"{record['operator']}: {record['well_name']}") """ response = self.client.request( method="GET", @@ -176,10 +188,11 @@ def by_chemical(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus by-chemical", + ) def search(self, query: str, **params) -> List[Dict[str, Any]]: """Search FracFocus data. @@ -189,10 +202,10 @@ def search(self, query: str, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of matching FracFocus records + The ``frac_focus_disclosures`` list from ``data``. Example: - >>> results = client.ei.frac_focus.search("Exxon") + >>> results = client.ei.frac_focus.search("Eagle Ford") >>> for result in results: ... print(f"{result['operator']}: {result['well_name']}") """ @@ -203,10 +216,11 @@ def search(self, query: str, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus search", + ) def chemicals(self, id: str) -> List[Dict[str, Any]]: """Get chemicals for a specific FracFocus record. @@ -215,22 +229,24 @@ def chemicals(self, id: str) -> List[Dict[str, Any]]: id: FracFocus record ID Returns: - List of chemicals used in the frac job + The ``chemicals`` list from ``data``. Each record has ``cas``, + ``name``, ``mass``, ``percent_hf_job`` and + ``percent_additive``. ``additives``, ``cas_numbers`` and + ``suppliers`` sit beside it in the envelope. Example: - >>> chemicals = client.ei.frac_focus.chemicals("123") + >>> chemicals = client.ei.frac_focus.chemicals("ec7d19aa-2004-4e39-bd88-e660230cf74e") >>> for chemical in chemicals: - ... print(f"{chemical['name']}: {chemical['concentration']}%") + ... print(f"{chemical['name']}: {chemical['percent_hf_job']}%") """ response = self.client.request( method="GET", path=f"/v1/ei/frac-focus/{id}/chemicals" ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="chemicals", subject="frac-focus chemicals" + ) def for_well(self, api_number: str) -> List[Dict[str, Any]]: """Get FracFocus data for a specific well. @@ -239,19 +255,20 @@ def for_well(self, api_number: str) -> List[Dict[str, Any]]: api_number: Well API number Returns: - List of FracFocus records for the well + The ``frac_focus_disclosures`` list from ``data``. Example: - >>> well_data = client.ei.frac_focus.for_well("42-123-45678") - >>> for record in well_data: - ... print(f"{record['job_date']}: {record['operator']}") + >>> records = client.ei.frac_focus.for_well("33053090090000") + >>> for record in records: + ... print(f"{record['well_name']}: {record['job']}") """ response = self.client.request( method="GET", path=f"/v1/ei/frac-focus/for-well/{api_number}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="frac_focus_disclosures", + subject="frac-focus for-well", + ) diff --git a/oilpriceapi/resources/ei/oil_inventories.py b/oilpriceapi/resources/ei/oil_inventories.py index 031c1aa..7660854 100644 --- a/oilpriceapi/resources/ei/oil_inventories.py +++ b/oilpriceapi/resources/ei/oil_inventories.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List +from ._envelopes import ei_data, unwrap_ei_collection + class EIOilInventoriesResource: """Resource for Energy Intelligence oil inventory data.""" @@ -38,10 +40,7 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def get(self, id: str) -> Dict[str, Any]: """Get a specific oil inventory record by ID. @@ -61,51 +60,42 @@ def get(self, id: str) -> Dict[str, Any]: path=f"/v1/ei/oil_inventories/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def latest(self) -> Dict[str, Any]: """Get latest oil inventory data. Returns: - Latest oil inventory summary + Report object with ``id``, ``report_date``, ``week_ending``, + ``source``, ``last_updated``, ``summary`` and ``inventories``. Example: >>> latest = client.ei.oil_inventories.latest() - >>> print(f"Total inventory: {latest['total']} barrels") + >>> print(f"Week ending: {latest['week_ending']}") """ response = self.client.request( method="GET", path="/v1/ei/oil_inventories/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def summary(self) -> Dict[str, Any]: """Get oil inventory summary. Returns: - Summary statistics for oil inventories + Object with ``week_ending``, ``inventories`` and ``headline``. Example: >>> summary = client.ei.oil_inventories.summary() - >>> print(f"Crude: {summary['crude']} barrels") - >>> print(f"Products: {summary['products']} barrels") + >>> print(summary['headline']) """ response = self.client.request( method="GET", path="/v1/ei/oil_inventories/summary" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def by_product(self, **params) -> List[Dict[str, Any]]: """Get oil inventories by product type. @@ -114,12 +104,14 @@ def by_product(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of product inventories + The ``products`` list from ``data``. Each record has + ``product_type``, ``location``, ``volume_mmbbl``, + ``week_over_week``, ``direction`` and ``vs_five_year_avg``. Example: >>> products = client.ei.oil_inventories.by_product() >>> for product in products: - ... print(f"{product['type']}: {product['volume']} barrels") + ... print(f"{product['product_type']}: {product['volume_mmbbl']} MMbbl") """ response = self.client.request( method="GET", @@ -127,10 +119,9 @@ def by_product(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="products", subject="oil-inventory by-product" + ) def historical(self, **params) -> List[Dict[str, Any]]: """Get historical oil inventory data. @@ -139,12 +130,13 @@ def historical(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of historical inventory records + The ``records`` list from ``data``. Each record has + ``week_ending``, ``volume_mmbbl`` and ``week_over_week``. Example: >>> history = client.ei.oil_inventories.historical() >>> for record in history: - ... print(f"{record['date']}: {record['volume']} barrels") + ... print(f"{record['week_ending']}: {record['volume_mmbbl']} MMbbl") """ response = self.client.request( method="GET", @@ -152,27 +144,23 @@ def historical(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="records", subject="oil-inventory historical" + ) def cushing(self) -> Dict[str, Any]: """Get Cushing, OK oil inventory data. Returns: - Cushing inventory data + Object with ``location``, ``latest`` and ``history``. Example: >>> cushing = client.ei.oil_inventories.cushing() - >>> print(f"Cushing inventory: {cushing['volume']} barrels") + >>> print(f"Cushing: {cushing['latest']}") """ response = self.client.request( method="GET", path="/v1/ei/oil_inventories/cushing" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) diff --git a/oilpriceapi/resources/ei/opec_production.py b/oilpriceapi/resources/ei/opec_production.py index e3a157d..a45cf7c 100644 --- a/oilpriceapi/resources/ei/opec_production.py +++ b/oilpriceapi/resources/ei/opec_production.py @@ -6,6 +6,8 @@ from typing import Any, Dict, List +from ._envelopes import ei_data, unwrap_ei_collection + class EIOpecProductionResource: """Resource for Energy Intelligence OPEC production data.""" @@ -38,10 +40,7 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def get(self, id: str) -> Dict[str, Any]: """Get a specific OPEC production record by ID. @@ -61,50 +60,43 @@ def get(self, id: str) -> Dict[str, Any]: path=f"/v1/ei/opec_productions/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def latest(self) -> Dict[str, Any]: """Get latest OPEC production data. Returns: - Latest OPEC production summary + Report object with ``id``, ``report_month``, + ``publication_month``, ``production_month``, ``source``, + ``opec_total``, ``countries`` and ``headline``. Example: >>> latest = client.ei.opec_production.latest() - >>> print(f"Total OPEC production: {latest['total']} bpd") + >>> print(f"OPEC total: {latest['opec_total']} mbpd") """ response = self.client.request( method="GET", path="/v1/ei/opec_productions/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def total(self) -> Dict[str, Any]: """Get total OPEC production. Returns: - Total OPEC production data + Object with ``latest``, ``history`` and ``trend``. Example: >>> total = client.ei.opec_production.total() - >>> print(f"OPEC total: {total['production']} bpd") + >>> print(total['trend']) """ response = self.client.request( method="GET", path="/v1/ei/opec_productions/total" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def by_country(self, **params) -> List[Dict[str, Any]]: """Get OPEC production by country. @@ -113,12 +105,14 @@ def by_country(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of country production records + The ``countries`` list from ``data``. Each record has + ``country``, ``name``, ``report_month``, ``publication_month``, + ``production_month`` and ``production_mbpd``. Example: >>> countries = client.ei.opec_production.by_country() >>> for country in countries: - ... print(f"{country['name']}: {country['production']} bpd") + ... print(f"{country['name']}: {country['production_mbpd']} mbpd") """ response = self.client.request( method="GET", @@ -126,10 +120,9 @@ def by_country(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="countries", subject="OPEC by-country" + ) def historical(self, **params) -> List[Dict[str, Any]]: """Get historical OPEC production data. @@ -138,12 +131,14 @@ def historical(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of historical production records + The ``records`` list from ``data``. Each record has + ``report_month``, ``publication_month``, ``production_month`` + and ``production_mbpd``. Example: - >>> history = client.ei.opec_production.historical() + >>> history = client.ei.opec_production.historical(country="saudi_arabia") >>> for record in history: - ... print(f"{record['date']}: {record['production']} bpd") + ... print(f"{record['production_month']}: {record['production_mbpd']}") """ response = self.client.request( method="GET", @@ -151,10 +146,9 @@ def historical(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="records", subject="OPEC historical" + ) def top_producers(self, **params) -> List[Dict[str, Any]]: """Get top OPEC producers. @@ -163,12 +157,14 @@ def top_producers(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of top producer records + The ``producers`` list from ``data``. Each record has ``rank``, + ``country``, ``name``, ``production_mbpd``, ``share_of_opec``, + ``publication_month`` and ``production_month``. Example: - >>> top = client.ei.opec_production.top_producers() - >>> for producer in top: - ... print(f"{producer['country']}: {producer['production']} bpd") + >>> producers = client.ei.opec_production.top_producers() + >>> for producer in producers: + ... print(f"{producer['rank']}. {producer['name']}") """ response = self.client.request( method="GET", @@ -176,7 +172,6 @@ def top_producers(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="producers", subject="OPEC top-producers" + ) diff --git a/oilpriceapi/resources/ei/rig_counts.py b/oilpriceapi/resources/ei/rig_counts.py index 98e72d8..3f489d0 100644 --- a/oilpriceapi/resources/ei/rig_counts.py +++ b/oilpriceapi/resources/ei/rig_counts.py @@ -2,10 +2,16 @@ EI Rig Counts Resource Energy Intelligence rig count data operations. + +Envelope reference (verified live 2026-09-13): every route below returns +``{"data": ..., "meta": {...}}``. ``by_basin``, ``by_state`` and ``historical`` +put their records under a named key inside ``data``. """ from typing import Any, Dict, List +from ._envelopes import ei_data, unwrap_ei_collection + class EIRigCountsResource: """Resource for Energy Intelligence rig count data.""" @@ -19,18 +25,19 @@ def __init__(self, client): self.client = client def list(self, **params) -> List[Dict[str, Any]]: - """Get all rig count data. + """Get all rig count reports. Args: - **params: Optional query parameters for filtering + **params: Optional query parameters (``page``, ``per_page``) Returns: - List of rig count records + List of report summaries, each with ``id``, ``report_date``, + ``summary`` and ``status``. Example: - >>> rigs = client.ei.rig_counts.list() - >>> for rig in rigs: - ... print(f"{rig['basin']}: {rig['count']} rigs") + >>> reports = client.ei.rig_counts.list() + >>> for report in reports: + ... print(f"{report['report_date']}: {report['status']}") """ response = self.client.request( method="GET", @@ -38,67 +45,64 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def get(self, id: str) -> Dict[str, Any]: - """Get a specific rig count record by ID. + """Get a specific rig count report by ID. Args: - id: Rig count record ID + id: Rig count report ID Returns: - Rig count record details + Report with ``report_date``, ``us_total``, ``basins``, + ``top_states`` and ``drilling_type``. Example: - >>> rig = client.ei.rig_counts.get("123") - >>> print(f"Rig count: {rig['count']}") + >>> report = client.ei.rig_counts.get("123") + >>> print(f"US total: {report['us_total']['total_rigs']}") """ response = self.client.request( method="GET", path=f"/v1/ei/rig_counts/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def latest(self) -> Dict[str, Any]: - """Get latest rig count data. + """Get the latest rig count report. Returns: - Latest rig count summary + Report object with ``id``, ``report_date``, ``source``, + ``last_updated``, ``us_total``, ``basins`` (a mapping keyed by + basin), ``top_states`` and ``drilling_type``. Example: >>> latest = client.ei.rig_counts.latest() - >>> print(f"Total rigs: {latest['total']}") + >>> print(f"Total rigs: {latest['us_total']['total_rigs']}") """ response = self.client.request( method="GET", path="/v1/ei/rig_counts/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def by_basin(self, **params) -> List[Dict[str, Any]]: """Get rig counts by basin. Args: - **params: Optional query parameters for filtering + **params: Optional ``basins`` (comma-separated) and ``date`` Returns: - List of basin rig counts + The ``basins`` list from ``data``. Each record has ``region``, + ``region_type``, ``count``, ``week_over_week`` and + ``change_direction``. The report date itself is not part of this + list; call :meth:`latest` when you need it. Example: >>> basins = client.ei.rig_counts.by_basin() >>> for basin in basins: - ... print(f"{basin['name']}: {basin['rig_count']} rigs") + ... print(f"{basin['region']}: {basin['count']} rigs") """ response = self.client.request( method="GET", @@ -106,24 +110,25 @@ def by_basin(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="basins", subject="rig-count by-basin" + ) def by_state(self, **params) -> List[Dict[str, Any]]: """Get rig counts by state. Args: - **params: Optional query parameters for filtering + **params: Optional ``states`` (comma-separated) and ``date`` Returns: - List of state rig counts + The ``states`` list from ``data``. Each record has ``region``, + ``region_type``, ``count``, ``week_over_week`` and + ``change_direction``. Example: >>> states = client.ei.rig_counts.by_state() >>> for state in states: - ... print(f"{state['name']}: {state['rig_count']} rigs") + ... print(f"{state['region']}: {state['count']} rigs") """ response = self.client.request( method="GET", @@ -131,22 +136,23 @@ def by_state(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="states", subject="rig-count by-state" + ) def historical(self, **params) -> List[Dict[str, Any]]: - """Get historical rig count data. + """Get historical rig counts for one region. Args: - **params: Optional query parameters for filtering + **params: Optional ``region`` (default ``us``), ``start_date``, + ``end_date`` and ``limit`` Returns: - List of historical rig count records + The ``records`` list from ``data``. Each record has ``date``, + ``count`` and ``week_over_week``. Example: - >>> history = client.ei.rig_counts.historical() + >>> history = client.ei.rig_counts.historical(region="us") >>> for record in history: ... print(f"{record['date']}: {record['count']} rigs") """ @@ -156,7 +162,6 @@ def historical(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="records", subject="rig-count historical" + ) diff --git a/oilpriceapi/resources/ei/well_permits.py b/oilpriceapi/resources/ei/well_permits.py index a00600d..88ad59e 100644 --- a/oilpriceapi/resources/ei/well_permits.py +++ b/oilpriceapi/resources/ei/well_permits.py @@ -6,31 +6,19 @@ from typing import Any, Dict, List, Optional -from ...exceptions import OilPriceAPIError +from ._envelopes import ei_data, unwrap_ei_collection, unwrap_ei_object def unwrap_well_permit_search_response(response: Any) -> List[Dict[str, Any]]: - """Return a typed permit list or fail on an unknown successful shape.""" - permits: Any - if isinstance(response, list): - permits = response - elif isinstance(response, dict) and "well_permits" in response: - permits = response["well_permits"] - elif isinstance(response, dict) and isinstance(response.get("data"), dict): - data = response["data"] - permits = data.get("well_permits") if "well_permits" in data else None - elif isinstance(response, dict) and "data" in response: - permits = response["data"] - else: - permits = None - - if not isinstance(permits, list) or not all(isinstance(item, dict) for item in permits): - raise OilPriceAPIError( - "Malformed well-permit search response: expected a well_permits list", - code="MALFORMED_RESPONSE", - raw_body=response, - ) - return permits + """Return a typed permit list or fail on an unknown successful shape. + + Kept as a named entry point because it is already public; the shape + knowledge now lives in the shared EI envelope helper so search and every + other permit collection stay in step. + """ + return unwrap_ei_collection( + response, collection="well_permits", subject="well-permit search" + ) class EIWellPermitsResource: @@ -51,12 +39,17 @@ def list(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of well permit records + The ``well_permits`` list from ``data``. Each record has + ``api_number``, ``state_code``, ``county``, ``permit_number``, + ``permit_type``, ``permit_status``, ``permit_date``, + ``operator``, ``well``, ``location``, ``target`` and + ``provenance``. Pagination lives in ``data['meta']`` and is not + returned here. Example: >>> permits = client.ei.well_permits.list() >>> for permit in permits: - ... print(f"{permit['operator']}: {permit['state']}") + ... print(f"{permit['operator']['name']}: {permit['well']['name']}") """ response = self.client.request( method="GET", @@ -64,10 +57,9 @@ def list(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="well_permits", subject="well-permit list" + ) def get(self, id: str) -> Dict[str, Any]: """Get a specific well permit record by ID. @@ -76,61 +68,60 @@ def get(self, id: str) -> Dict[str, Any]: id: Well permit record ID Returns: - Well permit record details + The permit record from ``data['well_permit']``. Example: - >>> permit = client.ei.well_permits.get("123") - >>> print(f"Operator: {permit['operator']}") + >>> permit = client.ei.well_permits.get("05123534110000") + >>> print(f"Operator: {permit['operator']['name']}") """ response = self.client.request( method="GET", path=f"/v1/ei/well-permits/{id}" ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_object( + response, key="well_permit", subject="well-permit record" + ) def latest(self) -> Dict[str, Any]: """Get latest well permit data. Returns: - Latest well permit summary + The latest-permits envelope: an object with ``well_permits`` + (the list of records) and ``meta`` (pagination and freshness). + This endpoint returns the envelope, not a bare list, so the + freshness counters stay reachable. Example: >>> latest = client.ei.well_permits.latest() - >>> print(f"Recent permits: {latest['count']}") + >>> print(f"Recent permits: {len(latest['well_permits'])}") """ response = self.client.request( method="GET", path="/v1/ei/well-permits/latest" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def summary(self) -> Dict[str, Any]: """Get well permit summary. Returns: - Summary statistics for well permits + Object with ``period_days``, ``total_permits``, ``by_state``, + ``top_operators``, ``top_formations``, ``by_permit_type``, + ``weekly_trend``, ``last_updated`` and the staleness fields + ``as_of``, ``data_age_days``, ``stale`` and ``stale_states``. Example: >>> summary = client.ei.well_permits.summary() - >>> print(f"Total permits: {summary['total']}") + >>> print(f"Total permits: {summary['total_permits']}") """ response = self.client.request( method="GET", path="/v1/ei/well-permits/summary" ) - # Parse response - if "data" in response: - return response["data"] - return response + return ei_data(response) def by_state(self, **params) -> List[Dict[str, Any]]: """Get well permits by state. @@ -139,12 +130,12 @@ def by_state(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of state permit records + The ``well_permits`` list from ``data``. Example: - >>> states = client.ei.well_permits.by_state() - >>> for state in states: - ... print(f"{state['name']}: {state['permit_count']}") + >>> permits = client.ei.well_permits.by_state(state="TX") + >>> for permit in permits: + ... print(f"{permit['county']}: {permit['permit_number']}") """ response = self.client.request( method="GET", @@ -152,10 +143,9 @@ def by_state(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, collection="well_permits", subject="well-permit by-state" + ) def by_operator(self, **params) -> List[Dict[str, Any]]: """Get well permits by operator. @@ -164,12 +154,12 @@ def by_operator(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of operator permit records + The ``well_permits`` list from ``data``. Example: - >>> operators = client.ei.well_permits.by_operator() - >>> for operator in operators: - ... print(f"{operator['name']}: {operator['permit_count']}") + >>> permits = client.ei.well_permits.by_operator(operator="Chesapeake") + >>> for permit in permits: + ... print(f"{permit['well']['name']}: {permit['state_code']}") """ response = self.client.request( method="GET", @@ -177,10 +167,11 @@ def by_operator(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="well_permits", + subject="well-permit by-operator", + ) def by_formation(self, **params) -> List[Dict[str, Any]]: """Get well permits by formation. @@ -189,12 +180,12 @@ def by_formation(self, **params) -> List[Dict[str, Any]]: **params: Optional query parameters for filtering Returns: - List of formation permit records + The ``well_permits`` list from ``data``. Example: - >>> formations = client.ei.well_permits.by_formation() - >>> for formation in formations: - ... print(f"{formation['name']}: {formation['permit_count']}") + >>> permits = client.ei.well_permits.by_formation(formation="Wolfcamp") + >>> for permit in permits: + ... print(f"{permit['well']['name']}: {permit['target']}") """ response = self.client.request( method="GET", @@ -202,10 +193,11 @@ def by_formation(self, **params) -> List[Dict[str, Any]]: params=params ) - # Parse response - if "data" in response: - return response["data"] - return response + return unwrap_ei_collection( + response, + collection="well_permits", + subject="well-permit by-formation", + ) def search( self, diff --git a/pyproject.toml b/pyproject.toml index 4b2f331..5ce2a9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,9 @@ dev = [ "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", "pytest-timeout>=2.1.0", + # Intercepts httpx at the transport layer so a resource test can assert on + # the real request/response round trip instead of a stubbed client method. + "respx>=0.20.2", "jsonschema>=4.17.0,<4.27", "black>=23.0.0", # Pin <3 defensively: mypy 2.x tightened defaults and rejected the old diff --git a/tests/unit/test_ei_envelopes.py b/tests/unit/test_ei_envelopes.py new file mode 100644 index 0000000..8ab6972 --- /dev/null +++ b/tests/unit/test_ei_envelopes.py @@ -0,0 +1,910 @@ +"""Energy Intelligence resources must return what they promise (#107). + +Every fixture in this file is the real production envelope, captured live from +``https://api.oilpriceapi.com`` on 2026-09-13 with a Scale-tier key. The bodies +are trimmed to one or two rows; no key name, nesting level or type is invented. + +These tests drive the **actual client transport** (respx intercepts httpx), not +a mocked resource object, because the defect in #107 lives in the unwrapping +step between the HTTP body and the returned value. A test that stubs +``client.request`` cannot see it. +""" + +from typing import Any, Dict, List + +import httpx +import pytest +import respx + +from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI +from oilpriceapi.exceptions import ( + AuthenticationError, + OilPriceAPIError, + PermissionDeniedError, + RateLimitError, +) + +BASE_URL = "https://api.oilpriceapi.com" + +# Not a credential: a fixture string. Every request in this module is mocked. +FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"]) + +META = {"api_version": "v1", "tier_required": "reservoir_mastery", "cache_ttl": 3600} + + +def _wrapped(data: Any) -> Dict[str, Any]: + """The ``{data, meta}`` envelope used by the report-backed EI controllers.""" + return {"data": data, "meta": META} + + +def _status_wrapped(data: Any) -> Dict[str, Any]: + """The ``{status, data}`` envelope used by well-permits and frac-focus.""" + return {"status": "success", "data": data} + + +# -------------------------------------------------------------------------- +# Live-captured rows +# -------------------------------------------------------------------------- + +BASIN_ROW = { + "region": "permian", + "region_type": "basin", + "count": 267, + "week_over_week": 0, + "change_direction": "flat", +} +STATE_ROW = { + "region": "texas", + "region_type": "state", + "count": 282, + "week_over_week": 1, + "change_direction": "up", +} +RIG_HISTORY_ROW = {"date": "2026-08-28", "count": 588, "week_over_week": 0} +DUC_ROW = { + "basin": "permian", + "basin_name": "Permian", + "duc_count": 1000, + "region": "Permian", + "type": "duc", +} +DP_MONTH_ROW = {"report_month": "2026-08-01", "basins": [{"basin": "permian"}]} +DP_HISTORY_ROW = { + "report_month": "2026-08-01", + "duc_count": 1000, + "new_well_oil_per_rig": 1234, + "new_well_gas_per_rig": 5678, +} +DP_TREND_ROW = { + "basin": "permian", + "current_duc": 1000, + "previous_duc": 1010, + "duc_change": -10, + "duc_trend": "declining", + "productivity_oil": 1234, + "productivity_gas": 5678, +} +FORECAST_ACTUAL_ROW = {"report_month": "2026-08-01", "period": "2026-07", "value": 68.1} +INVENTORY_PRODUCT_ROW = { + "product_type": "crude_commercial", + "location": "us", + "volume_mmbbl": 420.1, + "week_over_week": -1.2, + "direction": "draw", + "vs_five_year_avg": -5.0, +} +INVENTORY_HISTORY_ROW = { + "week_ending": "2026-09-05", + "volume_mmbbl": 420.1, + "week_over_week": -1.2, +} +OPEC_COUNTRY_ROW = { + "country": "saudi_arabia", + "name": "Saudi Arabia", + "report_month": "2026-08-01", + "publication_month": "2026-08-01", + "production_month": "2026-07-01", + "production_mbpd": 9000.0, +} +OPEC_HISTORY_ROW = { + "report_month": "2026-08-01", + "publication_month": "2026-08-01", + "production_month": "2026-07-01", + "production_mbpd": 9000.0, +} +OPEC_PRODUCER_ROW = { + "rank": 1, + "publication_month": "2026-08-01", + "production_month": "2026-07-01", + "country": "saudi_arabia", + "name": "Saudi Arabia", + "production_mbpd": 9000.0, + "share_of_opec": 33.0, +} +PERMIT_ROW = { + "api_number": "05123534110000", + "state_code": "CO", + "county": "Weld", + "permit_number": "P-1", + "permit_type": "drill", + "permit_status": "approved", + "permit_date": "2026-09-01", + "operator": {"name": "Fixture Operating"}, + "well": {"name": "Fixture 1H"}, + "location": {"lat": 40.1, "lng": -104.7}, + "target": {"formation": "Niobrara"}, + "provenance": {"source": "cogcc"}, +} +DISCLOSURE_ROW = { + "upload_key": "ec7d19aa-2004-4e39-bd88-e660230cf74e", + "api_number": "33053090090000", + "api_number_formatted": "33-053-09009-00-00", + "state_code": "ND", + "county": "McKenzie", + "operator": {"name": "Fixture Operating"}, + "well_name": "Fixture 1H", + "location": {"lat": 47.8, "lng": -103.3}, + "job": {"start_date": "2026-08-01"}, + "water": {"total_water_volume": 400000}, + "chemicals": {"count": 26}, + "provenance": {"source": "fracfocus"}, +} +CHEMICAL_ROW = { + "cas": "7732-18-5", + "mass": 1000.0, + "name": "Water", + "percent_hf_job": 88.0, + "percent_additive": None, +} + +# -------------------------------------------------------------------------- +# The contract table: every EI method that returns a NAMED collection. +# +# (dotted resource path, method name, route, envelope body, named key, rows) +# -------------------------------------------------------------------------- + +COLLECTION_CASES: List[tuple] = [ + ( + "rig_counts", + "by_basin", + "/v1/ei/rig_counts/by_basin", + lambda rows: _wrapped({"report_date": "2026-08-28", "basins": rows}), + "basins", + [BASIN_ROW], + ), + ( + "rig_counts", + "by_state", + "/v1/ei/rig_counts/by_state", + lambda rows: _wrapped({"report_date": "2026-08-28", "states": rows}), + "states", + [STATE_ROW], + ), + ( + "rig_counts", + "historical", + "/v1/ei/rig_counts/historical", + lambda rows: _wrapped( + { + "region": "us", + "start_date": "2025-09-13", + "end_date": "2026-09-13", + "records": rows, + } + ), + "records", + [RIG_HISTORY_ROW], + ), + ( + "drilling_productivity", + "duc_wells", + "/v1/ei/drilling_productivities/duc_wells", + lambda rows: _wrapped( + { + "report_month": "2026-08-01", + "total_duc": 5000, + "by_basin": rows, + "declining_basins": [], + } + ), + "by_basin", + [DUC_ROW], + ), + ( + "drilling_productivity", + "by_basin", + "/v1/ei/drilling_productivities/by_basin", + # `basins` here is the echoed filter (a string), NOT the collection. + lambda rows: _wrapped({"basins": "all", "months": rows}), + "months", + [DP_MONTH_ROW], + ), + ( + "drilling_productivity", + "historical", + "/v1/ei/drilling_productivities/historical", + lambda rows: _wrapped( + {"basin": "permian", "basin_name": "Permian", "records": rows} + ), + "records", + [DP_HISTORY_ROW], + ), + ( + "drilling_productivity", + "trends", + "/v1/ei/drilling_productivities/trends", + lambda rows: _wrapped( + { + "report_month": "2026-08-01", + "analysis_months": 6, + "trends": rows, + "declining_duc_basins": [], + } + ), + "trends", + [DP_TREND_ROW], + ), + ( + "forecasts", + "historical", + "/v1/ei/forecasts/historical", + lambda rows: _wrapped({"series_code": "BREPUUS", "actuals": rows}), + "actuals", + [FORECAST_ACTUAL_ROW], + ), + ( + "oil_inventories", + "by_product", + "/v1/ei/oil_inventories/by_product", + lambda rows: _wrapped({"week_ending": "2026-09-05", "products": rows}), + "products", + [INVENTORY_PRODUCT_ROW], + ), + ( + "oil_inventories", + "historical", + "/v1/ei/oil_inventories/historical", + lambda rows: _wrapped( + {"product_type": "crude_commercial", "location": "us", "records": rows} + ), + "records", + [INVENTORY_HISTORY_ROW], + ), + ( + "opec_production", + "by_country", + "/v1/ei/opec_productions/by_country", + lambda rows: _wrapped( + { + "report_month": "2026-08-01", + "publication_month": "2026-08-01", + "production_month": "2026-07-01", + "countries": rows, + "opec_total": 27000.0, + } + ), + "countries", + [OPEC_COUNTRY_ROW], + ), + ( + "opec_production", + "historical", + "/v1/ei/opec_productions/historical", + lambda rows: _wrapped( + {"country": "saudi_arabia", "country_name": "Saudi Arabia", "records": rows} + ), + "records", + [OPEC_HISTORY_ROW], + ), + ( + "opec_production", + "top_producers", + "/v1/ei/opec_productions/top_producers", + lambda rows: _wrapped( + { + "report_month": "2026-08-01", + "publication_month": "2026-08-01", + "production_month": "2026-07-01", + "producers": rows, + "opec_total": 27000.0, + } + ), + "producers", + [OPEC_PRODUCER_ROW], + ), + ( + "well_permits", + "list", + "/v1/ei/well-permits", + lambda rows: _status_wrapped({"well_permits": rows, "meta": {"total_count": 1}}), + "well_permits", + [PERMIT_ROW], + ), + ( + "well_permits", + "by_state", + "/v1/ei/well-permits/by-state", + lambda rows: _status_wrapped( + {"well_permits": rows, "state": "TX", "meta": {"total_count": 1}} + ), + "well_permits", + [PERMIT_ROW], + ), + ( + "well_permits", + "by_operator", + "/v1/ei/well-permits/by-operator", + lambda rows: _status_wrapped( + {"well_permits": rows, "operator_query": "x", "meta": {"total_count": 1}} + ), + "well_permits", + [PERMIT_ROW], + ), + ( + "well_permits", + "by_formation", + "/v1/ei/well-permits/by-formation", + lambda rows: _status_wrapped( + {"well_permits": rows, "formation_query": "x", "meta": {"total_count": 1}} + ), + "well_permits", + [PERMIT_ROW], + ), + ( + "frac_focus", + "list", + "/v1/ei/frac-focus", + lambda rows: _status_wrapped( + {"frac_focus_disclosures": rows, "meta": {"total_count": 1}} + ), + "frac_focus_disclosures", + [DISCLOSURE_ROW], + ), + ( + "frac_focus", + "by_state", + "/v1/ei/frac-focus/by-state", + lambda rows: _status_wrapped( + {"frac_focus_disclosures": rows, "state": "TX", "meta": {"total_count": 1}} + ), + "frac_focus_disclosures", + [DISCLOSURE_ROW], + ), + ( + "frac_focus", + "by_operator", + "/v1/ei/frac-focus/by-operator", + lambda rows: _status_wrapped( + { + "frac_focus_disclosures": rows, + "operator_query": "x", + "meta": {"total_count": 1}, + } + ), + "frac_focus_disclosures", + [DISCLOSURE_ROW], + ), + ( + "frac_focus", + "by_chemical", + "/v1/ei/frac-focus/by-chemical", + lambda rows: _status_wrapped( + { + "frac_focus_disclosures": rows, + "chemical_query": {"cas": "7732-18-5"}, + "meta": {"total_count": 1}, + } + ), + "frac_focus_disclosures", + [DISCLOSURE_ROW], + ), +] + +# Methods that take a positional argument, kept separate so the parametrised +# cases above stay uniform. +POSITIONAL_COLLECTION_CASES: List[tuple] = [ + ( + "frac_focus", + "chemicals", + ("ec7d19aa-2004-4e39-bd88-e660230cf74e",), + "/v1/ei/frac-focus/ec7d19aa-2004-4e39-bd88-e660230cf74e/chemicals", + lambda rows: _status_wrapped( + { + "upload_key": "ec7d19aa-2004-4e39-bd88-e660230cf74e", + "api_number": "33053090090000", + "well_name": "Fixture 1H", + "operator": "Fixture Operating", + "job_start_date": None, + "chemical_count": len(rows), + "chemicals": rows, + "additives": [], + "cas_numbers": [], + "suppliers": [], + } + ), + "chemicals", + [CHEMICAL_ROW], + ), + ( + "frac_focus", + "for_well", + ("33053090090000",), + "/v1/ei/frac-focus/for-well/33053090090000", + lambda rows: _status_wrapped( + { + "api_number": "33053090090000", + "frac_focus_disclosures": rows, + "count": len(rows), + } + ), + "frac_focus_disclosures", + [DISCLOSURE_ROW], + ), + ( + "frac_focus", + "search", + ("Fixture",), + "/v1/ei/frac-focus/search", + lambda rows: _status_wrapped( + {"frac_focus_disclosures": rows, "meta": {"total_count": 1}} + ), + "frac_focus_disclosures", + [DISCLOSURE_ROW], + ), +] + +ALL_COLLECTION_CASES = [ + (res, meth, (), route, body, key, rows) + for (res, meth, route, body, key, rows) in COLLECTION_CASES +] + POSITIONAL_COLLECTION_CASES + +CASE_IDS = ["%s.%s" % (c[0], c[1]) for c in ALL_COLLECTION_CASES] + +# Single-object endpoints whose record is nested under a named key. +OBJECT_CASES = [ + ( + "well_permits", + "get", + ("05123534110000",), + "/v1/ei/well-permits/05123534110000", + _status_wrapped({"well_permit": PERMIT_ROW}), + PERMIT_ROW, + ), + ( + "frac_focus", + "get", + ("ec7d19aa-2004-4e39-bd88-e660230cf74e",), + "/v1/ei/frac-focus/ec7d19aa-2004-4e39-bd88-e660230cf74e", + _status_wrapped({"frac_focus_disclosure": DISCLOSURE_ROW}), + DISCLOSURE_ROW, + ), +] +OBJECT_IDS = ["%s.%s" % (c[0], c[1]) for c in OBJECT_CASES] + + +def _sync_method(client: OilPriceAPI, resource: str, method: str): + return getattr(getattr(client.ei, resource), method) + + +def _async_method(client: AsyncOilPriceAPI, resource: str, method: str): + return getattr(getattr(client.ei, resource), method) + + +# -------------------------------------------------------------------------- +# 1. Valid: the named collection is returned, not the envelope object +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@respx.mock +def test_named_collection_is_unwrapped(resource, method, args, route, body, key, rows): + respx.get(BASE_URL + route).mock( + return_value=httpx.Response(200, json=body(rows)) + ) + client = OilPriceAPI(api_key=FIXTURE_KEY) + + result = _sync_method(client, resource, method)(*args) + + assert result == rows, ( + "%s.%s must return the %r list, got %r" % (resource, method, key, result) + ) + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@pytest.mark.asyncio +@respx.mock +async def test_named_collection_is_unwrapped_async( + resource, method, args, route, body, key, rows +): + respx.get(BASE_URL + route).mock( + return_value=httpx.Response(200, json=body(rows)) + ) + client = AsyncOilPriceAPI(api_key=FIXTURE_KEY) + + result = await _async_method(client, resource, method)(*args) + + assert result == rows, ( + "async %s.%s must return the %r list, got %r" % (resource, method, key, result) + ) + + +# -------------------------------------------------------------------------- +# 2. Empty collection is a valid empty list, never an error +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@respx.mock +def test_empty_named_collection_returns_empty_list( + resource, method, args, route, body, key, rows +): + respx.get(BASE_URL + route).mock(return_value=httpx.Response(200, json=body([]))) + client = OilPriceAPI(api_key=FIXTURE_KEY) + + assert _sync_method(client, resource, method)(*args) == [] + + +# -------------------------------------------------------------------------- +# 3. Missing collection key is an explicit failure, never a fabricated [] +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@respx.mock +def test_missing_collection_key_raises(resource, method, args, route, body, key, rows): + broken = body(rows) + # Drop the named collection, keep everything else the server sent. + if isinstance(broken.get("data"), dict): + broken["data"].pop(key, None) + respx.get(BASE_URL + route).mock(return_value=httpx.Response(200, json=broken)) + client = OilPriceAPI(api_key=FIXTURE_KEY) + + with pytest.raises(OilPriceAPIError, match=key) as error: + _sync_method(client, resource, method)(*args) + + assert error.value.code == "MALFORMED_RESPONSE" + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@pytest.mark.asyncio +@respx.mock +async def test_missing_collection_key_raises_async( + resource, method, args, route, body, key, rows +): + broken = body(rows) + if isinstance(broken.get("data"), dict): + broken["data"].pop(key, None) + respx.get(BASE_URL + route).mock(return_value=httpx.Response(200, json=broken)) + client = AsyncOilPriceAPI(api_key=FIXTURE_KEY) + + with pytest.raises(OilPriceAPIError, match=key) as error: + await _async_method(client, resource, method)(*args) + + assert error.value.code == "MALFORMED_RESPONSE" + + +# -------------------------------------------------------------------------- +# 4. A malformed row is an explicit failure +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@respx.mock +def test_malformed_row_raises(resource, method, args, route, body, key, rows): + respx.get(BASE_URL + route).mock( + return_value=httpx.Response(200, json=body(["not-a-record"])) + ) + client = OilPriceAPI(api_key=FIXTURE_KEY) + + with pytest.raises(OilPriceAPIError, match=key) as error: + _sync_method(client, resource, method)(*args) + + assert error.value.code == "MALFORMED_RESPONSE" + + +# -------------------------------------------------------------------------- +# 5. Single-object endpoints unwrap their named record +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource,method,args,route,body,expected", OBJECT_CASES, ids=OBJECT_IDS +) +@respx.mock +def test_named_object_is_unwrapped(resource, method, args, route, body, expected): + respx.get(BASE_URL + route).mock(return_value=httpx.Response(200, json=body)) + client = OilPriceAPI(api_key=FIXTURE_KEY) + + assert _sync_method(client, resource, method)(*args) == expected + + +@pytest.mark.parametrize( + "resource,method,args,route,body,expected", OBJECT_CASES, ids=OBJECT_IDS +) +@pytest.mark.asyncio +@respx.mock +async def test_named_object_is_unwrapped_async( + resource, method, args, route, body, expected +): + respx.get(BASE_URL + route).mock(return_value=httpx.Response(200, json=body)) + client = AsyncOilPriceAPI(api_key=FIXTURE_KEY) + + assert await _async_method(client, resource, method)(*args) == expected + + +# -------------------------------------------------------------------------- +# 6. Methods whose envelope is genuinely an object keep returning the object +# (regression guard: the fix must not broad-unwrap everything) +# -------------------------------------------------------------------------- + +OBJECT_PASSTHROUGH_CASES = [ + ( + "rig_counts", + "latest", + "/v1/ei/rig_counts/latest", + _wrapped( + { + "id": "1", + "report_date": "2026-08-28", + "source": "baker_hughes", + "last_updated": "2026-09-01T00:00:00Z", + "us_total": {"total_rigs": 588}, + "basins": {"permian": {"count": 267}}, + "top_states": [{"state": "texas", "count": 282}], + "drilling_type": None, + } + ), + ), + ( + "forecasts", + "compare", + "/v1/ei/forecasts/compare", + _wrapped( + { + "series_code": "BREPUUS", + "month1": "2026-07-01", + "month2": "2026-08-01", + "comparison": [], + } + ), + ), + ( + "forecasts", + "prices", + "/v1/ei/forecasts/prices", + _wrapped( + { + "report_month": "2026-08-01", + # A mapping keyed by commodity — not a list. + "commodities": {"brent": [{"period": "2026-09", "value": 68.0}]}, + } + ), + ), + ( + "forecasts", + "production", + "/v1/ei/forecasts/production", + _wrapped({"report_month": "2026-08-01", "series": {}}), + ), + ( + "oil_inventories", + "cushing", + "/v1/ei/oil_inventories/cushing", + _wrapped({"location": "cushing", "latest": {}, "history": []}), + ), + ( + "opec_production", + "total", + "/v1/ei/opec_productions/total", + _wrapped({"latest": {}, "history": [], "trend": {}}), + ), + ( + "well_permits", + "summary", + "/v1/ei/well-permits/summary", + _status_wrapped({"period_days": 90, "total_permits": 10, "by_state": {}}), + ), + ( + "frac_focus", + "summary", + "/v1/ei/frac-focus/summary", + _status_wrapped({"period_days": 90, "total_disclosures": 10, "by_state": {}}), + ), +] +PASSTHROUGH_IDS = ["%s.%s" % (c[0], c[1]) for c in OBJECT_PASSTHROUGH_CASES] + + +@pytest.mark.parametrize( + "resource,method,route,body", OBJECT_PASSTHROUGH_CASES, ids=PASSTHROUGH_IDS +) +@respx.mock +def test_object_envelopes_are_returned_whole(resource, method, route, body): + respx.get(BASE_URL + route).mock(return_value=httpx.Response(200, json=body)) + client = OilPriceAPI(api_key=FIXTURE_KEY) + + assert _sync_method(client, resource, method)() == body["data"] + + +# -------------------------------------------------------------------------- +# 7. Auth / permission / rate-limit paths stay explicit +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "status,body,expected", + [ + (401, {"error": "Invalid API key"}, AuthenticationError), + ( + 403, + { + "error": "This endpoint requires the Scale plan", + "upgrade_url": "https://oilpriceapi.com/pricing", + }, + PermissionDeniedError, + ), + (429, {"error": "Rate limit exceeded"}, RateLimitError), + ], +) +@respx.mock +def test_error_statuses_raise_and_never_return_empty(status, body, expected): + respx.get(BASE_URL + "/v1/ei/rig_counts/by_basin").mock( + return_value=httpx.Response(status, json=body) + ) + client = OilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) + + with pytest.raises(expected): + client.ei.rig_counts.by_basin() + + +@pytest.mark.parametrize( + "status,expected", + [(401, AuthenticationError), (403, PermissionDeniedError), (429, RateLimitError)], +) +@pytest.mark.asyncio +@respx.mock +async def test_error_statuses_raise_and_never_return_empty_async(status, expected): + respx.get(BASE_URL + "/v1/ei/well-permits/by-state").mock( + return_value=httpx.Response(status, json={"error": "nope"}) + ) + client = AsyncOilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) + + with pytest.raises(expected): + await client.ei.well_permits.by_state(state="TX") + + +# -------------------------------------------------------------------------- +# 8. Sync and async must not diverge +# -------------------------------------------------------------------------- + +EI_RESOURCE_PAIRS = [ + "rig_counts", + "oil_inventories", + "opec_production", + "drilling_productivity", + "forecasts", + "well_permits", + "frac_focus", +] + + +def _public_methods(obj) -> set: + return { + name + for name in dir(obj) + if not name.startswith("_") and callable(getattr(obj, name)) + } - {"client"} + + +@pytest.mark.parametrize("resource", EI_RESOURCE_PAIRS) +def test_sync_and_async_expose_the_same_ei_methods(resource): + sync_client = OilPriceAPI(api_key=FIXTURE_KEY) + async_client = AsyncOilPriceAPI(api_key=FIXTURE_KEY) + + sync_methods = _public_methods(getattr(sync_client.ei, resource)) + async_methods = _public_methods(getattr(async_client.ei, resource)) + + assert sync_methods == async_methods, ( + "sync/async drift on ei.%s: sync-only=%s async-only=%s" + % (resource, sorted(sync_methods - async_methods), sorted(async_methods - sync_methods)) + ) + + +@pytest.mark.parametrize( + "resource,method,args,route,body,key,rows", ALL_COLLECTION_CASES, ids=CASE_IDS +) +@pytest.mark.asyncio +async def test_sync_and_async_return_identical_values( + resource, method, args, route, body, key, rows +): + payload = body(rows) + + with respx.mock: + respx.get(BASE_URL + route).mock( + return_value=httpx.Response(200, json=payload) + ) + sync_result = _sync_method(OilPriceAPI(api_key=FIXTURE_KEY), resource, method)( + *args + ) + + with respx.mock: + respx.get(BASE_URL + route).mock( + return_value=httpx.Response(200, json=payload) + ) + async_result = await _async_method( + AsyncOilPriceAPI(api_key=FIXTURE_KEY), resource, method + )(*args) + + assert sync_result == async_result + + +# -------------------------------------------------------------------------- +# 9. Source-level parity: the async copy must unwrap exactly like the sync one +# -------------------------------------------------------------------------- + +SYNC_MODULES = { + "AsyncEIRigCountsResource": "rig_counts", + "AsyncEIOilInventoriesResource": "oil_inventories", + "AsyncEIOpecProductionResource": "opec_production", + "AsyncEIDrillingProductivityResource": "drilling_productivity", + "AsyncEIForecastsResource": "forecasts", + "AsyncEIWellPermitsResource": "well_permits", + "AsyncEIFracFocusResource": "frac_focus", +} + +SYNC_CLASSES = { + "rig_counts": "EIRigCountsResource", + "oil_inventories": "EIOilInventoriesResource", + "opec_production": "EIOpecProductionResource", + "drilling_productivity": "EIDrillingProductivityResource", + "forecasts": "EIForecastsResource", + "well_permits": "EIWellPermitsResource", + "frac_focus": "EIFracFocusResource", +} + + +def _return_expression(func) -> str: + """The text of the function's trailing return statement, normalised.""" + import inspect + import textwrap + + source = textwrap.dedent(inspect.getsource(func)) + index = source.rfind("\n return ") + if index == -1: + index = source.rfind("\nreturn ") + assert index != -1, func + return " ".join(source[index:].split()) + + +@pytest.mark.parametrize("async_class,module", sorted(SYNC_MODULES.items())) +def test_async_ei_unwrapping_matches_sync_line_for_line(async_class, module): + import importlib + + async_mod = importlib.import_module("oilpriceapi.async_resources") + sync_mod = importlib.import_module("oilpriceapi.resources.ei.%s" % module) + + async_cls = getattr(async_mod, async_class) + sync_cls = getattr(sync_mod, SYNC_CLASSES[module]) + + mismatches = [] + for name in sorted(_public_methods(sync_cls)): + sync_fn = getattr(sync_cls, name) + async_fn = getattr(async_cls, name, None) + assert async_fn is not None, "%s is missing %s" % (async_class, name) + sync_expr = _return_expression(sync_fn) + async_expr = _return_expression(async_fn) + if sync_expr != async_expr: + mismatches.append((name, sync_expr, async_expr)) + + assert not mismatches, "sync/async unwrapping drift: %r" % (mismatches,)