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

60 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-11 00:29 -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) are opt-in — implement their methods to activate.", 

84 ' """', 

85 "", 

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

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

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

89 " pass", 

90 ] 

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

92 

93 

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

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

96 parts: list[str] = [ 

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

98 "", 

99 "from __future__ import annotations", 

100 "", 

101 "from little_loops import LLTestBus", 

102 "", 

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

104 "", 

105 "", 

106 f"class Test{class_name}:", 

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

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

109 " bus = LLTestBus([])", 

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

111 " bus.register(ext)", 

112 " bus.replay()", 

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

114 ] 

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

116 

117 

118def main_create_extension() -> int: 

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

120 

121 Scaffold a new little-loops extension project directory. 

122 

123 Returns: 

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

125 """ 

126 parser = argparse.ArgumentParser( 

127 prog="ll-create-extension", 

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

129 formatter_class=argparse.RawDescriptionHelpFormatter, 

130 epilog=""" 

131Examples: 

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

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

134""", 

135 ) 

136 

137 parser.add_argument( 

138 "name", 

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

140 ) 

141 add_dry_run_arg(parser) 

142 

143 args = parser.parse_args() 

144 name: str = args.name 

145 dry_run: bool = args.dry_run 

146 

147 configure_output() 

148 logger = Logger(use_color=use_color_enabled()) 

149 

150 pkg_name = _to_pkg_name(name) 

151 class_name = _to_class_name(name) 

152 target = _get_cwd() / name 

153 

154 if _target_exists(target): 

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

156 return 1 

157 

158 files: dict[Path, str] = { 

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

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

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

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

163 } 

164 

165 if dry_run: 

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

167 for path in files: 

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

169 return 0 

170 

171 _write_scaffold(target, files) 

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

173 for path in files: 

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

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

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

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

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

179 

180 return 0