TypeDAL 2.1.5__py3-none-any.whl → 2.2.1__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.

Potentially problematic release.


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

typedal/__about__.py CHANGED
@@ -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__ = "2.1.5"
8
+ __version__ = "2.2.1"
typedal/cli.py ADDED
@@ -0,0 +1,375 @@
1
+ """
2
+ Typer CLI for TypeDAL.
3
+ """
4
+ import sys
5
+ import typing
6
+ import warnings
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import tomli
11
+ from configuraptor import asdict
12
+ from configuraptor.alias import is_alias
13
+ from configuraptor.helpers import is_optional
14
+
15
+ try:
16
+ import edwh_migrate
17
+ import pydal2sql # noqa: F401
18
+ import questionary
19
+ import rich
20
+ import tomlkit
21
+ import typer
22
+ except ImportError as e: # pragma: no cover
23
+ # ImportWarning is hidden by default
24
+ warnings.warn(
25
+ "`migrations` extra not installed. Please run `pip install typedal[migrations]` to fix this.",
26
+ source=e,
27
+ category=RuntimeWarning,
28
+ )
29
+ exit(127) # command not found
30
+
31
+ from pydal2sql.typer_support import IS_DEBUG, with_exit_code
32
+ from pydal2sql.types import (
33
+ DBType_Option,
34
+ OptionalArgument,
35
+ OutputFormat_Option,
36
+ Tables_Option,
37
+ )
38
+ from pydal2sql_core import core_alter, core_create
39
+ from typing_extensions import Never
40
+
41
+ from .__about__ import __version__
42
+ from .config import TypeDALConfig, fill_defaults, load_config, transform
43
+
44
+ app = typer.Typer(
45
+ no_args_is_help=True,
46
+ )
47
+
48
+ questionary_types: dict[typing.Hashable, Optional[dict[str, typing.Any]]] = {
49
+ str: {
50
+ "type": "text",
51
+ "validate": lambda text: True if len(text) > 0 else "Please enter a value",
52
+ },
53
+ Optional[str]: {
54
+ "type": "text",
55
+ # no validate because it's optional
56
+ },
57
+ bool: {
58
+ "type": "confirm",
59
+ },
60
+ int: {"type": "text", "validate": lambda text: True if text.isdigit() else "Please enter a number"},
61
+ # specific props:
62
+ "dialect": {
63
+ "type": "select",
64
+ "choices": ["sqlite", "postgres", "mysql"],
65
+ },
66
+ "folder": {
67
+ "type": "path",
68
+ "message": "Database directory:",
69
+ "only_directories": True,
70
+ "default": "",
71
+ },
72
+ "input": {
73
+ "type": "path",
74
+ "message": "Python file containing table definitions.",
75
+ "file_filter": lambda file: "." not in file or file.endswith(".py"),
76
+ },
77
+ "output": {
78
+ "type": "path",
79
+ "message": "Python file where migrations will be written to.",
80
+ "file_filter": lambda file: "." not in file or file.endswith(".py"),
81
+ },
82
+ # disabled props:
83
+ "pyproject": None,
84
+ "noop": None,
85
+ # bool: questionary.confirm,
86
+ # int: questionary.text,
87
+ # 'pyproject': None,
88
+ # 'input': questionary.text,
89
+ # 'output': questionary.text,
90
+ # 'tables': questionary.print,
91
+ # 'flag_location': questionary.path, # directory
92
+ }
93
+
94
+ T = typing.TypeVar("T")
95
+
96
+ notfound = object()
97
+
98
+
99
+ def _get_question(prop: str, annotation: typing.Type[T]) -> Optional[dict[str, typing.Any]]: # pragma: no cover
100
+ question = questionary_types.get(prop, notfound)
101
+ if question is notfound:
102
+ # None means skip the question, notfound means use the type default!
103
+ question = questionary_types.get(annotation) # type: ignore
104
+
105
+ if not question:
106
+ return None
107
+ # make a copy so the original is not overwritten:
108
+ return question.copy() # type: ignore
109
+
110
+
111
+ def get_question(prop: str, annotation: typing.Type[T], default: T | None) -> Optional[T]: # pragma: no cover
112
+ """
113
+ Generate a question based on a config property and prompt the user for it.
114
+ """
115
+ if not (question := _get_question(prop, annotation)):
116
+ return default
117
+
118
+ question["name"] = prop
119
+ question["message"] = question.get("message", f"{prop}? ")
120
+ default = typing.cast(T, default or question.get("default") or "")
121
+
122
+ if annotation == int:
123
+ default = typing.cast(T, str(default))
124
+
125
+ response = questionary.unsafe_prompt([question], default=default)[prop]
126
+ return typing.cast(T, response)
127
+
128
+
129
+ @app.command()
130
+ @with_exit_code(hide_tb=IS_DEBUG)
131
+ def setup(
132
+ config_file: typing.Annotated[Optional[str], typer.Option("--config", "-c")] = None,
133
+ minimal: bool = False,
134
+ ) -> None: # pragma: no cover
135
+ """
136
+ Setup a [tool.typedal] entry in the local pyproject.toml.
137
+ """
138
+ # 1. check if [tool.typedal] in pyproject.toml and ask missing questions (excl .env vars)
139
+ # 2. else if [tool.migrate] and/or [tool.pydal2sql] exist in the config, ask the user with copied defaults
140
+ # 3. else: ask the user every question or minimal questions based on cli arg
141
+
142
+ config = load_config(config_file)
143
+
144
+ toml_path = Path(config.pyproject)
145
+
146
+ if not (config.pyproject and toml_path.exists()):
147
+ # no pyproject.toml found!
148
+ toml_path = toml_path if config.pyproject else Path("pyproject.toml")
149
+ rich.print(f"[blue]Config toml doesn't exist yet, creating {toml_path}[/blue]", file=sys.stderr)
150
+ toml_path.touch()
151
+
152
+ toml_contents = toml_path.read_text()
153
+ # tomli has native Python types, tomlkit doesn't but preserves comments
154
+ toml_obj: dict[str, typing.Any] = tomli.loads(toml_contents)
155
+
156
+ if "[tool.typedal]" in toml_contents:
157
+ section = toml_obj["tool"]["typedal"]
158
+ config.update(**section, _overwrite=True)
159
+
160
+ if "[tool.pydal2sql]" in toml_contents:
161
+ mapping = {"": ""} # <- placeholder
162
+
163
+ extra_config = toml_obj["tool"]["pydal2sql"]
164
+ extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
165
+ extra_config.pop("format", None) # always edwh-migrate
166
+ config.update(**extra_config)
167
+
168
+ if "[tool.migrate]" in toml_contents:
169
+ mapping = {"migrate_uri": "database"}
170
+
171
+ extra_config = toml_obj["tool"]["migrate"]
172
+ extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
173
+
174
+ config.update(**extra_config)
175
+
176
+ data = asdict(config, with_top_level_key=False)
177
+ data["migrate"] = None # determined based on existence of input/output file.
178
+ for prop, annotation in TypeDALConfig.__annotations__.items():
179
+ if is_alias(config.__class__, prop):
180
+ # don't store aliases!
181
+ data.pop(prop, None)
182
+ continue
183
+
184
+ if minimal and getattr(config, prop, None) not in (None, "") or is_optional(annotation):
185
+ # property already present or not required, SKIP!
186
+ data[prop] = getattr(config, prop, None)
187
+ continue
188
+
189
+ fill_defaults(data, prop)
190
+ # default_value = getattr(config, prop, None)
191
+ default_value = data.get(prop, None)
192
+ answer: typing.Any = get_question(prop, annotation, default_value)
193
+
194
+ if annotation == bool:
195
+ answer = bool(answer)
196
+ elif annotation == int:
197
+ answer = int(answer)
198
+
199
+ config.update(**{prop: answer})
200
+ data[prop] = answer
201
+
202
+ for prop in TypeDALConfig.__annotations__:
203
+ transform(data, prop)
204
+
205
+ with toml_path.open("r") as f:
206
+ old_contents: dict[str, typing.Any] = tomlkit.load(f)
207
+
208
+ if "tool" not in old_contents:
209
+ old_contents["tool"] = {}
210
+
211
+ data.pop("pyproject", None)
212
+
213
+ # ignore any None:
214
+ old_contents["tool"]["typedal"] = {k: v for k, v in data.items() if v is not None}
215
+
216
+ with toml_path.open("w") as f:
217
+ tomlkit.dump(old_contents, f)
218
+
219
+ rich.print(f"[green]Wrote updated config to {toml_path}![/green]")
220
+
221
+
222
+ @app.command()
223
+ @with_exit_code(hide_tb=IS_DEBUG)
224
+ def generate_migrations(
225
+ filename_before: OptionalArgument[str] = None,
226
+ filename_after: OptionalArgument[str] = None,
227
+ dialect: DBType_Option = None,
228
+ tables: Tables_Option = None,
229
+ magic: Optional[bool] = None,
230
+ noop: Optional[bool] = None,
231
+ function: Optional[str] = None,
232
+ output_format: OutputFormat_Option = None,
233
+ output_file: Optional[str] = None,
234
+ dry_run: bool = False,
235
+ ) -> bool:
236
+ """
237
+ Run pydal2sql based on the typedal config.
238
+ """
239
+ # 1. choose CREATE or ALTER based on whether 'output' exists?
240
+ # 2. pass right args based on 'config' to function chosen in 1.
241
+ generic_config = load_config()
242
+ pydal2sql_config = generic_config.to_pydal2sql()
243
+ pydal2sql_config.update(
244
+ magic=magic,
245
+ noop=noop,
246
+ tables=tables,
247
+ db_type=dialect.value if dialect else None,
248
+ function=function,
249
+ format=output_format,
250
+ input=filename_before,
251
+ output=output_file,
252
+ )
253
+
254
+ if pydal2sql_config.output and Path(pydal2sql_config.output).exists():
255
+ if dry_run:
256
+ print("Would run `pyda2sql alter` with config", asdict(pydal2sql_config), file=sys.stderr)
257
+ sys.stderr.flush()
258
+
259
+ return True
260
+ else: # pragma: no cover
261
+ return core_alter(
262
+ pydal2sql_config.input,
263
+ filename_after or pydal2sql_config.input,
264
+ db_type=pydal2sql_config.db_type,
265
+ tables=pydal2sql_config.tables,
266
+ noop=pydal2sql_config.noop,
267
+ magic=pydal2sql_config.magic,
268
+ function=pydal2sql_config.function,
269
+ output_format=pydal2sql_config.format,
270
+ output_file=pydal2sql_config.output,
271
+ )
272
+ else:
273
+ if dry_run:
274
+ print("Would run `pyda2sql create` with config", asdict(pydal2sql_config), file=sys.stderr)
275
+ sys.stderr.flush()
276
+
277
+ return True
278
+ else: # pragma: no cover
279
+ return core_create(
280
+ filename=pydal2sql_config.input,
281
+ db_type=pydal2sql_config.db_type,
282
+ tables=pydal2sql_config.tables,
283
+ noop=pydal2sql_config.noop,
284
+ magic=pydal2sql_config.magic,
285
+ function=pydal2sql_config.function,
286
+ output_format=pydal2sql_config.format,
287
+ output_file=pydal2sql_config.output,
288
+ )
289
+
290
+
291
+ @app.command()
292
+ @with_exit_code(hide_tb=IS_DEBUG)
293
+ def run_migrations(
294
+ migrations_file: OptionalArgument[str] = None,
295
+ db_uri: Optional[str] = None,
296
+ db_folder: Optional[str] = None,
297
+ schema_version: Optional[str] = None,
298
+ redis_host: Optional[str] = None,
299
+ migrate_cat_command: Optional[str] = None,
300
+ database_to_restore: Optional[str] = None,
301
+ migrate_table: Optional[str] = None,
302
+ flag_location: Optional[str] = None,
303
+ schema: Optional[str] = None,
304
+ create_flag_location: Optional[bool] = None,
305
+ dry_run: bool = False,
306
+ ) -> bool:
307
+ """
308
+ Run edwh-migrate based on the typedal config.
309
+ """
310
+ # 1. build migrate Config from TypeDAL config
311
+ # 2. import right file
312
+ # 3. `activate_migrations`
313
+ generic_config = load_config()
314
+ migrate_config = generic_config.to_migrate()
315
+
316
+ migrate_config.update(
317
+ migrate_uri=db_uri,
318
+ schema_version=schema_version,
319
+ redis_host=redis_host,
320
+ migrate_cat_command=migrate_cat_command,
321
+ database_to_restore=database_to_restore,
322
+ migrate_table=migrate_table,
323
+ flag_location=flag_location,
324
+ schema=schema,
325
+ create_flag_location=create_flag_location,
326
+ db_folder=db_folder,
327
+ migrations_file=migrations_file,
328
+ )
329
+
330
+ if dry_run:
331
+ print("Would run `migrate` with config", asdict(migrate_config), file=sys.stderr)
332
+ else: # pragma: no cover
333
+ edwh_migrate.console_hook([], config=migrate_config)
334
+ return True
335
+
336
+
337
+ def version_callback() -> Never:
338
+ """
339
+ --version requested!
340
+ """
341
+ print(f"pydal2sql Version: {__version__}")
342
+
343
+ raise typer.Exit(0)
344
+
345
+
346
+ def config_callback() -> Never:
347
+ """
348
+ --show-config requested.
349
+ """
350
+ config = load_config()
351
+
352
+ print(repr(config))
353
+
354
+ raise typer.Exit(0)
355
+
356
+
357
+ @app.callback(invoke_without_command=True)
358
+ def main(
359
+ _: typer.Context,
360
+ # stops the program:
361
+ show_config: bool = False,
362
+ version: bool = False,
363
+ ) -> None:
364
+ """
365
+ This script can be used to generate the create or alter sql from pydal or typedal.
366
+ """
367
+ if show_config:
368
+ config_callback()
369
+ elif version:
370
+ version_callback()
371
+ # else: just continue
372
+
373
+
374
+ if __name__ == "__main__": # pragma: no cover
375
+ app()
typedal/config.py ADDED
@@ -0,0 +1,271 @@
1
+ """
2
+ TypeDAL can be configured by a combination of pyproject.toml (static), env (dynamic) and code (programmic).
3
+ """
4
+ import os
5
+ import typing
6
+ import warnings
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import black.files
11
+ import tomli
12
+ from configuraptor import TypedConfig, alias
13
+ from dotenv import dotenv_values, find_dotenv
14
+
15
+ if typing.TYPE_CHECKING: # pragma: no cover
16
+ from edwh_migrate import Config as MigrateConfig
17
+ from pydal2sql.typer_support import Config as P2SConfig
18
+
19
+
20
+ class TypeDALConfig(TypedConfig):
21
+ """
22
+ Unified config for TypeDAL runtime behavior and migration utilities.
23
+ """
24
+
25
+ # typedal:
26
+ database: str
27
+ dialect: str
28
+ folder: str = "databases"
29
+ caching: bool = True
30
+ pool_size: int = 0
31
+ pyproject: str
32
+ connection: str = "default"
33
+
34
+ # pydal2sql:
35
+ input: str = "" # noqa: A003
36
+ output: str = ""
37
+ noop: bool = False
38
+ magic: bool = True
39
+ tables: Optional[list[str]] = None
40
+ function: str = "define_tables"
41
+
42
+ # edwh-migrate:
43
+ # migrate uri = database
44
+ database_to_restore: Optional[str]
45
+ migrate_cat_command: Optional[str]
46
+ schema_version: Optional[str]
47
+ redis_host: Optional[str]
48
+ migrate_table: str = "typedal_implemented_features"
49
+ flag_location: str
50
+ create_flag_location: bool = True
51
+ schema: str = "public"
52
+
53
+ # typedal (depends on properties above)
54
+ migrate: bool = True
55
+ fake_migrate: bool = False
56
+
57
+ # aliases:
58
+ db_uri: str = alias("database")
59
+ db_type: str = alias("dialect")
60
+ db_folder: str = alias("folder")
61
+
62
+ def __repr__(self) -> str:
63
+ """
64
+ Dump the config to a (fancy) string.
65
+ """
66
+ return f"<TypeDAL {self.__dict__}>"
67
+
68
+ def to_pydal2sql(self) -> "P2SConfig":
69
+ """
70
+ Convert the config to the format required by pydal2sql.
71
+ """
72
+ from pydal2sql.typer_support import Config, get_pydal2sql_config
73
+
74
+ if self.pyproject: # pragma: no cover
75
+ project = Path(self.pyproject).read_text()
76
+
77
+ if "[tool.typedal]" not in project and "[tool.pydal2sql]" in project:
78
+ # no typedal config, but existing p2s config:
79
+ return get_pydal2sql_config(self.pyproject)
80
+
81
+ return Config.load(
82
+ {
83
+ "db_type": self.dialect,
84
+ "format": "edwh-migrate",
85
+ "tables": self.tables,
86
+ "magic": self.magic,
87
+ "function": self.function,
88
+ "input": self.input,
89
+ "output": self.output,
90
+ "pyproject": self.pyproject,
91
+ }
92
+ )
93
+
94
+ def to_migrate(self) -> "MigrateConfig":
95
+ """
96
+ Convert the config to the format required by edwh-migrate.
97
+ """
98
+ from edwh_migrate import Config, get_config
99
+
100
+ if self.pyproject: # pragma: no cover
101
+ project = Path(self.pyproject).read_text()
102
+
103
+ if "[tool.typedal]" not in project and "[tool.migrate]" in project:
104
+ # no typedal config, but existing p2s config:
105
+ return get_config()
106
+
107
+ return Config.load(
108
+ {
109
+ "migrate_uri": self.database,
110
+ "schema_version": self.schema_version,
111
+ "redis_host": self.redis_host,
112
+ "migrate_cat_command": self.migrate_cat_command,
113
+ "database_to_restore": self.database_to_restore,
114
+ "migrate_table": self.migrate_table,
115
+ "flag_location": self.flag_location,
116
+ "create_flag_location": self.create_flag_location,
117
+ "schema": self.schema,
118
+ "db_folder": self.folder,
119
+ "migrations_file": self.output,
120
+ }
121
+ )
122
+
123
+
124
+ def find_pyproject_toml(directory: str | None = None) -> typing.Optional[str]:
125
+ """
126
+ Find the project's config toml, looks up until it finds the project root (black's logic).
127
+ """
128
+ return black.files.find_pyproject_toml((directory or os.getcwd(),))
129
+
130
+
131
+ def _load_toml(path: str | bool | None = True) -> tuple[str, dict[str, Any]]:
132
+ """
133
+ Path can be a file, a directory, a bool or None.
134
+
135
+ If it is True or None, the default logic is used.
136
+ If it is False, no data is loaded.
137
+ if it is a directory, the pyproject.toml will be searched there.
138
+ If it is a path, that file will be used.
139
+ """
140
+ if path is False:
141
+ toml_path = None
142
+ elif path in (True, None):
143
+ toml_path = find_pyproject_toml()
144
+ elif Path(str(path)).is_file():
145
+ toml_path = str(path)
146
+ else:
147
+ toml_path = find_pyproject_toml(str(path))
148
+
149
+ if not toml_path:
150
+ # nothing to load
151
+ return "", {}
152
+
153
+ try:
154
+ with open(toml_path, "rb") as f:
155
+ data = tomli.load(f)
156
+
157
+ return toml_path or "", typing.cast(dict[str, Any], data["tool"]["typedal"])
158
+ except Exception as e:
159
+ warnings.warn(f"Could not load typedal config toml: {e}", source=e)
160
+ return toml_path or "", {}
161
+
162
+
163
+ def _load_dotenv(path: str | bool | None = True) -> tuple[str, dict[str, Any]]:
164
+ if path is False:
165
+ dotenv_path = None
166
+ elif path in (True, None):
167
+ dotenv_path = find_dotenv(usecwd=True)
168
+ elif Path(str(path)).is_file():
169
+ dotenv_path = str(path)
170
+ else:
171
+ dotenv_path = str(Path(str(path)) / ".env")
172
+
173
+ if not dotenv_path:
174
+ return "", {}
175
+
176
+ # 1. find everything with TYPEDAL_ prefix
177
+ # 2. remove that prefix
178
+ # 3. format values if possible
179
+ data = dotenv_values(dotenv_path)
180
+ data |= os.environ # higher prio than .env
181
+
182
+ typedal_data = {k.lower().removeprefix("typedal_"): v for k, v in data.items()}
183
+
184
+ return dotenv_path, typedal_data
185
+
186
+
187
+ DB_ALIASES = {
188
+ "postgresql": "postgres",
189
+ "psql": "postgres",
190
+ "sqlite3": "sqlite",
191
+ }
192
+
193
+
194
+ def get_db_for_alias(db_name: str) -> str:
195
+ """
196
+ Convert a db dialect alias to the standard name.
197
+ """
198
+ return DB_ALIASES.get(db_name, db_name)
199
+
200
+
201
+ DEFAULTS: dict[str, Any | typing.Callable[[dict[str, Any]], Any]] = {
202
+ "database": lambda data: data.get("db_uri") or "sqlite:memory",
203
+ "dialect": lambda data: get_db_for_alias(data["database"].split(":")[0])
204
+ if ":" in data["database"]
205
+ else data.get("db_type"),
206
+ "migrate": lambda data: not (data.get("input") or data.get("output")),
207
+ "folder": lambda data: data.get("db_folder"),
208
+ "flag_location": lambda data: f"{db_folder}/flags"
209
+ if (db_folder := (data.get("folder") or data.get("db_folder")))
210
+ else "/flags",
211
+ "pool_size": lambda data: 1 if data.get("dialect", "sqlite") == "sqlite" else 3,
212
+ }
213
+
214
+
215
+ def fill_defaults(data: dict[str, Any], prop: str) -> None:
216
+ """
217
+ Fill missing property defaults with (calculated) sane defaults.
218
+ """
219
+ if data.get(prop, None) is None:
220
+ default = DEFAULTS.get(prop)
221
+ if callable(default):
222
+ default = default(data)
223
+ data[prop] = default
224
+
225
+
226
+ TRANSFORMS: dict[str, typing.Callable[[dict[str, Any]], Any]] = {
227
+ "database": lambda data: data["database"]
228
+ if (":" in data["database"] or not data.get("dialect"))
229
+ else (data["dialect"] + "://" + data["database"])
230
+ }
231
+
232
+
233
+ def transform(data: dict[str, Any], prop: str) -> bool:
234
+ """
235
+ After the user has chosen a value, possibly transform it.
236
+ """
237
+ if fn := TRANSFORMS.get(prop):
238
+ data[prop] = fn(data)
239
+ return True
240
+ return False
241
+
242
+
243
+ def load_config(
244
+ _use_pyproject: bool | str | None = True, _use_env: bool | str | None = True, **fallback: Any
245
+ ) -> TypeDALConfig:
246
+ """
247
+ Combines multiple sources of config into one config instance.
248
+ """
249
+ # load toml data
250
+ # load .env data
251
+ # combine and fill with fallback values
252
+ # load typedal config or fail
253
+ toml_path, toml = _load_toml(_use_pyproject)
254
+ dotenv_path, dotenv = _load_dotenv(_use_env)
255
+
256
+ connection_name = dotenv.get("connection", "") or toml.get("default", "")
257
+ connection: dict[str, Any] = (toml.get(connection_name) if connection_name else toml) or {}
258
+
259
+ combined = connection | dotenv | fallback
260
+ combined = {k.replace("-", "_"): v for k, v in combined.items()}
261
+
262
+ combined["pyproject"] = toml_path
263
+ combined["connection"] = connection_name
264
+
265
+ for prop in TypeDALConfig.__annotations__:
266
+ fill_defaults(combined, prop)
267
+
268
+ for prop in TypeDALConfig.__annotations__:
269
+ transform(combined, prop)
270
+
271
+ return TypeDALConfig.load(combined, convert_types=True)
typedal/core.py CHANGED
@@ -19,10 +19,11 @@ import pydal
19
19
  from pydal._globals import DEFAULT
