Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

ArgumentHandler: Support Custom Validation Errors #156

Merged
merged 20 commits into from
Dec 6, 2024
2 changes: 1 addition & 1 deletion dependencies-mypy.log
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ colorama==0.4.6
# via crayons
coloredlogs==15.0.1
# via wipac-telemetry
coverage[toml]==7.6.8
coverage[toml]==7.6.9
# via pytest-cov
crayons==0.4.0
# via pycycle
Expand Down
2 changes: 1 addition & 1 deletion dependencies-tests.log
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ click-completion==0.5.2
# via pycycle
colorama==0.4.6
# via crayons
coverage[toml]==7.6.8
coverage[toml]==7.6.9
# via pytest-cov
crayons==0.4.0
# via pycycle
Expand Down
42 changes: 34 additions & 8 deletions rest_tools/server/arghandler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Handle argument parsing, defaulting, casting, and more."""


import argparse
import contextlib
import enum
Expand Down Expand Up @@ -37,6 +36,7 @@ class ArgumentSource(enum.Enum):
###############################################################################
# Utils


def _universal_to_bool(val: Any) -> bool:
if isinstance(val, bool):
return val
Expand All @@ -61,6 +61,9 @@ def _universal_to_bool(val: Any) -> bool:
# argument --pick_it: invalid choice: 'hammer' (choose from 'rock', 'paper', 'scissors')
INVALID_CHOICE_PATTERN = re.compile(r"(argument .+: invalid choice: .+)")

# argument --reco_algo: cannot be empty string / whitespace
FROM_ARGUMENT_TYPE_ERROR_PATTERN = re.compile(r"(argument .+: .+)")


###############################################################################

Expand Down Expand Up @@ -95,14 +98,31 @@ def add_argument(
) -> None:
"""Add an argument -- `argparse.add_argument` with minimal additions.

No `--`-prefix is needed.
Do not include a `--`-prefix on the `name`.

See https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.add_argument

Note: Not all of `argparse.add_argument`'s parameters make sense
for key-value based arguments, such as flag-oriented
options. Nevertheless, no given parameters are excluded,
just make sure to test it first :)
### Built-in Validation
Validation errors built into `argparse`, such as those triggered
by the `choices` parameter, are forwarded as `400 Bad Request`
responses. These responses include the corresponding validation
message from `argparse`.

### `type`-Validation
Many types of validation exceptions are also handled and returned
as `400 Bad Request` responses if the `type` parameter is a callable
that raises an exception.
> HOWEVER, to include a custom validation message in the
`400 Bad Request` response, you must raise an
`argparse.ArgumentTypeError(...)`. Otherwise, the
`400 Bad Request` response will contain a generic
message, such as 'argument myarg: invalid type'.

### Note
Not all of `argparse.add_argument`'s parameters make sense
for key-value based arguments, such as flag-oriented
options. Nevertheless, no given parameters are excluded,
just make sure to test it first :)
"""

def retrieve_json_body_arg(parsed_val: Any) -> Any:
Expand Down Expand Up @@ -147,6 +167,7 @@ def _translate_error(
captured_stderr: str,
) -> str:
"""Translate argparse-style error to a message str for HTTPError."""
LOGGER.error(f"Intercepted error to translate for requestor -> {exc}")

# errors not covered by 'exit_on_error=False' (in __init__)
if isinstance(exc, (SystemExit, argparse.ArgumentError)):
Expand Down Expand Up @@ -192,13 +213,18 @@ def _translate_error(
elif match := INVALID_CHOICE_PATTERN.search(err_msg):
return match.group(1).replace("--", "")

# argparse.ArgumentTypeError errors -- covers errors in this known format
# -> this is matched when the server code raises argparse.ArgumentTypeError
elif match := FROM_ARGUMENT_TYPE_ERROR_PATTERN.search(err_msg):
return match.group(1).replace("--", "")

# FALL-THROUGH -- log unknown exception
ts = time.time() # log timestamp to aid debugging
LOGGER.error(type(exc))
traceback.print_exception(type(exc), exc, exc.__traceback__)
LOGGER.error(captured_stderr)
LOGGER.error(type(exc))
LOGGER.exception(exc)
LOGGER.error(f"error timestamp: {ts}")
LOGGER.error(captured_stderr)
return f"Unknown argument-handling error ({ts})"

def parse_args(self) -> argparse.Namespace:
Expand Down
204 changes: 201 additions & 3 deletions tests/unit_server/arghandler_test.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Test server/arghandler.py."""

# pylint: disable=W0212,W0621

import argparse
import json
import re
import sys
from typing import Any, Dict, List, Tuple, Union, cast
from unittest.mock import Mock
Expand All @@ -16,6 +16,8 @@
from rest_tools.server.arghandler import ArgumentHandler, ArgumentSource
from rest_tools.server.handler import RestHandler

# pylint: disable=W0212,W0621

# these tests are only for 3.9+
if sys.version_info < (3, 9):
pytest.skip("only for 3.9+", allow_module_level=True) # type: ignore[var-annotated]
Expand Down Expand Up @@ -525,7 +527,9 @@ def test_211__argparse_choices__error(argument_source: str) -> None:
f"HTTP 400: argument pick_it: invalid choice: 'hank' "
f"(choose from {', '.join(repr(c) for c in choices)})"
)
assert str(e.value) == expected_message, f"Error does not match expected value for Python {sys.version_info}"
assert (
str(e.value) == expected_message
), f"Error does not match expected value for Python {sys.version_info}"


