mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
113 lines
3.6 KiB
Python
113 lines
3.6 KiB
Python
"""User-safe configuration diagnostics."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from pydantic import ValidationError
|
|
|
|
ConfigErrorKind = Literal[
|
|
"invalid_json",
|
|
"invalid_root",
|
|
"invalid_schema",
|
|
"missing_env",
|
|
"io_error",
|
|
]
|
|
ConfigPathPart = str | int
|
|
_SAFE_LOCATION_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}")
|
|
|
|
|
|
def _display_location_part(part: ConfigPathPart) -> str:
|
|
if isinstance(part, int):
|
|
return str(part)
|
|
return part if _SAFE_LOCATION_PART.fullmatch(part) else "<redacted>"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConfigIssue:
|
|
"""One actionable configuration problem."""
|
|
|
|
path: tuple[ConfigPathPart, ...]
|
|
message: str
|
|
|
|
@property
|
|
def location(self) -> str:
|
|
# Pydantic locations can contain user-controlled mapping keys. Only
|
|
# render conventional config identifiers so credential-bearing URLs
|
|
# and other free-form values cannot leak through a redacted error.
|
|
if not self.path:
|
|
return "<root>"
|
|
return ".".join(_display_location_part(part) for part in self.path)
|
|
|
|
|
|
class ConfigLoadError(ValueError):
|
|
"""A structured, user-safe configuration loading failure."""
|
|
|
|
def __init__(
|
|
self,
|
|
path: Path,
|
|
*,
|
|
kind: ConfigErrorKind,
|
|
summary: str,
|
|
issues: tuple[ConfigIssue, ...] = (),
|
|
) -> None:
|
|
self.path = path
|
|
self.kind = kind
|
|
self.summary = summary
|
|
self.issues = issues
|
|
super().__init__(summary)
|
|
|
|
def __str__(self) -> str:
|
|
lines = [f"Invalid configuration: {self.path}", "", self.summary]
|
|
for issue in self.issues[:10]:
|
|
lines.extend(("", f" {issue.location}", f" {issue.message}"))
|
|
remaining = len(self.issues) - 10
|
|
if remaining > 0:
|
|
lines.extend(("", f" … and {remaining} more issue(s)"))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def validation_issues(
|
|
error: ValidationError,
|
|
) -> tuple[ConfigIssue, ...]:
|
|
"""Convert Pydantic details to actionable messages without exposing input values."""
|
|
issues: list[ConfigIssue] = []
|
|
for detail in error.errors(
|
|
include_url=False,
|
|
include_context=False,
|
|
include_input=False,
|
|
):
|
|
location = tuple(detail.get("loc", ()))
|
|
code = str(detail.get("type") or "")
|
|
message = _friendly_validation_message(
|
|
str(detail.get("msg") or "Invalid value"),
|
|
code,
|
|
)
|
|
issues.append(ConfigIssue(path=location, message=message))
|
|
return tuple(issues)
|
|
|
|
|
|
def _friendly_validation_message(message: str, code: str) -> str:
|
|
if code == "extra_forbidden":
|
|
return "Unknown setting."
|
|
if code == "missing":
|
|
return "This setting is required."
|
|
if code in {"assertion_error", "value_error"}:
|
|
# Custom validators control these messages and may interpolate the
|
|
# rejected value. Keep the field location, but never render that text.
|
|
return "Value does not satisfy this setting's requirements."
|
|
if message.startswith("Value error, "):
|
|
message = message.removeprefix("Value error, ")
|
|
elif message.startswith("Input should be "):
|
|
message = "Must be " + message.removeprefix("Input should be ")
|
|
elif message.startswith("Input should have "):
|
|
message = "Must have " + message.removeprefix("Input should have ")
|
|
if message:
|
|
message = message[:1].upper() + message[1:]
|
|
if message and message[-1] not in ".!?":
|
|
message += "."
|
|
return message or "Invalid value."
|