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
7 changes: 4 additions & 3 deletions oilpriceapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Price(BaseModel):

commodity: str = Field(description="Commodity code (e.g., BRENT_CRUDE_USD)")
value: float = Field(description="Current price value")
currency: str = Field(description="Currency code")
currency: Optional[str] = Field(default=None, description="Currency code (may be absent in minimal API responses)")
unit: str = Field(description="Unit of measurement")
timestamp: datetime = Field(description="Price timestamp")
change: Optional[float] = Field(default=None, description="Price change amount")
Expand Down Expand Up @@ -57,7 +57,8 @@ def __str__(self) -> str:
if self.change_percent is not None:
arrow = "↑" if self.is_up else "↓" if self.is_down else "→"
change_str = f" {arrow} {abs(self.change_percent):.2f}%"
return f"{self.commodity}: {self.currency}{self.value:.2f}{change_str}"
currency_str = self.currency or ""
return f"{self.commodity}: {currency_str}{self.value:.2f}{change_str}"


class PriceResponse(BaseModel):
Expand Down Expand Up @@ -89,7 +90,7 @@ class HistoricalPrice(BaseModel):
date: datetime = Field(alias="created_at")
commodity: str = Field(alias="commodity_name")
value: float = Field(alias="price")
currency: str = Field(description="Currency code from API response")
currency: Optional[str] = Field(default=None, description="Currency code from API response (may be absent)")
unit: str = Field(alias="unit_of_measure")
type_name: Optional[str] = Field(default="spot_price")

Expand Down
4 changes: 3 additions & 1 deletion oilpriceapi/resources/prices.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ def get(self, commodity: str) -> Price:
mapped_data = {
"commodity": price_data.get("code", commodity),
"value": price_data.get("price"),
"currency": price_data.get("currency"),
# API responses are USD-denominated; default for backwards compatibility
# when the 'currency' field is absent from a minimal response.
"currency": price_data.get("currency", "USD"),
"unit": price_data.get("unit", "barrel"),
"timestamp": price_data.get("created_at"),
}
Expand Down
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-asyncio>=0.21.0",
"pytest-timeout>=2.1.0",
"black>=23.0.0",
"mypy>=1.0.0",
"ruff>=0.0.261",
Expand Down Expand Up @@ -147,7 +148,15 @@ markers = [

[tool.coverage.run]
source = ["oilpriceapi"]
omit = ["*/tests/*", "*/test_*.py"]
omit = [
"*/tests/*",
"*/test_*.py",
# Optional-extra modules requiring [cli] / matplotlib+pandas, not
# installed in the base unit-test environment, so not exercised by
# the publish gate. Excluded from coverage measurement.
"oilpriceapi/cli.py",
"oilpriceapi/visualization.py",
]

[tool.coverage.report]
precision = 2
Expand Down
28 changes: 14 additions & 14 deletions tests/unit/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async def test_get_price(self, mock_request, api_key, mock_price_response):
"""Test getting a single price async."""
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json = AsyncMock(return_value=mock_price_response)
mock_response.json = Mock(return_value=mock_price_response)
mock_request.return_value = mock_response

async with AsyncOilPriceAPI(api_key=api_key) as client:
Expand All @@ -87,7 +87,7 @@ async def test_get_multiple_prices_concurrently(self, mock_request, api_key):
async def make_response(code, price_value):
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json = AsyncMock(return_value={
mock_response.json = Mock(return_value={
"status": "success",
"data": {
"code": code,
Expand Down Expand Up @@ -129,7 +129,7 @@ async def test_get_historical_data(self, mock_request, api_key, mock_historical_
"""Test getting historical data async."""
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json = AsyncMock(return_value=mock_historical_response)
mock_response.json = Mock(return_value=mock_historical_response)
mock_request.return_value = mock_response

async with AsyncOilPriceAPI(api_key=api_key) as client:
Expand All @@ -150,7 +150,7 @@ async def test_get_all_historical(self, mock_request, api_key):
# Mock two pages
page1_response = AsyncMock()
page1_response.status_code = 200
page1_response.json = AsyncMock(return_value={
page1_response.json = Mock(return_value={
"status": "success",
"data": {
"prices": [
Expand All @@ -177,7 +177,7 @@ async def test_get_all_historical(self, mock_request, api_key):

page2_response = AsyncMock()
page2_response.status_code = 200
page2_response.json = AsyncMock(return_value={
page2_response.json = Mock(return_value={
"status": "success",
"data": {
"prices": [
Expand Down Expand Up @@ -223,7 +223,7 @@ async def test_authentication_error(self, mock_request, api_key):
"""Test authentication error handling."""
mock_response = AsyncMock()
mock_response.status_code = 401
mock_response.json = AsyncMock(return_value={"error": "Invalid API key"})
mock_response.json = Mock(return_value={"error": "Invalid API key"})
mock_request.return_value = mock_response

async with AsyncOilPriceAPI(api_key=api_key) as client:
Expand All @@ -241,7 +241,7 @@ async def test_rate_limit_error(self, mock_request, api_key):
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": "1705320000",
}
mock_response.json = AsyncMock(return_value={"error": "Rate limit exceeded"})
mock_response.json = Mock(return_value={"error": "Rate limit exceeded"})
mock_request.return_value = mock_response

async with AsyncOilPriceAPI(api_key=api_key) as client:
Expand All @@ -256,7 +256,7 @@ async def test_data_not_found_error(self, mock_request, api_key):
"""Test data not found error."""
mock_response = AsyncMock()
mock_response.status_code = 404
mock_response.json = AsyncMock(return_value={
mock_response.json = Mock(return_value={
"error": "Commodity not found",
"commodity": "INVALID_CODE",
})
Expand All @@ -273,15 +273,15 @@ async def test_retry_on_server_error(self, mock_request, api_key):
# First two calls fail, third succeeds
fail_response1 = AsyncMock()
fail_response1.status_code = 500
fail_response1.json = AsyncMock(return_value={"error": "Server error"})
fail_response1.json = Mock(return_value={"error": "Server error"})

fail_response2 = AsyncMock()
fail_response2.status_code = 502
fail_response2.json = AsyncMock(return_value={"error": "Bad gateway"})
fail_response2.json = Mock(return_value={"error": "Bad gateway"})

success_response = AsyncMock()
success_response.status_code = 200
success_response.json = AsyncMock(return_value={
success_response.json = Mock(return_value={
"status": "success",
"data": {
"code": "BRENT_CRUDE_USD",
Expand Down Expand Up @@ -312,7 +312,7 @@ async def test_concurrent_historical_requests(self, mock_request, api_key, mock_
"""Test multiple concurrent historical data requests."""
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json = AsyncMock(return_value=mock_historical_response)
mock_response.json = Mock(return_value=mock_historical_response)
mock_request.return_value = mock_response

async with AsyncOilPriceAPI(api_key=api_key) as client:
Expand All @@ -336,9 +336,9 @@ async def make_response(is_historical):
mock_resp = AsyncMock()
mock_resp.status_code = 200
if is_historical:
mock_resp.json = AsyncMock(return_value=mock_historical_response)
mock_resp.json = Mock(return_value=mock_historical_response)
else:
mock_resp.json = AsyncMock(return_value=mock_price_response)
mock_resp.json = Mock(return_value=mock_price_response)
return mock_resp

mock_request.side_effect = [
Expand Down
Loading