Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog

## 0.3.0 - 2026-09-04

- Identify latest and historical requests as `oilpriceapi-openbb/<version>` and
send canonical `X-SDK-*` attribution headers without exposing credentials.
- Add the `past_year` historical period.
- Keep symbol support deliberately curated to the ten documented OpenBB-style
mappings. The API catalog changes independently, while OpenBB query choices
must remain deterministic; new mappings will be reviewed and released here.
- Add a bounded two-request production smoke for latest and `past_year` history.

This release advances the attribution work tracked in
[oilpriceapi-api#4592](https://github.com/OilpriceAPI/oilpriceapi-api/issues/4592)
and [oilpriceapi-api#6434](https://github.com/OilpriceAPI/oilpriceapi-api/issues/6434).
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ environment, rebuild its extension map as described in the

## Symbols

The package maps the following OpenBB-style symbols to OilPriceAPI codes. A
mapping means the provider can construct the request; it does not guarantee
that every API key can access that dataset.
The package deliberately exposes a curated, versioned set of OpenBB-style
symbols rather than dynamically importing `/commodities`. OpenBB query choices
must be deterministic, while the API catalog and account entitlements can
change independently. A mapping means the provider can construct the request;
it does not guarantee that every API key can access that dataset. New mappings
are reviewed and shipped in provider releases.

| Symbol | OilPriceAPI code |
| --- | --- |
Expand Down Expand Up @@ -99,7 +102,8 @@ for record in history:
print(record.date, record.price, record.currency, record.unit, record.source)
```

Supported request values are `past_day`, `past_week`, and `past_month`.
Supported request values are `past_day`, `past_week`, `past_month`, and
`past_year`.
Response timestamps, units, currencies, and source labels are taken from the
API response. The provider raises a typed error instead of inventing values
when a successful response is empty or malformed.
Expand Down Expand Up @@ -132,6 +136,16 @@ poetry run pytest -q
poetry build
```

To run the bounded production smoke (one latest request and one `past_year`
request) with a safe test credential:

```bash
OPENBB_OILPRICEAPI_API_KEY="..." poetry run python scripts/live_smoke.py
```

The smoke prints only the returned symbol and history record count; it never
prints the credential or request headers.

## License

[MIT](LICENSE)
10 changes: 4 additions & 6 deletions openbb_oilpriceapi/models/oil_historical.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
RateLimitError,
ResponseSchemaError,
)
from openbb_oilpriceapi.utils.telemetry import build_request_headers


# Supported historical periods
HISTORICAL_PERIODS = ["past_day", "past_week", "past_month"]
HISTORICAL_PERIODS = ["past_day", "past_week", "past_month", "past_year"]


class OilHistoricalQueryParams(QueryParams):
Expand All @@ -47,7 +48,7 @@ class OilHistoricalQueryParams(QueryParams):
description="The commodity symbol to fetch historical data for. "
f"Available symbols: {', '.join(AVAILABLE_SYMBOLS)}",
)
period: Literal["past_day", "past_week", "past_month"] = Field(
period: Literal["past_day", "past_week", "past_month", "past_year"] = Field(
default="past_week",
description="Versioned OilPriceAPI historical period to request.",
)
Expand Down Expand Up @@ -101,10 +102,7 @@ async def aextract_data(
"Create or manage a key at https://www.oilpriceapi.com/auth/signup"
)

headers = {
"Authorization": f"Token {api_key}",
"Accept": "application/json",
}
headers = build_request_headers(api_key)

# Map OpenBB symbol to OilPriceAPI code
oilpriceapi_code = SYMBOL_MAPPING.get(query.symbol, query.symbol)
Expand Down
6 changes: 2 additions & 4 deletions openbb_oilpriceapi/models/oil_price.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
OILPRICEAPI_BASE_URL,
REVERSE_SYMBOL_MAPPING,
)
from openbb_oilpriceapi.utils.telemetry import build_request_headers


class OilPriceAPIError(Exception):
Expand Down Expand Up @@ -190,10 +191,7 @@ async def aextract_data(
"Create or manage a key at https://www.oilpriceapi.com/auth/signup"
)

headers = {
"Authorization": f"Token {api_key}",
"Accept": "application/json",
}
headers = build_request_headers(api_key)

async with httpx.AsyncClient(timeout=30.0) as client:
# Determine endpoint based on symbol
Expand Down
20 changes: 20 additions & 0 deletions openbb_oilpriceapi/utils/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Request attribution shared by every OilPriceAPI fetcher."""

from importlib.metadata import version


SDK_NAME = "oilpriceapi-openbb"
SDK_VERSION = version("openbb-oilpriceapi")


def build_request_headers(api_key: str) -> dict[str, str]:
"""Build authenticated headers without copying credentials into attribution."""
return {
"Authorization": f"Token {api_key}",
"Accept": "application/json",
"User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
"X-SDK-Name": SDK_NAME,
"X-SDK-Version": SDK_VERSION,
"X-SDK-Language": "python",
"X-Client-Type": "sdk",
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "openbb-oilpriceapi"
version = "0.2.0"
version = "0.3.0"
description = "OpenBB provider fetchers for source-timestamped OilPriceAPI energy prices"
authors = ["OilPriceAPI <support@oilpriceapi.com>"]
license = "MIT"
Expand Down
29 changes: 29 additions & 0 deletions scripts/live_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Bounded two-request production smoke for an installed provider release."""

