diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5c145..0a7282e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Well Production Resource (beta)**: `client.well_production` (and async mirror) covering `/v1/well-production*` — `summary()`, `states()`, `state()`, `well()`, `top_producers()`, `cycle_time()`, `cycle_time_cohorts()`. Per-well data is beta and limited to states with collected regulatory data; endpoints are gated on the Drilling Intelligence feature (403 `ENTERPRISE_REQUIRED`). Closes #50. + +### Security + +- Removed a committed API-key fallback from `tests/sdk_audit_test.py`; the audit script now reads `OILPRICEAPI_KEY`/`OILPRICEAPI_TEST_KEY` from the environment only and skips cleanly when unset. + ## [1.10.2] - 2026-07-10 ### Changed diff --git a/README.md b/README.md index 24cbabe..bb416d8 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,38 @@ print(f"Permian rigs: {permian['rig_count']}") completions = client.drilling.completions() ``` +### Well Production (Beta) + +US well production data. State/national monthly aggregates come from the +EIA API; per-well history and cycle-time analytics are **beta** and only +cover states where regulatory data has been collected — this is not a +complete US well-level production dataset. Requires a plan with the +Drilling Intelligence feature (403 `ENTERPRISE_REQUIRED` otherwise). + +```python +# National overview + top producing states +overview = client.well_production.summary() +for state in overview["top_states"]: + print(f"{state['state']}: {state['oil_bbl']:,} bbl ({state['period']})") + +# State-level production for a month +states = client.well_production.states(period="2026-04") + +# Production history for one state +tx = client.well_production.state("TX", start_date="2026-01-01") + +# Per-well history (beta; 14-digit API number, dashes OK) +well = client.well_production.well("42-285-34329-00-00") + +# Top producing wells in a state (beta) +top = client.well_production.top_producers("NM", limit=10, months=12) + +# Permit-to-production cycle times (beta) +ct = client.well_production.cycle_time(state="TX") +print(f"Median cycle: {ct['cycle_time_stats']['median_days']} days") +cohorts = client.well_production.cycle_time_cohorts(state="TX", group_by="quarter") +``` + ### Webhooks (New in v1.5.0) ```python @@ -573,6 +605,7 @@ async with AsyncOilPriceAPI() as client: - ✅ **Bunker Fuels** - Marine fuel prices across major ports - ✅ **Price Analytics** - Performance, correlations, trends, and forecasts - ✅ **Drilling Intelligence** - DUC wells, permits, completions, and basin data +- ✅ **Well Production (beta)** - State/national production aggregates, per-well history, cycle times - ✅ **Webhooks** - Manage event subscriptions and notifications - ✅ **EIA Forecasts** - Official monthly price forecasts with accuracy tracking - ✅ **Energy Intelligence** - EIA data, OPEC production, drilling productivity diff --git a/docs/reference/resources.md b/docs/reference/resources.md index e4d089b..196819c 100644 --- a/docs/reference/resources.md +++ b/docs/reference/resources.md @@ -52,6 +52,10 @@ ::: oilpriceapi.resources.drilling.DrillingIntelligenceResource +## Well Production (Beta) + +::: oilpriceapi.resources.well_production.WellProductionResource + ## Webhooks ::: oilpriceapi.resources.webhooks.WebhooksResource diff --git a/oilpriceapi/async_client.py b/oilpriceapi/async_client.py index 8557306..2c73a65 100644 --- a/oilpriceapi/async_client.py +++ b/oilpriceapi/async_client.py @@ -34,6 +34,7 @@ AsyncStorageResource, AsyncSubscriptionsResource, AsyncWebhooksResource, + AsyncWellProductionResource, ) from .exceptions import ( AuthenticationError, @@ -149,6 +150,8 @@ def __init__( self.forecasts = AsyncForecastsResource(self) self.data_quality = AsyncDataQualityResource(self) self.drilling = AsyncDrillingIntelligenceResource(self) + # US well production aggregates + per-well beta data (#50). + self.well_production = AsyncWellProductionResource(self) self.ei = AsyncEnergyIntelligenceResource(self) self.webhooks = AsyncWebhooksResource(self) self.data_sources = AsyncDataSourcesResource(self) diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index 60d188a..22fc084 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -10,7 +10,7 @@ ) from .exceptions import ValidationError from .models import DieselPrice, DieselStationsResponse, PriceAlert, Subscription, SubscriptionEvent -from .resource_validators import VALID_OPERATORS, format_date +from .resource_validators import VALID_OPERATORS, format_date, normalize_api_number from .resources._futures_slug import normalize_futures_slug from .resources.subscriptions import SubscriptionEventsPage @@ -773,6 +773,135 @@ async def basin(self, name: str) -> Dict[str, Any]: return response +class AsyncWellProductionResource: + """Async resource for US well production data (beta). + + Mirror of ``oilpriceapi.resources.well_production.WellProductionResource``. + """ + + def __init__(self, client): + self.client = client + + async def summary(self) -> Dict[str, Any]: + response = await self.client.request(method="GET", path="/v1/well-production") + if "data" in response: + return response["data"] + return response + + async def states(self, period: Optional[str] = None, **params) -> Dict[str, Any]: + if period is not None: + params["period"] = period + response = await self.client.request( + method="GET", path="/v1/well-production/states", params=params + ) + if "data" in response: + return response["data"] + return response + + async def state( + self, + code: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + **params, + ) -> Dict[str, Any]: + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + response = await self.client.request( + method="GET", path=f"/v1/well-production/states/{code}", params=params + ) + if "data" in response: + return response["data"] + return response + + async def well(self, api_number: str) -> Dict[str, Any]: + normalized = normalize_api_number(api_number) + response = await self.client.request( + method="GET", path=f"/v1/well-production/wells/{normalized}" + ) + if "data" in response: + return response["data"] + return response + + async def top_producers( + self, + state_code: str = "TX", + limit: int = 20, + months: Optional[int] = None, + **params, + ) -> Dict[str, Any]: + params["state_code"] = state_code + params["limit"] = limit + if months is not None: + params["months"] = months + response = await self.client.request( + method="GET", path="/v1/well-production/top-producers", params=params + ) + if "data" in response: + return response["data"] + return response + + async def cycle_time( + self, + state: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + operator: Optional[str] = None, + formation: Optional[str] = None, + lat: Optional[float] = None, + lng: Optional[float] = None, + radius_miles: Optional[float] = None, + **params, + ) -> Dict[str, Any]: + filters = { + "state": state, + "start_date": start_date, + "end_date": end_date, + "operator": operator, + "formation": formation, + "lat": lat, + "lng": lng, + "radius_miles": radius_miles, + } + params.update({k: v for k, v in filters.items() if v is not None}) + response = await self.client.request( + method="GET", path="/v1/well-production/cycle-time", params=params + ) + if "data" in response: + return response["data"] + return response + + async def cycle_time_cohorts( + self, + state: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + lat: Optional[float] = None, + lng: Optional[float] = None, + radius_miles: Optional[float] = None, + group_by: Optional[str] = None, + **params, + ) -> Dict[str, Any]: + filters = { + "state": state, + "start_date": start_date, + "end_date": end_date, + "lat": lat, + "lng": lng, + "radius_miles": radius_miles, + "group_by": group_by, + } + params.update({k: v for k, v in filters.items() if v is not None}) + response = await self.client.request( + method="GET", path="/v1/well-production/cycle-time/cohorts", params=params + ) + if "data" in response: + return response["data"] + return response + + # EI sub-resources class AsyncEIRigCountsResource: diff --git a/oilpriceapi/client.py b/oilpriceapi/client.py index cac71af..af2292f 100644 --- a/oilpriceapi/client.py +++ b/oilpriceapi/client.py @@ -50,6 +50,7 @@ from .resources.storage import StorageResource from .resources.subscriptions import SubscriptionsResource from .resources.webhooks import WebhooksResource +from .resources.well_production import WellProductionResource from .retry import RetryStrategy @@ -179,6 +180,8 @@ def __init__( self.forecasts = ForecastsResource(self) self.data_quality = DataQualityResource(self) self.drilling = DrillingIntelligenceResource(self) + # US well production aggregates + per-well beta data (#50). + self.well_production = WellProductionResource(self) self.ei = EnergyIntelligenceResource(self) self.webhooks = WebhooksResource(self) self.data_sources = DataSourcesResource(self) diff --git a/oilpriceapi/resource_validators.py b/oilpriceapi/resource_validators.py index 0e8f636..2ea55b8 100644 --- a/oilpriceapi/resource_validators.py +++ b/oilpriceapi/resource_validators.py @@ -53,3 +53,36 @@ def format_date(date_input: Union[str, date, datetime]) -> str: return date_input.isoformat() else: raise ValueError(f"Invalid date type: {type(date_input)}") + + +# --------------------------------------------------------------------------- +# Well production API numbers +# --------------------------------------------------------------------------- + +API_NUMBER_LENGTH = 14 + + +def normalize_api_number(api_number: str) -> str: + """Normalise a well API number to the 14-digit form the API expects. + + Strips any non-digit separators (dashes, spaces) and validates the + length client-side so callers get an immediate, descriptive error + instead of a 400 round-trip. + + Args: + api_number: A well API number, e.g. ``"42285343290000"`` or + ``"42-285-34329-00-00"``. + + Returns: + The 14-digit API number string. + + Raises: + ValueError: If the value does not contain exactly 14 digits. + """ + digits = "".join(ch for ch in str(api_number) if ch.isdigit()) + if len(digits) != API_NUMBER_LENGTH: + raise ValueError( + f"API number must be {API_NUMBER_LENGTH} digits, " + f"got {len(digits)} from {api_number!r}" + ) + return digits diff --git a/oilpriceapi/resources/__init__.py b/oilpriceapi/resources/__init__.py index efbde3a..47db0fc 100644 --- a/oilpriceapi/resources/__init__.py +++ b/oilpriceapi/resources/__init__.py @@ -23,6 +23,7 @@ from .storage import StorageResource from .subscriptions import SubscriptionsResource from .webhooks import WebhooksResource +from .well_production import WellProductionResource __all__ = [ "AnalysisResource", @@ -44,4 +45,5 @@ "DataSourcesResource", "DemoResource", "SubscriptionsResource", + "WellProductionResource", ] diff --git a/oilpriceapi/resources/well_production.py b/oilpriceapi/resources/well_production.py new file mode 100644 index 0000000..c3f5bfd --- /dev/null +++ b/oilpriceapi/resources/well_production.py @@ -0,0 +1,310 @@ +""" +Well Production Resource + +US well production data operations (beta). + +Covers the `/v1/well-production*` endpoints: national/state monthly +production aggregates (EIA API v2), per-well production history where +state regulatory data has been collected, and permit-to-production +cycle-time analytics. + +Note: well-level coverage is beta and limited to the states listed in +the summary's ``coverage.well_level_states_with_data``. This is NOT a +complete US well-level production dataset. Access requires a plan with +the Drilling Intelligence feature; other plans receive a 403 +``ENTERPRISE_REQUIRED`` error. +""" + +from typing import Any, Dict, Optional, cast + +from ..resource_validators import normalize_api_number + + +class WellProductionResource: + """Resource for US well production data (beta).""" + + def __init__(self, client: Any) -> None: + """Initialize well production resource. + + Args: + client: OilPriceAPI client instance + """ + self.client = client + + def summary(self) -> Dict[str, Any]: + """Get the national production overview. + + Returns the latest national rollup, top producing states, data + sources, and coverage metadata. + + Returns: + Summary dict with ``national``, ``top_states``, + ``data_sources``, and ``coverage`` keys. + + Example: + >>> overview = client.well_production.summary() + >>> for state in overview["top_states"]: + ... print(f"{state['state']}: {state['oil_bbl']} bbl") + """ + response = self.client.request( + method="GET", + path="/v1/well-production" + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) + + def states(self, period: Optional[str] = None, **params: Any) -> Dict[str, Any]: + """Get state-level production for a month. + + Args: + period: Optional month in ``YYYY-MM`` format. Defaults to the + latest available month. + **params: Additional query parameters. + + Returns: + Dict with ``period``, ``count``, and a ``states`` list ordered + by oil production descending. + + Example: + >>> result = client.well_production.states(period="2026-04") + >>> for state in result["states"]: + ... print(f"{state['state']}: {state['oil_bpd']} bpd") + """ + if period is not None: + params["period"] = period + response = self.client.request( + method="GET", + path="/v1/well-production/states", + params=params + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) + + def state( + self, + code: str, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + **params: Any, + ) -> Dict[str, Any]: + """Get production history for a specific state. + + Args: + code: Two-letter state code, e.g. ``"TX"``. + start_date: Optional ``YYYY-MM-DD`` range start (default: 2 years ago). + end_date: Optional ``YYYY-MM-DD`` range end (default: today). + **params: Additional query parameters. + + Returns: + Dict with ``state``, ``period`` (start/end), ``count``, and a + ``data`` list of monthly production records. + + Raises: + DataNotFoundError: If no production data exists for the state. + + Example: + >>> tx = client.well_production.state("TX", start_date="2026-01-01") + >>> for month in tx["data"]: + ... print(f"{month['period']}: {month['oil_bbl']} bbl") + """ + if start_date is not None: + params["start_date"] = start_date + if end_date is not None: + params["end_date"] = end_date + response = self.client.request( + method="GET", + path=f"/v1/well-production/states/{code}", + params=params + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) + + def well(self, api_number: str) -> Dict[str, Any]: + """Get production history for a specific well (beta). + + Args: + api_number: 14-digit API well number. Separators such as dashes + are stripped automatically (``"42-285-34329-00-00"`` is OK). + + Returns: + Dict with ``api_number``, ``operator``, ``well_name``, + ``state``, ``count``, and a ``data`` list of monthly records. + + Raises: + ValueError: If the API number is not 14 digits after + removing separators. + DataNotFoundError: If no production data exists for the well. + + Example: + >>> well = client.well_production.well("42285343290000") + >>> print(f"{well['well_name']} ({well['operator']})") + """ + normalized = normalize_api_number(api_number) + response = self.client.request( + method="GET", + path=f"/v1/well-production/wells/{normalized}" + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) + + def top_producers( + self, + state_code: str = "TX", + limit: int = 20, + months: Optional[int] = None, + **params: Any, + ) -> Dict[str, Any]: + """Get top producing wells for a state (beta). + + Args: + state_code: Two-letter state code (default ``"TX"``). + limit: Maximum wells to return (server caps at 100). + months: Optional lookback window in months (default 12). + **params: Additional query parameters. + + Returns: + Dict with ``state``, ``period``, ``count``, and a ``producers`` + list of wells with total oil/gas volumes. + + Example: + >>> top = client.well_production.top_producers("NM", limit=10) + >>> for well in top["producers"]: + ... print(f"{well['well_name']}: {well['total_oil_bbl']} bbl") + """ + params["state_code"] = state_code + params["limit"] = limit + if months is not None: + params["months"] = months + response = self.client.request( + method="GET", + path="/v1/well-production/top-producers", + params=params + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) + + def cycle_time( + self, + state: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + operator: Optional[str] = None, + formation: Optional[str] = None, + lat: Optional[float] = None, + lng: Optional[float] = None, + radius_miles: Optional[float] = None, + **params: Any, + ) -> Dict[str, Any]: + """Get permit-to-production cycle time analysis (beta). + + Args: + state: Optional two-letter state code filter. + start_date: Optional ``YYYY-MM-DD`` permit-date range start. + end_date: Optional ``YYYY-MM-DD`` permit-date range end. + operator: Optional operator name filter. + formation: Optional formation name filter. + lat: Optional latitude for a geographic cohort. + lng: Optional longitude for a geographic cohort. + radius_miles: Optional radius (miles) around lat/lng. + **params: Additional query parameters. + + Returns: + Dict with ``well_count``, ``cycle_time_stats`` (median/p25/p75/ + p90 days), ``quarterly_cohorts``, and fastest/slowest wells. + + Raises: + DataNotFoundError: If no wells match the filters. + + Example: + >>> ct = client.well_production.cycle_time(state="TX") + >>> print(f"Median: {ct['cycle_time_stats']['median_days']} days") + """ + filters: Dict[str, Any] = { + "state": state, + "start_date": start_date, + "end_date": end_date, + "operator": operator, + "formation": formation, + "lat": lat, + "lng": lng, + "radius_miles": radius_miles, + } + params.update({k: v for k, v in filters.items() if v is not None}) + response = self.client.request( + method="GET", + path="/v1/well-production/cycle-time", + params=params + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) + + def cycle_time_cohorts( + self, + state: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + lat: Optional[float] = None, + lng: Optional[float] = None, + radius_miles: Optional[float] = None, + group_by: Optional[str] = None, + **params: Any, + ) -> Dict[str, Any]: + """Compare cycle times across cohorts (beta). + + Args: + state: Optional two-letter state code filter. + start_date: Optional ``YYYY-MM-DD`` permit-date range start. + end_date: Optional ``YYYY-MM-DD`` permit-date range end. + lat: Optional latitude for a geographic cohort. + lng: Optional longitude for a geographic cohort. + radius_miles: Optional radius (miles) around lat/lng. + group_by: Cohort grouping field (default ``"quarter"``). + **params: Additional query parameters. + + Returns: + Dict with ``group_by`` and a ``cohorts`` mapping of cohort key + to well counts and cycle-time stats. + + Example: + >>> cohorts = client.well_production.cycle_time_cohorts(state="TX") + >>> for quarter, stats in cohorts["cohorts"].items(): + ... print(f"{quarter}: {stats['stats']['median_days']} days") + """ + filters: Dict[str, Any] = { + "state": state, + "start_date": start_date, + "end_date": end_date, + "lat": lat, + "lng": lng, + "radius_miles": radius_miles, + "group_by": group_by, + } + params.update({k: v for k, v in filters.items() if v is not None}) + response = self.client.request( + method="GET", + path="/v1/well-production/cycle-time/cohorts", + params=params + ) + + # Parse response + if "data" in response: + return cast(Dict[str, Any], response["data"]) + return cast(Dict[str, Any], response) diff --git a/pyproject.toml b/pyproject.toml index 461a809..2f705bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,11 @@ disable_error_code = ["no-untyped-def", "no-any-return", "no-untyped-call"] # Modules already fully strict-clean — keep them strict so they can't regress. [[tool.mypy.overrides]] -module = ["oilpriceapi.resources.forecasts", "oilpriceapi.resources.drilling"] +module = [ + "oilpriceapi.resources.forecasts", + "oilpriceapi.resources.drilling", + "oilpriceapi.resources.well_production", +] disable_error_code = [] # models.py optionally imports `dateutil` for a fallback timestamp parse. The diff --git a/tests/integration/test_live_well_production.py b/tests/integration/test_live_well_production.py new file mode 100644 index 0000000..05cbb88 --- /dev/null +++ b/tests/integration/test_live_well_production.py @@ -0,0 +1,79 @@ +""" +Live integration smoke for the well-production endpoints (#50). + +These hit the REAL OilPriceAPI and require a key in the ``OILPRICEAPI_TEST_KEY`` +environment variable. They are marked ``live`` and excluded from the default +unit gate (``--ignore=tests/integration``). They are skipped automatically when +the key is absent (e.g. on forks). + +The well-production endpoints are gated on the Drilling Intelligence feature +(HTTP 403 ``ENTERPRISE_REQUIRED`` otherwise). The CI test key is a free-tier +account, so BOTH outcomes are valid coverage here: + +* 200 -> assert the summary envelope shape (national + top_states), or +* 403 -> assert the SDK surfaces the entitlement gate as OilPriceAPIError. + +Either way the route must exist (a 404 is a real failure). +""" + +import os +import time + +import pytest + +from oilpriceapi import OilPriceAPI +from oilpriceapi.exceptions import OilPriceAPIError + +TEST_KEY = os.environ.get("OILPRICEAPI_TEST_KEY") + +pytestmark = [ + pytest.mark.live, + pytest.mark.integration, + pytest.mark.skipif( + not TEST_KEY, + reason="OILPRICEAPI_TEST_KEY not set; skipping live well-production tests", + ), +] + +# Respect the 1 req/sec rate limit. +RATE_LIMIT_SLEEP = 1.1 + + +@pytest.fixture(scope="module") +def live_client(): + client = OilPriceAPI(api_key=TEST_KEY) + yield client + client.close() + + +def test_summary_route_exists(live_client): + """GET /v1/well-production returns the summary envelope or a 403 gate.""" + time.sleep(RATE_LIMIT_SLEEP) + try: + summary = live_client.well_production.summary() + except OilPriceAPIError as exc: + # Free-tier keys hit the Drilling Intelligence plan gate — that is + # a valid outcome, but the route must exist (404 would surface as + # DataNotFoundError with status 404). + assert exc.status_code == 403, f"unexpected error: {exc}" + return + + assert "national" in summary + assert "top_states" in summary + if summary["top_states"]: + state = summary["top_states"][0] + assert "state" in state + assert "oil_bbl" in state + + +def test_states_route_exists(live_client): + """GET /v1/well-production/states returns states or a 403 gate.""" + time.sleep(RATE_LIMIT_SLEEP) + try: + result = live_client.well_production.states() + except OilPriceAPIError as exc: + assert exc.status_code == 403, f"unexpected error: {exc}" + return + + assert "states" in result + assert result["count"] == len(result["states"]) diff --git a/tests/sdk_audit_test.py b/tests/sdk_audit_test.py index 7d8febd..e506d0e 100644 --- a/tests/sdk_audit_test.py +++ b/tests/sdk_audit_test.py @@ -43,7 +43,13 @@ class PythonSDKAuditor: """Comprehensive Python SDK audit runner""" def __init__(self, api_key: str = None): - self.api_key = api_key or os.getenv('OILPRICEAPI_KEY') or '94cc54816ff442c372de223e8c33bee7f10a099ed96733d0dd2eeba449f8fb78' + # Key comes from the environment ONLY — never hardcode credentials + # in this public repo. + self.api_key = ( + api_key + or os.getenv('OILPRICEAPI_KEY') + or os.getenv('OILPRICEAPI_TEST_KEY') + ) self.results: List[TestResult] = [] self.client = None @@ -51,7 +57,7 @@ def run_all_tests(self): """Run all SDK audit tests""" print("🧪 Python SDK Audit Test Suite") print("=" * 60) - print(f"API Key: {self.api_key[:20]}...") + print("API Key: set (value masked)") print("=" * 60) print("") @@ -466,6 +472,11 @@ def print_summary(self): def main(): """Main entry point""" auditor = PythonSDKAuditor() + if not auditor.api_key: + # Same pattern as the live integration suite: skip cleanly when no + # key is available instead of failing (or shipping a fallback key). + print("OILPRICEAPI_KEY / OILPRICEAPI_TEST_KEY not set; skipping SDK audit.") + sys.exit(0) exit_code = auditor.run_all_tests() sys.exit(exit_code) diff --git a/tests/unit/test_async_well_production_resource.py b/tests/unit/test_async_well_production_resource.py new file mode 100644 index 0000000..88bf7b0 --- /dev/null +++ b/tests/unit/test_async_well_production_resource.py @@ -0,0 +1,133 @@ +""" +Unit tests for AsyncWellProductionResource (#50). + +Mirror of tests/unit/test_well_production_resource.py for the async client. +No API key required — all HTTP traffic is mocked. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from oilpriceapi import AsyncOilPriceAPI + + +SUMMARY_DATA = { + "national": { + "period": "2026-07", + "oil_bbl": 0, + "gas_mcf": 0, + "water_bbl": 0, + "boe": 0, + "days_producing": None, + "source": "market_reporting", + }, + "top_states": [ + { + "state": "TX", + "period": "2026-04", + "oil_bbl": 174743000, + "oil_bpd": 5824767, + "gas_mcf": 1164406000, + "boe": 368810667, + } + ], +} + + +@pytest.fixture +def client(): + return AsyncOilPriceAPI(api_key="test_key") + + +class TestAsyncWellProductionResource: + @pytest.mark.asyncio + async def test_summary(self, client): + mock = AsyncMock(return_value={"status": "success", "data": SUMMARY_DATA}) + with patch.object(client, "request", new=mock): + summary = await client.well_production.summary() + + assert summary["top_states"][0]["state"] == "TX" + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production" + + @pytest.mark.asyncio + async def test_states_with_period(self, client): + payload = {"data": {"period": "2026-04", "count": 0, "states": []}} + mock = AsyncMock(return_value=payload) + with patch.object(client, "request", new=mock): + result = await client.well_production.states(period="2026-04") + + assert result["count"] == 0 + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/states" + assert kwargs["params"] == {"period": "2026-04"} + + @pytest.mark.asyncio + async def test_state_detail_with_date_range(self, client): + payload = {"data": {"state": "TX", "count": 0, "data": []}} + mock = AsyncMock(return_value=payload) + with patch.object(client, "request", new=mock): + result = await client.well_production.state( + "TX", start_date="2026-01-01", end_date="2026-04-30" + ) + + assert result["state"] == "TX" + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/states/TX" + assert kwargs["params"] == {"start_date": "2026-01-01", "end_date": "2026-04-30"} + + @pytest.mark.asyncio + async def test_well_normalizes_api_number(self, client): + payload = {"data": {"api_number": "42285343290000", "count": 0, "data": []}} + mock = AsyncMock(return_value=payload) + with patch.object(client, "request", new=mock): + await client.well_production.well("42-285-34329-00-00") + + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/wells/42285343290000" + + @pytest.mark.asyncio + async def test_well_invalid_api_number_raises_before_http(self, client): + mock = AsyncMock() + with patch.object(client, "request", new=mock): + with pytest.raises(ValueError, match="14 digits"): + await client.well_production.well("123") + + mock.assert_not_called() + + @pytest.mark.asyncio + async def test_top_producers_params(self, client): + payload = {"data": {"state": "NM", "count": 0, "producers": []}} + mock = AsyncMock(return_value=payload) + with patch.object(client, "request", new=mock): + await client.well_production.top_producers("NM", limit=10, months=6) + + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/top-producers" + assert kwargs["params"] == {"state_code": "NM", "limit": 10, "months": 6} + + @pytest.mark.asyncio + async def test_cycle_time_filters(self, client): + payload = {"data": {"well_count": 0, "cycle_time_stats": {}}} + mock = AsyncMock(return_value=payload) + with patch.object(client, "request", new=mock): + await client.well_production.cycle_time(state="TX", formation="EAGLE FORD") + + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/cycle-time" + assert kwargs["params"] == {"state": "TX", "formation": "EAGLE FORD"} + + @pytest.mark.asyncio + async def test_cycle_time_cohorts_group_by(self, client): + payload = {"data": {"group_by": "quarter", "cohorts": {}}} + mock = AsyncMock(return_value=payload) + with patch.object(client, "request", new=mock): + result = await client.well_production.cycle_time_cohorts( + state="TX", group_by="quarter" + ) + + assert result["cohorts"] == {} + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/cycle-time/cohorts" + assert kwargs["params"] == {"state": "TX", "group_by": "quarter"} diff --git a/tests/unit/test_well_production_resource.py b/tests/unit/test_well_production_resource.py new file mode 100644 index 0000000..aa7a995 --- /dev/null +++ b/tests/unit/test_well_production_resource.py @@ -0,0 +1,317 @@ +""" +Unit tests for WellProductionResource (#50). + +Fixture shapes mirror live /v1/well-production responses captured 2026-07-13. +No API key required — all HTTP traffic is mocked. +""" + +from unittest.mock import Mock, patch + +import httpx +import pytest + +from oilpriceapi import OilPriceAPI +from oilpriceapi.exceptions import DataNotFoundError, OilPriceAPIError + + +SUMMARY_DATA = { + "national": { + "period": "2026-07", + "oil_bbl": 0, + "gas_mcf": 0, + "water_bbl": 0, + "boe": 0, + "days_producing": None, + "source": "market_reporting", + }, + "top_states": [ + { + "state": "TX", + "period": "2026-04", + "oil_bbl": 174743000, + "oil_bpd": 5824767, + "gas_mcf": 1164406000, + "boe": 368810667, + }, + { + "state": "NM", + "period": "2026-04", + "oil_bbl": 70985000, + "oil_bpd": 2366167, + "gas_mcf": 359485000, + "boe": 130899167, + }, + ], +} + +STATES_DATA = { + "period": "2026-04", + "count": 2, + "states": SUMMARY_DATA["top_states"], +} + +STATE_DETAIL_DATA = { + "state": "TX", + "period": {"start": "2026-01-01", "end": "2026-04-30"}, + "count": 1, + "data": [ + { + "period": "2026-01", + "oil_bbl": 172442000, + "gas_mcf": 1156809000, + "water_bbl": None, + "boe": 365243500, + "days_producing": None, + "source": "eia_api", + } + ], +} + +WELL_DATA = { + "api_number": "42285343290000", + "operator": "FW EAGLE FORD I, LLC", + "well_name": "Test Well #1", + "state": "TX", + "count": 1, + "data": [ + { + "period": "2025-09", + "oil_bbl": 12000, + "gas_mcf": 34000, + "water_bbl": 5000, + "boe": 17667, + "days_producing": 30, + "source": "state_regulatory", + } + ], +} + +CYCLE_TIME_DATA = { + "filters": {"state": "TX"}, + "well_count": 6988, + "wells_with_cycle_data": 2, + "cycle_time_stats": { + "count": 2, + "median_days": 132, + "p25_days": 46, + "p75_days": 132, + "p90_days": 132, + "min_days": 46, + "max_days": 132, + "avg_days": 89, + }, +} + +COHORTS_DATA = { + "group_by": "quarter", + "cohorts": { + "2025-Q2": { + "well_count": 1, + "wells_with_data": 1, + "stats": {"count": 1, "median_days": 132, "avg_days": 132}, + } + }, +} + + +@pytest.fixture +def client(): + """Create a test client.""" + return OilPriceAPI(api_key="test_key") + + +def _http_response(status_code, json_data): + """Build a mock httpx.Response for negative-path tests.""" + response = Mock(spec=httpx.Response) + response.status_code = status_code + response.json.return_value = json_data + response.headers = {} + response.text = str(json_data) + return response + + +class TestWellProductionResource: + """Happy-path tests (patch client.request, matching repo convention).""" + + def test_summary(self, client): + """Test national production overview.""" + with patch.object(client, "request", return_value={"status": "success", "data": SUMMARY_DATA}) as mock: + summary = client.well_production.summary() + + assert summary["national"]["period"] == "2026-07" + assert summary["top_states"][0]["state"] == "TX" + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production" + + def test_states_default_period(self, client): + """Test state-level production without a period.""" + with patch.object(client, "request", return_value={"data": STATES_DATA}) as mock: + result = client.well_production.states() + + assert result["count"] == 2 + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/states" + assert kwargs["params"] == {} + + def test_states_with_period(self, client): + """Test period param is passed as YYYY-MM.""" + with patch.object(client, "request", return_value={"data": STATES_DATA}) as mock: + result = client.well_production.states(period="2026-04") + + assert result["states"][0]["oil_bpd"] == 5824767 + _, kwargs = mock.call_args + assert kwargs["params"] == {"period": "2026-04"} + + def test_state_detail_with_date_range(self, client): + """Test per-state history with start/end dates.""" + with patch.object(client, "request", return_value={"data": STATE_DETAIL_DATA}) as mock: + result = client.well_production.state( + "TX", start_date="2026-01-01", end_date="2026-04-30" + ) + + assert result["state"] == "TX" + assert result["data"][0]["source"] == "eia_api" + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/states/TX" + assert kwargs["params"] == {"start_date": "2026-01-01", "end_date": "2026-04-30"} + + def test_well_normalizes_api_number(self, client): + """Test dashed API numbers are normalized to 14 digits.""" + with patch.object(client, "request", return_value={"data": WELL_DATA}) as mock: + well = client.well_production.well("42-285-34329-00-00") + + assert well["operator"] == "FW EAGLE FORD I, LLC" + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/wells/42285343290000" + + def test_well_invalid_api_number_raises_before_http(self, client): + """Test invalid API numbers fail client-side (no request made).""" + with patch.object(client, "request") as mock: + with pytest.raises(ValueError, match="14 digits"): + client.well_production.well("123") + + mock.assert_not_called() + + def test_top_producers_params(self, client): + """Test state_code/limit/months params.""" + payload = {"data": {"state": "NM", "count": 0, "producers": []}} + with patch.object(client, "request", return_value=payload) as mock: + result = client.well_production.top_producers("NM", limit=10, months=6) + + assert result["producers"] == [] + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/top-producers" + assert kwargs["params"] == {"state_code": "NM", "limit": 10, "months": 6} + + def test_cycle_time_filters(self, client): + """Test cycle-time filter params are compacted (None dropped).""" + with patch.object(client, "request", return_value={"data": CYCLE_TIME_DATA}) as mock: + result = client.well_production.cycle_time(state="TX", operator="FW EAGLE FORD I, LLC") + + assert result["cycle_time_stats"]["median_days"] == 132 + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/cycle-time" + assert kwargs["params"] == {"state": "TX", "operator": "FW EAGLE FORD I, LLC"} + + def test_cycle_time_geographic_cohort(self, client): + """Test lat/lng/radius params.""" + with patch.object(client, "request", return_value={"data": CYCLE_TIME_DATA}) as mock: + client.well_production.cycle_time(lat=31.9, lng=-102.1, radius_miles=25) + + _, kwargs = mock.call_args + assert kwargs["params"] == {"lat": 31.9, "lng": -102.1, "radius_miles": 25} + + def test_cycle_time_cohorts_group_by(self, client): + """Test cohort comparison with group_by.""" + with patch.object(client, "request", return_value={"data": COHORTS_DATA}) as mock: + result = client.well_production.cycle_time_cohorts(state="TX", group_by="quarter") + + assert "2025-Q2" in result["cohorts"] + _, kwargs = mock.call_args + assert kwargs["path"] == "/v1/well-production/cycle-time/cohorts" + assert kwargs["params"] == {"state": "TX", "group_by": "quarter"} + + def test_empty_successful_response(self, client): + """Test empty-but-successful payloads pass through unchanged.""" + payload = {"status": "success", "data": {"period": None, "count": 0, "states": []}} + with patch.object(client, "request", return_value=payload): + result = client.well_production.states() + + assert result["count"] == 0 + assert result["states"] == [] + + +class TestWellProductionErrors: + """Negative-path tests through the real client.request error mapping.""" + + def test_unsupported_state_raises_not_found(self, client): + """404 DATA_NOT_AVAILABLE (unsupported state) -> DataNotFoundError.""" + response = _http_response( + 404, + { + "error": { + "code": "DATA_NOT_AVAILABLE", + "message": "No production data for state: ZZ", + "status": 404, + } + }, + ) + with patch.object(client._client, "request", return_value=response): + with pytest.raises(DataNotFoundError): + client.well_production.state("ZZ") + + def test_missing_well_data_raises_not_found(self, client): + """404 for a valid-format API number with no data -> DataNotFoundError.""" + response = _http_response( + 404, + { + "error": { + "code": "DATA_NOT_AVAILABLE", + "message": "No production data for well: 99999999999999", + "status": 404, + } + }, + ) + with patch.object(client._client, "request", return_value=response): + with pytest.raises(DataNotFoundError): + client.well_production.well("99999999999999") + + def test_entitlement_gate_raises_api_error(self, client): + """403 ENTERPRISE_REQUIRED (plan gate) -> OilPriceAPIError with status 403. + + Note: the live gate is HTTP 403 with code ENTERPRISE_REQUIRED (the + drilling_intelligence feature), not 402. + """ + response = _http_response( + 403, + { + "error": { + "code": "ENTERPRISE_REQUIRED", + "message": "Well production data requires the Scale plan.", + "status": 403, + } + }, + ) + with patch.object(client._client, "request", return_value=response): + with pytest.raises(OilPriceAPIError) as exc_info: + client.well_production.summary() + + assert exc_info.value.status_code == 403 + + def test_invalid_period_raises_api_error(self, client): + """400 INVALID_PARAMETER (bad period format) -> OilPriceAPIError 400.""" + response = _http_response( + 400, + { + "error": { + "code": "INVALID_PARAMETER", + "message": "Invalid period format. Use YYYY-MM.", + "status": 400, + } + }, + ) + with patch.object(client._client, "request", return_value=response): + with pytest.raises(OilPriceAPIError) as exc_info: + client.well_production.states(period="bogus") + + assert exc_info.value.status_code == 400