zipcodes 2.0.1__tar.gz → 3.0.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.
@@ -216,7 +216,7 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
216
216
 
217
217
  [[package]]
218
218
  name = "zipcodes"
219
- version = "2.0.1"
219
+ version = "3.0.0"
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.0"
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.0"
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.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -106,6 +106,17 @@ $ cargo add zipcodes
106
106
  - Behavioral notes for upgraders: query results are fresh dicts (mutating a
107
107
  result no longer mutates the shared database list), and
108
108
  `filter_by(active=1)` no longer matches `active=True` (pass a bool).
109
+ - **Unified validation.** Input validation now lives once, in the Rust core,
110
+ instead of a separate Python validator. This changes a few edge cases:
111
+ - **Breaking:** `is_real`/`matching` now raise `ValueError` for input shorter
112
+ than five characters (after trimming), instead of returning `False`/`[]`.
113
+ - **Relaxations:** surrounding whitespace (`" 06903 "`) and space-separated
114
+ Zip+4 (`"12345 6789"`) are now accepted; any input of five or more
115
+ characters is normalized to its first five digits, generalizing the
116
+ existing `#####-####` handling.
117
+ - **Private API removal:** the undocumented helpers `zipcodes._clean` and
118
+ `zipcodes._contains_nondigits` (and the `_digits`/`_valid_zipcode_length`
119
+ module attributes) no longer exist.
109
120
 
110
121
  ## Zipcode Data
111
122
 
@@ -208,7 +219,7 @@ Traceback (most recent call last):
208
219
  ...
209
220
  ValueError: Invalid characters, zipcode may only contain digits and "-".
210
221
 
211
- >>> zipcodes.matching('064690')
222
+ >>> zipcodes.matching('0646')
212
223
  Traceback (most recent call last):
213
224
  ...
214
225
  ValueError: Invalid format, zipcode must be of the format: "#####" or "#####-####"
@@ -83,6 +83,17 @@ $ cargo add zipcodes
83
83
  - Behavioral notes for upgraders: query results are fresh dicts (mutating a
84
84
  result no longer mutates the shared database list), and
85
85
  `filter_by(active=1)` no longer matches `active=True` (pass a bool).
86
+ - **Unified validation.** Input validation now lives once, in the Rust core,
87
+ instead of a separate Python validator. This changes a few edge cases:
88
+ - **Breaking:** `is_real`/`matching` now raise `ValueError` for input shorter
89
+ than five characters (after trimming), instead of returning `False`/`[]`.
90
+ - **Relaxations:** surrounding whitespace (`" 06903 "`) and space-separated
91
+ Zip+4 (`"12345 6789"`) are now accepted; any input of five or more
92
+ characters is normalized to its first five digits, generalizing the
93
+ existing `#####-####` handling.
94
+ - **Private API removal:** the undocumented helpers `zipcodes._clean` and
95
+ `zipcodes._contains_nondigits` (and the `_digits`/`_valid_zipcode_length`
96
+ module attributes) no longer exist.
86
97
 
87
98
  ## Zipcode Data
88
99
 
@@ -185,7 +196,7 @@ Traceback (most recent call last):
185
196
  ...
186
197
  ValueError: Invalid characters, zipcode may only contain digits and "-".
187
198
 
188
- >>> zipcodes.matching('064690')
199
+ >>> zipcodes.matching('0646')
189
200
  Traceback (most recent call last):
190
201
  ...
