TypeDAL 3.9.3__tar.gz → 3.10.0__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.

Potentially problematic release.


This version of TypeDAL might be problematic. Click here for more details.

Files changed (58) hide show
  1. {typedal-3.9.3 → typedal-3.10.0}/CHANGELOG.md +12 -0
  2. {typedal-3.9.3 → typedal-3.10.0}/PKG-INFO +1 -1
  3. {typedal-3.9.3 → typedal-3.10.0}/pyproject.toml +1 -1
  4. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/__about__.py +1 -1
  5. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/for_py4web.py +2 -2
  6. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/mixins.py +75 -9
  7. {typedal-3.9.3 → typedal-3.10.0}/tests/test_mixins.py +36 -3
  8. {typedal-3.9.3 → typedal-3.10.0}/tests/test_py4web.py +20 -2
  9. {typedal-3.9.3 → typedal-3.10.0}/.github/workflows/su6.yml +0 -0
  10. {typedal-3.9.3 → typedal-3.10.0}/.gitignore +0 -0
  11. {typedal-3.9.3 → typedal-3.10.0}/.readthedocs.yml +0 -0
  12. {typedal-3.9.3 → typedal-3.10.0}/README.md +0 -0
  13. {typedal-3.9.3 → typedal-3.10.0}/coverage.svg +0 -0
  14. {typedal-3.9.3 → typedal-3.10.0}/docs/1_getting_started.md +0 -0
  15. {typedal-3.9.3 → typedal-3.10.0}/docs/2_defining_tables.md +0 -0
  16. {typedal-3.9.3 → typedal-3.10.0}/docs/3_building_queries.md +0 -0
  17. {typedal-3.9.3 → typedal-3.10.0}/docs/4_relationships.md +0 -0
  18. {typedal-3.9.3 → typedal-3.10.0}/docs/5_py4web.md +0 -0
  19. {typedal-3.9.3 → typedal-3.10.0}/docs/6_migrations.md +0 -0
  20. {typedal-3.9.3 → typedal-3.10.0}/docs/7_mixins.md +0 -0
  21. {typedal-3.9.3 → typedal-3.10.0}/docs/css/code_blocks.css +0 -0
  22. {typedal-3.9.3 → typedal-3.10.0}/docs/index.md +0 -0
  23. {typedal-3.9.3 → typedal-3.10.0}/docs/requirements.txt +0 -0
  24. {typedal-3.9.3 → typedal-3.10.0}/example_new.py +0 -0
  25. {typedal-3.9.3 → typedal-3.10.0}/example_old.py +0 -0
  26. {typedal-3.9.3 → typedal-3.10.0}/mkdocs.yml +0 -0
  27. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/__init__.py +0 -0
  28. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/caching.py +0 -0
  29. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/cli.py +0 -0
  30. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/config.py +0 -0
  31. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/core.py +0 -0
  32. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/fields.py +0 -0
  33. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/for_web2py.py +0 -0
  34. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/helpers.py +0 -0
  35. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/py.typed +0 -0
  36. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/serializers/as_json.py +0 -0
  37. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/types.py +0 -0
  38. {typedal-3.9.3 → typedal-3.10.0}/src/typedal/web2py_py4web_shared.py +0 -0
  39. {typedal-3.9.3 → typedal-3.10.0}/tests/__init__.py +0 -0
  40. {typedal-3.9.3 → typedal-3.10.0}/tests/configs/simple.toml +0 -0
  41. {typedal-3.9.3 → typedal-3.10.0}/tests/configs/valid.env +0 -0
  42. {typedal-3.9.3 → typedal-3.10.0}/tests/configs/valid.toml +0 -0
  43. {typedal-3.9.3 → typedal-3.10.0}/tests/test_cli.py +0 -0
  44. {typedal-3.9.3 → typedal-3.10.0}/tests/test_config.py +0 -0
  45. {typedal-3.9.3 → typedal-3.10.0}/tests/test_docs_examples.py +0 -0
  46. {typedal-3.9.3 → typedal-3.10.0}/tests/test_helpers.py +0 -0
  47. {typedal-3.9.3 → typedal-3.10.0}/tests/test_json.py +0 -0
  48. {typedal-3.9.3 → typedal-3.10.0}/tests/test_main.py +0 -0
  49. {typedal-3.9.3 → typedal-3.10.0}/tests/test_mypy.py +0 -0
  50. {typedal-3.9.3 → typedal-3.10.0}/tests/test_orm.py +0 -0
  51. {typedal-3.9.3 → typedal-3.10.0}/tests/test_query_builder.py +0 -0
  52. {typedal-3.9.3 → typedal-3.10.0}/tests/test_relationships.py +0 -0
  53. {typedal-3.9.3 → typedal-3.10.0}/tests/test_row.py +0 -0
  54. {typedal-3.9.3 → typedal-3.10.0}/tests/test_stats.py +0 -0
  55. {typedal-3.9.3 → typedal-3.10.0}/tests/test_table.py +0 -0
  56. {typedal-3.9.3 → typedal-3.10.0}/tests/test_web2py.py +0 -0
  57. {typedal-3.9.3 → typedal-3.10.0}/tests/test_xx_others.py +0 -0
  58. {typedal-3.9.3 → typedal-3.10.0}/tests/timings.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v3.10.0 (2025-02-17)
