tina4-python 3.13.42__tar.gz → 3.13.44__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.42 → tina4_python-3.13.44}/PKG-INFO +1 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/pyproject.toml +1 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/middleware.py +7 -4
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/request.py +5 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/server.py +3 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/adapter.py +65 -5
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/mssql.py +13 -2
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/mysql.py +8 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/postgres.py +18 -2
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/sqlite.py +21 -4
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/dev_admin/__init__.py +11 -11
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/migration/runner.py +18 -5
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue/__init__.py +31 -8
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue/kafka_backend.py +18 -8
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue/mongo_backend.py +20 -7
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue/rabbitmq_backend.py +18 -8
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue_backends/kafka_backend.py +22 -3
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue_backends/mongo_backend.py +23 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue_backends/rabbitmq_backend.py +45 -7
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/session/__init__.py +5 -1
- {tina4_python-3.13.42 → tina4_python-3.13.44}/.gitignore +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/README.md +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/env.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.42 → tina4_python-3.13.44}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -284,10 +284,13 @@ class CsrfMiddleware:
|
|
|
284
284
|
@staticmethod
|
|
285
285
|
def before_csrf(request, response):
|
|
286
286
|
"""Validate CSRF token before the route handler runs."""
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
#
|
|
290
|
-
#
|
|
287
|
+
# TINA4_CSRF=false (or 0/no) disables all CSRF checks, even when the
|
|
288
|
+
# middleware is attached explicitly — this is the documented kill
|
|
289
|
+
# switch ("TINA4_CSRF=false disables all checks"). When unset the
|
|
290
|
+
# default is enabled (true).
|
|
291
|
+
csrf_enabled = os.environ.get("TINA4_CSRF", "true").lower() not in ("false", "0", "no")
|
|
292
|
+
if not csrf_enabled:
|
|
293
|
+
return request, response
|
|
291
294
|
|
|
292
295
|
# Skip safe HTTP methods
|
|
293
296
|
method = getattr(request, "method", "GET").upper()
|
|
@@ -72,7 +72,7 @@ class Request:
|
|
|
72
72
|
__slots__ = (
|
|
73
73
|
"method", "path", "url", "query_string", "params", "query", "headers",
|
|
74
74
|
"body", "raw_body", "cookies", "files", "ip", "remote_ip",
|
|
75
|
-
"content_type", "session", "_route_params",
|
|
75
|
+
"content_type", "session", "_route_params", "_handler",
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
def __init__(self):
|
|
@@ -92,6 +92,10 @@ class Request:
|
|
|
92
92
|
self.content_type: str = ""
|
|
93
93
|
self.session = None # Set by session middleware
|
|
94
94
|
self._route_params: dict = {} # Dynamic route params ({id}, etc.)
|
|
95
|
+
self._handler = None # Matched route handler — set by dispatch
|
|
96
|
+
# before middleware runs, so before_*
|
|
97
|
+
# middleware (e.g. CsrfMiddleware) can read
|
|
98
|
+
# handler metadata like _noauth.
|
|
95
99
|
|
|
96
100
|
@classmethod
|
|
97
101
|
def from_scope(cls, scope: dict, body: bytes = b"") -> "Request":
|
|
@@ -1753,6 +1753,9 @@ async def handle(request: Request) -> Response:
|
|
|
1753
1753
|
if route:
|
|
1754
1754
|
request._route_params = params
|
|
1755
1755
|
request.merge_route_params()
|
|
1756
|
+
# Expose the matched handler on the request so before_* middleware
|
|
1757
|
+
# (e.g. CsrfMiddleware) can read handler metadata such as _noauth.
|
|
1758
|
+
request._handler = route.get("handler")
|
|
1756
1759
|
try:
|
|
1757
1760
|
skip = _check_auth(request, response, route)
|
|
1758
1761
|
if not skip:
|
|
@@ -351,13 +351,32 @@ class DatabaseAdapter:
|
|
|
351
351
|
["Alice"], ["Bob"], ["Eve"]
|
|
352
352
|
])
|
|
353
353
|
"""
|
|
354
|
+
rows = params_list or []
|
|
355
|
+
if not rows:
|
|
356
|
+
return DatabaseResult(affected_rows=0)
|
|
357
|
+
# Run the whole batch in ONE transaction on ONE connection so it is
|
|
358
|
+
# atomic AND affected_rows/last_id are reliable. In autocommit mode each
|
|
359
|
+
# standalone execute() commits on its own (possibly different, pooled)
|
|
360
|
+
# connection, which scattered the per-row rowcount / last_insert_id and
|
|
361
|
+
# made the aggregate non-deterministic. When already inside an explicit
|
|
362
|
+
# transaction we just join it (never nest).
|
|
363
|
+
owns_txn = self._autocommit and not getattr(self, "_in_transaction", False)
|
|
364
|
+
if owns_txn:
|
|
365
|
+
self.start_transaction()
|
|
354
366
|
total_affected = 0
|
|
355
367
|
last_id = None
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
368
|
+
try:
|
|
369
|
+
for params in rows:
|
|
370
|
+
result = self.execute(sql, params)
|
|
371
|
+
total_affected += result.affected_rows
|
|
372
|
+
if result.last_id is not None:
|
|
373
|
+
last_id = result.last_id
|
|
374
|
+
if owns_txn:
|
|
375
|
+
self.commit()
|
|
376
|
+
except Exception:
|
|
377
|
+
if owns_txn:
|
|
378
|
+
self.rollback()
|
|
379
|
+
raise
|
|
361
380
|
return DatabaseResult(
|
|
362
381
|
affected_rows=total_affected,
|
|
363
382
|
last_id=last_id,
|
|
@@ -389,6 +408,47 @@ class DatabaseAdapter:
|
|
|
389
408
|
stripped = stripped[:-1].rstrip()
|
|
390
409
|
return stripped
|
|
391
410
|
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _strip_trailing_order_by(sql: str) -> str:
|
|
413
|
+
"""Strip a trailing top-level ``ORDER BY`` so the SQL can be safely
|
|
414
|
+
wrapped in ``SELECT COUNT(*) FROM (<sql>)`` for the row-count probe.
|
|
415
|
+
|
|
416
|
+
SQL Server rejects an ``ORDER BY`` inside a derived-table subquery
|
|
417
|
+
unless it carries ``TOP``/``OFFSET``/``FETCH`` (error 20018), which
|
|
418
|
+
silently zeroed the MSSQL count probe for any query ending in
|
|
419
|
+
``ORDER BY`` (issue #262 -- the bug existed in this master adapter too,
|
|
420
|
+
not only the mirrors). ``ORDER BY`` does not affect ``COUNT(*)``, so
|
|
421
|
+
dropping it for the probe ONLY is safe; the paginated query keeps its
|
|
422
|
+
``ORDER BY`` for ``OFFSET/FETCH``. An ``ORDER BY`` nested in a subquery,
|
|
423
|
+
or one already legalised by a following ``OFFSET``/``FETCH``/``FOR``, is
|
|
424
|
+
left intact. Parity with PHP ``SqlNormalizerTrait::stripTrailingOrderBy``.
|
|
425
|
+
"""
|
|
426
|
+
if not sql or not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
|
|
427
|
+
return sql
|
|
428
|
+
last_top_level = -1
|
|
429
|
+
for match in re.finditer(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
|
|
430
|
+
pos = match.start()
|
|
431
|
+
before = sql[:pos]
|
|
432
|
+
balanced_before = before.count("(") == before.count(")")
|
|
433
|
+
depth = 0
|
|
434
|
+
balanced_after = True
|
|
435
|
+
for ch in sql[pos:]:
|
|
436
|
+
if ch == "(":
|
|
437
|
+
depth += 1
|
|
438
|
+
elif ch == ")":
|
|
439
|
+
depth -= 1
|
|
440
|
+
if depth < 0:
|
|
441
|
+
balanced_after = False
|
|
442
|
+
break
|
|
443
|
+
if balanced_before and balanced_after:
|
|
444
|
+
last_top_level = pos
|
|
445
|
+
if last_top_level == -1:
|
|
446
|
+
return sql
|
|
447
|
+
tail = sql[last_top_level:]
|
|
448
|
+
if re.search(r"\b(?:OFFSET|FETCH|FOR)\b", tail, re.IGNORECASE):
|
|
449
|
+
return sql
|
|
450
|
+
return sql[:last_top_level].rstrip()
|
|
451
|
+
|
|
392
452
|
@staticmethod
|
|
393
453
|
def _split_schema(name: str) -> tuple[str | None, str]:
|
|
394
454
|
"""Split a possibly-qualified table name into (schema, table).
|
|
@@ -116,7 +116,11 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
116
116
|
# the main cursor half-consumed, and the main query below is
|
|
117
117
|
# deliberately NOT wrapped so its error FAILS LOUD (parity with
|
|
118
118
|
# execute()) instead of looking like "no rows".
|
|
119
|
-
|
|
119
|
+
# Strip a trailing top-level ORDER BY before wrapping: SQL Server rejects
|
|
120
|
+
# ORDER BY in a derived-table subquery without TOP/OFFSET/FETCH (#262),
|
|
121
|
+
# which otherwise zeroed the count for any query ending in ORDER BY. The
|
|
122
|
+
# paginated query below keeps its ORDER BY.
|
|
123
|
+
count_sql = f"SELECT COUNT(*) AS cnt FROM ({self._strip_trailing_order_by(sql)}) AS _count_subquery"
|
|
120
124
|
probe = self._conn.cursor(as_dict=True)
|
|
121
125
|
try:
|
|
122
126
|
probe.execute(count_sql, tuple(params) if params else ())
|
|
@@ -155,7 +159,14 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
155
159
|
row = cursor.fetchone()
|
|
156
160
|
return dict(row) if row else None
|
|
157
161
|
|
|
158
|
-
def insert(self, table: str, data: dict) -> DatabaseResult:
|
|
162
|
+
def insert(self, table: str, data: dict | list) -> DatabaseResult:
|
|
163
|
+
# A list of dicts is a batch insert — delegate to the base class, which
|
|
164
|
+
# builds one parameterised INSERT and runs it per row via execute_many.
|
|
165
|
+
# (Database.insert / the docs advertise ``data: dict | list``; without
|
|
166
|
+
# this branch a list crashed with ``'list' object has no attribute
|
|
167
|
+
# 'keys'`` because this override only handled the single-dict case.)
|
|
168
|
+
if isinstance(data, list):
|
|
169
|
+
return super().insert(table, data)
|
|
159
170
|
columns = ", ".join(data.keys())
|
|
160
171
|
placeholders = ", ".join(["%s"] * len(data))
|
|
161
172
|
sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
|
|
@@ -140,7 +140,14 @@ class MySQLAdapter(DatabaseAdapter):
|
|
|
140
140
|
row = cursor.fetchone()
|
|
141
141
|
return dict(row) if row else None
|
|
142
142
|
|
|
143
|
-
def insert(self, table: str, data: dict) -> DatabaseResult:
|
|
143
|
+
def insert(self, table: str, data: dict | list) -> DatabaseResult:
|
|
144
|
+
# A list of dicts is a batch insert — delegate to the base class, which
|
|
145
|
+
# builds one parameterised INSERT and runs it per row via execute_many.
|
|
146
|
+
# (Database.insert / the docs advertise ``data: dict | list``; without
|
|
147
|
+
# this branch a list crashed with ``'list' object has no attribute
|
|
148
|
+
# 'keys'`` because this override only handled the single-dict case.)
|
|
149
|
+
if isinstance(data, list):
|
|
150
|
+
return super().insert(table, data)
|
|
144
151
|
columns = ", ".join(data.keys())
|
|
145
152
|
placeholders = ", ".join(["%s"] * len(data))
|
|
146
153
|
sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
|
|
@@ -283,6 +283,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
283
283
|
cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
284
284
|
self._exec_with_handling(cursor, sql, params)
|
|
285
285
|
|
|
286
|
+
# Capture rowcount NOW, before the lastval()/SAVEPOINT probes below run
|
|
287
|
+
# their own cursor.execute() calls and overwrite cursor.rowcount. A
|
|
288
|
+
# no-RETURNING INSERT was reporting affected_rows=0 because those probe
|
|
289
|
+
# statements clobbered the INSERT's rowcount before it was read at the end.
|
|
290
|
+
affected = cursor.rowcount if cursor.rowcount >= 0 else 0
|
|
291
|
+
|
|
286
292
|
records = []
|
|
287
293
|
last_id = None
|
|
288
294
|
|
|
@@ -317,7 +323,10 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
317
323
|
except Exception:
|
|
318
324
|
cursor.execute("ROLLBACK TO SAVEPOINT _t4_lastval_probe")
|
|
319
325
|
|
|
320
|
-
|
|
326
|
+
# NOTE: affected_rows was captured right after the main statement above,
|
|
327
|
+
# before the lastval()/SAVEPOINT probes ran their own cursor.execute()
|
|
328
|
+
# calls (which reset cursor.rowcount). Do not recompute it from
|
|
329
|
+
# cursor.rowcount here or a no-RETURNING INSERT reports 0.
|
|
321
330
|
|
|
322
331
|
if not self._in_transaction and self.autocommit:
|
|
323
332
|
self._conn.commit()
|
|
@@ -417,7 +426,14 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
417
426
|
row[key] = bytes(value)
|
|
418
427
|
return row
|
|
419
428
|
|
|
420
|
-
def insert(self, table: str, data: dict) -> DatabaseResult:
|
|
429
|
+
def insert(self, table: str, data: dict | list) -> DatabaseResult:
|
|
430
|
+
# A list of dicts is a batch insert — delegate to the base class, which
|
|
431
|
+
# builds one parameterised INSERT and runs it per row via execute_many.
|
|
432
|
+
# (Database.insert / the docs advertise ``data: dict | list``; without
|
|
433
|
+
# this branch a list crashed with ``'list' object has no attribute
|
|
434
|
+
# 'keys'`` because this override only handled the single-dict case.)
|
|
435
|
+
if isinstance(data, list):
|
|
436
|
+
return super().insert(table, data)
|
|
421
437
|
columns = ", ".join(data.keys())
|
|
422
438
|
placeholders = ", ".join(["%s"] * len(data))
|
|
423
439
|
sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
|
|
@@ -95,15 +95,25 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
95
95
|
def execute_many(self, sql: str, params_list: list[list] = None) -> DatabaseResult:
|
|
96
96
|
"""Optimized batch execute using SQLite's executemany."""
|
|
97
97
|
sql = self._translate_sql(sql)
|
|
98
|
-
|
|
98
|
+
rows = params_list or []
|
|
99
|
+
self._conn.executemany(sql, rows)
|
|
100
|
+
|
|
101
|
+
# cursor.rowcount / cursor.lastrowid are unreliable after executemany()
|
|
102
|
+
# in sqlite3 (rowcount can come back 0 or -1, lastrowid is not set) and
|
|
103
|
+
# were non-deterministic across pooled connections. The batch is
|
|
104
|
+
# all-or-raise, so every supplied row was applied; the last inserted id
|
|
105
|
+
# is read deterministically from last_insert_rowid() on this connection.
|
|
106
|
+
affected = len(rows)
|
|
107
|
+
last_row = self._conn.execute("SELECT last_insert_rowid()").fetchone()
|
|
108
|
+
last_id = last_row[0] if last_row and last_row[0] else None
|
|
99
109
|
|
|
100
110
|
if not self._in_transaction and self.autocommit:
|
|
101
111
|
if self._conn.in_transaction:
|
|
102
112
|
self._conn.execute("COMMIT")
|
|
103
113
|
|
|
104
114
|
return DatabaseResult(
|
|
105
|
-
affected_rows=
|
|
106
|
-
last_id=
|
|
115
|
+
affected_rows=affected,
|
|
116
|
+
last_id=last_id,
|
|
107
117
|
)
|
|
108
118
|
|
|
109
119
|
def fetch(self, sql: str, params: list = None,
|
|
@@ -144,7 +154,14 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
144
154
|
row = cursor.fetchone()
|
|
145
155
|
return dict(row) if row else None
|
|
146
156
|
|
|
147
|
-
def insert(self, table: str, data: dict) -> DatabaseResult:
|
|
157
|
+
def insert(self, table: str, data: dict | list) -> DatabaseResult:
|
|
158
|
+
# A list of dicts is a batch insert — delegate to the base class, which
|
|
159
|
+
# builds one parameterised INSERT and runs it per row via execute_many.
|
|
160
|
+
# (Database.insert / the docs advertise ``data: dict | list``; without
|
|
161
|
+
# this branch a list crashed with ``'list' object has no attribute
|
|
162
|
+
# 'keys'`` because this override only handled the single-dict case.)
|
|
163
|
+
if isinstance(data, list):
|
|
164
|
+
return super().insert(table, data)
|
|
148
165
|
columns = ", ".join(data.keys())
|
|
149
166
|
placeholders = ", ".join(["?"] * len(data))
|
|
150
167
|
sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
|
|
@@ -159,10 +159,10 @@ class BrokenTracker:
|
|
|
159
159
|
|
|
160
160
|
if filepath.exists():
|
|
161
161
|
try:
|
|
162
|
-
existing = json.loads(filepath.read_text())
|
|
162
|
+
existing = json.loads(filepath.read_text(encoding="utf-8"))
|
|
163
163
|
existing["count"] = existing.get("count", 1) + 1
|
|
164
164
|
existing["last_seen"] = datetime.now(timezone.utc).isoformat()
|
|
165
|
-
filepath.write_text(json.dumps(existing, indent=2))
|
|
165
|
+
filepath.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
166
166
|
return sig_hash
|
|
167
167
|
except (json.JSONDecodeError, OSError):
|
|
168
168
|
pass
|
|
@@ -178,7 +178,7 @@ class BrokenTracker:
|
|
|
178
178
|
"last_seen": datetime.now(timezone.utc).isoformat(),
|
|
179
179
|
"resolved": False,
|
|
180
180
|
}
|
|
181
|
-
filepath.write_text(json.dumps(entry, indent=2))
|
|
181
|
+
filepath.write_text(json.dumps(entry, indent=2), encoding="utf-8")
|
|
182
182
|
return sig_hash
|
|
183
183
|
|
|
184
184
|
@classmethod
|
|
@@ -190,7 +190,7 @@ class BrokenTracker:
|
|
|
190
190
|
entries = []
|
|
191
191
|
for f in sorted(broken_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
192
192
|
try:
|
|
193
|
-
entries.append(json.loads(f.read_text()))
|
|
193
|
+
entries.append(json.loads(f.read_text(encoding="utf-8")))
|
|
194
194
|
except (json.JSONDecodeError, OSError):
|
|
195
195
|
continue
|
|
196
196
|
return entries
|
|
@@ -202,9 +202,9 @@ class BrokenTracker:
|
|
|
202
202
|
if not filepath.exists():
|
|
203
203
|
return False
|
|
204
204
|
try:
|
|
205
|
-
entry = json.loads(filepath.read_text())
|
|
205
|
+
entry = json.loads(filepath.read_text(encoding="utf-8"))
|
|
206
206
|
entry["resolved"] = True
|
|
207
|
-
filepath.write_text(json.dumps(entry, indent=2))
|
|
207
|
+
filepath.write_text(json.dumps(entry, indent=2), encoding="utf-8")
|
|
208
208
|
return True
|
|
209
209
|
except (json.JSONDecodeError, OSError):
|
|
210
210
|
return False
|
|
@@ -217,7 +217,7 @@ class BrokenTracker:
|
|
|
217
217
|
return
|
|
218
218
|
for f in broken_dir.glob("*.json"):
|
|
219
219
|
try:
|
|
220
|
-
entry = json.loads(f.read_text())
|
|
220
|
+
entry = json.loads(f.read_text(encoding="utf-8"))
|
|
221
221
|
if entry.get("resolved"):
|
|
222
222
|
f.unlink()
|
|
223
223
|
except (json.JSONDecodeError, OSError):
|
|
@@ -1791,7 +1791,7 @@ async def _api_connections(request, response):
|
|
|
1791
1791
|
username = ""
|
|
1792
1792
|
password = ""
|
|
1793
1793
|
if env_path.exists():
|
|
1794
|
-
for line in env_path.read_text().splitlines():
|
|
1794
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
1795
1795
|
line = line.strip()
|
|
1796
1796
|
if line.startswith("#") or "=" not in line:
|
|
1797
1797
|
continue
|
|
@@ -1863,7 +1863,7 @@ async def _api_connections_save(request, response):
|
|
|
1863
1863
|
env_path = Path(".env")
|
|
1864
1864
|
lines = []
|
|
1865
1865
|
if env_path.exists():
|
|
1866
|
-
lines = env_path.read_text().splitlines()
|
|
1866
|
+
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
1867
1867
|
keys_found = {"TINA4_DATABASE_URL": False, "TINA4_DATABASE_USERNAME": False, "TINA4_DATABASE_PASSWORD": False}
|
|
1868
1868
|
new_lines = []
|
|
1869
1869
|
for line in lines:
|
|
@@ -1891,7 +1891,7 @@ async def _api_connections_save(request, response):
|
|
|
1891
1891
|
if not found:
|
|
1892
1892
|
val = {"TINA4_DATABASE_URL": url, "TINA4_DATABASE_USERNAME": username, "TINA4_DATABASE_PASSWORD": password}[key]
|
|
1893
1893
|
new_lines.append(f"{key}={val}")
|
|
1894
|
-
env_path.write_text("\n".join(new_lines) + "\n")
|
|
1894
|
+
env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
1895
1895
|
return response({"success": True})
|
|
1896
1896
|
except Exception as e:
|
|
1897
1897
|
return response({"success": False, "error": str(e)})
|
|
@@ -1906,7 +1906,7 @@ async def _api_gallery_list(request, response):
|
|
|
1906
1906
|
for entry in sorted(gallery_dir.iterdir()):
|
|
1907
1907
|
meta_file = entry / "meta.json"
|
|
1908
1908
|
if entry.is_dir() and meta_file.exists():
|
|
1909
|
-
meta = json.loads(meta_file.read_text())
|
|
1909
|
+
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
1910
1910
|
meta["id"] = entry.name
|
|
1911
1911
|
# List the files that would be deployed
|
|
1912
1912
|
src_dir = entry / "src"
|
|
@@ -95,13 +95,26 @@ def _create_v3_table(db) -> None:
|
|
|
95
95
|
)
|
|
96
96
|
""")
|
|
97
97
|
else:
|
|
98
|
-
|
|
98
|
+
# Engine-aware bookkeeping DDL. Each engine spells an auto-increment
|
|
99
|
+
# integer PK differently (SQLite AUTOINCREMENT, PostgreSQL SERIAL, MySQL
|
|
100
|
+
# AUTO_INCREMENT, MSSQL IDENTITY), and a TEXT column cannot carry a UNIQUE
|
|
101
|
+
# constraint on MySQL -- so migration_id is VARCHAR. (SQLite gives VARCHAR
|
|
102
|
+
# TEXT affinity, so this stays behaviour-identical there.) Mirrors the
|
|
103
|
+
# engine-aware DDL in ORM.create_table; without it `migrate()` died with
|
|
104
|
+
# "syntax error at AUTOINCREMENT" on PostgreSQL/MySQL/MSSQL.
|
|
105
|
+
engine = (db.get_database_type() or "sqlite").lower()
|
|
106
|
+
id_column = {
|
|
107
|
+
"postgresql": "id SERIAL PRIMARY KEY",
|
|
108
|
+
"mysql": "id INTEGER PRIMARY KEY AUTO_INCREMENT",
|
|
109
|
+
"mssql": "id INTEGER IDENTITY(1,1) PRIMARY KEY",
|
|
110
|
+
}.get(engine, "id INTEGER PRIMARY KEY AUTOINCREMENT")
|
|
111
|
+
db.execute(f"""
|
|
99
112
|
CREATE TABLE tina4_migration (
|
|
100
|
-
|
|
101
|
-
migration_id
|
|
102
|
-
description
|
|
113
|
+
{id_column},
|
|
114
|
+
migration_id VARCHAR(500) NOT NULL UNIQUE,
|
|
115
|
+
description VARCHAR(500),
|
|
103
116
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
104
|
-
executed_at
|
|
117
|
+
executed_at VARCHAR(50) NOT NULL,
|
|
105
118
|
passed INTEGER NOT NULL DEFAULT 1
|
|
106
119
|
)
|
|
107
120
|
""")
|
|
@@ -61,7 +61,7 @@ def _resolve_backend(topic: str, backend: str | None, max_retries: int,
|
|
|
61
61
|
# Consumer-group offsets manage redelivery — framework timeout N/A.
|
|
62
62
|
return KafkaBackend(topic, max_retries)
|
|
63
63
|
elif chosen in ("mongodb", "mongo"):
|
|
64
|
-
return MongoBackend(topic, max_retries, visibility_timeout)
|
|
64
|
+
return MongoBackend(topic, max_retries, visibility_timeout, retry_backoff)
|
|
65
65
|
else:
|
|
66
66
|
raise ValueError(f"Unknown queue backend: {chosen!r}. Use 'file', 'rabbitmq', 'kafka', or 'mongodb'.")
|
|
67
67
|
|
|
@@ -82,9 +82,12 @@ class Queue:
|
|
|
82
82
|
visibility_timeout: float | None = None):
|
|
83
83
|
self.topic = topic
|
|
84
84
|
self.max_retries = max_retries
|
|
85
|
-
# Seconds to wait before a failed job is re-attempted
|
|
85
|
+
# Seconds to wait before a failed job is re-attempted.
|
|
86
86
|
# Default 0 = retry on the very next pop()/consume() iteration.
|
|
87
87
|
self.retry_backoff = retry_backoff
|
|
88
|
+
# Remember an explicit backend= so retargeting a topic (produce/consume)
|
|
89
|
+
# does not silently fall back to the TINA4_QUEUE_BACKEND env default.
|
|
90
|
+
self._backend_choice = backend
|
|
88
91
|
# Reservation/visibility timeout (seconds). A popped job is reserved for
|
|
89
92
|
# this long; if the consumer dies before complete()/fail() the next
|
|
90
93
|
# pop() reclaims it (at-least-once delivery). Falls back to
|
|
@@ -96,6 +99,24 @@ class Queue:
|
|
|
96
99
|
self._backend = _resolve_backend(topic, backend, max_retries, retry_backoff,
|
|
97
100
|
self.visibility_timeout)
|
|
98
101
|
|
|
102
|
+
def _retarget(self, topic: str) -> None:
|
|
103
|
+
"""Point this queue (and its backend) at ``topic`` in place.
|
|
104
|
+
|
|
105
|
+
produce()/consume()/process() call this so a topic argument actually
|
|
106
|
+
changes which topic is read or written. Without it the argument was
|
|
107
|
+
accepted but ignored on the read path — pop() always used the
|
|
108
|
+
construction-time topic, so ``consume("other")`` silently drained the
|
|
109
|
+
wrong queue. Reuses the explicit backend choice (``_backend_choice``)
|
|
110
|
+
so an explicit ``backend=`` is preserved across the switch.
|
|
111
|
+
"""
|
|
112
|
+
if topic == self.topic:
|
|
113
|
+
return
|
|
114
|
+
self.topic = topic
|
|
115
|
+
self._backend = _resolve_backend(
|
|
116
|
+
topic, self._backend_choice, self.max_retries,
|
|
117
|
+
self.retry_backoff, self.visibility_timeout,
|
|
118
|
+
)
|
|
119
|
+
|
|
99
120
|
def push(self, data: dict, priority: int = 0, delay_seconds: int = 0):
|
|
100
121
|
"""Add a job to the queue. Returns job ID."""
|
|
101
122
|
return self._backend.push(data, priority, delay_seconds)
|
|
@@ -134,6 +155,8 @@ class Queue:
|
|
|
134
155
|
batch_size: Number of jobs to pass to handler at once (default 1).
|
|
135
156
|
When > 1, handler receives a list of Jobs.
|
|
136
157
|
"""
|
|
158
|
+
if topic is not None:
|
|
159
|
+
self._retarget(topic)
|
|
137
160
|
processed = 0
|
|
138
161
|
while max_jobs is None or processed < max_jobs:
|
|
139
162
|
if batch_size > 1:
|
|
@@ -235,15 +258,11 @@ class Queue:
|
|
|
235
258
|
delay_seconds = max(0, offset)
|
|
236
259
|
|
|
237
260
|
old_topic = self.topic
|
|
238
|
-
self.topic
|
|
239
|
-
self._backend = _resolve_backend(topic, None, self.max_retries, self.retry_backoff,
|
|
240
|
-
self.visibility_timeout)
|
|
261
|
+
self._retarget(topic)
|
|
241
262
|
try:
|
|
242
263
|
return self.push(data, priority, delay_seconds)
|
|
243
264
|
finally:
|
|
244
|
-
self.
|
|
245
|
-
self._backend = _resolve_backend(old_topic, None, self.max_retries, self.retry_backoff,
|
|
246
|
-
self.visibility_timeout)
|
|
265
|
+
self._retarget(old_topic)
|
|
247
266
|
|
|
248
267
|
def consume(self, topic: str = None, job_id: str = None, poll_interval: float = 1.0,
|
|
249
268
|
iterations: int = 0, batch_size: int = 1):
|
|
@@ -282,6 +301,10 @@ class Queue:
|
|
|
282
301
|
import time
|
|
283
302
|
|
|
284
303
|
topic = topic or self.topic
|
|
304
|
+
# Honor the topic argument: point the queue (and the backend that
|
|
305
|
+
# pop()/job.complete()/job.fail() route through) at it. Previously the
|
|
306
|
+
# argument was ignored and consume() drained the construction-time topic.
|
|
307
|
+
self._retarget(topic)
|
|
285
308
|
|
|
286
309
|
if job_id is not None:
|
|
287
310
|
# Consume a specific job by ID — single yield, no polling
|
|
@@ -54,16 +54,21 @@ class KafkaBackend:
|
|
|
54
54
|
def purge(self, status: str = "completed"):
|
|
55
55
|
pass # Kafka does not support purging
|
|
56
56
|
|
|
57
|
-
def retry_failed(self) -> int:
|
|
58
|
-
jobs = self.failed()
|
|
57
|
+
def retry_failed(self, max_retries: int = None) -> int:
|
|
58
|
+
jobs = self.failed(max_retries)
|
|
59
59
|
count = 0
|
|
60
60
|
for job in jobs:
|
|
61
61
|
if self.retry_job(job.get("id", "")):
|
|
62
62
|
count += 1
|
|
63
63
|
return count
|
|
64
64
|
|
|
65
|
-
def failed(self) -> list[dict]:
|
|
66
|
-
"""Consume dead_letter topic, republish, return jobs under max_retries.
|
|
65
|
+
def failed(self, max_retries: int = None) -> list[dict]:
|
|
66
|
+
"""Consume dead_letter topic, republish, return jobs under max_retries.
|
|
67
|
+
|
|
68
|
+
Accepts max_retries to match the LiteBackend contract — Queue.retry_failed()
|
|
69
|
+
passes it as a kwarg, so without this signature the call raised TypeError.
|
|
70
|
+
"""
|
|
71
|
+
mr = max_retries if max_retries is not None else self._max_retries
|
|
67
72
|
dl_topic = f"{self._topic}.dead_letter"
|
|
68
73
|
results = []
|
|
69
74
|
requeue = []
|
|
@@ -73,7 +78,7 @@ class KafkaBackend:
|
|
|
73
78
|
break
|
|
74
79
|
payload = msg.get("payload", msg)
|
|
75
80
|
attempts = msg.get("attempts", 0)
|
|
76
|
-
if attempts <
|
|
81
|
+
if attempts < mr:
|
|
77
82
|
results.append({"id": msg.get("id"), "data": payload,
|
|
78
83
|
"attempts": attempts, "error": msg.get("error")})
|
|
79
84
|
requeue.append(msg)
|
|
@@ -81,8 +86,13 @@ class KafkaBackend:
|
|
|
81
86
|
self._backend.enqueue(dl_topic, msg)
|
|
82
87
|
return results
|
|
83
88
|
|
|
84
|
-
def dead_letters(self) -> list[dict]:
|
|
85
|
-
"""Consume dead_letter topic, republish, return jobs at/over max_retries.
|
|
89
|
+
def dead_letters(self, max_retries: int = None) -> list[dict]:
|
|
90
|
+
"""Consume dead_letter topic, republish, return jobs at/over max_retries.
|
|
91
|
+
|
|
92
|
+
Accepts max_retries to match the LiteBackend contract — Queue.dead_letters()
|
|
93
|
+
passes it as a kwarg, so without this signature the call raised TypeError.
|
|
94
|
+
"""
|
|
95
|
+
mr = max_retries if max_retries is not None else self._max_retries
|
|
86
96
|
dl_topic = f"{self._topic}.dead_letter"
|
|
87
97
|
results = []
|
|
88
98
|
requeue = []
|
|
@@ -92,7 +102,7 @@ class KafkaBackend:
|
|
|
92
102
|
break
|
|
93
103
|
payload = msg.get("payload", msg)
|
|
94
104
|
attempts = msg.get("attempts", 0)
|
|
95
|
-
if attempts >=
|
|
105
|
+
if attempts >= mr:
|
|
96
106
|
results.append({"id": msg.get("id"), "data": payload,
|
|
97
107
|
"attempts": attempts, "error": msg.get("error")})
|
|
98
108
|
requeue.append(msg)
|
|
@@ -23,11 +23,12 @@ def _future(seconds: int) -> str:
|
|
|
23
23
|
class MongoBackend:
|
|
24
24
|
"""Backend adapter wrapping MongoBackend for the unified Queue API."""
|
|
25
25
|
|
|
26
|
-
def __init__(self, topic: str, max_retries: int, visibility_timeout: float = 300.0
|
|
26
|
+
def __init__(self, topic: str, max_retries: int, visibility_timeout: float = 300.0,
|
|
27
|
+
retry_backoff: float = 0):
|
|
27
28
|
from tina4_python.queue_backends import MongoConnector as _MongoBackend
|
|
28
29
|
|
|
29
30
|
url = os.environ.get("TINA4_QUEUE_URL", "")
|
|
30
|
-
config = {"visibility_timeout": visibility_timeout}
|
|
31
|
+
config = {"visibility_timeout": visibility_timeout, "retry_backoff": retry_backoff}
|
|
31
32
|
if url:
|
|
32
33
|
config["uri"] = url
|
|
33
34
|
self._backend = _MongoBackend(**config)
|
|
@@ -70,12 +71,19 @@ class MongoBackend:
|
|
|
70
71
|
if status == "pending":
|
|
71
72
|
self._backend.clear(self._topic)
|
|
72
73
|
|
|
73
|
-
def retry_failed(self) -> int:
|
|
74
|
+
def retry_failed(self, max_retries: int = None) -> int:
|
|
75
|
+
# Accept max_retries to match the LiteBackend contract (Queue passes it
|
|
76
|
+
# as a kwarg) — without this signature, Queue.retry_failed() raised
|
|
77
|
+
# TypeError on MongoDB.
|
|
78
|
+
mr = max_retries if max_retries is not None else self._max_retries
|
|
74
79
|
self._backend._ensure_connected()
|
|
75
80
|
result = self._backend._collection.update_many(
|
|
76
81
|
{"topic": self._topic, "status": "failed",
|
|
77
|
-
"attempts": {"$lt":
|
|
78
|
-
|
|
82
|
+
"attempts": {"$lt": mr}},
|
|
83
|
+
# Reset available_at so re-queued failed jobs are visible again
|
|
84
|
+
# (they were reserved with available_at in the future at dequeue).
|
|
85
|
+
{"$set": {"status": "pending", "error": None, "available_at": _now(),
|
|
86
|
+
"reserved_at": None}},
|
|
79
87
|
)
|
|
80
88
|
return result.modified_count
|
|
81
89
|
|
|
@@ -90,8 +98,13 @@ class MongoBackend:
|
|
|
90
98
|
"attempts": d.get("attempts", 0), "error": d.get("error")}
|
|
91
99
|
for d in docs]
|
|
92
100
|
|
|
93
|
-
def dead_letters(self) -> list[dict]:
|
|
94
|
-
"""Query the dead_letter collection in MongoDB.
|
|
101
|
+
def dead_letters(self, max_retries: int = None) -> list[dict]:
|
|
102
|
+
"""Query the dead_letter collection in MongoDB.
|
|
103
|
+
|
|
104
|
+
Accepts max_retries (unused — dead letters are terminal) to match the
|
|
105
|
+
LiteBackend contract; Queue.dead_letters() passes it as a kwarg, so
|
|
106
|
+
without this parameter the call raised TypeError on MongoDB.
|
|
107
|
+
"""
|
|
95
108
|
self._backend._ensure_connected()
|
|
96
109
|
dl_topic = f"{self._topic}.dead_letter"
|
|
97
110
|
docs = self._backend._collection.find({"topic": dl_topic})
|