tina4-python 3.13.90__tar.gz → 3.13.92__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.90 → tina4_python-3.13.92}/PKG-INFO +1 -1
- {tina4_python-3.13.90 → tina4_python-3.13.92}/pyproject.toml +1 -1
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/auth/__init__.py +68 -10
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/__init__.py +13 -3
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/metrics.py +67 -5
- tina4_python-3.13.92/tina4_python/dev_admin/metrics_engine.py +108 -0
- tina4_python-3.13.92/tina4_python/public/js/tina4-dev-admin.min.js +1127 -0
- tina4_python-3.13.90/tina4_python/public/js/tina4-dev-admin.min.js +0 -1127
- {tina4_python-3.13.90 → tina4_python-3.13.92}/.gitignore +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/README.md +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/env.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_pagination.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.90 → tina4_python-3.13.92}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -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
|
|
|
@@ -2041,9 +2041,19 @@ async def _api_metrics(request, response):
|
|
|
2041
2041
|
|
|
2042
2042
|
|
|
2043
2043
|
async def _api_metrics_full(request, response):
|
|
2044
|
-
"""Full analysis —
|
|
2045
|
-
|
|
2046
|
-
|
|
2044
|
+
"""Full analysis — from the tina4 CLI metrics engine, else the local module.
|
|
2045
|
+
|
|
2046
|
+
The engine (ADR-0002) resolves imports to real file paths, so it is the only
|
|
2047
|
+
source with usable coupling data. It returns None when it is absent or too
|
|
2048
|
+
old to emit a field the dashboard renders, and we fall back rather than
|
|
2049
|
+
serve a payload with holes in it.
|
|
2050
|
+
"""
|
|
2051
|
+
from tina4_python.dev_admin.metrics import full_analysis, resolve_scan_target
|
|
2052
|
+
from tina4_python.dev_admin.metrics_engine import engine_analysis
|
|
2053
|
+
|
|
2054
|
+
root, scan_mode = resolve_scan_target()
|
|
2055
|
+
from_engine = engine_analysis(root, scan_mode)
|
|
2056
|
+
return response(from_engine if from_engine is not None else full_analysis())
|
|
2047
2057
|
|
|
2048
2058
|
|
|
2049
2059
|
async def _api_metrics_file(request, response):
|
|
@@ -23,6 +23,18 @@ _last_scan_root: str = ""
|
|
|
23
23
|
# ── Quick Metrics ──────────────────────────────────────────────
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _is_code_line(line: str) -> bool:
|
|
27
|
+
"""True for a line that counts toward LOC: not blank, not a comment.
|
|
28
|
+
|
|
29
|
+
The single definition of the rule. It used to be restated at each file-level
|
|
30
|
+
call site while function LOC ignored it entirely and returned a raw line
|
|
31
|
+
span, so `loc` meant two different things in one payload - see
|
|
32
|
+
_function_loc.
|
|
33
|
+
"""
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
return bool(stripped) and not stripped.startswith("#")
|
|
36
|
+
|
|
37
|
+
|
|
26
38
|
def _resolve_root(root: str = "src") -> str:
|
|
27
39
|
"""Pick the right directory to scan.
|
|
28
40
|
|
|
@@ -41,6 +53,23 @@ def _resolve_root(root: str = "src") -> str:
|
|
|
41
53
|
return framework_dir
|
|
42
54
|
|
|
43
55
|
|
|
56
|
+
def resolve_scan_target(root: str = "src") -> tuple[str, str]:
|
|
57
|
+
"""Return (directory to scan, scan_mode) for any metrics producer.
|
|
58
|
+
|
|
59
|
+
The CLI engine is language-agnostic and cannot know which directory holds a
|
|
60
|
+
framework package, so root resolution and the "framework" label stay here -
|
|
61
|
+
shared by this module and the engine adapter so the two never disagree about
|
|
62
|
+
what was measured.
|
|
63
|
+
"""
|
|
64
|
+
resolved = _resolve_root(root)
|
|
65
|
+
framework_dir = str(Path(__import__("tina4_python").__file__).parent)
|
|
66
|
+
resolved_path = Path(resolved)
|
|
67
|
+
scanning_framework = (
|
|
68
|
+
resolved_path == Path(framework_dir) or str(resolved_path).startswith(framework_dir)
|
|
69
|
+
)
|
|
70
|
+
return resolved, "framework" if scanning_framework else "project"
|
|
71
|
+
|
|
72
|
+
|
|
44
73
|
def quick_metrics(root: str = "src") -> dict:
|
|
45
74
|
"""Scan project files and return instant metrics."""
|
|
46
75
|
root = _resolve_root(root)
|
|
@@ -224,7 +253,7 @@ def full_analysis(root: str = "src") -> dict:
|
|
|
224
253
|
except ValueError:
|
|
225
254
|
rel_path = (str(f.relative_to(root_path.parent)) if root_path.parent != f else f.name).replace("\\", "/")
|
|
226
255
|
lines = source.splitlines()
|
|
227
|
-
loc = sum(1 for l in lines if
|
|
256
|
+
loc = sum(1 for l in lines if _is_code_line(l))
|
|
228
257
|
|
|
229
258
|
# Extract imports for coupling analysis
|
|
230
259
|
imports = _extract_imports(tree, rel_path)
|
|
@@ -491,7 +520,7 @@ def file_detail(file_path: str) -> dict:
|
|
|
491
520
|
|
|
492
521
|
functions.sort(key=lambda x: x["complexity"], reverse=True)
|
|
493
522
|
|
|
494
|
-
loc = sum(1 for l in lines if
|
|
523
|
+
loc = sum(1 for l in lines if _is_code_line(l))
|
|
495
524
|
classes = sum(1 for n in ast.walk(tree) if isinstance(n, ast.ClassDef))
|
|
496
525
|
imports = _extract_imports(tree, file_path)
|
|
497
526
|
|
|
@@ -526,13 +555,37 @@ def file_detail(file_path: str) -> dict:
|
|
|
526
555
|
# ── AST Helpers ────────────────────────────────────────────────
|
|
527
556
|
|
|
528
557
|
|
|
558
|
+
def _own_body_nodes(node: ast.AST):
|
|
559
|
+
"""Yield the nodes belonging to this function's OWN body.
|
|
560
|
+
|
|
561
|
+
Nested function and class definitions are skipped: they are reported as
|
|
562
|
+
functions in their own right, so counting their decision points here too
|
|
563
|
+
would charge one branch to two different functions. That over-count grew
|
|
564
|
+
with nesting depth - an IIFE-style wrapper or a registrar that defines
|
|
565
|
+
twenty inner handlers absorbed the entire file's complexity and sat at the
|
|
566
|
+
top of the offenders list while the real hot spots hid below it.
|
|
567
|
+
|
|
568
|
+
A lambda is NOT skipped. Lambdas are never listed as separate functions, so
|
|
569
|
+
their decision points would simply vanish; they stay with the function that
|
|
570
|
+
encloses them.
|
|
571
|
+
"""
|
|
572
|
+
stack = list(ast.iter_child_nodes(node))
|
|
573
|
+
while stack:
|
|
574
|
+
child = stack.pop()
|
|
575
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
576
|
+
continue
|
|
577
|
+
yield child
|
|
578
|
+
stack.extend(ast.iter_child_nodes(child))
|
|
579
|
+
|
|
580
|
+
|
|
529
581
|
def _cyclomatic_complexity(node: ast.AST) -> int:
|
|
530
582
|
"""Calculate cyclomatic complexity for a function/method node.
|
|
531
583
|
|
|
532
584
|
CC = 1 + number of decision points (if/elif/for/while/except/and/or/assert)
|
|
585
|
+
in the function's own body. Nested functions are measured separately.
|
|
533
586
|
"""
|
|
534
587
|
cc = 1
|
|
535
|
-
for child in
|
|
588
|
+
for child in _own_body_nodes(node):
|
|
536
589
|
if isinstance(child, (ast.If, ast.IfExp)):
|
|
537
590
|
cc += 1
|
|
538
591
|
elif isinstance(child, ast.For):
|
|
@@ -553,9 +606,18 @@ def _cyclomatic_complexity(node: ast.AST) -> int:
|
|
|
553
606
|
|
|
554
607
|
|
|
555
608
|
def _function_loc(node: ast.AST, lines: list) -> int:
|
|
556
|
-
"""Count lines of
|
|
609
|
+
"""Count lines of CODE in a function, by the same rule as file-level LOC.
|
|
610
|
+
|
|
611
|
+
This returned `end_lineno - lineno + 1` - a raw line span - while the
|
|
612
|
+
docstring claimed lines of code and every file-level LOC excluded blanks and
|
|
613
|
+
comments. So a function's LOC and its file's LOC were different units sitting
|
|
614
|
+
side by side in the dashboard, and the maintainability index (which divides
|
|
615
|
+
by LOC) penalised a well-commented function for its comments.
|
|
616
|
+
"""
|
|
557
617
|
if hasattr(node, "end_lineno") and node.end_lineno:
|
|
558
|
-
|
|
618
|
+
span = lines[node.lineno - 1:node.end_lineno]
|
|
619
|
+
# Floor of 1: a one-line body must never report 0.
|
|
620
|
+
return max(1, sum(1 for l in span if _is_code_line(l)))
|
|
559
621
|
# Fallback: count indented lines
|
|
560
622
|
start = node.lineno - 1
|
|
561
623
|
count = 1
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Code metrics from the tina4 CLI engine (ADR-0002).
|
|
2
|
+
|
|
3
|
+
The metrics engine now lives in the Rust CLI: one tree-sitter implementation
|
|
4
|
+
covering every language, instead of a hand-rolled AST walker per framework. This
|
|
5
|
+
adapter shells out to it and reshapes the payload into the dashboard's contract.
|
|
6
|
+
|
|
7
|
+
It returns None whenever the CLI cannot supply a COMPLETE payload - no binary on
|
|
8
|
+
PATH, a non-zero exit, unparseable JSON, or an older CLI whose engine predates a
|
|
9
|
+
field the dashboard renders. The caller then falls back to the in-framework
|
|
10
|
+
module, so a stale CLI degrades quietly instead of blanking the chart.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# The dashboard reads these at the top level; the CLI nests them under "summary"
|
|
21
|
+
# because its own text report and the --fail-on gate group them there.
|
|
22
|
+
_SUMMARY_KEYS = (
|
|
23
|
+
"files_analyzed",
|
|
24
|
+
"total_functions",
|
|
25
|
+
"avg_complexity",
|
|
26
|
+
"avg_maintainability",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Fields the dashboard renders that a pre-3.8.59 engine does not emit. Checking
|
|
30
|
+
# for the DATA is honest where checking a version string is not: a user may run
|
|
31
|
+
# any CLI build, and the payload tells us what it can actually do.
|
|
32
|
+
_REQUIRED_FILE_KEYS = ("path", "loc", "avg_complexity", "maintainability", "has_tests")
|
|
33
|
+
_REQUIRED_FUNCTION_KEYS = ("name", "file", "line", "complexity", "loc")
|
|
34
|
+
|
|
35
|
+
_TIMEOUT_SECONDS = 60
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def engine_path() -> str | None:
|
|
39
|
+
"""Absolute path to the tina4 CLI binary, or None when it is not installed."""
|
|
40
|
+
return shutil.which("tina4")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def engine_analysis(root: str, scan_mode: str = "project") -> dict | None:
|
|
44
|
+
"""Run `tina4 metrics --json` over root and adapt it to the dashboard shape.
|
|
45
|
+
|
|
46
|
+
The framework passes --path and owns scan_mode: it knows where its own
|
|
47
|
+
package lives, and the engine is deliberately language-agnostic, so root
|
|
48
|
+
resolution stays on this side of the boundary.
|
|
49
|
+
"""
|
|
50
|
+
binary = engine_path()
|
|
51
|
+
if binary is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
[binary, "metrics", "--path", str(root), "--json"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=_TIMEOUT_SECONDS,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.SubprocessError):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
payload = json.loads(proc.stdout)
|
|
69
|
+
except (json.JSONDecodeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
return adapt_payload(payload, root, scan_mode)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def adapt_payload(payload: dict, root: str, scan_mode: str = "project") -> dict | None:
|
|
76
|
+
"""Flatten the engine payload into the dashboard contract, or None if short.
|
|
77
|
+
|
|
78
|
+
Split out from engine_analysis so the reshaping is testable without a
|
|
79
|
+
subprocess - the shaping is where the contract actually lives.
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(payload, dict):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
summary = payload.get("summary")
|
|
85
|
+
file_metrics = payload.get("file_metrics")
|
|
86
|
+
functions = payload.get("most_complex_functions")
|
|
87
|
+
if not isinstance(summary, dict) or not isinstance(file_metrics, list):
|
|
88
|
+
return None
|
|
89
|
+
if not isinstance(functions, list):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
if any(key not in summary for key in _SUMMARY_KEYS):
|
|
93
|
+
return None
|
|
94
|
+
if file_metrics and any(key not in file_metrics[0] for key in _REQUIRED_FILE_KEYS):
|
|
95
|
+
return None
|
|
96
|
+
if functions and any(key not in functions[0] for key in _REQUIRED_FUNCTION_KEYS):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
result = {key: summary[key] for key in _SUMMARY_KEYS}
|
|
100
|
+
result["file_metrics"] = file_metrics
|
|
101
|
+
result["most_complex_functions"] = functions[:15]
|
|
102
|
+
result["dependency_graph"] = payload.get("dependency_graph") or {}
|
|
103
|
+
# The framework decides these two - the engine always reports "project"
|
|
104
|
+
# because it cannot know which directory is a framework package.
|
|
105
|
+
result["scan_mode"] = scan_mode
|
|
106
|
+
result["scan_root"] = str(Path(root).resolve())
|
|
107
|
+
result["engine"] = "tina4-cli"
|
|
108
|
+
return result
|