tina4-python 3.13.91__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.91 → tina4_python-3.13.94}/PKG-INFO +2 -2
- {tina4_python-3.13.91 → tina4_python-3.13.94}/README.md +1 -1
- {tina4_python-3.13.91 → tina4_python-3.13.94}/pyproject.toml +1 -1
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/CLAUDE.md +9 -2
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/__init__.py +9 -1
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/auth/__init__.py +68 -10
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/connection.py +110 -5
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/engine.py +68 -8
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/messenger/__init__.py +89 -27
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/kafka_backend.py +246 -63
- {tina4_python-3.13.91 → tina4_python-3.13.94}/.gitignore +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics_engine.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/env.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_pagination.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.91 → 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()
|
|
@@ -196,19 +196,51 @@ def _resolve_secret(secret: str = None) -> str:
|
|
|
196
196
|
return env_secret
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
# Supported JWT algorithms. HMAC only — the whole family is in hashlib, so this
|
|
200
|
+
# stays zero-dependency. The header's "alg" is now always the one we actually sign
|
|
201
|
+
# with (python#105): the digest is looked up here rather than hardcoded.
|
|
202
|
+
_HMAC_ALGORITHMS = {
|
|
203
|
+
"HS256": hashlib.sha256,
|
|
204
|
+
"HS384": hashlib.sha384,
|
|
205
|
+
"HS512": hashlib.sha512,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without this a
|
|
209
|
+
# token minted on one host and validated on another a second behind is rejected
|
|
210
|
+
# for no real reason; RFC 7519 explicitly allows "a small leeway".
|
|
211
|
+
_JWT_LEEWAY_SECONDS = 60
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _resolve_algorithm(algorithm: str = None) -> str:
|
|
215
|
+
"""Pick the JWT algorithm: explicit arg, else TINA4_JWT_ALGORITHM, else HS256.
|
|
216
|
+
|
|
217
|
+
Raises ValueError naming the supported set when asked for one we cannot sign
|
|
218
|
+
(python#106 — the env var was registered in the CLI's known_vars and then
|
|
219
|
+
silently ignored, so a user could set HS512 and still get HS256 tokens).
|
|
220
|
+
"""
|
|
221
|
+
chosen = (algorithm or os.environ.get("TINA4_JWT_ALGORITHM") or "HS256").strip()
|
|
222
|
+
if chosen not in _HMAC_ALGORITHMS:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Unsupported JWT algorithm {chosen!r}. Tina4 signs with "
|
|
225
|
+
f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC only, zero-dependency). "
|
|
226
|
+
f"Set TINA4_JWT_ALGORITHM to one of those."
|
|
227
|
+
)
|
|
228
|
+
return chosen
|
|
229
|
+
|
|
230
|
+
|
|
199
231
|
class Auth:
|
|
200
232
|
"""JWT authentication and password hashing — zero dependencies."""
|
|
201
233
|
|
|
202
|
-
def __init__(self, secret: str = None, algorithm: str =
|
|
234
|
+
def __init__(self, secret: str = None, algorithm: str = None,
|
|
203
235
|
expires_in: int = None):
|
|
204
236
|
"""
|
|
205
237
|
Args:
|
|
206
238
|
secret: Signing secret (falls back to the TINA4_SECRET env var).
|
|
207
|
-
algorithm: JWT algorithm (
|
|
239
|
+
algorithm: JWT algorithm (falls back to TINA4_JWT_ALGORITHM, then HS256).
|
|
208
240
|
expires_in: Token lifetime in seconds (default 3600).
|
|
209
241
|
"""
|
|
210
242
|
self.secret = secret if secret is not None else _resolve_secret()
|
|
211
|
-
self.algorithm = algorithm
|
|
243
|
+
self.algorithm = _resolve_algorithm(algorithm)
|
|
212
244
|
# JWT expiry env var:
|
|
213
245
|
# - TINA4_TOKEN_EXPIRES_IN (preferred — matches docs and the
|
|
214
246
|
# form-token expiry env var used by Frond)
|
|
@@ -275,13 +307,29 @@ class Auth:
|
|
|
275
307
|
return None
|
|
276
308
|
|
|
277
309
|
h, p, sig = parts
|
|
310
|
+
|
|
311
|
+
# Pin the algorithm to OUR configured one instead of trusting the
|
|
312
|
+
# token's header. A token asking to be verified as anything else --
|
|
313
|
+
# "none", a weaker HMAC, or an RSA alg we do not implement -- is
|
|
314
|
+
# rejected before any signature work. Matches the Ruby check.
|
|
315
|
+
header = json.loads(_b64url_decode(h))
|
|
316
|
+
if header.get("alg") != self.algorithm:
|
|
317
|
+
return None
|
|
318
|
+
|
|
278
319
|
expected = self._sign(f"{h}.{p}")
|
|
279
320
|
if not hmac.compare_digest(sig, expected):
|
|
280
321
|
return None
|
|
281
322
|
|
|
282
323
|
payload = json.loads(_b64url_decode(p))
|
|
283
324
|
|
|
284
|
-
|
|
325
|
+
now = time.time()
|
|
326
|
+
if "exp" in payload and now > payload["exp"]:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
# "nbf" (not-before): a post-dated token is not valid yet. Was
|
|
330
|
+
# honoured only by Ruby, so Python/PHP/Node accepted tokens their
|
|
331
|
+
# issuer had explicitly marked as not-yet-usable (python#107).
|
|
332
|
+
if "nbf" in payload and now + _JWT_LEEWAY_SECONDS < payload["nbf"]:
|
|
285
333
|
return None
|
|
286
334
|
|
|
287
335
|
return payload
|
|
@@ -314,8 +362,10 @@ class Auth:
|
|
|
314
362
|
return self.get_token(payload, expires_in=expires_in)
|
|
315
363
|
|
|
316
364
|
def _sign(self, message: str) -> str:
|
|
365
|
+
# Digest comes from the configured algorithm, so the "alg" we advertise in
|
|
366
|
+
# the header is the one that actually produced this signature.
|
|
317
367
|
sig = hmac.new(
|
|
318
|
-
self.secret.encode(), message.encode(),
|
|
368
|
+
self.secret.encode(), message.encode(), _HMAC_ALGORITHMS[self.algorithm]
|
|
319
369
|
).digest()
|
|
320
370
|
return _b64url_encode(sig)
|
|
321
371
|
|
|
@@ -424,16 +474,24 @@ class Auth:
|
|
|
424
474
|
# ── Request Auth Helper ───────────────────────────────────────
|
|
425
475
|
|
|
426
476
|
@_DualMethod
|
|
427
|
-
def authenticate_request(self, headers: dict, secret: str = None, algorithm: str =
|
|
477
|
+
def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = None) -> dict | None:
|
|
428
478
|
"""Extract and validate auth from request headers.
|
|
429
479
|
|
|
430
480
|
Args:
|
|
431
481
|
secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
|
|
432
|
-
algorithm: JWT algorithm override (default:
|
|
482
|
+
algorithm: JWT algorithm override (default: self.algorithm / TINA4_JWT_ALGORITHM).
|
|
433
483
|
|
|
434
484
|
Checks: Bearer JWT, Bearer API key, Basic auth.
|
|
435
485
|
Returns payload dict on success, None on failure.
|
|
436
486
|
"""
|
|
487
|
+
# Both overrides used to be accepted and then dropped on the floor: the
|
|
488
|
+
# body called self.valid_token(), so a caller passing secret= or
|
|
489
|
+
# algorithm= silently got this instance's values instead. Honour them.
|
|
490
|
+
if secret is not None or algorithm is not None:
|
|
491
|
+
return Auth(secret=secret if secret is not None else self.secret,
|
|
492
|
+
algorithm=algorithm or self.algorithm,
|
|
493
|
+
expires_in=self.expires_in).authenticate_request(headers)
|
|
494
|
+
|
|
437
495
|
auth_header = headers.get("authorization", "")
|
|
438
496
|
|
|
439
497
|
if auth_header.startswith("Bearer "):
|
|
@@ -497,10 +555,10 @@ def refresh_token(token: str, expires_in: int = 60) -> str | None:
|
|
|
497
555
|
return Auth.refresh_token_static(token, expires_in=expires_in)
|
|
498
556
|
|
|
499
557
|
|
|
500
|
-
def authenticate_request(headers: dict, secret: str = None, algorithm: str =
|
|
558
|
+
def authenticate_request(headers: dict, secret: str = None, algorithm: str = None) -> dict | None:
|
|
501
559
|
"""Validate auth from request headers — reads TINA4_SECRET from env."""
|
|
502
|
-
if secret is not None:
|
|
503
|
-
return Auth(secret=secret
|
|
560
|
+
if secret is not None or algorithm is not None:
|
|
561
|
+
return Auth(secret=secret, algorithm=algorithm).authenticate_request(headers)
|
|
504
562
|
return Auth.authenticate_request_static(headers)
|
|
505
563
|
|
|
506
564
|
|
|
@@ -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
|
|