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

1""" 

2Typer CLI for TypeDAL. 

3""" 

4import sys 

5import typing 

6import warnings 

7from pathlib import Path 

8from typing import Optional 

9 

10from configuraptor import asdict 

11from configuraptor.alias import is_alias 

12from configuraptor.helpers import is_optional 

13 

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 

29 

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 

39 

40from .__about__ import __version__ 

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

42 

43app = typer.Typer( 

44 no_args_is_help=True, 

45) 

46 

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} 

92 

93T = typing.TypeVar("T") 

94 

95notfound = object() 

96 

97 

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 

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 

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 

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) 

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

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 ) 

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) 

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

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 

336def version_callback() -> Never: 

337 """ 

338 --version requested! 

339 """ 

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

341 

342 raise typer.Exit(0) 

343 

344 

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

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 

371 

372 

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

374 app()