diff --git a/shipengine_sdk/errors/__init__.py b/shipengine_sdk/errors/__init__.py index 6f70892..d771528 100644 --- a/shipengine_sdk/errors/__init__.py +++ b/shipengine_sdk/errors/__init__.py @@ -26,22 +26,22 @@ def __init__( def _are_enums_valid(self): if self.source is None: - return self + pass # noqa elif not does_member_value_exist(self.source, ErrorSource): raise ValueError( f"Error source must be a member of ErrorSource enum - [{self.source}] provided." ) if self.error_type is None: - return self + pass # noqa elif not does_member_value_exist(self.error_type, ErrorType): raise ValueError( f"Error type must be a member of ErrorType enum - [{self.error_type}] provided." ) if self.error_code is None: - return self - elif self.error_code not in (member.value for member in ErrorCode): + pass # noqa + elif not does_member_value_exist(self.error_code, ErrorCode): raise ValueError( f"Error type must be a member of ErrorCode enum - [{self.error_code}] provided." ) diff --git a/shipengine_sdk/http_client/client.py b/shipengine_sdk/http_client/client.py index 7cf5ecb..eea0bad 100644 --- a/shipengine_sdk/http_client/client.py +++ b/shipengine_sdk/http_client/client.py @@ -2,7 +2,7 @@ import json import os import platform -from typing import Dict, Optional +from typing import Any, Dict, Optional import requests from requests import PreparedRequest, Request, RequestException, Response, Session @@ -37,8 +37,8 @@ def __init__(self) -> None: self.session = requests.session() def send_rpc_request( - self, method: str, params: Optional[Dict[str, any]], retry: int, config: ShipEngineConfig - ) -> Dict[str, any]: + self, method: str, params: Optional[Dict[str, Any]], retry: int, config: ShipEngineConfig + ) -> Dict[str, Any]: """ Send a `JSON-RPC 2.0` request via HTTP Messages to ShipEngine API. If the response * is successful, the result is returned. Otherwise, an error is thrown. @@ -53,13 +53,13 @@ def send_rpc_request( else os.getenv("CLIENT_BASE_URI") ) - request_headers: Dict[str, any] = { + request_headers: Dict[str, Any] = { "User-Agent": self._derive_user_agent(), "Content-Type": "application/json", "Accept": "application/json", } - request_body: Dict[str, any] = wrap_request(method=method, params=params) + request_body: Dict[str, Any] = wrap_request(method=method, params=params) req: Request = Request( method="POST", @@ -80,7 +80,7 @@ def send_rpc_request( error_code=ErrorCode.UNSPECIFIED.value, ) - resp_body: Dict[str, any] = resp.json() + resp_body: Dict[str, Any] = resp.json() status_code: int = resp.status_code is_response_404(status_code=status_code, response_body=resp_body, config=config) diff --git a/shipengine_sdk/jsonrpc/__init__.py b/shipengine_sdk/jsonrpc/__init__.py index e0c3057..902dd94 100644 --- a/shipengine_sdk/jsonrpc/__init__.py +++ b/shipengine_sdk/jsonrpc/__init__.py @@ -3,7 +3,7 @@ functionality for sending HTTP requests from the ShipEngine SDK. """ import time -from typing import Dict, Optional +from typing import Any, Dict, Optional from ..errors import RateLimitExceededError from ..http_client import ShipEngineClient @@ -12,8 +12,8 @@ def rpc_request( - method: str, config: ShipEngineConfig, params: Optional[Dict[str, any]] = None -) -> Dict[str, any]: + method: str, config: ShipEngineConfig, params: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: """ Create and send a `JSON-RPC 2.0` request over HTTP messages. TODO: add param and return docs @@ -21,13 +21,15 @@ def rpc_request( return rpc_request_loop(method, params, config) -def rpc_request_loop(method: str, params: dict, config: ShipEngineConfig) -> Dict[str, any]: +def rpc_request_loop( + method: str, params: Optional[Dict[str, Any]], config: ShipEngineConfig +) -> Dict[str, Any]: client: ShipEngineClient = ShipEngineClient() - api_response: Optional[Dict[str, any]] = None + api_response: Optional[Dict[str, Any]] = None retry: int = 0 while retry <= config.retries: try: - api_response: Dict[str, any] = client.send_rpc_request( + api_response = client.send_rpc_request( method=method, params=params, retry=retry, config=config ) except Exception as err: diff --git a/shipengine_sdk/jsonrpc/process_request.py b/shipengine_sdk/jsonrpc/process_request.py index 244e7bc..a7d59df 100644 --- a/shipengine_sdk/jsonrpc/process_request.py +++ b/shipengine_sdk/jsonrpc/process_request.py @@ -1,5 +1,5 @@ """Functions that help with process requests and handle responses.""" -from typing import Dict, Optional +from typing import Any, Dict, Optional from uuid import uuid4 from ..errors import ( @@ -13,7 +13,7 @@ from ..models import ErrorType -def wrap_request(method: str, params: Optional[Dict[str, any]]) -> Dict[str, any]: +def wrap_request(method: str, params: Optional[Dict[str, Any]]) -> Dict[str, Any]: """ Wrap request per `JSON-RPC 2.0` spec. @@ -21,7 +21,7 @@ def wrap_request(method: str, params: Optional[Dict[str, any]]) -> Dict[str, any invoke a specific remote procedure. :param params: The request data for the RPC request. This argument is optional and can either be a dictionary or None. - :type params: Optional[Dict[str, any]] + :type params: Optional[Dict[str, Any]] """ if params is None: return dict(id=f"req_{str(uuid4()).replace('-', '')}", jsonrpc="2.0", method=method) @@ -31,13 +31,13 @@ def wrap_request(method: str, params: Optional[Dict[str, any]]) -> Dict[str, any ) -def handle_response(response_body: Dict[str, any]) -> Dict[str, any]: +def handle_response(response_body: Dict[str, Any]) -> Dict[str, Any]: """Handles the response from ShipEngine API.""" if "result" in response_body: return response_body - error: Dict[str, any] = response_body["error"] - error_data: Dict[str, any] = error["data"] + error: Dict[str, Any] = response_body["error"] + error_data: Dict[str, Any] = error["data"] error_type: str = error_data["type"] if error_type is ErrorType.ACCOUNT_STATUS.value: raise AccountStatusError( diff --git a/shipengine_sdk/models/__init__.py b/shipengine_sdk/models/__init__.py index 9dd9710..3b5deb5 100644 --- a/shipengine_sdk/models/__init__.py +++ b/shipengine_sdk/models/__init__.py @@ -1,13 +1,12 @@ """ShipEngine SDK Models & Enumerations""" from .address import Address, AddressValidateResult from .carriers import Carrier, CarrierAccount -from .enums import ( - CarrierNames, - Carriers, - Country, - Endpoints, - ErrorCode, - ErrorSource, - ErrorType, - RPCMethods, +from .enums import * # noqa +from .track_pacakge import ( + Location, + Package, + Shipment, + TrackingEvent, + TrackingQuery, + TrackPackageResult, ) diff --git a/shipengine_sdk/models/carriers/__init__.py b/shipengine_sdk/models/carriers/__init__.py index cd9ed06..3b11f69 100644 --- a/shipengine_sdk/models/carriers/__init__.py +++ b/shipengine_sdk/models/carriers/__init__.py @@ -1,38 +1,10 @@ """CarrierAccount class object and immutable carrier object.""" import json -from typing import Dict +from typing import Any, Dict from ...errors import InvalidFieldValueError, ShipEngineError from ..enums import Carriers, does_member_value_exist, get_carrier_name_value -# @dataclass_json(letter_case=LetterCase.CAMEL) -# @dataclass(frozen=True) -# class Carrier: -# """This immutable class object represents a given Carrier provider e.g. `FedEx`, `UPS`, `USPS`.""" -# name: str -# code: str -# -# def __post_init__(self) -> None: -# """An immutable Carrier object. e.g. `FedEx`, `UPS`, `USPS`.""" -# if not does_member_value_exist(self.name, CarrierNames): -# raise ShipEngineError(f"Carrier [{self.name}] not currently supported.") -# -# if not does_member_value_exist(self.code, Carriers): -# raise ShipEngineError(f"Carrier [{self.code}] not currently supported.") -# -# -# @dataclass_json(letter_case=LetterCase.CAMEL) -# @dataclass(frozen=True) -# class CarrierAccount: -# """This class represents a given account with a Carrier provider e.g. `FedEx`, `UPS`, `USPS`.""" -# carrier: Union[str, Carrier] -# account_id: str -# account_number: str -# name: str -# -# def __post_init__(self) -> None: -# if not does_member_value_exist() - class Carrier: def __init__(self, code: str) -> None: @@ -53,7 +25,7 @@ def to_json(self): class CarrierAccount: carrier: Carrier - def __init__(self, account_information: Dict[str, any]) -> None: + def __init__(self, account_information: Dict[str, Any]) -> None: """This class represents a given account with a Carrier provider e.g. `FedEx`, `UPS`, `USPS`.""" self._set_carrier(account_information["carrierCode"]) self.account_id = account_information["accountID"] diff --git a/shipengine_sdk/models/enums/__init__.py b/shipengine_sdk/models/enums/__init__.py index efdec97..9badc5a 100644 --- a/shipengine_sdk/models/enums/__init__.py +++ b/shipengine_sdk/models/enums/__init__.py @@ -6,6 +6,7 @@ from .error_code import ErrorCode from .error_source import ErrorSource from .error_type import ErrorType +from .regex_patterns import RegexPatterns class Endpoints(Enum): diff --git a/shipengine_sdk/models/enums/regex_patterns.py b/shipengine_sdk/models/enums/regex_patterns.py new file mode 100644 index 0000000..78d38c5 --- /dev/null +++ b/shipengine_sdk/models/enums/regex_patterns.py @@ -0,0 +1,7 @@ +"""Enumeration of Regular Expression patterns used in the ShipEngine SDK.""" +from enum import Enum + + +class RegexPatterns(Enum): + VALID_ISO_STRING = r"^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$" # noqa + VALID_ISO_STRING_NO_TZ = r"^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?([+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$" # noqa diff --git a/shipengine_sdk/models/track_pacakge/__init__.py b/shipengine_sdk/models/track_pacakge/__init__.py new file mode 100644 index 0000000..9d7c8b2 --- /dev/null +++ b/shipengine_sdk/models/track_pacakge/__init__.py @@ -0,0 +1,208 @@ +"""Data objects to be used in the `track_package` and `track` methods.""" +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union + +from dataclasses_json import LetterCase, dataclass_json + +from ...errors import ShipEngineError +from ...services.get_carrier_accounts import GetCarrierAccounts +from ...shipengine_config import ShipEngineConfig +from ...util.iso_string import IsoString +from .. import Carrier, CarrierAccount + + +class Shipment: + config: ShipEngineConfig + shipment_id: Optional[str] = None + account_id: Optional[str] = None + carrier_account: Optional[CarrierAccount] = None + carrier: Carrier + estimated_delivery_date: Union[IsoString, str] + actual_delivery_date: Union[IsoString, str] + + def __init__( + self, shipment: Dict[str, Any], actual_delivery_date: IsoString, config: ShipEngineConfig + ) -> None: + """This object represents a given Shipment.""" + self.config = config + self.shipment_id = shipment["shipmentID"] if "shipmentID" in shipment else None + self.account_id = shipment["carrierAccountID"] if "carrierAccountID" in shipment else None + + if self.account_id is not None: + self.carrier_account = self._get_carrier_account( + carrier=shipment["carrierCode"], account_id=self.account_id + ) + + if self.carrier_account is not None: + self.carrier = self.carrier_account.carrier + else: + self.carrier = ( + Carrier(shipment["carrierCode"]) + if "carrierCode" in shipment + else ShipEngineError("The carrierCode field was null from api response.") + ) + + self.estimated_delivery_date = IsoString(iso_string=shipment["estimatedDelivery"]) + self.actual_delivery_date = actual_delivery_date + + def _get_carrier_account(self, carrier: str, account_id: str) -> CarrierAccount: + get_accounts: GetCarrierAccounts = GetCarrierAccounts() + target_carrier: List[CarrierAccount] = list() + carrier_accounts: List[CarrierAccount] = get_accounts.fetch_cached_carrier_accounts( + carrier_code=carrier, config=self.config + ) + + for account in carrier_accounts: + if account_id == account.account_id: + target_carrier.append(account) + return target_carrier[0] + + raise ShipEngineError( + message=f"accountID [{account_id}] doesn't match any of the accounts connected to your ShipEngine Account." # noqa + ) + + def to_dict(self) -> Dict[str, Any]: + if hasattr(self, "config"): + del self.config + else: + pass # noqa + return (lambda o: o.__dict__)(self) + + def to_json(self) -> str: + if hasattr(self, "config"): + del self.config + else: + pass # noqa + return json.dumps(self, default=lambda o: o.__dict__, indent=2) + + +class Package: + """This object contains package information for a given shipment.""" + + package_id: Optional[str] + weight: Optional[Dict[str, Any]] + dimensions: Optional[Dict[str, Any]] + tracking_number: Optional[str] + tracking_url: Optional[str] + + def __init__(self, package: Dict[str, Any]) -> None: + self.package_id = package["packageID"] if "packageID" in package else None + self.weight = package["weight"] if "weight" in package else None + self.dimensions = package["dimensions"] if "dimensions" in package else None + self.tracking_number = package["trackingNumber"] if "trackingNumber" in package else None + self.tracking_url = package["trackingURL"] if "trackingURL" in package else None + + def to_dict(self) -> Dict[str, Any]: + return (lambda o: o.__dict__)(self) + + def to_json(self) -> str: + return json.dumps(self, default=lambda o: o.__dict__, indent=2) + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class TrackingQuery: + """This object is used as an argument in the `track_package` and `track` methods.""" + + carrier_code: str + tracking_number: str + + +@dataclass_json(letter_case=LetterCase.CAMEL) +@dataclass +class Location: + city_locality: Optional[str] = None + state_province: Optional[str] = None + postal_code: Optional[str] = None + country_code: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + + +class TrackingEvent: + date_time: Union[IsoString, str] + carrier_date_time: Union[IsoString, str] + status: str + description: Optional[str] + carrier_status_code: Optional[str] + carrier_detail_code: Optional[str] + signer: Optional[str] + location: Optional[Location] + + def __init__(self, event: Dict[str, Any]) -> None: + """Tracking event object.""" + self.date_time = IsoString(iso_string=event["timestamp"]) + + self.carrier_date_time = IsoString(iso_string=event["carrierTimestamp"]) + + self.status = event["status"] + self.description = event["description"] if "description" in event else None + self.carrier_status_code = ( + event["carrierStatusCode"] if "carrierStatusCode" in event else None + ) + self.signer = event["signer"] if "signer" in event else None + self.location = Location.from_dict(event["location"]) if "location" in event else None + + def to_dict(self): + return (lambda o: o.__dict__)(self) + + def to_json(self): + return json.dumps(self, default=lambda o: o.__dict__, indent=2) + + +class TrackPackageResult: + shipment: Optional[Shipment] + package: Optional[Package] + events: Optional[List[TrackingEvent]] = list() + + def __init__(self, api_response: Dict[str, Any], config: ShipEngineConfig) -> None: + """This object is used as the return type for the `track_package` and `track` methods.""" + self.events = list() + result = api_response["result"] + for event in result["events"]: + self.events.append(TrackingEvent(event=event)) + + self.shipment = ( + Shipment( + shipment=result["shipment"], + actual_delivery_date=self.get_latest_event().date_time, + config=config, + ) + if "shipment" in result + else None + ) + self.package = Package(result["package"]) if "package" in result else None + + def get_errors(self) -> List[TrackingEvent]: # TODO: debug + """Returns **only** the exception events.""" + errors: List[TrackingEvent] = list() + for event in self.events: + if event.status == "exception": + errors.append(event) + return errors + + def get_latest_event(self) -> TrackingEvent: # TODO: debug + """Returns the latest event to have occurred in the `events` list.""" + return self.events[-1] + + def has_errors(self) -> bool: # TODO: debug + """Returns `true` if there are any exception events.""" + for event in self.events: + if event.status == "exception": + return True + return False + + def to_dict(self): + if hasattr(self.shipment, "config"): + del self.shipment.config + else: + pass # noqa + return (lambda o: o.__dict__)(self) + + def to_json(self): + if hasattr(self.shipment, "config"): + del self.shipment.config + else: + pass # noqa + return json.dumps(self, default=lambda o: o.__dict__, indent=2) diff --git a/shipengine_sdk/services/address_validation.py b/shipengine_sdk/services/address_validation.py index a7fc851..f38318d 100644 --- a/shipengine_sdk/services/address_validation.py +++ b/shipengine_sdk/services/address_validation.py @@ -1,5 +1,5 @@ """Validate a single address or multiple addresses.""" -from typing import Dict +from typing import Any, Dict from ..jsonrpc import rpc_request from ..models.address import Address, AddressValidateResult @@ -17,12 +17,12 @@ def validate(address: Address, config: ShipEngineConfig) -> AddressValidateResul :returns: :class:`AddressValidateResult`: The response from ShipEngine API including the validated and normalized address. """ - api_response: Dict[str, any] = rpc_request( + api_response: Dict[str, Any] = rpc_request( method=RPCMethods.ADDRESS_VALIDATE.value, config=config, - params={"address": address.to_dict()}, + params={"address": address.to_dict()}, # type: ignore ) - result: Dict[str, any] = api_response["result"] + result: Dict[str, Any] = api_response["result"] return AddressValidateResult( is_valid=result["isValid"], request_id=api_response["id"], diff --git a/shipengine_sdk/services/get_carrier_accounts.py b/shipengine_sdk/services/get_carrier_accounts.py index 5bff042..c5da08e 100644 --- a/shipengine_sdk/services/get_carrier_accounts.py +++ b/shipengine_sdk/services/get_carrier_accounts.py @@ -2,19 +2,21 @@ Fetch the carrier account connected to a given ShipEngine Account based on the API Key passed into the ShipEngine SDK. """ -from typing import Dict, List, Optional +from typing import List, Optional from ..jsonrpc import rpc_request from ..models import CarrierAccount, RPCMethods from ..shipengine_config import ShipEngineConfig +cached_accounts: List = list() -class GetCarrierAccounts: - cached_accounts: List = list() +class GetCarrierAccounts: + @staticmethod def fetch_carrier_accounts( - self, config: ShipEngineConfig, carrier_code: Optional[str] = None - ) -> List[Dict[str, any]]: + config: ShipEngineConfig, carrier_code: Optional[str] = None + ) -> List[CarrierAccount]: + global cached_accounts if carrier_code is not None: api_response = rpc_request( method=RPCMethods.LIST_CARRIERS.value, @@ -28,29 +30,32 @@ def fetch_carrier_accounts( ) accounts = api_response["result"]["carrierAccounts"] - self.cached_accounts = list() + cached_accounts = list() for account in accounts: - carrier_account = CarrierAccount(account).to_dict() - self.cached_accounts.append(carrier_account) + carrier_account = CarrierAccount(account) + cached_accounts.append(carrier_account) - return self.cached_accounts + return cached_accounts def fetch_cached_carrier_accounts( self, config: ShipEngineConfig, carrier_code: Optional[str] - ) -> List[Dict[str, any]]: - accounts = self.cached_accounts + ) -> List[CarrierAccount]: + global cached_accounts + accounts = cached_accounts return ( accounts - if len(self.cached_accounts) > 0 + if len(cached_accounts) > 0 else self.fetch_carrier_accounts(config=config, carrier_code=carrier_code) ) - def get_cached_accounts_by_carrier_code(self, carrier_code: Optional[str]): + @staticmethod + def get_cached_accounts_by_carrier_code(carrier_code: Optional[str]) -> List[CarrierAccount]: + global cached_accounts accounts = list() - if carrier_code is not None: - return self.cached_accounts + if carrier_code is None: + return cached_accounts else: - for account in self.cached_accounts: - if account.carrier.code == carrier_code: + for account in cached_accounts: + if account.carrier["code"] == carrier_code: accounts.append(account) return accounts diff --git a/shipengine_sdk/services/track_package.py b/shipengine_sdk/services/track_package.py new file mode 100644 index 0000000..f76a6b2 --- /dev/null +++ b/shipengine_sdk/services/track_package.py @@ -0,0 +1,28 @@ +"""Track a given package to obtain status updates on it's progression through the fulfillment cycle.""" +from typing import Union + +from ..jsonrpc import rpc_request +from ..models import RPCMethods +from ..models.track_pacakge import TrackingQuery, TrackPackageResult +from ..shipengine_config import ShipEngineConfig +from ..util import is_package_id_valid + + +def track(tracking_data: Union[str, TrackingQuery], config: ShipEngineConfig) -> TrackPackageResult: + if type(tracking_data) is str: + is_package_id_valid(tracking_data) + + api_response = rpc_request( + method=RPCMethods.TRACK_PACKAGE.value, + config=config, + params={"packageID": tracking_data}, + ) + + return TrackPackageResult(api_response, config) + + if type(tracking_data) is TrackingQuery: + api_response = rpc_request( + method=RPCMethods.TRACK_PACKAGE.value, config=config, params=tracking_data.to_dict() # type: ignore + ) + + return TrackPackageResult(api_response, config) diff --git a/shipengine_sdk/shipengine.py b/shipengine_sdk/shipengine.py index f74abd3..0f76639 100644 --- a/shipengine_sdk/shipengine.py +++ b/shipengine_sdk/shipengine.py @@ -1,10 +1,11 @@ """The entrypoint to the ShipEngine API SDK.""" -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union -from .models import CarrierAccount +from .models import CarrierAccount, TrackingQuery, TrackPackageResult from .models.address import Address, AddressValidateResult from .services.address_validation import normalize, validate from .services.get_carrier_accounts import GetCarrierAccounts +from .services.track_package import track from .shipengine_config import ShipEngineConfig @@ -16,7 +17,7 @@ class ShipEngine: unless specifically overridden when calling a method. """ - def __init__(self, config: Union[str, Dict[str, any], ShipEngineConfig]) -> None: + def __init__(self, config: Union[str, Dict[str, Any], ShipEngineConfig]) -> None: """ Exposes the functionality of the ShipEngine API. @@ -25,12 +26,12 @@ def __init__(self, config: Union[str, Dict[str, any], ShipEngineConfig]) -> None """ if type(config) is str: - self.config: ShipEngineConfig = ShipEngineConfig({"api_key": config}) + self.config = ShipEngineConfig({"api_key": config}) elif type(config) is dict: - self.config: ShipEngineConfig = ShipEngineConfig(config) + self.config = ShipEngineConfig(config) def validate_address( - self, address: Address, config: Union[Dict[str, any], ShipEngineConfig] = None + self, address: Address, config: Optional[Union[Dict[str, Any], ShipEngineConfig]] = None ) -> AddressValidateResult: """ Validate an address in nearly any countryCode in the world. @@ -40,20 +41,34 @@ def validate_address( :returns: :class:`AddressValidateResult`: The response from ShipEngine API including the validated and normalized address. """ - config: ShipEngineConfig = self.config.merge(new_config=config) - return validate(address, config) + config = self.config.merge(new_config=config) + return validate(address=address, config=config) def normalize_address( - self, address: Address, config: Union[Dict[str, any], ShipEngineConfig] = None + self, address: Address, config: Optional[Union[Dict[str, Any], ShipEngineConfig]] = None ) -> Address: """Normalize a given address into a standardized format used by carriers.""" - config: ShipEngineConfig = self.config.merge(new_config=config) + config = self.config.merge(new_config=config) return normalize(address=address, config=config) def get_carrier_accounts( - self, carrier_code: Optional[str] = None, config: Optional[Dict[str, any]] = None + self, + carrier_code: Optional[str] = None, + config: Optional[Union[Dict[str, Any], ShipEngineConfig]] = None, ) -> List[CarrierAccount]: """Fetch a list of the carrier accounts connected to your ShipEngine Account.""" - config: ShipEngineConfig = self.config.merge(new_config=config) + config = self.config.merge(new_config=config) get_accounts = GetCarrierAccounts() return get_accounts.fetch_carrier_accounts(config=config, carrier_code=carrier_code) + + def track_package( + self, + tracking_data: Union[str, TrackingQuery], + config: Optional[Union[Dict[str, Any], ShipEngineConfig]] = None, + ) -> TrackPackageResult: + """ + Track a package by `tracking_number` and `carrier_code` via the **TrackingQuery** object, by using just the + **package_id**. + """ + config = self.config.merge(new_config=config) + return track(tracking_data=tracking_data, config=config) diff --git a/shipengine_sdk/shipengine_config.py b/shipengine_sdk/shipengine_config.py index 02dfcbd..5649b1b 100644 --- a/shipengine_sdk/shipengine_config.py +++ b/shipengine_sdk/shipengine_config.py @@ -1,6 +1,6 @@ """The global configuration object for the ShipEngine SDK.""" import json -from typing import Dict, Optional +from typing import Any, Dict, Optional from .models import Endpoints from .util import is_api_key_valid, is_retries_valid, is_timeout_valid @@ -19,7 +19,7 @@ class ShipEngineConfig: DEFAULT_TIMEOUT: int = 5 """Default timeout for the ShipEngineClient in seconds.""" - def __init__(self, config: Dict[str, any]) -> None: + def __init__(self, config: Dict[str, Any]) -> None: """ This is the configuration object for the ShipEngine object and it"s properties are used throughout this SDK. @@ -50,7 +50,7 @@ def __init__(self, config: Dict[str, any]) -> None: self.retries: int = self.DEFAULT_RETRIES # TODO: add event listener to config object once it"s implemented. - def merge(self, new_config: Optional[Dict[str, any]] = None): + def merge(self, new_config: Optional[Dict[str, Any]] = None): """ The method allows the merging of a method-level configuration adjustment into the current configuration. diff --git a/shipengine_sdk/util/iso_string.py b/shipengine_sdk/util/iso_string.py new file mode 100644 index 0000000..4966d20 --- /dev/null +++ b/shipengine_sdk/util/iso_string.py @@ -0,0 +1,51 @@ +"""Initial Docstring""" +import re +from datetime import datetime + +from shipengine_sdk.models import RegexPatterns + + +class IsoString: + def __init__(self, iso_string: str) -> None: + """ + A string representing a Date, DateTime, or DateTime with Timezone. The object + also has a method to return a `datetime.datetime` object, which is the native + datetime object in python as of 3.7. + + This class object takes in an **ISO-8601** string. Learn more here: https://en.wikipedia.org/wiki/ISO_8601 + + :param str iso_string: An `ISO-8601` string. Learn more here: https://en.wikipedia.org/wiki/ISO_8601 + """ + self.iso_string = iso_string + + def __str__(self) -> str: + return f"{self.iso_string}" + + def to_string(self) -> str: + return self.iso_string + + def to_datetime_object(self) -> datetime: + if self.has_timezone(): + return datetime.strptime(self.iso_string, "%Y-%m-%dT%H:%M:%S.%fZ") + elif self.is_valid_iso_string_no_tz(self.iso_string): + return datetime.fromisoformat(self.iso_string) + + def has_timezone(self) -> bool: + if self.is_valid_iso_string(self.iso_string): + return False if self.is_valid_iso_string_no_tz(self.iso_string) else True + + @staticmethod + def is_valid_iso_string_no_tz(iso_str: str): + pattern = re.compile(RegexPatterns.VALID_ISO_STRING_NO_TZ.value) + if pattern.match(iso_str): + return True + else: + return False + + @staticmethod + def is_valid_iso_string(iso_str: str): + pattern = re.compile(RegexPatterns.VALID_ISO_STRING.value) + if pattern.match(iso_str): + return True + else: + return False diff --git a/shipengine_sdk/util/sdk_assertions.py b/shipengine_sdk/util/sdk_assertions.py index 919f7f4..f3e5800 100644 --- a/shipengine_sdk/util/sdk_assertions.py +++ b/shipengine_sdk/util/sdk_assertions.py @@ -1,6 +1,6 @@ """Assertion helper functions.""" import re -from typing import Dict, List +from typing import Any, Dict, List from ..errors import ( ClientSystemError, @@ -16,7 +16,7 @@ def is_street_valid(street: List[str]) -> None: - """Checks that street is not empty and that it is not too many address lines.""" + """Checks that street is not empty and that it is not too mAny address lines.""" if len(street) == 0: raise ValidationError( message="Invalid address. At least one address line is required.", @@ -51,7 +51,7 @@ def is_city_valid(city: str) -> None: def is_state_valid(state: str) -> None: """Asserts that state is 2 capitalized letters and that it is not an empty string.""" - latin_pattern = re.compile(r"^[a-zA-Z\W]*$") + latin_pattern = re.compile(r"^[a-zA-Z]*$") non_latin_pattern = re.compile(r"[\u4e00-\u9fff]+") if non_latin_pattern.match(state): @@ -89,7 +89,7 @@ def is_country_code_valid(country: str) -> None: ) -def is_api_key_valid(config: Dict[str, any]) -> None: +def is_api_key_valid(config: Dict[str, Any]) -> None: """ Check if API Key is set and is not empty or whitespace. @@ -115,7 +115,7 @@ def is_api_key_valid(config: Dict[str, any]) -> None: ) -def is_retries_valid(config: Dict[str, any]) -> None: +def is_retries_valid(config: Dict[str, Any]) -> None: """ Checks that config.retries is a valid value. @@ -132,7 +132,7 @@ def is_retries_valid(config: Dict[str, any]) -> None: ) -def is_timeout_valid(config: Dict[str, any]) -> None: +def is_timeout_valid(config: Dict[str, Any]) -> None: """ Checks that config.timeout is valid value. @@ -174,7 +174,7 @@ def timeout_validation_error_assertions(error) -> None: assert error.source is ErrorSource.SHIPENGINE.value -def is_response_404(status_code: int, response_body: Dict[str, any], config) -> None: +def is_response_404(status_code: int, response_body: Dict[str, Any], config) -> None: """Check if status_code is 404 and raises an error if so.""" if "error" in response_body and status_code == 404: error = response_body["error"] @@ -195,7 +195,7 @@ def is_response_404(status_code: int, response_body: Dict[str, any], config) -> ) -def is_response_429(status_code: int, response_body: Dict[str, any], config) -> None: +def is_response_429(status_code: int, response_body: Dict[str, Any], config) -> None: """Check if status_code is 429 and raises an error if so.""" if "error" in response_body and status_code == 429: error = response_body["error"] @@ -214,7 +214,7 @@ def is_response_429(status_code: int, response_body: Dict[str, any], config) -> ) -def is_response_500(status_code: int, response_body: Dict[str, any]) -> None: +def is_response_500(status_code: int, response_body: Dict[str, Any]) -> None: """Check if the status code is 500 and raises an error if so.""" if status_code == 500: error = response_body["error"] @@ -230,7 +230,7 @@ def is_response_500(status_code: int, response_body: Dict[str, any]) -> None: def does_normalized_address_have_errors(result) -> None: """ - Assertions to check if the returned normalized address has any errors. If errors + Assertions to check if the returned normalized address has Any errors. If errors are present an exception is thrown. :param AddressValidateResult result: The address validation response from ShipEngine API. @@ -265,3 +265,11 @@ def does_normalized_address_have_errors(result) -> None: error_type=ErrorType.ERROR.value, error_code=ErrorCode.INVALID_ADDRESS.value, ) + + +def is_package_id_valid(package_id: str) -> None: + """Checks that package_id is valid.""" + pattern = re.compile(r"^pkg_[1-9a-zA-Z]+$") + + if not pattern.match(package_id): + raise ValidationError(message=f"[{package_id}] is not a valid package ID.") diff --git a/tests/errors/test_errors.py b/tests/errors/test_errors.py index 9365852..e748dec 100644 --- a/tests/errors/test_errors.py +++ b/tests/errors/test_errors.py @@ -14,10 +14,12 @@ def shipengine_error_defaults() -> ShipEngineError: + """Return a ShipEngineError that only has a message string passed in.""" raise ShipEngineError(message="Testing the defaults for this error.") def shipengine_error(): + """Return a ShipEngineError that have all fields populated by valid values.""" raise ShipEngineError( request_id="req_a523b1b19bd54054b7eb953f000e7f15", message="The is a test exception", @@ -28,7 +30,19 @@ def shipengine_error(): ) -def shipengine_error_with_bad_error_type(): +def shipengine_error_with_no_error_type() -> ShipEngineError: + """Return a ShipEngineError that only has the error_type set to None.""" + raise ShipEngineError( + request_id="req_a523b1b19bd54054b7eb953f000e7f15", + message="The is a test exception", + source="shipengine", + error_type=None, + error_code="invalid_address", + ) + + +def shipengine_error_with_bad_error_type() -> ShipEngineError: + """Return a ShipEngineError that has an invalid error_type.""" raise ShipEngineError( request_id="req_a523b1b19bd54054b7eb953f000e7f15", message="The is a test exception", @@ -38,7 +52,8 @@ def shipengine_error_with_bad_error_type(): ) -def shipengine_error_with_bad_error_source(): +def shipengine_error_with_bad_error_source() -> ShipEngineError: + """Return a ShipEngineError that has an invalid error_source.""" raise ShipEngineError( request_id="req_a523b1b19bd54054b7eb953f000e7f15", message="The is a test exception", @@ -48,7 +63,8 @@ def shipengine_error_with_bad_error_source(): ) -def shipengine_error_with_bad_error_code(): +def shipengine_error_with_bad_error_code() -> ShipEngineError: + """Return a ShipEngineError that has an invalid error_code.""" raise ShipEngineError( request_id="req_a523b1b19bd54054b7eb953f000e7f15", message="The is a test exception", @@ -58,31 +74,31 @@ def shipengine_error_with_bad_error_code(): ) -def account_status(): +def account_status() -> AccountStatusError: raise AccountStatusError("There was an issue with your ShipEngine account.") -def business_rule_error(): +def business_rule_error() -> BusinessRuleError: raise BusinessRuleError("Invalid postal code.") -def security_error(): +def security_error() -> ClientSecurityError: raise ClientSecurityError("Unauthorized - you API key is invalid.") -def validation_error(): +def validation_error() -> ValidationError: raise ValidationError("The value provided must be an integer - object provided.") -def client_timeout_error(): +def client_timeout_error() -> ClientTimeoutError: raise ClientTimeoutError(300, "shipengine", "req_a523b1b19bd54054b7eb953f000e7f15") -def invalid_filed_value_error(): +def invalid_filed_value_error() -> InvalidFieldValueError: raise InvalidFieldValueError("is_residential", "Value should be int but got str.", 1) -def rate_limit_exceeded_error(): +def rate_limit_exceeded_error() -> RateLimitExceededError: raise RateLimitExceededError(300, "shipengine", "req_a523b1b19bd54054b7eb953f000e7f15") diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 0000000..44cbe32 --- /dev/null +++ b/tests/models/__init__.py @@ -0,0 +1 @@ +"""Tests on the models used throughout the ShipEngine SDK.""" diff --git a/tests/models/carriers/test_carrier.py b/tests/models/carriers/test_carrier.py new file mode 100644 index 0000000..c920047 --- /dev/null +++ b/tests/models/carriers/test_carrier.py @@ -0,0 +1,15 @@ +"""Test the Carrier class object.""" +from shipengine_sdk.errors import ShipEngineError +from shipengine_sdk.models import Carrier + + +class TestCarrier: + def test_invalid_carrier(self) -> None: + try: + Carrier(code="royal_mail") + except ShipEngineError as err: + assert err.message == "Carrier [royal_mail] not currently supported." + + def test_to_json(self) -> None: + carrier = Carrier(code="fedex") + assert type(carrier.to_json()) is str diff --git a/tests/models/carriers/test_carrier_account.py b/tests/models/carriers/test_carrier_account.py new file mode 100644 index 0000000..d796aff --- /dev/null +++ b/tests/models/carriers/test_carrier_account.py @@ -0,0 +1,39 @@ +"""Test the CarrierAccount class object.""" +from typing import Any, Dict + +from shipengine_sdk.errors import InvalidFieldValueError +from shipengine_sdk.models import CarrierAccount + + +def stub_carrier_account_object() -> Dict[str, Any]: + """ + Return a dictionary that mimics the data this object would be passed + from the returned ShipEngine API response. + """ + return { + "accountID": "car_1knseddGBrseWTiw", + "accountNumber": "1169350", + "carrierCode": "royal_mail", + "name": "United Parcel Service", + } + + +class TestCarrierAccount: + def test_carrier_account_with_invalid_carrier(self) -> None: + k = stub_carrier_account_object() + try: + CarrierAccount(account_information=k) + except InvalidFieldValueError as err: + assert err.message == f"Carrier [{k['carrierCode']}] is currently not supported." + + def test_carrier_account_to_dict(self) -> None: + k = stub_carrier_account_object() + carrier_account = CarrierAccount(account_information=k) + + assert type(carrier_account.to_dict()) is dict + + def test_carrier_account_to_json(self) -> None: + k = stub_carrier_account_object() + carrier_account = CarrierAccount(account_information=k) + + assert type(carrier_account.to_json()) is str diff --git a/tests/models/track_package/__init__.py b/tests/models/track_package/__init__.py new file mode 100644 index 0000000..6491bdf --- /dev/null +++ b/tests/models/track_package/__init__.py @@ -0,0 +1 @@ +"""Test for the models used in the `track_package` method of the ShipEngine SDK.""" diff --git a/tests/models/track_package/test_package.py b/tests/models/track_package/test_package.py new file mode 100644 index 0000000..abe4813 --- /dev/null +++ b/tests/models/track_package/test_package.py @@ -0,0 +1,29 @@ +"""Testing the Package class object.""" +from typing import Any, Dict + +from shipengine_sdk.models import Package + + +def stub_package_data() -> Dict[str, Any]: + return { + "packageID": "pkg_1FedExAccepted", + "trackingNumber": "5fSkgyuh3GkfUjTZSEAQ8gHeTU29tZ", + "trackingURL": "https://www.fedex.com/track/5fSkgyuh3GkfUjTZSEAQ8gHeTU29tZ", + "weight": {"value": 76, "unit": "kilogram"}, + "dimensions": {"length": 36, "width": 36, "height": 23, "unit": "inch"}, + } + + +def stub_package() -> Package: + """Return a valid stub Package object.""" + return Package(stub_package_data()) + + +class TestPackage: + def test_package_to_dict(self) -> None: + package = stub_package() + assert type(package.to_dict()) is dict + + def test_package_to_json(self) -> None: + package = stub_package() + assert type(package.to_json()) is str diff --git a/tests/models/track_package/test_shipment.py b/tests/models/track_package/test_shipment.py new file mode 100644 index 0000000..1c692b7 --- /dev/null +++ b/tests/models/track_package/test_shipment.py @@ -0,0 +1,68 @@ +"""Testing the Shipment class object.""" +from typing import Any, Dict + +import pytest + +from shipengine_sdk.errors import ShipEngineError +from shipengine_sdk.models import Shipment +from shipengine_sdk.util.iso_string import IsoString + +from ...util import stub_shipengine_config + + +def stub_valid_shipment_data() -> Dict[str, Any]: + """ + Return a dictionary that mimics the Shipment data that would + be returned by ShipEngine API. + """ + return { + "carrierCode": "fedex", + "carrierAccountID": "car_kfUjTZSEAQ8gHeT", + "shipmentID": "shp_yuh3GkfUjTZSEAQ", + "estimatedDelivery": "2021-06-15T21:00:00.000Z", + } + + +def stub_invalid_shipment_data() -> Dict[str, Any]: + """ + Return a dictionary that mimics the Shipment data that would + be returned by ShipEngine API, where the `carrierAccountID` is invalid. + """ + return { + "carrierCode": "fedex", + "carrierAccountID": "car_kfUoSHIPENGINEQ8gHeT", + "shipmentID": "shp_yuh3GkfUjTZSEAQ", + "estimatedDelivery": "2021-06-15T21:00:00.000Z", + } + + +def stub_invalid_account_id_shipment_instantiation() -> Shipment: + """Return a test Shipment object that has an invalid `carrierAccountID`..""" + return Shipment( + shipment=stub_invalid_shipment_data(), + actual_delivery_date=IsoString("2021-06-10T21:00:00.000"), + config=stub_shipengine_config(), + ) + + +def stub_valid_shipment_instantiation() -> Shipment: + """Return a valid test Shipment object.""" + return Shipment( + shipment=stub_valid_shipment_data(), + actual_delivery_date=IsoString("2021-06-10T21:00:00.000"), + config=stub_shipengine_config(), + ) + + +class TestShipment: + def test_get_carrier_account_failure_via_invalid_account_id(self) -> None: + with pytest.raises(ShipEngineError): + stub_invalid_account_id_shipment_instantiation() + + def test_shipment_to_dict(self) -> None: + shipment = stub_valid_shipment_instantiation() + assert type(shipment.to_dict()) is dict + + def test_shipment_to_json(self) -> None: + shipment = stub_valid_shipment_instantiation() + assert type(shipment.to_json()) is str diff --git a/tests/models/track_package/test_track_package_result.py b/tests/models/track_package/test_track_package_result.py new file mode 100644 index 0000000..c0612e9 --- /dev/null +++ b/tests/models/track_package/test_track_package_result.py @@ -0,0 +1,147 @@ +"""Testing the TrackPackageResult class object.""" +from typing import Any, Dict + +from shipengine_sdk.models import TrackingEvent, TrackPackageResult + +from ...util import stub_shipengine_config + + +def stub_track_package_data() -> Dict[str, Any]: + """ + Return a dictionary that mimics the track_package response + from ShipEngine API. + """ + return { + "jsonrpc": "2.0", + "id": "req_1de9ca85b8544c1c91cd17abc43cbb5e", + "result": { + "shipment": { + "carrierCode": "fedex", + "carrierAccountID": "car_kfUjTZSEAQ8gHeT", + "shipmentID": "shp_tJUaQJz3Twz57iL", + "estimatedDelivery": "2021-06-15T21:00:00.000Z", + }, + "package": { + "packageID": "pkg_1FedexDeLiveredException", + "trackingNumber": "2A4g3tJUaQJz3Twz57iLWBciD7wZWH", + "trackingURL": "https://www.fedex.com/track/2A4g3tJUaQJz3Twz57iLWBciD7wZWH", + "weight": {"value": 76, "unit": "kilogram"}, + "dimensions": {"length": 36, "width": 36, "height": 23, "unit": "inch"}, + }, + "events": [ + { + "timestamp": "2021-06-10T19:00:00.000Z", + "carrierTimestamp": "2021-06-11T01:00:00", + "status": "accepted", + "description": "Dropped-off at shipping center", + "carrierStatusCode": "ACPT-2", + "carrierDetailCode": "PU7W", + }, + { + "timestamp": "2021-06-11T01:00:00.000Z", + "carrierTimestamp": "2021-06-11T07:00:00", + "status": "in_transit", + "description": "En-route to distribution center hub", + "carrierStatusCode": "ER00P", + }, + { + "timestamp": "2021-06-11T20:00:00.000Z", + "carrierTimestamp": "2021-06-12T02:00:00", + "status": "unknown", + "description": "Mechanically sorted", + "carrierStatusCode": "MMSa", + "carrierDetailCode": "00004134918400045", + }, + { + "timestamp": "2021-06-12T10:00:00.000Z", + "carrierTimestamp": "2021-06-12T16:00:00", + "status": "in_transit", + "description": "On vehicle for delivery", + "carrierStatusCode": "OFD-22", + "carrierDetailCode": "91R-159E", + }, + { + "timestamp": "2021-06-12T17:00:00.000Z", + "carrierTimestamp": "2021-06-12T23:00:00", + "status": "exception", + "description": "Weather delay", + "carrierStatusCode": "EX026", + "carrierDetailCode": "XX00016", + "location": { + "cityLocality": "Pittsburgh", + "stateProvince": "PA", + "postalCode": "15218", + "countryCode": "US", + }, + }, + { + "timestamp": "2021-06-13T02:00:00.000Z", + "carrierTimestamp": "2021-06-13T08:00:00", + "status": "exception", + "description": "Equipment failure", + "carrierStatusCode": "EX038", + "carrierDetailCode": "XX00184", + "location": { + "cityLocality": "Pittsburgh", + "stateProvince": "PA", + "postalCode": "15218", + "countryCode": "US", + }, + }, + { + "timestamp": "2021-06-13T10:00:00.000Z", + "carrierTimestamp": "2021-06-13T16:00:00", + "status": "in_transit", + "description": "On vehicle for delivery", + "carrierStatusCode": "OFD-22", + "carrierDetailCode": "91R-159E", + }, + { + "timestamp": "2021-06-13T20:00:00.000Z", + "carrierTimestamp": "2021-06-14T02:00:00", + "status": "delivered", + "description": "Delivered", + "carrierStatusCode": "DV99-0004", + "signer": "John P. Doe", + "location": { + "cityLocality": "Pittsburgh", + "stateProvince": "PA", + "postalCode": "15218", + "countryCode": "US", + "coordinates": {"latitude": 40.4504687, "longitude": -79.9352761}, + }, + }, + ], + }, + } + + +def stub_track_package_result() -> TrackPackageResult: + """Return a valid stub TrackPackageResult object.""" + return TrackPackageResult( + api_response=stub_track_package_data(), config=stub_shipengine_config() + ) + + +class TestTrackPackageResult: + def test_get_errors(self) -> None: + result = stub_track_package_result() + err = result.get_errors() + assert type(err) is list + assert len(err) == 2 + + def test_has_errors(self) -> None: + result = stub_track_package_result() + assert result.has_errors() is True + + def test_get_latest_event(self) -> None: + result = stub_track_package_result() + assert type(result.get_latest_event()) is TrackingEvent + + def test_track_package_result_to_dict(self) -> None: + result = stub_track_package_result() + assert type(result.to_dict()) is dict + + def test_track_package_result_to_json(self) -> None: + result = stub_track_package_result() + assert type(result.to_json()) is str diff --git a/tests/models/track_package/test_tracking_event.py b/tests/models/track_package/test_tracking_event.py new file mode 100644 index 0000000..4e37682 --- /dev/null +++ b/tests/models/track_package/test_tracking_event.py @@ -0,0 +1,30 @@ +"""Testing the TrackingEvent class object.""" +from typing import Any, Dict + +from shipengine_sdk.models import TrackingEvent + + +def stub_event_data() -> Dict[str, Any]: + """Return a dictionary that mimics a tracking event object in a response from ShipEngine API.""" + return { + "timestamp": "2021-06-13T13:00:00.000Z", + "carrierTimestamp": "2021-06-13T19:00:00", + "status": "accepted", + "description": "Dropped-off at shipping center", + "carrierStatusCode": "ACPT-2", + } + + +def stub_tracking_event() -> TrackingEvent: + """Return a valid stub TrackingEvent object.""" + return TrackingEvent(stub_event_data()) + + +class TestTrackingEvent: + def test_tracking_event_to_dict(self) -> None: + tracking_event = stub_tracking_event() + assert type(tracking_event.to_dict()) is dict + + def test_tracking_event_to_json(self) -> None: + tracking_event = stub_tracking_event() + assert type(tracking_event.to_json()) is str diff --git a/tests/services/test_address_validation.py b/tests/services/test_address_validation.py index aa7b071..bab1cfb 100644 --- a/tests/services/test_address_validation.py +++ b/tests/services/test_address_validation.py @@ -1,7 +1,7 @@ """Test the validate address method of the ShipEngine SDK.""" import re -from shipengine_sdk.errors import ClientSystemError, ValidationError +from shipengine_sdk.errors import ClientSystemError, ShipEngineError, ValidationError from shipengine_sdk.models import ( Address, AddressValidateResult, @@ -15,6 +15,8 @@ address_with_all_fields, address_with_errors, address_with_invalid_country, + address_with_invalid_postal_code, + address_with_invalid_state, address_with_warnings, canada_valid_avs_assertions, get_server_side_error, @@ -237,3 +239,25 @@ def test_address_with_name_company_phone(self) -> None: returned_address=validated_address, expected_residential_indicator=True, ) + + def test_address_with_invalid_state(self) -> None: + """Test validate_address when an invalid state is passed in.""" + try: + address_with_invalid_state() + except ShipEngineError as err: + assert type(err) is ValidationError + assert ( + err.message + == "Invalid address. Either the postal code or the city/locality and state/province must be specified." + ) # noqa + + def test_address_with_invalid_postal_code(self) -> None: + """Test validate_address when an invalid postal code is passed in.""" + try: + address_with_invalid_postal_code() + except ShipEngineError as err: + assert type(err) is ValidationError + assert ( + err.message + == "Invalid address. Either the postal code or the city/locality and state/province must be specified." + ) # noqa diff --git a/tests/services/test_get_carrier_accounts.py b/tests/services/test_get_carrier_accounts.py index ef251e6..1a1eabe 100644 --- a/tests/services/test_get_carrier_accounts.py +++ b/tests/services/test_get_carrier_accounts.py @@ -1,6 +1,7 @@ """Tests for the GetCarrierAccounts service in the ShipEngine SDK.""" from shipengine_sdk.errors import ClientSystemError from shipengine_sdk.models import Carriers, ErrorCode, ErrorSource, ErrorType +from shipengine_sdk.services.get_carrier_accounts import GetCarrierAccounts from ..util.test_helpers import stub_get_carrier_accounts @@ -18,37 +19,37 @@ def test_multiple_accounts(self) -> None: accounts = stub_get_carrier_accounts() assert len(accounts) == 5 - assert accounts[0]["carrier"]["code"] == Carriers.UPS.value - assert accounts[1]["carrier"]["code"] == Carriers.FEDEX.value - assert accounts[2]["carrier"]["code"] == Carriers.FEDEX.value - assert accounts[3]["carrier"]["code"] == Carriers.USPS.value - assert accounts[4]["carrier"]["code"] == Carriers.STAMPS_COM.value + assert accounts[0].carrier["code"] == Carriers.UPS.value + assert accounts[1].carrier["code"] == Carriers.FEDEX.value + assert accounts[2].carrier["code"] == Carriers.FEDEX.value + assert accounts[3].carrier["code"] == Carriers.USPS.value + assert accounts[4].carrier["code"] == Carriers.STAMPS_COM.value for account in accounts: - assert account["account_id"].startswith("car_") - assert account["name"] is not None - assert account["account_number"] is not None - assert account["carrier"] is not None - assert account["carrier"]["code"] is not None - assert type(account["carrier"]["code"]) == str - assert account["carrier"]["name"] is not None - assert type(account["carrier"]["name"]) == str + assert account.account_id.startswith("car_") + assert account.name is not None + assert account.account_number is not None + assert account.carrier is not None + assert account.carrier["code"] is not None + assert type(account.carrier["code"]) == str + assert account.carrier["name"] is not None + assert type(account.carrier["name"]) == str def test_multiple_accounts_of_same_carrier(self): """DX-1077 - Multiple accounts of the same carrier.""" accounts = stub_get_carrier_accounts() assert len(accounts) == 5 - assert accounts[0]["carrier"]["code"] == Carriers.UPS.value - assert accounts[0]["account_id"] != accounts[1]["account_id"] - assert accounts[1]["carrier"]["code"] == Carriers.FEDEX.value - assert accounts[2]["carrier"]["code"] == Carriers.FEDEX.value - assert accounts[3]["carrier"]["code"] == Carriers.USPS.value - assert accounts[4]["carrier"]["code"] == Carriers.STAMPS_COM.value + assert accounts[0].carrier["code"] == Carriers.UPS.value + assert accounts[0].account_id != accounts[1].account_id + assert accounts[1].carrier["code"] == Carriers.FEDEX.value + assert accounts[2].carrier["code"] == Carriers.FEDEX.value + assert accounts[3].carrier["code"] == Carriers.USPS.value + assert accounts[4].carrier["code"] == Carriers.STAMPS_COM.value for account in accounts: - assert account["account_id"].startswith("car_") - assert account["name"] is not None + assert account.account_id.startswith("car_") + assert account.name is not None def test_server_side_error(self) -> None: """DX-1078 - Get carrier accounts server-side error.""" @@ -60,3 +61,12 @@ def test_server_side_error(self) -> None: assert err.source == ErrorSource.SHIPENGINE.value assert err.error_type == ErrorType.SYSTEM.value assert err.error_code == ErrorCode.UNSPECIFIED.value + + def test_get_cached_accounts_by_carrier(self) -> None: + get_accounts = GetCarrierAccounts() + stub_get_carrier_accounts() # fill the cache + accounts = get_accounts.get_cached_accounts_by_carrier_code(carrier_code="fedex") + + assert len(accounts) == 2 + assert accounts[0].carrier["code"] == "fedex" + assert accounts[1].carrier["code"] == "fedex" diff --git a/tests/services/test_track_package.py b/tests/services/test_track_package.py new file mode 100644 index 0000000..ef02a65 --- /dev/null +++ b/tests/services/test_track_package.py @@ -0,0 +1,204 @@ +"""Testing the `track_package` method of the ShipEngine SDK.""" +from typing import List + +from shipengine_sdk.models import TrackingQuery, TrackPackageResult +from tests.util.test_helpers import configurable_stub_shipengine_instance, stub_config + + +def assertions_on_delivered_after_exception_or_multiple_attempts( + tracking_result: TrackPackageResult, +) -> None: + track_package_assertions(tracking_result=tracking_result) + does_delivery_date_match(tracking_result) + assert_events_in_order(tracking_result.events) + assert len(tracking_result.events) == 8 + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[1].status == "in_transit" + assert tracking_result.events[2].status == "unknown" + assert tracking_result.events[3].status == "in_transit" + assert tracking_result.events[4].status == "exception" + assert tracking_result.events[5].status == "exception" + assert tracking_result.events[6].status == "in_transit" + assert tracking_result.events[7].status == "delivered" + assert tracking_result.events[-1].status == "delivered" + + +def does_delivery_date_match(tracking_result: TrackPackageResult) -> None: + """Check that the delivery dates for a given tracking response match.""" + assert ( + tracking_result.shipment.actual_delivery_date.to_datetime_object() + == tracking_result.events[-1].date_time.to_datetime_object() + ) + + +def assert_events_in_order(events: List) -> None: + """ + Checks that the order of events is correct in that they should be ordered with + the newest event at the bottom of the list. + """ + previous_date_time = events[0].date_time + for event in events: + assert event.date_time.to_datetime_object() >= previous_date_time.to_datetime_object() + previous_date_time = event.date_time + + +def track_package_assertions(tracking_result: TrackPackageResult) -> None: + """Common `track_package` assertions.""" + carrier_account_carrier_code = tracking_result.shipment.carrier_account.carrier["code"] + carrier_code = tracking_result.shipment.carrier["code"] + estimated_delivery = tracking_result.shipment.estimated_delivery_date + + assert carrier_account_carrier_code is not None + assert type(carrier_account_carrier_code) is str + assert carrier_code is not None + assert type(carrier_code) is str + assert estimated_delivery.has_timezone() is True + + +class TestTrackPackage: + _PACKAGE_ID_FEDEX_ACCEPTED: str = "pkg_1FedExAccepted" + _PACKAGE_ID_FEDEX_DELIVERED: str = "pkg_1FedExDeLivered" + _PACKAGE_ID_FEDEX_DELIVERED_EXCEPTION: str = "pkg_1FedexDeLiveredException" + + def test_track_by_tracking_number_and_carrier_code(self) -> None: + """DX-1084 - Test track by tracking number and carrier code.""" + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_data = TrackingQuery(carrier_code="fedex", tracking_number="abcFedExDelivered") + tracking_result = shipengine.track_package(tracking_data=tracking_data) + + assert tracking_data.carrier_code == tracking_result.shipment.carrier.code + assert tracking_data.tracking_number == tracking_result.package.tracking_number + assert tracking_result.package.tracking_url is not None + assert type(tracking_result.package.tracking_url) is str + + def test_track_by_package_id(self) -> None: + """DX-1086 - Test track by package ID.""" + package_id = self._PACKAGE_ID_FEDEX_ACCEPTED + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + assert tracking_result.package.package_id == package_id + assert tracking_result.package.tracking_number is not None + assert tracking_result.package.tracking_url is not None + assert tracking_result.shipment.shipment_id is not None + assert tracking_result.shipment.account_id is not None + + def test_initial_scan_tracking_event(self) -> None: + """DX-1088 - Test initial scan tracking event.""" + package_id = self._PACKAGE_ID_FEDEX_ACCEPTED + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + assert len(tracking_result.events) == 1 + assert tracking_result.events[0].status == "accepted" + + def test_out_for_delivery_tracking_event(self) -> None: + """DX-1089 - Test out for delivery tracking event.""" + package_id = "pkg_1FedExAttempted" + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + assert len(tracking_result.events) == 5 + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[1].status == "in_transit" + + def test_multiple_delivery_attempts(self) -> None: + """DX-1090 - Test multiple delivery attempt events.""" + package_id = "pkg_1FedexDeLiveredAttempted" + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + assert len(tracking_result.events) == 9 + assert_events_in_order(tracking_result.events) + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[1].status == "in_transit" + assert tracking_result.events[2].status == "unknown" + assert tracking_result.events[3].status == "in_transit" + assert tracking_result.events[4].status == "attempted_delivery" + assert tracking_result.events[5].status == "in_transit" + assert tracking_result.events[6].status == "attempted_delivery" + assert tracking_result.events[7].status == "in_transit" + assert tracking_result.events[8].status == "delivered" + + def test_delivered_on_first_try(self) -> None: + """DX-1091 - Test delivered on first try tracking event.""" + package_id = self._PACKAGE_ID_FEDEX_DELIVERED + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + assert ( + tracking_result.shipment.actual_delivery_date.to_datetime_object() + == tracking_result.events[4].date_time.to_datetime_object() + ) + does_delivery_date_match(tracking_result) + assert_events_in_order(tracking_result.events) + assert len(tracking_result.events) == 5 + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[1].status == "in_transit" + assert tracking_result.events[4].status == "delivered" + assert tracking_result.events[-1].status == "delivered" + + def test_delivered_with_signature(self) -> None: + """DX-1092 - Test track delivered with signature event.""" + package_id = self._PACKAGE_ID_FEDEX_DELIVERED + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + does_delivery_date_match(tracking_result) + assert_events_in_order(tracking_result.events) + assert len(tracking_result.events) == 5 + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[1].status == "in_transit" + assert tracking_result.events[2].status == "unknown" + assert tracking_result.events[3].status == "in_transit" + assert tracking_result.events[4].status == "delivered" + assert tracking_result.events[-1].status == "delivered" + assert tracking_result.events[-1].signer is not None + assert type(tracking_result.events[-1].signer) is str + + def test_delivered_after_multiple_attempts(self) -> None: + """DX-1093 - Test delivered after multiple attempts tracking event.""" + package_id = self._PACKAGE_ID_FEDEX_DELIVERED_EXCEPTION + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + assertions_on_delivered_after_exception_or_multiple_attempts(tracking_result) + + def test_delivered_after_exception(self) -> None: + """DX-1094 - Test delivered after exception tracking event.""" + package_id = self._PACKAGE_ID_FEDEX_DELIVERED_EXCEPTION + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + assertions_on_delivered_after_exception_or_multiple_attempts(tracking_result) + + def test_single_exception_tracking_event(self) -> None: + """DX-1095 - Test single exception tracking event.""" + package_id = "pkg_1FedexException" + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + assert_events_in_order(tracking_result.events) + assert len(tracking_result.events) == 3 + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[1].status == "in_transit" + assert tracking_result.events[2].status == "exception" + + def test_track_with_multiple_exceptions(self) -> None: + """DX-1096 - Test track with multiple exceptions.""" + package_id = self._PACKAGE_ID_FEDEX_DELIVERED_EXCEPTION + shipengine = configurable_stub_shipengine_instance(stub_config()) + tracking_result = shipengine.track_package(tracking_data=package_id) + + track_package_assertions(tracking_result=tracking_result) + assert_events_in_order(tracking_result.events) + assert len(tracking_result.events) == 8 + assert tracking_result.events[0].status == "accepted" + assert tracking_result.events[4].status == "exception" + assert tracking_result.events[5].status == "exception" + assert tracking_result.events[7].status == "delivered" + assert tracking_result.events[-1].status == "delivered" diff --git a/tests/util/__init__.py b/tests/util/__init__.py index b5bcf10..c8fef9e 100644 --- a/tests/util/__init__.py +++ b/tests/util/__init__.py @@ -1 +1,2 @@ -"""Initial Docstring""" +"""Test helper functions and test data as functions used throughout the test suite.""" +from .test_helpers import * # noqa diff --git a/tests/util/test_helpers.py b/tests/util/test_helpers.py index 0fcb725..3eb7868 100644 --- a/tests/util/test_helpers.py +++ b/tests/util/test_helpers.py @@ -1,23 +1,44 @@ """Test data as functions and common assertion helper functions.""" from typing import Dict, Optional, Union -from shipengine_sdk import ShipEngine -from shipengine_sdk.models import Address, AddressValidateResult, Endpoints - - -def stub_config() -> Dict[str, any]: +from shipengine_sdk import ShipEngine, ShipEngineConfig +from shipengine_sdk.models import ( + Address, + AddressValidateResult, + Endpoints, + TrackingQuery, +) + + +def stub_config( + retries: int = 1, +) -> Dict[str, any]: """ Return a test configuration dictionary to be used when instantiating the ShipEngine object. """ return dict( - api_key="baz", base_uri=Endpoints.TEST_RPC_URL.value, page_size=50, retries=2, timeout=15 + api_key="baz", + base_uri=Endpoints.TEST_RPC_URL.value, + page_size=50, + retries=retries, + timeout=15, ) +def stub_shipengine_config() -> ShipEngineConfig: + """Return a valid test ShipEngineConfig object.""" + return ShipEngineConfig(config=stub_config()) + + +def configurable_stub_shipengine_instance(config: Dict[str, any]) -> ShipEngine: + """""" + return ShipEngine(config=config) + + def stub_shipengine_instance() -> ShipEngine: """Return a test instance of the ShipEngine object.""" - return ShipEngine(stub_config()) + return ShipEngine(config=stub_config()) def address_with_all_fields() -> Address: @@ -197,6 +218,28 @@ def address_with_invalid_country() -> Address: ) +def address_with_invalid_state() -> Address: + """Return an address with an invalid state value.""" + return Address( + street=["4 Jersey St", "Apt. 2b"], + city_locality="Boston", + state_province="&$", + postal_code="02215", + country_code="US", + ) + + +def address_with_invalid_postal_code() -> Address: + """Return an address with an invalid postal code.""" + return Address( + street=["4 Jersey St", "Apt. 2b"], + city_locality="Boston", + state_province="MA", + postal_code="2$1*5", + country_code="US", + ) + + def get_server_side_error() -> Address: """Return an address that will cause the server to return a 500 server error.""" return Address( @@ -223,7 +266,12 @@ def normalize_an_address(address: Address) -> Address: it and calls the `normalize_address` method, providing it the `address` that is passed into this function. """ - return stub_shipengine_instance().normalize_address(address) + return stub_shipengine_instance().normalize_address(address=address) + + +def track_a_package(tracking_data: Union[str, Dict[str, any], TrackingQuery]): + """""" + return stub_shipengine_instance().track_package(tracking_data=tracking_data) def stub_get_carrier_accounts(carrier_code: Optional[str] = None): diff --git a/tests/util/test_iso_string.py b/tests/util/test_iso_string.py new file mode 100644 index 0000000..63a723f --- /dev/null +++ b/tests/util/test_iso_string.py @@ -0,0 +1,27 @@ +"""Testing the IsoString class object.""" +import datetime + +from shipengine_sdk.util.iso_string import IsoString + + +class TestIsoString: + _test_iso_string_no_tz: str = "2021-06-10T21:00:00.000" + + def test_to_string(self) -> None: + iso_str = IsoString(self._test_iso_string_no_tz).to_string() + + assert type(iso_str) is str + + def test_to_datetime_object(self) -> None: + iso_str = IsoString(self._test_iso_string_no_tz).to_datetime_object() + + assert type(iso_str) is datetime.datetime + + def test_static_no_tz_check(self) -> None: + assert IsoString.is_valid_iso_string_no_tz(self._test_iso_string_no_tz) is True + + def test_static_valid_iso_check(self) -> None: + assert IsoString.is_valid_iso_string(self._test_iso_string_no_tz) is True + + def test_static_valid_iso_check_failure(self) -> None: + assert IsoString.is_valid_iso_string("2021-06-10T21:00:00.000K") is False