diff --git a/.dockerignore b/.dockerignore
index 6e75e67..897af85 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,3 +3,7 @@
**/*.pyc
.venv
tests
+**/build
+**/dist
+**/*.egg-info
+.idea
diff --git a/.gitignore b/.gitignore
index 00f2d38..01ebb8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,220 @@
+# Byte-compiled / optimized / DLL files
__pycache__/
-*.pyc
-.venv/
+*.py[codz]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py.cover
+*.lcov
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+# Pipfile.lock
+
+# uv
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+# poetry.lock
+# poetry.toml
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
+# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
+# pdm.lock
+# pdm.toml
+.pdm-python
+.pdm-build/
+
+# pixi
+# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
+# pixi.lock
+# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
+# in the .venv directory. It is recommended not to include this directory in version control.
+.pixi/*
+!.pixi/config.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule*
+celerybeat.pid
+
+# Redis
+*.rdb
+*.aof
+*.pid
+
+# RabbitMQ
+mnesia/
+rabbitmq/
+rabbitmq-data/
+
+# ActiveMQ
+activemq-data/
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.envrc
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs/Zensical documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+.idea/
+
+# Abstra
+# Abstra is an AI-powered process automation framework.
+# Ignore directories containing user credentials, local state, and settings.
+# Learn more at https://abstra.io/docs
+.abstra/
+
+# Visual Studio Code
+# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
+# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
+# and can be added to the global gitignore or merged into this file. However, if you prefer, you
+# could uncomment the following to ignore the entire vscode folder
+# .vscode/
+# Temporary file for partial code execution
+tempCodeRunnerFile.py
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Marimo
+marimo/_static/
+marimo/_lsp/
+__marimo__/
+
+# Streamlit
+.streamlit/secrets.toml
diff --git a/Dockerfile b/Dockerfile
index d951ae4..d56a063 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,8 +5,8 @@
#
# Stage 1 builds the neo4j-browser static frontend from source.
# Stage 2 is the Apache AGE image (PostgreSQL with the AGE extension) plus the
-# peo bridge, which speaks the Bolt protocol to the browser and translates
-# Cypher into AGE `cypher()` calls, and pgAdmin as a SQL interface.
+# peo Python package, which speaks the Bolt protocol to the browser and
+# translates Cypher into AGE `cypher()` calls, and pgAdmin as a SQL interface.
ARG AGE_IMAGE=docker.io/apache/age:release_PG18_1.8.0
# Commit of apache/age's release/PG18/1.8.0 branch, for AGE's LICENSE and NOTICE.
@@ -59,7 +59,12 @@ ADD https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/LICENSE \
/usr/share/doc/peo/apache-age/
COPY --from=browser /src/dist /opt/peo/browser
-COPY bridge /opt/peo/bridge
+# peo itself, using Debian's psycopg.
+COPY pyproject.toml README.md LICENSE /tmp/peo/
+COPY peo /tmp/peo/peo
+RUN python3 -m venv --system-site-packages /opt/peo/venv \
+ && /opt/peo/venv/bin/pip install --no-cache-dir --no-deps /tmp/peo \
+ && rm -r /tmp/peo
COPY docker/initdb/ /docker-entrypoint-initdb.d/
COPY docker/pgadmin/config_distro.py /opt/peo/pgadmin/config_distro.py
COPY docker/peo-entrypoint.sh /usr/local/bin/peo-entrypoint.sh
diff --git a/README.md b/README.md
index 4ba359b..6cb92e4 100644
--- a/README.md
+++ b/README.md
@@ -1,57 +1,73 @@
# peo
-[neo4j-browser](https://github.com/neo4j/neo4j-browser) running on PostgreSQL, using
-[Apache AGE](https://github.com/apache/age) as the graph engine.
+[neo4j-browser](https://github.com/neo4j/neo4j-browser), cypher-shell and the Neo4j
+drivers on top of PostgreSQL, using [Apache AGE](https://github.com/apache/age) as the
+graph engine.
-Everything ships in one container image, together with
-[pgAdmin](https://www.pgadmin.org/) for working with the same data in SQL:
+peo is a small Python server that speaks Neo4j's Bolt protocol and translates Cypher
+into AGE `cypher()` calls. It can also serve neo4j-browser. A [container
+image](#container-image) bundles everything, together with
+[pgAdmin](https://www.pgadmin.org/) for working with the same data in SQL.
```
- neo4j-browser (static files) peo bridge (Python) PostgreSQL + AGE
- ┌──────────────────────┐ Bolt ┌──────────────────────────┐ SQL ┌────────────────┐
- │ in your web browser │───────▶│ Bolt server (TCP + WS) │──────▶│ cypher('g',$$ │
- │ served on :7474 │ :7687 │ Cypher → AGE translation │ :5432 │ ... $$) │
+ neo4j-browser (static files) peo (Python) PostgreSQL + AGE
+ ┌──────────────────────┐ Bolt ┌────────────────────────-──┐ SQL ┌────────────────┐
+ │ in your web browser │───────▶│ Bolt server (TCP + WS) │──────▶│ cypher('g',$$ │
+ │ served on :7474 │ :7687 │ Cypher → AGE translation │ :5432 │ ... $$) │
└──────────────────────┘ │ emulated Neo4j procedures │ └────────────────┘
- └──────────────────────────┘
+ └─────────────────────────-─┘
```
-## Quick start
+## Installation
+
+peo needs Python 3.11 or later and a PostgreSQL server with the AGE extension installed
+and loaded (`shared_preload_libraries=age`).
```sh
-podman build -t peo .
-podman run -d --name peo -e POSTGRES_PASSWORD=secret \
- -p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
- -v peo-data:/var/lib/postgresql -v peo-pgadmin:/var/lib/pgadmin peo
+pip install 'peo[binary]'
+PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres peo
```
-| Port | Service | Log in with |
+The `binary` extra pulls in psycopg's prebuilt wheels; leave it out to use a psycopg
+built against your system's libpq. `python -m peo` works the same as `peo`.
+
+Neo4j clients can now connect to `neo4j://localhost:7687` with a PostgreSQL user name
+and password. peo does not include neo4j-browser itself: build it and point
+`PEO_BROWSER_DIR` at its `dist` directory to serve it on , or
+use the container image.
+
+## Configuration
+
+peo is configured through environment variables:
+
+| Variable | Default | Purpose |
| --- | --- | --- |
-| 7474 | neo4j-browser: | PostgreSQL user `postgres` and `POSTGRES_PASSWORD` |
-| 7687 | Bolt, for Neo4j drivers and cypher-shell (`neo4j://localhost:7687`) | same as above |
-| 5050 | pgAdmin: | `admin@example.com` and `POSTGRES_PASSWORD` |
-| 5432 | PostgreSQL, for `psql` and other SQL clients | same as neo4j-browser |
-
-pgAdmin comes with the bundled PostgreSQL server already registered as "peo";
-it asks for the PostgreSQL password the first time you open it. AGE stores each
-graph as a schema of the same name, with one table per label.
-
-`docker` works as a drop-in replacement for `podman` in the commands above.
+| `PEO_PG_HOST` | `127.0.0.1` | PostgreSQL host. |
+| `PEO_PG_PORT` | `5432` | PostgreSQL port. |
+| `PEO_PG_DATABASE` | `$POSTGRES_DB`, else `$POSTGRES_USER`, else `postgres` | PostgreSQL database holding the graphs. |
+| `PEO_DEFAULT_GRAPH` | `neo4j` | Graph used when a client does not pick a database. |
+| `PEO_BOLT_PORT` | `7687` | Port the Bolt server listens on (TCP and WebSocket). |
+| `PEO_HTTP_PORT` | `7474` | Port neo4j-browser is served on, when `PEO_BROWSER_DIR` is set. |
+| `PEO_BROWSER_DIR` | (unset) | neo4j-browser `dist` directory to serve. |
+| `PEO_ADVERTISED_BOLT_PORT` | `$PEO_BOLT_PORT` | Bolt port advertised to the browser; set it when clients reach the Bolt port on another port. |
+| `PEO_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. |
+| `PEO_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. |
## How it works
- **Databases are AGE graphs.** Every AGE graph shows up as a Neo4j database, and
- `:use ` switches between them. A graph named `neo4j` is created on first start
- (`PEO_DEFAULT_GRAPH`). `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
+ `:use ` switches between them. Clients that do not pick a database get `PEO_DEFAULT_GRAPH`
+ (`neo4j`); the container image creates that graph on first start. `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
database call `create_graph` / `drop_graph`; AGE requires names of at least 3 characters.
- **Queries** are sent to AGE as `SELECT * FROM cypher('', $$ $$) AS (...)`.
- The bridge works out the result columns from the final `RETURN` clause and inlines
+ peo works out the result columns from the final `RETURN` clause and inlines
query parameters as Cypher literals.
- **Neo4j procedures** that neo4j-browser relies on (`dbms.components`, `SHOW DATABASES`,
`db.labels`, `db.schema.visualization`, `SHOW PROCEDURES`, ...) are emulated from the
AGE catalog.
- **Workarounds for AGE**: neo4j-browser's "expand node" query uses a pattern
comprehension, and its relationship lookup uses `id(n) IN [...]`, which AGE rejects
- for lists of more than one element. The bridge rewrites both into equivalent Cypher
+ for lists of more than one element. peo rewrites both into equivalent Cypher
that AGE accepts.
- **Authentication** is passed straight through: every Bolt connection opens its own
PostgreSQL connection with the credentials you log in with.
@@ -84,16 +100,65 @@ and `REMOVE`, and they can be indexed. Some things to keep in mind:
- AGE has no date or time types, so store those as ISO 8601 strings and cast them in
SQL.
-## Configuration
+## Querying the graph from SQL
+
+AGE stores each node label and relationship type as a table in the graph's schema,
+with all properties in one `agtype` column, `properties`; `properties ->> 'key'::text`
+returns a property as text. For typed columns, add them yourself, either as a view or
+as a generated column:
+
+```sql
+CREATE VIEW neo4j.people AS
+ SELECT id, properties ->> 'name'::text AS name, (properties ->> 'age'::text)::bigint AS age
+ FROM neo4j."Person";
+
+ALTER TABLE neo4j."Person"
+ ADD COLUMN age bigint GENERATED ALWAYS AS ((properties ->> 'age'::text)::bigint) STORED;
+```
+
+PostgreSQL keeps generated columns up to date on every Cypher `CREATE`, `MERGE`, `SET`
+and `REMOVE`, and they can be indexed. Some things to keep in mind:
+
+- A value that does not fit the column's type makes the Cypher write fail: with the
+ column above, `CREATE (:Person {age: 'old'})` is rejected. A view only fails when it
+ is read.
+- AGE does not allow dropping columns from its label tables, so a generated column
+ cannot be removed again.
+- Adding a stored generated column rewrites the table and locks it while doing so.
+- AGE has no date or time types, so store those as ISO 8601 strings and cast them in
+ SQL.
+
+## Container image
+
+The container image runs PostgreSQL with AGE, peo, neo4j-browser and pgAdmin in one
+container:
+
+```sh
+podman build -t peo .
+podman run -d --name peo -e POSTGRES_PASSWORD=secret \
+ -p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
+ -v peo-data:/var/lib/postgresql -v peo-pgadmin:/var/lib/pgadmin peo
+```
+
+| Port | Service | Log in with |
+| --- | --- | --- |
+| 7474 | neo4j-browser: | PostgreSQL user `postgres` and `POSTGRES_PASSWORD` |
+| 7687 | Bolt, for Neo4j drivers and cypher-shell (`neo4j://localhost:7687`) | same as above |
+| 5050 | pgAdmin: | `admin@example.com` and `POSTGRES_PASSWORD` |
+| 5432 | PostgreSQL, for `psql` and other SQL clients | same as neo4j-browser |
+
+pgAdmin comes with the bundled PostgreSQL server already registered as "peo";
+it asks for the PostgreSQL password the first time you open it. AGE stores each
+graph as a schema of the same name, with one table per label.
+
+`docker` works as a drop-in replacement for `podman` in the commands above.
+
+Besides peo's own variables above, the image uses these:
| Variable | Default | Purpose |
| --- | --- | --- |
| `POSTGRES_PASSWORD` | (required) | Password of the PostgreSQL superuser, see the [postgres image docs](https://hub.docker.com/_/postgres). `POSTGRES_USER` and `POSTGRES_DB` work too. |
-| `PEO_DEFAULT_GRAPH` | `neo4j` | Graph created at initialisation and used when a client does not pick a database. |
-| `PEO_PG_DATABASE` | `$POSTGRES_DB` | PostgreSQL database holding the graphs. |
-| `PEO_ADVERTISED_BOLT_PORT` | `7687` | Bolt port advertised to the browser; set it when you publish 7687 on another host port. |
-| `PEO_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. |
-| `PEO_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. |
+| `PEO_DEFAULT_GRAPH` | `neo4j` | Also the graph created when the database is initialised. |
| `PGADMIN_DEFAULT_EMAIL` | `admin@example.com` | pgAdmin login, created on first start. |
| `PGADMIN_DEFAULT_PASSWORD` | `$POSTGRES_PASSWORD` | pgAdmin login password, set on first start. |
| `PEO_PGADMIN` | `true` | Set to `false` to not start pgAdmin. |
@@ -117,19 +182,15 @@ This is a first version. Things that do not work (yet):
- User and role management, `SHOW TRANSACTIONS`, `:sysinfo`.
- TLS (`bolt+s://`, HTTPS).
- Each query's full result is fetched from PostgreSQL before it is streamed to the
- client, so very large results use memory in the bridge.
+ client, so very large results use memory in peo.
## Development
-The bridge is a dependency-light Python package in [`bridge/peo`](bridge/peo)
-(only `psycopg` 3). To run it outside the container against any AGE-enabled
-PostgreSQL:
+peo's only dependency is `psycopg` 3. To work on it, install it in editable mode;
+`python -m build` (or `uv build`) builds an sdist and wheel:
```sh
-pip install 'psycopg[binary]'
-cd bridge
-PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres PEO_BROWSER_DIR=/path/to/neo4j-browser/dist \
- python -m peo
+pip install -e '.[binary]'
```
[`tests/smoke_test.py`](tests/smoke_test.py) runs end-to-end checks through the official
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 546cfd5..ce9d7fc 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,8 +1,8 @@
# Third-party notices
-peo's own code (the `bridge/`, `docker/` and `tests/` directories and the
-`Dockerfile`) is licensed under the GNU General Public License v3.0 or later;
-see [LICENSE](LICENSE).
+peo's own code (the `peo/`, `docker/` and `tests/` directories, the
+`Dockerfile` and `pyproject.toml`) is licensed under the GNU General Public
+License v3.0 or later; see [LICENSE](LICENSE).
The container image built from this repository also contains the third-party
software below. None of it is stored in this repository: it is downloaded when
@@ -27,8 +27,8 @@ the image is built. Inside the image, license texts are collected under
archive of the exact commit that was built. Its dependencies are pinned by
the `yarn.lock` inside that archive, and the build steps are in this
repository's `Dockerfile`.
-- **peo** itself ships as Python source code in `/opt/peo/bridge/`. This
- repository has the rest.
+- **peo** itself ships as Python source code in the `/opt/peo/venv/`
+ virtual environment. This repository has the rest.
- **Debian packages:** source is available from
[sources.debian.org](https://sources.debian.org/) and the Debian archive.
diff --git a/bridge/peo/__init__.py b/bridge/peo/__init__.py
deleted file mode 100644
index cd14312..0000000
--- a/bridge/peo/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# SPDX-FileCopyrightText: 2026 Bart Broere
-# SPDX-License-Identifier: GPL-3.0-or-later
diff --git a/docker/peo-entrypoint.sh b/docker/peo-entrypoint.sh
index e5a5835..e5508ad 100755
--- a/docker/peo-entrypoint.sh
+++ b/docker/peo-entrypoint.sh
@@ -40,8 +40,7 @@ EOF
docker-entrypoint.sh "$@" &
pg_pid=$!
-cd /opt/peo/bridge
-gosu postgres python3 -m peo &
+gosu postgres /opt/peo/venv/bin/peo &
bridge_pid=$!
pgadmin_pid=
diff --git a/peo/__init__.py b/peo/__init__.py
new file mode 100644
index 0000000..e3836d6
--- /dev/null
+++ b/peo/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2026 Bart Broere
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""peo: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
+
+__version__ = "0.1.0"
diff --git a/bridge/peo/__main__.py b/peo/__main__.py
similarity index 94%
rename from bridge/peo/__main__.py
rename to peo/__main__.py
index 141bcab..c24a269 100644
--- a/bridge/peo/__main__.py
+++ b/peo/__main__.py
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
-"""peo bridge: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
+"""peo: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
import asyncio
import functools
@@ -46,8 +46,12 @@ async def main():
await asyncio.gather(*(s.serve_forever() for s in servers))
-if __name__ == "__main__":
+def run():
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+
+
+if __name__ == "__main__":
+ run()
diff --git a/bridge/peo/agtype.py b/peo/agtype.py
similarity index 100%
rename from bridge/peo/agtype.py
rename to peo/agtype.py
diff --git a/bridge/peo/backend.py b/peo/backend.py
similarity index 100%
rename from bridge/peo/backend.py
rename to peo/backend.py
diff --git a/bridge/peo/bolt.py b/peo/bolt.py
similarity index 100%
rename from bridge/peo/bolt.py
rename to peo/bolt.py
diff --git a/bridge/peo/cypher.py b/peo/cypher.py
similarity index 100%
rename from bridge/peo/cypher.py
rename to peo/cypher.py
diff --git a/bridge/peo/packstream.py b/peo/packstream.py
similarity index 100%
rename from bridge/peo/packstream.py
rename to peo/packstream.py
diff --git a/bridge/peo/web.py b/peo/web.py
similarity index 100%
rename from bridge/peo/web.py
rename to peo/web.py
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d11561e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,56 @@
+# SPDX-FileCopyrightText: 2026 Bart Broere
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+[build-system]
+requires = ["hatchling>=1.27", "hatch-fancy-pypi-readme>=24.1"]
+build-backend = "hatchling.build"
+
+[project]
+name = "peo"
+dynamic = ["version", "readme"]
+description = "neo4j-browser and Neo4j drivers on top of PostgreSQL, using Apache AGE as the graph engine"
+license = "GPL-3.0-or-later"
+license-files = ["LICENSE"]
+authors = [{ name = "Bart Broere" }]
+requires-python = ">=3.11"
+dependencies = ["psycopg>=3.1"]
+keywords = ["neo4j", "bolt", "cypher", "postgresql", "apache-age", "graph"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Programming Language :: Python :: 3",
+ "Topic :: Database",
+]
+
+[project.optional-dependencies]
+binary = ["psycopg[binary]>=3.1"]
+
+[project.urls]
+Source = "https://git.bartbroe.re/bartbroere/peo"
+
+[project.scripts]
+peo = "peo.__main__:run"
+
+[tool.hatch.version]
+path = "peo/__init__.py"
+
+[tool.hatch.build.targets.sdist]
+include = ["/peo", "/README.md", "/LICENSE"]
+
+[tool.hatch.build.targets.wheel]
+packages = ["peo"]
+
+# The README links to other files in the repository with relative links, which
+# do not work on PyPI. Make them absolute in the package metadata only.
+[tool.hatch.metadata.hooks.fancy-pypi-readme]
+content-type = "text/markdown"
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
+path = "README.md"
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
+pattern = '\]\(#'
+replacement = '](https://git.bartbroe.re/bartbroere/peo#'
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
+pattern = '\]\((?![a-z]+:|#)([^)]+)\)'
+replacement = '](https://git.bartbroe.re/bartbroere/peo/src/branch/main/\1)'