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.
@@ -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
@@ -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.
@@ -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.