fleet-costs-sdk 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fleet_costs_sdk-0.4.0/.gitignore +9 -0
- fleet_costs_sdk-0.4.0/LICENSE +21 -0
- fleet_costs_sdk-0.4.0/PKG-INFO +335 -0
- fleet_costs_sdk-0.4.0/README.md +302 -0
- fleet_costs_sdk-0.4.0/fleet/__init__.py +6 -0
- fleet_costs_sdk-0.4.0/fleet/client.py +559 -0
- fleet_costs_sdk-0.4.0/fleet/py.typed +0 -0
- fleet_costs_sdk-0.4.0/pyproject.toml +77 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Lodewijk Wensveen
|
|
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,335 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fleet-costs-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Official Python SDK for the Fleet Costs landed-cost / duty / VAT / freight pricing API.
|
|
5
|
+
Project-URL: Homepage, https://getfleet.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.getfleet.dev/guides/python-sdk
|
|
7
|
+
Project-URL: API reference, https://docs.getfleet.dev
|
|
8
|
+
Project-URL: Support, https://getfleet.dev/contact
|
|
9
|
+
Author-email: Fleet <support@getfleet.dev>
|
|
10
|
+
Maintainer-email: Fleet <support@getfleet.dev>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: cross-border,customs,duty,fleet,hs-code,hts,import,landed-cost,sdk,vat
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: httpx>=0.25
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
31
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# fleet-costs-sdk
|
|
35
|
+
|
|
36
|
+
Official Python SDK for the [Fleet Costs](https://getfleet.dev) landed-cost /
|
|
37
|
+
duty / VAT / freight pricing API.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install fleet-costs-sdk
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from fleet import FleetClient
|
|
45
|
+
|
|
46
|
+
client = FleetClient(
|
|
47
|
+
base_url="https://api.getfleet.dev",
|
|
48
|
+
api_key="ck_live_...",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
quote = client.create_quote({
|
|
52
|
+
"origin": "US",
|
|
53
|
+
"dest": "DE",
|
|
54
|
+
"itemValue": {"amount": 100, "currency": "USD"},
|
|
55
|
+
"dimsCm": {"l": 20, "w": 15, "h": 5},
|
|
56
|
+
"weightKg": 0.5,
|
|
57
|
+
"categoryKey": "electronics.smartphone",
|
|
58
|
+
"mode": "air",
|
|
59
|
+
"hs6": "851712", # optional: supply a 6-, 8- or 10-digit commodity code
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
print(quote["total"])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Features
|
|
66
|
+
|
|
67
|
+
- Synchronous client built on [httpx](https://www.python-httpx.org/).
|
|
68
|
+
- Automatic retries on transient failures (429, 500, 502, 503, 504) with
|
|
69
|
+
exponential backoff.
|
|
70
|
+
- Idempotency key support on mutating endpoints — safe to retry writes.
|
|
71
|
+
- Context manager support (`with FleetClient(...) as client: ...`).
|
|
72
|
+
- Typed method surface covering quotes, classify, manifests, and lookups.
|
|
73
|
+
|
|
74
|
+
## Documentation
|
|
75
|
+
|
|
76
|
+
- API reference: https://docs.getfleet.dev
|
|
77
|
+
- Python SDK guide: https://docs.getfleet.dev/guides/python-sdk
|
|
78
|
+
- Support: https://getfleet.dev/contact or support@getfleet.dev
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT — see the `LICENSE` file included in this package.
|
|
83
|
+
|
|
84
|
+
## Collection assessment
|
|
85
|
+
|
|
86
|
+
Quote methods preserve the API's `assessment` dictionary. Before collecting import charges,
|
|
87
|
+
require `quote["assessment"]["collection"]["eligible"] is True`. Its `outcome` is
|
|
88
|
+
`complete`, `estimated`, or `refused`; display the `reasons` and their `message` and `action`
|
|
89
|
+
alongside any estimate. A maximum or confidence score is not collection permission.
|
|
90
|
+
Category-only quotes remain estimates. Missing assessment on an older saved response requires
|
|
91
|
+
a fresh quote with a new idempotency key before collection.
|
|
92
|
+
|
|
93
|
+
## Retry and recovery keys
|
|
94
|
+
|
|
95
|
+
Mutating methods return a `FleetResponse`: the usual response dictionary, plus an
|
|
96
|
+
`idempotency_key` attribute containing the key actually sent. The attribute is SDK
|
|
97
|
+
metadata and is not added to the API JSON fields.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import httpx
|
|
101
|
+
from fleet import FleetAPIError
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
quote = client.create_quote(body)
|
|
105
|
+
recovery_key = quote.idempotency_key
|
|
106
|
+
except FleetAPIError as error:
|
|
107
|
+
recovery_key = error.idempotency_key
|
|
108
|
+
server_code = error.code
|
|
109
|
+
except httpx.RequestError as error:
|
|
110
|
+
recovery_key = error.idempotency_key
|
|
111
|
+
except ValueError as error:
|
|
112
|
+
recovery_key = error.idempotency_key
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For a timed-out quote, use `client.get_quote_by_key(recovery_key)` to retrieve a
|
|
116
|
+
result that may have completed server-side. A missing result is not evidence that
|
|
117
|
+
it is safe to create the same order under another key.
|
|
118
|
+
|
|
119
|
+
The SDK keeps the same key on network errors, ordinary transient HTTP failures,
|
|
120
|
+
and `409 IDEMPOTENCY_KEY_PROCESSING`. It generates a replacement only after
|
|
121
|
+
`409 IDEMPOTENCY_KEY_FAILED` explicitly confirms rollback, and only when the SDK
|
|
122
|
+
created the key. A caller's `idempotency_key` is never replaced. Unrelated conflicts
|
|
123
|
+
are not retried. Retries share the configured `max_retries` budget; HTTP and
|
|
124
|
+
transport failures expose the final attempted key even when that budget is zero.
|
|
125
|
+
Invalid JSON and HTTPX response-decoding failures also retain the key on the
|
|
126
|
+
original exception; non-transport decoding failures are not retried.
|
|
127
|
+
|
|
128
|
+
`create_quote` and `create_quote_batch` require decoded JSON success responses to
|
|
129
|
+
be objects. JSON `null`, arrays and scalars raise `ValueError` with the final
|
|
130
|
+
`idempotency_key`, without retrying the write. This checks only the top-level
|
|
131
|
+
shape: empty objects and nested nullable fields are retained unchanged. Invalid
|
|
132
|
+
JSON still raises its original parsing exception with the final recovery key.
|
|
133
|
+
|
|
134
|
+
## Complete consignments and batch quotes
|
|
135
|
+
|
|
136
|
+
Use `client.create_quote_batch(items)` for 1–50 quote inputs. All items are sent in
|
|
137
|
+
one request, in their original order, including non-adjacent members of a declared
|
|
138
|
+
consignment. Supply the complete membership and the canonical declaration facts;
|
|
139
|
+
the SDK does not invent grouping, split oversized batches, or change delivery and
|
|
140
|
+
freight inputs. An explicit zero `deliveryCharge` is different from an omitted value.
|
|
141
|
+
|
|
142
|
+
`insurance` is the premium actually paid and not already in `itemValue`. It is handled as
|
|
143
|
+
freight is: part of `components.CIF` and `total` everywhere, and of the duty base only
|
|
144
|
+
where duty is valued CIF (not the US). For a CN destination, omit it when no insurance
|
|
145
|
+
was paid and the API applies the
|
|
146
|
+
customs presumption of 3 per mille of goods plus freight (`cn_insurance_presumed`); send
|
|
147
|
+
`{"amount": 0, ...}` when the insurance is already included in `itemValue`. In the
|
|
148
|
+
response, `insurance` is the declared premium inside `components.CIF` and `total`, and
|
|
149
|
+
`presumedInsurance` is the CN presumption: duty and VAT are computed on it, but it is not
|
|
150
|
+
part of `components.CIF` or `total`, since nobody pays it. The SDK sends whichever of the
|
|
151
|
+
three states you give it unchanged.
|
|
152
|
+
|
|
153
|
+
Inspect `results`, `succeeded` and `failed`: an HTTP success may contain per-item
|
|
154
|
+
errors or estimates, and `status == "ok"` does not grant collection permission.
|
|
155
|
+
Assessments and allocation details are retained unchanged.
|
|
156
|
+
|
|
157
|
+
The batch response exposes `result.idempotency_key`. To recover a timed-out batch,
|
|
158
|
+
repeat `create_quote_batch` with the **identical full items** and that final key.
|
|
159
|
+
`get_quote_by_key` reads single quotes and cannot replay a batch key.
|
|
160
|
+
|
|
161
|
+
## Dispatch and customs status
|
|
162
|
+
|
|
163
|
+
Quote inputs are dictionaries. Single and batch methods preserve the canonical
|
|
164
|
+
`declaration.movement` object without adding defaults or validating customs facts:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
body = {
|
|
168
|
+
"origin": "CN", # country of manufacture
|
|
169
|
+
"dispatchCountry": "DE", # actual ship-from country
|
|
170
|
+
"dest": "FR",
|
|
171
|
+
"itemValue": {"amount": 200, "currency": "EUR"},
|
|
172
|
+
"dimsCm": {"l": 10, "w": 10, "h": 10},
|
|
173
|
+
"weightKg": 2,
|
|
174
|
+
"categoryKey": "apparel.tshirt",
|
|
175
|
+
"hs6": "610910",
|
|
176
|
+
"mode": "air",
|
|
177
|
+
"freight": {"amount": 25, "currency": "EUR"},
|
|
178
|
+
"deliveryCharge": {"amount": 0, "currency": "EUR"},
|
|
179
|
+
"declaration": {"movement": {"customsStatus": "free_circulation"}},
|
|
180
|
+
}
|
|
181
|
+
quote = client.create_quote(body)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The status describes the goods **at this sale in the dispatch customs territory**.
|
|
185
|
+
`free_circulation` means qualifying goods are already in free circulation before
|
|
186
|
+
the sale, with no release/entry lodged to fulfil this order. Use
|
|
187
|
+
`not_in_free_circulation` when a customs debt or conditional procedure remains,
|
|
188
|
+
including bonded stock that will be released to fulfil this order. This is the
|
|
189
|
+
merchant's assertion from their records; Fleet does not verify release evidence.
|
|
190
|
+
Do not derive it from manufacture, warehouse country, IOSS registration or an unset
|
|
191
|
+
Boolean. Supply actual `dispatchCountry` with a known status; omit `movement` when
|
|
192
|
+
unknown. An empty movement object or a made-up status is not an unknown value.
|
|
193
|
+
|
|
194
|
+
Movement is per line: items sharing a complete consignment may have different
|
|
195
|
+
statuses. Preserve those facts; do not split a consignment to change its treatment.
|
|
196
|
+
On a no-border lane, a required release produces `customs_release_not_modelled`
|
|
197
|
+
and a refused assessment with a null maximum. Other qualifications remain
|
|
198
|
+
independent, including `vat_buyer_status_unresolved`. Neither declared free
|
|
199
|
+
circulation nor a successful batch item establishes collection permission, buyer
|
|
200
|
+
VAT entitlement, seller registration or the correct remittance jurisdiction.
|
|
201
|
+
|
|
202
|
+
`explainability.duty.customsStatusSource` records `request` for a merchant declaration
|
|
203
|
+
and `assumed` for an inferred status on applicable no-border quotes. It is absent
|
|
204
|
+
on border quotes and can be absent on historical records, including records with
|
|
205
|
+
no `explainability` block. Preserve that absence; never fill it with `assumed` or
|
|
206
|
+
interpret `request` as verified. The response dictionary retains complete reasons,
|
|
207
|
+
warnings, missing components and nullable ceilings alongside this provenance.
|
|
208
|
+
|
|
209
|
+
## Distance-sales attestation
|
|
210
|
+
|
|
211
|
+
A seller established in an EU member state, with no OSS registration, can attest
|
|
212
|
+
that it is under that state's distance-selling threshold (EUR 10,000 in the euro
|
|
213
|
+
area, the national figure elsewhere). State the total in the threshold's currency;
|
|
214
|
+
`client.get_distance_sales_threshold("PL")` says which. Quotes whose
|
|
215
|
+
request declares `dispatchCountry` equal to the attested state can then price
|
|
216
|
+
consumer sales at the dispatch state's rate:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
profile = client.set_distance_sales_attestation({
|
|
220
|
+
"statementVersion": 1,
|
|
221
|
+
"establishmentCountry": "NL",
|
|
222
|
+
"sellsOnOwnAccount": True,
|
|
223
|
+
"establishedOnlyThere": True,
|
|
224
|
+
"dispatchesOnlyFromThere": True,
|
|
225
|
+
"noDestinationTaxationOption": True,
|
|
226
|
+
"noSmallEnterpriseExemption": True,
|
|
227
|
+
"priorYearWithinThreshold": True,
|
|
228
|
+
"currentYearTotal": {"amount": 4200, "currency": "EUR"},
|
|
229
|
+
"currentYearTotalAsOf": "2026-09-20",
|
|
230
|
+
"exclusionsAcknowledged": True,
|
|
231
|
+
})
|
|
232
|
+
profile["distanceSalesAttestation"]["inEffect"]
|
|
233
|
+
|
|
234
|
+
# As soon as the current-year total passes the threshold:
|
|
235
|
+
client.withdraw_distance_sales_attestation()
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Every statement is a literal `True`: send the attestation only if the seller can
|
|
239
|
+
affirm each one. It needs a live key with `self:write`; Test-mode keys are refused,
|
|
240
|
+
and the API does not check who holds the key.
|
|
241
|
+
|
|
242
|
+
`explainability.vat.supplyVatSchemeSource` names what decided where a consumer sale is taxed (`oss_registration`,
|
|
243
|
+
`destination_registration`, `declared_destination`, `attestation` or `assumed`) on a cross-border intra-EU supply
|
|
244
|
+
priced OSS or domestic. `declared_destination` means the seller states destination taxation with no OSS or
|
|
245
|
+
destination VAT registration on file; such an OSS-priced quote cannot be collected.
|
|
246
|
+
It is absent on reverse charge, domestic movements, imports and older saved quotes.
|
|
247
|
+
`explainability.vat.thresholdAttestation` is present only where the engine consulted an attestation on file (no OSS
|
|
248
|
+
registration, a domestic-scheme price): `applied`, or `not_applied` with a `reason`.
|
|
249
|
+
Preserve both absences; do not fill them in.
|
|
250
|
+
|
|
251
|
+
## Buyer VAT numbers
|
|
252
|
+
|
|
253
|
+
Send the buyer's VAT number, country prefix included, as
|
|
254
|
+
`declaration.buyer.vatNumber`, and `declaration.buyer.taxStatus:
|
|
255
|
+
"vat_registered_business"` only where your checkout captured it. Never derive the
|
|
256
|
+
status from the presence of a number.
|
|
257
|
+
|
|
258
|
+
Where an EU number could decide an intra-EU supply, Fleet checks it against VIES and
|
|
259
|
+
reports the result in `explainability.vat.buyerVatCheck`: `status` (`valid`, `invalid`, `unavailable`, or
|
|
260
|
+
`not_checked` with a `reason`), `numberCountry`, `requesterCountry`, `checkedAt` and
|
|
261
|
+
`requestIdentifier`, the VIES consultation number to keep with the invoice. The
|
|
262
|
+
number itself is never echoed.
|
|
263
|
+
|
|
264
|
+
A check needs your VAT registration for the dispatch state recorded in Fleet as the
|
|
265
|
+
requester. Without it the result is `not_checked` with reason
|
|
266
|
+
`no_requester_registration`, test keys included. Live keys in production are not
|
|
267
|
+
checked until the switch-on (#2369). Test keys get a simulator that sends nothing
|
|
268
|
+
anywhere: national part `100` is valid and `200` invalid. GB numbers are not checked
|
|
269
|
+
yet.
|
|
270
|
+
|
|
271
|
+
What the result changes depends on the lane, so read each quote's warnings rather than
|
|
272
|
+
these notes:
|
|
273
|
+
|
|
274
|
+
- **A number from another member state** (an exempt supply). A `valid` number beside
|
|
275
|
+
`vat_registered_business` replaces `vat_reverse_charge_conditional` with the
|
|
276
|
+
information code `vat_intra_eu_exemption_number_verified`, and the quote can
|
|
277
|
+
complete. VIES confirms a registration, not who is buying, so a valid number without
|
|
278
|
+
that status does not. VAT confidence stays capped at `estimated` and
|
|
279
|
+
`guaranteedMax` keeps its reserve either way, because the exemption also rests on
|
|
280
|
+
your own recapitulative statement. `unavailable` and `not_checked` leave the quote
|
|
281
|
+
as it would be without a check.
|
|
282
|
+
- **A number from the dispatch state** never makes the supply exempt. A valid one
|
|
283
|
+
beside `vat_registered_business` prices dispatch-state VAT and discloses it with
|
|
284
|
+
`vat_reverse_charge_not_available`.
|
|
285
|
+
- **An `invalid` number**, from either, is priced as absent. It blocks collection with
|
|
286
|
+
`vat_buyer_vat_number_invalid` only where that could change the rate; where it
|
|
287
|
+
cannot, such as equal rates or an applied distance-sales attestation, the quote
|
|
288
|
+
discloses it with `vat_reverse_charge_not_available`.
|
|
289
|
+
|
|
290
|
+
On a GB business import of GBP 135 or less, a GB number gives a conditional zero
|
|
291
|
+
(`vat_reverse_charge_conditional`) with no `guaranteedMax`. Rows from `export_quotes` carry the same `buyerVatCheck`, so
|
|
292
|
+
the consultation number stays linked to its quote.
|
|
293
|
+
|
|
294
|
+
For the separate [organization data export](https://docs.getfleet.dev/api/data-retention),
|
|
295
|
+
use `GET /v1/data-retention/export` with `self:read` scope as an organization owner or admin.
|
|
296
|
+
Retained consultation records appear in `data.viesConsultations`, newest first, up to 10,000.
|
|
297
|
+
`completeness.viesConsultationsIncluded` gives the returned count;
|
|
298
|
+
`completeness.viesConsultationsTruncated` says whether more records exist.
|
|
299
|
+
These records are separate from per-quote `buyerVatCheck`. This organization export excludes
|
|
300
|
+
single-quote history, manifest quotes and manifest documents; it is not a complete quote-history export.
|
|
301
|
+
|
|
302
|
+
### Great Britain and Northern Ireland
|
|
303
|
+
|
|
304
|
+
For quote and manifest requests with destination `GB` (or `UK`), explicitly send
|
|
305
|
+
`gbDeliveryTerritory: "great_britain"` or `"northern_ireland"` for the actual delivery.
|
|
306
|
+
No SDK supplies a default. Omission returns HTTP 422
|
|
307
|
+
`GB_DELIVERY_TERRITORY_REQUIRED`; Northern Ireland returns HTTP 422
|
|
308
|
+
`TERRITORY_NOT_SUPPORTED`. Do not derive Great Britain from the country code alone.
|
|
309
|
+
|
|
310
|
+
## Gateway headers
|
|
311
|
+
|
|
312
|
+
Use the public `extra_headers` option when your chosen API gateway requires
|
|
313
|
+
additional headers. For example, a Cloudflare Access protected origin can use:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
import os
|
|
317
|
+
from fleet import FleetClient
|
|
318
|
+
|
|
319
|
+
with FleetClient(
|
|
320
|
+
base_url=os.environ["FLEET_API_URL"],
|
|
321
|
+
api_key=os.environ["FLEET_API_KEY"],
|
|
322
|
+
extra_headers={
|
|
323
|
+
"CF-Access-Client-Id": os.environ["CF_ACCESS_CLIENT_ID"],
|
|
324
|
+
"CF-Access-Client-Secret": os.environ["CF_ACCESS_CLIENT_SECRET"],
|
|
325
|
+
},
|
|
326
|
+
) as client:
|
|
327
|
+
quote = client.create_quote(body)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The mapping is copied at construction. Header names are case-insensitive;
|
|
331
|
+
invalid/duplicate names, non-ASCII/control-character values and SDK-controlled
|
|
332
|
+
authentication, idempotency, host, framing and hop-by-hop headers raise `ValueError`.
|
|
333
|
+
Keep the API key in `api_key` and operation keys in `idempotency_key`.
|
|
334
|
+
Redirects are not followed, so gateway credentials are not forwarded to a
|
|
335
|
+
redirect destination. Use a trusted HTTPS origin for real credentials.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# fleet-costs-sdk
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Fleet Costs](https://getfleet.dev) landed-cost /
|
|
4
|
+
duty / VAT / freight pricing API.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install fleet-costs-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fleet import FleetClient
|
|
12
|
+
|
|
13
|
+
client = FleetClient(
|
|
14
|
+
base_url="https://api.getfleet.dev",
|
|
15
|
+
api_key="ck_live_...",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
quote = client.create_quote({
|
|
19
|
+
"origin": "US",
|
|
20
|
+
"dest": "DE",
|
|
21
|
+
"itemValue": {"amount": 100, "currency": "USD"},
|
|
22
|
+
"dimsCm": {"l": 20, "w": 15, "h": 5},
|
|
23
|
+
"weightKg": 0.5,
|
|
24
|
+
"categoryKey": "electronics.smartphone",
|
|
25
|
+
"mode": "air",
|
|
26
|
+
"hs6": "851712", # optional: supply a 6-, 8- or 10-digit commodity code
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
print(quote["total"])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Synchronous client built on [httpx](https://www.python-httpx.org/).
|
|
35
|
+
- Automatic retries on transient failures (429, 500, 502, 503, 504) with
|
|
36
|
+
exponential backoff.
|
|
37
|
+
- Idempotency key support on mutating endpoints — safe to retry writes.
|
|
38
|
+
- Context manager support (`with FleetClient(...) as client: ...`).
|
|
39
|
+
- Typed method surface covering quotes, classify, manifests, and lookups.
|
|
40
|
+
|
|
41
|
+
## Documentation
|
|
42
|
+
|
|
43
|
+
- API reference: https://docs.getfleet.dev
|
|
44
|
+
- Python SDK guide: https://docs.getfleet.dev/guides/python-sdk
|
|
45
|
+
- Support: https://getfleet.dev/contact or support@getfleet.dev
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT — see the `LICENSE` file included in this package.
|
|
50
|
+
|
|
51
|
+
## Collection assessment
|
|
52
|
+
|
|
53
|
+
Quote methods preserve the API's `assessment` dictionary. Before collecting import charges,
|
|
54
|
+
require `quote["assessment"]["collection"]["eligible"] is True`. Its `outcome` is
|
|
55
|
+
`complete`, `estimated`, or `refused`; display the `reasons` and their `message` and `action`
|
|
56
|
+
alongside any estimate. A maximum or confidence score is not collection permission.
|
|
57
|
+
Category-only quotes remain estimates. Missing assessment on an older saved response requires
|
|
58
|
+
a fresh quote with a new idempotency key before collection.
|
|
59
|
+
|
|
60
|
+
## Retry and recovery keys
|
|
61
|
+
|
|
62
|
+
Mutating methods return a `FleetResponse`: the usual response dictionary, plus an
|
|
63
|
+
`idempotency_key` attribute containing the key actually sent. The attribute is SDK
|
|
64
|
+
metadata and is not added to the API JSON fields.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import httpx
|
|
68
|
+
from fleet import FleetAPIError
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
quote = client.create_quote(body)
|
|
72
|
+
recovery_key = quote.idempotency_key
|
|
73
|
+
except FleetAPIError as error:
|
|
74
|
+
recovery_key = error.idempotency_key
|
|
75
|
+
server_code = error.code
|
|
76
|
+
except httpx.RequestError as error:
|
|
77
|
+
recovery_key = error.idempotency_key
|
|
78
|
+
except ValueError as error:
|
|
79
|
+
recovery_key = error.idempotency_key
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For a timed-out quote, use `client.get_quote_by_key(recovery_key)` to retrieve a
|
|
83
|
+
result that may have completed server-side. A missing result is not evidence that
|
|
84
|
+
it is safe to create the same order under another key.
|
|
85
|
+
|
|
86
|
+
The SDK keeps the same key on network errors, ordinary transient HTTP failures,
|
|
87
|
+
and `409 IDEMPOTENCY_KEY_PROCESSING`. It generates a replacement only after
|
|
88
|
+
`409 IDEMPOTENCY_KEY_FAILED` explicitly confirms rollback, and only when the SDK
|
|
89
|
+
created the key. A caller's `idempotency_key` is never replaced. Unrelated conflicts
|
|
90
|
+
are not retried. Retries share the configured `max_retries` budget; HTTP and
|
|
91
|
+
transport failures expose the final attempted key even when that budget is zero.
|
|
92
|
+
Invalid JSON and HTTPX response-decoding failures also retain the key on the
|
|
93
|
+
original exception; non-transport decoding failures are not retried.
|
|
94
|
+
|
|
95
|
+
`create_quote` and `create_quote_batch` require decoded JSON success responses to
|
|
96
|
+
be objects. JSON `null`, arrays and scalars raise `ValueError` with the final
|
|
97
|
+
`idempotency_key`, without retrying the write. This checks only the top-level
|
|
98
|
+
shape: empty objects and nested nullable fields are retained unchanged. Invalid
|
|
99
|
+
JSON still raises its original parsing exception with the final recovery key.
|
|
100
|
+
|
|
101
|
+
## Complete consignments and batch quotes
|
|
102
|
+
|
|
103
|
+
Use `client.create_quote_batch(items)` for 1–50 quote inputs. All items are sent in
|
|
104
|
+
one request, in their original order, including non-adjacent members of a declared
|
|
105
|
+
consignment. Supply the complete membership and the canonical declaration facts;
|
|
106
|
+
the SDK does not invent grouping, split oversized batches, or change delivery and
|
|
107
|
+
freight inputs. An explicit zero `deliveryCharge` is different from an omitted value.
|
|
108
|
+
|
|
109
|
+
`insurance` is the premium actually paid and not already in `itemValue`. It is handled as
|
|
110
|
+
freight is: part of `components.CIF` and `total` everywhere, and of the duty base only
|
|
111
|
+
where duty is valued CIF (not the US). For a CN destination, omit it when no insurance
|
|
112
|
+
was paid and the API applies the
|
|
113
|
+
customs presumption of 3 per mille of goods plus freight (`cn_insurance_presumed`); send
|
|
114
|
+
`{"amount": 0, ...}` when the insurance is already included in `itemValue`. In the
|
|
115
|
+
response, `insurance` is the declared premium inside `components.CIF` and `total`, and
|
|
116
|
+
`presumedInsurance` is the CN presumption: duty and VAT are computed on it, but it is not
|
|
117
|
+
part of `components.CIF` or `total`, since nobody pays it. The SDK sends whichever of the
|
|
118
|
+
three states you give it unchanged.
|
|
119
|
+
|
|
120
|
+
Inspect `results`, `succeeded` and `failed`: an HTTP success may contain per-item
|
|
121
|
+
errors or estimates, and `status == "ok"` does not grant collection permission.
|
|
122
|
+
Assessments and allocation details are retained unchanged.
|
|
123
|
+
|
|
124
|
+
The batch response exposes `result.idempotency_key`. To recover a timed-out batch,
|
|
125
|
+
repeat `create_quote_batch` with the **identical full items** and that final key.
|
|
126
|
+
`get_quote_by_key` reads single quotes and cannot replay a batch key.
|
|
127
|
+
|
|
128
|
+
## Dispatch and customs status
|
|
129
|
+
|
|
130
|
+
Quote inputs are dictionaries. Single and batch methods preserve the canonical
|
|
131
|
+
`declaration.movement` object without adding defaults or validating customs facts:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
body = {
|
|
135
|
+
"origin": "CN", # country of manufacture
|
|
136
|
+
"dispatchCountry": "DE", # actual ship-from country
|
|
137
|
+
"dest": "FR",
|
|
138
|
+
"itemValue": {"amount": 200, "currency": "EUR"},
|
|
139
|
+
"dimsCm": {"l": 10, "w": 10, "h": 10},
|
|
140
|
+
"weightKg": 2,
|
|
141
|
+
"categoryKey": "apparel.tshirt",
|
|
142
|
+
"hs6": "610910",
|
|
143
|
+
"mode": "air",
|
|
144
|
+
"freight": {"amount": 25, "currency": "EUR"},
|
|
145
|
+
"deliveryCharge": {"amount": 0, "currency": "EUR"},
|
|
146
|
+
"declaration": {"movement": {"customsStatus": "free_circulation"}},
|
|
147
|
+
}
|
|
148
|
+
quote = client.create_quote(body)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The status describes the goods **at this sale in the dispatch customs territory**.
|
|
152
|
+
`free_circulation` means qualifying goods are already in free circulation before
|
|
153
|
+
the sale, with no release/entry lodged to fulfil this order. Use
|
|
154
|
+
`not_in_free_circulation` when a customs debt or conditional procedure remains,
|
|
155
|
+
including bonded stock that will be released to fulfil this order. This is the
|
|
156
|
+
merchant's assertion from their records; Fleet does not verify release evidence.
|
|
157
|
+
Do not derive it from manufacture, warehouse country, IOSS registration or an unset
|
|
158
|
+
Boolean. Supply actual `dispatchCountry` with a known status; omit `movement` when
|
|
159
|
+
unknown. An empty movement object or a made-up status is not an unknown value.
|
|
160
|
+
|
|
161
|
+
Movement is per line: items sharing a complete consignment may have different
|
|
162
|
+
statuses. Preserve those facts; do not split a consignment to change its treatment.
|
|
163
|
+
On a no-border lane, a required release produces `customs_release_not_modelled`
|
|
164
|
+
and a refused assessment with a null maximum. Other qualifications remain
|
|
165
|
+
independent, including `vat_buyer_status_unresolved`. Neither declared free
|
|
166
|
+
circulation nor a successful batch item establishes collection permission, buyer
|
|
167
|
+
VAT entitlement, seller registration or the correct remittance jurisdiction.
|
|
168
|
+
|
|
169
|
+
`explainability.duty.customsStatusSource` records `request` for a merchant declaration
|
|
170
|
+
and `assumed` for an inferred status on applicable no-border quotes. It is absent
|
|
171
|
+
on border quotes and can be absent on historical records, including records with
|
|
172
|
+
no `explainability` block. Preserve that absence; never fill it with `assumed` or
|
|
173
|
+
interpret `request` as verified. The response dictionary retains complete reasons,
|
|
174
|
+
warnings, missing components and nullable ceilings alongside this provenance.
|
|
175
|
+
|
|
176
|
+
## Distance-sales attestation
|
|
177
|
+
|
|
178
|
+
A seller established in an EU member state, with no OSS registration, can attest
|
|
179
|
+
that it is under that state's distance-selling threshold (EUR 10,000 in the euro
|
|
180
|
+
area, the national figure elsewhere). State the total in the threshold's currency;
|
|
181
|
+
`client.get_distance_sales_threshold("PL")` says which. Quotes whose
|
|
182
|
+
request declares `dispatchCountry` equal to the attested state can then price
|
|
183
|
+
consumer sales at the dispatch state's rate:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
profile = client.set_distance_sales_attestation({
|
|
187
|
+
"statementVersion": 1,
|
|
188
|
+
"establishmentCountry": "NL",
|
|
189
|
+
"sellsOnOwnAccount": True,
|
|
190
|
+
"establishedOnlyThere": True,
|
|
191
|
+
"dispatchesOnlyFromThere": True,
|
|
192
|
+
"noDestinationTaxationOption": True,
|
|
193
|
+
"noSmallEnterpriseExemption": True,
|
|
194
|
+
"priorYearWithinThreshold": True,
|
|
195
|
+
"currentYearTotal": {"amount": 4200, "currency": "EUR"},
|
|
196
|
+
"currentYearTotalAsOf": "2026-09-20",
|
|
197
|
+
"exclusionsAcknowledged": True,
|
|
198
|
+
})
|
|
199
|
+
profile["distanceSalesAttestation"]["inEffect"]
|
|
200
|
+
|
|
201
|
+
# As soon as the current-year total passes the threshold:
|
|
202
|
+
client.withdraw_distance_sales_attestation()
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Every statement is a literal `True`: send the attestation only if the seller can
|
|
206
|
+
affirm each one. It needs a live key with `self:write`; Test-mode keys are refused,
|
|
207
|
+
and the API does not check who holds the key.
|
|
208
|
+
|
|
209
|
+
`explainability.vat.supplyVatSchemeSource` names what decided where a consumer sale is taxed (`oss_registration`,
|
|
210
|
+
`destination_registration`, `declared_destination`, `attestation` or `assumed`) on a cross-border intra-EU supply
|
|
211
|
+
priced OSS or domestic. `declared_destination` means the seller states destination taxation with no OSS or
|
|
212
|
+
destination VAT registration on file; such an OSS-priced quote cannot be collected.
|
|
213
|
+
It is absent on reverse charge, domestic movements, imports and older saved quotes.
|
|
214
|
+
`explainability.vat.thresholdAttestation` is present only where the engine consulted an attestation on file (no OSS
|
|
215
|
+
registration, a domestic-scheme price): `applied`, or `not_applied` with a `reason`.
|
|
216
|
+
Preserve both absences; do not fill them in.
|
|
217
|
+
|
|
218
|
+
## Buyer VAT numbers
|
|
219
|
+
|
|
220
|
+
Send the buyer's VAT number, country prefix included, as
|
|
221
|
+
`declaration.buyer.vatNumber`, and `declaration.buyer.taxStatus:
|
|
222
|
+
"vat_registered_business"` only where your checkout captured it. Never derive the
|
|
223
|
+
status from the presence of a number.
|
|
224
|
+
|
|
225
|
+
Where an EU number could decide an intra-EU supply, Fleet checks it against VIES and
|
|
226
|
+
reports the result in `explainability.vat.buyerVatCheck`: `status` (`valid`, `invalid`, `unavailable`, or
|
|
227
|
+
`not_checked` with a `reason`), `numberCountry`, `requesterCountry`, `checkedAt` and
|
|
228
|
+
`requestIdentifier`, the VIES consultation number to keep with the invoice. The
|
|
229
|
+
number itself is never echoed.
|
|
230
|
+
|
|
231
|
+
A check needs your VAT registration for the dispatch state recorded in Fleet as the
|
|
232
|
+
requester. Without it the result is `not_checked` with reason
|
|
233
|
+
`no_requester_registration`, test keys included. Live keys in production are not
|
|
234
|
+
checked until the switch-on (#2369). Test keys get a simulator that sends nothing
|
|
235
|
+
anywhere: national part `100` is valid and `200` invalid. GB numbers are not checked
|
|
236
|
+
yet.
|
|
237
|
+
|
|
238
|
+
What the result changes depends on the lane, so read each quote's warnings rather than
|
|
239
|
+
these notes:
|
|
240
|
+
|
|
241
|
+
- **A number from another member state** (an exempt supply). A `valid` number beside
|
|
242
|
+
`vat_registered_business` replaces `vat_reverse_charge_conditional` with the
|
|
243
|
+
information code `vat_intra_eu_exemption_number_verified`, and the quote can
|
|
244
|
+
complete. VIES confirms a registration, not who is buying, so a valid number without
|
|
245
|
+
that status does not. VAT confidence stays capped at `estimated` and
|
|
246
|
+
`guaranteedMax` keeps its reserve either way, because the exemption also rests on
|
|
247
|
+
your own recapitulative statement. `unavailable` and `not_checked` leave the quote
|
|
248
|
+
as it would be without a check.
|
|
249
|
+
- **A number from the dispatch state** never makes the supply exempt. A valid one
|
|
250
|
+
beside `vat_registered_business` prices dispatch-state VAT and discloses it with
|
|
251
|
+
`vat_reverse_charge_not_available`.
|
|
252
|
+
- **An `invalid` number**, from either, is priced as absent. It blocks collection with
|
|
253
|
+
`vat_buyer_vat_number_invalid` only where that could change the rate; where it
|
|
254
|
+
cannot, such as equal rates or an applied distance-sales attestation, the quote
|
|
255
|
+
discloses it with `vat_reverse_charge_not_available`.
|
|
256
|
+
|
|
257
|
+
On a GB business import of GBP 135 or less, a GB number gives a conditional zero
|
|
258
|
+
(`vat_reverse_charge_conditional`) with no `guaranteedMax`. Rows from `export_quotes` carry the same `buyerVatCheck`, so
|
|
259
|
+
the consultation number stays linked to its quote.
|
|
260
|
+
|
|
261
|
+
For the separate [organization data export](https://docs.getfleet.dev/api/data-retention),
|
|
262
|
+
use `GET /v1/data-retention/export` with `self:read` scope as an organization owner or admin.
|
|
263
|
+
Retained consultation records appear in `data.viesConsultations`, newest first, up to 10,000.
|
|
264
|
+
`completeness.viesConsultationsIncluded` gives the returned count;
|
|
265
|
+
`completeness.viesConsultationsTruncated` says whether more records exist.
|
|
266
|
+
These records are separate from per-quote `buyerVatCheck`. This organization export excludes
|
|
267
|
+
single-quote history, manifest quotes and manifest documents; it is not a complete quote-history export.
|
|
268
|
+
|
|
269
|
+
### Great Britain and Northern Ireland
|
|
270
|
+
|
|
271
|
+
For quote and manifest requests with destination `GB` (or `UK`), explicitly send
|
|
272
|
+
`gbDeliveryTerritory: "great_britain"` or `"northern_ireland"` for the actual delivery.
|
|
273
|
+
No SDK supplies a default. Omission returns HTTP 422
|
|
274
|
+
`GB_DELIVERY_TERRITORY_REQUIRED`; Northern Ireland returns HTTP 422
|
|
275
|
+
`TERRITORY_NOT_SUPPORTED`. Do not derive Great Britain from the country code alone.
|
|
276
|
+
|
|
277
|
+
## Gateway headers
|
|
278
|
+
|
|
279
|
+
Use the public `extra_headers` option when your chosen API gateway requires
|
|
280
|
+
additional headers. For example, a Cloudflare Access protected origin can use:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
import os
|
|
284
|
+
from fleet import FleetClient
|
|
285
|
+
|
|
286
|
+
with FleetClient(
|
|
287
|
+
base_url=os.environ["FLEET_API_URL"],
|
|
288
|
+
api_key=os.environ["FLEET_API_KEY"],
|
|
289
|
+
extra_headers={
|
|
290
|
+
"CF-Access-Client-Id": os.environ["CF_ACCESS_CLIENT_ID"],
|
|
291
|
+
"CF-Access-Client-Secret": os.environ["CF_ACCESS_CLIENT_SECRET"],
|
|
292
|
+
},
|
|
293
|
+
) as client:
|
|
294
|
+
quote = client.create_quote(body)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The mapping is copied at construction. Header names are case-insensitive;
|
|
298
|
+
invalid/duplicate names, non-ASCII/control-character values and SDK-controlled
|
|
299
|
+
authentication, idempotency, host, framing and hop-by-hop headers raise `ValueError`.
|
|
300
|
+
Keep the API key in `api_key` and operation keys in `idempotency_key`.
|
|
301
|
+
Redirects are not followed, so gateway credentials are not forwarded to a
|
|
302
|
+
redirect destination. Use a trusted HTTPS origin for real credentials.
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""Fleet API client with retry logic and idempotency support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from base64 import urlsafe_b64encode
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from email.utils import parsedate_to_datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
# Maximum backoff/Retry-After wait, in seconds.
|
|
19
|
+
_MAX_DELAY = 30.0
|
|
20
|
+
|
|
21
|
+
# Authentication, recovery and HTTP framing belong to the SDK/HTTP client.
|
|
22
|
+
# Reject aliases case-insensitively so additional gateway headers cannot silently
|
|
23
|
+
# replace a bearer key or turn one logical retry into a different operation.
|
|
24
|
+
_RESERVED_HEADERS = frozenset({
|
|
25
|
+
"authorization", "x-api-key", "idempotency-key", "host", "content-type",
|
|
26
|
+
"content-length", "transfer-encoding", "connection", "keep-alive",
|
|
27
|
+
"proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extra_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
|
32
|
+
result: dict[str, str] = {}
|
|
33
|
+
for name, value in (headers or {}).items():
|
|
34
|
+
if (not isinstance(name, str) or not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", name)
|
|
35
|
+
or name.lower() in _RESERVED_HEADERS):
|
|
36
|
+
raise ValueError("extra_headers contains an invalid or SDK-controlled header name")
|
|
37
|
+
# Constant diagnostics deliberately exclude potentially secret values.
|
|
38
|
+
if (not isinstance(value, str) or value != value.strip()
|
|
39
|
+
or any(ord(c) < 32 or ord(c) > 126 for c in value)):
|
|
40
|
+
raise ValueError("extra_headers contains an invalid header value")
|
|
41
|
+
if name.lower() in result:
|
|
42
|
+
raise ValueError("extra_headers contains duplicate header names")
|
|
43
|
+
result[name.lower()] = value
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _encode_path_segment(segment: str) -> str:
|
|
48
|
+
"""Percent-encode a user-supplied value for use as a single URL path segment.
|
|
49
|
+
|
|
50
|
+
Reserved-character escaping matches the Go SDK's ``encodePathSegment``:
|
|
51
|
+
every non-unreserved byte is percent-encoded. It is slightly stricter than
|
|
52
|
+
the TS SDK's ``encodeURIComponent``,
|
|
53
|
+
which leaves ``! * ' ( )`` bare — both decode identically server-side: without this, a key containing ``/`` adds path
|
|
54
|
+
segments, ``?`` truncates into a query string, ``#`` into a fragment, and a
|
|
55
|
+
bare ``%`` produces an invalid percent-sequence.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(segment, str):
|
|
58
|
+
raise TypeError(f"path segment must be a str, got {type(segment).__name__}")
|
|
59
|
+
# quote() always leaves dots unescaped, even with safe="". HTTPX removes
|
|
60
|
+
# literal dot segments while merging the base URL, losing valid recovery
|
|
61
|
+
# keys "." and ".." before sending the request (#2784).
|
|
62
|
+
if segment in (".", ".."):
|
|
63
|
+
return "%2E" * len(segment)
|
|
64
|
+
return quote(segment, safe="")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _gen_idem_key() -> str:
|
|
68
|
+
"""Generate a unique idempotency key prefixed with ``ck_idem_``."""
|
|
69
|
+
raw = secrets.token_bytes(16)
|
|
70
|
+
b64 = urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
71
|
+
return f"ck_idem_{b64}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _backoff_delay(attempt: int, *, initial: float = 0.5, maximum: float = _MAX_DELAY) -> float:
|
|
75
|
+
"""Exponential backoff with full jitter, in seconds.
|
|
76
|
+
|
|
77
|
+
``delay = uniform(0, min(maximum, initial * 2**attempt))``
|
|
78
|
+
"""
|
|
79
|
+
ceiling = min(maximum, initial * (2**attempt))
|
|
80
|
+
return random.uniform(0.0, ceiling)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_retry_after(resp: httpx.Response) -> float | None:
|
|
84
|
+
"""Parse a ``Retry-After`` header into seconds.
|
|
85
|
+
|
|
86
|
+
Accepts both the numeric-seconds and HTTP-date forms. Returns ``None`` when
|
|
87
|
+
the header is absent or unparseable.
|
|
88
|
+
"""
|
|
89
|
+
header = resp.headers.get("retry-after")
|
|
90
|
+
if not header:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
# Numeric seconds.
|
|
94
|
+
try:
|
|
95
|
+
secs = float(header)
|
|
96
|
+
if secs >= 0:
|
|
97
|
+
return secs
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# HTTP-date.
|
|
102
|
+
try:
|
|
103
|
+
when = parsedate_to_datetime(header)
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
return None
|
|
106
|
+
# A "-0000" offset yields a naive datetime; normalise to UTC so the
|
|
107
|
+
# subtraction below never mixes aware and naive datetimes.
|
|
108
|
+
if when.tzinfo is None:
|
|
109
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
110
|
+
delta = (when - datetime.now(timezone.utc)).total_seconds()
|
|
111
|
+
return max(0.0, delta)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class FleetResponse(dict[str, Any]):
|
|
115
|
+
"""API response fields with SDK recovery metadata outside the JSON payload."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, data: dict[str, Any], idempotency_key: str) -> None:
|
|
118
|
+
super().__init__(data)
|
|
119
|
+
self.idempotency_key = idempotency_key
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FleetAPIError(httpx.HTTPStatusError):
|
|
123
|
+
"""HTTP failure with the server code and the key actually sent."""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self, error: httpx.HTTPStatusError, code: str | None, idempotency_key: str | None
|
|
127
|
+
) -> None:
|
|
128
|
+
super().__init__(str(error), request=error.request, response=error.response)
|
|
129
|
+
self.code = code
|
|
130
|
+
self.idempotency_key = idempotency_key
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class FleetClient:
|
|
134
|
+
"""Synchronous client for the Fleet API.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
base_url: API base URL (e.g. ``https://api.getfleet.dev``).
|
|
138
|
+
api_key: Bearer API key.
|
|
139
|
+
max_retries: Maximum retry attempts for transient failures (default 3).
|
|
140
|
+
timeout: Request timeout in seconds (default 30).
|
|
141
|
+
extra_headers: Optional gateway headers, copied at construction. SDK
|
|
142
|
+
authentication, idempotency and HTTP framing headers are reserved.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
base_url: str,
|
|
150
|
+
api_key: str,
|
|
151
|
+
*,
|
|
152
|
+
max_retries: int = 3,
|
|
153
|
+
timeout: float = 30,
|
|
154
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
self.base_url = base_url.rstrip("/")
|
|
157
|
+
self.api_key = api_key
|
|
158
|
+
self.max_retries = max_retries
|
|
159
|
+
self._client = httpx.Client(
|
|
160
|
+
base_url=self.base_url,
|
|
161
|
+
headers={**_extra_headers(extra_headers), "Authorization": f"Bearer {api_key}"},
|
|
162
|
+
timeout=timeout,
|
|
163
|
+
follow_redirects=False,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
self._client.close()
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> FleetClient:
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *args: object) -> None:
|
|
173
|
+
self.close()
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# Internal HTTP helper with retry
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _request(
|
|
180
|
+
self,
|
|
181
|
+
method: str,
|
|
182
|
+
path: str,
|
|
183
|
+
*,
|
|
184
|
+
json: Any | None = None,
|
|
185
|
+
params: dict[str, Any] | None = None,
|
|
186
|
+
headers: dict[str, str] | None = None,
|
|
187
|
+
idempotency_key: str | None = None,
|
|
188
|
+
idempotency_generated: bool = False,
|
|
189
|
+
require_json_object: bool = False,
|
|
190
|
+
) -> Any:
|
|
191
|
+
last_err: Exception | None = None
|
|
192
|
+
# Delay to wait before the *next* attempt: Retry-After when the server
|
|
193
|
+
# sent it, otherwise exponential backoff with jitter. Computing it once
|
|
194
|
+
# per retry decision means we never stack a Retry-After wait on top of a
|
|
195
|
+
# generic backoff wait.
|
|
196
|
+
next_delay = 0.0
|
|
197
|
+
for attempt in range(self.max_retries + 1):
|
|
198
|
+
if attempt > 0:
|
|
199
|
+
time.sleep(next_delay)
|
|
200
|
+
try:
|
|
201
|
+
attempt_headers = dict(headers or {})
|
|
202
|
+
if idempotency_key is not None:
|
|
203
|
+
attempt_headers["Idempotency-Key"] = idempotency_key
|
|
204
|
+
resp = self._client.request(
|
|
205
|
+
method, path, json=json, params=params, headers=attempt_headers
|
|
206
|
+
)
|
|
207
|
+
except httpx.TransportError as exc:
|
|
208
|
+
last_err = exc
|
|
209
|
+
if attempt < self.max_retries:
|
|
210
|
+
next_delay = _backoff_delay(attempt)
|
|
211
|
+
continue
|
|
212
|
+
# Preserve httpx's concrete timeout/network exception type.
|
|
213
|
+
exc.idempotency_key = idempotency_key
|
|
214
|
+
raise
|
|
215
|
+
except httpx.RequestError as exc:
|
|
216
|
+
# HTTPX may fail while decoding a response after the server
|
|
217
|
+
# committed the operation (for example corrupt gzip). Preserve
|
|
218
|
+
# recovery without retrying non-transport request errors.
|
|
219
|
+
exc.idempotency_key = idempotency_key
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
code = None
|
|
223
|
+
if not resp.is_success:
|
|
224
|
+
try:
|
|
225
|
+
payload = resp.json()
|
|
226
|
+
except ValueError:
|
|
227
|
+
payload = None
|
|
228
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
229
|
+
if isinstance(error, dict) and isinstance(error.get("code"), str):
|
|
230
|
+
code = error["code"]
|
|
231
|
+
|
|
232
|
+
if resp.status_code == 409 and attempt < self.max_retries:
|
|
233
|
+
# Only explicit rollback permits a new SDK-owned key. A plain
|
|
234
|
+
# 500 may hide committed work; PROCESSING must replay that work.
|
|
235
|
+
if code == "IDEMPOTENCY_KEY_FAILED" and idempotency_generated and idempotency_key:
|
|
236
|
+
idempotency_key = _gen_idem_key()
|
|
237
|
+
next_delay = _backoff_delay(attempt)
|
|
238
|
+
continue
|
|
239
|
+
if code == "IDEMPOTENCY_KEY_PROCESSING" and idempotency_key:
|
|
240
|
+
next_delay = _backoff_delay(attempt)
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
resp.status_code != 409
|
|
245
|
+
and resp.status_code in self.RETRYABLE_STATUSES
|
|
246
|
+
and attempt < self.max_retries
|
|
247
|
+
):
|
|
248
|
+
last_err = httpx.HTTPStatusError(
|
|
249
|
+
f"{resp.status_code}", request=resp.request, response=resp
|
|
250
|
+
)
|
|
251
|
+
retry_after = _parse_retry_after(resp)
|
|
252
|
+
next_delay = (
|
|
253
|
+
min(_MAX_DELAY, retry_after)
|
|
254
|
+
if retry_after is not None
|
|
255
|
+
else _backoff_delay(attempt)
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
resp.raise_for_status()
|
|
261
|
+
except httpx.HTTPStatusError as exc:
|
|
262
|
+
raise FleetAPIError(exc, code, idempotency_key) from exc
|
|
263
|
+
|
|
264
|
+
ct = resp.headers.get("content-type", "")
|
|
265
|
+
if "application/json" in ct:
|
|
266
|
+
try:
|
|
267
|
+
data = resp.json()
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
exc.idempotency_key = idempotency_key
|
|
270
|
+
raise
|
|
271
|
+
if require_json_object and not isinstance(data, dict):
|
|
272
|
+
# A successful write may already have committed. Report the
|
|
273
|
+
# final attempted key, including FAILED replacement (#2769),
|
|
274
|
+
# without replaying the write or validating nested fields.
|
|
275
|
+
exc = ValueError("Expected a JSON object for quote creation response")
|
|
276
|
+
exc.idempotency_key = idempotency_key
|
|
277
|
+
raise exc
|
|
278
|
+
if isinstance(data, dict) and idempotency_key is not None:
|
|
279
|
+
return FleetResponse(data, idempotency_key)
|
|
280
|
+
return data
|
|
281
|
+
return resp.text
|
|
282
|
+
|
|
283
|
+
raise last_err or RuntimeError("request failed")
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Quotes
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def create_quote(self, body: dict[str, Any], *, idempotency_key: str | None = None) -> FleetResponse:
|
|
290
|
+
"""Create a quote; ``result.idempotency_key`` is its recovery key.
|
|
291
|
+
|
|
292
|
+
``body["expectedCustomsEntryDate"]`` optionally selects the UTC calendar
|
|
293
|
+
day customs is expected to accept the declaration (``YYYY-MM-DD``).
|
|
294
|
+
Omission uses today. This does not predict FX or certify clearance;
|
|
295
|
+
inspect the returned assessment before collection.
|
|
296
|
+
|
|
297
|
+
For GB/UK, supply ``gbDeliveryTerritory`` as ``great_britain`` or
|
|
298
|
+
``northern_ireland``. Missing territory and Northern Ireland return
|
|
299
|
+
named 422 errors; the SDK never defaults this delivery fact.
|
|
300
|
+
"""
|
|
301
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
302
|
+
return self._request(
|
|
303
|
+
"POST", "/v1/quotes", json=body, idempotency_key=idem,
|
|
304
|
+
idempotency_generated=idempotency_key is None,
|
|
305
|
+
require_json_object=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def get_quote_by_key(self, key: str) -> dict:
|
|
309
|
+
"""Retrieve a quote by its idempotency key."""
|
|
310
|
+
return self._request("GET", f"/v1/quotes/by-key/{_encode_path_segment(key)}")
|
|
311
|
+
|
|
312
|
+
def create_quote_batch(
|
|
313
|
+
self, items: list[dict[str, Any]], *, idempotency_key: str | None = None
|
|
314
|
+
) -> FleetResponse:
|
|
315
|
+
"""Quote 1–50 items without splitting or changing declared consignments.
|
|
316
|
+
|
|
317
|
+
HTTP 200 can contain per-item errors. Inspect each result and assessment.
|
|
318
|
+
Recover a batch by repeating this identical POST with its final key;
|
|
319
|
+
``get_quote_by_key`` is for single quotes, not batch replay.
|
|
320
|
+
"""
|
|
321
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
322
|
+
return self._request(
|
|
323
|
+
"POST", "/v1/quotes/batch", json={"items": items}, idempotency_key=idem,
|
|
324
|
+
idempotency_generated=idempotency_key is None,
|
|
325
|
+
require_json_object=True,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def export_quotes(self, **params: Any) -> dict:
|
|
329
|
+
"""Export quote history with optional filters."""
|
|
330
|
+
return self._request("GET", "/v1/quotes/export", params=params)
|
|
331
|
+
|
|
332
|
+
def get_quote_stats(self) -> dict:
|
|
333
|
+
"""Get quote count statistics."""
|
|
334
|
+
return self._request("GET", "/v1/quotes/stats")
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# Classification
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def classify(self, body: dict[str, Any], *, idempotency_key: str | None = None) -> FleetResponse:
|
|
341
|
+
"""Classify a product and predict its HS6 code."""
|
|
342
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
343
|
+
return self._request(
|
|
344
|
+
"POST", "/v1/classify", json=body, idempotency_key=idem,
|
|
345
|
+
idempotency_generated=idempotency_key is None,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def classify_batch(
|
|
349
|
+
self, items: list[dict[str, Any]], *, idempotency_key: str | None = None
|
|
350
|
+
) -> FleetResponse:
|
|
351
|
+
"""Classify multiple products in a single request.
|
|
352
|
+
|
|
353
|
+
An idempotency key is attached (auto-generated unless
|
|
354
|
+
``idempotency_key`` is supplied) and forwarded verbatim, but
|
|
355
|
+
``POST /v1/classify/batch`` implements no dedup guard today, so a
|
|
356
|
+
retried batch is classified again rather than replayed (#1306). That
|
|
357
|
+
costs nothing: classification is a pure computation over the request
|
|
358
|
+
body, and the metered unit is quotes — this route writes no meter
|
|
359
|
+
event, so a duplicate batch is not billed. The header is still sent so
|
|
360
|
+
a caller-supplied key reaches the API and so nothing here changes if
|
|
361
|
+
the route later gains a guard.
|
|
362
|
+
|
|
363
|
+
The wording this replaces said the key kept "a retry from classifying —
|
|
364
|
+
and billing — the batch twice", which was wrong on both halves.
|
|
365
|
+
"""
|
|
366
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
367
|
+
return self._request(
|
|
368
|
+
"POST", "/v1/classify/batch", json={"items": items}, idempotency_key=idem,
|
|
369
|
+
idempotency_generated=idempotency_key is None,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------------------------
|
|
373
|
+
# Manifests
|
|
374
|
+
# ------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def create_manifest(self, body: dict[str, Any], *, idempotency_key: str | None = None) -> FleetResponse:
|
|
377
|
+
"""Create a new manifest."""
|
|
378
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
379
|
+
return self._request(
|
|
380
|
+
"POST", "/v1/manifests", json=body, idempotency_key=idem,
|
|
381
|
+
idempotency_generated=idempotency_key is None,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def list_manifests(self, **params: Any) -> dict:
|
|
385
|
+
"""List manifests with optional pagination."""
|
|
386
|
+
return self._request("GET", "/v1/manifests", params=params)
|
|
387
|
+
|
|
388
|
+
def get_manifest(self, manifest_id: str) -> dict:
|
|
389
|
+
"""Get a manifest by ID."""
|
|
390
|
+
return self._request("GET", f"/v1/manifests/{_encode_path_segment(manifest_id)}")
|
|
391
|
+
|
|
392
|
+
def compute_manifest(
|
|
393
|
+
self,
|
|
394
|
+
manifest_id: str,
|
|
395
|
+
allocation: str | None = None,
|
|
396
|
+
*,
|
|
397
|
+
dry_run: bool = False,
|
|
398
|
+
idempotency_key: str | None = None,
|
|
399
|
+
) -> FleetResponse:
|
|
400
|
+
"""Compute landed costs for a manifest.
|
|
401
|
+
|
|
402
|
+
When ``allocation`` is None it is omitted from the request so the API
|
|
403
|
+
applies its mode-aware default (chargeable-kg for air manifests,
|
|
404
|
+
volumetric m³ for sea). The old ``"chargeable"`` default here forced
|
|
405
|
+
every sea manifest onto the "chargeable" basis, which on sea degenerates
|
|
406
|
+
to plain actual-weight allocation (#1499).
|
|
407
|
+
"""
|
|
408
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
409
|
+
body: dict = {"dryRun": dry_run}
|
|
410
|
+
if allocation is not None:
|
|
411
|
+
body["allocation"] = allocation
|
|
412
|
+
return self._request(
|
|
413
|
+
"POST",
|
|
414
|
+
f"/v1/manifests/{_encode_path_segment(manifest_id)}/compute",
|
|
415
|
+
json=body,
|
|
416
|
+
idempotency_key=idem,
|
|
417
|
+
idempotency_generated=idempotency_key is None,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def compute_manifest_async(
|
|
421
|
+
self,
|
|
422
|
+
manifest_id: str,
|
|
423
|
+
allocation: str | None = None,
|
|
424
|
+
*,
|
|
425
|
+
dry_run: bool = False,
|
|
426
|
+
idempotency_key: str | None = None,
|
|
427
|
+
) -> FleetResponse:
|
|
428
|
+
"""Enqueue an async compute job.
|
|
429
|
+
|
|
430
|
+
``allocation`` semantics match :meth:`compute_manifest` — None omits
|
|
431
|
+
the field so the API resolves the mode-aware default (#1499).
|
|
432
|
+
"""
|
|
433
|
+
idem = idempotency_key if idempotency_key is not None else _gen_idem_key()
|
|
434
|
+
body: dict = {"dryRun": dry_run}
|
|
435
|
+
if allocation is not None:
|
|
436
|
+
body["allocation"] = allocation
|
|
437
|
+
return self._request(
|
|
438
|
+
"POST",
|
|
439
|
+
f"/v1/manifests/{_encode_path_segment(manifest_id)}/compute-async",
|
|
440
|
+
json=body,
|
|
441
|
+
idempotency_key=idem,
|
|
442
|
+
idempotency_generated=idempotency_key is None,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def get_compute_job_status(self, manifest_id: str, job_id: str) -> dict:
|
|
446
|
+
"""Poll the status of an async compute job."""
|
|
447
|
+
return self._request(
|
|
448
|
+
"GET",
|
|
449
|
+
f"/v1/manifests/{_encode_path_segment(manifest_id)}"
|
|
450
|
+
f"/compute-async/{_encode_path_segment(job_id)}",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
def get_manifest_quotes(self, manifest_id: str) -> dict:
|
|
454
|
+
"""Get the latest computed quotes for a manifest."""
|
|
455
|
+
return self._request("GET", f"/v1/manifests/{_encode_path_segment(manifest_id)}/quotes")
|
|
456
|
+
|
|
457
|
+
def get_customs_declaration(self, manifest_id: str, *, fmt: str = "json") -> Any:
|
|
458
|
+
"""Export a customs declaration (JSON or CSV)."""
|
|
459
|
+
return self._request(
|
|
460
|
+
"GET",
|
|
461
|
+
f"/v1/manifests/{_encode_path_segment(manifest_id)}/customs-declaration",
|
|
462
|
+
params={"format": fmt},
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def delete_manifest(self, manifest_id: str) -> dict:
|
|
466
|
+
"""Delete a manifest."""
|
|
467
|
+
return self._request("DELETE", f"/v1/manifests/{_encode_path_segment(manifest_id)}")
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Reference data lookups
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def lookup_duty_rate(self, **params: Any) -> dict:
|
|
474
|
+
"""Look up the duty rate for a destination and HS6 code."""
|
|
475
|
+
return self._request("GET", "/v1/duty-rates/lookup", params=params)
|
|
476
|
+
|
|
477
|
+
def lookup_vat(self, **params: Any) -> dict:
|
|
478
|
+
"""Look up VAT rate for a destination."""
|
|
479
|
+
return self._request("GET", "/v1/vat/lookup", params=params)
|
|
480
|
+
|
|
481
|
+
def lookup_surcharges(self, **params: Any) -> dict:
|
|
482
|
+
"""Look up applicable surcharges."""
|
|
483
|
+
return self._request("GET", "/v1/surcharges/lookup", params=params)
|
|
484
|
+
|
|
485
|
+
def convert_currency(
|
|
486
|
+
self, amount: float, from_ccy: str, to_ccy: str, *, on: str | None = None
|
|
487
|
+
) -> dict:
|
|
488
|
+
"""Convert between currencies.
|
|
489
|
+
|
|
490
|
+
Pass ``on="YYYY-MM-DD"`` to convert at a historical FX rate (matching a
|
|
491
|
+
quote computed on a prior date); omit it for the latest rate.
|
|
492
|
+
"""
|
|
493
|
+
params: dict[str, Any] = {"amount": amount, "from": from_ccy, "to": to_ccy}
|
|
494
|
+
if on is not None:
|
|
495
|
+
params["on"] = on
|
|
496
|
+
return self._request("GET", "/v1/fx/convert", params=params)
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
# Merchant profile
|
|
500
|
+
# ------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def set_distance_sales_attestation(self, body: dict[str, Any]) -> dict:
|
|
503
|
+
"""Record the seller's distance-sales attestation (#2081).
|
|
504
|
+
|
|
505
|
+
The seller states it is established in one member state, dispatches
|
|
506
|
+
only from there, and is under that state's distance-selling threshold
|
|
507
|
+
(EUR 10,000 in the euro area, the national figure elsewhere, #2360).
|
|
508
|
+
Every statement in ``body`` is a literal ``True``, and the server
|
|
509
|
+
refuses unknown keys: send the fields exactly as the API documents
|
|
510
|
+
them (``statementVersion``, ``establishmentCountry``, the seven
|
|
511
|
+
statements, ``currentYearTotal`` as ``{"amount", "currency"}`` in the
|
|
512
|
+
threshold's currency, and ``currentYearTotalAsOf``).
|
|
513
|
+
:meth:`get_distance_sales_threshold` says which currency applies; send
|
|
514
|
+
its ``amount`` and ``currency`` as ``reviewedThreshold`` and the API
|
|
515
|
+
refuses the attestation with 409 if the threshold changed since.
|
|
516
|
+
|
|
517
|
+
Quotes whose request declares ``dispatchCountry`` equal to
|
|
518
|
+
``establishmentCountry`` can then price consumer sales at the dispatch
|
|
519
|
+
state's rate. Where the engine consulted the attestation, the quote's
|
|
520
|
+
``explainability.vat.thresholdAttestation`` says whether it was priced on
|
|
521
|
+
it. Returns the merchant profile, whose
|
|
522
|
+
``distanceSalesAttestation`` shows the attestation on file, ``inEffect``
|
|
523
|
+
and ``expiresAt``.
|
|
524
|
+
|
|
525
|
+
Needs a live key with ``self:write``; the API refuses Test-mode keys.
|
|
526
|
+
It does not check who holds the key: its owner/admin check applies only
|
|
527
|
+
to dashboard sessions. The server stamps the year and
|
|
528
|
+
``attestedAt``, and the route takes no Idempotency-Key: a retry after a
|
|
529
|
+
lost response re-affirms the same statements with a later
|
|
530
|
+
``attestedAt`` and a second audit entry.
|
|
531
|
+
"""
|
|
532
|
+
return self._request(
|
|
533
|
+
"PUT", "/v1/merchant-profiles/distance-sales-attestation", json=body
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def get_distance_sales_threshold(self, country: str) -> dict:
|
|
537
|
+
"""The distance-selling threshold an attestation is held to (#2360).
|
|
538
|
+
|
|
539
|
+
Returns ``country``, ``amount``, ``currency``, ``basis``,
|
|
540
|
+
``legalCitation`` and ``effectiveFrom`` for a seller established in
|
|
541
|
+
``country`` today: state ``currentYearTotal`` in that ``currency`` and
|
|
542
|
+
no higher than ``amount``. Raises on a 404 while Fleet has no
|
|
543
|
+
threshold for the state, and the attestation is then refused. Needs a
|
|
544
|
+
key with ``self:read``.
|
|
545
|
+
"""
|
|
546
|
+
return self._request(
|
|
547
|
+
"GET",
|
|
548
|
+
"/v1/merchant-profiles/distance-sales-threshold",
|
|
549
|
+
params={"country": country},
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def withdraw_distance_sales_attestation(self) -> dict:
|
|
553
|
+
"""Withdraw the distance-sales attestation.
|
|
554
|
+
|
|
555
|
+
Withdraw it as soon as the current-year total passes the threshold.
|
|
556
|
+
Idempotent: with nothing on file it still returns the merchant profile.
|
|
557
|
+
Same key requirements as :meth:`set_distance_sales_attestation`.
|
|
558
|
+
"""
|
|
559
|
+
return self._request("DELETE", "/v1/merchant-profiles/distance-sales-attestation")
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# PyPI distribution name. The bare `fleet` name is already taken
|
|
7
|
+
# on PyPI, so we publish under `fleet-costs-sdk`. Consumers still
|
|
8
|
+
# `import fleet` because the top-level package directory stays `fleet/`.
|
|
9
|
+
name = "fleet-costs-sdk"
|
|
10
|
+
version = "0.4.0"
|
|
11
|
+
description = "Official Python SDK for the Fleet Costs landed-cost / duty / VAT / freight pricing API."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
# Keep the consumer floor and classifiers aligned with python-sdk-ci.yml,
|
|
14
|
+
# which now tests Python 3.10 through 3.13, including transport/recovery (#1602).
|
|
15
|
+
# Re-check the 3.10 floor when its upstream security support ends in October 2026.
|
|
16
|
+
requires-python = ">=3.10"
|
|
17
|
+
license = "MIT"
|
|
18
|
+
license-files = ["LICENSE"]
|
|
19
|
+
authors = [
|
|
20
|
+
{ name = "Fleet", email = "support@getfleet.dev" },
|
|
21
|
+
]
|
|
22
|
+
maintainers = [
|
|
23
|
+
{ name = "Fleet", email = "support@getfleet.dev" },
|
|
24
|
+
]
|
|
25
|
+
keywords = [
|
|
26
|
+
"fleet",
|
|
27
|
+
"landed-cost",
|
|
28
|
+
"duty",
|
|
29
|
+
"vat",
|
|
30
|
+
"hts",
|
|
31
|
+
"hs-code",
|
|
32
|
+
"customs",
|
|
33
|
+
"import",
|
|
34
|
+
"cross-border",
|
|
35
|
+
"sdk",
|
|
36
|
+
]
|
|
37
|
+
classifiers = [
|
|
38
|
+
"Development Status :: 4 - Beta",
|
|
39
|
+
"Intended Audience :: Developers",
|
|
40
|
+
"License :: OSI Approved :: MIT License",
|
|
41
|
+
"Operating System :: OS Independent",
|
|
42
|
+
"Programming Language :: Python :: 3",
|
|
43
|
+
# Keep this list in step with `requires-python` above; 3.9 was dropped with
|
|
44
|
+
# the floor when it went EOL.
|
|
45
|
+
"Programming Language :: Python :: 3.10",
|
|
46
|
+
"Programming Language :: Python :: 3.11",
|
|
47
|
+
"Programming Language :: Python :: 3.12",
|
|
48
|
+
"Programming Language :: Python :: 3.13",
|
|
49
|
+
"Topic :: Office/Business :: Financial",
|
|
50
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
51
|
+
"Typing :: Typed",
|
|
52
|
+
]
|
|
53
|
+
dependencies = ["httpx>=0.25"]
|
|
54
|
+
|
|
55
|
+
# Public pages only. The SDK is built in a private repository, so a
|
|
56
|
+
# Repository or Issues link would 404 for every customer on the PyPI page
|
|
57
|
+
# (#2335; the npm and Go packages were fixed the same way).
|
|
58
|
+
[project.urls]
|
|
59
|
+
Homepage = "https://getfleet.dev"
|
|
60
|
+
Documentation = "https://docs.getfleet.dev/guides/python-sdk"
|
|
61
|
+
"API reference" = "https://docs.getfleet.dev"
|
|
62
|
+
Support = "https://getfleet.dev/contact"
|
|
63
|
+
|
|
64
|
+
[project.optional-dependencies]
|
|
65
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21"]
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.wheel]
|
|
68
|
+
# Only ship the `fleet/` package — skip tests/, pyproject.toml, etc.
|
|
69
|
+
packages = ["fleet"]
|
|
70
|
+
|
|
71
|
+
[tool.hatch.build.targets.sdist]
|
|
72
|
+
include = [
|
|
73
|
+
"/fleet",
|
|
74
|
+
"/README.md",
|
|
75
|
+
"/LICENSE",
|
|
76
|
+
"/pyproject.toml",
|
|
77
|
+
]
|