Skip to content

Commit

Permalink
Merge branch 'master' into check-mac
Browse files Browse the repository at this point in the history
  • Loading branch information
pederhan authored Dec 9, 2024
2 parents 9de2927 + feda82f commit 34df091
Show file tree
Hide file tree
Showing 5 changed files with 252 additions and 3 deletions.
13 changes: 11 additions & 2 deletions mreg_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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()

Expand All @@ -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.
Expand Down
49 changes: 48 additions & 1 deletion mreg_cli/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions mreg_cli/utilities/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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}]"
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
175 changes: 175 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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
"""
)

0 comments on commit 34df091

Please sign in to comment.