Coverage for python/felis/cli.py: 35%

176 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-07 01:49 -0800

1"""Click command line interface.""" 

2 

3# This file is part of felis. 

4# 

5# Developed for the LSST Data Management System. 

6# This product includes software developed by the LSST Project 

7# (https://www.lsst.org). 

8# See the COPYRIGHT file at the top-level directory of this distribution 

9# for details of code ownership. 

10# 

11# This program is free software: you can redistribute it and/or modify 

12# it under the terms of the GNU General Public License as published by 

13# the Free Software Foundation, either version 3 of the License, or 

14# (at your option) any later version. 

15# 

16# This program is distributed in the hope that it will be useful, 

17# but WITHOUT ANY WARRANTY; without even the implied warranty of 

18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19# GNU General Public License for more details. 

20# 

21# You should have received a copy of the GNU General Public License 

22# along with this program. If not, see <https://www.gnu.org/licenses/>. 

23 

24from __future__ import annotations 

25 

26import logging 

27from collections.abc import Iterable 

28from typing import IO 

29 

30import click 

31from pydantic import ValidationError 

32from sqlalchemy.engine import Engine, create_engine, make_url 

33from sqlalchemy.engine.mock import MockConnection, create_mock_engine 

34 

35from . import __version__ 

36from .datamodel import Schema 

37from .db.schema import create_database 

38from .db.utils import DatabaseContext, is_mock_url 

39from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff 

40from .metadata import MetaDataBuilder 

41from .tap_schema import DataLoader, MetadataInserter, TableManager 

42 

43__all__ = ["cli"] 

44 

45logger = logging.getLogger("felis") 

46 

47loglevel_choices = ["CRITICAL", "FATAL", "ERROR", "WARNING", "INFO", "DEBUG"] 

48 

49 

50@click.group() 

51@click.version_option(__version__) 

52@click.option( 

53 "--log-level", 

54 type=click.Choice(loglevel_choices), 

55 envvar="FELIS_LOGLEVEL", 

56 help="Felis log level", 

57 default=logging.getLevelName(logging.INFO), 

58) 

59@click.option( 

60 "--log-file", 

61 type=click.Path(), 

62 envvar="FELIS_LOGFILE", 

63 help="Felis log file path", 

64) 

65@click.option( 

66 "--id-generation/--no-id-generation", 

67 is_flag=True, 

68 help="Generate IDs for all objects that do not have them", 

69 default=True, 

70) 

71@click.pass_context 

72def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation: bool) -> None: 

73 """Felis command line tools""" 

74 ctx.ensure_object(dict) 

75 ctx.obj["id_generation"] = id_generation 

76 if ctx.obj["id_generation"]: 

77 logger.info("ID generation is enabled") 

78 else: 

79 logger.info("ID generation is disabled") 

80 if log_file: 

81 logging.basicConfig(filename=log_file, level=log_level) 

82 else: 

83 logging.basicConfig(level=log_level) 

84 

85 

86@cli.command("create", help="Create database objects from the Felis file") 

87@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://") 

88@click.option("--schema-name", help="Alternate schema name to override Felis file") 

89@click.option( 

90 "--initialize", 

91 is_flag=True, 

92 help="Create the schema in the database if it does not exist (error if already exists)", 

93) 

94@click.option( 

95 "--drop", is_flag=True, help="Drop schema if it already exists in the database (implies --initialize)" 

96) 

97@click.option("--echo", is_flag=True, help="Echo database commands as they are executed") 

98@click.option("--dry-run", is_flag=True, help="Dry run only to print out commands instead of executing") 

99@click.option( 

100 "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing" 

101) 

102@click.option("--ignore-constraints", is_flag=True, help="Ignore constraints when creating tables") 

103@click.argument("file", type=click.File()) 

104@click.pass_context 

105def create( 

106 ctx: click.Context, 

107 engine_url: str, 

108 schema_name: str | None, 

109 initialize: bool, 

110 drop: bool, 

111 echo: bool, 

112 dry_run: bool, 

113 output_file: IO[str] | None, 

114 ignore_constraints: bool, 

115 file: IO[str], 

116) -> None: 

