Skip to content

Commit f7b9263

Browse files
authored
feat: validate dates and guide commodity codes (#76)
1 parent 2050788 commit f7b9263

18 files changed

Lines changed: 596 additions & 137 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add sync and async `client.commodities.search(...)`, backed by the current
13+
API catalog rather than a bundled commodity-code list.
14+
- Expose bounded, credential-redacted `suggestions` and `invalid_codes` from
15+
nested invalid-code error responses.
16+
1017
### Changed
1118

19+
- Date-bearing resources now reject malformed or impossible `YYYY-MM-DD`
20+
strings locally while leaving well-formed range semantics to the API.
1221
- Historical DataFrame helpers now accept a `per_page` value from 1 to 1000
1322
and fetch all pages automatically. The `client.prices.to_dataframe(...)`
1423
convenience path forwards the same option for date-range queries.

‎README.md‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,24 @@ The same `per_page` behavior applies to date-range queries through
162162
[DataFrames and pagination guide](docs/DATAFRAMES.md) for the complete
163163
contract.
164164

165+
## Dates and Commodity Codes
166+
167+
Date strings must be real calendar dates in exact `YYYY-MM-DD` form. Malformed
168+
values are rejected before an API request, while well-formed ranges are still
169+
validated authoritatively by the server.
170+
171+
Search the current API catalog instead of maintaining a local code list:
172+
173+
```python
174+
matches = client.commodities.search("brent crude", limit=5)
175+
print([commodity["code"] for commodity in matches])
176+
```
177+
178+
Invalid-code API responses expose sanitized recovery values through
179+
`error.suggestions` and `error.invalid_codes`. See the
180+
[dates and commodity-code guide](docs/CODE_GUIDANCE.md) for sync/async examples
181+
and failure behavior.
182+
165183
## Recovery
166184

167185
The package exposes typed errors for the customer-recoverable boundaries:
@@ -199,10 +217,10 @@ except OilPriceAPIError as error:
199217

200218
All non-2xx responses share the same `OilPriceAPIError` attributes, including
201219
`status_code`, `code`, `request_id`, plan/feature recovery fields, retry
202-
metadata, sanitized response `headers`, `raw_body`, and `raw_text`. Canonical
203-
nested and legacy flat API error envelopes are normalized into that contract.
204-
Transport failures use `NetworkError`; timeouts remain the more specific
205-
`TimeoutError`.
220+
metadata, commodity `suggestions` and `invalid_codes`, sanitized response
221+
`headers`, `raw_body`, and `raw_text`. Canonical nested, fail/data, and legacy
222+
flat API error envelopes are normalized into that contract. Transport failures
223+
use `NetworkError`; timeouts remain the more specific `TimeoutError`.
206224

207225
Executable recovery examples cover 401, 403, 429, and timeout responses under
208226
[`examples/snippets/`](examples/snippets/). Empty or malformed successful

‎docs/CODE_GUIDANCE.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Dates and commodity-code guidance
2+
3+
## Strict date syntax
4+
5+
SDK methods that accept dates validate each supplied value before sending a
6+
request. Strings must be a real calendar date in exact `YYYY-MM-DD` form.
7+
`datetime.date` and `datetime.datetime` values are also accepted and normalized
8+
to that form.
9+
10+
```python
11+
from datetime import date
12+
13+
client.historical.get(
14+
"BRENT_CRUDE_USD",
15+
start_date=date(2026, 1, 1),
16+
end_date="2026-01-31",
17+
)
18+
```
19+
20+
Malformed values such as `2026-01-010`, `2026-1-10`, impossible calendar
21+
dates, empty strings, and datetime strings are rejected locally with
22+
`ValueError`; no API request is made. A range whose individual dates are valid
23+
but whose ordering or business meaning is invalid is still sent to the API so
24+
the server remains the authority for range semantics.
25+
26+
## Search the current catalog
27+
28+
Use `commodities.search()` when a code is unknown. Each search fetches the
29+
current API catalog and ranks matches across code, name, category, description,
30+
currency, unit, and source. The SDK does not bundle a hand-maintained code list.
31+
32+
```python
33+
matches = client.commodities.search("brent crude", limit=5)
34+
for commodity in matches:
35+
print(commodity["code"], commodity.get("name"))
36+
```
37+
38+
The async client provides the same behavior:
39+
40+
```python
41+
matches = await client.commodities.search("natural gas")
42+
```
43+
44+
No match or an empty catalog returns `[]`. Authentication, network, timeout,
45+
and other API failures are not converted into an empty result; their existing
46+
typed SDK exceptions propagate so callers can distinguish failure from “no
47+
matches.”
48+
49+
## Recover from an invalid code
50+
51+
When the API includes commodity suggestions in an error response, the SDK
52+
exposes bounded string values on `error.suggestions`. Invalid submitted codes
53+
are available on `error.invalid_codes`.
54+
55+
```python
56+
from oilpriceapi import OilPriceAPIError
57+
58+
try:
59+
client.prices.get("BRENNT")
60+
except OilPriceAPIError as error:
61+
if error.code == "invalid_code" and error.suggestions:
62+
print("Try one of:", ", ".join(error.suggestions))
63+
else:
64+
raise
65+
```
66+
67+
The response parser ignores unexpected non-string suggestion values, limits
68+
the number and length of values retained, and applies the same credential
69+
redaction used for other error diagnostics.

‎docs/index.md‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ prices = client.prices.get_multiple([
5353

5454
### Historical Data
5555

56-
Access years of historical price data for backtesting and analysis:
56+
Access years of historical price data for backtesting and analysis:
5757

5858
```python
5959
# Get historical data
@@ -76,6 +76,10 @@ controls request size rather than total results. See
7676
boundary behavior.
7777

7878
**[Learn about historical endpoints →](https://docs.oilpriceapi.com/api-reference/historical)**
79+
80+
Date strings use strict `YYYY-MM-DD` syntax and are checked before a request.
81+
See **[Dates and commodity codes →](CODE_GUIDANCE.md)** for validation and
82+
recovery behavior.
7983

8084
### Technical Analysis
8185

@@ -134,24 +138,17 @@ Embed live commodity price widgets and charts in your applications.
134138

135139
**[Explore integration guides →](https://docs.oilpriceapi.com/integrations)**
136140

137-
## 📊 Available Commodities
138-
139-
### Crude Oil
140-
- **Brent Crude** (`BRENT_CRUDE_USD`) - International benchmark
141-
- **WTI** (`WTI_USD`) - US benchmark
142-
- **Dubai Crude** (`DUBAI_CRUDE_USD`) - Middle East benchmark
143-
144-
### Natural Gas
145-
- **Natural Gas** (`NATURAL_GAS_USD`) - Henry Hub spot price
146-
- **LNG** (`LNG_USD`) - Liquefied natural gas
147-
148-
### Refined Products
149-
- **Diesel** (`DIESEL_USD`)
150-
- **Gasoline** (`GASOLINE_USD`)
151-
- **Heating Oil** (`HEATING_OIL_USD`)
152-
- **Jet Fuel** (`JET_FUEL_USD`)
153-
154-
**[View complete commodity list →](https://docs.oilpriceapi.com/commodities)**
141+
## 📊 Find Commodity Codes
142+
143+
Search the current API catalog so an integration does not depend on a stale
144+
code list:
145+
146+
```python
147+
matches = client.commodities.search("brent crude", limit=5)
148+
print([commodity["code"] for commodity in matches])
149+
```
150+
151+
**[View dates and commodity-code guidance →](CODE_GUIDANCE.md)**
155152

156153
## 🔧 Advanced Configuration
157154

@@ -202,6 +199,8 @@ except RateLimitError as error:
202199
except DataNotFoundError:
203200
print("Commodity not found")
204201
except OilPriceAPIError as error:
202+
if error.code == "invalid_code" and error.suggestions:
203+
print("Try one of:", ", ".join(error.suggestions))
205204
if error.request_id:
206205
print("Support request ID:", error.request_id)
207206
if error.remediation_url:
@@ -210,8 +209,9 @@ except OilPriceAPIError as error:
210209
```
211210

212211
Every non-2xx response uses this shared typed contract. `status_code`, `code`,
213-
plan or feature requirements, retry metadata, sanitized response headers, and
214-
raw diagnostics remain available without exposing the configured API key.
212+
commodity suggestions, plan or feature requirements, retry metadata, sanitized
213+
response headers, and raw diagnostics remain available without exposing the
214+
configured API key.
215215

216216
## 💰 Pricing & Plans
217217

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ theme:
1515

1616
nav:
1717
- Home: index.md
18+
- Dates and Commodity Codes: CODE_GUIDANCE.md
1819
- DataFrames and Pagination: DATAFRAMES.md
1920
- Synthetic Monitoring: SYNTHETIC_MONITORING.md
2021
- Release Process: RELEASE_PROCESS.md

‎oilpriceapi/async_client.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
error_from_response,
4343
)
4444
from .models import HistoricalPrice, HistoricalResponse, MarketBrief, Price
45+
from .resource_validators import format_date
4546
from .retry import RetryStrategy
4647

4748

@@ -464,10 +465,10 @@ async def get(
464465
"by_type": type_name,
465466
}
466467

467-
if start_date:
468-
params["start_date"] = start_date
469-
if end_date:
470-
params["end_date"] = end_date
468+
if start_date is not None:
469+
params["start_date"] = format_date(start_date)
470+
if end_date is not None:
471+
params["end_date"] = format_date(end_date)
471472

472473
response = await self.client.request(
473474
method="GET", path="/v1/prices/past_year", params=params

‎oilpriceapi/async_resources.py‎

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
)
1111
from .exceptions import ValidationError
1212
from .models import DieselPrice, DieselStationsResponse, PriceAlert, Subscription, SubscriptionEvent
13-
from .resource_validators import VALID_OPERATORS, format_date, normalize_api_number
13+
from .resource_validators import (
14+
VALID_OPERATORS,
15+
extract_commodity_catalog,
16+
format_date,
17+
normalize_api_number,
18+
search_commodity_catalog,
19+
)
1420
from .resources._futures_slug import normalize_futures_slug
1521
from .resources.subscriptions import SubscriptionEventsPage
1622

@@ -286,16 +292,19 @@ def __init__(self, client):
286292

287293
async def list(self) -> List[Dict[str, Any]]:
288294
response = await self.client.request(method="GET", path="/v1/commodities")
289-
if "data" in response:
290-
return response["data"]
291-
return response
295+
return extract_commodity_catalog(response)
292296

293297
async def get(self, code: str) -> Dict[str, Any]:
294298
response = await self.client.request(method="GET", path=f"/v1/commodities/{code}")
295299
if "data" in response:
296300
return response["data"]
297301
return response
298302

303+
async def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
304+
"""Search the API's current catalog without a bundled code list."""
305+
catalog = await self.list()
306+
return search_commodity_catalog(catalog, query=query, limit=limit)
307+
299308
async def categories(self) -> Dict[str, List[Dict[str, Any]]]:
300309
response = await self.client.request(method="GET", path="/v1/commodities/categories")
301310
if "data" in response:
@@ -334,9 +343,9 @@ async def historical(
334343
) -> List[Dict[str, Any]]:
335344
slug = normalize_futures_slug(contract)
336345
params = {}
337-
if start_date:
346+
if start_date is not None:
338347
params["start_date"] = format_date(start_date)
339-
if end_date:
348+
if end_date is not None:
340349
params["end_date"] = format_date(end_date)
341350
response = await self.client.request(
342351
method="GET", path=f"/v1/futures/{slug}/historical", params=params
@@ -348,8 +357,8 @@ async def historical(
348357
async def ohlc(self, contract: str, date: Optional[str] = None) -> Dict[str, Any]:
349358
slug = normalize_futures_slug(contract)
350359
params = {}
351-
if date:
352-
params["date"] = date
360+
if date is not None:
361+
params["date"] = format_date(date)
353362
response = await self.client.request(
354363
method="GET", path=f"/v1/futures/{slug}/ohlc", params=params
355364
)
@@ -442,9 +451,9 @@ async def history(
442451
end_date: Optional[Union[str, date, datetime]] = None
443452
) -> List[Dict[str, Any]]:
444453
params = {}
445-
if start_date:
454+
if start_date is not None:
446455
params["start_date"] = format_date(start_date)
447-
if end_date:
456+
if end_date is not None:
448457
params["end_date"] = format_date(end_date)
449458
response = await self.client.request(
450459
method="GET", path=f"/v1/storage/{code}/history", params=params
@@ -476,9 +485,9 @@ async def historical(
476485
end_date: Optional[Union[str, date, datetime]] = None
477486
) -> List[Dict[str, Any]]:
478487
params = {}
479-
if start_date:
488+
if start_date is not None:
480489
params["start_date"] = format_date(start_date)
481-
if end_date:
490+
if end_date is not None:
482491
params["end_date"] = format_date(end_date)
483492
response = await self.client.request(
484493
method="GET", path="/v1/rig-counts/historical", params=params
@@ -541,9 +550,9 @@ async def historical(
541550
end_date: Optional[Union[str, date, datetime]] = None
542551
) -> List[Dict[str, Any]]:
543552
params: Dict[str, Any] = {"port": port, "fuel_type": fuel_type}
544-
if start_date:
553+
if start_date is not None:
545554
params["start_date"] = format_date(start_date)
546-
if end_date:
555+
if end_date is not None:
547556
params["end_date"] = format_date(end_date)
548557
response = await self.client.request(
549558
method="GET", path="/v1/bunker-fuels/historical", params=params
@@ -806,9 +815,9 @@ async def state(
806815
**params,
807816
) -> Dict[str, Any]:
808817
if start_date is not None:
809-
params["start_date"] = start_date
818+
params["start_date"] = format_date(start_date)
810819
if end_date is not None:
811-
params["end_date"] = end_date
820+
params["end_date"] = format_date(end_date)
812821
response = await self.client.request(
813822
method="GET", path=f"/v1/well-production/states/{code}", params=params
814823
)
@@ -857,8 +866,8 @@ async def cycle_time(
857866
) -> Dict[str, Any]:
858867
filters = {
859868
"state": state,
860-
"start_date": start_date,
861-
"end_date": end_date,
869+
"start_date": format_date(start_date) if start_date is not None else None,
870+
"end_date": format_date(end_date) if end_date is not None else None,
862871
"operator": operator,
863872
"formation": formation,
864873
"lat": lat,
@@ -886,8 +895,8 @@ async def cycle_time_cohorts(
886895
) -> Dict[str, Any]:
887896
filters = {
888897
"state": state,
889-
"start_date": start_date,
890-
"end_date": end_date,
898+
"start_date": format_date(start_date) if start_date is not None else None,
899+
"end_date": format_date(end_date) if end_date is not None else None,
891900
"lat": lat,
892901
"lng": lng,
893902
"radius_miles": radius_miles,

0 commit comments

Comments
 (0)