tina4-python 3.10.48__tar.gz → 3.10.54__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 (143) hide show
  1. {tina4_python-3.10.48 → tina4_python-3.10.54}/PKG-INFO +1 -1
  2. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/__init__.py +23 -1
  3. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/cli/__init__.py +23 -2
  4. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/request.py +15 -0
  5. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/router.py +1 -1
  6. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/server.py +52 -9
  7. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/adapter.py +23 -11
  8. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/connection.py +1 -1
  9. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/firebird.py +2 -2
  10. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/mssql.py +1 -1
  11. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/mysql.py +1 -1
  12. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/odbc.py +1 -1
  13. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/postgres.py +1 -1
  14. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/sqlite.py +1 -1
  15. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/dev_admin/__init__.py +4 -2
  16. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/frond/engine.py +69 -0
  17. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/orm/model.py +11 -1
  18. {tina4_python-3.10.48 → tina4_python-3.10.54}/.gitignore +0 -0
  19. {tina4_python-3.10.48 → tina4_python-3.10.54}/README.md +0 -0
  20. {tina4_python-3.10.48 → tina4_python-3.10.54}/pyproject.toml +0 -0
  21. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/CLAUDE.md +0 -0
  22. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/HtmlElement.py +0 -0
  23. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/Testing.py +0 -0
  24. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/ai/__init__.py +0 -0
  25. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/api/__init__.py +0 -0
  26. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/auth/__init__.py +0 -0
  27. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/cache/__init__.py +0 -0
  28. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/container/__init__.py +0 -0
  29. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/__init__.py +0 -0
  30. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/cache.py +0 -0
  31. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/constants.py +0 -0
  32. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/events.py +0 -0
  33. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/middleware.py +0 -0
  34. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/core/response.py +0 -0
  35. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/crud/__init__.py +0 -0
  36. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/database/__init__.py +0 -0
  37. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/dev_reload.py +0 -0
  41. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/dotenv/__init__.py +0 -0
  42. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/frond/FROND.md +0 -0
  43. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/frond/__init__.py +0 -0
  44. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/auth/meta.json +0 -0
  45. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  46. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/database/meta.json +0 -0
  47. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  48. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/error-overlay/meta.json +0 -0
  49. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  50. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/orm/meta.json +0 -0
  51. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  52. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  53. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/queue/meta.json +0 -0
  54. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  55. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/rest-api/meta.json +0 -0
  56. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  57. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/templates/meta.json +0 -0
  58. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  59. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  60. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/graphql/__init__.py +0 -0
  61. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/i18n/__init__.py +0 -0
  62. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/mcp/__init__.py +0 -0
  63. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/mcp/protocol.py +0 -0
  64. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/mcp/tools.py +0 -0
  65. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/messenger/__init__.py +0 -0
  66. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/migration/__init__.py +0 -0
  67. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/migration/runner.py +0 -0
  68. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/orm/__init__.py +0 -0
  69. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/orm/fields.py +0 -0
  70. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/css/tina4.css +0 -0
  71. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/css/tina4.min.css +0 -0
  72. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/favicon.ico +0 -0
  73. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/images/logo.svg +0 -0
  74. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  75. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/js/frond.min.js +0 -0
  76. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  77. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/js/tina4.min.js +0 -0
  78. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/js/tina4js.min.js +0 -0
  79. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/swagger/index.html +0 -0
  80. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  81. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/query_builder/__init__.py +0 -0
  82. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/queue/__init__.py +0 -0
  83. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/queue_backends/__init__.py +0 -0
  84. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/queue_backends/kafka_backend.py +0 -0
  85. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/queue_backends/mongo_backend.py +0 -0
  86. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  87. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/__init__.py +0 -0
  88. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  89. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_badges.scss +0 -0
  90. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  91. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_cards.scss +0 -0
  92. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_forms.scss +0 -0
  93. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_grid.scss +0 -0
  94. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_modals.scss +0 -0
  95. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_nav.scss +0 -0
  96. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_reset.scss +0 -0
  97. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_tables.scss +0 -0
  98. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_typography.scss +0 -0
  99. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  100. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/_variables.scss +0 -0
  101. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/base.scss +0 -0
  102. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/colors.scss +0 -0
  103. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/scss/tina4css/tina4.scss +0 -0
  104. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/seeder/__init__.py +0 -0
  105. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/service/__init__.py +0 -0
  106. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/session/__init__.py +0 -0
  107. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/session_handlers/__init__.py +0 -0
  108. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  109. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/session_handlers/redis_handler.py +0 -0
  110. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/session_handlers/valkey_handler.py +0 -0
  111. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/swagger/__init__.py +0 -0
  112. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/components/crud.twig +0 -0
  113. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  114. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  115. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/docker/python/Dockerfile +0 -0
  116. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  117. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/302.twig +0 -0
  118. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/401.twig +0 -0
  119. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/403.twig +0 -0
  120. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/404.twig +0 -0
  121. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/500.twig +0 -0
  122. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/502.twig +0 -0
  123. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/503.twig +0 -0
  124. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/errors/base.twig +0 -0
  125. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/frontend/README.md +0 -0
  126. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/templates/readme.md +0 -0
  127. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/test_client/__init__.py +0 -0
  128. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  129. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  130. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  131. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  132. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  133. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  134. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  135. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  136. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  137. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  138. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  139. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  140. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/validator/__init__.py +0 -0
  141. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/websocket/__init__.py +0 -0
  142. {tina4_python-3.10.48 → tina4_python-3.10.54}/tina4_python/websocket/backplane.py +0 -0
  143. {tina4_python-3.10.48 → tina4_python-3.10.54}/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.10.48
