tina4-python 3.13.110__tar.gz → 3.13.111__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina4_python-3.13.110 → tina4_python-3.13.111}/PKG-INFO +13 -1
- {tina4_python-3.13.110 → tina4_python-3.13.111}/pyproject.toml +8 -1
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/__init__.py +1 -1
- tina4_python-3.13.111/tina4_python/graph/__init__.py +114 -0
- tina4_python-3.13.111/tina4_python/graph/adapter.py +119 -0
- tina4_python-3.13.111/tina4_python/graph/adapters/__init__.py +1 -0
- tina4_python-3.13.111/tina4_python/graph/adapters/arango.py +145 -0
- tina4_python-3.13.111/tina4_python/graph/adapters/bolt.py +127 -0
- tina4_python-3.13.111/tina4_python/graph/adapters/ultipa.py +158 -0
- tina4_python-3.13.111/tina4_python/graph/graph_url.py +67 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/.gitignore +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/README.md +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/ai/client.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/env.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/point.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/sso.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.13.
|
|
3
|
+
Version: 3.13.111
|
|
4
4
|
Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
|
|
5
5
|
Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -12,8 +12,15 @@ Requires-Dist: psycopg2-binary>=2.9; extra == 'all-db'
|
|
|
12
12
|
Requires-Dist: pymongo>=4.0; extra == 'all-db'
|
|
13
13
|
Requires-Dist: pymssql>=2.3; extra == 'all-db'
|
|
14
14
|
Requires-Dist: pyodbc>=5.0; extra == 'all-db'
|
|
15
|
+
Provides-Extra: arango
|
|
16
|
+
Requires-Dist: python-arango>=8.0; extra == 'arango'
|
|
15
17
|
Provides-Extra: firebird
|
|
16
18
|
Requires-Dist: firebird-driver>=1.10; extra == 'firebird'
|
|
19
|
+
Provides-Extra: graph
|
|
20
|
+
Requires-Dist: neo4j>=5.0; extra == 'graph'
|
|
21
|
+
Requires-Dist: protobuf>=7.35.1; extra == 'graph'
|
|
22
|
+
Requires-Dist: python-arango>=8.0; extra == 'graph'
|
|
23
|
+
Requires-Dist: tina4-ultipa>=0.1; extra == 'graph'
|
|
17
24
|
Provides-Extra: kafka
|
|
18
25
|
Requires-Dist: confluent-kafka>=2.0; extra == 'kafka'
|
|
19
26
|
Provides-Extra: memcache
|
|
@@ -24,6 +31,8 @@ Provides-Extra: mssql
|
|
|
24
31
|
Requires-Dist: pymssql>=2.3; extra == 'mssql'
|
|
25
32
|
Provides-Extra: mysql
|
|
26
33
|
Requires-Dist: mysql-connector-python>=9.0; extra == 'mysql'
|
|
34
|
+
Provides-Extra: neo4j
|
|
35
|
+
Requires-Dist: neo4j>=5.0; extra == 'neo4j'
|
|
27
36
|
Provides-Extra: odbc
|
|
28
37
|
Requires-Dist: pyodbc>=5.0; extra == 'odbc'
|
|
29
38
|
Provides-Extra: postgres
|
|
@@ -47,6 +56,9 @@ Requires-Dist: pymongo>=4.0; extra == 'test'
|
|
|
47
56
|
Requires-Dist: pymssql>=2.3; extra == 'test'
|
|
48
57
|
Requires-Dist: pyodbc>=5.0; extra == 'test'
|
|
49
58
|
Requires-Dist: redis>=5.0; extra == 'test'
|
|
59
|
+
Provides-Extra: ultipa
|
|
60
|
+
Requires-Dist: protobuf>=7.35.1; extra == 'ultipa'
|
|
61
|
+
Requires-Dist: tina4-ultipa>=0.1; extra == 'ultipa'
|
|
50
62
|
Description-Content-Type: text/markdown
|
|
51
63
|
|
|
52
64
|
<p align="center">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tina4-python"
|
|
3
|
-
version = "3.13.
|
|
3
|
+
version = "3.13.111"
|
|
4
4
|
description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
|
|
@@ -21,6 +21,13 @@ firebird = ["firebird-driver>=1.10"]
|
|
|
21
21
|
mongo = ["pymongo>=4.0"]
|
|
22
22
|
odbc = ["pyodbc>=5.0"]
|
|
23
23
|
|
|
24
|
+
# Graph engine drivers — install what you need (loaded lazily on first use).
|
|
25
|
+
# The graph core is zero-dependency; a driver is a cost paid only per engine.
|
|
26
|
+
ultipa = ["tina4-ultipa>=0.1", "protobuf>=7.35.1"] # gencode needs protobuf 7.x; floor pins the CVE-free runtime
|
|
27
|
+
neo4j = ["neo4j>=5.0"] # the Bolt driver also serves Memgraph
|
|
28
|
+
arango = ["python-arango>=8.0"]
|
|
29
|
+
graph = ["tina4-ultipa>=0.1", "protobuf>=7.35.1", "neo4j>=5.0", "python-arango>=8.0"]
|
|
30
|
+
|
|
24
31
|
# Session backends — install what you need
|
|
25
32
|
redis = ["redis>=5.0"]
|
|
26
33
|
memcache = ["pymemcache>=4.0"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Tina4 graph data layer — a URL-selected graph database, shaped like Database.
|
|
2
|
+
"""One factory (`GraphDatabase.create` / `.from_env`), one portable
|
|
3
|
+
node/edge/traverse surface plus a raw `query`/`execute` pass-through, and neutral
|
|
4
|
+
`GraphNode` / `GraphEdge` / `GraphResult` shapes — the exact mirror of the
|
|
5
|
+
relational `Database` layer, for graph engines (Ultipa first). Engine drivers are
|
|
6
|
+
OPTIONAL and imported only on first use, so the zero-dependency core is preserved.
|
|
7
|
+
|
|
8
|
+
See ADR-0059 and tina4-documentation/plan/v3/features/139-graph-databases.md.
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from .graph_url import GraphUrl
|
|
13
|
+
from .adapter import GraphDatabase, GraphAdapter, GraphError, GraphConnectTimeout
|
|
14
|
+
|
|
15
|
+
# The graph connect bound — sibling of TINA4_DATABASE_CONNECT_TIMEOUT.
|
|
16
|
+
GRAPH_CONNECT_TIMEOUT_VARIABLE = "TINA4_GRAPH_CONNECT_TIMEOUT"
|
|
17
|
+
DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_graph_connect_timeout() -> float | None:
|
|
21
|
+
"""Seconds a graph connect may block; None means unbounded (<= 0).
|
|
22
|
+
|
|
23
|
+
Mirrors resolve_connect_timeout: a value <= 0 disables the bound, a
|
|
24
|
+
non-number warns and falls back to the default rather than waiting forever.
|
|
25
|
+
"""
|
|
26
|
+
raw = os.environ.get(GRAPH_CONNECT_TIMEOUT_VARIABLE, "").strip()
|
|
27
|
+
if not raw:
|
|
28
|
+
return DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS
|
|
29
|
+
try:
|
|
30
|
+
seconds = float(raw)
|
|
31
|
+
except ValueError:
|
|
32
|
+
from tina4_python.debug import Log
|
|
33
|
+
Log.warning(
|
|
34
|
+
f"{GRAPH_CONNECT_TIMEOUT_VARIABLE}={raw!r} is not a number of seconds — "
|
|
35
|
+
f"bounding graph connects at the {DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS:g}s default instead"
|
|
36
|
+
)
|
|
37
|
+
return DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS
|
|
38
|
+
return None if seconds <= 0 else seconds
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GraphNode:
|
|
42
|
+
"""Engine-neutral vertex: id, labels, properties."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("id", "labels", "properties")
|
|
45
|
+
|
|
46
|
+
def __init__(self, id, labels=None, properties=None):
|
|
47
|
+
self.id = id
|
|
48
|
+
self.labels = list(labels or [])
|
|
49
|
+
self.properties = dict(properties or {})
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return {"id": self.id, "labels": self.labels, "properties": self.properties}
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return f"GraphNode(id={self.id!r}, labels={self.labels!r})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GraphEdge:
|
|
59
|
+
"""Engine-neutral edge: id, type, from/to node ids, properties."""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("id", "type", "from_id", "to_id", "properties")
|
|
62
|
+
|
|
63
|
+
def __init__(self, id, type, from_id, to_id, properties=None):
|
|
64
|
+
self.id = id
|
|
65
|
+
self.type = type
|
|
66
|
+
self.from_id = from_id
|
|
67
|
+
self.to_id = to_id
|
|
68
|
+
self.properties = dict(properties or {})
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict:
|
|
71
|
+
return {
|
|
72
|
+
"id": self.id, "type": self.type,
|
|
73
|
+
"from": self.from_id, "to": self.to_id,
|
|
74
|
+
"properties": self.properties,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def __repr__(self):
|
|
78
|
+
return f"GraphEdge(id={self.id!r}, type={self.type!r}, {self.from_id!r}->{self.to_id!r})"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GraphResult:
|
|
82
|
+
"""A raw-query result — records + columns, same shape as DatabaseResult."""
|
|
83
|
+
|
|
84
|
+
__slots__ = ("records", "columns")
|
|
85
|
+
|
|
86
|
+
def __init__(self, records=None, columns=None):
|
|
87
|
+
self.records = list(records or [])
|
|
88
|
+
self.columns = list(columns or [])
|
|
89
|
+
|
|
90
|
+
def to_array(self) -> list:
|
|
91
|
+
return self.records
|
|
92
|
+
|
|
93
|
+
def scalar(self):
|
|
94
|
+
if not self.records:
|
|
95
|
+
return None
|
|
96
|
+
first = self.records[0]
|
|
97
|
+
if isinstance(first, dict):
|
|
98
|
+
return next(iter(first.values()), None)
|
|
99
|
+
return first[0] if first else None
|
|
100
|
+
|
|
101
|
+
def __iter__(self):
|
|
102
|
+
return iter(self.records)
|
|
103
|
+
|
|
104
|
+
def __len__(self):
|
|
105
|
+
return len(self.records)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"GraphDatabase", "GraphAdapter", "GraphUrl",
|
|
110
|
+
"GraphNode", "GraphEdge", "GraphResult",
|
|
111
|
+
"GraphError", "GraphConnectTimeout",
|
|
112
|
+
"resolve_graph_connect_timeout",
|
|
113
|
+
"GRAPH_CONNECT_TIMEOUT_VARIABLE", "DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS",
|
|
114
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# GraphAdapter contract + the GraphDatabase URL-selected factory.
|
|
2
|
+
"""Every engine adapter implements GraphAdapter; GraphDatabase.create picks one
|
|
3
|
+
by URL scheme and imports its driver lazily — a missing driver raises an
|
|
4
|
+
actionable install error, never a bare ImportError, and the core imports with no
|
|
5
|
+
driver present (the zero-dependency-core rule).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from .graph_url import GraphUrl
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GraphError(Exception):
|
|
13
|
+
"""A graph operation failed (bad statement, engine error)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GraphConnectTimeout(TimeoutError):
|
|
17
|
+
"""A graph connect exceeded TINA4_GRAPH_CONNECT_TIMEOUT.
|
|
18
|
+
|
|
19
|
+
A subclass of the builtin TimeoutError, so ``except TimeoutError`` catches it
|
|
20
|
+
without importing anything from Tina4 — same as DatabaseConnectTimeout.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# engine -> (module, class, install-package, install-command). Selected lazily so
|
|
25
|
+
# importing tina4_python.graph pulls in NO engine driver.
|
|
26
|
+
_ENGINE_ADAPTERS = {
|
|
27
|
+
"ultipa": ("tina4_python.graph.adapters.ultipa", "UltipaGraphAdapter",
|
|
28
|
+
"tina4-ultipa", "uv add tina4-ultipa # or: pip install tina4-ultipa"),
|
|
29
|
+
"bolt": ("tina4_python.graph.adapters.bolt", "BoltGraphAdapter",
|
|
30
|
+
"neo4j", "uv add neo4j # or: pip install neo4j"),
|
|
31
|
+
"arango": ("tina4_python.graph.adapters.arango", "ArangoGraphAdapter",
|
|
32
|
+
"python-arango", "uv add python-arango # or: pip install python-arango"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GraphAdapter:
|
|
37
|
+
"""The one surface every graph engine implements. Raising stubs — a driver
|
|
38
|
+
that does not override a method fails LOUDLY, naming itself and the method.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# -- portable node/edge/traverse core ----------------------------------
|
|
42
|
+
def add_node(self, label, properties=None):
|
|
43
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement add_node")
|
|
44
|
+
|
|
45
|
+
def add_edge(self, from_id, to_id, type, properties=None):
|
|
46
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement add_edge")
|
|
47
|
+
|
|
48
|
+
def get_node(self, node_id):
|
|
49
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement get_node")
|
|
50
|
+
|
|
51
|
+
def update_node(self, node_id, properties):
|
|
52
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement update_node")
|
|
53
|
+
|
|
54
|
+
def delete_node(self, node_id):
|
|
55
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement delete_node")
|
|
56
|
+
|
|
57
|
+
def neighbors(self, node_id, direction="both", edge_type=None, limit=100):
|
|
58
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement neighbors")
|
|
59
|
+
|
|
60
|
+
def traverse(self, start_id, depth=1, direction="both", edge_type=None, limit=1000):
|
|
61
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement traverse")
|
|
62
|
+
|
|
63
|
+
# -- raw pass-through (engine-native dialect) --------------------------
|
|
64
|
+
def query(self, text, params=None):
|
|
65
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement query")
|
|
66
|
+
|
|
67
|
+
def execute(self, text, params=None):
|
|
68
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement execute")
|
|
69
|
+
|
|
70
|
+
# -- lifecycle ---------------------------------------------------------
|
|
71
|
+
def close(self):
|
|
72
|
+
raise NotImplementedError(f"{type(self).__name__} does not implement close")
|
|
73
|
+
|
|
74
|
+
def get_error(self):
|
|
75
|
+
"""Cause of the last failed operation, or None."""
|
|
76
|
+
return getattr(self, "_last_error", None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GraphDatabase:
|
|
80
|
+
"""URL-selected graph factory — the GraphDatabase sibling of Database."""
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def create(url: str, username: str = "", password: str = "") -> GraphAdapter:
|
|
84
|
+
"""Parse the URL, pick the engine adapter, connect lazily.
|
|
85
|
+
|
|
86
|
+
The engine driver is imported only here (first use of that engine); if it
|
|
87
|
+
is absent the error names the package and the install command.
|
|
88
|
+
"""
|
|
89
|
+
graph_url = GraphUrl(url)
|
|
90
|
+
registration = _ENGINE_ADAPTERS.get(graph_url.engine)
|
|
91
|
+
if registration is None:
|
|
92
|
+
raise GraphError(
|
|
93
|
+
f"No graph adapter for engine {graph_url.engine!r} yet "
|
|
94
|
+
f"(scheme {graph_url.scheme!r}). Available: "
|
|
95
|
+
f"{', '.join(sorted(_ENGINE_ADAPTERS))}."
|
|
96
|
+
)
|
|
97
|
+
module_path, class_name, package, install_cmd = registration
|
|
98
|
+
try:
|
|
99
|
+
import importlib
|
|
100
|
+
module = importlib.import_module(module_path)
|
|
101
|
+
except ImportError as exc:
|
|
102
|
+
# The adapter module imports its driver at module top; a missing
|
|
103
|
+
# driver surfaces here as an actionable install error.
|
|
104
|
+
raise GraphError(
|
|
105
|
+
f"The graph driver for {graph_url.engine!r} is not installed "
|
|
106
|
+
f"({package}). Install it with:\n {install_cmd}"
|
|
107
|
+
) from exc
|
|
108
|
+
adapter_class = getattr(module, class_name)
|
|
109
|
+
return adapter_class(graph_url, username=username, password=password)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def from_env(env_key: str = "TINA4_GRAPH_URL") -> "GraphAdapter | None":
|
|
113
|
+
"""Build from TINA4_GRAPH_URL (+ TINA4_GRAPH_USERNAME/_PASSWORD)."""
|
|
114
|
+
url = os.environ.get(env_key)
|
|
115
|
+
if not url:
|
|
116
|
+
return None
|
|
117
|
+
username = os.environ.get("TINA4_GRAPH_USERNAME", "")
|
|
118
|
+
password = os.environ.get("TINA4_GRAPH_PASSWORD", "")
|
|
119
|
+
return GraphDatabase.create(url, username=username, password=password)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Graph engine adapters — imported lazily by GraphDatabase.create (one per engine).
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# ArangoDB graph adapter — the document/AQL engine behind the same surface.
|
|
2
|
+
"""Wraps the community `python-arango` driver (an OPTIONAL dependency — imported
|
|
3
|
+
here, so `import tina4_python.graph` stays driver-free). Arango is a document
|
|
4
|
+
store, not a labelled-property graph, so the portable core maps onto ONE vertex
|
|
5
|
+
collection + ONE edge collection: a node's `labels` and an edge's `type` are
|
|
6
|
+
stored as document fields, ids are Arango `_id` handles (e.g. `tina4_nodes/123`),
|
|
7
|
+
and traversal uses AQL `FOR v IN 1..N OUTBOUND ...`. Raw query()/execute() take
|
|
8
|
+
AQL directly.
|
|
9
|
+
"""
|
|
10
|
+
from arango import ArangoClient
|
|
11
|
+
from arango.exceptions import ArangoError, ArangoClientError, ArangoServerError
|
|
12
|
+
|
|
13
|
+
from tina4_python.graph import (
|
|
14
|
+
GraphNode, GraphEdge, GraphResult, resolve_graph_connect_timeout,
|
|
15
|
+
)
|
|
16
|
+
from tina4_python.graph.adapter import GraphAdapter, GraphError, GraphConnectTimeout
|
|
17
|
+
|
|
18
|
+
VERTEX_COLLECTION = "tina4_nodes"
|
|
19
|
+
EDGE_COLLECTION = "tina4_edges"
|
|
20
|
+
_RESERVED = {"_id", "_key", "_rev", "_from", "_to", "_labels", "_type"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clean_props(doc):
|
|
24
|
+
return {k: v for k, v in doc.items() if k not in _RESERVED}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ArangoGraphAdapter(GraphAdapter):
|
|
28
|
+
def __init__(self, graph_url, username="", password=""):
|
|
29
|
+
self._url = graph_url
|
|
30
|
+
self._last_error = None
|
|
31
|
+
timeout = resolve_graph_connect_timeout()
|
|
32
|
+
scheme = "https" if graph_url.use_tls else "http"
|
|
33
|
+
self._client = ArangoClient(
|
|
34
|
+
hosts=f"{scheme}://{graph_url.host}:{graph_url.port}",
|
|
35
|
+
request_timeout=timeout,
|
|
36
|
+
)
|
|
37
|
+
user = graph_url.username or username or "root"
|
|
38
|
+
pwd = graph_url.password or password or ""
|
|
39
|
+
database = graph_url.graph or "_system"
|
|
40
|
+
try:
|
|
41
|
+
self._db = self._client.db(database, username=user, password=pwd)
|
|
42
|
+
if not self._db.has_collection(VERTEX_COLLECTION):
|
|
43
|
+
self._db.create_collection(VERTEX_COLLECTION)
|
|
44
|
+
if not self._db.has_collection(EDGE_COLLECTION):
|
|
45
|
+
self._db.create_collection(EDGE_COLLECTION, edge=True)
|
|
46
|
+
except (ArangoClientError, ArangoServerError) as exc:
|
|
47
|
+
self._last_error = str(exc)
|
|
48
|
+
raise self._connect_or_error(exc)
|
|
49
|
+
|
|
50
|
+
def _connect_or_error(self, exc):
|
|
51
|
+
text = str(exc).lower()
|
|
52
|
+
if "timed out" in text or "connection" in text or "max retries" in text:
|
|
53
|
+
return GraphConnectTimeout(
|
|
54
|
+
f"Graph connect to {self._url.host}:{self._url.port} timed out "
|
|
55
|
+
f"(TINA4_GRAPH_CONNECT_TIMEOUT). Raise it if the server is simply "
|
|
56
|
+
f"slow, or set it to 0 to wait indefinitely."
|
|
57
|
+
)
|
|
58
|
+
return GraphError(str(exc))
|
|
59
|
+
|
|
60
|
+
def _aql(self, query, bind=None):
|
|
61
|
+
try:
|
|
62
|
+
return list(self._db.aql.execute(query, bind_vars=bind or {}))
|
|
63
|
+
except (ArangoClientError, ArangoServerError) as exc:
|
|
64
|
+
self._last_error = str(exc)
|
|
65
|
+
err = self._connect_or_error(exc)
|
|
66
|
+
raise err
|
|
67
|
+
except ArangoError as exc:
|
|
68
|
+
self._last_error = str(exc)
|
|
69
|
+
raise GraphError(str(exc)) from exc
|
|
70
|
+
|
|
71
|
+
def query(self, text, params=None):
|
|
72
|
+
rows = self._aql(text, params)
|
|
73
|
+
columns = list(rows[0].keys()) if rows and isinstance(rows[0], dict) else []
|
|
74
|
+
return GraphResult(records=rows, columns=columns)
|
|
75
|
+
|
|
76
|
+
def execute(self, text, params=None):
|
|
77
|
+
return self.query(text, params)
|
|
78
|
+
|
|
79
|
+
def _node(self, doc):
|
|
80
|
+
if doc is None:
|
|
81
|
+
return None
|
|
82
|
+
return GraphNode(id=doc.get("_id"), labels=doc.get("_labels") or [],
|
|
83
|
+
properties=_clean_props(doc))
|
|
84
|
+
|
|
85
|
+
def add_node(self, label, properties=None):
|
|
86
|
+
doc = dict(properties or {})
|
|
87
|
+
doc["_labels"] = [label]
|
|
88
|
+
rows = self._aql(
|
|
89
|
+
f"INSERT @doc INTO {VERTEX_COLLECTION} RETURN NEW", {"doc": doc})
|
|
90
|
+
return self._node(rows[0]) if rows else None
|
|
91
|
+
|
|
92
|
+
def add_edge(self, from_id, to_id, type, properties=None):
|
|
93
|
+
doc = dict(properties or {})
|
|
94
|
+
doc.update({"_from": from_id, "_to": to_id, "_type": type})
|
|
95
|
+
rows = self._aql(
|
|
96
|
+
f"INSERT @doc INTO {EDGE_COLLECTION} RETURN NEW", {"doc": doc})
|
|
97
|
+
if not rows:
|
|
98
|
+
return None
|
|
99
|
+
r = rows[0]
|
|
100
|
+
return GraphEdge(id=r.get("_id"), type=r.get("_type"), from_id=r.get("_from"),
|
|
101
|
+
to_id=r.get("_to"), properties=_clean_props(r))
|
|
102
|
+
|
|
103
|
+
def get_node(self, node_id):
|
|
104
|
+
rows = self._aql("RETURN DOCUMENT(@id)", {"id": node_id})
|
|
105
|
+
return self._node(rows[0]) if rows and rows[0] else None
|
|
106
|
+
|
|
107
|
+
def update_node(self, node_id, properties):
|
|
108
|
+
rows = self._aql(
|
|
109
|
+
f"UPDATE PARSE_IDENTIFIER(@id).key WITH @props IN {VERTEX_COLLECTION} RETURN NEW",
|
|
110
|
+
{"id": node_id, "props": properties or {}})
|
|
111
|
+
return self._node(rows[0]) if rows else None
|
|
112
|
+
|
|
113
|
+
def delete_node(self, node_id):
|
|
114
|
+
# remove the node and any edges touching it, so a re-read is a clean miss.
|
|
115
|
+
self._aql(
|
|
116
|
+
f"FOR e IN {EDGE_COLLECTION} FILTER e._from == @id OR e._to == @id "
|
|
117
|
+
f"REMOVE e IN {EDGE_COLLECTION}", {"id": node_id})
|
|
118
|
+
self._aql(
|
|
119
|
+
f"REMOVE PARSE_IDENTIFIER(@id).key IN {VERTEX_COLLECTION}", {"id": node_id})
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def neighbors(self, node_id, direction="both", edge_type=None, limit=100):
|
|
123
|
+
arango_dir = {"out": "OUTBOUND", "in": "INBOUND", "both": "ANY"}.get(direction, "ANY")
|
|
124
|
+
type_filter = "FILTER e._type == @etype " if edge_type else ""
|
|
125
|
+
bind = {"start": node_id, "limit": int(limit)}
|
|
126
|
+
if edge_type:
|
|
127
|
+
bind["etype"] = edge_type
|
|
128
|
+
rows = self._aql(
|
|
129
|
+
f"FOR v, e IN 1..1 {arango_dir} @start {EDGE_COLLECTION} "
|
|
130
|
+
f"{type_filter}LIMIT @limit RETURN DISTINCT v", bind)
|
|
131
|
+
return [self._node(v) for v in rows]
|
|
132
|
+
|
|
133
|
+
def traverse(self, start_id, depth=1, direction="both", edge_type=None, limit=1000):
|
|
134
|
+
arango_dir = {"out": "OUTBOUND", "in": "INBOUND", "both": "ANY"}.get(direction, "ANY")
|
|
135
|
+
type_filter = "FILTER e._type == @etype " if edge_type else ""
|
|
136
|
+
bind = {"start": start_id, "limit": int(limit)}
|
|
137
|
+
if edge_type:
|
|
138
|
+
bind["etype"] = edge_type
|
|
139
|
+
rows = self._aql(
|
|
140
|
+
f"FOR v, e IN 1..{int(depth)} {arango_dir} @start {EDGE_COLLECTION} "
|
|
141
|
+
f"{type_filter}LIMIT @limit RETURN DISTINCT v", bind)
|
|
142
|
+
return [self._node(v) for v in rows]
|
|
143
|
+
|
|
144
|
+
def close(self):
|
|
145
|
+
self._client.close()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Bolt graph adapter — Neo4j AND Memgraph (both speak Bolt + Cypher).
|
|
2
|
+
"""Wraps the community `neo4j` Python driver (an OPTIONAL dependency — imported
|
|
3
|
+
here, so `import tina4_python.graph` stays driver-free). Neo4j and Memgraph share
|
|
4
|
+
this ONE adapter; the URL scheme only picks the engine label and default port.
|
|
5
|
+
|
|
6
|
+
Cypher note (verified against live Neo4j + Memgraph): `id(n)` is the portable
|
|
7
|
+
node/edge id (an integer on both — Neo4j's `elementId` is Neo4j-only, Memgraph
|
|
8
|
+
has no `elementId`); variable-length traversal is Cypher's `[*1..N]` (the
|
|
9
|
+
opposite of Ultipa's GQL `{1,N}`); `SET n += $props` merges.
|
|
10
|
+
"""
|
|
11
|
+
from neo4j import GraphDatabase as _Neo4jDriver
|
|
12
|
+
from neo4j.exceptions import Neo4jError, ServiceUnavailable, DriverError
|
|
13
|
+
|
|
14
|
+
from tina4_python.graph import (
|
|
15
|
+
GraphNode, GraphEdge, GraphResult, resolve_graph_connect_timeout,
|
|
16
|
+
)
|
|
17
|
+
from tina4_python.graph.adapter import GraphAdapter, GraphError, GraphConnectTimeout
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BoltGraphAdapter(GraphAdapter):
|
|
21
|
+
def __init__(self, graph_url, username="", password=""):
|
|
22
|
+
self._url = graph_url
|
|
23
|
+
self._database = graph_url.graph or None
|
|
24
|
+
self._last_error = None
|
|
25
|
+
user = graph_url.username or username or "neo4j"
|
|
26
|
+
pwd = graph_url.password or password or ""
|
|
27
|
+
scheme = "bolt+s" if graph_url.use_tls else "bolt"
|
|
28
|
+
uri = f"{scheme}://{graph_url.host}:{graph_url.port}"
|
|
29
|
+
timeout = resolve_graph_connect_timeout()
|
|
30
|
+
cfg = {}
|
|
31
|
+
if timeout is not None:
|
|
32
|
+
cfg["connection_timeout"] = timeout
|
|
33
|
+
cfg["connection_acquisition_timeout"] = timeout
|
|
34
|
+
cfg["max_transaction_retry_time"] = timeout
|
|
35
|
+
self._driver = _Neo4jDriver.driver(uri, auth=(user, pwd), **cfg)
|
|
36
|
+
|
|
37
|
+
def _run(self, cypher, params=None):
|
|
38
|
+
try:
|
|
39
|
+
with self._driver.session(database=self._database) as session:
|
|
40
|
+
result = session.run(cypher, parameters=params or {})
|
|
41
|
+
return [record.data() for record in result]
|
|
42
|
+
except (ServiceUnavailable, DriverError) as exc:
|
|
43
|
+
self._last_error = str(exc)
|
|
44
|
+
# An unreachable host surfaces here as a service-unavailable/timeout.
|
|
45
|
+
if "timed out" in str(exc).lower() or isinstance(exc, ServiceUnavailable):
|
|
46
|
+
raise GraphConnectTimeout(
|
|
47
|
+
f"Graph connect to {self._url.host}:{self._url.port} timed out "
|
|
48
|
+
f"(TINA4_GRAPH_CONNECT_TIMEOUT). Raise it if the server is simply "
|
|
49
|
+
f"slow, or set it to 0 to wait indefinitely."
|
|
50
|
+
) from exc
|
|
51
|
+
raise GraphError(str(exc)) from exc
|
|
52
|
+
except Neo4jError as exc:
|
|
53
|
+
self._last_error = str(exc)
|
|
54
|
+
raise GraphError(str(exc)) from exc
|
|
55
|
+
|
|
56
|
+
def query(self, text, params=None):
|
|
57
|
+
rows = self._run(text, params)
|
|
58
|
+
columns = list(rows[0].keys()) if rows else []
|
|
59
|
+
return GraphResult(records=rows, columns=columns)
|
|
60
|
+
|
|
61
|
+
def execute(self, text, params=None):
|
|
62
|
+
return self.query(text, params)
|
|
63
|
+
|
|
64
|
+
def _node(self, row):
|
|
65
|
+
if row is None:
|
|
66
|
+
return None
|
|
67
|
+
return GraphNode(id=row.get("id"), labels=row.get("labels"),
|
|
68
|
+
properties=row.get("props") or {})
|
|
69
|
+
|
|
70
|
+
def add_node(self, label, properties=None):
|
|
71
|
+
cypher = (f"CREATE (n:`{label}` $props) "
|
|
72
|
+
f"RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props")
|
|
73
|
+
rows = self._run(cypher, {"props": properties or {}})
|
|
74
|
+
return self._node(rows[0]) if rows else None
|
|
75
|
+
|
|
76
|
+
def add_edge(self, from_id, to_id, type, properties=None):
|
|
77
|
+
cypher = (f"MATCH (a), (b) WHERE id(a) = $from_id AND id(b) = $to_id "
|
|
78
|
+
f"CREATE (a)-[e:`{type}` $props]->(b) "
|
|
79
|
+
f"RETURN id(e) AS id, type(e) AS type, id(a) AS f, id(b) AS t, "
|
|
80
|
+
f"properties(e) AS props")
|
|
81
|
+
rows = self._run(cypher, {"from_id": from_id, "to_id": to_id, "props": properties or {}})
|
|
82
|
+
if not rows:
|
|
83
|
+
return None
|
|
84
|
+
r = rows[0]
|
|
85
|
+
return GraphEdge(id=r.get("id"), type=r.get("type"), from_id=r.get("f"),
|
|
86
|
+
to_id=r.get("t"), properties=r.get("props") or {})
|
|
87
|
+
|
|
88
|
+
def get_node(self, node_id):
|
|
89
|
+
cypher = ("MATCH (n) WHERE id(n) = $id "
|
|
90
|
+
"RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props")
|
|
91
|
+
rows = self._run(cypher, {"id": node_id})
|
|
92
|
+
return self._node(rows[0]) if rows else None
|
|
93
|
+
|
|
94
|
+
def update_node(self, node_id, properties):
|
|
95
|
+
cypher = ("MATCH (n) WHERE id(n) = $id SET n += $props "
|
|
96
|
+
"RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props")
|
|
97
|
+
rows = self._run(cypher, {"id": node_id, "props": properties or {}})
|
|
98
|
+
return self._node(rows[0]) if rows else None
|
|
99
|
+
|
|
100
|
+
def delete_node(self, node_id):
|
|
101
|
+
self._run("MATCH (n) WHERE id(n) = $id DETACH DELETE n", {"id": node_id})
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def neighbors(self, node_id, direction="both", edge_type=None, limit=100):
|
|
105
|
+
edge = f":`{edge_type}`" if edge_type else ""
|
|
106
|
+
pattern = {
|
|
107
|
+
"out": f"(n)-[{edge}]->(m)",
|
|
108
|
+
"in": f"(n)<-[{edge}]-(m)",
|
|
109
|
+
"both": f"(n)-[{edge}]-(m)",
|
|
110
|
+
}.get(direction, f"(n)-[{edge}]-(m)")
|
|
111
|
+
cypher = (f"MATCH {pattern} WHERE id(n) = $id "
|
|
112
|
+
f"RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props "
|
|
113
|
+
f"LIMIT {int(limit)}")
|
|
114
|
+
return [self._node(r) for r in self._run(cypher, {"id": node_id})]
|
|
115
|
+
|
|
116
|
+
def traverse(self, start_id, depth=1, direction="both", edge_type=None, limit=1000):
|
|
117
|
+
# Cypher variable-length path `[*1..N]` — Neo4j AND Memgraph.
|
|
118
|
+
edge = f":`{edge_type}`" if edge_type else ""
|
|
119
|
+
arrow = {"out": f"-[{edge}*1..{int(depth)}]->", "in": f"<-[{edge}*1..{int(depth)}]-",
|
|
120
|
+
"both": f"-[{edge}*1..{int(depth)}]-"}.get(direction, f"-[{edge}*1..{int(depth)}]-")
|
|
121
|
+
cypher = (f"MATCH (n){arrow}(m) WHERE id(n) = $start "
|
|
122
|
+
f"RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props "
|
|
123
|
+
f"LIMIT {int(limit)}")
|
|
124
|
+
return [self._node(r) for r in self._run(cypher, {"start": start_id})]
|
|
125
|
+
|
|
126
|
+
def close(self):
|
|
127
|
+
self._driver.close()
|