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.
Files changed (78) hide show
  1. osmfeatures-0.7.3/AUTO_TILES.md +50 -0
  2. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/PKG-INFO +20 -11
  3. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/README.md +19 -10
  4. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/pyproject.toml +1 -1
  5. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/__init__.py +4 -0
  6. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/_http.py +3 -2
  7. osmfeatures-0.7.3/src/osmfeatures/_places.py +192 -0
  8. osmfeatures-0.7.3/src/osmfeatures/_query.py +250 -0
  9. osmfeatures-0.7.3/src/osmfeatures/_split.py +392 -0
  10. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/async_client.py +94 -112
  11. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/chunking.py +19 -5
  12. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/cli.py +21 -11
  13. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/client.py +84 -283
  14. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/convenience.py +1 -1
  15. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/AGENT_INSTRUCTIONS.md +41 -10
  16. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/_mcp_session.py +14 -8
  17. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/mcp_server.py +16 -6
  18. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/models.py +47 -4
  19. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/README.md +2 -1
  20. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/conftest.py +23 -0
  21. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_city_cycling_infrastructure.py +2 -2
  22. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_cycling_trails.py +2 -2
  23. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_geo_agent.py +1 -1
  24. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_geometry_filters.py +6 -6
  25. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_lakeside_ice_cream_hunt.py +2 -2
  26. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_park_bench_finder.py +1 -1
  27. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_park_explorer.py +1 -1
  28. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_pedestrian_shortest_path.py +1 -1
  29. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_pedestrian_wavefront_bfs.py +1 -1
  30. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_restaurant_guide.py +1 -1
  31. osmfeatures-0.7.3/tests/example_apps/test_split_until_fit.py +247 -0
  32. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_mcp_http.py +1 -1
  33. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_mcp_server.py +29 -7
  34. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_mcp_session.py +98 -15
  35. osmfeatures-0.7.3/tests/test_async_client.py +722 -0
  36. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_chunking.py +11 -0
  37. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_cli.py +25 -2
  38. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_client.py +2 -2
  39. osmfeatures-0.7.3/tests/test_geo_agent_client.py +822 -0
  40. osmfeatures-0.7.3/tests/test_pagination.py +489 -0
  41. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_retry.py +6 -5
  42. osmfeatures-0.7.3/tests/test_split.py +130 -0
  43. osmfeatures-0.7.3/uv.lock +2179 -0
  44. osmfeatures-0.7.2/tests/test_async_client.py +0 -343
  45. osmfeatures-0.7.2/tests/test_geo_agent_client.py +0 -185
  46. osmfeatures-0.7.2/tests/test_pagination.py +0 -176
  47. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/.github/copilot-instructions.md +0 -0
  48. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/.github/instructions/osmgeojson-python-conventions.instructions.md +0 -0
  49. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/.gitignore +0 -0
  50. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/Dockerfile +0 -0
  51. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/LICENSE +0 -0
  52. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/Makefile +0 -0
  53. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/_geo_agent.py +0 -0
  54. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/_pagination.py +0 -0
  55. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/geometry.py +0 -0
  56. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/__init__.py +0 -0
  57. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/_geocode.py +0 -0
  58. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/mcp/_preview.py +0 -0
  59. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/nearest.py +0 -0
  60. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/output.py +0 -0
  61. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/py.typed +0 -0
  62. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/src/osmfeatures/retry.py +0 -0
  63. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/.env.example +0 -0
  64. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/__init__.py +0 -0
  65. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/__init__.py +0 -0
  66. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/conftest.py +0 -0
  67. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/graph_utils.py +0 -0
  68. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/example_apps/test_bike_path_dijkstra_liljeholmen_to_djurgarden.py +0 -0
  69. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_geocode.py +0 -0
  70. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/mcp/test_preview.py +0 -0
  71. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_http.py +0 -0
  72. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_nearest_within.py +0 -0
  73. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/test_output.py +0 -0
  74. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/conftest.py +0 -0
  75. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/osm_features_query.py +0 -0
  76. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/places_nearby.py +0 -0
  77. {osmfeatures-0.7.2 → osmfeatures-0.7.3}/tests/tutorial/places_search.py +0 -0
  78. {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.2
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` (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`.
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
- - `split_until_fit=True` to split a bbox that exceeds `limit` (or the key's `max_limit`)
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
- split_until_fit=True,
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`, `stats` (count/histogram)
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, max 10_000).
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}], estimated_units, evaluated_at}`.
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, estimated_units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
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` (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`.
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
- - `split_until_fit=True` to split a bbox that exceeds `limit` (or the key's `max_limit`)
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
- split_until_fit=True,
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`, `stats` (count/histogram)
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, max 10_000).
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}], estimated_units, evaluated_at}`.
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, estimated_units, evaluated_at, timezone}`. Hours are annotated at request time in the place's IANA zone (from its coordinates).
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.2"
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
- 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
+ )