Move peo package to a different spot to prepare it for PyPI

This commit is contained in:
Bart Broere committed 2026-10-21 10:59:30 +02:00
1 parent 9a25aa11c9
commit 3e8e498b56
16 files changed
+408 -59

No files matched your search

+4
View File
@@ -3,3 +3,7 @@
**/*.pyc
.venv
tests
**/build
**/dist
**/*.egg-info
.idea
+219 -2
View File
@@ -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
+8 -3
View File
@@ -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
+104 -43
View File
@@ -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 <http://localhost:7474/>, or
use the container image.
## Configuration
peo is configured through environment variables:
| Variable | Default | Purpose |
| --- | --- | --- |
| 7474 | neo4j-browser: <http://localhost:7474/> | PostgreSQL user `postgres` and `POSTGRES_PASSWORD` |
| 7687 | Bolt, for Neo4j drivers and cypher-shell (`neo4j://localhost:7687`) | same as above |
| 5050 | pgAdmin: <http://localhost:5050/> | `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 <name>` 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 <name>` 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('<graph>', $$ <query> $$) 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: <http://localhost:7474/> | PostgreSQL user `postgres` and `POSTGRES_PASSWORD` |
| 7687 | Bolt, for Neo4j drivers and cypher-shell (`neo4j://localhost:7687`) | same as above |
| 5050 | pgAdmin: <http://localhost:5050/> | `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
+5 -5
View File
@@ -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.
-2
View File
@@ -1,2 +0,0 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
+1 -2
View File
@@ -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=
+5
View File
@@ -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"
+6 -2
View File
@@ -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()
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
+56
View File
@@ -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)'