117 """Create database objects from the Felis file. 

118 

119 Parameters 

120 ---------- 

121 engine_url 

122 SQLAlchemy Engine URL. 

123 schema_name 

124 Alternate schema name to override Felis file. 

125 initialize 

126 Create the schema in the database if it does not exist. 

127 drop 

128 Drop schema if it already exists in the database. 

129 echo 

130 Echo database commands as they are executed. 

131 dry_run 

132 Dry run only to print out commands instead of executing. 

133 output_file 

134 Write SQL commands to a file instead of executing. 

135 ignore_constraints 

136 Ignore constraints when creating tables. 

137 file 

138 Felis file to read. 

139 """ 

140 try: 

141 schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) 

142 url = make_url(engine_url) 

143 if schema_name: 

144 logger.info(f"Overriding schema name with: {schema_name}") 

145 schema.name = schema_name 

146 elif url.drivername == "sqlite": 

147 logger.info("Overriding schema name for sqlite with: main") 

148 schema.name = "main" 

149 if not url.host and not url.drivername == "sqlite": 

150 dry_run = True 

151 logger.info("Forcing dry run for non-sqlite engine URL with no host") 

152 

153 metadata = MetaDataBuilder(schema, ignore_constraints=ignore_constraints).build() 

154 logger.debug(f"Created metadata with schema name: {metadata.schema}") 

155 

156 engine: Engine | MockConnection 

157 if not dry_run and not output_file: 

158 engine = create_engine(url, echo=echo) 

159 else: 

160 if dry_run: 

161 logger.info("Dry run will be executed") 

162 engine = DatabaseContext.create_mock_engine(url, output_file) 

163 if output_file: 

164 logger.info("Writing SQL output to: " + output_file.name) 

165 

166 context = DatabaseContext(metadata, engine) 

167 

168 if drop and initialize: 

169 raise ValueError("Cannot drop and initialize schema at the same time") 

170 

171 if drop: 

172 logger.debug("Dropping schema if it exists") 

173 context.drop() 

174 initialize = True # If schema is dropped, it needs to be recreated. 

175 

176 if initialize: 

177 logger.debug("Creating schema if not exists") 

178 context.initialize() 

179 

180 context.create_all() 

181 except Exception as e: 

182 logger.exception(e) 

183 raise click.ClickException(str(e)) 

184 

185 

186@cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database") 

187@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL") 

188@click.option( 

189 "--tap-schema-name", "-n", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)" 

190) 

191@click.option( 

192 "--tap-tables-postfix", 

193 "-p", 

194 help="Postfix which is applied to standard TAP_SCHEMA table names", 

195 default="", 

196) 

197@click.option("--tap-schema-index", "-i", type=int, help="TAP_SCHEMA index of the schema in this environment") 

198@click.option("--dry-run", "-D", is_flag=True, help="Execute dry run only. Does not insert any data.") 

199@click.option("--echo", "-e", is_flag=True, help="Print out the generated insert statements to stdout") 

200@click.option("--output-file", "-o", type=click.Path(), help="Write SQL commands to a file") 

201@click.option( 

202 "--force-unbounded-arraysize", 

203 is_flag=True, 

204 help="Use unbounded arraysize by default for all variable length string columns" 

205 ", e.g., ``votable:arraysize: *`` (workaround for astropy bug #18099)", 

206) # DM-50899: Variable-length bounded strings are not handled correctly in astropy 

207@click.option( 

208 "--unique-keys", 

209 "-u", 

210 is_flag=True, 

211 help="Generate unique key_id values for keys and key_columns tables by prepending the schema name", 

212 default=False, 

213) 

214@click.argument("file", type=click.File()) 

215@click.pass_context 

216def load_tap_schema( 

217 ctx: click.Context, 

218 engine_url: str, 

219 tap_schema_name: str, 

220 tap_tables_postfix: str, 

221 tap_schema_index: int, 

222 dry_run: bool, 

223 echo: bool, 

224 output_file: str | None, 

225 force_unbounded_arraysize: bool, 

226 unique_keys: bool, 

227 file: IO[str], 

228) -> None: 

229 """Load TAP metadata from a Felis file. 

230 

231 Parameters 

232 ---------- 

233 engine_url 

234 SQLAlchemy Engine URL. 

235 tap_tables_postfix 

236 Postfix which is applied to standard TAP_SCHEMA table names. 

237 tap_schema_index 

238 TAP_SCHEMA index of the schema in this environment. 

239 dry_run 

240 Execute dry run only. Does not insert any data. 

241 echo 

242 Print out the generated insert statements to stdout. 

243 output_file 

244 Output file for writing generated SQL. 

245 file 

246 Felis file to read. 

247 

248 Notes 

249 ----- 

250 The TAP_SCHEMA database must already exist or the command will fail. This 

251 command will not initialize the TAP_SCHEMA tables. 

252 """ 

