Skip to content

Commit 5600835

Browse files
author
Nathaniel Henry
committed
Add multi-origin batch calls to the block, point, and catchment methods.
1 parent ccd08f7 commit 5600835

5 files changed

Lines changed: 220 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 1.5.0
4+
5+
- Multi-origin calls. `block_summary`, `block_pois`, `point_summary`,
6+
`point_pois`, and `poi_catchment` now accept many origins in one request:
7+
pass a list of GEOIDs (or `dest_id`s), or a list of `(lat, lon)` points, and
8+
the whole set is queried in a single call — one request against the
9+
300/minute rate limit instead of one per origin. Results come back as a flat
10+
frame tagged by origin (`geoid`, or `origin_lat` / `origin_lon`, or
11+
`dest_id`); per-origin `errors` and any `truncated` origins ride on
12+
`df.attrs`. A batch is charged only for what the account can pay for, so a
13+
huge query stops at the balance rather than overspending.
14+
315
## 1.4.0
416

517
- `Client.place_boundary(geoid)` — the boundary polygon of a census place, as a

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "closecity"
7-
version = "1.4.0"
7+
version = "1.5.0"
88
description = "Python client for the Close API (api.close.city). Travel times to points of interest for every US census block."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/closecity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .spatial import to_geopandas
2424
from .tabular import to_pandas
2525

26-
__version__ = "1.4.0"
26+
__version__ = "1.5.0"
2727

