gmaps-boundary 0.1.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.
Files changed (32) hide show
  1. gmaps_boundary-0.1.0/.gitignore +10 -0
  2. gmaps_boundary-0.1.0/CHANGELOG.md +13 -0
  3. gmaps_boundary-0.1.0/LICENSE +21 -0
  4. gmaps_boundary-0.1.0/PKG-INFO +214 -0
  5. gmaps_boundary-0.1.0/README.md +183 -0
  6. gmaps_boundary-0.1.0/pyproject.toml +79 -0
  7. gmaps_boundary-0.1.0/src/gmaps_boundary/__init__.py +45 -0
  8. gmaps_boundary-0.1.0/src/gmaps_boundary/_boundary.py +61 -0
  9. gmaps_boundary-0.1.0/src/gmaps_boundary/_places.py +130 -0
  10. gmaps_boundary-0.1.0/src/gmaps_boundary/_protobuf.py +76 -0
  11. gmaps_boundary-0.1.0/src/gmaps_boundary/_steps.py +252 -0
  12. gmaps_boundary-0.1.0/src/gmaps_boundary/_tiles.py +184 -0
  13. gmaps_boundary-0.1.0/src/gmaps_boundary/cli.py +87 -0
  14. gmaps_boundary-0.1.0/src/gmaps_boundary/client.py +328 -0
  15. gmaps_boundary-0.1.0/src/gmaps_boundary/errors.py +27 -0
  16. gmaps_boundary-0.1.0/src/gmaps_boundary/preview.py +166 -0
  17. gmaps_boundary-0.1.0/src/gmaps_boundary/py.typed +0 -0
  18. gmaps_boundary-0.1.0/tests/__init__.py +0 -0
  19. gmaps_boundary-0.1.0/tests/conftest.py +31 -0
  20. gmaps_boundary-0.1.0/tests/fixtures/locality.json +96 -0
  21. gmaps_boundary-0.1.0/tests/fixtures/no_boundary.json +4 -0
  22. gmaps_boundary-0.1.0/tests/fixtures/not_found.json +3 -0
  23. gmaps_boundary-0.1.0/tests/fixtures/postal_code.json +70 -0
  24. gmaps_boundary-0.1.0/tests/record_fixtures.py +50 -0
  25. gmaps_boundary-0.1.0/tests/test_cli.py +70 -0
  26. gmaps_boundary-0.1.0/tests/test_client.py +281 -0
  27. gmaps_boundary-0.1.0/tests/test_live.py +22 -0
  28. gmaps_boundary-0.1.0/tests/test_places.py +95 -0
  29. gmaps_boundary-0.1.0/tests/test_preview.py +62 -0
  30. gmaps_boundary-0.1.0/tests/test_protobuf.py +64 -0
  31. gmaps_boundary-0.1.0/tests/test_tiles.py +106 -0
  32. gmaps_boundary-0.1.0/tests/tile_encoding.py +54 -0