20
20
  from pydal.objects import Field as _Field
21
21
  from pydal.objects import Query as _Query
22
- from pydal.objects import Row, Rows
22
+ from pydal.objects import Row
23
23
  from pydal.objects import Table as _Table
24
24
  from typing_extensions import Self
25
25
 
26
+ from .config import load_config
26
27
  from .helpers import (
27
28
  DummyQuery,
28
29
  all_annotations,
@@ -52,6 +53,8 @@ from .types import (
52
53
  PaginateDict,
53
54
  Pagination,
54
55
  Query,
56
+ Rows,
57
+ Validator,
55
58
  _Types,
56
59
  )
57
60
 
@@ -336,13 +339,13 @@ class TypeDAL(pydal.DAL): # type: ignore
336
339
 
337
340
  def __init__(
338
341
  self,
339
- uri: str = "sqlite:memory",
340
- pool_size: int = 0,
341
- folder: Optional[str | Path] = None,
342
+ uri: Optional[str] = None, # default from config or 'sqlite:memory'
343
+ pool_size: int = None, # default 1 if sqlite else 3
344
+ folder: Optional[str | Path] = None, # default 'databases' in config
342
345
  db_codec: str = "UTF-8",
343
346
  check_reserved: Optional[list[str]] = None,
344
- migrate: bool = True,
345
- fake_migrate: bool = False,
347
+ migrate: Optional[bool] = None, # default True by config
348
+ fake_migrate: Optional[bool] = None, # default False by config
346
349
  migrate_enabled: bool = True,
347
350
  fake_migrate_all: bool = False,
348
351
  decode_credentials: bool = False,
@@ -359,24 +362,37 @@ class TypeDAL(pydal.DAL): # type: ignore
359
362
  ignore_field_case: bool = True,
360
363
  entity_quoting: bool = True,
361
364
  table_hash: Optional[str] = None,
362
- enable_typedal_caching: bool = True,
365
+ enable_typedal_caching: bool = None,
366
+ use_pyproject: bool | str = True,
367
+ use_env: bool | str = True,
363
368
  ) -> None:
364
369
  """
365
370
  Adds some internal tables after calling pydal's default init.
366
371
 
367
372
  Set enable_typedal_caching to False to disable this behavior.
368
373
  """
369
- if folder:
370
- Path(folder).mkdir(exist_ok=True)
374
+ config = load_config(_use_pyproject=use_pyproject, _use_env=use_env)
375
+ config.update(
376
+ database=uri,
377
+ dialect=uri.split(":")[0] if uri and ":" in uri else None,
378
+ folder=folder,
379
+ migrate=migrate,
380
+ fake_migrate=fake_migrate,
381
+ caching=enable_typedal_caching,
382
+ pool_size=pool_size,
383
+ )
384
+
385
+ if config.folder:
386
+ Path(config.folder).mkdir(exist_ok=True)
371
387
 
372
388
  super().__init__(
373
- uri,
374
- pool_size,
375
- str(folder),
389
+ config.database,
390
+ config.pool_size,
391
+ config.folder,
376
392
  db_codec,
377
393
  check_reserved,
378
- migrate,
379
- fake_migrate,
394
+ config.migrate,
395
+ config.fake_migrate,
380
396
  migrate_enabled,
381
397
  fake_migrate_all,
382
398
  decode_credentials,
@@ -395,7 +411,7 @@ class TypeDAL(pydal.DAL): # type: ignore
395
411
  table_hash,
396
412
  )
397
413
 
398
- if enable_typedal_caching:
414
+ if config.caching:
399
415
  self.try_define(_TypedalCache)
400
416
  self.try_define(_TypedalCacheDependency)
401
417
 
@@ -508,6 +524,7 @@ class TypeDAL(pydal.DAL): # type: ignore
508
524
  relationships=typing.cast(dict[str, Relationship[Any]], relationships),
509
525
  )
