Coverage for src/typedal/cli.py: 100%
65 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-14 15:09 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-14 15:09 +0100
1"""
2Typer CLI for TypeDAL.
3"""
4import sys
5import typing
6import warnings
7from pathlib import Path
8from typing import Optional
10import tomli
11from configuraptor import asdict
12from configuraptor.alias import is_alias
13from configuraptor.helpers import is_optional
15try:
16 import edwh_migrate
17 import pydal2sql # noqa: F401
18 import questionary
19 import rich
20 import tomlkit
21 import typer
22except 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
31from pydal2sql.typer_support import IS_DEBUG, with_exit_code
32from pydal2sql.types import (
33 DBType_Option,
34 OptionalArgument,
35 OutputFormat_Option,
36 Tables_Option,
37)
38from pydal2sql_core import core_alter, core_create
39from typing_extensions import Never
41from .__about__ import __version__
42from .config import TypeDALConfig, _fill_defaults, load_config, transform
44app = typer.Typer(
45 no_args_is_help=True,
46)
48questionary_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, # internal
84 "noop": None, # only for debugging
85 "connection": None, # internal
86 "migrate": None, # will probably conflict
87 "fake_migrate": None, # only enable via config if required
88}
90T = typing.TypeVar("T")
92notfound = object()
95def _get_question(prop: str, annotation: typing.Type[T]) -> Optional[dict[str, typing.Any]]: # pragma: no cover
96 question = questionary_types.get(prop, notfound)
97 if question is notfound:
98 # None means skip the question, notfound means use the type default!
99 question = questionary_types.get(annotation) # type: ignore
101 if not question:
102 return None
103 # make a copy so the original is not overwritten:
104 return question.copy() # type: ignore
107def get_question(prop: str, annotation: typing.Type[T], default: T | None) -> Optional[T]: # pragma: no cover
108 """
109 Generate a question based on a config property and prompt the user for it.
110 """
111 if not (question := _get_question(prop, annotation)):
112 return default
114 question["name"] = prop
115 question["message"] = question.get("message", f"{prop}? ")
116 default = typing.cast(T, default or question.get("default") or "")
118 if annotation == int:
119 default = typing.cast(T, str(default))
121 response = questionary.unsafe_prompt([question], default=default)[prop]
122 return typing.cast(T, response)
125@app.command()
126@with_exit_code(hide_tb=IS_DEBUG)
127def setup(
128 config_file: typing.Annotated[Optional[str], typer.Option("--config", "-c")] = None,
129 minimal: bool = False,
130) -> None: # pragma: no cover
131 """
132 Setup a [tool.typedal] entry in the local pyproject.toml.
133 """
134 # 1. check if [tool.typedal] in pyproject.toml and ask missing questions (excl .env vars)
135 # 2. else if [tool.migrate] and/or [tool.pydal2sql] exist in the config, ask the user with copied defaults
136 # 3. else: ask the user every question or minimal questions based on cli arg
138 config = load_config(config_file)
140 toml_path = Path(config.pyproject)
142 if not (config.pyproject and toml_path.exists()):
143 # no pyproject.toml found!
144 toml_path = toml_path if config.pyproject else Path("pyproject.toml")
145 rich.print(f"[blue]Config toml doesn't exist yet, creating {toml_path}[/blue]", file=sys.stderr)
146 toml_path.touch()
148 toml_contents = toml_path.read_text()
149 # tomli has native Python types, tomlkit doesn't but preserves comments
150 toml_obj: dict[str, typing.Any] = tomli.loads(toml_contents)
152 if "[tool.typedal]" in toml_contents:
153 section = toml_obj["tool"]["typedal"]
154 config.update(**section, _overwrite=True)
156 if "[tool.pydal2sql]" in toml_contents:
157 mapping = {"": ""} # <- placeholder
159 extra_config = toml_obj["tool"]["pydal2sql"]
160 extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
161 extra_config.pop("format", None) # always edwh-migrate
162 config.update(**extra_config)
164 if "[tool.migrate]" in toml_contents:
165 mapping = {"migrate_uri": "database"}
167 extra_config = toml_obj["tool"]["migrate"]
168 extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
170 config.update(**extra_config)
172 data = asdict(config, with_top_level_key=False)
173 data["migrate"] = None # determined based on existence of input/output file.
175 for prop, annotation in TypeDALConfig.__annotations__.items():
176 if is_alias(config.__class__, prop):
177 # don't store aliases!
178 data.pop(prop, None)
179 continue
181 if minimal and getattr(config, prop, None) not in (None, "") or is_optional(annotation):
182 # property already present or not required, SKIP!
183 data[prop] = getattr(config, prop, None)
184 continue
186 _fill_defaults(data, prop, data.get(prop))
187 default_value = data.get(prop, None)
188 answer: typing.Any = get_question(prop, annotation, default_value)
190 if isinstance(answer, str):
191 answer = answer.strip()
193 if annotation == bool:
194 answer = bool(answer)
195 elif annotation == int:
196 answer = int(answer)
198 config.update(**{prop: answer})
199 data[prop] = answer
201 for prop in TypeDALConfig.__annotations__:
202 transform(data, prop)
204 with toml_path.open("r") as f:
205 old_contents: dict[str, typing.Any] = tomlkit.load(f)
207 if "tool" not in old_contents:
208 old_contents["tool"] = {}
210 data.pop("pyproject", None)
211 data.pop("connection", None)
213 # ignore any None:
214 old_contents["tool"]["typedal"] = {k: v for k, v in data.items() if v is not None}
216 with toml_path.open("w") as f:
217 tomlkit.dump(old_contents, f)
219 rich.print(f"[green]Wrote updated config to {toml_path}![/green]")
222@app.command()
223@with_exit_code(hide_tb=IS_DEBUG)
224def 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 )
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()
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()
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 )
291@app.command()
292@with_exit_code(hide_tb=IS_DEBUG)
293def 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()
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 )
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
337def version_callback() -> Never:
338 """
339 --version requested!
340 """
341 print(f"pydal2sql Version: {__version__}")
343 raise typer.Exit(0)
346def config_callback() -> Never:
347 """
348 --show-config requested.
349 """
350 config = load_config()
352 print(repr(config))
354 raise typer.Exit(0)
357@app.callback(invoke_without_command=True)
358def 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
374if __name__ == "__main__": # pragma: no cover
375 app()