191
202
  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,261 @@
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
+ #[pyfunction]
179
+ #[pyo3(signature = (zips=None, **kwargs))]
180
+ fn filter_by<'py>(
181
+ py: Python<'py>,
182
+ zips: Option<Bound<'py, PyList>>,
183
+ kwargs: Option<&Bound<'py, PyDict>>,
184
+ ) -> PyResult<Bound<'py, PyList>> {
185
+ let mut filters = Vec::new();
186
+ if let Some(kwargs) = kwargs {
187
+ for (key, value) in kwargs.iter() {
188
+ let key: String = key.extract()?;
189
+ match py_to_json(&value) {
190
+ Some(value) => filters.push((key, value)),
191
+ // A value of an inconvertible type can never equal a record
192
+ // field, and all filters must match.
193
+ None => return Ok(PyList::empty(py)),
194
+ }
195
+ }
196
+ }
197
+ match zips {
198
+ None => to_pylist(py, &zipcodes::filter_by_fields(&filters, None)),
199
+ // Both sides of the comparison go through `py_to_json`, never Python
200
+ // rich comparison, so e.g. `active=1` does not match a True field.
201
+ Some(zips) => filter_pylist(py, &zips, |item| {
202
+ filters.iter().all(|(key, value)| {
203
+ item.get_item(key.as_str())
204
+ .ok()
205
+ .and_then(|v| py_to_json(&v))
206
+ .as_ref()
207
+ == Some(value)
208
+ })
209
+ }),
210
+ }
211
+ }
212
+
213
+ #[pyfunction]
214
+ #[pyo3(signature = (lat, long, radius_in_miles, zips=None))]
215
+ fn filter_by_coordinates<'py>(
216
+ py: Python<'py>,
217
+ lat: f64,
218
+ long: f64,
219
+ radius_in_miles: f64,
220
+ zips: Option<Bound<'py, PyList>>,
221
+ ) -> PyResult<Bound<'py, PyList>> {
222
+ match zips {
223
+ None => to_pylist(
224
+ py,
225
+ &zipcodes::filter_by_coordinates(lat, long, radius_in_miles, None),
226
+ ),
227
+ Some(zips) => filter_pylist(py, &zips, |item| {
228
+ match (get_coordinate(item, "lat"), get_coordinate(item, "long")) {
229
+ (Some(z_lat), Some(z_long)) => {
230
+ zipcodes::haversine(z_long, z_lat, long, lat) <= radius_in_miles
231
+ }
232
+ _ => false,
233
+ }
234
+ }),
235
+ }
236
+ }
237
+
238
+ #[pyfunction]
239
+ fn haversine(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
240
+ zipcodes::haversine(lon1, lat1, lon2, lat2)
241
+ }
242
+
243
+ #[pyfunction]
244
+ fn list_all<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
245
+ to_pylist(py, zipcodes::database())
246
+ }
247
+
248
+ #[pymodule]
249
+ fn _zipcodes(m: &Bound<'_, PyModule>) -> PyResult<()> {
250
+ m.add("__version__", env!("CARGO_PKG_VERSION"))?;
251
+ m.add("FIELD_ORDER", zipcodes::FIELD_ORDER.to_vec())?;
252
+ m.add_function(wrap_pyfunction!(matching, m)?)?;
253
+ m.add_function(wrap_pyfunction!(is_real, m)?)?;
254
+ m.add_function(wrap_pyfunction!(similar_to, m)?)?;
255
+ m.add_function(wrap_pyfunction!(contains, m)?)?;
256
+ m.add_function(wrap_pyfunction!(filter_by, m)?)?;
257
+ m.add_function(wrap_pyfunction!(filter_by_coordinates, m)?)?;
258
+ m.add_function(wrap_pyfunction!(haversine, m)?)?;
259
+ m.add_function(wrap_pyfunction!(list_all, m)?)?;
260
+ Ok(())
261
+ }
@@ -7,13 +7,11 @@ No-SQLite U.S. zipcode validation Python package, ready for use in AWS Lambda
7
7
  :author: Sean Pianka
8
8
  :github: @seanpianka
9
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.
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
13
  """
14
- import re
15
14
  import warnings
16
- from math import asin, cos, radians, sin, sqrt
17
15
 
18
16
  from zipcodes import _zipcodes
19
17
 
@@ -22,9 +20,6 @@ __email__ = "pianka@eml.cc"
22
20
  __license__ = "MIT"
23
21
  __version__ = _zipcodes.__version__
24
22
 
25
- _digits = re.compile(r"[^\d\-]")
26
- _valid_zipcode_length = 5
27
-
28
23
  _zips_cache = None
29
24
 
30
25
 
@@ -43,52 +38,42 @@ def __getattr__(name):
43
38
  raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name))
44
39
 
45
40
 
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
- )
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.")
54
47
 
55
- return decorator
56
48
 
57
-
58
- @_clean_zipcode
59
49
  def matching(zipcode, zips=None):
60
50
  """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]
51
+ _require_str(zipcode)
52
+ return _zipcodes.matching(zipcode, zips=zips)
64
53
 
65
54
 
66
- @_clean_zipcode
67
55
  def is_valid(zipcode):
56
+ _require_str(zipcode)
68
57
  warnings.warn("is_valid is deprecated; use is_real", DeprecationWarning, stacklevel=2)
69
58
  return is_real(zipcode)
70
59
 
71
60
 
72
- @_clean_zipcode
73
61
  def is_real(zipcode):
74
62
  """Determine whether a given zip or zip+4 zipcode is real."""
63
+ _require_str(zipcode)
75
64
  return _zipcodes.is_real(zipcode)
76
65
 
77
66
 
78
- @_clean_zipcode
79
67
  def similar_to(partial_zipcode, zips=None):
80
68
  """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)]
69
+ _require_str(partial_zipcode)
70
+ return _zipcodes.similar_to(partial_zipcode, zips=zips)
84
71
 
85
72
 
86
- @_clean_zipcode
87
73
  def contains(partial_zipcode, zips=None):
88
74
  """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"]]
75
+ _require_str(partial_zipcode)
76
+ return _zipcodes.contains(partial_zipcode, zips=zips)
92
77
 
93
78
 
94
79
  def filter_by_state(state, zips=None):
@@ -116,36 +101,17 @@ def haversine(lon1, lat1, lon2, lat2):
116
101
  Calculate the great circle distance in miles between two points
117
102
  on the earth (specified in decimal degrees)
118
103
  """
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
104
+ return _zipcodes.haversine(lon1, lat1, lon2, lat2)
127
105
 
128
106
 
129
107
  def filter_by_coordinates(lat, long, radius_in_miles=10, zips=None):
130
108
  """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
- ]
109
+ return _zipcodes.filter_by_coordinates(lat, long, radius_in_miles, zips=zips)
138
110
 
139
111
 
140
112
  def filter_by(zips=None, **filters):
141
113
  """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
- ]
114
+ return _zipcodes.filter_by(zips, **filters)
149
115
 
150
116
 
151
117
  def list_all(zips=None):
@@ -153,22 +119,3 @@ def list_all(zips=None):
153
119
  if zips is None:
154
120
  return _load_zips()
155
121
  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
@@ -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,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