510
526
  self._class_map[str(table)] = cls
527
+ cls.__on_define__(self)
511
528
  else:
512
529
  warnings.warn("db.define used without inheriting TypedTable. This could lead to strange problems!")
513
530
 
@@ -1042,6 +1059,13 @@ class TableMeta(type):
1042
1059
 
1043
1060
  # other table methods:
1044
1061
 
1062
+ def truncate(self, mode: str = "") -> None:
1063
+ """
1064
+ Remove all data and reset index.
1065
+ """
1066
+ table = self._ensure_table_defined()
1067
+ table.truncate(mode)
1068
+
1045
1069
  def drop(self, mode: str = "") -> None:
1046
1070
  """
1047
1071
  Remove the underlying table.
@@ -1143,6 +1167,8 @@ class TypedField(typing.Generic[T_Value]): # pragma: no cover
1143
1167
  _type: T_annotation
1144
1168
  kwargs: Any
1145
1169
 
1170
+ requires: Validator | typing.Iterable[Validator]
1171
+
1146
1172
  def __init__(self, _type: typing.Type[T_Value] | types.UnionType = str, /, **settings: Any) -> None: # type: ignore
1147
1173
  """
1148
1174
  A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used!
@@ -1359,6 +1385,15 @@ class TypedTable(metaclass=TableMeta):
1359
1385
  inst._setup_instance_methods()
