diff --git a/.travis.yml b/.travis.yml index f08dc3cc..f53132b4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -dist: xenial +dist: focal services: - xvfb @@ -13,6 +13,7 @@ python: - '3.7' - '3.8' - '3.9' + - '3.10' if: tag IS blank # do not build tags @@ -35,14 +36,14 @@ script: jobs: include: - stage: publish - python: '3.8' + python: '3.9' services: [] addons: firefox: 'skip' apt: [] install: - - nvm install 14 - - npm install semantic-release@15.x.x @google/semantic-release-replace-plugin@1.x.x + - nvm install 16 + - npm install semantic-release@19.x.x @google/semantic-release-replace-plugin@1.x.x script: - npx semantic-release deploy: diff --git a/REFERENCE.md b/REFERENCE.md index 39787a4f..b2b1deec 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -709,11 +709,17 @@ A compatible vehicle is a vehicle that: #### Return -| Value | Type | Description | -|:---------------------------|:-----------------------|:--------------------------------------------------------------------------------------| -| `Compatibility` | typing.NamedTuple | The returned object with vehicle's compatibility with the permissions (scope) checked | -| `Compatibility.compatible` | Boolean | Whether the vehicle is compatible with the permissions | -| `Compatibility.meta` | collections.namedtuple | Smartcar response headers (`request_id`, `data_age`, and/or `unit_system`) | +| Value | Type | Availability | Description | +|:------------------------------------------|:------------------------|:----------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------| +| `Compatibility` | typing.NamedTuple | **API v1.0 and v2.0** |The returned object with vehicle's compatibility with the permissions (scope) checked | +| `Compatibility.compatible` | Boolean | **API v1.0 and v2.0** | Whether the vehicle is compatible with the permissions | +| `Compatibility.reason` | String or None | **API v2.0 only** | One of the following string values if compatible is false, null otherwise: "VEHICLE_NOT_COMPATIBLE", "MAKE_NOT_COMPATIBLE" | +| `Compatibility.capabilities` | List | **API v2.0 only** | A list containing the set of endpoints that the provided scope value can provide authorization for. This list will be empty if compatible is false. | +| `Compatibility.capabilities[].permission` | String | **API v2.0 only** | One of the permissions provided in the scope parameter. | +| `Compatibility.capabilities[].endpoint` | String | **API v2.0 only** | One of the endpoints that the permission authorizes access to. | +| `Compatibility.capabilities[].capable` | Boolean | **API v2.0 only** | True if the vehicle is likely capable of this feature, False otherwise. | +| `Compatibility.capabilities[].reason` | String or None | **API v2.0 only** | One of the following string values if compatible is false, null otherwise: "VEHICLE_NOT_COMPATIBLE", "SMARTCAR_NOT_CAPABLE" | +| `Compatibility.meta` | collections.namedtuple | **API v1.0 and v2.0** | Smartcar response headers (`request_id`, `data_age`, and/or `unit_system`) | #### Raises diff --git a/smartcar/smartcar.py b/smartcar/smartcar.py index a69cd79f..9bd1467a 100644 --- a/smartcar/smartcar.py +++ b/smartcar/smartcar.py @@ -4,7 +4,7 @@ import os import re from datetime import datetime -from typing import List +from typing import List, Union import smartcar.config as config import smartcar.helpers as helpers @@ -25,7 +25,7 @@ def set_api_version(version: str) -> None: API_VERSION = version else: raise ValueError( - fr"Version '{version}' must match regex '\d+\.\d+' . e.g. '2.0', '1.0'" + rf"Version '{version}' must match regex '\d+\.\d+' . e.g. '2.0', '1.0'" ) @@ -91,7 +91,7 @@ def get_vehicles(access_token: str, paging: dict = None) -> types.Vehicles: def get_compatibility( vin: str, scope: List[str], country: str = "US", options: dict = None -) -> types.Compatibility: +) -> Union[types.CompatibilityV1, types.CompatibilityV2]: """ Verify if a vehicle (vin) is eligible to use Smartcar. Use to confirm whether specific vehicle is compatible with the permissions provided. @@ -102,6 +102,8 @@ def get_compatibility( 3. Is compatible with the required permissions (scope) that your app is requesting access to + Note: The `test_mode` and `test_mode_compatibility_level` options arguments are only valid for Smartcar API v1.0. + Args: vin (str) @@ -109,14 +111,14 @@ def get_compatibility( country (str, optional) - options (dictionary): Can contain client_id, client_secret, and flags. + options (dict): Can contain client_id, client_secret, and flags. client_id (str, optional) client_secret (str, optional) version (str): Version of API you want to use - flags (dictionary - {str: bool}): An optional list of feature flags + flags (dict - {str: bool}): An optional list of feature flags test_mode (bool): Indicates whether the API should be invoked in test mode (as opposed to live mode) @@ -124,7 +126,16 @@ def get_compatibility( Possible values with details are documented in our Integration Guide. Returns: - Compatibility: NamedTuple("Compatibility", [("compatible", bool), ("meta", namedtuple)]) + CompatibilityV1: NamedTuple("Compatibility", [("compatible", bool), ("meta", namedtuple)]) + OR + CompatibilityV2: NamedTuple("Compatibility", + [ + ("compatible", bool), + ("reason", Union[str, None]), + ("capabilities", List[dict]), + ("meta", namedtuple), + ], + ) """ client_id = os.environ.get("SMARTCAR_CLIENT_ID") client_secret = os.environ.get("SMARTCAR_CLIENT_SECRET") @@ -148,13 +159,15 @@ def get_compatibility( if options.get("version"): api_version = options["version"] - if options.get("test_mode_compatibility_level"): - params["mode"] = "test" - params["test_mode_compatibility_level"] = options[ - "test_mode_compatibility_level" - ] - elif options.get("test_mode"): - params["mode"] = "test" + if api_version == "1.0": + + if options.get("test_mode_compatibility_level"): + params["mode"] = "test" + params["test_mode_compatibility_level"] = options[ + "test_mode_compatibility_level" + ] + elif options.get("test_mode"): + params["mode"] = "test" # Ensuring client_id and client_secret are present if client_id is None or client_secret is None: @@ -176,7 +189,13 @@ def get_compatibility( headers = {"Authorization": f"Basic {base64_id_secret}"} response = helpers.requester("GET", url, headers=headers, params=params) - return types.select_named_tuple("compatibility", response) + + if api_version == "1.0": + return types.select_named_tuple("compatibility_v1", response) + elif api_version == "2.0": + return types.select_named_tuple("compatibility_v2", response) + else: + raise Exception("Please use a valid API version (e.g. '1.0' or '2.0')") def is_expired(expiration: datetime) -> bool: diff --git a/smartcar/types.py b/smartcar/types.py index 81251195..a5849d30 100644 --- a/smartcar/types.py +++ b/smartcar/types.py @@ -1,9 +1,9 @@ import datetime from collections import namedtuple -from typing import List, NamedTuple +from typing import List, NamedTuple, Union import re import requests.structures as rs - +import enum # Return types for Smartcar API. # @@ -126,10 +126,51 @@ def make_access_object(access: dict) -> Access: [("vehicles", List[str]), ("paging", Paging), ("meta", namedtuple)], ) -Compatibility = NamedTuple( + +class Reason(enum.Enum): + VEHICLE_NOT_COMPATIBLE = "VEHICLE_NOT_COMPATIBLE" + MAKE_NOT_COMPATIBLE = "MAKE_NOT_COMPATIBLE" + + +Capability = NamedTuple( + "Capability", + [ + ("permission", str), + ("endpoint", str), + ("capable", bool), + ("reason", Union[Reason, None]), + ], +) + + +def format_capabilities(capabilities_list: List[dict]) -> List[Capability]: + typed_capabilities_map = map( + lambda capability: Capability( + capability["permission"], + capability["endpoint"], + capability["capable"], + capability["reason"], + ), + capabilities_list, + ) # Returns iterable map + typed_capabilities_list = list(typed_capabilities_map) + return typed_capabilities_list + + +CompatibilityV1 = NamedTuple( "Compatibility", [("compatible", bool), ("meta", namedtuple)] ) +CompatibilityV2 = NamedTuple( + "Compatibility", + [ + ("compatible", bool), + ("reason", Union[Reason, None]), + ("capabilities", List[Capability]), + ("meta", namedtuple), + ], +) + # =========================================== # vehicle.py # =========================================== @@ -241,6 +282,7 @@ def select_named_tuple(path: str, response_or_dict) -> NamedTuple: NamedTuple: Appropriate to the path. """ + if type(response_or_dict) == dict: headers_dict = rs.CaseInsensitiveDict(response_or_dict["headers"]) headers = build_meta(headers_dict) @@ -260,8 +302,14 @@ def select_named_tuple(path: str, response_or_dict) -> NamedTuple: headers, ) - elif path == "compatibility": - return Compatibility(data["compatible"], headers) + elif path == "compatibility_v1": + return CompatibilityV1(data["compatible"], headers) + + elif path == "compatibility_v2": + typed_capabilities = format_capabilities(data["capabilities"]) + return CompatibilityV2( + data["compatible"], data["reason"], typed_capabilities, headers + ) # vehicle.py elif path == "vin": diff --git a/tests/e2e/test_smartcar.py b/tests/e2e/test_smartcar.py index c5cdfbc7..95550e02 100644 --- a/tests/e2e/test_smartcar.py +++ b/tests/e2e/test_smartcar.py @@ -62,6 +62,31 @@ def test_get_compatibility_in_test_mode_but_no_level(): ) +def test_get_compatibility_v2(): + + compatibility = smartcar.get_compatibility( + "0SCGMCT0386A85356", + scope=["read_odometer", "read_fuel"], + country="US", + options={ + "client_id": ah.CLIENT_ID, + "client_secret": ah.CLIENT_SECRET, + "version": "2.0", + }, + ) + + assert compatibility.compatible == True + assert compatibility.reason == None + assert compatibility.capabilities[0].permission == "read_odometer" + assert compatibility.capabilities[0].endpoint == "/odometer" + assert compatibility.capabilities[0].capable == True + assert compatibility.capabilities[0].reason == None + assert compatibility.capabilities[1].permission == "read_fuel" + assert compatibility.capabilities[1].endpoint == "/fuel" + assert compatibility.capabilities[1].capable == False + assert compatibility.capabilities[1].reason == "VEHICLE_NOT_CAPABLE" + + def test_get_compatibility_with_non_test_mode_vin(): res = get_compatibility( "WAUAFAFL1GN014882",