Metadata-Version: 2.4
Name: stapel-geo
Version: 0.4.0
Summary: Geohash proximity search and geocoding for the Stapel framework — no GDAL, no PostGIS
License: MIT
Project-URL: Homepage, https://github.com/usestapel/stapel-geo
Project-URL: Repository, https://github.com/usestapel/stapel-geo
Project-URL: Documentation, https://github.com/usestapel/stapel-geo#readme
Project-URL: Changelog, https://github.com/usestapel/stapel-geo/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/usestapel/stapel-geo/issues
Keywords: django,stapel,geo,geohash,geocoding,nearby,redis
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: stapel-core<1.0,>=0.41.0
Requires-Dist: django-treenode>=0.20
Requires-Dist: pygeohash>=2
Requires-Dist: requests>=2.28
Provides-Extra: redis
Requires-Dist: redis>=4.2; extra == "redis"
Provides-Extra: all
Requires-Dist: stapel-geo[redis]; extra == "all"
Dynamic: license-file

<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->

# stapel-geo

[![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-geo/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-geo/actions/workflows/ci.yml?query=branch%3Amain)
[![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-geo?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-geo)
[![pypi](https://img.shields.io/pypi/v/stapel-geo?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-geo/)
[![downloads](https://static.pepy.tech/badge/stapel-geo/month)](https://pepy.tech/project/stapel-geo)
[![python](https://img.shields.io/pypi/pyversions/stapel-geo?logo=python&logoColor=white)](https://pypi.org/project/stapel-geo/)
[![license](https://img.shields.io/github/license/usestapel/stapel-geo)](https://github.com/usestapel/stapel-geo/blob/main/LICENSE)
[![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-geo/blob/main/docs/llms.txt)

> Geohash proximity search and geocoding, no GDAL/PostGIS/spatial database: a hierarchical location tree (flat lat/lon points with an auto-encoded geohash and a stable cross-service UUID), a proximity search facade (nearby/radius/bbox) behind one swappable backend, and a geocoder proxy (forward/structured/reverse) behind a provider merge-registry, throttled, cached and spend-ledgered per call.

Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.

## Install

```bash
pip install stapel-geo
```

## At a glance

| Fact | Value |
|---|---|
| Version | `0.4.0` |
| Python | `>=3.11` (3.11, 3.12, 3.13, 3.14) |
| HTTP operations | 16 |
| Config axes | 2 |
| Usage surface | 21 |
| Extension points | 4 |
| Error codes | 50 |
| Documented flows | 5 |
| Fleet dependencies | [`stapel-core`](https://github.com/usestapel/stapel-core) |

## Documentation

**Flows:** [English](https://github.com/usestapel/stapel-geo/blob/main/docs/flows/en/README.md) · **Errors:** [English](https://github.com/usestapel/stapel-geo/blob/main/docs/errors.en.md) · [Русский](https://github.com/usestapel/stapel-geo/blob/main/docs/errors.ru.md) · [OpenAPI](https://github.com/usestapel/stapel-geo/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-geo/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-geo/blob/main/docs/llms.txt)

## What this is

- **Location tree** — hierarchical reference places (`django-treenode`):
  flat lat/lon points with an auto-encoded, indexed geohash and a stable
  cross-service UUID. No polygons.
- **Proximity search facade** — `nearby` (top-K) / `radius` (membership) /
  `bbox` (viewport, antimeridian-aware) behind one swappable backend key.
  The default runs on your primary database via geohash prefix expansion
  (correct across the equator, the antimeridian and the poles, ranked by
  exact haversine); a Redis `GEOSEARCH` side-index backend ships for the
  hot set; Elasticsearch/Solr are named stubs.
- **Geocoder proxy** — forward / structured / reverse / resolve behind a
  provider merge-registry (`photon` self-hosted default, `nominatim`
  keyless dev/fallback, `google`/`yandex` key-gated stubs), throttled,
  cached (30-day TTL) and spend-ledgered per call. Forward search takes
  the map's own narrowings: a hard `bbox` and a soft viewport bias.
- **The location picker's server half** — because a place is chosen by a
  *human*, not typed as two decimals:
  - every feature carries `properties.formatted`, the display line, in
    the country's own postal order;
  - `geocoding/resolve?lat=&lon=` turns one coordinate pair into a
    **confirmable place** (label, components, geohash, alternatives) in
    one round trip — the whole server side of "detect my position" and
    of a dropped map pin;
  - `map/config` (public) hands the frontend its tile template, **the
    attribution the ODbL licence obliges the map to display**, the zoom
    envelope, the operating bbox and the debounce discipline.

  The React pair builds against `docs/frontend-contract.md`.
- **comm surface** — `geo.nearby` / `geo.radius` / `geo.bbox` /
  `geo.geohash_encode` / `geo.resolve` / `geo.geocode` /
  `geo.reverse_geocode` / `geo.map_config`: consumers (listings,
  calendar) query geo by name, never importing it.

## Quick start

```bash
pip install "stapel-geo[redis]"  # + the Redis search backend
```

```python
INSTALLED_APPS = [
    # ...
    "stapel_geo",
]

# urls.py — the canonical versioned surface /geo/api/v1/...
path("geo/", include("stapel_geo.urls"))
# ... or mount only the geocoder proxy:
path("geo/api/v1/geocoding/", include("stapel_geo.geocoding.urls"))
```

Plain `manage.py migrate` — any Django database backend works.

## HTTP surface (`/geo/api/v1/`)

| Route | What |
|---|---|
| `locations/` | List roots / search by name (`?search=`) |
| `locations/{id-or-uuid}/` | Location detail (lat/lon/geohash, tree parent) |
| `locations/countries/` | Root level of the tree |
| `locations/by-parent/{id}/` | Children of a node |
| `locations/nearby-by-coords/?lat=&lon=` | Top-K nearest (exact `distance_km`) |
| `locations/nearby-by-geohash/?geohash=` | Same, geohash input |
| `locations/validate-uuid/{uuid}/` | Cross-service reference check |
| `geocoding/search?q=` | Forward geocoding, `?bbox=` + `?bias_lat=&bias_lon=` (guarded + throttled) |
| `geocoding/structured?city=&street=` | Structured address search |
| `geocoding/reverse?lat=&lon=` | Reverse geocoding (raw candidates) |
| `geocoding/resolve?lat=&lon=` | **One coordinate pair → one confirmable place** |
| `map/config` | Basemap + picker configuration (**public**) |

## Settings (`STAPEL_GEO`)

| Key | Default | Meaning |
|---|---|---|
| `SEARCH_BACKEND` | `…search.postgres.PostgresGeoSearchBackend` | Search engine behind nearby/radius/bbox (dotted path). |
| `REDIS_URL` / `REDIS_GEO_KEY` | `redis://localhost:6379/0` / `stapel:geo:locations` | Redis backend connection + side-index key. |
| `GEOHASH_PRECISION` | `8` | Stored geohash precision (1-12 chars). |
| `NEARBY_PRECISION` | `6` | Default precision for coordinate nearby search. |
| `NEARBY_LIMIT` / `NEARBY_MAX_LIMIT` | `10` / `50` | Default / max search results. |
| `GEOCODER` | `"photon"` | Default geocoder **name** (registry key). |
| `GEOCODERS` | `{}` | Extra providers, merged over the built-ins (`None` removes). |
| `PHOTON_URL` | `http://localhost:2322` | Photon instance the default provider proxies. |
| `PHOTON_LANGUAGES` | `[default,en,de,fr]` | What the Photon index **actually carries** — not a preference list. Photon 400s on anything else. |
| `PHOTON_LANGUAGE_FALLBACK` | `"default"` | Where an unindexed language clamps. `default` = Photon's local-name mode (Russian in Russia). |
| `NOMINATIM_URL` | `https://nominatim.openstreetmap.org` | Nominatim base (public: 1 rps, dev/fallback). |
| `GEOCODER_TIMEOUT` | `10` | Geocoder HTTP timeout (s). |
| `GEOCODER_THROTTLE` / `GEOCODER_ANON_THROTTLE` | `30/min` / `10/min` | Scoped throttle rates (identified / anonymous). |
| `GEOCODER_PERMISSIONS` | `[IsNotAnonymousUser]` | Guard of the proxy verbs. Set to `AllowAny` for a public address search. |
| `GEOCODE_CACHE_POLICY` | `…geocoding.cache.LedgerCachePolicy` | Cache seam (dotted path). |
| `GEOCODE_CACHE_TTL_DAYS` | `30` | Default cache TTL. |
| `ADDRESS_FORMATTER` | `…geocoding.format.format_address` | Builds `properties.formatted` (seam). |
| `MAP_TILE_URL` / `MAP_TILE_ATTRIBUTION_*` | OSM public tiles / OSM credit | Basemap and its **mandatory** attribution. The default tile server is a dev default (`W007`). |
| `MAP_BBOX` | `None` | The product's operating area; also the default hard restriction on forward geocoding. |
| `MAP_*` (zoom, centre, debounce) | see `CONFIG.MD` | The rest of the picker's configuration. |

> **Sending `lang`?** Send `default`, or nothing. `PHOTON_LANGUAGES` is
> what the index on disk carries, and Photon refuses anything else with
> HTTP 400 rather than degrading. Requests for an unindexed language
> clamp to `PHOTON_LANGUAGE_FALLBACK` (`"default"` = the local name on
> the map, which for a single-country product is already the right
> language), and the response's `lang` field tells you what was really
> used. To index another language for real, build the Photon database
> from the JSON dump with `photon.jar import -languages …`; listing it
> here **without** rebuilding turns every request into a 502.
> `manage.py check` says all of this (`stapel_geo.W005`/`W006`).

## comm Functions

```python
from stapel_core.comm import call

call("geo.nearby", {"lat": 49.61, "lon": 6.13, "limit": 5})
call("geo.radius", {"lat": 49.61, "lon": 6.13, "radius_km": 25})
call("geo.bbox", {"min_lat": 49, "min_lon": 5, "max_lat": 50, "max_lon": 7})
call("geo.geohash_encode", {"lat": 49.61, "lon": 6.13})   # -> {"geohash": ...}
call("geo.resolve", {"uuid": "<location-uuid>"})
```

`min_lon > max_lon` in `geo.bbox` means the box crosses the antimeridian.

## Swapping the search backend

```python
STAPEL_GEO = {"SEARCH_BACKEND": "stapel_geo.search.redis.RedisGeoSearchBackend"}
```

The Redis backend is a **side index**: the primary DB stays the source of
truth; `post_save`/`post_delete` keep it in sync and
`RedisGeoSearchBackend().rebuild()` re-indexes from scratch. Implement
`stapel_geo.search.base.GeoSearchBackend` (three verbs) to bring your own
engine — see `MODULE.md`.

## Extension points

See [MODULE.md](https://github.com/usestapel/stapel-geo/blob/main/MODULE.md) — the agent-facing map of every fork-free seam, and
[CHANGELOG.md](https://github.com/usestapel/stapel-geo/blob/main/CHANGELOG.md) — including what 0.3.0 removed and why.

## License

MIT — see [LICENSE](https://github.com/usestapel/stapel-geo/blob/main/LICENSE).

---

<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
