From ded4f0f7ac51d161930acf05d8f66233c2a1146c Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Fri, 4 Sep 2026 08:18:01 -0400 Subject: [PATCH] feat: identify OpenBB requests and add past-year history --- CHANGELOG.md | 15 ++++ README.md | 22 +++++- openbb_oilpriceapi/models/oil_historical.py | 10 +-- openbb_oilpriceapi/models/oil_price.py | 6 +- openbb_oilpriceapi/utils/telemetry.py | 20 +++++ pyproject.toml | 2 +- scripts/live_smoke.py | 29 +++++++ tests/test_oil_historical.py | 29 ++++++- tests/test_telemetry.py | 86 +++++++++++++++++++++ 9 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 openbb_oilpriceapi/utils/telemetry.py create mode 100644 scripts/live_smoke.py create mode 100644 tests/test_telemetry.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3a151c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.3.0 - 2026-09-04 + +- Identify latest and historical requests as `oilpriceapi-openbb/` 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). diff --git a/README.md b/README.md index 018f2a9..e32cbc0 100644 --- a/README.md +++ b/README.md @@ -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 | | --- | --- | @@ -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. @@ -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) diff --git a/openbb_oilpriceapi/models/oil_historical.py b/openbb_oilpriceapi/models/oil_historical.py index 5222a4b..84efef3 100644 --- a/openbb_oilpriceapi/models/oil_historical.py +++ b/openbb_oilpriceapi/models/oil_historical.py @@ -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): @@ -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.", ) @@ -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) diff --git a/openbb_oilpriceapi/models/oil_price.py b/openbb_oilpriceapi/models/oil_price.py index 6031d7c..6e245e4 100644 --- a/openbb_oilpriceapi/models/oil_price.py +++ b/openbb_oilpriceapi/models/oil_price.py @@ -22,6 +22,7 @@ OILPRICEAPI_BASE_URL, REVERSE_SYMBOL_MAPPING, ) +from openbb_oilpriceapi.utils.telemetry import build_request_headers class OilPriceAPIError(Exception): @@ -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 diff --git a/openbb_oilpriceapi/utils/telemetry.py b/openbb_oilpriceapi/utils/telemetry.py new file mode 100644 index 0000000..0c6dd09 --- /dev/null +++ b/openbb_oilpriceapi/utils/telemetry.py @@ -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", + } diff --git a/pyproject.toml b/pyproject.toml index 545f524..43edd01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "MIT" diff --git a/scripts/live_smoke.py b/scripts/live_smoke.py new file mode 100644 index 0000000..a6152df --- /dev/null +++ b/scripts/live_smoke.py @@ -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()) diff --git a/tests/test_oil_historical.py b/tests/test_oil_historical.py index f73e270..1509341 100644 --- a/tests/test_oil_historical.py +++ b/tests/test_oil_historical.py @@ -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 @@ -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.""" @@ -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.""" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..b2f5462 --- /dev/null +++ b/tests/test_telemetry.py @@ -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"