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

199 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-21 01:22 -0700

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 

32 

33from . import __version__ 

34from .datamodel import Schema 

35from .db.database_context import create_database_context 

36from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff 

37from .metadata import create_metadata 

38from .tap_schema import DataLoader, MetadataInserter, TableManager 

39 

40__all__ = ["cli"] 

41 

42logger = logging.getLogger(__name__) 

43 

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

45 

46 

47@click.group() 

48@click.version_option(__version__) 

49@click.option( 

50 "--log-level", 

51 type=click.Choice(loglevel_choices), 

52 envvar="FELIS_LOGLEVEL", 

53 help="Felis log level", 

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

55) 

56@click.option( 

57 "--log-file", 

58 type=click.Path(), 

59 envvar="FELIS_LOGFILE", 

60 help="Felis log file path", 

61) 

62@click.option( 

63 "--id-generation/--no-id-generation", 

64 is_flag=True, 

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

66 default=True, 

67) 

68@click.option( 

69 "--column-ref-index-increment", 

70 type=int, 

71 help="Automatically set 'tap:column_index' on column references, using the specified increment " 

72 "(must be at least 1)", 

73 default=None, 

74) 

75@click.pass_context 

76def cli( 

77 ctx: click.Context, 

78 log_level: str, 

79 log_file: str | None, 

80 id_generation: bool, 

81 column_ref_index_increment: int | None, 

82) -> None: 

83 """Felis command line tools""" 

84 ctx.ensure_object(dict) 

85 

86 # Configure logging on the felis package logger directly. 

87 # Do not use basicConfig() as it is a no-op when handlers already exist 

88 # (e.g., when running under pytest). 

89 felis_logger = logging.getLogger("felis") 

90 felis_logger.setLevel(log_level) 

91 if log_file: 

92 handler: logging.Handler = logging.FileHandler(log_file) 

93 else: 

94 handler = logging.StreamHandler() 

95 handler.setLevel(log_level) 

96 handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) 

97 felis_logger.addHandler(handler) 

98 

99 # Configure ID generation (flag can only turn it off) 

100 ctx.obj["id_generation"] = id_generation 

101 if ctx.obj["id_generation"]: 

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

103 else: 

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

105 

106 # Configure automatic indexing of column references (optional) 

107 if column_ref_index_increment is not None and column_ref_index_increment < 1: 

108 raise click.ClickException("column_ref_index_increment must be at least 1") 

109 ctx.obj["column_ref_index_increment"] = column_ref_index_increment 

110 if ctx.obj["column_ref_index_increment"] is not None: 

111 logger.info( 

112 "Automatic indexing of column references is enabled with increment %s", 

113 ctx.obj["column_ref_index_increment"], 

114 ) 

115 

116 

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

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

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

120@click.option( 

121 "--initialize", 

122 is_flag=True, 

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

124) 

125@click.option( 

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

127) 

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

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

130@click.option( 

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

132) 

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

134@click.option("--skip-indexes", is_flag=True, help="Skip creating indexes when building metadata") 

135@click.argument("uri", type=str) 

136@click.pass_context 

137def create( 

138 ctx: click.Context, 

139 engine_url: str, 

140 schema_name: str | None, 

141 initialize: bool, 

142 drop: bool, 

143 echo: bool, 

144 dry_run: bool, 

145 output_file: IO[str] | None, 

146 ignore_constraints: bool, 

147 skip_indexes: bool, 

148 uri: str, 

149) -> None: 

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

151 

152 Parameters 

153 ---------- 

154 engine_url 

155 SQLAlchemy Engine URL. 

156 schema_name 

157 Alternate schema name to override Felis file. 

158 initialize 

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

160 drop 

161 Drop schema if it already exists in the database. 

162 echo 

163 Echo database commands as they are executed. 

164 dry_run 

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

166 output_file 

167 Write SQL commands to a file instead of executing. 

168 ignore_constraints 

169 Ignore constraints when creating tables. 

170 skip_indexes 

171 Skip creating indexes when building metadata. 

172 uri 

173 URI of Felis file to read. 

174 """ 

175 try: 

176 schema = Schema.from_uri(uri, context={"id_generation": ctx.obj["id_generation"]}) 

177 

178 metadata = create_metadata( 

179 schema, 

180 schema_name=schema_name, 

181 ignore_constraints=ignore_constraints, 

182 skip_indexes=skip_indexes, 

183 engine_url=engine_url, 

184 ) 

185 

186 with create_database_context( 

187 engine_url, 

188 metadata, 

189 echo=echo, 

190 dry_run=dry_run, 

191 output_file=output_file, 

192 ) as db_ctx: 

193 if drop and initialize: 

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

195 

196 if drop: 

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

198 db_ctx.drop() 

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

200 

201 if initialize: 

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

203 db_ctx.initialize() 

204 

205 db_ctx.create_all() 

206 

207 except Exception as e: 

208 logger.exception(e) 

209 raise click.ClickException(str(e)) 

210 

211 

212@cli.command("create-indexes", help="Create database indexes defined in the Felis file") 

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

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

215@click.argument("uri", type=str) 

216@click.pass_context 

217def create_indexes( 

218 ctx: click.Context, 

219 engine_url: str, 

220 schema_name: str | None, 

221 uri: str, 

222) -> None: 

223 """Create indexes from a Felis YAML file in a target database. 

224 

225 Parameters 

226 ---------- 

227 engine_url 

228 SQLAlchemy Engine URL. 

229 file 

230 Felis file to read. 

231 """ 

232 try: 

233 schema = Schema.from_uri(uri, context={"id_generation": ctx.obj["id_generation"]}) 

234 

235 metadata = create_metadata(schema, schema_name=schema_name, engine_url=engine_url) 

236 

237 with create_database_context(engine_url, metadata) as db_ctx: 

238 db_ctx.create_indexes() 

239 except Exception as e: 

240 logger.exception(e) 

241 raise click.ClickException("Error creating indexes: " + str(e)) 

242 

243 

244@cli.command("drop-indexes", help="Drop database indexes defined in the Felis file") 

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

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

247@click.argument("uri", type=str) 

248@click.pass_context 

249def drop_indexes( 

250 ctx: click.Context, 

251 engine_url: str, 

252 schema_name: str | None, 

253 uri: str, 

254) -> None: 

255 """Drop indexes from a Felis YAML file in a target database. 

256 

257 Parameters 

258 ---------- 

259 engine_url 

260 SQLAlchemy Engine URL. 

261 schema-name 

262 Alternate schema name to override Felis file. 

263 file 

264 Felis file to read. 

265 """ 

266 try: 

267 schema = Schema.from_uri(uri, context={"id_generation": ctx.obj["id_generation"]}) 

268 

269 metadata = create_metadata(schema, schema_name=schema_name, engine_url=engine_url) 

270 with create_database_context(engine_url, metadata) as db: 

271 db.drop_indexes() 

272 except Exception as e: 

273 logger.exception(e) 

274 raise click.ClickException("Error dropping indexes: " + str(e)) 

275 

276 

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

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

279@click.option( 

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

281) 

282@click.option( 

283 "--tap-tables-postfix", 

284 "-p", 

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

286 default="", 

287) 

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

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

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

291@click.option( 

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

293) 

294@click.option( 

295 "--force-unbounded-arraysize", 

296 is_flag=True, 

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

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

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

300@click.option( 

301 "--unique-keys", 

302 "-u", 

303 is_flag=True, 

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

305 default=False, 

306) 

307@click.argument("uri", type=str) 

308@click.pass_context 

309def load_tap_schema( 

310 ctx: click.Context, 

311 engine_url: str, 

312 tap_schema_name: str, 

313 tap_tables_postfix: str, 

314 tap_schema_index: int, 

315 dry_run: bool, 

316 echo: bool, 

317 output_file: IO[str] | None, 

318 force_unbounded_arraysize: bool, 

319 unique_keys: bool, 

320 uri: str, 

321) -> None: 

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

323 

324 Parameters 

325 ---------- 

326 engine_url 

327 SQLAlchemy Engine URL. 

328 tap_tables_postfix 

329 Postfix which is applied to standard TAP_SCHEMA table names. 

330 tap_schema_index 

331 TAP_SCHEMA index of the schema in this environment. 

332 dry_run 

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

334 echo 

335 Print out the generated insert statements to stdout. 

336 output_file 

337 Output file for writing generated SQL. 

338 file 

339 Felis file to read. 

340 

341 Notes 

342 ----- 

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

344 command will not initialize the TAP_SCHEMA tables. 

345 """ 

346 # Create TableManager with automatic dialect detection 

347 mgr = TableManager( 

348 engine_url=engine_url, 

349 schema_name=tap_schema_name, 

350 table_name_postfix=tap_tables_postfix, 

351 ) 

352 

353 # Create DatabaseContext using TableManager's metadata 

354 with create_database_context( 

355 engine_url, mgr.metadata, echo=echo, dry_run=dry_run, output_file=output_file 

356 ) as db_ctx: 

357 schema = Schema.from_uri( 

358 uri, 

359 context={ 

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

361 "column_ref_index_increment": ctx.obj["column_ref_index_increment"], 

362 "force_unbounded_arraysize": force_unbounded_arraysize, 

363 }, 

364 ) 

365 

366 DataLoader( 

367 schema, 

368 mgr, 

369 db_context=db_ctx, 

370 tap_schema_index=tap_schema_index, 

371 dry_run=dry_run, 

372 print_sql=echo, 

373 output_file=output_file, 

374 unique_keys=unique_keys, 

375 ).load() 

376 

377 

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

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

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

381@click.option( 

382 "--extensions", 

383 type=str, 

384 default=None, 

385 help=( 

386 "Optional path to extensions YAML file (system path or resource:// URI). " 

387 "If not provided, no extensions will be applied. " 

388 "Example (default packaged extensions): " 

389 "--extensions resource://felis/config/tap_schema/tap_schema_extensions.yaml" 

390 ), 

391) 

392@click.option( 

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

394) 

395@click.option( 

396 "--insert-metadata/--no-insert-metadata", 

397 is_flag=True, 

398 help="Insert metadata describing TAP_SCHEMA itself", 

399 default=True, 

400) 

401@click.pass_context 

402def init_tap_schema( 

403 ctx: click.Context, 

404 engine_url: str, 

405 tap_schema_name: str, 

406 extensions: str | None, 

407 tap_tables_postfix: str, 

408 insert_metadata: bool, 

409) -> None: 

410 """Initialize a standard TAP_SCHEMA database. 

411 

412 Parameters 

413 ---------- 

414 engine_url 

415 SQLAlchemy Engine URL. 

416 tap_schema_name 

417 Name of the TAP_SCHEMA schema in the database. 

418 extensions 

419 Extensions YAML file. 

420 tap_tables_postfix 

421 Postfix which is applied to standard TAP_SCHEMA table names. 

422 insert_metadata 

423 Insert metadata describing TAP_SCHEMA itself. 

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

425 metadata will be inserted. 

426 """ 

427 # Create TableManager with automatic dialect detection 

428 mgr = TableManager( 

429 engine_url=engine_url, 

430 schema_name=tap_schema_name, 

431 table_name_postfix=tap_tables_postfix, 

432 extensions_path=extensions, 

433 ) 

434 

435 # Create DatabaseContext using TableManager's metadata 

436 with create_database_context(engine_url, mgr.metadata) as db_ctx: 

437 mgr.initialize_database(db_context=db_ctx) 

438 if insert_metadata: 

439 MetadataInserter(mgr, db_context=db_ctx).insert_metadata() 

440 

441 

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

443@click.option( 

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

445) 

446@click.option( 

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

448) 

449@click.option( 

450 "--check-tap-table-indexes", 

451 is_flag=True, 

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

453 default=False, 

454) 

455@click.option( 

456 "--check-tap-principal", 

457 is_flag=True, 

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

459 default=False, 

460) 

461@click.argument("uris", nargs=-1, type=str) 

462@click.pass_context 

463def validate( 

464 ctx: click.Context, 

465 check_description: bool, 

466 check_redundant_datatypes: bool, 

467 check_tap_table_indexes: bool, 

468 check_tap_principal: bool, 

469 uris: Iterable[str], 

470) -> None: 

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

472 

473 Parameters 

474 ---------- 

475 check_description 

476 Check that all objects have a valid description. 

477 check_redundant_datatypes 

478 Check for redundant type overrides. 

479 check_tap_table_indexes 

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

481 check_tap_principal 

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

483 uris 

484 A list of URIs representing the Felis YAML files to validate. These can 

485 be relative or absolute file paths or a URI with a supported scheme 

486 such as ``resource://`` for package resources. 

487 

488 Raises 

489 ------ 

490 click.exceptions.Exit 

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

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

493 error message. 

494 

495 Notes 

496 ----- 

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

498 optional validations controlled by the Pydantic context. 

499 """ 

500 rc = 0 

501 for uri in uris: 

502 logger.info("Validating schema at '%s'", uri) 

503 try: 

504 Schema.from_uri( 

505 uri, 

506 context={ 

507 "check_description": check_description, 

508 "check_redundant_datatypes": check_redundant_datatypes, 

509 "check_tap_table_indexes": check_tap_table_indexes, 

510 "check_tap_principal": check_tap_principal, 

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

512 "column_ref_index_increment": ctx.obj["column_ref_index_increment"], 

513 }, 

514 ) 

515 logger.info("Successfully validated schema at '%s'", uri) 

516 except ValidationError as e: 

517 logger.error(e) 

518 rc = 1 

519 if rc: 

520 raise click.exceptions.Exit(rc) 

521 

522 

523@cli.command( 

524 "diff", 

525 help=""" 

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

527 

528 Examples: 

529 

530 felis diff schema1.yaml schema2.yaml 

531 

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

533 

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

535 """, 

536) 

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

538@click.option( 

539 "-c", 

540 "--comparator", 

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

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

543 default="deepdiff", 

544) 

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

546@click.argument("uris", nargs=-1, type=str) 

547@click.pass_context 

548def diff( 

549 ctx: click.Context, 

550 engine_url: str | None, 

551 comparator: str, 

552 error_on_change: bool, 

553 uris: Iterable[str], 

554) -> None: 

555 uri_list = list(uris) 

556 schemas = [Schema.from_uri(uri, context={"id_generation": ctx.obj["id_generation"]}) for uri in uri_list] 

557 diff: SchemaDiff 

558 if len(schemas) == 2: 

559 if comparator == "alembic": 

560 metadata = create_metadata(schemas[0], engine_url=engine_url) 

561 with create_database_context( 

562 engine_url if engine_url else "sqlite:///:memory:", metadata 

563 ) as db_ctx: 

564 db_ctx.initialize() 

565 db_ctx.create_all() 

566 diff = DatabaseDiff(schemas[1], db_ctx.engine) 

567 else: 

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

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

570 # Create minimal metadata for the context manager 

571 from sqlalchemy import MetaData 

572 

573 metadata = MetaData() 

574 

575 with create_database_context(engine_url, metadata) as db_ctx: 

576 diff = DatabaseDiff(schemas[0], db_ctx.engine) 

577 else: 

578 raise click.ClickException( 

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

580 ) 

581 

582 diff.print() 

583 

584 if diff.has_changes and error_on_change: 

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

586 

587 

588@cli.command( 

589 "dump", 

590 help=""" 

591 Dump a schema file to YAML or JSON format 

592 

593 Example: 

594 

595 felis dump schema.yaml schema.json 

596 

597 felis dump schema.yaml schema_dump.yaml 

598 """, 

599) 

600@click.option( 

601 "--strip-ids/--no-strip-ids", 

602 is_flag=True, 

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

604 default=False, 

605) 

606@click.option( 

607 "--dereference-resources/--no-dereference-resources", 

608 is_flag=True, 

609 help="Remove any column references from resources and inline the full column definitions in the output", 

610 default=False, 

611) 

612@click.option( 

613 "--sort-columns/--no-sort-columns", 

614 is_flag=True, 

615 help="Sort columns alphabetically by name in the output", 

616 default=False, 

617) 

618@click.argument("uris", nargs=2, type=str) 

619@click.pass_context 

620def dump( 

621 ctx: click.Context, 

622 strip_ids: bool, 

623 dereference_resources: bool, 

624 sort_columns: bool, 

625 uris: list[str], 

626) -> None: 

627 if strip_ids: 

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

629 if sort_columns: 

630 logger.info("Sorting output columns alphabetically") 

631 if uris[1].endswith(".json"): 

632 format = "json" 

633 elif uris[1].endswith(".yaml"): 

634 format = "yaml" 

635 else: 

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

637 schema = Schema.from_uri( 

638 uris[0], 

639 context={"id_generation": ctx.obj["id_generation"], "dereference_resources": dereference_resources}, 

640 ) 

641 with open(uris[1], "w") as f: 

642 if format == "yaml": 

643 schema.dump_yaml(f, strip_ids=strip_ids, sort_columns=sort_columns) 

644 elif format == "json": 

645 schema.dump_json(f, strip_ids=strip_ids, sort_columns=sort_columns) 

646 logger.info("Dumped '%s' to '%s'", uris[0], uris[1]) 

647 

648 

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

650 cli()