Initial commit
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
"""ShipEngine API client — USPS label creation.
|
||||
|
||||
Authentication uses a simple API key header (no OAuth / token refresh).
|
||||
Keys prefixed with TEST_ create non-mailable test labels with no postage
|
||||
charged — same endpoint, same code path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.shipengine.com"
|
||||
|
||||
# Our internal package codes → ShipEngine package codes
|
||||
_PACKAGE_CODES: dict[str, str] = {
|
||||
"package": "package",
|
||||
"large_envelope": "large_envelope_or_flat",
|
||||
"letter": "letter",
|
||||
"thick_envelope": "thick_envelope",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabelResult:
|
||||
label_id: str
|
||||
tracking_number: str
|
||||
label_pdf_path: str
|
||||
|
||||
|
||||
class ShipEngineClient:
|
||||
"""Thin wrapper around the ShipEngine REST API."""
|
||||
|
||||
def __init__(self, api_key: str) -> None:
|
||||
self._key = api_key
|
||||
self._headers = {
|
||||
"API-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── Address validation ─────────────────────────────────────────────────
|
||||
|
||||
def validate_address(self, addr: dict) -> dict:
|
||||
"""Validate and normalise a structured address dict.
|
||||
|
||||
Input keys: name, company_name, address_line1, address_line2,
|
||||
city, state_province, postal_code
|
||||
Returns the same key set with corrected values, plus a
|
||||
'validation_status' key ("verified" | "warning" | "unverified" | "error").
|
||||
"""
|
||||
payload = [{
|
||||
"name": addr.get("name", ""),
|
||||
"company_name": addr.get("company_name", ""),
|
||||
"address_line1": addr.get("address_line1", ""),
|
||||
"address_line2": addr.get("address_line2", ""),
|
||||
"city_locality": addr.get("city", ""),
|
||||
"state_province": addr.get("state_province", ""),
|
||||
"postal_code": addr.get("postal_code", ""),
|
||||
"country_code": "US",
|
||||
}]
|
||||
resp = requests.post(
|
||||
f"{_BASE_URL}/v1/addresses/validate",
|
||||
json=payload,
|
||||
headers=self._headers,
|
||||
timeout=15,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(_format_error(resp, "Address validation"))
|
||||
|
||||
result = resp.json()
|
||||
if not result:
|
||||
return addr
|
||||
|
||||
item = result[0]
|
||||
status = item.get("status", "unverified")
|
||||
matched = item.get("matched_address") or {}
|
||||
|
||||
return {
|
||||
"name": matched.get("name", addr.get("name", "")),
|
||||
"company_name": matched.get("company_name", addr.get("company_name", "")),
|
||||
"address_line1": matched.get("address_line1", addr.get("address_line1", "")),
|
||||
"address_line2": matched.get("address_line2", addr.get("address_line2", "")),
|
||||
"city": matched.get("city_locality", addr.get("city", "")),
|
||||
"state_province": matched.get("state_province", addr.get("state_province", "")),
|
||||
"postal_code": matched.get("postal_code", addr.get("postal_code", "")),
|
||||
"validation_status": status,
|
||||
}
|
||||
|
||||
# ── Rate fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_rate(
|
||||
self,
|
||||
from_zip: str,
|
||||
to_zip: str,
|
||||
weight_oz: int,
|
||||
service_code: str,
|
||||
package_code: str,
|
||||
dim_l: float = 0,
|
||||
dim_w: float = 0,
|
||||
dim_h: float = 0,
|
||||
) -> float | None:
|
||||
"""Return total shipment cost in USD, or None on any failure."""
|
||||
package: dict = {
|
||||
"weight": {"value": max(1, weight_oz), "unit": "ounce"},
|
||||
}
|
||||
if dim_l and dim_w and dim_h:
|
||||
package["dimensions"] = {
|
||||
"length": dim_l, "width": dim_w, "height": dim_h,
|
||||
"unit": "inch",
|
||||
}
|
||||
if package_code and package_code != "package":
|
||||
package["package_code"] = _PACKAGE_CODES.get(package_code, package_code)
|
||||
|
||||
payload = {
|
||||
"rate_options": {"service_codes": [service_code]},
|
||||
"shipment": {
|
||||
"service_code": service_code,
|
||||
"ship_from": {"postal_code": from_zip, "country_code": "US"},
|
||||
"ship_to": {"postal_code": to_zip, "country_code": "US"},
|
||||
"packages": [package],
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{_BASE_URL}/v1/rates",
|
||||
json=payload,
|
||||
headers=self._headers,
|
||||
timeout=12,
|
||||
)
|
||||
if not resp.ok:
|
||||
return None
|
||||
data = resp.json()
|
||||
rate_list = (data.get("rate_response") or {}).get("rates") or []
|
||||
if not rate_list:
|
||||
return None
|
||||
total = rate_list[0].get("shipping_amount", {}).get("amount")
|
||||
return float(total) if total is not None else None
|
||||
except Exception as exc:
|
||||
log.debug("Rate fetch failed: %s", exc)
|
||||
return None
|
||||
|
||||
# ── Label creation ─────────────────────────────────────────────────────
|
||||
|
||||
def create_label(self, data: dict, parsed_addr: dict) -> LabelResult:
|
||||
"""Create a USPS label and save the PDF locally.
|
||||
|
||||
`data` is the dict from _UspsPanel._on_create().
|
||||
`parsed_addr` is a structured address dict (from parse_address_text
|
||||
+ validate_address).
|
||||
"""
|
||||
pkg_code = _PACKAGE_CODES.get(data["package_code"], data["package_code"])
|
||||
|
||||
package: dict = {
|
||||
"weight": {"value": max(1, data["weight_oz"]), "unit": "ounce"},
|
||||
"package_code": pkg_code,
|
||||
}
|
||||
if data.get("dim_l") and data.get("dim_w") and data.get("dim_h"):
|
||||
package["dimensions"] = {
|
||||
"length": data["dim_l"],
|
||||
"width": data["dim_w"],
|
||||
"height": data["dim_h"],
|
||||
"unit": "inch",
|
||||
}
|
||||
if data.get("print_message"):
|
||||
package["label_messages"] = {"reference1": data["print_message"][:60]}
|
||||
|
||||
ship_to: dict = {
|
||||
"name": parsed_addr.get("name", ""),
|
||||
"company_name": parsed_addr.get("company_name", ""),
|
||||
"address_line1": parsed_addr.get("address_line1", ""),
|
||||
"address_line2": parsed_addr.get("address_line2", ""),
|
||||
"city_locality": parsed_addr.get("city", ""),
|
||||
"state_province": parsed_addr.get("state_province", ""),
|
||||
"postal_code": parsed_addr.get("postal_code", ""),
|
||||
"country_code": "US",
|
||||
}
|
||||
if data.get("email"):
|
||||
ship_to["email"] = data["email"]
|
||||
|
||||
payload = {
|
||||
"label_format": "pdf",
|
||||
"label_layout": "4x6",
|
||||
"shipment": {
|
||||
"service_code": data["service_code"],
|
||||
"ship_from": self._shipper,
|
||||
"ship_to": ship_to,
|
||||
"packages": [package],
|
||||
},
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{_BASE_URL}/v1/labels",
|
||||
json=payload,
|
||||
headers=self._headers,
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(_format_error(resp, "Label creation"))
|
||||
|
||||
result = resp.json()
|
||||
label_id = result.get("label_id", "")
|
||||
tracking = result.get("tracking_number", "")
|
||||
pdf_url = (result.get("label_download") or {}).get("pdf", "")
|
||||
|
||||
if not pdf_url:
|
||||
raise RuntimeError(
|
||||
f"Label created (tracking: {tracking}) but ShipEngine returned no PDF URL."
|
||||
)
|
||||
|
||||
pdf_resp = requests.get(pdf_url, timeout=20)
|
||||
pdf_resp.raise_for_status()
|
||||
|
||||
from datetime import datetime
|
||||
from .paths import appdata_dir
|
||||
labels_dir = appdata_dir() / "labels"
|
||||
labels_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
label_path = labels_dir / f"USPS_{tracking or 'label'}_{stamp}.pdf"
|
||||
label_path.write_bytes(pdf_resp.content)
|
||||
|
||||
log.info("USPS label created: tracking=%s label=%s", tracking, label_path)
|
||||
return LabelResult(
|
||||
label_id = label_id,
|
||||
tracking_number = tracking,
|
||||
label_pdf_path = str(label_path),
|
||||
)
|
||||
|
||||
def set_shipper(self, cfg) -> None:
|
||||
"""Build the ship_from block from config and cache it."""
|
||||
self._shipper = {
|
||||
"name": cfg.usps_shipper_name,
|
||||
"company_name": cfg.usps_shipper_company,
|
||||
"phone": cfg.usps_shipper_phone,
|
||||
"address_line1": cfg.usps_shipper_address1,
|
||||
"city_locality": cfg.usps_shipper_city,
|
||||
"state_province": cfg.usps_shipper_state,
|
||||
"postal_code": cfg.usps_shipper_zip,
|
||||
"country_code": "US",
|
||||
}
|
||||
|
||||
|
||||
# ── Error formatting ────────────────────────────────────────────────────────
|
||||
|
||||
def _format_error(resp: requests.Response, context: str) -> str:
|
||||
status = resp.status_code
|
||||
try:
|
||||
body = resp.json()
|
||||
errors = body.get("errors") or []
|
||||
msgs = [e.get("message", "") for e in errors if e.get("message")]
|
||||
detail = " | ".join(msgs) if msgs else str(body)
|
||||
except Exception:
|
||||
detail = resp.text or "(no body)"
|
||||
return f"{context} failed — ShipEngine HTTP {status}: {detail}"
|
||||
|
||||
|
||||
# ── Freeform address parser ─────────────────────────────────────────────────
|
||||
|
||||
_CSZ_RE = re.compile(
|
||||
r'^(.+?),?\s+([A-Za-z]{2})\s+(\d{5}(?:-\d{4})?)$'
|
||||
)
|
||||
_STREET_RE = re.compile(r'^\d')
|
||||
|
||||
|
||||
def parse_address_text(text: str) -> dict:
|
||||
"""Parse a freeform US address block into a structured dict.
|
||||
|
||||
Handles the common copy-paste format:
|
||||
Name
|
||||
[Company]
|
||||
Street Address [Apt/Suite]
|
||||
City, ST ZIP
|
||||
"""
|
||||
lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
|
||||
if not lines:
|
||||
return {}
|
||||
|
||||
# Find the city/state/zip line (search from bottom)
|
||||
csz_idx = None
|
||||
city = state = zip_ = ""
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
m = _CSZ_RE.match(lines[i])
|
||||
if m:
|
||||
csz_idx = i
|
||||
city = m.group(1).strip()
|
||||
state = m.group(2).upper()
|
||||
zip_ = m.group(3)
|
||||
break
|
||||
|
||||
if csz_idx is None:
|
||||
# Can't find city/state/zip — return what we have
|
||||
return {"address_line1": lines[0]}
|
||||
|
||||
before = lines[:csz_idx]
|
||||
name = company = addr1 = addr2 = ""
|
||||
|
||||
if len(before) == 0:
|
||||
pass
|
||||
elif len(before) == 1:
|
||||
addr1 = before[0]
|
||||
elif len(before) == 2:
|
||||
name = before[0]
|
||||
addr1 = before[1]
|
||||
elif len(before) == 3:
|
||||
name = before[0]
|
||||
# Middle line: company or address depending on whether last line is street
|
||||
if _STREET_RE.match(before[2]):
|
||||
company = before[1]
|
||||
addr1 = before[2]
|
||||
else:
|
||||
addr1 = before[1]
|
||||
addr2 = before[2]
|
||||
else:
|
||||
name = before[0]
|
||||
company = before[1]
|
||||
addr1 = before[2]
|
||||
addr2 = before[3]
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"company_name": company,
|
||||
"address_line1": addr1,
|
||||
"address_line2": addr2,
|
||||
"city": city,
|
||||
"state_province": state,
|
||||
"postal_code": zip_,
|
||||
}
|
||||
Reference in New Issue
Block a user