karrio-parcelone 2026.1__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.
@@ -0,0 +1,3 @@
1
+ from karrio.mappers.parcelone.mapper import Mapper
2
+ from karrio.mappers.parcelone.proxy import Proxy
3
+ from karrio.mappers.parcelone.settings import Settings
@@ -0,0 +1,50 @@
1
+ """Karrio ParcelOne 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.parcelone as provider
8
+ import karrio.mappers.parcelone.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_rate_response(
33
+ self, response: lib.Deserializable[str]
34
+ ) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
35
+ return provider.parse_rate_response(response, self.settings)
36
+
37
+ def parse_cancel_shipment_response(
38
+ self, response: lib.Deserializable[str]
39
+ ) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
40
+ return provider.parse_shipment_cancel_response(response, self.settings)
41
+
42
+ def parse_shipment_response(
43
+ self, response: lib.Deserializable[str]
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[str]
49
+ ) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
50
+ return provider.parse_tracking_response(response, self.settings)
@@ -0,0 +1,84 @@
1
+ """Karrio ParcelOne REST API client proxy."""
2
+
3
+ import karrio.lib as lib
4
+ import karrio.api.proxy as proxy
5
+ import karrio.mappers.parcelone.settings as provider_settings
6
+
7
+
8
+ class Proxy(proxy.Proxy):
9
+ settings: provider_settings.Settings
10
+
11
+ def get_rates(self, request: lib.Serializable) -> lib.Deserializable[dict]:
12
+ """Get shipping rates - not directly supported by ParcelOne API.
13
+
14
+ ParcelOne returns charges after shipment creation.
15
+ This method creates a shipment with ReturnCharges=1 to get rates.
16
+ """
17
+ response = lib.request(
18
+ url=f"{self.settings.server_url}/shipment",
19
+ data=lib.to_json(request.serialize()),
20
+ trace=self.trace_as("json"),
21
+ method="POST",
22
+ headers={
23
+ "Content-Type": "application/json",
24
+ "Authorization": self.settings.authorization,
25
+ },
26
+ )
27
+
28
+ return lib.Deserializable(response, lib.to_dict, request.ctx)
29
+
30
+ def create_shipment(self, request: lib.Serializable) -> lib.Deserializable[dict]:
31
+ """Create a shipment and get label."""
32
+ response = lib.request(
33
+ url=f"{self.settings.server_url}/shipment",
34
+ data=lib.to_json(request.serialize()),
35
+ trace=self.trace_as("json"),
36
+ method="POST",
37
+ headers={
38
+ "Content-Type": "application/json",
39
+ "Authorization": self.settings.authorization,
40
+ },
41
+ )
42
+
43
+ return lib.Deserializable(response, lib.to_dict, request.ctx)
44
+
45
+ def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable[dict]:
46
+ """Cancel a shipment."""
47
+ data = request.serialize()
48
+ ref_field = data.get("ref_field", "ShipmentID")
49
+ ref_value = data.get("ref_value")
50
+
51
+ response = lib.request(
52
+ url=f"{self.settings.server_url}/shipment/{ref_field}/{ref_value}",
53
+ trace=self.trace_as("json"),
54
+ method="DELETE",
55
+ headers={
56
+ "Authorization": self.settings.authorization,
57
+ },
58
+ )
59
+
60
+ return lib.Deserializable(response, lib.to_dict)
61
+
62
+ def get_tracking(self, request: lib.Serializable) -> lib.Deserializable[list]:
63
+ """Get tracking information for multiple tracking numbers."""
64
+ tracking_requests = request.serialize()
65
+
66
+ # Make individual requests for each tracking number
67
+ responses = [
68
+ (
69
+ req["tracking_id"],
70
+ lib.to_dict(
71
+ lib.request(
72
+ url=f"{self.settings.tracking_url}/tracking/{req['carrier_id']}/{req['tracking_id']}",
73
+ trace=self.trace_as("json"),
74
+ method="GET",
75
+ headers={
76
+ "Authorization": self.settings.authorization,
77
+ },
78
+ )
79
+ ),
80
+ )
81
+ for req in tracking_requests
82
+ ]
83
+
84
+ return lib.Deserializable(responses, lambda x: x)
@@ -0,0 +1,36 @@
1
+ """Karrio ParcelOne client settings."""
2
+
3
+ import attr
4
+ import typing
5
+ import jstruct
6
+ import karrio.core.models as models
7
+ import karrio.providers.parcelone.units as provider_units
8
+ import karrio.providers.parcelone.utils as provider_utils
9
+ import karrio.universal.mappers.rating_proxy as rating_proxy
10
+
11
+
12
+ @attr.s(auto_attribs=True)
13
+ class Settings(provider_utils.Settings, rating_proxy.RatingMixinSettings):
14
+ """ParcelOne connection settings."""
15
+
16
+ # Required credentials
17
+ username: str
18
+ password: str
19
+ mandator_id: str
20
+ consigner_id: str
21
+
22
+ # Generic properties
23
+ id: str = None
24
+ test_mode: bool = False
25
+ carrier_id: str = "parcelone"
26
+ services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore
27
+ account_country_code: str = "DE"
28
+ metadata: dict = {}
29
+ config: dict = {}
30
+
31
+ @property
32
+ def shipping_services(self) -> typing.List[models.ServiceLevel]:
33
+ if any(self.services or []):
34
+ return self.services
35
+
36
+ return provider_units.DEFAULT_SERVICES
@@ -0,0 +1,28 @@
1
+ from karrio.core.metadata import PluginMetadata
2
+
3
+ from karrio.mappers.parcelone.mapper import Mapper
4
+ from karrio.mappers.parcelone.proxy import Proxy
5
+ from karrio.mappers.parcelone.settings import Settings
6
+ import karrio.providers.parcelone.units as units
7
+
8
+
9
+ # This METADATA object is used by Karrio to discover and register this plugin
10
+ # when loaded through Python entrypoints or local plugin directories.
11
+ # The entrypoint is defined in pyproject.toml under [project.entry-points."karrio.plugins"]
12
+ METADATA = PluginMetadata(
13
+ id="parcelone",
14
+ label="ParcelOne",
15
+ description="ParcelOne multi-carrier shipping integration for Karrio",
16
+ # Integrations
17
+ Mapper=Mapper,
18
+ Proxy=Proxy,
19
+ Settings=Settings,
20
+ # Data Units
21
+ is_hub=True, # ParcelOne is a multi-carrier hub
22
+ options=units.ShippingOption,
23
+ services=units.ShippingService,
24
+ connection_configs=units.ConnectionConfig,
25
+ # Extra info
26
+ website="https://parcel.one",
27
+ documentation="https://parcel.one/api-documentation",
28
+ )
@@ -0,0 +1,17 @@
1
+ """Karrio ParcelOne provider imports."""
2
+
3
+ from karrio.providers.parcelone.utils import Settings
4
+ from karrio.providers.parcelone.rate import (
5
+ parse_rate_response,
6
+ rate_request,
7
+ )
8
+ from karrio.providers.parcelone.tracking import (
9
+ parse_tracking_response,
10
+ tracking_request,
11
+ )
12
+ from karrio.providers.parcelone.shipment import (
13
+ parse_shipment_cancel_response,
14
+ parse_shipment_response,
15
+ shipment_cancel_request,
16
+ shipment_request,
17
+ )
@@ -0,0 +1,76 @@
1
+ """Karrio ParcelOne error parser."""
2
+
3
+ import typing
4
+ import karrio.lib as lib
5
+ import karrio.core.models as models
6
+ import karrio.providers.parcelone.utils as provider_utils
7
+
8
+
9
+ def parse_error_response(
10
+ response: dict,
11
+ settings: provider_utils.Settings,
12
+ **details,
13
+ ) -> typing.List[models.Message]:
14
+ """Parse error response from ParcelOne REST API."""
15
+ results = response.get("results") or {}
16
+ action_result = results.get("ActionResult") or results
17
+
18
+ # Collect all errors using list comprehensions
19
+ errors: typing.List[dict] = sum(
20
+ [
21
+ # Top-level API errors
22
+ [
23
+ *[
24
+ {
25
+ "code": e.get("ErrorNo") or e.get("StatusCode"),
26
+ "message": e.get("Message"),
27
+ "instance": response.get("instance"),
28
+ "uniq_id": response.get("UniqId"),
29
+ }
30
+ for e in (response.get("errors") or [])
31
+ ],
32
+ # Top-level error message when no specific errors
33
+ *(
34
+ [
35
+ {
36
+ "code": str(response.get("status", "")),
37
+ "message": response.get("message"),
38
+ "instance": response.get("instance"),
39
+ "uniq_id": response.get("UniqId"),
40
+ }
41
+ ]
42
+ if (
43
+ response.get("success") == -1
44
+ or response.get("type") == "error"
45
+ )
46
+ and not response.get("errors")
47
+ and response.get("message")
48
+ else []
49
+ ),
50
+ ],
51
+ # ActionResult errors
52
+ [
53
+ {
54
+ "code": e.get("ErrorNo") or e.get("StatusCode"),
55
+ "message": e.get("Message"),
56
+ "shipment_id": action_result.get("ShipmentID"),
57
+ "tracking_id": action_result.get("TrackingID"),
58
+ }
59
+ for e in (action_result.get("Errors") or [])
60
+ if isinstance(action_result, dict)
61
+ ],
62
+ ],
63
+ [],
64
+ )
65
+
66
+ return [
67
+ models.Message(
68
+ carrier_id=settings.carrier_id,
69
+ carrier_name=settings.carrier_name,
70
+ code=error.get("code"),
71
+ message=error.get("message"),
72
+ details=lib.to_dict({**details, **{k: v for k, v in error.items() if k not in ("code", "message")}}),
73
+ )
74
+ for error in errors
75
+ if error.get("code") or error.get("message")
76
+ ]
@@ -0,0 +1,162 @@
1
+ """Karrio ParcelOne rating implementation.
2
+
3
+ Note: ParcelOne REST API returns charges as part of the shipment creation response.
4
+ This implementation creates a rate request that can be used to get charges
5
+ by setting ReturnCharges=1 on the shipment request.
6
+ """
7
+
8
+ import typing
9
+ import karrio.schemas.parcelone as parcelone
10
+ import karrio.lib as lib
11
+ import karrio.core.models as models
12
+ import karrio.providers.parcelone.error as error
13
+ import karrio.providers.parcelone.utils as provider_utils
14
+ import karrio.providers.parcelone.units as provider_units
15
+
16
+
17
+ def parse_rate_response(
18
+ _response: lib.Deserializable[dict],
19
+ settings: provider_utils.Settings,
20
+ ) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
21
+ """Parse rate response from ParcelOne REST API."""
22
+ response = _response.deserialize()
23
+ messages = error.parse_error_response(response, settings)
24
+
25
+ rates = lib.identity(
26
+ [_extract_rate(response, settings, _response.ctx)]
27
+ if response.get("success") == 1 and response.get("results")
28
+ else []
29
+ )
30
+
31
+ return [rate for rate in rates if rate is not None], messages
32
+
33
+
34
+ def _extract_rate(
35
+ data: dict,
36
+ settings: provider_utils.Settings,
37
+ ctx: typing.Dict[str, typing.Any] = None,
38
+ ) -> typing.Optional[models.RateDetails]:
39
+ """Extract rate details from API response."""
40
+ ctx = ctx or {}
41
+ result = lib.to_object(parcelone.ShipmentResultType, data.get("results") or {})
42
+
43
+ # Check if we have charges in the response
44
+ if result.TotalCharges is None and not result.Charges:
45
+ return None
46
+
47
+ service_code = ctx.get("service_code", "parcelone_pa1_eco")
48
+ service = provider_units.ShippingService.map(service_code)
49
+
50
+ total_charge = lib.failsafe(lambda: float(result.TotalCharges.Value)) if result.TotalCharges else 0.0
51
+ currency = lib.failsafe(lambda: result.TotalCharges.Currency) or "EUR"
52
+
53
+ extra_charges = [
54
+ models.ChargeDetails(
55
+ name=charge.Description or "Shipping Charge",
56
+ amount=lib.to_money(charge.Value),
57
+ currency=charge.Currency or currency,
58
+ )
59
+ for charge in (result.Charges or [])
60
+ if charge.Value
61
+ ]
62
+
63
+ return models.RateDetails(
64
+ carrier_name=settings.carrier_name,
65
+ carrier_id=settings.carrier_id,
66
+ service=service.name_or_key,
67
+ total_charge=lib.to_money(total_charge),
68
+ currency=currency,
69
+ extra_charges=extra_charges,
70
+ meta=dict(
71
+ service_name=service.name_or_key,
72
+ ),
73
+ )
74
+
75
+
76
+ def rate_request(
77
+ payload: models.RateRequest,
78
+ settings: provider_utils.Settings,
79
+ ) -> lib.Serializable:
80
+ """Create ParcelOne rate request.
81
+
82
+ Uses the shipment endpoint with ReturnCharges=1 to get rates.
83
+ """
84
+ shipper = lib.to_address(payload.shipper)
85
+ recipient = lib.to_address(payload.recipient)
86
+ packages = lib.to_packages(payload.parcels, required=["weight"])
87
+ services = lib.to_services(payload.services, service_type=provider_units.ShippingService)
88
+ options = lib.to_shipping_options(
89
+ payload.options,
90
+ package_options=packages.options,
91
+ initializer=provider_units.shipping_options_initializer,
92
+ )
93
+
94
+ # Get first service or use default
95
+ service = lib.identity(
96
+ next(iter(services), None)
97
+ or provider_units.ShippingService.parcelone_pa1_eco
98
+ )
99
+
100
+ # Parse service for CEP and product IDs
101
+ service_code = service.value if hasattr(service, 'value') else str(service)
102
+ cep_id, product_id = provider_units.parse_service_code(service_code)
103
+ cep_id = cep_id or settings.connection_config.cep_id.state
104
+ product_id = product_id or settings.connection_config.product_id.state
105
+
106
+ request = parcelone.ShippingDataRequestType(
107
+ ShippingData=parcelone.ShipmentType(
108
+ CEPID=cep_id,
109
+ ProductID=product_id,
110
+ MandatorID=settings.mandator_id,
111
+ ConsignerID=settings.consigner_id,
112
+ ShipToData=parcelone.ShipToType(
113
+ Name1=recipient.company_name or recipient.person_name,
114
+ ShipmentAddress=parcelone.AddressType(
115
+ Street=recipient.street,
116
+ PostalCode=recipient.postal_code,
117
+ City=recipient.city,
118
+ Country=recipient.country_code,
119
+ State=recipient.state_code,
120
+ ),
121
+ PrivateAddressIndicator=1 if recipient.residential else 0,
122
+ ),
123
+ ShipFromData=parcelone.ShipFromType(
124
+ Name1=shipper.company_name or shipper.person_name,
125
+ ShipmentAddress=parcelone.AddressType(
126
+ Street=shipper.street,
127
+ PostalCode=shipper.postal_code,
128
+ City=shipper.city,
129
+ Country=shipper.country_code,
130
+ State=shipper.state_code,
131
+ ),
132
+ ) if shipper else None,
133
+ ReturnCharges=1, # Request charges only
134
+ PrintLabel=0, # Don't generate label for rate request
135
+ Software="Karrio",
136
+ Packages=[
137
+ parcelone.ShipmentPackageType(
138
+ PackageRef=str(index),
139
+ PackageWeight=parcelone.MeasurementType(
140
+ Value=str(pkg.weight.KG),
141
+ Unit="kg",
142
+ ),
143
+ PackageDimensions=(
144
+ parcelone.DimensionsType(
145
+ Length=str(pkg.length.CM),
146
+ Width=str(pkg.width.CM),
147
+ Height=str(pkg.height.CM),
148
+ )
149
+ if pkg.length.CM and pkg.width.CM and pkg.height.CM
150
+ else None
151
+ ),
152
+ )
153
+ for index, pkg in enumerate(packages, 1)
154
+ ],
155
+ ),
156
+ )
157
+
158
+ return lib.Serializable(
159
+ request,
160
+ lib.to_dict,
161
+ dict(service_code=service.name if hasattr(service, 'name') else str(service)),
162
+ )
@@ -0,0 +1,17 @@
1
+ """ParcelOne shipment operations."""
2
+
3
+ from karrio.providers.parcelone.shipment.create import (
4
+ parse_shipment_response,
5
+ shipment_request,
6
+ )
7
+ from karrio.providers.parcelone.shipment.cancel import (
8
+ parse_shipment_cancel_response,
9
+ shipment_cancel_request,
10
+ )
11
+
12
+ __all__ = [
13
+ "parse_shipment_response",
14
+ "shipment_request",
15
+ "parse_shipment_cancel_response",
16
+ "shipment_cancel_request",
17
+ ]
@@ -0,0 +1,60 @@
1
+ """Karrio ParcelOne shipment cancellation implementation."""
2
+
3
+ import typing
4
+ import karrio.lib as lib
5
+ import karrio.core.models as models
6
+ import karrio.providers.parcelone.error as error
7
+ import karrio.providers.parcelone.utils as provider_utils
8
+
9
+
10
+ def parse_shipment_cancel_response(
11
+ _response: lib.Deserializable[dict],
12
+ settings: provider_utils.Settings,
13
+ ) -> typing.Tuple[typing.Optional[models.ConfirmationDetails], typing.List[models.Message]]:
14
+ """Parse shipment cancellation response from ParcelOne REST API."""
15
+ response = _response.deserialize()
16
+ messages = error.parse_error_response(response, settings)
17
+ success = response.get("success") == 1
18
+
19
+ confirmation = lib.identity(
20
+ models.ConfirmationDetails(
21
+ carrier_id=settings.carrier_id,
22
+ carrier_name=settings.carrier_name,
23
+ operation="Cancel Shipment",
24
+ success=success,
25
+ )
26
+ if success
27
+ else None
28
+ )
29
+
30
+ return confirmation, messages
31
+
32
+
33
+ def shipment_cancel_request(
34
+ payload: models.ShipmentCancelRequest,
35
+ settings: provider_utils.Settings,
36
+ ) -> lib.Serializable:
37
+ """Create ParcelOne shipment cancel request.
38
+
39
+ The API uses DELETE /shipment/{ShipmentRefField}/{ShipmentRefValue}
40
+ where ShipmentRefField can be: ShipmentID, ShipmentRef, or TrackingID
41
+ """
42
+ # Determine which reference field to use based on identifier format
43
+ identifier = payload.shipment_identifier
44
+
45
+ # If it's a numeric string, it's likely a ShipmentID
46
+ # If it starts with digits and has a specific length (13), it's likely a TrackingID
47
+ # Otherwise, treat it as a ShipmentRef
48
+ if identifier.isdigit() and len(identifier) < 10:
49
+ ref_field = "ShipmentID"
50
+ elif identifier.isdigit() and len(identifier) >= 10:
51
+ ref_field = "TrackingID"
52
+ else:
53
+ ref_field = "ShipmentRef"
54
+
55
+ request = dict(
56
+ ref_field=ref_field,
57
+ ref_value=identifier,
58
+ )
59
+
60
+ return lib.Serializable(request, lib.to_dict)