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 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, # 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}

89

90 T = typing.TypeVar("T")

91

92 notfound = object()

93

94

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

100

101 if not question:

102 return None

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

104 return question.copy() # type: ignore

105

106

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

113

114 question["name"] = prop

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

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

117

118 if annotation == int:

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

120

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

122 return typing.cast(T, response)

123

124

125@app.command()

126@with_exit_code(hide_tb=IS_DEBUG)

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

137

138 config = load_config(config_file)

139

140 toml_path = Path(config.pyproject)

141

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

147

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)

151

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

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

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

155

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

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

158

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)

163

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

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

166

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

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

169

170 config.update(**extra_config)

171

172 data = asdict(config, with_top_level_key=False)

173 data["migrate"] = None # determined based on existence of input/output file.

174

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

180

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

185

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)

189

190 if isinstance(answer, str):

191 answer = answer.strip()

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 data.pop("connection", 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()