From 2d08ad9846d01f04e705c06d9f59e864da2ddea9 Mon Sep 17 00:00:00 2001 From: Tucker Beck Date: Mon, 21 Apr 2025 17:30:55 -0700 Subject: [PATCH] feat: Added the `ensure_type()` function Also updated some documentation. --- CHANGELOG.md | 4 + README.md | 1 + docs/mkdocs.yaml | 2 +- docs/source/demo.md | 4 +- docs/source/features.md | 187 +++++++++++++++++++++++++---------- docs/source/index.md | 59 +++++++---- pyproject.toml | 2 +- src/buzz/__init__.py | 10 +- src/buzz/base.py | 43 ++++++++ src/buzz/tools.py | 62 +++++++++++- src/buzz_demo/ensure_type.py | 88 +++++++++++++++++ src/buzz_demo/main.py | 1 + tests/test_base.py | 10 ++ tests/test_derived.py | 28 ++++++ tests/test_tools.py | 87 ++++++++++++++++ uv.lock | 2 +- 16 files changed, 506 insertions(+), 84 deletions(-) create mode 100644 src/buzz_demo/ensure_type.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9033d..8abc6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## v7.3.0 - 2025-04-21 +* Added the `ensure_type()` function +* Some docs updates + ## v7.2.0 - 2025-04-19 * Renamed the `demo` package to `buzz_demo` to avoid name collision diff --git a/README.md b/README.md index e733ea5..4450455 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ include: * checking many conditions and reporting which ones failed (`check_expressions()`) * catching exceptions wrapping them in clearer exception types with better error messages (`handle_errors()`) * checking that values are defined and raising errors if not (`enforce_defined()`) +* checking that values are a certain type and raising errors if not (`ensure_type()`) * checking conditions and raising errors on failure (`require_condition()`) py-buzz also provides an exception class, Buzz, that can be used as a base class diff --git a/docs/mkdocs.yaml b/docs/mkdocs.yaml index db2d9e2..847a77e 100644 --- a/docs/mkdocs.yaml +++ b/docs/mkdocs.yaml @@ -1,7 +1,7 @@ # Configuration for mkdocs site_name: py-buzz documentation -site_url: http://py-buzz.readthedocs.io/en/latest/ +site_url: https://dusktreader.github.io/py-buzz repo_url: https://github.com/dusktreader/py-buzz docs_dir: source theme: diff --git a/docs/source/demo.md b/docs/source/demo.md index 13f3b6c..4a3cd7c 100644 --- a/docs/source/demo.md +++ b/docs/source/demo.md @@ -39,7 +39,7 @@ If you want to run the demo but not include its dependencies in your system pyth or an activated virtual environment, you can execute the demo with uv: ```bash -uv run --with=py-buzz[demo] py-buzz-demo +uvx --from=py-buzz[demo] py-buzz-demo ``` @@ -47,4 +47,4 @@ uv run --with=py-buzz[demo] py-buzz-demo You can also examine the demo source to examine how `py-buzz` is used. -Check out the [source code on Github](https://github.com/dusktreader/py-buzz/tree/main/src/demo). +Check out the [source code on Github](https://github.com/dusktreader/py-buzz/tree/main/src/buzz_demo). diff --git a/docs/source/features.md b/docs/source/features.md index 0348d08..69356c0 100644 --- a/docs/source/features.md +++ b/docs/source/features.md @@ -7,10 +7,10 @@ There are a few main features of `py-buzz` that are noteworthy: ### Raise exception on condition failure -The `py-buzz` package provides a function that checks a condition and raises -an exception if it fails. This is nice, because you often find your self writing -a lot of `if : raise Exception()` throughout your code -base. It's just a little easier with `py-buzz`: +The `py-buzz` package provides the `require_condition()` function that checks a +condition and raises an exception if it fails. This is nice, because you often +find your self writing a lot of `if : raise Exception()` +throughout your code base. It's just a little easier with `py-buzz`: ```python # Vanilla python @@ -56,7 +56,7 @@ to the raised exception _after the message_. Here is an example: ```python class MyProjectError(Exception): - def __init__(self, message, init_arg1, init_arg2): + def __init__(self, message, init_arg1: str, init_arg2: str): self.init_arg1 = init_arg1 self.init_arg2 = init_arg2 @@ -68,7 +68,7 @@ require_condition( ) ``` -If the condition fails, `require_condition` will rais a `MyProjectError` initialized +If the condition fails, `require_condition()` will rais a `MyProjectError` initialized with positional args `init_arg1 == "foo"` and `init_arg2 == "bar"`. @@ -80,7 +80,7 @@ to the newly raised exception: ```python class MyProjectError(Exception): - def __init__(self, message, init_kwarg1=None, init_kwarg2=None): + def __init__(self, message, init_kwarg1: str | None = None, init_kwarg2: str | None = None): self.init_kwarg1 = init_kwarg1 self.init_kwarg2 = init_kwarg2 @@ -92,7 +92,7 @@ require_condition( ) ``` -If the condition fails, `require_condition` will rais a `MyProjectError` initialized +If the condition fails, `require_condition()` will rais a `MyProjectError` initialized with keyword arguments `init_kwarg1 == "foo"` and `init_kwarg2 == "bar"`. @@ -115,7 +115,7 @@ that accepts a single parameter of type `ExcBuilderParams` that can be imported ```python class WeirdArgsError(Exception): - def __init__(self, *args, detail="", **kwargs): + def __init__(self, *args, detail: str = "", **kwargs): self.detail = detail super().__init__(*args, **kwargs) @@ -174,22 +174,21 @@ require_condition( ### Raise exception if value is not defined -The `py-buzz` package provides a function that checks a value and raises an exception if -it is not defined. This is especially useful for both checking if a variable passed to a -function is defined and also to satisfy static type checkers when you want to call a -method on the object. +The `py-buzz` package provides the `enforce_defined()` function that checks a +value and raises an exception if it is not defined. This is especially useful +for both checking if a variable passed to a function is defined and also to +satisfy static type checkers when you want to call a method on the object. ```python # Vanilla python - def vanilla(val: Optional[str]) -> str: + def vanilla(val: str | None) -> str: if val is None: raise Exception("Received an undefined value!") return val.upper() # With py-buzz - def buzzy(val: Optional[str]) -> str: - val = enforce_defined(val) - return val.upper() + def buzzy(val: str | None) -> str: + return enforce_defined(val).upper() ``` This is also mostly just syntactic sugar, but it can save you a few lines of code and is @@ -197,15 +196,14 @@ still very expressive. It might also be useful if you need to supply some more c in your error: ```python -def neopolitan(val: Optional[str]): - val = enforce_defined( +def neopolitan(val: str | None): + return enforce_defined( val, "Received an undefined value!" raise_exc_class=MyProjectError, raise_args=["jawa", "ewok"], raise_kwargs=dict(hutt="pyke"), ) - return val ``` In this case, a `MyProjectError` with be raised with positional arguments of `"jawa"` and @@ -220,40 +218,119 @@ The `enforce_defined()` function also accepts some keyword arguments: #### `raise_exc_class` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. + + +#### `raise_args` + +Functions the same as `require_condition()`. + + +#### `raise_kwargs` + +Functions the same as `require_condition()`. + + +#### `exc_builder` + +Functions the same as `require_condition()`. + + +#### `do_except` + +Functions the same as `require_condition()`. + + +#### `do_else` + +Functions the same as `require_condition()`. + + +### Raise exception if value is wrong type + +The `py-buzz` package also provides the `ensure_type()` function that checks a +value and raises an exception if it is not of the expected type. This function +serves a dual purpose: it will raise an error if your value is the wrong type, +and it will also narrow the type for you to make your type-check happy. + +```python + # Vanilla python + def vanilla(val: str | int) -> str: + if not isinstance(val, str): + raise Exception("Received a non-string value!") + return val.upper() + + # With py-buzz + def buzzy(val: str | int) -> str: + return ensure_type(val, str).upper() +``` + +Though this could be called syntactic sugar as well, it saves some keystrokes +and is an elegant way to narrow your type. At the same time, it is a nice +approach to applying +[negativ-space-programming](https://double-trouble.dev/post/negativ-space-programming/) +because it doesn't just "cast" the type, it raises an exception if the type +doesn't match. It can also need to supply some more context in your error: + +```python +def neopolitan(val: str | int): + return ensure_type( + val, + str, + "Received a non-string value!" + raise_exc_class=MyProjectError, + raise_args=["jawa", "ewok"], + raise_kwargs=dict(hutt="pyke"), + ) +``` + +In this case, a `MyProjectError` with be raised with positional arguments of `"jawa"` and +`"ewok"` and a keyword argument of `hutt="pyke"` if the value passed in is not `str`. + +By default, `ensure_type()` raises an exception with a basic message saying that the +value was the wrong type. However, you may pass in a custom message with the `message` +keyword argument. + +The `ensure_type()` function also accepts some keyword arguments: + + +#### `raise_exc_class` + +Functions the same as `require_condition()`. #### `raise_args` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `raise_kwargs` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `exc_builder` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `do_except` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `do_else` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. ### Exception handling context manager -The `py-buzz` package also provides a context manager that catches any exceptions that -might be raised while executing a bit of code. The caught exceptions are re-packaged and -raised as another exception type. The message attached to the new expression captures the -initial exception's message: +The `py-buzz` package provides the `handle_errors()` context manager that +catches any exceptions that might be raised while executing a bit of code. The +caught exceptions are re-packaged and raised as another exception type. The +message attached to the new expression captures the initial exception's +message: ```python # Vanilla python @@ -274,7 +351,7 @@ with handle_errors("Something didn't work", raise_exc_class=RuntimeError): This actually can save a bit of code and makes things a bit cleaner. It is also a implements pattern that tends to get repeated over and over again. If you need to do very complicated things while handling an exception, you should use a standard try- -catch block. However, there are some extra bells and whistles on `handle_errors` that +catch block. However, there are some extra bells and whistles on `handle_errors()` that can be used by passing additional keyword arguments to the function. @@ -289,17 +366,17 @@ the first raised exception*. #### `raise_args` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `raise_kwargs` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `exc_builder` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `handle_exc_class` @@ -341,10 +418,10 @@ provides the ability to do this. The `do_except` option should be a callable fun that accepts a parameter of type `DoExceptParams` that can be imported from `buzz`. This `dataclass` has three attributes: -* err: The caught exception itself -* base_message: The message provided as the first argument to `handle_errors` -* final_message: A message describing the error (This will be the formatted error message) -* trace: A stack trace +* `err`: The caught exception itself +* `base_message`: The message provided as the first argument to `handle_errors()` +* `final_message`: A message describing the error (This will be the formatted error message) +* `trace`: A stack trace This option might be invoked something like this: @@ -389,15 +466,15 @@ with handle_errors("Something went wrong", do_finally=close_resource): #### `exc_builder` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. -### `check_expressions` +### Expression checking context manager -The `check_expressions` context manager is used to check multiple expressions inside of -a context manager. Each expression is checked and each failing expression is reported at -the end in a raised exception. If no expressions fail in the block, no exception is -raised. +The `check_expressions()` context manager is used to check multiple expressions +inside of a context manager. Each expression is checked and each failing +expression is reported at the end in a raised exception. If no expressions fail +in the block, no exception is raised. ```python with check_expressions(main_message='there will be errors') as check: @@ -422,39 +499,39 @@ The `check_expressions()` context manager also accepts some keyword arguments: #### `raise_exc_class` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `raise_args` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `raise_kwargs` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `exc_builder` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `do_except` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. #### `do_else` -Functions the same as `require_condition`. +Functions the same as `require_condition()`. ## Additional Features ### `reformat_exception` -This method is used internally by the `handle_errors` context manager. However, it is +This method is used internally by the `handle_errors()` context manager. However, it is sometimes useful in other circumstances. It simply allows you to wrap an exception message in a more informative block of text: @@ -497,8 +574,12 @@ MyProjectError.require_condition(check_vals(), "Value check failed!") The code above would raise a `MyProjectError` with the supplied message if the condition expression was falsey. -The `Buzz` base class provides the same sort of access for `handle_errors`, -`enforce_defined`, and `check_expressions`. +The `Buzz` base class provides the same sort of access for: + +- `enforce_defined()` +- `ensure-type()` +- `handle_errors()` +- `check_expressions()` ## Demo diff --git a/docs/source/index.md b/docs/source/index.md index 50c8408..bb26fad 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -9,41 +9,44 @@ Take Exceptions to infinity...and beyond with `py-buzz`! ## Overview -Have you ever found yourself writing the same code over and over to provide error -handling in your python projects? I certainly did. In fact, I found that I often -needed to re-implement the same patterns in almost every project. These patterns -included: +Have you ever found yourself writing the same code over and over to provide error handling in your python projects? I +certainly did. In fact, I found that I often needed to re-implement the same patterns in almost every project. These +patterns included: * checking many conditions and reporting which ones failed * catching exceptions and wrapping them in clearer exception types with better error messages * checking conditions and raising errors on failure * checking that values are defined and raising errors if they are not +* checking that values aer of a specific type and raising errors if they are not -This led me to create an exception toolkit called `py-buzz` that provides powerful -helper tools for each of the cases listed above. The `py-buzz` package intends to -make your error handling easy, expressive, and robust. +This led me to create an exception toolkit called `py-buzz` that provides powerful helper tools for each of the cases +listed above. The `py-buzz` package intends to make your error handling easy, expressive, and robust. -Because `py-buzz` requires only Python itself, it's a very light-weight package -that you can use in any project with very little overhead. +Because `py-buzz` requires only Python itself, it's a very light-weight package that you can use in any project with +very little overhead. -`py-buzz` provides functionality or two different main use-cases. Either use-case -allows you to focus on clear and concise error handling in your project without -a lot of repetitive code: +`py-buzz` provides functionality or two different main use-cases. Either use-case allows you to focus on clear and +concise error handling in your project without a lot of repetitive code: -### Helper Functions +### `py-buzz` tools -This set of functions can be used with any exception type. So, if you already -have a set of custom exceptions or simply wish to use existing exceptions, you can -import the py-buzz functions like `require_condition`, `handle_errors`, `enforce_defined`, -and use them in your code immediately. +This set of functions can be used with any exception type. So, if you already have a set of custom exceptions or simply +wish to use existing exceptions, you can import the py-buzz functions and use them in your code immediately. The helper +functions include: + +- [`require_condition()`](features#raise-exception-on-condition-failure) +- [`enforce_defined()`](features#raise-exception-if-value-is-not-defined) +- [`ensure_type()`](features#raise-exception-if-value-is-wrong-type) +- [`handle_errors()`](features#exception-handling-context-manager) +- [`check_expressions()`](features#expression-checking-context-manager) ### `Buzz` base class -This class is meant to be used as a base class for custom exceptions that you can use -in your project. `Buzz` includes all of the helper functions as class methods that will -use your custom exception type. +This class is meant to be used as a base class for custom exceptions that you can use in your project. +[`Buzz`](features#the-buzz-base-class) includes all of the helper functions as class methods that will use your custom +exception type. ## Quickstart @@ -73,3 +76,19 @@ require_condition(check_something(), "The check failed!") ``` For more examples of usage, see the [Features](features.md) page. + + +### Demos + +`py-buzz` comes with an optional demo extra that can show you how to use the features. To install and run, try: + +```bash +pip install py-buzz[demo] +py-buzz-demo +``` + +Alternatively, you can run them in one command using `uv`: + +```bash +uvx --from=py-buzz[demo] py-buzz-demo +``` diff --git a/pyproject.toml b/pyproject.toml index 41eda49..d813b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "py-buzz" -version = "7.2.0" +version = "7.3.0" description = "\"That's not flying, it's falling with style\": Exceptions with extras" authors = [ {name = "Tucker Beck", email ="tucker.beck@gmail.com"}, diff --git a/src/buzz/__init__.py b/src/buzz/__init__.py index 73eb500..25fe72f 100644 --- a/src/buzz/__init__.py +++ b/src/buzz/__init__.py @@ -3,6 +3,7 @@ DoExceptParams, check_expressions, enforce_defined, + ensure_type, get_traceback, handle_errors, handle_errors_async, @@ -13,12 +14,13 @@ __all__ = [ "Buzz", + "DoExceptParams", "check_expressions", + "enforce_defined", + "ensure_type", + "get_traceback", "handle_errors", "handle_errors_async", - "require_condition", - "enforce_defined", "reformat_exception", - "get_traceback", - "DoExceptParams", + "require_condition", ] diff --git a/src/buzz/base.py b/src/buzz/base.py index 8085f65..c212498 100644 --- a/src/buzz/base.py +++ b/src/buzz/base.py @@ -12,6 +12,7 @@ from buzz.tools import ( DoExceptParams, ExcBuilderParams, + ensure_type, require_condition, enforce_defined, check_expressions, @@ -20,6 +21,7 @@ ) TNonNull = TypeVar("TNonNull") +EnsuredType = TypeVar("EnsuredType") class Buzz(Exception): @@ -148,6 +150,47 @@ def enforce_defined( do_else=do_else, ) + @classmethod + def ensure_type( + cls, + value: Any, + type_: type[EnsuredType], + message: str = "Value was not of type {type_}", + raise_args: Iterable[Any] | None = None, + raise_kwargs: Mapping[str, Any] | None = None, + do_except: Callable[[Exception], None] | None = None, + do_else: Callable[[], None] | None = None, + ) -> EnsuredType: + """ + Assert that a value is of a specific type. If the assertion fails, raise an exception (instance of this class) + with the supplied message. + + Args: + + value: The value that is to be checked + type_: The type that the value must be of + message: The failure message to attach to the raised Exception + raise_args: Additional positional args (after the constructed message) that will passed when raising + an instance of the `raise_exc_class`. + raise_kwargs: Keyword args that will be passed when raising an instance of the `raise_exc_class`. + do_except: A function that should be called only if value is of the wrong type. + Must accept one parameter that is the exception that will be raised. + If not provided, nothing will be done. + do_else: A function that should be called if the value is of the wrong type. + If not provided, nothing will be done. + """ + return ensure_type( + value, + type_, + message=message, + raise_exc_class=cls, + raise_args=raise_args, + raise_kwargs=raise_kwargs, + exc_builder=cls.exc_builder, + do_except=do_except, + do_else=do_else, + ) + @classmethod def check_expressions( cls, diff --git a/src/buzz/tools.py b/src/buzz/tools.py index 4157cdb..0a531c7 100644 --- a/src/buzz/tools.py +++ b/src/buzz/tools.py @@ -118,8 +118,7 @@ def enforce_defined( value: The value that is checked to be non-null message: The failure message to attach to the raised Exception - expr: The value that is checked for truthiness (usually an evaluated expression) - raise_exc_class: The exception type to raise with the constructed message if the expression is falsey. + raise_exc_class: The exception type to raise with the constructed message if the expression is None. Defaults to Exception. May not be None. @@ -152,6 +151,65 @@ def enforce_defined( do_except(exc) raise exc + +EnsuredType = TypeVar("EnsuredType") + +def ensure_type( + value: Any, + type_: type[EnsuredType], + message: str | None = None, + raise_exc_class: type[Exception] = Exception, + raise_args: Iterable[Any] | None = None, + raise_kwargs: Mapping[str, Any] | None = None, + exc_builder: Callable[[ExcBuilderParams], Exception] = default_exc_builder, + do_except: Callable[[Exception], None] | None = None, + do_else: Callable[[], None] | None = None, +) -> EnsuredType: + """ + Assert that a value is of a specific type. If the assertion fails, raise an exception with the supplied message. + + Args: + + value: The value that is to be checked + type_: The type that the value must be of + message: The failure message to attach to the raised Exception + raise_exc_class: The exception type to raise with the constructed message if the type does not match + + Defaults to Exception + May not be None + + raise_args: Additional positional args (after the constructed message) that will passed when raising + an instance of the `raise_exc_class`. + raise_kwargs: Keyword args that will be passed when raising an instance of the `raise_exc_class`. + exc_builder: A function that should be called to construct the raised `raise_exc_class`. Useful for + exception classes that do not take a message as the first positional argument. + do_except: A function that should be called only if value is of the wrong type. + Must accept one parameter that is the exception that will be raised. + If not provided, nothing will be done. + do_else: A function that should be called if the value is of the wrong type. + If not provided, nothing will be done. + """ + if not message: + message = f"Value was not of type {type_}" + + if isinstance(value, type_): + if do_else: + do_else() + return value + else: + exc: Exception = exc_builder( + ExcBuilderParams( + raise_exc_class=raise_exc_class, + message=message, + raise_args=raise_args or [], + raise_kwargs=raise_kwargs or {}, + ) + ) + if do_except: + do_except(exc) + raise exc + + class _ExpressionChecker: """ A utility class to be used with the `check_expressions` context manager. diff --git a/src/buzz_demo/ensure_type.py b/src/buzz_demo/ensure_type.py new file mode 100644 index 0000000..2eb56a0 --- /dev/null +++ b/src/buzz_demo/ensure_type.py @@ -0,0 +1,88 @@ +""" +This set of demos show how to use `ensure_type()`. + +The `ensure_type()` function is useful for checking that a value +matches a specific type. If the value does not match, an +exception will be raised. Otherwise, the value will be returned _and_ +cast to the type it was checked against. + +This is especially useful for variables that can be initialized as more +than one type, but you need to access an attribute of them that only +exists in _one_ of the types that they may be. Typically, type checkers +will give you a hard time unless you explicitly cast the variable to a +that specific type or add an assertion of `isinstance()`. This can be an +annoying pattern to have to repeat all over the place. The `ensure_type()` +function an be used practically to guarantee that the variable is of the +and narrow its type. +""" +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from buzz import ensure_type + + +def demo_1__simple(): + """ + This function demonstrates the simplest use of the `ensure_type()` + function. This function can be used any time a a value needs to be checked + to ensure it is of a specific type. This function is best used in an + assignment expression so that static type checkers won't complain if you + attempt to access an attribute of a value that may be of many types and the + attribute only exists on one of them. + """ + val: str | int = "test-value" + val = ensure_type(val, str) + print("I should be able to safely access the `upper()` method of `val` now.") + print("There should also be no type errors because `val` is now guaranteed to be a string.") + val.upper() + + +def demo_2__failing(): + """ + This function demonstrates what happens when the `ensure_type()` function + raises an exception due to the value being of the wrong type. + """ + val: str | int = 13 + val = ensure_type(val, str) + print("I should not be able to get to this line because `ensure_type()` should fail.") + val.upper() + + +def demo_3__complex(): + """ + This function demonstrates a more complex usage of the `ensure_type()` + function. It will raise a specific exception type on failure with some + custom values bound to the exception instance. The following features are + demonstrated: + + * Using a custom failure message + * Raising a specific exception types on failure + * Passing specific args and kwargs to the exception when it is raised. + * Calling a `do_except()` function + """ + class DemoException(Exception): + def __init__(self, message: str, demo_arg: Any, demo_kwarg: Any | None = None): + super().__init__(message) + self.demo_arg: Any = demo_arg + self.demo_kwarg: Any = demo_kwarg + + @override + def __str__(self): + return f"{super().__str__()} (with demo_arg={self.demo_arg} and demo_kwarg={self.demo_kwarg})" + + val: str | int = "dummy" + val = ensure_type(val, str, "This condition should pass") + val.upper() + val = 13 + val = ensure_type( + val, + str, + "Value is not a string", + raise_exc_class=DemoException, + raise_args=["jawa"], + raise_kwargs=dict(demo_kwarg="ewok"), + do_except=lambda exc: print(f"do_except() was called: {exc}!"), + ) + val.upper() diff --git a/src/buzz_demo/main.py b/src/buzz_demo/main.py index 22c87e9..4b46e58 100644 --- a/src/buzz_demo/main.py +++ b/src/buzz_demo/main.py @@ -19,6 +19,7 @@ class Feature(AutoNameEnum): check_expressions = auto() handle_errors = auto() enforce_defined = auto() + ensure_type = auto() require_condition = auto() with_buzz_class = auto() using_exc_builder = auto() diff --git a/tests/test_base.py b/tests/test_base.py index 0d7eb29..592c9c5 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from buzz.base import Buzz @@ -16,6 +18,14 @@ def test_Buzz_enforce_defined__basic(): Buzz.enforce_defined(None, "fail message") +def test_Buzz_ensure_type__basic(): + val: str | int = "dummy" + Buzz.ensure_type(val, str, "should not fail") + with pytest.raises(Buzz, match="fail message"): + val = 13 + Buzz.ensure_type(val, str, "fail message") + + def test_Buzz_handle_errors__basic(): with pytest.raises(Buzz) as err_info: with Buzz.handle_errors("intercepted exception"): diff --git a/tests/test_derived.py b/tests/test_derived.py index 6f3fa07..bee7f96 100644 --- a/tests/test_derived.py +++ b/tests/test_derived.py @@ -26,6 +26,34 @@ def test_derived_require_condition(): assert err_info.value.extra_kwarg == "extra kwarg" +def test_derived_enforce_defined(): + with pytest.raises(MiraNova, match="fail message") as err_info: + MiraNova.enforce_defined( + None, + "fail message", + raise_args=["extra arg"], + raise_kwargs=dict(extra_kwarg="extra kwarg"), + ) + + assert err_info.value.extra_arg == "extra arg" + assert err_info.value.extra_kwarg == "extra kwarg" + + +def test_derived_ensure_type(): + with pytest.raises(MiraNova, match="fail message") as err_info: + val: str | int = 13 + MiraNova.ensure_type( + val, + str, + "fail message", + raise_args=["extra arg"], + raise_kwargs=dict(extra_kwarg="extra kwarg"), + ) + + assert err_info.value.extra_arg == "extra arg" + assert err_info.value.extra_kwarg == "extra kwarg" + + def test_derived_check_expressions(): with pytest.raises(MiraNova) as err_info: with MiraNova.check_expressions( diff --git a/tests/test_tools.py b/tests/test_tools.py index 4cd4f34..e77426c 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,6 +8,7 @@ from buzz.tools import ( + ensure_type, require_condition, enforce_defined, check_expressions, @@ -20,6 +21,8 @@ ) +# ==== helpers ========================================================================================================= + class DummyException(Exception): pass @@ -50,6 +53,8 @@ def alt_builder(params: ExcBuilderParams) -> Exception: ) +# ==== require_condition tests ========================================================================================= + def test_require_condition__basic(): require_condition(True, "should not fail") with pytest.raises(Exception, match="fail message"): @@ -106,6 +111,8 @@ def test_require_condition__with_do_except(): assert "fail message" in str(check_list[0]) +# ==== enforce_defined tests =========================================================================================== + def test_enforce_defined__basic(): some_val: str | None = "boo" some_val = enforce_defined(some_val, "should not fail") @@ -171,6 +178,80 @@ def test_enforce_defined__with_do_except(): assert "fail message" in str(check_list[0]) +# ==== ensure_type tests =============================================================================================== + +def test_ensure_type__basic(): + some_val: str | int = "boo" + some_val = ensure_type(some_val, str, "should not fail") + assert isinstance(some_val, str) + + with pytest.raises(Exception, match="fail message"): + some_val = 13 + ensure_type(some_val, str, "fail message") + + +def test_ensure_type__specific_raise_exc_class(): + some_val: str | int = "boo" + some_val = ensure_type( + some_val, + str, + "should not fail", + raise_exc_class=DummyArgsException, + raise_args=["dummy arg"], + raise_kwargs=dict(dummy_kwarg="dummy_kwarg"), + ) + + with pytest.raises(DummyArgsException, match="fail message") as err_info: + some_val = 13 + ensure_type( + some_val, + str, + "fail message", + raise_exc_class=DummyArgsException, + raise_args=["dummy arg"], + raise_kwargs=dict(dummy_kwarg="dummy kwarg"), + ) + + assert err_info.value.dummy_arg == "dummy arg" + assert err_info.value.dummy_kwarg == "dummy kwarg" + + +def test_ensure_type__using_alternative_exception_builder(): + with pytest.raises(DummyWeirdArgsException) as err_info: + some_val: str | int = 13 + ensure_type( + some_val, + str, + "fail message", + raise_exc_class=DummyWeirdArgsException, + raise_args=["dummy arg"], + raise_kwargs=dict(dummy_kwarg="dummy kwarg"), + exc_builder=alt_builder, + ) + + assert err_info.value.dummy_arg == "dummy arg" + assert err_info.value.dummy_kwarg == "dummy kwarg" + assert err_info.value.detail == "fail message" + + +def test_ensure_type__with_do_else(): + check_list: list[int] = [] + some_val: str | int = "boo" + some_val = ensure_type(some_val, str, "should not fail", do_else=lambda: check_list.append(1)) + assert check_list == [1] + + +def test_ensure_type__with_do_except(): + check_list: list[Exception] = [] + some_val: str | int = 13 + with pytest.raises(Exception, match="fail message"): + ensure_type(some_val, str, "fail message", do_except=lambda e: check_list.append(e)) + assert len(check_list) == 1 + assert "fail message" in str(check_list[0]) + + +# ==== handle_errors tests ============================================================================================= + def test_handle_errors__no_exceptions(): with handle_errors("no errors should happen here"): pass @@ -370,6 +451,8 @@ def test_handle_errors__ignores_errors_matching_ignore_exc_class(): raise RuntimeError("Boom!") +# ==== handle_errors_async tests ======================================================================================= + @pytest.mark.asyncio async def test_handle_errors_async__with_do_else(): async def _anoop(): @@ -453,6 +536,8 @@ async def _add_to_list(p: DoExceptParams): assert isinstance(problem.trace, TracebackType) +# ==== check_expressions tests ========================================================================================= + def test_check_expressions__basic(): with pytest.raises(Exception) as err_info: with check_expressions("there will be errors") as check: @@ -533,6 +618,8 @@ def test_check_expressions__with_do_except(): assert "there will be errors" in str(check_list[0]) +# ==== other tests ===================================================================================================== + def test_reformat_exception(): final_message = reformat_exception( "I want this to be included", diff --git a/uv.lock b/uv.lock index bbdf788..b9c93ed 100644 --- a/uv.lock +++ b/uv.lock @@ -847,7 +847,7 @@ wheels = [ [[package]] name = "py-buzz" -version = "7.2.0" +version = "7.3.0" source = { editable = "." } [package.optional-dependencies]