An exceptional library
Project description
“An exceptional library”
Installation
pip install izulu
Presenting izulu
Bring OOP into exception/error management
You can read docs from top to bottom or jump straight into “Quickstart” section. For details note “Specifications” sections below.
Neat #1: Stop messing with raw strings and manual message formatting
if not data:
raise ValueError("Data is invalid: no data")
amount = data["amount"]
if amount < 0:
raise ValueError(f"Data is invalid: amount can't be negative ({amount})")
elif amount > 1000:
raise ValueError(f"Data is invalid: amount is too large ({amount})")
if data["status"] not in {"READY", "IN_PROGRESS}:
raise ValueError("Data is invalid: unprocessable status")
With izulu you can forget about manual error message management all over the codebase!
class ValidationError(Error):
__template__ = "Data is invalid: {reason}"
class AmountValidationError(ValidationError):
__template__ = f"{ValidationError.__template__} ({{amount}})"
if not data:
raise ValidationError(reason="no data")
amount = data["amount"]
if amount < 0:
raise AmountValidationError(reason="amount can't be negative", amount=amount)
elif amount > 1000:
raise AmountValidationError(reason="amount is too large", amount=amount)
if data["status"] not in {"READY", "IN_PROGRESS}:
raise ValidationError(reason="unprocessable status")
Provide only variable data for error instantiations. Keep static data within error class.
Under the hood kwargs are used to format __template__ into final error message.
Neat #2: Attribute errors with useful fields
from falcon import HTTPBadRequest
class AmountValidationError(ValidationError):
__template__ = "Data is invalid: {reason} ({amount})"
amount: int
try:
validate(data)
except AmountValidationError as e:
if e.amount < 0:
raise HTTPBadRequest(f"Bad amount: {e.amount}")
raise
Annotated instance attributes automatically populated from kwargs.
Neat #3: Static and dynamic defaults
class AmountValidationError(ValidationError):
__template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}"
_MAX: ClassVar[int] = 1000
amount: int
reason: str = "amount is too large"
ts: datetime = factory(datetime.now)
print(AmountValidationError(amount=15000))
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 22:59:25.132699
print(AmountValidationError(amount=-1, reason="amount can't be negative"))
# Data is invalid: amount can't be negative (-1; MAX=1000) at 2024-01-13 22:59:54.482577
Quickstart
Let’s start with defining our initial error class (exception)
subclass Error
provide special message template for each of your exceptions
use only kwargs to instantiate exception (no more message copying across the codebase)
class MyError(Error):
__template__ = "Having count={count} for owner={owner}"
print(MyError(count=10, owner="me"))
# MyError: Having count=10 for owner=me
MyError(10, owner="me")
# TypeError: __init__() takes 1 positional argument but 2 were given
Move on and improve our class with attributes
define annotations for fields you want to publish as exception instance attributes
you have to define desired template fields in annotations too (see AttributeError for owner)
you can provide annotation for attributes not included in template (see timestamp)
type hinting from annotations are not enforced or checked (see timestamp)
class MyError(Error):
__template__ = "Having count={count} for owner={owner}"
count: int
timestamp: datetime
e = MyError(count=10, owner="me", timestamp=datetime.now())
print(e.count)
# 10
print(e.timestamp)
# 2023-09-27 18:18:22.957925
e.owner
# AttributeError: 'MyError' object has no attribute 'owner'
We can provide defaults for our attributes
define default static values after field annotation just as usual
for dynamic defaults use provided factory tool with your callable - it would be evaluated without arguments during exception instantiation
now fields would receive values from kwargs if present - otherwise from defaults
class MyError(Error):
__template__ = "Having count={count} for owner={owner}"
count: int
owner: str = "nobody"
timestamp: datetime = factory(datetime.now)
e = MyError(count=10)
print(e.count)
# 10
print(e.owner)
# nobody
print(e.timestamp)
# 2023-09-27 18:19:37.252577
Dynamic defaults also supported
class MyError(Error):
__template__ = "Having count={count} for owner={owner}"
count: int
begin: datetime
owner: str = "nobody"
timestamp: datetime = factory(datetime.now)
duration: timedelta = factory(lambda self: self.timestamp - self.begin, self=True)
begin = datetime.fromordinal(date.today().toordinal())
e = MyError(count=10, begin=begin)
print(e.begin)
# 2023-09-27 00:00:00
print(e.duration)
# 18:45:44.502490
print(e.timestamp)
# 2023-09-27 18:45:44.502490
very similar to dynamic defaults, but callable must accept single argument - your exception fresh instance
don’t forget to provide second True argument for factory tool (keyword or positional - doesn’t matter)
Specifications
izulu bases on class definitions to provide handy instance creation.
The 6 pillars of izulu
all behavior is defined on the class-level
__template__ class attribute defines the template for target error message
template may contain “fields” for substitution from kwargs and “defaults” to produce final error message
__features__ class attribute defines constraints and behaviour (see “Features” section below)
by default all constraints are enabled
“class hints” annotated with ClassVar are noted by izulu
annotated class attributes normally should have values (treated as “class defaults”)
“class defaults” can only be static
“class defaults” may be referred within __template__
“instance hints” regularly annotated (not with ClassVar) are noted by izulu
all annotated attributes are treated as “instance attributes”
each “instance attribute” will automatically obtain value from the kwarg of the same name
“instance attributes” with default are also treated as “instance defaults”
“instance defaults” may be static and dynamic
“instance defaults” may be referred within __template__
kwargs — the new and main way to form exceptions/error instance
forget about creating exception instances from message strings
kwargs are the datasource for template “fields” and “instance attributes” (shared input for templating attribution)
Mechanics
inheritance from izulu.root.Error is required
class AmountError(Error):
pass
optionally behaviour can be adjusted with __features__ (not recommended)
class AmountError(Error):
__features__ = Features.DEFAULT ^ Features.FORBID_UNDECLARED_FIELDS
you should provide a template for the target error message with __template__
class AmountError(Error): __template__ = "Data is invalid: {reason} (amount={amount})" print(AmountError(reason="negative amount", amount=-10.52)) # [2024-01-23 19:16] Data is invalid: negative amount (amount=-10.52)
sources of formatting arguments:
“class defaults”
“instance defaults”
kwargs (overlap any “default”)
new style formatting is used:
class AmountError(Error): __template__ = "[{ts:%Y-%m-%d %H:%M}] Data is invalid: {reason:_^20} (amount={amount:06.2f})" print(AmountError(ts=datetime.now(), reason="negative amount", amount=-10.52)) # [2024-01-23 19:16] Data is invalid: __negative amount___ (amount=-10.52)
only named fields are allowed
positional (digit) and empty field are forbidden
error instantiation requires data to format __template__
all data for __template__ fields must be provided
class AmountError(Error): __template__ = "Data is invalid: {reason} (amount={amount})" print(AmountError(reason="amount can't be negative", amount=-10)) # Data is invalid: amount can't be negative (amount=-10) AmountError() # TypeError: Missing arguments: 'reason', 'amount' AmountError(amount=-10) # TypeError: Missing arguments: 'reason'
only named arguments allowed: __init__() accepts only kwargs
class AmountError(Error): __template__ = "Data is invalid: {reason} (amount={amount})" print(AmountError(reason="amount can't be negative", amount=-10)) # Data is invalid: amount can't be negative (amount=-10) AmountError("amount can't be negative", -10) # TypeError: __init__() takes 1 positional argument but 3 were given AmountError("amount can't be negative", amount=-10) # TypeError: __init__() takes 1 positional argument but 2 were given
“class defaults” can be defined and used
“class defaults” must be type hinted with ClassVar annotation and provide static values
template “fields” may refer “class defaults”
class AmountError(Error):
LIMIT: ClassVar[int] = 10_000
__template__ = "Amount is too large: amount={amount} limit={LIMIT}"
amount: int
print(AmountError(amount=10_500))
# Amount is too large: amount=10500 limit=10000
“instance attributes” are populated from relevant kwargs
class AmountError(Error):
amount: int
print(AmountError(amount=-10).amount)
# -10
instance and class attribute types from annotations are not validated or enforced (izulu uses type hints just for attribute discovery and only ClassVar marker is processed for instance/class segregation)
class AmountError(Error):
amount: int
print(AmountError(amount="lots of money").amount)
# lots of money
static “instance defaults” can be provided regularly with instance type hints and static values
class AmountError(Error):
amount: int = 500
print(AmountError().amount)
# 500
dynamic “instance defaults” are also supported
they must be type hinted and have special value
value must be a callable object wrapped with factory helper
factory provides 2 modes depending on value of the self flag:
self=False (default): callable accepting no arguments
class AmountError(Error): ts: datetime = factory(datetime.now) print(AmountError().ts) # 2024-01-23 23:18:22.019963
self=True: provide callable accepting single argument (error instance)
class AmountError(Error): LIMIT = 10_000 amount: int overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True) print(AmountError(amount=10_500).overflow) # 500
“instance defaults” and “instance attributes” may be referred in __template__
class AmountError(Error):
__template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: {amount}"
amount: int
ts: datetime = factory(datetime.now)
print(AmountError(amount=10_500))
# [2024-01-23 23:21] Amount is too large: 10500
Pause and sum up: defaults, attributes and template
class AmountError(Error):
LIMIT: ClassVar[int] = 10_000
__template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}"
amount: int
overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True)
ts: datetime = factory(datetime.now)
err = AmountError(amount=15_000)
print(err.amount)
# 15000
print(err.LIMIT)
# 10000
print(err.overflow)
# 5000
print(err.ts)
# 2024-01-23 23:21:26
print(err)
# [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000
kwargs overlap “instance defaults”
class AmountError(Error):
LIMIT: ClassVar[int] = 10_000
__template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}"
amount: int = 15_000
overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True)
ts: datetime = factory(datetime.now)
print(AmountError())
# [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000
print(AmountError(amount=10_333, overflow=42, ts=datetime(1900, 1, 1)))
# [2024-01-23 23:21] Amount is too large: amount=10333 limit=10000 overflow=42
izulu provides flexibility for templates, fields, attributes and defaults
“defaults” are not required to be __template__ “fields”
class AmountError(Error): LIMIT: ClassVar[int] = 10_000 __template__ = "Amount is too large" print(AmountError().LIMIT) # 10000 print(AmountError()) # Amount is too large
there can be hints for attributes not present in error message template
class AmountError(Error): __template__ = "Amount is too large" amount: int print(AmountError(amount=500).amount) # 500 print(AmountError(amount=500)) # Amount is too large
“fields” don’t have to be hinted as instance attributes
class AmountError(Error): __template__ = "Amount is too large: {amount}" print(AmountError(amount=500)) # Amount is too large: 500 print(AmountError(amount=500).amount) # AttributeError: 'AmountError' object has no attribute 'amount'
Features
The izulu error class behaviour is controlled by __features__ class attribute.
(For details about “runtime” and “class definition” stages see Validation and behavior in case of problems below)
Supported features
FORBID_MISSING_FIELDS: checks provided kwargs contain data for all template “fields” and “instance attributes” that have no “defaults”
always should be enabled (provides consistent and detailed TypeError exceptions for appropriate arguments)
if disabled raw exceptions from izulu machinery internals could appear
Stage
Raises
runtime
TypeError
class AmountError(Error):
__template__ = "Some {amount} of money for {client_id} client"
client_id: int
# I. enabled
AmountError()
# TypeError: Missing arguments: client_id, amount
# II. disabled
AmountError.__features__ ^= Features.FORBID_MISSING_FIELDS
AmountError()
# ValueError: Failed to format template with provided kwargs:
FORBID_UNDECLARED_FIELDS: forbids undefined arguments in provided kwargs (names not present in template “fields” and “instance/class hints”)
if disabled allows and completely ignores unknown data in kwargs
Stage
Raises
runtime
TypeError
class MyError(Error):
__template__ = "My error occurred"
# I. enabled
MyError(unknown_data="data")
# Undeclared arguments: unknown_data
# II. disabled
MyError.__features__ ^= Features.FORBID_UNDECLARED_FIELDS
err = MyError(unknown_data="data")
print(err)
# Unspecified error
print(repr(err))
# __main__.MyError(unknown_data='data')
err.unknown_data
# AttributeError: 'MyError' object has no attribute 'unknown_data'
FORBID_KWARG_CONSTS: checks provided kwargs not to contain attributes defined as ClassVar
if disabled allows data in kwargs to overlap class attributes during template formatting
overlapping data won’t modify class attribute values
Stage
Raises
runtime
TypeError
class MyError(Error):
__template__ = "My error occurred {_TYPE}"
_TYPE: ClassVar[str]
# I. enabled
MyError(_TYPE="SOME_ERROR_TYPE")
# TypeError: Constants in arguments: _TYPE
# II. disabled
MyError.__features__ ^= Features.FORBID_KWARG_CONSTS
err = MyError(_TYPE="SOME_ERROR_TYPE")
print(err)
# My error occurred SOME_ERROR_TYPE
print(repr(err))
# __main__.MyError(_TYPE='SOME_ERROR_TYPE')
err._TYPE
# AttributeError: 'MyError' object has no attribute '_TYPE'
FORBID_NON_NAMED_FIELDS: forbids empty and digit field names in __template__
if disabled validation (runtime issues)
izulu relies on kwargs and named fields
by default it’s forbidden to provide empty ({}) and digit ({0}) fields in __template__
Stage
Raises
class definition
ValueError
class MyError(Error):
__template__ = "My error occurred {_TYPE}"
_TYPE: ClassVar[str]
# I. enabled
MyError(_TYPE="SOME_ERROR_TYPE")
# TypeError: Constants in arguments: _TYPE
# II. disabled
MyError.__features__ ^= Features.FORBID_KWARG_CONSTS
err = MyError(_TYPE="SOME_ERROR_TYPE")
print(err)
# My error occurred SOME_ERROR_TYPE
print(repr(err))
# __main__.MyError(_TYPE='SOME_ERROR_TYPE')
err._TYPE
# AttributeError: 'MyError' object has no attribute '_TYPE'
Tuning __features__
Features are represented as “Flag Enum”, so you can use regular operations to configure desired behaviour. Examples:
Use single option
class AmountError(Error):
__features__ = Features.FORBID_MISSING_FIELDS
Use presets
class AmountError(Error):
__features__ = Features.NONE
Combining wanted features:
class AmountError(Error):
__features__ = Features.FORBID_MISSING_FIELDS | Features.FORBID_KWARG_CONSTS
Discarding unwanted feature from default feature set:
class AmountError(Error):
__features__ = Features.DEFAULT ^ Features.FORBID_UNDECLARED_FIELDS
Validation and behavior in case of problems
izulu may trigger native Python exceptions on invalid data during validation process. By default you should expect following ones
TypeError: argument constraints issues
ValueError: template and formatting issues
Some exceptions are raised from original exception (e.g. template formatting issues), so you can check e.__cause__ and traceback output for details.
The validation behavior depends on the set of enabled features. Changing feature set may cause different and raw exceptions being raised. Read and understand “Features” section to predict and experiment with different situations and behaviours.
izulu has 2 validation stages:
class definition stage
validation is made during error class definition
# when you import error module from izulu import root # when you import error from module from izulu.root import Error # when you interactively define new error classes class MyError(Error): pass
class attributes __template__ and __features__ are validated
class MyError(Error): __template__ = "Hello {}" # ValueError: Field names can't be empty
runtime stage
validation is made during error instantiation
root.Error()
kwargs are validated according to enabled features
class MyError(Error): __template__ = "Hello {name}" MyError() # TypeError: Missing arguments: 'name'
Additional APIs
Representations
class AmountValidationError(Error):
__template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}"
_MAX: ClassVar[int] = 1000
amount: int
reason: str = "amount is too large"
ts: datetime = factory(datetime.now)
err = AmountValidationError(amount=15000)
print(str(err))
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586
print(repr(err))
# __main__.AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large')
str and repr output differs
str is for humans and Python (Python dictates the result to be exactly and only the message)
repr allows to reconstruct the same error instance from its output (if data provided into kwargs supports repr the same way)
note: class name is fully qualified name of class (dot-separated module full path with class name)
reconstructed = eval(repr(err).replace("__main__.", "", 1)) print(str(reconstructed)) # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586 print(repr(reconstructed)) # AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large')
in addition to str there is another human-readable representations provided by .as_str() method; it prepends message with class name:
print(err.as_str()) # AmountValidationError: Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586
Pickling, dumping and loading
Pickling
izulu-based errors support pickling by default.
Dumping
.as_kwargs() dumps shallow copy of original kwargs
err.as_kwargs()
# {'amount': 15000}
.as_dict() by default, combines original kwargs and all “instance attribute” values into “full state”
err.as_dict() # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large'}
Additionally, there is the wide flag for enriching the result with “class defaults” (note additional _MAX data)
err.as_dict(True) # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large', '_MAX': 1000}
Data combination process follows prioritization — if there are multiple values for same name then high priority data will overlap data with lower priority. Here is the prioritized list of data sources:
kwargs (max priority)
“instance attributes”
“class defaults”
Loading
.as_kwargs() result can be used to create inaccurate copy of original error, but pay attention to dynamic factories — datetime.now(), uuid() and many others would produce new values for data missing in kwargs (note ts field in the example below)
inaccurate_copy = AmountValidationError(**err.as_kwargs())
print(inaccurate_copy)
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080
print(repr(inaccurate_copy))
# __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080))
.as_dict() result can be used to create accurate copy of original error; flag wide should be False by default according to FORBID_KWARG_CONSTS restriction (if you disable FORBID_KWARG_CONSTS then you may need to use wide=True depending on your situation)
accurate_copy = AmountValidationError(**err.as_dict())
print(accurate_copy)
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080
print(repr(accurate_copy))
# __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080))
(advanced) Wedge
There is a special method you can override and additionally manage the machinery.
But it should not be need in 99,9% cases. Avoid it, please.
def _hook(self,
store: _utils.Store,
kwargs: dict[str, t.Any],
msg: str) -> str:
"""Adapter method to wedge user logic into izulu machinery
This is the place to override message/formatting if regular mechanics
don't work for you. It has to return original or your flavored message.
The method is invoked between izulu preparations and original
`Exception` constructor receiving the result of this hook.
You can also do any other logic here. You will be provided with
complete set of prepared data from izulu. But it's recommended
to use classic OOP inheritance for ordinary behaviour extension.
Params:
* store: dataclass containing inner error class specifications
* kwargs: original kwargs from user
* msg: formatted message from the error template
"""
return msg
Tips
1. inheritance / root exception
# intermediate class to centrally control the default behaviour
class BaseError(Error): # <-- inherit from this in your code (not directly from ``izulu``)
__features__ = Features.None
class MyRealError(BaseError):
__template__ = "Having count={count} for owner={owner}"
2. factories
TODO: self=True / self.as_kwargs() (as_dict forbidden? - recursion)
stdlib factories
from uuid import uuid4
class MyError(Error):
id: datetime = factory(uuid4)
timestamp: datetime = factory(datetime.now)
lambdas
class MyError(Error):
timestamp: datetime = factory(lambda: datetime.now().isoformat())
function
from random import randint
def flip_coin():
return "TAILS" if randint(0, 100) % 2 else "HEADS
class MyError(Error):
coin: str = factory(flip_coin)
method
class MyError(Error):
__template__ = "Having count={count} for owner={owner}"
def __make_duration(self) -> timedelta:
kwargs = self.as_kwargs()
return self.timestamp - kwargs["begin"]
timestamp: datetime = factory(datetime.now)
duration: timedelta = factory(__make_duration, self=True)
begin = datetime.fromordinal(date.today().toordinal())
e = MyError(count=10, begin=begin)
print(e.begin)
# 2023-09-27 00:00:00
print(e.duration)
# 18:45:44.502490
print(e.timestamp)
# 2023-09-27 18:45:44.502490
3. handling errors in presentation layers / APIs
err = Error()
view = RespModel(error=err.as_dict(wide=True)
class MyRealError(BaseError):
__template__ = "Having count={count} for owner={owner}"
Additional examples
TBD
For developers
Running tests:
tox
Building package:
tox -e build
Contributing: contact me through Issues
Versioning
SemVer used for versioning. For available versions see the repository tags and releases.
License
This project is licensed under the X11 License (extended MIT) - see the LICENSE file for details
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file izulu-0.5.2.tar.gz
.
File metadata
- Download URL: izulu-0.5.2.tar.gz
- Upload date:
- Size: 32.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/5.0.0 CPython/3.9.1
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 0c1276b4a2bbba0b47412302c5e1c5b667e651568c6644ae019131b48de84a89 |
|
MD5 | bd39004eb052243e5883a388be78a036 |
|
BLAKE2b-256 | 99429d311e6edf847f146ac9886e40138104a0334f925c8862eeba683c912751 |
File details
Details for the file izulu-0.5.2-py3-none-any.whl
.
File metadata
- Download URL: izulu-0.5.2-py3-none-any.whl
- Upload date:
- Size: 14.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/5.0.0 CPython/3.9.1
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | d8d315e7b63c99ec424b0f7cfc365f84db2d939f116509bbec9e5005844a982a |
|
MD5 | f400c5b685e4a67e17c8149efe668934 |
|
BLAKE2b-256 | 077fda0c31c80e63312a2748745cb7599926a8dba212676eb87ccbebfdcebec7 |