tina4-python 3.5.0__tar.gz → 3.7.1__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 (135) hide show
  1. {tina4_python-3.5.0 → tina4_python-3.7.1}/PKG-INFO +5 -5
  2. {tina4_python-3.5.0 → tina4_python-3.7.1}/README.md +4 -4
  3. {tina4_python-3.5.0 → tina4_python-3.7.1}/pyproject.toml +1 -1
  4. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/CLAUDE.md +6 -7
  5. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/__init__.py +1 -1
  6. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/auth/__init__.py +81 -15
  7. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/request.py +1 -0
  8. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/response.py +4 -0
  9. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/server.py +56 -17
  10. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/adapter.py +1 -1
  11. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/connection.py +5 -4
  12. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/firebird.py +3 -3
  13. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/mssql.py +2 -2
  14. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/mysql.py +2 -2
  15. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/odbc.py +3 -3
  16. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/postgres.py +2 -2
  17. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/sqlite.py +2 -2
  18. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/frond/engine.py +3 -3
  19. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +4 -4
  20. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/i18n/__init__.py +3 -1
  21. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/migration/runner.py +68 -0
  22. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/orm/fields.py +1 -1
  23. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/orm/model.py +17 -17
  24. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/queue/__init__.py +2 -2
  25. {tina4_python-3.5.0 → tina4_python-3.7.1}/.gitignore +0 -0
  26. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/HtmlElement.py +0 -0
  27. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/Testing.py +0 -0
  28. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/ai/__init__.py +0 -0
  29. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/api/__init__.py +0 -0
  30. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/cache/__init__.py +0 -0
  31. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/cli/__init__.py +0 -0
  32. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/container/__init__.py +0 -0
  33. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/__init__.py +0 -0
  34. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/cache.py +0 -0
  35. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/constants.py +0 -0
  36. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/events.py +0 -0
  37. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/middleware.py +0 -0
  38. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/core/router.py +0 -0
  39. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/crud/__init__.py +0 -0
  40. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/database/__init__.py +0 -0
  41. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/dev_admin/__init__.py +0 -0
  44. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/dev_reload.py +0 -0
  45. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/dotenv/__init__.py +0 -0
  46. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/frond/FROND.md +0 -0
  47. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/frond/__init__.py +0 -0
  48. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/auth/meta.json +0 -0
  49. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/database/meta.json +0 -0
  50. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  51. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/error-overlay/meta.json +0 -0
  52. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  53. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/orm/meta.json +0 -0
  54. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  55. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  56. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/queue/meta.json +0 -0
  57. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  58. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/rest-api/meta.json +0 -0
  59. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  60. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/templates/meta.json +0 -0
  61. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  62. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  63. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/graphql/__init__.py +0 -0
  64. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/messenger/__init__.py +0 -0
  65. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/migration/__init__.py +0 -0
  66. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/orm/__init__.py +0 -0
  67. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/css/tina4.css +0 -0
  68. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/css/tina4.min.css +0 -0
  69. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/favicon.ico +0 -0
  70. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/images/logo.svg +0 -0
  71. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  72. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/js/frond.min.js +0 -0
  73. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  74. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/js/tina4.min.js +0 -0
  75. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/js/tina4js.min.js +0 -0
  76. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/swagger/index.html +0 -0
  77. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  78. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/queue_backends/__init__.py +0 -0
  79. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/queue_backends/kafka_backend.py +0 -0
  80. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/queue_backends/mongo_backend.py +0 -0
  81. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  82. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/__init__.py +0 -0
  83. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  84. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_badges.scss +0 -0
  85. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  86. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_cards.scss +0 -0
  87. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_forms.scss +0 -0
  88. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_grid.scss +0 -0
  89. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_modals.scss +0 -0
  90. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_nav.scss +0 -0
  91. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_reset.scss +0 -0
  92. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_tables.scss +0 -0
  93. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_typography.scss +0 -0
  94. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  95. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/_variables.scss +0 -0
  96. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/base.scss +0 -0
  97. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/colors.scss +0 -0
  98. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/scss/tina4css/tina4.scss +0 -0
  99. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/seeder/__init__.py +0 -0
  100. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/service/__init__.py +0 -0
  101. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/session/__init__.py +0 -0
  102. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/session_handlers/__init__.py +0 -0
  103. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  104. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/session_handlers/redis_handler.py +0 -0
  105. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/session_handlers/valkey_handler.py +0 -0
  106. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/swagger/__init__.py +0 -0
  107. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/components/crud.twig +0 -0
  108. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  109. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  110. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/docker/python/Dockerfile +0 -0
  111. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  112. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/302.twig +0 -0
  113. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/401.twig +0 -0
  114. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/403.twig +0 -0
  115. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/404.twig +0 -0
  116. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/500.twig +0 -0
  117. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/502.twig +0 -0
  118. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/503.twig +0 -0
  119. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/errors/base.twig +0 -0
  120. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/frontend/README.md +0 -0
  121. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/templates/readme.md +0 -0
  122. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  123. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  124. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  125. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  126. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  127. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  128. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  129. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  130. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  131. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  132. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  133. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  134. {tina4_python-3.5.0 → tina4_python-3.7.1}/tina4_python/websocket/__init__.py +0 -0
  135. {tina4_python-3.5.0 → tina4_python-3.7.1}/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.5.0
