smklog-sdk 0.1.0__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.
- smklog/__init__.py +50 -0
- smklog/__main__.py +7 -0
- smklog/_version.py +3 -0
- smklog/cli.py +283 -0
- smklog/client.py +205 -0
- smklog/errors.py +112 -0
- smklog/models.py +257 -0
- smklog/py.typed +0 -0
- smklog_sdk-0.1.0.dist-info/METADATA +172 -0
- smklog_sdk-0.1.0.dist-info/RECORD +14 -0
- smklog_sdk-0.1.0.dist-info/WHEEL +5 -0
- smklog_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- smklog_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- smklog_sdk-0.1.0.dist-info/top_level.txt +1 -0
smklog/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Official Python client for the SMKlog Quote API: https://smklog.com/developers/
|
|
2
|
+
|
|
3
|
+
from smklog import SmklogClient
|
|
4
|
+
quote = SmklogClient().get_parcel_quote(product="paperback books",
|
|
5
|
+
from_postal_code="07030", to_postal_code="33101")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, SmklogClient
|
|
10
|
+
from .errors import (
|
|
11
|
+
InvalidRequestError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitedError,
|
|
14
|
+
SessionFailedError,
|
|
15
|
+
SmklogError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
Checkout,
|
|
19
|
+
CheckoutSession,
|
|
20
|
+
CheckoutSessionRequest,
|
|
21
|
+
CheckoutStatus,
|
|
22
|
+
Liveness,
|
|
23
|
+
Package,
|
|
24
|
+
Problem,
|
|
25
|
+
QuoteRequest,
|
|
26
|
+
QuoteResponse,
|
|
27
|
+
Rate,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"DEFAULT_BASE_URL",
|
|
33
|
+
"DEFAULT_TIMEOUT",
|
|
34
|
+
"SmklogClient",
|
|
35
|
+
"SmklogError",
|
|
36
|
+
"RateLimitedError",
|
|
37
|
+
"InvalidRequestError",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"SessionFailedError",
|
|
40
|
+
"QuoteRequest",
|
|
41
|
+
"QuoteResponse",
|
|
42
|
+
"Rate",
|
|
43
|
+
"Package",
|
|
44
|
+
"CheckoutSessionRequest",
|
|
45
|
+
"CheckoutSession",
|
|
46
|
+
"Checkout",
|
|
47
|
+
"CheckoutStatus",
|
|
48
|
+
"Liveness",
|
|
49
|
+
"Problem",
|
|
50
|
+
]
|
smklog/__main__.py
ADDED
smklog/_version.py
ADDED
smklog/cli.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The ``smklog`` command line: one subcommand per API operation.
|
|
2
|
+
|
|
3
|
+
smklog quote "Yamaha FG800 acoustic guitar in a hard case" 07102 30303 [--country US] [--qty 1]
|
|
4
|
+
[--dims 112x48x20 --kg 6.4] [--json]
|
|
5
|
+
smklog session --quote-id q_... [--service "USPS Ground Advantage"] [--json]
|
|
6
|
+
smklog session-status as_... [--json]
|
|
7
|
+
smklog status [--json]
|
|
8
|
+
|
|
9
|
+
Human output is a small table; ``--json`` prints the raw answer. A priced quote also
|
|
10
|
+
prints its quote_id on its own line, ready for ``smklog session --quote-id``.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 for a priced answer or a successful read, 2 when the API refuses (the
|
|
13
|
+
Problem is printed) or answers a quote without a price, 3 on a rate limit, 1 when the
|
|
14
|
+
host could not be reached.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
22
|
+
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .client import DEFAULT_BASE_URL, SmklogClient
|
|
25
|
+
from .errors import RateLimitedError, SmklogError
|
|
26
|
+
from .models import CheckoutSession, CheckoutStatus, Liveness, QuoteResponse
|
|
27
|
+
|
|
28
|
+
Number = Union[int, float]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
32
|
+
parser = build_parser()
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
client = SmklogClient(base_url=args.base_url, token=args.token or None)
|
|
35
|
+
try:
|
|
36
|
+
return args.run(client, args)
|
|
37
|
+
except RateLimitedError as exc:
|
|
38
|
+
_print_problem(exc, args.json)
|
|
39
|
+
return 3
|
|
40
|
+
except SmklogError as exc:
|
|
41
|
+
_print_problem(exc, args.json)
|
|
42
|
+
return 1 if exc.error == "network_error" else 2
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="smklog",
|
|
48
|
+
description="Live parcel rates from the SMKlog Quote API. No key needed; every quote spends a carrier rating, so quote once per shipment.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--version", action="version", version="smklog-sdk " + __version__)
|
|
51
|
+
# Shared flags live on each subcommand so they can follow it, as the usage lines show.
|
|
52
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
53
|
+
common.add_argument("--json", action="store_true", help="print the raw JSON answer instead of a table")
|
|
54
|
+
common.add_argument("--base-url", default=os.environ.get("SMKLOG_BASE_URL", DEFAULT_BASE_URL),
|
|
55
|
+
help="API host (default %(default)s; env SMKLOG_BASE_URL)")
|
|
56
|
+
common.add_argument("--token", default=os.environ.get("SMKLOG_TOKEN", ""),
|
|
57
|
+
help="optional OAuth bearer for a dedicated rate-limit bucket (env SMKLOG_TOKEN)")
|
|
58
|
+
|
|
59
|
+
commands = parser.add_subparsers(dest="command", metavar="command", required=True)
|
|
60
|
+
|
|
61
|
+
quote = commands.add_parser("quote", parents=[common], help="price one parcel")
|
|
62
|
+
quote.add_argument("product", help='the item in plain words, e.g. "paperback books"')
|
|
63
|
+
quote.add_argument("from_zip", help="5-digit US origin ZIP")
|
|
64
|
+
quote.add_argument("to_zip", help="destination ZIP or postal code")
|
|
65
|
+
quote.add_argument("--country", default="US", help="destination country: US, CA, GB, DE or AU (default US)")
|
|
66
|
+
quote.add_argument("--qty", type=int, default=1, help="units shipping together (default 1)")
|
|
67
|
+
quote.add_argument("--dims", type=_dims, metavar="LxWxH", help="packed box in cm, e.g. 30x22x12 (needs --kg)")
|
|
68
|
+
quote.add_argument("--kg", type=_number, help="packed weight in kg (needs --dims)")
|
|
69
|
+
quote.set_defaults(run=_cmd_quote)
|
|
70
|
+
|
|
71
|
+
session = commands.add_parser("session", parents=[common], help="open a checkout for a human from a quote")
|
|
72
|
+
session.add_argument("--quote-id", required=True, help="quote_id printed by `smklog quote`, valid 15 minutes")
|
|
73
|
+
session.add_argument("--service", help='anchor the amount to a service, e.g. "USPS Ground Advantage"')
|
|
74
|
+
session.set_defaults(run=_cmd_session)
|
|
75
|
+
|
|
76
|
+
session_status = commands.add_parser("session-status", parents=[common], help="where a checkout stands")
|
|
77
|
+
session_status.add_argument("session_id", help="session_id from `smklog session`")
|
|
78
|
+
session_status.set_defaults(run=_cmd_session_status)
|
|
79
|
+
|
|
80
|
+
status = commands.add_parser("status", parents=[common], help="liveness and the serving version")
|
|
81
|
+
status.set_defaults(run=_cmd_status)
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_quote(client: SmklogClient, args: argparse.Namespace) -> int:
|
|
86
|
+
if (args.dims is None) != (args.kg is None):
|
|
87
|
+
raise SmklogError(0, "invalid_request", "--dims and --kg go together.")
|
|
88
|
+
fields: Dict[str, Any] = {
|
|
89
|
+
"product": args.product,
|
|
90
|
+
"from_postal_code": args.from_zip,
|
|
91
|
+
"to_postal_code": args.to_zip,
|
|
92
|
+
"quantity": args.qty,
|
|
93
|
+
}
|
|
94
|
+
# US is the API's default, so it is only sent when the caller asked for elsewhere.
|
|
95
|
+
if args.country.upper() != "US":
|
|
96
|
+
fields["to_country_code"] = args.country.upper()
|
|
97
|
+
if args.dims is not None:
|
|
98
|
+
fields.update(zip(("length_cm", "width_cm", "height_cm"), args.dims))
|
|
99
|
+
fields["weight_kg"] = args.kg
|
|
100
|
+
quote = client.get_parcel_quote(**fields)
|
|
101
|
+
if args.json:
|
|
102
|
+
_print_json(quote.raw)
|
|
103
|
+
else:
|
|
104
|
+
_print_quote(quote)
|
|
105
|
+
return 0 if quote.is_priced() else 2
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cmd_session(client: SmklogClient, args: argparse.Namespace) -> int:
|
|
109
|
+
fields: Dict[str, Any] = {"quote_id": args.quote_id}
|
|
110
|
+
if args.service:
|
|
111
|
+
fields["service"] = args.service
|
|
112
|
+
session = client.create_payment_session(**fields)
|
|
113
|
+
if args.json:
|
|
114
|
+
_print_json(session.raw)
|
|
115
|
+
else:
|
|
116
|
+
_print_session(session)
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_session_status(client: SmklogClient, args: argparse.Namespace) -> int:
|
|
121
|
+
status = client.get_checkout_status(args.session_id)
|
|
122
|
+
if args.json:
|
|
123
|
+
_print_json(status.raw)
|
|
124
|
+
else:
|
|
125
|
+
_print_session_status(status)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cmd_status(client: SmklogClient, args: argparse.Namespace) -> int:
|
|
130
|
+
liveness = client.status()
|
|
131
|
+
if args.json:
|
|
132
|
+
_print_json(liveness.raw)
|
|
133
|
+
else:
|
|
134
|
+
_print_rows([
|
|
135
|
+
("ok", _yes_no(liveness.ok)),
|
|
136
|
+
("service", liveness.service),
|
|
137
|
+
("version", liveness.version),
|
|
138
|
+
("time", liveness.time),
|
|
139
|
+
])
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _print_quote(quote: QuoteResponse) -> None:
|
|
144
|
+
if quote.identified_product_title:
|
|
145
|
+
print(quote.identified_product_title)
|
|
146
|
+
box = quote.package
|
|
147
|
+
if box is not None and box.length_cm is not None:
|
|
148
|
+
origin = "exact" if box.confidence == "manual" else "estimate, %s confidence" % (box.confidence or "unknown")
|
|
149
|
+
print("Box: %s x %s x %s cm, %s kg (%s)" % (
|
|
150
|
+
_num(box.length_cm), _num(box.width_cm), _num(box.height_cm), _num(box.weight_kg), origin))
|
|
151
|
+
if not quote.is_priced():
|
|
152
|
+
print("No price: %s. %s" % (quote.mode, quote.notice or "The service did not price this shipment."))
|
|
153
|
+
else:
|
|
154
|
+
rows = [("Carrier", "Service", "Checkout", "Counter", "Delivery")]
|
|
155
|
+
for rate in quote.rates:
|
|
156
|
+
rows.append((rate.carrier or "", rate.service or "", _money(rate.amount), _money(rate.retail_amount), _delivery(rate)))
|
|
157
|
+
_print_table(rows)
|
|
158
|
+
if quote.quote_id:
|
|
159
|
+
# On its own line so it can be copied straight into `smklog session --quote-id`.
|
|
160
|
+
print("quote_id %s" % quote.quote_id)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _print_session(session: CheckoutSession) -> None:
|
|
164
|
+
_print_rows([
|
|
165
|
+
("session_id", session.session_id),
|
|
166
|
+
("amount", _money(session.amount, session.currency)),
|
|
167
|
+
("service", session.service),
|
|
168
|
+
("quote_reused", _yes_no(session.quote_reused)),
|
|
169
|
+
("handoff_url", session.handoff_url),
|
|
170
|
+
("note", session.note),
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _print_session_status(status: CheckoutStatus) -> None:
|
|
175
|
+
checkout = status.checkout
|
|
176
|
+
rows: List[Tuple[str, Any]] = [
|
|
177
|
+
("session_id", status.session_id),
|
|
178
|
+
("status", status.status),
|
|
179
|
+
("amount", _money(status.amount, status.currency)),
|
|
180
|
+
("service", status.service),
|
|
181
|
+
("expires_at", status.expires_at),
|
|
182
|
+
]
|
|
183
|
+
if checkout is not None:
|
|
184
|
+
carrier = " ".join(part for part in (checkout.carrier, checkout.service) if part)
|
|
185
|
+
rows += [
|
|
186
|
+
("order_id", checkout.order_id),
|
|
187
|
+
("paid", _yes_no(checkout.paid)),
|
|
188
|
+
("label_ready", _yes_no(checkout.label_ready)),
|
|
189
|
+
("refunded", _yes_no(checkout.refunded)),
|
|
190
|
+
("carrier", carrier or None),
|
|
191
|
+
("tracking_number", checkout.tracking_number),
|
|
192
|
+
("tracking_status", checkout.tracking_status),
|
|
193
|
+
("estimated_delivery", checkout.estimated_delivery),
|
|
194
|
+
("delivered_at", checkout.delivered_at),
|
|
195
|
+
]
|
|
196
|
+
rows.append(("next", status.next))
|
|
197
|
+
_print_rows(rows)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _print_problem(exc: SmklogError, as_json: bool) -> None:
|
|
201
|
+
if as_json:
|
|
202
|
+
_print_json(exc.problem())
|
|
203
|
+
return
|
|
204
|
+
line = "error: %s" % exc.error
|
|
205
|
+
if exc.status:
|
|
206
|
+
line += " (HTTP %d)" % exc.status
|
|
207
|
+
if exc.message:
|
|
208
|
+
line += ": " + exc.message
|
|
209
|
+
print(line, file=sys.stderr)
|
|
210
|
+
if exc.missing:
|
|
211
|
+
print("missing: " + ", ".join(exc.missing), file=sys.stderr)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _print_json(data: Any) -> None:
|
|
215
|
+
print(json.dumps(data, indent=2))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _print_rows(rows: Sequence[Tuple[str, Any]]) -> None:
|
|
219
|
+
present = [(key, value) for key, value in rows if value is not None and value != ""]
|
|
220
|
+
width = max(len(key) for key, _ in present) if present else 0
|
|
221
|
+
for key, value in present:
|
|
222
|
+
print("%-*s %s" % (width, key, value))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _print_table(rows: Sequence[Sequence[str]]) -> None:
|
|
226
|
+
widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))]
|
|
227
|
+
for row in rows:
|
|
228
|
+
print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _delivery(rate: Any) -> str:
|
|
232
|
+
parts = []
|
|
233
|
+
if rate.delivery_window:
|
|
234
|
+
parts.append(str(rate.delivery_window))
|
|
235
|
+
elif rate.delivery_days is not None:
|
|
236
|
+
parts.append("%s day%s" % (rate.delivery_days, "" if rate.delivery_days == 1 else "s"))
|
|
237
|
+
if rate.delivery_estimated_date:
|
|
238
|
+
parts.append("by %s" % rate.delivery_estimated_date)
|
|
239
|
+
return ", ".join(parts)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _money(amount: Optional[Number], currency: Optional[str] = None) -> str:
|
|
243
|
+
if amount is None:
|
|
244
|
+
return ""
|
|
245
|
+
text = "$%.2f" % amount
|
|
246
|
+
return text + " " + currency if currency and currency != "USD" else text
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _num(value: Optional[Number]) -> str:
|
|
250
|
+
if value is None:
|
|
251
|
+
return "?"
|
|
252
|
+
return str(int(value)) if float(value).is_integer() else str(value)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _yes_no(value: Optional[bool]) -> Optional[str]:
|
|
256
|
+
return None if value is None else ("yes" if value else "no")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _number(text: str) -> Number:
|
|
260
|
+
value: Number
|
|
261
|
+
try:
|
|
262
|
+
# Whole numbers stay integers so the wire shows 30, not 30.0.
|
|
263
|
+
value = int(text)
|
|
264
|
+
except ValueError:
|
|
265
|
+
try:
|
|
266
|
+
value = float(text)
|
|
267
|
+
except ValueError:
|
|
268
|
+
raise argparse.ArgumentTypeError("%r is not a number" % text)
|
|
269
|
+
if value <= 0:
|
|
270
|
+
raise argparse.ArgumentTypeError("%r must be above zero" % text)
|
|
271
|
+
return value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _dims(text: str) -> Tuple[Number, Number, Number]:
|
|
275
|
+
parts = text.lower().split("x")
|
|
276
|
+
if len(parts) != 3:
|
|
277
|
+
raise argparse.ArgumentTypeError("--dims wants LxWxH in cm, e.g. 30x22x12")
|
|
278
|
+
length, width, height = (_number(part) for part in parts)
|
|
279
|
+
return length, width, height
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
sys.exit(main())
|
smklog/client.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The client: four operations over urllib, nothing else."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from http.client import HTTPException
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .errors import InvalidRequestError, SmklogError, error_for
|
|
14
|
+
from .models import CheckoutSession, CheckoutStatus, Liveness, Model, QuoteResponse
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://quote-api.smklog.com"
|
|
17
|
+
# A quote can take 10-20 s while carriers answer, so the default is generous.
|
|
18
|
+
DEFAULT_TIMEOUT = 30.0
|
|
19
|
+
COUNTRIES = ("US", "CA", "GB", "DE", "AU")
|
|
20
|
+
MEASUREMENTS = ("length_cm", "width_cm", "height_cm", "weight_kg")
|
|
21
|
+
# One pause per retry, GETs only. POST /quote spends a live carrier rating and
|
|
22
|
+
# POST /agent/checkout-session opens a session, so neither is ever repeated by the SDK.
|
|
23
|
+
RETRY_BACKOFF_SECONDS = (0.5, 1.0)
|
|
24
|
+
|
|
25
|
+
_ZIP = re.compile(r"^[0-9]{5}$")
|
|
26
|
+
RequestLike = Union[Model, Mapping[str, Any], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SmklogClient:
|
|
30
|
+
"""Talks to the SMKlog Quote API. No key needed; ``token`` is the optional OAuth
|
|
31
|
+
bearer that buys a client its own rate-limit bucket."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
36
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
37
|
+
token: Optional[str] = None,
|
|
38
|
+
user_agent: Optional[str] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.base_url = base_url.rstrip("/")
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.token = token
|
|
43
|
+
self.user_agent = "smklog-sdk-python/" + __version__
|
|
44
|
+
if user_agent:
|
|
45
|
+
self.user_agent += " " + user_agent
|
|
46
|
+
|
|
47
|
+
def get_parcel_quote(self, request: RequestLike = None, **fields: Any) -> QuoteResponse:
|
|
48
|
+
"""POST /quote. Pass a QuoteRequest, a dict, or the fields as keywords.
|
|
49
|
+
|
|
50
|
+
Costs a live carrier rating on the server: one call per shipment, never a poll.
|
|
51
|
+
"""
|
|
52
|
+
body = _prepare_quote(_merge(request, fields))
|
|
53
|
+
return QuoteResponse.from_dict(self._request("POST", "/quote", body))
|
|
54
|
+
|
|
55
|
+
def create_payment_session(self, request: RequestLike = None, **fields: Any) -> CheckoutSession:
|
|
56
|
+
"""POST /agent/checkout-session. Give a ``quote_id`` (preferred: the quote is
|
|
57
|
+
reused, no second carrier call) or the shipment itself; the SDK does not turn
|
|
58
|
+
one into the other. A ``quote_id`` older than 15 minutes makes the API price
|
|
59
|
+
the shipment fields live, so send them along when the quote may have aged."""
|
|
60
|
+
body = _prepare_session(_merge(request, fields))
|
|
61
|
+
return CheckoutSession.from_dict(self._request("POST", "/agent/checkout-session", body))
|
|
62
|
+
|
|
63
|
+
def get_checkout_status(self, session_id: str) -> CheckoutStatus:
|
|
64
|
+
"""GET /agent/checkout-session/{session_id}."""
|
|
65
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
66
|
+
raise InvalidRequestError(0, "invalid_request", "session_id must be a non-empty string.")
|
|
67
|
+
path = "/agent/checkout-session/" + urllib.parse.quote(session_id, safe="")
|
|
68
|
+
return CheckoutStatus.from_dict(self._request("GET", path, retry=True))
|
|
69
|
+
|
|
70
|
+
def status(self) -> Liveness:
|
|
71
|
+
"""GET /status: liveness and the serving version."""
|
|
72
|
+
return Liveness.from_dict(self._request("GET", "/status", retry=True))
|
|
73
|
+
|
|
74
|
+
def _request(
|
|
75
|
+
self, method: str, path: str, body: Optional[Dict[str, Any]] = None, retry: bool = False
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
attempts = 1 + (len(RETRY_BACKOFF_SECONDS) if retry else 0)
|
|
78
|
+
for attempt in range(attempts):
|
|
79
|
+
try:
|
|
80
|
+
return self._send(method, path, body)
|
|
81
|
+
except SmklogError as exc:
|
|
82
|
+
# Only a dropped connection or a server-side failure is worth a second
|
|
83
|
+
# try; a 429 is deliberately excluded (retrying burns the bucket faster).
|
|
84
|
+
transient = exc.status == 0 or exc.status >= 500
|
|
85
|
+
if not transient or attempt + 1 == attempts:
|
|
86
|
+
raise
|
|
87
|
+
time.sleep(RETRY_BACKOFF_SECONDS[attempt])
|
|
88
|
+
raise AssertionError("unreachable")
|
|
89
|
+
|
|
90
|
+
def _send(self, method: str, path: str, body: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
91
|
+
url = self.base_url + path
|
|
92
|
+
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
|
93
|
+
data = None
|
|
94
|
+
if body is not None:
|
|
95
|
+
data = json.dumps(body).encode("utf-8")
|
|
96
|
+
headers["Content-Type"] = "application/json"
|
|
97
|
+
if self.token:
|
|
98
|
+
headers["Authorization"] = "Bearer " + self.token
|
|
99
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
100
|
+
try:
|
|
101
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
102
|
+
status, raw = response.status, response.read()
|
|
103
|
+
except urllib.error.HTTPError as exc:
|
|
104
|
+
status, raw = exc.code, exc.read()
|
|
105
|
+
except (OSError, HTTPException) as exc:
|
|
106
|
+
# URLError, socket timeouts and connection resets are all OSError.
|
|
107
|
+
raise SmklogError(0, "network_error", "%s %s: %s" % (method, url, exc)) from exc
|
|
108
|
+
parsed = _parse_json(raw)
|
|
109
|
+
if 200 <= status < 300:
|
|
110
|
+
if not isinstance(parsed, dict):
|
|
111
|
+
raise SmklogError(status, "unexpected_response", "HTTP %d with a non-JSON body" % status, body=_text(raw))
|
|
112
|
+
return parsed
|
|
113
|
+
raise error_for(status, parsed if parsed is not None else _text(raw))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_json(raw: bytes) -> Any:
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(raw.decode("utf-8"))
|
|
119
|
+
except (UnicodeDecodeError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _text(raw: bytes) -> str:
|
|
124
|
+
return raw.decode("utf-8", "replace")[:500]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _merge(request: RequestLike, fields: Dict[str, Any]) -> Dict[str, Any]:
|
|
128
|
+
if request is None:
|
|
129
|
+
body: Dict[str, Any] = {}
|
|
130
|
+
elif isinstance(request, Model):
|
|
131
|
+
body = request.to_dict()
|
|
132
|
+
elif isinstance(request, Mapping):
|
|
133
|
+
body = dict(request)
|
|
134
|
+
else:
|
|
135
|
+
raise TypeError("request must be a model, a dict or None, got %s" % type(request).__name__)
|
|
136
|
+
body.update(fields)
|
|
137
|
+
# A None means "not given", so it never reaches the wire as null.
|
|
138
|
+
return {name: value for name, value in body.items() if value is not None}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _reject(message: str, code: str = "invalid_request", missing: Optional[list] = None) -> InvalidRequestError:
|
|
142
|
+
return InvalidRequestError(0, code, message, missing)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _prepare_quote(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
146
|
+
"""Local checks that save a request the server would refuse anyway."""
|
|
147
|
+
missing = [name for name in ("from_postal_code", "to_postal_code") if not body.get(name)]
|
|
148
|
+
if not body.get("product") and not body.get("product_url"):
|
|
149
|
+
missing.append("product")
|
|
150
|
+
if missing:
|
|
151
|
+
raise _reject("Required: " + ", ".join(missing) + ".", "missing_required_fields", missing)
|
|
152
|
+
if not _is_zip(body["from_postal_code"]):
|
|
153
|
+
raise _reject("from_postal_code must be a 5-digit US ZIP, got %r." % (body["from_postal_code"],))
|
|
154
|
+
country = body.get("to_country_code", "US")
|
|
155
|
+
if country not in COUNTRIES:
|
|
156
|
+
raise _reject("to_country_code must be one of %s, got %r." % (", ".join(COUNTRIES), country))
|
|
157
|
+
if country == "US" and not _is_zip(body["to_postal_code"]):
|
|
158
|
+
raise _reject("to_postal_code must be a 5-digit US ZIP for a US destination, got %r." % (body["to_postal_code"],))
|
|
159
|
+
body.setdefault("quantity", 1)
|
|
160
|
+
_check_quantity(body["quantity"])
|
|
161
|
+
given = [name for name in MEASUREMENTS if name in body]
|
|
162
|
+
if given and len(given) != len(MEASUREMENTS):
|
|
163
|
+
absent = [name for name in MEASUREMENTS if name not in body]
|
|
164
|
+
raise _reject("length_cm, width_cm, height_cm and weight_kg go together; missing " + ", ".join(absent) + ".")
|
|
165
|
+
if given:
|
|
166
|
+
for name in MEASUREMENTS:
|
|
167
|
+
if not _is_positive_number(body[name]):
|
|
168
|
+
raise _reject("%s must be a positive number, got %r." % (name, body[name]))
|
|
169
|
+
# Exact measurements are only honored when the request says so.
|
|
170
|
+
body.setdefault("package_source", "manual")
|
|
171
|
+
return body
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _prepare_session(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
175
|
+
if body.get("quote_id"):
|
|
176
|
+
if not isinstance(body["quote_id"], str):
|
|
177
|
+
raise _reject("quote_id must be a string, got %r." % (body["quote_id"],))
|
|
178
|
+
else:
|
|
179
|
+
missing = [name for name in ("product", "from_zip", "to_zip") if not body.get(name)]
|
|
180
|
+
if missing:
|
|
181
|
+
raise _reject("Without quote_id the shipment itself is required: " + ", ".join(missing) + ".",
|
|
182
|
+
"missing_required_fields", missing)
|
|
183
|
+
if not _is_zip(body["from_zip"]):
|
|
184
|
+
raise _reject("from_zip must be a 5-digit US ZIP, got %r." % (body["from_zip"],))
|
|
185
|
+
country = body.get("to_country", "US")
|
|
186
|
+
if country not in COUNTRIES:
|
|
187
|
+
raise _reject("to_country must be one of %s, got %r." % (", ".join(COUNTRIES), country))
|
|
188
|
+
if country == "US" and not _is_zip(body["to_zip"]):
|
|
189
|
+
raise _reject("to_zip must be a 5-digit US ZIP for a US destination, got %r." % (body["to_zip"],))
|
|
190
|
+
if "quantity" in body:
|
|
191
|
+
_check_quantity(body["quantity"])
|
|
192
|
+
return body
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _check_quantity(value: Any) -> None:
|
|
196
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
197
|
+
raise _reject("quantity must be a whole number of 1 or more, got %r." % (value,))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_zip(value: Any) -> bool:
|
|
201
|
+
return isinstance(value, str) and bool(_ZIP.match(value))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_positive_number(value: Any) -> bool:
|
|
205
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
|
smklog/errors.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The error taxonomy: one base exception and four subclasses picked by the Problem code."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Sequence, Type
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SmklogError(Exception):
|
|
7
|
+
"""Any failure the client raises.
|
|
8
|
+
|
|
9
|
+
``status`` is the HTTP status, or 0 when no HTTP exchange happened: a network
|
|
10
|
+
failure or timeout (``error == "network_error"``) or a request the client refused
|
|
11
|
+
locally before spending a call. ``error`` is the stable code from the Problem body,
|
|
12
|
+
``message`` its sentence, ``missing`` the field names it asked for (often empty)
|
|
13
|
+
and ``body`` the response body as received (a dict when it was JSON).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
status: int,
|
|
19
|
+
error: str,
|
|
20
|
+
message: str = "",
|
|
21
|
+
missing: Optional[Sequence[str]] = None,
|
|
22
|
+
body: Any = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.status = status
|
|
25
|
+
self.error = error
|
|
26
|
+
self.message = message or ""
|
|
27
|
+
self.missing = [str(name) for name in (missing or [])]
|
|
28
|
+
self.body = body
|
|
29
|
+
super().__init__(str(self))
|
|
30
|
+
|
|
31
|
+
def __reduce__(self):
|
|
32
|
+
# Exception's default pickling replays ``args`` (the rendered text) into
|
|
33
|
+
# __init__, which wants the five fields; rebuild from them instead so an
|
|
34
|
+
# error crosses a multiprocessing boundary intact.
|
|
35
|
+
return (type(self), (self.status, self.error, self.message, self.missing, self.body))
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
text = self.error
|
|
39
|
+
if self.status:
|
|
40
|
+
text += " (HTTP %d)" % self.status
|
|
41
|
+
if self.message:
|
|
42
|
+
text += ": " + self.message
|
|
43
|
+
if self.missing:
|
|
44
|
+
text += " [missing: " + ", ".join(self.missing) + "]"
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
def problem(self) -> Dict[str, Any]:
|
|
48
|
+
"""The Problem shape for this error, rebuilt when the server sent none."""
|
|
49
|
+
if isinstance(self.body, dict) and "error" in self.body:
|
|
50
|
+
return dict(self.body)
|
|
51
|
+
out: Dict[str, Any] = {"error": self.error, "message": self.message}
|
|
52
|
+
if self.missing:
|
|
53
|
+
out["missing"] = list(self.missing)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RateLimitedError(SmklogError):
|
|
58
|
+
"""429: the hourly bucket is spent. The SDK never retries this; wait, then decide."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvalidRequestError(SmklogError):
|
|
62
|
+
"""The request cannot be served as written: missing fields, bad JSON, an item or
|
|
63
|
+
URL the estimator could not recognize, or a value this client rejected locally."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NotFoundError(SmklogError):
|
|
67
|
+
"""404: no payment session with that id (sessions answer for 30 days)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SessionFailedError(SmklogError):
|
|
71
|
+
"""422: the shipment cannot become a checkout (past parcel limits, restricted,
|
|
72
|
+
more than one box, or unpriceable)."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
ERROR_TYPES: Dict[str, Type[SmklogError]] = {
|
|
76
|
+
"rate_limited": RateLimitedError,
|
|
77
|
+
"slow_down": RateLimitedError,
|
|
78
|
+
"product_not_recognized": InvalidRequestError,
|
|
79
|
+
"product_url_not_detected": InvalidRequestError,
|
|
80
|
+
"missing_required_fields": InvalidRequestError,
|
|
81
|
+
"invalid_request": InvalidRequestError,
|
|
82
|
+
"invalid_json": InvalidRequestError,
|
|
83
|
+
"session_not_found": NotFoundError,
|
|
84
|
+
"session_failed": SessionFailedError,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def error_for(status: int, body: Any) -> SmklogError:
|
|
89
|
+
"""Builds the error for a non-2xx answer; the Problem's code picks the type.
|
|
90
|
+
|
|
91
|
+
A 429 without a Problem body (an edge rate limit answers in HTML) still becomes a
|
|
92
|
+
RateLimitedError, because the caller's reaction is the same either way. A Problem
|
|
93
|
+
may carry no sentence (missing_required_fields lists fields instead), so the
|
|
94
|
+
message is then the code plus the missing fields and a log line still says what
|
|
95
|
+
went wrong.
|
|
96
|
+
"""
|
|
97
|
+
problem = body if isinstance(body, dict) else {}
|
|
98
|
+
code = problem.get("error")
|
|
99
|
+
if not isinstance(code, str) or not code:
|
|
100
|
+
code = "rate_limited" if status == 429 else "unexpected_response"
|
|
101
|
+
missing = problem.get("missing")
|
|
102
|
+
if not isinstance(missing, list):
|
|
103
|
+
missing = None
|
|
104
|
+
message = problem.get("message")
|
|
105
|
+
if not isinstance(message, str) or not message:
|
|
106
|
+
if not problem:
|
|
107
|
+
message = "HTTP %d with a non-JSON body" % status
|
|
108
|
+
elif missing:
|
|
109
|
+
message = "%s: missing %s" % (code, ", ".join(str(name) for name in missing))
|
|
110
|
+
else:
|
|
111
|
+
message = code
|
|
112
|
+
return ERROR_TYPES.get(code, SmklogError)(status, code, message, missing, body)
|
smklog/models.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Typed views of the API's JSON shapes, one dataclass per schema in testdata/schemas.
|
|
2
|
+
|
|
3
|
+
Field names are the wire names (snake_case) so a model reads like the JSON it came
|
|
4
|
+
from. The API adds fields without a version bump, so ``from_dict`` never drops
|
|
5
|
+
anything: names this SDK does not know land in ``extra``, and ``raw`` keeps the body
|
|
6
|
+
exactly as received.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field, fields
|
|
10
|
+
from typing import Any, Callable, ClassVar, Dict, List, Optional, Type, TypeVar
|
|
11
|
+
|
|
12
|
+
M = TypeVar("M", bound="Model")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Model:
|
|
17
|
+
"""Shared behavior: build from a dict, serialize back, keep unknown fields."""
|
|
18
|
+
|
|
19
|
+
# init=False keeps these off the constructor so subclasses may still declare
|
|
20
|
+
# ordinary fields after them (dataclasses only order the init parameters).
|
|
21
|
+
extra: Dict[str, Any] = field(default_factory=dict, init=False, compare=False)
|
|
22
|
+
raw: Dict[str, Any] = field(default_factory=dict, init=False, repr=False, compare=False)
|
|
23
|
+
|
|
24
|
+
# Field name -> converter for nested shapes; subclasses override.
|
|
25
|
+
_nested: ClassVar[Dict[str, Callable[[Any], Any]]] = {}
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls: Type[M], data: Dict[str, Any]) -> M:
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise TypeError("%s.from_dict expects a dict, got %s" % (cls.__name__, type(data).__name__))
|
|
31
|
+
known = {f.name for f in fields(cls) if f.init}
|
|
32
|
+
values = {}
|
|
33
|
+
for name, value in data.items():
|
|
34
|
+
if name not in known:
|
|
35
|
+
continue
|
|
36
|
+
convert = cls._nested.get(name)
|
|
37
|
+
values[name] = convert(value) if convert and value is not None else value
|
|
38
|
+
model = cls(**values)
|
|
39
|
+
model.extra = {name: value for name, value in data.items() if name not in known}
|
|
40
|
+
model.raw = dict(data)
|
|
41
|
+
return model
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
44
|
+
"""JSON-ready dict: the fields that are set, nested models flattened, extra merged."""
|
|
45
|
+
out: Dict[str, Any] = {}
|
|
46
|
+
for f in fields(self):
|
|
47
|
+
if not f.init:
|
|
48
|
+
continue
|
|
49
|
+
value = getattr(self, f.name)
|
|
50
|
+
if value is not None:
|
|
51
|
+
out[f.name] = _plain(value)
|
|
52
|
+
out.update(self.extra)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _plain(value: Any) -> Any:
|
|
57
|
+
if isinstance(value, Model):
|
|
58
|
+
return value.to_dict()
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return [_plain(item) for item in value]
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _list_of(model: Type[M]) -> Callable[[Any], Any]:
|
|
65
|
+
# A list item that is not an object is passed through untouched rather than
|
|
66
|
+
# failing the whole response: the typed view is a convenience, not a validator.
|
|
67
|
+
def convert(items: Any) -> Any:
|
|
68
|
+
if not isinstance(items, list):
|
|
69
|
+
return items
|
|
70
|
+
return [model.from_dict(item) if isinstance(item, dict) else item for item in items]
|
|
71
|
+
|
|
72
|
+
return convert
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _one(model: Type[M]) -> Callable[[Any], Any]:
|
|
76
|
+
def convert(item: Any) -> Any:
|
|
77
|
+
return model.from_dict(item) if isinstance(item, dict) else item
|
|
78
|
+
|
|
79
|
+
return convert
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class QuoteRequest(Model):
|
|
84
|
+
"""One shipment to price (quote-request.schema.json)."""
|
|
85
|
+
|
|
86
|
+
product: Optional[str] = None
|
|
87
|
+
from_postal_code: Optional[str] = None
|
|
88
|
+
to_postal_code: Optional[str] = None
|
|
89
|
+
quantity: int = 1
|
|
90
|
+
to_country_code: Optional[str] = None
|
|
91
|
+
product_url: Optional[str] = None
|
|
92
|
+
length_cm: Optional[float] = None
|
|
93
|
+
width_cm: Optional[float] = None
|
|
94
|
+
height_cm: Optional[float] = None
|
|
95
|
+
weight_kg: Optional[float] = None
|
|
96
|
+
package_source: Optional[str] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Package(Model):
|
|
101
|
+
"""The packed box used for pricing (package.schema.json)."""
|
|
102
|
+
|
|
103
|
+
quantity: Optional[int] = None
|
|
104
|
+
length_cm: Optional[float] = None
|
|
105
|
+
width_cm: Optional[float] = None
|
|
106
|
+
height_cm: Optional[float] = None
|
|
107
|
+
weight_kg: Optional[float] = None
|
|
108
|
+
description: Optional[str] = None
|
|
109
|
+
confidence: Optional[str] = None
|
|
110
|
+
assumptions: Optional[List[str]] = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Rate(Model):
|
|
115
|
+
"""One purchasable service (rate.schema.json).
|
|
116
|
+
|
|
117
|
+
``amount`` is the checkout total the customer pays, SMKlog fee inside;
|
|
118
|
+
``retail_amount`` is the carrier's own counter price (0 when not published).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
carrier: Optional[str] = None
|
|
122
|
+
service: Optional[str] = None
|
|
123
|
+
service_code: Optional[str] = None
|
|
124
|
+
amount: Optional[float] = None
|
|
125
|
+
retail_amount: Optional[float] = None
|
|
126
|
+
delivery_days: Optional[int] = None
|
|
127
|
+
delivery_window: Optional[str] = None
|
|
128
|
+
delivery_estimated_date: Optional[str] = None
|
|
129
|
+
currency: Optional[str] = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class QuoteResponse(Model):
|
|
134
|
+
"""A priced parcel, or the reason it was not priced (quote-response.schema.json).
|
|
135
|
+
|
|
136
|
+
``quote_id`` is the handle the API keeps for 15 minutes: pass it to
|
|
137
|
+
``create_payment_session`` and the checkout reuses these rates instead of
|
|
138
|
+
spending a second carrier call. It is None when nothing was priced, when the
|
|
139
|
+
handle could not be stored, or when the answer carried no rates.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
mode: Optional[str] = None
|
|
143
|
+
identified_product_title: Optional[str] = None
|
|
144
|
+
quote_id: Optional[str] = None
|
|
145
|
+
package: Optional[Package] = None
|
|
146
|
+
packages: Optional[List[Package]] = None
|
|
147
|
+
rates: List[Rate] = field(default_factory=list)
|
|
148
|
+
dangerous_goods: Optional[Dict[str, Any]] = None
|
|
149
|
+
sources: Optional[List[Dict[str, Any]]] = None
|
|
150
|
+
notice: Optional[str] = None
|
|
151
|
+
source: Optional[Dict[str, Any]] = None
|
|
152
|
+
|
|
153
|
+
_nested: ClassVar[Dict[str, Callable[[Any], Any]]] = {
|
|
154
|
+
"package": _one(Package),
|
|
155
|
+
"packages": _list_of(Package),
|
|
156
|
+
"rates": _list_of(Rate),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def __post_init__(self) -> None:
|
|
160
|
+
# A body with "rates": null is an unpriced answer, so the typed view reads
|
|
161
|
+
# as an empty list; ``raw`` still shows the null the API sent.
|
|
162
|
+
if self.rates is None:
|
|
163
|
+
self.rates = []
|
|
164
|
+
|
|
165
|
+
def is_priced(self) -> bool:
|
|
166
|
+
"""True only when the shipment can be bought: a label-ready mode with a rate."""
|
|
167
|
+
return self.mode == "parcel_label_ready" and bool(self.rates)
|
|
168
|
+
|
|
169
|
+
def cheapest(self) -> Optional[Rate]:
|
|
170
|
+
"""The rate with the lowest checkout total; None when nothing was priced."""
|
|
171
|
+
priced = [rate for rate in self.rates or [] if isinstance(rate, Rate) and rate.amount is not None]
|
|
172
|
+
return min(priced, key=lambda rate: rate.amount) if priced else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class CheckoutSessionRequest(Model):
|
|
177
|
+
"""What to open a checkout for: a quote_id, or the shipment itself
|
|
178
|
+
(checkout-session-request.schema.json)."""
|
|
179
|
+
|
|
180
|
+
quote_id: Optional[str] = None
|
|
181
|
+
service: Optional[str] = None
|
|
182
|
+
product: Optional[str] = None
|
|
183
|
+
from_zip: Optional[str] = None
|
|
184
|
+
to_zip: Optional[str] = None
|
|
185
|
+
to_country: Optional[str] = None
|
|
186
|
+
quantity: Optional[int] = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class CheckoutSession(Model):
|
|
191
|
+
"""A payment session, not a charge (checkout-session.schema.json)."""
|
|
192
|
+
|
|
193
|
+
intent: Optional[str] = None
|
|
194
|
+
session_id: Optional[str] = None
|
|
195
|
+
method: Optional[List[str]] = None
|
|
196
|
+
amount: Optional[float] = None
|
|
197
|
+
currency: Optional[str] = None
|
|
198
|
+
service: Optional[str] = None
|
|
199
|
+
handoff_url: Optional[str] = None
|
|
200
|
+
identified_product_title: Optional[str] = None
|
|
201
|
+
quote_reused: Optional[bool] = None
|
|
202
|
+
note: Optional[str] = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class Checkout(Model):
|
|
207
|
+
"""The checkout a human started, nested in CheckoutStatus."""
|
|
208
|
+
|
|
209
|
+
order_id: Optional[str] = None
|
|
210
|
+
started_at: Optional[str] = None
|
|
211
|
+
paid: Optional[bool] = None
|
|
212
|
+
label_ready: Optional[bool] = None
|
|
213
|
+
refunded: Optional[bool] = None
|
|
214
|
+
carrier: Optional[str] = None
|
|
215
|
+
service: Optional[str] = None
|
|
216
|
+
tracking_number: Optional[str] = None
|
|
217
|
+
tracking_status: Optional[str] = None
|
|
218
|
+
estimated_delivery: Optional[str] = None
|
|
219
|
+
delivered_at: Optional[str] = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class CheckoutStatus(Model):
|
|
224
|
+
"""Where a checkout stands (checkout-status.schema.json)."""
|
|
225
|
+
|
|
226
|
+
session_id: Optional[str] = None
|
|
227
|
+
status: Optional[str] = None
|
|
228
|
+
created_at: Optional[str] = None
|
|
229
|
+
expires_at: Optional[str] = None
|
|
230
|
+
amount: Optional[float] = None
|
|
231
|
+
currency: Optional[str] = None
|
|
232
|
+
service: Optional[str] = None
|
|
233
|
+
quote_reused: Optional[bool] = None
|
|
234
|
+
checkout: Optional[Checkout] = None
|
|
235
|
+
next: Optional[str] = None
|
|
236
|
+
|
|
237
|
+
_nested: ClassVar[Dict[str, Callable[[Any], Any]]] = {"checkout": _one(Checkout)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class Liveness(Model):
|
|
242
|
+
"""The liveness answer (liveness.schema.json)."""
|
|
243
|
+
|
|
244
|
+
ok: Optional[bool] = None
|
|
245
|
+
service: Optional[str] = None
|
|
246
|
+
version: Optional[str] = None
|
|
247
|
+
time: Optional[str] = None
|
|
248
|
+
last_estimate_race: Optional[Dict[str, Any]] = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class Problem(Model):
|
|
253
|
+
"""Every error body on the host (problem.schema.json)."""
|
|
254
|
+
|
|
255
|
+
error: Optional[str] = None
|
|
256
|
+
message: Optional[str] = None
|
|
257
|
+
missing: List[str] = field(default_factory=list)
|
smklog/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smklog-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client and command line for the SMKlog Quote API: live USPS, UPS, FedEx and DHL parcel rates from a US origin, a checkout handed to a human, and where it stands.
|
|
5
|
+
Author-email: SMKlog <info@smklog.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://smklog.com/developers/
|
|
8
|
+
Project-URL: Documentation, https://smklog.com/developers/
|
|
9
|
+
Project-URL: Repository, https://github.com/smklog/smklog-sdk
|
|
10
|
+
Project-URL: Changelog, https://github.com/smklog/smklog-sdk/releases
|
|
11
|
+
Project-URL: OpenAPI, https://quote-api.smklog.com/openapi.json
|
|
12
|
+
Project-URL: Status page, https://smklog.com/developers/status
|
|
13
|
+
Keywords: smklog,shipping,parcel,rates,usps,ups,fedex,dhl,labels
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# smklog-sdk (Python)
|
|
33
|
+
|
|
34
|
+
Official Python client and command line for the [SMKlog Quote API](https://smklog.com/developers/):
|
|
35
|
+
live USPS, UPS, FedEx and DHL Express parcel rates from a US origin, a checkout handed
|
|
36
|
+
to a human, and where that checkout stands. No API key, no account, no dependencies
|
|
37
|
+
beyond the standard library. Python 3.9 or newer.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install smklog-sdk
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The distribution is `smklog-sdk`; the import package is `smklog`.
|
|
44
|
+
|
|
45
|
+
## Quickstart
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from smklog import SmklogClient
|
|
49
|
+
|
|
50
|
+
client = SmklogClient()
|
|
51
|
+
quote = client.get_parcel_quote(product="Yamaha FG800 acoustic guitar in a hard case",
|
|
52
|
+
from_postal_code="07102", to_postal_code="30303")
|
|
53
|
+
for rate in quote.rates: # best value first, as the API returns them
|
|
54
|
+
print(rate.carrier, rate.service, rate.amount, "counter", rate.retail_amount)
|
|
55
|
+
if quote.is_priced() and quote.quote_id: # the handle is good for 15 minutes
|
|
56
|
+
best = quote.cheapest()
|
|
57
|
+
session = client.create_payment_session(quote_id=quote.quote_id, service=best.service)
|
|
58
|
+
print(session.handoff_url) # the human confirms and pays here
|
|
59
|
+
if session.session_id: # None only when the record was not stored
|
|
60
|
+
print(client.get_checkout_status(session.session_id).status)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The pair costs one carrier call: the quote answer carries `quote_id`, and a checkout
|
|
64
|
+
opened with it reuses those rates (`session.quote_reused` is true) instead of pricing
|
|
65
|
+
the shipment again.
|
|
66
|
+
|
|
67
|
+
Exact measurements skip the box estimate: pass all four of `length_cm`, `width_cm`,
|
|
68
|
+
`height_cm`, `weight_kg` (the client marks the package `manual`). A request can also be a
|
|
69
|
+
`QuoteRequest` / `CheckoutSessionRequest` model or a plain dict; fields keep the wire names.
|
|
70
|
+
|
|
71
|
+
Every `get_parcel_quote` call spends a live carrier rating on the server: one call per
|
|
72
|
+
shipment, cache on your side, never poll. `create_payment_session` takes one of two forms:
|
|
73
|
+
|
|
74
|
+
- `create_payment_session(quote_id=..., service=...)`: the quote is reused and no second
|
|
75
|
+
carrier call is spent. `quote_id` is the handle every priced quote answer carries; it is
|
|
76
|
+
`None` when nothing was priced or the handle could not be stored. The API ignores any
|
|
77
|
+
shipment fields sent next to a valid `quote_id`.
|
|
78
|
+
- `create_payment_session(product=..., from_zip=..., to_zip=..., to_country=..., quantity=...,
|
|
79
|
+
service=...)`: the API prices the shipment live and opens the checkout in one call.
|
|
80
|
+
|
|
81
|
+
A `quote_id` older than 15 minutes makes the API price the shipment fields live, so send
|
|
82
|
+
them along when the quote may have aged. The client never turns one form into the other.
|
|
83
|
+
|
|
84
|
+
## Client options
|
|
85
|
+
|
|
86
|
+
| Option | Default | Meaning |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| `base_url` | `https://quote-api.smklog.com` | The API host. |
|
|
89
|
+
| `timeout` | `30.0` | Seconds per request; a quote can take 10-20 s while carriers answer. |
|
|
90
|
+
| `token` | `None` | Optional OAuth bearer for a dedicated rate-limit bucket ([auth.md](https://smklog.com/auth.md)); sent as `Authorization: Bearer ...` only when set. |
|
|
91
|
+
| `user_agent` | `None` | Suffix appended after `smklog-sdk-python/0.1.0`. |
|
|
92
|
+
|
|
93
|
+
Operations: `get_parcel_quote(...)` -> `QuoteResponse` (with `quote_id`, `rates`,
|
|
94
|
+
`cheapest()`, `is_priced()`), `create_payment_session(...)` -> `CheckoutSession`,
|
|
95
|
+
`get_checkout_status(session_id)` -> `CheckoutStatus`, `status()` -> `Liveness`.
|
|
96
|
+
|
|
97
|
+
Every model is a dataclass whose fields are the wire names. Fields this version does
|
|
98
|
+
not know about land in `.extra`; `.raw` is the body exactly as received; `.to_dict()`
|
|
99
|
+
serializes back. Only the idempotent GETs (`get_checkout_status`, `status`) are retried,
|
|
100
|
+
at most twice, on network failures and 5xx. POSTs are never retried, and a 429 is never
|
|
101
|
+
retried anywhere: the buckets are hourly (about 80 quotes, 60 status reads, 20 token
|
|
102
|
+
requests per client), so a retry loop only burns them faster.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
Every failure raises `SmklogError` or one of its subclasses, carrying `status` (HTTP
|
|
107
|
+
status, 0 when nothing was sent), `error` (the stable code), `message`, `missing`
|
|
108
|
+
(field names, often empty) and `body` (the response body as received). When the Problem
|
|
109
|
+
carries no `message`, the error's message is the code plus the missing fields
|
|
110
|
+
(`missing_required_fields: missing to_postal_code`). Errors pickle, so they cross a
|
|
111
|
+
`multiprocessing` boundary intact.
|
|
112
|
+
|
|
113
|
+
| Code | Exception |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `rate_limited`, `slow_down` | `RateLimitedError` |
|
|
116
|
+
| `product_not_recognized`, `product_url_not_detected`, `missing_required_fields`, `invalid_request`, `invalid_json` | `InvalidRequestError` |
|
|
117
|
+
| `session_not_found` | `NotFoundError` |
|
|
118
|
+
| `session_failed` | `SessionFailedError` |
|
|
119
|
+
| anything else | `SmklogError` |
|
|
120
|
+
|
|
121
|
+
A network failure or timeout is `SmklogError` with `status == 0` and
|
|
122
|
+
`error == "network_error"`; the native exception is its `__cause__`. Requests the client
|
|
123
|
+
refuses before sending (a 4-digit ZIP, a country outside US/CA/GB/DE/AU, a quantity
|
|
124
|
+
below 1, measurements that do not come as a set of four) raise `InvalidRequestError`
|
|
125
|
+
with `status == 0`.
|
|
126
|
+
|
|
127
|
+
## Command line
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
smklog quote "Yamaha FG800 acoustic guitar in a hard case" 07102 30303 [--country US] [--qty 1] [--dims 112x48x20 --kg 6.4] [--json]
|
|
131
|
+
smklog session --quote-id q_... [--service "USPS Ground Advantage"] [--json]
|
|
132
|
+
smklog session-status as_... [--json]
|
|
133
|
+
smklog status [--json]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Human output is a small table; `--json` prints the raw answer. A priced `smklog quote`
|
|
137
|
+
also prints the `quote_id` on its own line, ready to pass to `smklog session --quote-id`
|
|
138
|
+
within 15 minutes (`--json` carries it in the body):
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
$ smklog quote "Yamaha FG800 acoustic guitar in a hard case" 07102 30303
|
|
142
|
+
Yamaha FG800 acoustic guitar in a hard case
|
|
143
|
+
Box: 112 x 48 x 20 cm, 6.4 kg (estimate, medium confidence)
|
|
144
|
+
Carrier Service Checkout Counter Delivery
|
|
145
|
+
USPS Ground Advantage $38.42 $46.95 2-5 business days
|
|
146
|
+
UPS Ground $41.10 $67.35 3 days, by 2026-10-16
|
|
147
|
+
quote_id q_8f3c2e1a4b7d
|
|
148
|
+
$ smklog session --quote-id q_8f3c2e1a4b7d --service "USPS Ground Advantage"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Exit code 0 on a priced answer or a successful read, 2 when the API refuses (the Problem
|
|
152
|
+
is printed) or answers a quote without a price, 3 on a rate limit, 1 when the host could
|
|
153
|
+
not be reached.
|
|
154
|
+
`--base-url` and `--token` (or `SMKLOG_BASE_URL` / `SMKLOG_TOKEN`) are accepted by every
|
|
155
|
+
command. `python -m smklog` runs the same program.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
cd python
|
|
161
|
+
python3 -m unittest discover -s tests
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The tests replay the vendored examples in `../testdata/` over a local HTTP server and
|
|
165
|
+
never reach the network. `SMKLOG_LIVE=1` adds one live test that calls `status()` on
|
|
166
|
+
production; nothing in the suite ever requests a quote.
|
|
167
|
+
|
|
168
|
+
Docs, schemas, playground and status page: [smklog.com/developers](https://smklog.com/developers/).
|
|
169
|
+
The contract is the OpenAPI 3.1 document at
|
|
170
|
+
[quote-api.smklog.com/openapi.json](https://quote-api.smklog.com/openapi.json).
|
|
171
|
+
|
|
172
|
+
MIT license; see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
smklog/__init__.py,sha256=b1G6Ushsrj3A4IROMXFdB9Y6ZNd-_b3HcWNJD_njYW4,1126
|
|
2
|
+
smklog/__main__.py,sha256=_hb4IAc3u3TNm1eNR-sW26IweOj6eWXkVjw0IlaPgxc,128
|
|
3
|
+
smklog/_version.py,sha256=zq64KsfLwETMEhjUgMB5D0Ogktyq2x2zaxRFl-Vqcks,144
|
|
4
|
+
smklog/cli.py,sha256=3vN--XASlVcjIbCB6EpmSbgLFN0mGm0ymFKOZoDAkSE,11205
|
|
5
|
+
smklog/client.py,sha256=OnYR03k1-gqZ32C4YL_6FpSEKIFx2TYem_SRKRlENRk,9554
|
|
6
|
+
smklog/errors.py,sha256=sNCwgaY2MYMVbuGwVBYB1Qu1rVvgwCddQfHPZu1sCK8,4375
|
|
7
|
+
smklog/models.py,sha256=UF2Y7rAOYA2ws4dTkiWzbe_ViHcRt1sSHFTq057sJVU,8816
|
|
8
|
+
smklog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
smklog_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=06AdKTrykLR7O2TaVXFNXotCvj9YGEiE0ENwr2uBobk,1066
|
|
10
|
+
smklog_sdk-0.1.0.dist-info/METADATA,sha256=Drk-GeX_0iAJ6s9A0nZCTRYjG_ahROTqJBvFQ2uHEW0,8402
|
|
11
|
+
smklog_sdk-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
smklog_sdk-0.1.0.dist-info/entry_points.txt,sha256=M2LGx69lkPbSMKJuRCb1LZ6kB0C3x1jO3rH4yKvRQD8,43
|
|
13
|
+
smklog_sdk-0.1.0.dist-info/top_level.txt,sha256=wMOWK-ql0NobhHjITZnt-_FIJzfANnwPgpe1fd4b98Q,7
|
|
14
|
+
smklog_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitrii T
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smklog
|