3
+ Version: 3.10.54
4
4
  Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -8,7 +8,14 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.10.48"
11
+ __version__ = "3.10.54"
12
+
13
+ # ── Route decorators ──
14
+ from tina4_python.core.router import ( # noqa: E402, F401
15
+ get, post, put, patch, delete, any_method,
16
+ noauth, secured, cached, middleware, template,
17
+ Router,
18
+ )
12
19
 
13
20
  # ── HTTP Constants ──
14
21
  from tina4_python.core.constants import ( # noqa: E402, F401
@@ -22,6 +29,9 @@ from tina4_python.core.constants import ( # noqa: E402, F401
22
29
  APPLICATION_OCTET, TEXT_HTML, TEXT_PLAIN, TEXT_CSV, TEXT_XML,
23
30
  )
24
31
 
32
+ # ── Database ──
33
+ from tina4_python.database import Database # noqa: E402, F401
34
+
25
35
  # ── ORM ──
26
36
  from tina4_python.orm import ( # noqa: E402, F401
27
37
  ORM, orm_bind, Field,
@@ -31,6 +41,15 @@ from tina4_python.orm import ( # noqa: E402, F401
31
41
  has_many, has_one, belongs_to, # relationship descriptors
32
42
  )
33
43
 
44
+ # ── Auth ──
45
+ from tina4_python.auth import Auth # noqa: E402, F401
46
+
47
+ # ── Queue ──
48
+ from tina4_python.queue import Queue # noqa: E402, F401
49
+
50
+ # ── Template engine ──
51
+ from tina4_python.frond import Frond # noqa: E402, F401
52
+
34
53
  # ── Response Cache ──
35
54
  from tina4_python.cache import ( # noqa: E402, F401
36
55
  ResponseCache, cache_stats, clear_cache,
@@ -38,3 +57,6 @@ from tina4_python.cache import ( # noqa: E402, F401
38
57
 
39
58
  # ── DI Container ──
40
59
  from tina4_python.container import Container # noqa: E402, F401
60
+
61
+ # ── Server ──
62
+ from tina4_python.core.server import run # noqa: E402, F401
@@ -156,7 +156,7 @@ Usage: tina4python <command> [options]
156
156
 
157
157
  Commands:
158
158
  init [dir] Scaffold a new project
159
- serve [--port P] [--no-browser] Start dev server (default: 0.0.0.0:7146)
159
+ serve [--port P] [--no-browser] [--no-reload] Start dev server (default: 0.0.0.0:7146)
160
160
  migrate Run pending database migrations
161
161
  migrate:create <desc> Create a new migration file
162
162
  migrate:rollback Rollback last migration batch
@@ -202,6 +202,24 @@ def _init(args):
202
202
  for folder in folders:
203
203
  (target / folder).mkdir(parents=True, exist_ok=True)
204
204
 
205
+ # Copy framework public assets into the project so they're visible
206
+ framework_public = Path(__file__).parent.parent / "public"
207
+ project_public = target / "src" / "public"
208
+ assets_to_copy = [
209
+ "css/tina4.css",
210
+ "css/tina4.min.css",
211
+ "js/tina4.min.js",
212
+ "js/frond.min.js",
213
+ "images/tina4-logo-icon.webp",
214
+ ]
215
+ for asset in assets_to_copy:
216
+ src = framework_public / asset
217
+ dst = project_public / asset
218
+ dst.parent.mkdir(parents=True, exist_ok=True)
219
+ if src.exists() and not dst.exists():
220
+ import shutil
221
+ shutil.copy2(src, dst)
222
+
205
223
  # Copy frontend README
206
224
  frontend_readme = target / "frontend" / "README.md"
207
225
  if not frontend_readme.exists():
@@ -309,12 +327,15 @@ def _serve(args):
309
327
  if os.environ.get("TINA4_OPEN_BROWSER", "").lower() in ("false", "0", "no"):
310
328
  no_browser = True
311
329
 
330
+ # --no-reload flag
331
+ no_reload = "no-reload" in flags
332
+
312
333
  # Kill existing process on port
313
334
  port = cli_port or int(os.environ.get("PORT", os.environ.get("TINA4_PORT", "7146")))
314
335
  _kill_process_on_port(port)
315
336
 
316
337
  from tina4_python.core import run
317
- run(host=cli_host, port=cli_port, no_browser=no_browser)
338
+ run(host=cli_host, port=cli_port, no_browser=no_browser, no_reload=no_reload)
318
339
 
319
340
 
320
341
  # ── Migrate ───────────────────────────────────────────────────────────
@@ -80,6 +80,21 @@ class Request:
80
80
  # Parse body
81
81
  req.body = _parse_body(body, req.content_type)
82
82
 
83
+ # Separate files from body for multipart uploads
84
+ if isinstance(req.body, dict) and "multipart/form-data" in req.content_type:
85
+ files = {}
86
+ fields = {}
87
+ for key, value in req.body.items():
88
+ if isinstance(value, dict) and "filename" in value:
89
+ # Base64-encode file content for safe transport
90
+ import base64
91
+ value["content"] = base64.b64encode(value["content"]).decode()
92
+ files[key] = value
93
+ else:
94
+ fields[key] = value
95
+ req.files = files
96
+ req.body = fields
97
+
83
98
  return req
84
99
 
85
100
  def merge_route_params(self):
@@ -262,7 +262,7 @@ def _compile_pattern(path: str) -> tuple[re.Pattern, list[str]]:
262
262
  for i, segment in enumerate(segments):
263
263
  if segment == "*":
264
264
  # Wildcard: matches the rest of the path (greedy)
265
- param_names.append("wildcard")
265
+ param_names.append("*")
266
266
  regex_parts.append("(.+)")
267
267
  break # Nothing can follow a wildcard
268
268
  elif segment.startswith("{") and segment.endswith("}"):
@@ -9,6 +9,7 @@ import os
9
9
  import sys
10
10
  import signal
11
11
  import asyncio
12
+ import contextvars
12
13
  import importlib
13
14
  import uuid
14
15
  from pathlib import Path
@@ -25,6 +26,9 @@ _cors = CorsMiddleware()
25
26
  _rate_limiter = RateLimiter()
26
27
 
27
28
 
29
+ # ContextVar to signal that the current request is being served on the AI dev port
30
+ _ai_port_ctx: contextvars.ContextVar[bool] = contextvars.ContextVar("_ai_port_ctx", default=False)
31
+
28
32
  # Track startup time
29
33
  _start_time: float = 0
30
34
 
@@ -640,6 +644,10 @@ def _init_session(request: Request) -> None:
640
644
  sess = Session()
641
645
  sess.start(sid_match)
642
646
  request.session = sess
647
+ # Probabilistic garbage collection (1% of requests)
648
+ import random
649
+ if random.randint(1, 100) == 1:
650
+ sess.gc()
643
651
  except Exception:
644
652
  pass # Session module not available — session stays None
645
653
 
@@ -1218,7 +1226,7 @@ def resolve_config(cli_host: str | None = None, cli_port: int | None = None) ->
1218
1226
  return host, port
1219
1227
 
1220
1228
 
1221
- def _print_banner(host: str, port: int, server_name: str = "asyncio"):
1229
+ def _print_banner(host: str, port: int, server_name: str = "asyncio", ai_port: int | None = None):
1222
1230
  """Print the Tina4 Slant ASCII banner to stdout (not through the logger)."""
1223
1231
  from tina4_python.dotenv import is_truthy
1224
1232
 
@@ -1230,6 +1238,8 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio"):
1230
1238
  color = "\033[34m" if sys.stdout.isatty() else ""
1231
1239
  reset = "\033[0m" if sys.stdout.isatty() else ""
1232
1240
 
1241
+ ai_port_line = f"\n AI Port: http://{display}:{ai_port} (no-reload)" if ai_port else ""
1242
+
1233
1243
  banner = f"""{color}
1234
1244
  ______ _ __ __
1235
1245
  /_ __/(_)___ ____ _/ // /
@@ -1242,12 +1252,12 @@ def _print_banner(host: str, port: int, server_name: str = "asyncio"):
1242
1252
  Server: http://{display}:{port} ({server_name})
1243
1253
  Swagger: http://localhost:{port}/swagger
1244
1254
  Dashboard: http://localhost:{port}/__dev
1245
- Debug: {"ON" if is_debug else "OFF"} (Log level: {log_level})
1255
+ Debug: {"ON" if is_debug else "OFF"} (Log level: {log_level}){ai_port_line}
1246
1256
  """
1247
1257
  print(banner)
1248
1258
 
1249
1259
 
1250
- def run(host: str | None = None, port: int | None = None, no_browser: bool = False):
1260
+ def run(host: str | None = None, port: int | None = None, no_browser: bool = False, no_reload: bool = False):
1251
1261
  """Start the Tina4 dev server.
1252
1262
 
1253
1263
  Discovers routes from src/, starts ASGI server, handles shutdown.
@@ -1256,11 +1266,15 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1256
1266
  host: Bind address. Falls back to HOST env var, then 0.0.0.0.
1257
1267
  port: Bind port. Falls back to PORT env var, then 7146.
1258
1268
  no_browser: If True, do not open browser on startup.
1269
+ no_reload: If True, disable the file watcher / live-reload.
1259
1270
  """
1260
1271
  import time
1261
1272
  global _start_time
1262
1273
  _start_time = time.time()
1263
1274
 
1275
+ if no_reload:
1276
+ os.environ["TINA4_NO_RELOAD"] = "true"
1277
+
1264
1278
  # Ensure CWD is on sys.path so auto-discovered modules can be imported
1265
1279
  cwd = os.getcwd()
1266
1280
  if cwd not in sys.path:
@@ -1298,11 +1312,13 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1298
1312
 
1299
1313
  # Start DevReload file watcher in debug mode
1300
1314
  if is_debug:
1301
- try:
1302
- from tina4_python.dev_reload import start as _start_dev_reload
1303
- _start_dev_reload(["src", "public"])
1304
- except Exception as e:
1305
- Log.error(f"DevReload: failed to start: {e}")
1315
+ no_reload = os.environ.get("TINA4_NO_RELOAD", "").lower() in ("true", "1", "yes")
1316
+ if not no_reload:
1317
+ try:
1318
+ from tina4_python.dev_reload import start as _start_dev_reload
1319
+ _start_dev_reload(["src", "public"])
1320
+ except Exception as e:
1321
+ Log.error(f"DevReload: failed to start: {e}")
1306
1322
 
1307
1323
  prod = None
1308
1324
  if not is_debug:
@@ -1310,11 +1326,17 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1310
1326
 
1311
1327
  server_name = prod[0] if prod else "asyncio"
1312
1328
 
1329
+ # Determine AI dev port (port+1) when debug is on and not suppressed
1330
+ _no_ai_port = os.environ.get("TINA4_NO_AI_PORT", "").lower() in ("true", "1", "yes")
1331
+ _ai_port = (port + 1) if (is_debug and not _no_ai_port) else None
1332
+
1313
1333
  # Banner — printed directly to stdout, not through the logger
1314
- _print_banner(host, port, server_name)
1334
+ _print_banner(host, port, server_name, ai_port=_ai_port)
1315
1335
 
1316
1336
  display = "localhost" if host in ("0.0.0.0", "::") else host
1317
1337
  Log.info(f"Server started http://{display}:{port} ({server_name})")
1338
+ if _ai_port:
1339
+ Log.info(f"AI dev port: http://{display}:{_ai_port} (no-reload)")
1318
1340
 
1319
1341
  # Open browser after a short delay (unless --no-browser)
1320
1342
  _skip_browser = no_browser or os.environ.get("TINA4_NO_BROWSER", "").lower() in ("true", "1", "yes")
@@ -1383,6 +1405,11 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1383
1405
  # Check for WebSocket upgrade before reading body
1384
1406
  _header_dict = {k.decode(): v.decode() for k, v in headers}
1385
1407
  if _header_dict.get("upgrade", "").lower() == "websocket":
1408
+ if hasattr(writer, "_tina4_ai_port") and path == "/__dev_reload":
1409
+ writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
1410
+ await writer.drain()
1411
+ writer.close()
1412
+ return
1386
1413
  await _handle_dev_websocket(reader, writer, _header_dict, path)
1387
1414
  return
1388
1415
 
@@ -1437,6 +1464,19 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1437
1464
 
1438
1465
  server = await start_server(_handle_connection, host, port)
1439
1466
 
1467
+ # AI dev port (port + 1) — no-reload, no live-reload WebSocket
1468
+ ai_server = None
1469
+ if _ai_port:
1470
+ try:
1471
+ async def _handle_ai_connection(reader, writer):
1472
+ _ai_port_ctx.set(True)
1473
+ writer._tina4_ai_port = True
1474
+ await _handle_connection(reader, writer)
1475
+
1476
+ ai_server = await start_server(_handle_ai_connection, host, _ai_port)
1477
+ except OSError:
1478
+ Log.warning(f"AI port {_ai_port} in use — skipping")
1479
+
1440
1480
  loop = asyncio.get_running_loop()
1441
1481
  for sig in (signal.SIGINT, signal.SIGTERM):
1442
1482
  try:
@@ -1445,6 +1485,9 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
1445
1485
  pass # Windows
1446
1486
 
1447
1487
  await shutdown.wait()
1488
+ if ai_server:
1489
+ ai_server.close()
1490
+ await ai_server.wait_closed()
1448
1491
  server.close()
1449
1492
  await server.wait_closed()
1450
1493
  Log.info("Server stopped.")
@@ -33,8 +33,10 @@ class DatabaseResult:
33
33
 
34
34
  def to_paginate(self, page: int = 1, per_page: int = 20) -> dict:
35
35
  total_pages = max(1, -(-self.count // per_page)) # ceil division
36
+ start = (page - 1) * per_page
37
+ end = start + per_page
36
38
  return {
37
- "data": self.records,
39
+ "data": self.records[start:end],
38
40
  "total": self.count,
39
41
  "page": page,
40
42
  "per_page": per_page,
@@ -210,21 +212,31 @@ class DatabaseResult:
210
212
  return columns
211
213
 
212
214
  def _fallback_column_info(self) -> list[dict]:
213
- """Derive basic column info from record keys when no adapter is available."""
215
+ """Derive basic column info from record keys and values when no adapter is available."""
214
216
  if not self.records:
215
217
  return []
216
- keys = list(self.records[0].keys()) if isinstance(self.records[0], dict) else []
217
- return [
218
- {
218
+ row = self.records[0] if isinstance(self.records[0], dict) else {}
219
+ result = []
220
+ for k, v in row.items():
221
+ if isinstance(v, int):
222
+ col_type = "INTEGER"
223
+ elif isinstance(v, float):
224
+ col_type = "REAL"
225
+ elif isinstance(v, bool):
226
+ col_type = "BOOLEAN"
227
+ elif v is None:
228
+ col_type = "TEXT"
229
+ else:
230
+ col_type = "TEXT"
231
+ result.append({
219
232
  "name": k,
220
- "type": "UNKNOWN",
233
+ "type": col_type,
221
234
  "size": None,
222
235
  "decimals": None,
223
236
  "nullable": True,
224
- "primary_key": False,
225
- }
226
- for k in keys
227
- ]
237
+ "primary_key": k.lower() == "id",
238
+ })
239
+ return result
228
240
 
229
241
 
230
242
  class DatabaseAdapter:
@@ -285,7 +297,7 @@ class DatabaseAdapter:
285
297
  )
286
298
 
287
299
  def fetch(self, sql: str, params: list = None,
288
- limit: int = 20, offset: int = 0) -> DatabaseResult:
300
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
289
301
  """Execute a read query and return multiple rows."""
290
302
  raise NotImplementedError
291
303
 
@@ -283,7 +283,7 @@ class Database:
283
283
  return adapter.execute_many(sql, params_list)
284
284
 
285
285
  def fetch(self, sql: str, params: list = None,
286
- limit: int = 20, offset: int = 0) -> DatabaseResult:
286
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
287
287
  """Fetch rows with pagination."""
288
288
  if self._cache_enabled:
289
289
  key = self._cache_key(sql + f":L{limit}:S{offset}", params)
@@ -49,7 +49,7 @@ class FirebirdAdapter(DatabaseAdapter):
49
49
  host = parsed.hostname or "localhost"
50
50
  port = parsed.port or 3050
51
51
  # Firebird database path — decode URL-encoded characters
52
- db_path = unquote(parsed.path.lstrip("/")) if parsed.path else ""
52
+ db_path = unquote(parsed.path[1:]) if parsed.path.startswith("/") else unquote(parsed.path)
53
53
  user = parsed.username or username or "SYSDBA"
54
54
  password = parsed.password or password or "masterkey"
55
55
  charset = kwargs.pop("charset", "UTF8")
@@ -138,7 +138,7 @@ class FirebirdAdapter(DatabaseAdapter):
138
138
  )
139
139
 
140
140
  def fetch(self, sql: str, params: list = None,
141
- limit: int = 20, offset: int = 0) -> DatabaseResult:
141
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
142
142
  sql = self._translate_sql(sql)
143
143
  cursor = self._conn.cursor()
144
144
 
@@ -102,7 +102,7 @@ class MSSQLAdapter(DatabaseAdapter):
102
102
  )
103
103
 
104
104
  def fetch(self, sql: str, params: list = None,
105
- limit: int = 20, offset: int = 0) -> DatabaseResult:
105
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
106
106
  sql = self._translate_sql(sql)
107
107
  cursor = self._conn.cursor(as_dict=True)
108
108
 
@@ -91,7 +91,7 @@ class MySQLAdapter(DatabaseAdapter):
91
91
  )
92
92
 
93
93
  def fetch(self, sql: str, params: list = None,
94
- limit: int = 20, offset: int = 0) -> DatabaseResult:
94
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
95
95
  sql = self._translate_sql(sql)
96
96
  cursor = self._conn.cursor(dictionary=True)
97
97
 
@@ -74,7 +74,7 @@ class ODBCAdapter(DatabaseAdapter):
74
74
  )
75
75
 
76
76
  def fetch(self, sql: str, params: list = None,
77
- limit: int = 20, offset: int = 0) -> DatabaseResult:
77
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
78
78
  # Count total
79
79
  count_sql = f"SELECT COUNT(*) FROM ({sql}) AS _t"
80
80
  cursor = self._conn.cursor()
@@ -100,7 +100,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
100
100
  )
101
101
 
102
102
  def fetch(self, sql: str, params: list = None,
103
- limit: int = 20, offset: int = 0) -> DatabaseResult:
103
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
104
104
  import psycopg2.extras
105
105
 
106
106
  sql = self._translate_sql(sql)
@@ -93,7 +93,7 @@ class SQLiteAdapter(DatabaseAdapter):
93
93
  )
