From caafd6ee1fd6fab5fe544d042f25a06804c1f5d0 Mon Sep 17 00:00:00 2001 From: aytekin Date: Fri, 3 May 2024 11:00:05 -0500 Subject: [PATCH 1/5] feat(vehicle, types): add service history API endpoint and data classes --- REFERENCE.md | 25 +++++++++++++++++++++++++ smartcar/types.py | 29 ++++++++++++++++++++++++++--- smartcar/vehicle.py | 35 ++++++++++++++++++++++++++++++++++- tests/conftest.py | 2 +- tests/e2e/test_vehicle.py | 6 ++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index b53e226e..0de0d868 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -358,6 +358,31 @@ the [exceptions section](https://github.com/smartcar/python-sdk#handling-excepti --- +### `service_history(self, start_date: Optional[str] = None, end_date: Optional[str] = None)` + +Returns a list of all the service records performed on the vehicle, filtered by the optional date range. If no dates are specified, records from the last year are returned. + +#### Args + +| Argument | Type | Description | +| :----------- | :------------ | :------------------------------------------------------------------------------------------ | +| `start_date` | Optional[str] | The start date for the record filter, in 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS.SSSZ' format. | +| `end_date` | Optional[str] | The end date for the record filter, similar format to start_date. | + +#### Return + +| Value | Type | Description | +| :--------------------- | :--------------------- | :------------------------------------------------------------------------- | +| `ServiceHistory` | typing.NamedTuple | The returned object with a list of service entries. | +| `ServiceHistory.items` | List[ServiceRecord] | List of service records describing maintenance activities. | +| `ServiceHistory.meta` | collections.namedtuple | Smartcar response headers (`request_id`, `data_age`, and/or `unit_system`) | + +#### Raises + +`SmartcarException` - See the [exceptions section](https://github.com/smartcar/python-sdk#handling-exceptions) for all possible exceptions. + +--- + ### `attributes(self)` Returns a single vehicle object, containing identifying information. diff --git a/smartcar/types.py b/smartcar/types.py index 263a880f..427358bb 100644 --- a/smartcar/types.py +++ b/smartcar/types.py @@ -1,6 +1,6 @@ import datetime from collections import namedtuple -from typing import List, NamedTuple, Union +from typing import List, Optional, NamedTuple, Union import re import requests.structures as rs import enum @@ -133,7 +133,6 @@ class Reason(enum.Enum): VEHICLE_NOT_COMPATIBLE = "VEHICLE_NOT_COMPATIBLE" MAKE_NOT_COMPATIBLE = "MAKE_NOT_COMPATIBLE" - Capability = NamedTuple( "Capability", [ @@ -144,7 +143,6 @@ class Reason(enum.Enum): ], ) - def format_capabilities(capabilities_list: List[dict]) -> List[Capability]: typed_capabilities_map = map( lambda capability: Capability( @@ -186,6 +184,28 @@ def format_capabilities(capabilities_list: List[dict]) -> List[Capability]: ChargeLimit = NamedTuple("ChargeLimit", [("limit", float), ("meta", namedtuple)]) +class ServiceCost: + total_cost: Optional[float] = None + currency: Optional[str] = None + +class ServiceDetail: + type: str + value: Union[None, str, float] = None + +class ServiceTask: + task_id: Optional[str] = None + task_description: Optional[str] = None + +class ServiceRecord: + odometer_distance: float + service_date: datetime + service_id: Optional[str] = None + service_tasks: List[ServiceTask] + service_details: List[ServiceDetail] + service_cost: ServiceCost + +ServiceHistory = NamedTuple("ServiceHistory", [("items", List[ServiceRecord]), ("meta", namedtuple)]) + Battery = NamedTuple( "Battery", [("percent_remaining", float), ("range", float), ("meta", namedtuple)], @@ -398,6 +418,9 @@ def select_named_tuple(path: str, response_or_dict) -> NamedTuple: elif path == "charge/limit": return ChargeLimit(data["limit"], headers) + + elif path == "service/history": + return ServiceHistory(data, headers) elif path == "permissions": return Permissions( diff --git a/smartcar/vehicle.py b/smartcar/vehicle.py index 3e9cfa7a..375d84d7 100644 --- a/smartcar/vehicle.py +++ b/smartcar/vehicle.py @@ -1,6 +1,6 @@ from collections import namedtuple import json -from typing import Callable, List +from typing import Callable, List, Optional import smartcar.config as config import smartcar.helpers as helpers import smartcar.smartcar @@ -183,6 +183,39 @@ def odometer(self) -> types.Odometer: headers = self._get_headers() response = helpers.requester("GET", url, headers=headers) return types.select_named_tuple(path, response) + + def service_history(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> types.ServiceHistory: + """ + Returns a list of all the service records performed on the vehicle, + filtered by the optional date range. If no dates are specified, records from the + last year are returned. + + Args: + start_date (Optional[str]): The start date for the record filter, either in 'YYYY-MM-DD' or + 'YYYY-MM-DDTHH:MM:SS.SSSZ' format. + end_date (Optional[str]): The end date for the record filter, similar format to start_date. + + Returns: + ServiceHistory: NamedTuple("ServiceHistory", [("items", List[ServiceRecord]), ("meta", namedtuple)]) + + Raises: + SmartcarException: If an error occurs during the API call. + + See Also: + Smartcar API Documentation for Vehicle Service History: + https://smartcar.com/docs/api#get-vehicle-service-history + """ + path = "service/history" + url = self._format_url(path) + headers = self._get_headers() + params = {} + if start_date: + params['startDate'] = start_date + if end_date: + params['endDate'] = end_date + + response = helpers.requester("GET", url, headers=headers, params=params) + return types.select_named_tuple(path, response) def location(self) -> types.Location: """ diff --git a/tests/conftest.py b/tests/conftest.py index 3aff5cca..ca4f4ca2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,7 +158,7 @@ def access_ford(client): client = sc.AuthClient(*ah.get_auth_client_params()) code = ah.run_auth_flow( client.get_auth_url( - ["required:read_charge", "required:control_charge", "control_navigation"] + ["required:read_charge", "required:control_charge", "control_navigation", "read_service_history"] ), "FORD", ) diff --git a/tests/e2e/test_vehicle.py b/tests/e2e/test_vehicle.py index 7ddf2dcd..14f5fd61 100644 --- a/tests/e2e/test_vehicle.py +++ b/tests/e2e/test_vehicle.py @@ -146,6 +146,12 @@ def test_send_destination(ford_car): assert type(response) == types.Action assert response._fields == ("status", "message", "meta") +def test_service_history(ford_car): + response = ford_car.service_history("2023-05-20", "2024-02-10") + assert response.status == "success" + assert isinstance(response, types.ServiceHistory) + assert response._fields == ("status", "message", "meta") + def test_batch_success(chevy_volt): batch = chevy_volt.batch( From 475d15c68cb446104b5d6038e1f7d9f506e5052f Mon Sep 17 00:00:00 2001 From: aytekin Date: Fri, 3 May 2024 11:00:58 -0500 Subject: [PATCH 2/5] feat: formatting applied --- smartcar/types.py | 13 +++++++++++-- smartcar/vehicle.py | 10 ++++++---- tests/conftest.py | 7 ++++++- tests/e2e/test_vehicle.py | 1 + 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/smartcar/types.py b/smartcar/types.py index 427358bb..a721dc96 100644 --- a/smartcar/types.py +++ b/smartcar/types.py @@ -133,6 +133,7 @@ class Reason(enum.Enum): VEHICLE_NOT_COMPATIBLE = "VEHICLE_NOT_COMPATIBLE" MAKE_NOT_COMPATIBLE = "MAKE_NOT_COMPATIBLE" + Capability = NamedTuple( "Capability", [ @@ -143,6 +144,7 @@ class Reason(enum.Enum): ], ) + def format_capabilities(capabilities_list: List[dict]) -> List[Capability]: typed_capabilities_map = map( lambda capability: Capability( @@ -184,18 +186,22 @@ def format_capabilities(capabilities_list: List[dict]) -> List[Capability]: ChargeLimit = NamedTuple("ChargeLimit", [("limit", float), ("meta", namedtuple)]) + class ServiceCost: total_cost: Optional[float] = None currency: Optional[str] = None + class ServiceDetail: type: str value: Union[None, str, float] = None + class ServiceTask: task_id: Optional[str] = None task_description: Optional[str] = None + class ServiceRecord: odometer_distance: float service_date: datetime @@ -204,7 +210,10 @@ class ServiceRecord: service_details: List[ServiceDetail] service_cost: ServiceCost -ServiceHistory = NamedTuple("ServiceHistory", [("items", List[ServiceRecord]), ("meta", namedtuple)]) + +ServiceHistory = NamedTuple( + "ServiceHistory", [("items", List[ServiceRecord]), ("meta", namedtuple)] +) Battery = NamedTuple( "Battery", @@ -418,7 +427,7 @@ def select_named_tuple(path: str, response_or_dict) -> NamedTuple: elif path == "charge/limit": return ChargeLimit(data["limit"], headers) - + elif path == "service/history": return ServiceHistory(data, headers) diff --git a/smartcar/vehicle.py b/smartcar/vehicle.py index 375d84d7..6d594fe8 100644 --- a/smartcar/vehicle.py +++ b/smartcar/vehicle.py @@ -183,8 +183,10 @@ def odometer(self) -> types.Odometer: headers = self._get_headers() response = helpers.requester("GET", url, headers=headers) return types.select_named_tuple(path, response) - - def service_history(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> types.ServiceHistory: + + def service_history( + self, start_date: Optional[str] = None, end_date: Optional[str] = None + ) -> types.ServiceHistory: """ Returns a list of all the service records performed on the vehicle, filtered by the optional date range. If no dates are specified, records from the @@ -210,9 +212,9 @@ def service_history(self, start_date: Optional[str] = None, end_date: Optional[s headers = self._get_headers() params = {} if start_date: - params['startDate'] = start_date + params["startDate"] = start_date if end_date: - params['endDate'] = end_date + params["endDate"] = end_date response = helpers.requester("GET", url, headers=headers, params=params) return types.select_named_tuple(path, response) diff --git a/tests/conftest.py b/tests/conftest.py index ca4f4ca2..3030b556 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,7 +158,12 @@ def access_ford(client): client = sc.AuthClient(*ah.get_auth_client_params()) code = ah.run_auth_flow( client.get_auth_url( - ["required:read_charge", "required:control_charge", "control_navigation", "read_service_history"] + [ + "required:read_charge", + "required:control_charge", + "control_navigation", + "read_service_history", + ] ), "FORD", ) diff --git a/tests/e2e/test_vehicle.py b/tests/e2e/test_vehicle.py index 14f5fd61..36ef3191 100644 --- a/tests/e2e/test_vehicle.py +++ b/tests/e2e/test_vehicle.py @@ -146,6 +146,7 @@ def test_send_destination(ford_car): assert type(response) == types.Action assert response._fields == ("status", "message", "meta") + def test_service_history(ford_car): response = ford_car.service_history("2023-05-20", "2024-02-10") assert response.status == "success" From 4eaa6616fdd5ebecd2f2f5910e9a2e240551dd8a Mon Sep 17 00:00:00 2001 From: aytekin Date: Fri, 10 May 2024 15:22:50 -0500 Subject: [PATCH 3/5] feat: fixed e2e test assertions --- tests/e2e/test_vehicle.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/e2e/test_vehicle.py b/tests/e2e/test_vehicle.py index 36ef3191..4613c303 100644 --- a/tests/e2e/test_vehicle.py +++ b/tests/e2e/test_vehicle.py @@ -149,9 +149,19 @@ def test_send_destination(ford_car): def test_service_history(ford_car): response = ford_car.service_history("2023-05-20", "2024-02-10") - assert response.status == "success" - assert isinstance(response, types.ServiceHistory) - assert response._fields == ("status", "message", "meta") + assert isinstance(response, types.ServiceHistory), "Response should be an instance of ServiceHistory" + assert hasattr(response, '_fields'), "Response should have '_fields' attribute" + assert 'items' in response._fields, "'items' should be a key in the response fields" + + # Check the 'items' array. + assert isinstance(response.items, list), "Items should be a list" + + # Iterate over each item in the 'items' list to perform further validations. + for item in response.items: + assert isinstance(item['odometerDistance'], float), "Odometer distance should be a float" + assert item['odometerDistance'] > 0, "Odometer distance should be greater than zero" + + assert response._fields == ("items", "meta") def test_batch_success(chevy_volt): From d2970c9878e90ca75b920bc3b4f43eed9d3283db Mon Sep 17 00:00:00 2001 From: aytekin Date: Fri, 10 May 2024 15:35:56 -0500 Subject: [PATCH 4/5] feat: executed formatting --- tests/e2e/test_vehicle.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/e2e/test_vehicle.py b/tests/e2e/test_vehicle.py index 4613c303..cc88c470 100644 --- a/tests/e2e/test_vehicle.py +++ b/tests/e2e/test_vehicle.py @@ -149,18 +149,24 @@ def test_send_destination(ford_car): def test_service_history(ford_car): response = ford_car.service_history("2023-05-20", "2024-02-10") - assert isinstance(response, types.ServiceHistory), "Response should be an instance of ServiceHistory" - assert hasattr(response, '_fields'), "Response should have '_fields' attribute" - assert 'items' in response._fields, "'items' should be a key in the response fields" - + assert isinstance( + response, types.ServiceHistory + ), "Response should be an instance of ServiceHistory" + assert hasattr(response, "_fields"), "Response should have '_fields' attribute" + assert "items" in response._fields, "'items' should be a key in the response fields" + # Check the 'items' array. assert isinstance(response.items, list), "Items should be a list" - + # Iterate over each item in the 'items' list to perform further validations. for item in response.items: - assert isinstance(item['odometerDistance'], float), "Odometer distance should be a float" - assert item['odometerDistance'] > 0, "Odometer distance should be greater than zero" - + assert isinstance( + item["odometerDistance"], float + ), "Odometer distance should be a float" + assert ( + item["odometerDistance"] > 0 + ), "Odometer distance should be greater than zero" + assert response._fields == ("items", "meta") From 5d975d9d1295a75c8aa7a96d86646f05a7403b34 Mon Sep 17 00:00:00 2001 From: aytekin Date: Fri, 10 May 2024 16:06:40 -0500 Subject: [PATCH 5/5] feat: fixed assertions --- tests/e2e/test_vehicle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_vehicle.py b/tests/e2e/test_vehicle.py index cc88c470..4fb5a3b0 100644 --- a/tests/e2e/test_vehicle.py +++ b/tests/e2e/test_vehicle.py @@ -161,8 +161,8 @@ def test_service_history(ford_car): # Iterate over each item in the 'items' list to perform further validations. for item in response.items: assert isinstance( - item["odometerDistance"], float - ), "Odometer distance should be a float" + item["odometerDistance"], (float, int) + ), "Odometer distance should be a numeric type (float or int)" assert ( item["odometerDistance"] > 0 ), "Odometer distance should be greater than zero"