TypeDAL 3.12.1__py3-none-any.whl → 4.2.0__py3-none-any.whl

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.
typedal/for_py4web.py CHANGED
@@ -5,7 +5,6 @@ ONLY USE IN COMBINATION WITH PY4WEB!
5
5
  import typing
6
6
 
7
7
  import threadsafevariable
8
- from configuraptor.abs import AnyType
9
8
  from py4web.core import ICECUBE
10
9
  from py4web.core import Fixture as _Fixture
11
10
  from pydal.base import MetaDAL, hashlib_md5
@@ -15,7 +14,7 @@ from .types import AnyDict
15
14
  from .web2py_py4web_shared import AuthUser
16
15
 
17
16
 
18
- class Fixture(_Fixture): # type: ignore
17
+ class Fixture(_Fixture):
19
18
  """
20
19
  Make mypy happy.
21
20
  """
@@ -68,8 +67,8 @@ def setup_py4web_tables(db: TypeDAL) -> None:
68
67
 
69
68
 
70
69
  __all__ = [
70
+ "DAL",
71
71
  "AuthUser",
72
72
  "Fixture",
73
- "DAL",
74
73
  "setup_py4web_tables",
75
74
  ]
typedal/for_web2py.py CHANGED
@@ -6,7 +6,7 @@ import datetime as dt
6
6
 
7
7
  from pydal.validators import IS_NOT_IN_DB
8
8
 
9
- from .core import TypeDAL, TypedField, TypedTable
9
+ from . import TypeDAL, TypedField, TypedTable
10
10
  from .fields import TextField
11
11
  from .web2py_py4web_shared import AuthUser
12
12
 
typedal/helpers.py CHANGED
@@ -2,22 +2,30 @@
2
2
  Helpers that work independently of core.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import datetime as dt
6
8
  import fnmatch
7
9
  import io
10
+ import re
11
+ import sys
8
12
  import types
9
- import typing
13
+ import typing as t
10
14
  from collections import ChainMap
11
- from typing import Any
12
15
 
13
16
  from pydal import DAL
14
17
 
15
- from .types import AnyDict, Field, Table
18
+ from .types import AnyDict, Expression, Field, Row, T, Table, Template # type: ignore
19
+
20
+ try:
21
+ import annotationlib
22
+ except ImportError: # pragma: no cover
23
+ annotationlib = None
16
24
 
17
- if typing.TYPE_CHECKING:
18
- from . import TypeDAL, TypedField, TypedTable # noqa: F401
25
+ if t.TYPE_CHECKING:
26
+ from string.templatelib import Interpolation
19
27
 
20
- T = typing.TypeVar("T")
28
+ from . import TypeDAL, TypedField, TypedTable
21
29
 
22
30
 
23
31
  def is_union(some_type: type | types.UnionType) -> bool:
@@ -25,19 +33,34 @@ def is_union(some_type: type | types.UnionType) -> bool:
25
33
  Check if a type is some type of Union.
26
34
 
27
35
  Args:
28
- some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
36
+ some_type: types.UnionType = type(int | str); t.Union = t.Union[int, str]
29
37
 
30
38
  """
31
- return typing.get_origin(some_type) in (types.UnionType, typing.Union)
39
+ return t.get_origin(some_type) in (types.UnionType, t.Union)
32
40
 
33
41
 
34
- def reversed_mro(cls: type) -> typing.Iterable[type]:
42
+ def reversed_mro(cls: type) -> t.Iterable[type]:
35
43
  """
36
44
  Get the Method Resolution Order (mro) for a class, in reverse order to be used with ChainMap.
37
45
  """
38
46
  return reversed(getattr(cls, "__mro__", []))
39
47
 
40
48
 
49
+ def _cls_annotations(c: type) -> dict[str, type]: # pragma: no cover
50
+ """
51
+ Functions to get the annotations of a class (excl inherited, use _all_annotations for that).
52
+
53
+ Uses `annotationlib` if available (since 3.14) and if so, resolves forward references immediately.
54
+ """
55
+ if annotationlib:
56
+ return t.cast(
57
+ dict[str, type],
58
+ annotationlib.get_annotations(c, format=annotationlib.Format.VALUE, eval_str=True),
59
+ )
60
+ else:
61
+ return getattr(c, "__annotations__", {})
62
+
63
+
41
64
  def _all_annotations(cls: type) -> ChainMap[str, type]:
42
65
  """
