Coverage for little_loops / cli / create_extension.py: 92%

60 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""ll-create-extension: Scaffold a new little-loops extension project.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6from pathlib import Path 

7 

8from little_loops.cli.output import configure_output, use_color_enabled 

9from little_loops.cli_args import add_dry_run_arg 

10from little_loops.logger import Logger 

11 

12 

13def _to_pkg_name(name: str) -> str: 

14 """Convert kebab-case name to snake_case package name.""" 

15 return name.replace("-", "_") 

16 

17 

18def _to_class_name(name: str) -> str: 

19 """Convert kebab-case name to PascalCase class name.""" 

20 return "".join(part.capitalize() for part in name.replace("-", "_").split("_") if part) 

21 

22 

23def _get_cwd() -> Path: 

24 """Return current working directory.""" 

25 return Path.cwd() 

26 

27 

28def _target_exists(target: Path) -> bool: 

29 """Return True if target directory already exists.""" 

30 return target.exists() 

31 

32 

33def _write_scaffold(target: Path, files: dict[Path, str]) -> None: 

34 """Write scaffolded files to disk, creating parent directories as needed.""" 

35 for path, content in files.items(): 

36 path.parent.mkdir(parents=True, exist_ok=True) 

37 path.write_text(content, encoding="utf-8") 

38 

39 

40def _render_pyproject(name: str, pkg_name: str, class_name: str) -> str: 

41 """Render pyproject.toml content for the scaffolded extension.""" 

42 parts: list[str] = [ 

43 "[build-system]", 

44 'requires = ["hatchling"]', 

45 'build-backend = "hatchling.build"', 

46 "", 

47 "[project]", 

48 f'name = "{name}"', 

49 'version = "0.1.0"', 

50 'description = "A little-loops extension"', 

51 'requires-python = ">=3.11"', 

52 'dependencies = ["little-loops"]', 

53 "", 

54 '[project.entry-points."little_loops.extensions"]', 

55 f'{name} = "{pkg_name}.extension:{class_name}"', 

56 ] 

57 return "\n".join(parts) + "\n" 

58 

59 

60def _render_init(name: str) -> str: 

61 """Render __init__.py content for the scaffolded extension package.""" 

62 parts: list[str] = [ 

63 f'"""{name} — a little-loops extension."""', 

64 ] 

65 return "\n".join(parts) + "\n" 

66 

67 

68def _render_extension(name: str, class_name: str) -> str: 

69 """Render extension.py skeleton for the scaffolded extension.""" 

70 parts: list[str] = [ 

71 f'"""{name} — a little-loops extension."""', 

72 "", 

73 "from __future__ import annotations", 

74 "", 

75 "from little_loops import LLEvent", 

76 "", 

77 "", 

78 f"class {class_name}:", 

79 f' """{class_name} extension.', 

80 "", 

81 " Implement on_event to handle little-loops lifecycle events.", 

82 " Optional mixin Protocols (InterceptorExtension, ActionProviderExtension,", 

83 " EvaluatorProviderExtension, LLHookIntentExtension) are opt-in — implement", 

84 " their methods to activate.", 

85 ' """', 

86 "", 

87 " def on_event(self, event: LLEvent) -> None:", 

88 ' """Handle an incoming event."""', 

89 " # See docs/reference/EVENT-SCHEMA.md for all available event types and payload fields", 

90 " pass", 

91 ] 

92 return "\n".join(parts) + "\n" 

93 

94 

95def _render_test(class_name: str, pkg_name: str) -> str: 

96 """Render test_extension.py content using LLTestBus for the scaffolded extension.""" 

97 parts: list[str] = [ 

98 f'"""Tests for {class_name}."""', 

99 "", 

100 "from __future__ import annotations", 

101 "", 

102 "from little_loops import LLTestBus", 

103 "", 

104 f"from {pkg_name}.extension import {class_name}", 

105 "", 

106 "", 

107 f"class Test{class_name}:", 

108 " def test_receives_events(self) -> None:", 

109 ' """Extension receives events via LLTestBus replay."""', 

110 " bus = LLTestBus([])", 

111 f" ext = {class_name}()", 

112 " bus.register(ext)", 

113 " bus.replay()", 

114 " assert bus.delivered_events == []", 

115 ] 

116 return "\n".join(parts) + "\n" 

117 

118 

119def main_create_extension() -> int: 

120 """Entry point for ll-create-extension command. 

121 

122 Scaffold a new little-loops extension project directory. 

123 

124 Returns: 

125 Exit code (0 = success, 1 = error) 

126 """ 

127 parser = argparse.ArgumentParser( 

128 prog="ll-create-extension", 

129 description="Scaffold a new little-loops extension project", 

130 formatter_class=argparse.RawDescriptionHelpFormatter, 

131 epilog=""" 

132Examples: 

133 %(prog)s my-dashboard-ext # Create extension scaffold 

134 %(prog)s my-dashboard-ext --dry-run # Preview without writing files 

135""", 

136 ) 

137 

138 parser.add_argument( 

139 "name", 

140 help="Extension name in kebab-case (e.g. my-dashboard-ext)", 

141 ) 

142 add_dry_run_arg(parser) 

143 

144 args = parser.parse_args() 

145 name: str = args.name 

146 dry_run: bool = args.dry_run 

147 

148 configure_output() 

149 logger = Logger(use_color=use_color_enabled()) 

150 

151 pkg_name = _to_pkg_name(name) 

152 class_name = _to_class_name(name) 

153 target = _get_cwd() / name 

154 

155 if _target_exists(target): 

156 logger.error(f"directory '{name}' already exists. Remove it or choose a different name.") 

157 return 1 

158 

159 files: dict[Path, str] = { 

160 target / "pyproject.toml": _render_pyproject(name, pkg_name, class_name), 

161 target / pkg_name / "__init__.py": _render_init(name), 

162 target / pkg_name / "extension.py": _render_extension(name, class_name), 

163 target / "tests" / "test_extension.py": _render_test(class_name, pkg_name), 

164 } 

165 

166 if dry_run: 

167 logger.info(f"[DRY RUN] Would create: {name}/") 

168 for path in files: 

169 logger.info(f" {path.relative_to(target)}") 

170 return 0 

171 

172 _write_scaffold(target, files) 

173 logger.success(f"Created: {name}/") 

174 for path in files: 

175 logger.info(f" {path.relative_to(target)}") 

176 logger.info("\nNext steps:") 

177 logger.info(f" cd {name}") 

178 logger.info(" pip install -e .") 

179 logger.info(" python -m pytest tests/") 

180 

181 return 0