diff --git a/mreg_cli/cli.py b/mreg_cli/cli.py index f8aa934d..87b26570 100644 --- a/mreg_cli/cli.py +++ b/mreg_cli/cli.py @@ -16,6 +16,7 @@ from prompt_toolkit import HTML, document, print_formatted_text from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from pydantic import ValidationError as PydanticValidationError # Import all the commands from mreg_cli.commands.dhcp import DHCPCommands @@ -32,11 +33,11 @@ from mreg_cli.commands.zone import ZoneCommands # Import other mreg_cli modules -from mreg_cli.exceptions import CliError, CliExit, CliWarning +from mreg_cli.exceptions import CliError, CliExit, CliWarning, ValidationError from mreg_cli.help_formatter import CustomHelpFormatter from mreg_cli.outputmanager import OutputManager from mreg_cli.types import CommandFunc, Flag -from mreg_cli.utilities.api import create_and_set_corrolation_id +from mreg_cli.utilities.api import create_and_set_corrolation_id, last_request_url logger = logging.getLogger(__name__) @@ -166,6 +167,9 @@ def parse(self, command: str) -> None: # after it prints a help msg. self.last_errno = e.code + except PydanticValidationError as exc: + ValidationError.from_pydantic(exc).print_and_log() + except (CliWarning, CliError) as exc: exc.print_and_log() @@ -178,6 +182,11 @@ def parse(self, command: str) -> None: # If no exception occurred make sure errno isn't set to an error # code. self.last_errno = 0 + finally: + # Unset URL after we have finished processing the command + # so that validation errors that happen before a new request + # is made don't show a URL. + last_request_url.set(None) # We ignore ARG0002 (unused-argument) because the method signature is # required by the Completer class. diff --git a/mreg_cli/exceptions.py b/mreg_cli/exceptions.py index 4dd527e9..9fe8ecbb 100644 --- a/mreg_cli/exceptions.py +++ b/mreg_cli/exceptions.py @@ -13,6 +13,7 @@ from prompt_toolkit import print_formatted_text from prompt_toolkit.formatted_text import HTML from prompt_toolkit.formatted_text.html import html_escape +from pydantic import ValidationError as PydanticValidationError logger = logging.getLogger(__name__) @@ -136,7 +137,53 @@ class UnexpectedDataError(APIError): class ValidationError(CliError): """Error class for validation failures.""" - pass + def __init__(self, message: str, pydantic_error: PydanticValidationError | None = None): + """Initialize a ValidationError. + + :param message: The error message. + """ + super().__init__(message) + self.pydantic_error = pydantic_error + + @classmethod + def from_pydantic(cls, e: PydanticValidationError) -> ValidationError: + """Create a ValidationError from a Pydantic ValidationError. + + :param e: The Pydantic ValidationError. + :returns: The created ValidationError. + """ + from mreg_cli.utilities.api import last_request_method, last_request_url + + # Display a title containing the HTTP method and URL if available + method = last_request_method.get() + url = last_request_url.get() + msg = f"Failed to validate {e.title}" + if url and method: + msg += f" response from {method.upper()} {url}" + + exc_errors = e.errors() + + # Show the input used to instantiate the model if available + inp = exc_errors[0]["input"] if exc_errors else "" + + # Show field and reason for each error + errors: list[str] = [] + for err in exc_errors: + errlines: list[str] = [ + f"Field: {', '.join(str(l) for l in err['loc'])}", # noqa: E741 + f"Reason: {err['msg']}", + ] + errors.append("\n".join(f" {line}" for line in errlines)) + + err_msg = f"{msg}\n Input: {inp}\n Errors:\n" + "\n\n".join(errors) + return cls(err_msg, e) + + def log(self): + """Log the exception with traceback.""" + from mreg_cli.outputmanager import OutputManager + + logger.exception(str(self), stack_info=True, exc_info=self) + OutputManager().add_error(str(self)) class FileError(CliError): diff --git a/mreg_cli/utilities/api.py b/mreg_cli/utilities/api.py index 0d522772..a430099d 100644 --- a/mreg_cli/utilities/api.py +++ b/mreg_cli/utilities/api.py @@ -11,6 +11,7 @@ import os import re import sys +from contextvars import ContextVar from typing import Any, Literal, NoReturn, TypeVar, get_origin, overload from urllib.parse import urljoin from uuid import uuid4 @@ -45,6 +46,10 @@ JsonMappingValidator = TypeAdapter(JsonMapping) +# Thread-local context variables for storing the last request URL and method. +last_request_url: ContextVar[str | None] = ContextVar("last_request_url", default=None) +last_request_method: ContextVar[str | None] = ContextVar("last_request_method", default=None) + def error(msg: str | Exception, code: int = os.EX_UNAVAILABLE) -> NoReturn: """Print an error message and exits with the given code.""" @@ -272,6 +277,9 @@ def _request_wrapper( timeout=HTTP_TIMEOUT, ) + last_request_url.set(logurl) + last_request_method.set(operation_type) + request_id = result.headers.get("X-Request-Id", "?") correlation_id = result.headers.get("X-Correlation-ID", "?") id_str = f"[R:{request_id} C:{correlation_id}]" diff --git a/tests/conftest.py b/tests/conftest.py index abe1aac7..27eda337 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from pytest_httpserver import HTTPServer from mreg_cli.config import MregCliConfig +from mreg_cli.utilities.api import last_request_method, last_request_url @pytest.fixture(autouse=True) @@ -53,3 +54,12 @@ def empty_config() -> Iterator[MregCliConfig]: conf._config_env = {} conf._config_file = {} yield conf + + +@pytest.fixture(autouse=True) +def reset_context_vars() -> Iterator[None]: + """Reset all context variables after each test.""" + yield + + last_request_method.set(None) + last_request_url.set(None) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..03040887 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,175 @@ +from inline_snapshot import snapshot +from pydantic import ValidationError as PydanticValidationError +import pytest + +from mreg_cli.api.models import Host +from mreg_cli.config import MregCliConfig +from mreg_cli.exceptions import ValidationError + + +from pytest_httpserver import HTTPServer + +from mreg_cli.utilities.api import get + + +def test_validation_error_get_host(httpserver: HTTPServer) -> None: + """Test a validation error stemming from a GET request.""" + MregCliConfig()._config_cmd["url"] = httpserver.url_for("/") + + httpserver.expect_oneshot_request("/hosts/foobar").respond_with_json( + { + "created_at": "2022-06-16T09:15:40.775601+02:00", + "updated_at": "2024-01-26T10:23:06.631486+01:00", + "id": 76036, + "name": "_.--host123_example.com", # invalid name + "ipaddresses": [ + { + "host": 76036, + "created_at": "2022-06-16T09:47:43.761478+02:00", + "updated_at": "2022-06-16T12:20:40.722808+02:00", + "id": 78492, + "macaddress": "e4:54:e8:80:73:73", + "ipaddress": "192.168.0.1", + } + ], + "cnames": [], + "mxs": [], + "txts": [], + "ptr_overrides": [], + "hinfo": None, + "loc": None, + "bacnetid": None, + "contact": "user@example.com", + "ttl": None, + "comment": "", + "zone": 5, + } + ) + resp = get("/hosts/foobar") + with pytest.raises(PydanticValidationError) as exc_info: + Host.model_validate_json(resp.text) + + assert exc_info.value.error_count() == snapshot(1) + assert [repr(err) for err in exc_info.value.errors(include_url=False)] == snapshot( + [ + "{'type': 'value_error', 'loc': ('name',), 'msg': 'Value error, Invalid input for hostname: _.--host123_example.com', 'input': '_.--host123_example.com', 'ctx': {'error': InputFailure('Invalid input for hostname: _.--host123_example.com')}}" + ] + ) + + validationerror = ValidationError.from_pydantic(exc_info.value) + + # port-number is non-determinstic, so we need to replace that before comparing + err = validationerror.args[0].replace(f":{httpserver.port}", ":12345") + assert err == snapshot( + """\ +Failed to validate Host response from GET http://localhost:12345/hosts/foobar + Input: _.--host123_example.com + Errors: + Field: name + Reason: Value error, Invalid input for hostname: _.--host123_example.com\ +""" + ) + + +def test_validation_error_no_request(caplog, capsys) -> None: + """Test a validation error that did not originate from an API request.""" + with pytest.raises(PydanticValidationError) as exc_info: + Host.model_validate({"name": "test"}) # Missing required fields + + assert exc_info.value.error_count() == snapshot(6) + assert [repr(err) for err in exc_info.value.errors(include_url=False)] == snapshot( + [ + "{'type': 'missing', 'loc': ('created_at',), 'msg': 'Field required', 'input': {'name': 'test'}}", + "{'type': 'missing', 'loc': ('updated_at',), 'msg': 'Field required', 'input': {'name': 'test'}}", + "{'type': 'missing', 'loc': ('id',), 'msg': 'Field required', 'input': {'name': 'test'}}", + "{'type': 'missing', 'loc': ('ipaddresses',), 'msg': 'Field required', 'input': {'name': 'test'}}", + "{'type': 'missing', 'loc': ('contact',), 'msg': 'Field required', 'input': {'name': 'test'}}", + "{'type': 'missing', 'loc': ('comment',), 'msg': 'Field required', 'input': {'name': 'test'}}", + ] + ) + + validationerror = ValidationError.from_pydantic(exc_info.value) + assert validationerror.args[0] == snapshot( + """\ +Failed to validate Host + Input: {'name': 'test'} + Errors: + Field: created_at + Reason: Field required + + Field: updated_at + Reason: Field required + + Field: id + Reason: Field required + + Field: ipaddresses + Reason: Field required + + Field: contact + Reason: Field required + + Field: comment + Reason: Field required\ +""" + ) + + # Call method and check output + validationerror.print_and_log() + + assert caplog.record_tuples == snapshot( + [ + ( + "mreg_cli.exceptions", + 40, + """\ +Failed to validate Host + Input: {'name': 'test'} + Errors: + Field: created_at + Reason: Field required + + Field: updated_at + Reason: Field required + + Field: id + Reason: Field required + + Field: ipaddresses + Reason: Field required + + Field: contact + Reason: Field required + + Field: comment + Reason: Field required\ +""", + ) + ] + ) + + out, err = capsys.readouterr() + assert out == snapshot( + """\ +ERROR: Failed to validate Host\r + Input: {'name': 'test'}\r + Errors:\r + Field: created_at\r + Reason: Field required\r +\r + Field: updated_at\r + Reason: Field required\r +\r + Field: id\r + Reason: Field required\r +\r + Field: ipaddresses\r + Reason: Field required\r +\r + Field: contact\r + Reason: Field required\r +\r + Field: comment\r + Reason: Field required\r +""" + )