43
66
  Returns a dictionary-like ChainMap that includes annotations for all \
@@ -45,7 +68,7 @@ def _all_annotations(cls: type) -> ChainMap[str, type]:
45
68
  """
46
69
  # chainmap reverses the iterable, so reverse again beforehand to keep order normally:
47
70
 
48
- return ChainMap(*(c.__annotations__ for c in reversed_mro(cls) if "__annotations__" in c.__dict__))
71
+ return ChainMap(*(_cls_annotations(c) for c in reversed_mro(cls)))
49
72
 
50
73
 
51
74
  def all_dict(cls: type) -> AnyDict:
@@ -55,9 +78,9 @@ def all_dict(cls: type) -> AnyDict:
55
78
  return dict(ChainMap(*(c.__dict__ for c in reversed_mro(cls)))) # type: ignore
56
79
 
57
80
 
58
- def all_annotations(cls: type, _except: typing.Iterable[str] = None) -> dict[str, type]:
81
+ def all_annotations(cls: type, _except: t.Optional[t.Iterable[str]] = None) -> dict[str, type]:
59
82
  """
60
- Wrapper around `_all_annotations` that filters away any keys in _except.
83
+ Wrapper around `_all_annotations` that filters away t.Any keys in _except.
61
84
 
62
85
  It also flattens the ChainMap to a regular dict.
63
86
  """
@@ -68,7 +91,7 @@ def all_annotations(cls: type, _except: typing.Iterable[str] = None) -> dict[str
68
91
  return {k: v for k, v in _all.items() if k not in _except}
69
92
 
70
93
 
71
- def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T:
94
+ def instanciate(cls: t.Type[T] | T, with_args: bool = False) -> T:
72
95
  """
73
96
  Create an instance of T (if it is a class).
74
97
 
@@ -78,20 +101,20 @@ def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T:
78
101
  If with_args: spread the generic args into the class creation
79
102
  (needed for e.g. TypedField(str), but not for list[str])
80
103
  """
81
- if inner_cls := typing.get_origin(cls):
104
+ if inner_cls := t.get_origin(cls):
82
105
  if not with_args:
83
- return typing.cast(T, inner_cls())
106
+ return t.cast(T, inner_cls())
84
107
 
85
- args = typing.get_args(cls)
86
- return typing.cast(T, inner_cls(*args))
108
+ args = t.get_args(cls)
109
+ return t.cast(T, inner_cls(*args))
87
110
 
88
111
  if isinstance(cls, type):
89
- return typing.cast(T, cls())
112
+ return t.cast(T, cls())
90
113
 
91
114
  return cls
92
115
 
93
116
 
94
- def origin_is_subclass(obj: Any, _type: type) -> bool:
117
+ def origin_is_subclass(obj: t.Any, _type: type) -> bool:
95
118
  """
96
119
  Check if the origin of a generic is a subclass of _type.
97
120
 
@@ -99,14 +122,14 @@ def origin_is_subclass(obj: Any, _type: type) -> bool:
99
122
  origin_is_subclass(list[str], list) -> True
100
123
  """
101
124
  return bool(
102
- typing.get_origin(obj)
103
- and isinstance(typing.get_origin(obj), type)
104
- and issubclass(typing.get_origin(obj), _type)
125
+ t.get_origin(obj) and isinstance(t.get_origin(obj), type) and issubclass(t.get_origin(obj), _type),
105
126
  )
106
127
 
107
128
 
108
129
  def mktable(
109
- data: dict[Any, Any], header: typing.Optional[typing.Iterable[str] | range] = None, skip_first: bool = True
130
+ data: dict[t.Any, t.Any],
131
+ header: t.Optional[t.Iterable[str] | range] = None,
132
+ skip_first: bool = True,
110
133
  ) -> str:
111
134
  """
112
135
  Display a table for 'data'.
