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
« 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."""
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) 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"
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"
118def main_create_extension() -> int:
119 """Entry point for ll-create-extension command.
121 Scaffold a new little-loops extension project directory.
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 )
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)
143 args = parser.parse_args()
144 name: str = args.name
145 dry_run: bool = args.dry_run
147 configure_output()
148 logger = Logger(use_color=use_color_enabled())
150 pkg_name = _to_pkg_name(name)
151 class_name = _to_class_name(name)
152 target = _get_cwd() / name
154 if _target_exists(target):
155 logger.error(f"directory '{name}' already exists. Remove it or choose a different name.")
156 return 1
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 }
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
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/")
180 return 0