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
« 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."""
3from __future__ import annotations
5import sys
6from pathlib import Path
8import typer
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
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")
91def _print_version_and_exit(value: bool) -> None:
92 if value:
93 typer.echo(format_cli_version())
94 raise typer.Exit()
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")
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)
120def _load_catalog() -> Catalog:
121 cwd = Path.cwd()
122 cfg = load_effective_config(cwd=cwd)
123 return load_catalog_for_config(cfg, cwd=cwd)
126_LIST_HEADERS = ("name", "url", "revision", "subdirectory")
127_LIST_HEADERS_WITH_SOURCE = ("name", "source", "url", "revision", "subdirectory")
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)]
153 def _line(cells: tuple[str, ...]) -> str:
154 return " ".join(cell.ljust(width) for cell, width in zip(cells, widths, strict=True))
156 lines = [_line(headers)]
157 lines.extend(_line(row) for row in rows)
158 return "\n".join(lines)
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
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}")
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)
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)
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)
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 )
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 )
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()))
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}")
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))
474@app.command("version")
475def version() -> None:
476 """Print the lpip version and exit."""
477 typer.echo(format_cli_version())
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)
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)
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)
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))
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))
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)
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))
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))
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
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))
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))
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
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
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
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))
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))
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))
787def main() -> None:
788 app()