253 url = make_url(engine_url) 

254 engine: Engine | MockConnection 

255 if dry_run or is_mock_url(url): 

256 engine = create_mock_engine(url, executor=None) 

257 else: 

258 engine = create_engine(engine_url) 

259 mgr = TableManager( 

260 engine=engine, 

261 apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True, 

262 schema_name=tap_schema_name, 

263 table_name_postfix=tap_tables_postfix, 

264 ) 

265 

266 schema = Schema.from_stream( 

267 file, 

268 context={ 

269 "id_generation": ctx.obj["id_generation"], 

270 "force_unbounded_arraysize": force_unbounded_arraysize, 

271 }, 

272 ) 

273 

274 DataLoader( 

275 schema, 

276 mgr, 

277 engine, 

278 tap_schema_index=tap_schema_index, 

279 dry_run=dry_run, 

280 print_sql=echo, 

281 output_path=output_file, 

282 unique_keys=unique_keys, 

283 ).load() 

284 

285 

286@cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database") 

287@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True) 

288@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database") 

289@click.option( 

290 "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default="" 

291) 

292@click.option( 

293 "--insert-metadata/--no-insert-metadata", 

294 is_flag=True, 

295 help="Insert metadata describing TAP_SCHEMA itself", 

296 default=True, 

297) 

298@click.pass_context 

299def init_tap_schema( 

300 ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str, insert_metadata: bool 

301) -> None: 

302 """Initialize a standard TAP_SCHEMA database. 

303 

304 Parameters 

305 ---------- 

306 engine_url 

307 SQLAlchemy Engine URL. 

308 tap_schema_name 

309 Name of the TAP_SCHEMA schema in the database. 

310 tap_tables_postfix 

311 Postfix which is applied to standard TAP_SCHEMA table names. 

312 insert_metadata 

313 Insert metadata describing TAP_SCHEMA itself. 

314 If set to False, only the TAP_SCHEMA tables will be created, but no 

315 metadata will be inserted. 

316 """ 

317 url = make_url(engine_url) 

318 engine: Engine | MockConnection 

319 if is_mock_url(url): 

320 raise click.ClickException("Mock engine URL is not supported for this command") 

321 engine = create_engine(engine_url) 

322 mgr = TableManager( 

323 apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True, 

324 schema_name=tap_schema_name, 

325 table_name_postfix=tap_tables_postfix, 

326 ) 

327 mgr.initialize_database(engine) 

328 if insert_metadata: 

329 inserter = MetadataInserter(mgr, engine) 

330 inserter.insert_metadata() 

331 

332 

333@cli.command("validate", help="Validate one or more Felis YAML files") 

334@click.option( 

335 "--check-description", is_flag=True, help="Check that all objects have a description", default=False 

336) 

337@click.option( 

338 "--check-redundant-datatypes", is_flag=True, help="Check for redundant datatype overrides", default=False 

339) 

340@click.option( 

341 "--check-tap-table-indexes", 

342 is_flag=True, 

343 help="Check that every table has a unique TAP table index", 

344 default=False, 

345) 

346@click.option( 

347 "--check-tap-principal", 

348 is_flag=True, 

349 help="Check that at least one column per table is flagged as TAP principal", 

350 default=False, 

351) 

352@click.argument("files", nargs=-1, type=click.File()) 

353@click.pass_context 

354def validate( 

355 ctx: click.Context, 

356 check_description: bool, 

357 check_redundant_datatypes: bool, 

358 check_tap_table_indexes: bool, 

359 check_tap_principal: bool, 

360 files: Iterable[IO[str]], 

361) -> None: 

362 """Validate one or more felis YAML files. 

363 

364 Parameters 

365 ---------- 

366 check_description 

367 Check that all objects have a valid description. 

368 check_redundant_datatypes 

369 Check for redundant type overrides. 

370 check_tap_table_indexes 

371 Check that every table has a unique TAP table index. 

372 check_tap_principal 

373 Check that at least one column per table is flagged as TAP principal. 

374 files 

375 The Felis YAML files to validate. 

376 

377 Raises 

378 ------ 

379 click.exceptions.Exit 

380 Raised if any validation errors are found. The ``ValidationError`` 

381 which is thrown when a schema fails to validate will be logged as an 

382 error message. 

383 

384 Notes 

385 ----- 

386 All of the ``check`` flags are turned off by default and represent 

387 optional validations controlled by the Pydantic context. 

388 """ 

389 rc = 0 

390 for file in files: 

391 file_name = getattr(file, "name", None) 

392 logger.info(f"Validating {file_name}") 

393 try: 

394 Schema.from_stream( 

395 file, 

396 context={ 

397 "check_description": check_description, 

398 "check_redundant_datatypes": check_redundant_datatypes, 

399 "check_tap_table_indexes": check_tap_table_indexes, 

400 "check_tap_principal": check_tap_principal, 

401 "id_generation": ctx.obj["id_generation"], 

402 }, 

403 ) 

404 except ValidationError as e: 

405 logger.error(e) 

406 rc = 1 

407 if rc: 

408 raise click.exceptions.Exit(rc) 

409 

410 

411@cli.command( 

412 "diff", 

413 help=""" 

414 Compare two schemas or a schema and a database for changes 

415 

416 Examples: 

417 

418 felis diff schema1.yaml schema2.yaml 

419 

420 felis diff -c alembic schema1.yaml schema2.yaml 

421 

422 felis diff --engine-url sqlite:///test.db schema.yaml 

423 """, 

424) 

425@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL") 

426@click.option( 

427 "-c", 

428 "--comparator", 

429 type=click.Choice(["alembic", "deepdiff"], case_sensitive=False), 

430 help="Comparator to use for schema comparison", 

431 default="deepdiff", 

432) 

433@click.option("-E", "--error-on-change", is_flag=True, help="Exit with error code if schemas are different") 

434@click.argument("files", nargs=-1, type=click.File()) 

435@click.pass_context 

436def diff( 

437 ctx: click.Context, 

438 engine_url: str | None, 

439 comparator: str, 

440 error_on_change: bool, 

441 files: Iterable[IO[str]], 

442) -> None: 

443 schemas = [ 

444 Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files 

445 ] 

446 

447 diff: SchemaDiff 

448 if len(schemas) == 2 and engine_url is None: 

449 if comparator == "alembic": 

450 db_context = create_database(schemas[0]) 

451 assert isinstance(db_context.engine, Engine) 

452 diff = DatabaseDiff(schemas[1], db_context.engine) 

453 else: 

454 diff = FormattedSchemaDiff(schemas[0], schemas[1]) 

455 elif len(schemas) == 1 and engine_url is not None: 

456 engine = create_engine(engine_url) 

457 diff = DatabaseDiff(schemas[0], engine) 

458 else: 

459 raise click.ClickException( 

460 "Invalid arguments - provide two schemas or a schema and a database engine URL" 

461 ) 

462 

463 diff.print() 

464 

465 if diff.has_changes and error_on_change: 

466 raise click.ClickException("Schema was changed") 

467 

468 

469@cli.command( 

470 "dump", 

471 help=""" 

472 Dump a schema file to YAML or JSON format 

473 

474 Example: 

475 

476 felis dump schema.yaml schema.json 

477 

478 felis dump schema.yaml schema_dump.yaml 

479 """, 

480) 

481@click.option( 

482 "--strip-ids/--no-strip-ids", 

483 is_flag=True, 

484 help="Strip IDs from the output schema", 

485 default=False, 

486) 

487@click.argument("files", nargs=2, type=click.Path()) 

488@click.pass_context 

489def dump( 

490 ctx: click.Context, 

491 strip_ids: bool, 

492 files: list[str], 

493) -> None: 

494 if strip_ids: 

495 logger.info("Stripping IDs from the output schema") 

496 if files[1].endswith(".json"): 

497 format = "json" 

498 elif files[1].endswith(".yaml"): 

499 format = "yaml" 

500 else: 

501 raise click.ClickException("Output file must have a .json or .yaml extension") 

502 schema = Schema.from_uri(files[0], context={"id_generation": ctx.obj["id_generation"]}) 

503 with open(files[1], "w") as f: 

504 if format == "yaml": 

505 schema.dump_yaml(f, strip_ids=strip_ids) 

506 elif format == "json": 

507 schema.dump_json(f, strip_ids=strip_ids) 

508 

509 

510if __name__ == "__main__": 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true

511 cli()