zipcodes 2.0.1__tar.gz → 3.0.1__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.
- {zipcodes-2.0.1 → zipcodes-3.0.1}/Cargo.lock +2 -2
- {zipcodes-2.0.1 → zipcodes-3.0.1}/Cargo.toml +1 -1
- {zipcodes-2.0.1 → zipcodes-3.0.1}/PKG-INFO +26 -11
- {zipcodes-2.0.1 → zipcodes-3.0.1}/README.md +25 -10
- zipcodes-3.0.1/crates/zipcodes/examples/gen_schema.rs +12 -0
- {zipcodes-2.0.1 → zipcodes-3.0.1}/crates/zipcodes/src/lib.rs +98 -1
- zipcodes-3.0.1/crates/zipcodes/src/zips.json.bz2 +0 -0
- zipcodes-3.0.1/crates/zipcodes-py/src/lib.rs +264 -0
- zipcodes-3.0.1/python/zipcodes/__init__.py +116 -0
- zipcodes-3.0.1/python/zipcodes/_zipcodes.pyi +26 -0
- zipcodes-2.0.1/crates/zipcodes/src/zips.json.bz2 +0 -0
- zipcodes-2.0.1/crates/zipcodes-py/src/lib.rs +0 -156
- zipcodes-2.0.1/python/zipcodes/__init__.py +0 -174
- zipcodes-2.0.1/python/zipcodes/_zipcodes.pyi +0 -14
- {zipcodes-2.0.1 → zipcodes-3.0.1}/LICENSE.txt +0 -0
- {zipcodes-2.0.1 → zipcodes-3.0.1}/crates/zipcodes/Cargo.toml +0 -0
- {zipcodes-2.0.1 → zipcodes-3.0.1}/crates/zipcodes/README.md +0 -0
- {zipcodes-2.0.1 → zipcodes-3.0.1}/crates/zipcodes-py/Cargo.toml +0 -0
- {zipcodes-2.0.1 → zipcodes-3.0.1}/pyproject.toml +0 -0
- {zipcodes-2.0.1 → zipcodes-3.0.1}/python/zipcodes/py.typed +0 -0
|
@@ -216,7 +216,7 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
|
216
216
|
|
|
217
217
|
[[package]]
|
|
218
218
|
name = "zipcodes"
|
|
219
|
-
version = "
|
|
219
|
+
version = "3.0.1"
|
|
220
220
|
dependencies = [
|
|
221
221
|
"bzip2",
|
|
222
222
|
"serde",
|
|
@@ -226,7 +226,7 @@ dependencies = [
|
|
|
226
226
|
|
|
227
227
|
[[package]]
|
|
228
228
|
name = "zipcodes-py"
|
|
229
|
-
version = "
|
|
229
|
+
version = "3.0.1"
|
|
230
230
|
dependencies = [
|
|
231
231
|
"pyo3",
|
|
232
232
|
"serde_json",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zipcodes
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.1
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.9
|
|
@@ -23,14 +23,12 @@ Project-URL: Issues, https://github.com/seanpianka/zipcodes/issues
|
|
|
23
23
|
|
|
24
24
|
# Zipcodes
|
|
25
25
|
|
|
26
|
-

|
|
27
|
-
|
|
28
|
-
[](https://crates.io/crates/zipcodes)
|
|
29
|
-
[](https://pepy.tech/project/zipcodes)
|
|
30
|
-
[](https://github.com/seanpianka/zipcodes/graphs/contributors)
|
|
26
|
+
Python:  [](https://pypi.org/project/zipcodes/) [](https://pepy.tech/project/zipcodes) [](https://pepy.tech/project/zipcodes)
|
|
27
|
+
|
|
28
|
+
Rust: [](https://crates.io/crates/zipcodes) [](https://crates.io/crates/zipcodes) [)](https://crates.io/crates/zipcodes) [)](https://crates.io/crates/zipcodes)
|
|
31
29
|
|
|
32
30
|
Zipcodes is a simple library for querying U.S. zipcodes. No SQLite, no
|
|
33
|
-
network, no runtime data files — the full dataset is embedded in the package.
|
|
31
|
+
network, no runtime data files — the full dataset is embedded in the package. [](https://github.com/seanpianka/zipcodes/graphs/contributors)
|
|
34
32
|
|
|
35
33
|
Since 2.0, the library is implemented in Rust and published from a single
|
|
36
34
|
codebase as both the [`zipcodes` Python package](https://pypi.org/project/zipcodes/)
|
|
@@ -106,6 +104,17 @@ $ cargo add zipcodes
|
|
|
106
104
|
- Behavioral notes for upgraders: query results are fresh dicts (mutating a
|
|
107
105
|
result no longer mutates the shared database list), and
|
|
108
106
|
`filter_by(active=1)` no longer matches `active=True` (pass a bool).
|
|
107
|
+
- **Unified validation.** Input validation now lives once, in the Rust core,
|
|
108
|
+
instead of a separate Python validator. This changes a few edge cases:
|
|
109
|
+
- **Breaking:** `is_real`/`matching` now raise `ValueError` for input shorter
|
|
110
|
+
than five characters (after trimming), instead of returning `False`/`[]`.
|
|
111
|
+
- **Relaxations:** surrounding whitespace (`" 06903 "`) and space-separated
|
|
112
|
+
Zip+4 (`"12345 6789"`) are now accepted; any input of five or more
|
|
113
|
+
characters is normalized to its first five digits, generalizing the
|
|
114
|
+
existing `#####-####` handling.
|
|
115
|
+
- **Private API removal:** the undocumented helpers `zipcodes._clean` and
|
|
116
|
+
`zipcodes._contains_nondigits` (and the `_digits`/`_valid_zipcode_length`
|
|
117
|
+
module attributes) no longer exist.
|
|
109
118
|
|
|
110
119
|
## Zipcode Data
|
|
111
120
|
|
|
@@ -135,20 +144,26 @@ automatically.
|
|
|
135
144
|
To rebuild the dataset manually:
|
|
136
145
|
|
|
137
146
|
```shell script
|
|
138
|
-
$ pip install
|
|
147
|
+
$ pip install -r scripts/requirements.txt
|
|
139
148
|
$ curl -fsSL https://download.geonames.org/export/zip/US.zip -o /tmp/geonames_us.zip
|
|
140
|
-
$ # download the .
|
|
149
|
+
$ # download the .xlsx linked from https://postalpro.usps.com/ZIP_Locale_Detail
|
|
141
150
|
$ python scripts/update_zipcode_dataset.py \
|
|
142
151
|
--base scripts/data/zip_code_database.csv \
|
|
143
152
|
--gps scripts/data/zip-codes-database-FREE.csv \
|
|
144
153
|
--geonames-zip /tmp/geonames_us.zip \
|
|
145
|
-
--usps-
|
|
154
|
+
--usps-file /tmp/usps_zip_locale.xlsx \
|
|
146
155
|
--output-bz2 crates/zipcodes/src/zips.json.bz2 \
|
|
147
156
|
--summary-output /tmp/change_summary.json
|
|
148
157
|
```
|
|
149
158
|
|
|
150
159
|
## Tests
|
|
151
160
|
|
|
161
|
+
[CI](https://github.com/seanpianka/Zipcodes/actions/workflows/ci.yml) runs Rust
|
|
162
|
+
checks and Python tests on pushes to `master` and pull requests. Rust API docs
|
|
163
|
+
are built automatically on [docs.rs](https://docs.rs/zipcodes) after each crate
|
|
164
|
+
release; this README is published to [PyPI](https://pypi.org/project/zipcodes/)
|
|
165
|
+
with each Python release.
|
|
166
|
+
|
|
152
167
|
The tests are defined in a declarative, table-based format that generates test
|
|
153
168
|
cases.
|
|
154
169
|
|
|
@@ -208,7 +223,7 @@ Traceback (most recent call last):
|
|
|
208
223
|
...
|
|
209
224
|
ValueError: Invalid characters, zipcode may only contain digits and "-".
|
|
210
225
|
|
|
211
|
-
>>> zipcodes.matching('
|
|
226
|
+
>>> zipcodes.matching('0646')
|
|
212
227
|
Traceback (most recent call last):
|
|
213
228
|
...
|
|
214
229
|
ValueError: Invalid format, zipcode must be of the format: "#####" or "#####-####"
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# Zipcodes
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-
|
|
5
|
-
[](https://crates.io/crates/zipcodes)
|
|
6
|
-
[](https://pepy.tech/project/zipcodes)
|
|
7
|
-
[](https://github.com/seanpianka/zipcodes/graphs/contributors)
|
|
3
|
+
Python:  [](https://pypi.org/project/zipcodes/) [](https://pepy.tech/project/zipcodes) [](https://pepy.tech/project/zipcodes)
|
|
4
|
+
|
|
5
|
+
Rust: [](https://crates.io/crates/zipcodes) [](https://crates.io/crates/zipcodes) [)](https://crates.io/crates/zipcodes) [)](https://crates.io/crates/zipcodes)
|
|
8
6
|
|
|
9
7
|
Zipcodes is a simple library for querying U.S. zipcodes. No SQLite, no
|
|
10
|
-
network, no runtime data files — the full dataset is embedded in the package.
|
|
8
|
+
network, no runtime data files — the full dataset is embedded in the package. [](https://github.com/seanpianka/zipcodes/graphs/contributors)
|
|
11
9
|
|
|
12
10
|
Since 2.0, the library is implemented in Rust and published from a single
|
|
13
11
|
codebase as both the [`zipcodes` Python package](https://pypi.org/project/zipcodes/)
|
|
@@ -83,6 +81,17 @@ $ cargo add zipcodes
|
|
|
83
81
|
- Behavioral notes for upgraders: query results are fresh dicts (mutating a
|
|
84
82
|
result no longer mutates the shared database list), and
|
|
85
83
|
`filter_by(active=1)` no longer matches `active=True` (pass a bool).
|
|
84
|
+
- **Unified validation.** Input validation now lives once, in the Rust core,
|
|
85
|
+
instead of a separate Python validator. This changes a few edge cases:
|
|
86
|
+
- **Breaking:** `is_real`/`matching` now raise `ValueError` for input shorter
|
|
87
|
+
than five characters (after trimming), instead of returning `False`/`[]`.
|
|
88
|
+
- **Relaxations:** surrounding whitespace (`" 06903 "`) and space-separated
|
|
89
|
+
Zip+4 (`"12345 6789"`) are now accepted; any input of five or more
|
|
90
|
+
characters is normalized to its first five digits, generalizing the
|
|
91
|
+
existing `#####-####` handling.
|
|
92
|
+
- **Private API removal:** the undocumented helpers `zipcodes._clean` and
|
|
93
|
+
`zipcodes._contains_nondigits` (and the `_digits`/`_valid_zipcode_length`
|
|
94
|
+
module attributes) no longer exist.
|
|
86
95
|
|
|
87
96
|
## Zipcode Data
|
|
88
97
|
|
|
@@ -112,20 +121,26 @@ automatically.
|
|
|
112
121
|
To rebuild the dataset manually:
|
|
113
122
|
|
|
114
123
|
```shell script
|
|
115
|
-
$ pip install
|
|
124
|
+
$ pip install -r scripts/requirements.txt
|
|
116
125
|
$ curl -fsSL https://download.geonames.org/export/zip/US.zip -o /tmp/geonames_us.zip
|
|
117
|
-
$ # download the .
|
|
126
|
+
$ # download the .xlsx linked from https://postalpro.usps.com/ZIP_Locale_Detail
|
|
118
127
|
$ python scripts/update_zipcode_dataset.py \
|
|
119
128
|
--base scripts/data/zip_code_database.csv \
|
|
120
129
|
--gps scripts/data/zip-codes-database-FREE.csv \
|
|
121
130
|
--geonames-zip /tmp/geonames_us.zip \
|
|
122
|
-
--usps-
|
|
131
|
+
--usps-file /tmp/usps_zip_locale.xlsx \
|
|
123
132
|
--output-bz2 crates/zipcodes/src/zips.json.bz2 \
|
|
124
133
|
--summary-output /tmp/change_summary.json
|
|
125
134
|
```
|
|
126
135
|
|
|
127
136
|
## Tests
|
|
128
137
|
|
|
138
|
+
[CI](https://github.com/seanpianka/Zipcodes/actions/workflows/ci.yml) runs Rust
|
|
139
|
+
checks and Python tests on pushes to `master` and pull requests. Rust API docs
|
|
140
|
+
are built automatically on [docs.rs](https://docs.rs/zipcodes) after each crate
|
|
141
|
+
release; this README is published to [PyPI](https://pypi.org/project/zipcodes/)
|
|
142
|
+
with each Python release.
|
|
143
|
+
|
|
129
144
|
The tests are defined in a declarative, table-based format that generates test
|
|
130
145
|
cases.
|
|
131
146
|
|
|
@@ -185,7 +200,7 @@ Traceback (most recent call last):
|
|
|
185
200
|
...
|
|
186
201
|
ValueError: Invalid characters, zipcode may only contain digits and "-".
|
|
187
202
|
|
|
188
|
-
>>> zipcodes.matching('
|
|
203
|
+
>>> zipcodes.matching('0646')
|
|
189
204
|
Traceback (most recent call last):
|
|
190
205
|
...
|
|
191
206
|
ValueError: Invalid format, zipcode must be of the format: "#####" or "#####-####"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//! Emit the canonical record schema as JSON for the data pipeline to consume.
|
|
2
|
+
//!
|
|
3
|
+
//! `scripts/update_zipcode_dataset.py` loads `field_order` from the generated
|
|
4
|
+
//! file instead of hard-coding it, and CI diffs the committed copy against fresh
|
|
5
|
+
//! output so a struct/`FIELD_ORDER` change can't ship without refreshing it.
|
|
6
|
+
//!
|
|
7
|
+
//! Regenerate with:
|
|
8
|
+
//! cargo run -p zipcodes --example gen_schema > scripts/data/schema.json
|
|
9
|
+
fn main() {
|
|
10
|
+
let schema = serde_json::json!({ "field_order": zipcodes::FIELD_ORDER });
|
|
11
|
+
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
|
|
12
|
+
}
|
|
@@ -56,6 +56,31 @@ pub struct Zipcode {
|
|
|
56
56
|
pub zip_code_type: String,
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/// Canonical field order of records in `zips.json` and of the dicts returned by
|
|
60
|
+
/// the Python package. This is the base CSV's historical column order — **not**
|
|
61
|
+
/// alphabetical (the struct above is declared alphabetically; serde matches by
|
|
62
|
+
/// name, so the two need only agree on the field *set*, not its order).
|
|
63
|
+
///
|
|
64
|
+
/// `lat`/`long` are `String` here and round-trip losslessly through serde; if
|
|
65
|
+
/// they ever become `f64`, the serde-based `to_dict` in the Python binding and
|
|
66
|
+
/// the JSON build pipeline must be revisited together.
|
|
67
|
+
pub const FIELD_ORDER: [&str; 14] = [
|
|
68
|
+
"zip_code",
|
|
69
|
+
"zip_code_type",
|
|
70
|
+
"active",
|
|
71
|
+
"city",
|
|
72
|
+
"acceptable_cities",
|
|
73
|
+
"unacceptable_cities",
|
|
74
|
+
"state",
|
|
75
|
+
"county",
|
|
76
|
+
"timezone",
|
|
77
|
+
"area_codes",
|
|
78
|
+
"world_region",
|
|
79
|
+
"country",
|
|
80
|
+
"lat",
|
|
81
|
+
"long",
|
|
82
|
+
];
|
|
83
|
+
|
|
59
84
|
impl Zipcode {
|
|
60
85
|
/// Compare a named field against a JSON value, mirroring the Python
|
|
61
86
|
/// package's `filter_by(**kwargs)` semantics: an unknown field name or a
|
|
@@ -211,7 +236,10 @@ pub fn database() -> &'static [Zipcode] {
|
|
|
211
236
|
&ZIPCODES
|
|
212
237
|
}
|
|
213
238
|
|
|
214
|
-
|
|
239
|
+
/// Validate and normalize a full zipcode (`"#####"`, `"#####-####"`, or
|
|
240
|
+
/// `"##### ####"`): trim surrounding whitespace, require at least five
|
|
241
|
+
/// characters, then take the first five and require them to be ASCII digits.
|
|
242
|
+
pub fn clean_zipcode(zipcode: &str) -> Result<&str> {
|
|
215
243
|
let zipcode = zipcode.trim();
|
|
216
244
|
if zipcode.len() < ZIPCODE_LENGTH {
|
|
217
245
|
return Err(Error::InvalidFormat);
|
|
@@ -223,11 +251,46 @@ fn clean_zipcode(zipcode: &str) -> Result<&str> {
|
|
|
223
251
|
Ok(prefix)
|
|
224
252
|
}
|
|
225
253
|
|
|
254
|
+
/// Validate a partial zipcode (prefix or fragment): after trimming, require
|
|
255
|
+
/// 1–5 ASCII digits. Longer input is [`Error::InvalidFormat`]; non-digits are
|
|
256
|
+
/// [`Error::InvalidCharacters`]. Empty input is also rejected as
|
|
257
|
+
/// [`Error::InvalidFormat`] (the Python shim guards it earlier with a
|
|
258
|
+
/// `TypeError`), so this never returns `Ok("")`.
|
|
259
|
+
pub fn clean_prefix(partial: &str) -> Result<&str> {
|
|
260
|
+
let partial = partial.trim();
|
|
261
|
+
if partial.is_empty() || partial.len() > ZIPCODE_LENGTH {
|
|
262
|
+
return Err(Error::InvalidFormat);
|
|
263
|
+
}
|
|
264
|
+
if !partial.chars().all(|c| c.is_ascii_digit()) {
|
|
265
|
+
return Err(Error::InvalidCharacters);
|
|
266
|
+
}
|
|
267
|
+
Ok(partial)
|
|
268
|
+
}
|
|
269
|
+
|
|
226
270
|
#[cfg(test)]
|
|
227
271
|
mod tests {
|
|
228
272
|
use super::*;
|
|
229
273
|
use serde_json::json;
|
|
230
274
|
|
|
275
|
+
#[test]
|
|
276
|
+
fn field_order_matches_struct() {
|
|
277
|
+
// FIELD_ORDER must list exactly the struct's serialized fields. A field
|
|
278
|
+
// added to one but not the other fails here rather than silently
|
|
279
|
+
// dropping from the dicts the binding builds.
|
|
280
|
+
let record = &matching("06903", None).unwrap()[0];
|
|
281
|
+
let value = serde_json::to_value(record).unwrap();
|
|
282
|
+
let mut keys: Vec<&str> = value
|
|
283
|
+
.as_object()
|
|
284
|
+
.unwrap()
|
|
285
|
+
.keys()
|
|
286
|
+
.map(String::as_str)
|
|
287
|
+
.collect();
|
|
288
|
+
keys.sort_unstable();
|
|
289
|
+
let mut expected = FIELD_ORDER.to_vec();
|
|
290
|
+
expected.sort_unstable();
|
|
291
|
+
assert_eq!(keys, expected);
|
|
292
|
+
}
|
|
293
|
+
|
|
231
294
|
#[test]
|
|
232
295
|
fn should_find_real_zipcodes() {
|
|
233
296
|
assert!(is_real("06903").unwrap());
|
|
@@ -295,6 +358,40 @@ mod tests {
|
|
|
295
358
|
));
|
|
296
359
|
}
|
|
297
360
|
|
|
361
|
+
#[test]
|
|
362
|
+
fn clean_zipcode_normalizes_valid_input() {
|
|
363
|
+
assert_eq!(clean_zipcode("12345").unwrap(), "12345");
|
|
364
|
+
assert_eq!(clean_zipcode("12345-6789").unwrap(), "12345");
|
|
365
|
+
assert_eq!(clean_zipcode("12345 6789").unwrap(), "12345");
|
|
366
|
+
assert_eq!(clean_zipcode(" 06903 ").unwrap(), "06903");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[test]
|
|
370
|
+
fn clean_zipcode_rejects_invalid_input() {
|
|
371
|
+
assert!(matches!(clean_zipcode("123"), Err(Error::InvalidFormat)));
|
|
372
|
+
assert!(matches!(clean_zipcode(" "), Err(Error::InvalidFormat)));
|
|
373
|
+
assert!(matches!(
|
|
374
|
+
clean_zipcode("1234a"),
|
|
375
|
+
Err(Error::InvalidCharacters)
|
|
376
|
+
));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#[test]
|
|
380
|
+
fn clean_prefix_accepts_one_to_five_digits() {
|
|
381
|
+
for p in ["1", "12", "123", "1234", "12345"] {
|
|
382
|
+
assert_eq!(clean_prefix(p).unwrap(), p);
|
|
383
|
+
}
|
|
384
|
+
assert_eq!(clean_prefix(" 10 ").unwrap(), "10");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#[test]
|
|
388
|
+
fn clean_prefix_rejects_invalid_input() {
|
|
389
|
+
assert!(matches!(clean_prefix("123456"), Err(Error::InvalidFormat)));
|
|
390
|
+
assert!(matches!(clean_prefix(""), Err(Error::InvalidFormat)));
|
|
391
|
+
assert!(matches!(clean_prefix(" "), Err(Error::InvalidFormat)));
|
|
392
|
+
assert!(matches!(clean_prefix("10a"), Err(Error::InvalidCharacters)));
|
|
393
|
+
}
|
|
394
|
+
|
|
298
395
|
#[test]
|
|
299
396
|
fn should_include_county_field() {
|
|
300
397
|
let zips = matching("06475", None).unwrap();
|
|
Binary file
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
//! Native module `zipcodes._zipcodes`.
|
|
2
|
+
//!
|
|
3
|
+
//! Database scans delegate to the `zipcodes` crate's query functions, and the
|
|
4
|
+
//! `zips=` override path (chaining filters over previously returned dicts) is
|
|
5
|
+
//! filtered here in Rust, so the crate is the single implementation of scan
|
|
6
|
+
//! semantics. The Python-facing compat layer (argument validation, exact 1.x
|
|
7
|
+
//! exception messages) lives in `python/zipcodes/__init__.py`.
|
|
8
|
+
|
|
9
|
+
use pyo3::exceptions::PyValueError;
|
|
10
|
+
use pyo3::prelude::*;
|
|
11
|
+
use pyo3::types::{PyBool, PyDict, PyList};
|
|
12
|
+
use serde_json::Value;
|
|
13
|
+
use zipcodes::Zipcode;
|
|
14
|
+
|
|
15
|
+
/// Map a core validation error to the 1.x `ValueError`, preserving its message
|
|
16
|
+
/// verbatim. A free helper rather than `impl From<zipcodes::Error> for PyErr`
|
|
17
|
+
/// because both types are foreign to this crate (orphan rule).
|
|
18
|
+
fn to_py_err(e: zipcodes::Error) -> PyErr {
|
|
19
|
+
PyValueError::new_err(e.to_string())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Build a dict in `zipcodes::FIELD_ORDER` order (the 1.x pure-Python key order).
|
|
23
|
+
///
|
|
24
|
+
/// This is hand-written rather than derived from `serde_json::to_value` on
|
|
25
|
+
/// purpose: a serde-based builder iterating `FIELD_ORDER` cannot drift from the
|
|
26
|
+
/// struct, but it allocates a `Map<String, Value>` per record and measured ~60%
|
|
27
|
+
/// slower on `list_all()` (which materializes ~42k dicts) — past our tolerance.
|
|
28
|
+
/// Drift is instead caught by tests, not by construction: `FIELD_ORDER` is
|
|
29
|
+
/// pinned to the struct by `field_order_matches_struct` in the core crate, and
|
|
30
|
+
/// this builder's output is pinned to `FIELD_ORDER` by `tests/test_schema.py`.
|
|
31
|
+
/// Keep the `set_item` order identical to `FIELD_ORDER`.
|
|
32
|
+
fn to_dict<'py>(py: Python<'py>, z: &Zipcode) -> PyResult<Bound<'py, PyDict>> {
|
|
33
|
+
let dict = PyDict::new(py);
|
|
34
|
+
dict.set_item("zip_code", &z.zip_code)?;
|
|
35
|
+
dict.set_item("zip_code_type", &z.zip_code_type)?;
|
|
36
|
+
dict.set_item("active", z.active)?;
|
|
37
|
+
dict.set_item("city", &z.city)?;
|
|
38
|
+
dict.set_item("acceptable_cities", &z.acceptable_cities)?;
|
|
39
|
+
dict.set_item("unacceptable_cities", &z.unacceptable_cities)?;
|
|
40
|
+
dict.set_item("state", &z.state)?;
|
|
41
|
+
dict.set_item("county", &z.county)?;
|
|
42
|
+
dict.set_item("timezone", &z.timezone)?;
|
|
43
|
+
dict.set_item("area_codes", &z.area_codes)?;
|
|
44
|
+
dict.set_item("world_region", &z.world_region)?;
|
|
45
|
+
dict.set_item("country", &z.country)?;
|
|
46
|
+
dict.set_item("lat", &z.lat)?;
|
|
47
|
+
dict.set_item("long", &z.long)?;
|
|
48
|
+
Ok(dict)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Convert crate query results to a list of dicts (1.x field order).
|
|
52
|
+
fn to_pylist<'py>(py: Python<'py>, zips: &[Zipcode]) -> PyResult<Bound<'py, PyList>> {
|
|
53
|
+
let dicts = zips
|
|
54
|
+
.iter()
|
|
55
|
+
.map(|z| to_dict(py, z))
|
|
56
|
+
.collect::<PyResult<Vec<_>>>()?;
|
|
57
|
+
PyList::new(py, dicts)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Filter a caller-supplied list of dicts, returning the matching items as-is
|
|
61
|
+
/// (no deserialization round-trip, no re-ordering of caller keys). The
|
|
62
|
+
/// predicate treats any per-item failure (missing key, non-dict item,
|
|
63
|
+
/// inconvertible value) as "not a match".
|
|
64
|
+
fn filter_pylist<'py, F>(
|
|
65
|
+
py: Python<'py>,
|
|
66
|
+
zips: &Bound<'py, PyList>,
|
|
67
|
+
pred: F,
|
|
68
|
+
) -> PyResult<Bound<'py, PyList>>
|
|
69
|
+
where
|
|
70
|
+
F: Fn(&Bound<'py, PyAny>) -> bool,
|
|
71
|
+
{
|
|
72
|
+
let matches: Vec<_> = zips.iter().filter(|item| pred(item)).collect();
|
|
73
|
+
PyList::new(py, matches)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Extract `item[key]` as a string; `None` on a missing key or non-string value.
|
|
77
|
+
fn get_str(item: &Bound<'_, PyAny>, key: &str) -> Option<String> {
|
|
78
|
+
item.get_item(key).ok()?.extract::<String>().ok()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Extract `item[key]` as a coordinate, accepting str or float values as the
|
|
82
|
+
/// 1.x `float(z["lat"])` did.
|
|
83
|
+
fn get_coordinate(item: &Bound<'_, PyAny>, key: &str) -> Option<f64> {
|
|
84
|
+
let value = item.get_item(key).ok()?;
|
|
85
|
+
if let Ok(f) = value.extract::<f64>() {
|
|
86
|
+
return Some(f);
|
|
87
|
+
}
|
|
88
|
+
value.extract::<String>().ok()?.parse::<f64>().ok()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Convert a Python filter value to JSON for comparison against record fields.
|
|
92
|
+
/// Returns None for values no record field could ever equal (e.g. sets,
|
|
93
|
+
/// arbitrary objects), which the caller treats as "matches nothing".
|
|
94
|
+
fn py_to_json(value: &Bound<'_, PyAny>) -> Option<Value> {
|
|
95
|
+
if value.is_none() {
|
|
96
|
+
return Some(Value::Null);
|
|
97
|
+
}
|
|
98
|
+
if let Ok(b) = value.cast::<PyBool>() {
|
|
99
|
+
return Some(Value::Bool(b.is_true()));
|
|
100
|
+
}
|
|
101
|
+
if let Ok(i) = value.extract::<i64>() {
|
|
102
|
+
return Some(Value::from(i));
|
|
103
|
+
}
|
|
104
|
+
if let Ok(f) = value.extract::<f64>() {
|
|
105
|
+
return Some(Value::from(f));
|
|
106
|
+
}
|
|
107
|
+
if let Ok(s) = value.extract::<String>() {
|
|
108
|
+
return Some(Value::String(s));
|
|
109
|
+
}
|
|
110
|
+
if let Ok(list) = value.cast::<PyList>() {
|
|
111
|
+
let mut arr = Vec::with_capacity(list.len());
|
|
112
|
+
for item in list.iter() {
|
|
113
|
+
arr.push(py_to_json(&item)?);
|
|
114
|
+
}
|
|
115
|
+
return Some(Value::Array(arr));
|
|
116
|
+
}
|
|
117
|
+
None
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[pyfunction]
|
|
121
|
+
#[pyo3(signature = (zipcode, zips=None))]
|
|
122
|
+
fn matching<'py>(
|
|
123
|
+
py: Python<'py>,
|
|
124
|
+
zipcode: &str,
|
|
125
|
+
zips: Option<Bound<'py, PyList>>,
|
|
126
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
127
|
+
match zips {
|
|
128
|
+
None => {
|
|
129
|
+
let found = zipcodes::matching(zipcode, None).map_err(to_py_err)?;
|
|
130
|
+
to_pylist(py, &found)
|
|
131
|
+
}
|
|
132
|
+
Some(zips) => {
|
|
133
|
+
let zipcode = zipcodes::clean_zipcode(zipcode).map_err(to_py_err)?;
|
|
134
|
+
filter_pylist(py, &zips, |item| {
|
|
135
|
+
get_str(item, "zip_code").as_deref() == Some(zipcode)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[pyfunction]
|
|
142
|
+
fn is_real(zipcode: &str) -> PyResult<bool> {
|
|
143
|
+
zipcodes::is_real(zipcode).map_err(to_py_err)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[pyfunction]
|
|
147
|
+
#[pyo3(signature = (prefix, zips=None))]
|
|
148
|
+
fn similar_to<'py>(
|
|
149
|
+
py: Python<'py>,
|
|
150
|
+
prefix: &str,
|
|
151
|
+
zips: Option<Bound<'py, PyList>>,
|
|
152
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
153
|
+
let prefix = zipcodes::clean_prefix(prefix).map_err(to_py_err)?;
|
|
154
|
+
match zips {
|
|
155
|
+
None => to_pylist(py, &zipcodes::similar_to(prefix, None)),
|
|
156
|
+
Some(zips) => filter_pylist(py, &zips, |item| {
|
|
157
|
+
get_str(item, "zip_code").is_some_and(|zc| zc.starts_with(prefix))
|
|
158
|
+
}),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#[pyfunction]
|
|
163
|
+
#[pyo3(signature = (fragment, zips=None))]
|
|
164
|
+
fn contains<'py>(
|
|
165
|
+
py: Python<'py>,
|
|
166
|
+
fragment: &str,
|
|
167
|
+
zips: Option<Bound<'py, PyList>>,
|
|
168
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
169
|
+
let fragment = zipcodes::clean_prefix(fragment).map_err(to_py_err)?;
|
|
170
|
+
match zips {
|
|
171
|
+
None => to_pylist(py, &zipcodes::contains(fragment, None)),
|
|
172
|
+
Some(zips) => filter_pylist(py, &zips, |item| {
|
|
173
|
+
get_str(item, "zip_code").is_some_and(|zc| zc.contains(fragment))
|
|
174
|
+
}),
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Use `kwargs` to select for desired attributes from list of zipcode dicts
|
|
179
|
+
#[pyfunction]
|
|
180
|
+
#[pyo3(signature = (zips=None, **kwargs))]
|
|
181
|
+
fn filter_by<'py>(
|
|
182
|
+
py: Python<'py>,
|
|
183
|
+
zips: Option<Bound<'py, PyList>>,
|
|
184
|
+
kwargs: Option<&Bound<'py, PyDict>>,
|
|
185
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
186
|
+
let mut filters = Vec::new();
|
|
187
|
+
if let Some(kwargs) = kwargs {
|
|
188
|
+
for (key, value) in kwargs.iter() {
|
|
189
|
+
let key: String = key.extract()?;
|
|
190
|
+
match py_to_json(&value) {
|
|
191
|
+
Some(value) => filters.push((key, value)),
|
|
192
|
+
// A value of an inconvertible type can never equal a record
|
|
193
|
+
// field, and all filters must match.
|
|
194
|
+
None => return Ok(PyList::empty(py)),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
match zips {
|
|
199
|
+
None => to_pylist(py, &zipcodes::filter_by_fields(&filters, None)),
|
|
200
|
+
// Both sides of the comparison go through `py_to_json`, never Python
|
|
201
|
+
// rich comparison, so e.g. `active=1` does not match a True field.
|
|
202
|
+
Some(zips) => filter_pylist(py, &zips, |item| {
|
|
203
|
+
filters.iter().all(|(key, value)| {
|
|
204
|
+
item.get_item(key.as_str())
|
|
205
|
+
.ok()
|
|
206
|
+
.and_then(|v| py_to_json(&v))
|
|
207
|
+
.as_ref()
|
|
208
|
+
== Some(value)
|
|
209
|
+
})
|
|
210
|
+
}),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[pyfunction]
|
|
215
|
+
#[pyo3(signature = (lat, long, radius_in_miles, zips=None))]
|
|
216
|
+
fn filter_by_coordinates<'py>(
|
|
217
|
+
py: Python<'py>,
|
|
218
|
+
lat: f64,
|
|
219
|
+
long: f64,
|
|
220
|
+
radius_in_miles: f64,
|
|
221
|
+
zips: Option<Bound<'py, PyList>>,
|
|
222
|
+
) -> PyResult<Bound<'py, PyList>> {
|
|
223
|
+
match zips {
|
|
224
|
+
None => to_pylist(
|
|
225
|
+
py,
|
|
226
|
+
&zipcodes::filter_by_coordinates(lat, long, radius_in_miles, None),
|
|
227
|
+
),
|
|
228
|
+
Some(zips) => filter_pylist(py, &zips, |item| {
|
|
229
|
+
match (get_coordinate(item, "lat"), get_coordinate(item, "long")) {
|
|
230
|
+
(Some(z_lat), Some(z_long)) => {
|
|
231
|
+
zipcodes::haversine(z_long, z_lat, long, lat) <= radius_in_miles
|
|
232
|
+
}
|
|
233
|
+
_ => false,
|
|
234
|
+
}
|
|
235
|
+
}),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Calculate the great circle distance in miles between two points on the
|
|
240
|
+
/// earth (specified in decimal degrees).
|
|
241
|
+
#[pyfunction]
|
|
242
|
+
fn haversine(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
|
|
243
|
+
zipcodes::haversine(lon1, lat1, lon2, lat2)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[pyfunction]
|
|
247
|
+
fn list_all<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
|
|
248
|
+
to_pylist(py, zipcodes::database())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#[pymodule]
|
|
252
|
+
fn _zipcodes(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
253
|
+
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
254
|
+
m.add("FIELD_ORDER", zipcodes::FIELD_ORDER.to_vec())?;
|
|
255
|
+
m.add_function(wrap_pyfunction!(matching, m)?)?;
|
|
256
|
+
m.add_function(wrap_pyfunction!(is_real, m)?)?;
|
|
257
|
+
m.add_function(wrap_pyfunction!(similar_to, m)?)?;
|
|
258
|
+
m.add_function(wrap_pyfunction!(contains, m)?)?;
|
|
259
|
+
m.add_function(wrap_pyfunction!(filter_by, m)?)?;
|
|
260
|
+
m.add_function(wrap_pyfunction!(filter_by_coordinates, m)?)?;
|
|
261
|
+
m.add_function(wrap_pyfunction!(haversine, m)?)?;
|
|
262
|
+
m.add_function(wrap_pyfunction!(list_all, m)?)?;
|
|
263
|
+
Ok(())
|
|
264
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zipcodes
|
|
3
|
+
~~~~~~~~
|
|
4
|
+
|
|
5
|
+
No-SQLite U.S. zipcode validation Python package, ready for use in AWS Lambda
|
|
6
|
+
|
|
7
|
+
:author: Sean Pianka
|
|
8
|
+
:github: @seanpianka
|
|
9
|
+
|
|
10
|
+
All queries — full-database scans and ``zips=`` chaining lists alike — run in
|
|
11
|
+
the compiled Rust extension (``zipcodes._zipcodes``); this module preserves
|
|
12
|
+
the exact 1.x behavior for argument validation and exceptions.
|
|
13
|
+
"""
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
from zipcodes import _zipcodes
|
|
17
|
+
|
|
18
|
+
__author__ = "Sean Pianka"
|
|
19
|
+
__email__ = "pianka@eml.cc"
|
|
20
|
+
__license__ = "MIT"
|
|
21
|
+
__version__ = _zipcodes.__version__
|
|
22
|
+
|
|
23
|
+
_zips_cache = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_zips():
|
|
27
|
+
global _zips_cache
|
|
28
|
+
if _zips_cache is None:
|
|
29
|
+
_zips_cache = _zipcodes.list_all()
|
|
30
|
+
return _zips_cache
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name):
|
|
34
|
+
# `_zips` was a module-level list in 1.x; keep it importable, but
|
|
35
|
+
# materialize the 42k dicts lazily instead of at import time.
|
|
36
|
+
if name == "_zips":
|
|
37
|
+
return _load_zips()
|
|
38
|
+
raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require_str(zipcode):
|
|
42
|
+
# The only validation that must stay in Python: PyO3's auto-generated
|
|
43
|
+
# TypeError for a non-str argument has a different message than 1.x. All
|
|
44
|
+
# format/character validation lives in the Rust core.
|
|
45
|
+
if not zipcode or not isinstance(zipcode, str):
|
|
46
|
+
raise TypeError("Invalid type, zipcode must be a string.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def matching(zipcode, zips=None):
|
|
50
|
+
"""Retrieve zipcode dict for provided zipcode"""
|
|
51
|
+
_require_str(zipcode)
|
|
52
|
+
return _zipcodes.matching(zipcode, zips=zips)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_valid(zipcode):
|
|
56
|
+
_require_str(zipcode)
|
|
57
|
+
warnings.warn("is_valid is deprecated; use is_real", DeprecationWarning, stacklevel=2)
|
|
58
|
+
return is_real(zipcode)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_real(zipcode):
|
|
62
|
+
"""Determine whether a given zip or zip+4 zipcode is real."""
|
|
63
|
+
_require_str(zipcode)
|
|
64
|
+
return _zipcodes.is_real(zipcode)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def similar_to(partial_zipcode, zips=None):
|
|
68
|
+
"""List of zipcode dicts where zipcode prefix matches `partial_zipcode`"""
|
|
69
|
+
_require_str(partial_zipcode)
|
|
70
|
+
return _zipcodes.similar_to(partial_zipcode, zips=zips)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def contains(partial_zipcode, zips=None):
|
|
74
|
+
"""List of zipcode dicts where zipcode contains `partial_zipcode` fragment"""
|
|
75
|
+
_require_str(partial_zipcode)
|
|
76
|
+
return _zipcodes.contains(partial_zipcode, zips=zips)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def filter_by_state(state, zips=None):
|
|
80
|
+
return filter_by(zips, state=state)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def filter_by_city(city, zips=None):
|
|
84
|
+
return filter_by(zips, city=city)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def filter_by_county(county, zips=None):
|
|
88
|
+
return filter_by(zips, county=county)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def filter_by_timezone(timezone, zips=None):
|
|
92
|
+
return filter_by(zips, timezone=timezone)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def filter_by_zip_code_type(zip_code_type, zips=None):
|
|
96
|
+
return filter_by(zips, zip_code_type=zip_code_type)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def filter_by_coordinates(lat, long, radius_in_miles=10, zips=None):
|
|
100
|
+
"""List of zipcode dicts within `radius_in_miles` of (`lat`, `long`)."""
|
|
101
|
+
return _zipcodes.filter_by_coordinates(lat, long, radius_in_miles, zips=zips)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Pure passthroughs: the native functions already have the desired signature
|
|
105
|
+
# and docstring, so re-export them verbatim rather than wrapping. (The wrappers
|
|
106
|
+
# above add Python-only behavior — a type guard, a default arg, or caching —
|
|
107
|
+
# and so are kept.)
|
|
108
|
+
filter_by = _zipcodes.filter_by
|
|
109
|
+
haversine = _zipcodes.haversine
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_all(zips=None):
|
|
113
|
+
"""Return a list containing all zip-code objects."""
|
|
114
|
+
if zips is None:
|
|
115
|
+
return _load_zips()
|
|
116
|
+
return zips
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
__version__: str
|
|
4
|
+
FIELD_ORDER: List[str]
|
|
5
|
+
|
|
6
|
+
def matching(
|
|
7
|
+
zipcode: str, zips: Optional[List[Dict[str, Any]]] = None
|
|
8
|
+
) -> List[Dict[str, Any]]: ...
|
|
9
|
+
def is_real(zipcode: str) -> bool: ...
|
|
10
|
+
def similar_to(
|
|
11
|
+
prefix: str, zips: Optional[List[Dict[str, Any]]] = None
|
|
12
|
+
) -> List[Dict[str, Any]]: ...
|
|
13
|
+
def contains(
|
|
14
|
+
fragment: str, zips: Optional[List[Dict[str, Any]]] = None
|
|
15
|
+
) -> List[Dict[str, Any]]: ...
|
|
16
|
+
def filter_by(
|
|
17
|
+
zips: Optional[List[Dict[str, Any]]] = None, **filters: Any
|
|
18
|
+
) -> List[Dict[str, Any]]: ...
|
|
19
|
+
def filter_by_coordinates(
|
|
20
|
+
lat: float,
|
|
21
|
+
long: float,
|
|
22
|
+
radius_in_miles: float,
|
|
23
|
+
zips: Optional[List[Dict[str, Any]]] = None,
|
|
24
|
+
) -> List[Dict[str, Any]]: ...
|
|
25
|
+
def haversine(lon1: float, lat1: float, lon2: float, lat2: float) -> float: ...
|
|
26
|
+
def list_all() -> List[Dict[str, Any]]: ...
|
|
Binary file
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
//! Native module `zipcodes._zipcodes`.
|
|
2
|
-
//!
|
|
3
|
-
//! The Python-facing compat layer (validation, exact 1.x exception messages,
|
|
4
|
-
//! `zips=` chaining over caller-supplied dicts) lives in
|
|
5
|
-
//! `python/zipcodes/__init__.py`; this module only handles scans over the
|
|
6
|
-
//! embedded database, materializing matching records as Python dicts.
|
|
7
|
-
|
|
8
|
-
use pyo3::prelude::*;
|
|
9
|
-
use pyo3::types::{PyBool, PyDict, PyList};
|
|
10
|
-
use serde_json::Value;
|
|
11
|
-
use zipcodes::Zipcode;
|
|
12
|
-
|
|
13
|
-
/// Build a dict with the same field order the 1.x pure-Python package produced.
|
|
14
|
-
fn to_dict<'py>(py: Python<'py>, z: &Zipcode) -> PyResult<Bound<'py, PyDict>> {
|
|
15
|
-
let dict = PyDict::new(py);
|
|
16
|
-
dict.set_item("zip_code", &z.zip_code)?;
|
|
17
|
-
dict.set_item("zip_code_type", &z.zip_code_type)?;
|
|
18
|
-
dict.set_item("active", z.active)?;
|
|
19
|
-
dict.set_item("city", &z.city)?;
|
|
20
|
-
dict.set_item("acceptable_cities", &z.acceptable_cities)?;
|
|
21
|
-
dict.set_item("unacceptable_cities", &z.unacceptable_cities)?;
|
|
22
|
-
dict.set_item("state", &z.state)?;
|
|
23
|
-
dict.set_item("county", &z.county)?;
|
|
24
|
-
dict.set_item("timezone", &z.timezone)?;
|
|
25
|
-
dict.set_item("area_codes", &z.area_codes)?;
|
|
26
|
-
dict.set_item("world_region", &z.world_region)?;
|
|
27
|
-
dict.set_item("country", &z.country)?;
|
|
28
|
-
dict.set_item("lat", &z.lat)?;
|
|
29
|
-
dict.set_item("long", &z.long)?;
|
|
30
|
-
Ok(dict)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
fn collect<'py, F>(py: Python<'py>, predicate: F) -> PyResult<Bound<'py, PyList>>
|
|
34
|
-
where
|
|
35
|
-
F: Fn(&Zipcode) -> bool,
|
|
36
|
-
{
|
|
37
|
-
let dicts = zipcodes::database()
|
|
38
|
-
.iter()
|
|
39
|
-
.filter(|z| predicate(z))
|
|
40
|
-
.map(|z| to_dict(py, z))
|
|
41
|
-
.collect::<PyResult<Vec<_>>>()?;
|
|
42
|
-
PyList::new(py, dicts)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/// Convert a Python filter value to JSON for comparison against record fields.
|
|
46
|
-
/// Returns None for values no record field could ever equal (e.g. sets,
|
|
47
|
-
/// arbitrary objects), which the caller treats as "matches nothing".
|
|
48
|
-
fn py_to_json(value: &Bound<'_, PyAny>) -> Option<Value> {
|
|
49
|
-
if value.is_none() {
|
|
50
|
-
return Some(Value::Null);
|
|
51
|
-
}
|
|
52
|
-
if let Ok(b) = value.cast::<PyBool>() {
|
|
53
|
-
return Some(Value::Bool(b.is_true()));
|
|
54
|
-
}
|
|
55
|
-
if let Ok(i) = value.extract::<i64>() {
|
|
56
|
-
return Some(Value::from(i));
|
|
57
|
-
}
|
|
58
|
-
if let Ok(f) = value.extract::<f64>() {
|
|
59
|
-
return Some(Value::from(f));
|
|
60
|
-
}
|
|
61
|
-
if let Ok(s) = value.extract::<String>() {
|
|
62
|
-
return Some(Value::String(s));
|
|
63
|
-
}
|
|
64
|
-
if let Ok(list) = value.cast::<PyList>() {
|
|
65
|
-
let mut arr = Vec::with_capacity(list.len());
|
|
66
|
-
for item in list.iter() {
|
|
67
|
-
arr.push(py_to_json(&item)?);
|
|
68
|
-
}
|
|
69
|
-
return Some(Value::Array(arr));
|
|
70
|
-
}
|
|
71
|
-
None
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/// `zipcode` arrives pre-validated by the Python shim (digits only, length <= 5).
|
|
75
|
-
#[pyfunction]
|
|
76
|
-
fn matching<'py>(py: Python<'py>, zipcode: &str) -> PyResult<Bound<'py, PyList>> {
|
|
77
|
-
collect(py, |z| z.zip_code == zipcode)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
#[pyfunction]
|
|
81
|
-
fn is_real(zipcode: &str) -> bool {
|
|
82
|
-
zipcodes::database().iter().any(|z| z.zip_code == zipcode)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
#[pyfunction]
|
|
86
|
-
fn similar_to<'py>(py: Python<'py>, prefix: &str) -> PyResult<Bound<'py, PyList>> {
|
|
87
|
-
collect(py, |z| z.zip_code.starts_with(prefix))
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
#[pyfunction]
|
|
91
|
-
fn contains<'py>(py: Python<'py>, fragment: &str) -> PyResult<Bound<'py, PyList>> {
|
|
92
|
-
collect(py, |z| z.zip_code.contains(fragment))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
#[pyfunction]
|
|
96
|
-
#[pyo3(signature = (**kwargs))]
|
|
97
|
-
fn filter_by<'py>(
|
|
98
|
-
py: Python<'py>,
|
|
99
|
-
kwargs: Option<&Bound<'py, PyDict>>,
|
|
100
|
-
) -> PyResult<Bound<'py, PyList>> {
|
|
101
|
-
let mut filters = Vec::new();
|
|
102
|
-
if let Some(kwargs) = kwargs {
|
|
103
|
-
for (key, value) in kwargs.iter() {
|
|
104
|
-
let key: String = key.extract()?;
|
|
105
|
-
match py_to_json(&value) {
|
|
106
|
-
Some(value) => filters.push((key, value)),
|
|
107
|
-
// A value of an inconvertible type can never equal a record
|
|
108
|
-
// field, and all filters must match.
|
|
109
|
-
None => return Ok(PyList::empty(py)),
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
let filters = filters; // freeze for the closure
|
|
114
|
-
collect(py, |z| filters.iter().all(|(k, v)| z.field_matches(k, v)))
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
#[pyfunction]
|
|
118
|
-
fn filter_by_coordinates<'py>(
|
|
119
|
-
py: Python<'py>,
|
|
120
|
-
lat: f64,
|
|
121
|
-
long: f64,
|
|
122
|
-
radius_in_miles: f64,
|
|
123
|
-
) -> PyResult<Bound<'py, PyList>> {
|
|
124
|
-
collect(py, |z| {
|
|
125
|
-
match (z.lat.parse::<f64>(), z.long.parse::<f64>()) {
|
|
126
|
-
(Ok(z_lat), Ok(z_long)) => {
|
|
127
|
-
zipcodes::haversine(z_long, z_lat, long, lat) <= radius_in_miles
|
|
128
|
-
}
|
|
129
|
-
_ => false,
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
#[pyfunction]
|
|
135
|
-
fn haversine(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
|
|
136
|
-
zipcodes::haversine(lon1, lat1, lon2, lat2)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
#[pyfunction]
|
|
140
|
-
fn list_all<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
|
|
141
|
-
collect(py, |_| true)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
#[pymodule]
|
|
145
|
-
fn _zipcodes(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
146
|
-
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
147
|
-
m.add_function(wrap_pyfunction!(matching, m)?)?;
|
|
148
|
-
m.add_function(wrap_pyfunction!(is_real, m)?)?;
|
|
149
|
-
m.add_function(wrap_pyfunction!(similar_to, m)?)?;
|
|
150
|
-
m.add_function(wrap_pyfunction!(contains, m)?)?;
|
|
151
|
-
m.add_function(wrap_pyfunction!(filter_by, m)?)?;
|
|
152
|
-
m.add_function(wrap_pyfunction!(filter_by_coordinates, m)?)?;
|
|
153
|
-
m.add_function(wrap_pyfunction!(haversine, m)?)?;
|
|
154
|
-
m.add_function(wrap_pyfunction!(list_all, m)?)?;
|
|
155
|
-
Ok(())
|
|
156
|
-
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
zipcodes
|
|
3
|
-
~~~~~~~~
|
|
4
|
-
|
|
5
|
-
No-SQLite U.S. zipcode validation Python package, ready for use in AWS Lambda
|
|
6
|
-
|
|
7
|
-
:author: Sean Pianka
|
|
8
|
-
:github: @seanpianka
|
|
9
|
-
|
|
10
|
-
The full-database scans run in the compiled Rust extension
|
|
11
|
-
(``zipcodes._zipcodes``); this module preserves the exact 1.x behavior for
|
|
12
|
-
argument validation, exceptions, and the ``zips=`` chaining lists.
|
|
13
|
-
"""
|
|
14
|
-
import re
|
|
15
|
-
import warnings
|
|
16
|
-
from math import asin, cos, radians, sin, sqrt
|
|
17
|
-
|
|
18
|
-
from zipcodes import _zipcodes
|
|
19
|
-
|
|
20
|
-
__author__ = "Sean Pianka"
|
|
21
|
-
__email__ = "pianka@eml.cc"
|
|
22
|
-
__license__ = "MIT"
|
|
23
|
-
__version__ = _zipcodes.__version__
|
|
24
|
-
|
|
25
|
-
_digits = re.compile(r"[^\d\-]")
|
|
26
|
-
_valid_zipcode_length = 5
|
|
27
|
-
|
|
28
|
-
_zips_cache = None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _load_zips():
|
|
32
|
-
global _zips_cache
|
|
33
|
-
if _zips_cache is None:
|
|
34
|
-
_zips_cache = _zipcodes.list_all()
|
|
35
|
-
return _zips_cache
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def __getattr__(name):
|
|
39
|
-
# `_zips` was a module-level list in 1.x; keep it importable, but
|
|
40
|
-
# materialize the 42k dicts lazily instead of at import time.
|
|
41
|
-
if name == "_zips":
|
|
42
|
-
return _load_zips()
|
|
43
|
-
raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name))
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _clean_zipcode(fn):
|
|
47
|
-
def decorator(zipcode, *args, **kwargs):
|
|
48
|
-
if not zipcode or not isinstance(zipcode, str):
|
|
49
|
-
raise TypeError("Invalid type, zipcode must be a string.")
|
|
50
|
-
|
|
51
|
-
return fn(
|
|
52
|
-
_clean(zipcode, min(len(zipcode), _valid_zipcode_length)), *args, **kwargs
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
return decorator
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@_clean_zipcode
|
|
59
|
-
def matching(zipcode, zips=None):
|
|
60
|
-
"""Retrieve zipcode dict for provided zipcode"""
|
|
61
|
-
if zips is None:
|
|
62
|
-
return _zipcodes.matching(zipcode)
|
|
63
|
-
return [z for z in zips if z["zip_code"] == zipcode]
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@_clean_zipcode
|
|
67
|
-
def is_valid(zipcode):
|
|
68
|
-
warnings.warn("is_valid is deprecated; use is_real", DeprecationWarning, stacklevel=2)
|
|
69
|
-
return is_real(zipcode)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@_clean_zipcode
|
|
73
|
-
def is_real(zipcode):
|
|
74
|
-
"""Determine whether a given zip or zip+4 zipcode is real."""
|
|
75
|
-
return _zipcodes.is_real(zipcode)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
@_clean_zipcode
|
|
79
|
-
def similar_to(partial_zipcode, zips=None):
|
|
80
|
-
"""List of zipcode dicts where zipcode prefix matches `partial_zipcode`"""
|
|
81
|
-
if zips is None:
|
|
82
|
-
return _zipcodes.similar_to(partial_zipcode)
|
|
83
|
-
return [z for z in zips if z["zip_code"].startswith(partial_zipcode)]
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@_clean_zipcode
|
|
87
|
-
def contains(partial_zipcode, zips=None):
|
|
88
|
-
"""List of zipcode dicts where zipcode contains `partial_zipcode` fragment"""
|
|
89
|
-
if zips is None:
|
|
90
|
-
return _zipcodes.contains(partial_zipcode)
|
|
91
|
-
return [z for z in zips if partial_zipcode in z["zip_code"]]
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def filter_by_state(state, zips=None):
|
|
95
|
-
return filter_by(zips, state=state)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def filter_by_city(city, zips=None):
|
|
99
|
-
return filter_by(zips, city=city)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def filter_by_county(county, zips=None):
|
|
103
|
-
return filter_by(zips, county=county)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def filter_by_timezone(timezone, zips=None):
|
|
107
|
-
return filter_by(zips, timezone=timezone)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def filter_by_zip_code_type(zip_code_type, zips=None):
|
|
111
|
-
return filter_by(zips, zip_code_type=zip_code_type)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def haversine(lon1, lat1, lon2, lat2):
|
|
115
|
-
"""
|
|
116
|
-
Calculate the great circle distance in miles between two points
|
|
117
|
-
on the earth (specified in decimal degrees)
|
|
118
|
-
"""
|
|
119
|
-
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])
|
|
120
|
-
|
|
121
|
-
dlon = lon2 - lon1
|
|
122
|
-
dlat = lat2 - lat1
|
|
123
|
-
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
|
124
|
-
c = 2 * asin(sqrt(a))
|
|
125
|
-
r = 3956 # Radius of earth in miles. Use 6371 for kilometers.
|
|
126
|
-
return c * r
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def filter_by_coordinates(lat, long, radius_in_miles=10, zips=None):
|
|
130
|
-
"""List of zipcode dicts within `radius_in_miles` of (`lat`, `long`)."""
|
|
131
|
-
if zips is None:
|
|
132
|
-
return _zipcodes.filter_by_coordinates(lat, long, radius_in_miles)
|
|
133
|
-
return [
|
|
134
|
-
z
|
|
135
|
-
for z in zips
|
|
136
|
-
if haversine(float(z["long"]), float(z["lat"]), long, lat) <= radius_in_miles
|
|
137
|
-
]
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def filter_by(zips=None, **filters):
|
|
141
|
-
"""Use `kwargs` to select for desired attributes from list of zipcode dicts"""
|
|
142
|
-
if zips is None:
|
|
143
|
-
return _zipcodes.filter_by(**filters)
|
|
144
|
-
return [
|
|
145
|
-
z
|
|
146
|
-
for z in zips
|
|
147
|
-
if all(key in z and z[key] == value for key, value in filters.items())
|
|
148
|
-
]
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def list_all(zips=None):
|
|
152
|
-
"""Return a list containing all zip-code objects."""
|
|
153
|
-
if zips is None:
|
|
154
|
-
return _load_zips()
|
|
155
|
-
return zips
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _contains_nondigits(s):
|
|
159
|
-
return bool(_digits.search(s))
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _clean(zipcode, valid_length=_valid_zipcode_length):
|
|
163
|
-
"""Assumes zipcode is of type `str`"""
|
|
164
|
-
zipcode = zipcode.split("-")[0] # Convert #####-#### to #####
|
|
165
|
-
|
|
166
|
-
if len(zipcode) != valid_length:
|
|
167
|
-
raise ValueError(
|
|
168
|
-
'Invalid format, zipcode must be of the format: "#####" or "#####-####"'
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
if _contains_nondigits(zipcode):
|
|
172
|
-
raise ValueError('Invalid characters, zipcode may only contain digits and "-".')
|
|
173
|
-
|
|
174
|
-
return zipcode
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
from typing import Any, Dict, List
|
|
2
|
-
|
|
3
|
-
__version__: str
|
|
4
|
-
|
|
5
|
-
def matching(zipcode: str) -> List[Dict[str, Any]]: ...
|
|
6
|
-
def is_real(zipcode: str) -> bool: ...
|
|
7
|
-
def similar_to(prefix: str) -> List[Dict[str, Any]]: ...
|
|
8
|
-
def contains(fragment: str) -> List[Dict[str, Any]]: ...
|
|
9
|
-
def filter_by(**filters: Any) -> List[Dict[str, Any]]: ...
|
|
10
|
-
def filter_by_coordinates(
|
|
11
|
-
lat: float, long: float, radius_in_miles: float
|
|
12
|
-
) -> List[Dict[str, Any]]: ...
|
|
13
|
-
def haversine(lon1: float, lat1: float, lon2: float, lat2: float) -> float: ...
|
|
14
|
-
def list_all() -> List[Dict[str, Any]]: ...
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|