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

65 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-14 15:09 +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, # 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 

90T = typing.TypeVar("T") 

91 

92notfound = object() 

93 

94 

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

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

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

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