Coverage for src/typedal/cli.py: 100%
64 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-04 19:01 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-04 19:01 +0100
1"""
2Typer CLI for TypeDAL.
3"""
4import sys
5import typing
6import warnings
7from pathlib import Path
8from typing import Optional
10from configuraptor import asdict
11from configuraptor.alias import is_alias
12from configuraptor.helpers import is_optional
14try:
15 import edwh_migrate
16 import pydal2sql # noqa: F401
17 import questionary
18 import rich
19 import tomlkit
20 import typer
21except ImportError as e: # pragma: no cover
22 # ImportWarning is hidden by default
23 warnings.warn(
24 "`migrations` extra not installed. Please run `pip install typedal[migrations]` to fix this.",
25 source=e,
26 category=RuntimeWarning,
27 )
28 exit(127) # command not found
30from pydal2sql.typer_support import IS_DEBUG, with_exit_code
31from pydal2sql.types import (
32 DBType_Option,
33 OptionalArgument,
34 OutputFormat_Option,
35 Tables_Option,
36)
37from pydal2sql_core import core_alter, core_create
38from typing_extensions import Never
40from .__about__ import __version__
41from .config import TypeDALConfig, fill_defaults, load_config, transform
43app = typer.Typer(
44 no_args_is_help=True,
45)
47questionary_types: dict[typing.Hashable, Optional[dict[str, typing.Any]]] = {
48 str: {
49 "type": "text",
50 "validate": lambda text: True if len(text) > 0 else "Please enter a value",
51 },
52 Optional[str]: {
53 "type": "text",
54 # no validate because it's optional
55 },
56 bool: {
57 "type": "confirm",
58 },
59 int: {"type": "text", "validate": lambda text: True if text.isdigit() else "Please enter a number"},
60 # specific props:
61 "dialect": {
62 "type": "select",
63 "choices": ["sqlite", "postgres", "mysql"],
64 },
65 "folder": {
66 "type": "path",
67 "message": "Database directory:",
68 "only_directories": True,
69 "default": "",
70 },
71 "input": {
72 "type": "path",
73 "message": "Python file containing table definitions.",
74 "file_filter": lambda file: "." not in file or file.endswith(".py"),
75 },
76 "output": {
77 "type": "path",
78 "message": "Python file where migrations will be written to.",
79 "file_filter": lambda file: "." not in file or file.endswith(".py"),
80 },
81 # disabled props:
82 "pyproject": None,
83 "noop": None,
84 # bool: questionary.confirm,
85 # int: questionary.text,
86 # 'pyproject': None,
87 # 'input': questionary.text,
88 # 'output': questionary.text,
89 # 'tables': questionary.print,
90 # 'flag_location': questionary.path, # directory
91}
93T = typing.TypeVar("T")
95notfound = object()
98def _get_question(prop: str, annotation: typing.Type[T]) -> Optional[dict[str, typing.Any]]: # pragma: no cover
99 question = questionary_types.get(prop, notfound)
100 if question is notfound:
101 # None means skip the question, notfound means use the type default!
102 question = questionary_types.get(annotation) # type: ignore
104 if not question:
105 return None
106 # make a copy so the original is not overwritten:
107 return question.copy() # type: ignore
110def get_question(prop: str, annotation: typing.Type[T], default: T | None) -> Optional[T]: # pragma: no cover
111 """
112 Generate a question based on a config property and prompt the user for it.
113 """
114 if not (question := _get_question(prop, annotation)):
115 return default
117 question["name"] = prop
118 question["message"] = question.get("message", f"{prop}? ")
119 default = typing.cast(T, default or question.get("default") or "")
121 if annotation == int:
122 default = typing.cast(T, str(default))
124 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 toml_obj: dict[str, typing.Any] = tomlkit.loads(toml_contents)
155 if "[tool.typedal]" in toml_contents:
156 section = toml_obj["tool"]["typedal"]
157 config.update(**section, _overwrite=True)
159 if "[tool.pydal2sql]" in toml_contents:
160 mapping = {"": ""} # <- placeholder
162 extra_config = toml_obj["tool"]["pydal2sql"]
163 extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
164 extra_config.pop("format", None) # always edwh-migrate
165 config.update(**extra_config)
167 if "[tool.migrate]" in toml_contents:
168 mapping = {"migrate_uri": "database"}
170 extra_config = toml_obj["tool"]["migrate"]
171 extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}
173 config.update(**extra_config)
175 data = asdict(config, with_top_level_key=False)
176 data["migrate"] = None # determined based on existence of input/output file.
177 for prop, annotation in TypeDALConfig.__annotations__.items():
178 if is_alias(config.__class__, prop):
179 # don't store aliases!
180 data.pop(prop, None)
181 continue
183 if minimal and getattr(config, prop, None) not in (None, "") or is_optional(annotation):
184 # property already present or not required, SKIP!
185 data[prop] = getattr(config, prop, None)
186 continue
188 fill_defaults(data, prop)
189 # default_value = getattr(config, prop, None)
190 default_value = data.get(prop, None)
191 answer: typing.Any = get_question(prop, annotation, default_value)
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)
212 # ignore any None:
213 old_contents["tool"]["typedal"] = {k: v for k, v in data.items() if v is not None}
215 with toml_path.open("w") as f:
216 tomlkit.dump(old_contents, f)
218 rich.print(f"[green]Wrote updated config to {toml_path}![/green]")
221@app.command()
222@with_exit_code(hide_tb=IS_DEBUG)
223def generate_migrations(
224 filename_before: OptionalArgument[str] = None,
225 filename_after: OptionalArgument[str] = None,
226 dialect: DBType_Option = None,
227 tables: Tables_Option = None,
228 magic: Optional[bool] = None,
229 noop: Optional[bool] = None,
230 function: Optional[str] = None,
231 output_format: OutputFormat_Option = None,
232 output_file: Optional[str] = None,
233 dry_run: bool = False,
234) -> bool:
235 """
236 Run pydal2sql based on the typedal config.
237 """
238 # 1. choose CREATE or ALTER based on whether 'output' exists?
239 # 2. pass right args based on 'config' to function chosen in 1.
240 generic_config = load_config()
241 pydal2sql_config = generic_config.to_pydal2sql()
242 pydal2sql_config.update(
243 magic=magic,
244 noop=noop,
245 tables=tables,
246 db_type=dialect.value if dialect else None,
247 function=function,
248 format=output_format,
249 input=filename_before,
250 output=output_file,
251 )
253 if pydal2sql_config.output and Path(pydal2sql_config.output).exists():
254 if dry_run:
255 print("Would run `pyda2sql alter` with config", asdict(pydal2sql_config), file=sys.stderr)
256 sys.stderr.flush()
258 return True
259 else: # pragma: no cover
260 return core_alter(
261 pydal2sql_config.input,
262 filename_after or pydal2sql_config.input,
263 db_type=pydal2sql_config.db_type,
264 tables=pydal2sql_config.tables,
265 noop=pydal2sql_config.noop,
266 magic=pydal2sql_config.magic,
267 function=pydal2sql_config.function,
268 output_format=pydal2sql_config.format,
269 output_file=pydal2sql_config.output,
270 )
271 else:
272 if dry_run:
273 print("Would run `pyda2sql create` with config", asdict(pydal2sql_config), file=sys.stderr)
274 sys.stderr.flush()
276 return True
277 else: # pragma: no cover
278 return core_create(
279 filename=pydal2sql_config.input,
280 db_type=pydal2sql_config.db_type,
281 tables=pydal2sql_config.tables,
282 noop=pydal2sql_config.noop,
283 magic=pydal2sql_config.magic,
284 function=pydal2sql_config.function,
285 output_format=pydal2sql_config.format,
286 output_file=pydal2sql_config.output,
287 )
290@app.command()
291@with_exit_code(hide_tb=IS_DEBUG)
292def run_migrations(
293 migrations_file: OptionalArgument[str] = None,
294 db_uri: Optional[str] = None,
295 db_folder: Optional[str] = None,
296 schema_version: Optional[str] = None,
297 redis_host: Optional[str] = None,
298 migrate_cat_command: Optional[str] = None,
299 database_to_restore: Optional[str] = None,
300 migrate_table: Optional[str] = None,
301 flag_location: Optional[str] = None,
302 schema: Optional[str] = None,
303 create_flag_location: Optional[bool] = None,
304 dry_run: bool = False,
305) -> bool:
306 """
307 Run edwh-migrate based on the typedal config.
308 """
309 # 1. build migrate Config from TypeDAL config
310 # 2. import right file
311 # 3. `activate_migrations`
312 generic_config = load_config()
313 migrate_config = generic_config.to_migrate()
315 migrate_config.update(
316 migrate_uri=db_uri,
317 schema_version=schema_version,
318 redis_host=redis_host,
319 migrate_cat_command=migrate_cat_command,
320 database_to_restore=database_to_restore,
321 migrate_table=migrate_table,
322 flag_location=flag_location,
323 schema=schema,
324 create_flag_location=create_flag_location,
325 db_folder=db_folder,
326 migrations_file=migrations_file,
327 )
329 if dry_run:
330 print("Would run `migrate` with config", asdict(migrate_config), file=sys.stderr)
331 else: # pragma: no cover
332 edwh_migrate.console_hook([], config=migrate_config)
333 return True
336def version_callback() -> Never:
337 """
338 --version requested!
339 """
340 print(f"pydal2sql Version: {__version__}")
342 raise typer.Exit(0)
345def config_callback() -> Never:
346 """
347 --show-config requested.
348 """
349 config = load_config()
351 print(repr(config))
353 raise typer.Exit(0)
356@app.callback(invoke_without_command=True)
357def main(
358 _: typer.Context,
359 # stops the program:
360 show_config: bool = False,
361 version: bool = False,
362) -> None:
363 """
364 This script can be used to generate the create or alter sql from pydal or typedal.
365 """
366 if show_config:
367 config_callback()
368 elif version:
369 version_callback()
370 # else: just continue
373if __name__ == "__main__": # pragma: no cover
374 app()