lewisham-council-client
Framework-neutral Python client for Lewisham Council civic data. Provides asynchronous address resolution and waste collection schedule retrieval for any Lewisham UPRN.
No dependency on FastAPI, Home Assistant, MCP, uvicorn, or any web framework. Designed to be embedded directly in another Python application.
Unofficial — not affiliated with or endorsed by Lewisham Council. Uses undocumented public endpoints that may change without notice; the client raises
UpstreamScraperChangedErrorwhen the response shape no longer matches expectations.
Installation
pip install lewisham-council-client
Usage
import asyncio
from lewisham_client import LewishamService
async def main() -> None:
service = LewishamService()
try:
addresses = await service.lookup_addresses("SE6 1SQ")
for address in addresses:
print(address.uprn, address.title)
if addresses:
schedule = await service.get_collection_schedule(addresses[0].uprn)
for entry in schedule.collections:
print(entry.waste_type, entry.frequency, entry.next_collection)
finally:
await service.aclose()
asyncio.run(main())
LewishamService creates and manages its own httpx.AsyncClient by default.
If the host application already manages a shared httpx.AsyncClient, pass it
via LewishamClient:
import httpx
from lewisham_client import LewishamClient, LewishamService
async with httpx.AsyncClient() as http:
service = LewishamService(client=LewishamClient(http_client=http))
schedule = await service.get_collection_schedule(uprn)
Errors
All errors are subclasses of lewisham_client.DomainError:
| Exception | When |
|---|---|
InvalidAddressSearchError |
Search input is too short or malformed. |
InvalidUprnError |
UPRN is not a non-empty decimal string. |
AddressNotFoundError |
No Lewisham address found for the given UPRN. |
CollectionScheduleNotFoundError |
Lewisham returned no parseable schedule for the address. |
UpstreamScraperChangedError |
Lewisham's response shape no longer matches expectations. |
UpstreamUnavailableError |
Lewisham could not be reached (timeout or transport error). |
Diagnostics
Every DomainError carries a diagnostics: ContractDriftDiagnostics | None
attribute — useful for building a rich failure report without intercepting
log output. It is set automatically by LewishamParser/LewishamClient when
the failure was an upstream contract-drift (a parse failure or an unexpected
HTTP response) and None otherwise — e.g. for validation errors,
address-not-found errors, and cached negative results that did not
themselves re-parse a payload:
from lewisham_client import LewishamParser, LewishamService, UpstreamScraperChangedError
service = LewishamService(parser=LewishamParser(include_raw_upstream=True))
try:
schedule = await service.get_collection_schedule(uprn)
except UpstreamScraperChangedError as err:
if err.diagnostics is not None:
print(err.diagnostics.source) # "client" or "parser"
print(err.diagnostics.payload_sha256) # always populated, no PII
print(err.diagnostics.payload_preview) # parser only, opted in above
source ("client" or "parser") tells you which other fields to expect:
"client" diagnostics (an unexpected HTTP status, or a response
LewishamClient itself could not decode/shape-validate) always populate
status_code/endpoint; "parser" diagnostics (LewishamParser could not
extract entries from the decoded HTML) never do. payload_size_bytes and
payload_sha256 are always populated and never contain PII.
payload_preview (a truncated slice of the raw upstream body, which may
contain PII scraped from the page) is only populated for "parser"
diagnostics when the parser was constructed with include_raw_upstream=True
— this is independent of the logging module's level, unlike the
parser_contract_drift log event, which additionally requires the
lewisham_client.clients.lewisham.parser logger to be at DEBUG.
If a consumer wraps our exceptions in its own type — whether via explicit
raise SomeError(...) from err or a bare raise SomeError(...) inside an
except block — use find_diagnostics instead of catching the library's
exceptions directly. It walks both the __cause__ and __context__ chains
for you, preferring the more specific __cause__ when both are set:
from lewisham_client import find_diagnostics
diagnostics = find_diagnostics(caught_exception)
CollectionSchedule.data_quality() returns a DataQualitySummary — counts
of entries whose next_collection came from a council-published date vs. a
derived weekday guess vs. nothing published — for a consumer that wants to
report on schedule confidence without re-deriving it from
next_collection_basis itself:
summary = schedule.data_quality()
print(summary.published_count, summary.weekday_derived_count)
Logging
The client uses Python's standard logging package and does not configure
handlers, formatting, destinations, or process-wide levels. Cache activity,
upstream request metadata, parser diagnostics, and operation outcomes are
available at DEBUG:
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("lewisham_client").setLevel(logging.DEBUG)
Structured context is attached to each LogRecord through extra, so a host
application can render it as text or JSON. Raised DomainError exceptions do
not also produce warning or error records from the client; the host decides
how visibly to report the operation it is handling.
Caching
LewishamService uses an in-process MemoryTtlCache by default with
conservative TTLs (schedules: 24 h, address searches: 7 d, UPRNs: 30 d,
negative results: 1 h). The defaults are suitable for a single-process consumer
such as a Home Assistant integration.
Injectable cache and TTL parameters are available for consumers that need different policy or a shared cache.
Metadata
Release files for lewisham-council-client 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lewisham_council_client-0.3.0.tar.gz | 23.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lewisham_council_client-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.2 kB
Release files / lewisham_council_client-0.3.0.tar.gz
| Download URL | lewisham_council_client-0.3.0.tar.gz |
|---|---|
| Size | 23.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
226efa699b1398e3c369f5b0178ada7cbf9656c0367417217a4cdf16967fda9d
|
|
BLAKE2b-256 checksum How to use checksums |
26bcd49510bb3527e0d6c023534cff5974959d8fc16990a581e06058a2efb934
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 12, 2026.
Transparency logRelease files / lewisham_council_client-0.3.0-py3-none-any.whl
| Download URL | lewisham_council_client-0.3.0-py3-none-any.whl |
|---|---|
| Size | 22.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
151da42fa561a8c32fee60ea0323ab9eeee5d9b82900e0b02542202c730be64f
|
|
BLAKE2b-256 checksum How to use checksums |
6edbddfe6c89c7ba11e49050f01c432fa677a6ea77a02f510d0796cb8961edd4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 12, 2026.
Transparency log