tina4-python 3.9.1__tar.gz → 3.9.2__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.
Files changed (139) hide show
  1. {tina4_python-3.9.1 → tina4_python-3.9.2}/PKG-INFO +1 -1
  2. {tina4_python-3.9.1 → tina4_python-3.9.2}/pyproject.toml +1 -1
  3. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/CLAUDE.md +6 -6
  4. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/cli/__init__.py +31 -0
  6. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/server.py +2 -1
  7. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/query_builder/__init__.py +146 -0
  8. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/session/__init__.py +5 -0
  9. tina4_python-3.9.2/tina4_python/websocket/backplane.py +121 -0
  10. {tina4_python-3.9.1 → tina4_python-3.9.2}/.gitignore +0 -0
  11. {tina4_python-3.9.1 → tina4_python-3.9.2}/README.md +0 -0
  12. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/__init__.py +0 -0
  20. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/cache.py +0 -0
  21. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/constants.py +0 -0
  22. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/events.py +0 -0
  23. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/middleware.py +0 -0
  24. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/request.py +0 -0
  25. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/response.py +0 -0
  26. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/core/router.py +0 -0
  27. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/crud/__init__.py +0 -0
  28. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/__init__.py +0 -0
  29. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/adapter.py +0 -0
  30. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/connection.py +0 -0
  31. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/firebird.py +0 -0
  32. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/mssql.py +0 -0
  33. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/mysql.py +0 -0
  34. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/odbc.py +0 -0
  35. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/postgres.py +0 -0
  36. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/database/sqlite.py +0 -0
  37. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/dev_admin/__init__.py +0 -0
  40. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/dev_reload.py +0 -0
  41. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/dotenv/__init__.py +0 -0
  42. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/frond/FROND.md +0 -0
  43. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/frond/__init__.py +0 -0
  44. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/frond/engine.py +0 -0
  45. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/auth/meta.json +0 -0
  46. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  47. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/database/meta.json +0 -0
  48. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  49. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/error-overlay/meta.json +0 -0
  50. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  51. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/orm/meta.json +0 -0
  52. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  53. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  54. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/queue/meta.json +0 -0
  55. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  56. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/rest-api/meta.json +0 -0
  57. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  58. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/templates/meta.json +0 -0
  59. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  60. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  61. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/graphql/__init__.py +0 -0
  62. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/i18n/__init__.py +0 -0
  63. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/messenger/__init__.py +0 -0
  64. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/migration/__init__.py +0 -0
  65. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/migration/runner.py +0 -0
  66. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/orm/__init__.py +0 -0
  67. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/orm/fields.py +0 -0
  68. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/orm/model.py +0 -0
  69. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/css/tina4.css +0 -0
  70. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/css/tina4.min.css +0 -0
  71. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/favicon.ico +0 -0
  72. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/images/logo.svg +0 -0
  73. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  74. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/js/frond.min.js +0 -0
  75. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  76. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/js/tina4.min.js +0 -0
  77. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/js/tina4js.min.js +0 -0
  78. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/swagger/index.html +0 -0
  79. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  80. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/queue/__init__.py +0 -0
  81. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/queue_backends/__init__.py +0 -0
  82. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/queue_backends/kafka_backend.py +0 -0
  83. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/queue_backends/mongo_backend.py +0 -0
  84. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  85. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/__init__.py +0 -0
  86. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  87. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_badges.scss +0 -0
  88. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  89. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_cards.scss +0 -0
  90. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_forms.scss +0 -0
  91. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_grid.scss +0 -0
  92. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_modals.scss +0 -0
  93. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_nav.scss +0 -0
  94. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_reset.scss +0 -0
  95. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_tables.scss +0 -0
  96. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_typography.scss +0 -0
  97. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  98. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/_variables.scss +0 -0
  99. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/base.scss +0 -0
  100. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/colors.scss +0 -0
  101. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/scss/tina4css/tina4.scss +0 -0
  102. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/seeder/__init__.py +0 -0
  103. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/service/__init__.py +0 -0
  104. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/session_handlers/__init__.py +0 -0
  105. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  106. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/session_handlers/redis_handler.py +0 -0
  107. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/session_handlers/valkey_handler.py +0 -0
  108. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/swagger/__init__.py +0 -0
  109. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/components/crud.twig +0 -0
  110. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  111. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  112. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/docker/python/Dockerfile +0 -0
  113. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  114. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/302.twig +0 -0
  115. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/401.twig +0 -0
  116. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/403.twig +0 -0
  117. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/404.twig +0 -0
  118. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/500.twig +0 -0
  119. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/502.twig +0 -0
  120. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/503.twig +0 -0
  121. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/errors/base.twig +0 -0
  122. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/frontend/README.md +0 -0
  123. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/templates/readme.md +0 -0
  124. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/test_client/__init__.py +0 -0
  125. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  126. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  127. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  128. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  129. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  130. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  131. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  132. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  133. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  134. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  135. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  136. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  137. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/validator/__init__.py +0 -0
  138. {tina4_python-3.9.1 → tina4_python-3.9.2}/tina4_python/websocket/__init__.py +0 -0
  139. {tina4_python-3.9.1 → tina4_python-3.9.2}/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.9.1
