Coverage for src/typedal/cli.py: 100%

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 from configuraptor import asdict

11 from configuraptor.alias import is_alias

12 from configuraptor.helpers import is_optional

13

14 try:

15 import edwh_migrate

16 import pydal2sql # noqa: F401

17 import questionary

18 import rich

19 import tomlkit

20 import typer

21 except 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

29

30 from pydal2sql.typer_support import IS_DEBUG, with_exit_code

31 from pydal2sql.types import (

32 DBType_Option,

33 OptionalArgument,

34 OutputFormat_Option,

35 Tables_Option,

36)

37 from pydal2sql_core import core_alter, core_create

38 from typing_extensions import Never

39

40 from .__about__ import __version__

41 from .config import TypeDALConfig, fill_defaults, load_config, transform

42

43 app = typer.Typer(

44 no_args_is_help=True,

45)

46

47 questionary_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}

92

93 T = typing.TypeVar("T")

94

95 notfound = object()

96

97

98 def _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

103

104 if not question:

105 return None

106 # make a copy so the original is not overwritten:

107 return question.copy() # type: ignore

108

109

110 def 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

116

117 question["name"] = prop

118 question["message"] = question.get("message", f"{prop}? ")

119 default = typing.cast(T, default or question.get("default") or "")

120

121 if annotation == int:

122 default = typing.cast(T, str(default))

123

124 response = questionary.unsafe_prompt([question], default=default)[prop]

125

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 toml_obj: dict[str, typing.Any] = tomlkit.loads(toml_contents)

154

155 if "[tool.typedal]" in toml_contents:

156 section = toml_obj["tool"]["typedal"]

157 config.update(**section, _overwrite=True)

158

159 if "[tool.pydal2sql]" in toml_contents:

160 mapping = {"": ""} # <- placeholder

161

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)

166

167 if "[tool.migrate]" in toml_contents:

168 mapping = {"migrate_uri": "database"}

169

170 extra_config = toml_obj["tool"]["migrate"]

171 extra_config = {mapping.get(k, k): v for k, v in extra_config.items()}

172

173 config.update(**extra_config)

174

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

182

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

187

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)

192

193 if annotation == bool:

194 answer = bool(answer)

195 elif annotation == int:

196 answer = int(answer)

197

198 config.update(**{prop: answer})

199 data[prop] = answer

200

201 for prop in TypeDALConfig.__annotations__:

202 transform(data, prop)

203

204 with toml_path.open("r") as f:

205 old_contents: dict[str, typing.Any] = tomlkit.load(f)

206

207 if "tool" not in old_contents:

208 old_contents["tool"] = {}

209

210 data.pop("pyproject", None)

211

212 # ignore any None:

213 old_contents["tool"]["typedal"] = {k: v for k, v in data.items() if v is not None}

214

215 with toml_path.open("w") as f:

216 tomlkit.dump(old_contents, f)

217

218 rich.print(f"[green]Wrote updated config to {toml_path}![/green]")

219

220

221@app.command()

222@with_exit_code(hide_tb=IS_DEBUG)

223 def 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 )

252

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()

257

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()

275

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 )

288

289

290@app.command()

291@with_exit_code(hide_tb=IS_DEBUG)

292 def 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()

314

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 )

328

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

334

335

336 def version_callback() -> Never:

337 """

338 --version requested!

339 """

340 print(f"pydal2sql Version: {__version__}")

341

342 raise typer.Exit(0)

343

344

345 def config_callback() -> Never:

346 """

347 --show-config requested.

348 """

349 config = load_config()

350

351 print(repr(config))

352

353 raise typer.Exit(0)

354

355

356@app.callback(invoke_without_command=True)

357 def 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

371

372

373 if __name__ == "__main__": # pragma: no cover

374 app()