@@ -0,0 +1,10 @@
1
+ .scratch/
2
+ .venv/
3
+ dist/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ .vscode/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ This project follows [Semantic Versioning](https://semver.org/).
4
+
5
+ ## 0.1.0 - 2026-11-11
6
+
7
+ - `fetch_boundary` and `find_place` helpers.
8
+ - `BoundaryClient` and `AsyncBoundaryClient` with proxy, retry and locale settings.
9
+ - Lookup by place name, Google Maps place URL or `Place`.
10
+ - Automatic zoom selection within a tile cap.
11
+ - `http_client` option to send requests through your own httpx session.
12
+ - `gmaps-boundary` command with GeoJSON output.
13
+ - PNG preview over an OpenStreetMap basemap, from the command or `save_preview`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ujjwal Kumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.5
2
+ Name: gmaps-boundary
3
+ Version: 0.1.0
4
+ Summary: Fetch the boundary polygon Google Maps draws for a locality, city or postal code, as GeoJSON.
5
+ Project-URL: Homepage, https://github.com/kujjwal02/gmaps-boundary
6
+ Project-URL: Issues, https://github.com/kujjwal02/gmaps-boundary/issues
7
+ Project-URL: Changelog, https://github.com/kujjwal02/gmaps-boundary/blob/main/CHANGELOG.md
8
+ Author: Ujjwal Kumar
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: boundary,geofence,geojson,gis,google maps,locality,polygon
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Scientific/Engineering :: GIS
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: numpy>=1.24
27
+ Requires-Dist: shapely>=2.0
28
+ Provides-Extra: preview
29
+ Requires-Dist: matplotlib>=3.7; extra == 'preview'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # gmaps-boundary
33
+
34
+ Fetch the boundary polygon that Google Maps draws for a locality, city or postal code, as GeoJSON.
35
+
36
+ When you search for an area on Google Maps, the map outlines it with a red dotted line. This library reads that outline from the map's vector tiles and returns it as a polygon. It needs no API key and no browser.
37
+
38
+ ![Boundary of Indiranagar, Bengaluru, drawn over OpenStreetMap](https://raw.githubusercontent.com/kujjwal02/gmaps-boundary/main/docs/indiranagar.png)
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install gmaps-boundary
44
+ ```
45
+
46
+ Python 3.10 or newer. Dependencies: `httpx`, `shapely`, `numpy`.
47
+
48
+ ## Use it
49
+
50
+ ```python
51
+ from gmaps_boundary import fetch_boundary
52
+
53
+ boundary = fetch_boundary("Indiranagar, Bengaluru")
54
+
55
+ boundary.place.name # 'Indiranagar'
56
+ boundary.geometry # shapely Polygon in WGS 84 (x = longitude, y = latitude)
57
+ boundary.bounds # (west, south, east, north)
58
+ boundary.zoom # 17
59
+ boundary.resolution_m # 0.07, the size of one coordinate step in metres
60
+ boundary.to_geojson() # GeoJSON Feature as a dict
61
+ ```
62
+
63
+ `fetch_boundary` accepts any of these:
64
+
65
+ - a place name: `"Indiranagar, Bengaluru"`, `"560038"`, `"Manhattan, New York"`
66
+ - a full Google Maps place URL, the one that contains `!1s0x...:0x...`
67
+ - a `Place` object, from `find_place` or built by hand
68
+
69
+ `Boundary` implements `__geo_interface__`, so GeoPandas and other libraries accept it directly:
70
+
71
+ ```python
72
+ import geopandas
73
+
74
+ frame = geopandas.GeoDataFrame.from_features([boundary.to_geojson()], crs="EPSG:4326")
75
+ ```
76
+
77
+ ### Many places
78
+
79
+ Reuse one client. It keeps connections open and applies the same settings to every request.
80
+
81
+ ```python
82
+ from gmaps_boundary import BoundaryClient
83
+
84
+ with BoundaryClient(proxy="http://user:pass@host:8080", request_delay_s=0.2) as client:
85
+ for name in ["Indiranagar, Bengaluru", "Koramangala, Bengaluru"]:
86
+ boundary = client.fetch_boundary(name)
87
+ ```
88
+
89
+ ### Your own HTTP session
90
+
91
+ Pass your own `httpx.Client` to control cookies, headers, proxies, timeouts and connection limits. The boundary client sends every request through it and never closes it.
92
+
93
+ ```python
94
+ import httpx
95
+ from gmaps_boundary import BoundaryClient
96
+
97
+ session = httpx.Client(
98
+ headers={"User-Agent": "Mozilla/5.0 ..."},
99
+ cookies={"NID": "..."},
100
+ proxy="http://user:pass@host:8080",
101
+ timeout=30.0,
102
+ )
103
+ with BoundaryClient(http_client=session) as client:
104
+ boundary = client.fetch_boundary("Indiranagar, Bengaluru")
105
+ session.close()
106
+ ```
107
+
108
+ `AsyncBoundaryClient` takes an `httpx.AsyncClient` the same way. Set a browser User-Agent on your session: Google answers the default httpx one differently. `http_client` cannot be combined with `proxy`, `timeout_s`, `user_agent` or `transport`; set those on the session.
109
+
110
+ ### Async
111
+
112
+ `AsyncBoundaryClient` fetches tiles concurrently. A locality takes about 6 seconds with `concurrency=8`, against about 25 seconds for the sync client.
113
+
114
+ ```python
115
+ import asyncio
116
+ from gmaps_boundary import AsyncBoundaryClient
117
+
118
+
119
+ async def main() -> None:
120
+ async with AsyncBoundaryClient(concurrency=8) as client:
121
+ boundary = await client.fetch_boundary("Koramangala, Bengaluru")
122
+
123
+
124
+ asyncio.run(main())
125
+ ```
126
+
127
+ ### Command line
128
+
129
+ ```bash
130
+ gmaps-boundary "Indiranagar, Bengaluru" -o indiranagar.geojson
131
+ gmaps-boundary "560038" --zoom 16 --indent 2 > pincode.geojson
132
+ gmaps-boundary "Manhattan, New York" -o manhattan.geojson --preview manhattan.png
133
+ ```
134
+
135
+ `--preview` draws the boundary over an OpenStreetMap map and needs `pip install 'gmaps-boundary[preview]'`. Add `--no-basemap` to draw the outline alone, with no request to OpenStreetMap. Run `gmaps-boundary --help` for every option.
136
+
137
+ The same drawing is available from Python:
138
+
139
+ ```python
140
+ from gmaps_boundary.preview import save_preview
141
+
142
+ save_preview(boundary, "indiranagar.png") # over OpenStreetMap
143
+ save_preview(boundary, "outline.png", basemap=False) # outline only
144
+ ```
145
+
146
+ The basemap uses at most 20 tiles from `tile.openstreetmap.org` per image. Map data (C) OpenStreetMap contributors.
147
+
148
+ ## Name lookup
149
+
150
+ A name goes to Google Maps search, and the library takes the place Google opens for it.
151
+
152
+ - A name that matches nothing, or a category search such as `"cafes in Indiranagar"`, raises `PlaceNotFoundError`.
153
+ - An ambiguous name resolves to Google's top pick, which depends on the region of your IP address. `"Springfield"` can resolve to a landmark instead of a city. Add the city, state or country to the name, and check `boundary.place.address`.
154
+ - A name that resolves to a place without a boundary, such as a shop or a road, raises `NoBoundaryError`.
155
+ - To check a name before you fetch, call `find_place(name)`. It makes one request and returns the `Place`.
156
+
157
+ ## Zoom and accuracy
158
+
159
+ Coordinates come from Google's tiles as integers, 4096 steps per tile side. A higher zoom means smaller steps and more vertices.
160
+
161
+ | zoom | step at the equator | typical use |
162
+ |---|---|---|
163
+ | 17 | 0.07 m | localities, postal codes |
164
+ | 15 | 0.30 m | large districts |
165
+ | 14 | 0.60 m | cities |
166
+ | 12 | 2.4 m | regions |
167
+
168
+ Zoom 17 is the finest zoom that carries boundaries. With `zoom=None`, the default, the library picks the finest zoom that fits `max_tiles` (default 500 tile requests per pass). Pass `zoom=` to fix it, or raise `max_tiles` for more detail on a large place.
169
+
170
+ For Indiranagar, the zoom 17 polygon has 214 vertices. Its bounds match the viewport Google Maps itself uses for the place to within 0.2 m.
171
+
172
+ ## Errors
173
+
174
+ Every error is a subclass of `GmapsBoundaryError`.
175
+
176
+ | error | meaning |
177
+ |---|---|
178
+ | `PlaceNotFoundError` | The name matches no single place. Try a more specific name. |
179
+ | `NoBoundaryError` | Google Maps draws no boundary for the place, for example a shop or a road. |
180
+ | `TooManyTilesError` | The place does not fit `max_tiles` at the requested zoom. |
181
+ | `ResponseFormatError` | A response had an unexpected shape. Google may have changed the format. |
182
+ | `RequestFailedError` | A request failed after every retry. |
183
+
184
+ ## How it works
185
+
186
+ 1. A name is resolved to a place through Google Maps search. This gives the feature id and the label point.
187
+ 2. Google Maps highlights a place through a vector tile layer named `spotlit`. Each tile carries a mask polygon that covers everything outside the place.
188
+ 3. The library reads the outline at a coarse zoom first. That pass sizes the job and tells the fine pass which tiles to fetch.
189
+ 4. For each fine tile, the place part is the tile square minus the mask. The parts are merged into one polygon and converted to longitude and latitude.
190
+
191
+ ## Limits
192
+
193
+ - The tile and search formats are undocumented. Google can change them without notice. The library then raises `ResponseFormatError` instead of returning a wrong polygon.
194
+ - A place made of separate pieces is returned as a `MultiPolygon`. A piece that lies more than about 10 km from the label point and does not touch the rest can be missed.
195
+ - Short `maps.app.goo.gl` links are not accepted. Open the link and copy the full URL.
196
+ - The boundary is what Google Maps displays. It is not an official administrative boundary.
197
+
198
+ ## Terms of use
199
+
200
+ This library is not affiliated with Google. It reads undocumented endpoints, which the Google Maps Platform Terms of Service do not permit for all uses. You are responsible for how you use it and for the data you collect. Keep the request rate low.
201
+
202
+ ## Development
203
+
204
+ ```bash
205
+ uv sync
206
+ uv run pytest # offline, replays recorded responses
207
+ uv run pytest -m live # calls Google
208
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
209
+ uv run python tests/record_fixtures.py # refresh the recorded responses
210
+ ```
211
+
212
+ ## License
213
+
214
+ MIT
@@ -0,0 +1,183 @@
1
+ # gmaps-boundary
2
+
3
+ Fetch the boundary polygon that Google Maps draws for a locality, city or postal code, as GeoJSON.
4
+
5
+ When you search for an area on Google Maps, the map outlines it with a red dotted line. This library reads that outline from the map's vector tiles and returns it as a polygon. It needs no API key and no browser.
6
+
7
+ ![Boundary of Indiranagar, Bengaluru, drawn over OpenStreetMap](https://raw.githubusercontent.com/kujjwal02/gmaps-boundary/main/docs/indiranagar.png)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install gmaps-boundary
13
+ ```
14
+
15
+ Python 3.10 or newer. Dependencies: `httpx`, `shapely`, `numpy`.
16
+
17
+ ## Use it
18
+
19
+ ```python
20
+ from gmaps_boundary import fetch_boundary
21
+
22
+ boundary = fetch_boundary("Indiranagar, Bengaluru")
23
+
24
+ boundary.place.name # 'Indiranagar'
25
+ boundary.geometry # shapely Polygon in WGS 84 (x = longitude, y = latitude)
26
+ boundary.bounds # (west, south, east, north)
27
+ boundary.zoom # 17
28
+ boundary.resolution_m # 0.07, the size of one coordinate step in metres
29
+ boundary.to_geojson() # GeoJSON Feature as a dict
30
+ ```
31
+
32
+ `fetch_boundary` accepts any of these:
33
+
34
+ - a place name: `"Indiranagar, Bengaluru"`, `"560038"`, `"Manhattan, New York"`
35
+ - a full Google Maps place URL, the one that contains `!1s0x...:0x...`
36
+ - a `Place` object, from `find_place` or built by hand
37
+
38
+ `Boundary` implements `__geo_interface__`, so GeoPandas and other libraries accept it directly:
39
+
40
+ ```python
41
+ import geopandas
42
+
43
+ frame = geopandas.GeoDataFrame.from_features([boundary.to_geojson()], crs="EPSG:4326")
44
+ ```
45
+
46
+ ### Many places
47
+
48
+ Reuse one client. It keeps connections open and applies the same settings to every request.
49
+
50
+ ```python
51
+ from gmaps_boundary import BoundaryClient
52
+
53
+ with BoundaryClient(proxy="http://user:pass@host:8080", request_delay_s=0.2) as client:
54
+ for name in ["Indiranagar, Bengaluru", "Koramangala, Bengaluru"]:
55
+ boundary = client.fetch_boundary(name)
56
+ ```
57
+
58
+ ### Your own HTTP session
59
+
60
+ Pass your own `httpx.Client` to control cookies, headers, proxies, timeouts and connection limits. The boundary client sends every request through it and never closes it.
61
+
62
+ ```python
63
+ import httpx
64
+ from gmaps_boundary import BoundaryClient
65
+
66
+ session = httpx.Client(
67
+ headers={"User-Agent": "Mozilla/5.0 ..."},
68
+ cookies={"NID": "..."},
69
+ proxy="http://user:pass@host:8080",
70
+ timeout=30.0,
71
+ )
72
+ with BoundaryClient(http_client=session) as client:
73
+ boundary = client.fetch_boundary("Indiranagar, Bengaluru")
74
+ session.close()
75
+ ```
76
+
77
+ `AsyncBoundaryClient` takes an `httpx.AsyncClient` the same way. Set a browser User-Agent on your session: Google answers the default httpx one differently. `http_client` cannot be combined with `proxy`, `timeout_s`, `user_agent` or `transport`; set those on the session.
78
+
79
+ ### Async
80
+
81
+ `AsyncBoundaryClient` fetches tiles concurrently. A locality takes about 6 seconds with `concurrency=8`, against about 25 seconds for the sync client.
82
+
83
+ ```python
84
+ import asyncio
85
+ from gmaps_boundary import AsyncBoundaryClient
86
+
87
+
88
+ async def main() -> None:
89
+ async with AsyncBoundaryClient(concurrency=8) as client:
90
+ boundary = await client.fetch_boundary("Koramangala, Bengaluru")
91
+
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ ### Command line
97
+
98
+ ```bash
99
+ gmaps-boundary "Indiranagar, Bengaluru" -o indiranagar.geojson
100
+ gmaps-boundary "560038" --zoom 16 --indent 2 > pincode.geojson
101
+ gmaps-boundary "Manhattan, New York" -o manhattan.geojson --preview manhattan.png
102
+ ```
103
+
104
+ `--preview` draws the boundary over an OpenStreetMap map and needs `pip install 'gmaps-boundary[preview]'`. Add `--no-basemap` to draw the outline alone, with no request to OpenStreetMap. Run `gmaps-boundary --help` for every option.
105
+
106
+ The same drawing is available from Python:
107
+
108
+ ```python
109
+ from gmaps_boundary.preview import save_preview
110
+
111
+ save_preview(boundary, "indiranagar.png") # over OpenStreetMap
112
+ save_preview(boundary, "outline.png", basemap=False) # outline only
113
+ ```
114
+
115
+ The basemap uses at most 20 tiles from `tile.openstreetmap.org` per image. Map data (C) OpenStreetMap contributors.
116
+
117
+ ## Name lookup
118
+
119
+ A name goes to Google Maps search, and the library takes the place Google opens for it.
120
+
121
+ - A name that matches nothing, or a category search such as `"cafes in Indiranagar"`, raises `PlaceNotFoundError`.
122
+ - An ambiguous name resolves to Google's top pick, which depends on the region of your IP address. `"Springfield"` can resolve to a landmark instead of a city. Add the city, state or country to the name, and check `boundary.place.address`.
123
+ - A name that resolves to a place without a boundary, such as a shop or a road, raises `NoBoundaryError`.
124
+ - To check a name before you fetch, call `find_place(name)`. It makes one request and returns the `Place`.
125
+
126
+ ## Zoom and accuracy
127
+
128
+ Coordinates come from Google's tiles as integers, 4096 steps per tile side. A higher zoom means smaller steps and more vertices.
129
+
130
+ | zoom | step at the equator | typical use |
131
+ |---|---|---|
132
+ | 17 | 0.07 m | localities, postal codes |
133
+ | 15 | 0.30 m | large districts |
134
+ | 14 | 0.60 m | cities |
135
+ | 12 | 2.4 m | regions |
136
+
137
+ Zoom 17 is the finest zoom that carries boundaries. With `zoom=None`, the default, the library picks the finest zoom that fits `max_tiles` (default 500 tile requests per pass). Pass `zoom=` to fix it, or raise `max_tiles` for more detail on a large place.
138
+
139
+ For Indiranagar, the zoom 17 polygon has 214 vertices. Its bounds match the viewport Google Maps itself uses for the place to within 0.2 m.
140
+
141
+ ## Errors
142
+
143
+ Every error is a subclass of `GmapsBoundaryError`.
144
+
145
+ | error | meaning |
146
+ |---|---|
147
+ | `PlaceNotFoundError` | The name matches no single place. Try a more specific name. |
148
+ | `NoBoundaryError` | Google Maps draws no boundary for the place, for example a shop or a road. |
149
+ | `TooManyTilesError` | The place does not fit `max_tiles` at the requested zoom. |
150
+ | `ResponseFormatError` | A response had an unexpected shape. Google may have changed the format. |
151
+ | `RequestFailedError` | A request failed after every retry. |
152
+
153
+ ## How it works
154
+
155
+ 1. A name is resolved to a place through Google Maps search. This gives the feature id and the label point.
156
+ 2. Google Maps highlights a place through a vector tile layer named `spotlit`. Each tile carries a mask polygon that covers everything outside the place.
157
+ 3. The library reads the outline at a coarse zoom first. That pass sizes the job and tells the fine pass which tiles to fetch.
158
+ 4. For each fine tile, the place part is the tile square minus the mask. The parts are merged into one polygon and converted to longitude and latitude.
159
+
160
+ ## Limits
161
+
162
+ - The tile and search formats are undocumented. Google can change them without notice. The library then raises `ResponseFormatError` instead of returning a wrong polygon.
163
+ - A place made of separate pieces is returned as a `MultiPolygon`. A piece that lies more than about 10 km from the label point and does not touch the rest can be missed.
164
+ - Short `maps.app.goo.gl` links are not accepted. Open the link and copy the full URL.
165
+ - The boundary is what Google Maps displays. It is not an official administrative boundary.
166
+
167
+ ## Terms of use
168
+
169
+ This library is not affiliated with Google. It reads undocumented endpoints, which the Google Maps Platform Terms of Service do not permit for all uses. You are responsible for how you use it and for the data you collect. Keep the request rate low.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ uv sync
175
+ uv run pytest # offline, replays recorded responses
176
+ uv run pytest -m live # calls Google
177
+ uv run ruff check . && uv run ruff format --check . && uv run mypy
178
+ uv run python tests/record_fixtures.py # refresh the recorded responses
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,79 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gmaps-boundary"
7
+ version = "0.1.0"
8
+ description = "Fetch the boundary polygon Google Maps draws for a locality, city or postal code, as GeoJSON."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Ujjwal Kumar" }]
14
+ keywords = ["google maps", "boundary", "polygon", "geojson", "locality", "geofence", "gis"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Science/Research",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Scientific/Engineering :: GIS",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.27",
31
+ "numpy>=1.24",
32
+ "shapely>=2.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ preview = ["matplotlib>=3.7"]
37
+
38
+ [project.scripts]
39
+ gmaps-boundary = "gmaps_boundary.cli:main"
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/kujjwal02/gmaps-boundary"
43
+ Issues = "https://github.com/kujjwal02/gmaps-boundary/issues"
44
+ Changelog = "https://github.com/kujjwal02/gmaps-boundary/blob/main/CHANGELOG.md"
45
+
46
+ [dependency-groups]
47
+ dev = [
48
+ "matplotlib>=3.7",
49
+ "mypy>=1.13",
50
+ "pytest>=8",
51
+ "ruff>=0.8",
52
+ "types-shapely>=2.0",
53
+ ]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/gmaps_boundary"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ addopts = "-m 'not live'"
64
+ markers = ["live: calls Google over the network"]
65
+
66
+ [tool.ruff]
67
+ line-length = 100
68
+ src = ["src", "tests"]
69
+
70
+ [tool.ruff.lint]
71
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF", "PT", "C4"]
72
+
73
+ [tool.mypy]
74
+ strict = true
75
+ files = ["src", "tests"]
76
+
77
+ [[tool.mypy.overrides]]
78
+ module = ["matplotlib.*"]
79
+ ignore_missing_imports = true
@@ -0,0 +1,45 @@
1
+ """Fetch the boundary polygon Google Maps draws for a place.
2
+
3
+ Public names:
4
+ fetch_boundary, find_place: one-off helpers.
5
+ BoundaryClient, AsyncBoundaryClient: reusable clients.
6
+ Boundary, Place: the returned types.
7
+ MAX_ZOOM: the finest tile zoom that carries boundaries.
8
+ preview.save_preview: draw a boundary to a PNG (needs the preview extra).
9
+ GmapsBoundaryError and its subclasses: PlaceNotFoundError, NoBoundaryError,
10
+ TooManyTilesError, ResponseFormatError, RequestFailedError.
11
+ """
12
+
13
+ from importlib.metadata import version
14
+
15
+ from ._boundary import Boundary
16
+ from ._places import Place
17
+ from ._tiles import MAX_ZOOM
18
+ from .client import AsyncBoundaryClient, BoundaryClient, fetch_boundary, find_place
19
+ from .errors import (
20
+ GmapsBoundaryError,
21
+ NoBoundaryError,
22
+ PlaceNotFoundError,
23
+ RequestFailedError,
24
+ ResponseFormatError,
25
+ TooManyTilesError,
26
+ )
27
+
28
+ __version__ = version("gmaps-boundary")
29
+
30
+ __all__ = [
31
+ "MAX_ZOOM",
32
+ "AsyncBoundaryClient",
33
+ "Boundary",
34
+ "BoundaryClient",
35
+ "GmapsBoundaryError",
36
+ "NoBoundaryError",
37
+ "Place",
38
+ "PlaceNotFoundError",
39
+ "RequestFailedError",
40
+ "ResponseFormatError",
41
+ "TooManyTilesError",
42
+ "__version__",
43
+ "fetch_boundary",
44
+ "find_place",
45
+ ]
@@ -0,0 +1,61 @@
1
+ """The public ``Boundary`` type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from shapely import MultiPolygon, Polygon
10
+ from shapely.geometry import mapping
11
+
12
+ from ._places import Place
13
+ from ._tiles import TILE_EXTENT
14
+
15
+ _EQUATOR_LENGTH_M = 40_075_016.686
16
+
17
+ PolygonalGeometry = Polygon | MultiPolygon
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Boundary:
22
+ """The boundary Google Maps draws for a place.
23
+
24
+ ``geometry`` is a shapely geometry in WGS 84, with x as longitude and y as latitude.
25
+ ``zoom`` is the tile zoom the boundary was read at. A higher zoom has more vertices.
26
+ """
27
+
28
+ place: Place
29
+ zoom: int
30
+ geometry: PolygonalGeometry
31
+
32
+ @property
33
+ def bounds(self) -> tuple[float, float, float, float]:
34
+ """``(west, south, east, north)`` in degrees."""
35
+ west, south, east, north = self.geometry.bounds
36
+ return west, south, east, north
37
+
38
+ @property
39
+ def resolution_m(self) -> float:
40
+ """The size of one coordinate step at this zoom and latitude, in metres."""
41
+ metres_per_tile = _EQUATOR_LENGTH_M * math.cos(math.radians(self.place.latitude))
42
+ return metres_per_tile / ((1 << self.zoom) * TILE_EXTENT)
43
+
44
+ @property
45
+ def __geo_interface__(self) -> dict[str, Any]:
46
+ return self.to_geojson()
47
+
48
+ def to_geojson(self) -> dict[str, Any]:
49
+ """Return a GeoJSON Feature. Its properties describe the place."""
50
+ return {
51
+ "type": "Feature",
52
+ "properties": {
53
+ "name": self.place.name,
54
+ "address": self.place.address,
55
+ "feature_id": self.place.feature_id,
56
+ "label_latitude": self.place.latitude,
57
+ "label_longitude": self.place.longitude,
58
+ "zoom": self.zoom,
59
+ },
60
+ "geometry": mapping(self.geometry),
61
+ }