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

56 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -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_args import add_dry_run_arg 

9 

10 

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

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

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

14 

15 

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

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

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

19 

20 

21def _get_cwd() -> Path: 

22 """Return current working directory.""" 

23 return Path.cwd() 

24 

25 

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

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

28 return target.exists() 

29 

30 

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

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

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

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

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

36 

37 

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

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

40 parts: list[str] = [ 

41 "[build-system]", 

42 'requires = ["hatchling"]', 

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

44 "", 

45 "[project]", 

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

47 'version = "0.1.0"', 

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

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

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

51 "", 

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

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

54 ] 

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

56 

57 

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

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

60 parts: list[str] = [ 

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

62 ] 

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

64 

65 

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

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

68 parts: list[str] = [ 

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

70 "", 

71 "from __future__ import annotations", 

72 "", 

73 "from little_loops import LLEvent", 

74 "", 

75 "", 

76 f"class {class_name}:", 

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

78 "", 

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

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

81 " EvaluatorProviderExtension) are opt-in — implement their methods to activate.", 

82 ' """', 

83 "", 

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

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

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

87 " pass", 

88 ] 

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

90 

91 

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

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

94 parts: list[str] = [ 

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

96 "", 

97 "from __future__ import annotations", 

98 "", 

99 "from little_loops import LLTestBus", 

100 "", 

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

102 "", 

103 "", 

104 f"class Test{class_name}:", 

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

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

107 " bus = LLTestBus([])", 

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

109 " bus.register(ext)", 

110 " bus.replay()", 

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

112 ] 

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

114 

115 

116def main_create_extension() -> int: 

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

118 

119 Scaffold a new little-loops extension project directory. 

120 

121 Returns: 

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

123 """ 

124 parser = argparse.ArgumentParser( 

125 prog="ll-create-extension", 

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

127 formatter_class=argparse.RawDescriptionHelpFormatter, 

128 epilog=""" 

129Examples: 

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

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

132""", 

133 ) 

134 

135 parser.add_argument( 

136 "name", 

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

138 ) 

139 add_dry_run_arg(parser) 

140 

141 args = parser.parse_args() 

142 name: str = args.name 

143 dry_run: bool = args.dry_run 

144 

145 pkg_name = _to_pkg_name(name) 

146 class_name = _to_class_name(name) 

147 target = _get_cwd() / name 

148 

149 if _target_exists(target): 

150 print(f"Error: directory '{name}' already exists. Remove it or choose a different name.") 

151 return 1 

152 

153 files: dict[Path, str] = { 

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

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

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

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

158 } 

159 

160 if dry_run: 

161 print(f"[DRY RUN] Would create: {name}/") 

162 for path in files: 

163 print(f" {path.relative_to(target)}") 

164 return 0 

165 

166 _write_scaffold(target, files) 

167 print(f"Created: {name}/") 

168 for path in files: 

169 print(f" {path.relative_to(target)}") 

170 print(f"\nNext steps:") 

171 print(f" cd {name}") 

172 print(f" pip install -e .") 

173 print(f" python -m pytest tests/") 

174 

175 return 0