Coverage for src / apcore_cli / strategy.py: 84%

83 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-26 10:23 +0800

1"""Pipeline strategy commands — describe-pipeline (FE-11).""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import json 

7import logging 

8import sys 

9from typing import Any 

10 

11import click 

12 

13from apcore_cli.output import resolve_format 

14 

15logger = logging.getLogger(__name__) 

16 

17_PRESET_STEPS = { 

18 "standard": [ 

19 "context_creation", 

20 "call_chain_guard", 

21 "module_lookup", 

22 "acl_check", 

23 "approval_gate", 

24 "middleware_before", 

25 "input_validation", 

26 "execute", 

27 "output_validation", 

28 "middleware_after", 

29 "return_result", 

30 ], 

31 "internal": [ 

32 "context_creation", 

33 "call_chain_guard", 

34 "module_lookup", 

35 "middleware_before", 

36 "input_validation", 

37 "execute", 

38 "output_validation", 

39 "middleware_after", 

40 "return_result", 

41 ], 

42 "testing": [ 

43 "context_creation", 

44 "module_lookup", 

45 "middleware_before", 

46 "input_validation", 

47 "execute", 

48 "output_validation", 

49 "middleware_after", 

50 "return_result", 

51 ], 

52 "performance": [ 

53 "context_creation", 

54 "call_chain_guard", 

55 "module_lookup", 

56 "acl_check", 

57 "approval_gate", 

58 "input_validation", 

59 "execute", 

60 "output_validation", 

61 "return_result", 

62 ], 

63 "minimal": [ 

64 "context_creation", 

65 "module_lookup", 

66 "execute", 

67 "return_result", 

68 ], 

69} 

70 

71 

72def _render_pipeline_table( 

73 steps_info: list[dict[str, Any]], 

74 fmt: str, 

75 strategy_name: str, 

76 step_count: int, 

77) -> None: 

78 """Render pipeline steps as JSON or a table. 

79 

80 When fmt == "json" (or stdout is not a TTY), emits JSON. 

81 Otherwise renders a full-metadata table (pure/removable/timeout columns) when 

82 any step carries non-default metadata; renders a name-only table otherwise. 

