Coverage for src / lexigram / ui / cli / add.py: 91%

69 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1"""``lexigram-ui add`` — copy components into the user's project.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6import shutil 

7 

8import typer 

9 

10from lexigram.ui.cli.registry import COMPONENT_REGISTRY, ComponentEntry 

11 

12app = typer.Typer(name="add") 

13 

14 

15@app.command() 

16def add( 

17 component_name: str = typer.Argument( 

18 ..., help="Component name (e.g. 'button', 'card')" 

19 ), 

20 output_dir: str = typer.Option( 

21 "src/components/ui", "--output", "-o", help="Output directory" 

22 ), 

23 force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"), 

24) -> None: 

25 """Copy a UI component into your project.""" 

26 entry = COMPONENT_REGISTRY.get(component_name) 

27 if entry is None: 

28 available = ", ".join(sorted(COMPONENT_REGISTRY)) 

29 typer.echo(f"Unknown component: {component_name!r}", err=True) 

30 typer.echo(f"Available: {available}", err=True) 

31 raise typer.Exit(1) 

32 

33 ui_pkg = _find_ui_package() 

34 if ui_pkg is None: 

35 typer.echo( 

36 "Cannot locate lexigram-ui source. Install it in editable mode:\n" 

37 " uv pip install -e path/to/lexigram-ui", 

38 err=True, 

39 ) 

40 raise typer.Exit(1) 

41 

42 all_files = _collect_files(entry, ui_pkg) 

43 out_root = Path(output_dir) 

44 

45 copied: list[Path] = [] 

46 skipped: list[Path] = [] 

47 

48 for src in all_files: 

49 relative = src.relative_to(ui_pkg) 

50 dest = out_root / relative 

51 if dest.exists() and not force: 

52 skipped.append(dest) 

53 continue 

54 dest.parent.mkdir(parents=True, exist_ok=True) 

55 shutil.copy2(src, dest) 

56 copied.append(dest) 

57 

58 if copied: 

59 typer.echo(f"Added {component_name} component:") 

60 for p in copied: 

61 typer.echo(f" Created: {p}") 

62 if skipped: 

63 typer.echo(" Skipped (use --force to overwrite):") 

64 for p in skipped: 

65 typer.echo(f" {p}") 

66 if not copied: 

67 typer.echo("No files were copied (all already exist).") 

68 

69 

70def _find_ui_package() -> Path | None: 

71 """Locate the lexigram-ui source directory (package root containing lexigram/).""" 

72 import lexigram.ui # noqa: F811 

73 

74 pkg_path = Path(lexigram.ui.__file__).resolve().parent 

75 # pkg_path is the ui module directory 

76 if pkg_path.name == "ui" and pkg_path.parent.name == "lexigram": 

77 # source layout: .../src/lexigram/ui/ or .../lexigram/ui/ 

78 parent = pkg_path.parent.parent # .../src/ or .../ 

79 if (parent / "lexigram" / "ui").exists(): 

80 return parent 

81 # Check if src/ prefix is present 

82 if parent.name == "src" and (parent.parent / "lexigram" / "ui").exists(): 

83 return parent.parent 

84 return pkg_path.parent 

85 

86 

87def _collect_files(entry: ComponentEntry, ui_pkg: Path) -> list[Path]: 

88 """Collect all source files for a component and its dependencies.""" 

89 seen: set[Path] = set() 

90 result: list[Path] = [] 

91 

92 def _add(dep_path: str) -> None: 

93 full = (ui_pkg / dep_path).resolve() 

94 if full.exists() and full not in seen: 

95 seen.add(full) 

96 result.append(full) 

97 for other in COMPONENT_REGISTRY.values(): 

98 if dep_path.endswith(other.source_path): 

99 for subdep in other.dependencies: 

100 _add(subdep) 

101 

102 _add(entry.source_path) 

103 for dep in entry.dependencies: 

104 _add(dep) 

105 

106 return result 

107 

108 

109if __name__ == "__main__": 

110 app()