py-canada-post 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. py_canada_post/__init__.py +6 -0
  2. py_canada_post/__main__.py +4 -0
  3. py_canada_post/cli/__init__.py +0 -0
  4. py_canada_post/cli/app.py +32 -0
  5. py_canada_post/cli/rating/__init__.py +1 -0
  6. py_canada_post/cli/rating/commands/__init__.py +0 -0
  7. py_canada_post/cli/rating/commands/discover_services.py +54 -0
  8. py_canada_post/cli/rating/commands/get_rates.py +154 -0
  9. py_canada_post/cli/rating/rating_app.py +12 -0
  10. py_canada_post/client.py +113 -0
  11. py_canada_post/exceptions/__init__.py +37 -0
  12. py_canada_post/exceptions/exception_map.py +138 -0
  13. py_canada_post/exceptions/exceptions.py +150 -0
  14. py_canada_post/services/__init__.py +0 -0
  15. py_canada_post/services/rating/__init__.py +31 -0
  16. py_canada_post/services/rating/operations/__init__.py +0 -0
  17. py_canada_post/services/rating/operations/discover_services.py +131 -0
  18. py_canada_post/services/rating/operations/get_rates.py +259 -0
  19. py_canada_post/services/rating/rating.py +51 -0
  20. py_canada_post/services/rating/types.py +364 -0
  21. py_canada_post/utils/__init__.py +0 -0
  22. py_canada_post/utils/construct_xml_element.py +160 -0
  23. py_canada_post/utils/error_handler.py +43 -0
  24. py_canada_post/utils/response_to_object/__init__.py +0 -0
  25. py_canada_post/utils/response_to_object/response_to_object.py +222 -0
  26. py_canada_post/utils/response_to_object/serialization/__init__.py +0 -0
  27. py_canada_post/utils/response_to_object/serialization/rate_to_object.py +212 -0
  28. py_canada_post/utils/response_to_object/serialization/service_to_object.py +58 -0
  29. py_canada_post-1.0.0.dist-info/METADATA +261 -0
  30. py_canada_post-1.0.0.dist-info/RECORD +33 -0
  31. py_canada_post-1.0.0.dist-info/WHEEL +4 -0
  32. py_canada_post-1.0.0.dist-info/entry_points.txt +2 -0
  33. py_canada_post-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,6 @@
