Metadata-Version: 2.5
Name: nspot-geo-engine
Version: 0.2.0
Summary: Region membership for your own PostGIS tables: install the geo schema, register tables, install region packs, query by region.
Project-URL: Homepage, https://github.com/NSpot-Games/geo-engine/tree/main/packages/sdk-python#readme
Project-URL: Repository, https://github.com/NSpot-Games/geo-engine
Project-URL: Issues, https://github.com/NSpot-Games/geo-engine/issues
Author: NSpot Games
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: psycopg[binary]>=3.2
Description-Content-Type: text/markdown

# nspot-geo-engine (Python)

Region membership for your own PostGIS tables, as a library. `nspot-geo-engine` (import `geo_engine`) installs the `geo`
schema into any Postgres 14+ database with PostGIS, registers any table that has a row id and
a geometry (or a lng/lat pair) so it gets a maintained region-membership cache, installs
portable "region pack" JSON documents (countries + regions you can move between databases),
and gives you typed query helpers plus a `geo-engine` CLI for all of the above.

It is a port of [`@nspot/geo-engine`](../sdk-node/README.md), the Node SDK, on psycopg 3:
same schema, same SQL, same error codes, same CLI. The two are interchangeable — a database
migrated by one is a database the other can use.

## Install

```bash
pip install nspot-geo-engine
```

or, with uv:

```bash
uv add nspot-geo-engine
```

Python 3.12 or newer. `psycopg[binary]>=3.2` comes with it: every function here takes a
`psycopg.Connection` as its first argument and you own the connection.

On a non-autocommit connection, a failed call leaves the connection in psycopg's aborted
state (`InFailedSqlTransaction`): every subsequent statement raises `current transaction is
aborted, commands ignored until end of transaction block` until you call `conn.rollback()`.
`migrate` is the exception — it wraps each migration file in its own `with
conn.transaction():`, a savepoint on a connection that is already inside a transaction, so a
failed migration rolls back to that savepoint and the connection stays usable.

## Requirements