1360
1386
  return inst
1361
1387
 
1388
+ @classmethod
1389
+ def __on_define__(cls, db: TypeDAL) -> None:
1390
+ """
1391
+ Method that can be implemented by tables to do an action after db.define is completed.
1392
+
1393
+ This can be useful if you need to add something like requires=IS_NOT_IN_DB(db, "table.field"),
1394
+ where you need a reference to the current database, which may not exist yet when defining the model.
1395
+ """
1396
+
1362
1397
  def __iter__(self) -> typing.Generator[Any, None, None]:
1363
1398
  """
1364
1399
  Allows looping through the columns.
typedal/for_py4web.py CHANGED
@@ -1,14 +1,25 @@
1
1
  """
2
2
  ONLY USE IN COMBINATION WITH PY4WEB!
3
3
  """
4
+ import typing
4
5
  from datetime import datetime
5
6
  from typing import Any, Optional
6
7
 
7
8
  import json_fix # noqa: F401
8
9
  import threadsafevariable
9
- from py4web.core import ICECUBE, Fixture
10
+ from py4web.core import ICECUBE
11
+ from py4web.core import Fixture as _Fixture
12
+ from pydal.validators import CRYPT, IS_EMAIL, IS_NOT_EMPTY, IS_NOT_IN_DB, IS_STRONG
10
13
 
11
- from .core import TypeDAL, TypedTable
14
+ from .core import TypeDAL, TypedField, TypedTable
15
+ from .fields import PasswordField
16
+ from .types import Validator
17
+
18
+
19
+ class Fixture(_Fixture): # type: ignore
20
+ """
21
+ Make mypy happy.
22
+ """
12
23
 
13
24
 
14
25
  class DAL(TypeDAL, Fixture): # pragma: no cover
@@ -46,13 +57,23 @@ class AuthUser(TypedTable):
46
57
 
47
58
  # call db.define on this when ready
48
59
 
49
- email: str
50
- password: str
51
- first_name: Optional[str]
52
- last_name: Optional[str]
53
- sso_id: Optional[str]
54
- action_token: Optional[str]
55
- last_password_change: Optional[datetime]
60
+ email: TypedField[str]
61
+ password = PasswordField(requires=[IS_STRONG(entropy=45), CRYPT()])
62
+ first_name: TypedField[Optional[str]]
63
+ last_name: TypedField[Optional[str]]
64
+ sso_id: TypedField[Optional[str]]
65
+ action_token: TypedField[Optional[str]]
66
+ last_password_change: TypedField[Optional[datetime]]
67
+
56
68
  # past_passwords_hash: Optional[str]
57
69
  # username: Optional[str]
58
70
  # phone_number: Optional[str]
71
+
72
+ @classmethod
73
+ def __on_define__(cls, db: TypeDAL) -> None:
74
+ """
75
+ Add some requires= to the auth_user fields.
76
+ """
77
+ cls.email.requires = typing.cast(tuple[Validator, ...], (IS_EMAIL(), IS_NOT_IN_DB(db, "auth_user.email")))
78
+ cls.first_name.requires = IS_NOT_EMPTY()
79
+ cls.last_name.requires = IS_NOT_EMPTY()
typedal/types.py CHANGED
@@ -10,7 +10,9 @@ from pydal.helpers.classes import Reference as _Reference
10
10
  from pydal.objects import Expression as _Expression
11
11
  from pydal.objects import Field as _Field
12
12
  from pydal.objects import Query as _Query
13
+ from pydal.objects import Rows as _Rows
13
14
  from pydal.objects import Set as _Set
15
+ from pydal.validators import Validator as _Validator
14
16
  from typing_extensions import NotRequired
15
17
 
16
18
 
@@ -30,7 +32,7 @@ class Expression(_Expression): # type: ignore
30
32
  """
