Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@

::: oilpriceapi.resources.drilling.DrillingIntelligenceResource

## Well Production (Beta)

::: oilpriceapi.resources.well_production.WellProductionResource

## Webhooks

::: oilpriceapi.resources.webhooks.WebhooksResource
Expand Down
3 changes: 3 additions & 0 deletions oilpriceapi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
AsyncStorageResource,
AsyncSubscriptionsResource,
AsyncWebhooksResource,
AsyncWellProductionResource,
)
from .exceptions import (
AuthenticationError,
Expand Down Expand Up @@ -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)
Expand Down
131 changes: 130 additions & 1 deletion oilpriceapi/async_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions oilpriceapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions oilpriceapi/resource_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions oilpriceapi/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .storage import StorageResource
from .subscriptions import SubscriptionsResource
from .webhooks import WebhooksResource
from .well_production import WellProductionResource

__all__ = [
"AnalysisResource",
Expand All @@ -44,4 +45,5 @@
"DataSourcesResource",
"DemoResource",
"SubscriptionsResource",
"WellProductionResource",
]
Loading
Loading