3
+ Version: 3.7.1
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
@@ -412,7 +412,7 @@ db = Database("firebird://SYSDBA:masterkey@localhost:3050//path/to/db")
412
412
  db = Database("mongodb://localhost:27017/mydb")
413
413
  db = Database("odbc://DSN=mydsn")
414
414
 
415
- result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit=20, skip=0)
415
+ result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit=20, offset=0)
416
416
  row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
417
417
  db.insert("users", {"name": "Alice", "email": "alice@test.com"})
418
418
  db.commit()
@@ -440,8 +440,8 @@ async def protected(request, response):
440
440
  from tina4_python.auth import Auth
441
441
 
442
442
  auth = Auth(secret="your-secret")
443
- token = auth.create_token({"user_id": 42})
444
- payload = auth.validate_token(token)
443
+ token = auth.get_token({"user_id": 42})
444
+ payload = auth.valid_token(token)
445
445
  ```
446
446
 
447
447
  POST/PUT/PATCH/DELETE routes require `Authorization: Bearer <token>` by default. Use `@noauth()` to make public, `@secured()` to protect GET routes.
@@ -699,7 +699,7 @@ SECRET=your-jwt-secret
699
699
  DATABASE_URL=sqlite:///data/app.db
700
700
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
701
701
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
702
- TINA4_LANGUAGE=en # en, fr, af, zh, ja, es
702
+ TINA4_LOCALE=en # en, fr, af, zh, ja, es
703
703
  TINA4_SESSION_HANDLER=SessionFileHandler
704
704
  SWAGGER_TITLE=My API
705
705
  ```
@@ -380,7 +380,7 @@ db = Database("firebird://SYSDBA:masterkey@localhost:3050//path/to/db")
380
380
  db = Database("mongodb://localhost:27017/mydb")
381
381
  db = Database("odbc://DSN=mydsn")
382
382
 
383
- result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit=20, skip=0)
383
+ result = db.fetch("SELECT * FROM users WHERE age > ?", [18], limit=20, offset=0)
384
384
  row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
385
385
  db.insert("users", {"name": "Alice", "email": "alice@test.com"})
386
386
  db.commit()
@@ -408,8 +408,8 @@ async def protected(request, response):
408
408
  from tina4_python.auth import Auth
409
409
 
410
410
  auth = Auth(secret="your-secret")
