From 01c2d20dfde866d4bbed9a416cfbed8485156dd1 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 29 Jul 2026 04:29:00 +0200 Subject: [PATCH] Accept upper-case base32 in the pure-Python decoder The _geohash extension's build_base32_decode_map accepts upper-case base32 letters, but the pure-Python fallback's _base32_map is lower-case only, so decode/decode_exactly/bbox/neighbors/expand raise a bare KeyError on an upper-case geohash whenever the extension is not built. Populate _base32_map with the upper-case keys as well, and raise ValueError with the same message the extension reports for a character outside the alphabet instead of leaking KeyError. --- geohash.py | 12 +++++++++- tests/test_geohash.py | 52 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/geohash.py b/geohash.py index 89a19c8..89e7f20 100644 --- a/geohash.py +++ b/geohash.py @@ -14,6 +14,11 @@ _base32_map = {} for i in range(len(_base32)): _base32_map[_base32[i]] = i + # The _geohash extension accepts upper-case base32 letters (see + # build_base32_decode_map in src/geohash_core.rs); mirror that here so the + # pure-Python fallback decodes the same codes the extension does. + if _base32[i].isalpha(): + _base32_map[_base32[i].upper()] = i del i LONG_ZERO = 0 @@ -146,7 +151,12 @@ def _decode_c2i(hashcode): lat_length = 0 lon_length = 0 for i in hashcode: - t = _base32_map[i] + try: + t = _base32_map[i] + except KeyError: + # Match the extension's error (map_error in src/lib.rs) instead of + # leaking a bare KeyError for an out-of-alphabet character. + raise ValueError("geohash code is [0123456789bcdefghjkmnpqrstuvwxyz]+") from None if bit_length%2==0: lon = lon<<3 lat = lat<<2 diff --git a/tests/test_geohash.py b/tests/test_geohash.py index 6cf3c6e..07a3db6 100644 --- a/tests/test_geohash.py +++ b/tests/test_geohash.py @@ -92,5 +92,57 @@ def test_one(self): self.assertEqual(set(['w', 'x', 'y', '8', 'b']), set(geohash.neighbors("z"))) self.assertEqual(set(['2', '6', '1', '0', '4', '9', '8', 'd']), set(geohash.neighbors("3"))) +class TestUpperCase(unittest.TestCase): + # The _geohash extension accepts upper-case base32 letters, so the + # pure-Python fallback (used whenever the extension is not built, e.g. the + # README's "copy geohash.py" path and every non-x86_64-Linux install) must + # decode the same codes to the same result. + codes = ['ezs42', 'u4pruydqqvj', 'dr5regw3pg', 'sunny', 'bgr96qxvpd46'] + + def test_decode_upper_equals_lower(self): + for code in self.codes: + self.assertEqual(geohash.decode(code.upper()), geohash.decode(code)) + + def test_decode_exactly_upper_equals_lower(self): + for code in self.codes: + self.assertEqual(geohash.decode_exactly(code.upper()), geohash.decode_exactly(code)) + + def test_bbox_upper_equals_lower(self): + for code in self.codes: + self.assertEqual(geohash.bbox(code.upper()), geohash.bbox(code)) + + def test_neighbors_upper_equals_lower(self): + for code in self.codes: + self.assertEqual(set(geohash.neighbors(code.upper())), set(geohash.neighbors(code))) + + def test_expand_upper_equals_lower(self): + for code in self.codes: + expanded = geohash.expand(code.upper()) + self.assertIn(code.upper(), expanded) + self.assertEqual(set(expanded) - {code.upper()}, set(geohash.neighbors(code))) + + def test_mixed_case(self): + self.assertEqual(geohash.decode('Ezs42'), geohash.decode('ezs42')) + self.assertEqual(geohash.decode('eZS42'), geohash.decode('ezs42')) + + def test_every_base32_letter_uppercases(self): + for c in '0123456789bcdefghjkmnpqrstuvwxyz': + code = 'e' + c + 's' + self.assertEqual(geohash.decode(code.upper()), geohash.decode(code)) + +class TestInvalidCode(unittest.TestCase): + # Letters a, i, l, o (and their upper-case forms) are not in the geohash + # base32 alphabet; the extension raises ValueError for them, so the + # fallback must too rather than leaking a bare KeyError. + invalid = ['abc', 'io', 'ezs42a', 'ezs42L', 'EZS42O', 'ezs 42', 'ezs42!', 'A', 'I', 'l', 'o'] + + def test_invalid_char_raises_value_error(self): + for code in self.invalid: + for fn in (geohash.decode, geohash.decode_exactly, geohash.bbox, + geohash.neighbors, geohash.expand): + with self.assertRaises(ValueError): + fn(code) + + if __name__=='__main__': unittest.main()