PostgreSQL 14+ with PostGIS installed (any schema — Supabase's `extensions`, for example).
While `migrate` applies the migrations, the connecting role's `search_path` must include the
PostGIS schema; after that, every `geo` function pins its own `search_path`, so only
`geometry` expressions you pass to `register_source` need to qualify any non-PostGIS
functions they call. A fresh install also refuses to run if certain names already exist in
`public` (`find_reserved_public_objects(conn)` checks this ahead of time). See
[Prerequisites and reserved names](#prerequisites-and-reserved-names) below.

## Quick start

**1. Apply the schema.**

```python
import os
import psycopg
from geo_engine import migrate

conn = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
migrate(conn)
```

Every function in this package uses the connection exactly as you hand it over. On an
**autocommit** connection each call commits on its own, which is what the examples above and
below assume. On a plain (non-autocommit) connection every call runs inside the ambient
transaction instead and **you** must `conn.commit()` — nothing here commits behind your back,
and nothing here opens a transaction of its own except `migrate`, which wraps each migration
file in `with conn.transaction():` (a real transaction on an autocommit connection, a
savepoint inside yours otherwise). `schema_version(conn)` returns the number of migrations
already applied, without applying any.

**2. Register one of your own tables.**

```python
from geo_engine import register_source

register_source(
    conn,
    name="hotels",
    table="public.hotels",
    id_column="id",
    geometry=("lng", "lat"),
)
```

`geometry` is either a geometry column name (`geometry="geom"`) or an `(lng, lat)` pair of
column names to build a point from. `table` may be `"hotels"` (schema defaults to `public`)
or `"public.hotels"`.

This installs a row trigger that keeps `hotels` rows matched against every region as rows or
regions change, and computes the initial memberships.

**3. Install a region pack.**

```python
from geo_engine import install_pack

install_pack(conn, "npm:@nspot/geo-pack-romania@1.0.0")
```

`install_pack` accepts an `npm:<package>@<version>` spec, an `http(s)` URL, a file path, or
an already-parsed pack document (any mapping):

```python
import json
from pathlib import Path

install_pack(conn, "./romania-1.0.0.json")  # a file path

doc = json.loads(Path("romania-1.0.0.json").read_text("utf-8"))
install_pack(conn, doc)  # an already-parsed document
```

The packs in this repository are published to npm, not to PyPI, and every public npm
package is served by the jsDelivr CDN. `npm:@nspot/geo-pack-romania@1.0.0` is therefore just
a short form of

```python
install_pack(
    conn, "https://cdn.jsdelivr.net/npm/@nspot/geo-pack-romania@1.0.0/pack.json"
)
```

(`npm_pack_url(spec)` gives you that URL). The version is required so that an install is
reproducible; there is no `latest`. Unlike the Node SDK there is no bare package-name source
resolved from `node_modules`, because a Python host has none. A download is aborted after
30 seconds; pass `timeout=<seconds>` to change that.

`export_pack(conn, slug, version)`, `list_packs(conn)` and `uninstall_pack(conn, slug)` are
the rest of the pack surface, and `validate_pack(doc)` validates and normalises a document
without touching the database. `REGION_TYPES` is the `("admin", "cultural", "natural",
"custom")` tuple a region's `type` must be one of.

**4. Filter your own query by region.**

`in_regions` and `not_in_regions` return a `Fragment` — a `text` with psycopg `%s`
placeholders and the `params` that fill them, in order — for you to splice into your own
statement:

```python
from geo_engine import in_regions

frag = in_regions(
    "hotels",
    ["transilvania"],
    column="h.id",
    id_type="int",
    exclude=["sibiu"],
)

with conn.cursor() as cur:
    cur.execute(
        f"SELECT h.* FROM hotels h WHERE h.price < %s AND {frag.text}",
        [100, *frag.params],
    )
    rows = cur.fetchall()
```

There is no `offset` option, and none is needed: psycopg's placeholders are positional `%s`,
not numbered `$1`, so a fragment composes anywhere in the statement. The only rule is that
`frag.params` go into the parameter list **in the position where `frag.text` was spliced** —
here the host's own `%s` comes first, so `100` comes before `*frag.params`.

Because the whole statement is one `%`-formatted string, a literal percent sign anywhere in
it — including inside `column` — must be doubled: write `column='t."rate%%"'` for a column
actually named `rate%`. psycopg turns `%%` back into a single `%`.

`not_in_regions` gives you rows in none of the listed regions, including rows in no region at
all. It builds a SQL `NOT IN`, which is null-propagating: if the `column` you give it can be
NULL — a LEFT JOINed id, for example — the predicate is NULL rather than true for that row
and the row does not come back. Add an explicit `OR <column> IS NULL` (in parentheses with
the fragment) when you want those rows too.

**Trust model for `column`.** `column` (and `id_type`) are interpolated into the fragment's
SQL verbatim: they are raw SQL authored by you, not values. Never build them from request
input — use a literal, or pick from a fixed list in your own code. Only `source`, the region
slugs and the exclusions travel as bound parameters. `id_type` is additionally checked
against a plain-identifier pattern and rejected with
`GeoEngineError("invalid_input", …)` when it is anything else.

**5. Look up the regions covering one row.**

```python
from geo_engine import ids, regions_at, regions_of

regions_of(conn, "hotels", 42)  # regions covering one row, admin first
ids(conn, "hotels", ["transilvania"], match="all", exclude=["sibiu"])
regions_at(conn, 24.15, 45.79)  # regions covering a point, admin first
```

`ids` returns the external ids as text, without your writing any SQL; `match` is `"any"`
(default) or `"all"`.

Sources have their own helpers too: `list_sources(conn)`, `rebuild_memberships(conn, name)`
(or no name for every source) and `unregister_source(conn, name)`.

## CLI

The package ships a `geo-engine` console script (`uv run geo-engine …`, or just `geo-engine …`
once the package is installed in the active environment):

```
usage: geo-engine [--url URL] <command> [options]

  check                                   list objects in public that would block a fresh install
  migrate                                 apply pending migrations
  register --name N --table [S.]T --id C (--geometry G | --lng X --lat Y)
  unregister --name N
  rebuild [--source N]                    recompute memberships (one source or all)
  pack install <url|file|npm:package@version>
  pack list
  pack uninstall <slug>

Connection: DATABASE_URL or --url.
```

Every command reads `DATABASE_URL` from the environment, or takes an explicit `--url` (before
or after the subcommand). It connects with `autocommit=True`. Exit codes: `0` success, `1` a
failed command (the message goes to stderr) — and `check` also exits `1` when it found
conflicting names — `2` a usage error (unknown command or flag, a missing required
flag, or no database URL at all).

## Errors

Every failure raised by this package is a `GeoEngineError` (a plain `Exception` subclass) —
with no exceptions: validation done before the database is reached raises one too. Each
carries a stable `code`, plus the Postgres `sqlstate` and `detail` when the failure came from
the database, and `__cause__` when it wraps something else (a `psycopg.Error`, a download
failure, an OS error):

| code             | when                                                                                                 |
| ---------------- | ---------------------------------------------------------------------------------------------------- |
| `invalid_input`  | Postgres rejected a value (`22023`) — e.g. a malformed geometry or invalid enum value                 |
| `invalid_input`  | `id_type` is not a plain SQL type name (`in_regions` / `not_in_regions`), raised before any SQL is built |
| `invalid_input`  | `regions` is empty in `in_regions` / `not_in_regions` / `ids`, raised before the database is touched  |
| `invalid_input`  | `match` is neither `"any"` nor `"all"`                                                                |
| `invalid_input`  | `install_pack` could not download, read or JSON-parse the pack (`__cause__` is the underlying error)  |
| `invalid_input`  | the pack document failed validation — `Invalid pack document: <path>: <issue>`                       |
| `invalid_input`  | `migrate` found a database whose schema is newer than the one this package ships                     |
| `invalid_input`  | `migrate` found reserved names already in `public` (the refusal message lists them)                  |
| `invalid_input`  | `register_source` got a `table` that is not `name` or `schema.name`                                  |
| `unknown_source` | the request named a source that isn't registered                                                     |
| `conflict`       | a unique-key violation (`23505`) — e.g. registering a name or pack slug that already exists          |
| `invalid_source` | the registered table or column doesn't exist (`42P01` / `42703`)                                     |
| `database`       | any other Postgres error                                                                             |

## Prerequisites and reserved names

A fresh install runs migrations `001`/`002` in `public` and `003` then moves
`public.region_type`, `public.countries` and `public.regions` into `geo`, dropping six helper
functions on the way. If any of those names already exists in `public`, `migrate` stops
before applying any migration:

```
Refusing to install: these objects already exist in schema public and would be taken over by
the geo-engine migrations: regions, set_updated_at. Rename or drop them, or install
geo-engine into a separate database.
```

Rename or drop the conflicting objects, or give geo-engine its own database.
`find_reserved_public_objects(conn)` (and `geo-engine check`) runs the same check on demand.

## The geometry-expression trust model

The geometry expression built from what you pass to `register_source` (a raw geometry column
name, or the lng/lat pair used to build a point) is stored in `geo.sources` and evaluated
later, inside the membership triggers and `rebuild_memberships`, with the privileges of
whichever role is writing rows or editing regions at that moment. Registering a source is as
powerful as writing a trigger by hand — only let database administrators call
`register_source` / `unregister_source` (the underlying `geo.register_source` /
`geo.unregister_source` are not executable by PUBLIC).
