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

63 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8 

9from little_loops.cli.output import configure_output, use_color_enabled 

10from little_loops.cli_args import add_dry_run_arg 

11from little_loops.logger import Logger 

12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

13 

14 

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

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

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

18 

19 

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

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

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

23 

24 

25def _get_cwd() -> Path: 

26 """Return current working directory.""" 

27 return Path.cwd() 

28 

29 

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

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

32 return target.exists() 

33 

34 

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

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

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

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

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

40 

41 

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

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

44 parts: list[str] = [ 

45 "[build-system]", 

46 'requires = ["hatchling"]', 

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

48 "", 

49 "[project]", 

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

51 'version = "0.1.0"', 

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

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

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

55 "", 

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

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

58 ] 

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

60 

61 

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

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

64 parts: list[str] = [ 

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

66 ] 

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

68 

69 

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

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

72 parts: list[str] = [ 

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

74 "", 

75 "from __future__ import annotations", 

76 "", 

77 "from little_loops import LLEvent", 

78 "", 

79 "", 

80 f"class {class_name}:", 

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

82 "", 

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

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

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

86 " their methods to activate.", 

87 ' """', 

88 "", 

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

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

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

92 " pass", 

93 ] 

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

95 

96 

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

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

99 parts: list[str] = [ 

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

101 "", 

102 "from __future__ import annotations", 

103 "", 

104 "from little_loops import LLTestBus", 

105 "", 

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

107 "", 

108 "", 

109 f"class Test{class_name}:", 

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

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

112 " bus = LLTestBus([])", 

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

114 " bus.register(ext)", 

115 " bus.replay()", 

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

117 ] 

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

119 

120 

121def main_create_extension() -> int: 

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

123 

124 Scaffold a new little-loops extension project directory. 

125 

126 Returns: 

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

128 """ 

129 with cli_event_context(DEFAULT_DB_PATH, "ll-create-extension", sys.argv[1:]): 

130 parser = argparse.ArgumentParser( 

131 prog="ll-create-extension", 

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

133 formatter_class=argparse.RawDescriptionHelpFormatter, 

134 epilog=""" 

135Examples: 

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

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

138""", 

139 ) 

140 

141 parser.add_argument( 

142 "name", 

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

144 ) 

145 add_dry_run_arg(parser) 

146 

147 args = parser.parse_args() 

148 name: str = args.name 

149 dry_run: bool = args.dry_run 

150 

151 configure_output() 

152 logger = Logger(use_color=use_color_enabled()) 

153 

154 pkg_name = _to_pkg_name(name) 

155 class_name = _to_class_name(name) 

156 target = _get_cwd() / name 

157 

158 if _target_exists(target): 

159 logger.error( 

160 f"directory '{name}' already exists. Remove it or choose a different name." 

161 ) 

162 return 1 

163 

164 files: dict[Path, str] = { 

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

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

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

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

169 } 

170 

171 if dry_run: 

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

173 for path in files: 

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

175 return 0 

176 

177 _write_scaffold(target, files) 

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

179 for path in files: 

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

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

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

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

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

185 

186 return 0