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

1""" 

2Typer CLI for TypeDAL. 

3""" 

4import sys 

5import typing 

6import warnings 

7from pathlib import Path 

8from typing import Optional 

9 

10import tomli 

11from configuraptor import asdict 

12from configuraptor.alias import is_alias 

13from configuraptor.helpers import is_optional 

14 

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 

30 

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 

40 

41from .__about__ import __version__ 

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

43 

44app = typer.Typer( 

45 no_args_is_help=True, 

46) 

47 

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} 

93 

94T = typing.TypeVar("T") 

95 

96notfound = object() 

97 

98 

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 

104 

105 if not question: 

106 return None 

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

108 return question.copy() # type: ignore 

109 

110 

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 

117 

118 question["name"] = prop 

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

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

121 

122 if annotation == int: 

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

124 

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

126 return typing.cast(T, response) 

127 

128 

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 

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 # tomli has native Python types, tomlkit doesn't but preserves comments 

154 toml_obj: dict[str, typing.Any] = tomli.loads(toml_contents) 

155 

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

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

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

159 

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

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

162 

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) 

167 

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

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

170 

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

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

173 

174 config.update(**extra_config) 

175 

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 

183 

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 

188 

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) 

193 

194 if annotation == bool: 

195 answer = bool(answer) 

196 elif annotation == int: 

197 answer = int(answer) 

198 

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

200 data[prop] = answer 

201 

202 for prop in TypeDALConfig.__annotations__: 

203 transform(data, prop) 

204 

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

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

207 

208 if "tool" not in old_contents: 

209 old_contents["tool"] = {} 

210 

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

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 ) 

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) 

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

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 

337def version_callback() -> Never: 

338 """ 

339 --version requested! 

340 """ 

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

342 

343 raise typer.Exit(0) 

344 

345 

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

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 

372 

373 

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

375 app()