Skip to content

Commit e9ebbce

Browse files
author
Nathaniel Henry
committed
Return GeoDataFrames by default and rewrite the docs for beginners
1 parent ef9a95d commit e9ebbce

16 files changed

Lines changed: 474 additions & 491 deletions

CHANGELOG.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
First public release of the `closecity` Python client for the Close API
66
(api.close.city).
77

8-
- A thin, typed `Client` over the metered, read-only public endpoints: catalog,
9-
block/point summaries and POIs, POI search/detail/catchment, areal block
10-
queries, and isochrones.
11-
- First-class metering (`tokens_charged` / `tokens_remaining`), ETag/304
12-
conditional requests, keyset pagination (`Paginator`), and typed RFC 9457
13-
errors.
14-
- `places()` place-name lookup (city/town → GEOID + centroid).
15-
- Opt-in spatial output: `Reply.to_geopandas()` / `Paginator.to_geopandas()`
16-
(POI points, isochrone polygons, and census-block joins) under the `geo` extra.
8+
- A typed `Client` over the metered, read-only public endpoints: catalog, place
9+
lookup, block and point summaries and POIs, POI search, detail, and catchment,
10+
areal block queries, and isochrones.
11+
- Feature methods return `geopandas.GeoDataFrame` objects by default. Pass
12+
`spatial=False` for the raw `Reply` / `Paginator`. Block boundaries are joined
13+
with `pygris` (the `tiger` extra).
14+
- First-class metering (`tokens_charged`, `tokens_remaining`), ETag/304 conditional
15+
requests, keyset pagination (`Paginator`), and typed RFC 9457 errors.
16+
- `places()` looks up a city or town by name and returns its GEOID and centre.

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2026 Close
3+
Copyright (c) 2026 Henry Spatial Analysis
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 47 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,84 @@
1-
# closecity — Python client for the Close API
1+
# closecity
22