83 """ 

84 if fmt == "json" or not sys.stdout.isatty(): 

85 payload = { 

86 "strategy": strategy_name, 

87 "step_count": step_count, 

88 "steps": [{"index": i + 1, **s} for i, s in enumerate(steps_info)], 

89 } 

90 click.echo(json.dumps(payload, indent=2)) 

91 return 

92 

93 click.echo(f"Pipeline: {strategy_name} ({step_count} steps)\n") 

94 has_metadata = steps_info and any( 

95 s.get("pure") is not False or s.get("removable") is not True or s.get("timeout_ms") for s in steps_info 

96 ) 

97 

98 if has_metadata: 

99 click.echo(f" {'#':<4} {'Step':<28} {'Pure':<6} {'Removable':<11} Timeout") 

100 click.echo(f" {'-' * 4} {'-' * 28} {'-' * 6} {'-' * 11} {'-' * 8}") 

101 for i, s in enumerate(steps_info, 1): 

102 pure = "yes" if s.get("pure") else "no" 

103 removable = "yes" if s.get("removable", True) else "no" 

104 timeout = f"{s['timeout_ms']}ms" if s.get("timeout_ms") else "\u2014" 

105 click.echo(f" {i:<4} {s['name']:<28} {pure:<6} {removable:<11} {timeout}") 

106 else: 

107 click.echo(f" {'#':<4} {'Step':<28}") 

108 click.echo(f" {'-' * 4} {'-' * 28}") 

109 for i, s in enumerate(steps_info, 1): 

110 click.echo(f" {i:<4} {s['name']:<28}") 

111 

112 

113def register_pipeline_command(cli: click.Group, executor: Any) -> None: 

114 """Register the describe-pipeline command.""" 

115 

116 @cli.command("describe-pipeline") 

117 @click.option( 

118 "--strategy", 

119 type=click.Choice(["standard", "internal", "testing", "performance", "minimal"]), 

120 default="standard", 

121 help="Strategy to describe (default: standard).", 

122 ) 

123 @click.option("--format", "output_format", type=click.Choice(["table", "json"]), default=None) 

124 def _describe_pipeline_cmd(strategy: str, output_format: str | None) -> None: # pyright: ignore[reportUnusedVariable] 

125 """Show the execution pipeline steps for a strategy.""" 

126 fmt = resolve_format(output_format) 

127 

128 # Try to get StrategyInfo from executor.describe_pipeline() (apcore >= 0.18.0) 

129 strategy_info = None 

130 if hasattr(executor, "describe_pipeline"): 

131 with contextlib.suppress(AttributeError, NotImplementedError, TypeError): 

132 strategy_info = executor.describe_pipeline(strategy) 

133 

134 if strategy_info is not None: 

135 # Use StrategyInfo dataclass fields for output 

136 info_name = getattr(strategy_info, "name", strategy) 

137 info_step_count = getattr(strategy_info, "step_count", 0) 

138 info_step_names = getattr(strategy_info, "step_names", []) 

139 

140 # Try to get full step metadata from executor._strategy.steps 

141 steps_info: list[dict[str, Any]] = [] 

142 strategy_obj = None 

143 if hasattr(executor, "_strategy"): 

144 try: 

145 strategy_obj = executor._strategy 

146 except (AttributeError, NotImplementedError, TypeError) as e: 

147 logger.warning("executor._strategy access failed; using step-names fallback: %s", e) 

148 if strategy_obj is None and hasattr(executor, "_resolve_strategy_name"): 

149 try: 

150 strategy_obj = executor._resolve_strategy_name(strategy) 

151 except (AttributeError, NotImplementedError, TypeError) as e: 

152 logger.warning("executor._resolve_strategy_name failed; using step-names fallback: %s", e) 

153 

154 if strategy_obj is not None and hasattr(strategy_obj, "steps"): 

155 for step in strategy_obj.steps: 

156 steps_info.append( 

157 { 

158 "name": getattr(step, "name", ""), 

159 "pure": getattr(step, "pure", False), 

160 "removable": getattr(step, "removable", True), 

161 "timeout_ms": getattr(step, "timeout_ms", None), 

162 } 

163 ) 

164 else: 

165 steps_info = [ 

166 {"name": s, "pure": False, "removable": True, "timeout_ms": None} for s in info_step_names 

167 ] 

168 

169 _render_pipeline_table(steps_info, fmt, info_name, info_step_count) 

170 return 

171 

172 # Fall back to legacy _resolve_strategy_name (apcore < 0.18.0) 

173 strategy_obj = None 

174 if hasattr(executor, "_resolve_strategy_name"): 

175 try: 

176 strategy_obj = executor._resolve_strategy_name(strategy) 

177 except (AttributeError, NotImplementedError, TypeError) as e: 

178 logger.warning("executor._resolve_strategy_name failed; using preset-steps fallback: %s", e) 

179 

180 if strategy_obj is None: 

181 # Provide static info for known strategies 

182 steps = _PRESET_STEPS.get(strategy, []) 

183 

184 if fmt == "json" or not sys.stdout.isatty(): 

185 payload = { 

186 "strategy": strategy, 

187 "step_count": len(steps), 

188 "steps": [{"index": i + 1, "name": s} for i, s in enumerate(steps)], 

189 } 

190 click.echo(json.dumps(payload, indent=2)) 

191 else: 

192 click.echo(f"Pipeline: {strategy} ({len(steps)} steps)\n") 

193 click.echo(f" {'#':<4} {'Step':<28}") 

194 click.echo(f" {'-' * 4} {'-' * 28}") 

195 for i, s in enumerate(steps, 1): 

196 click.echo(f" {i:<4} {s:<28}") 

197 return 

198 

199 # Use actual strategy object for detailed info 

200 steps_info = [] 

201 for step in strategy_obj.steps: 

202 step_entry: dict[str, Any] = { 

203 "name": step.name, 

204 "pure": getattr(step, "pure", False), 

205 "removable": getattr(step, "removable", True), 

206 "timeout_ms": getattr(step, "timeout_ms", None), 

207 } 

208 steps_info.append(step_entry) 

209 

210 _render_pipeline_table(steps_info, fmt, strategy, len(steps_info))