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)'