Skip to content

Commit 06a45c6

Browse files
author
Nathaniel Henry
committed
Read the API key from CLOSECITY_KEY, ship the py.typed marker, and cut 1.2.0
1 parent 4326963 commit 06a45c6

7 files changed

Lines changed: 101 additions & 6 deletions

File tree

CHANGELOG.md

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

3+
## 1.2.0
4+
5+
- `Client()` reads the `CLOSECITY_KEY` environment variable when no `api_key` is
6+
passed, so scripts and agents can authenticate without hardcoding the key. A
7+
401 with no key set now carries an actionable hint pointing at `CLOSECITY_KEY`
8+
and the free-signup page (`err.hint`).
9+
- Ship the PEP 561 `py.typed` marker so downstream type checkers (mypy, pyright)
10+
see the inline type hints.
11+
- Document that the client does not retry: on `RateLimitedError` /
12+
`ServiceUnavailableError`, wait `err.retry_after` seconds and retry yourself.
13+
314
## 1.1.0
415

516
Tabular results by default, across every route.

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ GeoDataFrames, so you can map them right away.
2525
```python
2626
from closecity import Client
2727

28-
# The key (ck_live_) comes from https://account.close.city
28+
# The key (ck_live_) comes from https://account.close.city (5,000 free tokens
29+
# on signup, no card). You can also set the CLOSECITY_KEY environment variable
30+
# and call Client() with no argument.
2931
close = Client("ck_live_your_key") # use your own key here
3032

3133
# Grocery stores within a 1.5 km walk of a point, as points:
@@ -88,6 +90,10 @@ except CloseAPIError as err:
8890
print(err.status, err.slug)
8991
```
9092

93+
The client does not retry automatically. On a `RateLimitedError` or
94+
`ServiceUnavailableError`, wait `err.retry_after` seconds (from the
95+
`Retry-After` header) and retry the request yourself.
96+
9197
## Reference
9298

9399
- Documentation: https://henryspatialanalysis.github.io/closecity-python/

pyproject.toml

