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
« 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."""
3from __future__ import annotations
5import argparse
6from pathlib import Path
8from little_loops.cli_args import add_dry_run_arg
11def _to_pkg_name(name: str) -> str:
12 """Convert kebab-case name to snake_case package name."""
13 return name.replace("-", "_")
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)
21def _get_cwd() -> Path:
22 """Return current working directory."""
23 return Path.cwd()
26def _target_exists(target: Path) -> bool:
27 """Return True if target directory already exists."""
28 return target.exists()
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")
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"
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"
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"
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"
116def main_create_extension() -> int:
117 """Entry point for ll-create-extension command.
119 Scaffold a new little-loops extension project directory.
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 )
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)
141 args = parser.parse_args()
142 name: str = args.name
143 dry_run: bool = args.dry_run
145 pkg_name = _to_pkg_name(name)
146 class_name = _to_class_name(name)
147 target = _get_cwd() / name
149 if _target_exists(target):
150 print(f"Error: directory '{name}' already exists. Remove it or choose a different name.")
151 return 1
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 }
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
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/")
175 return 0