Coverage for src/typedal/cli.py: 100%
65 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 14:43 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 14:43 +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,
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}
94T = typing.TypeVar("T")
96notfound = object()
99def _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
105 if not question:
106 return None
107 # make a copy so the original is not overwritten:
108 return question.copy() # type: ignore
111def 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
118 question["name"] = prop
119 question["message"] = question.get("message", f"{prop}? ")
120 default = typing.cast(T, default or question.get("default") or "")
122 if annotation == int:
123 default = typing.cast(T, str(default))
125 response = questionary.unsafe_prompt([question], default=default)[prop]
126 return typing.cast(T, response)
129@app.command()
130@with_exit_code(hide_tb=IS_DEBUG)
131def 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
142 config = load_config(config_file)
144 toml_path = Path(config.pyproject)
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()
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)
156 if "[tool.typedal]" in toml_contents:
157 section = toml_obj["tool"]["typedal"]
158 config.update(**section, _overwrite=True)
160 if "[tool.pydal2sql]" in toml_contents:
161 mapping = {"": ""} # <- placeholder
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)
168 if "[tool.migrate]" in toml_contents:
169 mapping = {"migrate_uri": "database"}
171 extra_config = toml_obj["tool"]["migrate"]
172 extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
174 config.update(**extra_config)
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
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
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)
194 if annotation == bool:
195 answer = bool(answer)
196 elif annotation == int:
197 answer = int(answer)
199 config.update(**{prop: answer})
200 data[prop] = answer
202 for prop in TypeDALConfig.__annotations__:
203 transform(data, prop)
205 with toml_path.open("r") as f:
206 old_contents: dict[str, typing.Any] = tomlkit.load(f)
208 if "tool" not in old_contents:
209 old_contents["tool"] = {}
211 data.pop("pyproject", 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()