411
- token = auth.create_token({"user_id": 42})
412
- payload = auth.validate_token(token)
411
+ token = auth.get_token({"user_id": 42})
412
+ payload = auth.valid_token(token)
413
413
  ```
414
414
 
415
415
  POST/PUT/PATCH/DELETE routes require `Authorization: Bearer <token>` by default. Use `@noauth()` to make public, `@secured()` to protect GET routes.
@@ -667,7 +667,7 @@ SECRET=your-jwt-secret
667
667
  DATABASE_URL=sqlite:///data/app.db
668
668
  TINA4_DEBUG=true # Enable dev toolbar, error overlay
669
669
  TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
670
- TINA4_LANGUAGE=en # en, fr, af, zh, ja, es
670
+ TINA4_LOCALE=en # en, fr, af, zh, ja, es
671
671
  TINA4_SESSION_HANDLER=SessionFileHandler
672
672
  SWAGGER_TITLE=My API
673
673
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.5.0"
3
+ version = "3.7.1"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -221,7 +221,7 @@ Tina4 provides a full toolkit. Before writing custom code, check if the framewor
221
221
  |------|--------------------------------|
222
222
  | Background jobs / async work | `Queue` from `tina4_python.queue` (use `queue.push()`, `queue.consume()`) |
223
223
  | HTTP calls to external APIs | `Api` from `tina4_python.api` |
224
- | JWT tokens & auth | `Auth` from `tina4_python.auth` (create_token, validate_token, get_payload) |
224
+ | JWT tokens & auth | `Auth` from `tina4_python.auth` (get_token, valid_token, get_payload) |
225
225
  | Password hashing | `Auth.hash_password()` / `Auth.check_password()` from `tina4_python.auth` |
226
226
  | Session management | `Session` from `tina4_python.session` |
227
227
  | Database queries & CRUD | `Database` from `tina4_python.database` |
@@ -480,7 +480,7 @@ Response.add_header("X-Custom", "value")
480
480
 
481
481
  ## Sessions
482
482
 
483
- TINA4_TOKEN_LIMIT is used to set the session time, recommend 15-30 minutes
483
+ TINA4_TOKEN_EXPIRES_IN is used to set the session time, recommend 15-60 minutes
484
484
 
485
485
  ### Session Backends
486
486
 
@@ -775,7 +775,7 @@ row = db.fetch_one("SELECT * FROM users WHERE id = ?", [1])
775
775
  # Result methods
776
776
  result.to_json() # JSON string
777
777
  result.to_array() # List of dicts
778
- result.to_paginate() # Dict with records, count, limit, skip
778
+ result.to_paginate() # Dict with records, count, limit, offset
779
779
  result.to_csv() # CSV string
780
780
 
781
781
  # Transactions
@@ -1185,7 +1185,7 @@ Tina4 v3 supports translations via JSON files in `src/locales/`.
1185
1185
 
1186
1186
  Set the language in `.env`:
1187
1187
  ```bash
1188
- TINA4_LANGUAGE=en # Default locale
1188
+ TINA4_LOCALE=en # Default locale
1189
1189
  TINA4_LOCALE_DIR=src/locales # Directory for translation files
