gmaps-boundary 0.1.0__py3-none-any.whl

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.
@@ -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
+ }
@@ -0,0 +1,130 @@
1
+ """Places: the public ``Place`` type, place URL parsing and search response parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlencode
9
+
10
+ from .errors import PlaceNotFoundError, ResponseFormatError
11
+
12
+ _SEARCH_URL = "https://www.google.com/search"
13
+ _FEATURE_ID_PATTERN = re.compile(r"0x([0-9a-f]{1,16}):0x([0-9a-f]{1,16})")
14
+ _URL_FEATURE_ID_PATTERN = re.compile(r"!1s(0x[0-9a-f]{1,16}:0x[0-9a-f]{1,16})")
15
+ _URL_LABEL_POINT_PATTERN = re.compile(r"!3d(-?[0-9.]+)!4d(-?[0-9.]+)")
16
+ _URL_NAME_PATTERN = re.compile(r"/maps/place/([^/@]+)")
17
+ _JSON_GUARD = ")]}'"
18
+
19
+ # Positions inside one search hit. Google sends positional JSON arrays.
20
+ _HIT_RECORD_INDEX = 14
21
+ _RECORD_POINT_INDEX = 9
22
+ _RECORD_FEATURE_ID_INDEX = 10
23
+ _RECORD_NAME_INDEX = 11
24
+ _RECORD_ADDRESS_INDEX = 18
25
+ _POINT_LATITUDE_INDEX = 2
26
+ _POINT_LONGITUDE_INDEX = 3
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Place:
31
+ """A Google Maps place.
32
+
33
+ ``feature_id`` is the ``0x..:0x..`` pair found in Google Maps place URLs.
34
+ The label point is where Google Maps puts the place name on the map.
35
+ """
36
+
37
+ feature_id: str
38
+ latitude: float
39
+ longitude: float
40
+ name: str | None = None
41
+ address: str | None = None
42
+
43
+ def __post_init__(self) -> None:
44
+ if _FEATURE_ID_PATTERN.fullmatch(self.feature_id) is None:
45
+ raise ValueError(f"feature_id: expected '0x<hex>:0x<hex>', got {self.feature_id!r}")
46
+ if not -90.0 <= self.latitude <= 90.0:
47
+ raise ValueError(f"latitude: expected -90 to 90, got {self.latitude}")
48
+ if not -180.0 <= self.longitude <= 180.0:
49
+ raise ValueError(f"longitude: expected -180 to 180, got {self.longitude}")
50
+
51
+ @classmethod
52
+ def from_url(cls, url: str) -> Place:
53
+ """Read a place from a full Google Maps place URL.
54
+
55
+ The URL must carry ``!1s0x..:0x..`` and ``!3d<lat>!4d<lng>``. That is the URL
56
+ the browser shows after a place opens. Short ``maps.app.goo.gl`` links do not.
57
+ """
58
+ feature_id = _URL_FEATURE_ID_PATTERN.search(url)
59
+ if feature_id is None:
60
+ raise ValueError("url: no '!1s0x..:0x..' feature id found; pass the full place URL")
61
+ label_point = _URL_LABEL_POINT_PATTERN.search(url)
62
+ if label_point is None:
63
+ raise ValueError(
64
+ "url: no '!3d<lat>!4d<lng>' label point found; pass the full place URL"
65
+ )
66
+ name = _URL_NAME_PATTERN.search(url)
67
+ return cls(
68
+ feature_id=feature_id.group(1),
69
+ latitude=float(label_point.group(1)),
70
+ longitude=float(label_point.group(2)),
71
+ name=name.group(1).replace("+", " ") if name else None,
72
+ )
73
+
74
+ @property
75
+ def cell_id(self) -> int:
76
+ """The first half of the feature id, as the decimal the tile request needs."""
77
+ return int(self.feature_id.split(":")[0], 16)
78
+
79
+ @property
80
+ def feature_number(self) -> int:
81
+ """The second half of the feature id, as the decimal the tile request needs."""
82
+ return int(self.feature_id.split(":")[1], 16)
83
+
84
+
85
+ def search_url(query: str, language: str, region: str) -> str:
86
+ return f"{_SEARCH_URL}?{urlencode({'tbm': 'map', 'hl': language, 'gl': region, 'q': query})}"
87
+
88
+
89
+ def _element(sequence: object, index: int) -> object:
90
+ """Return ``sequence[index]``, or None when the array is too short or not an array."""
91
+ if isinstance(sequence, list) and len(sequence) > index:
92
+ return sequence[index]
93
+ return None
94
+
95
+
96
+ def _optional_text(record: object, index: int) -> str | None:
97
+ text = _element(record, index)
98
+ return text if isinstance(text, str) else None
99
+
100
+
101
+ def parse_search_response(body: bytes, query: str) -> Place:
102
+ """Return the first place of a Google Maps search response."""
103
+ text = body.decode("utf-8", errors="replace")
104
+ if not text.startswith(_JSON_GUARD):
105
+ raise ResponseFormatError("search: response does not start with the JSON guard prefix")
106
+ try:
107
+ payload = json.loads(text[len(_JSON_GUARD) :])
108
+ except json.JSONDecodeError as error:
109
+ raise ResponseFormatError(f"search: response is not JSON: {error}") from error
110
+ hits = _element(_element(payload, 0), 1)
111
+ if not isinstance(hits, list):
112
+ raise ResponseFormatError("search: response has no hit list")
113
+ for hit in hits:
114
+ record = _element(hit, _HIT_RECORD_INDEX)
115
+ feature_id = _element(record, _RECORD_FEATURE_ID_INDEX)
116
+ if not isinstance(feature_id, str):
117
+ continue
118
+ point = _element(record, _RECORD_POINT_INDEX)
119
+ latitude = _element(point, _POINT_LATITUDE_INDEX)
120
+ longitude = _element(point, _POINT_LONGITUDE_INDEX)
121
+ if not isinstance(latitude, float) or not isinstance(longitude, float):
122
+ raise ResponseFormatError(f"search: place {feature_id} has no label point")
123
+ return Place(
124
+ feature_id=feature_id,
125
+ latitude=latitude,
126
+ longitude=longitude,
127
+ name=_optional_text(record, _RECORD_NAME_INDEX),
128
+ address=_optional_text(record, _RECORD_ADDRESS_INDEX),
129
+ )
130
+ raise PlaceNotFoundError(f"no single place matches {query!r}; try a more specific name")
@@ -0,0 +1,76 @@
1
+ """Schema-free protobuf wire decoding, enough to read Google vector tiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import ResponseFormatError
6
+
7
+ FieldValue = int | bytes
8
+
9
+ _VARINT = 0
10
+ _FIXED64 = 1
11
+ _LENGTH_DELIMITED = 2
12
+ _FIXED32 = 5
13
+ _MAX_VARINT_BYTES = 10
14
+
15
+
16
+ def read_varint(buffer: bytes, position: int) -> tuple[int, int]:
17
+ """Return the varint at ``position`` and the position after it."""
18
+ value = 0
19
+ for byte_index in range(_MAX_VARINT_BYTES):
20
+ if position >= len(buffer):
21
+ raise ResponseFormatError("protobuf: varint runs past the end of its message")
22
+ byte = buffer[position]
23
+ position += 1
24
+ value |= (byte & 0x7F) << (7 * byte_index)
25
+ if not byte & 0x80:
26
+ return value, position
27
+ raise ResponseFormatError("protobuf: varint is longer than 10 bytes")
28
+
29
+
30
+ def read_fields(message: bytes) -> list[tuple[int, FieldValue]]:
31
+ """Decode one message into ``(field number, value)`` pairs, in wire order."""
32
+ fields: list[tuple[int, FieldValue]] = []
33
+ position = 0
34
+ while position < len(message):
35
+ key, position = read_varint(message, position)
36
+ number, wire_type = key >> 3, key & 7
37
+ if wire_type == _VARINT:
38
+ value, position = read_varint(message, position)
39
+ fields.append((number, value))
40
+ continue
41
+ if wire_type == _LENGTH_DELIMITED:
42
+ size, position = read_varint(message, position)
43
+ elif wire_type == _FIXED64:
44
+ size = 8
45
+ elif wire_type == _FIXED32:
46
+ size = 4
47
+ else:
48
+ raise ResponseFormatError(f"protobuf: field {number} has unknown wire type {wire_type}")
49
+ if position + size > len(message):
50
+ raise ResponseFormatError(f"protobuf: field {number} runs past the end of its message")
51
+ fields.append((number, message[position : position + size]))
52
+ position += size
53
+ return fields
54
+
55
+
56
+ def read_packed_varints(packed: bytes) -> list[int]:
57
+ numbers: list[int] = []
58
+ position = 0
59
+ while position < len(packed):
60
+ number, position = read_varint(packed, position)
61
+ numbers.append(number)
62
+ return numbers
63
+
64
+
65
+ def unzigzag(number: int) -> int:
66
+ return (number >> 1) ^ -(number & 1)
67
+
68
+
69
+ def first_bytes_field(fields: list[tuple[int, FieldValue]], number: int) -> bytes | None:
70
+ """Return the first length-delimited value of field ``number``, or None when absent."""
71
+ for field_number, value in fields:
72
+ if field_number == number:
73
+ if not isinstance(value, bytes):
74
+ raise ResponseFormatError(f"protobuf: field {number} is not length-delimited")
75
+ return value
76
+ return None
@@ -0,0 +1,252 @@
1
+ """The request plan for one boundary, written without I/O.
2
+
3
+ Each function here is a generator. It yields a batch of URLs, receives the
4
+ response bodies in the same order, and returns its value when done. The sync and
5
+ async clients run the same generators, so the logic exists once.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from collections.abc import Generator, Iterable
12
+ from dataclasses import dataclass
13
+ from typing import TypeVar
14
+
15
+ import shapely
16
+ from shapely import MultiPolygon, Polygon, set_precision, unary_union
17
+ from shapely.geometry.base import BaseGeometry
18
+ from shapely.prepared import prep
19
+
20
+ from ._boundary import Boundary, PolygonalGeometry
21
+ from ._places import Place, parse_search_response, search_url
22
+ from ._tiles import (
23
+ MAX_ZOOM,
24
+ TILE_EXTENT,
25
+ Tile,
26
+ decode_place_part,
27
+ global_units_to_lng_lat,
28
+ tile_containing,
29
+ tile_url,
30
+ )
31
+ from .errors import NoBoundaryError, TooManyTilesError
32
+
33
+ ReturnValue = TypeVar("ReturnValue")
34
+ Steps = Generator[list[str], list[bytes], ReturnValue]
35
+ PlaceQuery = str | Place
36
+
37
+ # The outline is first read at a coarse zoom, to size the job and to seed the fine pass.
38
+ _COARSE_ZOOMS = (12, 8, 4, 0)
39
+ _PROBE_ZOOM = 10
40
+ # The tile estimate is rough, so automatic zoom aims below the cap.
41
+ _AUTO_ZOOM_FILL_RATIO = 0.75
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class FetchOptions:
46
+ zoom: int | None
47
+ max_tiles: int
48
+ language: str
49
+ region: str
50
+ batch_size: int
51
+
52
+ def __post_init__(self) -> None:
53
+ if self.zoom is not None and not 0 <= self.zoom <= MAX_ZOOM:
54
+ raise ValueError(f"zoom: expected 0 to {MAX_ZOOM} or None, got {self.zoom}")
55
+ if self.max_tiles < 16:
56
+ raise ValueError(f"max_tiles: expected at least 16, got {self.max_tiles}")
57
+ if self.batch_size < 1:
58
+ raise ValueError(f"batch_size: expected at least 1, got {self.batch_size}")
59
+
60
+
61
+ class _WalkTooLargeError(Exception):
62
+ """A tile walk passed the tile cap."""
63
+
64
+
65
+ def place_steps(query: PlaceQuery, language: str, region: str) -> Steps[Place]:
66
+ """Resolve a place from a ``Place``, a Google Maps place URL or a search text."""
67
+ if isinstance(query, Place):
68
+ return query
69
+ if query.startswith(("http://", "https://")):
70
+ return Place.from_url(query)
71
+ if not query.strip():
72
+ raise ValueError(
73
+ "query: expected a place name or a Google Maps place URL, got an empty text"
74
+ )
75
+ (body,) = yield [search_url(query, language, region)]
76
+ return parse_search_response(body, query)
77
+
78
+
79
+ def _polygons_only(geometry: BaseGeometry) -> PolygonalGeometry:
80
+ """Drop the stray points and lines a union can leave, and keep the polygons."""
81
+ if isinstance(geometry, (Polygon, MultiPolygon)):
82
+ return geometry
83
+ polygons = [part for part in getattr(geometry, "geoms", []) if isinstance(part, Polygon)]
84
+ return MultiPolygon(polygons)
85
+
86
+
87
+ def _walk_steps(
88
+ place: Place, seeds: Iterable[Tile], tile_cap: int, options: FetchOptions
89
+ ) -> Steps[PolygonalGeometry]:
90
+ """Fetch tiles outward from ``seeds`` and merge the place parts, in zoom-level units."""
91
+ pending = deque(dict.fromkeys(seeds))
92
+ seen = set(pending)
93
+ parts: list[BaseGeometry] = []
94
+ while pending:
95
+ if len(seen) > tile_cap:
96
+ raise _WalkTooLargeError
97
+ batch = [pending.popleft() for _ in range(min(len(pending), options.batch_size))]
98
+ bodies = yield [
99
+ tile_url(tile, place.cell_id, place.feature_number, options.language, options.region)
100
+ for tile in batch
101
+ ]
102
+ for tile, body in zip(batch, bodies, strict=True):
103
+ part = decode_place_part(body, tile).geometry
104
+ if part.is_empty:
105
+ continue
106
+ parts.append(set_precision(part, 1.0))
107
+ for neighbour in tile.neighbours():
108
+ if neighbour not in seen:
109
+ seen.add(neighbour)
110
+ pending.append(neighbour)
111
+ # Zero tolerance drops only the collinear vertices the tile seams leave behind.
112
+ return _polygons_only(unary_union(parts).simplify(0))
113
+
114
+
115
+ def _label_seeds(place: Place, zoom: int) -> list[Tile]:
116
+ """The tile under the label point and its ring. The label can sit just off the polygon."""
117
+ centre = tile_containing(place.latitude, place.longitude, zoom)
118
+ tiles_per_side = 1 << zoom
119
+ return [
120
+ Tile(zoom, centre.x + dx, centre.y + dy)
121
+ for dx in (-1, 0, 1)
122
+ for dy in (-1, 0, 1)
123
+ if 0 <= centre.x + dx < tiles_per_side and 0 <= centre.y + dy < tiles_per_side
124
+ ]
125
+
126
+
127
+ def _outline_seeds(coarse: PolygonalGeometry, coarse_zoom: int, zoom: int) -> list[Tile]:
128
+ """Every tile at ``zoom`` that touches the coarse outline, with half a tile of margin."""
129
+ scale = 1 << (zoom - coarse_zoom)
130
+ reach = shapely.transform(coarse, lambda points: points * scale).buffer(TILE_EXTENT / 2)
131
+ prepared_reach = prep(reach)
132
+ west, north, east, south = (int(edge // TILE_EXTENT) for edge in reach.bounds)
133
+ last = (1 << zoom) - 1
134
+ return [
135
+ tile
136
+ for x in range(max(west, 0), min(east, last) + 1)
137
+ for y in range(max(north, 0), min(south, last) + 1)
138
+ if prepared_reach.intersects((tile := Tile(zoom, x, y)).square)
139
+ ]
140
+
141
+
142
+ def _estimated_tiles(coarse: PolygonalGeometry, coarse_zoom: int, zoom: int) -> float:
143
+ """Roughly how many tiles a walk at ``zoom`` fetches: the inside plus a ring both sides."""
144
+ scale = 1 << (zoom - coarse_zoom)
145
+ inside_tiles = coarse.area / (TILE_EXTENT * TILE_EXTENT) * scale * scale
146
+ outline_tiles = coarse.length / TILE_EXTENT * scale
147
+ return inside_tiles + 2 * outline_tiles + 4
148
+
149
+
150
+ def _require_boundary_steps(place: Place, options: FetchOptions) -> Steps[None]:
151
+ """Raise when Google draws no boundary for the place.
152
+
153
+ A tile on the far side of the world is outside any real place. For a place with a
154
+ boundary it carries a full mask. For a place without one it carries no mask.
155
+ """
156
+ near = tile_containing(place.latitude, place.longitude, _PROBE_ZOOM)
157
+ tiles_per_side = 1 << _PROBE_ZOOM
158
+ far = Tile(_PROBE_ZOOM, (near.x + tiles_per_side // 2) % tiles_per_side, near.y)
159
+ (body,) = yield [
160
+ tile_url(far, place.cell_id, place.feature_number, options.language, options.region)
161
+ ]
162
+ if not decode_place_part(body, far).has_mask:
163
+ label = place.name or place.feature_id
164
+ raise NoBoundaryError(
165
+ f"Google Maps draws no boundary for {label!r}. "
166
+ "Boundaries exist for areas such as localities, cities and postal codes."
167
+ )
168
+
169
+
170
+ def _coarse_outline_steps(
171
+ place: Place, finest_zoom: int, options: FetchOptions
172
+ ) -> Steps[tuple[PolygonalGeometry, int]]:
173
+ """Read the outline at the first coarse zoom that fits the tile cap."""
174
+ for coarse_zoom in _COARSE_ZOOMS:
175
+ if coarse_zoom > finest_zoom:
176
+ continue
177
+ try:
178
+ coarse = yield from _walk_steps(
179
+ place, _label_seeds(place, coarse_zoom), options.max_tiles, options
180
+ )
181
+ except _WalkTooLargeError:
182
+ continue
183
+ return coarse, coarse_zoom
184
+ raise TooManyTilesError(
185
+ f"the place needs more than max_tiles={options.max_tiles} tiles even at zoom 0"
186
+ )
187
+
188
+
189
+ def _finest_zoom_within_cap(
190
+ coarse: PolygonalGeometry, coarse_zoom: int, options: FetchOptions
191
+ ) -> int:
192
+ tile_budget = options.max_tiles * _AUTO_ZOOM_FILL_RATIO
193
+ for zoom in range(MAX_ZOOM, coarse_zoom, -1):
194
+ if _estimated_tiles(coarse, coarse_zoom, zoom) <= tile_budget:
195
+ return zoom
196
+ return coarse_zoom
197
+
198
+
199
+ def boundary_steps(query: PlaceQuery, options: FetchOptions) -> Steps[Boundary]:
200
+ """The whole job: resolve the place, check it has a boundary, read the outline."""
201
+ place = yield from place_steps(query, options.language, options.region)
202
+ yield from _require_boundary_steps(place, options)
203
+ finest_zoom = MAX_ZOOM if options.zoom is None else options.zoom
204
+ coarse, coarse_zoom = yield from _coarse_outline_steps(place, finest_zoom, options)
205
+
206
+ if coarse.is_empty:
207
+ # The place is too small to show at the coarse zoom. Read it at the finest zoom directly.
208
+ try:
209
+ outline = yield from _walk_steps(
210
+ place, _label_seeds(place, finest_zoom), options.max_tiles, options
211
+ )
212
+ except _WalkTooLargeError as error:
213
+ raise TooManyTilesError(
214
+ f"the place needs more than max_tiles={options.max_tiles} tiles "
215
+ f"at zoom {finest_zoom}"
216
+ ) from error
217
+ zoom = finest_zoom
218
+ elif options.zoom is None:
219
+ zoom = _finest_zoom_within_cap(coarse, coarse_zoom, options)
220
+ outline = coarse
221
+ while zoom > coarse_zoom:
222
+ try:
223
+ outline = yield from _walk_steps(
224
+ place, _outline_seeds(coarse, coarse_zoom, zoom), options.max_tiles, options
225
+ )
226
+ break
227
+ except _WalkTooLargeError:
228
+ zoom -= 1
229
+ else:
230
+ zoom = options.zoom
231
+ outline = coarse
232
+ if zoom > coarse_zoom:
233
+ estimate = _estimated_tiles(coarse, coarse_zoom, zoom)
234
+ too_many = TooManyTilesError(
235
+ f"zoom {zoom} needs about {estimate:.0f} tiles, more than max_tiles="
236
+ f"{options.max_tiles}. Lower zoom, raise max_tiles, or pass zoom=None."
237
+ )
238
+ if estimate > options.max_tiles:
239
+ raise too_many
240
+ try:
241
+ outline = yield from _walk_steps(
242
+ place, _outline_seeds(coarse, coarse_zoom, zoom), options.max_tiles, options
243
+ )
244
+ except _WalkTooLargeError as error:
245
+ raise too_many from error
246
+
247
+ if outline.is_empty:
248
+ raise NoBoundaryError(
249
+ f"no boundary found near the label point of {place.name or place.feature_id!r}"
250
+ )
251
+ geometry = shapely.transform(outline, lambda points: global_units_to_lng_lat(points, zoom))
252
+ return Boundary(place=place, zoom=zoom, geometry=_polygons_only(geometry))