3
+ Version: 3.9.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.9.1"
3
+ version = "3.9.2"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -480,7 +480,7 @@ Response.add_header("X-Custom", "value")
480
480
 
481
481
  ## Sessions
482
482
 
483
- TINA4_TOKEN_EXPIRES_IN is used to set the session time, recommend 15-60 minutes
483
+ TINA4_TOKEN_LIMIT is used to set the session time, default 60 minutes
484
484
 
485
485
  ### Session Backends
486
486
 
@@ -1515,8 +1515,8 @@ Key `.env` settings:
1515
1515
  ```bash
1516
1516
  # Authentication
1517
1517
  SECRET=your-jwt-secret # JWT signing (default uses insecure placeholder)
1518
- API_KEY=your-api-key # Static bearer token for API auth
1519
- TINA4_TOKEN_EXPIRES_IN=60 # Token lifetime in minutes (default: 60)
1518
+ TINA4_API_KEY=your-api-key # Static bearer token for API auth (API_KEY fallback supported)
1519
+ TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
1520
1520
 
1521
1521
  # Database
1522
1522
  DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
@@ -1525,7 +1525,7 @@ DATABASE_PASSWORD= # DB password
1525
1525
 
1526
1526
  # Framework
1527
1527
  TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
1528
- TINA4_LOG_LEVEL=ALL # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR
1528
+ TINA4_LOG_LEVEL=ERROR # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR (default: ERROR)
1529
1529
  TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
1530
1530
  TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
1531
1531
  HOST_NAME=localhost:7145
@@ -1534,7 +1534,7 @@ HOST_NAME=localhost:7145
1534
1534
  TINA4_SESSION_HANDLER=SessionFileHandler # SessionFileHandler, SessionRedisHandler, SessionValkeyHandler, SessionMongoHandler
1535
1535
 
1536
1536
  # Swagger/OpenAPI
1537
- SWAGGER_TITLE=My API # API title (default: "Tina4 Python API")
1537
+ SWAGGER_TITLE=Tina4 API # API title (default: "Tina4 API")
1538
1538
  SWAGGER_VERSION=1.0.0 # API version
1539
1539
  SWAGGER_DESCRIPTION= # API description
1540
1540
  SWAGGER_CONTACT_TEAM= # Contact name
@@ -1763,7 +1763,7 @@ async def dashboard(request, response):
1763
1763
  - **`tina4python generate`**: model, route, migration, middleware scaffolding
1764
1764
  - **Database**: 5 engines (SQLite, PostgreSQL, MySQL, MSSQL, Firebird), query caching (`TINA4_DB_CACHE=true`, `cache_stats()`, `cache_clear()`)
1765
1765
  - **Sessions**: 4 backends (file, Redis/Valkey, MongoDB, database)
1766
- - **Queue**: SQLite/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1766
+ - **Queue**: file/RabbitMQ/Kafka/MongoDB backends, configured via env vars
1767
1767
  - **Cache**: memory/Redis/file backends
1768
1768
  - **Messenger**: .env driven SMTP/IMAP
1769
1769
  - **ORM relationships**: `has_many`, `has_one`, `belongs_to` with eager loading (`include=`)
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.9.1"
11
+ __version__ = "3.9.2"
12
12
 
13
13
  # ── HTTP Constants ──
14
14
  from tina4_python.core.constants import ( # noqa: E402, F401
@@ -141,6 +141,37 @@ def _init(args):
141
141
  if not dst_file.exists() and src_file.exists():
142
142
  dst_file.write_text(src_file.read_text(encoding="utf-8"), encoding="utf-8")
143
143
 
144
+ # Create root Dockerfile (uv variant) if it doesn't exist
145
+ root_dockerfile = target / "Dockerfile"
146
+ if not root_dockerfile.exists():
147
+ root_dockerfile.write_text(
148
+ 'FROM python:3.13-slim AS build\n'
149
+ 'WORKDIR /app\n'
150
+ 'COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv\n'
151
+ 'COPY pyproject.toml uv.lock* ./\n'
152
+ 'RUN uv sync --frozen --no-dev\n'
153
+ 'COPY . .\n'
154
+ '\n'
155
+ 'FROM python:3.13-slim\n'
156
+ 'WORKDIR /app\n'
157
+ 'COPY --from=build /app .\n'
158
+ 'COPY --from=build /usr/local/bin/uv /usr/local/bin/uv\n'
159
+ 'ENV PATH="/app/.venv/bin:$PATH"\n'
160
+ 'ENV HOST=0.0.0.0\n'
161
+ 'ENV PORT=7145\n'
162
+ 'EXPOSE 7145\n'
163
+ 'CMD ["python", "app.py"]\n',
164
+ encoding="utf-8",
165
+ )
166
+
167
+ # Create root .dockerignore if it doesn't exist
168
+ root_dockerignore = target / ".dockerignore"
169
+ if not root_dockerignore.exists():
170
+ root_dockerignore.write_text(
171
+ ".venv\n__pycache__\n.git\n.claude\n.env\n*.log\ntests\ntmp\n",
172
+ encoding="utf-8",
173
+ )
174
+
144
175
  # Auto-detect AI tools and install context
145
176
  from tina4_python.ai import detect_ai_names, install_context, install_all
146
177
  detected = detect_ai_names(str(target))
@@ -911,7 +911,8 @@ async def app(scope: dict, receive, send):
911
911
  sid = request.session.session_id if hasattr(request.session, 'session_id') else getattr(request.session, 'id', None)
912
912
  if sid:
913
913
  ttl = int(os.environ.get("TINA4_SESSION_TTL", "3600"))
914
- response.header("set-cookie", f"tina4_session={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}")
914
+ samesite = os.environ.get("TINA4_SESSION_SAMESITE", "Lax")
915
+ response.header("set-cookie", f"tina4_session={sid}; Path=/; HttpOnly; SameSite={samesite}; Max-Age={ttl}")
915
916
  except Exception:
916
917
  pass
917
918
 
@@ -257,6 +257,152 @@ class QueryBuilder:
257
257
  """
258
258
  return self.count() > 0
259
259
 
260
+ def to_mongo(self) -> dict:
261
+ """Convert the fluent builder state into a MongoDB-compatible query.
262
+
263
+ Returns:
264
+ A dict with keys: filter, projection, sort, limit, skip.
265
+ Only non-empty keys are included.
266
+ """
267
+ result = {}
268
+
269
+ # -- projection --
270
+ if self._columns != ["*"]:
271
+ result["projection"] = {col.strip(): 1 for col in self._columns}
272
+
273
+ # -- filter --
274
+ if self._wheres:
275
+ param_index = 0
276
+ and_conditions = []
277
+ or_conditions = []
278
+
279
+ for i, (connector, condition) in enumerate(self._wheres):
280
+ mongo_cond, param_index = self._parse_condition_to_mongo(
281
+ condition, param_index
282
+ )
283
+ if i == 0 or connector == "AND":
284
+ and_conditions.append(mongo_cond)
285
+ else:
286
+ or_conditions.append(mongo_cond)
287
+
288
+ if or_conditions:
289
+ # Merge AND conditions into a single dict, then $or with OR ones
290
+ and_merged = self._merge_mongo_conditions(and_conditions)
291
+ all_branches = [and_merged] + or_conditions
292
+ result["filter"] = {"$or": all_branches}
293
+ else:
294
+ result["filter"] = self._merge_mongo_conditions(and_conditions)
295
+
296
+ # -- sort --
297
+ if self._order_by_cols:
298
+ sort_list = []
299
+ for expr in self._order_by_cols:
300
+ parts = expr.strip().split()
301
+ field = parts[0]
302
+ direction = -1 if len(parts) > 1 and parts[1].upper() == "DESC" else 1
303
+ sort_list.append((field, direction))
304
+ result["sort"] = sort_list
305
+
306
+ # -- limit / skip --
307
+ if self._limit_val is not None:
308
+ result["limit"] = self._limit_val
309
+ if self._offset_val is not None:
310
+ result["skip"] = self._offset_val
311
+
312
+ return result
313
+
314
+ def _parse_condition_to_mongo(self, condition: str, param_index: int) -> tuple[dict, int]:
315
+ """Parse a single SQL condition string into a MongoDB filter dict.
316
+
317
+ Returns:
318
+ (mongo_filter_dict, updated_param_index)
319
+ """
320
+ import re
321
+
322
+ cond = condition.strip()
323
+
324
+ # IS NOT NULL
325
+ match = re.match(r"^(\w+)\s+IS\s+NOT\s+NULL$", cond, re.IGNORECASE)
326
+ if match:
327
+ return {match.group(1): {"$exists": True, "$ne": None}}, param_index
328
+
329
+ # IS NULL
330
+ match = re.match(r"^(\w+)\s+IS\s+NULL$", cond, re.IGNORECASE)
331
+ if match:
332
+ return {match.group(1): {"$exists": False}}, param_index
333
+
334
+ # NOT IN
335
+ match = re.match(r"^(\w+)\s+NOT\s+IN\s*\(\s*\?\s*\)$", cond, re.IGNORECASE)
336
+ if match:
337
+ val = self._params[param_index] if param_index < len(self._params) else []
338
+ values = val if isinstance(val, list) else [val]
339
+ return {match.group(1): {"$nin": values}}, param_index + 1
340
+
341
+ # IN
342
+ match = re.match(r"^(\w+)\s+IN\s*\(\s*\?\s*\)$", cond, re.IGNORECASE)
343
+ if match:
344
+ val = self._params[param_index] if param_index < len(self._params) else []
345
+ values = val if isinstance(val, list) else [val]
346
+ return {match.group(1): {"$in": values}}, param_index + 1
347
+
348
+ # LIKE
349
+ match = re.match(r"^(\w+)\s+LIKE\s+\?$", cond, re.IGNORECASE)
350
+ if match:
351
+ val = self._params[param_index] if param_index < len(self._params) else ""
352
+ # Convert SQL LIKE pattern to regex: % -> .*, _ -> .
353
+ pattern = str(val).replace("%", ".*").replace("_", ".")
354
+ return {match.group(1): {"$regex": pattern, "$options": "i"}}, param_index + 1
355
+
356
+ # Comparison operators: >=, <=, <>, !=, >, <, =
357
+ match = re.match(r"^(\w+)\s*(>=|<=|<>|!=|>|<|=)\s*\?$", cond)
358
+ if match:
359
+ field = match.group(1)
360
+ op = match.group(2)
361
+ val = self._params[param_index] if param_index < len(self._params) else None
362
+ op_map = {
363
+ "=": None,
364
+ "!=": "$ne",
365
+ "<>": "$ne",
366
+ ">": "$gt",
367
+ ">=": "$gte",
368
+ "<": "$lt",
369
+ "<=": "$lte",
370
+ }
371
+ mongo_op = op_map.get(op)
372
+ if mongo_op is None:
373
+ return {field: val}, param_index + 1
374
+ return {field: {mongo_op: val}}, param_index + 1
375
+
376
+ # Fallback: return condition as-is in $where (raw JS expression)
377
+ return {"$where": cond}, param_index
378
+
379
+ @staticmethod
380
+ def _merge_mongo_conditions(conditions: list[dict]) -> dict:
381
+ """Merge a list of single-field mongo condition dicts into one dict.
382
+
383
+ If the same field appears in multiple conditions, wraps them in $and.
384
+ """
385
+ if len(conditions) == 1:
386
+ return conditions[0]
387
+
388
+ merged = {}
389
+ conflicts = []
390
+ for cond in conditions:
391
+ for key, val in cond.items():
392
+ if key in merged:
393
+ conflicts.append(cond)
394
+ break
395
+ else:
396
+ merged[key] = val
397
+ else:
398
+ continue
399
+ # If we broke out due to conflict, don't add to merged
400
+ pass
401
+
402
+ if conflicts:
403
+ return {"$and": conditions}
404
+ return merged
405
+
260
406
  # -- Private helpers --
261
407
 
262
408
  def _build_where(self) -> str:
@@ -265,6 +265,11 @@ class Session:
265
265
  self.unset(flash_key)
266
266
  return val
267
267
 
268
+ def cookie_header(self, cookie_name: str = "tina4_session") -> str:
269
+ """Return a Set-Cookie header value for this session."""
270
+ samesite = os.environ.get("TINA4_SESSION_SAMESITE", "Lax")
271
+ return f"{cookie_name}={self._session_id}; Path=/; HttpOnly; SameSite={samesite}; Max-Age={self._ttl}"
272
+
268
273
  def gc(self):
269
274
  """Run garbage collection on the backend."""
270
275
  self._handler.gc(self._ttl)
@@ -0,0 +1,121 @@
1
+ """
2
+ WebSocket Backplane Abstraction for Tina4 Python.
3
+
4
+ Enables broadcasting WebSocket messages across multiple server instances
5
+ using a shared pub/sub channel (e.g. Redis). Without a backplane configured,
6
+ broadcast() only reaches connections on the local process.
7
+
8
+ Configuration via environment variables:
9
+ TINA4_WS_BACKPLANE — Backend type: "redis", "nats", or "" (default: none)
10
+ TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
11
+
12
+ Usage:
13
+ backplane = create_backplane()
14
+ if backplane:
15
+ backplane.subscribe("chat", lambda msg: relay_to_local(msg))
16
+ backplane.publish("chat", '{"user": "A", "text": "hello"}')
17
+ """
18
+
19
+ import os
20
+ import threading
21
+ import logging
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class WebSocketBackplane:
27
+ """Base backplane interface for scaling WebSocket broadcast across instances.
28
+
29
+ Subclasses implement publish/subscribe over a shared message bus so that
30
+ every server instance receives every broadcast, not just the originator.
31
+ """
32
+
33
+ def publish(self, channel: str, message: str) -> None:
34
+ """Publish a message to all instances listening on *channel*."""
35
+ raise NotImplementedError
36
+
37
+ def subscribe(self, channel: str, callback) -> None:
38
+ """Subscribe to *channel*. *callback(message: str)* is invoked for
39
+ each incoming message. Runs in a background thread."""
40
+ raise NotImplementedError
41
+
42
+ def unsubscribe(self, channel: str) -> None:
43
+ """Stop listening on *channel*."""
44
+ raise NotImplementedError
45
+
46
+ def close(self) -> None:
47
+ """Tear down connections and background threads."""
48
+ raise NotImplementedError
49
+
50
+
51
+ class RedisBackplane(WebSocketBackplane):
52
+ """Redis pub/sub backplane.
53
+
54
+ Requires the ``redis`` package (``pip install redis``). The import is
55
+ deferred so the rest of Tina4 works fine without it installed — an error
56
+ is raised only when this class is actually instantiated.
57
+ """
58
+
59
+ def __init__(self, url: str | None = None):
60
+ try:
61
+ import redis
62
+ except ImportError:
63
+ raise ImportError(
64
+ "The 'redis' package is required for RedisBackplane. "
65
+ "Install it with: pip install redis"
66
+ )
67
+
68
+ self._url = url or os.environ.get(
69
+ "TINA4_WS_BACKPLANE_URL", "redis://localhost:6379"
70
+ )
71
+ self._redis = redis.Redis.from_url(self._url)
72
+ self._pubsub = self._redis.pubsub()
73
+ self._threads: dict[str, threading.Thread] = {}
74
+ self._running = True
75
+ logger.info("RedisBackplane connected to %s", self._url)
76
+
77
+ def publish(self, channel: str, message: str) -> None:
78
+ self._redis.publish(channel, message)
79
+
80
+ def subscribe(self, channel: str, callback) -> None:
81
+ self._pubsub.subscribe(**{channel: lambda raw: callback(raw["data"].decode() if isinstance(raw["data"], bytes) else raw["data"])})
82
+ thread = self._pubsub.run_in_thread(sleep_time=0.01, daemon=True)
83
+ self._threads[channel] = thread
84
+ logger.info("RedisBackplane subscribed to channel '%s'", channel)
85
+
86
+ def unsubscribe(self, channel: str) -> None:
87
+ self._pubsub.unsubscribe(channel)
88
+ thread = self._threads.pop(channel, None)
89
+ if thread:
90
+ thread.stop()
91
+ logger.info("RedisBackplane unsubscribed from channel '%s'", channel)
92
+
93
+ def close(self) -> None:
94
+ self._running = False
95
+ for thread in self._threads.values():
96
+ thread.stop()
97
+ self._threads.clear()
98
+ self._pubsub.close()
99
+ self._redis.close()
100
+ logger.info("RedisBackplane closed")
101
+
102
+
103
+ def create_backplane(url: str | None = None) -> WebSocketBackplane | None:
104
+ """Factory that reads TINA4_WS_BACKPLANE and returns the appropriate
105
+ backplane instance, or *None* if no backplane is configured.
106
+
107
+ This keeps backplane usage entirely optional — callers simply check
108
+ ``if backplane:`` before publishing.
109
+ """
110
+ backend = os.environ.get("TINA4_WS_BACKPLANE", "").strip().lower()
111
+
112
+ if backend == "redis":
113
+ return RedisBackplane(url=url)
114
+ elif backend == "nats":
115
+ raise NotImplementedError(
116
+ "NATS backplane is on the roadmap but not yet implemented."
117
+ )
118
+ elif backend == "":
119
+ return None
120
+ else:
121
+ raise ValueError(f"Unknown TINA4_WS_BACKPLANE value: '{backend}'")
File without changes
File without changes