Initial commit
This commit is contained in:
+241
@@ -0,0 +1,241 @@
|
||||
"""FedEx Ship API client — One Rate shipments.
|
||||
|
||||
Handles OAuth token fetch and shipment creation. Returns a tracking number
|
||||
and a temp-file path to the PDF label, which the caller opens for printing.
|
||||
|
||||
Phase 2 hook: swap _SHIP_URL / _TOKEN_URL to the sandbox URLs for testing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_URL_PROD = "https://apis.fedex.com/oauth/token"
|
||||
_SHIP_URL_PROD = "https://apis.fedex.com/ship/v1/shipments"
|
||||
_TOKEN_URL_SANDBOX = "https://apis-sandbox.fedex.com/oauth/token"
|
||||
_SHIP_URL_SANDBOX = "https://apis-sandbox.fedex.com/ship/v1/shipments"
|
||||
|
||||
# Maps UI display strings → FedEx API codes
|
||||
_SERVICE_CODES: dict[str, str] = {
|
||||
"FedEx 2Day": "FEDEX_2_DAY",
|
||||
"FedEx 2Day AM": "FEDEX_2_DAY_AM",
|
||||
}
|
||||
_PACKAGE_CODES: dict[str, str] = {
|
||||
"FedEx Envelope": "FEDEX_ENVELOPE",
|
||||
"FedEx Pak": "FEDEX_PAK",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShipmentResult:
|
||||
tracking_number: str
|
||||
label_pdf_path: str # absolute path to a temp PDF file
|
||||
|
||||
|
||||
class FedExClient:
|
||||
"""Thin wrapper around the FedEx REST Ship API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
secret: str,
|
||||
account: str,
|
||||
shipper: dict,
|
||||
sandbox: bool = False,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._secret = secret
|
||||
self._account = account
|
||||
self._shipper = shipper # keys: name, company, phone, address1, city, state, zip
|
||||
self._token_url = _TOKEN_URL_SANDBOX if sandbox else _TOKEN_URL_PROD
|
||||
self._ship_url = _SHIP_URL_SANDBOX if sandbox else _SHIP_URL_PROD
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_shipment(self, data: dict) -> ShipmentResult:
|
||||
"""Create a One Rate shipment and return tracking number + label PDF path.
|
||||
|
||||
`data` is the dict from ShippingTab._get_shipment_data().
|
||||
Raises RuntimeError with a user-readable message on any failure.
|
||||
"""
|
||||
token = self._fetch_token()
|
||||
|
||||
service = _SERVICE_CODES.get(data["service"], "FEDEX_2_DAY")
|
||||
pkg_type = _PACKAGE_CODES.get(data["package_type"], "FEDEX_PAK")
|
||||
|
||||
street_lines = [data["address1"]]
|
||||
if data.get("address2"):
|
||||
street_lines.append(data["address2"])
|
||||
|
||||
payload = self._build_payload(data, service, pkg_type, street_lines)
|
||||
|
||||
resp = requests.post(
|
||||
self._ship_url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
raise RuntimeError(_format_api_error(resp))
|
||||
|
||||
return self._parse_response(resp.json())
|
||||
|
||||
# ── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _fetch_token(self) -> str:
|
||||
try:
|
||||
resp = requests.post(
|
||||
self._token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self._api_key,
|
||||
"client_secret": self._secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not connect to FedEx (authentication step):\n{exc}"
|
||||
) from exc
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
data: dict,
|
||||
service: str,
|
||||
pkg_type: str,
|
||||
street_lines: list[str],
|
||||
) -> dict:
|
||||
s = self._shipper
|
||||
payload: dict = {
|
||||
"labelResponseOptions": "LABEL",
|
||||
"accountNumber": {"value": self._account},
|
||||
"requestedShipment": {
|
||||
"shipper": {
|
||||
"contact": {
|
||||
"personName": s.get("name", ""),
|
||||
"companyName": s.get("company", ""),
|
||||
"phoneNumber": s.get("phone", ""),
|
||||
},
|
||||
"address": {
|
||||
"streetLines": [s.get("address1", "")],
|
||||
"city": s.get("city", ""),
|
||||
"stateOrProvinceCode": s.get("state", ""),
|
||||
"postalCode": s.get("zip", ""),
|
||||
"countryCode": "US",
|
||||
},
|
||||
},
|
||||
"recipients": [{
|
||||
"contact": {
|
||||
"personName": data["name"],
|
||||
"companyName": data.get("company", ""),
|
||||
"phoneNumber": data["phone"],
|
||||
**({"emailAddress": data["email"]}
|
||||
if data.get("email") else {}),
|
||||
},
|
||||
"address": {
|
||||
"streetLines": street_lines,
|
||||
"city": data["city"],
|
||||
"stateOrProvinceCode": data["state"],
|
||||
"postalCode": data["zip"],
|
||||
"countryCode": "US",
|
||||
"residential": False,
|
||||
},
|
||||
}],
|
||||
"serviceType": service,
|
||||
"packagingType": pkg_type,
|
||||
"pickupType": "USE_SCHEDULED_PICKUP",
|
||||
"shippingChargesPayment": {
|
||||
"paymentType": "SENDER",
|
||||
"payor": {
|
||||
"responsibleParty": {
|
||||
"accountNumber": {"value": self._account},
|
||||
},
|
||||
},
|
||||
},
|
||||
"labelSpecification": {
|
||||
"labelFormatType": "COMMON2D",
|
||||
"imageType": "PDF",
|
||||
"labelStockType": "PAPER_LETTER",
|
||||
},
|
||||
"requestedPackageLineItems": [{
|
||||
"weight": {"units": "LB", "value": 1},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
def _parse_response(self, result: dict) -> ShipmentResult:
|
||||
shipments = result.get("output", {}).get("transactionShipments", [])
|
||||
if not shipments:
|
||||
raise RuntimeError("FedEx accepted the request but returned no shipment data.")
|
||||
|
||||
shipment = shipments[0]
|
||||
pieces = shipment.get("pieceResponses", [{}])
|
||||
piece = pieces[0] if pieces else {}
|
||||
|
||||
tracking = (
|
||||
shipment.get("masterTrackingNumber")
|
||||
or piece.get("trackingNumber")
|
||||
or ""
|
||||
)
|
||||
|
||||
docs = piece.get("packageDocuments", [{}])
|
||||
doc = docs[0] if docs else {}
|
||||
b64_pdf = doc.get("encodedLabel", "")
|
||||
|
||||
if not b64_pdf:
|
||||
raise RuntimeError(
|
||||
f"Shipment created (tracking: {tracking}) but FedEx sent no label."
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
from .paths import appdata_dir
|
||||
|
||||
pdf_bytes = base64.b64decode(b64_pdf)
|
||||
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"FedEx_{tracking or 'label'}_{stamp}.pdf"
|
||||
label_path.write_bytes(pdf_bytes)
|
||||
|
||||
log.info("FedEx shipment created: tracking=%s label=%s", tracking, label_path)
|
||||
return ShipmentResult(tracking_number=tracking, label_pdf_path=str(label_path))
|
||||
|
||||
|
||||
# ── Error formatting ────────────────────────────────────────────────────────
|
||||
|
||||
def _format_api_error(resp: requests.Response) -> str:
|
||||
status = resp.status_code
|
||||
try:
|
||||
body = resp.json()
|
||||
errors = body.get("errors", [])
|
||||
msgs = [
|
||||
f"{e.get('code', '')}: {e.get('message', '')}".strip(": ")
|
||||
for e in errors
|
||||
]
|
||||
detail = " | ".join(msgs) if msgs else str(body)
|
||||
except Exception:
|
||||
detail = resp.text or "(no body)"
|
||||
|
||||
hint = ""
|
||||
if status == 401:
|
||||
hint = "\n\nCheck that the API key and secret in config.json are correct."
|
||||
elif status == 400:
|
||||
hint = "\n\nCheck the recipient address — a required field may be missing."
|
||||
|
||||
return f"FedEx returned HTTP {status}: {detail}{hint}"
|
||||
Reference in New Issue
Block a user