Coverage for src / apcore_cli / init_cmd.py: 93%
82 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-26 10:23 +0800
1"""Init command — scaffold new apcore modules (Phase 1)."""
3from __future__ import annotations
5import sys
6from pathlib import Path
8import click
10_DECORATOR_TEMPLATE = '''"""Module: {module_id}"""
12from apcore import module
15@module(id="{module_id}", description="{description}")
16def {func_name}({params_str}) -> dict:
17 """{description}"""
18 # TODO: implement
19 return {{"status": "ok"}}
20'''
22_CONVENTION_TEMPLATE = '''"""{description}"""
24{cli_group_line}{tags_line}
26def {func_name}({params_str}) -> dict:
27 """{description}"""
28 # TODO: implement
29 return {{"status": "ok"}}
30'''
32_BINDING_TEMPLATE = """bindings:
33 - module_id: "{module_id}"
34 target: "{target}"
35 description: "{description}"
36 auto_schema: true
37"""
40def register_init_command(cli: click.Group) -> None:
41 """Register the init command on the CLI group."""
43 @cli.group("init")
44 def init_group():
45 """Scaffold new apcore modules."""
46 pass
48 @init_group.command("module")
49 @click.argument("module_id")
50 @click.option(
51 "--style",
52 type=click.Choice(["decorator", "convention", "binding"]),
53 default="convention",
54 help="Module style: decorator (@module), convention (plain function), or binding (YAML).",
55 )
56 @click.option("--dir", "output_dir", default=None, help="Output directory. Default: extensions/ or commands/.")
57 @click.option("--description", "-d", default="TODO: add description", help="Module description.")
58 @click.option(
59 "-f",
60 "--force",
61 is_flag=True,
62 default=False,
63 help="Overwrite existing files. Without this flag, init refuses to clobber.",
64 )
65 def init_module(module_id: str, style: str, output_dir: str | None, description: str, force: bool) -> None:
66 """Create a new module from a template.
68 MODULE_ID is the module identifier (e.g., ops.deploy, user.create).
69 """
70 if output_dir is not None and ".." in Path(output_dir).parts:
71 click.echo("Error: Output directory must not contain '..' path components.", err=True)
72 sys.exit(2)
74 # Parse module_id into parts
75 parts = module_id.rsplit(".", 1)
76 if len(parts) == 2:
77 prefix, func_name = parts
78 else:
79 prefix = parts[0]
80 func_name = parts[0]
82 if style == "decorator":
83 _create_decorator_module(module_id, prefix, func_name, description, output_dir, force)
84 elif style == "convention":
85 _create_convention_module(module_id, prefix, func_name, description, output_dir, force)
86 elif style == "binding":
87 _create_binding_module(module_id, prefix, func_name, description, output_dir, force)
90def _refuse_if_exists(filepath: Path, force: bool) -> bool:
91 """Return True if writing should proceed; False if skipped.
93 When ``force`` is False and the file already exists, emit a refusal message
94 to stderr and return False so the caller can skip the write. Never aborts
95 the whole init (one-file refusal should not kill the other templates).
96 """
97 if filepath.exists() and not force:
98 click.echo(
99 f"Error: {filepath} already exists. Use -f/--force to overwrite.",
100 err=True,
101 )
102 return False
103 return True
106def _create_decorator_module(
107 module_id: str, prefix: str, func_name: str, description: str, output_dir: str | None, force: bool
108) -> None:
109 base = Path(output_dir or "extensions")
110 base.mkdir(parents=True, exist_ok=True)
111 filename = module_id.replace(".", "_") + ".py"
112 filepath = base / filename
114 if not _refuse_if_exists(filepath, force):
115 return
117 content = _DECORATOR_TEMPLATE.format(
118 module_id=module_id,
119 func_name=func_name,
120 description=description,
121 params_str="",
122 )
123 filepath.write_text(content)
124 click.echo(f"Created {filepath}")
127def _create_convention_module(
128 module_id: str, prefix: str, func_name: str, description: str, output_dir: str | None, force: bool
129) -> None:
130 base = Path(output_dir or "commands")
131 # If prefix has dots, create subdirectories
132 prefix_parts = prefix.split(".")
133 dir_path = base / Path(*prefix_parts) if len(prefix_parts) > 1 else base
134 dir_path.mkdir(parents=True, exist_ok=True)
136 filename = (prefix_parts[-1] if len(prefix_parts) > 1 else prefix) + ".py"
137 # If the file would be the same as the function name, use prefix as filename
138 if prefix == func_name:
139 filename = prefix + ".py"
140 filepath = dir_path / filename
142 if not _refuse_if_exists(filepath, force):
143 return
145 cli_group_line = f'CLI_GROUP = "{prefix_parts[0]}"\n' if len(prefix_parts) >= 1 and "." in module_id else ""
146 tags_line = ""
148 content = _CONVENTION_TEMPLATE.format(
149 func_name=func_name,
150 description=description,
151 params_str="",
152 cli_group_line=cli_group_line,
153 tags_line=tags_line,
154 )
155 filepath.write_text(content)
156 click.echo(f"Created {filepath}")
159def _create_binding_module(
160 module_id: str, prefix: str, func_name: str, description: str, output_dir: str | None, force: bool
161) -> None:
162 base_bindings = Path(output_dir or "bindings")
163 base_bindings.mkdir(parents=True, exist_ok=True)
165 yaml_file = base_bindings / (module_id.replace(".", "_") + ".binding.yaml")
166 src_base_name = Path(output_dir or "commands").name
167 target = f"{src_base_name}.{prefix}:{func_name}"
169 if not _refuse_if_exists(yaml_file, force):
170 return
172 yaml_content = _BINDING_TEMPLATE.format(
173 module_id=module_id,
174 target=target,
175 description=description,
176 )
177 yaml_file.write_text(yaml_content)
178 click.echo(f"Created {yaml_file}")
180 # Also create the target function file — honour --dir so all artifacts land together
181 base_src = Path(output_dir or "commands")
182 base_src.mkdir(parents=True, exist_ok=True)
183 src_file = base_src / (prefix.replace(".", "_") + ".py")
184 if not _refuse_if_exists(src_file, force):
185 return
186 src_content = (
187 f'def {func_name}() -> dict:\n """{description}"""\n # TODO: implement\n return {{"status": "ok"}}\n'
188 )
189 src_file.write_text(src_content)
190 click.echo(f"Created {src_file}")