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
« 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."""
3from __future__ import annotations
5import argparse
6from pathlib import Path
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
13def _to_pkg_name(name: str) -> str:
14 """Convert kebab-case name to snake_case package name."""
15 return name.replace("-", "_")
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)
23def _get_cwd() -> Path:
24 """Return current working directory."""
25 return Path.cwd()
28def _target_exists(target: Path) -> bool:
29 """Return True if target directory already exists."""
30 return target.exists()
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")
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"
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"
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"
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"
119def main_create_extension() -> int:
120 """Entry point for ll-create-extension command.
122 Scaffold a new little-loops extension project directory.
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 )
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)
144 args = parser.parse_args()
145 name: str = args.name
146 dry_run: bool = args.dry_run
148 configure_output()
149 logger = Logger(use_color=use_color_enabled())
151 pkg_name = _to_pkg_name(name)
152 class_name = _to_class_name(name)
153 target = _get_cwd() / name
155 if _target_exists(target):
156 logger.error(f"directory '{name}' already exists. Remove it or choose a different name.")
157 return 1
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 }
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
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/")
181 return 0