Lines changed: 5 additions & 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.1.0"
7+
version = "1.2.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"
@@ -40,6 +40,10 @@ docs = ["sphinx>=7", "furo", "sphinx-copybutton", "myst-nb", "jupytext",
4040
[tool.setuptools.packages.find]
4141
where = ["src"]
4242

43+
[tool.setuptools.package-data]
44+
# Ship the PEP 561 marker so downstream mypy/pyright see the inline type hints.
45+
closecity = ["py.typed"]
46+
4347
[tool.ruff]
4448
line-length = 90
4549
target-version = "py39"

src/closecity/client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import os
1112
from collections.abc import Iterator, Sequence
1213
from dataclasses import dataclass, field
1314
from typing import Any
@@ -166,7 +167,9 @@ class Client:
166167
167168
``api_key`` is optional (the catalog and health routes are free), but every
168169
data route needs one (a ``ck_live_`` key), created at
169-
https://account.close.city. Usable as a context manager.
170+
https://account.close.city (5,000 free tokens on signup, no card). When
171+
``api_key`` is not given, the ``CLOSECITY_KEY`` environment variable is used
172+
if set. Usable as a context manager.
170173
171174
``output`` sets how results come back, and defaults to ``"spatial"``:
172175
@@ -192,6 +195,9 @@ def __init__(
192195
http_client: httpx.Client | None = None,
193196
transport: httpx.BaseTransport | None = None,
194197
):
198+
if api_key is None:
199+
api_key = os.getenv("CLOSECITY_KEY") or None
200+
self._has_key = bool(api_key)
195201
self.output = _check_output(output)
196202
headers = {"Accept": "application/json"}
197203
if api_key:
@@ -257,9 +263,16 @@ def _request(
257263
body = resp.json() if resp.content else {}
258264
if not isinstance(body, dict):
259265
body = {"title": str(body)}
266+
hint = None
267+
if resp.status_code == 401 and not self._has_key:
268+
hint = (
269+
"No API key set. Pass Client(api_key=...) or set the "
270+
"CLOSECITY_KEY environment variable. Create a free key "
271+
"(5,000 tokens, no card) at https://account.close.city."
272+
)
260273
raise errors.error_from_problem(
261274
resp.status_code, body, request_id,
262-
_retry_after(resp.headers.get("Retry-After")),
275+
_retry_after(resp.headers.get("Retry-After")), hint,
263276
)
264277
return Reply(
265278
data = resp.json() if resp.content else None,

src/closecity/errors.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ class CloseAPIError(CloseError):
2121
2222
Attributes mirror the problem document: ``slug`` (the stable machine key),
2323
``title``, ``status``, ``detail``, plus ``request_id`` (from ``X-Request-Id``,
24-
quote it in support requests), ``retry_after`` when present, and ``extras``
25-
for any additional problem members (e.g. validation ``errors``).
24+
quote it in support requests), ``retry_after`` when present, ``extras`` for
25+
any additional problem members (e.g. validation ``errors``), and ``hint``, a
26+
client-side note appended to the message (e.g. "set CLOSECITY_KEY").
2627
"""
2728

2829
def __init__(
@@ -35,6 +36,7 @@ def __init__(
3536
request_id: str | None = None,
3637
retry_after: float | None = None,
3738
extras: dict[str, Any] | None = None,
39+
hint: str | None = None,
3840
):
3941
self.status = status
4042
self.slug = slug
@@ -43,9 +45,12 @@ def __init__(
4345
self.request_id = request_id
4446
self.retry_after = retry_after
4547
self.extras = extras or {}
48+
self.hint = hint
4649
message = f"{status} {slug}: {title}"
4750
if detail:
4851
message += f": {detail}"
52+
if hint:
53+
message += f" — {hint}"
4954
if request_id:
5055
message += f" (request {request_id})"
5156
super().__init__(message)
@@ -103,6 +108,7 @@ def error_from_problem(
103108
body: dict[str, Any],
104109
request_id: str | None,
105110
retry_after: float | None,
111+
hint: str | None = None,
106112
) -> CloseAPIError:
107113
"""Build the most specific exception for a problem+json body."""
108114

@@ -121,4 +127,5 @@ def error_from_problem(
121127
request_id = request_id,
122128
retry_after = retry_after,
123129
extras = extras,
130+
hint = hint,
124131
)

src/closecity/py.typed

Whitespace-only changes.

tests/test_client.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,57 @@ def handler(request):
234234
make_client(handler).isochrone(block = "410390020001010",
235235
contours = [15, 30, 45])
236236
assert seen["contours"] == "15,30,45"
237+
238+
239+
# -- api key sourcing --------------------------------------------------------
240+
241+
def test_api_key_read_from_environment(monkeypatch):
242+
monkeypatch.setenv("CLOSECITY_KEY", "ck_live_env")
243+
seen = {}
244+
245+
def handler(request):
246+
seen["auth"] = request.headers.get("authorization")
247+
return httpx.Response(200, json = {"modes": []})
248+
249+
Client(base_url = "https://api.close.city", output = "raw",
250+
transport = httpx.MockTransport(handler)).modes()
251+
assert seen["auth"] == "Bearer ck_live_env"
252+
253+
254+
def test_explicit_key_overrides_environment(monkeypatch):
255+
monkeypatch.setenv("CLOSECITY_KEY", "ck_live_env")
256+
seen = {}
257+
258+
def handler(request):
259+
seen["auth"] = request.headers.get("authorization")
260+
return httpx.Response(200, json = {"modes": []})
261+
262+
Client("ck_live_explicit", base_url = "https://api.close.city",
263+
output = "raw", transport = httpx.MockTransport(handler)).modes()
264+
assert seen["auth"] == "Bearer ck_live_explicit"
265+
266+
267+
def test_missing_key_401_adds_actionable_hint(monkeypatch):
268+
monkeypatch.delenv("CLOSECITY_KEY", raising = False)
269+
270+
def handler(request):
271+
return problem(401, "missing-key", "Provide an API key.")
272+
273+
client = Client(base_url = "https://api.close.city", output = "raw",
274+
transport = httpx.MockTransport(handler))
275+
with pytest.raises(AuthenticationError) as ei:
276+
client.block_summary("410390020001010")
277+
assert ei.value.hint is not None
278+
assert "CLOSECITY_KEY" in ei.value.hint
279+
assert "CLOSECITY_KEY" in str(ei.value)
280+
281+
282+
def test_invalid_key_401_has_no_missing_key_hint():
283+
# A key IS set (make_client passes one), so the "you forgot your key" hint
284+
# must not fire on an invalid-key 401.
285+
def handler(request):
286+
return problem(401, "invalid-key", "Unknown or revoked API key.")
287+
288+
with pytest.raises(AuthenticationError) as ei:
289+
make_client(handler).block_summary("410390020001010")
290+
assert ei.value.hint is None

0 commit comments

Comments
 (0)