import asyncio
import os

from openbb_oilpriceapi.models import OilHistoricalFetcher, OilPriceAPIFetcher


async def main() -> None:
api_key = os.environ.get("OPENBB_OILPRICEAPI_API_KEY")
if not api_key:
raise SystemExit("Set OPENBB_OILPRICEAPI_API_KEY to run the live smoke.")

credentials = {"api_key": api_key}
latest = await OilPriceAPIFetcher.fetch_data({"symbol": "WTI"}, credentials)
history = await OilHistoricalFetcher.fetch_data(
{"symbol": "WTI", "period": "past_year"}, credentials
)
if not latest or not history:
raise RuntimeError("OilPriceAPI returned an empty successful response.")

print(
"OpenBB live smoke passed: "
f"latest={latest[0].symbol}, history_records={len(history)}"
)


if __name__ == "__main__":
asyncio.run(main())
29 changes: 27 additions & 2 deletions tests/test_oil_historical.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def test_valid_periods(self):
"""Test all valid periods."""
from openbb_oilpriceapi.models.oil_historical import OilHistoricalQueryParams

for period in ["past_day", "past_week", "past_month"]:
for period in ["past_day", "past_week", "past_month", "past_year"]:
params = OilHistoricalQueryParams(symbol="WTI", period=period)
assert params.period == period

Expand All @@ -37,7 +37,7 @@ def test_invalid_period_raises(self):
from pydantic import ValidationError

with pytest.raises(ValidationError):
OilHistoricalQueryParams(symbol="WTI", period="past_year")
OilHistoricalQueryParams(symbol="WTI", period="past_decade")

def test_symbol_case_insensitive(self):
"""Test that symbol is case-insensitive."""
Expand Down Expand Up @@ -274,6 +274,31 @@ async def test_successful_fetch(self, mock_api_response):
assert "past_week" in call_args[0][0]
assert "WTI_USD" in call_args[0][0]

@pytest.mark.asyncio
async def test_past_year_fetch_uses_versioned_endpoint(self, mock_api_response):
"""The added period must reach the API instead of only validating locally."""
from openbb_oilpriceapi.models.oil_historical import (
OilHistoricalFetcher,
OilHistoricalQueryParams,
)

response = MagicMock(status_code=200)
response.json.return_value = mock_api_response

with patch("httpx.AsyncClient") as mock_client:
client = AsyncMock()
client.get.return_value = response
client.__aenter__.return_value = client
client.__aexit__.return_value = None
mock_client.return_value = client

await OilHistoricalFetcher.aextract_data(
OilHistoricalQueryParams(symbol="WTI", period="past_year"),
{"api_key": "test_key"},
)

assert "/prices/past_year?" in client.get.call_args.args[0]

@pytest.mark.asyncio
async def test_auth_error_on_401(self):
"""Test that 401 response raises AuthenticationError."""
Expand Down
86 changes: 86 additions & 0 deletions tests/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Regression tests for server-visible, secret-safe SDK attribution."""

from importlib.metadata import version
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from openbb_oilpriceapi.models.oil_historical import (
OilHistoricalFetcher,
OilHistoricalQueryParams,
)
from openbb_oilpriceapi.models.oil_price import (
OilPriceAPIFetcher,
OilPriceAPIQueryParams,
)
from openbb_oilpriceapi.utils.telemetry import SDK_NAME, SDK_VERSION


EXPECTED_ATTRIBUTION = {
"User-Agent": f"oilpriceapi-openbb/{SDK_VERSION}",
"X-SDK-Name": "oilpriceapi-openbb",
"X-SDK-Version": SDK_VERSION,
"X-SDK-Language": "python",
"X-Client-Type": "sdk",
}


def _successful_response() -> MagicMock:
response = MagicMock(status_code=200)
response.json.return_value = {
"data": {
"prices": [
{
"code": "WTI_USD",
"price": 72.5,
"currency": "USD",
"unit": "per barrel",
"created_at": "2026-09-04T12:00:00Z",
}
]
}
}
return response


async def _captured_headers(
fetcher: type, query: object, secret: str
) -> dict[str, str]:
with patch("httpx.AsyncClient") as mock_client:
client = AsyncMock()
client.get.return_value = _successful_response()
client.__aenter__.return_value = client
client.__aexit__.return_value = None
mock_client.return_value = client

await fetcher.aextract_data(query, {"api_key": secret})

return client.get.call_args.kwargs["headers"]


@pytest.mark.asyncio
@pytest.mark.parametrize(
("fetcher", "query"),
[
(OilPriceAPIFetcher, OilPriceAPIQueryParams(symbol="WTI")),
(
OilHistoricalFetcher,
OilHistoricalQueryParams(symbol="WTI", period="past_year"),
),
],
)
async def test_all_request_paths_send_canonical_secret_safe_attribution(
fetcher: type, query: object
) -> None:
secret = "opa_test_secret_must_not_leak"
headers = await _captured_headers(fetcher, query, secret)

for name, value in EXPECTED_ATTRIBUTION.items():
assert headers[name] == value
assert secret not in value
assert headers["Authorization"] == f"Token {secret}"


def test_attribution_version_is_the_installed_distribution_version() -> None:
assert SDK_NAME == "oilpriceapi-openbb"
assert SDK_VERSION == version("openbb-oilpriceapi") == "0.3.0"
Loading