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.
- {tina4_python-0.2.200 → tina4_python-0.2.202}/PKG-INFO +29 -3
- {tina4_python-0.2.200 → tina4_python-0.2.202}/README.md +10 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/pyproject.toml +18 -4
- tina4_python-0.2.202/tina4_python/Auth.py +236 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/CLAUDE.md +137 -7
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Database.py +1 -1
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/DevReload.py +70 -0
- tina4_python-0.2.202/tina4_python/GraphQL.py +894 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Messages.py +1 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Router.py +5 -1
- tina4_python-0.2.202/tina4_python/Seeder.py +864 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Webserver.py +10 -1
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/__init__.py +8 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/cli.py +127 -0
- tina4_python-0.2.202/tina4_python/public/css/tina4.css +2463 -0
- tina4_python-0.2.202/tina4_python/public/css/tina4.min.css +1 -0
- tina4_python-0.2.202/tina4_python/public/js/tina4.js +134 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_alerts.scss +34 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_badges.scss +22 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_buttons.scss +69 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_cards.scss +49 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_forms.scss +156 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_grid.scss +81 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_modals.scss +84 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_nav.scss +149 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_reset.scss +94 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_tables.scss +54 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_typography.scss +55 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_utilities.scss +197 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/_variables.scss +117 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/base.scss +1 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/colors.scss +48 -0
- tina4_python-0.2.202/tina4_python/scss/tina4css/tina4.scss +17 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/components/crud.twig +20 -35
- tina4_python-0.2.200/tina4_python/Auth.py +0 -354
- {tina4_python-0.2.200 → tina4_python-0.2.202}/.gitignore +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Api.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/CRUD.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Constant.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/DatabaseResult.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/DatabaseTypes.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Debug.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Env.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/FieldTypes.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Localization.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/MiddleWare.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Migration.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/ORM.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Queue.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Request.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Response.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/SQLToMongo.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Session.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/ShellColors.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Swagger.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Template.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Testing.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/WSDL.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/Websocket.py +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/messages.pot +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/css/readme.md +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/403.png +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/404.png +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/500.png +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/logo.png +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/images/readme.md +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/js/readme.md +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/js/reconnecting-websocket.js +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/js/tina4helper.js +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/templates/readme.md +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.200 → tina4_python-0.2.202}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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="
|
|
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="
|
|
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 —
|
|
574
|
+
## Frontend — Tina4 CSS + tina4helper.js
|
|
574
575
|
|
|
575
|
-
The framework includes Bootstrap
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|