ziptz-us 0.1.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.
- ziptz_us-0.1.0/.github/workflows/test.yml +103 -0
- ziptz_us-0.1.0/CHANGELOG.md +52 -0
- ziptz_us-0.1.0/LICENSE +21 -0
- ziptz_us-0.1.0/MANIFEST.in +17 -0
- ziptz_us-0.1.0/Makefile +37 -0
- ziptz_us-0.1.0/NOTICE +67 -0
- ziptz_us-0.1.0/PKG-INFO +362 -0
- ziptz_us-0.1.0/README.md +334 -0
- ziptz_us-0.1.0/__init__.py +22 -0
- ziptz_us-0.1.0/go.mod +3 -0
- ziptz_us-0.1.0/py.typed +3 -0
- ziptz_us-0.1.0/pyproject.toml +61 -0
- ziptz_us-0.1.0/setup.cfg +4 -0
- ziptz_us-0.1.0/test_ziptz.py +351 -0
- ziptz_us-0.1.0/testdata/cases.json +154 -0
- ziptz_us-0.1.0/tools/genzips.py +359 -0
- ziptz_us-0.1.0/tools/sweep.go +53 -0
- ziptz_us-0.1.0/tools/sweep.py +46 -0
- ziptz_us-0.1.0/ziptz.go +339 -0
- ziptz_us-0.1.0/ziptz.py +333 -0
- ziptz_us-0.1.0/ziptz_test.go +472 -0
- ziptz_us-0.1.0/ziptz_us.egg-info/PKG-INFO +362 -0
- ziptz_us-0.1.0/ziptz_us.egg-info/SOURCES.txt +28 -0
- ziptz_us-0.1.0/ziptz_us.egg-info/dependency_links.txt +1 -0
- ziptz_us-0.1.0/ziptz_us.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Runs what the README tells a contributor to run, on every push and pull
|
|
2
|
+
# request. Nothing here is clever: if it does not match `make test` locally,
|
|
3
|
+
# the local one is the definition and this is wrong.
|
|
4
|
+
name: test
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
# The floor and the ceiling. The floor is what the project says it needs --
|
|
13
|
+
# go.mod says 1.21 and pyproject says 3.9 -- and is where a syntax or
|
|
14
|
+
# stdlib slip shows up. The ceiling is where a deprecation shows up first.
|
|
15
|
+
# There is no platform axis: this library reads no files, opens no sockets
|
|
16
|
+
# and calls nothing but the standard library, so the only OS-dependent
|
|
17
|
+
# thing it touches is the tz database, and Location's behaviour there is
|
|
18
|
+
# covered by the cases rather than by the runner.
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
include:
|
|
23
|
+
- go: "1.21"
|
|
24
|
+
python: "3.9"
|
|
25
|
+
- go: "stable"
|
|
26
|
+
python: "3.13"
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- uses: actions/setup-go@v5
|
|
32
|
+
with:
|
|
33
|
+
go-version: ${{ matrix.go }}
|
|
34
|
+
|
|
35
|
+
- uses: actions/setup-python@v5
|
|
36
|
+
with:
|
|
37
|
+
python-version: ${{ matrix.python }}
|
|
38
|
+
|
|
39
|
+
- name: gofmt
|
|
40
|
+
# gofmt prints the files it would change and says nothing otherwise, so
|
|
41
|
+
# any output at all is the failure.
|
|
42
|
+
run: |
|
|
43
|
+
unformatted=$(gofmt -l .)
|
|
44
|
+
if [ -n "$unformatted" ]; then
|
|
45
|
+
echo "gofmt would rewrite:"
|
|
46
|
+
echo "$unformatted"
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
- name: vet
|
|
51
|
+
run: go vet ./...
|
|
52
|
+
|
|
53
|
+
- name: compile the Python side
|
|
54
|
+
run: python3 -m compileall -q ziptz.py __init__.py test_ziptz.py tools
|
|
55
|
+
|
|
56
|
+
# The annotations are shipped, so they are a promise to callers rather
|
|
57
|
+
# than a comment: --strict is what keeps them true. It has already caught
|
|
58
|
+
# one -- abbrev() returning tzinfo.tzname()'s Optional through a -> str.
|
|
59
|
+
- name: mypy
|
|
60
|
+
run: |
|
|
61
|
+
pip install mypy
|
|
62
|
+
mypy --strict ziptz.py
|
|
63
|
+
|
|
64
|
+
# Both suites, the text-level parity of the two generated tables, and the
|
|
65
|
+
# sweep -- every ZIP there is, through both libraries, compared.
|
|
66
|
+
- name: make test
|
|
67
|
+
run: make test
|
|
68
|
+
|
|
69
|
+
# The wheel is what most people will actually get, and it is the only form
|
|
70
|
+
# where the package layout, the test data and NOTICE can be wrong without any
|
|
71
|
+
# of the above noticing.
|
|
72
|
+
package:
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
steps:
|
|
75
|
+
- uses: actions/checkout@v4
|
|
76
|
+
- uses: actions/setup-python@v5
|
|
77
|
+
with:
|
|
78
|
+
python-version: "3.13"
|
|
79
|
+
- run: pip install build twine
|
|
80
|
+
- run: python -m build
|
|
81
|
+
- name: the metadata PyPI will render
|
|
82
|
+
run: twine check dist/*
|
|
83
|
+
- name: what landed in the wheel
|
|
84
|
+
run: |
|
|
85
|
+
python - <<'PY'
|
|
86
|
+
import pathlib, zipfile
|
|
87
|
+
wheel = next(pathlib.Path("dist").glob("*.whl"))
|
|
88
|
+
names = zipfile.ZipFile(wheel).namelist()
|
|
89
|
+
print("\n".join(sorted(names)))
|
|
90
|
+
# py.typed is the one of these that is silent when it goes missing:
|
|
91
|
+
# the package still installs and still works, it just stops being
|
|
92
|
+
# typed, and nothing but this notices.
|
|
93
|
+
for want in ("ziptz/ziptz.py", "ziptz/testdata/cases.json", "ziptz/py.typed"):
|
|
94
|
+
assert want in names, f"missing from the wheel: {want}"
|
|
95
|
+
# NOTICE carries the ODbL attribution; a wheel without it is a
|
|
96
|
+
# redistribution that dropped something it has to keep.
|
|
97
|
+
assert any(n.endswith("NOTICE") for n in names), "missing from the wheel: NOTICE"
|
|
98
|
+
assert any(n.endswith("LICENSE") for n in names), "missing from the wheel: LICENSE"
|
|
99
|
+
PY
|
|
100
|
+
- name: the installed copy proves itself
|
|
101
|
+
run: |
|
|
102
|
+
pip install dist/*.whl
|
|
103
|
+
cd /tmp && python -m unittest ziptz.test_ziptz
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
What changed between releases, and what it means for a caller. Versions follow
|
|
4
|
+
[semantic versioning](https://semver.org): the answer a ZIP resolves to is part
|
|
5
|
+
of the API, so a table regeneration that *moves* a ZIP between zones is a minor
|
|
6
|
+
bump at least, not a patch.
|
|
7
|
+
|
|
8
|
+
The version is written in `ziptz.go`, `ziptz.py` and `pyproject.toml`, and
|
|
9
|
+
`make parity` refuses to build if the three disagree.
|
|
10
|
+
|
|
11
|
+
## Unreleased
|
|
12
|
+
|
|
13
|
+
Nothing yet.
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
First release, and the first as a module of its own — the library was extracted
|
|
18
|
+
from the [clock](https://github.com/choey/clock) repository it grew up in, and
|
|
19
|
+
its import path changed with it:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
github.com/choey/clock/ziptz -> github.com/choey/ziptz
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
On PyPI the distribution is **`ziptz-us`**, while the module is still `ziptz`.
|
|
26
|
+
The bare name is held by a 2013-era registration with no files attached, so
|
|
27
|
+
`pip install ziptz` fails for everyone and only PEP 541 could free it; `-us` is
|
|
28
|
+
in any case an accurate thing to call a library that resolves US ZIP codes and
|
|
29
|
+
nothing else. Nobody's `import ziptz` changes.
|
|
30
|
+
|
|
31
|
+
Nothing else changed in the split; the tables, the answers and the error texts
|
|
32
|
+
are the ones the clock had been using.
|
|
33
|
+
|
|
34
|
+
What it does, for a first reader:
|
|
35
|
+
|
|
36
|
+
- `Zone`/`zone` — a 3- or 5-digit US ZIP to an IANA name, from about 1.3 KB of
|
|
37
|
+
tables compiled into the source. No dependencies, no data files, no network.
|
|
38
|
+
- `Location`/`location`, `Abbrev`/`abbrev`, `Generic`/`generic` — the same
|
|
39
|
+
answer as a loaded zone, as the abbreviation at an instant, and as the
|
|
40
|
+
daylight-saving-agnostic short name.
|
|
41
|
+
- `PrefixZone`/`prefix_zone` and `ExactZone`/`exact_zone` — the two table
|
|
42
|
+
lookups the above are composed from, for callers who want to know which one
|
|
43
|
+
answered.
|
|
44
|
+
|
|
45
|
+
Accuracy: five digits are exact; three digits give the majority zone for that
|
|
46
|
+
prefix, right for 33,558 of 33,791 ZIPs and wrong for the 233 that sit on the
|
|
47
|
+
losing side of a boundary their prefix has to round across. PO-box and
|
|
48
|
+
single-building ZIPs have no delivery area in the source data and fall back to
|
|
49
|
+
their prefix even when given in full.
|
|
50
|
+
|
|
51
|
+
The Go and Python implementations are held to the same answers by a sweep of
|
|
52
|
+
all 101,000 tokens on every build, not by inspection.
|
ziptz_us-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cho
|
|
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,17 @@
|
|
|
1
|
+
# What the sdist carries beyond the Python package.
|
|
2
|
+
#
|
|
3
|
+
# Without this it shipped only the Python half, while carrying a README that
|
|
4
|
+
# documents `make test`, `make regen` and `go test ./...` -- none of which the
|
|
5
|
+
# sdist could run, and one of which is how you would check the two halves still
|
|
6
|
+
# agree. An sdist is meant to be the project, not the importable part of it.
|
|
7
|
+
include CHANGELOG.md
|
|
8
|
+
include Makefile
|
|
9
|
+
include go.mod
|
|
10
|
+
include ziptz.go
|
|
11
|
+
include ziptz_test.go
|
|
12
|
+
recursive-include tools *.py *.go
|
|
13
|
+
recursive-include .github *.yml
|
|
14
|
+
|
|
15
|
+
# Not the generator's download cache, which is megabytes and reproducible from
|
|
16
|
+
# the URL inside genzips.py.
|
|
17
|
+
prune tools/cache
|
ziptz_us-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
.PHONY: test test-go test-py parity sweep regen
|
|
2
|
+
|
|
3
|
+
test: test-go test-py parity sweep
|
|
4
|
+
|
|
5
|
+
test-go:
|
|
6
|
+
go test ./...
|
|
7
|
+
|
|
8
|
+
test-py:
|
|
9
|
+
python3 -m unittest -q test_ziptz
|
|
10
|
+
|
|
11
|
+
# What neither suite can check, because each reads only its own language: the
|
|
12
|
+
# generated tables, the hand-written ZONES and GENERIC tables, and the version
|
|
13
|
+
# in its three places. See the header of the script.
|
|
14
|
+
parity:
|
|
15
|
+
@tools/parity.sh $(V)
|
|
16
|
+
|
|
17
|
+
# Every ZIP there is, through both libraries, compared. The tests above cover
|
|
18
|
+
# the cases someone thought of; this covers the ones nobody did -- all 1,000
|
|
19
|
+
# prefixes and all 100,000 five-digit codes, answers and error text alike.
|
|
20
|
+
sweep:
|
|
21
|
+
@out=$${TMPDIR:-/tmp}/ziptz-sweep.$$$$; mkdir -p $$out; \
|
|
22
|
+
go run tools/sweep.go >$$out/go && python3 tools/sweep.py >$$out/py && \
|
|
23
|
+
if cmp -s $$out/go $$out/py; then \
|
|
24
|
+
echo "sweep: $$(wc -l <$$out/go | tr -d ' ') answers, identical"; \
|
|
25
|
+
else \
|
|
26
|
+
echo "sweep: the two libraries disagree:"; diff $$out/py $$out/go | head -20; \
|
|
27
|
+
rm -rf $$out; exit 1; \
|
|
28
|
+
fi; rm -rf $$out
|
|
29
|
+
|
|
30
|
+
# Rewrites the tables in ziptz.go and ziptz.py from Census data -- see
|
|
31
|
+
# "Regenerating" in README.md, and expect to need it almost never. Needs
|
|
32
|
+
# timezonefinder, which nothing else here does:
|
|
33
|
+
#
|
|
34
|
+
# python3 -m venv .venv && .venv/bin/pip install timezonefinder
|
|
35
|
+
# .venv/bin/python tools/genzips.py
|
|
36
|
+
regen:
|
|
37
|
+
tools/genzips.py
|
ziptz_us-0.1.0/NOTICE
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
ziptz
|
|
2
|
+
Copyright (c) 2026 cho
|
|
3
|
+
|
|
4
|
+
Distributed on PyPI as "ziptz-us" and imported as "ziptz"; the Go module is
|
|
5
|
+
github.com/choey/ziptz. All three name this one library, so a licence audit
|
|
6
|
+
that finds this file under ziptz_us-*.dist-info/ is looking at the right thing.
|
|
7
|
+
|
|
8
|
+
The source code in this repository is licensed under the MIT License; see
|
|
9
|
+
LICENSE.
|
|
10
|
+
|
|
11
|
+
This file records where the generated lookup tables in ziptz.go and ziptz.py
|
|
12
|
+
come from, and the attribution their sources ask for. It travels with the
|
|
13
|
+
source and with any distribution of it.
|
|
14
|
+
|
|
15
|
+
--------------------------------------------------------------------------------
|
|
16
|
+
ZIP code centroids
|
|
17
|
+
--------------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
US Census Bureau ZCTA Gazetteer file (2024).
|
|
20
|
+
|
|
21
|
+
A work of the United States Government, and so not subject to copyright
|
|
22
|
+
protection in the United States (17 U.S.C. Sec. 105). No conditions attach.
|
|
23
|
+
|
|
24
|
+
https://www.census.gov/geographies/reference-files/time-series/geo/
|
|
25
|
+
gazetteer-files.html
|
|
26
|
+
|
|
27
|
+
--------------------------------------------------------------------------------
|
|
28
|
+
Time zone boundaries
|
|
29
|
+
--------------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
timezone-boundary-builder, by Evan Siroky, whose boundaries derive from
|
|
32
|
+
OpenStreetMap. Made available under the Open Database License (ODbL) v1.0.
|
|
33
|
+
|
|
34
|
+
https://github.com/evansiroky/timezone-boundary-builder
|
|
35
|
+
https://opendatacommons.org/licenses/odbl/1-0/
|
|
36
|
+
|
|
37
|
+
Reached at generation time through timezonefinder, a development-only
|
|
38
|
+
dependency that is neither imported by this library nor shipped with it.
|
|
39
|
+
|
|
40
|
+
https://github.com/jannikmi/timezonefinder
|
|
41
|
+
|
|
42
|
+
Attribution, as ODbL Sec. 4.3 asks for:
|
|
43
|
+
|
|
44
|
+
Contains information from timezone-boundary-builder, which is made
|
|
45
|
+
available under the Open Database License (ODbL), and from OpenStreetMap,
|
|
46
|
+
(c) OpenStreetMap contributors.
|
|
47
|
+
|
|
48
|
+
--------------------------------------------------------------------------------
|
|
49
|
+
What the tables are
|
|
50
|
+
--------------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
The tables are not a copy or a redistribution of the boundary data. They were
|
|
53
|
+
produced by taking public-domain Census ZCTA centroids and asking, for each
|
|
54
|
+
one, which zone it falls in -- then reducing 33,791 answers to 157 run-length
|
|
55
|
+
range records over 3-digit prefixes plus 233 individually named exceptions,
|
|
56
|
+
about 1.3 KB in total. They carry no geometry: no boundary, coordinate, or
|
|
57
|
+
shape from the source survives in them, and the source cannot be reconstructed
|
|
58
|
+
from them.
|
|
59
|
+
|
|
60
|
+
This is recorded as an aggregate rather than a substantial extract of the
|
|
61
|
+
source database -- a Produced Work in ODbL's terms rather than a Derivative
|
|
62
|
+
Database -- which is why the code is offered under a permissive licence with
|
|
63
|
+
the attribution above carried alongside.
|
|
64
|
+
|
|
65
|
+
Anyone redistributing this library should keep this file with it. Anyone whose
|
|
66
|
+
own use makes that characterisation matter to them should read the ODbL and
|
|
67
|
+
reach their own conclusion rather than relying on this note.
|
ziptz_us-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ziptz-us
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: US ZIP code to IANA time zone, standard library only
|
|
5
|
+
Author-email: cho <choey2k5@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/choey/ziptz
|
|
8
|
+
Project-URL: Repository, https://github.com/choey/ziptz
|
|
9
|
+
Project-URL: Issues, https://github.com/choey/ziptz/issues
|
|
10
|
+
Keywords: zip,zipcode,postal,timezone,time-zone,tz,iana,olson,usa
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Localization
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
License-File: NOTICE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# ziptz
|
|
30
|
+
|
|
31
|
+
US ZIP code to IANA time zone, in Go and in Python, from about 1.3 KB of
|
|
32
|
+
tables. No dependencies, no data files, no network: both tables are string
|
|
33
|
+
constants compiled into the source, and the first lookup unpacks them into a
|
|
34
|
+
map — so the tables stay small on disk and answering one is a hash.
|
|
35
|
+
|
|
36
|
+
```go
|
|
37
|
+
name, err := ziptz.Zone("94110") // "America/Los_Angeles"
|
|
38
|
+
loc, err := ziptz.Location("10001") // *time.Location
|
|
39
|
+
abb, err := ziptz.Abbrev("94110", when) // "PST" in January, "PDT" in July
|
|
40
|
+
gen, err := ziptz.Generic("94110") // "PT", whatever the date
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
ziptz.zone("94110") # 'America/Los_Angeles'
|
|
45
|
+
ziptz.location("10001") # ZoneInfo(key='America/New_York')
|
|
46
|
+
ziptz.abbrev("94110", when) # 'PST' in January, 'PDT' in July
|
|
47
|
+
ziptz.generic("94110") # 'PT', whatever the date
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The two implementations answer identically for every ZIP code, down to the
|
|
51
|
+
wording of the errors — the tables are generated into both in one pass by
|
|
52
|
+
[`tools/genzips.py`](tools/genzips.py), and `make test` puts all 101,000
|
|
53
|
+
tokens through both and compares every answer. Not a claim; a build step.
|
|
54
|
+
|
|
55
|
+
## What it is for, and what it is not for
|
|
56
|
+
|
|
57
|
+
It is small enough to stop being a dependency and start being a file. The
|
|
58
|
+
library is a single 13 KB source file per language, 1.3 KB of which is the
|
|
59
|
+
tables, and its own footprint once imported is about 120 KB on top of the
|
|
60
|
+
standard library it needs. Neither side has a dependency, and `Zone` and
|
|
61
|
+
`Generic` read no time zone database at all, so both answer on a machine that
|
|
62
|
+
has none. That is what makes vendoring one file a real option rather than a
|
|
63
|
+
compromise.
|
|
64
|
+
|
|
65
|
+
The 24 KB wheel is mostly not the library: the tests and their case file ship
|
|
66
|
+
with it on purpose, so an installed copy can prove itself where it landed
|
|
67
|
+
rather than only in a checkout.
|
|
68
|
+
|
|
69
|
+
It covers the places a US-only table usually forgets:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
00601 America/Puerto_Rico 00802 America/Puerto_Rico (US Virgin Islands)
|
|
73
|
+
96799 Pacific/Pago_Pago 96910 Pacific/Guam
|
|
74
|
+
96950 Pacific/Guam (Northern Mariana Islands)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
And it answers a 3-digit prefix, not just a whole ZIP, which is what you have
|
|
78
|
+
when an address is partial or a form was only half filled in.
|
|
79
|
+
|
|
80
|
+
Two things it is deliberately or unavoidably bad at:
|
|
81
|
+
|
|
82
|
+
- **Zone identity.** About 34 IANA zones are folded onto the 11 that agree with
|
|
83
|
+
them *today*, so a ZIP in Knox County, Indiana answers `America/New_York`
|
|
84
|
+
rather than `America/Indiana/Knox`. The clock is right; the name is coarser
|
|
85
|
+
than the database's. [When to
|
|
86
|
+
regenerate](#when-to-regenerate) explains what keeps that true.
|
|
87
|
+
- **Validating ZIP codes.** A five-digit code with an assigned prefix always
|
|
88
|
+
gets an answer, whether or not the Postal Service has ever issued it. An
|
|
89
|
+
error means "no such prefix", never "no such ZIP".
|
|
90
|
+
|
|
91
|
+
## Accuracy
|
|
92
|
+
|
|
93
|
+
Give all five digits and the answer is exact. Three digits — the prefix alone —
|
|
94
|
+
gets the majority zone for that prefix, which is right for 33,558 of the 33,791
|
|
95
|
+
ZIP codes and wrong for the 233 that sit on the losing side of a zone boundary
|
|
96
|
+
their prefix has to round the wrong way.
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
ziptz.zone("79835") # America/Denver — Canutillo, TX; five digits are exact
|
|
100
|
+
ziptz.zone("798") # America/Chicago — the prefix rounds to the majority
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
One gap: PO-box and single-building ZIPs have no delivery area in the source
|
|
104
|
+
data, so even given in full they fall back to their prefix's answer. Where a
|
|
105
|
+
whole prefix is nothing but those, there is no answer to fall back to and the
|
|
106
|
+
lookup is an error instead — `00501` (Holtsville, NY, an IRS building) is the
|
|
107
|
+
named case, and cross-referencing a per-ZIP dataset finds about 275 of them
|
|
108
|
+
across 19 prefixes: IRS centres like `73301` and `45999`, federal agency ZIPs
|
|
109
|
+
in `569xx`, state government in `942xx`, and PO-box banks in `311xx` and
|
|
110
|
+
`332xx`. They are exactly the ZIPs with no delivery area, so the gap is one
|
|
111
|
+
thing rather than two.
|
|
112
|
+
|
|
113
|
+
The tables carry today's zone *names*, not today's offsets — daylight saving
|
|
114
|
+
comes from whatever tzdata the machine has, so a rule change needs no new
|
|
115
|
+
release. They are wrong for historical dates: several places have changed zone
|
|
116
|
+
(Kentucky/Monticello left Central in 2000, North Dakota/Beulah left Mountain in
|
|
117
|
+
2010) and the tables record only where each ZIP is now.
|
|
118
|
+
|
|
119
|
+
## Install
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
go get github.com/choey/ziptz
|
|
123
|
+
pip install ziptz-us # imports as ziptz; see below
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The Python distribution is `ziptz-us` and the module is `ziptz` — the same
|
|
127
|
+
split as `python-dateutil`/`dateutil`. `ziptz` on PyPI is a 2013-era name
|
|
128
|
+
registration with no files ever attached, so `pip install ziptz` fails for
|
|
129
|
+
everyone; the `-us` is also simply true, since this resolves US ZIP codes and
|
|
130
|
+
nothing else. Go has no central registry, so the import path there is the
|
|
131
|
+
repository's own.
|
|
132
|
+
|
|
133
|
+
One consequence worth knowing: `importlib.metadata.version("ziptz")` raises,
|
|
134
|
+
because that asks the *distribution* name. `ziptz.__version__` is the answer to
|
|
135
|
+
use, and is the better one anyway — it works for a copied single file, where
|
|
136
|
+
there is no installed distribution to ask about at all.
|
|
137
|
+
|
|
138
|
+
Or copy it. Each side is one standard-library-only file: drop `ziptz.go` into
|
|
139
|
+
a package of your own, or `ziptz.py` next to whatever imports it — no build
|
|
140
|
+
step, nothing to fetch, and the tables come along because they *are* source.
|
|
141
|
+
That is a supported way to use this rather than a workaround: at 1.3 KB of
|
|
142
|
+
tables, the library is smaller than most manifests that would name it.
|
|
143
|
+
|
|
144
|
+
Python therefore imports two ways, and both answer the same: `ziptz.py` alone
|
|
145
|
+
is a module, and the directory around it is a package whose `__init__.py`
|
|
146
|
+
hands through to that module — which is what lets a clone `import ziptz` with
|
|
147
|
+
nothing installed. A test compares the two, since only the package form is
|
|
148
|
+
what a wheel contains.
|
|
149
|
+
|
|
150
|
+
The Python side is annotated and ships `py.typed`, so mypy and editors read the
|
|
151
|
+
signatures rather than treating the package as untyped. The annotations are
|
|
152
|
+
`from __future__ import annotations` strings, which is what lets them be spelled
|
|
153
|
+
`datetime | None` while the package still imports on the 3.9 it supports.
|
|
154
|
+
|
|
155
|
+
## API
|
|
156
|
+
|
|
157
|
+
| Go | Python | |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| `Zone(token) (string, error)` | `zone(token) -> str` | the IANA name for a 3- or 5-digit ZIP |
|
|
160
|
+
| `Location(token) (*time.Location, error)` | `location(token) -> ZoneInfo` | the same, loaded from the system tz database |
|
|
161
|
+
| `Abbrev(token, at) (string, error)` | `abbrev(token, at=None) -> str` | the abbreviation at a given instant — `PST` in winter, `PDT` in summer |
|
|
162
|
+
| `Generic(token) (string, error)` | `generic(token) -> str` | the name without daylight saving, e.g. `PT` |
|
|
163
|
+
| `PrefixZone(p3) string` | `prefix_zone(p3) -> str` | the majority zone for a prefix, `""` if unassigned |
|
|
164
|
+
| `ExactZone(zip5) string` | `exact_zone(zip5) -> str` | the zone for one of the 233 ZIPs its prefix gets wrong, `""` for the rest |
|
|
165
|
+
|
|
166
|
+
Those first four report an error for anything that is not three or five ASCII
|
|
167
|
+
digits, and for prefixes the Postal Service has never assigned. Python raises
|
|
168
|
+
`ZipError`, a `ValueError`. Both error texts are written to be printed as-is.
|
|
169
|
+
|
|
170
|
+
The last two are the two table lookups `Zone` is built from, exposed for a
|
|
171
|
+
caller that wants to know which of them answered. Neither reports an error:
|
|
172
|
+
each returns `""` both for anything that is not a well-formed prefix or ZIP
|
|
173
|
+
and for anything it simply has no record of. For `ExactZone` the second is
|
|
174
|
+
almost every ZIP — only the 233 exceptions have a record at all — so `""`
|
|
175
|
+
there means *no exception; the prefix is the answer*, not *unknown*. `Zone` is
|
|
176
|
+
exactly the two composed in that order.
|
|
177
|
+
|
|
178
|
+
### What `Location` costs
|
|
179
|
+
|
|
180
|
+
`Location` and `location` return the same thing under two names: `*time.Location`
|
|
181
|
+
is Go's loaded zone and `ZoneInfo` is Python's, and neither language spells it
|
|
182
|
+
the other's way. What differs is the price of asking twice, and that is the
|
|
183
|
+
standard libraries' doing rather than this library's.
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
20,000 calls for one zone (one machine; the ratio is the point, not the ms)
|
|
187
|
+
Go time.LoadLocation 459 ms reads the tz database every call
|
|
188
|
+
Py ZoneInfo 2 ms interned by name; the first call does the work
|
|
189
|
+
Py ZoneInfo.no_cache 1,053 ms what that cache is saving
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Python interns by name, so `location("94110") is location("90210")` — two ZIPs,
|
|
193
|
+
one zone, one object. Go does not, and every `Location` is a file read. Hold the
|
|
194
|
+
result if you are calling it per row of anything, and note that `Abbrev` goes
|
|
195
|
+
through `Location` and inherits the same cost.
|
|
196
|
+
|
|
197
|
+
`Zone` and `Generic` are the cheap ones in both languages: two map lookups and
|
|
198
|
+
no I/O at all once the first call has unpacked the tables. Go allocates nothing
|
|
199
|
+
per call; Python allocates one short slice, for the prefix.
|
|
200
|
+
|
|
201
|
+
### The three names for one zone
|
|
202
|
+
|
|
203
|
+
`Zone` gives the IANA name, `Abbrev` the abbreviation at an instant, `Generic`
|
|
204
|
+
the abbreviation with the daylight-saving question left out — CLDR's terms for
|
|
205
|
+
the last two are the specific and generic non-location short formats.
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
94110 → America/Los_Angeles PST in January, PDT in July PT
|
|
209
|
+
85001 → America/Phoenix MST all year MST
|
|
210
|
+
99546 → America/Adak HST in January, HDT in July HAT
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Which to print depends on whether you have an instant to be right about. A
|
|
214
|
+
label on a clock face has one; a form field asking which coast you are on does
|
|
215
|
+
not.
|
|
216
|
+
|
|
217
|
+
They come from different places, which decides what each needs and when each
|
|
218
|
+
can be wrong. `Abbrev` asks the system's time zone database, so it follows
|
|
219
|
+
rule changes without a new release of this library — but needs that database
|
|
220
|
+
present, and needs the instant. `Generic` is a table here, eleven entries, and
|
|
221
|
+
so answers on a machine with no tzdata at all. A zone that never shifts has no
|
|
222
|
+
pair to generalise over, so its generic name is simply its abbreviation:
|
|
223
|
+
Phoenix is `MST`, Honolulu `HST`, and the tests hold every entry to that rule.
|
|
224
|
+
|
|
225
|
+
Go has no default arguments, so `Abbrev` always takes the instant; Python's
|
|
226
|
+
defaults to now. Pass an aware `datetime` — a naive one is read as system
|
|
227
|
+
local time, the way `astimezone()` reads it.
|
|
228
|
+
|
|
229
|
+
## Data
|
|
230
|
+
|
|
231
|
+
US Census ZCTA Gazetteer centroids (a US Government work, public domain)
|
|
232
|
+
resolved through [timezone-boundary-builder](
|
|
233
|
+
https://github.com/evansiroky/timezone-boundary-builder) (ODbL), by way of
|
|
234
|
+
[timezonefinder](https://github.com/jannikmi/timezonefinder). The generated
|
|
235
|
+
output is 157 range records, 94 of which name a zone, and 233 exceptions — an
|
|
236
|
+
aggregate, not a substantial extract — but both sources are credited here and
|
|
237
|
+
in the source.
|
|
238
|
+
|
|
239
|
+
## Regenerating
|
|
240
|
+
|
|
241
|
+
`tools/genzips.py` writes the tables into `ziptz.go` and `ziptz.py` in one
|
|
242
|
+
pass, which is what keeps the two literals from drifting. Never edit them by
|
|
243
|
+
hand.
|
|
244
|
+
|
|
245
|
+
```sh
|
|
246
|
+
python3 -m venv .venv && .venv/bin/pip install timezonefinder
|
|
247
|
+
.venv/bin/python tools/genzips.py # or: make regen, if it is on your path
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
It is deliberately *not* a build step. timezonefinder pulls in numpy and a
|
|
251
|
+
megabyte of boundary data, where ziptz itself needs neither, and a generator
|
|
252
|
+
that ran at install time would let two builds of one version ship different
|
|
253
|
+
tables. Checked-in generated source is what makes every install byte-identical
|
|
254
|
+
— and what lets the Go and Python copies be compared character for character.
|
|
255
|
+
`go generate ./...` runs the same script, for the same reason `go:generate`
|
|
256
|
+
exists: it is a developer's command, not the build's.
|
|
257
|
+
|
|
258
|
+
The Census archive is cached in `tools/cache/` (gitignored) and reused on
|
|
259
|
+
every later run, so only the first regeneration touches the network. Pass a
|
|
260
|
+
path to read a local copy instead.
|
|
261
|
+
|
|
262
|
+
### When to regenerate
|
|
263
|
+
|
|
264
|
+
Almost never, and *not* for daylight-saving changes. The tables store zone
|
|
265
|
+
names, not offsets or rules, so the answer to "is Denver on MDT today" comes
|
|
266
|
+
from whatever tzdata the machine has. A state dropping daylight saving, or the
|
|
267
|
+
country abolishing the switch, arrives with an OS update and needs nothing
|
|
268
|
+
here.
|
|
269
|
+
|
|
270
|
+
Regenerate when the mapping itself moves:
|
|
271
|
+
|
|
272
|
+
| what changed | why it matters |
|
|
273
|
+
|---|---|
|
|
274
|
+
| a place changes zone | Kentucky/Monticello left Central for Eastern in 2000; its ZIPs now belong to a different name |
|
|
275
|
+
| new or redrawn ZIP codes | a new Census gazetteer describes them |
|
|
276
|
+
| a zone splits from the letter it folds onto | `CANONICAL` collapses ~34 zones onto 11 letters, and that only holds while they keep the same rules |
|
|
277
|
+
|
|
278
|
+
The last is the one that could go wrong quietly, so `genzips.py` re-tests it on
|
|
279
|
+
every run: each folded zone is compared against its letter's zone every six
|
|
280
|
+
hours for the next thirteen months, and the run aborts if any of them parts
|
|
281
|
+
company. Indiana observed no daylight saving until 2006 and North Dakota/Beulah
|
|
282
|
+
left Mountain in 2010, so this is not hypothetical.
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
genzips: these zones no longer track the letter they fold onto, so folding
|
|
286
|
+
them would serve the wrong hour:
|
|
287
|
+
America/Phoenix parts from America/Denver on 2026-08-12
|
|
288
|
+
Give the divergent one its own letter in CANONICAL, and add that letter to
|
|
289
|
+
zones/ZONES in both ziptz libraries.
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Tests
|
|
293
|
+
|
|
294
|
+
```sh
|
|
295
|
+
make test # both suites, then the sweep below
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`make test-go` and `make test-py` run one suite each. `make sweep` is the
|
|
299
|
+
third thing `make test` does, and the one the cases cannot be: every ZIP there
|
|
300
|
+
is, through both libraries, compared. All 1,000 prefixes and all 100,000
|
|
301
|
+
five-digit codes — 101,000 answers, zone names and error text alike — must
|
|
302
|
+
come out identical, which is what makes "the two answer the same, ZIP for ZIP"
|
|
303
|
+
a measured claim rather than a hopeful one. The cases above cover what someone
|
|
304
|
+
thought of; this covers what nobody did.
|
|
305
|
+
|
|
306
|
+
An installed copy carries its tests and their data, so it can prove itself
|
|
307
|
+
where it landed rather than only in a checkout:
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
python3 -m unittest ziptz.test_ziptz
|
|
311
|
+
go test github.com/choey/ziptz
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The data is the point: `testdata/cases.json` holds the cases, the zones, the
|
|
315
|
+
figures the generated tables should contain, and — in `checks` — the names of
|
|
316
|
+
the properties both suites must test. Each suite maps every name to a test and
|
|
317
|
+
fails on one it does not implement, so neither can quietly cover less than the
|
|
318
|
+
other. Cases alone were not enough: the structural checks around them were
|
|
319
|
+
written twice, once per language, and two hand-written lists drift — one suite
|
|
320
|
+
had a check on the exception suffixes being in order for a while before the
|
|
321
|
+
other did. Each case is a token and either the zone it must resolve to or the kind
|
|
322
|
+
of failure it must produce, with a note saying why it is there.
|
|
323
|
+
|
|
324
|
+
```json
|
|
325
|
+
{"token": "96799", "zone": "Pacific/Pago_Pago", "why": "the worst exception: American Samoa, an hour behind Honolulu"}
|
|
326
|
+
{"token": "00501", "error": "unassigned", "why": "known gap: Holtsville NY, a single-building ZIP; really America/New_York"}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Adding a case there is the whole edit — both suites pick it up. They cover the
|
|
330
|
+
ordinary lookups, both sides of every kind of boundary (first and last
|
|
331
|
+
exception in the table, first and last suffix of the largest group, a ZIP just
|
|
332
|
+
outside one, the top and bottom of the prefix range), the malformed tokens
|
|
333
|
+
(wrong length, letters, whitespace, a trailing newline, Arabic-Indic and
|
|
334
|
+
fullwidth digits that Python's `isdigit()` accepts and this must not), and the
|
|
335
|
+
**known gaps** — the ZIPs that resolve wrongly or not at all, pinned so that
|
|
336
|
+
fixing one fails the file and makes someone update it.
|
|
337
|
+
|
|
338
|
+
Only two things are language-only, and the file says which: Go has no default
|
|
339
|
+
arguments, so `abbrev`'s default of now is Python's to test, and only Python
|
|
340
|
+
can be imported two ways, as a module and as a package.
|
|
341
|
+
|
|
342
|
+
## Licence
|
|
343
|
+
|
|
344
|
+
The code is MIT; see [LICENSE](LICENSE).
|
|
345
|
+
|
|
346
|
+
The tables are a separate question, and [NOTICE](NOTICE) is the answer to it.
|
|
347
|
+
They were produced from public-domain Census centroids resolved through
|
|
348
|
+
timezone-boundary-builder, which is ODbL — so `NOTICE` carries that
|
|
349
|
+
attribution and the reasoning for treating 1.3 KB of zone names as a produced
|
|
350
|
+
work rather than an extract of the boundary database. It ships in the wheel
|
|
351
|
+
and the sdist, and travels with the Go module. Keep it with any copy you make,
|
|
352
|
+
including the copy-one-file install above.
|
|
353
|
+
|
|
354
|
+
## Credits
|
|
355
|
+
|
|
356
|
+
The hard part was already done by other people. [Evan
|
|
357
|
+
Siroky](https://github.com/evansiroky/timezone-boundary-builder) builds the
|
|
358
|
+
time zone boundaries out of OpenStreetMap, [Jannik
|
|
359
|
+
Michel](https://github.com/jannikmi/timezonefinder) makes them queryable in
|
|
360
|
+
Python, and the US Census Bureau publishes the ZCTA centroids. This library is
|
|
361
|
+
the small, boring artefact left over once their work has been asked 33,791
|
|
362
|
+
questions.
|