|
26 | 26 |
|
27 | 27 | from pydantic import ValidationError |
28 | 28 |
|
29 | | -from bbot.core.helpers.misc import get_closest_match |
| 29 | +from bbot.core.helpers.misc import get_closest_match, get_keys_in_dot_syntax |
30 | 30 |
|
31 | 31 |
|
32 | 32 | log = logging.getLogger("bbot.presets.validate") |
@@ -72,18 +72,25 @@ def _classify_loc(loc: tuple) -> tuple[str, str]: |
72 | 72 | return ("preset", ".".join(parts)) |
73 | 73 |
|
74 | 74 |
|
75 | | -def _format_msg(err: dict, known_modules: set | None = None) -> str: |
| 75 | +def _format_msg(err: dict, known_modules: set | None = None, known_paths: set | None = None) -> str: |
76 | 76 | kind = err["type"] |
77 | 77 | input_value = err.get("input") |
78 | 78 | loc = err["loc"] |
79 | 79 | field = str(loc[-1]) if loc else "" |
80 | 80 | path = ".".join(str(p) for p in loc) |
81 | 81 |
|
82 | 82 | if kind == "extra_forbidden": |
83 | | - # Special-case unknown module name (config.modules.<bad>) so users get |
84 | | - # a suggestion rather than "Unknown option". |
| 83 | + # Special-case unknown module name (config.modules.<bad>) — users get |
| 84 | + # a suggestion drawn from the set of known module names. |
85 | 85 | if len(loc) == 3 and loc[0] == "config" and loc[1] == "modules": |
86 | 86 | return get_closest_match(field, known_modules or set(), msg="module") |
| 87 | + # For everything else, suggest from the known dotted-path universe |
| 88 | + # (`web.spier_distance` → `web.spider_distance`). |
| 89 | + if known_paths: |
| 90 | + # strip the leading "config." prefix when matching, since |
| 91 | + # default_config dotted paths don't include it |
| 92 | + lookup_path = ".".join(str(p) for p in loc[1:]) if loc and loc[0] == "config" else path |
| 93 | + return get_closest_match(lookup_path, known_paths, msg="config option") |
87 | 94 | msg = f"Unknown option: {field!r}" |
88 | 95 | if isinstance(input_value, (str, int, bool, float)): |
89 | 96 | msg += f" (value: {input_value!r})" |
@@ -114,11 +121,19 @@ def _format_msg(err: dict, known_modules: set | None = None) -> str: |
114 | 121 | return err["msg"] if err.get("msg") else f"validation error at {path}" |
115 | 122 |
|
116 | 123 |
|
117 | | -def _format_errors(exc: ValidationError, known_modules: set | None = None) -> list[PresetValidationError]: |
| 124 | +def _format_errors( |
| 125 | + exc: ValidationError, |
| 126 | + known_modules: set | None = None, |
| 127 | + known_paths: set | None = None, |
| 128 | +) -> list[PresetValidationError]: |
118 | 129 | out: list[PresetValidationError] = [] |
119 | 130 | for err in exc.errors(): |
120 | 131 | where, path = _classify_loc(err["loc"]) |
121 | | - out.append(PresetValidationError(where=where, path=path, message=_format_msg(err, known_modules))) |
| 132 | + out.append( |
| 133 | + PresetValidationError( |
| 134 | + where=where, path=path, message=_format_msg(err, known_modules, known_paths) |
| 135 | + ) |
| 136 | + ) |
122 | 137 | return out |
123 | 138 |
|
124 | 139 |
|
@@ -168,14 +183,17 @@ def validate_preset(preset_dict: Any, module_loader=None) -> list[PresetValidati |
168 | 183 |
|
169 | 184 | errors: list[PresetValidationError] = [] |
170 | 185 | known_modules = set(module_loader.all_module_choices) |
| 186 | + # Universe of valid dotted config paths, used for "did you mean ...?" |
| 187 | + # suggestions on unknown global-config keys. |
| 188 | + known_paths = set(get_keys_in_dot_syntax(module_loader.core.default_config)) |
171 | 189 |
|
172 | 190 | # Validate against the composite schema (rebuilt automatically if new |
173 | 191 | # module_dirs were just preloaded above). Closest-match suggestions |
174 | | - # for unknown module names are produced inside the formatter. |
| 192 | + # for unknown module names + config options are produced inside the formatter. |
175 | 193 | try: |
176 | 194 | module_loader.validation_schema.model_validate(preset_dict) |
177 | 195 | except ValidationError as e: |
178 | | - errors.extend(_format_errors(e, known_modules=known_modules)) |
| 196 | + errors.extend(_format_errors(e, known_modules=known_modules, known_paths=known_paths)) |
179 | 197 |
|
180 | 198 | # Module names listed in top-level `modules`/`output_modules`/`exclude_modules` |
181 | 199 | # aren't covered by the composite schema (they're a list of strings, not a |
|
0 commit comments