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 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
@@ -0,0 +1,7 @@
1
+ """Lets ``python -m smklog`` behave like the ``smklog`` console script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
smklog/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ # Kept in its own module so both the package root and the client's User-Agent
2
+ # can read it without importing each other.
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ smklog = smklog.cli:main
@@ -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