tina4-python 0.2.172__tar.gz → 0.2.173__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 (57) hide show
  1. {tina4_python-0.2.172 → tina4_python-0.2.173}/PKG-INFO +1 -1
  2. {tina4_python-0.2.172 → tina4_python-0.2.173}/pyproject.toml +1 -1
  3. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Auth.py +3 -0
  4. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Database.py +14 -17
  5. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/ORM.py +24 -8
  6. tina4_python-0.2.173/tina4_python/Request.py +27 -0
  7. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Response.py +35 -53
  8. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Router.py +146 -140
  9. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Session.py +6 -2
  10. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/__init__.py +6 -2
  11. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/cli.py +9 -2
  12. tina4_python-0.2.172/tina4_python/Request.py +0 -21
  13. {tina4_python-0.2.172 → tina4_python-0.2.173}/.gitignore +0 -0
  14. {tina4_python-0.2.172 → tina4_python-0.2.173}/README.md +0 -0
  15. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Api.py +0 -0
  16. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/CRUD.py +0 -0
  17. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Constant.py +0 -0
  18. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/DatabaseResult.py +0 -0
  19. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/DatabaseTypes.py +0 -0
  20. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Debug.py +0 -0
  21. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Env.py +0 -0
  22. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/FieldTypes.py +0 -0
  23. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/HtmlElement.py +0 -0
  24. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Localization.py +0 -0
  25. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Messages.py +0 -0
  26. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/MiddleWare.py +0 -0
  27. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Migration.py +0 -0
  28. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Queue.py +0 -0
  29. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/ShellColors.py +0 -0
  30. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Swagger.py +0 -0
  31. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Template.py +0 -0
  32. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Testing.py +0 -0
  33. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/WSDL.py +0 -0
  34. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Webserver.py +0 -0
  35. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/Websocket.py +0 -0
  36. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/messages.pot +0 -0
  37. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/css/readme.md +0 -0
  38. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/favicon.ico +0 -0
  39. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/images/403.png +0 -0
  40. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/images/404.png +0 -0
  41. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/images/500.png +0 -0
  42. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/images/logo.png +0 -0
  43. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/images/readme.md +0 -0
  44. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/js/readme.md +0 -0
  45. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/js/reconnecting-websocket.js +0 -0
  46. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/js/tina4helper.js +0 -0
  47. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/swagger/index.html +0 -0
  48. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  49. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/templates/components/crud.twig +0 -0
  50. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/templates/errors/403.twig +0 -0
  51. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/templates/errors/404.twig +0 -0
  52. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/templates/errors/500.twig +0 -0
  53. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/templates/readme.md +0 -0
  54. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  55. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  56. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  57. {tina4_python-0.2.172 → tina4_python-0.2.173}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 0.2.172
3
+ Version: 0.2.173
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "0.2.172"
3
+ version = "0.2.173"
4
4
  description = "Tina4Python - This is not another framework for Python"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
@@ -152,6 +152,9 @@ class Auth:
152
152
  """
153
153
  self.root_path = root_path
154
154
  self.secret = os.environ.get("SECRET", "{self.secret}")
155
+ if self.secret == "{self.secret}":
156
+ from tina4_python.Debug import Debug
157
+ Debug.warning("No SECRET env var set - using default secret. Set SECRET in your .env for production.")
155
158
  self.private_key = os.path.join(root_path, "secrets", "private.key")
156
159
  self.public_key = os.path.join(root_path, "secrets", "public.key")
157
160
  self.self_signed = os.path.join(root_path, "secrets", "domain.cert")
@@ -34,7 +34,7 @@ class Database:
34
34
  if _username == "":
35
35
  _username = os.environ.get("DATABASE_USERNAME", "")
36
36
  if _password == "":
37
- _password = os.environ.get("DATABASE_USERNAME", "")
37
+ _password = os.environ.get("DATABASE_PASSWORD", "")
38
38
 
39
39
  if _connection_string is None:
40
40
  raise Exception("Database connection string is missing, try declaring DATABASE_PATH in the .env file.")
@@ -270,7 +270,7 @@ class Database:
270
270
  """
271
271
  if params is None:
272
272
  params = []
273
- if params is list:
273
+ if isinstance(params, list):
274
274
  params = params.copy()
275
275
  else:
276
276
  params = list(params)
@@ -298,8 +298,8 @@ class Database:
298
298
  cols.append(name)
299
299
 
300
300
  if cols:
301
- if self.database_engine == "POSTGRES":
302
- like_op = "LIKE"
301
+ if self.database_engine == POSTGRES:
302
+ like_op = "ILIKE"
303
303
  else:
304
304
  like_op = "LIKE"
305
305
 
@@ -371,7 +371,7 @@ class Database:
371
371
  finally:
372
372
  cursor.close()
373
373
 
374
- def fetch_one(self, sql, params=[], skip=0):
374
+ def fetch_one(self, sql, params=None, skip=0):
375
375
  """
376
376
  Fetch a single record based on a SQL statement, take note that BLOB and byte record data is converted into base64 automatically
377
377
  :param str sql: A plain SQL statement or one with params in it designated by ?
@@ -379,6 +379,8 @@ class Database:
379
379
  :param int skip: Offset of records to skip
380
380
  :return: dict : A dictionary containing the single record
381
381
  """