2828
__all__ = [
2929
"Client",

src/closecity/client.py

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ def _as_list(value: Any) -> Any:
4949
return [value]
5050

5151

52+
def _point_origins(points: Any) -> list[dict[str, float]]:
53+
"""Normalise a batch of point origins to ``[{"lon": .., "lat": ..}, ...]``.
54+
55+
Each item is either a ``(lat, lon)`` pair — matching the ``(lat, lon)``
56+
argument order of the single-point calls — or a ``{"lat": .., "lon": ..}``
57+
mapping.
58+
"""
59+
out: list[dict[str, float]] = []
60+
for point in points:
61+
if isinstance(point, dict):
62+
out.append({"lon": point["lon"], "lat": point["lat"]})
63+
else:
64+
lat, lon = point
65+
out.append({"lon": lon, "lat": lat})
66+
return out
67+
68+
5269
def _int(value: str | None) -> int | None:
5370
return int(value) if value is not None else None
5471

@@ -329,39 +346,78 @@ def places(self, q: str, *, limit: int | None = None, output: str | None = None)
329346
# -- origin block / point (metered) -------------------------------------
330347

331348
def block_summary(
332-
self, geoid: str, *, mode = None, type = None,
349+
self, geoid, *, mode = None, type = None,
333350
if_none_match: str | None = None, output: str | None = None,
334351
):
335352
"""Fastest travel time to each destination category from a census block,
336353
by mode. `geoid` is a 15-digit block GEOID; `mode`/`type` are optional
337354
filters (scalar or list). Supports `if_none_match` (ETag/304). A DataFrame
338-
(the origin GEOID broadcast to a `geoid` column), or a raw `Reply`."""
355+
(the origin GEOID broadcast to a `geoid` column), or a raw `Reply`.
356+
357+
Pass a **list of GEOIDs** to query many blocks in one call (one request,
358+
one rate-limit tick): the frame is the flat, `geoid`-tagged union of every
359+
origin's rows, and per-origin `errors` / `truncated` / `truncated_reason`
360+
ride on `df.attrs`. `if_none_match` applies to the single-block form only.
361+
"""
362+
if isinstance(geoid, (list, tuple)):
363+
reply = self._request("POST", "/v1/blocks/summary", json = _clean(
364+
{"origins": list(geoid), "mode": _as_list(mode),
365+
"type": _as_list(type)}))
366+
return self._deliver(reply, geometry = False, key = "results",
367+
output = output)
339368
return self._deliver(
340369
self._get(f"/v1/blocks/{geoid}/summary",
341370
{"mode": mode, "type": type}, if_none_match = if_none_match),
342371
geometry = False, key = "results", output = output,
343372
)
344373

345374
def block_pois(
346-
self, geoid: str, *, mode = None, type = None, dest_id = None,
375+
self, geoid, *, mode = None, type = None, dest_id = None,
347376
max_minutes = None, limit = None, output: str | None = None,
348377
):
349378
"""Every nearby POI and its travel time from a block, one row per (POI,
350379
mode). A GeoDataFrame of points (a plain DataFrame under
351-
`output="tabular"`, a `Paginator` under `output="raw"`)."""
380+
`output="tabular"`, a `Paginator` under `output="raw"`).
381+
382+
Pass a **list of GEOIDs** to query many blocks in one call (one request,
383+
one rate-limit tick): rows are the flat, `geoid`-tagged union of every
384+
origin's POIs, with per-origin `errors` / `truncated` / `truncated_reason`
385+
on `df.attrs`. The batch form is not paginated (`limit` is ignored)."""
386+
if isinstance(geoid, (list, tuple)):
387+
reply = self._request("POST", "/v1/blocks/pois", json = _clean(
388+
{"origins": list(geoid), "mode": _as_list(mode),
389+
"type": _as_list(type), "dest_id": _as_list(dest_id),
390+
"max_minutes": max_minutes}))
391+
return self._deliver(reply, geometry = True, output = output)
352392
return self._deliver(Paginator(
353393
self, "GET", f"/v1/blocks/{geoid}/pois",
354394
_clean({"mode": mode, "type": type, "dest_id": dest_id,
355395
"max_minutes": max_minutes, "limit": limit}),
356396
), geometry = True, output = output)
357397

358398
def point_summary(
359-
self, lat: float, lon: float, *, mode = None, type = None,
399+
self, lat, lon = None, *, mode = None, type = None,
360400
if_none_match: str | None = None, output: str | None = None,
361401
):
362402
"""Like `block_summary`, but from the census block containing a lat/lon.
363403
The resolved block GEOID is echoed as `resolved_block` and broadcast to a
364-
`geoid` column. A DataFrame, or a raw `Reply`."""
404+
`geoid` column. A DataFrame, or a raw `Reply`.
405+
406+
Pass a **list of points** as the first argument — each a `(lat, lon)` pair
407+
or a `{"lat": .., "lon": ..}` mapping — to query many points in one call.
408+
Each row is tagged with its `origin_lat` / `origin_lon`; resolved blocks,
409+
`errors`, and `truncated` ride on `df.attrs`."""
410+
if isinstance(lat, (list, tuple)):
411+
if lon is not None:
412+
raise ValueError(
413+
"For multiple points pass a list of (lat, lon) pairs as the "
414+
"first argument and leave lon unset."
415+
)
416+
reply = self._request("POST", "/v1/point/summary", json = _clean(
417+
{"origins": _point_origins(lat), "mode": _as_list(mode),
418+
"type": _as_list(type)}))
419+
return self._deliver(reply, geometry = False, key = "results",
420+
output = output)
365421
return self._deliver(
366422
self._get("/v1/point/summary",
367423
{"lat": lat, "lon": lon, "mode": mode, "type": type},
@@ -370,12 +426,28 @@ def point_summary(
370426
)
371427

372428
def point_pois(
373-
self, lat: float, lon: float, *, mode = None, type = None, dest_id = None,
429+
self, lat, lon = None, *, mode = None, type = None, dest_id = None,
374430
max_minutes = None, limit = None, output: str | None = None,
375431
):
376432
"""Like `block_pois`, but from the block containing a lat/lon. A
377433
GeoDataFrame of points (a plain DataFrame under `output="tabular"`, a
378-
`Paginator` under `output="raw"`)."""
434+
`Paginator` under `output="raw"`).
435+
436+
Pass a **list of points** as the first argument — each a `(lat, lon)` pair
437+
or a `{"lat": .., "lon": ..}` mapping — to query many points in one call.
438+
Each row is tagged with its `origin_lat` / `origin_lon`. The batch form is
439+
not paginated (`limit` is ignored)."""
440+
if isinstance(lat, (list, tuple)):
441+
if lon is not None:
442+
raise ValueError(
443+
"For multiple points pass a list of (lat, lon) pairs as the "
444+
"first argument and leave lon unset."
445+
)
446+
reply = self._request("POST", "/v1/point/pois", json = _clean(
447+
{"origins": _point_origins(lat), "mode": _as_list(mode),
448+
"type": _as_list(type), "dest_id": _as_list(dest_id),
449+
"max_minutes": max_minutes}))
450+
return self._deliver(reply, geometry = True, output = output)
379451
return self._deliver(Paginator(
380452
self, "GET", "/v1/point/pois",
381453
_clean({"lat": lat, "lon": lon, "mode": mode, "type": type,
@@ -408,12 +480,22 @@ def poi(self, dest_id: int, *, if_none_match: str | None = None,
408480
)
409481

410482
def poi_catchment(
411-
self, dest_id: int, *, mode = None, block = None, max_minutes = None,
483+
self, dest_id, *, mode = None, block = None, max_minutes = None,
412484
limit = None, output: str | None = None,
413485
):
414486
"""Every census block that can reach a POI, one row per (block, mode).
415487
A GeoDataFrame of block polygons (a plain DataFrame under
416-
`output="tabular"`, a `Paginator` under `output="raw"`)."""
488+
`output="tabular"`, a `Paginator` under `output="raw"`).
489+
490+
Pass a **list of dest_ids** to query many POIs in one call (one request,
491+
one rate-limit tick): rows are the flat, `dest_id`-tagged union of every
492+
POI's catchment, with per-POI `errors` / `truncated` on `df.attrs`. The
493+
batch form is not paginated (`limit` is ignored)."""
494+
if isinstance(dest_id, (list, tuple)):
495+
reply = self._request("POST", "/v1/pois/catchment", json = _clean(
496+
{"dest_ids": list(dest_id), "mode": _as_list(mode),
497+
"block": _as_list(block), "max_minutes": max_minutes}))
498+
return self._deliver(reply, geometry = True, output = output)
417499
return self._deliver(Paginator(
418500
self, "GET", f"/v1/pois/{dest_id}/catchment",
419501
_clean({"mode": mode, "block": block, "max_minutes": max_minutes,

tests/test_client.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,120 @@ def handler(request):
160160
assert bodies[1]["cursor"] == "C2" # cursor threaded through the body
161161

162162

163+
# -- multi-origin (batch) ----------------------------------------------------
164+
165+
def test_block_summary_list_posts_to_batch():
166+
seen = {}
167+
168+
def handler(request):
169+
seen["method"] = request.method
170+
seen["path"] = request.url.path
171+
seen["body"] = json.loads(request.content)
172+
return httpx.Response(200, json = {
173+
"results": [{"geoid": "g1", "dest_type_id": 30, "mode": "walk",
174+
"travel_time": 6.5}],
175+
"blocks": [{"geoid": "g1", "population": 10, "land_area_m2": 1.0}],
176+
"errors": [], "truncated": [], "truncated_reason": None,
177+
})
178+
179+
reply = make_client(handler).block_summary(["g1", "g2"], mode = "walk", type = 30)
180+
assert seen["method"] == "POST"
181+
assert seen["path"] == "/v1/blocks/summary"
182+
assert seen["body"]["origins"] == ["g1", "g2"]
183+
# Scalar mode/type get wrapped to arrays for the POST body.
184+
assert seen["body"]["mode"] == ["walk"] and seen["body"]["type"] == [30]
185+
assert reply.data["results"][0]["geoid"] == "g1"
186+
187+
188+
def test_block_summary_scalar_still_gets():
189+
seen = {}
190+
191+
def handler(request):
192+
seen["method"] = request.method
193+
seen["path"] = request.url.path
194+
return httpx.Response(200, json = {"block": {"geoid": "g", "population": None,
195+
"land_area_m2": None}, "results": []})
196+
197+
make_client(handler).block_summary("410390020001010")
198+
assert seen["method"] == "GET"
199+
assert seen["path"] == "/v1/blocks/410390020001010/summary"
200+
201+
202+
def test_block_pois_list_posts_to_batch():
203+
seen = {}
204+
205+
def handler(request):
206+
seen["method"] = request.method
207+
seen["path"] = request.url.path
208+
seen["body"] = json.loads(request.content)
209+
return httpx.Response(200, json = {"results": [], "errors": [],
210+
"truncated": [], "truncated_reason": None})
211+
212+
make_client(handler).block_pois(["g1", "g2"], type = 30, max_minutes = 10)
213+
assert seen["method"] == "POST"
214+
assert seen["path"] == "/v1/blocks/pois"
215+
assert seen["body"]["origins"] == ["g1", "g2"]
216+
assert seen["body"]["type"] == [30] and seen["body"]["max_minutes"] == 10
217+
218+
219+
def test_point_summary_list_posts_origins_lonlat():
220+
seen = {}
221+
222+
def handler(request):
223+
seen["path"] = request.url.path
224+
seen["body"] = json.loads(request.content)
225+
return httpx.Response(200, json = {"results": [], "origins": [],
226+
"errors": [], "truncated": [], "truncated_reason": None})
227+
228+
# A (lat, lon) pair and a {lat, lon} mapping both normalise to {lon, lat}.
229+
make_client(handler).point_summary([(44.0, -123.0), {"lat": 45.0, "lon": -122.0}])
230+
assert seen["path"] == "/v1/point/summary"
231+
assert seen["body"]["origins"] == [
232+
{"lon": -123.0, "lat": 44.0}, {"lon": -122.0, "lat": 45.0}]
233+
234+
235+
def test_point_pois_list_with_lon_raises():
236+
with pytest.raises(ValueError, match = "list of \\(lat, lon\\) pairs"):
237+
make_client(lambda r: httpx.Response(200)).point_pois([(44.0, -123.0)], -1.0)
238+
239+
240+
def test_poi_catchment_list_posts_dest_ids():
241+
seen = {}
242+
243+
def handler(request):
244+
seen["path"] = request.url.path
245+
seen["body"] = json.loads(request.content)
246+
return httpx.Response(200, json = {"results": [], "errors": [],
247+
"truncated": [], "truncated_reason": None})
248+
249+
make_client(handler).poi_catchment([4181, 4182], mode = "walk")
250+
assert seen["path"] == "/v1/pois/catchment"
251+
assert seen["body"]["dest_ids"] == [4181, 4182]
252+
assert seen["body"]["mode"] == ["walk"]
253+
254+
255+
def test_batch_tabular_frame_carries_origin_and_attrs():
256+
def handler(request):
257+
return httpx.Response(200, json = {
258+
"results": [{"geoid": "g1", "dest_type_id": 30, "mode": "walk",
259+
"travel_time": 6.5},
260+
{"geoid": "g2", "dest_type_id": 30, "mode": "walk",
261+
"travel_time": 9.0}],
262+
"blocks": [], "errors": [{"geoid": "gX", "type": "block-not-found",
263+
"detail": "no"}],
264+
"truncated": ["g9"], "truncated_reason": "row-budget",
265+
})
266+
267+
client = Client("ck_live_abc", base_url = "https://api.close.city",
268+
output = "tabular", transport = httpx.MockTransport(handler))
269+
df = client.block_summary(["g1", "g2", "gX", "g9"])
270+
assert list(df["geoid"]) == ["g1", "g2"]
271+
# Per-origin errors / truncation ride on df.attrs.
272+
assert df.attrs["truncated"] == ["g9"]
273+
assert df.attrs["truncated_reason"] == "row-budget"
274+
assert df.attrs["errors"][0]["type"] == "block-not-found"
275+
276+
163277
# -- conditional requests ----------------------------------------------------
164278

165279
def test_if_none_match_304_is_free_and_not_modified():

0 commit comments

Comments
 (0)