94
94
 
95
95
  def fetch(self, sql: str, params: list = None,
96
- limit: int = 20, offset: int = 0) -> DatabaseResult:
96
+ limit: int = 100, offset: int = 0) -> DatabaseResult:
97
97
  # Count total rows (without LIMIT/OFFSET)
98
98
  count_sql = f"SELECT COUNT(*) as cnt FROM ({sql})"
99
99
  try:
@@ -2447,8 +2447,10 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2447
2447
  and a close button.
2448
2448
  """
2449
2449
  import sys
2450
+ from tina4_python.core.server import _ai_port_ctx
2450
2451
  python_version = sys.version.split()[0]
2451
2452
  poll_interval_ms = int(os.environ.get("TINA4_DEV_POLL_INTERVAL", "3000"))
2453
+ no_reload = os.environ.get("TINA4_NO_RELOAD", "").lower() in ("true", "1", "yes") or _ai_port_ctx.get()
2452
2454
 
2453
2455
  return f"""<div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
2454
2456
  <span id="tina4-ver-btn" style="color:#3572A5;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v{__version__}</span>
@@ -2472,7 +2474,7 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2472
2474
  <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
2473
2475
  </div>
2474
2476
  <script>
2475
- (function(){{
2477
+ {'(function(){})();' if no_reload else f"""(function(){{
2476
2478
  var _t4_mtime=0,_t4_css_exts=['.css','.scss'],_t4_debounce=null;
2477
2479
  var _t4_interval=parseInt('{poll_interval_ms}')||3000;
2478
2480
  function _t4_apply(d){{
@@ -2499,7 +2501,7 @@ def render_dev_toolbar(method: str, path: str, matched_pattern: str,
2499
2501
  }}).catch(function(){{}});
2500
2502
  }}
2501
2503
  setInterval(_t4_poll,_t4_interval);
2502
- }})();
2504
+ }})();"""}
2503
2505
  function tina4VersionModal(){{
2504
2506
  var m=document.getElementById('tina4-ver-modal');
2505
2507
  if(m.style.display==='block'){{m.style.display='none';return;}}
@@ -118,6 +118,7 @@ _SET_RE = re.compile(r"set\s+(\w+)\s*=\s*(.+)")
118
118
  _INCLUDE_RE = re.compile(r'include\s+["\'](.+?)["\'](?:\s+with\s+(.+))?')
119
119
  _MACRO_RE = re.compile(r"macro\s+(\w+)\s*\(([^)]*)\)")
120
120
  _FROM_IMPORT_RE = re.compile(r'from\s+["\'](.+?)["\']\s+import\s+(.+)')
121
+ _IMPORT_AS_RE = re.compile(r'import\s+["\'](.+?)["\']\s+as\s+(\w+)')
121
122
  _CACHE_RE = re.compile(r'cache\s+["\'](.+?)["\']\s*(\d+)?')
122
123
  _AUTOESCAPE_RE = re.compile(r"autoescape\s+(false|true)")
123
124
  _SPACELESS_RE = re.compile(r">\s+<")
@@ -1328,6 +1329,10 @@ class Frond:
1328
1329
  self._handle_from_import(content, context)
1329
1330
  i += 1
1330
1331
 
1332
+ elif tag == "import":
1333
+ self._handle_import_as(content, context)
1334
+ i += 1
1335
+
1331
1336
  elif tag == "cache":
1332
1337
  result, skip = self._handle_cache(tokens, i, context)
1333
1338
  output.append(result)
@@ -1803,6 +1808,70 @@ class Frond:
1803
1808
  continue
1804
1809
  i += 1
1805
1810
 
1811
+ def _handle_import_as(self, content: str, context: dict):
1812
+ """Handle {% import "file" as alias %}.
1813
+
1814
+ Loads ALL macros from the file and registers them as an object
1815
+ with methods, so {{ alias.macro_name(args) }} works.
1816
+ """
1817
+ m = _IMPORT_AS_RE.match(content)
1818
+ if not m:
1819
+ return
1820
+
1821
+ filename = m.group(1)
1822
+ alias = m.group(2)
1823
+
1824
+ # Load and tokenize the macro file
1825
+ source = self._load(filename)
1826
+ tokens = _tokenize(source)
1827
+
1828
+ # Collect all macro definitions
1829
+ macros = {}
1830
+ i = 0
1831
+ while i < len(tokens):
1832
+ ttype, raw = tokens[i]
1833
+ if ttype == BLOCK:
1834
+ tag_content, _, _ = _strip_tag(raw)
1835
+ tag = tag_content.split()[0] if tag_content.split() else ""
1836
+ if tag == "macro":
1837
+ macro_m = _MACRO_RE.match(tag_content)
1838
+ if macro_m:
1839
+ macro_name = macro_m.group(1)
1840
+ parsed_params = self._parse_macro_params(macro_m.group(2))
1841
+
1842
+ body_tokens = []
1843
+ i += 1
1844
+ while i < len(tokens):
1845
+ if tokens[i][0] == BLOCK and "endmacro" in tokens[i][1]:
1846
+ i += 1
1847
+ break
1848
+ body_tokens.append(tokens[i])
1849
+ i += 1
1850
+
1851
+ engine = self
1852
+ captured_body = list(body_tokens)
1853
+ captured_params = list(parsed_params)
1854
+ captured_context = dict(context)
1855
+
1856
+ def make_fn(_params, _body, _ctx):
1857
+ def fn(*args):
1858
+ macro_ctx = dict(_ctx)
1859
+ for pi, (pname, pdefault) in enumerate(_params):
1860
+ if pi < len(args):
1861
+ macro_ctx[pname] = args[pi]
1862
+ else:
1863
+ macro_ctx[pname] = pdefault
1864
+ return SafeString(engine._render_tokens(list(_body), macro_ctx))
1865
+ return fn
1866
+
1867
+ macros[macro_name] = make_fn(captured_params, captured_body, captured_context)
1868
+ continue
1869
+ i += 1
1870
+
1871
+ # Create a namespace object so alias.macro_name() works
1872
+ namespace = type("MacroNamespace", (), macros)()
1873
+ context[alias] = namespace
1874
+
1806
1875
  def _handle_cache(self, tokens: list, start: int, context: dict) -> tuple[str, int]:
1807
1876
  """Handle {% cache "key" ttl %}...{% endcache %}.
1808
1877
 
@@ -204,8 +204,18 @@ class ORM(metaclass=ORMMeta):
204
204
  return cls._db # Direct Database instance
205
205
 
206
206
  if _database is None:
207
+ # Try auto-discovery from DATABASE_URL
208
+ import os
209
+ url = os.environ.get("DATABASE_URL")
210
+ if url:
211
+ from tina4_python.database import Database
212
+ username = os.environ.get("DATABASE_USERNAME", "")
213
+ password = os.environ.get("DATABASE_PASSWORD", "")
214
+ global _database
215
+ _database = Database(url, username, password)
216
+ return _database
207
217
  raise RuntimeError(
208
- "No database bound. Call orm_bind(db) before using ORM models."
218
+ "No database bound. Call orm_bind(db) or set DATABASE_URL in .env"
209
219
  )
210
220
  return _database
211
221
 
File without changes