382
+ if params is None:
383
+ params = []
382
384
  # Calling the fetch method with limit as 1 and returning the result
383
385
  record = self.fetch(sql, params=params, limit=1, skip=skip)
384
386
  if record.error is None and record.count == 1:
@@ -386,15 +388,14 @@ class Database:
386
388
  for key in record.records[0]:
387
389
  if isinstance(record.records[0][key], Decimal):
388
390
  data[key] = float(record.records[0][key])
389
- if isinstance(record.records[0][key], (datetime.date, datetime.datetime)):
391
+ elif isinstance(record.records[0][key], (datetime.date, datetime.datetime)):
390
392
  data[key] = record.records[0][key].isoformat()
391
- if isinstance(record.records[0][key], bytes):
393
+ elif isinstance(record.records[0][key], bytes):
392
394
  data[key] = base64.b64encode(record.records[0][key]).decode('utf-8')
395
+ elif isinstance(record.records[0][key], str) and self.is_json(record.records[0][key]):
396
+ data[key] = json.loads(record.records[0][key])
393
397
  else:
394
- if isinstance(record.records[0][key], str) and self.is_json(record.records[0][key]):
395
- data[key] = json.loads(record.records[0][key])
396
- else:
397
- data[key] = record.records[0][key]
398
+ data[key] = record.records[0][key]
398
399
  return data
399
400
  else:
400
401
  return None
@@ -562,8 +563,8 @@ class Database:
562
563
 
563
564
  result = None
564
565
  for record in data:
565
- columns = ", ".join(data[0].keys())
566
- placeholders = ", ".join(['?'] * len(data[0]))
566
+ columns = ", ".join(record.keys())
567
+ placeholders = ", ".join(['?'] * len(record))
567
568
 
568
569
  # Determine if we need to auto-generate the primary key
