From cfc75c92e2f744bc780786b0e5287581e8c28d35 Mon Sep 17 00:00:00 2001 From: autumn_atlas <3477314+Autumn-Atlas@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:20:03 +0800 Subject: [PATCH] fix(client): error message fallback, tz-aware UTC conversion, ValueError on bad dates - CurrentsAPIError: message falls back to API 'msg' key; str(e) returns the human-readable message; status coerces numeric strings to int - search(): tz-aware datetimes converted to UTC before strftime - _parse_date: unparsable date strings raise ValueError, not ParserError - 4 new regression tests; version 0.1.1 --- CHANGELOG.md | 14 ++++++++++++ currentsapi/__init__.py | 2 +- currentsapi/client.py | 31 ++++++++++++++++++++------ tests/test_client.py | 48 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a87f54..ccd5f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/currentsapi/__init__.py b/currentsapi/__init__.py index ed7b500..0e7c8dd 100644 --- a/currentsapi/__init__.py +++ b/currentsapi/__init__.py @@ -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 diff --git a/currentsapi/client.py b/currentsapi/client.py index a8d171a..6f17dda 100644 --- a/currentsapi/client.py +++ b/currentsapi/client.py @@ -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: @@ -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) @@ -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: @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index 51d027e..5a480db 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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()