tina4-python 3.13.94__tar.gz → 3.13.95__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.94 → tina4_python-3.13.95}/.gitignore +12 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/PKG-INFO +20 -2
- {tina4_python-3.13.94 → tina4_python-3.13.95}/README.md +1 -1
- tina4_python-3.13.95/pyproject.toml +121 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/CLAUDE.md +14 -5
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/auth/__init__.py +239 -35
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/cache/__init__.py +630 -163
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/cli/__init__.py +5 -1
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/middleware.py +269 -31
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/rate_limiter.py +28 -2
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/request.py +108 -6
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/response.py +92 -3
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/router.py +55 -11
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/server.py +858 -293
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/adapter.py +404 -135
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/connection.py +180 -23
- tina4_python-3.13.95/tina4_python/database/database_url.py +365 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/firebird.py +163 -48
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/mongodb.py +15 -3
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/mssql.py +58 -49
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/mysql.py +47 -49
- tina4_python-3.13.95/tina4_python/database/odbc.py +224 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/postgres.py +39 -48
- tina4_python-3.13.95/tina4_python/database/sql_translator.py +272 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/sqlite.py +19 -38
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/debug/__init__.py +262 -45
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/dev_admin/__init__.py +96 -49
- tina4_python-3.13.95/tina4_python/dev_admin/metrics.py +375 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/docstore/__init__.py +240 -49
- tina4_python-3.13.95/tina4_python/dotenv/__init__.py +288 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mcp/tools.py +12 -2
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mqtt/__init__.py +5 -1
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/orm/model.py +91 -18
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/__init__.py +35 -28
- tina4_python-3.13.95/tina4_python/queue/amqp_url.py +73 -0
- tina4_python-3.13.95/tina4_python/queue/kafka_backend.py +210 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/lite_backend.py +23 -1
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/mongo_backend.py +65 -6
- tina4_python-3.13.95/tina4_python/queue/rabbitmq_backend.py +221 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/kafka_backend.py +13 -7
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/mongo_backend.py +20 -7
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/rabbitmq_backend.py +31 -17
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session/__init__.py +190 -16
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/__init__.py +8 -2
- tina4_python-3.13.95/tina4_python/session_handlers/memcached_handler.py +155 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/mongodb_handler.py +81 -29
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/redis_handler.py +4 -1
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/valkey_handler.py +4 -1
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/websocket/__init__.py +9 -3
- tina4_python-3.13.94/pyproject.toml +0 -73
- tina4_python-3.13.94/tina4_python/database/odbc.py +0 -177
- tina4_python-3.13.94/tina4_python/dev_admin/metrics.py +0 -867
- tina4_python-3.13.94/tina4_python/dev_admin/metrics_engine.py +0 -108
- tina4_python-3.13.94/tina4_python/dotenv/__init__.py +0 -163
- tina4_python-3.13.94/tina4_python/queue/kafka_backend.py +0 -151
- tina4_python-3.13.94/tina4_python/queue/rabbitmq_backend.py +0 -186
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/env.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_pagination.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
.env.local
|
|
11
11
|
/public/css/test.css
|
|
12
12
|
/sessions/
|
|
13
|
+
# Runtime session files written by the suite. This was the ONE gap of the four:
|
|
14
|
+
# php ignores /data/, node ignores data/, ruby ignores sessions/ (and defaults to
|
|
15
|
+
# pwd/sessions anyway) - only python left data/sessions/ visible, so a plain test
|
|
16
|
+
# run dirtied the tree and `git add -A` committed session payloads. It did: five
|
|
17
|
+
# of them are tracked on feature/audit-auth. FileSessionHandler already does
|
|
18
|
+
# mkdir(parents=True, exist_ok=True), so a fresh clone needs nothing here.
|
|
19
|
+
/data/sessions/
|
|
13
20
|
/tests/secrets/domain.cert
|
|
14
21
|
/tests/secrets/private.key
|
|
15
22
|
/tests/secrets/public.key
|
|
@@ -98,3 +105,8 @@ example/src/**/__pycache__/
|
|
|
98
105
|
# relative to cwd would create these under the repo. Never commit them.
|
|
99
106
|
/private/
|
|
100
107
|
/var/folders/
|
|
108
|
+
/data/sessions/
|
|
109
|
+
|
|
110
|
+
# Local file-backend queue store - runtime artefacts, never committed.
|
|
111
|
+
# Four probe .queue-data files were swept in by `git add -A` on 2026-08-03.
|
|
112
|
+
data/queue/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tina4-python
|
|
3
|
-
Version: 3.13.
|
|
3
|
+
Version: 3.13.95
|
|
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
|
|
@@ -14,6 +14,8 @@ Requires-Dist: pymssql>=2.3; extra == 'all-db'
|
|
|
14
14
|
Requires-Dist: pyodbc>=5.0; extra == 'all-db'
|
|
15
15
|
Provides-Extra: firebird
|
|
16
16
|
Requires-Dist: firebird-driver>=1.10; extra == 'firebird'
|
|
17
|
+
Provides-Extra: kafka
|
|
18
|
+
Requires-Dist: confluent-kafka>=2.0; extra == 'kafka'
|
|
17
19
|
Provides-Extra: memcache
|
|
18
20
|
Requires-Dist: pymemcache>=4.0; extra == 'memcache'
|
|
19
21
|
Provides-Extra: mongo
|
|
@@ -26,8 +28,24 @@ Provides-Extra: odbc
|
|
|
26
28
|
Requires-Dist: pyodbc>=5.0; extra == 'odbc'
|
|
27
29
|
Provides-Extra: postgres
|
|
28
30
|
Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
|
|
31
|
+
Provides-Extra: rabbitmq
|
|
32
|
+
Requires-Dist: pika>=1.3; extra == 'rabbitmq'
|
|
29
33
|
Provides-Extra: redis
|
|
30
34
|
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
35
|
+
Provides-Extra: s3
|
|
36
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
37
|
+
Provides-Extra: test
|
|
38
|
+
Requires-Dist: boto3>=1.34; extra == 'test'
|
|
39
|
+
Requires-Dist: confluent-kafka>=2.0; extra == 'test'
|
|
40
|
+
Requires-Dist: firebird-driver>=1.10; extra == 'test'
|
|
41
|
+
Requires-Dist: mysql-connector-python>=9.0; extra == 'test'
|
|
42
|
+
Requires-Dist: pika>=1.3; extra == 'test'
|
|
43
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == 'test'
|
|
44
|
+
Requires-Dist: pymemcache>=4.0; extra == 'test'
|
|
45
|
+
Requires-Dist: pymongo>=4.0; extra == 'test'
|
|
46
|
+
Requires-Dist: pymssql>=2.3; extra == 'test'
|
|
47
|
+
Requires-Dist: pyodbc>=5.0; extra == 'test'
|
|
48
|
+
Requires-Dist: redis>=5.0; extra == 'test'
|
|
31
49
|
Description-Content-Type: text/markdown
|
|
32
50
|
|
|
33
51
|
<p align="center">
|
|
@@ -116,7 +134,7 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
|
|
|
116
134
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
117
135
|
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
118
136
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
119
|
-
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
137
|
+
| **Auth & Security** (5) | JWT (HS256/HS384/HS512, zero-dependency; RS256 opt-in, needs `cryptography`), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
120
138
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8x faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
121
139
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
122
140
|
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
@@ -84,7 +84,7 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
|
|
|
84
84
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
85
85
|
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
86
86
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
87
|
-
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
87
|
+
| **Auth & Security** (5) | JWT (HS256/HS384/HS512, zero-dependency; RS256 opt-in, needs `cryptography`), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
88
88
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8x faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
89
89
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
90
90
|
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tina4-python"
|
|
3
|
+
version = "3.13.95"
|
|
4
|
+
description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
|
|
12
|
+
# ZERO core dependencies — this is v3's defining feature
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
# Database drivers — install what you need
|
|
17
|
+
postgres = ["psycopg2-binary>=2.9"]
|
|
18
|
+
mysql = ["mysql-connector-python>=9.0"]
|
|
19
|
+
mssql = ["pymssql>=2.3"]
|
|
20
|
+
firebird = ["firebird-driver>=1.10"]
|
|
21
|
+
mongo = ["pymongo>=4.0"]
|
|
22
|
+
odbc = ["pyodbc>=5.0"]
|
|
23
|
+
|
|
24
|
+
# Session backends — install what you need
|
|
25
|
+
redis = ["redis>=5.0"]
|
|
26
|
+
memcache = ["pymemcache>=4.0"]
|
|
27
|
+
|
|
28
|
+
# File-storage backends — install what you need.
|
|
29
|
+
# The `local` filesystem backend is the zero-dependency default; S3Storage
|
|
30
|
+
# (TINA4_STORAGE_BACKEND=s3) is opt-in and imports boto3 lazily.
|
|
31
|
+
s3 = ["boto3>=1.34"]
|
|
32
|
+
|
|
33
|
+
# Queue backends — install what you need
|
|
34
|
+
kafka = ["confluent-kafka>=2.0"]
|
|
35
|
+
rabbitmq = ["pika>=1.3"]
|
|
36
|
+
|
|
37
|
+
# All database drivers
|
|
38
|
+
all-db = [
|
|
39
|
+
"psycopg2-binary>=2.9",
|
|
40
|
+
"mysql-connector-python>=9.0",
|
|
41
|
+
"pymssql>=2.3",
|
|
42
|
+
"firebird-driver>=1.10",
|
|
43
|
+
"pymongo>=4.0",
|
|
44
|
+
"pyodbc>=5.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Every client the TEST SUITE needs against live services.
|
|
48
|
+
#
|
|
49
|
+
# These were installed ad hoc by CI (`uv pip install pymongo confluent-kafka
|
|
50
|
+
# pika ...`) and declared nowhere, so a fresh host had no way to know what the
|
|
51
|
+
# suite requires. confluent-kafka and pika in particular were in NO extra: the
|
|
52
|
+
# lab host's first full-suite run failed with three "confluent-kafka not
|
|
53
|
+
# installed" errors, correctly raised by the TINA4_REQUIRE_SERVICES gate, and
|
|
54
|
+
# `uv sync --all-extras` could not have fixed it because the dependency was not
|
|
55
|
+
# expressed anywhere. One declared extra means `uv sync --extra test` is
|
|
56
|
+
# reproducible on any machine.
|
|
57
|
+
#
|
|
58
|
+
# Includes firebird-driver even though the Firebird CI job installs it on its
|
|
59
|
+
# own: `uv sync --extra X` PRUNES anything outside the named extras, so a test
|
|
60
|
+
# extra that omitted it would silently uninstall it on a host that had it.
|
|
61
|
+
#
|
|
62
|
+
# boto3 is here for exactly that reason, MEASURED on the lab 2026-08-06
|
|
63
|
+
# (Ubuntu 24.04.4 LTS x86_64, Python 3.13.3): it was declared in NO extra, but
|
|
64
|
+
# happened to be installed ad hoc, so the two REAL-MinIO tests in
|
|
65
|
+
# tests/test_realtime_files.py were passing. Running `uv sync --extra test`
|
|
66
|
+
# pruned boto3 (plus s3transfer and jmespath) and those tests went straight from
|
|
67
|
+
# "2 passed" to "2 skipped" -- with the suite still GREEN under
|
|
68
|
+
# TINA4_REQUIRE_SERVICES=1, because the skip reason matched neither a service
|
|
69
|
+
# keyword nor an unavailable hint in the conftest gate. Live S3 coverage
|
|
70
|
+
# evaporated silently. Declaring it here is the fix; tests/conftest.py now also
|
|
71
|
+
# recognises a missing boto3 so the gate can never miss it again.
|
|
72
|
+
test = [
|
|
73
|
+
"psycopg2-binary>=2.9",
|
|
74
|
+
"mysql-connector-python>=9.0",
|
|
75
|
+
"pymssql>=2.3",
|
|
76
|
+
"firebird-driver>=1.10",
|
|
77
|
+
"pymongo>=4.0",
|
|
78
|
+
"pyodbc>=5.0",
|
|
79
|
+
"redis>=5.0",
|
|
80
|
+
"pymemcache>=4.0",
|
|
81
|
+
"confluent-kafka>=2.0",
|
|
82
|
+
"pika>=1.3",
|
|
83
|
+
"boto3>=1.34",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[dependency-groups]
|
|
87
|
+
dev = [
|
|
88
|
+
"bottle>=0.13.4",
|
|
89
|
+
"cython>=3.2.4",
|
|
90
|
+
"django>=6.0.3",
|
|
91
|
+
"fastapi>=0.135.1",
|
|
92
|
+
"flask>=3.1.3",
|
|
93
|
+
"pytest>=8.0",
|
|
94
|
+
"pytest-asyncio>=0.23",
|
|
95
|
+
"pytest-cov>=5.0",
|
|
96
|
+
"reportlab>=4.4.10",
|
|
97
|
+
"ruff>=0.4",
|
|
98
|
+
"setuptools>=82.0.1",
|
|
99
|
+
"uvicorn>=0.42.0",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[build-system]
|
|
103
|
+
requires = ["hatchling"]
|
|
104
|
+
build-backend = "hatchling.build"
|
|
105
|
+
|
|
106
|
+
[tool.hatch.build]
|
|
107
|
+
include = ["tina4_python/**/*"]
|
|
108
|
+
|
|
109
|
+
[project.scripts]
|
|
110
|
+
tina4python = "tina4_python.cli:main"
|
|
111
|
+
|
|
112
|
+
[tool.pytest.ini_options]
|
|
113
|
+
asyncio_mode = "auto"
|
|
114
|
+
testpaths = ["tests"]
|
|
115
|
+
markers = [
|
|
116
|
+
"slow: a test that needs real wall-clock time to reproduce a timing bug (deselect with -m 'not slow')",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
[tool.ruff]
|
|
120
|
+
target-version = "py312"
|
|
121
|
+
line-length = 120
|
|
@@ -514,8 +514,11 @@ TINA4_TOKEN_LIMIT is used to set the session time, default 60 minutes
|
|
|
514
514
|
|
|
515
515
|
Set `TINA4_SESSION_BACKEND` to choose a backend:
|
|
516
516
|
|
|
517
|
-
The value is the backend NAME, not a class name
|
|
518
|
-
|
|
517
|
+
The value is the backend NAME, not a class name, and it is normalised (trimmed and
|
|
518
|
+
lowercased), so " Redis " resolves. An UNRECOGNISED value raises `ValueError` at
|
|
519
|
+
startup, naming the bad value and the valid ones -- it used to fall back to the
|
|
520
|
+
file backend silently, which turned a typo into sessions quietly written to local
|
|
521
|
+
disk. Unset or blank still means file.
|
|
519
522
|
|
|
520
523
|
| Value | Backend | Required package |
|
|
521
524
|
|---------|---------|-----------------|
|
|
@@ -1641,17 +1644,23 @@ TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
|
|
|
1641
1644
|
TINA4_DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
|
|
1642
1645
|
TINA4_DATABASE_USERNAME= # DB username (for PostgreSQL, MySQL, etc.)
|
|
1643
1646
|
TINA4_DATABASE_PASSWORD= # DB password
|
|
1647
|
+
TINA4_DATABASE_CONNECT_TIMEOUT=10 # Seconds a database connect may block (default 10). Set <= 0 to wait
|
|
1648
|
+
# indefinitely (the old behaviour); a non-numeric value warns and uses 10.
|
|
1649
|
+
# On expiry the error names the host, port, elapsed seconds and this variable.
|
|
1650
|
+
# Applies to postgres/mysql/mssql/odbc/firebird, and to mongodb's deferred
|
|
1651
|
+
# connect. SQLite is exempt: it opens a local file, there is no peer to hang on
|
|
1644
1652
|
|
|
1645
1653
|
# Framework
|
|
1646
1654
|
TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
|
|
1647
1655
|
TINA4_LOG_LEVEL=INFO # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
|
|
1648
1656
|
TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
|
|
1649
|
-
TINA4_DEFAULT_WEBSERVER=FALSE #
|
|
1657
|
+
TINA4_DEFAULT_WEBSERVER=FALSE # TRUE pins Tina4's built-in asyncio webserver even when a production ASGI server (uvicorn/hypercorn/granian) is installed. Unset/FALSE: a production server is used when one is importable and TINA4_DEBUG is off
|
|
1658
|
+
TINA4_SHUTDOWN_TIMEOUT=30 # Seconds to drain in-flight requests on SIGTERM/SIGINT (default 30). Honoured on the built-in server, and mapped onto uvicorn's timeout_graceful_shutdown / hypercorn's graceful_timeout. granian has no drain deadline, so it is not honoured there (logged at startup). A non-positive or unparseable value warns and uses 30
|
|
1650
1659
|
TINA4_OVERRIDE_CLIENT=false # Set to true to allow running without tina4 CLI (e.g. Docker)
|
|
1651
1660
|
HOST_NAME=localhost:7145
|
|
1652
1661
|
|
|
1653
1662
|
# Sessions
|
|
1654
|
-
TINA4_SESSION_BACKEND=file # file, redis, valkey, mongodb, database (unknown value
|
|
1663
|
+
TINA4_SESSION_BACKEND=file # file, redis, valkey, mongodb, memcached, database (an unknown value RAISES; unset/blank = file)
|
|
1655
1664
|
TINA4_SESSION_SAMESITE=Lax # SameSite attribute for session cookies (default: Lax)
|
|
1656
1665
|
|
|
1657
1666
|
# Swagger/OpenAPI
|
|
@@ -1713,7 +1722,7 @@ anywhere (the production on/off switch, wired for real in 3.13.40).
|
|
|
1713
1722
|
|
|
1714
1723
|
## CORS
|
|
1715
1724
|
|
|
1716
|
-
Built-in
|
|
1725
|
+
Built-in, and it DENIES by default (ADR-0018). Set `TINA4_CORS_ORIGINS` to the origins you want to allow (comma-separated), or `*` for any. With it unset no `Access-Control-Allow-Origin` is sent and the browser blocks the cross-origin request. `TINA4_CORS_CREDENTIALS=true` is never sent alongside a `*` origin (the Fetch Standard forbids the pair). OPTIONS pre-flight is handled automatically.
|
|
1717
1726
|
|
|
1718
1727
|
## SCSS Workflow
|
|
1719
1728
|
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# Tina4 Auth — Zero-dependency JWT, password hashing, and token validation.
|
|
2
2
|
"""
|
|
3
|
-
JWT implementation using Python stdlib only (hmac + hashlib).
|
|
4
|
-
|
|
3
|
+
JWT implementation using Python stdlib only (hmac + hashlib). No PyJWT.
|
|
4
|
+
|
|
5
|
+
HMAC — HS256 (default), HS384, HS512 — is Tina4's JWT algorithm family, and it is
|
|
6
|
+
zero-dependency in all four frameworks. RS256 is OPT-IN: Python's standard library
|
|
7
|
+
has no asymmetric crypto, so it needs the ``cryptography`` package, which Tina4
|
|
8
|
+
never declares as a dependency. Nothing about RS256 is loaded, probed or warned
|
|
9
|
+
about unless a caller actually asks for it; when they do without a backend
|
|
10
|
+
installed, they get ``RS256UnavailableError`` naming the exact install command.
|
|
11
|
+
|
|
12
|
+
SECURITY — HMAC IS SYMMETRIC. Every service that VERIFIES a token holds the SAME
|
|
13
|
+
secret that signs it, so any verifier can also MINT tokens. That is fine for one
|
|
14
|
+
app or a trusted fleet, and wrong for handing tokens to a third party you do not
|
|
15
|
+
control. RS256 exists so a verifier can hold only a public key; reach for it (and
|
|
16
|
+
install ``cryptography``) when the verifier is not someone you trust to sign.
|
|
5
17
|
|
|
6
18
|
from tina4_python.auth import Auth
|
|
7
19
|
|
|
@@ -196,8 +208,9 @@ def _resolve_secret(secret: str = None) -> str:
|
|
|
196
208
|
return env_secret
|
|
197
209
|
|
|
198
210
|
|
|
199
|
-
#
|
|
200
|
-
#
|
|
211
|
+
# The STANDARD JWT algorithm family for Tina4: HMAC, in every framework, with zero
|
|
212
|
+
# dependencies (python: hmac + hashlib, php: hash_hmac, ruby: OpenSSL::HMAC, node:
|
|
213
|
+
# node:crypto createHmac). The header's "alg" is always the one we actually sign
|
|
201
214
|
# with (python#105): the digest is looked up here rather than hardcoded.
|
|
202
215
|
_HMAC_ALGORITHMS = {
|
|
203
216
|
"HS256": hashlib.sha256,
|
|
@@ -205,24 +218,170 @@ _HMAC_ALGORITHMS = {
|
|
|
205
218
|
"HS512": hashlib.sha512,
|
|
206
219
|
}
|
|
207
220
|
|
|
221
|
+
# RS256 is OPT-IN. It is a recognised algorithm NAME everywhere, but it only works
|
|
222
|
+
# where the runtime provides asymmetric crypto. Python's standard library provides
|
|
223
|
+
# NONE, so RS256 in Python needs a library the app already installed - and Tina4
|
|
224
|
+
# still declares no dependency on one. See _require_rs256() for the contract.
|
|
225
|
+
_RSA_ALGORITHMS = ("RS256",)
|
|
226
|
+
|
|
227
|
+
# Every algorithm name Tina4 recognises. RS256's PRESENCE here is deliberate: an app
|
|
228
|
+
# may configure it, and whether it WORKS is decided later, at the point of use.
|
|
229
|
+
_SUPPORTED_ALGORITHMS = tuple(_HMAC_ALGORITHMS) + _RSA_ALGORITHMS
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class RS256UnavailableError(RuntimeError):
|
|
233
|
+
"""RS256 was asked to sign or verify and no RSA backend is installed.
|
|
234
|
+
|
|
235
|
+
Raised at the POINT OF USE (signing or verifying), never at import, boot, or
|
|
236
|
+
construction - an app that never touches RS256 never sees this type at all.
|
|
237
|
+
|
|
238
|
+
It is deliberately loud. Returning ``None`` here would be indistinguishable
|
|
239
|
+
from "that token is invalid", so a missing deployment dependency would read
|
|
240
|
+
as an authentication failure and be debugged as the wrong problem.
|
|
241
|
+
|
|
242
|
+
Cross-framework contract: the BEHAVIOUR is identical in all four - loud, at
|
|
243
|
+
the point of use, naming the remedy - while the raised TYPE is each runtime's
|
|
244
|
+
own idiom, because that is what a developer's existing handlers already
|
|
245
|
+
catch:
|
|
246
|
+
|
|
247
|
+
python RS256UnavailableError(RuntimeError) pip install cryptography
|
|
248
|
+
php \\RuntimeException install ext-openssl
|
|
249
|
+
ruby NotImplementedError rebuild Ruby with OpenSSL
|
|
250
|
+
node Error (node:crypto is builtin)
|
|
251
|
+
|
|
252
|
+
Only Python and PHP can realistically raise: Ruby's OpenSSL is a stdlib
|
|
253
|
+
default gem and Node's crypto is builtin, so their branches are unreachable
|
|
254
|
+
on any normal build. A single shared class name in four languages would be
|
|
255
|
+
four new public types for a state two of them cannot reach, so the contract
|
|
256
|
+
is pinned on the message and the timing instead - see
|
|
257
|
+
tests/test_auth_rs256_optin.py and the equivalents in the other three.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# The message a developer actually sees. It names the package, the exact install
|
|
262
|
+
# command, and the zero-dependency alternative, because "RS256 unavailable" with no
|
|
263
|
+
# remedy is the mysterious failure this ruling exists to remove.
|
|
264
|
+
#
|
|
265
|
+
# ONE library is named on purpose. Supporting cryptography AND PyJWT AND
|
|
266
|
+
# python-jose would double this code to buy nothing: they all end up calling
|
|
267
|
+
# cryptography for RSA, and the base64url header.payload.signature envelope is
|
|
268
|
+
# something Auth already builds for HMAC. cryptography supplies the missing
|
|
269
|
+
# PRIMITIVE and nothing else.
|
|
270
|
+
_RS256_UNAVAILABLE_MESSAGE = (
|
|
271
|
+
"JWT algorithm RS256 was requested, but no RSA backend is installed. "
|
|
272
|
+
"Python's standard library has no asymmetric crypto, so Tina4 cannot sign or "
|
|
273
|
+
"verify RS256 on its own - and Tina4 will not add a dependency for you. "
|
|
274
|
+
"Install it:\n"
|
|
275
|
+
" uv add cryptography (or: pip install cryptography)\n"
|
|
276
|
+
"Or stay zero-dependency: the HMAC family (HS256/HS384/HS512) is Tina4's "
|
|
277
|
+
"default and works in all four frameworks with no install at all - set "
|
|
278
|
+
"TINA4_JWT_ALGORITHM=HS256."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _require_rs256():
|
|
283
|
+
"""Return the RSA primitives RS256 needs, or raise ``RS256UnavailableError``.
|
|
284
|
+
|
|
285
|
+
LAZY CAPABILITY DETECTION. This function is called from the RS256 signing and
|
|
286
|
+
verifying paths and NOWHERE else: there is no import-time probe, no boot-time
|
|
287
|
+
probe, no startup warning and no log line. An app on the default HMAC path
|
|
288
|
+
never executes a line of this function, so it pays nothing - not an import,
|
|
289
|
+
not a warning - for a feature it does not use.
|
|
290
|
+
|
|
291
|
+
The import is the detection: if ``cryptography`` is installed we use it, and
|
|
292
|
+
if it is not, the ImportError becomes the actionable error above.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
296
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
297
|
+
except ImportError:
|
|
298
|
+
raise RS256UnavailableError(_RS256_UNAVAILABLE_MESSAGE) from None
|
|
299
|
+
return hashes, serialization, padding
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _pem_bytes(key: str) -> bytes:
|
|
303
|
+
"""Coerce PEM key material to bytes.
|
|
304
|
+
|
|
305
|
+
The key travels in the same ``secret`` slot the HMAC family uses, which is the
|
|
306
|
+
contract PHP and Node already implement (PHP hands ``$secret`` straight to
|
|
307
|
+
openssl_pkey_get_private/openssl_pkey_get_public). One slot, one env var
|
|
308
|
+
(TINA4_SECRET), no new API surface for the other three to mirror.
|
|
309
|
+
"""
|
|
310
|
+
return key.encode() if isinstance(key, str) else bytes(key or b"")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _rs256_sign(signing_input: bytes, private_key_pem: str) -> bytes:
|
|
314
|
+
"""RSASSA-PKCS1-v1_5 over SHA-256 - the signature RFC 7518 calls RS256."""
|
|
315
|
+
hashes, serialization, padding = _require_rs256()
|
|
316
|
+
private_key = serialization.load_pem_private_key(
|
|
317
|
+
_pem_bytes(private_key_pem), password=None
|
|
318
|
+
)
|
|
319
|
+
return private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _rs256_verify(signing_input: bytes, signature: bytes, key_pem: str) -> bool:
|
|
323
|
+
"""Verify an RS256 signature with a public (or private) PEM key.
|
|
324
|
+
|
|
325
|
+
A verifier normally holds ONLY the public key - that is the entire reason
|
|
326
|
+
RS256 exists. A private PEM is accepted too so one configured key round-trips
|
|
327
|
+
in a single-service app.
|
|
328
|
+
"""
|
|
329
|
+
hashes, serialization, padding = _require_rs256()
|
|
330
|
+
pem = _pem_bytes(key_pem)
|
|
331
|
+
try:
|
|
332
|
+
public_key = serialization.load_pem_public_key(pem)
|
|
333
|
+
except Exception:
|
|
334
|
+
try:
|
|
335
|
+
public_key = serialization.load_pem_private_key(pem, password=None).public_key()
|
|
336
|
+
except Exception:
|
|
337
|
+
return False # unusable key material is a failed verification
|
|
338
|
+
try:
|
|
339
|
+
public_key.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
340
|
+
return True
|
|
341
|
+
except Exception:
|
|
342
|
+
return False
|
|
343
|
+
|
|
208
344
|
# Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without this a
|
|
209
345
|
# token minted on one host and validated on another a second behind is rejected
|
|
210
346
|
# for no real reason; RFC 7519 explicitly allows "a small leeway".
|
|
211
347
|
_JWT_LEEWAY_SECONDS = 60
|
|
212
348
|
|
|
213
349
|
|
|
350
|
+
def _numeric_date(value) -> "int | None":
|
|
351
|
+
"""Coerce an RFC 7519 NumericDate claim to int seconds, else ``None``.
|
|
352
|
+
|
|
353
|
+
RFC 7519 s2 defines ``exp``/``nbf``/``iat`` as a NumericDate — a JSON
|
|
354
|
+
numeric value. A claim that is PRESENT but not a number is malformed, and a
|
|
355
|
+
malformed constraint must never read as "no constraint": treating a
|
|
356
|
+
non-numeric ``exp`` as absent turns a broken token into one that never
|
|
357
|
+
expires. ``bool`` is excluded deliberately — it is an ``int`` subclass in
|
|
358
|
+
Python, so ``exp: true`` would otherwise compare as the year 1970.
|
|
359
|
+
"""
|
|
360
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
361
|
+
return None
|
|
362
|
+
return int(value)
|
|
363
|
+
|
|
364
|
+
|
|
214
365
|
def _resolve_algorithm(algorithm: str = None) -> str:
|
|
215
366
|
"""Pick the JWT algorithm: explicit arg, else TINA4_JWT_ALGORITHM, else HS256.
|
|
216
367
|
|
|
217
|
-
Raises ValueError naming the supported set when asked for one we
|
|
218
|
-
(python#106 — the env var was registered in the CLI's
|
|
219
|
-
silently ignored, so a user could set HS512 and still get
|
|
368
|
+
Raises ValueError naming the supported set when asked for one we do not
|
|
369
|
+
recognise at all (python#106 — the env var was registered in the CLI's
|
|
370
|
+
known_vars and then silently ignored, so a user could set HS512 and still get
|
|
371
|
+
HS256 tokens).
|
|
372
|
+
|
|
373
|
+
RS256 RESOLVES HERE WITHOUT ANY PROBE. Recognising the name is not the same as
|
|
374
|
+
being able to sign with it: whether an RSA backend exists is decided lazily, in
|
|
375
|
+
_require_rs256(), the first time a token is actually signed or verified. That
|
|
376
|
+
split is what keeps the HMAC majority free of any RS256 detection cost.
|
|
220
377
|
"""
|
|
221
378
|
chosen = (algorithm or os.environ.get("TINA4_JWT_ALGORITHM") or "HS256").strip()
|
|
222
|
-
if chosen not in
|
|
379
|
+
if chosen not in _SUPPORTED_ALGORITHMS:
|
|
223
380
|
raise ValueError(
|
|
224
381
|
f"Unsupported JWT algorithm {chosen!r}. Tina4 signs with "
|
|
225
|
-
f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC
|
|
382
|
+
f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC, zero-dependency, the "
|
|
383
|
+
f"default and the cross-framework standard) or RS256 (opt-in, needs an "
|
|
384
|
+
f"RSA backend installed - see RS256UnavailableError). "
|
|
226
385
|
f"Set TINA4_JWT_ALGORITHM to one of those."
|
|
227
386
|
)
|
|
228
387
|
return chosen
|
|
@@ -310,29 +469,51 @@ class Auth:
|
|
|
310
469
|
|
|
311
470
|
# Pin the algorithm to OUR configured one instead of trusting the
|
|
312
471
|
# token's header. A token asking to be verified as anything else --
|
|
313
|
-
# "none", a weaker HMAC, or
|
|
472
|
+
# "none", a weaker HMAC, or RS256 when we are configured for HMAC -- is
|
|
314
473
|
# rejected before any signature work. Matches the Ruby check.
|
|
474
|
+
#
|
|
475
|
+
# This matters MORE now that RS256 is opt-in, not less: pinning is what
|
|
476
|
+
# stops an attacker re-labelling a token's header to pick an algorithm
|
|
477
|
+
# we did not choose, and it also means an RS256-labelled token can never
|
|
478
|
+
# drive an HMAC-configured app into the RS256 capability check.
|
|
315
479
|
header = json.loads(_b64url_decode(h))
|
|
316
480
|
if header.get("alg") != self.algorithm:
|
|
317
481
|
return None
|
|
318
482
|
|
|
319
|
-
|
|
320
|
-
if not hmac.compare_digest(sig, expected):
|
|
483
|
+
if not self._verify(f"{h}.{p}", sig):
|
|
321
484
|
return None
|
|
322
485
|
|
|
323
486
|
payload = json.loads(_b64url_decode(p))
|
|
324
487
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
#
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
488
|
+
# Integer seconds, so all four frameworks compare the SAME number.
|
|
489
|
+
# PHP/Ruby read an integer clock and Python/Node a float one; with a
|
|
490
|
+
# float clock "now > exp" and "now >= exp" differ only on an exactly
|
|
491
|
+
# integral instant, so truncating here is what makes the boundary
|
|
492
|
+
# identical everywhere rather than merely close.
|
|
493
|
+
now = int(time.time())
|
|
494
|
+
|
|
495
|
+
# RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that
|
|
496
|
+
# the current date/time MUST be before the expiration date/time".
|
|
497
|
+
# now == exp is therefore ALREADY expired, so the test is >=.
|
|
498
|
+
if "exp" in payload:
|
|
499
|
+
exp = _numeric_date(payload["exp"])
|
|
500
|
+
if exp is None or now >= exp:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
# "nbf" (not-before): a post-dated token is not valid yet. Tolerates
|
|
504
|
+
# _JWT_LEEWAY_SECONDS of clock skew, which RFC 7519 s4.1.5 permits.
|
|
505
|
+
if "nbf" in payload:
|
|
506
|
+
nbf = _numeric_date(payload["nbf"])
|
|
507
|
+
if nbf is None or now + _JWT_LEEWAY_SECONDS < nbf:
|
|
508
|
+
return None
|
|
334
509
|
|
|
335
510
|
return payload
|
|
511
|
+
except RS256UnavailableError:
|
|
512
|
+
# A missing RSA backend is a DEPLOYMENT fault, not an invalid token.
|
|
513
|
+
# Swallowing it into the None below would report "authentication
|
|
514
|
+
# failed" for every request and hide the one-line fix, so it is the
|
|
515
|
+
# single exception this catch-all deliberately lets through.
|
|
516
|
+
raise
|
|
336
517
|
except Exception:
|
|
337
518
|
return None
|
|
338
519
|
|
|
@@ -362,6 +543,11 @@ class Auth:
|
|
|
362
543
|
return self.get_token(payload, expires_in=expires_in)
|
|
363
544
|
|
|
364
545
|
def _sign(self, message: str) -> str:
|
|
546
|
+
# RS256 is the opt-in branch: the capability check happens HERE, at the
|
|
547
|
+
# point of use, and raises RS256UnavailableError naming the fix if no RSA
|
|
548
|
+
# backend is installed. self.secret carries the PEM private key.
|
|
549
|
+
if self.algorithm in _RSA_ALGORITHMS:
|
|
550
|
+
return _b64url_encode(_rs256_sign(message.encode(), self.secret))
|
|
365
551
|
# Digest comes from the configured algorithm, so the "alg" we advertise in
|
|
366
552
|
# the header is the one that actually produced this signature.
|
|
367
553
|
sig = hmac.new(
|
|
@@ -369,6 +555,19 @@ class Auth:
|
|
|
369
555
|
).digest()
|
|
370
556
|
return _b64url_encode(sig)
|
|
371
557
|
|
|
558
|
+
def _verify(self, message: str, signature: str) -> bool:
|
|
559
|
+
"""Check a signature against the CONFIGURED algorithm.
|
|
560
|
+
|
|
561
|
+
Symmetric and asymmetric verification are genuinely different operations:
|
|
562
|
+
HMAC recomputes the signature and compares it in constant time, while RS256
|
|
563
|
+
verifies against a public key it cannot use to sign. Recomputing an RSA
|
|
564
|
+
signature would demand the private key on every verifier, which is exactly
|
|
565
|
+
the property RS256 exists to avoid.
|
|
566
|
+
"""
|
|
567
|
+
if self.algorithm in _RSA_ALGORITHMS:
|
|
568
|
+
return _rs256_verify(message.encode(), _b64url_decode(signature), self.secret)
|
|
569
|
+
return hmac.compare_digest(signature, self._sign(message))
|
|
570
|
+
|
|
372
571
|
# ── Legacy aliases ─────────────────────────────────────────────
|
|
373
572
|
|
|
374
573
|
# get_token and valid_token are the primary names
|
|
@@ -410,7 +609,8 @@ class Auth:
|
|
|
410
609
|
def authenticate_request_static(cls, headers: dict) -> dict | None:
|
|
411
610
|
"""Extract and validate auth from request headers without instantiating Auth.
|
|
412
611
|
|
|
413
|
-
Reads TINA4_SECRET from env. Checks
|
|
612
|
+
Reads TINA4_SECRET from env. Checks a Bearer JWT, then a Bearer API key.
|
|
613
|
+
There is no Basic branch - see authenticate_request.
|
|
414
614
|
Returns payload dict on success, None on failure.
|
|
415
615
|
"""
|
|
416
616
|
secret = _resolve_secret()
|
|
@@ -481,8 +681,17 @@ class Auth:
|
|
|
481
681
|
secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
|
|
482
682
|
algorithm: JWT algorithm override (default: self.algorithm / TINA4_JWT_ALGORITHM).
|
|
483
683
|
|
|
484
|
-
Checks
|
|
485
|
-
|
|
684
|
+
Checks a Bearer JWT, then falls back to a Bearer API key. Returns the
|
|
685
|
+
verified payload dict on success, ``None`` on failure.
|
|
686
|
+
|
|
687
|
+
There is deliberately NO Basic branch. This method used to decode
|
|
688
|
+
``Authorization: Basic`` and return a truthy
|
|
689
|
+
``{"auth_type": "basic", "username": ..., "password": ...}`` — but it
|
|
690
|
+
never checked those credentials against anything, so every caller
|
|
691
|
+
following the documented ``if auth is None: return 401`` idiom
|
|
692
|
+
authenticated any client that sent a base64 string. PHP, Ruby and Node
|
|
693
|
+
all returned None there. An app that wants Basic auth must verify the
|
|
694
|
+
credentials itself against its own user store.
|
|
486
695
|
"""
|
|
487
696
|
# Both overrides used to be accepted and then dropped on the floor: the
|
|
488
697
|
# body called self.valid_token(), so a caller passing secret= or
|
|
@@ -498,18 +707,13 @@ class Auth:
|
|
|
498
707
|
token = auth_header[7:]
|
|
499
708
|
if self.valid_token(token):
|
|
500
709
|
return self.get_payload(token)
|
|
710
|
+
# "_auth" is the cross-framework key for a non-JWT auth result; PHP
|
|
711
|
+
# and Node already used it, Python used "auth_type" and Ruby
|
|
712
|
+
# "api_key", so the same successful auth read three different ways.
|
|
501
713
|
if self.validate_api_key(token):
|
|
502
|
-
return {"
|
|
714
|
+
return {"_auth": "api_key"}
|
|
503
715
|
return None
|
|
504
716
|
|
|
505
|
-
if auth_header.startswith("Basic "):
|
|
506
|
-
try:
|
|
507
|
-
decoded = base64.b64decode(auth_header[6:]).decode()
|
|
508
|
-
username, password = decoded.split(":", 1)
|
|
509
|
-
return {"auth_type": "basic", "username": username, "password": password}
|
|
510
|
-
except Exception:
|
|
511
|
-
return None
|
|
512
|
-
|
|
513
717
|
return None
|
|
514
718
|
|
|
515
719
|
|
|
@@ -584,7 +788,7 @@ class AuthMiddleware:
|
|
|
584
788
|
|
|
585
789
|
|
|
586
790
|
__all__ = [
|
|
587
|
-
"Auth", "AuthMiddleware", "
|
|
588
|
-
"refresh_token", "authenticate_request", "validate_api_key",
|
|
791
|
+
"Auth", "AuthMiddleware", "RS256UnavailableError", "get_token", "valid_token",
|
|
792
|
+
"get_payload", "refresh_token", "authenticate_request", "validate_api_key",
|
|
589
793
|
"ensure_dev_secret",
|
|
590
794
|
]
|