From 19dfde7d8dc7f5084618b29385b1eb59efc555a7 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Mon, 18 Mar 2024 22:20:56 +0000 Subject: [PATCH 1/7] feat(api): client implements generic PUT handler --- littlepay/api/__init__.py | 20 ++++++++++++ littlepay/api/client.py | 10 ++++++ tests/api/test_client.py | 67 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/littlepay/api/__init__.py b/littlepay/api/__init__.py index 1a0627e..0424ebf 100644 --- a/littlepay/api/__init__.py +++ b/littlepay/api/__init__.py @@ -99,3 +99,23 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa A TResponse instance of the JSON response. """ pass + + def _put(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwargs) -> TResponse: + """Make a PUT request to a JSON endpoint. + + Args: + self (ClientProtocol): The current ClientProtocol reference. + + endpoint (str): The fully-formed endpoint where the PUT request should be made. + + data (dict): Data to send as JSON in the PUT body. + + response_cls (TResponse): A dataclass representing the JSON response to the PUT. By default, simply returns a + boolean indicating success. + + Extra kwargs are passed to requests.put(...) + + Returns (TResponse): + A TResponse instance of the PUT response. + """ + pass diff --git a/littlepay/api/client.py b/littlepay/api/client.py index c5ee380..9205d76 100644 --- a/littlepay/api/client.py +++ b/littlepay/api/client.py @@ -158,3 +158,13 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa except json.JSONDecodeError: data = {"status_code": response.status_code} return response_cls(**data) + + def _put(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwargs) -> TResponse: + response = self.oauth.put(endpoint, headers=self.headers, json=data, **kwargs) + response.raise_for_status() + try: + # response body may be empty, cannot be decoded + data = response.json() + except json.JSONDecodeError: + data = {"status_code": response.status_code} + return response_cls(**data) diff --git a/tests/api/test_client.py b/tests/api/test_client.py index 064b44f..a6dc0ce 100644 --- a/tests/api/test_client.py +++ b/tests/api/test_client.py @@ -372,3 +372,70 @@ def test_Client_post_error_status(mocker, make_client: ClientFunc, url): client._post(url, data, dict) req_spy.assert_called_once_with(url, headers=client.headers, json=data) + + +def test_Client_put(mocker, make_client: ClientFunc, url, SampleResponse_json): + client = make_client() + mock_response = mocker.Mock( + raise_for_status=mocker.Mock(return_value=False), json=mocker.Mock(return_value=SampleResponse_json) + ) + req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response) + + data = {"data": "123"} + result = client._put(url, data, SampleResponse) + + req_spy.assert_called_once_with(url, headers=client.headers, json=data) + assert isinstance(result, SampleResponse) + assert result.one == "single" + assert result.two == "double" + assert result.three == 3 + + +def test_Client_put_default_cls(mocker, make_client: ClientFunc, url, SampleResponse_json): + client = make_client() + mock_response = mocker.Mock( + raise_for_status=mocker.Mock(return_value=False), json=mocker.Mock(return_value=SampleResponse_json) + ) + req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response) + + data = {"data": "123"} + result = client._put(url, data) + + req_spy.assert_called_once_with(url, headers=client.headers, json=data) + assert isinstance(result, dict) + assert result["one"] == "single" + assert result["two"] == "double" + assert result["three"] == 3 + + +def test_Client_put_empty_response(mocker, make_client: ClientFunc, url): + client = make_client() + mock_response = mocker.Mock( + # json() throws a JSONDecodeError, simulating an empty response + json=mocker.Mock(side_effect=JSONDecodeError("msg", "doc", 0)), + # raise_for_status() returns None + raise_for_status=mocker.Mock(return_value=False), + # fake a 201 status_code + status_code=201, + ) + req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response) + + data = {"data": "123"} + + result = client._put(url, data, dict) + + req_spy.assert_called_once_with(url, headers=client.headers, json=data) + assert result == {"status_code": 201} + + +def test_Client_put_error_status(mocker, make_client: ClientFunc, url): + client = make_client() + mock_response = mocker.Mock(raise_for_status=mocker.Mock(side_effect=HTTPError)) + req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response) + + data = {"data": "123"} + + with pytest.raises(HTTPError): + client._put(url, data, dict) + + req_spy.assert_called_once_with(url, headers=client.headers, json=data) From 809d27b69f80f77f4deec2cc9c35082b80e5330d Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Mon, 18 Mar 2024 22:24:18 +0000 Subject: [PATCH 2/7] feat(api): implement PUT endpoint to update expiry date for a funding source already linked to a concession group --- littlepay/api/groups.py | 9 ++++++++- tests/api/test_groups.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/littlepay/api/groups.py b/littlepay/api/groups.py index 5a925a4..1227d94 100644 --- a/littlepay/api/groups.py +++ b/littlepay/api/groups.py @@ -86,4 +86,11 @@ def link_concession_group_funding_source( return self._post(endpoint, data, dict) - return self._post(endpoint, data, dict) + def update_concession_group_funding_source_expiry( + self, group_id: str, funding_source_id: str, concession_expiry: datetime + ) -> dict: + """Update the expiry of a funding source already linked to a concession group.""" + endpoint = self.concession_group_funding_source_endpoint(group_id) + data = {"id": funding_source_id, "concession_expiry": self._format_concession_expiry(concession_expiry)} + + return self._put(endpoint, data, dict) diff --git a/tests/api/test_groups.py b/tests/api/test_groups.py index 8d7c696..6c493ca 100644 --- a/tests/api/test_groups.py +++ b/tests/api/test_groups.py @@ -28,6 +28,12 @@ def mock_ClientProtocol_post_link_concession_group_funding_source(mocker): return mocker.patch("littlepay.api.ClientProtocol._post", side_effect=lambda *args, **kwargs: response) +@pytest.fixture +def mock_ClientProtocol_put_update_concession_group_funding_source(mocker): + response = {"status_code": 200} + return mocker.patch("littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: response) + + def test_GroupResponse_csv(): group = GroupResponse("id", "label", "participant") assert group.csv() == "id,label,participant" @@ -171,3 +177,18 @@ def test_GroupsMixin_link_concession_group_funding_source_expiry( endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, dict ) assert result == {"status_code": 201} + + +def test_GroupsMixin_update_concession_group_funding_source_expiry( + mock_ClientProtocol_put_update_concession_group_funding_source, mocker +): + client = GroupsMixin() + mocker.patch.object(client, "_format_concession_expiry", return_value="formatted concession expiry") + + result = client.update_concession_group_funding_source_expiry("group-1234", "funding-source-1234", datetime.now()) + + endpoint = client.concession_group_funding_source_endpoint("group-1234") + mock_ClientProtocol_put_update_concession_group_funding_source.assert_called_once_with( + endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, dict + ) + assert result == {"status_code": 200} From f4ca317f73aa6f73fbdbedf3638ed87e1a45512d Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Mon, 18 Mar 2024 22:50:03 +0000 Subject: [PATCH 3/7] refactor(api): PUT responds with a ListResponse --- littlepay/api/__init__.py | 5 ++--- littlepay/api/client.py | 2 +- littlepay/api/groups.py | 6 +++--- tests/api/test_client.py | 19 +++++++++++-------- tests/api/test_groups.py | 3 ++- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/littlepay/api/__init__.py b/littlepay/api/__init__.py index 0424ebf..9650d97 100644 --- a/littlepay/api/__init__.py +++ b/littlepay/api/__init__.py @@ -100,7 +100,7 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa """ pass - def _put(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwargs) -> TResponse: + def _put(self, endpoint: str, data: dict, response_cls: TResponse = ListResponse, **kwargs) -> TResponse: """Make a PUT request to a JSON endpoint. Args: @@ -110,8 +110,7 @@ def _put(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwar data (dict): Data to send as JSON in the PUT body. - response_cls (TResponse): A dataclass representing the JSON response to the PUT. By default, simply returns a - boolean indicating success. + response_cls (TResponse): A dataclass representing the JSON response to the PUT. By default, returns a ListResponse. # noqa Extra kwargs are passed to requests.put(...) diff --git a/littlepay/api/client.py b/littlepay/api/client.py index 9205d76..d37f62a 100644 --- a/littlepay/api/client.py +++ b/littlepay/api/client.py @@ -159,7 +159,7 @@ def _post(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwa data = {"status_code": response.status_code} return response_cls(**data) - def _put(self, endpoint: str, data: dict, response_cls: TResponse = dict, **kwargs) -> TResponse: + def _put(self, endpoint: str, data: dict, response_cls: TResponse = ListResponse, **kwargs) -> TResponse: response = self.oauth.put(endpoint, headers=self.headers, json=data, **kwargs) response.raise_for_status() try: diff --git a/littlepay/api/groups.py b/littlepay/api/groups.py index 1227d94..152a9e7 100644 --- a/littlepay/api/groups.py +++ b/littlepay/api/groups.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from typing import Generator -from littlepay.api import ClientProtocol +from littlepay.api import ClientProtocol, ListResponse from littlepay.api.funding_sources import FundingSourcesMixin @@ -88,9 +88,9 @@ def link_concession_group_funding_source( def update_concession_group_funding_source_expiry( self, group_id: str, funding_source_id: str, concession_expiry: datetime - ) -> dict: + ) -> ListResponse: """Update the expiry of a funding source already linked to a concession group.""" endpoint = self.concession_group_funding_source_endpoint(group_id) data = {"id": funding_source_id, "concession_expiry": self._format_concession_expiry(concession_expiry)} - return self._put(endpoint, data, dict) + return self._put(endpoint, data, ListResponse) diff --git a/tests/api/test_client.py b/tests/api/test_client.py index a6dc0ce..68c01d0 100644 --- a/tests/api/test_client.py +++ b/tests/api/test_client.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +import dataclasses from json import JSONDecodeError import time from typing import Callable, Generator, TypeAlias @@ -42,7 +42,7 @@ def mock_active_Config(mocker, credentials, token, url): return config -@dataclass +@dataclasses.dataclass class SampleResponse: one: str two: str @@ -391,10 +391,11 @@ def test_Client_put(mocker, make_client: ClientFunc, url, SampleResponse_json): assert result.three == 3 -def test_Client_put_default_cls(mocker, make_client: ClientFunc, url, SampleResponse_json): +def test_Client_put_default_cls(mocker, make_client: ClientFunc, url, ListResponse_sample): client = make_client() mock_response = mocker.Mock( - raise_for_status=mocker.Mock(return_value=False), json=mocker.Mock(return_value=SampleResponse_json) + raise_for_status=mocker.Mock(return_value=False), + json=mocker.Mock(return_value=dataclasses.asdict(ListResponse_sample)), ) req_spy = mocker.patch.object(client.oauth, "put", return_value=mock_response) @@ -402,10 +403,12 @@ def test_Client_put_default_cls(mocker, make_client: ClientFunc, url, SampleResp result = client._put(url, data) req_spy.assert_called_once_with(url, headers=client.headers, json=data) - assert isinstance(result, dict) - assert result["one"] == "single" - assert result["two"] == "double" - assert result["three"] == 3 + assert isinstance(result, ListResponse) + assert result.total_count == ListResponse_sample.total_count + assert len(result.list) == len(ListResponse_sample.list) + + for list_item in result.list: + assert list_item == ListResponse_sample.list[result.list.index(list_item)] def test_Client_put_empty_response(mocker, make_client: ClientFunc, url): diff --git a/tests/api/test_groups.py b/tests/api/test_groups.py index 6c493ca..4d79c32 100644 --- a/tests/api/test_groups.py +++ b/tests/api/test_groups.py @@ -3,6 +3,7 @@ import pytest +from littlepay.api import ListResponse from littlepay.api.groups import GroupResponse, GroupsMixin @@ -189,6 +190,6 @@ def test_GroupsMixin_update_concession_group_funding_source_expiry( endpoint = client.concession_group_funding_source_endpoint("group-1234") mock_ClientProtocol_put_update_concession_group_funding_source.assert_called_once_with( - endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, dict + endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, ListResponse ) assert result == {"status_code": 200} From 5655864304baa62adb4566a91277ee9a6b24c8c9 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 19:18:17 +0000 Subject: [PATCH 4/7] refactor(api): pull out the (only) list item when updating expiry PUT returns a ListResponse, but with only a single item, so pull it out and return that instead to simplify caller consumption --- littlepay/api/groups.py | 6 ++++-- tests/api/test_client.py | 5 ----- tests/api/test_groups.py | 9 ++++----- tests/conftest.py | 6 ++++++ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/littlepay/api/groups.py b/littlepay/api/groups.py index 152a9e7..d336a0e 100644 --- a/littlepay/api/groups.py +++ b/littlepay/api/groups.py @@ -88,9 +88,11 @@ def link_concession_group_funding_source( def update_concession_group_funding_source_expiry( self, group_id: str, funding_source_id: str, concession_expiry: datetime - ) -> ListResponse: + ) -> dict: """Update the expiry of a funding source already linked to a concession group.""" endpoint = self.concession_group_funding_source_endpoint(group_id) data = {"id": funding_source_id, "concession_expiry": self._format_concession_expiry(concession_expiry)} - return self._put(endpoint, data, ListResponse) + response = self._put(endpoint, data, ListResponse) + + return response.list[0] diff --git a/tests/api/test_client.py b/tests/api/test_client.py index 68c01d0..1101d27 100644 --- a/tests/api/test_client.py +++ b/tests/api/test_client.py @@ -54,11 +54,6 @@ def SampleResponse_json(): return {"one": "single", "two": "double", "three": 3} -@pytest.fixture -def ListResponse_sample(): - return ListResponse(list=[{"one": 1}, {"two": 2}, {"three": 3}], total_count=3) - - @pytest.fixture def default_list_params(): return dict(page=1, perPage=100) diff --git a/tests/api/test_groups.py b/tests/api/test_groups.py index 4d79c32..6a196a3 100644 --- a/tests/api/test_groups.py +++ b/tests/api/test_groups.py @@ -30,9 +30,8 @@ def mock_ClientProtocol_post_link_concession_group_funding_source(mocker): @pytest.fixture -def mock_ClientProtocol_put_update_concession_group_funding_source(mocker): - response = {"status_code": 200} - return mocker.patch("littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: response) +def mock_ClientProtocol_put_update_concession_group_funding_source(mocker, ListResponse_sample): + return mocker.patch("littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: ListResponse_sample) def test_GroupResponse_csv(): @@ -181,7 +180,7 @@ def test_GroupsMixin_link_concession_group_funding_source_expiry( def test_GroupsMixin_update_concession_group_funding_source_expiry( - mock_ClientProtocol_put_update_concession_group_funding_source, mocker + mock_ClientProtocol_put_update_concession_group_funding_source, ListResponse_sample, mocker ): client = GroupsMixin() mocker.patch.object(client, "_format_concession_expiry", return_value="formatted concession expiry") @@ -192,4 +191,4 @@ def test_GroupsMixin_update_concession_group_funding_source_expiry( mock_ClientProtocol_put_update_concession_group_funding_source.assert_called_once_with( endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, ListResponse ) - assert result == {"status_code": 200} + assert result == ListResponse_sample.list[0] diff --git a/tests/conftest.py b/tests/conftest.py index a334424..8954812 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from pytest_socket import disable_socket from littlepay import __version__ +from littlepay.api import ListResponse import littlepay.config from littlepay.commands import RESULT_SUCCESS @@ -140,3 +141,8 @@ def mock_ClientProtocol_make_endpoint(mocker, url): mocker.patch( "littlepay.api.ClientProtocol._make_endpoint", side_effect=lambda *args: f"{url}/{'/'.join([a for a in args if a])}" ) + + +@pytest.fixture +def ListResponse_sample(): + return ListResponse(list=[{"one": 1}, {"two": 2}, {"three": 3}], total_count=3) From 9932a59ea27778855949fe20bd71afa4a5fab3c2 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 20:51:11 +0000 Subject: [PATCH 5/7] feat(api): define dataclass for group funding source response parses optional datetime values when provided --- littlepay/api/groups.py | 25 ++++++++++++++++++++++++- tests/api/test_groups.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/littlepay/api/groups.py b/littlepay/api/groups.py index d336a0e..4eef920 100644 --- a/littlepay/api/groups.py +++ b/littlepay/api/groups.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import InitVar, dataclass from datetime import datetime, timezone from typing import Generator @@ -25,6 +25,29 @@ def csv_header() -> str: return ",".join(vars(instance).keys()) +@dataclass +class GroupFundingSourceResponse: + id: str + participant_id: str + concession_expiry: InitVar[datetime | None] = None + concession_created_at: InitVar[datetime | None] = None + concession_updated_at: InitVar[datetime | None] = None + + def __post_init__(self, concession_expiry, concession_created_at, concession_updated_at): + if concession_expiry: + self.concession_expiry = datetime.fromisoformat(concession_expiry) + else: + self.concession_expiry = None + if concession_created_at: + self.concession_created_at = datetime.fromisoformat(concession_created_at) + else: + self.concession_created_at = None + if concession_updated_at: + self.concession_updated_at = datetime.fromisoformat(concession_updated_at) + else: + self.concession_updated_at = None + + class GroupsMixin(ClientProtocol): """Mixin implements APIs for concession groups.""" diff --git a/tests/api/test_groups.py b/tests/api/test_groups.py index 6a196a3..cf24156 100644 --- a/tests/api/test_groups.py +++ b/tests/api/test_groups.py @@ -4,7 +4,7 @@ import pytest from littlepay.api import ListResponse -from littlepay.api.groups import GroupResponse, GroupsMixin +from littlepay.api.groups import GroupFundingSourceResponse, GroupResponse, GroupsMixin @pytest.fixture @@ -46,6 +46,39 @@ def test_GroupResponse_csv_header(): assert GroupResponse.csv_header() == "id,label,participant_id" +def test_GroupFundingSourceResponse_no_dates(): + response = GroupFundingSourceResponse("id", "participant_id") + + assert response.id == "id" + assert response.participant_id == "participant_id" + assert response.concession_expiry is None + assert response.concession_created_at is None + assert response.concession_updated_at is None + + +def test_GroupFundingSourceResponse_empty_dates(): + response = GroupFundingSourceResponse("id", "participant_id", "", "", "") + + assert response.id == "id" + assert response.participant_id == "participant_id" + assert response.concession_expiry is None + assert response.concession_created_at is None + assert response.concession_updated_at is None + + +def test_GroupFundingSourceResponse_with_dates(): + str_date = "2024-03-19T20:00:00Z" + expected_date = datetime(2024, 3, 19, 20, 0, 0, tzinfo=timezone.utc) + + response = GroupFundingSourceResponse("id", "participant_id", str_date, str_date, str_date) + + assert response.id == "id" + assert response.participant_id == "participant_id" + assert response.concession_expiry == expected_date + assert response.concession_created_at == expected_date + assert response.concession_updated_at == expected_date + + def test_GroupsMixin_concession_groups_endpoint(url): client = GroupsMixin() From e866872cfdba8369c4687930e14dc457a37830eb Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 20:52:00 +0000 Subject: [PATCH 6/7] refactor(api): update expiry returns GroupFundingSourceResponse --- littlepay/api/groups.py | 4 ++-- tests/api/test_groups.py | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/littlepay/api/groups.py b/littlepay/api/groups.py index 4eef920..329f50f 100644 --- a/littlepay/api/groups.py +++ b/littlepay/api/groups.py @@ -111,11 +111,11 @@ def link_concession_group_funding_source( def update_concession_group_funding_source_expiry( self, group_id: str, funding_source_id: str, concession_expiry: datetime - ) -> dict: + ) -> GroupFundingSourceResponse: """Update the expiry of a funding source already linked to a concession group.""" endpoint = self.concession_group_funding_source_endpoint(group_id) data = {"id": funding_source_id, "concession_expiry": self._format_concession_expiry(concession_expiry)} response = self._put(endpoint, data, ListResponse) - return response.list[0] + return GroupFundingSourceResponse(**response.list[0]) diff --git a/tests/api/test_groups.py b/tests/api/test_groups.py index cf24156..abbb7fc 100644 --- a/tests/api/test_groups.py +++ b/tests/api/test_groups.py @@ -7,6 +7,28 @@ from littlepay.api.groups import GroupFundingSourceResponse, GroupResponse, GroupsMixin +@pytest.fixture +def ListResponse_GroupFundingSources(): + items = [ + dict( + id="0", + participant_id="zero_0", + concession_expiry="2024-03-19T20:00:00Z", + concession_created_at="2024-03-19T20:00:00Z", + concession_updated_at="2024-03-19T20:00:00Z", + ), + dict( + id="1", + participant_id="one_1", + concession_expiry="2024-03-19T20:00:00Z", + concession_created_at="2024-03-19T20:00:00Z", + concession_updated_at="2024-03-19T20:00:00Z", + ), + dict(id="2", participant_id="two_2", concession_expiry="", concession_created_at=""), + ] + return ListResponse(list=items, total_count=3) + + @pytest.fixture def mock_ClientProtocol_get_list_Groups(mocker): items = [ @@ -30,8 +52,10 @@ def mock_ClientProtocol_post_link_concession_group_funding_source(mocker): @pytest.fixture -def mock_ClientProtocol_put_update_concession_group_funding_source(mocker, ListResponse_sample): - return mocker.patch("littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: ListResponse_sample) +def mock_ClientProtocol_put_update_concession_group_funding_source(mocker, ListResponse_GroupFundingSources): + return mocker.patch( + "littlepay.api.ClientProtocol._put", side_effect=lambda *args, **kwargs: ListResponse_GroupFundingSources + ) def test_GroupResponse_csv(): @@ -213,7 +237,7 @@ def test_GroupsMixin_link_concession_group_funding_source_expiry( def test_GroupsMixin_update_concession_group_funding_source_expiry( - mock_ClientProtocol_put_update_concession_group_funding_source, ListResponse_sample, mocker + mock_ClientProtocol_put_update_concession_group_funding_source, ListResponse_GroupFundingSources, mocker ): client = GroupsMixin() mocker.patch.object(client, "_format_concession_expiry", return_value="formatted concession expiry") @@ -224,4 +248,6 @@ def test_GroupsMixin_update_concession_group_funding_source_expiry( mock_ClientProtocol_put_update_concession_group_funding_source.assert_called_once_with( endpoint, {"id": "funding-source-1234", "concession_expiry": "formatted concession expiry"}, ListResponse ) - assert result == ListResponse_sample.list[0] + + expected = GroupFundingSourceResponse(**ListResponse_GroupFundingSources.list[0]) + assert result == expected From 7bc81b9c5196ee118fcaf8a7f685c9adbb2fe62e Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 19 Mar 2024 21:20:10 +0000 Subject: [PATCH 7/7] fix(api): support Python 3.10 parsing support Python 3.10+ by fixing datetime parsing from known ISO 8601 format with 'Z' offset character for UTC see https://docs.python.org/3.11/library/datetime.html#datetime.datetime.fromisoformat --- littlepay/api/groups.py | 30 +++++++++++++++++++----------- tests/api/test_groups.py | 3 +++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/littlepay/api/groups.py b/littlepay/api/groups.py index 329f50f..b44da07 100644 --- a/littlepay/api/groups.py +++ b/littlepay/api/groups.py @@ -1,4 +1,4 @@ -from dataclasses import InitVar, dataclass +from dataclasses import dataclass from datetime import datetime, timezone from typing import Generator @@ -29,21 +29,29 @@ def csv_header() -> str: class GroupFundingSourceResponse: id: str participant_id: str - concession_expiry: InitVar[datetime | None] = None - concession_created_at: InitVar[datetime | None] = None - concession_updated_at: InitVar[datetime | None] = None + concession_expiry: datetime | None = None + concession_created_at: datetime | None = None + concession_updated_at: datetime | None = None - def __post_init__(self, concession_expiry, concession_created_at, concession_updated_at): - if concession_expiry: - self.concession_expiry = datetime.fromisoformat(concession_expiry) + def __post_init__(self): + """Parses any date parameters into Python datetime objects. + + Includes a workaround for Python 3.10 where datetime.fromisoformat() can only parse the format output + by datetime.isoformat(), i.e. without a trailing 'Z' offset character and with UTC offset expressed + as +/-HH:mm + + https://docs.python.org/3.11/library/datetime.html#datetime.datetime.fromisoformat + """ + if self.concession_expiry: + self.concession_expiry = datetime.fromisoformat(self.concession_expiry.replace("Z", "+00:00", 1)) else: self.concession_expiry = None - if concession_created_at: - self.concession_created_at = datetime.fromisoformat(concession_created_at) + if self.concession_created_at: + self.concession_created_at = datetime.fromisoformat(self.concession_created_at.replace("Z", "+00:00", 1)) else: self.concession_created_at = None - if concession_updated_at: - self.concession_updated_at = datetime.fromisoformat(concession_updated_at) + if self.concession_updated_at: + self.concession_updated_at = datetime.fromisoformat(self.concession_updated_at.replace("Z", "+00:00", 1)) else: self.concession_updated_at = None diff --git a/tests/api/test_groups.py b/tests/api/test_groups.py index abbb7fc..efb873a 100644 --- a/tests/api/test_groups.py +++ b/tests/api/test_groups.py @@ -99,8 +99,11 @@ def test_GroupFundingSourceResponse_with_dates(): assert response.id == "id" assert response.participant_id == "participant_id" assert response.concession_expiry == expected_date + assert response.concession_expiry.tzinfo == timezone.utc assert response.concession_created_at == expected_date + assert response.concession_created_at.tzinfo == timezone.utc assert response.concession_updated_at == expected_date + assert response.concession_updated_at.tzinfo == timezone.utc def test_GroupsMixin_concession_groups_endpoint(url):