From b0d1758c89f9feced2687ac23258a9c91f39a9a0 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 14 Jan 2026 16:36:25 -0500 Subject: [PATCH 1/6] Add a decorator to make the WIT API raise exceptions more idiomatically. Refs #19. See test cases for examples. --- fastly_compute/exceptions.py | 68 +++++++++++++++++++++++++++++ tests/test_nice_exceptions.py | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 fastly_compute/exceptions.py create mode 100644 tests/test_nice_exceptions.py diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py new file mode 100644 index 0000000..8be17a6 --- /dev/null +++ b/fastly_compute/exceptions.py @@ -0,0 +1,68 @@ +from collections.abc import Callable, Mapping +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 eminating 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: Any): + """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 nice_exceptions( + nice_classes: Mapping[type, 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 nice_classes: 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``. + + 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 nice_classes is None: + nice_classes = {} + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Err as e: + error_value = e.value + idiomatic_class = nice_classes.get(type(error_value)) + raise (idiomatic_class or UnexpectedFastlyError)(error_value) from e + + return wrapper + + return decorator diff --git a/tests/test_nice_exceptions.py b/tests/test_nice_exceptions.py new file mode 100644 index 0000000..0d8681f --- /dev/null +++ b/tests/test_nice_exceptions.py @@ -0,0 +1,80 @@ +"""Show (and test) some motivating examples of how the ``nice_exceptions`` +decorator makes WIT's ``result``-driven errors more Pythonic.""" + +import sys +from pathlib import Path + +# Bring in stubs for local testing: +sys.path.append(str(Path(__file__).parent.parent / "stubs")) + +from wit_world.imports.types import Error_BufferLen +from wit_world.types import Err + +from fastly_compute.exceptions import ( + FastlyError, + UnexpectedFastlyError, + nice_exceptions, +) + + +class BufferTooShortError(FastlyError): + 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.""" + + @nice_exceptions({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. + """ + + @nice_exceptions() + 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.""" + + @nice_exceptions({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 From b428c2cab8c3b72707534059c7d0018af36aeabb Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 14 Jan 2026 17:18:55 -0500 Subject: [PATCH 2/6] Make linter happy. --- fastly_compute/exceptions.py | 2 ++ pyproject.toml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py index 8be17a6..a6350b9 100644 --- a/fastly_compute/exceptions.py +++ b/fastly_compute/exceptions.py @@ -1,3 +1,5 @@ +"""Top-level exceptions emitted by the Fastly API""" + from collections.abc import Callable, Mapping from functools import wraps from typing import Any 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] From 4fd72a2c9a164822215ac79e884228923606f57a Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Wed, 14 Jan 2026 17:24:53 -0500 Subject: [PATCH 3/6] Use `get()` default for concision. --- fastly_compute/exceptions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py index a6350b9..1870dae 100644 --- a/fastly_compute/exceptions.py +++ b/fastly_compute/exceptions.py @@ -62,8 +62,10 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) except Err as e: error_value = e.value - idiomatic_class = nice_classes.get(type(error_value)) - raise (idiomatic_class or UnexpectedFastlyError)(error_value) from e + idiomatic_class = nice_classes.get( + type(error_value), UnexpectedFastlyError + ) + raise idiomatic_class(error_value) from e return wrapper From fa853853a78a9df5747ee787a9243c5cae7f7270 Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 15 Jan 2026 12:04:36 -0500 Subject: [PATCH 4/6] Add a design comment. --- fastly_compute/exceptions.py | 5 +++-- tests/test_nice_exceptions.py | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py index 1870dae..31a38f0 100644 --- a/fastly_compute/exceptions.py +++ b/fastly_compute/exceptions.py @@ -10,7 +10,7 @@ class FastlyError(Exception): """Abstract base class for all errors raised by Fastly APIs - This allows catching all errors eminating from Fastly APIs at once. + This allows catching all errors emanating from Fastly APIs at once. """ @@ -45,7 +45,8 @@ def nice_exceptions( as a constructor argument. If the value's type is not found in the map, wrap it in an ``UnexpectedFastlyError``. - Goals: Be idiomatic. Be reasonably efficient. Be readable as documentation. In that order. + 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 diff --git a/tests/test_nice_exceptions.py b/tests/test_nice_exceptions.py index 0d8681f..1071e50 100644 --- a/tests/test_nice_exceptions.py +++ b/tests/test_nice_exceptions.py @@ -18,6 +18,13 @@ 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 nice_exceptions() 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 From 4723c9017ca303969d1a19e0fba2f672584457ed Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 15 Jan 2026 12:06:04 -0500 Subject: [PATCH 5/6] Support remapping enum members as well as classes. --- fastly_compute/exceptions.py | 24 +++++++++++++++++------ tests/test_nice_exceptions.py | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py index 31a38f0..3af31f1 100644 --- a/fastly_compute/exceptions.py +++ b/fastly_compute/exceptions.py @@ -1,6 +1,7 @@ """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 @@ -22,7 +23,7 @@ class UnexpectedFastlyError(FastlyError): wrappers up to date with the WIT. """ - def __init__(self, error_value: Any): + def __init__(self, error_value: object): """Construct. :arg error_value: The ``value`` attr of the raised ``Err`` @@ -32,7 +33,7 @@ def __init__(self, error_value: Any): # TODO: Move to somewhere more private once it becomes clear where. def nice_exceptions( - nice_classes: Mapping[type, type[FastlyError]] | None = None, + nice_classes: Mapping[Any, type[FastlyError]] | None = None, ) -> Callable: """Raise more idiomatic exceptions from a function that returns a WIT ``result``. @@ -45,6 +46,10 @@ def nice_exceptions( 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. @@ -63,10 +68,17 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) except Err as e: error_value = e.value - idiomatic_class = nice_classes.get( - type(error_value), UnexpectedFastlyError - ) - raise idiomatic_class(error_value) from e + + # 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_class = nice_classes.get(key, UnexpectedFastlyError) + raise idiomatic_class(*exc_args) from e return wrapper diff --git a/tests/test_nice_exceptions.py b/tests/test_nice_exceptions.py index 1071e50..b9f5834 100644 --- a/tests/test_nice_exceptions.py +++ b/tests/test_nice_exceptions.py @@ -4,10 +4,12 @@ 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 +from wit_world.imports.types import Error_BufferLen, OpenError from wit_world.types import Err from fastly_compute.exceptions import ( @@ -85,3 +87,36 @@ def raise_variant() -> Err: 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, + } + + @nice_exceptions(enum_map) + def raise_one_enum() -> Err: + raise Err(value=OpenError.INVALID_SYNTAX) + + @nice_exceptions(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() From 5e1bf560924b5d9cbcc57dcf9e1cb1510b87935a Mon Sep 17 00:00:00 2001 From: Erik Rose Date: Thu, 15 Jan 2026 17:34:27 -0500 Subject: [PATCH 6/6] Rename `nice_exceptions()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …per https://github.com/fastly/compute-sdk-python/pull/29#discussion_r2695222772 --- fastly_compute/exceptions.py | 22 ++++++++++++---------- tests/test_nice_exceptions.py | 16 ++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/fastly_compute/exceptions.py b/fastly_compute/exceptions.py index 3af31f1..dc829a1 100644 --- a/fastly_compute/exceptions.py +++ b/fastly_compute/exceptions.py @@ -32,8 +32,8 @@ def __init__(self, error_value: object): # TODO: Move to somewhere more private once it becomes clear where. -def nice_exceptions( - nice_classes: Mapping[Any, type[FastlyError]] | None = None, +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``. @@ -41,10 +41,10 @@ def nice_exceptions( componentize-py. Convert that to a more descriptive exception that can be selectively caught. - :arg nice_classes: 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``. + :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 @@ -58,8 +58,8 @@ def nice_exceptions( # 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 nice_classes is None: - nice_classes = {} + if idiomatic_exceptions is None: + idiomatic_exceptions = {} def decorator(func: Callable) -> Callable: @wraps(func) @@ -77,8 +77,10 @@ def wrapper(*args, **kwargs): else: key = type(error_value) exc_args = (error_value,) - idiomatic_class = nice_classes.get(key, UnexpectedFastlyError) - raise idiomatic_class(*exc_args) from e + idiomatic_exception = idiomatic_exceptions.get( + key, UnexpectedFastlyError + ) + raise idiomatic_exception(*exc_args) from e return wrapper diff --git a/tests/test_nice_exceptions.py b/tests/test_nice_exceptions.py index b9f5834..3cae55b 100644 --- a/tests/test_nice_exceptions.py +++ b/tests/test_nice_exceptions.py @@ -1,4 +1,4 @@ -"""Show (and test) some motivating examples of how the ``nice_exceptions`` +"""Show (and test) some motivating examples of how the ``remap_wit_errors`` decorator makes WIT's ``result``-driven errors more Pythonic.""" import sys @@ -15,7 +15,7 @@ from fastly_compute.exceptions import ( FastlyError, UnexpectedFastlyError, - nice_exceptions, + remap_wit_errors, ) @@ -24,7 +24,7 @@ class BufferTooShortError(FastlyError): # 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 nice_exceptions() for "escape-hatch" callables which + # 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): @@ -44,7 +44,7 @@ def __init__(self, height: int): def test_primitive(): """Show that a primitive type can be mapped to a meaningful exception.""" - @nice_exceptions({int: NegativeHeightError}) + @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) @@ -64,7 +64,7 @@ def test_unexpected(): someday. """ - @nice_exceptions() + @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) @@ -78,7 +78,7 @@ def raise_int_by_surprise() -> Err: def test_variant(): """Show how a WIT variant case can be concisely mapped into a more idiomatic exception.""" - @nice_exceptions({Error_BufferLen: BufferTooShortError}) + @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)) @@ -103,11 +103,11 @@ class NotFoundError(FastlyError): OpenError.NOT_FOUND: NotFoundError, } - @nice_exceptions(enum_map) + @remap_wit_errors(enum_map) def raise_one_enum() -> Err: raise Err(value=OpenError.INVALID_SYNTAX) - @nice_exceptions(enum_map) + @remap_wit_errors(enum_map) def raise_other_enum() -> Err: raise Err(value=OpenError.NOT_FOUND)