6
+
7
+ ### Feature
8
+
9
+ * Add a pydal validator to tables using the slug mixin (without a random suffix) to catch duplicates before the actual database insert (which raises a unique violation exception) ([`6466345`](https://github.com/trialandsuccess/TypeDAL/commit/64663454eab7a4f281660cb2df6e50e2dadd7740))
10
+
11
+ ## v3.9.4 (2024-11-10)
12
+
13
+ ### Fix
14
+
15
+ * **p4w:** Calling `for_py4web.DAL()` without any arguments (to load from config) should work even with singleton ([`16f9e68`](https://github.com/trialandsuccess/TypeDAL/commit/16f9e681eaac2fccd06a110031c0d55261a0a7e9))
16
+
5
17
  ## v3.9.3 (2024-11-08)
6
18
 
7
19
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: TypeDAL
3
- Version: 3.9.3
3
+ Version: 3.10.0
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
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = 'Typing support for PyDAL'
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
- # license = "MIT"
11
+ license-expression = "MIT"
12
12
  keywords = []
13
13
  authors = [
14
14
  { name = "Robin van der Noord", email = "contact@trialandsuccess.nl" },
@@ -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__ = "3.9.3"
8
+ __version__ = "3.10.0"
@@ -24,8 +24,8 @@ class Fixture(_Fixture): # type: ignore
24
24
  class PY4WEB_DAL_SINGLETON(MetaDAL):
25
25
  _instances: typing.ClassVar[typing.MutableMapping[str, AnyType]] = {}
26
26
 
27
- def __call__(cls, uri: str, *args: typing.Any, **kwargs: typing.Any) -> AnyType:
28
- db_uid = kwargs.get("db_uid", hashlib_md5(repr(uri)).hexdigest())
27
+ def __call__(cls, uri: typing.Optional[str] = None, *args: typing.Any, **kwargs: typing.Any) -> AnyType:
28
+ db_uid = kwargs.get("db_uid", hashlib_md5(repr(uri or (args, kwargs))).hexdigest())
29
29
  if db_uid not in cls._instances:
30
30
  cls._instances[db_uid] = super().__call__(uri, *args, **kwargs)
31
31
 
@@ -11,6 +11,8 @@ import warnings
11
11
  from datetime import datetime
12
12
  from typing import Any, Optional
13
13
 
14
+ from pydal import DAL
15
+ from pydal.validators import IS_NOT_IN_DB, ValidationError
14
16
  from slugify import slugify
15
17
 
16
18
  from .core import ( # noqa F401 - used by example in docstring
@@ -87,6 +89,56 @@ def slug_random_suffix(length: int = 8) -> str:
87
89
  return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode().strip("=")
88
90
 
89
91
 
92
+ T = typing.TypeVar("T")
93
+
94
+
95
+ # noinspection PyPep8Naming
96
+ class HAS_UNIQUE_SLUG(IS_NOT_IN_DB):
97
+ """
98
+ Checks if slugified field is already in the db.
99
+
100
+ Usage:
101
+ table.name = HAS_UNIQUE_SLUG(db, "table.slug")
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ db: TypeDAL | DAL,
107
+ field: str, # table.slug
108
+ error_message: str = "This slug is not unique: %s.",
109
+ ):
110
+ """
111
+ Based on IS_NOT_IN_DB but with less options and a different default error message.
112
+ """
113
+ super().__init__(db, field, error_message)
114
+
115
+ def validate(self, original: T, record_id: Optional[int] = None) -> T:
116
+ """
117
+ Performs checks to see if the slug already exists for a different row.
118
+ """
119
+ value = slugify(str(original))
120
+ if not value.strip():
121
+ raise ValidationError(self.translator(self.error_message))
122
+
123
+ (tablename, fieldname) = str(self.field).split(".")
124
+ table = self.dbset.db[tablename]
125
+ field = table[fieldname]
126
+ query = field == value
127
+
128
+ # make sure exclude the record_id
129
+ row_id = record_id or self.record_id
130
+ if isinstance(row_id, dict): # pragma: no cover
131
+ row_id = table(**row_id)
132
+ if row_id is not None:
133
+ query &= table._id != row_id
134
+ subset = self.dbset(query)
135
+
136
+ if subset.count():
137
+ raise ValidationError(self.error_message % value)
138
+
139
+ return original
140
+
141
+
90
142
  class SlugMixin(Mixin):
91
143
  """
92
144
  (Opinionated) example mixin to add a 'slug' field, which depends on a user-provided other field.
@@ -139,26 +191,40 @@ class SlugMixin(Mixin):
139
191
  cls.__settings__["slug_field"] = slug_field
140
192
  cls.__settings__["slug_suffix"] = slug_suffix
141
193
 
194
+ @classmethod
195
+ def __generate_slug_before_insert(cls, row: OpRow) -> None:
196
+ settings = cls.__settings__
197
+
198
+ text_input = row[settings["slug_field"]]
199
+ generated_slug = slugify(text_input)
200
+
201
+ if suffix_len := settings["slug_suffix"]:
202
+ generated_slug += f"-{slug_random_suffix(suffix_len)}"
203
+
204
+ row["slug"] = slugify(generated_slug)
205
+ return None
206
+
142
207
  @classmethod
143
208
  def __on_define__(cls, db: TypeDAL) -> None:
144
209
  """
145
210
  When db is available, include a before_insert hook to generate and include a slug.
146
211
  """
147
212
  super().__on_define__(db)
213
+ settings = cls.__settings__
148
214
 
149
215
  # slugs should not be editable (for SEO reasons), so there is only a before insert hook:
150
- def generate_slug_before_insert(row: OpRow) -> None:
151
- settings = cls.__settings__
152
-
153
- text_input = row[settings["slug_field"]]
154
- generated_slug = slugify(text_input)
216
+ cls._before_insert.append(cls.__generate_slug_before_insert)
155
217
 
156
- if suffix_len := settings["slug_suffix"]:
157
- generated_slug += f"-{slug_random_suffix(suffix_len)}"
218
+ if settings["slug_suffix"] == 0:
219
+ # add a validator to the field that will be slugified:
220
+ slug_field = getattr(cls, settings["slug_field"])
221
+ current_requires = getattr(slug_field, "requires", None) or []
222
+ if not isinstance(current_requires, list):
223
+ current_requires = [current_requires]
158
224
 
159
- row["slug"] = slugify(generated_slug)
225
+ current_requires.append(HAS_UNIQUE_SLUG(db, f"{cls}.slug"))
160
226
 
161
- cls._before_insert.append(generate_slug_before_insert)
227
+ slug_field.requires = current_requires
162
228
 
163
229
  @classmethod
164
230
  def from_slug(cls: typing.Type[T_MetaInstance], slug: str, join: bool = True) -> Optional[T_MetaInstance]:
@@ -14,7 +14,12 @@ class AllMixins(TypedTable, SlugMixin, TimestampsMixin, slug_field="name"):
14
14
  name: str
15
15
 
16
16
 
17
- class TableWithMixins(TypedTable, SlugMixin, slug_field="name", slug_suffix_length=1):
17
+ class TableWithMixins(TypedTable, SlugMixin, slug_field="name", slug_suffix_length=0):
18
+ name: str
19
+ number: Optional[int]
20
+
21
+
22
+ class TableWithSlugSuffix(TypedTable, SlugMixin, slug_field="name", slug_suffix_length=1):
18
23
  name: str
19
24
  number: Optional[int]
20
25
 
@@ -43,6 +48,7 @@ def db():
43
48
 
44
49
  _db.define(AllMixins)
45
50
  _db.define(TableWithMixins)
51
+ _db.define(TableWithSlugSuffix)
46
52
  _db.define(TableWithTimestamps)
47
53
  yield _db
48
54
 
@@ -57,10 +63,17 @@ def test_order(db):
57
63
 
58
64
 
59
65
  def test_slug(db):
60
- row = TableWithMixins.insert(name="Two Words")
66
+ row, error = TableWithMixins.validate_and_insert(name="")
67
+ assert row is None
68
+ assert error
69
+
70
+ # without random suffix: duplicates are forbidden
71
+
72
+ row, error = TableWithMixins.validate_and_insert(name="Two Words")
73
+ assert error is None
61
74
 
62
75
  assert row.name == "Two Words"
63
- assert row.slug.startswith("two-words")
76
+ assert row.slug == "two-words"
64
77
 
65
78
  assert TableWithMixins.from_slug(row.slug)
66
79
  assert TableWithMixins.from_slug("missing") is None
@@ -70,6 +83,26 @@ def test_slug(db):
70
83
  with pytest.raises(ValueError):
71
84
  TableWithMixins.from_slug_or_fail("missing")
72
85
 
86
+ row, error = TableWithMixins.validate_and_insert(name="Two Words")
87
+ assert row is None
88
+ assert error == {
89
+ 'name': 'This slug is not unique: two-words.',
90
+ }
91
+
92
+ # with random suffix: duplicates are fine
93
+
94
+ row, error = TableWithSlugSuffix.validate_and_insert(name="Two Words")
95
+ assert error is None
96
+
97
+ assert row.name == "Two Words"
98
+ assert row.slug.startswith("two-words")
99
+
100
+ row, error = TableWithSlugSuffix.validate_and_insert(name="Two Words")
101
+ assert error is None
102
+
103
+ assert row.name == "Two Words"
104
+ assert row.slug.startswith("two-words")
105
+
73
106
 
74
107
  def test_timestamps(db):
75
108
  row = TableWithTimestamps.insert(unrelated="Hi")
@@ -1,12 +1,13 @@
1
1
  import json
2
2
  import tempfile
3
- from contextlib import chdir
3
+ from contextlib_chdir import chdir
4
4
 
5
5
  from pydal.validators import IS_EMAIL, IS_NOT_IN_DB
6
6
 
7
7
  from src.typedal import TypedTable
8
8
  from src.typedal.for_py4web import DAL, AuthUser, setup_py4web_tables
9
9
  from src.typedal.serializers import as_json
10
+ from typedal.config import TypeDALConfig
10
11
 
11
12
  db = DAL("sqlite:memory")
12
13
 
@@ -59,6 +60,15 @@ def test_py4web_dal_singleton():
59
60
  db_2a = DAL("sqlite://test_py4web_dal_singleton", folder=d, enable_typedal_caching=False)
60
61
  db_2b = DAL("sqlite://test_py4web_dal_singleton", folder=d, enable_typedal_caching=False)
61
62
 
63
+ conf = {
64
+ "database": "sqlite:memory",
65
+ "dialect": "sqlite",
66
+ "pyproject": "",
67
+ "flag_location": f"{d}/flags"
68
+ }
69
+ db_3a = DAL(config=TypeDALConfig.load(conf))
70
+ db_3b = DAL(config=TypeDALConfig.load(conf))
71
+
62
72
  assert db_1a is db_1b
63
73
  assert db_1a._uri == db_1b._uri
64
74
  assert db_1a._db_uid == db_1b._db_uid
@@ -74,6 +84,14 @@ def test_py4web_dal_singleton():
74
84
  assert db_1b is not db_2b
75
85
  assert db_1b._uri != db_2b._uri
76
86
  assert db_1b._db_uid != db_2b._db_uid
77
-
87
+
88
+ assert db_3a is db_3b
89
+ assert db_3a is not db_1a
90
+ assert db_3a is not db_2a
91
+
92
+ assert db_1a._uri == db_1b._uri
93
+ assert db_3a._uri == db_1a._uri
94
+ assert db_3a._uri != db_2a._uri
95
+
78
96
  # reset singletons for later use:
79
97
  DAL._clear()
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