karrio-boxknight 2025.5rc1__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.
- karrio/mappers/boxknight/__init__.py +3 -0
- karrio/mappers/boxknight/mapper.py +50 -0
- karrio/mappers/boxknight/proxy.py +82 -0
- karrio/mappers/boxknight/settings.py +23 -0
- karrio/plugins/boxknight/__init__.py +22 -0
- karrio/providers/boxknight/__init__.py +13 -0
- karrio/providers/boxknight/error.py +29 -0
- karrio/providers/boxknight/rate.py +91 -0
- karrio/providers/boxknight/shipment/__init__.py +9 -0
- karrio/providers/boxknight/shipment/cancel.py +37 -0
- karrio/providers/boxknight/shipment/create.py +134 -0
- karrio/providers/boxknight/tracking.py +83 -0
- karrio/providers/boxknight/units.py +65 -0
- karrio/providers/boxknight/utils.py +60 -0
- karrio/schemas/boxknight/__init__.py +0 -0
- karrio/schemas/boxknight/error.py +8 -0
- karrio/schemas/boxknight/order_request.py +57 -0
- karrio/schemas/boxknight/rate_request.py +31 -0
- karrio/schemas/boxknight/rate_response.py +19 -0
- karrio/schemas/boxknight/tracking_response.py +53 -0
- karrio_boxknight-2025.5rc1.dist-info/METADATA +45 -0
- karrio_boxknight-2025.5rc1.dist-info/RECORD +25 -0
- karrio_boxknight-2025.5rc1.dist-info/WHEEL +5 -0
- karrio_boxknight-2025.5rc1.dist-info/entry_points.txt +2 -0
- karrio_boxknight-2025.5rc1.dist-info/top_level.txt +3 -0
@@ -0,0 +1,50 @@
|
|
1
|
+
"""Karrio BoxKnight client mapper."""
|
2
|
+
|
3
|
+
import typing
|
4
|
+
import karrio.lib as lib
|
5
|
+
import karrio.api.mapper as mapper
|
6
|
+
import karrio.core.models as models
|
7
|
+
import karrio.providers.boxknight as provider
|
8
|
+
import karrio.mappers.boxknight.settings as provider_settings
|
9
|
+
|
10
|
+
|
11
|
+
class Mapper(mapper.Mapper):
|
12
|
+
settings: provider_settings.Settings
|
13
|
+
|
14
|
+
def create_rate_request(self, payload: models.RateRequest) -> lib.Serializable:
|
15
|
+
return provider.rate_request(payload, self.settings)
|
16
|
+
|
17
|
+
def create_tracking_request(
|
18
|
+
self, payload: models.TrackingRequest
|
19
|
+
) -> lib.Serializable:
|
20
|
+
return provider.tracking_request(payload, self.settings)
|
21
|
+
|
22
|
+
def create_shipment_request(
|
23
|
+
self, payload: models.ShipmentRequest
|
24
|
+
) -> lib.Serializable:
|
25
|
+
return provider.shipment_request(payload, self.settings)
|
26
|
+
|
27
|
+
def create_cancel_shipment_request(
|
28
|
+
self, payload: models.ShipmentCancelRequest
|
29
|
+
) -> lib.Serializable:
|
30
|
+
return provider.shipment_cancel_request(payload, self.settings)
|
31
|
+
|
32
|
+
def parse_cancel_shipment_response(
|
33
|
+
self, response: lib.Deserializable
|
34
|
+
) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
|
35
|
+
return provider.parse_shipment_cancel_response(response, self.settings)
|
36
|
+
|
37
|
+
def parse_rate_response(
|
38
|
+
self, response: lib.Deserializable
|
39
|
+
) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
|
40
|
+
return provider.parse_rate_response(response, self.settings)
|
41
|
+
|
42
|
+
def parse_shipment_response(
|
43
|
+
self, response: lib.Deserializable
|
44
|
+
) -> typing.Tuple[models.ShipmentDetails, typing.List[models.Message]]:
|
45
|
+
return provider.parse_shipment_response(response, self.settings)
|
46
|
+
|
47
|
+
def parse_tracking_response(
|
48
|
+
self, response: lib.Deserializable
|
49
|
+
) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
|
50
|
+
return provider.parse_tracking_response(response, self.settings)
|
@@ -0,0 +1,82 @@
|
|
1
|
+
"""Karrio BoxKnight client proxy."""
|
2
|
+
|
3
|
+
import typing
|
4
|
+
import base64
|
5
|
+
import karrio.lib as lib
|
6
|
+
import karrio.api.proxy as proxy
|
7
|
+
import karrio.mappers.boxknight.settings as provider_settings
|
8
|
+
|
9
|
+
|
10
|
+
class Proxy(proxy.Proxy):
|
11
|
+
settings: provider_settings.Settings
|
12
|
+
|
13
|
+
def get_rates(self, request: lib.Serializable) -> lib.Deserializable:
|
14
|
+
response = lib.request(
|
15
|
+
url=f"{self.settings.server_url}/rates",
|
16
|
+
data=request.serialize(),
|
17
|
+
trace=self.trace_as("json"),
|
18
|
+
method="POST",
|
19
|
+
headers={"Authorization": self.settings.auth_token},
|
20
|
+
)
|
21
|
+
|
22
|
+
return lib.Deserializable(response, lib.to_dict)
|
23
|
+
|
24
|
+
def create_shipment(self, request: lib.Serializable) -> lib.Deserializable:
|
25
|
+
payload = request.serialize()
|
26
|
+
result = lib.to_dict(
|
27
|
+
lib.request(
|
28
|
+
url=f"{self.settings.server_url}/orders",
|
29
|
+
data=payload["order"],
|
30
|
+
trace=self.trace_as("json"),
|
31
|
+
method="POST",
|
32
|
+
headers={"Authorization": self.settings.auth_token},
|
33
|
+
)
|
34
|
+
)
|
35
|
+
|
36
|
+
response = (
|
37
|
+
dict(
|
38
|
+
order_id=result["id"],
|
39
|
+
label_type=payload["label_type"],
|
40
|
+
service=payload["order"]["service"],
|
41
|
+
label=lib.request(
|
42
|
+
url=f"{self.settings.server_url}/labels/{result['id']}?format={payload['label_type']}",
|
43
|
+
decoder=lambda b: base64.encodebytes(b).decode("utf-8"),
|
44
|
+
headers={"Authorization": self.settings.auth_token},
|
45
|
+
trace=self.trace_as("json"),
|
46
|
+
),
|
47
|
+
)
|
48
|
+
if result.get("error") is None
|
49
|
+
else result
|
50
|
+
)
|
51
|
+
|
52
|
+
return lib.Deserializable(response)
|
53
|
+
|
54
|
+
def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable:
|
55
|
+
response = lib.request(
|
56
|
+
url=f"{self.settings.server_url}/orders/{request.serialize()['order_id']}",
|
57
|
+
trace=self.trace_as("json"),
|
58
|
+
method="DELETE",
|
59
|
+
headers={"Authorization": self.settings.auth_token},
|
60
|
+
)
|
61
|
+
|
62
|
+
return lib.Deserializable(response, lib.to_dict)
|
63
|
+
|
64
|
+
def get_tracking(self, requests: lib.Serializable) -> lib.Deserializable:
|
65
|
+
track = lambda data: (
|
66
|
+
data["order_id"],
|
67
|
+
lib.request(
|
68
|
+
url=f"{self.settings.server_url}/orders/{data['order_id']}",
|
69
|
+
trace=self.trace_as("json"),
|
70
|
+
method="GET",
|
71
|
+
headers={"Authorization": self.settings.auth_token},
|
72
|
+
),
|
73
|
+
)
|
74
|
+
|
75
|
+
responses: typing.List[typing.Tuple[str, str]] = lib.run_asynchronously(
|
76
|
+
track, requests.serialize()
|
77
|
+
)
|
78
|
+
|
79
|
+
return lib.Deserializable(
|
80
|
+
responses,
|
81
|
+
lambda response: [(key, lib.to_dict(res)) for key, res in response],
|
82
|
+
)
|
@@ -0,0 +1,23 @@
|
|
1
|
+
"""Karrio BoxKnight client settings."""
|
2
|
+
|
3
|
+
import attr
|
4
|
+
import jstruct
|
5
|
+
import karrio.lib as lib
|
6
|
+
import karrio.providers.boxknight.utils as provider_utils
|
7
|
+
|
8
|
+
|
9
|
+
@attr.s(auto_attribs=True)
|
10
|
+
class Settings(provider_utils.Settings):
|
11
|
+
"""BoxKnight connection settings."""
|
12
|
+
|
13
|
+
# required carrier specific properties
|
14
|
+
username: str
|
15
|
+
password: str
|
16
|
+
|
17
|
+
# generic properties
|
18
|
+
id: str = None
|
19
|
+
test_mode: bool = False
|
20
|
+
carrier_id: str = "boxknight"
|
21
|
+
account_country_code: str = "CA"
|
22
|
+
metadata: dict = {}
|
23
|
+
config: dict = {}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
import karrio.core.metadata as metadata
|
2
|
+
import karrio.mappers.boxknight as mappers
|
3
|
+
import karrio.providers.boxknight.units as units
|
4
|
+
|
5
|
+
|
6
|
+
METADATA = metadata.PluginMetadata(
|
7
|
+
status="beta",
|
8
|
+
id="boxknight",
|
9
|
+
label="BoxKnight",
|
10
|
+
# Integrations
|
11
|
+
Mapper=mappers.Mapper,
|
12
|
+
Proxy=mappers.Proxy,
|
13
|
+
Settings=mappers.Settings,
|
14
|
+
# Data Units
|
15
|
+
is_hub=False,
|
16
|
+
services=units.ShippingService,
|
17
|
+
options=units.ShippingOption,
|
18
|
+
# New fields
|
19
|
+
website="https://www.boxknight.com/",
|
20
|
+
documentation="https://www.docs.boxknight.com/",
|
21
|
+
description="Specializes in same-day delivery at affordable prices for e-commerce retailers. Our mission is to get packages to your customers when they are actually home and as quickly as possible.",
|
22
|
+
)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
|
2
|
+
from karrio.providers.boxknight.utils import Settings
|
3
|
+
from karrio.providers.boxknight.rate import parse_rate_response, rate_request
|
4
|
+
from karrio.providers.boxknight.shipment import (
|
5
|
+
parse_shipment_cancel_response,
|
6
|
+
parse_shipment_response,
|
7
|
+
shipment_cancel_request,
|
8
|
+
shipment_request,
|
9
|
+
)
|
10
|
+
from karrio.providers.boxknight.tracking import (
|
11
|
+
parse_tracking_response,
|
12
|
+
tracking_request,
|
13
|
+
)
|
@@ -0,0 +1,29 @@
|
|
1
|
+
import karrio.schemas.boxknight.error as boxknight
|
2
|
+
import typing
|
3
|
+
import karrio.lib as lib
|
4
|
+
import karrio.core.models as models
|
5
|
+
import karrio.providers.boxknight.utils as provider_utils
|
6
|
+
|
7
|
+
|
8
|
+
def parse_error_response(
|
9
|
+
response: typing.Union[dict, typing.List[dict]],
|
10
|
+
settings: provider_utils.Settings,
|
11
|
+
**kwargs,
|
12
|
+
) -> typing.List[models.Message]:
|
13
|
+
responses = response if isinstance(response, list) else [response]
|
14
|
+
errors = [
|
15
|
+
lib.to_object(boxknight.Error, res)
|
16
|
+
for res in responses
|
17
|
+
if res.get("error") is not None
|
18
|
+
]
|
19
|
+
|
20
|
+
return [
|
21
|
+
models.Message(
|
22
|
+
carrier_id=settings.carrier_id,
|
23
|
+
carrier_name=settings.carrier_name,
|
24
|
+
code=None,
|
25
|
+
message=error.error,
|
26
|
+
details={**kwargs},
|
27
|
+
)
|
28
|
+
for error in errors
|
29
|
+
]
|
@@ -0,0 +1,91 @@
|
|
1
|
+
import karrio.schemas.boxknight.rate_request as boxknight
|
2
|
+
import karrio.schemas.boxknight.rate_response as rating
|
3
|
+
import typing
|
4
|
+
import karrio.lib as lib
|
5
|
+
import karrio.core.units as units
|
6
|
+
import karrio.core.models as models
|
7
|
+
import karrio.core.errors as errors
|
8
|
+
import karrio.providers.boxknight.error as error
|
9
|
+
import karrio.providers.boxknight.utils as provider_utils
|
10
|
+
import karrio.providers.boxknight.units as provider_units
|
11
|
+
|
12
|
+
|
13
|
+
def parse_rate_response(
|
14
|
+
_response: lib.Deserializable[typing.Union[dict, typing.List[dict]]],
|
15
|
+
settings: provider_utils.Settings,
|
16
|
+
) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
|
17
|
+
response = _response.deserialize()
|
18
|
+
messages = error.parse_error_response(response, settings)
|
19
|
+
rates = [
|
20
|
+
_extract_details(rate, settings)
|
21
|
+
for rate in response
|
22
|
+
if isinstance(response, list)
|
23
|
+
]
|
24
|
+
|
25
|
+
return rates, messages
|
26
|
+
|
27
|
+
|
28
|
+
def _extract_details(
|
29
|
+
data: dict,
|
30
|
+
settings: provider_utils.Settings,
|
31
|
+
) -> models.RateDetails:
|
32
|
+
rate = lib.to_object(rating.Rate, data)
|
33
|
+
service = provider_units.ShippingService.map(rate.service)
|
34
|
+
transit_days = (
|
35
|
+
lib.to_date(rate.estimateTo, "%Y-%m-%d")
|
36
|
+
- lib.to_date(rate.estimateFrom, "%Y-%m-%d")
|
37
|
+
).days
|
38
|
+
|
39
|
+
return models.RateDetails(
|
40
|
+
carrier_id=settings.carrier_id,
|
41
|
+
carrier_name=settings.carrier_name,
|
42
|
+
service=service.name_or_key,
|
43
|
+
total_charge=lib.to_money(rate.price),
|
44
|
+
currency=units.Currency.CAD.name,
|
45
|
+
transit_days=transit_days if transit_days > 0 else 1,
|
46
|
+
meta=dict(service_name=rate.name),
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
def rate_request(
|
51
|
+
payload: models.RateRequest,
|
52
|
+
settings: provider_utils.Settings,
|
53
|
+
) -> lib.Serializable:
|
54
|
+
if (
|
55
|
+
payload.shipper.country_code is not None
|
56
|
+
and payload.shipper.country_code != units.Country.CA.name
|
57
|
+
):
|
58
|
+
raise errors.OriginNotServicedError(payload.shipper.country_code)
|
59
|
+
|
60
|
+
if (
|
61
|
+
payload.recipient.country_code is not None
|
62
|
+
and payload.recipient.country_code != units.Country.CA.name
|
63
|
+
):
|
64
|
+
raise errors.DestinationNotServicedError(payload.recipient.country_code)
|
65
|
+
|
66
|
+
packages = lib.to_packages(payload.parcels)
|
67
|
+
|
68
|
+
request = boxknight.RateRequest(
|
69
|
+
postalCode=payload.recipient.postal_code,
|
70
|
+
originPostalCode=payload.shipper.postal_code,
|
71
|
+
packages=[
|
72
|
+
boxknight.Package(
|
73
|
+
refNumber=package.parcel.reference_number or str(idx),
|
74
|
+
weightOptions=boxknight.WeightOptions(
|
75
|
+
weight=package.weight.value,
|
76
|
+
unit=package.weight_unit.value.lower(),
|
77
|
+
),
|
78
|
+
sizeOptions=boxknight.SizeOptions(
|
79
|
+
length=package.length.value,
|
80
|
+
width=package.width.value,
|
81
|
+
height=package.height.value,
|
82
|
+
unit=provider_units.DimensionUnit.map(
|
83
|
+
package.dimension_unit.name
|
84
|
+
).value,
|
85
|
+
),
|
86
|
+
)
|
87
|
+
for idx, package in enumerate(packages, start=1)
|
88
|
+
],
|
89
|
+
)
|
90
|
+
|
91
|
+
return lib.Serializable(request, lib.to_dict)
|
@@ -0,0 +1,37 @@
|
|
1
|
+
import typing
|
2
|
+
import karrio.lib as lib
|
3
|
+
import karrio.core.models as models
|
4
|
+
import karrio.providers.boxknight.error as error
|
5
|
+
import karrio.providers.boxknight.utils as provider_utils
|
6
|
+
import karrio.providers.boxknight.units as provider_units
|
7
|
+
|
8
|
+
|
9
|
+
def parse_shipment_cancel_response(
|
10
|
+
_response: lib.Deserializable[dict],
|
11
|
+
settings: provider_utils.Settings,
|
12
|
+
) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
|
13
|
+
response = _response.deserialize()
|
14
|
+
messages = error.parse_error_response(response, settings)
|
15
|
+
success = response.get("error") is None
|
16
|
+
|
17
|
+
confirmation = (
|
18
|
+
models.ConfirmationDetails(
|
19
|
+
carrier_id=settings.carrier_id,
|
20
|
+
carrier_name=settings.carrier_name,
|
21
|
+
operation="Cancel Shipment",
|
22
|
+
success=success,
|
23
|
+
)
|
24
|
+
if success
|
25
|
+
else None
|
26
|
+
)
|
27
|
+
|
28
|
+
return confirmation, messages
|
29
|
+
|
30
|
+
|
31
|
+
def shipment_cancel_request(
|
32
|
+
payload: models.ShipmentCancelRequest,
|
33
|
+
settings: provider_utils.Settings,
|
34
|
+
) -> lib.Serializable:
|
35
|
+
request = dict(order_id=payload.shipment_identifier)
|
36
|
+
|
37
|
+
return lib.Serializable(request)
|
@@ -0,0 +1,134 @@
|
|
1
|
+
import karrio.schemas.boxknight.order_request as boxknight
|
2
|
+
import typing
|
3
|
+
import karrio.lib as lib
|
4
|
+
import karrio.core.units as units
|
5
|
+
import karrio.core.models as models
|
6
|
+
import karrio.core.errors as errors
|
7
|
+
import karrio.providers.boxknight.error as error
|
8
|
+
import karrio.providers.boxknight.utils as provider_utils
|
9
|
+
import karrio.providers.boxknight.units as provider_units
|
10
|
+
|
11
|
+
|
12
|
+
def parse_shipment_response(
|
13
|
+
_response: lib.Deserializable[dict],
|
14
|
+
settings: provider_utils.Settings,
|
15
|
+
) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
|
16
|
+
response = _response.deserialize()
|
17
|
+
messages = error.parse_error_response(response, settings)
|
18
|
+
shipment = (
|
19
|
+
_extract_details(response, settings) if response.get("error") is None else None
|
20
|
+
)
|
21
|
+
|
22
|
+
return shipment, messages
|
23
|
+
|
24
|
+
|
25
|
+
def _extract_details(
|
26
|
+
data: dict,
|
27
|
+
settings: provider_utils.Settings,
|
28
|
+
) -> models.ShipmentDetails:
|
29
|
+
return models.ShipmentDetails(
|
30
|
+
carrier_id=settings.carrier_id,
|
31
|
+
carrier_name=settings.carrier_name,
|
32
|
+
tracking_number=data["order_id"],
|
33
|
+
shipment_identifier=data["order_id"],
|
34
|
+
label_type=data["label_type"],
|
35
|
+
docs=models.Documents(label=data["label"]),
|
36
|
+
meta=dict(service_name=data.get("service")),
|
37
|
+
)
|
38
|
+
|
39
|
+
|
40
|
+
def shipment_request(
|
41
|
+
payload: models.ShipmentRequest,
|
42
|
+
settings: provider_utils.Settings,
|
43
|
+
) -> lib.Serializable:
|
44
|
+
if (
|
45
|
+
payload.shipper.country_code is not None
|
46
|
+
and payload.shipper.country_code != units.Country.CA.name
|
47
|
+
):
|
48
|
+
raise errors.OriginNotServicedError(payload.shipper.country_code)
|
49
|
+
|
50
|
+
if (
|
51
|
+
payload.recipient.country_code is not None
|
52
|
+
and payload.recipient.country_code != units.Country.CA.name
|
53
|
+
):
|
54
|
+
raise errors.DestinationNotServicedError(payload.recipient.country_code)
|
55
|
+
|
56
|
+
shipper = lib.to_address(payload.shipper)
|
57
|
+
recipient = lib.to_address(payload.recipient)
|
58
|
+
packages = lib.to_packages(payload.parcels)
|
59
|
+
service = provider_units.ShippingService.map(payload.service).value_or_key
|
60
|
+
options = lib.to_shipping_options(
|
61
|
+
payload.options,
|
62
|
+
package_options=packages.options,
|
63
|
+
initializer=provider_units.shipping_options_initializer,
|
64
|
+
)
|
65
|
+
|
66
|
+
request = dict(
|
67
|
+
order=boxknight.OrderRequest(
|
68
|
+
recipient=(
|
69
|
+
boxknight.Recipient(
|
70
|
+
name=recipient.contact,
|
71
|
+
phone=recipient.phone_number,
|
72
|
+
notes=None,
|
73
|
+
email=recipient.email,
|
74
|
+
)
|
75
|
+
if any([recipient.contact, recipient.phone_number, recipient.email])
|
76
|
+
else None
|
77
|
+
),
|
78
|
+
recipientAddress=boxknight.Address(
|
79
|
+
street=recipient.address_line1,
|
80
|
+
unit=recipient.address_line2,
|
81
|
+
city=recipient.city,
|
82
|
+
province=units.CountryState.CA.value[recipient.state_code].value,
|
83
|
+
country=units.Country.CA.value,
|
84
|
+
postalCode=recipient.postal_code,
|
85
|
+
isBusinessAddress=recipient.residential is False,
|
86
|
+
),
|
87
|
+
originAddress=boxknight.Address(
|
88
|
+
street=shipper.address_line1,
|
89
|
+
unit=shipper.address_line2,
|
90
|
+
city=shipper.city,
|
91
|
+
province=units.CountryState.CA.value[shipper.state_code].value,
|
92
|
+
country=units.Country.CA.value,
|
93
|
+
postalCode=shipper.postal_code,
|
94
|
+
isBusinessAddress=shipper.residential is False,
|
95
|
+
),
|
96
|
+
packageCount=len(packages),
|
97
|
+
service=service,
|
98
|
+
notes=options.boxknight_notes.state,
|
99
|
+
refNumber=payload.reference,
|
100
|
+
merchantDisplayName=(
|
101
|
+
shipper.company_name or options.boxknight_merchant_display_name.state
|
102
|
+
),
|
103
|
+
signatureRequired=options.boxknight_signature_required.state,
|
104
|
+
packages=[
|
105
|
+
boxknight.Package(
|
106
|
+
refNumber=package.parcel.reference_number or str(idx),
|
107
|
+
weightOptions=boxknight.WeightOptions(
|
108
|
+
weight=package.weight.value,
|
109
|
+
unit=package.weight_unit.value.lower(),
|
110
|
+
),
|
111
|
+
sizeOptions=boxknight.SizeOptions(
|
112
|
+
length=package.length.value,
|
113
|
+
width=package.width.value,
|
114
|
+
height=package.height.value,
|
115
|
+
unit=provider_units.DimensionUnit.map(
|
116
|
+
package.dimension_unit.value
|
117
|
+
).value,
|
118
|
+
),
|
119
|
+
)
|
120
|
+
for idx, package in enumerate(packages, start=1)
|
121
|
+
],
|
122
|
+
),
|
123
|
+
label_type=units.LabelType.map(
|
124
|
+
payload.label_type or "PDF"
|
125
|
+
).value_or_key.lower(),
|
126
|
+
)
|
127
|
+
|
128
|
+
return lib.Serializable(
|
129
|
+
request,
|
130
|
+
lambda _: {
|
131
|
+
"order": lib.to_dict(_["order"]),
|
132
|
+
"label_type": _["label_type"],
|
133
|
+
},
|
134
|
+
)
|
@@ -0,0 +1,83 @@
|
|
1
|
+
import datetime
|
2
|
+
import karrio.schemas.boxknight.tracking_response as boxknight
|
3
|
+
import typing
|
4
|
+
import karrio.lib as lib
|
5
|
+
import karrio.core.units as units
|
6
|
+
import karrio.core.models as models
|
7
|
+
import karrio.providers.boxknight.error as error
|
8
|
+
import karrio.providers.boxknight.utils as provider_utils
|
9
|
+
import karrio.providers.boxknight.units as provider_units
|
10
|
+
|
11
|
+
|
12
|
+
def parse_tracking_response(
|
13
|
+
_responses: lib.Deserializable[typing.List[typing.Tuple[str, dict]]],
|
14
|
+
settings: provider_utils.Settings,
|
15
|
+
) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
|
16
|
+
responses = _responses.deserialize()
|
17
|
+
messages: typing.List[models.Message] = sum(
|
18
|
+
[
|
19
|
+
error.parse_error_response(res, settings, tracking_number=number)
|
20
|
+
for number, res in responses
|
21
|
+
if res.get("error") is not None
|
22
|
+
],
|
23
|
+
start=[],
|
24
|
+
)
|
25
|
+
tracking_details = [
|
26
|
+
_extract_details(res, settings)
|
27
|
+
for _, res in responses
|
28
|
+
if res.get("error") is None
|
29
|
+
]
|
30
|
+
|
31
|
+
return tracking_details, messages
|
32
|
+
|
33
|
+
|
34
|
+
def _extract_details(
|
35
|
+
data: dict,
|
36
|
+
settings: provider_utils.Settings,
|
37
|
+
) -> models.TrackingDetails:
|
38
|
+
order = lib.to_object(boxknight.TrackingResponse, data)
|
39
|
+
delivered = order.orderStatus == "DELIVERY_COMPLETED"
|
40
|
+
|
41
|
+
return models.TrackingDetails(
|
42
|
+
carrier_id=settings.carrier_id,
|
43
|
+
carrier_name=settings.carrier_name,
|
44
|
+
tracking_number=order.id,
|
45
|
+
events=[
|
46
|
+
models.TrackingEvent(
|
47
|
+
description=order.orderStatus,
|
48
|
+
code=order.orderStatus,
|
49
|
+
date=lib.fdate(datetime.datetime.now(), "%Y-%m-%dT%H:%M:%S.%fZ"),
|
50
|
+
time=lib.flocaltime(datetime.datetime.now(), "%Y-%m-%dT%H:%M:%S.%fZ"),
|
51
|
+
)
|
52
|
+
],
|
53
|
+
delivered=delivered,
|
54
|
+
meta=dict(reference=order.refNumber),
|
55
|
+
info=models.TrackingInfo(
|
56
|
+
carrier_tracking_link=settings.tracking_url.format(order.id),
|
57
|
+
customer_name=getattr(order.recipient, "name", None),
|
58
|
+
shipment_package_count=order.packageCount,
|
59
|
+
shipment_service=order.service,
|
60
|
+
shipment_origin_postal_code=getattr(
|
61
|
+
order.originAddress, "postalCode", None
|
62
|
+
),
|
63
|
+
shipment_origin_country=units.Country.map(
|
64
|
+
getattr(order.originAddress, "country", None)
|
65
|
+
).name,
|
66
|
+
shipment_destination_postal_code=getattr(
|
67
|
+
order.recipientAddress, "postalCode", None
|
68
|
+
),
|
69
|
+
shipment_destination_country=units.Country.map(
|
70
|
+
getattr(order.recipientAddress, "country", None)
|
71
|
+
).name,
|
72
|
+
shipping_date=lib.fdate(order.createdAt, "%Y-%m-%dT%H:%M:%S.%fZ"),
|
73
|
+
),
|
74
|
+
)
|
75
|
+
|
76
|
+
|
77
|
+
def tracking_request(
|
78
|
+
payload: models.TrackingRequest,
|
79
|
+
settings: provider_utils.Settings,
|
80
|
+
) -> lib.Serializable:
|
81
|
+
request = [dict(order_id=number) for number in payload.tracking_numbers]
|
82
|
+
|
83
|
+
return lib.Serializable(request)
|
@@ -0,0 +1,65 @@
|
|
1
|
+
import karrio.lib as lib
|
2
|
+
import karrio.core.units as units
|
3
|
+
|
4
|
+
|
5
|
+
class DimensionUnit(lib.StrEnum):
|
6
|
+
CM = "cm"
|
7
|
+
IN = "inch"
|
8
|
+
|
9
|
+
|
10
|
+
class PackagingType(lib.StrEnum):
|
11
|
+
"""Carrier specific packaging type"""
|
12
|
+
|
13
|
+
PACKAGE = "PACKAGE"
|
14
|
+
|
15
|
+
""" Unified Packaging type mapping """
|
16
|
+
envelope = PACKAGE
|
17
|
+
pak = PACKAGE
|
18
|
+
tube = PACKAGE
|
19
|
+
pallet = PACKAGE
|
20
|
+
small_box = PACKAGE
|
21
|
+
medium_box = PACKAGE
|
22
|
+
your_packaging = PACKAGE
|
23
|
+
|
24
|
+
|
25
|
+
class ShippingService(lib.StrEnum):
|
26
|
+
"""Carrier specific services"""
|
27
|
+
|
28
|
+
boxknight_sameday = "SAMEDAY"
|
29
|
+
boxknight_nextday = "NEXTDAY"
|
30
|
+
boxknight_scheduled = "SCHEDULED"
|
31
|
+
|
32
|
+
|
33
|
+
class ShippingOption(lib.Enum):
|
34
|
+
"""Carrier specific options"""
|
35
|
+
|
36
|
+
boxknight_signature_required = lib.OptionEnum("signatureRequired", bool)
|
37
|
+
boxknight_merchant_display_name = lib.OptionEnum("merchantDisplayName")
|
38
|
+
boxknight_notes = lib.OptionEnum("notes")
|
39
|
+
|
40
|
+
signature_required = boxknight_signature_required
|
41
|
+
|
42
|
+
|
43
|
+
def shipping_options_initializer(
|
44
|
+
options: dict,
|
45
|
+
package_options: units.ShippingOptions = None,
|
46
|
+
) -> units.ShippingOptions:
|
47
|
+
"""
|
48
|
+
Apply default values to the given options.
|
49
|
+
"""
|
50
|
+
|
51
|
+
if package_options is not None:
|
52
|
+
options.update(package_options.content)
|
53
|
+
|
54
|
+
def items_filter(key: str) -> bool:
|
55
|
+
return key in ShippingOption # type: ignore
|
56
|
+
|
57
|
+
return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
|
58
|
+
|
59
|
+
|
60
|
+
class TrackingStatus(lib.Enum):
|
61
|
+
on_hold = ["CREATED", "GEOCODED"]
|
62
|
+
delivered = ["DELIVERY_COMPLETED"]
|
63
|
+
in_transit = ["DELIVERY_ASSIGNED", "PICKUP_EN_ROUTE"]
|
64
|
+
delivery_delayed = ["delivery_delayed"]
|
65
|
+
out_for_delivery = ["PICKUP_COMPLETED", "DELIVERY_EN_ROUTE"]
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import jstruct
|
2
|
+
import karrio.lib as lib
|
3
|
+
import karrio.core as core
|
4
|
+
import karrio.core.errors as errors
|
5
|
+
|
6
|
+
|
7
|
+
class Settings(core.Settings):
|
8
|
+
"""BoxKnight connection settings."""
|
9
|
+
|
10
|
+
username: str
|
11
|
+
password: str
|
12
|
+
|
13
|
+
@property
|
14
|
+
def carrier_name(self):
|
15
|
+
return "boxknight"
|
16
|
+
|
17
|
+
@property
|
18
|
+
def server_url(self):
|
19
|
+
return "https://api.boxknight.com/v1"
|
20
|
+
|
21
|
+
@property
|
22
|
+
def tracking_url(self):
|
23
|
+
return "https://www.tracking.boxknight.com/tracking?trackingNo={}"
|
24
|
+
|
25
|
+
@property
|
26
|
+
def auth_token(self):
|
27
|
+
"""Retrieve the auth token using the username|passwword pair
|
28
|
+
or collect it from the cache if an unexpired token exist.
|
29
|
+
"""
|
30
|
+
cache_key = f"{self.carrier_name}|{self.username}|{self.password}"
|
31
|
+
auth = self.connection_cache.get(cache_key) or {}
|
32
|
+
token = auth.get("token")
|
33
|
+
|
34
|
+
if token is not None:
|
35
|
+
return token
|
36
|
+
|
37
|
+
self.connection_cache.set(cache_key, lambda: authenticate(self))
|
38
|
+
new_auth = self.connection_cache.get(cache_key)
|
39
|
+
|
40
|
+
if any(self.depot or "") is False:
|
41
|
+
self.depot = new_auth["depot"]
|
42
|
+
|
43
|
+
return new_auth["token"]
|
44
|
+
|
45
|
+
|
46
|
+
def authenticate(settings: Settings):
|
47
|
+
import karrio.providers.boxknight.error as error
|
48
|
+
|
49
|
+
result = lib.request(
|
50
|
+
url=f"{settings.server_url}/soap/services/LoginService/V2_1",
|
51
|
+
data=dict(username=settings.username, password=settings.password),
|
52
|
+
method="POST",
|
53
|
+
)
|
54
|
+
response = lib.to_dict(result)
|
55
|
+
messages = error.parse_error_response(response, settings)
|
56
|
+
|
57
|
+
if any(messages):
|
58
|
+
raise errors.ParsedMessagesError(messages=messages)
|
59
|
+
|
60
|
+
return dict(token=response["token"])
|
File without changes
|
@@ -0,0 +1,57 @@
|
|
1
|
+
import attr
|
2
|
+
import jstruct
|
3
|
+
import typing
|
4
|
+
|
5
|
+
|
6
|
+
@attr.s(auto_attribs=True)
|
7
|
+
class Address:
|
8
|
+
street: typing.Optional[str] = None
|
9
|
+
unit: typing.Optional[str] = None
|
10
|
+
city: typing.Optional[str] = None
|
11
|
+
province: typing.Optional[str] = None
|
12
|
+
country: typing.Optional[str] = None
|
13
|
+
postalCode: typing.Optional[str] = None
|
14
|
+
isBusinessAddress: typing.Optional[bool] = None
|
15
|
+
|
16
|
+
|
17
|
+
@attr.s(auto_attribs=True)
|
18
|
+
class SizeOptions:
|
19
|
+
length: typing.Optional[float] = None
|
20
|
+
width: typing.Optional[float] = None
|
21
|
+
height: typing.Optional[float] = None
|
22
|
+
unit: typing.Optional[str] = None
|
23
|
+
|
24
|
+
|
25
|
+
@attr.s(auto_attribs=True)
|
26
|
+
class WeightOptions:
|
27
|
+
weight: typing.Optional[float] = None
|
28
|
+
unit: typing.Optional[str] = None
|
29
|
+
|
30
|
+
|
31
|
+
@attr.s(auto_attribs=True)
|
32
|
+
class Package:
|
33
|
+
refNumber: typing.Optional[int] = None
|
34
|
+
weightOptions: typing.Optional[WeightOptions] = jstruct.JStruct[WeightOptions]
|
35
|
+
sizeOptions: typing.Optional[SizeOptions] = jstruct.JStruct[SizeOptions]
|
36
|
+
|
37
|
+
|
38
|
+
@attr.s(auto_attribs=True)
|
39
|
+
class Recipient:
|
40
|
+
name: typing.Optional[str] = None
|
41
|
+
phone: typing.Optional[str] = None
|
42
|
+
notes: typing.Optional[str] = None
|
43
|
+
email: typing.Optional[str] = None
|
44
|
+
|
45
|
+
|
46
|
+
@attr.s(auto_attribs=True)
|
47
|
+
class OrderRequest:
|
48
|
+
recipient: typing.Optional[Recipient] = jstruct.JStruct[Recipient]
|
49
|
+
recipientAddress: typing.Optional[Address] = jstruct.JStruct[Address]
|
50
|
+
originAddress: typing.Optional[Address] = jstruct.JStruct[Address]
|
51
|
+
packageCount: typing.Optional[int] = None
|
52
|
+
service: typing.Optional[str] = None
|
53
|
+
notes: typing.Optional[str] = None
|
54
|
+
refNumber: typing.Optional[str] = None
|
55
|
+
merchantDisplayName: typing.Optional[str] = None
|
56
|
+
signatureRequired: typing.Optional[bool] = None
|
57
|
+
packages: typing.Optional[typing.List[Package]] = jstruct.JList[Package]
|
@@ -0,0 +1,31 @@
|
|
1
|
+
import attr
|
2
|
+
import jstruct
|
3
|
+
import typing
|
4
|
+
|
5
|
+
|
6
|
+
@attr.s(auto_attribs=True)
|
7
|
+
class SizeOptions:
|
8
|
+
length: typing.Optional[float] = None
|
9
|
+
width: typing.Optional[float] = None
|
10
|
+
height: typing.Optional[float] = None
|
11
|
+
unit: typing.Optional[str] = None
|
12
|
+
|
13
|
+
|
14
|
+
@attr.s(auto_attribs=True)
|
15
|
+
class WeightOptions:
|
16
|
+
weight: typing.Optional[float] = None
|
17
|
+
unit: typing.Optional[str] = None
|
18
|
+
|
19
|
+
|
20
|
+
@attr.s(auto_attribs=True)
|
21
|
+
class Package:
|
22
|
+
refNumber: typing.Optional[int] = None
|
23
|
+
weightOptions: typing.Optional[WeightOptions] = jstruct.JStruct[WeightOptions]
|
24
|
+
sizeOptions: typing.Optional[SizeOptions] = jstruct.JStruct[SizeOptions]
|
25
|
+
|
26
|
+
|
27
|
+
@attr.s(auto_attribs=True)
|
28
|
+
class RateRequest:
|
29
|
+
postalCode: typing.Optional[str] = None
|
30
|
+
originPostalCode: typing.Optional[str] = None
|
31
|
+
packages: typing.Optional[typing.List[Package]] = jstruct.JList[Package]
|
@@ -0,0 +1,19 @@
|
|
1
|
+
import attr
|
2
|
+
import jstruct
|
3
|
+
import typing
|
4
|
+
|
5
|
+
|
6
|
+
@attr.s(auto_attribs=True)
|
7
|
+
class Rate:
|
8
|
+
price: typing.Optional[int] = None
|
9
|
+
service: typing.Optional[str] = None
|
10
|
+
name: typing.Optional[str] = None
|
11
|
+
description: typing.Optional[str] = None
|
12
|
+
estimateDay: typing.Optional[str] = None
|
13
|
+
estimateFrom: typing.Optional[str] = None
|
14
|
+
estimateTo: typing.Optional[str] = None
|
15
|
+
|
16
|
+
|
17
|
+
@attr.s(auto_attribs=True)
|
18
|
+
class RateResponse:
|
19
|
+
rates: typing.Optional[typing.List[Rate]] = jstruct.JList[Rate]
|
@@ -0,0 +1,53 @@
|
|
1
|
+
import attr
|
2
|
+
import jstruct
|
3
|
+
import typing
|
4
|
+
|
5
|
+
|
6
|
+
@attr.s(auto_attribs=True)
|
7
|
+
class Location:
|
8
|
+
lat: typing.Optional[float] = None
|
9
|
+
lng: typing.Optional[float] = None
|
10
|
+
|
11
|
+
|
12
|
+
@attr.s(auto_attribs=True)
|
13
|
+
class Address:
|
14
|
+
number: typing.Optional[int] = None
|
15
|
+
street: typing.Optional[str] = None
|
16
|
+
city: typing.Optional[str] = None
|
17
|
+
province: typing.Optional[str] = None
|
18
|
+
country: typing.Optional[str] = None
|
19
|
+
postalCode: typing.Optional[str] = None
|
20
|
+
sublocality: typing.Optional[str] = None
|
21
|
+
location: typing.Optional[Location] = jstruct.JStruct[Location]
|
22
|
+
|
23
|
+
|
24
|
+
@attr.s(auto_attribs=True)
|
25
|
+
class Recipient:
|
26
|
+
name: typing.Optional[str] = None
|
27
|
+
phone: typing.Optional[str] = None
|
28
|
+
notes: typing.Optional[str] = None
|
29
|
+
email: typing.Optional[str] = None
|
30
|
+
|
31
|
+
|
32
|
+
@attr.s(auto_attribs=True)
|
33
|
+
class TrackingResponse:
|
34
|
+
id: typing.Optional[str] = None
|
35
|
+
createdAt: typing.Optional[str] = None
|
36
|
+
createdBy: typing.Optional[str] = None
|
37
|
+
merchantId: typing.Optional[str] = None
|
38
|
+
orderStatus: typing.Optional[str] = None
|
39
|
+
scanningRequired: typing.Optional[bool] = None
|
40
|
+
validAddress: typing.Optional[bool] = None
|
41
|
+
labelUrl: typing.Optional[str] = None
|
42
|
+
pdfLabelUrl: typing.Optional[str] = None
|
43
|
+
recipient: typing.Optional[Recipient] = jstruct.JStruct[Recipient]
|
44
|
+
recipientAddress: typing.Optional[Address] = jstruct.JStruct[Address]
|
45
|
+
originAddress: typing.Optional[Address] = jstruct.JStruct[Address]
|
46
|
+
packageCount: typing.Optional[int] = None
|
47
|
+
signatureRequired: typing.Optional[bool] = None
|
48
|
+
service: typing.Optional[str] = None
|
49
|
+
notes: typing.Optional[str] = None
|
50
|
+
refNumber: typing.Optional[str] = None
|
51
|
+
completeAfter: typing.Optional[int] = None
|
52
|
+
completeBefore: typing.Optional[int] = None
|
53
|
+
merchantDisplayName: typing.Optional[str] = None
|
@@ -0,0 +1,45 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: karrio_boxknight
|
3
|
+
Version: 2025.5rc1
|
4
|
+
Summary: Karrio - BoxKnight Shipping Extension
|
5
|
+
Author-email: karrio <hello@karrio.io>
|
6
|
+
License-Expression: Apache-2.0
|
7
|
+
Project-URL: Homepage, https://github.com/karrioapi/karrio
|
8
|
+
Classifier: Intended Audience :: Developers
|
9
|
+
Classifier: Operating System :: OS Independent
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Requires-Python: >=3.7
|
12
|
+
Description-Content-Type: text/markdown
|
13
|
+
Requires-Dist: karrio
|
14
|
+
|
15
|
+
|
16
|
+
# karrio.boxknight
|
17
|
+
|
18
|
+
This package is a BoxKnight extension of the [karrio](https://pypi.org/project/karrio) multi carrier shipping SDK.
|
19
|
+
|
20
|
+
## Requirements
|
21
|
+
|
22
|
+
`Python 3.7+`
|
23
|
+
|
24
|
+
## Installation
|
25
|
+
|
26
|
+
```bash
|
27
|
+
pip install karrio.boxknight
|
28
|
+
```
|
29
|
+
|
30
|
+
## Usage
|
31
|
+
|
32
|
+
```python
|
33
|
+
import karrio.sdk as karrio
|
34
|
+
from karrio.mappers.boxknight.settings import Settings
|
35
|
+
|
36
|
+
|
37
|
+
# Initialize a carrier gateway
|
38
|
+
boxknight = karrio.gateway["boxknight"].create(
|
39
|
+
Settings(
|
40
|
+
...
|
41
|
+
)
|
42
|
+
)
|
43
|
+
```
|
44
|
+
|
45
|
+
Check the [Karrio Mutli-carrier SDK docs](https://docs.karrio.io) for Shipping API requests
|
@@ -0,0 +1,25 @@
|
|
1
|
+
karrio/mappers/boxknight/__init__.py,sha256=SALvjoaukPIeMxCGQIM9aUjjsIz7BY0UUIx5-k9eJxY,155
|
2
|
+
karrio/mappers/boxknight/mapper.py,sha256=HFHxpy2VDrzHtV6ZYzqWBKw47eMkXbxv4ia2QJhdGr0,1947
|
3
|
+
karrio/mappers/boxknight/proxy.py,sha256=4OHf2vM2hx8u4ri_ZDS0Oc7sIVPv6PhenxxOpVl9UMU,2870
|
4
|
+
karrio/mappers/boxknight/settings.py,sha256=Ilqa1vgwpah5o-1M6pt_ftb5WpMG7pO-VLuWMCEGyHY,532
|
5
|
+
karrio/plugins/boxknight/__init__.py,sha256=pJmgB7-NH5Gl-bAgBogK0OogPS0BA7nuZeWXWq9dQRs,756
|
6
|
+
karrio/providers/boxknight/__init__.py,sha256=0WwIjVf2pRM4uE9NrqZ_PSdzmTTeeGjZfdWdfsh0tSY,404
|
7
|
+
karrio/providers/boxknight/error.py,sha256=W6R_Q1fwRw-kW1p9XuLrRitmT4suGB3eW6UNszAViss,819
|
8
|
+
karrio/providers/boxknight/rate.py,sha256=Vh3OqiY-IHpf--bffMQuyrtC4VIabl3FENz-rnvrS_Q,3130
|
9
|
+
karrio/providers/boxknight/tracking.py,sha256=4KzbOB-VOx6ZItvCface3YL-k2IGJtp1EFtR43NO5No,2982
|
10
|
+
karrio/providers/boxknight/units.py,sha256=JIgfBxvYcX-QpSC9G-h762tChA6HGDDV7E2VEUV_RRg,1676
|
11
|
+
karrio/providers/boxknight/utils.py,sha256=UCWT4utryNAAajgQXJ2MMv7kNpdwketRJNwr3k83d-w,1667
|
12
|
+
karrio/providers/boxknight/shipment/__init__.py,sha256=AKoH2eQcuZuJhyJNh7oGcM5_9qh-pUPrnAa4RBgZDbU,235
|
13
|
+
karrio/providers/boxknight/shipment/cancel.py,sha256=Od0CUtJyvcdufb-WAhbJ6emWPFS61mCXZX5eMV5CA68,1118
|
14
|
+
karrio/providers/boxknight/shipment/create.py,sha256=QwJIWb7rQLzZwSHAvE9LdmZPw5FSriVzuPve6GbNw_Y,5001
|
15
|
+
karrio/schemas/boxknight/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
16
|
+
karrio/schemas/boxknight/error.py,sha256=P2l807AVgrtoxDoLWJcshp0gmO3giul7j_CecNpVk3Q,122
|
17
|
+
karrio/schemas/boxknight/order_request.py,sha256=YKoMUvxPTtQVO5vRb1Oa5afu-wLD3ktkMA2AgsMts9g,1785
|
18
|
+
karrio/schemas/boxknight/rate_request.py,sha256=rdmfzMK-2zpuQzu8gahHUHo0gfM2R60uL6C17ijVtQo,848
|
19
|
+
karrio/schemas/boxknight/rate_response.py,sha256=mYg7ThzQ7qzD9bp2_PVmVT996lbUsC2UAeVT8hH7hBU,497
|
20
|
+
karrio/schemas/boxknight/tracking_response.py,sha256=f3r-edDsF1_AFZjJ11PSv8eM0b-YVdTIZWar-Ta045s,1801
|
21
|
+
karrio_boxknight-2025.5rc1.dist-info/METADATA,sha256=o5nmgmAFQlzm24jKazpxANnuWhaXphLRm-hOaM0vz7E,1008
|
22
|
+
karrio_boxknight-2025.5rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
23
|
+
karrio_boxknight-2025.5rc1.dist-info/entry_points.txt,sha256=YnqTcqXFHHm7AAutRMn78415tSbWLShqgQKNgsl6Ows,63
|
24
|
+
karrio_boxknight-2025.5rc1.dist-info/top_level.txt,sha256=FZCY8Nwft8oEGHdl--xku8P3TrnOxu5dETEU_fWpRSM,20
|
25
|
+
karrio_boxknight-2025.5rc1.dist-info/RECORD,,
|