Skip to content

Commit

Permalink
Use full USPS zipcode database as of November 1, 2023
Browse files Browse the repository at this point in the history
  • Loading branch information
davepeck committed Nov 27, 2023
1 parent 0769fce commit de91ef8
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 34 deletions.
36 changes: 25 additions & 11 deletions server/data/usps/test_zipcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
PHYSICAL ZIP,PHYSICAL CITY,PHYSICAL STATE
12345,NEW YORK,NY
12345,NEW YORK,NY
12345,BRONX,NY
98101,SEATTLE,WA
98102,SEATTLE,WA
98103,SEATTLE,WA
Expand All @@ -21,31 +22,44 @@ def setUp(self):
self.data = io.StringIO(FAKE_CSV_DATA)
self.zip_code_manager = z.ZipCodeManager.from_csv_io(self.data)
self.new_york = z.CityState("NEW YORK", "NY")
self.bronx = z.CityState("BRONX", "NY")
self.seattle = z.CityState("SEATTLE", "WA")

def test_init(self):
self.assertEqual(len(self.zip_code_manager.zip_codes), 7)
self.assertEqual(len(self.zip_code_manager.zip_codes), 8)

def test_city_to_zip_codes(self):
self.assertEqual(len(self.zip_code_manager.city_to_zip_codes), 2)
self.assertEqual(len(self.zip_code_manager.city_to_zip_codes), 3)
self.assertEqual(len(self.zip_code_manager.city_to_zip_codes[self.new_york]), 1)
self.assertEqual(len(self.zip_code_manager.city_to_zip_codes[self.bronx]), 1)
self.assertEqual(len(self.zip_code_manager.city_to_zip_codes[self.seattle]), 5)

def test_zip5_to_city(self):
self.assertEqual(len(self.zip_code_manager.zip5_to_city), 6)
self.assertEqual(self.zip_code_manager.zip5_to_city["12345"], self.new_york)
self.assertEqual(self.zip_code_manager.zip5_to_city["98101"], self.seattle)
def test_zip5_to_cities(self):
self.assertEqual(len(self.zip_code_manager.zip5_to_cities), 6)
self.assertEqual(
self.zip_code_manager.zip5_to_cities["12345"],
frozenset([self.new_york, self.bronx]),
)
self.assertEqual(
self.zip_code_manager.zip5_to_cities["98101"], frozenset([self.seattle])
)

def test_get_zip_codes(self):
self.assertEqual(len(self.zip_code_manager.get_zip_codes(self.new_york)), 1)
self.assertEqual(len(self.zip_code_manager.get_zip_codes(self.bronx)), 1)
self.assertEqual(len(self.zip_code_manager.get_zip_codes(self.seattle)), 5)
self.assertEqual(len(self.zip_code_manager.get_zip_codes("seattle")), 5)
self.assertEqual(len(self.zip_code_manager.get_zip_codes("nowhere")), 0)

def test_get_city_state(self):
self.assertEqual(self.zip_code_manager.get_city_state("12345"), self.new_york)
self.assertEqual(self.zip_code_manager.get_city_state("98101"), self.seattle)
def test_get_city_states(self):
self.assertEqual(
self.zip_code_manager.get_city_states("12345"),
frozenset([self.new_york, self.bronx]),
)
self.assertEqual(
self.zip_code_manager.get_city_states("98101"), frozenset([self.seattle])
)

def test_get_city_state_not_found(self):
self.assertIsNone(self.zip_code_manager.get_city_state("00000"))
self.assertIsNone(self.zip_code_manager.get_city_state("99999"))
self.assertEqual(self.zip_code_manager.get_city_states("00000"), frozenset())
self.assertEqual(self.zip_code_manager.get_city_states("99999"), frozenset())
51 changes: 28 additions & 23 deletions server/data/usps/zipcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ class ZipCodeManager:
"""Offers methods for managing the raw USPS-supplied unique ZIP code data csv."""

_zip_codes: list[ZipCode]
_city_to_zip_codes: dict[CityState, set[ZipCode]] | None
_zip5_to_city: dict[str, CityState] | None
_city_to_zip_codes: dict[CityState, frozenset[ZipCode]] | None
_zip5_to_cities: dict[str, frozenset[CityState]] | None

