diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py new file mode 100644 index 0000000..dc829a1 --- /dev/null +++ b/fastly_compute/exceptions.py @@ -0,0 +1,87 @@ +"""Top-level exceptions emitted by the Fastly API""" + +from collections.abc import Callable, Mapping +from enum import Enum +from functools import wraps +from typing import Any + +from wit_world.imports.types import Err + + +class FastlyError(Exception): + """Abstract base class for all errors raised by Fastly APIs + + This allows catching all errors emanating from Fastly APIs at once. + """ + + +class UnexpectedFastlyError(FastlyError): + """An error arising from a Fastly API but of an unanticipated kind, such + that we merely package up the low-level error and send it along. + + Any of these encountered in the wild means we neglected to keep our Python + wrappers up to date with the WIT. + """ + + def __init__(self, error_value: object): + """Construct. + + :arg error_value: The ``value`` attr of the raised ``Err`` + """ + self.value = error_value + + +# TODO: Move to somewhere more private once it becomes clear where. +def remap_wit_errors( + idiomatic_exceptions: Mapping[Any, type[FastlyError]] | None = None, +) -> Callable: + """Raise more idiomatic exceptions from a function that returns a WIT ``result``. + + A ``result``s error case is always wrapped in a generic ``Err`` exception by + componentize-py. Convert that to a more descriptive exception that can be + selectively caught. + + :arg idiomatic_exceptions: A map of the types of WIT-level ``Err.value``s to + more informative exception classes. These classes receive the + ``Err.value`` as a constructor argument. If the value's type is not + found in the map, wrap it in an ``UnexpectedFastlyError``. + + Enum members may also be used as mapping keys. Exceptions raised based + on these receive no constructor arguments, since the values of enum + members are generated and meaningless. + + Goals: Be idiomatic. Be reasonably efficient. Be readable as documentation. + In that order. + + """ + # Someday, if we need more flexibility than class-by-class mapping, we can + # take a fallback callable that can do further thinking. Also, only the type + # signature keeps you from passing along an arbitrary callable that can + # emit, say, different exception classes for even and odd ints. + if idiomatic_exceptions is None: + idiomatic_exceptions = {} + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Err as e: + error_value = e.value + + # Look up ordinary instances by class but enum fields by value so + # we can easily give each enum member its own exception class: + if isinstance(error_value, Enum): + key = error_value + exc_args = () + else: + key = type(error_value) + exc_args = (error_value,) + idiomatic_exception = idiomatic_exceptions.get( + key, UnexpectedFastlyError + ) + raise idiomatic_exception(*exc_args) from e + + return wrapper + + return decorator diff --git a/pyproject.toml b/pyproject.toml index c68dac0..0e0d248 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ select = [ ignore = [ "E501", # line too long, handled by formatter "UP031", # % string formatting, minimally disruptive for stdlib-based HTML templating + "D415", # Don't require punctuation at the end of a docstring summary: sometimes they are noun phrases, not sentences. + "D205", # This spuriously complains about line-wrapped single-sentence docstring summaries. Sometimes 80 chars isn't enough. ] [tool.ruff.format] diff --git a/tests/test_nice_exceptions.py b/tests/test_nice_exceptions.py new file mode 100644 index 0000000..3cae55b --- /dev/null +++ b/tests/test_nice_exceptions.py @@ -0,0 +1,122 @@ +"""Show (and test) some motivating examples of how the ``remap_wit_errors`` +decorator makes WIT's ``result``-driven errors more Pythonic.""" + +import sys +from pathlib import Path + +from pytest import raises + +# Bring in stubs for local testing: +sys.path.append(str(Path(__file__).parent.parent / "stubs")) + +from wit_world.imports.types import Error_BufferLen, OpenError +from wit_world.types import Err + +from fastly_compute.exceptions import ( + FastlyError, + UnexpectedFastlyError, + remap_wit_errors, +) + + +class BufferTooShortError(FastlyError): + # A "nice" version of a WIT exception takes the WIT error as the sole arg of + # its constructor. While it would make the exception class more + # constructable by customer code if we took, for example, simply an int here + # and added a from_wit_error() class method, this would complicate the + # calling contract of remap_wit_errors() for "escape-hatch" callables which + # conditionally choose exception mappings. It remains to be seen if we ever + # need those. + def __init__(self, wit_error: Error_BufferLen): + self.length = wit_error.value + + # Freed of the generated skeletal dataclasses, we can add niceties like good + # error messages. + def __str__(self): + return f"Buffer was too short to hold the result. At least {self.length} is needed." + + +class NegativeHeightError(FastlyError): + def __init__(self, height: int): + self.height = height + + +def test_primitive(): + """Show that a primitive type can be mapped to a meaningful exception.""" + + @remap_wit_errors({int: NegativeHeightError}) + def raise_int() -> Err: + """Raise a primitive value, which is expected and gets wrapped in a descriptive exception.""" + raise Err(value=-3) + + try: + raise_int() + except NegativeHeightError as e: + assert e.height == -3 + + +def test_unexpected(): + """For unexpected error types, an UnexpectedFastlyError should be raised. + + This preserves the value of the original error and the ability for customers + to catch all Fastly API errors by catching FastlyError. It also keeps them + insulated from componentize-py's Err class, lest we move away from it + someday. + """ + + @remap_wit_errors() + def raise_int_by_surprise() -> Err: + """Raise a primitive value, which is a type we didn't expect.""" + raise Err(value=-3) + + try: + raise_int_by_surprise() + except UnexpectedFastlyError as e: + assert e.value == -3 + + +def test_variant(): + """Show how a WIT variant case can be concisely mapped into a more idiomatic exception.""" + + @remap_wit_errors({Error_BufferLen: BufferTooShortError}) + def raise_variant() -> Err: + """Raise an Err whose value is a case of our generic ``error`` variant.""" + raise Err(value=Error_BufferLen(64)) + + try: + raise_variant() + except BufferTooShortError as e: + assert e.length == 64 + + +def test_enum(): + """Show how we can also map individual enum cases to exception classes.""" + + class InvalidSyntaxError(FastlyError): + pass + + class NotFoundError(FastlyError): + pass + + enum_map = { + OpenError.INVALID_SYNTAX: InvalidSyntaxError, + OpenError.NOT_FOUND: NotFoundError, + } + + @remap_wit_errors(enum_map) + def raise_one_enum() -> Err: + raise Err(value=OpenError.INVALID_SYNTAX) + + @remap_wit_errors(enum_map) + def raise_other_enum() -> Err: + raise Err(value=OpenError.NOT_FOUND) + + try: + raise_one_enum() + except InvalidSyntaxError as e: + assert len(e.args) == 0, ( + "Exceptions raised based on enum members should receive no constructor args." + ) + + with raises(NotFoundError): + raise_other_enum()