Rename peo4c to peo
Renames the Python package, environment variables (PEO4C_* to PEO_*), the SQL helper schema, image paths and the entrypoint script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7bd044ddb9
commit
fd0dda6470
17 files changed
+124
-124
No files matched your search
+22
-22
@@ -1,11 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# peo4c: neo4j-browser on top of PostgreSQL + Apache AGE.
|
||||
# peo: neo4j-browser on top of PostgreSQL + Apache AGE.
|
||||
#
|
||||
# Stage 1 builds the neo4j-browser static frontend from source.
|
||||
# Stage 2 is the Apache AGE image (PostgreSQL with the AGE extension) plus the
|
||||
# peo4c bridge, which speaks the Bolt protocol to the browser and translates
|
||||
# peo bridge, 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
|
||||
@@ -49,35 +49,35 @@ RUN python3 -m venv /opt/pgadmin \
|
||||
COPY docker/collect-python-licenses.py /tmp/
|
||||
COPY docker/licenses/ /tmp/licenses/
|
||||
RUN /opt/pgadmin/bin/python /tmp/collect-python-licenses.py \
|
||||
/usr/share/doc/peo4c/pgadmin-python-licenses /tmp/licenses \
|
||||
/usr/share/doc/peo/pgadmin-python-licenses /tmp/licenses \
|
||||
&& rm -r /tmp/collect-python-licenses.py /tmp/licenses
|
||||
COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/peo4c/
|
||||
COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/peo/
|
||||
COPY --from=browser /src/LICENSE /src/NOTICE.txt /src/LICENSES.txt /neo4j-browser-source.tar.gz \
|
||||
/usr/share/doc/peo4c/neo4j-browser/
|
||||
/usr/share/doc/peo/neo4j-browser/
|
||||
ADD https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/LICENSE \
|
||||
https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/NOTICE \
|
||||
/usr/share/doc/peo4c/apache-age/
|
||||
/usr/share/doc/peo/apache-age/
|
||||
|
||||
COPY --from=browser /src/dist /opt/peo4c/browser
|
||||
COPY bridge /opt/peo4c/bridge
|
||||
COPY --from=browser /src/dist /opt/peo/browser
|
||||
COPY bridge /opt/peo/bridge
|
||||
COPY docker/initdb/ /docker-entrypoint-initdb.d/
|
||||
COPY docker/pgadmin/config_distro.py /opt/peo4c/pgadmin/config_distro.py
|
||||
COPY docker/peo4c-entrypoint.sh /usr/local/bin/peo4c-entrypoint.sh
|
||||
COPY docker/pgadmin/config_distro.py /opt/peo/pgadmin/config_distro.py
|
||||
COPY docker/peo-entrypoint.sh /usr/local/bin/peo-entrypoint.sh
|
||||
|
||||
ENV PEO4C_DEFAULT_GRAPH=neo4j \
|
||||
PEO4C_BROWSER_DIR=/opt/peo4c/browser \
|
||||
PEO4C_HTTP_PORT=7474 \
|
||||
PEO4C_BOLT_PORT=7687 \
|
||||
PEO4C_PG_HOST=127.0.0.1 \
|
||||
PEO4C_PG_PORT=5432 \
|
||||
PEO4C_PROPERTY_COLUMNS=true \
|
||||
PEO4C_PROPERTY_COLUMNS_INTERVAL=5 \
|
||||
PEO4C_PGADMIN=true \
|
||||
PEO4C_PGADMIN_PORT=5050 \
|
||||
CONFIG_DISTRO_FILE_PATH=/opt/peo4c/pgadmin/config_distro.py
|
||||
ENV PEO_DEFAULT_GRAPH=neo4j \
|
||||
PEO_BROWSER_DIR=/opt/peo/browser \
|
||||
PEO_HTTP_PORT=7474 \
|
||||
PEO_BOLT_PORT=7687 \
|
||||
PEO_PG_HOST=127.0.0.1 \
|
||||
PEO_PG_PORT=5432 \
|
||||
PEO_PROPERTY_COLUMNS=true \
|
||||
PEO_PROPERTY_COLUMNS_INTERVAL=5 \
|
||||
PEO_PGADMIN=true \
|
||||
PEO_PGADMIN_PORT=5050 \
|
||||
CONFIG_DISTRO_FILE_PATH=/opt/peo/pgadmin/config_distro.py
|
||||
|
||||
# 7474: neo4j-browser (HTTP), 7687: Bolt, 5050: pgAdmin, 5432: PostgreSQL
|
||||
EXPOSE 7474 7687 5050 5432
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/peo4c-entrypoint.sh"]
|
||||
ENTRYPOINT ["/usr/local/bin/peo-entrypoint.sh"]
|
||||
CMD ["postgres", "-c", "shared_preload_libraries=age"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# peo4c
|
||||
# peo
|
||||
|
||||
[neo4j-browser](https://github.com/neo4j/neo4j-browser) running on PostgreSQL, using
|
||||
[Apache AGE](https://github.com/apache/age) as the graph engine.
|
||||
@@ -7,7 +7,7 @@ Everything ships in one container image, together with
|
||||
[pgAdmin](https://www.pgadmin.org/) for working with the same data in SQL:
|
||||
|
||||
```
|
||||
neo4j-browser (static files) peo4c bridge (Python) PostgreSQL + AGE
|
||||
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 │ ... $$) │
|
||||
@@ -18,10 +18,10 @@ Everything ships in one container image, together with
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
podman build -t peo4c .
|
||||
podman run -d --name peo4c -e POSTGRES_PASSWORD=secret \
|
||||
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 peo4c-data:/var/lib/postgresql -v peo4c-pgadmin:/var/lib/pgadmin peo4c
|
||||
-v peo-data:/var/lib/postgresql -v peo-pgadmin:/var/lib/pgadmin peo
|
||||
```
|
||||
|
||||
| Port | Service | Log in with |
|
||||
@@ -31,7 +31,7 @@ podman run -d --name peo4c -e POSTGRES_PASSWORD=secret \
|
||||
| 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 "peo4c";
|
||||
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.
|
||||
|
||||
@@ -41,9 +41,9 @@ graph as a schema of the same name, with one table per label.
|
||||
|
||||
- **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
|
||||
(`PEO4C_DEFAULT_GRAPH`). `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
|
||||
(`PEO_DEFAULT_GRAPH`). `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
|
||||
database call `create_graph` / `drop_graph`; AGE requires names of at least 3 characters,
|
||||
and `peo4c` is reserved for the schema holding peo4c's SQL helpers.
|
||||
and `peo` is reserved for the schema holding peo's SQL helpers.
|
||||
- **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
|
||||
query parameters as Cypher literals.
|
||||
@@ -60,7 +60,7 @@ graph as a schema of the same name, with one table per label.
|
||||
## 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`. peo4c adds a generated
|
||||
with all properties in one `agtype` column, `properties`. peo adds a generated
|
||||
column for every property key it finds, so you can use plain SQL:
|
||||
|
||||
```sql
|
||||
@@ -70,8 +70,8 @@ CREATE INDEX ON neo4j."Person" (name);
|
||||
|
||||
AGE's Cypher writes do not fire PostgreSQL triggers, so new keys cannot be caught
|
||||
the moment they are written. Instead the bridge calls
|
||||
`CALL peo4c.sync_property_columns()` every few seconds
|
||||
(`PEO4C_PROPERTY_COLUMNS_INTERVAL`), which you can also run yourself. It only scans
|
||||
`CALL peo.sync_property_columns()` every few seconds
|
||||
(`PEO_PROPERTY_COLUMNS_INTERVAL`), which you can also run yourself. It only scans
|
||||
tables that had inserts or updates since its previous run, and adds all new keys of a
|
||||
table with a single `ALTER TABLE`. Once a column exists, PostgreSQL keeps it in step
|
||||
with every `CREATE`, `MERGE`, `SET` and `REMOVE`.
|
||||
@@ -91,17 +91,17 @@ with every `CREATE`, `MERGE`, `SET` and `REMOVE`.
|
||||
| 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. |
|
||||
| `PEO4C_DEFAULT_GRAPH` | `neo4j` | Graph created at initialisation and used when a client does not pick a database. |
|
||||
| `PEO4C_PG_DATABASE` | `$POSTGRES_DB` | PostgreSQL database holding the graphs. |
|
||||
| `PEO4C_ADVERTISED_BOLT_PORT` | `7687` | Bolt port advertised to the browser; set it when you publish 7687 on another host port. |
|
||||
| `PEO4C_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. |
|
||||
| `PEO4C_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. |
|
||||
| `PEO4C_PROPERTY_COLUMNS` | `true` | Set to `false` to stop adding generated property columns. |
|
||||
| `PEO4C_PROPERTY_COLUMNS_INTERVAL` | `5` | Seconds between checks for new property keys. |
|
||||
| `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_PROPERTY_COLUMNS` | `true` | Set to `false` to stop adding generated property columns. |
|
||||
| `PEO_PROPERTY_COLUMNS_INTERVAL` | `5` | Seconds between checks for new property keys. |
|
||||
| `PGADMIN_DEFAULT_EMAIL` | `admin@example.com` | pgAdmin login, created on first start. |
|
||||
| `PGADMIN_DEFAULT_PASSWORD` | `$POSTGRES_PASSWORD` | pgAdmin login password, set on first start. |
|
||||
| `PEO4C_PGADMIN` | `true` | Set to `false` to not start pgAdmin. |
|
||||
| `PEO4C_PGADMIN_PORT` | `5050` | Port pgAdmin listens on inside the container. |
|
||||
| `PEO_PGADMIN` | `true` | Set to `false` to not start pgAdmin. |
|
||||
| `PEO_PGADMIN_PORT` | `5050` | Port pgAdmin listens on inside the container. |
|
||||
|
||||
Build arguments: `AGE_IMAGE` (base image, default `apache/age:release_PG18_1.8.0`),
|
||||
`NEO4J_BROWSER_REF` (neo4j-browser commit to build) and `PGADMIN_VERSION` (default `9.18`).
|
||||
@@ -125,15 +125,15 @@ This is a first version. Things that do not work (yet):
|
||||
|
||||
## Development
|
||||
|
||||
The bridge is a dependency-light Python package in [`bridge/peo4c`](bridge/peo4c)
|
||||
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:
|
||||
|
||||
```sh
|
||||
pip install 'psycopg[binary]'
|
||||
cd bridge
|
||||
PEO4C_PG_HOST=localhost PEO4C_PG_DATABASE=postgres PEO4C_BROWSER_DIR=/path/to/neo4j-browser/dist \
|
||||
python -m peo4c
|
||||
PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres PEO_BROWSER_DIR=/path/to/neo4j-browser/dist \
|
||||
python -m peo
|
||||
```
|
||||
|
||||
[`tests/smoke_test.py`](tests/smoke_test.py) runs end-to-end checks through the official
|
||||
@@ -146,12 +146,12 @@ python tests/smoke_test.py neo4j://localhost:7687 postgres secret
|
||||
|
||||
## License
|
||||
|
||||
peo4c is free software, licensed under the GNU General Public License v3.0 or
|
||||
peo is free software, licensed under the GNU General Public License v3.0 or
|
||||
later; see [LICENSE](LICENSE). The container image bundles third-party software
|
||||
under its own licenses, including neo4j-browser (GPL-3.0), Apache AGE
|
||||
(Apache-2.0), PostgreSQL and pgAdmin (PostgreSQL License). See
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for the full list and where
|
||||
each license text and source archive is in the image.
|
||||
|
||||
Neo4j is a trademark of Neo4j, Inc.; peo4c is not affiliated with or endorsed
|
||||
Neo4j is a trademark of Neo4j, Inc.; peo is not affiliated with or endorsed
|
||||
by Neo4j.
|
||||
+10
-10
@@ -1,40 +1,40 @@
|
||||
# Third-party notices
|
||||
|
||||
peo4c's own code (the `bridge/`, `docker/` and `tests/` directories and the
|
||||
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).
|
||||
|
||||
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
|
||||
the image is built. Inside the image, license texts are collected under
|
||||
`/usr/share/doc/peo4c/`.
|
||||
`/usr/share/doc/peo/`.
|
||||
|
||||
| Component | License | Copyright | License text in the image |
|
||||
| --- | --- | --- | --- |
|
||||
| [neo4j-browser](https://github.com/neo4j/neo4j-browser), built unmodified from the commit pinned in the `Dockerfile` | GPL-3.0-or-later | Neo4j Sweden AB | `/usr/share/doc/peo4c/neo4j-browser/` (`LICENSE`, `NOTICE.txt`) |
|
||||
| JavaScript libraries bundled into neo4j-browser | various (MIT, Apache-2.0, BSD, ...) | their respective authors | `/usr/share/doc/peo4c/neo4j-browser/LICENSES.txt` |
|
||||
| [Apache AGE](https://github.com/apache/age), from the `apache/age` base image | Apache-2.0 | The Apache Software Foundation; portions Bitnine Co., Ltd. | `/usr/share/doc/peo4c/apache-age/` (`LICENSE`, `NOTICE`) |
|
||||
| [neo4j-browser](https://github.com/neo4j/neo4j-browser), built unmodified from the commit pinned in the `Dockerfile` | GPL-3.0-or-later | Neo4j Sweden AB | `/usr/share/doc/peo/neo4j-browser/` (`LICENSE`, `NOTICE.txt`) |
|
||||
| JavaScript libraries bundled into neo4j-browser | various (MIT, Apache-2.0, BSD, ...) | their respective authors | `/usr/share/doc/peo/neo4j-browser/LICENSES.txt` |
|
||||
| [Apache AGE](https://github.com/apache/age), from the `apache/age` base image | Apache-2.0 | The Apache Software Foundation; portions Bitnine Co., Ltd. | `/usr/share/doc/peo/apache-age/` (`LICENSE`, `NOTICE`) |
|
||||
| [PostgreSQL](https://www.postgresql.org/), from the `apache/age` base image | PostgreSQL License | The PostgreSQL Global Development Group, The Regents of the University of California | `/usr/share/doc/postgresql-18/copyright` |
|
||||
| Debian GNU/Linux base system and packages, including Python 3 and `python3-psycopg` (LGPL-3.0) | various | their respective authors | `/usr/share/doc/<package>/copyright` |
|
||||
| [pgAdmin 4](https://www.pgadmin.org/), installed from PyPI | PostgreSQL License | The pgAdmin Development Team | `/opt/pgadmin/app/LICENSE` |
|
||||
| Python packages pgAdmin depends on, and `gunicorn` (MIT) | various | their respective authors | `*.dist-info/` under `/opt/pgadmin/lib/python3*/site-packages/`, or `/usr/share/doc/peo4c/pgadmin-python-licenses/` for packages whose wheel lacks a license file |
|
||||
| Python packages pgAdmin depends on, and `gunicorn` (MIT) | various | their respective authors | `*.dist-info/` under `/opt/pgadmin/lib/python3*/site-packages/`, or `/usr/share/doc/peo/pgadmin-python-licenses/` for packages whose wheel lacks a license file |
|
||||
|
||||
## Source code
|
||||
|
||||
- **neo4j-browser** is licensed under the GPL, which requires the complete
|
||||
corresponding source to accompany the built application. The image contains
|
||||
it as `/usr/share/doc/peo4c/neo4j-browser/neo4j-browser-source.tar.gz`, an
|
||||
it as `/usr/share/doc/peo/neo4j-browser/neo4j-browser-source.tar.gz`, an
|
||||
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`.
|
||||
- **peo4c** itself ships as Python source code in `/opt/peo4c/bridge/`. This
|
||||
- **peo** itself ships as Python source code in `/opt/peo/bridge/`. This
|
||||
repository has the rest.
|
||||
- **Debian packages:** source is available from
|
||||
[sources.debian.org](https://sources.debian.org/) and the Debian archive.
|
||||
|
||||
## Interoperability
|
||||
|
||||
The peo4c bridge implements the Bolt protocol and PackStream format from their
|
||||
The peo bridge implements the Bolt protocol and PackStream format from their
|
||||
public specifications so that unmodified Neo4j clients can connect. It contains
|
||||
no source code from Neo4j. To work with those clients it reports itself as
|
||||
"Neo4j" with a Neo4j version number, and it recognises the exact query strings
|
||||
@@ -46,5 +46,5 @@ strings to check compatibility.
|
||||
Neo4j is a trademark of Neo4j, Inc. PostgreSQL is a trademark of the PostgreSQL
|
||||
Community Association. Apache, Apache AGE and the Apache feather logo are
|
||||
trademarks of The Apache Software Foundation. pgAdmin is a trademark of the
|
||||
pgAdmin Development Team. peo4c is not affiliated with or endorsed by any of
|
||||
pgAdmin Development Team. peo is not affiliated with or endorsed by any of
|
||||
them; the names are used only to identify the software it works with.
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""peo4c bridge: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
|
||||
"""peo bridge: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
@@ -20,19 +20,19 @@ def env(name, default):
|
||||
|
||||
|
||||
async def main():
|
||||
logging.basicConfig(level=env("PEO4C_LOG_LEVEL", "INFO").upper(),
|
||||
logging.basicConfig(level=env("PEO_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("peo4c")
|
||||
log = logging.getLogger("peo")
|
||||
|
||||
bolt_port = int(env("PEO4C_BOLT_PORT", "7687"))
|
||||
http_port = int(env("PEO4C_HTTP_PORT", "7474"))
|
||||
advertised_port = env("PEO4C_ADVERTISED_BOLT_PORT", str(bolt_port))
|
||||
bolt_port = int(env("PEO_BOLT_PORT", "7687"))
|
||||
http_port = int(env("PEO_HTTP_PORT", "7474"))
|
||||
advertised_port = env("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
|
||||
config = Config(
|
||||
pg_host=env("PEO4C_PG_HOST", "127.0.0.1"),
|
||||
pg_port=int(env("PEO4C_PG_PORT", "5432")),
|
||||
pg_database=env("PEO4C_PG_DATABASE", env("POSTGRES_DB", env("POSTGRES_USER", "postgres"))),
|
||||
default_graph=env("PEO4C_DEFAULT_GRAPH", "neo4j"),
|
||||
advertised_bolt=f"{env('PEO4C_ADVERTISED_HOST', 'localhost')}:{advertised_port}",
|
||||
pg_host=env("PEO_PG_HOST", "127.0.0.1"),
|
||||
pg_port=int(env("PEO_PG_PORT", "5432")),
|
||||
pg_database=env("PEO_PG_DATABASE", env("POSTGRES_DB", env("POSTGRES_USER", "postgres"))),
|
||||
default_graph=env("PEO_DEFAULT_GRAPH", "neo4j"),
|
||||
advertised_bolt=f"{env('PEO_ADVERTISED_HOST', 'localhost')}:{advertised_port}",
|
||||
)
|
||||
|
||||
bolt = await asyncio.start_server(functools.partial(handle_client, config=config), "0.0.0.0", bolt_port)
|
||||
@@ -40,23 +40,23 @@ async def main():
|
||||
log.info("Bolt listening on :%d (PostgreSQL %s:%d/%s, default graph %r)", bolt_port,
|
||||
config.pg_host, config.pg_port, config.pg_database, config.default_graph)
|
||||
|
||||
browser_dir = env("PEO4C_BROWSER_DIR", "")
|
||||
browser_dir = env("PEO_BROWSER_DIR", "")
|
||||
if browser_dir:
|
||||
http = HttpServer(browser_dir, bolt_port)
|
||||
servers.append(await asyncio.start_server(http.handle, "0.0.0.0", http_port))
|
||||
log.info("neo4j-browser on http://localhost:%d/browser/", http_port)
|
||||
|
||||
tasks = [s.serve_forever() for s in servers]
|
||||
if env("PEO4C_PROPERTY_COLUMNS", "false") == "true":
|
||||
if env("PEO_PROPERTY_COLUMNS", "false") == "true":
|
||||
conninfo = make_conninfo(
|
||||
host=env("PEO4C_PROPERTY_COLUMNS_PG_HOST", "/var/run/postgresql"),
|
||||
host=env("PEO_PROPERTY_COLUMNS_PG_HOST", "/var/run/postgresql"),
|
||||
port=config.pg_port,
|
||||
dbname=config.pg_database,
|
||||
user=env("PEO4C_PROPERTY_COLUMNS_PG_USER", env("POSTGRES_USER", "postgres")),
|
||||
application_name="peo4c property columns",
|
||||
user=env("PEO_PROPERTY_COLUMNS_PG_USER", env("POSTGRES_USER", "postgres")),
|
||||
application_name="peo property columns",
|
||||
)
|
||||
tasks.append(property_columns.sync_forever(
|
||||
conninfo, float(env("PEO4C_PROPERTY_COLUMNS_INTERVAL", "5"))))
|
||||
conninfo, float(env("PEO_PROPERTY_COLUMNS_INTERVAL", "5"))))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
File renamed without changes.
@@ -21,14 +21,14 @@ from .cypher import (CypherError, expand_id_in_lists, first_keyword, inline_para
|
||||
return_columns, rewrite_for_age, strip_statement)
|
||||
from .packstream import Node, Relationship
|
||||
|
||||
log = logging.getLogger("peo4c.backend")
|
||||
log = logging.getLogger("peo.backend")
|
||||
|
||||
NEO4J_VERSION = "5.26.0"
|
||||
SYSTEM_DB = "system"
|
||||
SYSTEM_LABELS = ("_ag_label_vertex", "_ag_label_edge")
|
||||
GRAPH_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{2,62}$")
|
||||
|
||||
# Procedures emulated by peo4c, reported through SHOW PROCEDURES.
|
||||
# Procedures emulated by peo, reported through SHOW PROCEDURES.
|
||||
PROCEDURES = [
|
||||
("db.labels", "List all node labels in the graph.", "db.labels() :: (label :: STRING)"),
|
||||
("db.relationshipTypes", "List all relationship types in the graph.",
|
||||
@@ -123,7 +123,7 @@ class Session:
|
||||
self.conn = await psycopg.AsyncConnection.connect(
|
||||
host=self.config.pg_host, port=self.config.pg_port, dbname=self.config.pg_database,
|
||||
user=self.user, password=password, autocommit=True,
|
||||
application_name="peo4c")
|
||||
application_name="peo")
|
||||
except psycopg.OperationalError as e:
|
||||
msg = str(e)
|
||||
if "password authentication failed" in msg or "role" in msg and "does not exist" in msg:
|
||||
@@ -201,7 +201,7 @@ class Session:
|
||||
if db == SYSTEM_DB:
|
||||
raise BoltFailure("Neo.ClientError.Statement.NotSystemDatabaseCommand",
|
||||
"This is an administration command and it should be executed "
|
||||
"against the system database: peo4c only supports a few of them.")
|
||||
"against the system database: peo only supports a few of them.")
|
||||
result = await self._run_cypher(query, params or {}, db)
|
||||
except CypherError as e:
|
||||
raise BoltFailure(e.code, e.message) from e
|
||||
@@ -217,9 +217,9 @@ class Session:
|
||||
f"{keyword} is not supported by Apache AGE.")
|
||||
text = expand_id_in_lists(inline_parameters(rewrite_for_age(text), params))
|
||||
columns = return_columns(text)
|
||||
tag = "peo4c"
|
||||
tag = "peo"
|
||||
while f"${tag}$" in text:
|
||||
tag = "peo4c_" + secrets.token_hex(4)
|
||||
tag = "peo_" + secrets.token_hex(4)
|
||||
col_defs = ", ".join(f"c{i} ag_catalog.agtype" for i in range(max(len(columns), 1)))
|
||||
stmt = (f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
|
||||
f"${tag}$ {text} ${tag}$) AS ({col_defs})")
|
||||
@@ -309,7 +309,7 @@ class Session:
|
||||
if m:
|
||||
raise BoltFailure("Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
f"There is no procedure with the name `{m.group(0)[5:]}` registered "
|
||||
"for this database instance (peo4c).")
|
||||
"for this database instance (peo).")
|
||||
return None
|
||||
|
||||
async def _show_databases(self, special, name):
|
||||
@@ -337,9 +337,9 @@ class Session:
|
||||
raise BoltFailure("Neo.ClientError.Statement.ArgumentError",
|
||||
f"Invalid graph name '{name}': AGE graph names must be 3-63 characters "
|
||||
"of letters, digits, '_', '.' or '-', starting with a letter or '_'.")
|
||||
if name == "peo4c":
|
||||
if name == "peo":
|
||||
raise BoltFailure("Neo.ClientError.Statement.ArgumentError",
|
||||
"The name 'peo4c' is reserved: it is the schema of peo4c's own SQL helpers.")
|
||||
"The name 'peo' is reserved: it is the schema of peo's own SQL helpers.")
|
||||
if name in await self.graphs():
|
||||
if if_not_exists:
|
||||
return Result([], query_type="s")
|
||||
@@ -369,7 +369,7 @@ class Session:
|
||||
async def _cypher_values(self, graph, query):
|
||||
rows = await self._sql(
|
||||
f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
|
||||
f"$peo4c$ {query} $peo4c$) AS (c0 ag_catalog.agtype)")
|
||||
f"$peo$ {query} $peo$) AS (c0 ag_catalog.agtype)")
|
||||
return [agtype.parse(r[0]) for r in rows]
|
||||
|
||||
async def _property_keys(self, graph):
|
||||
@@ -18,7 +18,7 @@ from .backend import BoltFailure, Config, Session
|
||||
from .packstream import Structure
|
||||
from .web import discovery_document
|
||||
|
||||
log = logging.getLogger("peo4c.bolt")
|
||||
log = logging.getLogger("peo.bolt")
|
||||
|
||||
MAGIC = b"\x60\x60\xB0\x17"
|
||||
SUPPORTED = [(5, 4), (5, 3), (5, 2), (5, 1), (5, 0), (4, 4)]
|
||||
@@ -248,7 +248,7 @@ class BoltConnection:
|
||||
except Exception as e: # noqa: BLE001 - report anything to the client
|
||||
log.exception("%s: internal error", self.id)
|
||||
self.results.clear()
|
||||
await self.failure("Neo.DatabaseError.General.UnknownError", f"peo4c internal error: {e}")
|
||||
await self.failure("Neo.DatabaseError.General.UnknownError", f"peo internal error: {e}")
|
||||
return True
|
||||
|
||||
async def dispatch(self, tag, f):
|
||||
@@ -281,7 +281,7 @@ class BoltConnection:
|
||||
await self.success()
|
||||
elif tag == COMMIT:
|
||||
await self.require_session().commit()
|
||||
await self.success({"bookmark": "peo4c:bookmark"})
|
||||
await self.success({"bookmark": "peo:bookmark"})
|
||||
elif tag == ROLLBACK:
|
||||
await self.require_session().rollback()
|
||||
await self.success()
|
||||
@@ -296,7 +296,7 @@ class BoltConnection:
|
||||
async def logon(self, auth):
|
||||
if auth.get("scheme") != "basic":
|
||||
raise BoltFailure("Neo.ClientError.Security.Unauthorized",
|
||||
"peo4c requires basic authentication with a PostgreSQL user name and password.")
|
||||
"peo requires basic authentication with a PostgreSQL user name and password.")
|
||||
session = Session(self.config, auth.get("principal", ""))
|
||||
await session.connect(auth.get("credentials", ""))
|
||||
self.session = session
|
||||
@@ -345,7 +345,7 @@ class BoltConnection:
|
||||
del self.results[qid]
|
||||
metadata = {"type": result.query_type, "t_last": 0, "db": result.db}
|
||||
if not self.session.in_transaction:
|
||||
metadata["bookmark"] = "peo4c:bookmark"
|
||||
metadata["bookmark"] = "peo:bookmark"
|
||||
await self.success(metadata)
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ def return_columns(text: str) -> list:
|
||||
expr = text[start:end].strip()
|
||||
if expr == "*":
|
||||
raise CypherError("Neo.ClientError.Statement.FeatureNotSupported",
|
||||
"RETURN * is not supported by peo4c; list the returned variables explicitly.")
|
||||
"RETURN * is not supported by peo; list the returned variables explicitly.")
|
||||
alias = None
|
||||
for a_start, a_end in _top_level(s, r"(?<![\w.$])AS\s+(`[^`]*`|\w+)\s*$", start, end):
|
||||
alias = re.match(r"AS\s+(.*)", text[a_start:a_end].strip(), re.IGNORECASE | re.DOTALL).group(1)
|
||||
@@ -195,7 +195,7 @@ def literal(v) -> str:
|
||||
return "{" + ", ".join(
|
||||
"`" + str(k).replace("`", "``") + "`: " + literal(x) for k, x in v.items()) + "}"
|
||||
raise CypherError("Neo.ClientError.Statement.TypeError",
|
||||
f"Parameters of type {type(v).__name__} are not supported by peo4c")
|
||||
f"Parameters of type {type(v).__name__} are not supported by peo")
|
||||
|
||||
|
||||
# neo4j-browser's "expand node" query uses a pattern comprehension, which AGE
|
||||
@@ -207,8 +207,8 @@ _NEIGHBOUR_COUNT = re.compile(
|
||||
|
||||
def rewrite_for_age(text: str) -> str:
|
||||
return _NEIGHBOUR_COUNT.sub(
|
||||
lambda m: f"OPTIONAL MATCH ({m.group(1)})-[peo4c_r]-() "
|
||||
f"WITH {m.group(1)}, count(peo4c_r) AS {m.group(2)}",
|
||||
lambda m: f"OPTIONAL MATCH ({m.group(1)})-[peo_r]-() "
|
||||
f"WITH {m.group(1)}, count(peo_r) AS {m.group(2)}",
|
||||
text)
|
||||
|
||||
|
||||
File renamed without changes.
@@ -3,7 +3,7 @@
|
||||
"""Keeps generated property columns on AGE label tables up to date.
|
||||
|
||||
Installs the procedures in ``property_columns.sql`` and calls
|
||||
``peo4c.sync_property_columns()`` every ``interval`` seconds. This needs a
|
||||
``peo.sync_property_columns()`` every ``interval`` seconds. This needs a
|
||||
connection that may alter every graph's tables, so it uses its own
|
||||
(superuser) connection rather than the credentials of Bolt users.
|
||||
"""
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
|
||||
log = logging.getLogger("peo4c.property_columns")
|
||||
log = logging.getLogger("peo.property_columns")
|
||||
|
||||
INSTALL_SQL = (Path(__file__).parent / "property_columns.sql").read_text()
|
||||
|
||||
@@ -34,7 +34,7 @@ async def sync_forever(conninfo, interval):
|
||||
log.info("syncing property columns every %gs", interval)
|
||||
error = None
|
||||
while True:
|
||||
await conn.execute("CALL peo4c.sync_property_columns()")
|
||||
await conn.execute("CALL peo.sync_property_columns()")
|
||||
await asyncio.sleep(interval)
|
||||
except psycopg.Error as e:
|
||||
# PostgreSQL is still starting or restarted, or AGE is not
|
||||
@@ -13,7 +13,7 @@
|
||||
--
|
||||
-- AGE's Cypher writes bypass the executor and fire no triggers, row or
|
||||
-- statement level, so new keys cannot be caught by a trigger. Instead
|
||||
-- CALL peo4c.sync_property_columns() looks for them; the peo4c bridge calls
|
||||
-- CALL peo.sync_property_columns() looks for them; the peo bridge calls
|
||||
-- it every few seconds. It only scans label tables that saw inserts or updates
|
||||
-- since its previous run, according to the statistics collector, and commits
|
||||
-- after each table so a table rewrite never holds locks on other tables.
|
||||
@@ -22,9 +22,9 @@
|
||||
-- Columns are text, like ->> on json, because a property can hold values of
|
||||
-- different types in different rows and a failing cast would make writes fail.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS peo4c;
|
||||
CREATE SCHEMA IF NOT EXISTS peo;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS peo4c.property_column_state (
|
||||
CREATE TABLE IF NOT EXISTS peo.property_column_state (
|
||||
relid oid PRIMARY KEY,
|
||||
changes bigint NOT NULL -- inserts + updates seen when the table was last scanned
|
||||
);
|
||||
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS peo4c.property_column_state (
|
||||
-- that name yet, and returns the keys it added. Keys that are empty or longer
|
||||
-- than PostgreSQL's 63-byte identifier limit are skipped, as are keys that
|
||||
-- clash with an existing column such as id, start_id or end_id.
|
||||
CREATE OR REPLACE FUNCTION peo4c.add_property_columns(tbl regclass)
|
||||
CREATE OR REPLACE FUNCTION peo.add_property_columns(tbl regclass)
|
||||
RETURNS SETOF text LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
new_keys text[];
|
||||
@@ -63,13 +63,13 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE peo4c.sync_property_columns()
|
||||
CREATE OR REPLACE PROCEDURE peo.sync_property_columns()
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
t record;
|
||||
added text[];
|
||||
BEGIN
|
||||
DELETE FROM peo4c.property_column_state s
|
||||
DELETE FROM peo.property_column_state s
|
||||
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_class WHERE oid = s.relid);
|
||||
|
||||
FOR t IN
|
||||
@@ -77,7 +77,7 @@ BEGIN
|
||||
FROM ag_catalog.ag_label AS l
|
||||
CROSS JOIN LATERAL (SELECT pg_stat_get_tuples_inserted(l.relation)
|
||||
+ pg_stat_get_tuples_updated(l.relation) AS changes) AS c
|
||||
LEFT JOIN peo4c.property_column_state AS s ON s.relid = l.relation
|
||||
LEFT JOIN peo.property_column_state AS s ON s.relid = l.relation
|
||||
WHERE l.name NOT IN ('_ag_label_vertex', '_ag_label_edge')
|
||||
AND s.changes IS DISTINCT FROM c.changes
|
||||
LOOP
|
||||
@@ -85,10 +85,10 @@ BEGIN
|
||||
-- Adding a column rewrites the table under an exclusive lock;
|
||||
-- rather than queue behind a long transaction, retry next run.
|
||||
PERFORM set_config('lock_timeout', '2s', true);
|
||||
SELECT array_agg(key) INTO added FROM peo4c.add_property_columns(t.tbl) AS key;
|
||||
SELECT array_agg(key) INTO added FROM peo.add_property_columns(t.tbl) AS key;
|
||||
-- The rewrite counts as inserts in this transaction; include them
|
||||
-- so the table is not scanned again for its own rewrite.
|
||||
INSERT INTO peo4c.property_column_state AS s
|
||||
INSERT INTO peo.property_column_state AS s
|
||||
VALUES (t.tbl, t.changes + pg_stat_get_xact_tuples_inserted(t.tbl)
|
||||
+ pg_stat_get_xact_tuples_updated(t.tbl))
|
||||
ON CONFLICT (relid) DO UPDATE SET changes = excluded.changes;
|
||||
@@ -12,7 +12,7 @@ from urllib.parse import unquote, urlsplit
|
||||
|
||||
from .backend import NEO4J_VERSION
|
||||
|
||||
log = logging.getLogger("peo4c.http")
|
||||
log = logging.getLogger("peo.http")
|
||||
|
||||
|
||||
class HttpServer:
|
||||
@@ -77,7 +77,7 @@ def discovery_document(headers, bolt_port) -> bytes:
|
||||
host = host[:host.index("]") + 1]
|
||||
else:
|
||||
host = host.split(":", 1)[0]
|
||||
port = os.environ.get("PEO4C_ADVERTISED_BOLT_PORT", str(bolt_port))
|
||||
port = os.environ.get("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
|
||||
return json.dumps({
|
||||
"bolt_routing": f"neo4j://{host}:{port}",
|
||||
"bolt_direct": f"bolt://{host}:{port}",
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Prepares a freshly initialised cluster for peo4c.
|
||||
# Prepares a freshly initialised cluster for peo.
|
||||
set -euo pipefail
|
||||
|
||||
# The bridge connects over 127.0.0.1 with the credentials users log in with, so
|
||||
@@ -12,7 +12,7 @@ sed -i -E 's/^(host[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]
|
||||
|
||||
# Create the default AGE graph that neo4j-browser opens on first connect.
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "${POSTGRES_DB:-$POSTGRES_USER}" \
|
||||
-v graph="${PEO4C_DEFAULT_GRAPH:-neo4j}" <<'EOSQL'
|
||||
-v graph="${PEO_DEFAULT_GRAPH:-neo4j}" <<'EOSQL'
|
||||
LOAD 'age';
|
||||
SET search_path = ag_catalog, "$user", public;
|
||||
SELECT ag_catalog.create_graph(:'graph')
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Runs PostgreSQL (through the stock postgres entrypoint), the peo4c bridge and
|
||||
# Runs PostgreSQL (through the stock postgres entrypoint), the peo bridge and
|
||||
# pgAdmin side by side. The container stops when PostgreSQL or the bridge exits;
|
||||
# pgAdmin is optional and its failure does not take the others down.
|
||||
set -euo pipefail
|
||||
@@ -10,7 +10,7 @@ if [ "${1:-}" != "postgres" ]; then
|
||||
exec docker-entrypoint.sh "$@"
|
||||
fi
|
||||
|
||||
export PEO4C_PG_DATABASE="${PEO4C_PG_DATABASE:-${POSTGRES_DB:-${POSTGRES_USER:-postgres}}}"
|
||||
export PEO_PG_DATABASE="${PEO_PG_DATABASE:-${POSTGRES_DB:-${POSTGRES_USER:-postgres}}}"
|
||||
|
||||
start_pgadmin() {
|
||||
local app=/opt/pgadmin/app
|
||||
@@ -19,8 +19,8 @@ start_pgadmin() {
|
||||
if [ ! -f /var/lib/pgadmin/pgadmin4.db ]; then
|
||||
# First start: create the login user and register the bundled PostgreSQL.
|
||||
cat > /var/lib/pgadmin/servers.json <<EOF
|
||||
{"Servers": {"1": {"Name": "peo4c", "Group": "Servers", "Host": "127.0.0.1", "Port": 5432,
|
||||
"MaintenanceDB": "${PEO4C_PG_DATABASE}", "Username": "${POSTGRES_USER:-postgres}", "SSLMode": "prefer"}}}
|
||||
{"Servers": {"1": {"Name": "peo", "Group": "Servers", "Host": "127.0.0.1", "Port": 5432,
|
||||
"MaintenanceDB": "${PEO_PG_DATABASE}", "Username": "${POSTGRES_USER:-postgres}", "SSLMode": "prefer"}}}
|
||||
EOF
|
||||
chown postgres:postgres /var/lib/pgadmin/servers.json
|
||||
PGADMIN_SETUP_EMAIL="$email" \
|
||||
@@ -31,22 +31,22 @@ EOF
|
||||
|| { rm -f /var/lib/pgadmin/pgadmin4.db; return 1; }
|
||||
fi
|
||||
|
||||
gosu postgres /opt/pgadmin/bin/gunicorn --bind "0.0.0.0:${PEO4C_PGADMIN_PORT}" \
|
||||
gosu postgres /opt/pgadmin/bin/gunicorn --bind "0.0.0.0:${PEO_PGADMIN_PORT}" \
|
||||
--workers 1 --threads 25 --timeout 0 --chdir "$app" --log-level warning pgAdmin4:app &
|
||||
pgadmin_pid=$!
|
||||
echo "peo4c: pgAdmin on http://localhost:${PEO4C_PGADMIN_PORT}/ (login: $email)"
|
||||
echo "peo: pgAdmin on http://localhost:${PEO_PGADMIN_PORT}/ (login: $email)"
|
||||
}
|
||||
|
||||
docker-entrypoint.sh "$@" &
|
||||
pg_pid=$!
|
||||
|
||||
cd /opt/peo4c/bridge
|
||||
gosu postgres python3 -m peo4c &
|
||||
cd /opt/peo/bridge
|
||||
gosu postgres python3 -m peo &
|
||||
bridge_pid=$!
|
||||
|
||||
pgadmin_pid=
|
||||
if [ "${PEO4C_PGADMIN}" = "true" ]; then
|
||||
start_pgadmin || echo "peo4c: WARNING: pgAdmin setup failed, continuing without it" >&2
|
||||
if [ "${PEO_PGADMIN}" = "true" ]; then
|
||||
start_pgadmin || echo "peo: WARNING: pgAdmin setup failed, continuing without it" >&2
|
||||
fi
|
||||
|
||||
shutdown() {
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# pgAdmin settings for the peo4c image, loaded via CONFIG_DISTRO_FILE_PATH.
|
||||
# pgAdmin settings for the peo image, loaded via CONFIG_DISTRO_FILE_PATH.
|
||||
# Paths derived from DATA_DIR in pgAdmin's config.py are computed before this
|
||||
# file is applied, so they are all set explicitly here.
|
||||
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""End-to-end smoke test for a running peo4c bridge, using the official Neo4j driver.
|
||||
"""End-to-end smoke test for a running peo bridge, using the official Neo4j driver.
|
||||
|
||||
Usage: python tests/smoke_test.py [bolt-uri] [user] [password]
|
||||
Requires: pip install neo4j
|
||||
Set PEO4C_SMOKE_PG_DSN (e.g. "host=localhost dbname=postgres") to also check the
|
||||
Set PEO_SMOKE_PG_DSN (e.g. "host=localhost dbname=postgres") to also check the
|
||||
generated property columns over SQL; that needs psycopg.
|
||||
"""
|
||||
|
||||
@@ -18,7 +18,7 @@ from neo4j.graph import Node, Path, Relationship
|
||||
uri = sys.argv[1] if len(sys.argv) > 1 else "neo4j://localhost:7687"
|
||||
user = sys.argv[2] if len(sys.argv) > 2 else "postgres"
|
||||
password = sys.argv[3] if len(sys.argv) > 3 else "postgres"
|
||||
GRAPH = "peo4c_smoke"
|
||||
GRAPH = "peo_smoke"
|
||||
|
||||
|
||||
def check(label, condition):
|
||||
@@ -121,7 +121,7 @@ MATCH ()-[]->() RETURN { name:'relationships', data: count(*)} AS result
|
||||
|
||||
check("managed write transaction", s.execute_write(work) == 1)
|
||||
|
||||
dsn = os.environ.get("PEO4C_SMOKE_PG_DSN")
|
||||
dsn = os.environ.get("PEO_SMOKE_PG_DSN")
|
||||
if dsn:
|
||||
import psycopg
|
||||
|
||||
|
||||
Reference in new issue
Block a user