31
33
 
32
34
 
33
- class Set(_Set):
35
+ class Set(_Set): # type: ignore
34
36
  """
35
37
  Pydal Set object.
36
38
 
@@ -38,7 +40,7 @@ class Set(_Set):
38
40
  """
39
41
 
40
42
 
41
- class OpRow(_OpRow):
43
+ class OpRow(_OpRow): # type: ignore
42
44
  """
43
45
  Pydal OpRow object.
44
46
 
@@ -46,7 +48,7 @@ class OpRow(_OpRow):
46
48
  """
47
49
 
48
50
 
49
- class Reference(_Reference):
51
+ class Reference(_Reference): # type: ignore
50
52
  """
51
53
  Pydal Reference object.
52
54
 
@@ -54,7 +56,7 @@ class Reference(_Reference):
54
56
  """
55
57
 
56
58
 
57
- class Field(_Field):
59
+ class Field(_Field): # type: ignore
58
60
  """
59
61
  Pydal Field object.
60
62
 
@@ -62,6 +64,22 @@ class Field(_Field):
62
64
  """
63
65
 
64
66
 
67
+ class Rows(_Rows): # type: ignore
68
+ """
69
+ Pydal Rows object.
70
+
71
+ Make mypy happy.
72
+ """
73
+
74
+
75
+ class Validator(_Validator): # type: ignore
76
+ """
77
+ Pydal Validator object.
78
+
79
+ Make mypy happy.
80
+ """
81
+
82
+
65
83
  class _Types:
66
84
  """
67
85
  Internal type storage for stuff that mypy otherwise won't understand.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: TypeDAL
3
- Version: 2.1.5
3
+ Version: 2.2.1
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
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: Implementation :: CPython
16
16
  Classifier: Programming Language :: Python :: Implementation :: PyPy
17
17
  Requires-Python: >=3.10
18
+ Requires-Dist: configuraptor>=1.24.0
18
19
  Requires-Dist: dill
19
20
  Requires-Dist: pydal
20
21
  Provides-Extra: all
@@ -22,6 +23,9 @@ Requires-Dist: edwh-migrate; extra == 'all'
22
23
  Requires-Dist: json-fix; extra == 'all'
23
24
  Requires-Dist: py4web; extra == 'all'
24
25
  Requires-Dist: pydal2sql[all]; extra == 'all'
26
+ Requires-Dist: questionary; extra == 'all'
27
+ Requires-Dist: tomlkit; extra == 'all'
28
+ Requires-Dist: typer; extra == 'all'
25
29
  Provides-Extra: dev
26
30
  Requires-Dist: hatch; extra == 'dev'
27
31
  Requires-Dist: mkdocs; extra == 'dev'
@@ -32,6 +36,9 @@ Requires-Dist: su6[all]; extra == 'dev'
32
36
  Provides-Extra: migrations
33
37
  Requires-Dist: edwh-migrate; extra == 'migrations'
34
38
  Requires-Dist: pydal2sql; extra == 'migrations'
39
+ Requires-Dist: questionary; extra == 'migrations'
40
+ Requires-Dist: tomlkit; extra == 'migrations'
41
+ Requires-Dist: typer; extra == 'migrations'
35
42
  Provides-Extra: py4web
36
43
  Requires-Dist: json-fix; extra == 'py4web'
37
44
  Requires-Dist: py4web; extra == 'py4web'
@@ -55,10 +62,13 @@ the underlying `db.define_table` pydal Tables.
55
62
  - `TypeDAL` is the replacement class for DAL that manages the code on top of DAL.
56
63
  - `TypedTable` must be the parent class of any custom Tables you define (e.g. `class SomeTable(TypedTable)`)
57
64
  - `TypedField` can be used instead of Python native types when extra settings (such as `default`) are required (
58
- e.g. `name = TypedField(str, default="John Doe")`). It can also be used in an annotation (`name: TypedField[str]`) to improve
65
+ e.g. `name = TypedField(str, default="John Doe")`). It can also be used in an annotation (`name: TypedField[str]`) to
66
+ improve
59
67
  editor support over only annotating with `str`.
60
- - `TypedRows`: can be used as the return type annotation of pydal's `.select()` and subscribed with the actual table class, so
61
- e.g. `rows: TypedRows[SomeTable] = db(...).select()`. When using the QueryBuilder, a `TypedRows` instance is returned by `.collect()`.
68
+ - `TypedRows`: can be used as the return type annotation of pydal's `.select()` and subscribed with the actual table
69
+ class, so
70
+ e.g. `rows: TypedRows[SomeTable] = db(...).select()`. When using the QueryBuilder, a `TypedRows` instance is returned
71
+ by `.collect()`.
62
72
 
63
73
  Version 2.0 also introduces more ORM-like funcionality.
64
74
  Most notably, a Typed Query Builder that sees your table classes as models with relationships to each other.
@@ -68,7 +78,7 @@ details.
68
78
  ## Quick Overview
69
79
 
70
80
  Below you'll find a quick overview of translation from pydal to TypeDAL. For more info,
71
- see [the docs](https://github.com/trialandsuccess/TypeDAL/tree/master/docs).
81
+ see [the docs](https://typedal.readthedocs.io/en/latest/).
72
82
 
73
83
  ### Translations from pydal to typedal
74
84
 
@@ -247,16 +257,6 @@ row: TableName = db.table_name(id=1)
247
257
 
248
258
  See [2. Defining Tables](docs/2_defining_tables.md)
249
259
 
250
- ## Roadmap
251
-
252
- This section contains a non-exhaustive list of planned features for future feature releases:
253
-
254
- - 2.2
255
- - Migrations: currently, you can use pydal's automatic migrations or disable those and manage them yourself, but
256
- adding something like [`edwh-migrate`](https://github.com/educationwarehouse/migrate#readme)
257
- with [`pydal2sql`](https://github.com/robinvandernoord/pydal2sql-core) as an option could make this project more
258
- production-friendly.
259
-
260
260
  ## Caveats
261
261
 
262
262
  - This package depends heavily on the current implementation of annotations (which are computed when the class is
@@ -0,0 +1,15 @@
1
+ typedal/__about__.py,sha256=h_6wVhT5zBpkimIzSHhAe8DZCR_Nn-sqaS8GZXghPko,206
2
+ typedal/__init__.py,sha256=QQpLiVl9w9hm2LBxey49Y_tCF_VB2bScVaS_mCjYy54,366
3
+ typedal/caching.py,sha256=53WU5J_yRLygkKoDfD0rV_dkh5oqqT--64R9Mvz050Y,7457
4
+ typedal/cli.py,sha256=MF2bGwL4SOaIqGzr1JRSvGD6CSujy88hKGgVIehlFNk,11789
5
+ typedal/config.py,sha256=9ORQqJJ_3tDmFiL2Py_9PiceXLihpMR69t6OKZ_qpe8,8387
6
+ typedal/core.py,sha256=LV0Fszbmh0pojAiARD1GKj5ooxakJmmj_4ebpvZZkcs,93644
7
+ typedal/fields.py,sha256=z2PD9vLWqBR_zXtiY0DthqTG4AeF3yxKoeuVfGXnSdg,5197
8
+ typedal/for_py4web.py,sha256=kw44_55-dBWlYmepB7YEev5sj7tbYcgzvp-Ecc7K9_I,2230
9
+ typedal/helpers.py,sha256=ZpHdwBMSANw-P9I5gs56Vf6GUbxGzFsIwbBvASKXX8s,6487
10
+ typedal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ typedal/types.py,sha256=5qm3PgS8DXGCu9ZTUWQiIi2XXD8gz4_4Csg_vZlu_yo,3379
12
+ typedal-2.2.1.dist-info/METADATA,sha256=-4kp0PN1msDhP9C07HqMh_4VCigOu0AhkN6yNrRPDkM,7623
13
+ typedal-2.2.1.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
14
+ typedal-2.2.1.dist-info/entry_points.txt,sha256=m1wqcc_10rHWPdlQ71zEkmJDADUAnZtn7Jac_6mbyUc,44
15
+ typedal-2.2.1.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ typedal = typedal.cli:app
@@ -1,12 +0,0 @@
1
- typedal/__about__.py,sha256=z5coRWunglB2VeEfC8bQlGvTzl_8V8VDJJvYOu95z3A,206
2
- typedal/__init__.py,sha256=QQpLiVl9w9hm2LBxey49Y_tCF_VB2bScVaS_mCjYy54,366
3
- typedal/caching.py,sha256=53WU5J_yRLygkKoDfD0rV_dkh5oqqT--64R9Mvz050Y,7457
4
- typedal/core.py,sha256=OVLs-1xb3i-FsHEcVXeSgyCNhzcHu-WCAlX7YX95ulg,92238
5
- typedal/fields.py,sha256=z2PD9vLWqBR_zXtiY0DthqTG4AeF3yxKoeuVfGXnSdg,5197
6
- typedal/for_py4web.py,sha256=NN6GxZpp971HFxqiI5QZaNQYydsEvJh1TctxKuZE5pw,1460
7
- typedal/helpers.py,sha256=ZpHdwBMSANw-P9I5gs56Vf6GUbxGzFsIwbBvASKXX8s,6487
8
- typedal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- typedal/types.py,sha256=AADKhLbBotpDm5FM__fhvaRNGoHPjG-LcxflnjvhOz0,3011
10
- typedal-2.1.5.dist-info/METADATA,sha256=x9hqsQdodHt8tcU5v2W-6J78Kx2YJDI0azqicUNHE1w,7814
11
- typedal-2.1.5.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
12
- typedal-2.1.5.dist-info/RECORD,,