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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.1.1

### Fixed
- `CurrentsAPIError.message` now falls back to the API's `msg` key (401
responses carry `msg`, not `message`), and `str(exception)` returns the
human-readable message instead of a raw dict repr.
- `CurrentsAPIError.status` now returns an `int` when the payload carries a
numeric status string, so `e.status == 401` works as expected.
- Timezone-aware `datetime` inputs to `search()` are converted to UTC before
formatting, instead of silently losing their offset while the string gains a
misleading `Z` suffix.
- Unparsable date strings (e.g. `"2026-13-45"`) now raise `ValueError` with a
clear message instead of leaking `dateutil.parser.ParserError`.

## 0.1.0

### Added
Expand Down
2 changes: 1 addition & 1 deletion currentsapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys

__project__ = "currentsapi"
__version__ = "0.1.0"
__version__ = "0.1.1"
__repo__ = "https://github.com/currentslab/currentsapi-python"

from .client import CurrentsAPI
Expand Down
31 changes: 24 additions & 7 deletions currentsapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,25 @@ class CurrentsAPIError(Exception):

def __init__(self, response):
self.response = response
super().__init__(str(response))
self._status = response.get("status")
self._code = response.get("code")
self._message = response.get("message") or response.get("msg")
super().__init__(self._message or str(response))

@property
def status(self):
return self.response.get("status")
try:
return int(self._status)
except (TypeError, ValueError):
return self._status

@property
def code(self):
return self.response.get("code")
return self._code

@property
def message(self):
return self.response.get("message")
return self._message


class CurrentsAPI:
Expand Down Expand Up @@ -95,11 +101,11 @@ def search(
params["category"] = category

if start_date:
date = self._parse_date(start_date, "start_date")
date = self._normalize_date(self._parse_date(start_date, "start_date"))
params["start_date"] = date.strftime("%Y-%m-%dT%H:%M:%SZ")

if end_date:
date = self._parse_date(end_date, "end_date")
date = self._normalize_date(self._parse_date(end_date, "end_date"))
params["end_date"] = date.strftime("%Y-%m-%dT%H:%M:%SZ")

return self._get(self.search_endpoint, params)
Expand All @@ -116,7 +122,12 @@ def available_category(self):
@staticmethod
def _parse_date(date_value, param_name):
if isinstance(date_value, str):
return parser.parse(date_value)
try:
return parser.parse(date_value)
except (parser.ParserError, OverflowError, ValueError) as exc:
raise ValueError(
"{} is not a parsable date: {}".format(param_name, exc)
) from exc
elif isinstance(date_value, datetime.date):
return date_value
else:
Expand All @@ -125,3 +136,9 @@ def _parse_date(date_value, param_name):
param_name
)
)

@staticmethod
def _normalize_date(date_value):
if isinstance(date_value, datetime.datetime) and date_value.tzinfo is not None:
return date_value.astimezone(datetime.timezone.utc)
return date_value
48 changes: 48 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,54 @@ def test_invalid_start_date(self):
with self.assertRaises(ValueError):
api.search(start_date=123)

@patch("currentsapi.client.requests.get")
def test_api_error_uses_msg_key(self, mock_get):
mock_get.return_value = Mock(
status_code=401,
json=Mock(return_value={"status": "401", "msg": "Invalid token"}),
)
api = CurrentsAPI("key")
with self.assertRaises(CurrentsAPIError) as ctx:
api.latest_news()
self.assertEqual(ctx.exception.status, 401)
self.assertIsNone(ctx.exception.code)
self.assertEqual(ctx.exception.message, "Invalid token")
self.assertEqual(str(ctx.exception), "Invalid token")

@patch("currentsapi.client.requests.get")
def test_api_error_prefers_message_over_msg(self, mock_get):
mock_get.return_value = Mock(
status_code=400,
json=Mock(
return_value={
"status": "400",
"msg": "Bad request",
"code": "INVALID_QUERY",
"message": "Invalid parameters",
}
),
)
api = CurrentsAPI("key")
with self.assertRaises(CurrentsAPIError) as ctx:
api.search(keywords="x", category="nope")
self.assertEqual(ctx.exception.status, 400)
self.assertEqual(ctx.exception.code, "INVALID_QUERY")
self.assertEqual(ctx.exception.message, "Invalid parameters")
self.assertEqual(str(ctx.exception), "Invalid parameters")

@patch("currentsapi.client.requests.get")
def test_tz_aware_datetime_converted_to_utc(self, mock_get):
mock_get.return_value = Mock(status_code=200, json=Mock(return_value={"status": "ok"}))
api = CurrentsAPI("key")
tz = datetime.timezone(datetime.timedelta(hours=8))
api.search(start_date=datetime.datetime(2024, 6, 1, 12, 0, tzinfo=tz))
kwargs = mock_get.call_args.kwargs
self.assertEqual(kwargs["params"]["start_date"], "2024-06-01T04:00:00Z")

def test_impossible_date_string_raises_valueerror(self):
api = CurrentsAPI("key")
with self.assertRaises(ValueError):
api.search(start_date="2026-13-45")

if __name__ == "__main__":
unittest.main()
Loading