tina4-python 3.13.92__tar.gz → 3.13.94__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.92 → tina4_python-3.13.94}/PKG-INFO +2 -2
- {tina4_python-3.13.92 → tina4_python-3.13.94}/README.md +1 -1
- {tina4_python-3.13.92 → tina4_python-3.13.94}/pyproject.toml +1 -1
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/CLAUDE.md +9 -2
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/__init__.py +9 -1
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/connection.py +110 -5
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/engine.py +68 -8
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/messenger/__init__.py +89 -27
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/kafka_backend.py +246 -63
- {tina4_python-3.13.92 → tina4_python-3.13.94}/.gitignore +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics_engine.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/env.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_pagination.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.92 → tina4_python-3.13.94}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.13.
|
|
3
|
+
Version: 3.13.94
|
|
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
|
|
@@ -742,7 +742,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
|
|
|
742
742
|
|
|
743
743
|
## Cross-Framework Parity
|
|
744
744
|
|
|
745
|
-
Tina4 ships identical features across four languages: same architecture, same conventions, same
|
|
745
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
|
|
746
746
|
|
|
747
747
|
| | Python | PHP | Ruby | Node.js |
|
|
748
748
|
|---|--------|-----|------|---------|
|
|
@@ -710,7 +710,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
|
|
|
710
710
|
|
|
711
711
|
## Cross-Framework Parity
|
|
712
712
|
|
|
713
|
-
Tina4 ships identical features across four languages: same architecture, same conventions, same
|
|
713
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
|
|
714
714
|
|
|
715
715
|
| | Python | PHP | Ruby | Node.js |
|
|
716
716
|
|---|--------|-----|------|---------|
|
|
@@ -794,11 +794,18 @@ db.insert("users", {"name": "Alice", "email": "alice@example.com"})
|
|
|
794
794
|
# Insert multiple
|
|
795
795
|
db.insert("users", [{"name": "Bob"}, {"name": "Eve"}])
|
|
796
796
|
|
|
797
|
-
# Update (
|
|
797
|
+
# Update (the primary key in the data IS the filter — introspected, not assumed)
|
|
798
798
|
db.update("users", {"id": 1, "name": "Alice Updated"})
|
|
799
799
|
|
|
800
|
-
#
|
|
800
|
+
# Update many rows — the filter must be explicit
|
|
801
|
+
db.update("users", {"active": 0}, "last_login < ?", ["2025-01-01"])
|
|
802
|
+
|
|
803
|
+
# Delete (dict filter or string + params, both accepted)
|
|
801
804
|
db.delete("users", {"id": 1})
|
|
805
|
+
db.delete("users", "age < ?", [18])
|
|
806
|
+
|
|
807
|
+
# Empty a table — explicit, never accidental
|
|
808
|
+
db.truncate("users")
|
|
802
809
|
|
|
803
810
|
# Raw query
|
|
804
811
|
result = db.fetch("SELECT * FROM users WHERE age > ?", [18])
|
|
@@ -43,7 +43,15 @@ def _resolve_version() -> str:
|
|
|
43
43
|
pass
|
|
44
44
|
except Exception:
|
|
45
45
|
pass
|
|
46
|
-
|
|
46
|
+
# Floor literal, reached only when BOTH lookups above miss. That is not
|
|
47
|
+
# theoretical: the Docker build used to prune dist-info from the installed
|
|
48
|
+
# package, so path 2 raised PackageNotFoundError and the published
|
|
49
|
+
# tina4-python:3.13.94 image served this literal on /health -- then reading
|
|
50
|
+
# "3.13.56", 36 releases stale, because nothing kept it current.
|
|
51
|
+
#
|
|
52
|
+
# test_version_constant.py now asserts this literal equals the pyproject
|
|
53
|
+
# version, so the release bump cannot leave it behind again.
|
|
54
|
+
return "3.13.94"
|
|
47
55
|
|
|
48
56
|
|
|
49
57
|
__version__ = _resolve_version()
|
|
@@ -188,6 +188,7 @@ class Database:
|
|
|
188
188
|
self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
|
|
189
189
|
self.last_error = None # Last execute() error message
|
|
190
190
|
self._last_id = None # Last insert ID from execute/insert
|
|
191
|
+
self._pk_cache = {} # table -> primary-key column name (or None)
|
|
191
192
|
|
|
192
193
|
if self.pool_size > 0:
|
|
193
194
|
# Pooled mode — create a ConnectionPool with lazy adapter creation
|
|
@@ -681,19 +682,123 @@ class Database:
|
|
|
681
682
|
self._last_id = result.last_id
|
|
682
683
|
return result
|
|
683
684
|
|
|
685
|
+
def primary_key(self, table: str) -> list[str]:
|
|
686
|
+
"""The table's primary-key columns, introspected once and cached.
|
|
687
|
+
|
|
688
|
+
Returns a LIST because a primary key may span several columns. A
|
|
689
|
+
composite key is still one primary key; it just has more than one
|
|
690
|
+
column. Returns ``[]`` when the table has no primary key or cannot be
|
|
691
|
+
introspected.
|
|
692
|
+
|
|
693
|
+
Uses the cross-engine ``get_columns()`` contract (v3.13.14, #48), which
|
|
694
|
+
reports ``primary_key`` per column on every adapter.
|
|
695
|
+
"""
|
|
696
|
+
if table not in self._pk_cache:
|
|
697
|
+
try:
|
|
698
|
+
columns = self._get_adapter().get_columns(table)
|
|
699
|
+
self._pk_cache[table] = [
|
|
700
|
+
c["name"] for c in columns if c.get("primary_key")
|
|
701
|
+
]
|
|
702
|
+
except Exception: # noqa: BLE001 - a missing table is not an error here
|
|
703
|
+
self._pk_cache[table] = []
|
|
704
|
+
return self._pk_cache[table]
|
|
705
|
+
|
|
706
|
+
def _as_where(self, filter_sql, params):
|
|
707
|
+
"""Normalise a filter to ``(sql, params)``, accepting a dict or a string.
|
|
708
|
+
|
|
709
|
+
The declared type on ``delete`` has always been ``str | dict | list``, but
|
|
710
|
+
only the base adapter honoured the dict and every engine overrides it, so a
|
|
711
|
+
dict landed in the SQL string verbatim. Normalising here makes the declared
|
|
712
|
+
type true for every engine at once.
|
|
713
|
+
"""
|
|
714
|
+
if isinstance(filter_sql, dict):
|
|
715
|
+
if not filter_sql:
|
|
716
|
+
return "", []
|
|
717
|
+
where = " AND ".join(
|
|
718
|
+
f"{self.quote_identifier(k)} = ?" for k in filter_sql
|
|
719
|
+
)
|
|
720
|
+
return where, list(filter_sql.values())
|
|
721
|
+
return filter_sql, params or []
|
|
722
|
+
|
|
684
723
|
def update(self, table: str, data: dict,
|
|
685
|
-
filter_sql: str = "", params: list = None) -> DatabaseResult:
|
|
724
|
+
filter_sql: str | dict = "", params: list = None) -> DatabaseResult:
|
|
725
|
+
"""Update rows. A write with no filter is an error, not a full-table write.
|
|
726
|
+
|
|
727
|
+
With no explicit filter, the primary key is taken out of ``data`` and used
|
|
728
|
+
as the WHERE clause. With neither a filter nor a primary key in ``data``,
|
|
729
|
+
this raises rather than overwriting every row (audit feature 4, P1).
|
|
730
|
+
"""
|
|
731
|
+
filter_sql, params = self._as_where(filter_sql, params)
|
|
732
|
+
|
|
733
|
+
if not filter_sql:
|
|
734
|
+
pk_columns = self.primary_key(table)
|
|
735
|
+
missing = [c for c in pk_columns if c not in data]
|
|
736
|
+
if not pk_columns or missing:
|
|
737
|
+
raise ValueError(
|
|
738
|
+
f"update requires a filter or the complete primary key in the "
|
|
739
|
+
f"data; pass filter explicitly to update multiple rows "
|
|
740
|
+
f"(table={table!r}, primary key={pk_columns!r}, "
|
|
741
|
+
f"missing from data={missing!r}). "
|
|
742
|
+
f"To empty a table use truncate({table!r})."
|
|
743
|
+
)
|
|
744
|
+
# Every primary-key column becomes part of the WHERE clause. A
|
|
745
|
+
# composite key that used only its first column would match every
|
|
746
|
+
# row sharing that value - the data-loss bug this method exists to
|
|
747
|
+
# prevent, reintroduced.
|
|
748
|
+
data = dict(data)
|
|
749
|
+
params = [data.pop(c) for c in pk_columns]
|
|
750
|
+
if not data:
|
|
751
|
+
raise ValueError(
|
|
752
|
+
f"update was given only the primary key {pk_columns!r} and no "
|
|
753
|
+
f"columns to set (table={table!r})"
|
|
754
|
+
)
|
|
755
|
+
filter_sql = " AND ".join(
|
|
756
|
+
f"{self.quote_identifier(c)} = ?" for c in pk_columns
|
|
757
|
+
)
|
|
758
|
+
|
|
686
759
|
if self._cache_enabled:
|
|
687
760
|
self._cache_invalidate()
|
|
688
|
-
|
|
689
|
-
return
|
|
761
|
+
result = self._get_adapter().update(table, data, filter_sql, params)
|
|
762
|
+
return self._without_last_id(result)
|
|
690
763
|
|
|
691
764
|
def delete(self, table: str,
|
|
692
765
|
filter_sql: str | dict | list = "", params: list = None) -> DatabaseResult:
|
|
766
|
+
"""Delete rows. A filterless delete raises; use ``truncate()`` to empty."""
|
|
767
|
+
if isinstance(filter_sql, list):
|
|
768
|
+
total = 0
|
|
769
|
+
for row_filter in filter_sql:
|
|
770
|
+
total += self.delete(table, row_filter).affected_rows
|
|
771
|
+
return DatabaseResult(affected_rows=total)
|
|
772
|
+
|
|
773
|
+
filter_sql, params = self._as_where(filter_sql, params)
|
|
774
|
+
if not filter_sql:
|
|
775
|
+
raise ValueError(
|
|
776
|
+
f"delete requires a filter (table={table!r}). "
|
|
777
|
+
f"To remove every row use truncate({table!r})."
|
|
778
|
+
)
|
|
779
|
+
|
|
693
780
|
if self._cache_enabled:
|
|
694
781
|
self._cache_invalidate()
|
|
695
|
-
|
|
696
|
-
return
|
|
782
|
+
result = self._get_adapter().delete(table, filter_sql, params)
|
|
783
|
+
return self._without_last_id(result)
|
|
784
|
+
|
|
785
|
+
def truncate(self, table: str) -> DatabaseResult:
|
|
786
|
+
"""Remove every row. The explicit spelling of a whole-table delete."""
|
|
787
|
+
if self._cache_enabled:
|
|
788
|
+
self._cache_invalidate()
|
|
789
|
+
result = self._get_adapter().delete(table, "1 = 1", [])
|
|
790
|
+
return self._without_last_id(result)
|
|
791
|
+
|
|
792
|
+
@staticmethod
|
|
793
|
+
def _without_last_id(result: DatabaseResult) -> DatabaseResult:
|
|
794
|
+
"""``last_id`` is insert-only, per the documented contract.
|
|
795
|
+
|
|
796
|
+
PHP already does this via ``writeResult($adapter, withLastId: false)``;
|
|
797
|
+
Python reported the connection's last insert id on an UPDATE.
|
|
798
|
+
"""
|
|
799
|
+
if result is not None and getattr(result, "last_id", None) is not None:
|
|
800
|
+
result.last_id = None
|
|
801
|
+
return result
|
|
697
802
|
|
|
698
803
|
def start_transaction(self):
|
|
699
804
|
"""Begin a transaction. Pins the adapter to this thread for the
|
|
@@ -1626,11 +1626,57 @@ class Frond:
|
|
|
1626
1626
|
)
|
|
1627
1627
|
"""
|
|
1628
1628
|
self._sandbox = True
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1629
|
+
# `is not None`, NOT truthiness: an EMPTY list means "permit nothing",
|
|
1630
|
+
# while None means "permit everything". Testing truthiness collapsed the
|
|
1631
|
+
# two, so a caller that computed an allow-list and got an empty result
|
|
1632
|
+
# silently opened the sandbox instead of closing it.
|
|
1633
|
+
self._allowed_filters = set(allowed_filters) if allowed_filters is not None else None
|
|
1634
|
+
self._allowed_tags = set(allowed_tags) if allowed_tags is not None else None
|
|
1635
|
+
self._allowed_vars = set(allowed_vars) if allowed_vars is not None else None
|
|
1632
1636
|
return self
|
|
1633
1637
|
|
|
1638
|
+
def _filter_permitted(self, fname: str) -> bool:
|
|
1639
|
+
"""Is this filter permitted to run under the current sandbox?
|
|
1640
|
+
|
|
1641
|
+
The single source of truth for "may this filter run", so the escaping
|
|
1642
|
+
decision and the filter loop can never disagree.
|
|
1643
|
+
"""
|
|
1644
|
+
if not self._sandbox or self._allowed_filters is None:
|
|
1645
|
+
return True
|
|
1646
|
+
return fname in self._allowed_filters
|
|
1647
|
+
|
|
1648
|
+
#: Node kinds that are TAGS a template author writes, mapped to the name
|
|
1649
|
+
#: they type. Kinds absent from this map are structure (text, output,
|
|
1650
|
+
#: comment, extends/block markers) and are never gated - blocking those
|
|
1651
|
+
#: would stop the template rendering at all rather than restrict it.
|
|
1652
|
+
_GATEABLE_TAGS = {
|
|
1653
|
+
"if": "if",
|
|
1654
|
+
"for": "for",
|
|
1655
|
+
"set": "set",
|
|
1656
|
+
"set_block": "set",
|
|
1657
|
+
"include": "include",
|
|
1658
|
+
"macro": "macro",
|
|
1659
|
+
"from_import": "from",
|
|
1660
|
+
"import_as": "import",
|
|
1661
|
+
"cache": "cache",
|
|
1662
|
+
"spaceless": "spaceless",
|
|
1663
|
+
"autoescape": "autoescape",
|
|
1664
|
+
"live": "live",
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
def _tag_permitted(self, kind: str) -> bool:
|
|
1668
|
+
"""Is this tag permitted under the current sandbox?
|
|
1669
|
+
|
|
1670
|
+
One gate for every tag, so the allow-list governs the whole tag
|
|
1671
|
+
vocabulary instead of whichever names somebody remembered to check.
|
|
1672
|
+
"""
|
|
1673
|
+
if not self._sandbox or self._allowed_tags is None:
|
|
1674
|
+
return True
|
|
1675
|
+
tag = self._GATEABLE_TAGS.get(kind)
|
|
1676
|
+
if tag is None:
|
|
1677
|
+
return True # structural node, not an author-written tag
|
|
1678
|
+
return tag in self._allowed_tags
|
|
1679
|
+
|
|
1634
1680
|
def unsandbox(self):
|
|
1635
1681
|
"""Disable sandbox mode."""
|
|
1636
1682
|
self._sandbox = False
|
|
@@ -2017,6 +2063,14 @@ class Frond:
|
|
|
2017
2063
|
if node.strip_before and output:
|
|
2018
2064
|
output[-1] = output[-1].rstrip()
|
|
2019
2065
|
|
|
2066
|
+
# Sandbox: ONE tag gate, here at dispatch, so every tag is gated by
|
|
2067
|
+
# construction and a tag added later is gated the day it is added.
|
|
2068
|
+
# This replaced a single per-name conditional that covered `include`
|
|
2069
|
+
# only, which let `{% autoescape false %}` turn escaping OFF from
|
|
2070
|
+
# inside a sandbox that had not allowed it (audit feature 38, P1b).
|
|
2071
|
+
if not self._tag_permitted(kind):
|
|
2072
|
+
continue
|
|
2073
|
+
|
|
2020
2074
|
if kind == "output":
|
|
2021
2075
|
result = self._eval_var(node.expr, context)
|
|
2022
2076
|
output.append(self._to_output(result))
|
|
@@ -2042,10 +2096,9 @@ class Frond:
|
|
|
2042
2096
|
)
|
|
2043
2097
|
|
|
2044
2098
|
elif kind == "include":
|
|
2045
|
-
#
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
output.append(self._handle_include(node.content, context))
|
|
2099
|
+
# The sandbox tag gate now runs once at dispatch (above), so the
|
|
2100
|
+
# per-name conditional that used to live here is gone.
|
|
2101
|
+
output.append(self._handle_include(node.content, context))
|
|
2049
2102
|
|
|
2050
2103
|
elif kind == "macro":
|
|
2051
2104
|
self._handle_macro(node, context)
|
|
@@ -2275,7 +2328,14 @@ class Frond:
|
|
|
2275
2328
|
# the var_name eval lets a parenthesised pipe like `{{ (a|f) }}` resolve
|
|
2276
2329
|
# too — the old code returned empty for that.
|
|
2277
2330
|
value = _eval_expr(var_name, context, self._apply_filters)
|
|
2278
|
-
|
|
2331
|
+
# `raw`/`safe` suppress escaping only if the sandbox PERMITS them to run.
|
|
2332
|
+
# Reading the filter name straight out of the parsed source meant a
|
|
2333
|
+
# sandbox that DENIED raw still marked the value safe, so denying raw
|
|
2334
|
+
# produced byte-identical output to allowing it (audit feature 38, P1).
|
|
2335
|
+
is_safe = any(
|
|
2336
|
+
name in ("raw", "safe") and self._filter_permitted(name)
|
|
2337
|
+
for name, _ in filters
|
|
2338
|
+
)
|
|
2279
2339
|
value = self._apply_filters(value, filters, context)
|
|
2280
2340
|
|
|
2281
2341
|
# Auto-escape HTML unless marked safe or SafeString
|
|
@@ -106,6 +106,11 @@ class Messenger:
|
|
|
106
106
|
encryption: str = None, use_tls: bool = None,
|
|
107
107
|
imap_host: str = None, imap_port: int = None):
|
|
108
108
|
# SMTP (send) — priority: constructor > .env > sensible default
|
|
109
|
+
# Whether a host was actually CONFIGURED, which is not the same as
|
|
110
|
+
# self.host being set: self.host falls back to "localhost", so it is never
|
|
111
|
+
# empty and cannot answer "can this messenger send?". The dev-capture gate
|
|
112
|
+
# needs that answer, so record it here while the real inputs are in scope.
|
|
113
|
+
self._smtp_configured = bool(host or os.environ.get("TINA4_MAIL_HOST"))
|
|
109
114
|
self.host = host or os.environ.get("TINA4_MAIL_HOST", "localhost")
|
|
110
115
|
self.port = port or int(os.environ.get("TINA4_MAIL_PORT", "587"))
|
|
111
116
|
self.username = username or os.environ.get("TINA4_MAIL_USERNAME", "")
|
|
@@ -141,6 +146,37 @@ class Messenger:
|
|
|
141
146
|
"""Add a default header to all outgoing emails."""
|
|
142
147
|
self._default_headers[name] = value
|
|
143
148
|
|
|
149
|
+
def _should_capture(self) -> bool:
|
|
150
|
+
"""Should send() capture to a local mailbox instead of talking to SMTP?
|
|
151
|
+
|
|
152
|
+
Availability decides, not verbosity. With no SMTP host configured sending
|
|
153
|
+
is impossible, so simulate it into a folder rather than failing -- that is
|
|
154
|
+
what makes a laptop with no mail server usable. ``TINA4_MAIL_CAPTURE``
|
|
155
|
+
forces capture even when a host IS configured, for anyone who wants
|
|
156
|
+
"never send real mail from this box".
|
|
157
|
+
|
|
158
|
+
``TINA4_DEBUG`` deliberately does NOT gate this. Debug must still be able
|
|
159
|
+
to send: tying capture to it means nobody can test a real send from a dev
|
|
160
|
+
box, which is the common case.
|
|
161
|
+
"""
|
|
162
|
+
from tina4_python.dotenv import is_truthy
|
|
163
|
+
if is_truthy(os.environ.get("TINA4_MAIL_CAPTURE", "")):
|
|
164
|
+
return True
|
|
165
|
+
return not self._smtp_configured
|
|
166
|
+
|
|
167
|
+
def _dev_mailbox(self) -> "DevMailbox":
|
|
168
|
+
"""The local mailbox, created on first capture and reused after.
|
|
169
|
+
|
|
170
|
+
Attached lazily so a messenger that never captures never grows the
|
|
171
|
+
attribute -- callers test ``hasattr(messenger, "dev_mailbox")`` to ask
|
|
172
|
+
"is this a capturing messenger?".
|
|
173
|
+
"""
|
|
174
|
+
mailbox = getattr(self, "dev_mailbox", None)
|
|
175
|
+
if mailbox is None:
|
|
176
|
+
mailbox = DevMailbox()
|
|
177
|
+
self.dev_mailbox = mailbox
|
|
178
|
+
return mailbox
|
|
179
|
+
|
|
144
180
|
def send(self, to: str | list[str], subject: str, body: str,
|
|
145
181
|
html: bool = False, text: str = None,
|
|
146
182
|
cc: str | list[str] = None,
|
|
@@ -167,6 +203,21 @@ class Messenger:
|
|
|
167
203
|
cc_list = [cc] if isinstance(cc, str) else list(cc or [])
|
|
168
204
|
bcc_list = [bcc] if isinstance(bcc, str) else list(bcc or [])
|
|
169
205
|
|
|
206
|
+
# Dev capture is a BRANCH here, not a method swapped onto the instance.
|
|
207
|
+
# The swap was the cause of tina4-nodejs#42 in Python: dev_send declared
|
|
208
|
+
# (to, subject, body, html, cc, ...) while this method declares
|
|
209
|
+
# (to, subject, body, html, text, cc, ...), so one name meant two
|
|
210
|
+
# signatures -- send(to, subj, body, True, "plain text") filed the
|
|
211
|
+
# plain-text body as a CC RECIPIENT and reported success, and
|
|
212
|
+
# send(text=...) raised TypeError on a dev messenger.
|
|
213
|
+
if self._should_capture():
|
|
214
|
+
return self._dev_mailbox().capture(
|
|
215
|
+
to, subject, body, html, text,
|
|
216
|
+
cc=cc_list, bcc=bcc_list, reply_to=reply_to,
|
|
217
|
+
from_address=self.from_address, from_name=self.from_name,
|
|
218
|
+
attachments=attachments,
|
|
219
|
+
)
|
|
220
|
+
|
|
170
221
|
has_attachments = bool(attachments)
|
|
171
222
|
has_text_alt = text is not None and html
|
|
172
223
|
|
|
@@ -728,24 +779,44 @@ class DevMailbox:
|
|
|
728
779
|
self._inbox_dir.mkdir(exist_ok=True)
|
|
729
780
|
|
|
730
781
|
def capture(self, to: str | list[str], subject: str, body: str,
|
|
731
|
-
html: bool = False,
|
|
732
|
-
|
|
782
|
+
html: bool = False, text: str = None,
|
|
783
|
+
cc: str | list[str] = None,
|
|
784
|
+
bcc: str | list[str] = None, reply_to: str = None,
|
|
733
785
|
from_address: str = "", from_name: str = "",
|
|
734
786
|
attachments: list = None) -> dict:
|
|
735
|
-
"""Capture a message to the local outbox (instead of sending via SMTP).
|
|
787
|
+
"""Capture a message to the local outbox (instead of sending via SMTP).
|
|
788
|
+
|
|
789
|
+
The parameter order MATCHES ``Messenger.send`` on purpose. It did not
|
|
790
|
+
before: send's 5th positional was ``text`` and capture's was ``cc``, so the
|
|
791
|
+
same call meant different things depending on which door it came through --
|
|
792
|
+
that mismatch IS tina4-nodejs#42.
|
|
793
|
+
|
|
794
|
+
BREAKING: ``text`` is now the 5th positional. A caller passing ``cc``
|
|
795
|
+
positionally must switch to the keyword form. Aligning the two signatures
|
|
796
|
+
is the fix; leaving them apart would preserve the bug.
|
|
797
|
+
|
|
798
|
+
cc/bcc are normalised HERE, at the boundary, so a message is well formed
|
|
799
|
+
however it arrived. Normalising in one caller only (which is what Python
|
|
800
|
+
did -- in dev_send, never in capture) means a direct caller stores a
|
|
801
|
+
malformed message and is told it succeeded, and a dev mailbox that accepts
|
|
802
|
+
a broken message defeats its own purpose.
|
|
803
|
+
"""
|
|
736
804
|
msg_id = f"{int(time.time() * 1000)}_{id(subject) & 0xFFFF:04x}"
|
|
737
805
|
to_list = [to] if isinstance(to, str) else list(to)
|
|
806
|
+
cc_list = [cc] if isinstance(cc, str) else list(cc or [])
|
|
807
|
+
bcc_list = [bcc] if isinstance(bcc, str) else list(bcc or [])
|
|
738
808
|
|
|
739
809
|
message = {
|
|
740
810
|
"id": msg_id,
|
|
741
811
|
"type": "outbox",
|
|
742
812
|
"from": f"{from_name} <{from_address}>" if from_name else from_address,
|
|
743
813
|
"to": to_list,
|
|
744
|
-
"cc":
|
|
745
|
-
"bcc":
|
|
814
|
+
"cc": cc_list,
|
|
815
|
+
"bcc": bcc_list,
|
|
746
816
|
"reply_to": reply_to or "",
|
|
747
817
|
"subject": subject,
|
|
748
818
|
"body": body,
|
|
819
|
+
"text": text,
|
|
749
820
|
"html": html,
|
|
750
821
|
"attachments": [
|
|
751
822
|
a if isinstance(a, str) else a.get("filename", "attachment")
|
|
@@ -909,31 +980,22 @@ def _is_dev_mode() -> bool:
|
|
|
909
980
|
def create_messenger(**kwargs) -> Messenger:
|
|
910
981
|
"""Factory that returns a Messenger configured for the current environment.
|
|
911
982
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
983
|
+
Returns ONE concrete type, always. When sending is impossible (no SMTP host
|
|
984
|
+
configured) or suppressed (``TINA4_MAIL_CAPTURE``), ``send()`` captures to a
|
|
985
|
+
local ``DevMailbox`` instead -- decided by a branch inside ``send()``, so the
|
|
986
|
+
object you get back has one ``send`` with one signature either way.
|
|
987
|
+
|
|
988
|
+
It no longer replaces ``send`` on the instance. That swap installed a function
|
|
989
|
+
with a DIFFERENT signature than ``Messenger.send`` under the same name, which
|
|
990
|
+
is how the documented call ``send(to, subj, body, True, "plain text")`` came to
|
|
991
|
+
file the plain-text body as a CC recipient and report success.
|
|
915
992
|
"""
|
|
916
993
|
messenger = Messenger(**kwargs)
|
|
917
994
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
def dev_send(to, subject, body, html=False, cc=None, bcc=None,
|
|
924
|
-
reply_to=None, attachments=None, headers=None):
|
|
925
|
-
return mailbox.capture(
|
|
926
|
-
to=to, subject=subject, body=body, html=html,
|
|
927
|
-
cc=[cc] if isinstance(cc, str) else (cc or []),
|
|
928
|
-
bcc=[bcc] if isinstance(bcc, str) else (bcc or []),
|
|
929
|
-
reply_to=reply_to,
|
|
930
|
-
from_address=messenger.from_address,
|
|
931
|
-
from_name=messenger.from_name,
|
|
932
|
-
attachments=attachments,
|
|
933
|
-
)
|
|
934
|
-
|
|
935
|
-
messenger.send = dev_send
|
|
936
|
-
messenger.dev_mailbox = mailbox
|
|
995
|
+
# Attach the mailbox eagerly when this messenger will capture, so callers can
|
|
996
|
+
# inspect it (and the dev admin panel can list it) before the first send.
|
|
997
|
+
if messenger._should_capture():
|
|
998
|
+
messenger.dev_mailbox = DevMailbox()
|
|
937
999
|
|
|
938
1000
|
return messenger
|
|
939
1001
|
|