3-
Travel times from every US census block to nearby points of interest, by walking,
4-
biking, and public transitthe data behind [close.city](https://close.city),
5-
over the [Close API](https://api.close.city).
3+
Python client for the Close API. Get travel times from every US census block to
4+
nearby places, on foot, by bike, and by public transit. This is the data behind
5+
[close.city](https://close.city), read over the [Close API](https://api.close.city).
66

77
**Documentation:** https://henryspatialanalysis.github.io/closecity-python/
88

9+
## Install
10+
911
```bash
1012
pip install closecity
11-
pip install "closecity[geo]" # + GeoPandas output (to_geopandas)
13+
pip install "closecity[tiger]" # to auto-download census-block boundaries
1214
```
1315

14-
## Quickstart
16+
This pulls in `httpx` and `geopandas`, so feature methods return GeoDataFrames out
17+
of the box.
1518

16-
```python
17-
from closecity import Client
19+
## A first call
1820

19-
# The API key (ck_live_… / ck_test_…) is created at https://account.close.city.
20-
with Client("ck_live_your_key_here") as close:
21-
# Fastest travel time to each category from a census block.
22-
summary = close.block_summary("410390020001010", mode = ["walk", "transit"])
23-
for row in summary.results:
24-
print(row["dest_type_id"], row["mode"], row["travel_time"])
25-
26-
# Metering is surfaced on every metered reply.
27-
print(summary.tokens_charged, "charged;", summary.tokens_remaining, "left")
28-
```
29-
30-
Free routes (catalog, place lookup, health) need no key:
21+
You make requests through a client. Feature results come back as GeoDataFrames, so
22+
you can map them right away.
3123

3224
```python
3325
from closecity import Client
34-
close = Client()
35-
print(close.modes().data["modes"])
36-
print(close.last_updated().data["last_updated"])
3726

38-
# Resolve a city name to its census place GEOID + centroid:
39-
print(close.places("Providence").data["places"][0])
40-
```
27+
# The key (ck_live_ or ck_test_) comes from https://account.close.city
28+
close = Client("ck_live_your_key") # use your own key here
4129

42-
## Pagination
30+
# Grocery stores within a 1.5 km walk of a point, as points:
31+
groceries = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500, type = 30)
32+
groceries.plot()
33+
```
4334

44-
List endpoints return a `Paginator` that follows the opaque keyset cursor for you.
45-
Iterate it for every record, or use `.pages()` for per-page metadata:
35+
Catalog and lookup routes are free and need no key:
4636

4737
```python
48-
# Every POI near a point, across all pages:
49-
for poi in close.pois_search(lat = 44.05, lon = -123.09, radius_m = 2000):
50-
print(poi["name"], poi["address"]["city"])
51-
52-
# Or page-by-page, watching the token balance drop:
53-
for page in close.block_pois("410390020001010", limit = 500).pages():
54-
print(len(page.results), "rows;", page.tokens_remaining, "tokens left")
38+
close = Client()
39+
close.modes().data["modes"] # walk, bike, transit
40+
close.places("Providence").data["places"][0] # a city name to its GEOID and centre
5541
```
5642

57-
Cursors are opaque and signed — never construct or modify them.
43+
## Words you will see
5844

59-
## Conditional requests (free revalidation)
45+
A few terms come up throughout the API:
6046

61-
Metered `GET`s return an `ETag`. Send it back to revalidate for free — a `304`
62-
costs no tokens, even at a zero balance:
47+
- **Census block.** The smallest area the Census Bureau publishes. Each one has a
48+
15-digit id called a **GEOID**.
49+
- **Destination type.** A category of place, such as grocery stores or libraries.
50+
Each type has a numeric id. Look them up with `close.destination_types()`.
51+
- **Mode.** How someone travels: walk, bike, or transit.
52+
- **Isochrone.** The area you can reach from a point within a time limit, as a
53+
polygon.
54+
- **Catchment.** The reverse of an isochrone: every block that can reach a place.
6355

64-
```python
65-
first = close.block_summary("410390020001010")
66-
again = close.block_summary("410390020001010", if_none_match = first.etag)
67-
if again.not_modified:
68-
... # your cached copy is still current; nothing was charged
69-
```
56+
## Spatial output, on or off
7057

71-
## Isochrones
58+
Feature methods return GeoDataFrames by default. Block routes join census-block
59+
boundaries for you, using `pygris` (the `tiger` extra) to download them once. To work
60+
with the raw data instead, build the client with `spatial=False`:
7261

7362
```python
74-
iso = close.isochrone(block = "410390020001010", contours = [15, 30, 45],
75-
mode = "walk", direction = "to")
76-
for feature in iso.data["features"]:
77-
print(feature["properties"]["contour"], feature["properties"]["reachable_blocks"])
63+
close = Client("ck_live_your_key", spatial = False) # use your own key here
64+
for poi in close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500):
65+
print(poi["name"])
7866
```
7967

80-
## Errors
68+
## Handling errors
8169

82-
Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `problem+json`,
83-
mapped to exceptions. Catch a precise class or the `CloseAPIError` base; the stable
84-
machine key is `err.slug`:
70+
Problem responses become typed exceptions. Catch a specific one, or the
71+
`CloseAPIError` base.
8572

8673
```python
87-
from closecity import CloseAPIError, TokensExhaustedError, RateLimitedError
74+
from closecity import TokensExhaustedError, CloseAPIError
8875

8976
try:
9077
close.block_summary("000000000000000")
9178
except TokensExhaustedError:
92-
... # buy more tokens at account.close.city
93-
except RateLimitedError as err:
94-
time.sleep(err.retry_after or 1)
79+
...
9580
except CloseAPIError as err:
96-
print(err.slug, err.status, err.title, err.request_id)
97-
```
98-
99-
## Spatial output
100-
101-
With `pip install "closecity[geo]"`, turn any reply into a GeoDataFrame — POIs
102-
become points, isochrones become polygons, and block replies join census-block
103-
boundaries:
104-
105-
```python
106-
pts = close.pois_search(lat = 41.823, lon = -71.412, radius_m = 1500).to_geopandas()
107-
iso = close.isochrone(block = "440070036001010", minutes = 15).to_geopandas()
81+
print(err.status, err.slug)
10882
```
10983

11084
## Reference
@@ -116,7 +90,7 @@ iso = close.isochrone(block = "440070036001010", minutes = 15).to_geopandas()
11690
## Development
11791

11892
```bash
119-
pip install -e '.[dev,geo]'
120-
pytest # unit tests (no network httpx MockTransport)
93+
pip install -e '.[dev]'
94+
pytest # unit tests, no network (httpx MockTransport)
12195
ruff check src tests
12296
```

docs/getting_started.rst

Lines changed: 62 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,108 @@
11
Getting started
22
===============
33

4-
This is a five-minute tour of the client. For end-to-end analyses see the
5-
:doc:`tutorials <tutorials/index>`.
4+
A short tour of the client. The tutorials go further.
65

7-
A client
8-
--------
6+
Words you will see
7+
------------------
8+
9+
A few terms come up throughout:
10+
11+
- **Census block.** The smallest area the Census Bureau publishes. Each one has a
12+
15-digit id, its **GEOID**.
13+
- **Destination type.** A category of place, such as grocery stores or libraries.
14+
Every type has a numeric id.
15+
- **Mode.** How someone travels: walk, bike, or transit.
16+
- **Isochrone.** The area reachable from a point within a time limit, as a polygon.
17+
- **Catchment.** The reverse: every block that can reach a given place.
18+
19+
Build a client
20+
--------------
21+
22+
You make every request through a client.
923

1024
.. code-block:: python
1125
1226
from closecity import Client
1327
14-
close = Client("ck_live_your_key_here") # or Client() for the free routes
28+
# The key (ck_live_ or ck_test_) comes from https://account.close.city
29+
close = Client("ck_live_your_key") # use your own key here
1530
16-
``Client`` is usable as a context manager (``with Client(...) as close:``) so the
17-
underlying HTTP connection is closed for you.
18-
19-
Free catalog (no key)
20-
---------------------
31+
The catalog and lookup routes are free, so you can also start without a key:
2132

2233
.. code-block:: python
2334
2435
close = Client()
25-
print(close.modes().data["modes"]) # walk / bike / transit
26-
print(close.destination_types().data["destination_types"][:3])
27-
print(close.last_updated().data["last_updated"])
36+
close.modes().data["modes"] # walk, bike, transit
37+
close.last_updated().data["last_updated"]
2838
29-
Use :meth:`~closecity.Client.destination_types` to find the numeric ``type`` ids the
30-
data routes filter on (e.g. grocery ``30``, restaurants ``27``, cafes ``31``,
31-
libraries ``43``, parks ``63``, frequent-transit stops ``61``).
39+
Look things up instead of guessing
40+
----------------------------------
3241

33-
One metered call
34-
----------------
42+
Two free calls save you from memorising codes. Read the numeric id for a category
43+
from the catalog, and turn a city name into a GEOID and a centre point.
3544

3645
.. code-block:: python
3746
38-
summary = close.block_summary("250173523004004", mode="walk")
39-
for row in summary.results:
40-
print(row["dest_type_id"], row["mode"], row["travel_time"])
47+
types = close.destination_types().data["destination_types"]
48+
ids = {t["label"]: t["dest_type_id"] for t in types}
49+
grocery = ids["grocery_stores"]
4150
42-
Every metered reply carries its accounting:
51+
providence = close.places("Providence").data["places"][0]
52+
providence["geoid"]
4353
44-
.. code-block:: python
54+
Make a call and map it
55+
----------------------
4556

46-
print(summary.tokens_charged, "charged;", summary.tokens_remaining, "left")
57+
Feature methods return a :class:`geopandas.GeoDataFrame`, so you can map the result
58+
straight away.
59+
60+
.. code-block:: python
4761
48-
Metering is **1 token per returned row** (minimum 1 per request); isochrones are the
49-
exception at **10 tokens per contour**. A ``304`` revalidation is free.
62+
groceries = close.pois_search(lat = providence["lat"], lon = providence["lon"],
63+
radius_m = 1500, type = grocery)
64+
groceries.plot(color = "#202a5b") # needs matplotlib
5065
51-
Pagination
52-
----------
66+
Turn spatial output off
67+
-----------------------
5368

54-
List endpoints return a :class:`~closecity.Paginator` that transparently follows the
55-
opaque cursor:
69+
Set ``spatial=False`` to work with the raw data. Then list routes return a
70+
:class:`~closecity.Paginator` you can iterate.
5671

5772
.. code-block:: python
5873
59-
pois = close.pois_search(lat=41.823, lon=-71.412, radius_m=1500, type=31)
60-
for poi in pois: # every record across all pages
61-
print(poi["name"], poi["lat"], poi["lon"])
74+
close = Client("ck_live_your_key", spatial = False) # use your own key here
75+
for poi in close.pois_search(lat = providence["lat"], lon = providence["lon"],
76+
radius_m = 1500):
77+
print(poi["name"])
6278
63-
# or page-by-page, to read per-page token metadata:
64-
for page in close.pois_search(lat=41.823, lon=-71.412, radius_m=1500).pages():
65-
print(len(page.results), page.tokens_remaining)
79+
Block methods join census-block boundaries for you, using the ``pygris`` package
80+
(``pip install "closecity[tiger]"``) to download them once.
6681

67-
Conditional requests (free revalidation)
68-
-----------------------------------------
82+
Conditional requests
83+
--------------------
84+
85+
Metered routes return an ``ETag``. Send it back to revalidate for free.
6986

7087
.. code-block:: python
7188
72-
first = close.block_summary("250173523004004")
73-
again = close.block_summary("250173523004004", if_none_match=first.etag)
74-
assert again.not_modified # 304 — no tokens charged
89+
close = Client("ck_live_your_key", spatial = False) # use your own key here
90+
first = close.block_summary("440070036001010")
91+
again = close.block_summary("440070036001010", if_none_match = first.etag)
92+
assert again.not_modified
7593
7694
Errors
7795
------
7896

79-
Problem+JSON responses become typed exceptions:
97+
Problem responses become typed exceptions.
8098

8199
.. code-block:: python
82100
83101
from closecity import TokensExhaustedError, CloseAPIError
84102
85103
try:
86-
close.block_summary("250173523004004")
104+
close.block_summary("000000000000000")
87105
except TokensExhaustedError:
88-
... # out of tokens (HTTP 429)
106+
...
89107
except CloseAPIError as err:
90108
print(err.status, err.slug)
91-
92-
Spatial output
93-
--------------
94-
95-
Turn a reply into a GeoDataFrame (needs ``pip install "closecity[geo]"``):
96-
97-
.. code-block:: python
98-
99-
pts = close.pois_search(lat=41.823, lon=-71.412, radius_m=1500).to_geopandas()
100-
iso = close.isochrone(block="250173523004004", minutes=15).to_geopandas()
101-
102-
POIs become point geometry and isochrones become polygons offline; block replies
103-
join to census-block boundaries (pass ``block_geometry=`` or ``fetch=True``).

0 commit comments

Comments
 (0)