def __init__(self, zip_codes: t.Sequence[ZipCode]) -> None:
self._zip_codes = list(zip_codes)
self._city_to_zip_codes = None
self._zip5_to_city = None
self._zip5_to_cities = None

@classmethod
def from_csv_io(cls, io: t.TextIO) -> "ZipCodeManager":
Expand All @@ -57,29 +57,34 @@ def from_path(cls, path: str | pathlib.Path) -> "ZipCodeManager":
@classmethod
def from_data_manager(cls, data_manager: DataManager) -> "ZipCodeManager":
"""Return a ZipCodeManager with the same path as the given DataManager."""
return cls.from_path(data_manager.path / "usps" / "unique-zips.csv")
return cls.from_path(data_manager.path / "usps" / "zips.csv")

def _index_cities(self) -> None:
assert self._city_to_zip_codes is None
self._city_to_zip_codes = {}
unfrozen_city_to_zip_codes: dict[CityState, set[ZipCode]] = {}
for zip_code in self.zip_codes:
self._city_to_zip_codes.setdefault(zip_code.as_cs(), set()).add(zip_code)
unfrozen_city_to_zip_codes.setdefault(zip_code.as_cs(), set()).add(zip_code)
self._city_to_zip_codes = {
k: frozenset(v) for k, v in unfrozen_city_to_zip_codes.items()
}

def _index_cities_if_needed(self) -> None:
if self._city_to_zip_codes is None:
self._index_cities()

def _index_zip5s(self) -> None:
assert self._zip5_to_city is None
self._zip5_to_city = {}
assert self._zip5_to_cities is None
unfrozen_zip5_to_cities: dict[str, set[CityState]] = {}
for zip_code in self.zip_codes:
if zip_code.zip5 not in self._zip5_to_city:
self._zip5_to_city[zip_code.zip5] = zip_code.as_cs()
else:
assert self._zip5_to_city[zip_code.zip5] == zip_code.as_cs()
unfrozen_zip5_to_cities.setdefault(zip_code.zip5, set()).add(
zip_code.as_cs()
)
self._zip5_to_cities = {
k: frozenset(v) for k, v in unfrozen_zip5_to_cities.items()
}

def _index_zip5s_if_needed(self) -> None:
if self._zip5_to_city is None:
if self._zip5_to_cities is None:
self._index_zip5s()

@property
Expand All @@ -88,7 +93,7 @@ def zip_codes(self) -> t.Sequence[ZipCode]:
return self._zip_codes

@property
def city_to_zip_codes(self) -> t.Mapping[CityState, set[ZipCode]]:
def city_to_zip_codes(self) -> t.Mapping[CityState, frozenset[ZipCode]]:
"""
Return a dict mapping each city to a set of all unique ZIP
codes in that city.
Expand All @@ -98,20 +103,20 @@ def city_to_zip_codes(self) -> t.Mapping[CityState, set[ZipCode]]:
return self._city_to_zip_codes

@property
def zip5_to_city(self) -> t.Mapping[str, CityState]:
def zip5_to_cities(self) -> t.Mapping[str, frozenset[CityState]]:
"""Return a dict mapping each ZIP5 to the city and state it belongs to."""
self._index_zip5s_if_needed()
assert self._zip5_to_city is not None
return self._zip5_to_city
assert self._zip5_to_cities is not None
return {k: frozenset(v) for k, v in self._zip5_to_cities.items()}

def get_zip_codes(self, city: str | CityState | None) -> set[ZipCode]:
def get_zip_codes(self, city: str | CityState | None) -> frozenset[ZipCode]:
"""Return a set of all unique ZIP codes in the given city."""
if isinstance(city, str):
city = MajorMetros.for_city(city)
if city is None:
return set()
return self.city_to_zip_codes.get(city, set())
return frozenset()
return self.city_to_zip_codes.get(city, frozenset())

def get_city_state(self, zip5: str) -> CityState | None:
"""Return the city and state for the given ZIP5."""
return self.zip5_to_city.get(zip5)
def get_city_states(self, zip5: str) -> frozenset[CityState]:
"""Return all cities and states for the given ZIP5."""
return self.zip5_to_cities.get(zip5, frozenset())

0 comments on commit de91ef8

Please sign in to comment.