hts-lookup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hts_lookup/__init__.py ADDED
@@ -0,0 +1,145 @@
1
+ """Offline lookup and validation of US Harmonized Tariff Schedule (HTS) codes.
2
+
3
+ >>> import hts_lookup as hts
4
+ >>> hts.lookup("6201.40.70").general
5
+ '7.1%'
6
+ >>> hts.is_valid("6201.40.99.99")
7
+ False
8
+ """
9
+ import gzip
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import List, Optional, Tuple
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = ["Line", "lookup", "is_valid", "closest", "children", "search", "format_code", "RELEASE", "TITLE", "EFFECTIVE"]
18
+
19
+
20
+ def _load():
21
+ with gzip.open(Path(__file__).with_name("data") / "hts.json.gz", "rt", encoding="utf-8") as f:
22
+ return json.load(f)
23
+
24
+
25
+ _DATA = _load()
26
+ RELEASE: str = _DATA["release"] # e.g. "2026HTSRev21"
27
+ TITLE: str = _DATA["title"] # e.g. "Revision 21 (2026)"
28
+ EFFECTIVE: str = _DATA["effective"] # MM/DD/YYYY, as published by USITC
29
+ _ROWS = _DATA["rows"] # [digits, indent, description, general, special, column 2, units]
30
+ _INDEX = {r[0]: i for i, r in enumerate(_ROWS) if r[0]}
31
+
32
+
33
+ @dataclass
34
+ class Line:
35
+ code: str # dotted, e.g. "6201.40.70.00"
36
+ description: str
37
+ path: List[Tuple[str, str]] # (code or "", description) from the heading down, including unnumbered levels
38
+ general: str # general (MFN) rate that applies, inherited from the 8-digit line if needed
39
+ special: str = ""
40
+ column2: str = ""
41
+ units: List[str] = field(default_factory=list)
42
+ declarable: bool = True # False for headings and subheadings that have more detailed lines under them
43
+
44
+ def __str__(self):
45
+ return f"{self.code} {self.description} | general {self.general or '-'}"
46
+
47
+
48
+ def _digits(code) -> str:
49
+ return re.sub(r"\D", "", str(code))
50
+
51
+
52
+ def format_code(code) -> str:
53
+ """'6201407000' -> '6201.40.70.00'."""
54
+ d = _digits(code)
55
+ parts = [d[:4], d[4:6], d[6:8], d[8:10]]
56
+ return ".".join(p for p in parts if p)
57
+
58
+
59
+ def _path(i: int) -> List[list]:
60
+ stack: List[list] = []
61
+ start = i
62
+ while start > 0 and not (_ROWS[start][0] and len(_ROWS[start][0]) == 4):
63
+ start -= 1
64
+ for r in _ROWS[start:i + 1]:
65
+ while stack and stack[-1][1] >= r[1]:
66
+ stack.pop()
67
+ stack.append(r)
68
+ return stack
69
+
70
+
71
+ def _has_children(d: str) -> bool:
72
+ return any(k.startswith(d) and len(k) > len(d) for k in _INDEX)
73
+
74
+
75
+ def lookup(code) -> Optional[Line]:
76
+ """Return the line for a 4- to 10-digit code, or None if it is not in the schedule.
77
+
78
+ A six- or eight-digit code that has no row of its own but has lines under it (common in the US
79
+ schedule) is returned as a non-declarable subheading with the description of its first line's parent.
80
+ """
81
+ d = _digits(code)
82
+ i = _INDEX.get(d)
83
+ if i is None:
84
+ sub = sorted(k for k in _INDEX if k.startswith(d) and len(k) > len(d))
85
+ if len(d) < 4 or not sub:
86
+ return None
87
+ first = _INDEX[sub[0]]
88
+ path = _path(first)[:-1]
89
+ top = [k for k in sub if len(k) == min(len(x) for x in sub)]
90
+ if len(top) == 1 and len(top[0]) == 10:
91
+ # e.g. 6201.40.70 has only the line 6201.40.70.00: that line is the answer.
92
+ return lookup(top[0])
93
+ # A subheading with a single line under it carries its text on that line (e.g. 8471.30 -> 8471.30.01.00).
94
+ desc = _ROWS[first][2] if len(top) == 1 else (path[-1][2] if path else "")
95
+ return Line(format_code(d), desc, [(format_code(p[0]) if p[0] else "", p[2]) for p in path], "", declarable=False)
96
+ stack = _path(i)
97
+ r = _ROWS[i]
98
+ general = r[3] or next((s[3] for s in reversed(stack) if s[3]), "")
99
+ special = r[4] or next((s[4] for s in reversed(stack) if s[3]), "")
100
+ column2 = r[5] or next((s[5] for s in reversed(stack) if s[3]), "")
101
+ return Line(format_code(d), r[2], [(format_code(s[0]) if s[0] else "", s[2]) for s in stack], general, special, column2,
102
+ list(r[6]), declarable=not _has_children(d))
103
+
104
+
105
+ def is_valid(code) -> bool:
106
+ """True if the code exists in the current US schedule (as a line or as a subheading with lines under it)."""
107
+ return lookup(code) is not None
108
+
109
+
110
+ def closest(code) -> Optional[Line]:
111
+ """The longest valid code that the given code starts with, e.g. for a made-up 10-digit code."""
112
+ d = _digits(code)
113
+ for n in (10, 8, 6, 4):
114
+ if len(d) >= n:
115
+ hit = lookup(d[:n])
116
+ if hit:
117
+ return hit
118
+ return None
119
+
120
+
121
+ def children(code) -> List[Line]:
122
+ """The next level of numbered lines under a heading or subheading."""
123
+ d = _digits(code)
124
+ kids = sorted(k for k in _INDEX if k.startswith(d) and len(k) > len(d))
125
+ if not kids:
126
+ return []
127
+ step = min(len(k) for k in kids)
128
+ return [lookup(k) for k in kids if len(k) == step]
129
+
130
+
131
+ def search(text: str, limit: int = 20, declarable_only: bool = True) -> List[Line]:
132
+ """Lines whose full official path contains every word of `text` (case-insensitive)."""
133
+ words = [w for w in re.findall(r"[a-z0-9]+", text.lower()) if w]
134
+ out = []
135
+ for k, i in _INDEX.items():
136
+ if declarable_only and len(k) < 8:
137
+ continue
138
+ hay = " ".join(p[2] for p in _path(i)).lower()
139
+ if all(w in hay for w in words):
140
+ line = lookup(k)
141
+ if not declarable_only or line.declarable:
142
+ out.append(line)
143
+ if len(out) >= limit:
144
+ break
145
+ return out
hts_lookup/__main__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Command line: hts-lookup 6201.40.70 | hts-lookup --search "lithium ion" | hts-lookup --children 8507"""
2
+ import argparse
3
+ import sys
4
+
5
+ from . import TITLE, children, closest, lookup, search
6
+
7
+
8
+ def show(line):
9
+ rows = list(line.path) if line.declarable else list(line.path) + [(line.code, line.description)]
10
+ for i, (code, desc) in enumerate(rows):
11
+ print(f"{' ' * i}{code or '·'} {desc}")
12
+ if line.general:
13
+ print(f"General rate: {line.general}" + (f" | Special: {line.special}" if line.special else ""))
14
+ if not line.declarable:
15
+ print("Not a declarable line: more detailed codes exist under it (use --children).")
16
+
17
+
18
+ def main(argv=None):
19
+ p = argparse.ArgumentParser(prog="hts-lookup", description=f"US Harmonized Tariff Schedule lookup ({TITLE}).")
20
+ p.add_argument("code", nargs="?", help="HTS code, 4 to 10 digits, dots optional")
21
+ p.add_argument("--search", help="find lines whose official text contains all these words")
22
+ p.add_argument("--children", metavar="CODE", help="list the next level of lines under a code")
23
+ a = p.parse_args(argv)
24
+ if a.search:
25
+ for line in search(a.search):
26
+ print(line)
27
+ elif a.children:
28
+ for line in children(a.children):
29
+ print(line)
30
+ elif a.code:
31
+ line = lookup(a.code)
32
+ if line:
33
+ show(line)
34
+ else:
35
+ near = closest(a.code)
36
+ print(f"{a.code} is not in the US tariff schedule ({TITLE}).")
37
+ if near:
38
+ print(f"Closest valid code: {near.code} {near.description}")
39
+ return 1
40
+ else:
41
+ p.print_help()
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ sys.exit(main())
Binary file
@@ -0,0 +1,18 @@
1
+ MIT License (code)
2
+
3
+ Copyright (c) 2026 Anders Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17
+
18
+ The data files in steel_sections/data/ are not covered by this licence; see DATA-LICENSE.md.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.1
2
+ Name: hts-lookup
3
+ Version: 0.1.0
4
+ Summary: Offline lookup and validation of US Harmonized Tariff Schedule (HTS) codes: official descriptions, duty rates, closest valid code. No dependencies.
5
+ Author: Anders Lee
6
+ License: MIT (code); public domain US government data (see DATA-LICENSE.md)
7
+ Project-URL: Homepage, https://hs-code.net/
8
+ Keywords: hts,hs code,harmonized tariff schedule,tariff,customs,duty rate,import,trade,classification,usitc
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Office/Business
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # hts-lookup
19
+
20
+ Offline lookup and validation of US Harmonized Tariff Schedule (HTS) codes. The current schedule is bundled
21
+ (about 0.5 MB compressed), so it works without network access or API keys. Pure Python, no dependencies.
22
+
23
+ ```
24
+ pip install hts-lookup
25
+ ```
26
+
27
+ ```python
28
+ import hts_lookup as hts
29
+
30
+ hts.TITLE # 'Revision 21 (2026)'
31
+ line = hts.lookup("6201.40.70")
32
+ line.description # 'Water resistant (634)'
33
+ line.general # '7.1%'
34
+ [c for c, _ in line.path] # ['6201', '6201.40', '', '', '', '', '', '6201.40.70.00']
35
+
36
+ hts.lookup("6109.10.00.12").general # '16.5%' (10-digit lines inherit the 8-digit rate)
37
+ hts.lookup("6201.40").declarable # False (a subheading with lines under it)
38
+ hts.lookup("8471.30").code # '8471.30.01.00' (only one line under it, so that line)
39
+
40
+ hts.is_valid("6201.40.99.99") # False
41
+ hts.closest("6201409999").code # '6201.40'
42
+
43
+ [l.code for l in hts.children("8507.60")]
44
+ hts.search("lithium ion", limit=5)
45
+ ```
46
+
47
+ Command line:
48
+
49
+ ```
50
+ $ hts-lookup 6201.40.70
51
+ $ hts-lookup --search "vinyl stickers"
52
+ $ hts-lookup --children 4202.92
53
+ ```
54
+
55
+ ## Why validate codes
56
+
57
+ HS and HTS codes copied from supplier invoices, old spreadsheets or AI chat answers are often out of date or do not exist.
58
+ `is_valid()` and `closest()` catch those before a shipment does.
59
+
60
+ ## Notes
61
+
62
+ - `general` is the column 1 general (MFN) rate. Extra duties under Chapter 99 depend on the country of origin and are not applied.
63
+ - The first six digits are the international HS code; digits 7 to 10 are US-specific.
64
+ - The bundled schedule is updated with new USITC revisions; check `hts_lookup.TITLE` for the edition you have.
65
+
66
+ Step-by-step product guides, UK and EU rates, and the customs rulings behind each code: [hs-code.net](https://hs-code.net/)
67
+
68
+ Code: MIT. Data: public domain US government work, see DATA-LICENSE.md. Not affiliated with USITC or CBP.
@@ -0,0 +1,9 @@
1
+ hts_lookup/__init__.py,sha256=WSzknsn05X0Z6i8Qu7iXmM7-X_-0vI_BL9auzg6oLag,5420
2
+ hts_lookup/__main__.py,sha256=CGiCpBVNBTkRyCOM4cykT5g3NKY-KiC1lHbHjnD_AA0,1687
3
+ hts_lookup/data/hts.json.gz,sha256=AR9V1oZ6wmhCVwSbA_EmUuIECp1F-6pbDzQRxReP8zA,519394
4
+ hts_lookup-0.1.0.dist-info/LICENSE,sha256=G2hYZ60E0bF6gxfQzDNC9BkuecEF1EVHviTvp6ilaFw,1168
5
+ hts_lookup-0.1.0.dist-info/METADATA,sha256=PpmIGIwXrvyutcPM2lA3_b3qWi9sp5jGua_T6xzpdME,2741
6
+ hts_lookup-0.1.0.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
7
+ hts_lookup-0.1.0.dist-info/entry_points.txt,sha256=0RkQqETthGFwPcFiZ0BMMeBw447AeDofTFiv-6bGp5Y,56
8
+ hts_lookup-0.1.0.dist-info/top_level.txt,sha256=o8Vh-CdEpS_kQUOj5F5ITWFuAopDjo5Qcu3-TmSH5yo,11
9
+ hts_lookup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.4)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hts-lookup = hts_lookup.__main__:main
@@ -0,0 +1 @@
1
+ hts_lookup