569
570
  need_auto_id = (
@@ -687,11 +688,7 @@ class Database:
687
688
 
688
689
  params = set_values + [pk_value]
689
690
 
690
- if self.database_engine == MSSQL:
691
- self.execute(f"SET IDENTITY_UPDATE {table_name} ON")
692
691
  result = self.execute(sql, params)
693
- if self.database_engine == MSSQL:
694
- self.execute(f"SET IDENTITY_UPDATE {table_name} OFF")
695
692
  if result.error is not None:
696
693
  break
697
694
 
@@ -4,6 +4,7 @@
4
4
  # License: MIT https://opensource.org/licenses/MIT
5
5
  #
6
6
  # flake8: noqa: E501
7
+ import ast
7
8
  import base64
8
9
  from datetime import date
9
10
  import json
@@ -17,6 +18,7 @@ def find_all_sub_classes(a_class):
17
18
 
18
19
 
19
20
  def orm(dba):
21
+ import importlib
20
22
  from tina4_python import root_path
21
23
  Debug("Initializing ORM")
22
24
  orm_path = root_path + os.sep + "src" + os.sep + "orm"
@@ -32,7 +34,9 @@ def orm(dba):
32
34
  # import and set the database object
33
35
  try:
34
36
  Debug('from src.orm.' + mod_name + ' import ' + mod_name)
35
- exec('from src.orm.' + mod_name + ' import ' + mod_name + "\n" + mod_name + ".__dba__ = dba")
37
+ module = importlib.import_module('src.orm.' + mod_name)
38
+ orm_class = getattr(module, mod_name)
39
+ orm_class.__dba__ = dba
36
40
  except Exception as e:
37
41
  Debug("Failed to import " + mod_name, str(e))
38
42
  classes = find_all_sub_classes(ORM)
@@ -263,7 +267,7 @@ class ORM:
263
267
  if order_by: sql += "\norder by " + ", ".join(order_by)
264
268
  return sql
265
269
 
266
- def fetch_one(self, column_names="*", filter="", params=[], join="", group_by="", having="", order_by=""):
270
+ def fetch_one(self, column_names="*", filter="", params=None, join="", group_by="", having="", order_by=""):
267
271
  """
268
272
  Fetch one record from the database
269
273
  :param column_names:
@@ -275,10 +279,12 @@ class ORM:
275
279
  :param order_by:
276
280
  :return:
277
281
  """
282
+ if params is None:
283
+ params = []
278
284
  sql = self.__build_sql(column_names, join, filter, group_by, having, order_by)
279
285
  return self.__dba__.fetch_one(sql, params=params)
280
286
 
281
- def fetch(self, column_names="*", filter="", params=[], join="", group_by="", having="", order_by="", limit=10,
287
+ def fetch(self, column_names="*", filter="", params=None, join="", group_by="", having="", order_by="", limit=10,
282
288
  skip=0):
283
289
  """
284
290
  Fetch multiple records from the database
@@ -293,20 +299,24 @@ class ORM:
293
299
  :param skip:
294
300
  :return:
295
301
  """
302
+ if params is None:
303
+ params = []
296
304
  sql = self.__build_sql(column_names, join, filter, group_by, having, order_by)
297
305
  return self.__dba__.fetch(sql, params=params, limit=limit, skip=skip)
298
306
 
299
- def select(self, column_names="*", filter="", params=[], join="", group_by="", having="", order_by="", limit=10,
307
+ def select(self, column_names="*", filter="", params=None, join="", group_by="", having="", order_by="", limit=10,
300
308
  skip=0):
301
309
  return self.fetch(column_names, filter, params, join, group_by, having, order_by, limit, skip)
302
310
 
303
- def load(self, query="", params=[]):
311
+ def load(self, query="", params=None):
304
312
  """
305
313
  Loads a single record into the object based on the primary key or query if query is set
306
314
  :param query:
307
315
  :param params:
308
316
  :return:
309
317
  """
318
+ if params is None:
319
+ params = []
310
320
  if not self.__table_exists:
311
321
  Debug("ORM: Load Error - Table", self.__table_name__, "does not exist", TINA4_LOG_ERROR)
312
322
  return False
@@ -315,11 +325,13 @@ class ORM:
315
325
  sql = f"select * from {self.__table_name__} where "
316
326
  primary_keys = self.__get_primary_keys()
317
327
  values = self.to_dict()
328
+ params = []
318
329
  counter = 0
319
330
  for key in primary_keys:
320
331
  if counter > 0:
321
332
  sql += " and "
322
- sql += key + " = '" + str(values[key]) + "'"
333
+ sql += key + " = ?"
334
+ params.append(values[key])
323
335
  counter += 1
324
336
  else:
325
337
  sql = f"select * from {self.__table_name__} where {query}"
@@ -396,7 +408,9 @@ class ORM:
396
408
 
397
409
  return result
398
410
 
399
- def delete(self, query="", params=[]):
411
+ def delete(self, query="", params=None):
412
+ if params is None:
413
+ params = []
400
414
  if not self.__table_exists:
401
415
  Debug("ORM: Load Error - Table", self.__table_name__, "does not exist", TINA4_LOG_ERROR)
402
416
  return False
@@ -404,11 +418,13 @@ class ORM:
404
418
  sql = f"delete from {self.__table_name__} where "
405
419
  primary_keys = self.__get_primary_keys()
406
420
  values = self.to_dict()
421
+ params = []
407
422
  counter = 0
408
423
  for key in primary_keys:
409
424
  if counter > 0:
410
425
  sql += " and "
411
- sql += key + " = '" + str(values[key]) + "'"
426
+ sql += key + " = ?"
427
+ params.append(values[key])
412
428
  counter += 1
413
429
  else:
414
430
  sql = f"delete from {self.__table_name__} where {query}"
@@ -0,0 +1,27 @@
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
+
8
+
9
+ class Request:
10
+ """Per-request object holding all request data. Instantiated fresh for each incoming request."""
11
+
12
+ def __init__(self):
13
+ self.request = None
14
+ self.body = None
15
+ self.params = {}
16
+ self.headers = {}
17
+ self.cookies = {}
18
+ self.url = None
19
+ self.session = None
20
+ self.files = {}
21
+ self.raw_request = None
22
+ self.raw_data = None
23
+ self.raw_content = None
24
+ self.asgi_scope = None
25
+ self.asgi_reader = None
26
+ self.asgi_writer = None
27
+ self.asgi_response = None
@@ -7,6 +7,7 @@
7
7
  import os
8
8
  import json
9
9
  import inspect
10
+ import contextvars
10
11
  from datetime import datetime, date
11
12
  from types import ModuleType
12
13
  from tina4_python import Constant
@@ -14,10 +15,9 @@ from tina4_python import DatabaseResult
14
15
  from tina4_python.ORM import ORM
15
16
  from tina4_python.Template import Template
16
17
 
17
- headers = {}
18
- content = ""
19
- http_code = Constant.HTTP_OK
20
- content_type = Constant.TEXT_HTML
18
+ # Per-coroutine header accumulation for add_header() calls before Response creation
19
+ _pending_headers = contextvars.ContextVar('_pending_headers', default=None)
20
+
21
21
 
22
22
  class Response:
23
23
 
@@ -32,12 +32,14 @@ class Response:
32
32
  else:
33
33
  return obj
34
34
 
35
+ @staticmethod
36
+ def reset_context():
37
+ """Reset per-request response state. Called by Router before each request."""
38
+ _pending_headers.set({})
39
+
35
40
  def __init__(self, content_in=None, http_code_in=None, content_type_in=None,
36
41
  headers_in=None):
37
- global headers
38
- global content
39
- global http_code
40
- global content_type
42
+ content_type = content_type_in if content_type_in is not None else Constant.TEXT_HTML
41
43
 
42
44
  if http_code_in is None:
43
45
  http_code_in = Constant.HTTP_OK
@@ -72,17 +74,19 @@ class Response:
72
74
  content_in = json.dumps({"error": "Cannot decode object of type " + str(type(content_in))})
73
75
  content_type = Constant.APPLICATION_JSON
74
76
 
75
- if content is not None and isinstance(content_in, str) and http_code_in == Constant.HTTP_OK:
76
- content_in = content + content_in
77
+ # Merge any headers added via add_header() before this Response was created
78
+ pending = _pending_headers.get()
79
+ if headers_in is not None:
80
+ merged_headers = dict(headers_in)
81
+ elif pending:
82
+ merged_headers = dict(pending)
83
+ else:
84
+ merged_headers = {}
77
85
 
78
- self.headers = headers_in if headers_in is not None else headers
79
- self.content = content_in if content_in is not None else content
80
- self.http_code = http_code_in if http_code_in is not None else http_code
81
- self.content_type = content_type_in if content_type_in is not None else content_type
82
- headers = self.headers
83
- http_code = self.http_code
84
- content_type = self.content_type
85
- content = self.content
86
+ self.headers = merged_headers
87
+ self.content = content_in if content_in is not None else ""
88
+ self.http_code = http_code_in
89
+ self.content_type = content_type
86
90
 
87
91
  @staticmethod
88
92
  def redirect(redirect_url, http_code_in=Constant.HTTP_REDIRECT):
@@ -92,25 +96,12 @@ class Response:
92
96
  :param redirect_url:
93
97
  :return:
94
98
  """
95
- global headers
96
- global content
97
- global http_code
98
- global content_type
99
- headers = {}
100
- http_code = http_code_in
101
- headers["Location"] = redirect_url
102
- content = "Redirecting..."
103
- content_type = Constant.TEXT_HTML
104
- return Response("Redirecting...", http_code, content_type, headers)
105
-
99
+ headers = {"Location": redirect_url}
100
+ return Response("Redirecting...", http_code_in, Constant.TEXT_HTML, headers)
106
101
 
107
102
  @staticmethod
108
103
  def render(template_name, data=None):
109
- global content, content_type, http_code
110
- http_code = Constant.HTTP_OK
111
- content_type = Constant.TEXT_HTML
112
-
113
- return Response(Template.render(template_name, data=data), http_code, content_type)
104
+ return Response(Template.render(template_name, data=data), Constant.HTTP_OK, Constant.TEXT_HTML)
114
105
 
115
106
  @staticmethod
116
107
  def file(file_path: str, root_path: str = "src/public"):
@@ -124,24 +115,16 @@ class Response:
124
115
  Returns:
125
116
  Response: A properly configured Response object with file content and correct MIME type
126
117
  """
127
- global content, content_type, http_code
128
-
129
118
  # Resolve full path and prevent directory traversal
130
119
  full_path = os.path.abspath(os.path.join(root_path, file_path.lstrip("/")))
131
120
 
132
121
  # Security: ensure the requested file is inside the root_path
133
122
  if not full_path.startswith(os.path.abspath(root_path)):
134
- http_code = Constant.HTTP_FORBIDDEN
135
- content_type = Constant.TEXT_PLAIN
136
- content = "403 - Forbidden"
137
- return Response(content, http_code, content_type)
123
+ return Response("403 - Forbidden", Constant.HTTP_FORBIDDEN, Constant.TEXT_PLAIN)
138
124
 
139
125
  # Check if file exists
140
126
  if not os.path.isfile(full_path):
141
- http_code = Constant.HTTP_NOT_FOUND
142
- content_type = Constant.TEXT_PLAIN
143
- content = "404 - File Not Found"
144
- return Response(content, http_code, content_type)
127
+ return Response("404 - File Not Found", Constant.HTTP_NOT_FOUND, Constant.TEXT_PLAIN)
145
128
 
146
129
  # Determine MIME type
147
130
  extension = os.path.splitext(file_path)[1].lower()
@@ -173,24 +156,23 @@ class Response:
173
156
  with open(full_path, "rb") as f:
174
157
  content = f.read()
175
158
  except Exception as e:
176
- http_code = Constant.HTTP_BAD_REQUEST
177
- content_type = Constant.TEXT_PLAIN
178
- content = f"Error reading file: {str(e)}"
179
- return Response(content, http_code, content_type)
159
+ return Response(f"Error reading file: {str(e)}", Constant.HTTP_BAD_REQUEST, Constant.TEXT_PLAIN)
180
160
 
181
- http_code = Constant.HTTP_OK
182
- return Response(content, http_code, content_type)
161
+ return Response(content, Constant.HTTP_OK, content_type)
183
162
 
184
163
  @staticmethod
185
164
  def add_header(key, value):
186
165
  """
187
- Adds a header for the response
166
+ Adds a header for the response (concurrency-safe via contextvars)
188
167
  :param key:
189
168
  :param value:
190
169
  :return:
191
170
  """
192
- global headers
193
- headers[key] = value
171
+ h = _pending_headers.get()
172
+ if h is None:
173
+ h = {}
174
+ _pending_headers.set(h)
175
+ h[key] = value
194
176
 
195
177
  @staticmethod
196
178
  def wsdl(wsdl_instance):
@@ -38,7 +38,14 @@ class Router:
38
38
 
39
39
  @staticmethod
40
40
  def clean_url(url):
41
- """Normalize URL: strip query, domain, collapse slashes, trim whitespace"""
41
+ """Collapse double slashes in a URL path."""
42
+ if not url:
43
+ return "/"
44
+ return re.sub(r'/+', '/', url)
45
+
46
+ @staticmethod
47
+ def _normalize_url(url):
48
+ """Full URL normalization: strip query, domain, collapse slashes, add trailing slash."""
42
49
  if not url:
43
50
  return "/"
44
51
  url = url.split('?')[0]
@@ -54,7 +61,7 @@ class Router:
54
61
  def get_variables(url, route_path):
55
62
  """Legacy helper - extracts variables with type conversion"""
56
63
  variables = {}
57
- url_path = Router.clean_url(url).rstrip('/')
64
+ url_path = Router._normalize_url(url).rstrip('/')
58
65
  url_segments = [s for s in url_path.strip('/').split('/') if s]
59
66
  route_segments = [s for s in route_path.strip('/').split('/') if s]
60
67
 
@@ -188,18 +195,17 @@ class Router:
188
195
  # Renders the URL and returns the content
189
196
  @staticmethod
190
197
  async def get_result(url, method, request, headers, session):
191
- from tina4_python import Request
192
- from tina4_python import Response
198
+ from tina4_python.Request import Request
199
+ from tina4_python.Response import Response
200
+
201
+ # Reset per-request response context (clears pending headers from add_header)
202
+ Response.reset_context()
193
203
 
194
- Response.headers = {}
195
- Response.content = ""
196
- Response.http_code = Constant.HTTP_NOT_FOUND
197
- Response.content_type = Constant.TEXT_HTML
198
- result = Response
204
+ result = None
205
+ route_matched = False
199
206
 
200
207
  Debug.debug("Root Path " + tina4_python.root_path + " " + url, method)
201
- tina4_python.tina4_current_request["url"] = url
202
- tina4_python.tina4_current_request["headers"] = headers
208
+ tina4_python.tina4_current_request = {"url": url, "headers": headers}
203
209
 
204
210
  validated = False
205
211
  # we can add other methods later but right now we validate gets, posts and other risky methods
@@ -245,9 +251,8 @@ class Router:
245
251
  if os.path.isfile(static_file):
246
252
  mime_type = mimetypes.guess_type(url)[0]
247
253
  with open(static_file, 'rb') as file:
248
- return Response.Response(file.read(), Constant.HTTP_OK, mime_type)
254
+ return Response(file.read(), Constant.HTTP_OK, mime_type)
249
255
 
250
- old_stdout = None
251
256
  buffer = io.StringIO()
252
257
  for route in tina4_python.tina4_routes.values():
253
258
  if "methods" not in route or method not in route["methods"]:
@@ -255,33 +260,37 @@ class Router:
255
260
 
256
261
  Debug.debug(method, "Matching route ", route['routes'], " to ", url)
257
262
  if Router.match(url, route['routes']):
263
+ route_matched = True
264
+ # Snapshot variables immediately to prevent race between concurrent requests
265
+ matched_vars = dict(Router.variables)
258
266
 
259
- if "noauth" not in route or "noauth" in route and not route["noauth"] and not validated:
267
+ if not route.get("noauth", False):
260
268
  if not validated and Router.requires_auth(route, method, validated):
261
- return Response.Response("Forbidden - Access denied", Constant.HTTP_FORBIDDEN,
269
+ return Response("Forbidden - Access denied", Constant.HTTP_FORBIDDEN,
262
270
  Constant.TEXT_HTML)
263
271
 
264
272
  router_response = route["callback"]
265
273
 
266
- # Add the inline variables & construct a Request variable
267
- request["params"].update(Router.variables)
268
-
269
- Request.request = request # Add the request object
270
- Request.headers = headers # Add the headers
271
- Request.params = request["params"]
272
- Request.body = request["body"] if "body" in request else None
273
- Request.files = request["files"] if "files" in request else None
274
- Request.session = session
275
- Request.raw_data = request["raw_data"] if "raw_data" in request else None
276
- Request.raw_request = request["raw_request"] if "raw_request" in request else None
277
- Request.raw_content = request["raw_content"] if "raw_content" in request else None
278
- Request.url = url
279
- Request.asgi_scope = request["asgi_scope"] if "asgi_scope" in request else None
280
- Request.asgi_reader = request["asgi_reader"] if "asgi_reader" in request else None
281
- Request.asgi_writer = request["asgi_writer"] if "asgi_writer" in request else None
282
- Request.asgi_response = request["asgi_response"] if "asgi_response" in request else None
283
-
284
- tina4_python.tina4_current_request = Request
274
+ # Add the inline variables & construct a per-request Request object
275
+ request["params"].update(matched_vars)
276
+
277
+ req = Request()
278
+ req.request = request
279
+ req.headers = headers
280
+ req.params = request["params"]
281
+ req.body = request.get("body")
282
+ req.files = request.get("files")
283
+ req.session = session
284
+ req.raw_data = request.get("raw_data")
285
+ req.raw_request = request.get("raw_request")
286
+ req.raw_content = request.get("raw_content")
287
+ req.url = url
288
+ req.asgi_scope = request.get("asgi_scope")
289
+ req.asgi_reader = request.get("asgi_reader")
290
+ req.asgi_writer = request.get("asgi_writer")
291
+ req.asgi_response = request.get("asgi_response")
292
+
293
+ tina4_python.tina4_current_request = req
285
294
 
286
295
  old_stdout = sys.stdout # Memorize the default stdout stream
287
296
  sys.stdout = buffer = io.StringIO()
@@ -289,112 +298,112 @@ class Router:
289
298
  Constant.HTTP_FORBIDDEN, Constant.HTTP_BAD_REQUEST, Constant.HTTP_UNAUTHORIZED,
290
299
  Constant.HTTP_SERVER_ERROR)
291
300
 
292
- if "middleware" in route:
293
- middleware_runner = MiddleWare(route["middleware"]["class"])
294
-
295
- if "methods" in route["middleware"] and route["middleware"]["methods"] is not None and len(
296
- route["middleware"]["methods"]) > 0:
297
- for method in route["middleware"]["methods"]:
298
- Request, result = middleware_runner.call_direct_method(Request, result, method)
299
- if result.http_code in error_set:
300
- return Response.Response(result.content, result.http_code, result.content_type)
301
- else:
302
- Request, result = await middleware_runner.call_before_methods(Request, result)
303
- if result.http_code in error_set:
304
- return Response.Response(result.content, result.http_code, result.content_type)
305
- Request, result = await middleware_runner.call_any_methods(Request, result)
306
- if result.http_code in error_set:
307
- return Response.Response(result.content, result.http_code, result.content_type)
308
-
309
301
  try:
310
- sig = inspect.signature(router_response)
311
- kwargs = {}
312
-
313
- for param_name, param in sig.parameters.items():
314
- if param_name in Router.variables:
315
- value = Router.variables[param_name]
316
- if param.annotation != inspect.Parameter.empty and callable(param.annotation):
317
- try:
318
- value = param.annotation(value)
319
- except ValueError:
320
- raise ValueError(
321
- f"Invalid type for path param '{param_name}': expected {param.annotation.__name__}, got '{Router.variables[param_name]}'")
322
- kwargs[param_name] = value
323
- elif param_name == 'request':
324
- kwargs[param_name] = Request
325
- elif param_name == 'response':
326
- kwargs[param_name] = Response.Response
327
- elif param.default == inspect.Parameter.empty:
328
- raise TypeError(f"Missing required parameter: {param_name}")
329
-
330
- result = await router_response(**kwargs)
331
- except Exception as e:
332
- error_msg = tina4_python.global_exception_handler(e)
333
- tina4_python.container_broken(error_msg)
334
- if Constant.TINA4_LOG_DEBUG in os.getenv(
335
- "TINA4_DEBUG_LEVEL") or Constant.TINA4_LOG_ALL in os.getenv("TINA4_DEBUG_LEVEL"):
336
- html = Template.render_twig_template("errors/500.twig",
337
- {"server": {"url": url}, "error_message": error_msg})
338
- return Response.Response(html, Constant.HTTP_SERVER_ERROR, Constant.TEXT_HTML)
339
- else:
340
- return Response.Response(error_msg, Constant.HTTP_SERVER_ERROR, Constant.TEXT_HTML)
341
-
342
- # we have found a result ... make sure we reflect this if the user didn't actually put the correct http response code in
343
- if result is not None:
344
- if result.http_code == Constant.HTTP_NOT_FOUND:
345
- result.http_code = Constant.HTTP_OK
346
-
347
- if "middleware" in route:
348
- middleware_runner = MiddleWare(route["middleware"]["class"])
302
+ if "middleware" in route:
303
+ middleware_runner = MiddleWare(route["middleware"]["class"])
304
+ # Pre-route middleware needs a response to pass; create a default
305
+ mw_response = Response("", Constant.HTTP_OK, Constant.TEXT_HTML)
306
+
307
+ if "methods" in route["middleware"] and route["middleware"]["methods"] is not None and len(
308
+ route["middleware"]["methods"]) > 0:
309
+ for mw_method in route["middleware"]["methods"]:
310
+ req, mw_response = middleware_runner.call_direct_method(req, mw_response, mw_method)
311
+ if mw_response.http_code in error_set:
312
+ return Response(mw_response.content, mw_response.http_code, mw_response.content_type)
313
+ else:
314
+ req, mw_response = await middleware_runner.call_before_methods(req, mw_response)
315
+ if mw_response.http_code in error_set:
316
+ return Response(mw_response.content, mw_response.http_code, mw_response.content_type)
317
+ req, mw_response = await middleware_runner.call_any_methods(req, mw_response)
318
+ if mw_response.http_code in error_set:
319
+ return Response(mw_response.content, mw_response.http_code, mw_response.content_type)
320
+
321
+ try:
322
+ sig = inspect.signature(router_response)
323
+ kwargs = {}
324
+
325
+ for param_name, param in sig.parameters.items():
326
+ if param_name in matched_vars:
327
+ value = matched_vars[param_name]
328
+ if param.annotation != inspect.Parameter.empty and callable(param.annotation):
329
+ try:
330
+ value = param.annotation(value)
331
+ except ValueError:
332
+ raise ValueError(
333
+ f"Invalid type for path param '{param_name}': expected {param.annotation.__name__}, got '{matched_vars[param_name]}'")
334
+ kwargs[param_name] = value
335
+ elif param_name == 'request':
336
+ kwargs[param_name] = req
337
+ elif param_name == 'response':
338
+ kwargs[param_name] = Response
339
+ elif param.default == inspect.Parameter.empty:
340
+ raise TypeError(f"Missing required parameter: {param_name}")
341
+
342
+ result = await router_response(**kwargs)
343
+ except Exception as e:
344
+ error_msg = tina4_python.global_exception_handler(e)
345
+ tina4_python.container_broken(error_msg)
346
+ if Constant.TINA4_LOG_DEBUG in os.getenv(
347
+ "TINA4_DEBUG_LEVEL") or Constant.TINA4_LOG_ALL in os.getenv("TINA4_DEBUG_LEVEL"):
348
+ html = Template.render_twig_template("errors/500.twig",
349
+ {"server": {"url": url}, "error_message": error_msg})
350
+ return Response(html, Constant.HTTP_SERVER_ERROR, Constant.TEXT_HTML)
351
+ else:
352
+ return Response(error_msg, Constant.HTTP_SERVER_ERROR, Constant.TEXT_HTML)
353
+
354
+ # we have found a result ... make sure we reflect this if the user didn't actually put the correct http response code in
355
+ if result is not None:
356
+ if result.http_code == Constant.HTTP_NOT_FOUND:
357
+ result.http_code = Constant.HTTP_OK
358
+
359
+ if result is not None and "middleware" in route:
360
+ middleware_runner = MiddleWare(route["middleware"]["class"])
361
+
362
+ if "methods" in route["middleware"] and route["middleware"]["methods"] is not None and len(
363
+ route["middleware"]["methods"]) > 0:
364
+ for mw_method in route["middleware"]["methods"]:
365
+ req, result = middleware_runner.call_direct_method(req, result, mw_method)
366
+ if result.http_code in error_set:
367
+ return Response(result.content, result.http_code, result.content_type)
368
+ else:
369
+ req, result = await middleware_runner.call_any_methods(req, result)
370
+ if result.http_code in error_set:
371
+ return Response(result.content, result.http_code, result.content_type)
349
372
 
350
- if "methods" in route["middleware"] and route["middleware"]["methods"] is not None and len(
351
- route["middleware"]["methods"]) > 0:
352
- for method in route["middleware"]["methods"]:
353
- Request, result = middleware_runner.call_direct_method(Request, result, method)
373
+ req, result = await middleware_runner.call_after_methods(req, result)
354
374
  if result.http_code in error_set:
355
- return Response.Response(result.content, result.http_code, result.content_type)
356
- else:
357
- Request, result = await middleware_runner.call_any_methods(Request, result)
358
- if result.http_code in error_set:
359
- return Response.Response(result.content, result.http_code, result.content_type)
360
-
361
- Request, result = await middleware_runner.call_after_methods(Request, result)
362
- if result.http_code in error_set:
363
- return Response.Response(result.content, result.http_code, result.content_type)
364
-
365
- if result is not None:
366
- result.headers["FreshToken"] = tina4_python.tina4_auth.get_token({"path": url})
367
- if "cache" in route and route["cache"] is not None:
368
- if not route["cache"]["cached"]:
369
- result.headers["Cache-Control"] = "max-age=1, must-revalidate"
370
- result.headers["Pragma"] = "no-cache"
375
+ return Response(result.content, result.http_code, result.content_type)
376
+
377
+ if result is not None:
378
+ result.headers["FreshToken"] = tina4_python.tina4_auth.get_token({"path": url})
379
+ if "cache" in route and route["cache"] is not None:
380
+ if not route["cache"]["cached"]:
381
+ result.headers["Cache-Control"] = "max-age=1, must-revalidate"
382
+ result.headers["Pragma"] = "no-cache"
383
+ else:
384
+ result.headers["Cache-Control"] = "max-age=" + str(
385
+ route["cache"]["max_age"]) + ", must-revalidate"
386
+ result.headers["Pragma"] = "cache"
371
387
  else:
372
- result.headers["Cache-Control"] = "max-age=" + str(
373
- route["cache"]["max_age"]) + ", must-revalidate"
388
+ result.headers["Cache-Control"] = "max-age=-1, must-revalidate"
374
389
  result.headers["Pragma"] = "cache"
375
- else:
376
- result.headers["Cache-Control"] = "max-age=-1, must-revalidate"
377
- result.headers["Pragma"] = "cache"
390
+ finally:
391
+ sys.stdout = old_stdout
378
392
 
379
393
  break
380
394
 
381
- if result is None and old_stdout is not None:
382
- result = Response
383
-
384
- result.headers["FreshToken"] = tina4_python.tina4_auth.get_token({"path": url})
385
- sys.stdout = old_stdout
386
- if buffer.getvalue() != "":
395
+ # Callback returned None but captured stdout output
396
+ if result is None and route_matched:
397
+ output = buffer.getvalue()
398
+ if output:
399
+ fresh_headers = {"FreshToken": tina4_python.tina4_auth.get_token({"path": url})}
387
400
  try:
388
- return Response.Response(json.loads(buffer.getvalue()), Constant.HTTP_OK, Constant.APPLICATION_JSON,
389
- result.headers)
401
+ return Response(json.loads(output), Constant.HTTP_OK, Constant.APPLICATION_JSON, fresh_headers)
390
402
  except Exception:
391
- return Response.Response(buffer.getvalue(), Constant.HTTP_OK, Constant.TEXT_HTML, result.headers)
392
- else:
393
- result = Response
394
- result.http_code = Constant.HTTP_NOT_FOUND
403
+ return Response(output, Constant.HTTP_OK, Constant.TEXT_HTML, fresh_headers)
395
404
 
396
- # If no route is matched, serve 404
397
- if result.http_code == Constant.HTTP_NOT_FOUND:
405
+ # If no route matched or result is still None, try twig templates then 404
406
+ if result is None:
398
407
  # Serve twigs if the files exist
399
408
  twig_files = []
400
409
  if url == "/":
@@ -410,17 +419,19 @@ class Router:
410
419
  tina4_python.root_path + os.sep + "src" + os.sep + "templates" + os.sep + twig_file,
411
420
  )
412
421
 
413
- result.headers["FreshToken"] = tina4_python.tina4_auth.get_token({"path": url})
414
- result.headers["Cache-Control"] = "max-age=-1, public"
415
- result.headers["Pragma"] = "no-cache"
422
+ twig_headers = {
423
+ "FreshToken": tina4_python.tina4_auth.get_token({"path": url}),
424
+ "Cache-Control": "max-age=-1, public",
425
+ "Pragma": "no-cache"
426
+ }
416
427
  content = Template.render_twig_template(twig_file, {"request": tina4_python.tina4_current_request})
417
428
  if content != "":
418
- return Response.Response(content, Constant.HTTP_OK, Constant.TEXT_HTML, result.headers)
429
+ return Response(content, Constant.HTTP_OK, Constant.TEXT_HTML, twig_headers)
419
430
 
420
- if result.http_code == Constant.HTTP_NOT_FOUND:
431
+ if result is None:
421
432
  content = Template.render_twig_template(
422
433
  "errors/404.twig", {"server": {"url": url}})
423
- return Response.Response(content, Constant.HTTP_NOT_FOUND, Constant.TEXT_HTML)
434
+ return Response(content, Constant.HTTP_NOT_FOUND, Constant.TEXT_HTML)
424
435
 
425
436
  result.headers["FreshToken"] = tina4_python.tina4_auth.get_token({"path": url})
426
437
  return result
@@ -431,11 +442,6 @@ class Router:
431
442
  Debug.debug(method, "Resolving URL: " + url)
432
443
  return await Router.get_result(url, method, request, headers, session)
433
444
 
434
- # cleans the url of double slashes
435
- @staticmethod
436
- def clean_url(url):
437
- return url.replace('//', '/')
438
-
439
445
  # adds a route to the router
440
446
  @staticmethod
441
447
  def add(method, route, callback):
@@ -271,8 +271,12 @@ class Session:
271
271
  self.session_path = _default_path
272
272
  self.session_values = {}
273
273
  self.session_hash = ""
274
- self.default_handler = _default_handler
275
- exec("self.default_handler = "+_default_handler)
274
+ _handlers = {
275
+ "SessionFileHandler": SessionFileHandler,
276
+ "SessionRedisHandler": SessionRedisHandler,
277
+ "SessionValkeyHandler": SessionValkeyHandler,
278
+ }
279
+ self.default_handler = _handlers.get(_default_handler, SessionFileHandler)
276
280
 
277
281
  def start(self, _hash=None):
278
282
  # create a file for the session?
@@ -243,7 +243,11 @@ builtins.orm = orm
243
243
  # Auto-import everything from src folders
244
244
  if os.path.exists(root_path + os.sep + "src"):
245
245
  try:
246
- exec("from src import *")
246
+ import importlib
247
+ src_module = importlib.import_module("src")
248
+ for attr in dir(src_module):
249
+ if not attr.startswith("_"):
250
+ globals()[attr] = getattr(src_module, attr)
247
251
  Debug.info("Initializing src folder")
248
252
  except ImportError as e:
249
253
  Debug.error("Cannot import src folder", str(e))
@@ -594,7 +598,7 @@ def _has_control_methods():
594
598
  if "tina4-python" in file_path:
595
599
  return True
596
600
 
597
- if "tina4" in file_path or "uvicorn" in file_path in file_path or "hypercorn" in file_path:
601
+ if "tina4" in file_path or "uvicorn" in file_path or "hypercorn" in file_path:
598
602
  return True
599
603
 
600
604
  if not file_path or not file_path.endswith('.py'):
@@ -56,13 +56,20 @@ def create_project(project_name: str) -> None:
56
56
  print(f"Creating project in {project_path}")
57
57
 
58
58
  app_content = '''\
59
+ import os
60
+ import tina4_python
61
+ from tina4_python import Debug
59
62
  from tina4_python import run_web_server
60
63
  from tina4_python.Router import get
61
64
 
65
+
62
66
  @get("/health-check")
63
- async def index(request, response):
67
+ async def get_healthcheck(request, response):
68
+ if os.path.isfile(tina4_python.root_path + "/broken"):
69
+ Debug.error("broken", tina4_python.root_path + "/broken")
70
+ return response("Broken", 503)
71
+ return response("OK")
64
72
 
65
- return response(f"OK")
66
73
 
67
74
  if __name__ == "__main__":
68
75
  run_web_server("0.0.0.0", 7145)
@@ -1,21 +0,0 @@
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
- request = None
8
- body = None
9
- params = {}
10
- headers = {}
11
- cookies = {}
12
- url = None
13
- session = None
14
- files = {}
15
- raw_request = None
16
- raw_data = None
17
- raw_content = None
18
- asgi_scope = None
19
- asgi_reader = None
20
- asgi_writer = None
21
- asgi_response = None
File without changes