TypeDAL 4.9.7__tar.gz → 4.9.8__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 (80) hide show
  1. {typedal-4.9.7 → typedal-4.9.8}/CHANGELOG.md +18 -0
  2. {typedal-4.9.7 → typedal-4.9.8}/PKG-INFO +1 -1
  3. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/__about__.py +1 -1
  4. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/core.py +88 -0
  5. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/fields.py +1 -0
  6. {typedal-4.9.7 → typedal-4.9.8}/tests/conftest.py +6 -4
  7. {typedal-4.9.7 → typedal-4.9.8}/tests/test_main.py +76 -1
  8. {typedal-4.9.7 → typedal-4.9.8}/.crush/.gitignore +0 -0
  9. {typedal-4.9.7 → typedal-4.9.8}/.crush/crush.db-shm +0 -0
  10. {typedal-4.9.7 → typedal-4.9.8}/.crush/crush.db-wal +0 -0
  11. {typedal-4.9.7 → typedal-4.9.8}/.crush/init +0 -0
  12. {typedal-4.9.7 → typedal-4.9.8}/.crush/logs/crush.log +0 -0
  13. {typedal-4.9.7 → typedal-4.9.8}/.github/workflows/su6.yml +0 -0
  14. {typedal-4.9.7 → typedal-4.9.8}/.gitignore +0 -0
  15. {typedal-4.9.7 → typedal-4.9.8}/.readthedocs.yml +0 -0
  16. {typedal-4.9.7 → typedal-4.9.8}/README.md +0 -0
  17. {typedal-4.9.7 → typedal-4.9.8}/coverage.svg +0 -0
  18. {typedal-4.9.7 → typedal-4.9.8}/docs/10_advanced_apis.md +0 -0
  19. {typedal-4.9.7 → typedal-4.9.8}/docs/1_getting_started.md +0 -0
  20. {typedal-4.9.7 → typedal-4.9.8}/docs/2_defining_tables.md +0 -0
  21. {typedal-4.9.7 → typedal-4.9.8}/docs/3_building_queries.md +0 -0
  22. {typedal-4.9.7 → typedal-4.9.8}/docs/4_relationships.md +0 -0
  23. {typedal-4.9.7 → typedal-4.9.8}/docs/5_py4web.md +0 -0
  24. {typedal-4.9.7 → typedal-4.9.8}/docs/6_migrations.md +0 -0
  25. {typedal-4.9.7 → typedal-4.9.8}/docs/7_configuration.md +0 -0
  26. {typedal-4.9.7 → typedal-4.9.8}/docs/8_mixins.md +0 -0
  27. {typedal-4.9.7 → typedal-4.9.8}/docs/9_memoization.md +0 -0
  28. {typedal-4.9.7 → typedal-4.9.8}/docs/css/code_blocks.css +0 -0
  29. {typedal-4.9.7 → typedal-4.9.8}/docs/index.md +0 -0
  30. {typedal-4.9.7 → typedal-4.9.8}/docs/requirements.txt +0 -0
  31. {typedal-4.9.7 → typedal-4.9.8}/example_new.py +0 -0
  32. {typedal-4.9.7 → typedal-4.9.8}/example_old.py +0 -0
  33. {typedal-4.9.7 → typedal-4.9.8}/mkdocs.yml +0 -0
  34. {typedal-4.9.7 → typedal-4.9.8}/pyproject.toml +0 -0
  35. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/__init__.py +0 -0
  36. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/caching.py +0 -0
  37. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/cli.py +0 -0
  38. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/config.py +0 -0
  39. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/constants.py +0 -0
  40. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/define.py +0 -0
  41. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/enum_helpers.py +0 -0
  42. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/for_py4web.py +0 -0
  43. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/for_web2py.py +0 -0
  44. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/helpers.py +0 -0
  45. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/mixins.py +0 -0
  46. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/py.typed +0 -0
  47. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/query_builder.py +0 -0
  48. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/relationships.py +0 -0
  49. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/rows.py +0 -0
  50. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/serializers/as_json.py +0 -0
  51. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/serializers/typescript.py +0 -0
  52. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/tables.py +0 -0
  53. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/types.py +0 -0
  54. {typedal-4.9.7 → typedal-4.9.8}/src/typedal/web2py_py4web_shared.py +0 -0
  55. {typedal-4.9.7 → typedal-4.9.8}/tasks.py +0 -0
  56. {typedal-4.9.7 → typedal-4.9.8}/tests/__init__.py +0 -0
  57. {typedal-4.9.7 → typedal-4.9.8}/tests/configs/simple.toml +0 -0
  58. {typedal-4.9.7 → typedal-4.9.8}/tests/configs/valid.env +0 -0
  59. {typedal-4.9.7 → typedal-4.9.8}/tests/configs/valid.toml +0 -0
  60. {typedal-4.9.7 → typedal-4.9.8}/tests/py314_tests.py +0 -0
  61. {typedal-4.9.7 → typedal-4.9.8}/tests/test_cli.py +0 -0
  62. {typedal-4.9.7 → typedal-4.9.8}/tests/test_config.py +0 -0
  63. {typedal-4.9.7 → typedal-4.9.8}/tests/test_docs_examples.py +0 -0
  64. {typedal-4.9.7 → typedal-4.9.8}/tests/test_helpers.py +0 -0
  65. {typedal-4.9.7 → typedal-4.9.8}/tests/test_json.py +0 -0
  66. {typedal-4.9.7 → typedal-4.9.8}/tests/test_mixins.py +0 -0
  67. {typedal-4.9.7 → typedal-4.9.8}/tests/test_mypy.py +0 -0
  68. {typedal-4.9.7 → typedal-4.9.8}/tests/test_orm.py +0 -0
  69. {typedal-4.9.7 → typedal-4.9.8}/tests/test_py4web.py +0 -0
  70. {typedal-4.9.7 → typedal-4.9.8}/tests/test_query_builder.py +0 -0
  71. {typedal-4.9.7 → typedal-4.9.8}/tests/test_relationships.py +0 -0
  72. {typedal-4.9.7 → typedal-4.9.8}/tests/test_row.py +0 -0
  73. {typedal-4.9.7 → typedal-4.9.8}/tests/test_stats.py +0 -0
  74. {typedal-4.9.7 → typedal-4.9.8}/tests/test_table.py +0 -0
  75. {typedal-4.9.7 → typedal-4.9.8}/tests/test_typescript.py +0 -0
  76. {typedal-4.9.7 → typedal-4.9.8}/tests/test_typing_mypy.md +0 -0
  77. {typedal-4.9.7 → typedal-4.9.8}/tests/test_typing_pyright.md +0 -0
  78. {typedal-4.9.7 → typedal-4.9.8}/tests/test_web2py.py +0 -0
  79. {typedal-4.9.7 → typedal-4.9.8}/tests/test_xx_others.py +0 -0
  80. {typedal-4.9.7 → typedal-4.9.8}/tests/timings.py +0 -0
