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

1"""Init command — scaffold new apcore modules (Phase 1).""" 

2 

3from __future__ import annotations 

4 

5import sys 

6from pathlib import Path 

7 

8import click 

9 

10_DECORATOR_TEMPLATE = '''"""Module: {module_id}""" 

11 

12from apcore import module 

13 

14 

15@module(id="{module_id}", description="{description}") 

16def {func_name}({params_str}) -> dict: 

17 """{description}""" 

18 # TODO: implement 

19 return {{"status": "ok"}} 

20''' 

21 

22_CONVENTION_TEMPLATE = '''"""{description}""" 

23 

24{cli_group_line}{tags_line} 

25 

26def {func_name}({params_str}) -> dict: 

27 """{description}""" 

28 # TODO: implement 

29 return {{"status": "ok"}} 

30''' 

31 

32_BINDING_TEMPLATE = """bindings: 

33 - module_id: "{module_id}" 

34 target: "{target}" 

35 description: "{description}" 

36 auto_schema: true 

37""" 

38 

39 

40def register_init_command(cli: click.Group) -> None: 

41 """Register the init command on the CLI group.""" 

42 

43 @cli.group("init") 

44 def init_group(): 

45 """Scaffold new apcore modules.""" 

46 pass 

47 

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. 

67 

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) 

73 

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] 

81 

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) 

88 

89 

90def _refuse_if_exists(filepath: Path, force: bool) -> bool: 

91 """Return True if writing should proceed; False if skipped. 

92 

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 

104 

105 

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 

113 

114 if not _refuse_if_exists(filepath, force): 

115 return 

116 

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}") 

125 

126 

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) 

135 

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 

141 

142 if not _refuse_if_exists(filepath, force): 

143 return 

144 

145 cli_group_line = f'CLI_GROUP = "{prefix_parts[0]}"\n' if len(prefix_parts) >= 1 and "." in module_id else "" 

146 tags_line = "" 

147 

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}") 

157 

158 

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) 

164 

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}" 

168 

169 if not _refuse_if_exists(yaml_file, force): 

170 return 

171 

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}") 

179 

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}")