Skip to content

Commit 15956cb

Browse files
committed
Add immutable Tariff edition selection and contextual results
1 parent 83e55f7 commit 15956cb

7 files changed

Lines changed: 175 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 1.7.0 - 2026-09-24
4+
5+
- Add exact Tariff edition/date selection, answering metadata, contextual search lineage and explanatory null reasons. Explicit selections reject unsupported or mismatched server responses.
6+
37
## 1.6.0 - 2026-09-24
48

59
Adds Time location inputs and explicit ambiguity candidates, filtered timezone discovery, multiple conversion targets, wall-time disambiguation, and standard/seasonal offset detail. Existing Timezone methods and API 2.0.0 selection remain unchanged.

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Get a key at [parseapi.com](https://parseapi.com). The client also reads `PARSEA
1717

1818
## API versions
1919

20-
Version 1.6.0 explicitly selects the API contract supported by this SDK. It sends `Parse-Version: 2.0.0` on every lookup so responses match the API contract supported by the package. Your key and the team's saved default stay the same.
20+
Version 1.7.0 explicitly selects the API contract supported by this SDK. It sends `Parse-Version: 2.0.0` on every lookup so responses match the API contract supported by the package. Your key and the team's saved default stay the same.
2121

2222
Upgrade the dependency in staging, review the [release notes](https://parseapi.com/docs/releases), and test the application before deploying the same code and dependency version to production. Commit your dependency lockfile so the tested package travels with your deployment. Future major SDK upgrades can select a newer API contract.
2323

@@ -231,6 +231,12 @@ parse.weather(40.7128, -74.006, deep=True, date="2026-08-15")
231231

232232
Tariff starts with the general schedule line. Paid deep adds units and the special and other schedule columns. An optional origin then resolves country-specific measures. The three calls below show those successive choices. Without origin, schedule detail is still returned and origin-dependent fields are null. A null effective rate is not a zero rate.
233233

234+
Tariff lookup and search accept an optional `edition` fingerprint and `date` (`YYYY-MM-DD`). The edition pins exact immutable source bytes. A date is accepted only when verified source coverage exists. An edition without a date returns undated schedule context (`date: null`). Default requests use today. Paid detail exposes an open-string `reason` when `effective_rate` is null, including `incomplete_coverage`. A null rate never means zero. Explicit selections fail with `tariff_selection_mismatch` if an older server ignores the requested scope.
235+
236+
Origin means where the goods originate, not where they ship from. The effective rate covers matched stored schedule measures only. It is not complete duty or landed cost.
237+
238+
Codes contain 4, 6, 8 or 10 ASCII digits; dots and whitespace are optional. Search returns up to 20 description matches with parent `lineage` so a result named "Other" has context. Search is not product classification. In deep, `measures: null` means origin-dependent measures were not resolved. `measures: []` means the resolved lookup found none.
239+
234240
```python
235241
parse.tariff("8471.30.01.00")
236242
parse.tariff("8471.30.01.00", deep=True)

‎api/public.json‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,18 @@
13241324
"kind": "KEYWORD_ONLY",
13251325
"name": "origin",
13261326
"type": "Optional[str]"
1327+
},
1328+
{
1329+
"default": null,
1330+
"kind": "KEYWORD_ONLY",
1331+
"name": "edition",
1332+
"type": "Optional[str]"
1333+
},
1334+
{
1335+
"default": null,
1336+
"kind": "KEYWORD_ONLY",
1337+
"name": "date",
1338+
"type": "Optional[str]"
13271339
}
13281340
],
13291341
"returns": "Json"
@@ -1335,6 +1347,18 @@
13351347
"kind": "POSITIONAL_OR_KEYWORD",
13361348
"name": "query",
13371349
"type": "str"
1350+
},
1351+
{
1352+
"default": null,
1353+
"kind": "KEYWORD_ONLY",
1354+
"name": "edition",
1355+
"type": "Optional[str]"
1356+
},
1357+
{
1358+
"default": null,
1359+
"kind": "KEYWORD_ONLY",
1360+
"name": "date",
1361+
"type": "Optional[str]"
13381362
}
13391363
],
13401364
"returns": "Json"
@@ -3039,6 +3063,18 @@
30393063
"kind": "KEYWORD_ONLY",
30403064
"name": "origin",
30413065
"type": "Optional[str]"
3066+
},
3067+
{
3068+
"default": null,
3069+
"kind": "KEYWORD_ONLY",
3070+
"name": "edition",
3071+
"type": "Optional[str]"
3072+
},
3073+
{
3074+
"default": null,
3075+
"kind": "KEYWORD_ONLY",
3076+
"name": "date",
3077+
"type": "Optional[str]"
30423078
}
30433079
],
30443080
"returns": "Json"
@@ -3050,6 +3086,18 @@
30503086
"kind": "POSITIONAL_OR_KEYWORD",
30513087
"name": "query",
30523088
"type": "str"
3089+
},
3090+
{
3091+
"default": null,
3092+
"kind": "KEYWORD_ONLY",
3093+
"name": "edition",
3094+
"type": "Optional[str]"
3095+
},
3096+
{
3097+
"default": null,
3098+
"kind": "KEYWORD_ONLY",
3099+
"name": "date",
3100+
"type": "Optional[str]"
30533101
}
30543102
],
30553103
"returns": "Json"

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "parseapi"
7-
version = "1.6.0"
7+
version = "1.7.0"
88
description = "Official ParseAPI client for Python. One key, minimal JSON, fast."
99
readme = "README.md"
1010
license = "MIT"

‎src/parseapi/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
from ._client import AsyncParseAPI, ParseAPI, ParseAPIError
44

5-
__version__ = "1.6.0"
5+
__version__ = "1.7.0"
66
__all__ = ["ParseAPI", "AsyncParseAPI", "ParseAPIError", "__version__"]

‎src/parseapi/_client.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import re
45
import math
56
import random
67
import time
@@ -10,7 +11,7 @@
1011

1112
import httpx
1213

13-
VERSION = "1.6.0"
14+
VERSION = "1.7.0"
1415
_API_VERSION = "2.0.0"
1516
DEFAULT_BASE_URL = "https://api.parseapi.com"
1617
DEFAULT_TIMEOUT = 10.0
@@ -474,19 +475,30 @@ def search(self, query: str, *, limit: Optional[int] = None, deep: bool = False)
474475
return self._client._get("/naics", {"q": query, "limit": limit, "deep": deep})
475476

476477

478+
def _tariff_selection(result: Json, edition: Optional[str], date: Optional[str]) -> Json:
479+
if (edition is not None or date is not None) and (
480+
not isinstance(result.get("edition"), str) or re.fullmatch(r"[a-f0-9]{64}", result["edition"]) is None
481+
or (edition is not None and result.get("edition") != edition)
482+
or (result.get("date") != date)
483+
):
484+
raise ParseAPIError(0, "tariff_selection_mismatch", "Tariff response did not confirm the requested edition/date. The server may not support this selection.", None, None)
485+
return result
486+
487+
477488
class _TariffSync:
478489
def __init__(self, client: ParseAPI):
479490
self._client = client
480491

481-
def __call__(self, code: str, *, deep: bool = False, origin: Optional[str] = None) -> Json:
492+
def __call__(self, code: str, *, deep: bool = False, origin: Optional[str] = None, edition: Optional[str] = None, date: Optional[str] = None) -> Json:
482493
"""Look up the general US duty schedule line. Paid deep adds units and the special and other
483494
schedule columns. Add origin with deep to resolve country-specific measures. Without
484495
origin, schedule detail remains available and origin-dependent fields are null. A null
485496
effective rate is not a zero rate."""
486-
return self._client._get(f"/tariff/{_seg(code)}", {"deep": deep, "origin": origin})
497+
return _tariff_selection(self._client._get(f"/tariff/{_seg(code)}", {"deep": deep, "origin": origin, "edition": edition, "date": date}), edition, date)
498+
499+
def search(self, query: str, *, edition: Optional[str] = None, date: Optional[str] = None) -> Json:
500+
return _tariff_selection(self._client._get("/tariff", {"q": query, "edition": edition, "date": date}), edition, date)
487501

488-
def search(self, query: str) -> Json:
489-
return self._client._get("/tariff", {"q": query})
490502

491503

492504
class AsyncParseAPI:
@@ -861,15 +873,16 @@ class _TariffAsync:
861873
def __init__(self, client: AsyncParseAPI):
862874
self._client = client
863875

864-
async def __call__(self, code: str, *, deep: bool = False, origin: Optional[str] = None) -> Json:
876+
async def __call__(self, code: str, *, deep: bool = False, origin: Optional[str] = None, edition: Optional[str] = None, date: Optional[str] = None) -> Json:
865877
"""Look up the general US duty schedule line. Paid deep adds units and the special and other
866878
schedule columns. Add origin with deep to resolve country-specific measures. Without
867879
origin, schedule detail remains available and origin-dependent fields are null. A null
868880
effective rate is not a zero rate."""
869-
return await self._client._get(f"/tariff/{_seg(code)}", {"deep": deep, "origin": origin})
881+
return _tariff_selection(await self._client._get(f"/tariff/{_seg(code)}", {"deep": deep, "origin": origin, "edition": edition, "date": date}), edition, date)
882+
883+
async def search(self, query: str, *, edition: Optional[str] = None, date: Optional[str] = None) -> Json:
884+
return _tariff_selection(await self._client._get("/tariff", {"q": query, "edition": edition, "date": date}), edition, date)
870885

871-
async def search(self, query: str) -> Json:
872-
return await self._client._get("/tariff", {"q": query})
873886

874887

875888
class _DateSync:

‎tests/test_tariff.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import asyncio
2+
3+
import httpx
4+
import pytest
5+
6+
from parseapi import AsyncParseAPI, ParseAPI, ParseAPIError
7+
8+
9+
@pytest.mark.parametrize("async_client", [False, True])
10+
@pytest.mark.parametrize("extra", [{}, {"lineage": None}, {"lineage": []}, {"lineage": ["Live horses", "Other horses"]}])
11+
def test_tariff_search_preserves_parent_context_and_older_results(async_client, extra):
12+
body = {"q": "horses & ponies", "revision": "fixture", "lines": [{"hts": "0101.29.00.90", "description": "Other", "general": None, **extra, "future": True}]}
13+
14+
def respond(request):
15+
assert request.url.path == "/tariff"
16+
assert dict(request.url.params) == {"q": "horses & ponies"}
17+
return httpx.Response(200, json=body)
18+
19+
transport = httpx.MockTransport(respond)
20+
if async_client:
21+
async def run():
22+
async with AsyncParseAPI("test", transport=transport) as client:
23+
assert await client.tariff.search("horses & ponies") == body
24+
asyncio.run(run())
25+
else:
26+
with ParseAPI("test", transport=transport) as client:
27+
assert client.tariff.search("horses & ponies") == body
28+
29+
@pytest.mark.parametrize("async_client", [False, True])
30+
def test_tariff_edition_date_roundtrip(async_client):
31+
edition = "a" * 64
32+
body = {"hts": "0101", "revision": "fixture", "edition": edition, "date": "2026-09-15", "deep": {"effective_rate": None, "reason": "future_reason", "measures": []}}
33+
seen = []
34+
35+
def respond(request):
36+
seen.append(dict(request.url.params))
37+
return httpx.Response(200, json={**body, "date": request.url.params.get("date")})
38+
39+
transport = httpx.MockTransport(respond)
40+
if async_client:
41+
async def run():
42+
async with AsyncParseAPI("test", transport=transport) as client:
43+
assert await client.tariff("0101", deep=True, origin="CA", edition=edition, date="2026-09-15") == body
44+
assert await client.tariff.search("horses", edition=edition, date="2026-09-15") == body
45+
await client.tariff("0101", edition=edition)
46+
asyncio.run(run())
47+
else:
48+
with ParseAPI("test", transport=transport) as client:
49+
assert client.tariff("0101", deep=True, origin="CA", edition=edition, date="2026-09-15") == body
50+
assert client.tariff.search("horses", edition=edition, date="2026-09-15") == body
51+
client.tariff("0101", edition=edition)
52+
assert seen == [{"deep": "true", "origin": "CA", "edition": edition, "date": "2026-09-15"}, {"q": "horses", "edition": edition, "date": "2026-09-15"}, {"edition": edition}]
53+
54+
@pytest.mark.parametrize("async_client", [False, True])
55+
def test_tariff_rejects_ignored_selection(async_client):
56+
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"revision": "old"}))
57+
if async_client:
58+
async def run():
59+
async with AsyncParseAPI("test", transport=transport) as client:
60+
with pytest.raises(Exception, match="did not confirm"):
61+
await client.tariff("0101", edition="a" * 64)
62+
with pytest.raises(Exception, match="did not confirm"):
63+
await client.tariff.search("horses", date="2026-09-15")
64+
asyncio.run(run())
65+
else:
66+
with ParseAPI("test", transport=transport) as client:
67+
with pytest.raises(Exception, match="did not confirm"):
68+
client.tariff("0101", edition="a" * 64)
69+
with pytest.raises(Exception, match="did not confirm"):
70+
client.tariff.search("horses", date="2026-09-15")
71+
72+
73+
@pytest.mark.parametrize("async_client", [False, True])
74+
@pytest.mark.parametrize("edition", ["legacy", "", "A" * 64, "a" * 64 + "\n"])
75+
def test_tariff_rejects_invalid_returned_edition(async_client, edition):
76+
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"edition": edition, "date": "2026-09-15"}))
77+
if async_client:
78+
async def run():
79+
async with AsyncParseAPI("test", transport=transport) as client:
80+
for operation, value in [(client.tariff, "0101"), (client.tariff.search, "horses")]:
81+
with pytest.raises(ParseAPIError) as error:
82+
await operation(value, date="2026-09-15")
83+
assert error.value.code == "tariff_selection_mismatch"
84+
assert error.value.status == 0
85+
asyncio.run(run())
86+
else:
87+
with ParseAPI("test", transport=transport) as client:
88+
for operation, value in [(client.tariff, "0101"), (client.tariff.search, "horses")]:
89+
with pytest.raises(ParseAPIError) as error:
90+
operation(value, date="2026-09-15")
91+
assert error.value.code == "tariff_selection_mismatch"
92+
assert error.value.status == 0

0 commit comments

Comments
 (0)