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
« 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)."""
3from __future__ import annotations
5import logging
6import sys
7from typing import Any
9import click
11logger = logging.getLogger("apcore_cli.schema_parser")
13# Sentinel for boolean flag marker
14_BOOLEAN_FLAG = object()
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)
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")
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)
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 }
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
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
70 return result
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
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] = {}
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 )
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 )
107 flag_name = "--" + prop_name.replace("_", "-")
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)
118 flag_names[flag_name] = prop_name
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)
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 )
173 options.append(option)
175 return options
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