osmfeatures 0.7.2__tar.gz → 0.7.3__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.
- osmfeatures-0.7.3/AUTO_TILES.md +50 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/PKG-INFO +20 -11
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/README.md +19 -10
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/pyproject.toml +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/__init__.py +4 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/_http.py +3 -2
- osmfeatures-0.7.3/src/osmfeatures/_places.py +192 -0
- osmfeatures-0.7.3/src/osmfeatures/_query.py +250 -0
- osmfeatures-0.7.3/src/osmfeatures/_split.py +392 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/async_client.py +94 -112
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/chunking.py +19 -5
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/cli.py +21 -11
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/client.py +84 -283
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/convenience.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/AGENT_INSTRUCTIONS.md +41 -10
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/_mcp_session.py +14 -8
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/mcp_server.py +16 -6
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/models.py +47 -4
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/README.md +2 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/conftest.py +23 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_city_cycling_infrastructure.py +2 -2
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_cycling_trails.py +2 -2
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_geo_agent.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_geometry_filters.py +6 -6
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_lakeside_ice_cream_hunt.py +2 -2
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_park_bench_finder.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_park_explorer.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_pedestrian_shortest_path.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_pedestrian_wavefront_bfs.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_restaurant_guide.py +1 -1
- osmfeatures-0.7.3/tests/example_apps/test_split_until_fit.py +247 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_mcp_http.py +1 -1
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_mcp_server.py +29 -7
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_mcp_session.py +98 -15
- osmfeatures-0.7.3/tests/test_async_client.py +722 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_chunking.py +11 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_cli.py +25 -2
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_client.py +2 -2
- osmfeatures-0.7.3/tests/test_geo_agent_client.py +822 -0
- osmfeatures-0.7.3/tests/test_pagination.py +489 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_retry.py +6 -5
- osmfeatures-0.7.3/tests/test_split.py +130 -0
- osmfeatures-0.7.3/uv.lock +2179 -0
- osmfeatures-0.7.2/tests/test_async_client.py +0 -343
- osmfeatures-0.7.2/tests/test_geo_agent_client.py +0 -185
- osmfeatures-0.7.2/tests/test_pagination.py +0 -176
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/.github/copilot-instructions.md +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/.github/instructions/osmgeojson-python-conventions.instructions.md +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/.gitignore +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/Dockerfile +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/LICENSE +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/Makefile +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/_geo_agent.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/_pagination.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/geometry.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/__init__.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/_geocode.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/_preview.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/nearest.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/output.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/py.typed +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/retry.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/.env.example +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/__init__.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/__init__.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/conftest.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/graph_utils.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_bike_path_dijkstra_liljeholmen_to_djurgarden.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_geocode.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_preview.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_http.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_nearest_within.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_output.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/conftest.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/osm_features_query.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/places_nearby.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/places_search.py +0 -0
- {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/test_tutorial.py +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# auto_split tiling
|
|
2
|
+
|
|
3
|
+
Code: `iter_split_tiles` in `src/osmfeatures/_split.py`. Same walk for `query(auto_split=True)` and `places_search(auto_split=True)`.
|
|
4
|
+
|
|
5
|
+
The API will not return one huge bbox. Two different things can go wrong, so we use two different probes.
|
|
6
|
+
|
|
7
|
+
## Two problems, two probes
|
|
8
|
+
|
|
9
|
+
**Too many matches (density).** Inner Stockholm can be a small rectangle with 200k buildings. One fetch is only allowed `max_limit` rows (from `GET /v1/account/tier`). If we fetch first, the server 400s `result_too_large` and we have learned nothing except “too many.”
|
|
10
|
+
|
|
11
|
+
So we call `GET /v2/osm_features/count` first. That returns `{total: N}` with no geometries. If `N` is over the cap, we split the bbox into enough tiles that each *would* hold at most the cap, assuming matches are spread evenly. Then we count (and maybe fetch) each smaller tile. Empty tiles (`total == 0`) are skipped.
|
|
12
|
+
|
|
13
|
+
**Too big a rectangle (area).** Greater Stockholm cafes can be a tiny `total` and still fail: the bbox is larger than one search/query is allowed to scan (free tier example: 0.04 square degrees). Count does not tell you that. Count is allowed a much bigger window (~400 deg²).
|
|
14
|
+
|
|
15
|
+
So we do **not** split on a count-area error. That error looks like `count bbox area … limit 400`. The `400` is how big a **count** may be, not how big a fetch may be. Jumping to 400 deg² tiles would still 400 on every fetch.
|
|
16
|
+
|
|
17
|
+
We split on a **fetch** area error instead: `bbox area 5.0 exceeds the tagged tier limit 0.04`. The `0.04` is the real tile size. We remember it and jump the rest of the bbox to that size in one shot, instead of trying every quarter.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
density → count.total vs max_limit → split before fetch
|
|
21
|
+
area → fetch 400 “bbox area … Y” → split to tiles of size Y
|
|
22
|
+
not area → count 400 “count bbox area” → give up (window too big to count)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The algorithm
|
|
26
|
+
|
|
27
|
+
Queue starts with the requested bbox (or `bbox_tiles` pieces if the caller already split). Cap: 32 tiles.
|
|
28
|
+
|
|
29
|
+
For each tile:
|
|
30
|
+
|
|
31
|
+
1. If we already know the fetch area cap `Y`, and this tile is larger than `Y`, split it to that size and skip count/fetch.
|
|
32
|
+
2. Count the tile.
|
|
33
|
+
- `total == 0`: skip.
|
|
34
|
+
- `total` over the row cap: replace with a power-of-2 grid (`ceil(total / cap)`, rounded up to 2, 4, 8, …). Do this at most twice (the original tile, then one leftover dense child). Need more than 32 tiles, or a third split: `OSMFeaturesTooDenseError`.
|
|
35
|
+
- Count 400 `count bbox area`: re-raise. Do not split.
|
|
36
|
+
- Count 400 that names the **fetch** cap (`bbox area … limit Y`, no `count` prefix): remember `Y` and jump.
|
|
37
|
+
3. Fetch the tile (`GET /v3/osm_features` or `POST /v1/places/search`).
|
|
38
|
+
- Fetch 400 `bbox area … Y`: remember `Y`, jump, do not probe every quarter.
|
|
39
|
+
- Fetch 400 `result_too_large` after a count that already fit: `OSMFeaturesTooDenseError`. Count was wrong; splitting again would thrash.
|
|
40
|
+
- Success: keep the page.
|
|
41
|
+
|
|
42
|
+
Covering the bbox would need more than 32 tiles (usually an area jump): `OSMFeaturesTooManyTilesError`. That is “the window is too large,” not “too crowded.” Shrink the named place. Do not keep bisecting.
|
|
43
|
+
|
|
44
|
+
Results from tiles are merged and deduped by feature `id` (tiles share edges).
|
|
45
|
+
|
|
46
|
+
`limit=10` is a page size. It truncates; it does not lower the density cap. 20 matches with `limit=10` stay one tile.
|
|
47
|
+
|
|
48
|
+
`bbox_tiles=4` without `auto_split` just fetches those 4 tiles. No count, no second jump.
|
|
49
|
+
|
|
50
|
+
Hours on `places_search` (`as_of` / `open_now`) use a 10k parse/scan cap instead of `max_limit`. See `_places.py`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: osmfeatures
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: Official client for the MapLark OpenStreetMap Features API (GeoJSON, FlatGeobuf, GeoParquet, CSV)
|
|
5
5
|
Project-URL: Homepage, https://maplark.com
|
|
6
6
|
Project-URL: Repository, https://github.com/MapLark/osmfeatures-python
|
|
@@ -145,7 +145,7 @@ with OSMFeaturesClient(api_key="sk-...") as client:
|
|
|
145
145
|
|
|
146
146
|
### Query OSM features
|
|
147
147
|
|
|
148
|
-
`query()` calls `GET /v3/osm_features` and returns the whole tile. Omit `limit` for the API default. Paid keys may raise `limit` up to their `max_limit
|
|
148
|
+
`query()` calls `GET /v3/osm_features` and returns the whole tile. Omit `limit` for the API default. Paid keys may raise `limit` up to their `max_limit`. A larger match set is truncated (`X-Has-More: true`). Pass `auto_split=True` to read the key's `max_limit` from `GET /v1/account/tier`, count first with a scalar `GET /v2/osm_features/count` (no `group_by`), and jump to a power-of-2 tile grid when the match set exceeds that cap (one extra jump if a leftover leaf is still dense). That adds latency. Pass `bbox_tiles=2` (or 4, 8, ...) to split the bbox up front. `query()` does not take `cursor`.
|
|
149
149
|
|
|
150
150
|
```python
|
|
151
151
|
fc = client.query(
|
|
@@ -168,7 +168,7 @@ Common filters:
|
|
|
168
168
|
- `way_shape="polygon" | "line" | "all"` (omit = both shapes; `all` also means both)
|
|
169
169
|
- `clip_geometry=True | False` (omit for the API default; set `False` to keep full geometry outside bbox)
|
|
170
170
|
- `bbox_tiles=2` (power of 2) to split a bbox that exceeds your tier area cap
|
|
171
|
-
- `
|
|
171
|
+
- `auto_split=True` to count first, then fetch (reads `max_limit` from `GET /v1/account/tier`, cached on the client; jumps to a power-of-2 grid when the match set exceeds that cap; one extra jump if a leftover leaf is still dense; a lower `limit` truncates; jumps when a 400 names a bbox-area cap). Caps at 32 tiles (`OSMFeaturesTooManyTilesError` if covering the bbox needs more, `OSMFeaturesTooDenseError` if a tile still overflows). `OSMFeaturesTimeoutError` is only a real wall-clock miss.
|
|
172
172
|
- `accept="text/csv"` / `"application/flatgeobuf"` / `"application/vnd.apache.parquet"` (returns `BinaryQueryResult`)
|
|
173
173
|
|
|
174
174
|
|
|
@@ -180,8 +180,7 @@ Use this to query a larger bbox than what is allowed by splitting the bbox up in
|
|
|
180
180
|
all_restaurants = client.query(
|
|
181
181
|
bbox="18.063,59.322,18.082,59.332",
|
|
182
182
|
tags="amenity=restaurant",
|
|
183
|
-
|
|
184
|
-
max_features=1_000_000,
|
|
183
|
+
auto_split=True,
|
|
185
184
|
timeout=60,
|
|
186
185
|
)
|
|
187
186
|
```
|
|
@@ -191,7 +190,7 @@ all_restaurants = client.query(
|
|
|
191
190
|
#### Count
|
|
192
191
|
Analyze feature counts with a histogram over a very large area - city and country sized bounding boxes allowed. For example, you can find out how many cafes, bars, and restaurants are in different cities or countries.
|
|
193
192
|
|
|
194
|
-
`client.count` calls `GET /v2/osm_features/count`. Example amenity histogram:
|
|
193
|
+
`client.count` calls `GET /v2/osm_features/count`. Omit `group_by` for a scalar total. Example amenity histogram:
|
|
195
194
|
|
|
196
195
|
```python
|
|
197
196
|
client.count(
|
|
@@ -264,7 +263,7 @@ Results come back as summaries (ids, names, OSM tags, lon/lat scalars, `distance
|
|
|
264
263
|
- Places: `places_search`, `places_nearby`, `places_details`
|
|
265
264
|
- Geocode: `geocode` (Nominatim interim)
|
|
266
265
|
- Routes: `routes_isochrone`, `routes_path`, `routes_optimized_path`
|
|
267
|
-
- Generic OSM: `query`, `
|
|
266
|
+
- Generic OSM: `query`, `count` (histogram / total)
|
|
268
267
|
- Local (no HTTP): `nearest_within`, `pairs_within`, `filter_open`, `point_in_polygon`, `points_in_polygon`
|
|
269
268
|
- Draw / export: `preview_map`, `export_geojson`
|
|
270
269
|
|
|
@@ -343,7 +342,16 @@ Runnable Python chains live in `tests/example_apps/test_geo_agent.py`. Full HTTP
|
|
|
343
342
|
|
|
344
343
|
### Places search
|
|
345
344
|
|
|
346
|
-
`places_search()` finds places in a bounding box **or** a `location` plus `radius` (not both). Optional `tags` (AND) and `or_tags` (OR) use the same OSM filters as `query()`. Omit `limit` to use the API default (100
|
|
345
|
+
`places_search()` finds places in a bounding box **or** a `location` plus `radius` (not both). Optional `tags` (AND) and `or_tags` (OR) use the same OSM filters as `query()`. Omit `limit` to use the API default (100). Paid keys may raise `limit` up to their `/v3` `max_limit`; `asOf` / `openNow` still cap hours parse at 10_000.
|
|
346
|
+
|
|
347
|
+
`bbox_tiles` and `auto_split` **only apply to bbox search** (same names as `query()`). Pass `bbox_tiles=2` (or 4, 8, ...) to split a bbox up front. Pass `auto_split=True` to count first via `GET /v2/osm_features/count` with `way_shape=polygon`, then fetch. No hours: split on `total` vs the key's `max_limit`. With `asOf` / `openNow`: AND `opening_hours`. An `asOf` page `limit` (default 100) truncates; density-split vs the 10k parse cap only when `limit` is raised above 10k. `openNow` density-splits vs that 10k scan cap even with a small fill limit. A search that exceeds the tagged **fetch** area cap 400s; that 400 is split in one jump from the limit in the message, not by probing every quarter, up to 32 tiles. A `count bbox area` 400 is not split (the window is still too big to count). location+radius is a circle and cannot split. To tile a radius window, convert it first with `around_to_bbox` (a square that contains the circle; corner hits can fall outside the original radius):
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
from osmfeatures import around_to_bbox
|
|
351
|
+
|
|
352
|
+
bbox = around_to_bbox(lon=18.075, lat=59.316, radius_m=800)
|
|
353
|
+
cafes = client.places_search(bbox=bbox, or_tags=["amenity=cafe"], auto_split=True)
|
|
354
|
+
```
|
|
347
355
|
|
|
348
356
|
```python
|
|
349
357
|
cafes = client.places_search(
|
|
@@ -357,7 +365,7 @@ print(len(cafes["features"]), "open cafes")
|
|
|
357
365
|
print(cafes["metadata"]["evaluated_at"])
|
|
358
366
|
```
|
|
359
367
|
|
|
360
|
-
Response is a GeoJSON FeatureCollection plus `metadata.evaluated_at` (UTC instant used for hours).
|
|
368
|
+
Response is a GeoJSON FeatureCollection plus `metadata.evaluated_at` (UTC instant used for hours) and `metadata.units` (credits charged). After `bbox_tiles` / `auto_split`, `units` is the sum of the search tiles.
|
|
361
369
|
|
|
362
370
|
### Nearby (ranked from a point)
|
|
363
371
|
|
|
@@ -375,7 +383,7 @@ for item in nearby["items"]:
|
|
|
375
383
|
print(item["distance_m"], item["feature"]["id"])
|
|
376
384
|
```
|
|
377
385
|
|
|
378
|
-
Response: `{status, items: [{feature, distance_m}],
|
|
386
|
+
Response: `{status, items: [{feature, distance_m}], units, evaluated_at}`.
|
|
379
387
|
|
|
380
388
|
### Place details
|
|
381
389
|
|
|
@@ -388,7 +396,7 @@ print(details["feature"]["properties"]["tags"])
|
|
|
388
396
|
print(details["timezone"], details["evaluated_at"])
|
|
389
397
|
```
|
|
390
398
|
|
|
391
|
-
Response: `{status, feature,
|
|
399
|
+
Response: `{status, feature, units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
|
|
392
400
|
|
|
393
401
|
### Opening hours
|
|
394
402
|
|
|
@@ -499,6 +507,7 @@ The repository includes runnable example-app tests in `tests/example_apps/` show
|
|
|
499
507
|
- `test_bike_path_dijkstra_liljeholmen_to_djurgarden.py`: tiled corridor bike routing from Liljeholmen to Djurgarden.
|
|
500
508
|
- `test_geometry_filters.py`: zoom + area/length filters for large buildings and long roads.
|
|
501
509
|
- `test_geo_agent.py`: Python places/routes chains (bar crawl, bike parks, isochrone filter/compare/coverage, client-side open-at-clock).
|
|
510
|
+
- `test_auto_split.py`: ``auto_split`` for a city-scale cafe bbox (area cap) and dense inner-Stockholm buildings (``max_limit``).
|
|
502
511
|
|
|
503
512
|
Run all example apps:
|
|
504
513
|
|
|
@@ -102,7 +102,7 @@ with OSMFeaturesClient(api_key="sk-...") as client:
|
|
|
102
102
|
|
|
103
103
|
### Query OSM features
|
|
104
104
|
|
|
105
|
-
`query()` calls `GET /v3/osm_features` and returns the whole tile. Omit `limit` for the API default. Paid keys may raise `limit` up to their `max_limit
|
|
105
|
+
`query()` calls `GET /v3/osm_features` and returns the whole tile. Omit `limit` for the API default. Paid keys may raise `limit` up to their `max_limit`. A larger match set is truncated (`X-Has-More: true`). Pass `auto_split=True` to read the key's `max_limit` from `GET /v1/account/tier`, count first with a scalar `GET /v2/osm_features/count` (no `group_by`), and jump to a power-of-2 tile grid when the match set exceeds that cap (one extra jump if a leftover leaf is still dense). That adds latency. Pass `bbox_tiles=2` (or 4, 8, ...) to split the bbox up front. `query()` does not take `cursor`.
|
|
106
106
|
|
|
107
107
|
```python
|
|
108
108
|
fc = client.query(
|
|
@@ -125,7 +125,7 @@ Common filters:
|
|
|
125
125
|
- `way_shape="polygon" | "line" | "all"` (omit = both shapes; `all` also means both)
|
|
126
126
|
- `clip_geometry=True | False` (omit for the API default; set `False` to keep full geometry outside bbox)
|
|
127
127
|
- `bbox_tiles=2` (power of 2) to split a bbox that exceeds your tier area cap
|
|
128
|
-
- `
|
|
128
|
+
- `auto_split=True` to count first, then fetch (reads `max_limit` from `GET /v1/account/tier`, cached on the client; jumps to a power-of-2 grid when the match set exceeds that cap; one extra jump if a leftover leaf is still dense; a lower `limit` truncates; jumps when a 400 names a bbox-area cap). Caps at 32 tiles (`OSMFeaturesTooManyTilesError` if covering the bbox needs more, `OSMFeaturesTooDenseError` if a tile still overflows). `OSMFeaturesTimeoutError` is only a real wall-clock miss.
|
|
129
129
|
- `accept="text/csv"` / `"application/flatgeobuf"` / `"application/vnd.apache.parquet"` (returns `BinaryQueryResult`)
|
|
130
130
|
|
|
131
131
|
|
|
@@ -137,8 +137,7 @@ Use this to query a larger bbox than what is allowed by splitting the bbox up in
|
|
|
137
137
|
all_restaurants = client.query(
|
|
138
138
|
bbox="18.063,59.322,18.082,59.332",
|
|
139
139
|
tags="amenity=restaurant",
|
|
140
|
-
|
|
141
|
-
max_features=1_000_000,
|
|
140
|
+
auto_split=True,
|
|
142
141
|
timeout=60,
|
|
143
142
|
)
|
|
144
143
|
```
|
|
@@ -148,7 +147,7 @@ all_restaurants = client.query(
|
|
|
148
147
|
#### Count
|
|
149
148
|
Analyze feature counts with a histogram over a very large area - city and country sized bounding boxes allowed. For example, you can find out how many cafes, bars, and restaurants are in different cities or countries.
|
|
150
149
|
|
|
151
|
-
`client.count` calls `GET /v2/osm_features/count`. Example amenity histogram:
|
|
150
|
+
`client.count` calls `GET /v2/osm_features/count`. Omit `group_by` for a scalar total. Example amenity histogram:
|
|
152
151
|
|
|
153
152
|
```python
|
|
154
153
|
client.count(
|
|
@@ -221,7 +220,7 @@ Results come back as summaries (ids, names, OSM tags, lon/lat scalars, `distance
|
|
|
221
220
|
- Places: `places_search`, `places_nearby`, `places_details`
|
|
222
221
|
- Geocode: `geocode` (Nominatim interim)
|
|
223
222
|
- Routes: `routes_isochrone`, `routes_path`, `routes_optimized_path`
|
|
224
|
-
- Generic OSM: `query`, `
|
|
223
|
+
- Generic OSM: `query`, `count` (histogram / total)
|
|
225
224
|
- Local (no HTTP): `nearest_within`, `pairs_within`, `filter_open`, `point_in_polygon`, `points_in_polygon`
|
|
226
225
|
- Draw / export: `preview_map`, `export_geojson`
|
|
227
226
|
|
|
@@ -300,7 +299,16 @@ Runnable Python chains live in `tests/example_apps/test_geo_agent.py`. Full HTTP
|
|
|
300
299
|
|
|
301
300
|
### Places search
|
|
302
301
|
|
|
303
|
-
`places_search()` finds places in a bounding box **or** a `location` plus `radius` (not both). Optional `tags` (AND) and `or_tags` (OR) use the same OSM filters as `query()`. Omit `limit` to use the API default (100
|
|
302
|
+
`places_search()` finds places in a bounding box **or** a `location` plus `radius` (not both). Optional `tags` (AND) and `or_tags` (OR) use the same OSM filters as `query()`. Omit `limit` to use the API default (100). Paid keys may raise `limit` up to their `/v3` `max_limit`; `asOf` / `openNow` still cap hours parse at 10_000.
|
|
303
|
+
|
|
304
|
+
`bbox_tiles` and `auto_split` **only apply to bbox search** (same names as `query()`). Pass `bbox_tiles=2` (or 4, 8, ...) to split a bbox up front. Pass `auto_split=True` to count first via `GET /v2/osm_features/count` with `way_shape=polygon`, then fetch. No hours: split on `total` vs the key's `max_limit`. With `asOf` / `openNow`: AND `opening_hours`. An `asOf` page `limit` (default 100) truncates; density-split vs the 10k parse cap only when `limit` is raised above 10k. `openNow` density-splits vs that 10k scan cap even with a small fill limit. A search that exceeds the tagged **fetch** area cap 400s; that 400 is split in one jump from the limit in the message, not by probing every quarter, up to 32 tiles. A `count bbox area` 400 is not split (the window is still too big to count). location+radius is a circle and cannot split. To tile a radius window, convert it first with `around_to_bbox` (a square that contains the circle; corner hits can fall outside the original radius):
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from osmfeatures import around_to_bbox
|
|
308
|
+
|
|
309
|
+
bbox = around_to_bbox(lon=18.075, lat=59.316, radius_m=800)
|
|
310
|
+
cafes = client.places_search(bbox=bbox, or_tags=["amenity=cafe"], auto_split=True)
|
|
311
|
+
```
|
|
304
312
|
|
|
305
313
|
```python
|
|
306
314
|
cafes = client.places_search(
|
|
@@ -314,7 +322,7 @@ print(len(cafes["features"]), "open cafes")
|
|
|
314
322
|
print(cafes["metadata"]["evaluated_at"])
|
|
315
323
|
```
|
|
316
324
|
|
|
317
|
-
Response is a GeoJSON FeatureCollection plus `metadata.evaluated_at` (UTC instant used for hours).
|
|
325
|
+
Response is a GeoJSON FeatureCollection plus `metadata.evaluated_at` (UTC instant used for hours) and `metadata.units` (credits charged). After `bbox_tiles` / `auto_split`, `units` is the sum of the search tiles.
|
|
318
326
|
|
|
319
327
|
### Nearby (ranked from a point)
|
|
320
328
|
|
|
@@ -332,7 +340,7 @@ for item in nearby["items"]:
|
|
|
332
340
|
print(item["distance_m"], item["feature"]["id"])
|
|
333
341
|
```
|
|
334
342
|
|
|
335
|
-
Response: `{status, items: [{feature, distance_m}],
|
|
343
|
+
Response: `{status, items: [{feature, distance_m}], units, evaluated_at}`.
|
|
336
344
|
|
|
337
345
|
### Place details
|
|
338
346
|
|
|
@@ -345,7 +353,7 @@ print(details["feature"]["properties"]["tags"])
|
|
|
345
353
|
print(details["timezone"], details["evaluated_at"])
|
|
346
354
|
```
|
|
347
355
|
|
|
348
|
-
Response: `{status, feature,
|
|
356
|
+
Response: `{status, feature, units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
|
|
349
357
|
|
|
350
358
|
### Opening hours
|
|
351
359
|
|
|
@@ -456,6 +464,7 @@ The repository includes runnable example-app tests in `tests/example_apps/` show
|
|
|
456
464
|
- `test_bike_path_dijkstra_liljeholmen_to_djurgarden.py`: tiled corridor bike routing from Liljeholmen to Djurgarden.
|
|
457
465
|
- `test_geometry_filters.py`: zoom + area/length filters for large buildings and long roads.
|
|
458
466
|
- `test_geo_agent.py`: Python places/routes chains (bar crawl, bike parks, isochrone filter/compare/coverage, client-side open-at-clock).
|
|
467
|
+
- `test_auto_split.py`: ``auto_split`` for a city-scale cafe bbox (area cap) and dense inner-Stockholm buildings (``max_limit``).
|
|
459
468
|
|
|
460
469
|
Run all example apps:
|
|
461
470
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "osmfeatures"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.3"
|
|
8
8
|
description = "Official client for the MapLark OpenStreetMap Features API (GeoJSON, FlatGeobuf, GeoParquet, CSV)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -49,6 +49,8 @@ from .models import (
|
|
|
49
49
|
OSMFeaturesForbiddenError,
|
|
50
50
|
OSMFeaturesRateLimitError,
|
|
51
51
|
OSMFeaturesTimeoutError,
|
|
52
|
+
OSMFeaturesTooDenseError,
|
|
53
|
+
OSMFeaturesTooManyTilesError,
|
|
52
54
|
ResponseMeta,
|
|
53
55
|
)
|
|
54
56
|
from .output import to_dataframe, to_geodataframe
|
|
@@ -72,6 +74,8 @@ __all__ = [
|
|
|
72
74
|
"OSMFeaturesRateLimitError",
|
|
73
75
|
"OSMFeaturesAPIError",
|
|
74
76
|
"OSMFeaturesTimeoutError",
|
|
77
|
+
"OSMFeaturesTooDenseError",
|
|
78
|
+
"OSMFeaturesTooManyTilesError",
|
|
75
79
|
# Output
|
|
76
80
|
"to_dataframe",
|
|
77
81
|
"to_geodataframe",
|
|
@@ -14,6 +14,7 @@ from .models import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
DEFAULT_BASE_URL = "https://api.maplark.com"
|
|
17
|
+
ACCOUNT_TIER_PATH = "/v1/account/tier"
|
|
17
18
|
GEOJSON_ACCEPT = "application/geo+json"
|
|
18
19
|
DEFAULT_TIMEOUT = 60.0
|
|
19
20
|
_LOG_BODY_MAX = 4000
|
|
@@ -126,7 +127,7 @@ def raise_for_response(resp: Any) -> None:
|
|
|
126
127
|
body.get("detail", body.get("message", f"Rate limit exceeded (HTTP 429): {resp.text[:200]}")),
|
|
127
128
|
error_code=body.get("error", ""),
|
|
128
129
|
tier=body.get("tier", ""),
|
|
129
|
-
|
|
130
|
+
units=body.get("units"),
|
|
130
131
|
max_units_per_request=body.get("max_units_per_request"),
|
|
131
132
|
retry_after=retry_after,
|
|
132
133
|
)
|
|
@@ -184,7 +185,7 @@ def build_rate_limit_error(resp: Any) -> OSMFeaturesRateLimitError:
|
|
|
184
185
|
body.get("detail", body.get("message", "Rate limit exceeded")),
|
|
185
186
|
error_code=body.get("error", ""),
|
|
186
187
|
tier=body.get("tier", ""),
|
|
187
|
-
|
|
188
|
+
units=body.get("units"),
|
|
188
189
|
max_units_per_request=body.get("max_units_per_request"),
|
|
189
190
|
retry_after=retry_after,
|
|
190
191
|
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Shared ``POST /v1/places/search`` tiling: bbox_tiles and auto_split.
|
|
2
|
+
|
|
3
|
+
Sync and async clients pass their POST/count callables into
|
|
4
|
+
:func:`execute_places_search`. ``bbox_tiles`` and ``auto_split`` are
|
|
5
|
+
client-side; they are not sent in the JSON body. Both only apply to bbox
|
|
6
|
+
search. location+radius is a circle (same as ``query`` radius) and cannot
|
|
7
|
+
split; convert with :func:`around_to_bbox` and search that bbox if
|
|
8
|
+
you need tiles.
|
|
9
|
+
|
|
10
|
+
``auto_split`` uses the same count-first walk as :func:`execute_query`.
|
|
11
|
+
Counts are ``GET /v2/osm_features/count`` with ``way_shape=polygon``.
|
|
12
|
+
``asOf`` / ``openNow`` AND ``opening_hours``. An ``asOf`` page ``limit``
|
|
13
|
+
truncates; density-split vs the 10k parse cap only when ``limit`` is
|
|
14
|
+
raised above 10k. ``openNow`` always density-splits vs that 10k scan cap
|
|
15
|
+
(the drain returns 200 at 10k hours-tagged rows).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ._geo_agent import places_search_body
|
|
24
|
+
from ._pagination import DEFAULT_QUERY_ALL_TIMEOUT_S
|
|
25
|
+
from ._split import (
|
|
26
|
+
await_maybe,
|
|
27
|
+
fold_auto_split,
|
|
28
|
+
hours_parse_split_cap,
|
|
29
|
+
iter_split_tiles,
|
|
30
|
+
match_split_cap,
|
|
31
|
+
max_limit_from_tier,
|
|
32
|
+
run_sync,
|
|
33
|
+
)
|
|
34
|
+
from .chunking import merge_features
|
|
35
|
+
|
|
36
|
+
BBOX_TILES_REQUIRES_BBOX = (
|
|
37
|
+
"bbox_tiles only applies to bbox search, not location+radius"
|
|
38
|
+
)
|
|
39
|
+
AUTO_SPLIT_REQUIRES_BBOX = (
|
|
40
|
+
"auto_split only applies to bbox search, not location+radius"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_SUM_META_KEYS = ("units", "input_count", "kept_count", "hours_parsed")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _merge_place_metadata(metas: list[dict[str, Any]]) -> dict[str, Any]:
|
|
48
|
+
"""Sum billed/scan fields across tiles. Keep first ``evaluated_at``."""
|
|
49
|
+
out = dict(metas[0])
|
|
50
|
+
out.pop("hint", None)
|
|
51
|
+
for key in _SUM_META_KEYS:
|
|
52
|
+
total = 0
|
|
53
|
+
seen = False
|
|
54
|
+
for meta in metas:
|
|
55
|
+
value = meta.get(key)
|
|
56
|
+
if value is None:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
total += int(value)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
continue
|
|
62
|
+
seen = True
|
|
63
|
+
if seen:
|
|
64
|
+
out[key] = total
|
|
65
|
+
if any(m.get("hours_parse_capped") for m in metas):
|
|
66
|
+
out["hours_parse_capped"] = True
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _merge_place_collections(payloads: list[dict[str, Any]]) -> dict[str, Any]:
|
|
71
|
+
if not payloads:
|
|
72
|
+
return {"type": "FeatureCollection", "features": []}
|
|
73
|
+
features = merge_features(
|
|
74
|
+
[list(p.get("features") or []) for p in payloads if isinstance(p, dict)]
|
|
75
|
+
)
|
|
76
|
+
out = dict(payloads[0]) if isinstance(payloads[0], dict) else {}
|
|
77
|
+
out["type"] = "FeatureCollection"
|
|
78
|
+
out["features"] = features
|
|
79
|
+
metas = [
|
|
80
|
+
p["metadata"]
|
|
81
|
+
for p in payloads
|
|
82
|
+
if isinstance(p, dict) and isinstance(p.get("metadata"), dict)
|
|
83
|
+
]
|
|
84
|
+
if metas:
|
|
85
|
+
out["metadata"] = _merge_place_metadata(metas)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hours_on(open_now: bool, as_of: str | None) -> bool:
|
|
90
|
+
return bool(open_now or as_of is not None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def execute_places_search(
|
|
94
|
+
*,
|
|
95
|
+
post: Callable[[dict[str, Any]], Any],
|
|
96
|
+
count: Callable[..., Any],
|
|
97
|
+
account_tier: Callable[..., Any] | None = None,
|
|
98
|
+
bbox: str | None = None,
|
|
99
|
+
location: dict[str, float] | None = None,
|
|
100
|
+
radius: float | None = None,
|
|
101
|
+
type: str | None = None, # noqa: A002
|
|
102
|
+
tags: list[str] | None = None,
|
|
103
|
+
or_tags: list[str] | None = None,
|
|
104
|
+
limit: int | None = None,
|
|
105
|
+
open_now: bool = False,
|
|
106
|
+
as_of: str | None = None,
|
|
107
|
+
bbox_tiles: int = 1,
|
|
108
|
+
auto_split: bool = False,
|
|
109
|
+
split_until_fit: bool | None = None,
|
|
110
|
+
timeout: float | None = DEFAULT_QUERY_ALL_TIMEOUT_S,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""POST places/search, optionally tiling *bbox* like :func:`execute_query`."""
|
|
113
|
+
auto_split = fold_auto_split(auto_split, split_until_fit)
|
|
114
|
+
if bbox_tiles != 1 and not isinstance(bbox, str):
|
|
115
|
+
raise ValueError(BBOX_TILES_REQUIRES_BBOX)
|
|
116
|
+
if auto_split and not isinstance(bbox, str):
|
|
117
|
+
raise ValueError(AUTO_SPLIT_REQUIRES_BBOX)
|
|
118
|
+
|
|
119
|
+
def _body(*, tile: str | None = None) -> dict[str, Any]:
|
|
120
|
+
return places_search_body(
|
|
121
|
+
bbox=tile if tile is not None else bbox,
|
|
122
|
+
location=location,
|
|
123
|
+
radius=radius,
|
|
124
|
+
type=type,
|
|
125
|
+
tags=tags,
|
|
126
|
+
or_tags=or_tags,
|
|
127
|
+
limit=limit,
|
|
128
|
+
open_now=open_now,
|
|
129
|
+
as_of=as_of,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if bbox_tiles == 1 and not auto_split:
|
|
133
|
+
return await await_maybe(post(_body())) # type: ignore[no-any-return]
|
|
134
|
+
|
|
135
|
+
assert isinstance(bbox, str)
|
|
136
|
+
|
|
137
|
+
count_kwargs: dict[str, Any] = {"way_shape": "polygon"}
|
|
138
|
+
if tags is not None:
|
|
139
|
+
count_kwargs["tags"] = list(tags)
|
|
140
|
+
if or_tags is not None:
|
|
141
|
+
count_kwargs["or_tags"] = list(or_tags)
|
|
142
|
+
hours_on = _hours_on(open_now, as_of)
|
|
143
|
+
if hours_on:
|
|
144
|
+
count_kwargs["tags"] = [*count_kwargs.get("tags", []), "opening_hours"]
|
|
145
|
+
omitted_cap = 1
|
|
146
|
+
if auto_split and not hours_on:
|
|
147
|
+
if account_tier is None:
|
|
148
|
+
raise TypeError("auto_split requires account_tier")
|
|
149
|
+
omitted_cap = max_limit_from_tier(await await_maybe(account_tier()))
|
|
150
|
+
tile_cap = (
|
|
151
|
+
hours_parse_split_cap(limit, open_now=open_now)
|
|
152
|
+
if hours_on
|
|
153
|
+
else match_split_cap(limit, omitted_cap)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def _fetch_tile(tile: str) -> dict[str, Any]:
|
|
157
|
+
return await await_maybe(post(_body(tile=tile))) # type: ignore[no-any-return]
|
|
158
|
+
|
|
159
|
+
async def _match_count(tile: str) -> int | None:
|
|
160
|
+
body = await await_maybe(count(bbox=tile, **count_kwargs))
|
|
161
|
+
return int(body["total"])
|
|
162
|
+
|
|
163
|
+
payloads: list[dict[str, Any]] = []
|
|
164
|
+
async for page in iter_split_tiles(
|
|
165
|
+
bbox,
|
|
166
|
+
bbox_tiles=bbox_tiles,
|
|
167
|
+
auto_split=auto_split,
|
|
168
|
+
timeout=timeout,
|
|
169
|
+
tile_cap=tile_cap,
|
|
170
|
+
match_count=_match_count if auto_split else None,
|
|
171
|
+
fetch_tile=_fetch_tile,
|
|
172
|
+
overflow_name="places_search",
|
|
173
|
+
):
|
|
174
|
+
payloads.append(page)
|
|
175
|
+
return _merge_place_collections(payloads)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def execute_places_search_sync(
|
|
179
|
+
*,
|
|
180
|
+
post: Callable[[dict[str, Any]], Any],
|
|
181
|
+
count: Callable[..., Any],
|
|
182
|
+
account_tier: Callable[..., Any] | None = None,
|
|
183
|
+
**kwargs: Any,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Run :func:`execute_places_search` on a private loop (or a worker if nested)."""
|
|
186
|
+
return run_sync(
|
|
187
|
+
execute_places_search,
|
|
188
|
+
post=post,
|
|
189
|
+
count=count,
|
|
190
|
+
account_tier=account_tier,
|
|
191
|
+
**kwargs,
|
|
192
|
+
)
|