1190
1190
  ```
1191
1191
 
@@ -1516,8 +1516,7 @@ Key `.env` settings:
1516
1516
  # Authentication
1517
1517
  SECRET=your-jwt-secret # JWT signing (default uses insecure placeholder)
1518
1518
  API_KEY=your-api-key # Static bearer token for API auth
1519
- TINA4_TOKEN_LIMIT=2 # Token lifetime in minutes (default: 2)
1520
- TINA4_TOKEN_EXPIRES_IN=2 # Alternative token expiry setting
1519
+ TINA4_TOKEN_EXPIRES_IN=60 # Token lifetime in minutes (default: 60)
1521
1520
 
1522
1521
  # Database
1523
1522
  DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
@@ -1527,7 +1526,7 @@ DATABASE_PASSWORD= # DB password
1527
1526
  # Framework
1528
1527
  TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
1529
1528
  TINA4_LOG_LEVEL=ALL # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR
1530
- TINA4_LANGUAGE=en # Language for framework messages (en, fr, af, zh, ja, es)
1529
+ TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
1531
1530
  TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
1532
1531
  HOST_NAME=localhost:7145
1533
1532
 
@@ -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.5.0"
11
+ __version__ = "3.6.0"
12
12
 
13
13
  # ── HTTP Constants ──
14
14
  from tina4_python.core.constants import ( # noqa: E402, F401
@@ -10,8 +10,8 @@ No PyJWT, no cryptography package.
10
10
  payload = auth.valid_token(token)
11
11
 
12
12
  # Legacy aliases also work:
13
- token = auth.create_token({"user_id": 1, "role": "admin"})
14
- payload = auth.validate_token(token)
13
+ token = auth.get_token({"user_id": 1, "role": "admin"})
14
+ payload = auth.valid_token(token)
15
15
 
16
16
  hashed = Auth.hash_password("secret123")
17
17
  Auth.check_password(hashed, "secret123") # True
@@ -29,27 +29,38 @@ class Auth:
29
29
  """JWT authentication and password hashing — zero dependencies."""
30
30
 
31
31
  def __init__(self, secret: str = None, algorithm: str = "HS256",
32
- token_expiry: int = None):
32
+ expires_in: int = None):
33
+ """
34
+ Args:
35
+ secret: Signing secret (falls back to SECRET env var).
36
+ algorithm: JWT algorithm (default HS256).
37
+ expires_in: Token lifetime in seconds (default 3600).
38
+ """
33
39
  self.secret = secret or os.environ.get("SECRET", "tina4-default-secret")
34
40
  self.algorithm = algorithm
35
- self.token_expiry = token_expiry or int(
36
- os.environ.get("TINA4_TOKEN_LIMIT", "30")
41
+ self.expires_in = expires_in or int(
42
+ os.environ.get("TINA4_TOKEN_EXPIRES_IN", "60")
37
43
  )
38
44
 
39
45
  # ── JWT ────────────────────────────────────────────────────────
40
46
 
41
- def get_token(self, payload: dict, expiry_minutes: int = None) -> str:
47
+ def get_token(self, payload: dict, expires_in: int = None) -> str:
42
48
  """Create a signed JWT token.
43
49
 
50
+ Args:
51
+ expires_in: Lifetime in minutes (default: self.expires_in).
52
+
44
53
  Returns: header.payload.signature
45
54
  """
46
- exp = expiry_minutes if expiry_minutes is not None else self.token_expiry
55
+ exp_minutes = expires_in if expires_in is not None else self.expires_in
56
+ exp_seconds = exp_minutes * 60
57
+
47
58
  header = {"alg": self.algorithm, "typ": "JWT"}
48
59
 
49
60
  claims = dict(payload)
50
61
  claims["iat"] = int(time.time())
51
- if exp > 0:
52
- claims["exp"] = claims["iat"] + (exp * 60)
62
+ if exp_seconds > 0:
63
+ claims["exp"] = claims["iat"] + exp_seconds
53
64
 
54
65
  h = _b64url_encode(json.dumps(header).encode())
55
66
  p = _b64url_encode(json.dumps(claims, default=str).encode())
@@ -88,14 +99,18 @@ class Auth:
88
99
  except Exception:
89
100
  return None
90
101
 
91
- def refresh_token(self, token: str, expiry_minutes: int = None) -> str | None:
92
- """Validate and issue a fresh token with the same claims."""
102
+ def refresh_token(self, token: str, expires_in: int = None) -> str | None:
103
+ """Validate and issue a fresh token with the same claims.
104
+
105
+ Args:
106
+ expires_in: Lifetime in minutes (default: self.expires_in).
107
+ """
93
108
  payload = self.valid_token(token)
94
109
  if payload is None:
95
110
  return None
96
111
  payload.pop("iat", None)
97
112
  payload.pop("exp", None)
98
- return self.get_token(payload, expiry_minutes)
113
+ return self.get_token(payload, expires_in=expires_in)
99
114
 
100
115
  def _sign(self, message: str) -> str:
101
116
  sig = hmac.new(
@@ -105,8 +120,37 @@ class Auth:
105
120
 
106
121
  # ── Legacy aliases ─────────────────────────────────────────────
107
122
 
108
- create_token = get_token
109
- validate_token = valid_token
123
+ # get_token and valid_token are the primary names
124
+
125
+ # ── Class-level convenience methods (read SECRET from env) ────
126
+
127
+ @classmethod
128
+ def get_token_static(cls, payload: dict, expires_in: int = 60) -> str:
129
+ """Create a JWT without instantiating Auth — reads SECRET from env."""
130
+ secret = os.environ.get("SECRET", "tina4-default-secret")
131
+ auth = cls(secret=secret, expires_in=expires_in)
132
+ return auth.get_token(payload)
133
+
134
+ @classmethod
135
+ def valid_token_static(cls, token: str) -> dict | None:
136
+ """Validate a JWT without instantiating Auth — reads SECRET from env."""
137
+ secret = os.environ.get("SECRET", "tina4-default-secret")
138
+ auth = cls(secret=secret)
139
+ return auth.valid_token(token)
140
+
141
+ @classmethod
142
+ def get_payload_static(cls, token: str) -> dict | None:
143
+ """Decode payload (no validation) without instantiating Auth."""
144
+ secret = os.environ.get("SECRET", "tina4-default-secret")
145
+ auth = cls(secret=secret)
146
+ return auth.get_payload(token)
147
+
148
+ @classmethod
149
+ def refresh_token_static(cls, token: str, expires_in: int = 60) -> str | None:
150
+ """Refresh a JWT without instantiating Auth — reads SECRET from env."""
151
+ secret = os.environ.get("SECRET", "tina4-default-secret")
152
+ auth = cls(secret=secret, expires_in=expires_in)
153
+ return auth.refresh_token(token)
110
154
 
111
155
  # ── Password Hashing ──────────────────────────────────────────
112
156
 
@@ -189,4 +233,26 @@ def _b64url_decode(s: str) -> bytes:
189
233
  return base64.urlsafe_b64decode(s)
190
234
 
191
235
 
192
- __all__ = ["Auth"]
236
+ # ── Module-level convenience functions (use static methods) ────
237
+
238
+ def get_token(payload: dict, expires_in: int = 60) -> str:
239
+ """Create a JWT — reads SECRET from env. Shortcut for Auth.get_token_static()."""
240
+ return Auth.get_token_static(payload, expires_in=expires_in)
241
+
242
+
243
+ def valid_token(token: str) -> dict | None:
244
+ """Validate a JWT — reads SECRET from env. Shortcut for Auth.valid_token_static()."""
245
+ return Auth.valid_token_static(token)
246
+
247
+
248
+ def get_payload(token: str) -> dict | None:
249
+ """Decode JWT payload (no validation). Shortcut for Auth.get_payload_static()."""
250
+ return Auth.get_payload_static(token)
251
+
252
+
253
+ def refresh_token(token: str, expires_in: int = 60) -> str | None:
254
+ """Refresh a JWT — reads SECRET from env. Shortcut for Auth.refresh_token_static()."""
255
+ return Auth.refresh_token_static(token, expires_in=expires_in)
256
+
257
+
258
+ __all__ = ["Auth", "get_token", "valid_token", "get_payload", "refresh_token"]
@@ -75,6 +75,7 @@ class Request:
75
75
  return self.params.get(key, self._route_params.get(key, default))
76
76
 
77
77
 
78
+
78
79
  def _extract_ip(scope: dict, headers: dict) -> str:
79
80
  """Extract client IP, respecting X-Forwarded-For."""
80
81
  forwarded = headers.get("x-forwarded-for", "")
@@ -191,6 +191,10 @@ class Response:
191
191
 
192
192
  return self.html(f"<pre>Template not found: {template}</pre>", 404)
193
193
 
194
+ def template(self, template: str, data: dict = None) -> "Response":
195
+ """Alias for render() — parity with PHP/Node.js naming."""
196
+ return self.render(template, data)
197
+
194
198
  def build_headers(self, accept_encoding: str = "") -> list[tuple[bytes, bytes]]:
195
199
  """Build final ASGI headers with compression and ETag."""
196
200
  # Compress if applicable
@@ -136,13 +136,45 @@ def _render_error_page(status_code: int, path: str, request_id: str, error_messa
136
136
  return None
137
137
 
138
138
 
139
- def _has_index_template() -> bool:
140
- """Check if the user has an index template in src/templates/."""
139
+ _template_cache: dict[str, str] | None = None
140
+
141
+
142
+ def _resolve_template(path: str) -> str | None:
143
+ """Resolve a URL path to a template file in src/templates/.
144
+ Dev mode: checks filesystem every time for live changes.
145
+ Production: uses a cached lookup built once at startup.
146
+ """
147
+ clean_path = path.strip("/") or "index"
148
+ is_dev = os.environ.get("TINA4_DEBUG", "false").lower() in ("true", "1", "yes")
149
+
150
+ if is_dev:
151
+ template_dir = Path("src/templates")
152
+ for ext in (".twig", ".html"):
153
+ candidate = clean_path + ext
154
+ if (template_dir / candidate).is_file():
155
+ return candidate
156
+ return None
157
+
158
+ global _template_cache
159
+ if _template_cache is None:
160
+ _build_template_cache()
161
+ return _template_cache.get(clean_path)
162
+
163
+
164
+ def _build_template_cache() -> None:
165
+ """Scan src/templates/ once and build url_path -> template_file lookup."""
166
+ global _template_cache
167
+ _template_cache = {}
141
168
  template_dir = Path("src/templates")
142
- for name in ("index.html", "index.twig", "index.php", "index.erb"):
143
- if (template_dir / name).is_file():
144
- return True
145
- return False
169
+ if not template_dir.is_dir():
170
+ return
171
+ for f in template_dir.rglob("*"):
172
+ if not f.is_file() or f.suffix not in (".twig", ".html"):
173
+ continue
174
+ rel = str(f.relative_to(template_dir)).replace("\\", "/")
175
+ url_path = rel.rsplit(".", 1)[0]
176
+ if url_path not in _template_cache:
177
+ _template_cache[url_path] = rel
146
178
 
147
179
 
148
180
  def _is_gallery_deployed(name: str) -> bool:
@@ -779,19 +811,26 @@ async def app(scope: dict, receive, send):
779
811
  static = _try_static(request.path)
780
812
  if static:
781
813
  response = static
782
- elif request.path == "/" and not _has_index_template():
783
- # No "/" route registered and no index template — show default landing page
784
- response.html(_render_landing_page())
785
814
  else:
786
- html = _render_error_page(404, request.path, request_id)
787
- if html:
788
- response.status(404).html(html)
815
+ # Try serving a template file (e.g. /hello -> src/templates/hello.twig or hello.html)
816
+ tpl_file = _resolve_template(request.path)
817
+ if tpl_file:
818
+ from tina4_python.frond import Frond
819
+ frond = Frond()
820
+ html = frond.render(tpl_file, {})
821
+ response.html(html)
822
+ elif request.path == "/":
823
+ response.html(_render_landing_page())
789
824
  else:
790
- response.status(404).json({
791
- "error": "Not Found",
792
- "path": request.path,
793
- "status": 404,
794
- })
825
+ html = _render_error_page(404, request.path, request_id)
826
+ if html:
827
+ response.status(404).html(html)
828
+ else:
829
+ response.status(404).json({
830
+ "error": "Not Found",
831
+ "path": request.path,
832
+ "status": 404,
833
+ })
795
834
 
796
835
  # Apply CORS headers to all responses
797
836
  _cors.apply(request, response)
@@ -285,7 +285,7 @@ class DatabaseAdapter:
285
285
  )
286
286
 
287
287
  def fetch(self, sql: str, params: list = None,
288
- limit: int = 20, skip: int = 0) -> DatabaseResult:
288
+ limit: int = 20, offset: int = 0) -> DatabaseResult:
289
289
  """Execute a read query and return multiple rows."""
290
290
  raise NotImplementedError
291
291
 
@@ -182,20 +182,21 @@ class Database:
182
182
  return self._adapter.execute_many(sql, params_list)
183
183
 
184
184
  def fetch(self, sql: str, params: list = None,
185
- limit: int = 20, skip: int = 0) -> DatabaseResult:
185
+ limit: int = 20, offset: int = 0) -> DatabaseResult:
186
+ """Fetch rows with pagination."""
186
187
  if self._cache_enabled:
187
- key = self._cache_key(sql + f":L{limit}:S{skip}", params)
188
+ key = self._cache_key(sql + f":L{limit}:S{offset}", params)
188
189
  cached = self._cache_get(key)
189
190
  if cached is not None:
190
191
  with self._cache_lock:
191
192
  self._cache_hits += 1
192
193
  return cached
193
- result = self._adapter.fetch(sql, params, limit, skip)
194
+ result = self._adapter.fetch(sql, params, limit, offset)
194
195
  self._cache_set(key, result)
195
196
  with self._cache_lock:
196
197
  self._cache_misses += 1
197
198
  return result
198
- return self._adapter.fetch(sql, params, limit, skip)
199
+ return self._adapter.fetch(sql, params, limit, offset)
199
200
 
200
201
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
201
202
  if self._cache_enabled:
@@ -114,7 +114,7 @@ class FirebirdAdapter(DatabaseAdapter):
114
114
  )
115
115
 
116
116
  def fetch(self, sql: str, params: list = None,
117
- limit: int = 20, skip: int = 0) -> DatabaseResult:
117
+ limit: int = 20, offset: int = 0) -> DatabaseResult:
118
118
  sql = self._translate_sql(sql)
119
119
  cursor = self._conn.cursor()
120
120
 
@@ -127,8 +127,8 @@ class FirebirdAdapter(DatabaseAdapter):
127
127
  total = 0
128
128
 
129
129
  # Apply Firebird pagination — ROWS start TO end
130
- start = skip + 1
131
- end = skip + limit
130
+ start = offset + 1
131
+ end = offset + limit
132
132
  paginated_sql = f"{sql} ROWS {start} TO {end}"
133
133
  cursor.execute(paginated_sql, params or [])
134
134
 
@@ -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, skip: int = 0) -> DatabaseResult:
105
+ limit: int = 20, offset: int = 0) -> DatabaseResult:
106
106
  sql = self._translate_sql(sql)
107
107
  cursor = self._conn.cursor(as_dict=True)
108
108
 
@@ -121,7 +121,7 @@ class MSSQLAdapter(DatabaseAdapter):
121
121
  else:
122
122
  paginated_sql = f"{sql} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
123
123
 
124
- paginated_params = tuple(params or []) + (skip, limit)
124
+ paginated_params = tuple(params or []) + (offset, limit)
125
125
  cursor.execute(paginated_sql, paginated_params)
126
126
  rows = [dict(row) for row in cursor.fetchall()]
127
127
 
@@ -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, skip: int = 0) -> DatabaseResult:
94
+ limit: int = 20, offset: int = 0) -> DatabaseResult:
95
95
  sql = self._translate_sql(sql)
96
96
  cursor = self._conn.cursor(dictionary=True)
97
97
 
@@ -105,7 +105,7 @@ class MySQLAdapter(DatabaseAdapter):
105
105
 
106
106
  # Apply pagination
107
107
  paginated_sql = f"{sql} LIMIT %s OFFSET %s"
108
- paginated_params = (params or []) + [limit, skip]
108
+ paginated_params = (params or []) + [limit, offset]
109
109
  cursor.execute(paginated_sql, paginated_params)
110
110
  rows = [dict(row) for row in cursor.fetchall()]
111
111
 
@@ -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, skip: int = 0) -> DatabaseResult:
77
+ limit: int = 20, 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()
@@ -86,14 +86,14 @@ class ODBCAdapter(DatabaseAdapter):
86
86
 
87
87
  # Apply pagination — use OFFSET/FETCH for ODBC (SQL Server style)
88
88
  paginated_sql = f"{sql} OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"
89
- paginated_params = (params or []) + [skip, limit]
89
+ paginated_params = (params or []) + [offset, limit]
90
90
 
91
91
  try:
92
92
  cursor.execute(paginated_sql, paginated_params)
93
93
  except Exception:
94
94
  # Fallback: try LIMIT/OFFSET for non-SQL Server ODBC sources
95
95
  paginated_sql = f"{sql} LIMIT ? OFFSET ?"
96
- paginated_params = (params or []) + [limit, skip]
96
+ paginated_params = (params or []) + [limit, offset]
97
97
  cursor.execute(paginated_sql, paginated_params)
98
98
 
99
99
  columns = [desc[0] for desc in cursor.description] if cursor.description else []
@@ -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, skip: int = 0) -> DatabaseResult:
103
+ limit: int = 20, offset: int = 0) -> DatabaseResult:
104
104
  import psycopg2.extras
105
105
 
106
106
  sql = self._translate_sql(sql)
@@ -117,7 +117,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
117
117
 
118
118
  # Apply pagination
119
119
  paginated_sql = f"{sql} LIMIT %s OFFSET %s"
120
- paginated_params = (params or []) + [limit, skip]
120
+ paginated_params = (params or []) + [limit, offset]
121
121
  cursor.execute(paginated_sql, paginated_params)
122
122
  rows = [dict(row) for row in cursor.fetchall()]
123
123
 
@@ -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, skip: int = 0) -> DatabaseResult:
96
+ limit: int = 20, 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:
@@ -103,7 +103,7 @@ class SQLiteAdapter(DatabaseAdapter):
103
103
 
104
104
  # Apply pagination
105
105
  paginated_sql = f"{sql} LIMIT ? OFFSET ?"
106
- paginated_params = (params or []) + [limit, skip]
106
+ paginated_params = (params or []) + [limit, offset]
107
107
  cursor = self._conn.execute(paginated_sql, paginated_params)
108
108
  rows = [dict(row) for row in cursor.fetchall()]
109
109
 
@@ -599,9 +599,9 @@ def _form_token(descriptor: str = "") -> str:
599
599
  payload["context"] = descriptor
600
600
 
601
601
  secret = os.environ.get("SECRET", "tina4-default-secret")
602
- ttl = int(os.environ.get("TINA4_TOKEN_LIMIT", "30"))
603
- auth = _FrondAuth(secret=secret, token_expiry=ttl)
604
- token = auth.create_token(payload)
602
+ ttl = int(os.environ.get("TINA4_TOKEN_EXPIRES_IN", "60"))
603
+ auth = _FrondAuth(secret=secret, expires_in=ttl)
604
+ token = auth.get_token(payload)
605
605
  return SafeString(f'<input type="hidden" name="formToken" value="{token}">')
606
606
 
607
607
 
@@ -49,9 +49,9 @@ async def gallery_auth_page(request, response):
49
49
  <div class="card-body">
50
50
  <h6 style="color:#e2e8f0;">How it works</h6>
51
51
  <pre style="background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem;"><code>auth = Auth()
52
- token = auth.create_token({"username": "admin"})
52
+ token = auth.get_token({"username": "admin"})
53
53
  payload = auth.get_payload(token)
54
- is_valid = auth.validate_token(token)</code></pre>
54
+ is_valid = auth.valid_token(token)</code></pre>
55
55
  </div>
56
56
  </div>
57
57
  </div>
@@ -107,7 +107,7 @@ async def gallery_login(request, response):
107
107
  password = body.get("password", "")
108
108
  if username and password:
109
109
  auth = Auth()
110
- token = auth.create_token({"username": username, "role": "user"})
110
+ token = auth.get_token({"username": username, "role": "user"})
111
111
  return response({"token": token, "message": f"Welcome {username}!"})
112
112
  return response({"error": "Username and password required"}, 401)
113
113
 
@@ -117,5 +117,5 @@ async def gallery_login(request, response):
117
117
  async def gallery_verify(request, response):
118
118
  token = request.params.get("token", "")
119
119
  auth = Auth()
120
- is_valid = auth.validate_token(token)
120
+ is_valid = auth.valid_token(token)
121
121
  return response({"valid": is_valid})
@@ -24,7 +24,9 @@ class I18n:
24
24
  self._locale_dir = Path(
25
25
  locale_dir or os.environ.get("TINA4_LOCALE_DIR", "src/locales")
26
26
  )
27
- self._default_locale = default_locale or os.environ.get("TINA4_LANGUAGE", "en")
27
+ self._default_locale = default_locale or os.environ.get(
28
+ "TINA4_LOCALE", "en"
29
+ )
28
30
  self._current_locale = self._default_locale
29
31
  self._translations: dict[str, dict] = {}
30
32
  self._load_locale(self._default_locale)