tina4-python 0.2.205__tar.gz → 0.2.206__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 (89) hide show
  1. {tina4_python-0.2.205 → tina4_python-0.2.206}/PKG-INFO +2 -1
  2. {tina4_python-0.2.205 → tina4_python-0.2.206}/pyproject.toml +2 -1
  3. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Response.py +4 -5
  4. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Router.py +17 -4
  5. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Template.py +66 -66
  6. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/__init__.py +55 -34
  7. tina4_python-0.2.205/tina4_python/TwigEngine.py +0 -1255
  8. {tina4_python-0.2.205 → tina4_python-0.2.206}/.gitignore +0 -0
  9. {tina4_python-0.2.205 → tina4_python-0.2.206}/README.md +0 -0
  10. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Api.py +0 -0
  11. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Auth.py +0 -0
  12. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/CLAUDE.md +0 -0
  13. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/CRUD.py +0 -0
  14. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Constant.py +0 -0
  15. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Database.py +0 -0
  16. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/DatabaseResult.py +0 -0
  17. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/DatabaseTypes.py +0 -0
  18. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Debug.py +0 -0
  19. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/DevReload.py +0 -0
  20. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Env.py +0 -0
  21. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/FieldTypes.py +0 -0
  22. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/GraphQL.py +0 -0
  23. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/HtmlElement.py +0 -0
  24. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Localization.py +0 -0
  25. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Messages.py +0 -0
  26. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/MiddleWare.py +0 -0
  27. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Migration.py +0 -0
  28. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/ORM.py +0 -0
  29. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Queue.py +0 -0
  30. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Request.py +0 -0
  31. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/SQLToMongo.py +0 -0
  32. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Seeder.py +0 -0
  33. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Session.py +0 -0
  34. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/ShellColors.py +0 -0
  35. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Swagger.py +0 -0
  36. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Testing.py +0 -0
  37. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/WSDL.py +0 -0
  38. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Webserver.py +0 -0
  39. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/Websocket.py +0 -0
  40. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/cli.py +0 -0
  41. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/messages.pot +0 -0
  42. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/css/readme.md +0 -0
  43. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/css/tina4.css +0 -0
  44. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/css/tina4.min.css +0 -0
  45. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/favicon.ico +0 -0
  46. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/images/403.png +0 -0
  47. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/images/404.png +0 -0
  48. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/images/500.png +0 -0
  49. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/images/logo.png +0 -0
  50. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/images/readme.md +0 -0
  51. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/js/readme.md +0 -0
  52. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/js/reconnecting-websocket.js +0 -0
  53. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/js/tina4.js +0 -0
  54. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/js/tina4helper.js +0 -0
  55. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/swagger/index.html +0 -0
  56. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  57. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  58. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_badges.scss +0 -0
  59. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  60. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_cards.scss +0 -0
  61. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_forms.scss +0 -0
  62. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_grid.scss +0 -0
  63. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_modals.scss +0 -0
  64. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_nav.scss +0 -0
  65. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_reset.scss +0 -0
  66. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_tables.scss +0 -0
  67. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_typography.scss +0 -0
  68. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  69. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/_variables.scss +0 -0
  70. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/base.scss +0 -0
  71. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/colors.scss +0 -0
  72. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/scss/tina4css/tina4.scss +0 -0
  73. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/templates/components/crud.twig +0 -0
  74. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/templates/errors/403.twig +0 -0
  75. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/templates/errors/404.twig +0 -0
  76. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/templates/errors/500.twig +0 -0
  77. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/templates/readme.md +0 -0
  78. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  79. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  80. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  81. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  82. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  83. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  84. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  85. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  86. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  87. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  88. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  89. {tina4_python-0.2.205 → tina4_python-0.2.206}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 0.2.205
3
+ Version: 0.2.206
4
4
  Summary: Tina4Python - This is not another framework for Python
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  Requires-Python: <4.0,>=3.12
7
7
  Requires-Dist: bcrypt<5.0.0,>=4.2.1
8
8
  Requires-Dist: hypercorn>=0.18.0
