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.
Files changed (169) hide show
  1. {tina4_python-3.13.110 → tina4_python-3.13.111}/PKG-INFO +13 -1
  2. {tina4_python-3.13.110 → tina4_python-3.13.111}/pyproject.toml +8 -1
  3. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/__init__.py +1 -1
  4. tina4_python-3.13.111/tina4_python/graph/__init__.py +114 -0
  5. tina4_python-3.13.111/tina4_python/graph/adapter.py +119 -0
  6. tina4_python-3.13.111/tina4_python/graph/adapters/__init__.py +1 -0
  7. tina4_python-3.13.111/tina4_python/graph/adapters/arango.py +145 -0
  8. tina4_python-3.13.111/tina4_python/graph/adapters/bolt.py +127 -0
  9. tina4_python-3.13.111/tina4_python/graph/adapters/ultipa.py +158 -0
  10. tina4_python-3.13.111/tina4_python/graph/graph_url.py +67 -0
  11. {tina4_python-3.13.110 → tina4_python-3.13.111}/.gitignore +0 -0
  12. {tina4_python-3.13.110 → tina4_python-3.13.111}/README.md +0 -0
  13. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/CLAUDE.md +0 -0
  14. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/HtmlElement.py +0 -0
  15. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/Testing.py +0 -0
  16. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/ai/__init__.py +0 -0
  17. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/ai/client.py +0 -0
  18. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/api/__init__.py +0 -0
  19. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/auth/__init__.py +0 -0
  20. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/cache/__init__.py +0 -0
  21. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/cli/__init__.py +0 -0
  22. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/container/__init__.py +0 -0
  23. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/context/__init__.py +0 -0
  24. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/context/chunker.py +0 -0
  25. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/__init__.py +0 -0
  26. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/cache.py +0 -0
  27. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/constants.py +0 -0
  28. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/events.py +0 -0
  29. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/middleware.py +0 -0
  30. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/port_takeover.py +0 -0
  31. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/rate_limiter.py +0 -0
  32. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/request.py +0 -0
  33. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/response.py +0 -0
  34. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/router.py +0 -0
  35. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/core/server.py +0 -0
  36. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/crud/__init__.py +0 -0
  37. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/__init__.py +0 -0
  38. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/adapter.py +0 -0
  39. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/connection.py +0 -0
  40. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/database_url.py +0 -0
  41. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/firebird.py +0 -0
  42. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/mongodb.py +0 -0
  43. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/mssql.py +0 -0
  44. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/mysql.py +0 -0
  45. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/odbc.py +0 -0
  46. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/postgres.py +0 -0
  47. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/sql_translator.py +0 -0
  48. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/database/sqlite.py +0 -0
  49. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/debug/__init__.py +0 -0
  50. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/debug/error_overlay.py +0 -0
  51. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/__init__.py +0 -0
  52. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/metrics.py +0 -0
  53. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/plan.py +0 -0
  54. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dev_admin/project_index.py +0 -0
  55. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/docs.py +0 -0
  56. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/docstore/__init__.py +0 -0
  57. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/dotenv/__init__.py +0 -0
  58. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/env.py +0 -0
  59. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/FROND.md +0 -0
  60. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/__init__.py +0 -0
  61. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/compiler.py +0 -0
  62. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/engine.py +0 -0
  63. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/frond/parser.py +0 -0
  64. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/auth/meta.json +0 -0
  65. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  66. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/database/meta.json +0 -0
  67. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  68. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/error-overlay/meta.json +0 -0
  69. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  70. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/orm/meta.json +0 -0
  71. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  72. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  73. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/queue/meta.json +0 -0
  74. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  75. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/rest-api/meta.json +0 -0
  76. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  77. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/templates/meta.json +0 -0
  78. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  79. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  80. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/graphql/__init__.py +0 -0
  81. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/i18n/__init__.py +0 -0
  82. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mcp/__init__.py +0 -0
  83. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mcp/protocol.py +0 -0
  84. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mcp/tools.py +0 -0
  85. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/messenger/__init__.py +0 -0
  86. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/migration/__init__.py +0 -0
  87. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/migration/runner.py +0 -0
  88. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mqtt/__init__.py +0 -0
  89. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/mqtt/message.py +0 -0
  90. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/__init__.py +0 -0
  91. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/fields.py +0 -0
  92. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/model.py +0 -0
  93. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/orm/point.py +0 -0
  94. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/__feedback/widget.js +0 -0
  95. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/css/tina4.css +0 -0
  96. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/css/tina4.min.css +0 -0
  97. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/favicon.ico +0 -0
  98. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/images/logo.svg +0 -0
  99. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  100. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/frond.js +0 -0
  101. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/frond.min.js +0 -0
  102. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  103. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/tina4.min.js +0 -0
  104. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/js/tina4js.min.js +0 -0
  105. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/swagger/index.html +0 -0
  106. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  107. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/query_builder/__init__.py +0 -0
  108. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/__init__.py +0 -0
  109. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/amqp_url.py +0 -0
  110. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/job.py +0 -0
  111. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/kafka_backend.py +0 -0
  112. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/lite_backend.py +0 -0
  113. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/mongo_backend.py +0 -0
  114. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue/rabbitmq_backend.py +0 -0
  115. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/__init__.py +0 -0
  116. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/kafka_backend.py +0 -0
  117. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/mongo_backend.py +0 -0
  118. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  119. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/__init__.py +0 -0
  120. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Attachment.py +0 -0
  121. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Channel.py +0 -0
  122. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/ChannelMember.py +0 -0
  123. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Message.py +0 -0
  124. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/Workspace.py +0 -0
  125. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/models/__init__.py +0 -0
  126. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/realtime/storage.py +0 -0
  127. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/seeder/__init__.py +0 -0
  128. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/service/__init__.py +0 -0
  129. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session/__init__.py +0 -0
  130. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/__init__.py +0 -0
  131. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/memcached_handler.py +0 -0
  132. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  133. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/redis_handler.py +0 -0
  134. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/session_handlers/valkey_handler.py +0 -0
  135. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/sso.py +0 -0
  136. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/swagger/__init__.py +0 -0
  137. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/components/crud.twig +0 -0
  138. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  139. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  140. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/python/Dockerfile +0 -0
  141. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  142. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/302.twig +0 -0
  143. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/401.twig +0 -0
  144. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/403.twig +0 -0
  145. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/404.twig +0 -0
  146. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/500.twig +0 -0
  147. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/502.twig +0 -0
  148. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/503.twig +0 -0
  149. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/errors/base.twig +0 -0
  150. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/frontend/README.md +0 -0
  151. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/templates/readme.md +0 -0
  152. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/test/__init__.py +0 -0
  153. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/test_client/__init__.py +0 -0
  154. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  161. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  162. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  163. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  164. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  165. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  166. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/validator/__init__.py +0 -0
  167. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/websocket/__init__.py +0 -0
  168. {tina4_python-3.13.110 → tina4_python-3.13.111}/tina4_python/websocket/backplane.py +0 -0
  169. {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.110
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.110"
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"]
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.110"
54
+ return "3.13.111"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -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()