TypeDAL 4.8.7__tar.gz → 4.9.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.
Files changed (79) hide show
  1. typedal-4.9.0/.crush/.gitignore +1 -0
  2. typedal-4.9.0/.crush/crush.db-shm +0 -0
  3. typedal-4.9.0/.crush/crush.db-wal +0 -0
  4. typedal-4.9.0/.crush/logs/crush.log +37 -0
  5. {typedal-4.8.7 → typedal-4.9.0}/CHANGELOG.md +6 -0
  6. {typedal-4.8.7 → typedal-4.9.0}/PKG-INFO +1 -1
  7. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/__about__.py +1 -1
  8. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/core.py +8 -4
  9. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/define.py +4 -2
  10. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/query_builder.py +24 -0
  11. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/tables.py +33 -2
  12. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/types.py +64 -0
  13. typedal-4.9.0/tests/__init__.py +0 -0
  14. {typedal-4.8.7 → typedal-4.9.0}/tests/test_helpers.py +6 -1
  15. {typedal-4.8.7 → typedal-4.9.0}/tests/test_main.py +41 -0
  16. {typedal-4.8.7 → typedal-4.9.0}/tests/test_query_builder.py +26 -0
  17. /typedal-4.8.7/src/typedal/py.typed → /typedal-4.9.0/.crush/init +0 -0
  18. {typedal-4.8.7 → typedal-4.9.0}/.github/workflows/su6.yml +0 -0
  19. {typedal-4.8.7 → typedal-4.9.0}/.gitignore +0 -0
  20. {typedal-4.8.7 → typedal-4.9.0}/.readthedocs.yml +0 -0
  21. {typedal-4.8.7 → typedal-4.9.0}/README.md +0 -0
  22. {typedal-4.8.7 → typedal-4.9.0}/coverage.svg +0 -0
  23. {typedal-4.8.7 → typedal-4.9.0}/docs/10_advanced_apis.md +0 -0
  24. {typedal-4.8.7 → typedal-4.9.0}/docs/1_getting_started.md +0 -0
  25. {typedal-4.8.7 → typedal-4.9.0}/docs/2_defining_tables.md +0 -0
  26. {typedal-4.8.7 → typedal-4.9.0}/docs/3_building_queries.md +0 -0
  27. {typedal-4.8.7 → typedal-4.9.0}/docs/4_relationships.md +0 -0
  28. {typedal-4.8.7 → typedal-4.9.0}/docs/5_py4web.md +0 -0
  29. {typedal-4.8.7 → typedal-4.9.0}/docs/6_migrations.md +0 -0
  30. {typedal-4.8.7 → typedal-4.9.0}/docs/7_configuration.md +0 -0
  31. {typedal-4.8.7 → typedal-4.9.0}/docs/8_mixins.md +0 -0
  32. {typedal-4.8.7 → typedal-4.9.0}/docs/9_memoization.md +0 -0
  33. {typedal-4.8.7 → typedal-4.9.0}/docs/css/code_blocks.css +0 -0
  34. {typedal-4.8.7 → typedal-4.9.0}/docs/index.md +0 -0
  35. {typedal-4.8.7 → typedal-4.9.0}/docs/requirements.txt +0 -0
  36. {typedal-4.8.7 → typedal-4.9.0}/example_new.py +0 -0
  37. {typedal-4.8.7 → typedal-4.9.0}/example_old.py +0 -0
  38. {typedal-4.8.7 → typedal-4.9.0}/mkdocs.yml +0 -0
  39. {typedal-4.8.7 → typedal-4.9.0}/pyproject.toml +0 -0
  40. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/__init__.py +0 -0
  41. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/caching.py +0 -0
  42. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/cli.py +0 -0
  43. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/config.py +0 -0
  44. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/constants.py +0 -0
  45. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/enum_helpers.py +0 -0
  46. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/fields.py +0 -0
  47. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/for_py4web.py +0 -0
  48. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/for_web2py.py +0 -0
  49. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/helpers.py +0 -0
  50. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/mixins.py +0 -0
  51. /typedal-4.8.7/tests/__init__.py → /typedal-4.9.0/src/typedal/py.typed +0 -0
  52. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/relationships.py +0 -0
  53. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/rows.py +0 -0
  54. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/serializers/as_json.py +0 -0
  55. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/serializers/typescript.py +0 -0
  56. {typedal-4.8.7 → typedal-4.9.0}/src/typedal/web2py_py4web_shared.py +0 -0
  57. {typedal-4.8.7 → typedal-4.9.0}/tasks.py +0 -0
  58. {typedal-4.8.7 → typedal-4.9.0}/tests/configs/simple.toml +0 -0
  59. {typedal-4.8.7 → typedal-4.9.0}/tests/configs/valid.env +0 -0
  60. {typedal-4.8.7 → typedal-4.9.0}/tests/configs/valid.toml +0 -0
  61. {typedal-4.8.7 → typedal-4.9.0}/tests/py314_tests.py +0 -0
  62. {typedal-4.8.7 → typedal-4.9.0}/tests/test_cli.py +0 -0
  63. {typedal-4.8.7 → typedal-4.9.0}/tests/test_config.py +0 -0
  64. {typedal-4.8.7 → typedal-4.9.0}/tests/test_docs_examples.py +0 -0
  65. {typedal-4.8.7 → typedal-4.9.0}/tests/test_json.py +0 -0
  66. {typedal-4.8.7 → typedal-4.9.0}/tests/test_mixins.py +0 -0
  67. {typedal-4.8.7 → typedal-4.9.0}/tests/test_mypy.py +0 -0
  68. {typedal-4.8.7 → typedal-4.9.0}/tests/test_orm.py +0 -0
  69. {typedal-4.8.7 → typedal-4.9.0}/tests/test_py4web.py +0 -0
  70. {typedal-4.8.7 → typedal-4.9.0}/tests/test_relationships.py +0 -0
  71. {typedal-4.8.7 → typedal-4.9.0}/tests/test_row.py +0 -0
  72. {typedal-4.8.7 → typedal-4.9.0}/tests/test_stats.py +0 -0
  73. {typedal-4.8.7 → typedal-4.9.0}/tests/test_table.py +0 -0
  74. {typedal-4.8.7 → typedal-4.9.0}/tests/test_typescript.py +0 -0
  75. {typedal-4.8.7 → typedal-4.9.0}/tests/test_typing_mypy.md +0 -0
  76. {typedal-4.8.7 → typedal-4.9.0}/tests/test_typing_pyright.md +0 -0
  77. {typedal-4.8.7 → typedal-4.9.0}/tests/test_web2py.py +0 -0
  78. {typedal-4.8.7 → typedal-4.9.0}/tests/test_xx_others.py +0 -0
  79. {typedal-4.8.7 → typedal-4.9.0}/tests/timings.py +0 -0