9
+ Requires-Dist: jinja2<4.0.0,>=3.1.5
9
10
  Requires-Dist: libsass<0.24.0,>=0.23.0
10
11
  Requires-Dist: litequeue<0.10,>=0.9
11
12
  Requires-Dist: pyjwt<3.0.0,>=2.10.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "0.2.205"
3
+ version = "0.2.206"
4
4
  description = "Tina4Python - This is not another framework for Python"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
@@ -8,6 +8,7 @@ authors = [
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12,<4.0"
10
10
  dependencies = [
11
+ "jinja2>=3.1.5,<4.0.0",
11
12
  "libsass (>=0.23.0,<0.24.0)",
12
13
  "python-dotenv (>=1.0.1,<2.0.0)",
13
14
  "pyjwt>=2.10.1,<3.0.0",
@@ -104,12 +104,11 @@ class Response:
104
104
 
105
105
  # Merge any headers added via add_header() before this Response was created
106
106
  pending = _pending_headers.get()
107
+ merged_headers = {}
108
+ if pending:
109
+ merged_headers.update(pending)
107
110
  if headers_in is not None:
108
- merged_headers = dict(headers_in)
109
- elif pending:
110
- merged_headers = dict(pending)
111
- else:
112
- merged_headers = {}
111
+ merged_headers.update(headers_in)
113
112
 
114
113
  self.headers = merged_headers
115
114
  self.content = content_in if content_in is not None else ""
@@ -501,10 +501,23 @@ class Router:
501
501
  with open(cached_static, 'rb') as file:
502
502
  return Response(file.read(), Constant.HTTP_OK, mime_type)
503
503
 
504
- # Always use full scan for route matching simple, correct, and fast
505
- # enough for typical route counts. The pre-compiled segments and cached
506
- # signatures provide the main per-request speedup.
507
- route_iter = list(tina4_python.tina4_routes.values())
504
+ # Build candidate route list using prefix index (O(1) lookup vs O(n) scan)
505
+ url_segments = Router._normalize_request_url(url)
506
+ first_seg = url_segments[0] if url_segments else ""
507
+
508
+ # Collect indexed candidates
509
+ candidates = set()
510
+ if first_seg in Router._route_index:
511
+ candidates.update(Router._route_index[first_seg])
512
+ candidates.update(Router._wildcard_routes)
513
+ if not url_segments and "" in Router._route_index:
514
+ candidates.update(Router._route_index[""])
515
+
516
+ # If index is empty (e.g. tests reset tina4_routes directly), fall back to full scan
517
+ if not Router._route_index and not Router._wildcard_routes:
518
+ route_iter = tina4_python.tina4_routes.values()
519
+ else:
520
+ route_iter = (tina4_python.tina4_routes[cb] for cb in candidates if cb in tina4_python.tina4_routes)
508
521
 
509
522
  buffer = io.StringIO()
510
523
  for route in route_iter:
@@ -4,14 +4,13 @@
4
4
  # License: MIT https://opensource.org/licenses/MIT
5
5
  #
6
6
  # flake8: noqa: E501
7
- """Twig template engine for Tina4.
7
+ """Jinja2-based template engine for Tina4.
8
8
 
9
- The ``Template`` class wraps Tina4's built-in TwigEngine to provide
10
- Twig/PHP-compatible HTML rendering with automatic template discovery
11
- from ``src/templates/``.
9
+ The ``Template`` class wraps Jinja2 to provide Twig-compatible HTML
10
+ rendering with automatic template discovery from ``src/templates/``.
12
11
 
13
12
  Features:
14
- - Automatic template path setup scanning ``src/templates/``
13
+ - Automatic ``FileSystemLoader`` setup scanning ``src/templates/``
15
14
  - Built-in filters: ``json_decode``, ``base64_encode``, ``date``,
16
15
  ``slugify``, and more
17
16
  - Built-in globals: ``url``, ``root``, ``session``, ``uniqid``, ``localize``
@@ -37,9 +36,9 @@ import base64
37
36
  import tina4_python
38
37
  from tina4_python import Constant
39
38
  from tina4_python.Debug import Debug
40
- from tina4_python.TwigEngine import TwigEngine, TemplateNotFound
41
39
  from pathlib import Path
42
40
  from datetime import datetime, date
41
+ from jinja2 import Environment, FileSystemLoader, Undefined, TemplateNotFound
43
42
  from tina4_python.Session import Session
44
43
  from random import random as RANDOM
45
44
  from typing import Dict, Any
@@ -55,34 +54,36 @@ class Template:
55
54
 
56
55
  @staticmethod
57
56
  def add_filter(name, func):
58
- """Register a custom template filter."""
57
+ """Register a custom Jinja2 filter."""
59
58
  Template._custom_filters[name] = func
60
59
  if Template.twig is not None:
61
- Template.twig.add_filter(name, func)
60
+ Template.twig.filters[name] = func
62
61
 
63
62
  @staticmethod
64
63
  def add_global(name, value):
65
- """Register a custom template global (function or value)."""
64
+ """Register a custom Jinja2 global (function or value)."""
66
65
  Template._custom_globals[name] = value
67
66
  if Template.twig is not None:
68
- Template.twig.add_global(name, value)
67
+ Template.twig.globals[name] = value
69
68
 
70
69
  @staticmethod
71
70
  def add_test(name, func):
72
- """Register a custom template test for use with {% if x is testname %}."""
71
+ """Register a custom Jinja2 test for use with {% if x is testname %}."""
73
72
  Template._custom_tests[name] = func
74
73
  if Template.twig is not None:
75
- Template.twig.add_test(name, func)
74
+ Template.twig.tests[name] = func
76
75
 
77
76
  @staticmethod
78
77
  def add_extension(extension):
79
- """Register a template extension (kept for API compatibility)."""
78
+ """Register a Jinja2 extension class."""
80
79
  if extension not in Template._custom_extensions:
81
80
  Template._custom_extensions.append(extension)
81
+ if Template.twig is not None:
82
+ Template.twig.add_extension(extension)
82
83
 
83
84
  @staticmethod
84
85
  def get_environment():
85
- """Return the underlying TwigEngine instance, initializing if needed."""
86
+ """Return the underlying Jinja2 Environment instance, initializing if needed."""
86
87
  if Template.twig is None:
87
88
  Template.init_twig(tina4_python.root_path + os.sep + "src" + os.sep + "templates")
88
89
  return Template.twig
@@ -104,55 +105,52 @@ class Template:
104
105
  return Template.twig
105
106
  Debug.debug("Initializing Twig on " + path)
106
107
  twig_path = Path(path)
107
- # Search project templates first, then framework built-in templates as fallback
108
- fw_templates = Path(tina4_python.library_path) / "templates"
109
- search_paths = [str(twig_path)]
110
- if fw_templates.is_dir() and str(fw_templates) != str(twig_path):
111
- search_paths.append(str(fw_templates))
112
- Template.twig = TwigEngine(search_paths)
113
-
108
+ Template.twig = Environment(loader=FileSystemLoader(Path(twig_path)))
109
+ Template.twig.add_extension('jinja2.ext.debug')
110
+ Template.twig.add_extension('jinja2.ext.do')
114
111
  # i18n translation function — available as _() global and | translate filter
115
112
  from tina4_python.Localization import localize
116
113
  _translate = localize()
117
- Template.twig.add_global('_', _translate)
118
- Template.twig.add_filter('translate', _translate)
119
- Template.twig.add_global('RANDOM', RANDOM)
120
- Template.twig.add_global('range', range)
121
- Template.twig.add_global('json', json)
122
- Template.twig.add_global('base64encode', Template.base64encode)
123
- Template.twig.add_filter('base64encode', Template.base64encode)
124
- Template.twig.add_filter('detect_image', Template.detect_image)
125
- Template.twig.add_filter('json_encode', json.dumps)
126
- Template.twig.add_global('json_encode', json.dumps)
127
- Template.twig.add_filter('json_decode', Template.json_decode)
128
- Template.twig.add_global('json_decode', Template.json_decode)
129
- Template.twig.add_filter('nice_label', Template.get_nice_label)
130
- Template.twig.add_global('datetime_format', Template.datetime_format)
131
- Template.twig.add_filter('datetime_format', Template.datetime_format)
132
- Template.twig.add_global('formToken', Template.get_form_token)
133
- Template.twig.add_filter('formToken', Template.get_form_token_input)
134
- Template.twig.add_global('form_token', Template.get_form_token)
135
- Template.twig.add_filter('form_token', Template.get_form_token_input)
114
+ Template.twig.globals['_'] = _translate
115
+ Template.twig.filters['translate'] = _translate
116
+ Template.twig.globals['RANDOM'] = RANDOM
117
+ Template.twig.globals['json'] = json
118
+ Template.twig.globals['base64encode'] = Template.base64encode
119
+ Template.twig.filters['base64encode'] = Template.base64encode
120
+ Template.twig.filters['detect_image'] = Template.detect_image
121
+ Template.twig.filters['json_encode'] = json.dumps
122
+ Template.twig.globals['json_encode'] = json.dumps
123
+ Template.twig.filters['json_decode'] = Template.json_decode
124
+ Template.twig.globals['json_decode'] = Template.json_decode
125
+ Template.twig.filters['nice_label'] = Template.get_nice_label
126
+ Template.twig.globals['datetime_format'] = Template.datetime_format
127
+ Template.twig.filters['datetime_format'] = Template.datetime_format
128
+ Template.twig.globals['formToken'] = Template.get_form_token
129
+ Template.twig.filters['formToken'] = Template.get_form_token_input
130
+ Template.twig.globals['form_token'] = Template.get_form_token
131
+ Template.twig.filters['form_token'] = Template.get_form_token_input
136
132
  debug_level = os.getenv("TINA4_DEBUG_LEVEL", "")
137
133
  if Constant.TINA4_LOG_DEBUG in debug_level or Constant.TINA4_LOG_ALL in debug_level:
138
- Template.twig.add_global('dump', Template.dump)
134
+ Template.twig.globals['dump'] = Template.dump
139
135
  else:
140
- Template.twig.add_global('dump', Template.production_dump)
136
+ Template.twig.globals['dump'] = Template.production_dump
141
137
 
142
- # Apply any custom filters, globals, tests registered before init
138
+ # Apply any custom filters, globals, tests, and extensions registered before init
143
139
  for name, func in Template._custom_filters.items():
144
- Template.twig.add_filter(name, func)
140
+ Template.twig.filters[name] = func
145
141
  for name, value in Template._custom_globals.items():
146
- Template.twig.add_global(name, value)
142
+ Template.twig.globals[name] = value
147
143
  for name, func in Template._custom_tests.items():
148
- Template.twig.add_test(name, func)
144
+ Template.twig.tests[name] = func
145
+ for ext in Template._custom_extensions:
146
+ Template.twig.add_extension(ext)
149
147
 
150
148
  Debug.debug("Twig Initialized on " + path)
151
149
  return Template.twig
152
150
 
153
151
  @staticmethod
154
152
  def datetime_format(value, format="%H:%M %d-%m-%y"):
155
- if isinstance(value, str) and value.strip().upper() == "NOW":
153
+ if value.strip().upper() == "NOW":
156
154
  value = datetime.now()
157
155
  try:
158
156
  return value.strftime(format)
@@ -171,14 +169,14 @@ class Template:
171
169
 
172
170
  @staticmethod
173
171
  def dump(param):
174
- param = html.unescape(str(param)) if param is not None else None
175
- if param is not None:
172
+ param = html.unescape(param)
173
+ if param is not None and not isinstance(param, Undefined):
176
174
  def json_serialize(obj):
177
175
  if isinstance(obj, (date, datetime)):
178
176
  return obj.isoformat()
179
177
  if isinstance(obj, Session):
180
178
  return obj.session_values
181
- raise TypeError("Type %s not serializable" % type(obj))
179
+ raise TypeError("Type %s not serializable to Jinja2 template" % type(obj))
182
180
 
183
181
  return "<pre>" + json.dumps(param, indent=True, default=json_serialize) + "</pre>"
184
182
  else:
@@ -186,7 +184,7 @@ class Template:
186
184
 
187
185
  @staticmethod
188
186
  def base64encode(param):
189
- value = base64.b64encode(param.encode('utf-8')).decode('utf-8')
187
+ value = base64.b64encode(param.encode('utf-8')).decode('utf-8')
190
188
  return value
191
189
 
192
190
  @staticmethod
@@ -204,9 +202,9 @@ class Template:
204
202
  def convert_special_types(obj):
205
203
  """
206
204
  Recursively convert non-JSON-serializable objects:
207
- - datetime/date -> ISO 8601 string
208
- - bytes -> base64 string
209
- - dict/list/tuple/set -> recursively processed
205
+ datetime/date ISO 8601 string
206
+ bytes base64 string
207
+ dict/list/tuple/set recursively processed
210
208
  Safe for deeply nested data (arrays of arrays, dicts in lists, etc.)
211
209
  """
212
210
  if isinstance(obj, (date, datetime)):
@@ -228,7 +226,7 @@ class Template:
228
226
  ]
229
227
 
230
228
  else:
231
- # Primitives: str, int, float, bool, None -> pass through
229
+ # Primitives: str, int, float, bool, None pass through
232
230
  return obj
233
231
 
234
232
  @staticmethod
@@ -243,8 +241,9 @@ class Template:
243
241
  twig = Template.init_twig(tina4_python.root_path + os.sep + "src" + os.sep + "templates")
244
242
  try:
245
243
  try:
246
- template = twig.get_template(template_or_file_name)
247
- content = template.render(data)
244
+ if twig.get_template(template_or_file_name):
245
+ template = twig.get_template(template_or_file_name)
246
+ content = template.render(data)
248
247
  except TemplateNotFound:
249
248
  template = twig.from_string(template_or_file_name)
250
249
  content = template.render(data)
@@ -261,12 +260,13 @@ class Template:
261
260
 
262
261
  @staticmethod
263
262
  def get_nice_label(field_name: str) -> str:
264
- # snake_case / camelCase / PascalCase -> words
263
+ # snake_case / camelCase / PascalCase words
265
264
  s = re.sub(r'[_.-]+', ' ', field_name)
266
265
  s = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', s)
267
266
  # Capitalize words & strip id
268
267
  words = s.split()
269
- return " ".join(word.capitalize() for word in words)
268
+ return " ".join(word.capitalize() for word in words )
269
+
270
270
 
271
271
  @staticmethod
272
272
  def detect_image(value: Any) -> Dict[str, str]:
@@ -280,12 +280,12 @@ class Template:
280
280
  content = data.get('content', '')
281
281
 
282
282
  if not content:
283
- return {"content": value, "content_type": ""}
283
+ return {"content": value, "content_type": ""}
284
284
 
285
285
  content_type = data.get('content_type', '')
286
286
  if content_type.startswith('image/'):
287
287
  mime_type = content_type.split('/')[1]
288
- return {"content": content, "content_type": mime_type}
288
+ return {"content": content, "content_type": mime_type}
289
289
 
290
290
  # Fallback to magic bytes if no content_type
291
291
  if content[:4] == '/9j/':
@@ -297,7 +297,7 @@ class Template:
297
297
  elif content[:5] == 'UklGR':
298
298
  mime_type = 'webp'
299
299
  else:
300
- return {"content": content, "content_type": ""}
300
+ return {"content": content, "content_type": ""}
301
301
 
302
302
  return {"content": content, "content_type": mime_type}
303
303
  except json.JSONDecodeError as e:
@@ -328,14 +328,14 @@ def template(twig_file: str):
328
328
  async def wrapper(*args, **kwargs):
329
329
  result = await func(*args, **kwargs)
330
330
 
331
- # If the route returns a dict -> render the template
331
+ # If the route returns a dict render the template
332
332
  if isinstance(result, dict):
333
- rendered = Template.render(twig_file, result)
333
+ html = Template.render(twig_file, result)
334
334
  from tina4_python.Response import Response
335
- return Response(rendered, Constant.HTTP_OK, Constant.TEXT_HTML)
335
+ return Response(html, Constant.HTTP_OK, Constant.TEXT_HTML)
336
336
 
337
337
  # Anything else (redirects, JSON, etc.) is passed through unchanged
338
338
  return result
339
339
 
340
340
  return wrapper
341
- return decorator
341
+ return decorator
@@ -223,9 +223,9 @@ if not os.path.exists(root_path + os.sep + "src" + os.sep + "public"):
223
223
  shutil.copytree(source_dir, destination_dir)
224
224
 
225
225
  # Declare built-ins so we don't always have to import stuff.
226
- # NOTE: Names that shadow submodule names (Database, ORM, Api, etc.) MUST be
227
- # imported eagerly Python's import system resolves submodules before __getattr__.
228
- # Only names that don't shadow submodules can use lazy __getattr__.
226
+ # Light imports (no heavy deps) are loaded eagerly.
227
+ # Heavy imports (jwt, jinja2, database drivers) are deferred until first use
228
+ # via module __getattr__ and registered as builtins on first access.
229
229
  import builtins
230
230
  from .Router import get, post, put, patch, delete, middleware, cached, noauth, secured, wsdl
231
231
  from .Testing import tests, assert_equal, assert_raises
@@ -233,34 +233,57 @@ from .Debug import Debug
233
233
  from .FieldTypes import IntegerField, StringField, JSONBField, TextField, BlobField, NumericField, DateTimeField
234
234
  from .Constant import TEXT_HTML, TEXT_PLAIN, TEXT_CSS, TINA4_POST, TINA4_DELETE, TINA4_ANY, TINA4_PUT, TINA4_PATCH, TINA4_OPTIONS, TINA4_LOG_ALL, TINA4_LOG_WARNING, TINA4_LOG_ERROR, TINA4_LOG_DEBUG, TINA4_GET, TINA4_LOG_INFO, HTTP_OK, HTTP_SERVER_ERROR, HTTP_FORBIDDEN, HTTP_NO_CONTENT, HTTP_PARTIAL_CONTENT, HTTP_CREATED, HTTP_UNAUTHORIZED, HTTP_ACCEPTED, HTTP_REDIRECT, HTTP_REDIRECT_MOVED, HTTP_REDIRECT_OTHER, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, LOOKUP_HTTP_CODE, APPLICATION_JSON, APPLICATION_XML
235
235
 
236
- # These shadow submodule names so they must be imported eagerly
237
- from .Database import Database
238
- from .ORM import ORM, orm
239
- from .Api import Api
240
- from .Template import template
241
- from .Swagger import Swagger, description, secure, summary, example, example_response, tags, params, describe
242
- from .GraphQL import GraphQL, GraphQLSchema, GraphQLType
243
- from .Seeder import FakeData, Seeder, seed_orm, seed_table, seed
244
-
245
- # Register all as builtins
246
- for _obj in (get, post, put, patch, delete, middleware, cached, noauth, secured, wsdl,
247
- tests, assert_equal, assert_raises,
248
- IntegerField, StringField, JSONBField, TextField, BlobField, NumericField, DateTimeField,
249
- description, secure, summary, example, example_response, tags, params, describe, template):
250
- if _obj.__name__ not in builtins.__dict__:
251
- builtins.__dict__[_obj.__name__] = _obj
236
+ # Register light builtins immediately
237
+ for deco in (get, post, put, patch, delete, middleware, cached, noauth, secured, wsdl, tests, assert_equal, assert_raises,
238
+ IntegerField, StringField, JSONBField, TextField, BlobField, NumericField, DateTimeField):
239
+ if deco.__name__ not in builtins.__dict__:
240
+ builtins.__dict__[deco.__name__] = deco
252
241
 
253
242
  builtins.Debug = Debug
254
- builtins.Database = Database
255
- builtins.ORM = ORM
256
- builtins.orm = orm
257
- builtins.Api = Api
258
- builtins.GraphQL = GraphQL
259
- builtins.GraphQLSchema = GraphQLSchema
260
- builtins.FakeData = FakeData
261
- builtins.Seeder = Seeder
262
- builtins.seed_orm = seed_orm
263
- builtins.seed_table = seed_table
243
+
244
+ # Lazy module attributes — resolved on `from tina4_python import X` or `tina4_python.X`
245
+ # Maps attribute name → (submodule_path, attribute_name)
246
+ _LAZY_ATTRS = {
247
+ "Database": (".Database", "Database"),
248
+ "ORM": (".ORM", "ORM"),
249
+ "orm": (".ORM", "orm"),
250
+ "Api": (".Api", "Api"),
251
+ "template": (".Template", "template"),
252
+ "description": (".Swagger", "description"),
253
+ "secure": (".Swagger", "secure"),
254
+ "summary": (".Swagger", "summary"),
255
+ "example": (".Swagger", "example"),
256
+ "example_response": (".Swagger", "example_response"),
257
+ "tags": (".Swagger", "tags"),
258
+ "params": (".Swagger", "params"),
259
+ "describe": (".Swagger", "describe"),
260
+ "GraphQL": (".GraphQL", "GraphQL"),
261
+ "GraphQLSchema": (".GraphQL", "GraphQLSchema"),
262
+ "GraphQLType": (".GraphQL", "GraphQLType"),
263
+ "FakeData": (".Seeder", "FakeData"),
264
+ "Seeder": (".Seeder", "Seeder"),
265
+ "seed_orm": (".Seeder", "seed_orm"),
266
+ "seed_table": (".Seeder", "seed_table"),
267
+ "seed": (".Seeder", "seed"),
268
+ }
269
+
270
+ def __getattr__(name):
271
+ """Module-level __getattr__ for lazy imports.
272
+
273
+ Called when ``from tina4_python import X`` or ``tina4_python.X`` is used
274
+ and X isn't already in the module namespace. Loads the real attribute
275
+ from the submodule and caches it in both the module dict and builtins.
276
+ """
277
+ if name in _LAZY_ATTRS:
278
+ submod, attr = _LAZY_ATTRS[name]
279
+ mod = importlib.import_module(submod, package="tina4_python")
280
+ obj = getattr(mod, attr)
281
+ # Cache in module dict so __getattr__ isn't called again
282
+ globals()[name] = obj
283
+ # Also register as builtin for zero-import convenience
284
+ builtins.__dict__[name] = obj
285
+ return obj
286
+ raise AttributeError(f"module 'tina4_python' has no attribute {name!r}")
264
287
 
265
288
 
266
289
  # src/ is loaded lazily inside run_web_server() via _autoload_routes()
@@ -351,10 +374,8 @@ async def get_swagger_json(request, response):
351
374
  @get(os.getenv("SWAGGER_ROUTE", "/swagger"))
352
375
  async def get_swagger(request, response):
353
376
  """Serve interactive Swagger UI page."""
354
- swagger_path = root_path + os.sep + "src" + os.sep + "public" + os.sep + "swagger" + os.sep + "index.html"
355
- if not os.path.isfile(swagger_path):
356
- swagger_path = library_path + os.sep + "public" + os.sep + "swagger" + os.sep + "index.html"
357
- html = file_get_contents(swagger_path)
377
+ html = file_get_contents(
378
+ root_path + os.sep + "src" + os.sep + "public" + os.sep + "swagger" + os.sep + "index.html")
358
379
 
359
380
  html = html.replace("{SWAGGER_ROUTE}", os.getenv("SWAGGER_ROUTE", "/swagger"))
360
381
  return response(html)
@@ -579,7 +600,7 @@ def run_web_server(hostname="localhost", port=7145, debug: bool = False):
579
600
  # ------------------------------------------------------------------
580
601
  # Show a clickable URL in the banner (0.0.0.0 isn't useful for devs)
581
602
  display_host = "localhost" if hostname in ("0.0.0.0", "::") else hostname
582
- Debug.info(f"Server started http://{display_host}:{port} ({len(tina4_routes)} routes)")
603
+ Debug.info(f"Server started http://{display_host}:{port}")
583
604
  webserver(hostname, port, debug=debug) # Pass debug flag down
584
605
 
585
606