@@ -150,11 +173,11 @@ def mktable(
150
173
  return output.getvalue()
151
174
 
152
175
 
153
- K = typing.TypeVar("K")
154
- V = typing.TypeVar("V")
176
+ K = t.TypeVar("K")
177
+ V = t.TypeVar("V")
155
178
 
156
179
 
157
- def looks_like(v: Any, _type: type[Any]) -> bool:
180
+ def looks_like(v: t.Any, _type: type[t.Any]) -> bool:
158
181
  """
159
182
  Returns true if v or v's class is of type _type, including if it is a generic.
160
183
 
@@ -166,7 +189,7 @@ def looks_like(v: Any, _type: type[Any]) -> bool:
166
189
  return isinstance(v, _type) or (isinstance(v, type) and issubclass(v, _type)) or origin_is_subclass(v, _type)
167
190
 
168
191
 
169
- def filter_out(mut_dict: dict[K, V], _type: type[T]) -> dict[K, type[T]]:
192
+ def filter_out(mut_dict: dict[K, V], _type: type[T]) -> dict[K, T]:
170
193
  """
171
194
  Split a dictionary into things matching _type and the rest.
172
195
 
@@ -182,19 +205,19 @@ def unwrap_type(_type: type) -> type:
182
205
  Example:
183
206
  list[list[str]] -> str
184
207
  """
185
- while args := typing.get_args(_type):
208
+ while args := t.get_args(_type):
186
209
  _type = args[0]
187
210
  return _type
188
211
 
189
212
 
190
- @typing.overload
213
+ @t.overload
191
214
  def extract_type_optional(annotation: T) -> tuple[T, bool]:
192
215
  """
193
216
  T -> T is not exactly right because you'll get the inner type, but mypy seems happy with this.
194
217
  """
195
218
 
196
219
 
197
- @typing.overload
220
+ @t.overload
198
221
  def extract_type_optional(annotation: None) -> tuple[None, bool]:
199
222
  """
200
223
  None leads to None, False.
@@ -208,10 +231,10 @@ def extract_type_optional(annotation: T | None) -> tuple[T | None, bool]:
208
231
  if annotation is None:
209
232
  return None, False
210
233
 
211
- if origin := typing.get_origin(annotation):
212
- args = typing.get_args(annotation)
234
+ if origin := t.get_origin(annotation):
235
+ args = t.get_args(annotation)
213
236
 
214
- if origin in (typing.Union, types.UnionType, typing.Optional) and args:
237
+ if origin in (t.Union, types.UnionType, t.Optional) and args:
215
238
  # remove None:
216
239
  return next(_ for _ in args if _ and _ != types.NoneType and not isinstance(_, types.NoneType)), True
217
240
 
@@ -252,7 +275,7 @@ class DummyQuery:
252
275
  return False
253
276
 
254
277
 
255
- def as_lambda(value: T) -> typing.Callable[..., T]:
278
+ def as_lambda(value: T) -> t.Callable[..., T]:
256
279
  """
257
280
  Wrap value in a callable.
258
281
  """
@@ -285,21 +308,315 @@ def get_db(table: "TypedTable | Table") -> "DAL":
285
308
  """
286
309
  Get the underlying DAL instance for a pydal or typedal table.
287
310
  """
288
- return typing.cast("DAL", table._db)
311
+ return t.cast("DAL", table._db)
289
312
 
290
313
 
291
314
  def get_table(table: "TypedTable | Table") -> "Table":
292
315
  """
293
316
  Get the underlying pydal table for a typedal table.
294
317
  """
295
- return typing.cast("Table", table._table)
318
+ return t.cast("Table", table._table)
296
319
 
297
320
 
298
- def get_field(field: "TypedField[typing.Any] | Field") -> "Field":
321
+ def get_field(field: "TypedField[t.Any] | Field") -> "Field":
299
322
  """
300
323
  Get the underlying pydal field from a typedal field.
301
324
  """
302
- return typing.cast(
325
+ return t.cast(
303
326
  "Field",
304
327
  field, # Table.field already is a Field, but cast to make sure the editor knows this too.
305
328
  )
329
+
330
+
331
+ class classproperty:
332
+ """
333
+ Combination of @classmethod and @property.
334
+ """
335
+
336
+ def __init__(self, fget: t.Callable[..., t.Any]) -> None:
337
+ """
338
+ Initialize the classproperty.
339
+
340
+ Args:
341
+ fget: A function that takes the class as an argument and returns a value.
342
+ """
343
+ self.fget = fget
344
+
345
+ def __get__(self, obj: t.Any, owner: t.Type[T]) -> t.Any:
346
+ """
347
+ Retrieve the property value.
348
+
349
+ Args:
350
+ obj: The instance of the class (unused).
351
+ owner: The class that owns the property.
352
+
353
+ Returns:
354
+ The value returned by the function.
355
+ """
356
+ return self.fget(owner)
357
+
358
+
359
+ def smarter_adapt(db: TypeDAL, placeholder: t.Any) -> str:
360
+ """
361
+ Smarter adaptation of placeholder to quote if needed.
362
+
363
+ Args:
364
+ db: Database object.
365
+ placeholder: Placeholder object.
366
+
367
+ Returns:
368
+ Quoted placeholder if needed, except for numbers (smart_adapt logic)
369
+ or fields/tables (use already quoted rname).
370
+ """
371
+ return t.cast(
372
+ str,
373
+ getattr(placeholder, "sql_shortref", None) # for tables
374
+ or getattr(placeholder, "sqlsafe", None) # for fields
375
+ or db._adapter.smart_adapt(placeholder), # for others
376
+ )
377
+
378
+
379
+ # https://docs.python.org/3.14/library/string.templatelib.html
380
+ SYSTEM_SUPPORTS_TEMPLATES = sys.version_info > (3, 14)
381
+
382
+
383
+ def process_tstring(template: Template, operation: t.Callable[["Interpolation"], str]) -> str: # pragma: no cover
384
+ """
385
+ Process a Template string by applying an operation to each interpolation.
386
+
387
+ This function iterates through a Template object, which contains both string literals
388
+ and Interpolation objects. String literals are preserved as-is, while Interpolation
389
+ objects are transformed using the provided operation function.
390
+
391
+ Args:
392
+ template: A Template object containing mixed string literals and Interpolation objects.
393
+ operation: A callable that takes an Interpolation object and returns a string.
394
+ This function will be applied to each interpolated value in the template.
395
+
396
+ Returns:
397
+ str: The processed string with all interpolations replaced by the results of
398
+ applying the operation function.
399
+
400
+ Example:
401
+ Basic f-string functionality can be implemented as:
402
+
403
+ >>> def fstring_operation(interpolation):
404
+ ... return str(interpolation.value)
405
+ >>> value = "test"
406
+ >>> template = t"{value = }" # Template string literal
407
+ >>> result = process_tstring(template, fstring_operation)
408
+ >>> print(result) # "value = test"
409
+
410
+ Note:
411
+ This is a generic template processor. The specific behavior depends entirely
412
+ on the operation function provided.
413
+ """
414
+ return "".join(part if isinstance(part, str) else operation(part) for part in template)
415
+
416
+
417
+ def sql_escape_template(db: TypeDAL, sql_fragment: Template) -> str: # pragma: no cover
418
+ r"""
419
+ Safely escape a Template string for SQL execution using database-specific escaping.
420
+
421
+ This function processes a Template string (t-string) by escaping all interpolated
422
+ values using the database adapter's escape mechanism, preventing SQL injection
423
+ attacks while maintaining the structure of the SQL query.
424
+
425
+ Args:
426
+ db: TypeDAL database connection object that provides the adapter for escaping.
427
+ sql_fragment: A Template object (t-string) containing SQL with interpolated values.
428
+ The interpolated values will be automatically escaped.
429
+
430
+ Returns:
431
+ str: SQL string with all interpolated values properly escaped for safe execution.
432
+
433
+ Example:
434
+ >>> user_input = "'; DROP TABLE users; --"
435
+ >>> query = t"SELECT * FROM users WHERE name = {user_input}"
436
+ >>> safe_query = sql_escape_template(db, query)
437
+ >>> print(safe_query) # "SELECT * FROM users WHERE name = '\'; DROP TABLE users; --'"
438
+
439
+ Security:
440
+ This function is essential for preventing SQL injection attacks when using
441
+ user-provided data in SQL queries. All interpolated values are escaped
442
+ according to the database adapter's rules.
443
+
444
+ Note:
445
+ Only available in Python 3.14+ when SYSTEM_SUPPORTS_TEMPLATES is True.
446
+ For earlier Python versions, use sql_escape() with string formatting.
447
+ """
448
+ return process_tstring(sql_fragment, lambda part: smarter_adapt(db, part.value))
449
+
450
+
451
+ def sql_escape(db: TypeDAL, sql_fragment: str | Template, *raw_args: t.Any, **raw_kwargs: t.Any) -> str:
452
+ """
453
+ Generate escaped SQL fragments with safely substituted placeholders.
454
+
455
+ This function provides secure SQL string construction by escaping all provided
456
+ arguments using the database adapter's escaping mechanism. It supports both
457
+ traditional string formatting (Python < 3.14) and Template strings (Python 3.14+).
458
+
459
+ Args:
460
+ db: TypeDAL database connection object that provides the adapter for escaping.
461
+ sql_fragment: SQL fragment with placeholders (%s for positional, %(name)s for named).
462
+ In Python 3.14+, this can also be a Template (t-string) with
463
+ interpolated values that will be automatically escaped.
464
+ *raw_args: Positional arguments to be escaped and substituted for %s placeholders.
465
+ Only use with string fragments, not Template objects.
466
+ **raw_kwargs: Keyword arguments to be escaped and substituted for %(name)s placeholders.
467
+ Only use with string fragments, not Template objects.
468
+
469
+ Returns:
470
+ str: SQL fragment with all placeholders replaced by properly escaped values.
471
+
472
+ Raises:
473
+ ValueError: If both positional and keyword arguments are provided simultaneously.
474
+
475
+ Examples:
476
+ Positional arguments:
477
+ >>> safe_sql = sql_escape(db, "SELECT * FROM users WHERE id = %s", user_id)
478
+
479
+ Keyword arguments:
480
+ >>> safe_sql = sql_escape(db, "SELECT * FROM users WHERE name = %(name)s", name=username)
481
+
482
+ Template strings (Python 3.14+):
483
+ >>> safe_sql = sql_escape(db, t"SELECT * FROM users WHERE id = {user_id}")
484
+
485
+ Security:
486
+ All arguments are escaped using the database adapter's escaping rules to prevent
487
+ SQL injection attacks. Never concatenate user input directly into SQL strings.
488
+ """
489
+ if raw_args and raw_kwargs: # pragma: no cover
490
+ raise ValueError("Please provide either args or kwargs, not both.")
491
+
492
+ if SYSTEM_SUPPORTS_TEMPLATES and isinstance(sql_fragment, Template): # pragma: no cover
493
+ return sql_escape_template(db, sql_fragment)
494
+
495
+ if raw_args:
496
+ # list
497
+ return sql_fragment % tuple(smarter_adapt(db, placeholder) for placeholder in raw_args)
498
+ else:
499
+ # dict
500
+ return sql_fragment % {key: smarter_adapt(db, placeholder) for key, placeholder in raw_kwargs.items()}
501
+
502
+
503
+ def sql_expression(
504
+ db: TypeDAL,
505
+ sql_fragment: str | Template,
506
+ *raw_args: t.Any,
507
+ output_type: str | None = None,
508
+ **raw_kwargs: t.Any,
509
+ ) -> Expression:
510
+ """
511
+ Create a PyDAL Expression object from a raw SQL fragment with safe parameter substitution.
512
+
513
+ This function combines SQL escaping with PyDAL's Expression system, allowing you to
514
+ create database expressions from raw SQL while maintaining security through proper
515
+ parameter escaping.
516
+
517
+ Args:
518
+ db: The TypeDAL database connection object.
519
+ sql_fragment: Raw SQL fragment with placeholders (%s for positional, %(name)s for named).
520
+ In Python 3.14+, this can also be a Template (t-string) with
521
+ interpolated values that will be automatically escaped.
522
+ *raw_args: Positional arguments to be escaped and interpolated into the SQL fragment.
523
+ Only use with string fragments, not Template objects.
524
+ output_type: Optional type hint for the expected output type of the expression.
525
+ This can help with query analysis and optimization.
526
+ **raw_kwargs: Keyword arguments to be escaped and interpolated into the SQL fragment.
527
+ Only use with string fragments, not Template objects.
528
+
529
+ Returns:
530
+ Expression: A PyDAL Expression object wrapping the safely escaped SQL fragment.
531
+
532
+ Examples:
533
+ Creating a complex WHERE clause:
534
+ >>> expr = sql_expression(db,
535
+ ... "age > %s AND status = %s",
536
+ ... 18, "active",
537
+ ... output_type="boolean")
538
+ >>> query = db(expr).select()
539
+
540
+ Using keyword arguments:
541
+ >>> expr = sql_expression(db,
542
+ ... "EXTRACT(year FROM %(date_col)s) = %(year)s",
543
+ ... date_col="created_at", year=2023,
544
+ ... output_type="boolean")
545
+
546
+ Template strings (Python 3.14+):
547
+ >>> min_age = 21
548
+ >>> expr = sql_expression(db, t"age >= {min_age}", output_type="boolean")
549
+
550
+ Security:
551
+ All parameters are escaped using sql_escape() before being wrapped in the Expression,
552
+ ensuring protection against SQL injection attacks.
553
+
554
+ Note:
555
+ The returned Expression can be used anywhere PyDAL expects an expression,
556
+ such as in db().select(), .update(), or .delete() operations.
557
+ """
558
+ safe_sql = sql_escape(db, sql_fragment, *raw_args, **raw_kwargs)
559
+
560
+ # create a pydal Expression wrapping a raw SQL fragment + placeholders
561
+ return Expression(
562
+ db,
563
+ db._adapter.dialect.raw,
564
+ safe_sql,
565
+ type=output_type, # optional type hint
566
+ )
567
+
568
+
569
+ def normalize_table_keys(row: Row, pattern: re.Pattern[str] = re.compile(r"^([a-zA-Z_]+)_(\d{5,})$")) -> Row:
570
+ """
571
+ Normalize table keys in a PyDAL Row object by stripping numeric hash suffixes from table names, \
572
+ only if the suffix is 5 or more digits.
573
+
574
+ For example:
575
+ Row({'articles_12345': {...}}) -> Row({'articles': {...}})
576
+ Row({'articles_123': {...}}) -> unchanged
577
+
578
+ Returns:
579
+ Row: A new Row object with normalized keys.
580
+ """
581
+ new_data: dict[str, t.Any] = {}
582
+ for key, value in row.items():
583
+ if match := pattern.match(key):
584
+ base, _suffix = match.groups()
585
+ normalized_key = base
586
+ new_data[normalized_key] = value
587
+ else:
588
+ new_data[key] = value
589
+ return Row(new_data)
590
+
591
+
592
+ def default_representer(field: TypedField[T], value: T, table: t.Type[TypedTable]) -> str:
593
+ """
594
+ Simply call field.represent on the value.
595
+ """
596
+ if represent := getattr(field, "represent", None):
597
+ return str(represent(value, table))
598
+ else:
599
+ return repr(value)
600
+
601
+
602
+ def throw(exc: BaseException) -> t.Never:
603
+ """
604
+ Raise the given exception.
605
+
606
+ This function provides a functional way to raise exceptions, allowing
607
+ exception raising to be used in expressions where a statement wouldn't work.
608
+
609
+ Args:
610
+ exc: The exception to be raised.
611
+
612
+ Returns:
613
+ Never returns normally as an exception is always raised.
614
+
615
+ Raises:
616
+ BaseException: Always raises the provided exception.
617
+
618
+ Examples:
619
+ >>> value = get_value() or throw(ValueError("No value available"))
620
+ >>> result = data.get('key') if data else throw(KeyError("Missing data"))
621
+ """
622
+ raise exc