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.
@@ -216,7 +216,7 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
216
216
 
217
217
  [[package]]
218
218
  name = "zipcodes"
219
- version = "2.0.1"
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 = "2.0.1"
229
+ version = "3.0.1"
230
230
  dependencies = [
231
231
  "pyo3",
232
232
  "serde_json",
@@ -3,7 +3,7 @@ members = ["crates/zipcodes", "crates/zipcodes-py"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "2.0.1"
6
+ version = "3.0.1"
7
7
  edition = "2021"
8
8
  rust-version = "1.82"
9
9
  authors = ["Sean Pianka <pianka@eml.cc>"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zipcodes
3
- Version: 2.0.1
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
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/zipcodes)
27
- [![PyPI](https://img.shields.io/pypi/v/zipcodes)](https://pypi.org/project/zipcodes/)
28
- [![crates.io](https://img.shields.io/crates/v/zipcodes)](https://crates.io/crates/zipcodes)
29
- [![Downloads](https://static.pepy.tech/badge/zipcodes/month)](https://pepy.tech/project/zipcodes)
30
- [![Contributors](https://img.shields.io/github/contributors/seanpianka/zipcodes.svg)](https://github.com/seanpianka/zipcodes/graphs/contributors)
26
+ Python: ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/zipcodes) [![PyPI](https://img.shields.io/pypi/v/zipcodes)](https://pypi.org/project/zipcodes/) [![Python downloads (all time)](https://static.pepy.tech/badge/zipcodes)](https://pepy.tech/project/zipcodes) [![Python downloads (monthly)](https://static.pepy.tech/badge/zipcodes/month)](https://pepy.tech/project/zipcodes)
27
+
28
+ Rust: [![Minimum Rust version](https://img.shields.io/crates/msrv/zipcodes)](https://crates.io/crates/zipcodes) [![crates.io](https://img.shields.io/crates/v/zipcodes)](https://crates.io/crates/zipcodes) [![Rust downloads (all time)](https://img.shields.io/crates/d/zipcodes?label=downloads%20(all%20time))](https://crates.io/crates/zipcodes) [![Rust downloads (recent)](https://img.shields.io/crates/dr/zipcodes?label=downloads%20(recent))](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. [![Contributors](https://img.shields.io/github/contributors/seanpianka/zipcodes.svg)](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 xlrd
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 .xls linked from https://postalpro.usps.com/ZIP_Locale_Detail
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-xls /tmp/usps_zip_locale.xls \
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('064690')
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
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/zipcodes)
4
- [![PyPI](https://img.shields.io/pypi/v/zipcodes)](https://pypi.org/project/zipcodes/)
5
- [![crates.io](https://img.shields.io/crates/v/zipcodes)](https://crates.io/crates/zipcodes)
6
- [![Downloads](https://static.pepy.tech/badge/zipcodes/month)](https://pepy.tech/project/zipcodes)
7
- [![Contributors](https://img.shields.io/github/contributors/seanpianka/zipcodes.svg)](https://github.com/seanpianka/zipcodes/graphs/contributors)
3
+ Python: ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/zipcodes) [![PyPI](https://img.shields.io/pypi/v/zipcodes)](https://pypi.org/project/zipcodes/) [![Python downloads (all time)](https://static.pepy.tech/badge/zipcodes)](https://pepy.tech/project/zipcodes) [![Python downloads (monthly)](https://static.pepy.tech/badge/zipcodes/month)](https://pepy.tech/project/zipcodes)
4
+
5
+ Rust: [![Minimum Rust version](https://img.shields.io/crates/msrv/zipcodes)](https://crates.io/crates/zipcodes) [![crates.io](https://img.shields.io/crates/v/zipcodes)](https://crates.io/crates/zipcodes) [![Rust downloads (all time)](https://img.shields.io/crates/d/zipcodes?label=downloads%20(all%20time))](https://crates.io/crates/zipcodes) [![Rust downloads (recent)](https://img.shields.io/crates/dr/zipcodes?label=downloads%20(recent))](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. [![Contributors](https://img.shields.io/github/contributors/seanpianka/zipcodes.svg)](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 xlrd
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 .xls linked from https://postalpro.usps.com/ZIP_Locale_Detail
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-xls /tmp/usps_zip_locale.xls \
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('064690')
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
- fn clean_zipcode(zipcode: &str) -> Result<&str> {
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();
@@ -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]]: ...
@@ -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