1
+ """py-canada-post wrapper."""
2
+ from py_canada_post import client
3
+
4
+ __all__ = ["client"]
5
+
6
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from py_canada_post.cli.app import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
File without changes
@@ -0,0 +1,32 @@
1
+ from importlib.metadata import version
2
+
3
+ from cyclopts import App
4
+
5
+ from .rating import rating_app
6
+
7
+
8
+ def version_callback() -> str:
9
+ """
10
+ Function to get a version of the wrapper.
11
+
12
+ Returns
13
+ -------
14
+ str
15
+ Version of the wrapper.
16
+ """
17
+
18
+ return version('py_canada_post')
19
+
20
+
21
+ app = App(
22
+ name="py-canada-post",
23
+ help="Python wrapper to interact with Canada Post API.",
24
+ version_format="rich",
25
+ version_flags=["--version", "-v"],
26
+ version=version_callback,
27
+ help_format="rich",
28
+ )
29
+ app.command(rating_app)
30
+
31
+ if __name__ == "__main__":
32
+ app()
@@ -0,0 +1 @@
1
+ from .rating_app import rating_app
File without changes
@@ -0,0 +1,54 @@
1
+ from typing import Annotated
2
+
3
+ from cyclopts import Parameter, App
4
+
5
+ from py_canada_post.client import PyCanadaPost
6
+ from py_canada_post.services.rating.types import Service
7
+
8
+ client = PyCanadaPost.from_env()
9
+
10
+ discover_services = App(
11
+ name="discover-services",
12
+ help="Command to get available services."
13
+ )
14
+
15
+
16
+ @discover_services.command
17
+ def discover_services(
18
+ country_code: Annotated[
19
+ str,
20
+ Parameter(name=["country-code", "-c"], required=True)
21
+ ],
22
+ origin_postal_code: Annotated[
23
+ str,
24
+ Parameter(name=["origin-postal-code", "-o"], required=False)
25
+ ] = None,
26
+ destination_postal_code: Annotated[
27
+ str,
28
+ Parameter(name=["destination-postal-code", "-d"], required=False)
29
+ ] = None
30
+ ) -> list[Service] | None:
31
+ """
32
+ Command to get available services.
33
+
34
+ Parameters
35
+ ----------
36
+ country_code : str
37
+ Country code in a 2-letter format (e.g. JP, CA, US).
38
+ origin_postal_code : str, optional
39
+ Origin postal code where the package will be sent from.
40
+ destination_postal_code : str, optional
41
+ Destination postal code where the package will be delivered to.
42
+
43
+ Returns
44
+ -------
45
+ list[Service] | None
46
+ List of services or None.
47
+ """
48
+
49
+ services = client.rating.services.discover_services(
50
+ country_code,
51
+ origin_postal_code,
52
+ destination_postal_code
53
+ )
54
+ return services
@@ -0,0 +1,154 @@
1
+ from datetime import datetime
2
+ from typing import Annotated, Literal
3
+
4
+ from cyclopts import Parameter, App
5
+
6
+ from py_canada_post.client import PyCanadaPost
7
+ from py_canada_post.services.rating.types import Destination, Option, ParcelCharacteristics, Rate
8
+
9
+ client = PyCanadaPost.from_env()
10
+
11
+ get_rates = App(
12
+ name="get-rates",
13
+ help="Command to get rates."
14
+ )
15
+
16
+
17
+ @get_rates.command
18
+ def get_rates(
19
+ origin_postal_code: Annotated[
20
+ str,
21
+ Parameter(name=["origin-postal-code", "-o"], required=True)
22
+ ],
23
+ destination: Annotated[
24
+ Destination,
25
+ Parameter(name=["destination", "-d"], required=True)
26
+ ],
27
+ promo_code: Annotated[
28
+ str,
29
+ Parameter(name=["promo-code", "-p"], required=False)
30
+ ] = None,
31
+ quote_type: Annotated[
32
+ Literal["commercial", "counter"],
33
+ Parameter(name=["quote-type", "-q"], required=False)
34
+ ] = "commercial",
35
+ expected_mailing_date: Annotated[
36
+ datetime,
37
+ Parameter(name=["expected-mailing-date", "-m"], required=False)
38
+ ] = None,
39
+ options: Annotated[
40
+ list[Option],
41
+ Parameter(name=["options", "-O"], required=False, consume_multiple=True)
42
+ ] = None,
43
+ parcel_characteristics: Annotated[
44
+ ParcelCharacteristics,
45
+ Parameter(name=["parcel-characteristics", "-c"], required=False)
46
+ ] = None,
47
+ unpackaged: Annotated[
48
+ bool,
49
+ Parameter(name=["unpackaged", "-u"], required=False)
50
+ ] = False,
51
+ mailing_tube: Annotated[
52
+ bool,
53
+ Parameter(name=["mailing-tube", "-t"], required=False)
54
+ ] = False,
55
+ oversized: Annotated[
56
+ bool,
57
+ Parameter(name=["oversized", "-z"], required=False)
58
+ ] = False,
59
+ services: Annotated[
60
+ list[Literal[
61
+ "DOM.RP",
62
+ "DOM.EP",
63
+ "DOM.XP",
64
+ "DOM.XP.CERT",
65
+ "DOM.PC",
66
+ "DOM.LIB",
67
+ "USA.EP",
68
+ "USA.SP.AIR",
69
+ "USA.TP",
70
+ "USA.TP.LVM",
71
+ "USA.XP",
72
+ "INT.XP",
73
+ "INT.IP.AIR",
74
+ "INT.IP.SURF",
75
+ "INT.SP.AIR",
76
+ "INT.SP.SURF",
77
+ "INT.TP"
78
+ ]],
79
+ Parameter(name=["services", "-s"], required=False, consume_multiple=True)
80
+ ] = None
81
+ ) -> list[Rate] | None:
82
+ """
83
+ Command to get rates.
84
+
85
+ Parameters
86
+ ----------
87
+ origin_postal_code : str
88
+ Postal Code from which the parcel will be sent.
89
+ Format ANANAN (only accepted with uppercase)
90
+ destination : Destination
91
+ Defines the destination of the parcel.
92
+ promo_code : str, optional
93
+ If you have a promotional discount code, enter it here. The discount amount will be returned in the response under the adjustment structure.
94
+ quote_type : Literal["commercial", "counter"], default "commercial"
95
+ Either commercial or counter.
96
+
97
+ - "commercial" will return the discounted price for the commercial customer or Solutions for Small Business member.
98
+ - "counter" will return the regular price paid by consumers.
99
+ Defaults to "commercial" if not specified.
100
+ expected_mailing_date : datetime, optional
101
+ The expected mailing date for the parcel.
102
+
103
+ This date is used in calculations of the expected delivery date, however all rate quotes are based on the current system date.
104
+ options : list[Option], optional
105
+ Structure containing the list of options desired for the shipment.
106
+ parcel_characteristics : ParcelCharacteristics, optional
107
+ Details of the parcel such as weight and dimensions.
108
+ unpackaged : bool, default False
109
+ Indicates that the parcel will be unpackaged (e.g. tires)
110
+ mailing_tube : bool, default False
111
+ Indicates that the object will be shipped in a mailing tube
112
+ oversized : bool, default False
113
+ Indicates that the object has oversized dimensions. Automatically set correctly if dimensions are provided.
114
+ services : list[Literal[
115
+ "DOM.RP",
116
+ "DOM.EP",
117
+ "DOM.XP",
118
+ "DOM.XP.CERT",
119
+ "DOM.PC",
120
+ "DOM.LIB",
121
+ "USA.EP",
122
+ "USA.SP.AIR",
123
+ "USA.TP",
124
+ "USA.TP.LVM",
125
+ "USA.XP",
126
+ "INT.XP",
127
+ "INT.IP.AIR",
128
+ "INT.IP.SURF",
129
+ "INT.SP.AIR",
130
+ "INT.SP.SURF",
131
+ "INT.TP"
132
+ ]], optional
133
+ List of services to be used for the shipment.
134
+
135
+ Returns
136
+ -------
137
+ list[Rate] or None
138
+ List of rates or None.
139
+ """
140
+
141
+ rates = client.rating.rates.get_rates(
142
+ origin_postal_code,
143
+ destination,
144
+ promo_code,
145
+ quote_type,
146
+ expected_mailing_date,
147
+ options,
148
+ parcel_characteristics,
149
+ unpackaged,
150
+ mailing_tube,
151
+ oversized,
152
+ services
153
+ )
154
+ return rates
@@ -0,0 +1,12 @@
1
+ from cyclopts import App
2
+
3
+ from .commands.discover_services import discover_services
4
+ from .commands.get_rates import get_rates
5
+
6
+ rating_app = App(
7
+ name="rating",
8
+ help="Command to contain all rating-related commands (e.g. get_rates, discover_services etc.)."
9
+ )
10
+
11
+ rating_app.command(get_rates)
12
+ rating_app.command(discover_services)
@@ -0,0 +1,113 @@
1
+ import os
2
+ from base64 import b64encode
3
+ from typing import Literal
4
+ from typing import Self
5
+
6
+ from dotenv import load_dotenv
7
+ from requests.auth import to_native_string
8
+
9
+ from py_canada_post.services.rating.rating import Rating
10
+
11
+ load_dotenv()
12
+
13
+
14
+ class PyCanadaPost:
15
+ def __init__(
16
+ self,
17
+ customer_number: int,
18
+ api_key: str,
19
+ environment: Literal["SANDBOX", "PRODUCTION"] = "SANDBOX",
20
+ contract_id: int = None,
21
+ language: Literal["en-CA", "fr-CA"] = "en-CA"
22
+ ) -> None:
23
+ """
24
+ Initialize class variables.
25
+
26
+ Parameters
27
+ ----------
28
+ customer_number : int
29
+ Customer number that was obtained from Canada Post Developer Portal.
30
+ api_key : str
31
+ Api key that was obtained from Canada Post Developer Porta
32
+ environment : Literal["SANDBOX", "PRODUCTION"], default "SANDBOX"
33
+ Environment of the app.
34
+ contract_id : int, optional
35
+ Contract id that was obtained from Canada Post Developer Portal.
36
+ language : Literal["en-CA", "fr-CA"], default "en-CA"
37
+ Language to use.
38
+ """
39
+
40
+ self.customer_number = customer_number
41
+ self.contract_id = contract_id
42
+ self.environment = environment
43
+ self.language = language
44
+ self._api_key = api_key
45
+
46
+ self.endpoint = self._get_endpoint()
47
+ self.headers = self._get_headers()
48
+
49
+ self.rating = Rating(self.headers, self.endpoint, self.customer_number, self.contract_id)
50
+
51
+ def _get_endpoint(self) -> str:
52
+ """
53
+ Function to get an endpoint based on the environment.
54
+
55
+ Returns
56
+ -------
57
+ str
58
+ Endpoint.
59
+ """
60
+
61
+ endpoints = {
62
+ "SANDBOX": "https://ct.soa-gw.canadapost.ca",
63
+ "PRODUCTION": "https://soa-gw.canadapost.ca"
64
+ }
65
+
66
+ return endpoints[self.environment]
67
+
68
+ def _get_headers(self) -> dict:
69
+ """
70
+ Function to get essential headers.
71
+
72
+ Returns
73
+ -------
74
+ dict
75
+ Headers.
76
+ """
77
+
78
+ username, password = self._api_key.split(":")
79
+
80
+ username = username.encode("latin1")
81
+ password = password.encode("latin1")
82
+
83
+ return {
84
+ "Accept-Language": self.language,
85
+ "Authorization": "Basic " + to_native_string(b64encode(b":".join((username, password))).strip())
86
+ }
87
+
88
+ @classmethod
89
+ def from_env(cls) -> Self:
90
+ """
91
+ Function to initialize PyCanadaPost client object based on the .env variables.
92
+ Function raises an error if .env variables don't exist.
93
+
94
+ Returns
95
+ -------
96
+ Self@PyCanadaPost
97
+ PyCanadaPost client object.
98
+ """
99
+ customer_number = os.getenv("CUSTOMER_NUMBER", None)
100
+ api_key = os.getenv("API_KEY", None)
101
+ contract_id = os.getenv("CONTRACT_ID", None)
102
+
103
+ if not customer_number or not api_key or not contract_id:
104
+ raise AssertionError(
105
+ "Missing .env variables. "
106
+ "Please make sure you have CUSTOMER_NUMBER, API_KEY and CONTRACT_ID set up in your .env"
107
+ )
108
+
109
+ return cls(
110
+ customer_number=int(customer_number),
111
+ api_key=api_key,
112
+ contract_id=int(contract_id),
113
+ )
@@ -0,0 +1,37 @@
1
+ from py_canada_post.exceptions.exceptions import (
2
+ CanadaPostError,
3
+ ServerError,
4
+ UserIdDeactivated,
5
+ EndpointMissmatch,
6
+ APIMissmatch,
7
+ InvalidCustomer,
8
+ UnspecifiedPlatform,
9
+ PlatformNotAuthorized,
10
+ InactivePlatform,
11
+ UnauthorizedPlatform,
12
+ InvalidPlatformKeyType,
13
+ IncorrectPlatformRequest,
14
+ PostOfficesNotFound,
15
+ InvalidContractNumber,
16
+ InvalidPostalCode,
17
+ MissingOriginPostalCode
18
+ )
19
+
20
+ __all__ = [
21
+ "CanadaPostError",
22
+ "ServerError",
23
+ "UserIdDeactivated",
24
+ "EndpointMissmatch",
25
+ "APIMissmatch",
26
+ "InvalidCustomer",
27
+ "UnspecifiedPlatform",
28
+ "PlatformNotAuthorized",
29
+ "InactivePlatform",
30
+ "UnauthorizedPlatform",
31
+ "InvalidPlatformKeyType",
32
+ "IncorrectPlatformRequest",
33
+ "PostOfficesNotFound",
34
+ "InvalidContractNumber",
35
+ "InvalidPostalCode",
36
+ "MissingOriginPostalCode"
37
+ ]
@@ -0,0 +1,138 @@
1
+ from .exceptions import (
2
+ ServerError,
3
+ UserIdDeactivated,
4
+ EndpointMissmatch,
5
+ APIMissmatch,
6
+ InvalidCustomer,
7
+ UnspecifiedPlatform,
8
+ PlatformNotAuthorized,
9
+ InactivePlatform,
10
+ UnauthorizedPlatform,
11
+ InvalidPlatformKeyType,
12
+ IncorrectPlatformRequest,
13
+ PostOfficesNotFound,
14
+ InvalidContractNumber,
15
+ InvalidPostalCode,
16
+ InvalidDestinationCountry,
17
+ MissingOriginPostalCode
18
+ )
19
+
20
+
21
+ class ExceptionDefinition:
22
+ def __init__(self, exception: type[Exception], description: str, mitigation: str = None) -> None:
23
+ """
24
+ Initialize class variables.
25
+
26
+ Parameters
27
+ ----------
28
+ exception : type[Exception]
29
+ Exception-type (e.g. ServerError, UserIdDeactivated).
30
+ description : str
31
+ Description of the error to display.
32
+ mitigation : str, optional
33
+ Steps to fix the issue arisen.
34
+ """
35
+
36
+ self.exception = exception
37
+ self.description = description
38
+ self.mitigation = mitigation
39
+
40
+
41
+ ERROR_MAP: dict[str, ExceptionDefinition] = {
42
+ "Server": ExceptionDefinition(
43
+ ServerError,
44
+ "Rejected by SLM Monitor",
45
+ "You have exceeded the throttle limit for your API key. "
46
+ "You will be blocked from making additional calls for up to a minute."
47
+ ),
48
+ "AA001": ExceptionDefinition(
49
+ UserIdDeactivated,
50
+ "The user id for the request has been deactivated. "
51
+ "If you withdrew from the Developer Program, rejoin the program.",
52
+ "From the Developer Program website, select Join Now."
53
+ ),
54
+ "AA002": ExceptionDefinition(
55
+ EndpointMissmatch,
56
+ "The username and password of the request do not match the endpoint. "
57
+ "E.g. development key against production endpoint or vice versa.",
58
+ "Merchant requests cannot be sent to the development environment."
59
+ ),
60
+ "AA003": ExceptionDefinition(
61
+ APIMissmatch,
62
+ "The API key in the 'Authorization' header does not match "
63
+ "the mailed-by customer number in the request.",
64
+ "Verify your data."
65
+ ),
66
+ "AA004": ExceptionDefinition(
67
+ InvalidCustomer,
68
+ "You cannot mail on behalf of the requested customer."
69
+ ),
70
+ "AA005": ExceptionDefinition(
71
+ UnspecifiedPlatform,
72
+ "Platform id not specified",
73
+ "The platform-id header variable is empty or not present in the URL. "
74
+ "This should only be encountered during platform development coding."
75
+ ),
76
+ "AA006": ExceptionDefinition(
77
+ PlatformNotAuthorized,
78
+ "Platform not authorized",
79
+ "The platform-id specified is incorrect or the merchant subsequently came to "
80
+ "Canada Post and intentionally revoked permission for your platform to submit transactions on its behalf. "
81
+ "The merchant could be asked to revalidate with Canada Post if they "
82
+ "want to re-establish their relationship with the platform. "
83
+ "This error would also occur if the online owner of the platform key voluntarily "
84
+ "withdrew from the Developer Program. In rare cases, Canada Post may have deactivated "
85
+ "the entire platform status due to fraud or misuse concerns."
86
+ ),
87
+ "AA007": ExceptionDefinition(
88
+ InactivePlatform,
89
+ "Platform not active",
90
+ "You will receive this error if you have tried to use "
91
+ "Get Merchant Registration Token while your application to become an approved e-commerce "
92
+ "platform with Canada Post is still pending. "
93
+ "You cannot use this service until Canada Post has approved your application. "
94
+ "In rare cases, Canada Post may have deactivated the entire platform status due to fraud or misuse concerns."
95
+ ),
96
+ "AA008": ExceptionDefinition(
97
+ UnauthorizedPlatform,
98
+ "Unauthorized Platform",
99
+ "You will receive this error if you are attempting to use Get Merchant"
100
+ " Registration Token service but have not applied to become an e-commerce platform with Canada Post. "
101
+ "To apply, sign in to the Developer Program home page and select Become a Platform."
102
+ ),
103
+ "AA009": ExceptionDefinition(
104
+ InvalidPlatformKeyType,
105
+ "Key type not valid for platform-id",
106
+ "If a key other than a merchant key is being used to authenticate a transaction, "
107
+ "the platform-id field must not be specified. Remove the platform-id field from the "
108
+ "request even if you are a registered platform. Only requests done on behalf of a "
109
+ "merchant can specify the platform-id."
110
+ ),
111
+ "AA010": ExceptionDefinition(
112
+ IncorrectPlatformRequest,
113
+ "Incorrectly configured platform request.",
114
+ "The platform-id in the header and the platform-id in the URL disagree. "
115
+ "These values must match."
116
+ ),
117
+ "E00010": ExceptionDefinition(
118
+ PostOfficesNotFound,
119
+ "No Post Offices found"
120
+ ),
121
+ "2550": ExceptionDefinition(
122
+ InvalidContractNumber,
123
+ "The contract number is not valid.",
124
+ "Please enter the correct contract number"
125
+ ),
126
+ "7266": ExceptionDefinition(
127
+ InvalidPostalCode,
128
+ "Postal Code must be in format A9A or A9A9A9."
129
+ ),
130
+ "8534": ExceptionDefinition(
131
+ InvalidDestinationCountry,
132
+ "A valid destination country must be supplied."
133
+ ),
134
+ "9194": ExceptionDefinition(
135
+ MissingOriginPostalCode,
136
+ "origin-postal-code must also be provided when destination-postal-code is provided."
137
+ )
138
+ }