@@ -0,0 +1 @@
1
+ *
Binary file
Binary file
@@ -0,0 +1,37 @@
1
+ {"time":"2026-01-20T14:19:21.359487106+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
2
+ {"time":"2026-01-20T14:19:21.518115889+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
3
+ {"time":"2026-01-20T14:19:21.52626179+01:00","level":"INFO","msg":"OK 20250424200609_initial.sql (1.52ms)"}
4
+ {"time":"2026-01-20T14:19:21.526651475+01:00","level":"INFO","msg":"OK 20250515105448_add_summary_message_id.sql (330.71µs)"}
5
+ {"time":"2026-01-20T14:19:21.526995425+01:00","level":"INFO","msg":"OK 20250624000000_add_created_at_indexes.sql (325.29µs)"}
6
+ {"time":"2026-01-20T14:19:21.527303588+01:00","level":"INFO","msg":"OK 20250627000000_add_provider_to_messages.sql (293.15µs)"}
7
+ {"time":"2026-01-20T14:19:21.52790648+01:00","level":"INFO","msg":"OK 20250810000000_add_is_summary_message.sql (495.71µs)"}
8
+ {"time":"2026-01-20T14:19:21.528316924+01:00","level":"INFO","msg":"OK 20250812000000_add_todos_to_sessions.sql (389.38µs)"}
9
+ {"time":"2026-01-20T14:19:21.528324939+01:00","level":"INFO","msg":"goose: successfully migrated database to version: 20250812000000"}
10
+ {"time":"2026-01-20T14:19:21.528356959+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
11
+ {"time":"2026-01-20T14:19:21.52843831+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":102},"msg":"Initializing MCP clients"}
12
+ {"time":"2026-01-20T14:21:06.413595152+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
13
+ {"time":"2026-01-20T14:21:14.539289381+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
14
+ {"time":"2026-01-20T14:21:15.710688424+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
15
+ {"time":"2026-01-20T14:21:17.08089755+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
16
+ {"time":"2026-01-20T14:21:17.151106375+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
17
+ {"time":"2026-01-20T14:22:05.503761296+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
18
+ {"time":"2026-01-20T14:26:05.030425805+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
19
+ {"time":"2026-01-20T14:36:26.778665189+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
20
+ {"time":"2026-01-20T14:38:51.314473736+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
21
+ {"time":"2026-01-20T15:02:22.602405814+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).Shutdown.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":403},"msg":"Shutdown took 6.62949ms"}
22
+ {"time":"2026-01-20T15:02:23.719585648+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
23
+ {"time":"2026-01-20T15:02:23.881789126+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
24
+ {"time":"2026-01-20T15:02:23.884044691+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
25
+ {"time":"2026-01-20T15:02:23.884082792+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
26
+ {"time":"2026-01-20T15:02:23.884273487+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":102},"msg":"Initializing MCP clients"}
27
+ {"time":"2026-01-20T15:04:13.024457515+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
28
+ {"time":"2026-01-20T15:04:14.232072885+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
29
+ {"time":"2026-01-20T15:11:24.612161916+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).Shutdown.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":403},"msg":"Shutdown took 9.537141ms"}
30
+ {"time":"2026-01-20T15:11:25.263960575+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
31
+ {"time":"2026-01-20T15:11:25.431990051+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
32
+ {"time":"2026-01-20T15:11:25.434282035+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
33
+ {"time":"2026-01-20T15:11:25.434366662+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
34
+ {"time":"2026-01-20T15:11:25.434579879+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":102},"msg":"Initializing MCP clients"}
35
+ {"time":"2026-01-20T15:24:40.456010578+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
36
+ {"time":"2026-01-20T15:24:51.673808433+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
37
+ {"time":"2026-01-20T15:26:03.868420566+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).Cancel","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":907},"msg":"Request cancellation initiated","session_id":"4c2d1684-8db2-4ffd-83bb-9c08fe850f29"}
@@ -2,6 +2,12 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v4.9.0 (2026-06-12)
6
+
7
+ ### Feature
8
+
9
+ * **orm:** Add table and query builder permissions ([`e737666`](https://github.com/trialandsuccess/TypeDAL/commit/e737666b5f16a62a7c71ff1e0c21ed9d4f7418f5))
10
+
5
11
  ## v4.8.7 (2026-06-11)
6
12
 
7
13
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.8.7
3
+ Version: 4.9.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
@@ -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.8.7"
8
+ __version__ = "4.9.0"
@@ -37,7 +37,7 @@ except ImportError: # pragma: no cover
37
37
  if t.TYPE_CHECKING:
38
38
  from .fields import TypedField
39
39
  from .query_builder import QueryBuilder
40
- from .types import AnyDict, Expression, Rows, Set, T_Query, Table
40
+ from .types import AnyDict, DefineKwargs, Expression, Rows, Set, T_Query, Table
41
41
 
42
42
 
43
43
  # note: these functions can not be moved to a different file,
@@ -304,7 +304,11 @@ class TypeDAL(_TypeDALBase):
304
304
  }
305
305
 
306
306
  @t.overload
307
- def define[T: t.Any](self, maybe_cls: None = None, **kwargs: t.Any) -> t.Callable[[t.Type[T]], t.Type[T]]:
307
+ def define[T: t.Any](
308
+ self,
309
+ maybe_cls: None = None,
310
+ **kwargs: t.Unpack[DefineKwargs],
311
+ ) -> t.Callable[[t.Type[T]], t.Type[T]]:
308
312
  """
309
313
  Typing Overload for define without a class.
310
314
 
@@ -313,7 +317,7 @@ class TypeDAL(_TypeDALBase):
313
317
  """
314
318
 
315
319
  @t.overload
316
- def define[T: t.Any](self, maybe_cls: t.Type[T], **kwargs: t.Any) -> t.Type[T]:
320
+ def define[T: t.Any](self, maybe_cls: t.Type[T], **kwargs: t.Unpack[DefineKwargs]) -> t.Type[T]:
317
321
  """
318
322
  Typing Overload for define with a class.
319
323
 
@@ -324,7 +328,7 @@ class TypeDAL(_TypeDALBase):
324
328
  def define[T: t.Any](
325
329
  self,
326
330
  maybe_cls: t.Type[T] | None = None,
327
- **kwargs: t.Any,
331
+ **kwargs: t.Unpack[DefineKwargs],
328
332
  ) -> t.Type[T] | t.Callable[[t.Type[T]], t.Type[T]]:
329
333
  """
330
334
  Can be used as a decorator on a class that inherits `TypedTable`, \
@@ -30,7 +30,7 @@ from .helpers import (
30
30
  )
31
31
  from .relationships import Relationship, to_relationship
32
32
  from .tables import TypedTable
33
- from .types import Field, T_annotation, Table, _Types
33
+ from .types import DefineKwargs, Field, Permissions, T_annotation, Table, _Types
34
34
 
35
35
  try:
36
36
  # python 3.14+
@@ -68,8 +68,9 @@ class TableDefinitionBuilder:
68
68
  self.db = db
69
69
  self.class_map: dict[str, t.Type["TypedTable"]] = {}
70
70
 
71
- def define[T: t.Any](self, cls: t.Type[T], **kwargs: t.Any) -> t.Type[T]:
71
+ def define[T: t.Any](self, cls: t.Type[T], **kwargs: t.Unpack[DefineKwargs]) -> t.Type[T]:
72
72
  """Build and register a table from a TypedTable class."""
73
+ permissions: Permissions | None = kwargs.pop("permissions", None)
73
74
  full_dict = all_dict(cls)
74
75
  tablename = to_snake(cls.__name__)
75
76
  annotations = all_annotations(cls)
@@ -116,6 +117,7 @@ class TableDefinitionBuilder:
116
117
  db=self.db,
117
118
  table=table,
118
119
  relationships=t.cast(dict[str, Relationship[t.Any]], relationships),
120
+ permissions=permissions,
119
121
  )
120
122
  self.class_map[str(table)] = cls # tablename - pydal name
121
123
  self.class_map[cls.__name__] = cls # TableName - typedal name
@@ -33,12 +33,15 @@ from .types import (
33
33
  Metadata,
34
34
  OnQuery,
35
35
  OrderBy,
36
+ Permissions,
36
37
  Query,
37
38
  Row,
38
39
  Rows,
39
40
  SelectKwargs,
40
41
  T_MetaInstance,
41
42
  Table,
43
+ merge_permissions,
44
+ require_permission,
42
45
  )
43
46
 
44
47
 
@@ -53,6 +56,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
53
56
  select_kwargs: SelectKwargs
54
57
  relationships: dict[str, Relationship[t.Any]]
55
58
  metadata: Metadata
59
+ _permissions: Permissions
56
60
 
57
61
  def __init__(
58
62
  self,
@@ -62,6 +66,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
62
66
  select_kwargs: t.Optional[SelectKwargs] = None,
63
67
  relationships: dict[str, Relationship[t.Any]] = None,
64
68
  metadata: Metadata = None,
69
+ permissions: Permissions | None = None,
65
70
  ):
66
71
  """
67
72
  Normally, you wouldn't manually initialize a QueryBuilder but start using a method on a TypedTable.
@@ -77,6 +82,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
77
82
  self.select_kwargs = select_kwargs or {}
78
83
  self.relationships = relationships or {}
79
84
  self.metadata = metadata or {}
85
+ self._permissions = merge_permissions(getattr(model, "_permissions", None), permissions)
80
86
 
81
87
  def _ensure_table_defined(self) -> Table:
82
88
  model = self.model
@@ -130,6 +136,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
130
136
  select_kwargs: t.Optional[SelectKwargs] = None,
131
137
  relationships: dict[str, Relationship[t.Any]] = None,
132
138
  metadata: Metadata = None,
139
+ permissions: Permissions | None = None,
133
140
  ) -> "QueryBuilder[T_MetaInstance]":
134
141
  return QueryBuilder(
135
142
  self.model,
@@ -138,8 +145,15 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
138
145
  (self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs,
139
146
  (self.relationships | relationships) if relationships else self.relationships,
140
147
  (self.metadata | (metadata or {})) if metadata else self.metadata,
148
+ permissions=merge_permissions(self._permissions, permissions),
141
149
  )
142
150
 
151
+ def permissions(self, **permissions: t.Unpack[Permissions]) -> "QueryBuilder[T_MetaInstance]":
152
+ """
153
+ Return a clone of this builder with permission overrides merged in.
154
+ """
155
+ return self._extend(permissions=t.cast(Permissions, permissions))
156
+
143
157
  def select(self, *fields: t.Any, **options: t.Unpack[SelectKwargs]) -> "QueryBuilder[T_MetaInstance]":
144
158
  """
145
159
  Fields: database columns by name ('id'), by field reference (table.id) or other (e.g. table.ALL).
@@ -455,6 +469,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
455
469
  """
456
470
  Based on the current query, delete rows and return a list of deleted IDs.
457
471
  """
472
+ require_permission(self._permissions, "delete")
458
473
  db = self._get_db()
459
474
  removed_ids = [_.id for _ in db(self.query).select("id")]
460
475
  if db(self.query).delete():
@@ -472,6 +487,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
472
487
  Based on the current query, update `fields` and return a list of updated IDs.
473
488
  """
474
489
  # todo: limit?
490
+ require_permission(self._permissions, "update")
475
491
  db = self._get_db()
476
492
  updated_ids = db(self.query).select("id").column("id")
477
493
  if db(self.query).update(**fields):
@@ -553,6 +569,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
553
569
  """
554
570
  Raw version of .collect which only executes the SQL, without performing t.Any magic afterwards.
555
571
  """
572
+ require_permission(self._permissions, "read")
556
573
  db = self._get_db()
557
574
  metadata: Metadata = self.metadata.copy()
558
575
 
@@ -579,6 +596,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
579
596
  """
580
597
  Execute the built query and turn it into model instances, while handling relationships.
581
598
  """
599
+ require_permission(self._permissions, "read")
582
600
  if _to is None:
583
601
  _to = TypedRows
584
602
  into = _into or self.model
@@ -1092,6 +1110,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
1092
1110
  """
1093
1111
  Return the amount of rows matching the current query.
1094
1112
  """
1113
+ require_permission(self._permissions, "read")
1095
1114
  db = self._get_db()
1096
1115
  query = self.__count(db, distinct=distinct)
1097
1116
 
@@ -1115,6 +1134,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
1115
1134
  Returns:
1116
1135
  bool: A boolean indicating whether t.Any records exist.
1117
1136
  """
1137
+ require_permission(self._permissions, "read")
1118
1138
  return bool(self.count())
1119
1139
 
1120
1140
  def __paginate(
@@ -1146,6 +1166,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
1146
1166
  Note: when using relationships, this limit is only applied to the 'main' table and t.Any number of extra rows \
1147
1167
  can be loaded with relationship data!
1148
1168
  """
1169
+ require_permission(self._permissions, "read")
1149
1170
  builder = self.__paginate(limit, page)
1150
1171
 
1151
1172
  rows = t.cast(PaginatedRows[T_MetaInstance], builder.collect(verbose=verbose, _to=PaginatedRows))
@@ -1176,6 +1197,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
1176
1197
  pass
1177
1198
  ```
1178
1199
  """
1200
+ require_permission(self._permissions, "read")
1179
1201
  page = 1
1180
1202
 
1181
1203
  while rows := self.__paginate(chunk_size, page).collect():
@@ -1188,6 +1210,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
1188
1210
 
1189
1211
  Also adds paginate, since it would be a waste to select more rows than needed.
1190
1212
  """
1213
+ require_permission(self._permissions, "read")
1191
1214
  row = self.paginate(page=1, limit=1, verbose=verbose).first()
1192
1215
  if not row:
1193
1216
  return None
@@ -1207,6 +1230,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
1207
1230
 
1208
1231
  Basically unwraps t.Optional type.
1209
1232
  """
1233
+ require_permission(self._permissions, "read")
1210
1234
  return self.first(verbose=verbose) or throw(exception or ValueError("Nothing found!"))
1211
1235
 
1212
1236
 
@@ -28,6 +28,7 @@ from .types import (
28
28
  OnQuery,
29
29
  OpRow,
30
30
  OrderBy,
31
+ Permissions,
31
32
  Query,
32
33
  QueryLike,
33
34
  Reference,
@@ -37,6 +38,8 @@ from .types import (
37
38
  T_MetaInstance,
38
39
  T_Query,
39
40
  Table,
41
+ merge_permissions,
42
+ require_permission,
40
43
  )
41
44
 
42
45
  if t.TYPE_CHECKING:
@@ -97,18 +100,26 @@ class TableMeta(type):
97
100
  _db: TypeDAL | None = None
98
101
  _table: Table | None = None
99
102
  _relationships: dict[str, Relationship[t.Any]] | None = None
103
+ _permissions: Permissions | None = None
100
104
 
101
105
  #########################
102
106
  # TypeDAL custom logic: #
103
107
  #########################
104
108
 
105
- def __set_internals__(self, db: pydal.DAL, table: Table, relationships: dict[str, Relationship[t.Any]]) -> None:
109
+ def __set_internals__(
110
+ self,
111
+ db: pydal.DAL,
112
+ table: Table,
113
+ relationships: dict[str, Relationship[t.Any]],
114
+ permissions: Permissions | None = None,
115
+ ) -> None:
106
116
  """
107
117
  Store the related database and pydal table for later usage.
108
118
  """
109
119
  self._db = db
110
120
  self._table = table
111
121
  self._relationships = relationships
122
+ self._permissions = merge_permissions(permissions)
112
123
 
113
124
  def __getattr__(self, col: str) -> t.Optional[Field]:
114
125
  """
@@ -186,6 +197,7 @@ class TableMeta(type):
186
197
 
187
198
  """
188
199
  table = self._ensure_table_defined()
200
+ require_permission(self._permissions, "insert")
189
201
 
190
202
  result = table.insert(**fields)
191
203
  # it already is an int but mypy doesn't understand that
@@ -201,6 +213,7 @@ class TableMeta(type):
201
213
  Insert multiple rows, returns a TypedRows set of new instances.
202
214
  """
203
215
  table = self._ensure_table_defined()
216
+ require_permission(self._permissions, "insert")
204
217
  result = table.bulk_insert(items)
205
218
  return self.where(lambda row: row.id.belongs(result)).collect()
206
219
 
@@ -239,6 +252,7 @@ class TableMeta(type):
239
252
  Returns a tuple of (the created instance, a dict of errors).
240
253
  """
241
254
  table = self._ensure_table_defined()
255
+ require_permission(self._permissions, "insert")
242
256
  result = table.validate_and_insert(**fields)
243
257
  if row_id := result.get("id"):
244
258
  return self(row_id), None
@@ -256,6 +270,7 @@ class TableMeta(type):
256
270
  Returns a tuple of (the updated instance, a dict of errors).
257
271
  """
258
272
  table = self._ensure_table_defined()
273
+ require_permission(self._permissions, "update")
259
274
 
260
275
  result = table.validate_and_update(query, **fields)
261
276
 
@@ -278,7 +293,14 @@ class TableMeta(type):
278
293
  Returns a tuple of (the updated/created instance, a dict of errors).
279
294
  """
280
295
  table = self._ensure_table_defined()
281
- result = table.validate_and_update_or_insert(query, **fields)
296
+ record = table(query)
297
+
298
+ if record:
299
+ require_permission(self._permissions, "update")
300
+ result = table.validate_and_update(query, **fields)
301
+ else:
302
+ require_permission(self._permissions, "insert")
303
+ result = table.validate_and_insert(**fields)
282
304
 
283
305
  if errors := result.get("errors"):
284
306
  return None, errors
@@ -348,6 +370,12 @@ class TableMeta(type):
348
370
  """
349
371
  return QueryBuilder(self).cache(*deps, **kwargs)
350
372
 
373
+ def permissions(self: t.Type[T_MetaInstance], **permissions: bool) -> "QueryBuilder[T_MetaInstance]":
374
+ """
375
+ See QueryBuilder.permissions!
376
+ """
377
+ return QueryBuilder(self).permissions(**permissions)
378
+
351
379
  def count(self: t.Type[T_MetaInstance]) -> int:
352
380
  """
353
381
  See QueryBuilder.count!
@@ -1254,12 +1282,14 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
1254
1282
  return None
1255
1283
 
1256
1284
  def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
1285
+ require_permission(getattr(self, "_permissions", None), "update")
1257
1286
  row = self._ensure_matching_row()
1258
1287
  row.update(**fields)
1259
1288
  self.__dict__.update(**fields)
1260
1289
  return self
1261
1290
 
1262
1291
  def _update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
1292
+ require_permission(getattr(self, "_permissions", None), "update")
1263
1293
  row = self._ensure_matching_row()
1264
1294
  new_row = row.update_record(**fields)
1265
1295
  self._update(**new_row)
@@ -1277,6 +1307,7 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
1277
1307
  """
1278
1308
  Actual logic in `pydal.helpers.classes.RecordDeleter`.
1279
1309
  """
1310
+ require_permission(getattr(self, "_permissions", None), "delete")
1280
1311
  row = self._ensure_matching_row()
1281
1312
  result = row.delete_record()
1282
1313
  self.__dict__ = {} # empty self, since row is no more.
@@ -44,6 +44,41 @@ Template: t.TypeAlias = TemplateAlias # explicit export for mypy, NOT a `type`
44
44
  type AnyCallable = t.Callable[..., t.Any]
45
45
  type AnyDict = dict[str, t.Any]
46
46
 
47
+ PermissionType = t.Literal["read", "insert", "update", "delete"]
48
+
49
+ type Permissions = dict[PermissionType, bool]
50
+
51
+
52
+ def merge_permissions(*permission_sets: Permissions | None) -> Permissions:
53
+ """
54
+ Merge zero or more permission mappings, keeping the most restrictive result.
55
+
56
+ Unspecified flags default to allowed.
57
+ """
58
+ permission_types = t.get_args(PermissionType)
59
+ merged: dict[str, bool] = {key: True for key in permission_types}
60
+
61
+ for permission_set in permission_sets:
62
+ if not permission_set:
63
+ continue
64
+
65
+ for key in permission_types:
66
+ if key in permission_set:
67
+ merged[key] = merged[key] and bool(permission_set[key])
68
+
69
+ return t.cast(Permissions, merged)
70
+
71
+
72
+ def permission_allowed(permissions: Permissions | None, action: PermissionType) -> bool:
73
+ """Return whether the given permission flag is enabled."""
74
+ return merge_permissions(permissions).get(action, False)
75
+
76
+
77
+ def require_permission(permissions: Permissions | None, action: PermissionType) -> None:
78
+ """Raise PermissionError when a permission flag is disabled."""
79
+ if not permission_allowed(permissions, action):
80
+ raise PermissionError(f"{action} is not allowed")
81
+
47
82
 
48
83
  # ---------------------------------------------------------------------------
49
84
  # Protocols
@@ -307,6 +342,35 @@ class FieldSettings(t.TypedDict, total=False):
307
342
  rname: str
308
343
 
309
344
 
345
+ class DefineKwargs(t.TypedDict, total=False):
346
+ """
347
+ Keyword arguments accepted by `db.define()` and forwarded to `define_table()`.
348
+
349
+ `cache_dependency` is internal to TypeDAL and stripped before calling PyDAL.
350
+ """
351
+
352
+ # typedal-specific
353
+ cache_dependency: bool
354
+ permissions: Permissions
355
+
356
+ # common
357
+ fake_migrate: bool
358
+ migrate: bool
359
+ redefine: bool
360
+ rname: str
361
+ singular: str
362
+ plural: str
363
+ format: str
364
+
365
+ # pydal-internals
366
+ common_filter: t.Callable[..., t.Any]
367
+ on_define: t.Callable[["Table"], t.Any]
368
+ primarykey: list[str] | tuple[str, ...]
369
+ sequence_name: str
370
+ table_class: type[t.Any]
371
+ trigger_name: str
372
+
373
+
310
374
  # ---------------------------------------------------------------------------
311
375
  # Generics & Query Helpers
312
376
  # ---------------------------------------------------------------------------
File without changes
@@ -26,7 +26,7 @@ from src.typedal.helpers import (
26
26
  to_snake,
27
27
  unwrap_type,
28
28
  )
29
- from src.typedal.types import Field
29
+ from src.typedal.types import Field, merge_permissions
30
30
 
31
31
 
32
32
  def test_is_union():
@@ -273,3 +273,8 @@ def test_sql_expression_314():
273
273
  from .py314_tests import test_sql_expression_314
274
274
 
275
275
  test_sql_expression_314(database)
276
+
277
+ def test_merge_permissions():
278
+ combined = merge_permissions({"read": True, "insert": False}, {"read": False, "insert": False})
279
+ assert len(combined) == 4
280
+ assert combined == {"read": False, "insert": False, "update": True, "delete": True}
@@ -187,6 +187,47 @@ def test_mixed_defines(capsys):
187
187
  assert db.find_model(SecondNewSyntax._rname) is SecondNewSyntax
188
188
 
189
189
 
190
+ def test_disallow_insert():
191
+ @db.define(permissions={"insert": False})
192
+ class ReadOnlyTable(TypedTable):
193
+ name: str
194
+
195
+ with pytest.raises(PermissionError, match="insert"):
196
+ ReadOnlyTable.insert(name="blocked")
197
+
198
+ with pytest.raises(PermissionError, match="insert"):
199
+ ReadOnlyTable.bulk_insert([{"name": "blocked"}])
200
+
201
+ with pytest.raises(PermissionError, match="insert"):
202
+ ReadOnlyTable.validate_and_insert(name="blocked")
203
+
204
+ with pytest.raises(PermissionError, match="insert"):
205
+ ReadOnlyTable.update_or_insert(name="blocked")
206
+
207
+
208
+ def test_disallow_update_and_delete():
209
+ @db.define(permissions={"update": False})
210
+ class ReadOnlyUpdateTable(TypedTable):
211
+ name: str
212
+
213
+ update_row = ReadOnlyUpdateTable.insert(name="seed")
214
+
215
+ with pytest.raises(PermissionError, match="update"):
216
+ update_row.update_record(name="changed")
217
+
218
+ with pytest.raises(PermissionError, match="update"):
219
+ ReadOnlyUpdateTable.update(ReadOnlyUpdateTable.id == update_row.id, name="changed")
220
+
221
+ @db.define(permissions={"delete": False})
222
+ class ReadOnlyDeleteTable(TypedTable):
223
+ name: str
224
+
225
+ delete_row = ReadOnlyDeleteTable.insert(name="seed")
226
+
227
+ with pytest.raises(PermissionError, match="delete"):
228
+ delete_row.delete_record()
229
+
230
+
190
231
  def test_dont_allow_bool_in_query():
191
232
  with pytest.raises(ValueError):
192
233
  db(True)
@@ -627,6 +627,32 @@ def test_minimal_functionality_on_pydal_style_tables():
627
627
  assert first_or_fail.id == qb1.first().id
628
628
 
629
629
 
630
+ def test_query_builder_permissions():
631
+ _setup_data()
632
+
633
+ read_restricted = TestQueryTable.permissions(read=False).where(number=2)
634
+
635
+ # to_sql stays available because it is an internal/debug helper, not a query execution path.
636
+ assert "select" in read_restricted.to_sql().lower()
637
+
638
+ with pytest.raises(PermissionError, match="read"):
639
+ read_restricted.collect()
640
+
641
+ update_only = TestQueryTable.permissions(read=False, update=True, delete=False).where(number=2)
642
+ assert update_only.update(number=20) == [3]
643
+ assert TestQueryTable(3).number == 20
644
+
645
+ delete_only = TestQueryTable.where(number=4).permissions(read=False, update=False, delete=True)
646
+ assert delete_only.delete() == [5]
647
+ assert TestQueryTable(5) is None
648
+
649
+ with pytest.raises(PermissionError, match="update"):
650
+ TestQueryTable.where(number=1).permissions(update=False).update(number=11)
651
+
652
+ with pytest.raises(PermissionError, match="delete"):
653
+ TestQueryTable.where(number=0).permissions(delete=False).delete()
654
+
655
+
630
656
  def test_before_after_collect(capsys):
631
657
  _setup_data()
632
658
 
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