osmfeatures 0.7.2__tar.gz → 0.7.4__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 (83) hide show
  1. osmfeatures-0.7.4/.cursor/rules/maplark-mcp-dogfood.mdc +12 -0
  2. osmfeatures-0.7.4/AUTO_TILES.md +50 -0
  3. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/PKG-INFO +40 -11
  4. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/README.md +39 -10
  5. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/pyproject.toml +1 -1
  6. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/__init__.py +6 -0
  7. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/_http.py +3 -2
  8. osmfeatures-0.7.4/src/osmfeatures/_places.py +192 -0
  9. osmfeatures-0.7.4/src/osmfeatures/_query.py +250 -0
  10. osmfeatures-0.7.4/src/osmfeatures/_split.py +392 -0
  11. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/async_client.py +150 -89
  12. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/chunking.py +19 -5
  13. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/cli.py +124 -11
  14. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/client.py +178 -283
  15. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/convenience.py +1 -1
  16. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/mcp/AGENT_INSTRUCTIONS.md +45 -45
  17. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/mcp/_mcp_session.py +108 -16
  18. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/mcp/_preview.py +259 -54
  19. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/mcp/mcp_server.py +50 -7
  20. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/models.py +99 -4
  21. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/README.md +2 -1
  22. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/conftest.py +24 -0
  23. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_city_cycling_infrastructure.py +2 -2
  24. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_cycling_trails.py +2 -2
  25. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_geo_agent.py +1 -1
  26. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_geometry_filters.py +6 -6
  27. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_lakeside_ice_cream_hunt.py +2 -2
  28. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_park_bench_finder.py +1 -1
  29. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_park_explorer.py +1 -1
  30. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_pedestrian_shortest_path.py +1 -1
  31. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_pedestrian_wavefront_bfs.py +1 -1
  32. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_restaurant_guide.py +1 -1
  33. osmfeatures-0.7.4/tests/example_apps/test_split_until_fit.py +247 -0
  34. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/mcp/test_mcp_http.py +1 -1
  35. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/mcp/test_mcp_server.py +86 -13
  36. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/mcp/test_mcp_session.py +168 -15
  37. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/mcp/test_preview.py +91 -1
  38. osmfeatures-0.7.4/tests/test_async_client.py +763 -0
  39. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_chunking.py +11 -0
  40. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_cli.py +59 -2
  41. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_client.py +45 -2
  42. osmfeatures-0.7.4/tests/test_geo_agent_client.py +822 -0
  43. osmfeatures-0.7.4/tests/test_pagination.py +489 -0
  44. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_retry.py +6 -5
  45. osmfeatures-0.7.4/tests/test_split.py +130 -0
  46. osmfeatures-0.7.4/tmp/preview_redraw.pid +1 -0
  47. osmfeatures-0.7.4/tmp/preview_redraw.url +1 -0
  48. osmfeatures-0.7.4/tmp/preview_sweden_buildings.pid +1 -0
  49. osmfeatures-0.7.4/tmp/sweden_amenities_combined.json +1 -0
  50. osmfeatures-0.7.4/uv.lock +2179 -0
  51. osmfeatures-0.7.2/tests/test_async_client.py +0 -343
  52. osmfeatures-0.7.2/tests/test_geo_agent_client.py +0 -185
  53. osmfeatures-0.7.2/tests/test_pagination.py +0 -176
  54. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/.github/copilot-instructions.md +0 -0
  55. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/.github/instructions/osmgeojson-python-conventions.instructions.md +0 -0
  56. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/.gitignore +0 -0
  57. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/Dockerfile +0 -0
  58. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/LICENSE +0 -0
  59. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/Makefile +0 -0
  60. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/_geo_agent.py +0 -0
  61. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/_pagination.py +0 -0
  62. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/geometry.py +0 -0
  63. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/mcp/__init__.py +0 -0
  64. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/mcp/_geocode.py +0 -0
  65. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/nearest.py +0 -0
  66. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/output.py +0 -0
  67. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/py.typed +0 -0
  68. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/src/osmfeatures/retry.py +0 -0
  69. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/.env.example +0 -0
  70. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/__init__.py +0 -0
  71. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/__init__.py +0 -0
  72. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/conftest.py +0 -0
  73. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/graph_utils.py +0 -0
  74. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/example_apps/test_bike_path_dijkstra_liljeholmen_to_djurgarden.py +0 -0
  75. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/mcp/test_geocode.py +0 -0
  76. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_http.py +0 -0
  77. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_nearest_within.py +0 -0
  78. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/test_output.py +0 -0
  79. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/tutorial/conftest.py +0 -0
  80. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/tutorial/osm_features_query.py +0 -0
  81. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/tutorial/places_nearby.py +0 -0
  82. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/tutorial/places_search.py +0 -0
  83. {osmfeatures-0.7.2 → osmfeatures-0.7.4}/tests/tutorial/test_tutorial.py +0 -0
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Prefer MapLark MCP for map and density demos in this repo
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # MapLark MCP in osmfeatures-py
7
+
8
+ When the user asks for a map, heatmap, density view, or live OSM visualization—and MapLark MCP is connected—use those MCP tools (`density`, `preview_map`, `places_search`, etc.), not the local CLI (`osmfeatures …`), shell scripts, or `PreviewServer` in this codebase.
9
+
10
+ Use the SDK/CLI/local preview only when the user is clearly developing or debugging the client (tests, CLI flags, MCP server code).
11
+
12
+ Geo flow for heatmaps: `density` → `preview_map` on the returned `collection_id`. Country or region: `geocode` first for bbox when the user named a place.
@@ -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.2
3
+ Version: 0.7.4
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
@@ -54,6 +54,7 @@ Official Python client for the [MapLark OSM Features API](https://maplark.com) w
54
54
  - [Query OSM features](#query-osm-features)
55
55
  - [Query a large bbox](#query-a-large-bbox)
56
56
  - [Count](#count)
57
+ - [Density](#density)
57
58
  - [Async client](#async-client)
58
59
  - [Convenience helpers](#convenience-helpers)
59
60
  - [Usage](#usage)
@@ -145,7 +146,7 @@ with OSMFeaturesClient(api_key="sk-...") as client:
145
146
 
146
147
  ### Query OSM features
147
148
 
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` (enterprise 1000000). A larger match set is truncated (`X-Has-More: true`). Pass `split_until_fit=True` to quarter the bbox until each piece fits. That adds latency. Pass `bbox_tiles=2` (or 4, 8, ...) to split the bbox up front. `query()` does not take `cursor`.
149
+ `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
150
 
150
151
  ```python
151
152
  fc = client.query(
@@ -168,7 +169,7 @@ Common filters:
168
169
  - `way_shape="polygon" | "line" | "all"` (omit = both shapes; `all` also means both)
169
170
  - `clip_geometry=True | False` (omit for the API default; set `False` to keep full geometry outside bbox)
170
171
  - `bbox_tiles=2` (power of 2) to split a bbox that exceeds your tier area cap
171
- - `split_until_fit=True` to split a bbox that exceeds `limit` (or the key's `max_limit`)
172
+ - `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
173
  - `accept="text/csv"` / `"application/flatgeobuf"` / `"application/vnd.apache.parquet"` (returns `BinaryQueryResult`)
173
174
 
174
175
 
@@ -180,8 +181,7 @@ Use this to query a larger bbox than what is allowed by splitting the bbox up in
180
181
  all_restaurants = client.query(
181
182
  bbox="18.063,59.322,18.082,59.332",
182
183
  tags="amenity=restaurant",
183
- split_until_fit=True,
184
- max_features=1_000_000,
184
+ auto_split=True,
185
185
  timeout=60,
186
186
  )
187
187
  ```
@@ -191,7 +191,7 @@ all_restaurants = client.query(
191
191
  #### Count
192
192
  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
193
 
194
- `client.count` calls `GET /v2/osm_features/count`. Example amenity histogram:
194
+ `client.count` calls `GET /v2/osm_features/count`. Omit `group_by` for a scalar total. Example amenity histogram:
195
195
 
196
196
  ```python
197
197
  client.count(
@@ -205,6 +205,24 @@ client.count(
205
205
  ```
206
206
 
207
207
 
208
+ #### Density
209
+
210
+ `client.density` calls `GET /v2/osm_features/density`. Occupied grid bins come back as a GeoJSON FeatureCollection of points with `properties.count`, plus `total` and `truncated`. These are not OSM features (no `osm_id` or tags). Omit `cell_deg` for 0.1. `type` omitted is nodes. `tags` or `or_tags` is required. `limit` is the max occupied cells (the key's `max_limit` when omitted).
211
+
212
+ A Europe box (`-26,33,52,72`, about 3042 deg²) fits the density area cap (4000 deg²) and the 80° span. It is above `max_units_per_request` on every non-enterprise tier, so pass `disable_budget_warning=True` to run it. The same box still 400s on count (area cap 400 deg²). A unit-cap 400 on count can use that flag the same way. A bbox-area 400 cannot.
213
+
214
+ ```python
215
+ client.density(
216
+ bbox="-26,33,52,72",
217
+ tags=["place"],
218
+ cell_deg=0.1,
219
+ disable_budget_warning=True,
220
+ )
221
+ ```
222
+
223
+ CLI: `osmfeatures density --bbox -26,33,52,72 --tags place --cell-deg 0.1 --disable-budget-warning`.
224
+
225
+
208
226
  ### Async client
209
227
 
210
228
  Async methods mirror the sync API (`query_async`, `query_all_async`):
@@ -264,7 +282,7 @@ Results come back as summaries (ids, names, OSM tags, lon/lat scalars, `distance
264
282
  - Places: `places_search`, `places_nearby`, `places_details`
265
283
  - Geocode: `geocode` (Nominatim interim)
266
284
  - Routes: `routes_isochrone`, `routes_path`, `routes_optimized_path`
267
- - Generic OSM: `query`, `stats` (count/histogram)
285
+ - Generic OSM: `query`, `count` (histogram / total), `density` (grid bins; `preview_map` the id)
268
286
  - Local (no HTTP): `nearest_within`, `pairs_within`, `filter_open`, `point_in_polygon`, `points_in_polygon`
269
287
  - Draw / export: `preview_map`, `export_geojson`
270
288
 
@@ -328,6 +346,7 @@ Planner rules: you pick tags, bbox or location+radius, budgets, `openNow`/`asOf`
328
346
  | "Cafes on a tour of Gamla Stan" | `places_search`, `filter_open` + `routes_optimized_path` (`loop=true`) |
329
347
  | "Suggest a walk to a bar, a restaurant, and a cafe, no particular order" | `routes_optimized_path` with `loop=false` |
330
348
  | "Is the office a 20-minute walk from the apartment?" | `routes_isochrone` from A, `point_in_polygon` for B |
349
+ | "Amenity heatmap for Sweden" | `geocode` (bbox), `density` (`amenity`; `disable_budget_warning` if the unit cap 400s), `preview_map` |
331
350
  | "Show this on a map" | `preview_map(collection_id)` after a search or route |
332
351
 
333
352
  The same operations exist on `OSMFeaturesClient` when you are not going through an LLM (see [Places and routes](#places-and-routes)).
@@ -343,7 +362,16 @@ Runnable Python chains live in `tests/example_apps/test_geo_agent.py`. Full HTTP
343
362
 
344
363
  ### Places search
345
364
 
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, max 10_000).
365
+ `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.
366
+
367
+ `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):
368
+
369
+ ```python
370
+ from osmfeatures import around_to_bbox
371
+
372
+ bbox = around_to_bbox(lon=18.075, lat=59.316, radius_m=800)
373
+ cafes = client.places_search(bbox=bbox, or_tags=["amenity=cafe"], auto_split=True)
374
+ ```
347
375
 
348
376
  ```python
349
377
  cafes = client.places_search(
@@ -357,7 +385,7 @@ print(len(cafes["features"]), "open cafes")
357
385
  print(cafes["metadata"]["evaluated_at"])
358
386
  ```
359
387
 
360
- Response is a GeoJSON FeatureCollection plus `metadata.evaluated_at` (UTC instant used for hours).
388
+ 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
389
 
362
390
  ### Nearby (ranked from a point)
363
391
 
@@ -375,7 +403,7 @@ for item in nearby["items"]:
375
403
  print(item["distance_m"], item["feature"]["id"])
376
404
  ```
377
405
 
378
- Response: `{status, items: [{feature, distance_m}], estimated_units, evaluated_at}`.
406
+ Response: `{status, items: [{feature, distance_m}], units, evaluated_at}`.
379
407
 
380
408
  ### Place details
381
409
 
@@ -388,7 +416,7 @@ print(details["feature"]["properties"]["tags"])
388
416
  print(details["timezone"], details["evaluated_at"])
389
417
  ```
390
418
 
391
- Response: `{status, feature, estimated_units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
419
+ Response: `{status, feature, units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
392
420
 
393
421
  ### Opening hours
394
422
 
@@ -499,6 +527,7 @@ The repository includes runnable example-app tests in `tests/example_apps/` show
499
527
  - `test_bike_path_dijkstra_liljeholmen_to_djurgarden.py`: tiled corridor bike routing from Liljeholmen to Djurgarden.
500
528
  - `test_geometry_filters.py`: zoom + area/length filters for large buildings and long roads.
501
529
  - `test_geo_agent.py`: Python places/routes chains (bar crawl, bike parks, isochrone filter/compare/coverage, client-side open-at-clock).
530
+ - `test_auto_split.py`: ``auto_split`` for a city-scale cafe bbox (area cap) and dense inner-Stockholm buildings (``max_limit``).
502
531
 
503
532
  Run all example apps:
504
533
 
@@ -11,6 +11,7 @@ Official Python client for the [MapLark OSM Features API](https://maplark.com) w
11
11
  - [Query OSM features](#query-osm-features)
12
12
  - [Query a large bbox](#query-a-large-bbox)
13
13
  - [Count](#count)
14
+ - [Density](#density)
14
15
  - [Async client](#async-client)
15
16
  - [Convenience helpers](#convenience-helpers)
16
17
  - [Usage](#usage)
@@ -102,7 +103,7 @@ with OSMFeaturesClient(api_key="sk-...") as client:
102
103
 
103
104
  ### Query OSM features
104
105
 
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` (enterprise 1000000). A larger match set is truncated (`X-Has-More: true`). Pass `split_until_fit=True` to quarter the bbox until each piece fits. That adds latency. Pass `bbox_tiles=2` (or 4, 8, ...) to split the bbox up front. `query()` does not take `cursor`.
106
+ `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
107
 
107
108
  ```python
108
109
  fc = client.query(
@@ -125,7 +126,7 @@ Common filters:
125
126
  - `way_shape="polygon" | "line" | "all"` (omit = both shapes; `all` also means both)
126
127
  - `clip_geometry=True | False` (omit for the API default; set `False` to keep full geometry outside bbox)
127
128
  - `bbox_tiles=2` (power of 2) to split a bbox that exceeds your tier area cap
128
- - `split_until_fit=True` to split a bbox that exceeds `limit` (or the key's `max_limit`)
129
+ - `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
130
  - `accept="text/csv"` / `"application/flatgeobuf"` / `"application/vnd.apache.parquet"` (returns `BinaryQueryResult`)
130
131
 
131
132
 
@@ -137,8 +138,7 @@ Use this to query a larger bbox than what is allowed by splitting the bbox up in
137
138
  all_restaurants = client.query(
138
139
  bbox="18.063,59.322,18.082,59.332",
139
140
  tags="amenity=restaurant",
140
- split_until_fit=True,
141
- max_features=1_000_000,
141
+ auto_split=True,
142
142
  timeout=60,
143
143
  )
144
144
  ```
@@ -148,7 +148,7 @@ all_restaurants = client.query(
148
148
  #### Count
149
149
  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
150
 
151
- `client.count` calls `GET /v2/osm_features/count`. Example amenity histogram:
151
+ `client.count` calls `GET /v2/osm_features/count`. Omit `group_by` for a scalar total. Example amenity histogram:
152
152
 
153
153
  ```python
154
154
  client.count(
@@ -162,6 +162,24 @@ client.count(
162
162
  ```
163
163
 
164
164
 
165
+ #### Density
166
+
167
+ `client.density` calls `GET /v2/osm_features/density`. Occupied grid bins come back as a GeoJSON FeatureCollection of points with `properties.count`, plus `total` and `truncated`. These are not OSM features (no `osm_id` or tags). Omit `cell_deg` for 0.1. `type` omitted is nodes. `tags` or `or_tags` is required. `limit` is the max occupied cells (the key's `max_limit` when omitted).
168
+
169
+ A Europe box (`-26,33,52,72`, about 3042 deg²) fits the density area cap (4000 deg²) and the 80° span. It is above `max_units_per_request` on every non-enterprise tier, so pass `disable_budget_warning=True` to run it. The same box still 400s on count (area cap 400 deg²). A unit-cap 400 on count can use that flag the same way. A bbox-area 400 cannot.
170
+
171
+ ```python
172
+ client.density(
173
+ bbox="-26,33,52,72",
174
+ tags=["place"],
175
+ cell_deg=0.1,
176
+ disable_budget_warning=True,
177
+ )
178
+ ```
179
+
180
+ CLI: `osmfeatures density --bbox -26,33,52,72 --tags place --cell-deg 0.1 --disable-budget-warning`.
181
+
182
+
165
183
  ### Async client
166
184
 
167
185
  Async methods mirror the sync API (`query_async`, `query_all_async`):
@@ -221,7 +239,7 @@ Results come back as summaries (ids, names, OSM tags, lon/lat scalars, `distance
221
239
  - Places: `places_search`, `places_nearby`, `places_details`
222
240
  - Geocode: `geocode` (Nominatim interim)
223
241
  - Routes: `routes_isochrone`, `routes_path`, `routes_optimized_path`
224
- - Generic OSM: `query`, `stats` (count/histogram)
242
+ - Generic OSM: `query`, `count` (histogram / total), `density` (grid bins; `preview_map` the id)
225
243
  - Local (no HTTP): `nearest_within`, `pairs_within`, `filter_open`, `point_in_polygon`, `points_in_polygon`
226
244
  - Draw / export: `preview_map`, `export_geojson`
227
245
 
@@ -285,6 +303,7 @@ Planner rules: you pick tags, bbox or location+radius, budgets, `openNow`/`asOf`
285
303
  | "Cafes on a tour of Gamla Stan" | `places_search`, `filter_open` + `routes_optimized_path` (`loop=true`) |
286
304
  | "Suggest a walk to a bar, a restaurant, and a cafe, no particular order" | `routes_optimized_path` with `loop=false` |
287
305
  | "Is the office a 20-minute walk from the apartment?" | `routes_isochrone` from A, `point_in_polygon` for B |
306
+ | "Amenity heatmap for Sweden" | `geocode` (bbox), `density` (`amenity`; `disable_budget_warning` if the unit cap 400s), `preview_map` |
288
307
  | "Show this on a map" | `preview_map(collection_id)` after a search or route |
289
308
 
290
309
  The same operations exist on `OSMFeaturesClient` when you are not going through an LLM (see [Places and routes](#places-and-routes)).
@@ -300,7 +319,16 @@ Runnable Python chains live in `tests/example_apps/test_geo_agent.py`. Full HTTP
300
319
 
301
320
  ### Places search
302
321
 
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, max 10_000).
322
+ `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.
323
+
324
+ `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):
325
+
326
+ ```python
327
+ from osmfeatures import around_to_bbox
328
+
329
+ bbox = around_to_bbox(lon=18.075, lat=59.316, radius_m=800)
330
+ cafes = client.places_search(bbox=bbox, or_tags=["amenity=cafe"], auto_split=True)
331
+ ```
304
332
 
305
333
  ```python
306
334
  cafes = client.places_search(
@@ -314,7 +342,7 @@ print(len(cafes["features"]), "open cafes")
314
342
  print(cafes["metadata"]["evaluated_at"])
315
343
  ```
316
344
 
317
- Response is a GeoJSON FeatureCollection plus `metadata.evaluated_at` (UTC instant used for hours).
345
+ 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
346
 
319
347
  ### Nearby (ranked from a point)
320
348
 
@@ -332,7 +360,7 @@ for item in nearby["items"]:
332
360
  print(item["distance_m"], item["feature"]["id"])
333
361
  ```
334
362
 
335
- Response: `{status, items: [{feature, distance_m}], estimated_units, evaluated_at}`.
363
+ Response: `{status, items: [{feature, distance_m}], units, evaluated_at}`.
336
364
 
337
365
  ### Place details
338
366
 
@@ -345,7 +373,7 @@ print(details["feature"]["properties"]["tags"])
345
373
  print(details["timezone"], details["evaluated_at"])
346
374
  ```
347
375
 
348
- Response: `{status, feature, estimated_units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
376
+ Response: `{status, feature, units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
349
377
 
350
378
  ### Opening hours
351
379
 
@@ -456,6 +484,7 @@ The repository includes runnable example-app tests in `tests/example_apps/` show
456
484
  - `test_bike_path_dijkstra_liljeholmen_to_djurgarden.py`: tiled corridor bike routing from Liljeholmen to Djurgarden.
457
485
  - `test_geometry_filters.py`: zoom + area/length filters for large buildings and long roads.
458
486
  - `test_geo_agent.py`: Python places/routes chains (bar crawl, bike parks, isochrone filter/compare/coverage, client-side open-at-clock).
487
+ - `test_auto_split.py`: ``auto_split`` for a city-scale cafe bbox (area cap) and dense inner-Stockholm buildings (``max_limit``).
459
488
 
460
489
  Run all example apps:
461
490
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osmfeatures"
7
- version = "0.7.2"
7
+ version = "0.7.4"
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" }
@@ -41,6 +41,7 @@ from .convenience import (
41
41
  )
42
42
  from .models import (
43
43
  BinaryQueryResult,
44
+ DensityFeatureCollection,
44
45
  OSMFeature,
45
46
  OSMFeatureCollection,
46
47
  OSMFeaturesAPIError,
@@ -49,6 +50,8 @@ from .models import (
49
50
  OSMFeaturesForbiddenError,
50
51
  OSMFeaturesRateLimitError,
51
52
  OSMFeaturesTimeoutError,
53
+ OSMFeaturesTooDenseError,
54
+ OSMFeaturesTooManyTilesError,
52
55
  ResponseMeta,
53
56
  )
54
57
  from .output import to_dataframe, to_geodataframe
@@ -63,6 +66,7 @@ __all__ = [
63
66
  # Models
64
67
  "OSMFeature",
65
68
  "OSMFeatureCollection",
69
+ "DensityFeatureCollection",
66
70
  "BinaryQueryResult",
67
71
  "ResponseMeta",
68
72
  # Exceptions
@@ -72,6 +76,8 @@ __all__ = [
72
76
  "OSMFeaturesRateLimitError",
73
77
  "OSMFeaturesAPIError",
74
78
  "OSMFeaturesTimeoutError",
79
+ "OSMFeaturesTooDenseError",
80
+ "OSMFeaturesTooManyTilesError",
75
81
  # Output
76
82
  "to_dataframe",
77
83
  "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
- estimated_units=body.get("estimated_units"),
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
- estimated_units=body.get("estimated_units"),
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
+ )