Coverage for src / apcore_cli / schema_parser.py: 93%

85 statements  

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

1"""Schema Parser — JSON Schema to Click options mapping (FE-02).""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import sys 

7from typing import Any 

8 

9import click 

10 

11logger = logging.getLogger("apcore_cli.schema_parser") 

12 

13# Sentinel for boolean flag marker 

14_BOOLEAN_FLAG = object() 

15 

16# Property names that collide with built-in CLI options — schema_to_click_options 

17# rejects these early so the error surfaces at schema-parse time, not at invocation. 

18RESERVED_PROPERTY_NAMES: frozenset[str] = frozenset( 

19 { 

20 "format", 

21 "input", 

22 "yes", 

23 "large_input", 

24 "fields", 

25 "sandbox", 

26 "verbose", 

27 "dry_run", 

28 "trace", 

29 "stream", 

30 "strategy", 

31 "approval_timeout", 

32 "approval_token", 

33 } 

34) 

35 

36 

37def _map_type(prop_name: str, prop_schema: dict) -> Any: 

38 """Map JSON Schema type to Click parameter type.""" 

39 schema_type = prop_schema.get("type") 

40 

41 # Check file convention 

42 if schema_type == "string" and (prop_name.endswith("_file") or prop_schema.get("x-cli-file") is True): 

43 return click.Path(exists=True) 

44 

45 type_map = { 

46 "string": click.STRING, 

47 "integer": click.INT, 

48 "number": click.FLOAT, 

49 "boolean": _BOOLEAN_FLAG, 

50 "object": click.STRING, 

51 "array": click.STRING, 

52 } 

53 

54 if schema_type is None: 

55 logger.warning( 

56 "No type specified for property '%s', defaulting to string.", 

57 prop_name, 

58 ) 

59 return click.STRING 

60 

61 result = type_map.get(schema_type) 

62 if result is None: 

63 logger.warning( 

64 "Unknown schema type '%s' for property '%s', defaulting to string.", 

65 schema_type, 

66 prop_name, 

67 ) 

68 return click.STRING 

69 

70 return result 

71 

72 

73def _extract_help(prop_schema: dict, max_length: int = 1000) -> str | None: 

74 """Extract help text from schema property, preferring x-llm-description.""" 

75 text = prop_schema.get("x-llm-description") 

76 if not text: 

77 text = prop_schema.get("description") 

78 if not text: 

79 return None 

80 if max_length > 0 and len(text) > max_length: 

81 return text[: max_length - 3] + "..." 

82 return text 

83 

84 

85def schema_to_click_options(schema: dict, max_help_length: int = 1000) -> list[click.Option]: 

86 """Convert JSON Schema properties to a list of Click options.""" 

87 properties = schema.get("properties", {}) 

88 required_list = schema.get("required", []) 

89 options: list[click.Option] = [] 

90 flag_names: dict[str, str] = {} 

91 

92 # Warn about required properties not found in properties 

93 for req_name in required_list: 

94 if req_name not in properties: 

95 logger.warning( 

96 "Required property '%s' not found in properties, skipping.", 

97 req_name, 

98 ) 

99 

100 for prop_name, prop_schema in properties.items(): 

101 if prop_name in RESERVED_PROPERTY_NAMES: 

102 raise ValueError( 

103 f"Schema property '{prop_name}' is reserved and conflicts with a built-in CLI option. " 

104 "Rename the property." 

105 ) 

106 

107 flag_name = "--" + prop_name.replace("_", "-") 

108 

109 # Collision detection 

110 if flag_name in flag_names: 

111 click.echo( 

112 f"Error: Flag name collision: properties '{prop_name}' and " 

113 f"'{flag_names[flag_name]}' both map to '{flag_name}'.", 

114 err=True, 

115 ) 

116 sys.exit(48) 

117 

118 flag_names[flag_name] = prop_name 

119 

120 click_type = _map_type(prop_name, prop_schema) 

121 is_required = prop_name in required_list 

122 _help_base = _extract_help(prop_schema, max_length=max_help_length) 

123 # Append [required] to help text for user clarity; do NOT set required=True 

124 # at the Click level because that would block --input - (STDIN) from working. 

125 # Schema-level required validation happens in the callback via jsonschema.validate(). 

126 help_text = ((_help_base + " ") if _help_base else "") + "[required]" if is_required else _help_base 

127 default = prop_schema.get("default", None) 

128 

129 if click_type is _BOOLEAN_FLAG: 

130 # Boolean flag pair 

131 default_val = prop_schema.get("default", False) 

132 flag_base = prop_name.replace("_", "-") 

133 option = click.Option( 

134 [f"--{flag_base}/--no-{flag_base}"], 

135 default=default_val, 

136 help=help_text, 

137 show_default=True, 

138 ) 

139 elif "enum" in prop_schema and click_type is not _BOOLEAN_FLAG: 

140 enum_values = prop_schema["enum"] 

141 if not enum_values: 

142 logger.warning( 

143 "Empty enum for property '%s', no values allowed.", 

144 prop_name, 

145 ) 

146 option = click.Option( 

147 [flag_name], 

148 type=click.STRING, 

149 required=False, 

150 default=default, 

151 help=help_text, 

152 ) 

153 else: 

154 string_values = [str(v) for v in enum_values] 

155 option = click.Option( 

156 [flag_name], 

157 type=click.Choice(string_values), 

158 required=False, 

159 default=str(default) if default is not None else None, 

160 help=help_text, 

161 ) 

162 # Store original types for post-parse reconversion 

163 option._enum_original_types = {str(v): type(v) for v in enum_values} 

164 else: 

165 option = click.Option( 

166 [flag_name], 

167 type=click_type, 

168 required=False, 

169 default=default, 

170 help=help_text, 

171 ) 

172 

173 options.append(option) 

174 

175 return options 

176 

177 

178def reconvert_enum_values(kwargs: dict[str, Any], options: list[click.Option]) -> dict[str, Any]: 

179 """Reconvert enum values from string back to their original types.""" 

180 result = dict(kwargs) 

181 for opt in options: 

182 original_types = getattr(opt, "_enum_original_types", None) 

183 if original_types is None: 

184 continue 

185 # Get the parameter name (Click uses the dest name) 

186 param_name = opt.name 

187 if param_name not in result or result[param_name] is None: 

188 continue 

189 str_val = str(result[param_name]) 

190 if str_val in original_types: 

191 orig_type = original_types[str_val] 

192 if orig_type is int: 

193 result[param_name] = int(str_val) 

194 elif orig_type is float: 

195 result[param_name] = float(str_val) 

196 elif orig_type is bool: 

197 result[param_name] = str_val.lower() == "true" 

198 return result