from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) @dataclass class ValidationIssue: path: str message: str severity: str = "error" allowed_values: list[str] | None = None allowed_values_hidden_count: int | None = None def to_dict(self) -> dict: result = {"path": self.path, "message": self.message, "severity": self.severity} if self.allowed_values is not None: result["allowedValues"] = self.allowed_values if self.allowed_values_hidden_count is not None: result["allowedValuesHiddenCount"] = self.allowed_values_hidden_count return result @dataclass class ValidationResult: ok: bool = True issues: list[ValidationIssue] = field(default_factory=list) warnings: list[ValidationIssue] = field(default_factory=list) legacy_issues: list[ValidationIssue] = field(default_factory=list) def add_issue( self, path: str, message: str, severity: str = "error", allowed_values: list[str] | None = None, allowed_values_hidden_count: int | None = None, ) -> None: issue = ValidationIssue( path=path, message=message, severity=severity, allowed_values=allowed_values, allowed_values_hidden_count=allowed_values_hidden_count, ) if severity == "warning": self.warnings.append(issue) elif severity == "legacy": self.legacy_issues.append(issue) else: self.issues.append(issue) self.ok = False def merge(self, other: ValidationResult) -> None: self.issues.extend(other.issues) self.warnings.extend(other.warnings) self.legacy_issues.extend(other.legacy_issues) if not other.ok: self.ok = False def to_dict(self) -> dict: return { "ok": self.ok, "issues": [i.to_dict() for i in self.issues], "warnings": [w.to_dict() for w in self.warnings], "legacy_issues": [li.to_dict() for li in self.legacy_issues], } # ── Schema Validation ──────────────────────────────────── def _collect_allowed_values_from_schema_node(node: dict) -> tuple[list[str], bool]: if "const" in node: return [str(node["const"])], False if "enum" in node and isinstance(node["enum"], list): return [str(v) for v in node["enum"]], False node_type = node.get("type") if node_type == "boolean" or (isinstance(node_type, list) and "boolean" in node_type): return ["true", "false"], False union_branches = node.get("anyOf") or node.get("oneOf") if not isinstance(union_branches, list): return [], False collected: list[str] = [] for branch in union_branches: if not isinstance(branch, dict): return [], True branch_values, incomplete = _collect_allowed_values_from_schema_node(branch) if incomplete or not branch_values: return [], True collected.extend(branch_values) return collected, False def _lookup_schema_node(schema: dict, path_parts: list[str]) -> dict | None: current: Any = schema for part in path_parts: if not isinstance(current, dict): return None properties = current.get("properties") if isinstance(properties, dict): current = properties.get(part) elif "additionalProperties" in current: current = current["additionalProperties"] else: return None return current if isinstance(current, dict) else None def _check_type(entry_value: Any, expected_type: str) -> bool: if expected_type == "string": return isinstance(entry_value, str) if expected_type == "number": return isinstance(entry_value, (int, float)) and not isinstance(entry_value, bool) if expected_type == "integer": return isinstance(entry_value, int) and not isinstance(entry_value, bool) if expected_type == "boolean": return isinstance(entry_value, bool) if expected_type == "array": return isinstance(entry_value, list) if expected_type == "object": return isinstance(entry_value, dict) if expected_type == "null": return entry_value is None return True def _matches_type(entry_value: Any, prop_type: str | list[str] | None) -> bool: if prop_type is None: return True if isinstance(prop_type, list): return any(_check_type(entry_value, t) for t in prop_type) return _check_type(entry_value, prop_type) def _validate_numeric_bounds( entry_value: Any, prop_schema: dict, field_path: str, key: str, result: ValidationResult, ) -> None: if not isinstance(entry_value, (int, float)) or isinstance(entry_value, bool): return minimum = prop_schema.get("minimum") maximum = prop_schema.get("maximum") exclusive_minimum = prop_schema.get("exclusiveMinimum") exclusive_maximum = prop_schema.get("exclusiveMaximum") if minimum is not None and entry_value < minimum: result.add_issue(field_path, f"'{key}' must be >= {minimum}") if maximum is not None and entry_value > maximum: result.add_issue(field_path, f"'{key}' must be <= {maximum}") if exclusive_minimum is not None and entry_value <= exclusive_minimum: result.add_issue(field_path, f"'{key}' must be > {exclusive_minimum}") if exclusive_maximum is not None and entry_value >= exclusive_maximum: result.add_issue(field_path, f"'{key}' must be < {exclusive_maximum}") def _validate_string_constraints( entry_value: Any, prop_schema: dict, field_path: str, key: str, result: ValidationResult, ) -> None: if not isinstance(entry_value, str): return min_length = prop_schema.get("minLength") max_length = prop_schema.get("maxLength") pattern = prop_schema.get("pattern") format_ = prop_schema.get("format") if min_length is not None and len(entry_value) < min_length: result.add_issue(field_path, f"'{key}' must be at least {min_length} characters") if max_length is not None and len(entry_value) > max_length: result.add_issue(field_path, f"'{key}' must be at most {max_length} characters") if pattern is not None: import re try: if not re.search(pattern, entry_value): result.add_issue(field_path, f"'{key}' does not match pattern '{pattern}'") except re.error: logger.warning("Invalid pattern in schema for '%s': %s", key, pattern) if format_ == "email" and "@" not in entry_value: result.add_issue(field_path, f"'{key}' must be a valid email address") if format_ == "uri" and not (entry_value.startswith("http://") or entry_value.startswith("https://")): result.add_issue(field_path, f"'{key}' must be a valid URI") def _validate_array_constraints( entry_value: Any, prop_schema: dict, field_path: str, key: str, result: ValidationResult, ) -> None: if not isinstance(entry_value, list): return min_items = prop_schema.get("minItems") max_items = prop_schema.get("maxItems") items_schema = prop_schema.get("items") if min_items is not None and len(entry_value) < min_items: result.add_issue(field_path, f"'{key}' must have at least {min_items} items") if max_items is not None and len(entry_value) > max_items: result.add_issue(field_path, f"'{key}' must have at most {max_items} items") if isinstance(items_schema, dict): for i, item in enumerate(entry_value): item_path = f"{field_path}[{i}]" item_type = items_schema.get("type") if not _matches_type(item, item_type): type_label = item_type if isinstance(item_type, str) else " | ".join(item_type) if isinstance(item_type, list) else "any" result.add_issue(item_path, f"item expects a {type_label} value") if isinstance(item, dict) and isinstance(items_schema.get("properties"), dict): _validate_entry_against_schema(item, items_schema, item_path, result) def _validate_entry_against_schema( entry: dict, schema: dict, prefix: str, result: ValidationResult, ) -> None: if schema.get("type") != "object": return properties = schema.get("properties") required_fields: list[str] = schema.get("required", []) if not isinstance(properties, dict): return seen_keys: set[str] = set() for key, entry_value in entry.items(): if key in ("channel_type", "account_id"): continue seen_keys.add(key) prop_schema = properties.get(key) if isinstance(properties, dict) else None if prop_schema is None: continue field_path = f"{prefix}.{key}" prop_type = prop_schema.get("type") if not _matches_type(entry_value, prop_type): help_text = prop_schema.get("description", "") if isinstance(prop_type, list): type_label = " | ".join(prop_type) elif isinstance(prop_type, str): type_label = prop_type else: type_label = "any" msg = f"'{key}' expects a {type_label} value. {help_text}".strip() result.add_issue(field_path, msg) continue if isinstance(entry_value, dict) and isinstance(prop_schema.get("properties"), dict): _validate_entry_against_schema(entry_value, prop_schema, field_path, result) if isinstance(entry_value, str) and not entry_value.strip() and key in required_fields: help_text = prop_schema.get("description", "") msg = f"'{key}' is required but empty. {help_text}".strip() result.add_issue(field_path, msg) continue if isinstance(entry_value, str) and entry_value.strip(): allowed, incomplete = _collect_allowed_values_from_schema_node(prop_schema) if not incomplete and allowed and entry_value not in allowed: result.add_issue( field_path, f"'{key}' has invalid value '{entry_value}'", allowed_values=allowed, ) _validate_numeric_bounds(entry_value, prop_schema, field_path, key, result) _validate_string_constraints(entry_value, prop_schema, field_path, key, result) _validate_array_constraints(entry_value, prop_schema, field_path, key, result) for req_key in required_fields: if req_key not in seen_keys: prop_schema = properties.get(req_key, {}) help_text = prop_schema.get("description", "") msg = f"missing required field '{req_key}'. {help_text}".strip() result.add_issue(f"{prefix}.{req_key}", msg) # ── Plugin Validation ──────────────────────────────────── def validate_channel_plugin(plugin: Any, result: ValidationResult) -> None: from yuxi.channel.protocols import ConfigProtocol, ConfigSchemaProtocol if not isinstance(plugin, ConfigProtocol): return plugin_id = getattr(plugin, "id", None) if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip(): result.add_issue("plugin", "channel plugin missing id") return prefix = f"plugin.{plugin_id}" if not callable(getattr(plugin, "is_configured", None)): result.add_issue(prefix, "missing is_configured method") if not callable(getattr(plugin, "list_account_ids", None)): result.add_issue(prefix, "missing list_account_ids method") if not callable(getattr(plugin, "resolve_account", None)): result.add_issue(prefix, "missing resolve_account method") if isinstance(plugin, ConfigSchemaProtocol): try: schema = plugin.config_schema() if not isinstance(schema, dict) or not schema: result.add_issue(prefix, "config_schema() returned empty or invalid schema", "warning") except Exception as e: result.add_issue(prefix, f"config_schema() raised: {e}", "warning") # ── Main Validation ────────────────────────────────────── async def validate_all_channel_configs( plugins: list, config_entries: list[dict], *, validate_schema: bool = True, validate_plugins: bool = False, ) -> ValidationResult: result = ValidationResult() from yuxi.channel.protocols import ConfigProtocol, ConfigSchemaProtocol from yuxi.channel.secrets.models import is_secret_ref configured_plugins = [p for p in plugins if isinstance(p, ConfigProtocol)] if not configured_plugins: result.add_issue("global", "No ConfigProtocol plugins registered") return result if validate_plugins: for plugin in configured_plugins: validate_channel_plugin(plugin, result) plugin_ids = {p.id for p in configured_plugins} for entry in config_entries: channel_type = entry.get("channel_type", "unknown") account_id = entry.get("account_id", "unknown") prefix = f"channels.{channel_type}.{account_id}" if channel_type not in plugin_ids: result.add_issue(prefix, f"No plugin registered for channel_type '{channel_type}'") continue plugin = next((p for p in configured_plugins if p.id == channel_type), None) if plugin is None: result.add_issue(prefix, f"Plugin '{channel_type}' disappeared during validation") continue try: configured = plugin.is_configured(entry) except Exception as e: result.add_issue(prefix, f"is_configured check failed: {e}") continue if not configured: reason = "" if callable(getattr(plugin, "unconfigured_reason", None)): try: reason = plugin.unconfigured_reason(entry, {}) or "" except Exception: pass msg = "Account not fully configured" if reason: msg = f"{msg}: {reason}" result.add_issue(prefix, msg, "warning") continue if validate_schema and isinstance(plugin, ConfigSchemaProtocol): try: schema = plugin.config_schema() if isinstance(schema, dict) and schema: _validate_entry_against_schema(entry, schema, prefix, result) except Exception as e: logger.warning("Schema validation skipped for %s: %s", channel_type, e) has_secret_refs = any( is_secret_ref(v) for v in entry.values() if isinstance(v, dict) ) if has_secret_refs: try: from yuxi.channel.secrets import SecretResolver resolver = SecretResolver() resolved = await resolver.resolve(entry) for issue in resolved.issues: result.add_issue(f"{prefix}.secrets", str(issue)) for warning in resolved.warnings: result.add_issue(f"{prefix}.secrets", str(warning), "warning") except Exception as e: result.add_issue(f"{prefix}.secrets", f"Secret resolution failed: {e}") return result