apc-hypaship 0.1.1__tar.gz

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,13 @@
1
+ Metadata-Version: 2.3
2
+ Name: apc-hypaship
3
+ Version: 0.1.1
4
+ Summary: Basic API client for APC-Hypaship platform
5
+ Author: paw
6
+ Author-email: paw <pawrequest@users.noreply.github.com>
7
+ Requires-Dist: dotenv>=0.9.9
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: loguru>=0.7.3
10
+ Requires-Dist: pydantic-settings>=2.11.0
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
File without changes
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8.22,<0.9.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "apc-hypaship"
7
+ version = "0.1.1"
8
+ description = "Basic API client for APC-Hypaship platform"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "paw", email = "pawrequest@users.noreply.github.com" }
12
+ ]
13
+ requires-python = ">=3.13"
14
+ dependencies = [
15
+ "dotenv>=0.9.9",
16
+ "httpx>=0.28.1",
17
+ "loguru>=0.7.3",
18
+ "pydantic-settings>=2.11.0",
19
+ ]
20
+ [dependency-groups]
21
+ dev = [
22
+ "autodoc-pydantic>=2.2.0",
23
+ "myst-parser>=4.0.1",
24
+ "pytest>=8.4.2",
25
+ "sphinx>=8.2.3",
26
+ "sphinx-autodoc-typehints>=3.2.0",
27
+ "sphinx-readme>=1.2.1",
28
+ "sphinx-rtd-dark-mode>=1.3.0",
29
+ "sphinx-rtd-theme>=3.0.2",
30
+ ]
31
+ #[[tool.uv.index]]
32
+ #name = "testpypi"
33
+ #url = "https://test.pypi.org/simple/"
34
+ #publish-url = "https://test.pypi.org/legacy/"
35
+ #explicit = true
36
+
File without changes
@@ -0,0 +1,77 @@
1
+ from typing import Literal
2
+
3
+ import httpx
4
+
5
+ from apc_hypaship.config import APCSettings, apc_settings
6
+ from apc_hypaship.models.response.label_track import Tracks
7
+ from apc_hypaship.models.response.resp import BookingResponse, ServiceAvailabilityResponse
8
+ from apc_hypaship.models.response.shipment import Label
9
+ from apc_hypaship.models.request.shipment import Shipment
10
+
11
+ ResponseMode = Literal['raw'] | Literal['json'] | type
12
+
13
+
14
+ def make_post(
15
+ url: str,
16
+ data: dict | None = None,
17
+ settings: APCSettings = apc_settings(),
18
+ ) -> httpx.Response:
19
+ headers = settings.headers
20
+ res = httpx.post(url, headers=headers, json=data, timeout=30)
21
+ res.raise_for_status()
22
+ return res
23
+
24
+
25
+ def make_get(
26
+ url: str,
27
+ params: dict | None = None,
28
+ settings: APCSettings = apc_settings(),
29
+ ) -> httpx.Response:
30
+ headers = settings.headers
31
+ res = httpx.get(url, headers=headers, params=params, timeout=30)
32
+ res.raise_for_status()
33
+ return res
34
+
35
+
36
+ def service_available(
37
+ shipment: Shipment,
38
+ settings: APCSettings = apc_settings(),
39
+ ) -> ServiceAvailabilityResponse:
40
+ shipment_dict = shipment.model_dump(by_alias=True, mode='json')
41
+ res = make_post(settings.services_endpoint, shipment_dict)
42
+ return ServiceAvailabilityResponse(**res.json())
43
+
44
+
45
+ def book_shipment(
46
+ shipment: Shipment,
47
+ settings=apc_settings(),
48
+ ) -> BookingResponse:
49
+ shipment_dict = shipment.model_dump(by_alias=True, mode='json')
50
+ res = make_post(url=settings.orders_endpoint, data=shipment_dict, settings=settings)
51
+ return BookingResponse(**res.json())
52
+
53
+
54
+ def get_label(
55
+ shipment_num: str,
56
+ settings: APCSettings = apc_settings(),
57
+ ) -> Label:
58
+ params = {'labelformat': 'PDF'}
59
+ label = make_get(
60
+ url=settings.one_order_endpoint(shipment_num),
61
+ params=params,
62
+ settings=settings,
63
+ )
64
+ label = BookingResponse(**label.json())
65
+ return label.orders.order.label
66
+
67
+
68
+ def get_tracks(
69
+ shipment_num: str,
70
+ settings: APCSettings = apc_settings(),
71
+ ) -> Tracks:
72
+ res = make_get(url=settings.track_endpoint(shipment_num), settings=settings)
73
+ res = res.json()
74
+ t = res.get('Tracks')
75
+ return Tracks(**t)
76
+
77
+
@@ -0,0 +1,88 @@
1
+ import os
2
+ from datetime import date, time
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ import base64
6
+ from typing import Annotated
7
+
8
+ from loguru import logger
9
+ from pydantic import BaseModel, ConfigDict, SecretStr, AliasGenerator, StringConstraints
10
+ from pydantic.alias_generators import to_pascal
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ def encode_b64_str(s: str) -> str:
15
+ return base64.b64encode(s.encode('utf8')).decode('utf8')
16
+
17
+
18
+ def get_remote_user(login, password) -> str:
19
+ usr_str = f'{login}:{password}'
20
+ return f'Basic {encode_b64_str(usr_str)}'
21
+
22
+
23
+ def apc_date(d: date) -> str:
24
+ return d.strftime('%d/%m/%Y')
25
+
26
+
27
+ def load_env() -> Path:
28
+ apc_env = Path(os.getenv('APC_ENV'))
29
+ if not apc_env.exists():
30
+ raise ValueError('APC_ENV not set correctly')
31
+ logger.debug(f'Loading APC environment from {apc_env}')
32
+ return apc_env
33
+
34
+
35
+ class APCSettings(BaseSettings):
36
+ apc_email: SecretStr
37
+ apc_password: SecretStr
38
+ base_url: str
39
+ model_config = SettingsConfigDict(env_file=load_env())
40
+
41
+ @property
42
+ def headers(self) -> dict:
43
+ usr = self.apc_email.get_secret_value()
44
+ pwd = self.apc_password.get_secret_value()
45
+ return {
46
+ 'Content-Type': 'application/json',
47
+ 'remote-user': get_remote_user(usr, pwd),
48
+ }
49
+
50
+ @property
51
+ def services_endpoint(self) -> str:
52
+ return self.base_url + 'ServiceAvailability.json'
53
+
54
+ @property
55
+ def orders_endpoint(self) -> str:
56
+ return self.base_url + 'Orders.json'
57
+
58
+ def one_order_endpoint(self, order_num: str) -> str:
59
+ return self.base_url + f'Orders/{order_num}.json'
60
+
61
+ def track_endpoint(self, order_num: str) -> str:
62
+ return self.base_url + f'Tracks/{order_num}.json'
63
+
64
+
65
+ @lru_cache
66
+ def apc_settings() -> APCSettings:
67
+ return APCSettings()
68
+
69
+
70
+ class APCBaseModel(BaseModel):
71
+ model_config = ConfigDict(
72
+ json_encoders={
73
+ time: lambda v: v.strftime('%H:%M'),
74
+ date: lambda v: v.strftime('%d/%m/%Y'),
75
+ },
76
+ alias_generator=AliasGenerator(
77
+ alias=to_pascal,
78
+ ),
79
+ populate_by_name=True,
80
+ use_enum_values=True,
81
+ )
82
+
83
+
84
+ def str_length_const(length: int):
85
+ return Annotated[
86
+ str,
87
+ StringConstraints(strip_whitespace=True, max_length=length),
88
+ ]
File without changes
@@ -0,0 +1,29 @@
1
+ # from pawdantic.paw_types import str_length_const
2
+
3
+ from apc_hypaship.config import APCBaseModel, str_length_const
4
+
5
+
6
+ class Contact(APCBaseModel):
7
+ person_name: str
8
+ phone_number: str | None = None
9
+ mobile_number: str
10
+ email: str | None
11
+
12
+
13
+ class Address(APCBaseModel):
14
+ contact: Contact
15
+ company_name: str = str_length_const(34)
16
+ address_line_1: str_length_const(64)
17
+ address_line_2: str_length_const(64) | None = None
18
+ city: str
19
+ county: str | None = None
20
+ postal_code: str
21
+ country_code: str = 'GB'
22
+ country_name: str | None = None
23
+ instructions: str | None = None
24
+ safeplace: str | None = None
25
+
26
+
27
+ class AddressDelivery(Address):
28
+ instructions: str = ''
29
+
@@ -0,0 +1,42 @@
1
+ from types import MappingProxyType
2
+
3
+ from pydantic import ConfigDict
4
+
5
+ from apc_hypaship.config import APCBaseModel
6
+
7
+ APC_SERVICES_DICT = dict(
8
+ NEXT_DAY="ND16",
9
+ NEXT_DAY_12="ND12",
10
+ NEXT_DAY_9="ND09",
11
+ )
12
+
13
+
14
+ class ServiceSpec(APCBaseModel):
15
+ model_config = ConfigDict(extra="ignore")
16
+ Carrier: str
17
+ CollectionDate: str
18
+ Currency: str
19
+ DeliveryGroup: str
20
+ EstimatedDeliveryDate: str
21
+ ExtraCharges: str
22
+ FuelCharge: str
23
+ InsuranceCharge: str
24
+ ItemType: str
25
+ LatestBookingDateTime: str
26
+ MaxCompensation: str
27
+ MaxItemHeight: str
28
+ MaxItemLength: str
29
+ MaxItemWidth: str
30
+ MaxTransitDays: str
31
+ MinTransitDays: str
32
+ ProductCode: str
33
+ Rate: str
34
+ ServiceName: str
35
+ Signed: str
36
+ TotalCost: str
37
+ Tracked: str
38
+ Vat: str
39
+ VolumetricWeight: str
40
+ WeightUnit: str
41
+
42
+
@@ -0,0 +1,69 @@
1
+ from datetime import date, time
2
+ from typing import Literal, Sequence
3
+
4
+ from pydantic import Field, model_validator
5
+ from loguru import logger
6
+
7
+ from apc_hypaship.models.request.address import Address
8
+ from apc_hypaship.config import APCBaseModel
9
+
10
+
11
+ class Item(APCBaseModel):
12
+ type: Literal["ALL"] = "ALL"
13
+ weight: float | None = 12
14
+ length: float | None = None
15
+ width: float | None = None
16
+ height: float | None = None
17
+ reference: str | None = None
18
+
19
+
20
+ class Items(APCBaseModel):
21
+ item: Sequence[Item] = Field(default_factory=list)
22
+
23
+
24
+ class ShipmentDetails(APCBaseModel):
25
+ number_of_pieces: int
26
+ items: Items | None = None
27
+
28
+ @model_validator(mode="after")
29
+ def check_items(self):
30
+ if not self.items:
31
+ self.items = Items(item=[Item() for _ in range(self.number_of_pieces)])
32
+ logger.debug("Auto-filled items with weight = 12kg per box")
33
+ return self
34
+
35
+
36
+ class GoodsInfo(APCBaseModel):
37
+ goods_value: str = "1000"
38
+ goods_description: str = "Radios"
39
+ fragile: bool = False
40
+ security: bool = False
41
+ increased_liability: bool = False
42
+ premium_insurance: bool = False
43
+ premium: bool = False
44
+
45
+
46
+
47
+
48
+ class Order(APCBaseModel):
49
+ collection_date: date
50
+ ready_at: time = time(hour=9)
51
+ closed_at: time = time(hour=17)
52
+ product_code: str
53
+ reference: str
54
+ collection: Address | None = None
55
+ delivery: Address
56
+ goods_info: GoodsInfo
57
+ shipment_details: ShipmentDetails
58
+
59
+
60
+ class Orders(APCBaseModel):
61
+ order: Order
62
+
63
+
64
+ class Shipment(APCBaseModel):
65
+ orders: Orders
66
+
67
+ @classmethod
68
+ def from_order(cls, order: Order) -> "Shipment":
69
+ return cls(orders=Orders(order=order))
@@ -0,0 +1,16 @@
1
+ from apc_hypaship.models.request.address import Address as _Address, Contact as _Contact
2
+
3
+
4
+ class Contact(_Contact):
5
+ person_name: str | None = None
6
+ mobile_number: str | None = None
7
+ email: str | None = None
8
+
9
+
10
+ class Address(_Address):
11
+ company_name: str | None = None
12
+ address_line_1: str | None = None
13
+ postal_code: str | None = None
14
+ city: str | None = None
15
+ country_code: str | None = None
16
+ contact: Contact | None = None
@@ -0,0 +1,6 @@
1
+ from apc_hypaship.config import APCBaseModel
2
+
3
+
4
+ class Messages(APCBaseModel):
5
+ code: str | None = None
6
+ description: str | None = None
@@ -0,0 +1,31 @@
1
+ from apc_hypaship.config import APCBaseModel
2
+ from apc_hypaship.models.response.address import Address
3
+ from apc_hypaship.models.response.common import Messages
4
+ from apc_hypaship.models.response.service import GoodsInfo
5
+ from apc_hypaship.models.response.shipment import Depots, ShipmentDetails
6
+
7
+
8
+ class Track(APCBaseModel):
9
+ adult_signature: str | None = None
10
+ closed_at: str | None = None
11
+ collection: Address | None = None
12
+ collection_date: str | None = None
13
+ custom_reference1: str | None = None
14
+ custom_reference2: str | None = None
15
+ custom_reference3: str | None = None
16
+ delivery: Address | None = None
17
+ depots: Depots | None = None
18
+ goods_info: GoodsInfo | None = None
19
+ item_option: str | None = None
20
+ order_number: str | None = None
21
+ product_code: str | None = None
22
+ ready_at: str | None = None
23
+ reference: str | None = None
24
+ shipment_details: ShipmentDetails | None = None
25
+ way_bill: str | None = None
26
+
27
+
28
+ class Tracks(APCBaseModel):
29
+ account_number: str | None = None
30
+ messages: Messages | None = None
31
+ track: Track | None = None
@@ -0,0 +1,66 @@
1
+ from enum import Enum
2
+
3
+ from apc_hypaship.config import APCBaseModel
4
+
5
+
6
+ class ParcelSpec(APCBaseModel):
7
+ name: str
8
+ service_code: str
9
+ max_len: float
10
+ max_width: float
11
+ max_height: float | None
12
+ max_weight: float
13
+ max_items: int
14
+ notes: str | None = None
15
+
16
+
17
+ class ParcelSpecs(Enum):
18
+ MAILPACK = ParcelSpec(
19
+ name='MailPack',
20
+ service_code='MP',
21
+ max_len=37.5,
22
+ max_width=29.5,
23
+ max_height=None,
24
+ max_weight=1,
25
+ max_items=1,
26
+ notes='must fit in provided or pre-approved packaging',
27
+ )
28
+ COURIERPACK = ParcelSpec(
29
+ name='CourierPack',
30
+ service_code='CP',
31
+ max_len=54.5,
32
+ max_width=45.5,
33
+ max_height=None,
34
+ max_weight=5,
35
+ max_items=1,
36
+ notes='must fit in provided or pre-approved packaging',
37
+ )
38
+ LIGHTWEIGHTPARCEL = ParcelSpec(
39
+ name='Lightweight Parcel',
40
+ service_code='LW',
41
+ max_len=45.0,
42
+ max_width=35.0,
43
+ max_height=20.0,
44
+ max_weight=5,
45
+ max_items=1,
46
+ )
47
+ STANDARDNEXTDAYPARCEL = ParcelSpec(
48
+ name='Standard Next Day Parcel',
49
+ service_code='ND',
50
+ max_len=120.0,
51
+ max_width=55.0,
52
+ max_height=50.0,
53
+ max_weight=30,
54
+ max_items=20,
55
+ notes='max 60x60x60 also ok',
56
+ )
57
+ NONCONVEYABLEPARCEL = ParcelSpec(
58
+ name='Non-Conveyable Parcel',
59
+ service_code='NC',
60
+ max_len=160.0,
61
+ max_width=60.0,
62
+ max_height=60.0,
63
+ max_weight=30,
64
+ max_items=2,
65
+ notes='width+height < 120 also ok',
66
+ )
@@ -0,0 +1,31 @@
1
+ from apc_hypaship.config import APCBaseModel
2
+ from apc_hypaship.models.response.common import Messages
3
+ from apc_hypaship.models.response.service import Services
4
+ from apc_hypaship.models.response.shipment import Orders, Order
5
+
6
+
7
+ class Response(APCBaseModel): ...
8
+
9
+
10
+ class BookingResponse(Response):
11
+ orders: Orders | None = None
12
+
13
+ @property
14
+ def order_num(self):
15
+ return self.orders.order.order_number if self.orders and self.orders.order else None
16
+
17
+
18
+ class OrdersResponse(APCBaseModel):
19
+ orders: Orders
20
+
21
+
22
+ class ServiceAvailability(APCBaseModel):
23
+ account_number: str | None = None
24
+ messages: Messages | None = None
25
+ order: Order | None = None
26
+ services: Services | None = None
27
+
28
+
29
+ class ServiceAvailabilityResponse(APCBaseModel):
30
+ service_availability: ServiceAvailability
31
+
@@ -0,0 +1,53 @@
1
+ from pydantic import Field
2
+
3
+ from apc_hypaship.config import APCBaseModel
4
+ from apc_hypaship.models.request.shipment import GoodsInfo as _GoodsInfo
5
+
6
+
7
+ class GoodsInfo(_GoodsInfo):
8
+ goods_description: str | None = None
9
+ non_conv: str | None = None
10
+ charge_on_delivery: str | None = None
11
+ goods_value: str | None = None
12
+
13
+
14
+ class Rates(APCBaseModel):
15
+ rate: str | None = None
16
+ extra_charges: str | None = None
17
+ fuel_charge: str | None = None
18
+ insurance_charge: str | None = None
19
+ vat: str | None = None
20
+ total_cost: str | None = None
21
+ currency: str | None = None
22
+
23
+
24
+ class Service(APCBaseModel):
25
+ carrier: str | None = None
26
+ service_name: str | None = None
27
+ product_code: str | None = None
28
+ min_transit_days: str | None = None
29
+ max_transit_days: str | None = None
30
+ tracked: str | None = None
31
+ signed: str | None = None
32
+ max_compensation: str | None = None
33
+ max_item_length: str | None = None
34
+ max_item_width: str | None = None
35
+ max_item_height: str | None = None
36
+ item_type: str | None = None
37
+ delivery_group: str | None = None
38
+ collection_date: str | None = None
39
+ estimated_delivery_date: str | None = None
40
+ latest_booking_date_time: str | None = None
41
+ rate: str | None = None
42
+ extra_charges: str | None = None
43
+ fuel_charge: str | None = None
44
+ insurance_charge: str | None = None
45
+ vat: str | None = None
46
+ total_cost: str | None = None
47
+ currency: str | None = None
48
+ volumetric_weight: str | None = None
49
+ weight_unit: str | None = None
50
+
51
+
52
+ class Services(APCBaseModel):
53
+ service: list[Service] = Field(default_factory=list)
@@ -0,0 +1,82 @@
1
+ from typing import Union, List
2
+
3
+ from apc_hypaship.config import APCBaseModel
4
+ from apc_hypaship.models.response.address import Address
5
+ from apc_hypaship.models.response.common import Messages
6
+ from apc_hypaship.models.response.service import GoodsInfo, Rates
7
+
8
+
9
+ class Label(APCBaseModel):
10
+ content: bytes | None = None
11
+ format: str | None = None
12
+
13
+
14
+ class Item(APCBaseModel):
15
+ item_number: str | None = None
16
+ tracking_number: str | None = None
17
+ type: str | None = None
18
+ weight: str | None = None
19
+ length: str | None = None
20
+ width: str | None = None
21
+ height: str | None = None
22
+ value: str | None = None
23
+ reference: str | None = None
24
+ label: Label | None = None
25
+
26
+
27
+ class Items(APCBaseModel):
28
+ item: Item | None = None
29
+
30
+
31
+ class ShipmentDetails(APCBaseModel):
32
+ items: Union[Item, List[Item]] | None = None
33
+ number_of_pieces: str | None = None
34
+ total_weight: str | None = None
35
+ volumetric_weight: str | None = None
36
+
37
+
38
+ class Depots(APCBaseModel):
39
+ collecting_depot: str | None = None
40
+ delivery_depot: str | None = None
41
+ delivery_route: str | None = None
42
+ is_scottish: str | None = None
43
+ presort: str | None = None
44
+ request_depot: str | None = None
45
+ route: str | None = None
46
+ seg_code: str | None = None
47
+ zone: str | None = None
48
+
49
+
50
+ class Order(APCBaseModel):
51
+ messages: Messages | None = None
52
+ account_number: list[str] | None = None
53
+ entry_type: str | None = None
54
+ collection_date: str | None = None
55
+ ready_at: str | None = None
56
+ closed_at: str | None = None
57
+ product_code: str | None = None
58
+ rule_name: str | None = None
59
+ item_option: str | None = None
60
+ order_number: str | None = None
61
+ way_bill: str | None = None
62
+ reference: str | None = None
63
+ custom_reference1: str | None = None
64
+ custom_reference2: str | None = None
65
+ custom_reference3: str | None = None
66
+ adult_signature: str | None = None
67
+ depots: Depots | None = None
68
+ collection: Address | None = None
69
+ delivery: Address | None = None
70
+ goods_info: GoodsInfo | None = None
71
+ shipment_details: ShipmentDetails | None = None
72
+ rates: Rates | None = None
73
+ barcode: str | None = None
74
+ delivery_date: str | None = None
75
+ label: Label | None = None
76
+ network_name: str | None = None
77
+
78
+
79
+ class Orders(APCBaseModel):
80
+ account_number: str | None = None
81
+ messages: Messages | None = None
82
+ order: Order | None = None