tina4-python 0.2.200__tar.gz → 0.2.202__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.200 → tina4_python-0.2.202}/PKG-INFO +29 -3
  2. {tina4_python-0.2.200 → tina4_python-0.2.202}/README.md +10 -0
  3. {tina4_python-0.2.200 → tina4_python-0.2.202}/pyproject.toml +18 -4
  4. tina4_python-0.2.202/tina4_python/Auth.py +236 -0
  5. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/CLAUDE.md +137 -7
  6. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Database.py +1 -1
  7. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/DevReload.py +70 -0
  8. tina4_python-0.2.202/tina4_python/GraphQL.py +894 -0
  9. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Messages.py +1 -0
  10. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Router.py +5 -1
  11. tina4_python-0.2.202/tina4_python/Seeder.py +864 -0
  12. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Webserver.py +10 -1
  13. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/__init__.py +8 -0
  14. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/cli.py +127 -0
  15. tina4_python-0.2.202/tina4_python/public/css/tina4.css +2463 -0
  16. tina4_python-0.2.202/tina4_python/public/css/tina4.min.css +1 -0
  17. tina4_python-0.2.202/tina4_python/public/js/tina4.js +134 -0
  18. tina4_python-0.2.202/tina4_python/scss/tina4css/_alerts.scss +34 -0
  19. tina4_python-0.2.202/tina4_python/scss/tina4css/_badges.scss +22 -0
  20. tina4_python-0.2.202/tina4_python/scss/tina4css/_buttons.scss +69 -0
  21. tina4_python-0.2.202/tina4_python/scss/tina4css/_cards.scss +49 -0
  22. tina4_python-0.2.202/tina4_python/scss/tina4css/_forms.scss +156 -0
  23. tina4_python-0.2.202/tina4_python/scss/tina4css/_grid.scss +81 -0
  24. tina4_python-0.2.202/tina4_python/scss/tina4css/_modals.scss +84 -0
  25. tina4_python-0.2.202/tina4_python/scss/tina4css/_nav.scss +149 -0
  26. tina4_python-0.2.202/tina4_python/scss/tina4css/_reset.scss +94 -0
  27. tina4_python-0.2.202/tina4_python/scss/tina4css/_tables.scss +54 -0
  28. tina4_python-0.2.202/tina4_python/scss/tina4css/_typography.scss +55 -0
  29. tina4_python-0.2.202/tina4_python/scss/tina4css/_utilities.scss +197 -0
  30. tina4_python-0.2.202/tina4_python/scss/tina4css/_variables.scss +117 -0
  31. tina4_python-0.2.202/tina4_python/scss/tina4css/base.scss +1 -0
  32. tina4_python-0.2.202/tina4_python/scss/tina4css/colors.scss +48 -0
  33. tina4_python-0.2.202/tina4_python/scss/tina4css/tina4.scss +17 -0
  34. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/components/crud.twig +20 -35
  35. tina4_python-0.2.200/tina4_python/Auth.py +0 -354
  36. {tina4_python-0.2.200 → tina4_python-0.2.202}/.gitignore +0 -0
  37. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Api.py +0 -0
  38. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/CRUD.py +0 -0
  39. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Constant.py +0 -0
  40. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/DatabaseResult.py +0 -0
  41. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/DatabaseTypes.py +0 -0
  42. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Debug.py +0 -0
  43. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Env.py +0 -0
  44. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/FieldTypes.py +0 -0
  45. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/HtmlElement.py +0 -0
  46. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Localization.py +0 -0
  47. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/MiddleWare.py +0 -0
  48. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Migration.py +0 -0
  49. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/ORM.py +0 -0
  50. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Queue.py +0 -0
  51. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Request.py +0 -0
  52. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Response.py +0 -0
  53. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/SQLToMongo.py +0 -0
  54. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Session.py +0 -0
  55. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/ShellColors.py +0 -0
  56. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Swagger.py +0 -0
  57. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Template.py +0 -0
  58. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Testing.py +0 -0
  59. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/WSDL.py +0 -0
  60. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Websocket.py +0 -0
  61. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/messages.pot +0 -0
  62. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/css/readme.md +0 -0
  63. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/favicon.ico +0 -0
  64. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/403.png +0 -0
  65. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/404.png +0 -0
  66. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/500.png +0 -0
  67. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/logo.png +0 -0
  68. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/readme.md +0 -0
  69. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/js/readme.md +0 -0
  70. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/js/reconnecting-websocket.js +0 -0
  71. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/js/tina4helper.js +0 -0
  72. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/swagger/index.html +0 -0
  73. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  74. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/errors/403.twig +0 -0
  75. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/errors/404.twig +0 -0
  76. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/errors/500.twig +0 -0
  77. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/readme.md +0 -0
  78. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  79. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  80. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  81. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  82. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  83. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  84. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  85. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  86. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  87. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  88. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  89. {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
@@ -1,15 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 0.2.200
3
+ Version: 0.2.202
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: asyncer>=0.0.8
8
8
  Requires-Dist: bcrypt<5.0.0,>=4.2.1
9
- Requires-Dist: cryptography<45.0.0,>=44.0.0
10
9
  Requires-Dist: hypercorn>=0.18.0
11
10
  Requires-Dist: jinja2<4.0.0,>=3.1.5
12
- Requires-Dist: jurigged>=0.6.0
13
11
  Requires-Dist: libsass<0.24.0,>=0.23.0
14
12
  Requires-Dist: litequeue<0.10,>=0.9
15
13
  Requires-Dist: pyjwt<3.0.0,>=2.10.1
@@ -17,6 +15,24 @@ Requires-Dist: python-dotenv<2.0.0,>=1.0.1
17
15
  Requires-Dist: requests>=2.32.5
18
16
  Requires-Dist: simple-websocket<2.0.0,>=1.1.0
19
17
  Requires-Dist: watchdog<7.0.0,>=6.0.0
18
+ Provides-Extra: all-db
19
+ Requires-Dist: firebird-driver>=1.10.0; extra == 'all-db'
20
+ Requires-Dist: mysql-connector-python>=9.3.0; extra == 'all-db'
21
+ Requires-Dist: psycopg2-binary>=2.9.10; extra == 'all-db'
22
+ Requires-Dist: pymongo>=4.0.0; extra == 'all-db'
23
+ Requires-Dist: pymssql>=2.3.0; extra == 'all-db'
24
+ Provides-Extra: dev-reload
25
+ Requires-Dist: jurigged>=0.6.0; extra == 'dev-reload'
26
+ Provides-Extra: firebird
27
+ Requires-Dist: firebird-driver>=1.10.0; extra == 'firebird'
28
+ Provides-Extra: mongo
29
+ Requires-Dist: pymongo>=4.0.0; extra == 'mongo'
30
+ Provides-Extra: mssql
31
+ Requires-Dist: pymssql>=2.3.0; extra == 'mssql'
32
+ Provides-Extra: mysql
33
+ Requires-Dist: mysql-connector-python>=9.3.0; extra == 'mysql'
34
+ Provides-Extra: postgres
35
+ Requires-Dist: psycopg2-binary>=2.9.10; extra == 'postgres'
20
36
  Description-Content-Type: text/markdown
21
37
 
22
38
  # Tina4 Python — This is not a framework
@@ -262,3 +278,13 @@ https://opensource.org/licenses/MIT
262
278
  ---
263
279
 
264
280
  **Tina4** — The framework that keeps out of the way of your coding.
281
+
282
+ ---
283
+
284
+ ## Our Sponsors
285
+
286
+ **Sponsored with 🩵 by Code Infinity**
287
+
288
+ [<img src="https://codeinfinity.co.za/wp-content/uploads/2025/09/c8e-logo-github.png" alt="Code Infinity" width="100">](https://codeinfinity.co.za/about-open-source-policy?utm_source=github&utm_medium=website&utm_campaign=opensource_campaign&utm_id=opensource)
289
+
290
+ *Supporting open source communities <span style="color: #1DC7DE;">•</span> Innovate <span style="color: #1DC7DE;">•</span> Code <span style="color: #1DC7DE;">•</span> Empower*
@@ -241,3 +241,13 @@ https://opensource.org/licenses/MIT
241
241
  ---
242
242
 
243
243
  **Tina4** — The framework that keeps out of the way of your coding.
244
+
245
+ ---
246
+
247
+ ## Our Sponsors
248
+
249
+ **Sponsored with 🩵 by Code Infinity**
250
+
251
+ [<img src="https://codeinfinity.co.za/wp-content/uploads/2025/09/c8e-logo-github.png" alt="Code Infinity" width="100">](https://codeinfinity.co.za/about-open-source-policy?utm_source=github&utm_medium=website&utm_campaign=opensource_campaign&utm_id=opensource)
252
+
253
+ *Supporting open source communities <span style="color: #1DC7DE;">•</span> Innovate <span style="color: #1DC7DE;">•</span> Code <span style="color: #1DC7DE;">•</span> Empower*
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "0.2.200"
3
+ version = "0.2.202"
4
4
  description = "Tina4Python - This is not another framework for Python"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
@@ -11,8 +11,7 @@ dependencies = [
11
11
  "jinja2>=3.1.5,<4.0.0",
12
12
  "libsass (>=0.23.0,<0.24.0)",
13
13
  "python-dotenv (>=1.0.1,<2.0.0)",
14
- "pyjwt (>=2.10.1,<3.0.0)",
15
- "cryptography (>=44.0.0,<45.0.0)",
14
+ "pyjwt>=2.10.1,<3.0.0",
16
15
  "watchdog (>=6.0.0,<7.0.0)",
17
16
  "bcrypt (>=4.2.1,<5.0.0)",
18
17
  "litequeue (>=0.9,<0.10)",
@@ -20,11 +19,26 @@ dependencies = [
20
19
  "asyncer>=0.0.8",
21
20
  "hypercorn>=0.18.0",
22
21
  "requests>=2.32.5",
23
- "jurigged>=0.6.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev-reload = ["jurigged>=0.6.0"]
26
+ postgres = ["psycopg2-binary>=2.9.10"]
27
+ mysql = ["mysql-connector-python>=9.3.0"]
28
+ mssql = ["pymssql>=2.3.0"]
29
+ firebird = ["firebird-driver>=1.10.0"]
30
+ mongo = ["pymongo>=4.0.0"]
31
+ all-db = [
32
+ "psycopg2-binary>=2.9.10",
33
+ "mysql-connector-python>=9.3.0",
34
+ "pymssql>=2.3.0",
35
+ "firebird-driver>=1.10.0",
36
+ "pymongo>=4.0.0",
24
37
  ]
25
38
 
26
39
  [dependency-groups]
27
40
  dev = [
41
+ "jurigged>=0.6.0",
28
42
  "flake8>=7.2.0",
29
43
  "mkdocs>=1.6.1",
30
44
  "mysql-connector-python>=9.3.0",
@@ -0,0 +1,236 @@
1
+ #
2
+ # Tina4 - This is not a 4ramework.
3
+ # Copy-right 2007 - current Tina4
4
+ # License: MIT https://opensource.org/licenses/MIT
5
+ #
6
+ # flake8: noqa: E501
7
+ """JWT authentication and password hashing for Tina4.
8
+
9
+ Provides the ``Auth`` class which handles:
10
+ - HS256 JWT token creation and validation using the ``SECRET`` env var
11
+ - Configurable token expiry (default 2 minutes, override via
12
+ ``TINA4_TOKEN_LIMIT`` environment variable)
13
+ - Password hashing and verification using bcrypt
14
+ - Payload extraction from valid or expired tokens
15
+
16
+ The framework creates a global ``tina4_python.tina4_auth`` instance at
17
+ startup. Sessions, secured routes, and middleware all delegate to this
18
+ instance for token operations.
19
+
20
+ Example::
21
+
22
+ from tina4_python import tina4_auth
23
+
24
+ token = tina4_auth.get_token({"user_id": 42})
25
+ is_valid = tina4_auth.valid(token)
26
+ payload = tina4_auth.get_payload(token)
27
+ hashed = tina4_auth.get_password("secret")
28
+ ok = tina4_auth.check_password("secret", hashed)
29
+ """
30
+
31
+ __all__ = ["Auth", "AuthJSONSerializer"]
32
+
33
+ import datetime
34
+ import os
35
+ import jwt
36
+ import bcrypt
37
+ from json import JSONEncoder
38
+
39
+
40
+ class AuthJSONSerializer(JSONEncoder):
41
+ """
42
+ Custom JSON encoder used by PyJWT when serializing payload objects.
43
+
44
+ Ensures that ``datetime.datetime`` instances are correctly converted to ISO-8601
45
+ strings so they can be embedded in JWT claims.
46
+ """
47
+
48
+ def default(self, o):
49
+ if isinstance(o, datetime.datetime):
50
+ return o.isoformat()
51
+ return super().default(o)
52
+
53
+
54
+ class Auth:
55
+ """
56
+ Authentication & authorization helper for Tina4 projects.
57
+
58
+ Handles:
59
+ - Password hashing/verification (bcrypt)
60
+ - Signing JWT tokens with HS256 (SECRET env var)
61
+ - Verifying JWT tokens with HS256
62
+ - Simple API-KEY fallback validation
63
+
64
+ Environment variables used:
65
+ - ``SECRET`` → HMAC-SHA256 signing secret for JWT
66
+ - ``API_KEY`` → optional static API key (checked before JWT)
67
+ - ``TINA4_TOKEN_LIMIT`` → default token lifetime in minutes (default: 2)
68
+ """
69
+
70
+ secret: str | None = None
71
+ root_path: str = None
72
+
73
+ # ------------------------------------------------------------------
74
+ # Password handling (bcrypt)
75
+ # ------------------------------------------------------------------
76
+ def hash_password(self, text: str) -> str:
77
+ """
78
+ Generate a bcrypt hash for the given plain-text password.
79
+
80
+ Args:
81
+ text (str): Plain-text password.
82
+
83
+ Returns:
84
+ str: bcrypt hash (as UTF-8 string). Safe to store in a database.
85
+ """
86
+ password_bytes = text.encode("utf-8")
87
+ salt = bcrypt.gensalt()
88
+ return bcrypt.hashpw(password_bytes, salt).decode("utf-8")
89
+
90
+ def check_password(self, password_hash: str, text: str) -> bool:
91
+ """
92
+ Verify a plain-text password against a previously created bcrypt hash.
93
+
94
+ Args:
95
+ password_hash (str): Hash returned by :meth:`hash_password`.
96
+ text (str): Plain-text password to verify.
97
+
98
+ Returns:
99
+ bool: ``True`` if the password matches the hash.
100
+ """
101
+ password_bytes = text.encode("utf-8")
102
+ return bcrypt.checkpw(password_bytes, password_hash.encode("utf-8"))
103
+
104
+ # ------------------------------------------------------------------
105
+ # Constructor
106
+ # ------------------------------------------------------------------
107
+ def __init__(self, root_path: str):
108
+ """
109
+ Initialise the Auth helper.
110
+
111
+ Detects and removes legacy RS256 key files from older Tina4 installs,
112
+ printing a one-time migration notice when found.
113
+
114
+ Args:
115
+ root_path (str): Absolute path to the project root.
116
+ """
117
+ from tina4_python.Debug import Debug
118
+ from tina4_python import Messages
119
+
120
+ self.root_path = root_path
121
+ self.secret = os.environ.get("SECRET", "{self.secret}")
122
+ if self.secret == "{self.secret}":
123
+ Debug.warning(Messages.MSG_AUTH_NO_SECRET)
124
+
125
+ # ------------------------------------------------------------------
126
+ # One-time RS256 → HS256 migration: remove old key files if present
127
+ # ------------------------------------------------------------------
128
+ secrets_dir = os.path.join(root_path, "secrets")
129
+ legacy_files = [
130
+ os.path.join(secrets_dir, "private.key"),
131
+ os.path.join(secrets_dir, "public.key"),
132
+ os.path.join(secrets_dir, "domain.cert"),
133
+ ]
134
+ if any(os.path.isfile(f) for f in legacy_files):
135
+ for f in legacy_files:
136
+ if os.path.isfile(f):
137
+ os.remove(f)
138
+ Debug.warning(Messages.MSG_AUTH_RS256_MIGRATION)
139
+
140
+ # ------------------------------------------------------------------
141
+ # JWT creation & verification (HS256)
142
+ # ------------------------------------------------------------------
143
+ def get_token(self, payload_data: dict, expiry_minutes: int = 0) -> str:
144
+ """
145
+ Create a signed JWT (HS256) containing the supplied payload.
146
+
147
+ If ``expires`` is not present in ``payload_data`` an expiration claim
148
+ will be added automatically using ``TINA4_TOKEN_LIMIT`` (default 2 minutes)
149
+ or the value supplied via ``expiry_minutes``.
150
+
151
+ Args:
152
+ payload_data (dict): Claims to embed in the token.
153
+ expiry_minutes (int): Override default token lifetime (0 = use env default).
154
+
155
+ Returns:
156
+ str: Signed JWT (compact serialization).
157
+ """
158
+ now = datetime.datetime.now(datetime.timezone.utc)
159
+
160
+ if "expires" not in payload_data:
161
+ token_limit_minutes = int(os.environ.get("TINA4_TOKEN_LIMIT", 2))
162
+ if expiry_minutes != 0:
163
+ token_limit_minutes = expiry_minutes
164
+ expiry_time = now + datetime.timedelta(minutes=token_limit_minutes)
165
+ payload_data["expires"] = expiry_time.isoformat()
166
+
167
+ token = jwt.encode(
168
+ payload=payload_data,
169
+ key=self.secret,
170
+ algorithm="HS256",
171
+ json_encoder=AuthJSONSerializer,
172
+ )
173
+ return token
174
+
175
+ def get_payload(self, token: str) -> dict | None:
176
+ """
177
+ Decode a JWT and return its payload (without verification of expiry).
178
+
179
+ Args:
180
+ token (str): JWT to decode.
181
+
182
+ Returns:
183
+ dict | None: Payload dictionary or ``None`` on invalid signature.
184
+ """
185
+ try:
186
+ payload = jwt.decode(
187
+ token,
188
+ key=self.secret,
189
+ algorithms=["HS256"],
190
+ options={"verify_exp": False},
191
+ )
192
+ return payload
193
+ except Exception:
194
+ return None
195
+
196
+ def validate(self, token: str) -> bool:
197
+ """
198
+ Full token validation.
199
+
200
+ Checks:
201
+ 1. Optional static ``API_KEY`` environment variable (quick bypass)
202
+ 2. HS256 signature using SECRET
203
+ 3. Presence and validity of the ``expires`` claim
204
+
205
+ Args:
206
+ token (str): Bearer token.
207
+
208
+ Returns:
209
+ bool: ``True`` if the token is valid and not expired.
210
+ """
211
+ if os.environ.get("API_KEY") and token == os.environ.get("API_KEY"):
212
+ return True
213
+
214
+ try:
215
+ payload = jwt.decode(
216
+ token,
217
+ key=self.secret,
218
+ algorithms=["HS256"],
219
+ options={"verify_exp": False},
220
+ )
221
+
222
+ if "expires" not in payload:
223
+ return False
224
+
225
+ expiry_time = datetime.datetime.fromisoformat(payload["expires"])
226
+ if expiry_time.tzinfo is None:
227
+ expiry_time = expiry_time.replace(tzinfo=datetime.timezone.utc)
228
+
229
+ return datetime.datetime.now(datetime.timezone.utc) <= expiry_time
230
+
231
+ except Exception: # noqa: BLE001
232
+ return False
233
+
234
+ def valid(self, token: str) -> bool:
235
+ """Alias of :meth:`validate`. Kept for backward compatibility."""
236
+ return self.validate(token)
@@ -116,7 +116,7 @@ Never use `style="..."` attributes in Twig templates. All styling must live in S
116
116
  ```
117
117
 
118
118
  Rules:
119
- - Use Bootstrap 5 utility classes (e.g. `mt-4`, `text-center`, `d-flex`) for spacing, alignment, and layout instead of inline styles
119
+ - Use Tina4 CSS utility classes (e.g. `mt-4`, `text-center`, `d-flex`) for spacing, alignment, and layout instead of inline styles — Tina4 CSS ships built-in, no external CDN needed
120
120
  - Use SCSS variables for colours, spacing, and font sizes — never hardcode hex values in templates
121
121
  - One SCSS file per page or component (e.g. `dashboard.scss`, `user-card.scss`)
122
122
  - Prefer semantic class names (`.product-card`, `.nav-sidebar`) over generic ones (`.box`, `.wrapper`)
@@ -230,6 +230,7 @@ Tina4 provides a full toolkit. Before writing custom code, check if the framewor
230
230
  | Form token validation | `{{ form_token() }}` in templates + built-in middleware |
231
231
  | CRUD admin interfaces | `result.to_crud(request)` or `CRUD.to_crud()` |
232
232
  | Swagger/OpenAPI docs | `@description()`, `@tags()`, `@example()` decorators |
233
+ | GraphQL API | `GraphQL` from `tina4_python.GraphQL` |
233
234
  | SOAP/WSDL services | `WSDL` class from `tina4_python.WSDL` |
234
235
  | Database migrations | `tina4 migrate:create` + `tina4 migrate` |
235
236
  | WebSockets | `Websocket` from `tina4_python.Websocket` |
@@ -467,7 +468,7 @@ Every project must have a `src/templates/base.twig`. All pages extend it.
467
468
  <meta charset="UTF-8">
468
469
  <title>{% block title %}{{ APP_NAME }}{% endblock %}</title>
469
470
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
470
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
471
+ <link rel="stylesheet" href="/css/tina4.min.css">
471
472
  <link rel="stylesheet" href="/css/default.css">
472
473
  {% block stylesheets %}{% endblock %}
473
474
  </head>
@@ -475,7 +476,7 @@ Every project must have a `src/templates/base.twig`. All pages extend it.
475
476
  {% block nav %}{% include "partials/nav.twig" ignore missing %}{% endblock %}
476
477
  {% block content %}{% endblock %}
477
478
  {% block javascripts %}
478
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
479
+ <script src="/js/tina4.js"></script>
479
480
  <script src="/js/tina4helper.js"></script>
480
481
  {% endblock %}
481
482
  </body>
@@ -570,9 +571,9 @@ async def dashboard(request, response):
570
571
  return {"title": "Dashboard", "stats": get_stats()}
571
572
  ```
572
573
 
573
- ## Frontend — Bootstrap + tina4helper.js
574
+ ## Frontend — Tina4 CSS + tina4helper.js
574
575
 
575
- The framework includes Bootstrap 5 by default and `tina4helper.js` for AJAX calls.
576
+ The framework includes Tina4 CSS (~24KB, Bootstrap-compatible class names) and `tina4helper.js` for AJAX calls. No external CDN dependencies — everything ships built-in.
576
577
 
577
578
  ### tina4helper.js functions
578
579
 
@@ -598,7 +599,7 @@ getRoute("/api/partial", function(html) {
598
599
  // Post data to a URL, render response into element
599
600
  postUrl("/api/save", {name: "Alice"}, "resultDiv");
600
601
 
601
- // Show a Bootstrap alert
602
+ // Show an alert message
602
603
  showMessage("Record saved successfully!");
603
604
  ```
604
605
 
@@ -815,6 +816,86 @@ uv run tina4 migrate:create "create products table"
815
816
  uv run tina4 migrate
816
817
  ```
817
818
 
819
+ ### How migrations work internally
820
+
821
+ - SQL files live in `migrations/` folder, named `NNNNNN_description.sql` (6-digit sequence)
822
+ - Files are executed **alphabetically** and split on the `;` delimiter
823
+ - State is tracked in the `tina4_migration` table (auto-created per engine)
824
+ - A migration only runs once — if `passed = 1` in the tracking table, it is skipped
825
+ - Failed migrations (passed = 0) are deleted and retried on the next run
826
+ - On **any** error, the migration rolls back and the process exits with `sys.exit(1)` — fix the error before re-running
827
+
828
+ ### Engine-specific DDL patterns
829
+
830
+ Each database engine has different syntax for auto-increment primary keys, column types, and DDL features. **Always use the correct syntax for your target engine:**
831
+
832
+ ```sql
833
+ -- SQLite
834
+ CREATE TABLE IF NOT EXISTS users (
835
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
836
+ name TEXT NOT NULL,
837
+ email TEXT,
838
+ age INTEGER,
839
+ active INTEGER DEFAULT 1
840
+ );
841
+
842
+ -- PostgreSQL
843
+ CREATE TABLE IF NOT EXISTS users (
844
+ id SERIAL PRIMARY KEY,
845
+ name VARCHAR(200) NOT NULL,
846
+ email VARCHAR(200),
847
+ age INTEGER,
848
+ active INTEGER DEFAULT 1
849
+ );
850
+
851
+ -- MySQL / MariaDB
852
+ CREATE TABLE IF NOT EXISTS users (
853
+ id INTEGER NOT NULL AUTO_INCREMENT,
854
+ name VARCHAR(200) NOT NULL,
855
+ email VARCHAR(200),
856
+ age INTEGER,
857
+ active INTEGER DEFAULT 1,
858
+ PRIMARY KEY(id)
859
+ );
860
+
861
+ -- MSSQL (no IF NOT EXISTS — use table_exists check or handle errors)
862
+ CREATE TABLE users (
863
+ id INTEGER IDENTITY(1,1) NOT NULL,
864
+ name VARCHAR(200) NOT NULL,
865
+ email VARCHAR(200),
866
+ age INTEGER,
867
+ active INTEGER DEFAULT 1,
868
+ PRIMARY KEY(id)
869
+ );
870
+
871
+ -- Firebird (no IF NOT EXISTS, no AUTOINCREMENT — use generators/sequences for auto-IDs)
872
+ CREATE TABLE users (
873
+ id INTEGER NOT NULL,
874
+ name VARCHAR(200) NOT NULL,
875
+ email VARCHAR(200),
876
+ age INTEGER,
877
+ active INTEGER DEFAULT 1,
878
+ PRIMARY KEY(id)
879
+ );
880
+ ```
881
+
882
+ ### Firebird idempotency
883
+
884
+ Firebird does **not** support `IF NOT EXISTS` for `ALTER TABLE ... ADD` statements. The framework handles this automatically — when running on Firebird, it checks `RDB$RELATION_FIELDS` before executing `ALTER TABLE ... ADD <column>` and skips if the column already exists. No special handling is needed in your migration SQL files.
885
+
886
+ ### Common migration mistakes to avoid
887
+
888
+ 1. **Don't use the wrong auto-increment syntax** — `AUTOINCREMENT` (SQLite), `SERIAL` (PostgreSQL), `AUTO_INCREMENT` (MySQL), `IDENTITY(1,1)` (MSSQL) are all different and not interchangeable
889
+ 2. **Don't put `BEGIN`/`COMMIT`/`ROLLBACK` in migration SQL** — the framework handles transactions automatically
890
+ 3. **Don't use `IF NOT EXISTS` on Firebird or MSSQL** — they don't support it (Firebird ALTER TABLE ADD is handled automatically; for CREATE TABLE, check `table_exists()` or handle the error)
891
+ 4. **Don't modify a migration file that has already run** — create a new migration instead
892
+ 5. **Don't mix unrelated schema changes** — one logical change per migration file
893
+ 6. **Don't use `TEXT` on Firebird** — use `VARCHAR(n)` or `BLOB SUB_TYPE TEXT` instead
894
+ 7. **Don't use `REAL`/`FLOAT` on Firebird** — use `DOUBLE PRECISION` instead
895
+ 8. **Don't forget to handle `DEFAULT` clause differences** — MSSQL puts `DEFAULT` after `NOT NULL`, Firebird puts it before
896
+ 9. **Don't create ORM models without a corresponding migration** — the schema must exist before the ORM can use it
897
+ 10. **Don't use database-specific functions** (e.g. `NOW()`, `GETDATE()`, `CURRENT_TIMESTAMP`) without checking engine compatibility
898
+
818
899
  ## Middleware
819
900
 
820
901
  Middleware methods are classified by name prefix:
@@ -1015,6 +1096,55 @@ class SecureService(WSDL):
1015
1096
  return result
1016
1097
  ```
1017
1098
 
1099
+ ## GraphQL
1100
+
1101
+ Tina4 includes a zero-dependency GraphQL engine with a recursive-descent parser, schema builder, query executor, and ORM auto-generation.
1102
+
1103
+ ```python
1104
+ from tina4_python.GraphQL import GraphQL
1105
+
1106
+ # Auto-generate from ORM
1107
+ gql = GraphQL()
1108
+ gql.schema.from_orm(User)
1109
+ gql.schema.from_orm(Product)
1110
+ gql.register_route("/graphql") # POST = queries, GET = GraphiQL IDE
1111
+ ```
1112
+
1113
+ `from_orm()` creates: type, single query (`user(id: ID)`), list query (`users(limit, offset)`), create/update/delete mutations.
1114
+
1115
+ ### Manual schema
1116
+
1117
+ ```python
1118
+ gql.schema.add_type("Widget", {"id": "ID", "name": "String", "price": "Float"})
1119
+ gql.schema.add_query("widget", {
1120
+ "type": "Widget",
1121
+ "args": {"id": "ID"},
1122
+ "resolve": lambda root, args, ctx: {"id": args["id"], "name": "Cog", "price": 5.0},
1123
+ })
1124
+ gql.schema.add_mutation("deleteWidget", {
1125
+ "type": "Boolean",
1126
+ "args": {"id": "ID!"},
1127
+ "resolve": lambda root, args, ctx: True,
1128
+ })
1129
+ ```
1130
+
1131
+ ### Programmatic usage (no HTTP)
1132
+
1133
+ ```python
1134
+ result = gql.execute('{ users(limit: 3) { id name } }', variables={}, context={})
1135
+ # {"data": {"users": [...]}}
1136
+ ```
1137
+
1138
+ Supports: queries, mutations, variables, fragments, aliases, `@skip`/`@include` directives, nested selections, list types, inline fragments. Resolver exceptions are captured as GraphQL errors.
1139
+
1140
+ | ORM Field | GraphQL Type |
1141
+ |-----------|-------------|
1142
+ | IntegerField | Int |
1143
+ | NumericField | Float |
1144
+ | StringField/TextField | String |
1145
+ | DateTimeField | String |
1146
+ | Primary key | ID |
1147
+
1018
1148
  ## Localization (i18n)
1019
1149
 
1020
1150
  Tina4 supports translations via Python's `gettext` module. Translation files live in `tina4_python/translations/`.
@@ -1218,7 +1348,7 @@ async def admin_users(request, response):
1218
1348
  ```
1219
1349
 
1220
1350
  This auto-generates:
1221
- - Searchable, paginated HTML table with Bootstrap 5
1351
+ - Searchable, paginated HTML table with Tina4 CSS
1222
1352
  - Create / Edit / Delete modals with form tokens
1223
1353
  - 4 RESTful API routes (GET list, POST create, POST update, DELETE)
1224
1354
  - Per-table Twig template in `src/templates/crud/` (customisable after generation)
@@ -99,7 +99,7 @@ class Database:
99
99
  elif params[0] == MONGODB:
100
100
  install_message = f"Please install pymongo: {MONGODB_INSTALL}"
101
101
 
102
- sys.exit(Messages.MSG_DB_DRIVER_NOT_FOUND.format(driver=params[0]) + "\n" + install_message + "\n" + str(e))
102
+ raise ImportError(Messages.MSG_DB_DRIVER_NOT_FOUND.format(driver=params[0]) + "\n" + install_message + "\n" + str(e))
103
103
 
104
104
 
105
105
  self.database_engine = params[0]
@@ -40,6 +40,10 @@ import time
40
40
  from watchdog.observers import Observer
41
41
  from watchdog.events import PatternMatchingEventHandler, FileSystemEvent
42
42
 
43
+ import importlib
44
+ import sys
45
+ from pathlib import Path
46
+
43
47
  from tina4_python.Debug import Debug
44
48
 
45
49
  # Global set of asyncio.Queue objects — one per connected browser tab
@@ -112,6 +116,67 @@ def _debounced_notify(change_type: str, delay: float = 0.1):
112
116
  _debounce_timer.start()
113
117
 
114
118
 
119
+ def _reload_python_module(file_path: str):
120
+ """Re-import a Python module when its source file changes.
121
+
122
+ For files inside ``src/routes/``, ``src/orm/``, or ``src/app/``,
123
+ this clears any routes previously registered by the module, then
124
+ reloads it so that decorators (``@get``, ``@post``, etc.) re-run
125
+ and re-register in ``tina4_routes``.
126
+
127
+ New route files are imported for the first time; deleted files have
128
+ their routes cleaned up.
129
+
130
+ Args:
131
+ file_path: Absolute path to the changed ``.py`` file.
132
+ """
133
+ import tina4_python
134
+
135
+ # Only reload files under src/ (routes, orm, app)
136
+ try:
137
+ rel = Path(file_path).relative_to(Path.cwd())
138
+ except ValueError:
139
+ return # Outside project directory
140
+
141
+ parts = rel.parts
142
+ if len(parts) < 2 or parts[0] != "src":
143
+ return
144
+
145
+ # Skip __init__.py, __pycache__, private files
146
+ if any(p.startswith("_") for p in parts):
147
+ return
148
+
149
+ # Skip non-code directories (templates, scss, public)
150
+ if parts[1] in ("templates", "scss", "public"):
151
+ return
152
+
153
+ # Build dotted module name: src/routes/users.py → src.routes.users
154
+ module_name = ".".join(rel.with_suffix("").parts)
155
+
156
+ # Remove routes previously registered by callbacks defined in this module
157
+ old_module = sys.modules.get(module_name)
158
+ if old_module is not None:
159
+ callbacks_to_remove = []
160
+ for callback in list(tina4_python.tina4_routes.keys()):
161
+ cb_module = getattr(callback, "__module__", None)
162
+ if cb_module == module_name:
163
+ callbacks_to_remove.append(callback)
164
+ for cb in callbacks_to_remove:
165
+ del tina4_python.tina4_routes[cb]
166
+ Debug.debug(f"[DevReload] Removed route for {cb.__name__} from {module_name}")
167
+
168
+ # (Re-)import the module — decorators will re-register routes
169
+ try:
170
+ if module_name in sys.modules:
171
+ importlib.reload(sys.modules[module_name])
172
+ Debug.info(f"[DevReload] Reloaded module: {module_name}")
173
+ elif os.path.isfile(file_path):
174
+ importlib.import_module(module_name)
175
+ Debug.info(f"[DevReload] Loaded new module: {module_name}")
176
+ except Exception as e:
177
+ Debug.error(f"[DevReload] Failed to reload {module_name}: {e}")
178
+
179
+
115
180
  class DevFileWatcher(PatternMatchingEventHandler):
116
181
  """Watchdog handler that triggers live-reload on file changes.
117
182
 
@@ -152,6 +217,11 @@ class DevFileWatcher(PatternMatchingEventHandler):
152
217
  except Exception as e:
153
218
  Debug.error(f"[DevReload] SCSS compile error: {e}")
154
219
  _debounced_notify("css-reload")
220
+ elif ext == ".py":
221
+ # Hot-reload Python routes/orm/app modules so new or changed
222
+ # decorators (@get, @post, etc.) re-register in tina4_routes
223
+ _reload_python_module(path)
224
+ _debounced_notify("reload")
155
225
  else:
156
226
  _debounced_notify("reload")
157
227