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.
- gmaps_boundary-0.1.0/.gitignore +10 -0
- gmaps_boundary-0.1.0/CHANGELOG.md +13 -0
- gmaps_boundary-0.1.0/LICENSE +21 -0
- gmaps_boundary-0.1.0/PKG-INFO +214 -0
- gmaps_boundary-0.1.0/README.md +183 -0
- gmaps_boundary-0.1.0/pyproject.toml +79 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/__init__.py +45 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/_boundary.py +61 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/_places.py +130 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/_protobuf.py +76 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/_steps.py +252 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/_tiles.py +184 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/cli.py +87 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/client.py +328 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/errors.py +27 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/preview.py +166 -0
- gmaps_boundary-0.1.0/src/gmaps_boundary/py.typed +0 -0
- gmaps_boundary-0.1.0/tests/__init__.py +0 -0
- gmaps_boundary-0.1.0/tests/conftest.py +31 -0
- gmaps_boundary-0.1.0/tests/fixtures/locality.json +96 -0
- gmaps_boundary-0.1.0/tests/fixtures/no_boundary.json +4 -0
- gmaps_boundary-0.1.0/tests/fixtures/not_found.json +3 -0
- gmaps_boundary-0.1.0/tests/fixtures/postal_code.json +70 -0
- gmaps_boundary-0.1.0/tests/record_fixtures.py +50 -0
- gmaps_boundary-0.1.0/tests/test_cli.py +70 -0
- gmaps_boundary-0.1.0/tests/test_client.py +281 -0
- gmaps_boundary-0.1.0/tests/test_live.py +22 -0
- gmaps_boundary-0.1.0/tests/test_places.py +95 -0
- gmaps_boundary-0.1.0/tests/test_preview.py +62 -0
- gmaps_boundary-0.1.0/tests/test_protobuf.py +64 -0
- gmaps_boundary-0.1.0/tests/test_tiles.py +106 -0
- gmaps_boundary-0.1.0/tests/tile_encoding.py +54 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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
|
+
}
|