"An exceptional library"
Installation
pip install izulu
You can read docs from top to bottom or jump straight into "Quickstart" section. For details note "Specifications" sections below.
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.
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
.
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
Note
Prepare playground
pip install ipython izulu ipython -i -c 'from izulu.root import *; from typing import *; from datetime import *'
- 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
- define annotations for fields you want to publish as exception instance attributes
- you have to define desired template fields in annotations too
(see
AttributeError
forowner
) - 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'
- 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
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 forfactory
tool (keyword or positional - doesn't matter)
izulu
bases on class definitions to provide handy instance creation.
- 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
- template may contain "fields" for substitution from
__features__
class attribute defines constraints and behaviour (see "Features" section below)- by default all constraints are enabled
- "class hints" annotated with
ClassVar
are noted byizulu
- 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 byizulu
- 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)
Warning
Types from type hints are not validated or enforced!
Note
Prepare playground
pip install ipython izulu ipython -i -c 'from izulu.root import *; from typing import *; from datetime import *'
- 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)
help(str.format)
https://docs.python.org/3/library/string.html#format-string-syntax
Warning
There is a difference between docs and actual behaviour: https://discuss.python.org/t/format-string-syntax-specification-differs-from-actual-behaviour/46716
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 providedclass 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 onlykwargs
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 defaults" must be type hinted with
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 onlyClassVar
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
helperfactory
provides 2 modes depending on value of theself
flag:self=False
(default): callable accepting no argumentsclass 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'
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)
FORBID_MISSING_FIELDS
: checks providedkwargs
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
- always should be enabled (provides consistent and detailed
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 providedkwargs
(names not present in template "fields" and "instance/class hints")- if disabled allows and completely ignores unknown data in
kwargs
Stage
Raises
runtime
TypeError
- if disabled allows and completely ignores unknown data in
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 providedkwargs
not to contain attributes defined asClassVar
- 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
- if disabled allows data in
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 onkwargs
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'
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
izulu
may trigger native Python exceptions on invalid data during validation process.
By default you should expect following ones
TypeError
: argument constraints issuesValueError
: 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 validatedclass 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 featuresclass MyError(Error): __template__ = "Hello {name}" MyError() # TypeError: Missing arguments: 'name'
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
andrepr
output differsstr
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 intokwargs
supportsrepr
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
izulu
-based errors support pickling by default.
.as_kwargs()
dumps shallow copy of originalkwargs
err.as_kwargs()
# {'amount': 15000}
.as_dict()
by default, combines originalkwargs
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"
.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 inkwargs
(notets
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; flagwide
should beFalse
by default according toFORBID_KWARG_CONSTS
restriction (if you disableFORBID_KWARG_CONSTS
then you may need to usewide=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))
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
# 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}"
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
err = Error()
view = RespModel(error=err.as_dict(wide=True)
class MyRealError(BaseError):
__template__ = "Having count={count} for owner={owner}"
TBD
Running tests:
tox
Building package:
tox -e build
Contributing: contact me through Issues
SemVer used for versioning. For available versions see the repository tags and releases.
- Dima Burmistrov - Initial work - pyctrl
Special thanks to Eugene Frolov for inspiration.
This project is licensed under the X11 License (extended MIT) - see the LICENSE file for details