@pytest.mark.parametrize(
Expand All @@ -535,3 +539,197 @@ def test_211__argparse_choices__error(argument_source: str) -> None:
def test_220__argparse_nargs(argument_source: str) -> None:
"""Test `argument_source` arguments using argparse's advanced options."""
test_130__duplicates(argument_source)


@pytest.mark.parametrize(
"argument_source",
[QUERY_ARGUMENTS, JSON_BODY_ARGUMENTS],
)
@pytest.mark.parametrize(
"exc",
[TypeError, ValueError, argparse.ArgumentError],
)
def test_230__argparse_custom_validation__supported_builtins__error(
argument_source: str, exc: Exception
) -> None:
"""Test `argument_source` arguments using argparse's advanced options."""
args: Dict[str, Any] = {
"foo": "True",
}
if argument_source == JSON_BODY_ARGUMENTS:
args = {
"foo": [1, 2, 3],
}

# set up ArgumentHandler
arghand = setup_argument_handler(
argument_source,
args,
)

def _error_it(_: Any, exc: Exception):
raise exc

for arg, _ in args.items():
print()
print(arg)
arghand.add_argument(
arg,
type=lambda x: _error_it(
x,
exc("it's a bad value but you won't see this message anyway..."), # type: ignore
),
)

with pytest.raises(tornado.web.HTTPError) as e:
arghand.parse_args()

assert str(e.value) == "HTTP 400: argument foo: invalid type"


class MyError(Exception):
"""Used below."""


@pytest.mark.parametrize(
"argument_source",
[QUERY_ARGUMENTS, JSON_BODY_ARGUMENTS],
)
@pytest.mark.parametrize(
"exc",
[RuntimeError, IndexError, MyError],
)
def test_232__argparse_custom_validation__unsupported_errors__error(
argument_source: str, exc: Exception
) -> None:
"""Test `argument_source` arguments using argparse's advanced options."""
args: Dict[str, Any] = {
"foo": "True",
}
if argument_source == JSON_BODY_ARGUMENTS:
args = {
"foo": [1, 2, 3],
}

# set up ArgumentHandler
arghand = setup_argument_handler(
argument_source,
args,
)

def _error_it(_: Any, exc: Exception):
raise exc

for arg, _ in args.items():
print()
print(arg)
arghand.add_argument(
arg,
type=lambda x: _error_it(
x,
exc("something went wrong but not in an unexpected way, not-validation"), # type: ignore
),
)

with pytest.raises(tornado.web.HTTPError) as e:
arghand.parse_args()

assert re.fullmatch(
r"HTTP 400: Unknown argument-handling error \(\d+\.\d+\)", str(e.value)
)


@pytest.mark.parametrize(
"argument_source",
[QUERY_ARGUMENTS, JSON_BODY_ARGUMENTS],
)
def test_234__argparse_custom_validation__argumenttypeerror__error(
argument_source: str,
) -> None:
"""Test `argument_source` arguments using argparse's advanced options."""
args: Dict[str, Any] = {
"foo": "True",
}
if argument_source == JSON_BODY_ARGUMENTS:
args = {
"foo": [1, 2, 3],
}

# set up ArgumentHandler
arghand = setup_argument_handler(
argument_source,
args,
)

def _error_it(_: Any):
raise argparse.ArgumentTypeError("it's a bad value and you *will* see this!")

for arg, _ in args.items():
print()
print(arg)
arghand.add_argument(
arg,
type=_error_it,
)

with pytest.raises(tornado.web.HTTPError) as e:
arghand.parse_args()

assert (
str(e.value)
== "HTTP 400: argument foo: it's a bad value and you *will* see this!"
)


@pytest.mark.parametrize(
"argument_source",
[QUERY_ARGUMENTS, JSON_BODY_ARGUMENTS],
)
@pytest.mark.parametrize(
"exc",
[ # all the exceptions!
TypeError,
ValueError,
argparse.ArgumentError,
argparse.ArgumentTypeError,
RuntimeError,
IndexError,
MyError,
],
)
def test_236__argparse_custom_validation__validator_no_param__error(
argument_source: str,
exc: Exception,
) -> None:
"""Test `argument_source` arguments using argparse's advanced options."""
args: Dict[str, Any] = {
"foo": "True",
}
if argument_source == JSON_BODY_ARGUMENTS:
args = {
"foo": [1, 2, 3],
}

# set up ArgumentHandler
arghand = setup_argument_handler(
argument_source,
args,
)

def _error_it__no_param():
raise exc("it's a bad value and you *will* see this!") # type: ignore

for arg, _ in args.items():
print()
print(arg)
arghand.add_argument(
arg,
type=_error_it__no_param,
# NOTE: ^^^ because this takes no arguments (isn't a func/lambda),
# argparse treats it like any other error. why? idk :/
)

with pytest.raises(tornado.web.HTTPError) as e:
arghand.parse_args()

assert str(e.value) == "HTTP 400: argument foo: invalid type"
Loading