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.
@@ -0,0 +1,9 @@
1
+ # Python build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ *.pyc
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -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,6 @@
1
+ """Fleet Python SDK — landed-cost API client."""
2
+
3
+ from .client import FleetAPIError, FleetClient, FleetResponse
4
+
5
+ __all__ = ["FleetAPIError", "FleetClient", "FleetResponse"]
6
+ __version__ = "0.4.0"
@@ -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
+ ]