@@ -2,6 +2,24 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v4.9.8 (2026-07-14)
6
+
7
+ ### Fix
8
+
9
+ * **core:** Clear table hooks and field validators on close ([`3a3fe7b`](https://github.com/trialandsuccess/TypeDAL/commit/3a3fe7b3387476a9ee50f85d492dea067f218069))
10
+
11
+ ## v4.9.8-beta.2 (2026-07-14)
12
+
13
+ ### Fix
14
+
15
+ * **core:** Fully detach tables and fields when closing TypeDAL ([`469e0cb`](https://github.com/trialandsuccess/TypeDAL/commit/469e0cbc5b4f1584849040735ced0232adc18b65))
16
+
17
+ ## v4.9.8-beta.1 (2026-07-14)
18
+
19
+ ### Fix
20
+
21
+ * **core:** Purge dialect expressions when closing TypeDAL ([`d848cf7`](https://github.com/trialandsuccess/TypeDAL/commit/d848cf72ca0ecee24ba857d6b5e03d3cacce7693))
22
+
5
23
  ## v4.9.7 (2026-07-14)
6
24
 
7
25
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.9.7
3
+ Version: 4.9.8
4
4
  Summary: Typing support for PyDAL
5
5
  Project-URL: Documentation, https://typedal.readthedocs.io/
6
6
  Project-URL: Issues, https://github.com/trialandsuccess/TypeDAL/issues
@@ -5,4 +5,4 @@ This file contains the Version info for this package.
5
5
  # SPDX-FileCopyrightText: 2023-present Robin van der Noord <robinvandernoord@gmail.com>
6
6
  #
7
7
  # SPDX-License-Identifier: MIT
8
- __version__ = "4.9.7"
8
+ __version__ = "4.9.8"
@@ -40,6 +40,53 @@ if t.TYPE_CHECKING:
40
40
  from .types import AnyDict, DefineKwargs, Expression, Rows, Set, T_Query, Table
41
41
 
42
42
 
43
+ def _expression_subclasses() -> t.Iterator[type]:
44
+ """
45
+ Yield pydal.objects.Expression and every (nested) subclass currently loaded, e.g. Field and TypedField.
46
+ """
47
+ seen = {pydal.objects.Expression}
48
+ stack = [pydal.objects.Expression]
49
+ while stack:
50
+ for subclass in stack.pop().__subclasses__():
51
+ if subclass not in seen:
52
+ seen.add(subclass)
53
+ stack.append(subclass)
54
+
55
+ return iter(seen)
56
+
57
+
58
+ def _purge_dialect_expressions(adapter: t.Any) -> None:
59
+ """
60
+ Undo pydal's global dialect-expression registration for a closed adapter.
61
+
62
+ Some dialects (currently Postgres and Snowflake) register extra Expression methods
63
+ (e.g. `.dow`, `.doy`) by stashing a wrapper per name in the process-wide, class-level
64
+ `Expression._dialect_expressions_` dict. `Expression.__new__` then copies each wrapper
65
+ onto every instantiated Expression subclass (Field, TypedField, ...) as a bound method.
66
+ Both of those are permanent references living outside of any object graph tied to a
67
+ specific db, so they are not reference cycles: closing the adapter and even running
68
+ `gc.collect()` will not free it, and with it the entire db (tables, fields, models)
69
+ it belongs to, for the lifetime of the process, unless explicitly undone here.
70
+ """
71
+ expressions = pydal.objects.Expression._dialect_expressions_
72
+ stale = [
73
+ name
74
+ for name, wrapper in expressions.items()
75
+ if getattr(getattr(wrapper, "dialect", None), "adapter", None) is adapter
76
+ ]
77
+
78
+ if not stale:
79
+ return
80
+
81
+ for name in stale:
82
+ del expressions[name]
83
+
84
+ for cls in _expression_subclasses():
85
+ for name in stale:
86
+ if name in cls.__dict__:
87
+ delattr(cls, name)
88
+
89
+
43
90
  # note: these functions can not be moved to a different file,
44
91
  # because then they will have different globals and it breaks!
45
92
 
@@ -282,6 +329,7 @@ class TypeDAL(_TypeDALBase):
282
329
 
283
330
  def close(self) -> None:
284
331
  """Close the database connection and unbind all defined TypedTable models."""
332
+ adapter = self._adapter
285
333
  try:
286
334
  super().close()
287
335
  finally:
@@ -289,6 +337,46 @@ class TypeDAL(_TypeDALBase):
289
337
  model.unbind()
290
338
  self._builder.class_map.clear()
291
339
 
340
+ if adapter is not None:
341
+ adapter.db = None
342
+ _purge_dialect_expressions(adapter)
343
+
344
+ for table_name in tuple(getattr(self, "tables", ())):
345
+ table: Table | None = getattr(self, table_name, None)
346
+ if table is None: # pragma: no cover
347
+ continue
348
+
349
+ for hook_name in (
350
+ "_before_insert",
351
+ "_after_insert",
352
+ "_before_update",
353
+ "_after_update",
354
+ "_before_delete",
355
+ "_after_delete",
356
+ ):
357
+ hooks = getattr(table, hook_name, None)
358
+ if isinstance(hooks, list):
359
+ hooks.clear()
360
+
361
+ if hasattr(table, "_db"):
362
+ table._db = None
363
+
364
+ for field_name in getattr(table, "fields", ()):
365
+ field: Field | None = getattr(table, field_name, None)
366
+ if field is None: # pragma: no cover
367
+ continue
368
+
369
+ if hasattr(field, "_db"):
370
+ field._db = None
371
+ if hasattr(field, "db"):
372
+ field.db = None
373
+ if hasattr(field, "table"):
374
+ field.table = None
375
+ if hasattr(field, "_table"):
376
+ field._table = None
377
+ if hasattr(field, "requires"):
378
+ field.requires = []
379
+
292
380
  def try_define[T: t.Any](self, model: t.Type[T], verbose: bool = False) -> t.Type[T]:
293
381
  """
294
382
  Try to define a model with migrate or fall back to fake migrate.
@@ -206,6 +206,7 @@ class TypedField[T_Value](Expression): # pragma: no cover
206
206
  self.requires = []
207
207
 
208
208
  self._table = None
209
+ self._db = None
209
210
  self._field = None
210
211
 
211
212
  def __getattr__(self, key: str) -> t.Any:
@@ -21,10 +21,12 @@ def psql(request):
21
21
  request.addfinalizer(postgres.stop)
22
22
  postgres.start()
23
23
 
24
-
25
24
  @pytest.fixture
26
- def dal_psql(psql):
25
+ def dal_psql_uri(psql) -> str:
27
26
  conn_str = postgres.get_connection_url()
28
- uri = "postgres://" + conn_str.split("://")[-1]
27
+ return "postgres://" + conn_str.split("://")[-1]
28
+
29
+ @pytest.fixture
30
+ def dal_psql(dal_psql_uri: str):
29
31
  with tempfile.TemporaryDirectory() as d:
30
- yield TypeDAL(uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=d)
32
+ yield TypeDAL(dal_psql_uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=d)
@@ -3,14 +3,17 @@ import gc
3
3
  import re
4
4
  import sys
5
5
  import typing
6
+ import types
6
7
  import weakref
8
+ from contextlib import contextmanager
7
9
  from copy import copy
8
10
  from sqlite3 import IntegrityError
9
11
  from typing import ForwardRef
10
12
 
11
13
  import pytest
14
+ from pydal.validators import IS_NOT_IN_DB
12
15
 
13
- from src.typedal import TypedRows
16
+ from src.typedal import TypeDAL, TypedRows
14
17
  from src.typedal.__about__ import __version__
15
18
  from src.typedal.enum_helpers import InvalidEnumValue
16
19
  from src.typedal.fields import *
@@ -48,6 +51,47 @@ def test_database_is_garbage_collected_after_close():
48
51
  assert db_ref() is None
49
52
 
50
53
 
54
+ def test_database_is_garbage_collected_after_context_manager():
55
+
56
+ @contextmanager
57
+ def typedal_context():
58
+ db = TypeDAL("sqlite:memory", enable_typedal_caching=False)
59
+ try:
60
+ yield db
61
+ db.commit()
62
+ except:
63
+ db.rollback()
64
+ finally:
65
+ db.close()
66
+
67
+ def in_scope():
68
+ with typedal_context() as db:
69
+ @db.define
70
+ class RelatedTable(TypedTable):
71
+ value = TypedField(str)
72
+
73
+ @db.define
74
+ class TemporaryTable(TypedTable):
75
+ related: RelatedTable
76
+ value = TypedField(str)
77
+
78
+ @classmethod
79
+ def __on_define__(cls, db):
80
+ cls.id.requires = IS_NOT_IN_DB(db, cls.id)
81
+ cls.after_insert(lambda _row, _id, db=db: None)
82
+
83
+ query = TemporaryTable.where(id=0)
84
+ assert not query.paginate(limit=1)
85
+
86
+ return weakref.ref(db)
87
+
88
+ db_ref = in_scope()
89
+
90
+ gc.collect()
91
+
92
+ assert db_ref() is None
93
+
94
+
51
95
  def test_database_with_auth_user_is_garbage_collected_after_close():
52
96
  db = TypeDAL("sqlite:memory", enable_typedal_caching=False)
53
97
  db.define(AuthUser, redefine=True)
@@ -67,6 +111,37 @@ def test_database_with_auth_user_is_garbage_collected_after_close():
67
111
  assert db_ref() is None
68
112
 
69
113
 
114
+ def test_database_is_garbage_collected_after_close_postgres(dal_psql_uri, tmp_path):
115
+ # Postgres (and Snowflake) dialects register extra Expression methods (e.g. `.dow`) in the
116
+ # process-wide, class-level `Expression._dialect_expressions_` dict. That's a permanent
117
+ # reference living outside of any db-specific object graph, so unlike the sqlite case above,
118
+ # gc.collect() alone can't free it - close() has to explicitly undo the registration.
119
+ # (built directly from the conftest `postgres` container, not the `dal_psql` fixture: a
120
+ # suspended fixture generator would itself keep `db` alive for the duration of the test)
121
+
122
+ db = TypeDAL(dal_psql_uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=str(tmp_path))
123
+
124
+ @db.define
125
+ class TemporaryPsqlTable(TypedTable):
126
+ value = TypedField(str)
127
+
128
+ db_ref = weakref.ref(db)
129
+ adapter_ref = weakref.ref(db._adapter)
130
+
131
+ db.close()
132
+
133
+ assert TemporaryPsqlTable._db is None
134
+ assert TemporaryPsqlTable._table is None
135
+
136
+ del db
137
+ gc.collect()
138
+
139
+ assert db_ref() is None
140
+ # the adapter itself is allowed to leak (pydal has no public API to fully undo the global
141
+ # dialect registration) as long as it no longer drags the rest of the db graph with it:
142
+ assert adapter_ref() is None or adapter_ref().db is None
143
+
144
+
70
145
  def test_mixed_defines(capsys):
71
146
  ### DEFINE
72
147
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes