Coverage for src/lpip/cli.py: 86%

375 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 20:19 +0000

1"""Thin Typer CLI (ADR-0006). Command bodies call services only.""" 

2 

3from __future__ import annotations 

4 

5import sys 

6from pathlib import Path 

7 

8import typer 

9 

10from lpip import format_cli_version 

11from lpip.catalog import ( 

12 Catalog, 

13 add_package_to_catalog_file, 

14 git_sources, 

15 load_catalog, 

16 load_catalog_for_config, 

17 load_merged_catalog, 

18 local_catalog_path, 

19 planned_git_cache_paths_for, 

20 purge_all_git_catalog_caches, 

21 resolve_authoring_catalog_path, 

22 resolve_catalog_location, 

23 resolve_source_location, 

24 write_catalog_init_template, 

25) 

26from lpip.config import ( 

27 add_configured_source, 

28 format_config_value, 

29 format_effective_config, 

30 format_sources_table, 

31 load_effective_config, 

32 remove_configured_source, 

33 resolve_init_target, 

34 set_config_key, 

35 unset_config_key, 

36 write_init_template, 

37) 

38from lpip.errors import ( 

39 GitNotFoundError, 

40 InstallUsageError, 

41 LpipError, 

42 MissingCatalogAddFieldsError, 

43 UnknownPackageError, 

44) 

45from lpip.gitutils import find_git_executable 

46from lpip.models import EffectiveConfig, PackageSource, SourceType 

47from lpip.pipwrap import ( 

48 format_pip_install_command, 

49 render_catalog_install, 

50 render_requirement, 

51 run_pip_install, 

52) 

53from lpip.pipxutils import format_pipx_install_command, require_pipx, run_pipx_install 

54from lpip.reqfile import load_requirement_lines, requirement_name 

55from lpip.validation import url_scheme 

56from lpip.versions import format_versions_report, list_package_versions 

57 

58app = typer.Typer( 

59 name="lpip", 

60 help="Name-oriented front end to pip for Git-backed internal packages.", 

61 no_args_is_help=True, 

62 pretty_exceptions_enable=False, 

63 pretty_exceptions_show_locals=False, 

64) 

65config_app = typer.Typer( 

66 name="config", 

67 help="Persistent configuration.", 

68 no_args_is_help=True, 

69) 

70catalog_app = typer.Typer( 

71 name="catalog", 

72 help="Author a catalog file, or manage the Git-backed catalog cache.", 

73 no_args_is_help=True, 

74) 

75index_app = typer.Typer( 

76 name="index", 

77 help="Inspect versions (pip-compatible spelling of lpip versions).", 

78 no_args_is_help=True, 

79) 

80source_app = typer.Typer( 

81 name="source", 

82 help="Register catalog locations (not package authoring).", 

83 no_args_is_help=True, 

84) 

85app.add_typer(config_app, name="config") 

86app.add_typer(catalog_app, name="catalog") 

87app.add_typer(index_app, name="index") 

88app.add_typer(source_app, name="source") 

89 

90 

91def _print_version_and_exit(value: bool) -> None: 

92 if value: 

93 typer.echo(format_cli_version()) 

94 raise typer.Exit() 

95 

96 

97def _invoked_as_lpipx(ctx: typer.Context) -> bool: 

98 prog = (ctx.info_name or "").lower() 

99 argv0 = Path(sys.argv[0]).name.lower() 

100 return prog.startswith("lpipx") or argv0.startswith("lpipx") 

101 

102 

103@app.callback() 

104def _root( 

105 ctx: typer.Context, 

106 _version: bool = typer.Option( 

107 False, 

108 "--version", 

109 "-V", 

110 help="Print the lpip version and exit.", 

111 callback=_print_version_and_exit, 

112 is_eager=True, 

113 ), 

114) -> None: 

115 """lpip command group (subcommands only).""" 

116 ctx.ensure_object(dict) 

117 ctx.obj["pipx_default"] = _invoked_as_lpipx(ctx) 

118 

119 

120def _load_catalog() -> Catalog: 

121 cwd = Path.cwd() 

122 cfg = load_effective_config(cwd=cwd) 

123 return load_catalog_for_config(cfg, cwd=cwd) 

124 

125 

126_LIST_HEADERS = ("name", "url", "revision", "subdirectory") 

127_LIST_HEADERS_WITH_SOURCE = ("name", "source", "url", "revision", "subdirectory") 

128 

129 

130def format_list_table(packages: tuple[PackageSource, ...]) -> str: 

131 """Aligned table; cells are never truncated (AC-0007).""" 

132 origins = {pkg.catalog_source for pkg in packages if pkg.catalog_source} 

133 with_source = len(origins) > 1 

134 headers = _LIST_HEADERS_WITH_SOURCE if with_source else _LIST_HEADERS 

135 rows: list[tuple[str, ...]] 

136 if with_source: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true

137 rows = [ 

138 ( 

139 pkg.name, 

140 pkg.catalog_source or "", 

141 pkg.url, 

142 pkg.revision or "", 

143 pkg.subdirectory or "", 

144 ) 

145 for pkg in packages 

146 ] 

147 else: 

148 rows = [(pkg.name, pkg.url, pkg.revision or "", pkg.subdirectory or "") for pkg in packages] 

149 widths = [len(h) for h in headers] 

150 for row in rows: 

151 widths = [max(width, len(cell)) for width, cell in zip(widths, row, strict=True)] 

152 

153 def _line(cells: tuple[str, ...]) -> str: 

154 return " ".join(cell.ljust(width) for cell, width in zip(cells, widths, strict=True)) 

155 

156 lines = [_line(headers)] 

157 lines.extend(_line(row) for row in rows) 

158 return "\n".join(lines) 

159 

160 

161def _git_needed_reason(cfg: EffectiveConfig, catalog: Catalog | None) -> str | None: 

162 if any(src.type is SourceType.GIT for src in cfg.sources): 

163 return "Git is required because a configured source.type is git (catalog checkout)." 

164 if catalog is None: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true

165 return None 

166 for pkg in catalog.packages: 

167 if url_scheme(pkg.url).startswith("git"): 167 ↛ 166line 167 didn't jump to line 166 because the condition on line 167 was always true

168 return f"Git is required because package {pkg.name!r} uses a git URL." 

169 return None 

170 

171 

172@app.command() 

173def doctor() -> None: 

174 """Report prerequisites. Read-only: does not clone or refresh the cache.""" 

175 try: 

176 cwd = Path.cwd() 

177 cfg = load_effective_config(cwd=cwd) 

178 catalog = None 

179 if cfg.source.type is SourceType.FILE: 

180 catalog = load_catalog(local_catalog_path(cfg, cwd=cwd), cfg.policy) 

181 except LpipError as exc: 

182 typer.echo(exc.format(), err=True) 

183 raise typer.Exit(code=1) from None 

184 git = find_git_executable() 

185 needed = _git_needed_reason(cfg, catalog) 

186 if needed and git is None: 

187 typer.echo(GitNotFoundError().format(), err=True) 

188 typer.echo(needed, err=True) 

189 raise typer.Exit(code=1) 

190 if git is not None: 190 ↛ 193line 190 didn't jump to line 193 because the condition on line 190 was always true

191 typer.echo(f"OK git: {git}") 

192 else: 

193 typer.echo("OK git: not required") 

194 if cfg.config_path is not None: 194 ↛ 197line 194 didn't jump to line 197 because the condition on line 194 was always true

195 typer.echo(f"OK config: {cfg.config_path}") 

196 else: 

197 typer.echo("OK config: defaults (no persistent file)") 

198 typer.echo(f"OK python: {cfg.runtime.python}") 

199 

200 

201@app.command() 

202def validate( 

203 strict: bool = typer.Option( 

204 False, 

205 "--strict", 

206 help="Fail when a package name is defined in more than one source.", 

207 ), 

208) -> None: 

209 """Validate effective config and catalog sources.""" 

210 try: 

211 cwd = Path.cwd() 

212 cfg = load_effective_config(cwd=cwd) 

213 catalog, clashes = load_merged_catalog(cfg, cwd=cwd) 

214 except LpipError as exc: 

215 typer.echo(exc.format(), err=True) 

216 raise typer.Exit(code=1) from None 

217 typer.echo(f"OK {catalog.path} ({len(catalog.packages)} packages)") 

218 for clash in clashes: 

219 kind = "same definition" if clash.identical else "different definition" 

220 typer.echo( 

221 f"warning: package {clash.name!r} in source {clash.shadowed_source!r} " 

222 f"is shadowed by {clash.winner_source!r} ({kind})", 

223 err=True, 

224 ) 

225 if strict and clashes: 

226 typer.echo("error: package name clashes are not allowed with --strict", err=True) 

227 raise typer.Exit(code=1) 

228 

229 

230def _install_requirement( 

231 python: str, 

232 requirement: str, 

233 *, 

234 dry_run: bool, 

235 upgrade: bool, 

236 pipx: bool, 

237) -> int: 

238 if pipx: 

239 exe = str(require_pipx()) 

240 if dry_run: 240 ↛ 243line 240 didn't jump to line 243 because the condition on line 240 was always true

241 typer.echo(format_pipx_install_command(exe, requirement)) 

242 return 0 

243 return run_pipx_install(exe, requirement) 

244 if dry_run: 

245 typer.echo(format_pip_install_command(python, requirement, upgrade=upgrade)) 

246 return 0 

247 return run_pip_install(python, requirement, upgrade=upgrade) 

248 

249 

250def _run_install( 

251 package: str | None, 

252 requirements: Path | None, 

253 *, 

254 dry_run: bool, 

255 catalog_only: bool, 

256 upgrade: bool, 

257 verb: str, 

258 revision: str | None, 

259 pipx: bool, 

260) -> None: 

261 try: 

262 if package and requirements is not None: 

263 raise InstallUsageError( 

264 "Pass a package name or -r FILE, not both.", 

265 hint=f"Use lpip {verb} libfoo, or lpip {verb} -r lpip-requirements.txt.", 

266 ) 

267 if catalog_only and requirements is None: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 raise InstallUsageError( 

269 f"--catalog-only applies only to lpip {verb} -r.", 

270 hint="Omit --catalog-only, or pass -r FILE.", 

271 ) 

272 if pipx and requirements is not None: 

273 raise InstallUsageError( 

274 "--pipx cannot be used with -r.", 

275 hint=f"Install one catalog CLI: lpip {verb} mycli --pipx " 

276 f"(or lpipx {verb} mycli).", 

277 ) 

278 if revision is not None and requirements is not None: 

279 raise InstallUsageError( 

280 "--revision cannot be used with -r.", 

281 hint=f"Install one catalog name: lpip {verb} libfoo --revision <ref>.", 

282 ) 

283 if revision is not None and not revision.strip(): 

284 raise InstallUsageError( 

285 "--revision requires a Git ref (branch, tag, or SHA).", 

286 hint=f"Example: lpip {verb} libfoo --revision abc1234", 

287 ) 

288 if package is None and requirements is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 raise InstallUsageError( 

290 "Pass a catalog package name or -r FILE.", 

291 hint=f"Example: lpip {verb} libfoo or lpip {verb} -r lpip-requirements.txt.", 

292 ) 

293 cwd = Path.cwd() 

294 cfg = load_effective_config(cwd=cwd) 

295 catalog = load_catalog_for_config(cfg, cwd=cwd) 

296 python = cfg.runtime.python 

297 if requirements is not None: 

298 last_code = 0 

299 for line in load_requirement_lines(requirements): 

300 name = requirement_name(line) 

301 entry = catalog.find(name) 

302 if entry is not None: 

303 req = render_catalog_install(entry, line) 

304 elif catalog_only: 

305 raise UnknownPackageError(name, catalog_path=catalog.path) 

306 else: 

307 req = line 

308 last_code = _install_requirement( 

309 python, req, dry_run=dry_run, upgrade=upgrade, pipx=False 

310 ) 

311 if last_code != 0: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 raise typer.Exit(code=last_code) 

313 return 

314 if package is None: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 raise InstallUsageError( 

316 "Pass a catalog package name or -r FILE.", 

317 hint=f"Example: lpip {verb} libfoo or lpip {verb} -r lpip-requirements.txt.", 

318 ) 

319 name = requirement_name(package) 

320 entry = catalog.find(name) 

321 if entry is None: 

322 raise UnknownPackageError(name) 

323 last_code = _install_requirement( 

324 python, 

325 render_catalog_install( 

326 entry, 

327 package, 

328 revision=revision.strip() if revision is not None else None, 

329 ), 

330 dry_run=dry_run, 

331 upgrade=upgrade, 

332 pipx=pipx, 

333 ) 

334 except LpipError as exc: 

335 typer.echo(exc.format(), err=True) 

336 raise typer.Exit(code=1) from None 

337 if last_code != 0: 

338 raise typer.Exit(code=last_code) 

339 

340 

341@app.command() 

342def install( 

343 ctx: typer.Context, 

344 package: str | None = typer.Argument(None, help="Catalog package name."), 

345 requirements: Path | None = typer.Option( 

346 None, 

347 "-r", 

348 "--requirement", 

349 help="Pip requirements file (conventional name: lpip-requirements.txt).", 

350 ), 

351 dry_run: bool = typer.Option(False, "--dry-run", help="Print the pip command; do not run pip."), 

352 catalog_only: bool = typer.Option( 

353 False, 

354 "--catalog-only", 

355 help="With -r, fail if a name is not in the catalog (no pip fallback).", 

356 ), 

357 upgrade: bool = typer.Option( 

358 False, 

359 "--upgrade", 

360 "-U", 

361 help="Pass pip --upgrade (same as lpip upgrade).", 

362 ), 

363 revision: str | None = typer.Option( 

364 None, 

365 "--revision", 

366 help="Git ref for this install only (branch, tag, or SHA). Does not write the catalog.", 

367 ), 

368 pipx: bool = typer.Option( 

369 False, 

370 "--pipx", 

371 help="Install with pipx (isolated app on PATH). Implied when invoked as lpipx.", 

372 ), 

373) -> None: 

374 """Resolve a package or a requirements file and run ``python -m pip install``.""" 

375 use_pipx = pipx or bool((ctx.obj or {}).get("pipx_default")) 

376 _run_install( 

377 package, 

378 requirements, 

379 dry_run=dry_run, 

380 catalog_only=catalog_only, 

381 upgrade=upgrade, 

382 verb="install", 

383 revision=revision, 

384 pipx=use_pipx, 

385 ) 

386 

387 

388@app.command() 

389def upgrade( 

390 ctx: typer.Context, 

391 package: str | None = typer.Argument(None, help="Catalog package name."), 

392 requirements: Path | None = typer.Option( 

393 None, 

394 "-r", 

395 "--requirement", 

396 help="Pip requirements file (conventional name: lpip-requirements.txt).", 

397 ), 

398 dry_run: bool = typer.Option(False, "--dry-run", help="Print the pip command; do not run pip."), 

399 catalog_only: bool = typer.Option( 

400 False, 

401 "--catalog-only", 

402 help="With -r, fail if a name is not in the catalog (no pip fallback).", 

403 ), 

404 revision: str | None = typer.Option( 

405 None, 

406 "--revision", 

407 help="Git ref for this install only (branch, tag, or SHA). Does not write the catalog.", 

408 ), 

409 pipx: bool = typer.Option( 

410 False, 

411 "--pipx", 

412 help="Install with pipx (isolated app on PATH). Implied when invoked as lpipx.", 

413 ), 

414) -> None: 

415 """Same as ``lpip install --upgrade``.""" 

416 use_pipx = pipx or bool((ctx.obj or {}).get("pipx_default")) 

417 _run_install( 

418 package, 

419 requirements, 

420 dry_run=dry_run, 

421 catalog_only=catalog_only, 

422 upgrade=True, 

423 verb="upgrade", 

424 revision=revision, 

425 pipx=use_pipx, 

426 ) 

427 

428 

429@app.command("list") 

430def list_packages() -> None: 

431 """Print catalog packages as a name/url/revision/subdirectory table.""" 

432 try: 

433 catalog = _load_catalog() 

434 except LpipError as exc: 

435 typer.echo(exc.format(), err=True) 

436 raise typer.Exit(code=1) from None 

437 typer.echo(format_list_table(catalog.sorted_by_name())) 

438 

439 

440@app.command() 

441def show(package: str = typer.Argument(..., help="Catalog package name.")) -> None: 

442 """Print one catalog entry and its rendered pip requirement.""" 

443 try: 

444 catalog = _load_catalog() 

445 entry = catalog.get(package) 

446 rendered = render_requirement(entry) 

447 except LpipError as exc: 

448 typer.echo(exc.format(), err=True) 

449 raise typer.Exit(code=1) from None 

450 typer.echo(f"name: {entry.name}") 

451 if entry.catalog_source: 451 ↛ 453line 451 didn't jump to line 453 because the condition on line 451 was always true

452 typer.echo(f"source: {entry.catalog_source}") 

453 typer.echo(f"url: {entry.url}") 

454 typer.echo(f"revision: {entry.revision or ''}") 

455 typer.echo(f"subdirectory: {entry.subdirectory or ''}") 

456 typer.echo(f"requirement: {rendered.text}") 

457 

458 

459def _run_versions(package: str, *, pre: bool, all_tags: bool) -> None: 

460 try: 

461 catalog = _load_catalog() 

462 entry = catalog.get(package) 

463 report = list_package_versions( 

464 entry, 

465 include_prereleases=pre, 

466 include_other=all_tags, 

467 ) 

468 except LpipError as exc: 

469 typer.echo(exc.format(), err=True) 

470 raise typer.Exit(code=1) from None 

471 typer.echo(format_versions_report(report)) 

472 

473 

474@app.command("version") 

475def version() -> None: 

476 """Print the lpip version and exit.""" 

477 typer.echo(format_cli_version()) 

478 

479 

480@app.command("versions") 

481def versions( 

482 package: str = typer.Argument(..., help="Catalog package name."), 

483 pre: bool = typer.Option(False, "--pre", help="Include pre-release versions."), 

484 all_tags: bool = typer.Option( 

485 False, "--all", help="Also list tags that are not PEP 440 versions." 

486 ), 

487) -> None: 

488 """List PEP 440 versions from Git tags (no clone).""" 

489 _run_versions(package, pre=pre, all_tags=all_tags) 

490 

491 

492@index_app.command("versions") 

493def index_versions( 

494 package: str = typer.Argument(..., help="Catalog package name."), 

495 pre: bool = typer.Option(False, "--pre", help="Include pre-release versions."), 

496 all_tags: bool = typer.Option( 

497 False, "--all", help="Also list tags that are not PEP 440 versions." 

498 ), 

499) -> None: 

500 """Same as lpip versions (pip-compatible spelling).""" 

501 _run_versions(package, pre=pre, all_tags=all_tags) 

502 

503 

504@app.command("export-requirements") 

505def export_requirements() -> None: 

506 """Print pip requirements for every catalog package.""" 

507 try: 

508 catalog = _load_catalog() 

509 except LpipError as exc: 

510 typer.echo(exc.format(), err=True) 

511 raise typer.Exit(code=1) from None 

512 for pkg in catalog.sorted_by_name(): 

513 typer.echo(render_requirement(pkg).text) 

514 

515 

516@config_app.command("init") 

517def config_init() -> None: 

518 """Write a commented v1 config.toml if the target path does not exist.""" 

519 try: 

520 target = resolve_init_target(cwd=Path.cwd()) 

521 write_init_template(target) 

522 except LpipError as exc: 

523 typer.echo(exc.format(), err=True) 

524 raise typer.Exit(code=1) from None 

525 typer.echo(str(target)) 

526 

527 

528@config_app.command("show") 

529def config_show() -> None: 

530 """Print the effective configuration.""" 

531 try: 

532 cfg = load_effective_config(cwd=Path.cwd()) 

533 except LpipError as exc: 

534 typer.echo(exc.format(), err=True) 

535 raise typer.Exit(code=1) from None 

536 typer.echo(format_effective_config(cfg)) 

537 

538 

539@config_app.command("get") 

540def config_get( 

541 key: str = typer.Argument(..., help="Dotted v1 key, for example source.path."), 

542) -> None: 

543 """Print one effective configuration value.""" 

544 try: 

545 cfg = load_effective_config(cwd=Path.cwd()) 

546 value = format_config_value(cfg, key) 

547 except LpipError as exc: 

548 typer.echo(exc.format(), err=True) 

549 raise typer.Exit(code=1) from None 

550 typer.echo(value) 

551 

552 

553@config_app.command("set") 

554def config_set( 

555 key: str = typer.Argument(..., help="Dotted v1 key, for example source.path."), 

556 value: str = typer.Argument(..., help="New value (TOML array syntax for lists)."), 

557) -> None: 

558 """Set a key in the persistent config file, keeping comments.""" 

559 try: 

560 path = set_config_key(key, value, cwd=Path.cwd()) 

561 except LpipError as exc: 

562 typer.echo(exc.format(), err=True) 

563 raise typer.Exit(code=1) from None 

564 typer.echo(str(path)) 

565 

566 

567@config_app.command("unset") 

568def config_unset( 

569 key: str = typer.Argument(..., help="Dotted v1 key, for example runtime.python."), 

570) -> None: 

571 """Remove a key from the persistent config file, keeping comments.""" 

572 try: 

573 path = unset_config_key(key, cwd=Path.cwd()) 

574 except LpipError as exc: 

575 typer.echo(exc.format(), err=True) 

576 raise typer.Exit(code=1) from None 

577 typer.echo(str(path)) 

578 

579 

580def _blank_to_none(value: str | None) -> str | None: 

581 if value is None: 

582 return None 

583 stripped = value.strip() 

584 return stripped or None 

585 

586 

587@catalog_app.command("init") 

588def catalog_init( 

589 path: str | None = typer.Option( 

590 None, 

591 "--path", 

592 help="Catalog file to create (default: lpip-sources.toml in the current directory).", 

593 ), 

594) -> None: 

595 """Write a commented empty catalog in this working tree (does not commit).""" 

596 try: 

597 target = resolve_authoring_catalog_path(path, cwd=Path.cwd()) 

598 write_catalog_init_template(target) 

599 except LpipError as exc: 

600 typer.echo(exc.format(), err=True) 

601 raise typer.Exit(code=1) from None 

602 typer.echo(str(target)) 

603 

604 

605@catalog_app.command("add") 

606def catalog_add( 

607 package: str | None = typer.Argument( 

608 None, help="Catalog package name (pip distribution name)." 

609 ), 

610 url: str | None = typer.Option( 

611 None, 

612 "--url", 

613 help="Git URL: ssh://, https://, git@host:path, git+ssh://, or git+https://.", 

614 ), 

615 revision: str | None = typer.Option(None, "--revision", help="Branch, tag, or SHA."), 

616 subdirectory: str | None = typer.Option( 

617 None, 

618 "--subdirectory", 

619 help="Path of the Python project inside the repository.", 

620 ), 

621 path: str | None = typer.Option( 

622 None, 

623 "--path", 

624 help="Catalog file to edit (default: lpip-sources.toml in the current directory).", 

625 ), 

626 force: bool = typer.Option( 

627 False, "--force", help="Replace an existing entry with the same name." 

628 ), 

629 non_interactive: bool = typer.Option( 

630 False, 

631 "--non-interactive", 

632 help="Do not prompt; require a name and --url.", 

633 ), 

634) -> None: 

635 """Add a package table to a catalog file in this working tree.""" 

636 try: 

637 name = _blank_to_none(package) 

638 pkg_url = _blank_to_none(url) 

639 if non_interactive: 

640 if name is None or pkg_url is None: 640 ↛ 642line 640 didn't jump to line 642 because the condition on line 640 was always true

641 raise MissingCatalogAddFieldsError() 

642 rev = _blank_to_none(revision) 

643 sub = _blank_to_none(subdirectory) 

644 elif name is not None and pkg_url is not None: 

645 rev = _blank_to_none(revision) 

646 sub = _blank_to_none(subdirectory) 

647 else: 

648 if name is None: 648 ↛ 650line 648 didn't jump to line 650 because the condition on line 648 was always true

649 name = _blank_to_none(typer.prompt("Package name")) 

650 if pkg_url is None: 650 ↛ 654line 650 didn't jump to line 654 because the condition on line 650 was always true

651 pkg_url = _blank_to_none( 

652 typer.prompt("Git URL (ssh://, https://, or git@host:path)") 

653 ) 

654 if name is None or pkg_url is None: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

655 raise MissingCatalogAddFieldsError() 

656 if revision is None: 656 ↛ 658line 656 didn't jump to line 658 because the condition on line 656 was always true

657 revision = typer.prompt("Revision (branch, tag, or SHA)", default="") 

658 if subdirectory is None: 658 ↛ 663line 658 didn't jump to line 663 because the condition on line 658 was always true

659 subdirectory = typer.prompt( 

660 "Subdirectory (empty if the project is at the repo root)", 

661 default="", 

662 ) 

663 rev = _blank_to_none(revision) 

664 sub = _blank_to_none(subdirectory) 

665 cwd = Path.cwd() 

666 cfg = load_effective_config(cwd=cwd) 

667 written = add_package_to_catalog_file( 

668 resolve_authoring_catalog_path(path, cwd=cwd), 

669 PackageSource(name=name, url=pkg_url, revision=rev, subdirectory=sub), 

670 policy=cfg.policy, 

671 force=force, 

672 ) 

673 except LpipError as exc: 

674 typer.echo(exc.format(), err=True) 

675 raise typer.Exit(code=1) from None 

676 typer.echo(str(written)) 

677 

678 

679@catalog_app.command("refresh") 

680def catalog_refresh() -> None: 

681 """Clone or fetch every Git catalog and confirm catalog files.""" 

682 try: 

683 cwd = Path.cwd() 

684 cfg = load_effective_config(cwd=cwd) 

685 if git_sources(cfg): 685 ↛ 692line 685 didn't jump to line 692 because the condition on line 685 was always true

686 for src in git_sources(cfg): 

687 location = resolve_source_location(src, cwd=cwd, force_refresh=True) 

688 if location.cache is not None: 688 ↛ 690line 688 didn't jump to line 690 because the condition on line 688 was always true

689 typer.echo(str(location.cache.root)) 

690 typer.echo(str(location.catalog_file)) 

691 else: 

692 location = resolve_catalog_location(cfg, cwd=cwd, force_refresh=True) 

693 typer.echo(str(location.catalog_file)) 

694 except LpipError as exc: 

695 typer.echo(exc.format(), err=True) 

696 raise typer.Exit(code=1) from None 

697 

698 

699@catalog_app.command("path") 

700def catalog_path() -> None: 

701 """Print cache and catalog file paths without fetching.""" 

702 try: 

703 cwd = Path.cwd() 

704 cfg = load_effective_config(cwd=cwd) 

705 if git_sources(cfg): 705 ↛ 711line 705 didn't jump to line 711 because the condition on line 705 was always true

706 for src in git_sources(cfg): 

707 cache = planned_git_cache_paths_for(src) 

708 typer.echo(str(cache.root)) 

709 typer.echo(str(cache.repo / src.path)) 

710 else: 

711 location = resolve_catalog_location(cfg, cwd=cwd) 

712 typer.echo(str(location.catalog_file)) 

713 except LpipError as exc: 

714 typer.echo(exc.format(), err=True) 

715 raise typer.Exit(code=1) from None 

716 

717 

718@catalog_app.command("purge") 

719def catalog_purge() -> None: 

720 """Delete cached checkouts for every configured Git catalog.""" 

721 try: 

722 cwd = Path.cwd() 

723 cfg = load_effective_config(cwd=cwd) 

724 for cache in purge_all_git_catalog_caches(cfg): 

725 typer.echo(str(cache.root)) 

726 except LpipError as exc: 

727 typer.echo(exc.format(), err=True) 

728 raise typer.Exit(code=1) from None 

729 

730 

731@source_app.command("list") 

732def source_list() -> None: 

733 """List configured catalog sources in lookup order.""" 

734 try: 

735 cfg = load_effective_config(cwd=Path.cwd()) 

736 except LpipError as exc: 

737 typer.echo(exc.format(), err=True) 

738 raise typer.Exit(code=1) from None 

739 typer.echo(format_sources_table(cfg.sources)) 

740 

741 

742@source_app.command("add") 

743def source_add( 

744 name: str = typer.Argument(..., help="Source name (first-wins order is file order)."), 

745 source_type: SourceType = typer.Option(..., "--type", help="file or git."), 

746 path: str = typer.Option( 

747 "lpip-sources.toml", 

748 "--path", 

749 help="Local catalog file, or path inside a Git catalog repo.", 

750 ), 

751 url: str | None = typer.Option(None, "--url", help="Catalog Git URL when --type git."), 

752 revision: str | None = typer.Option(None, "--revision", help="Catalog Git revision."), 

753 refresh: str | None = typer.Option( 

754 None, "--refresh", help="always, auto, or never (git sources)." 

755 ), 

756) -> None: 

757 """Register a catalog in persistent config ([[sources]]).""" 

758 try: 

759 written = add_configured_source( 

760 name, 

761 source_type=source_type, 

762 path=path, 

763 url=url, 

764 revision=revision, 

765 refresh=refresh, 

766 cwd=Path.cwd(), 

767 ) 

768 except LpipError as exc: 

769 typer.echo(exc.format(), err=True) 

770 raise typer.Exit(code=1) from None 

771 typer.echo(str(written)) 

772 

773 

774@source_app.command("remove") 

775def source_remove( 

776 name: str = typer.Argument(..., help="Source name to drop."), 

777) -> None: 

778 """Remove a catalog source from persistent config.""" 

779 try: 

780 written = remove_configured_source(name, cwd=Path.cwd()) 

781 except LpipError as exc: 

782 typer.echo(exc.format(), err=True) 

783 raise typer.Exit(code=1) from None 

784 typer.echo(str(written)) 

785 

786 

787def main() -> None: 

788 app()