I have a validator class with a method that performs multiple checks and may raise different exceptions:
class Validator:
def validate(something) -> None:
if a:
raise ErrorA()
if b:
raise ErrorB()
if c:
raise ErrorC()
There's a place in the outside (caller) code where I want to customize its behaviour and prevent ErrorB from being raised, without preventing ErrorC. Something like resumption semantics would be useful here. Hovewer, I haven't found a good way to achieve this.
To clarify: I have the control over Validator source code, but prefer to preserve its existing interface as much as possible.
Some possible solutions that I've considered:
The obvious
try: validator.validate(something) except ErrorB: ...is no good because it also suppresses
ErrorCin cases where bothErrorBandErrorCshould be raised.Copy-paste the method and remove the check:
# In the caller module class CustomValidator(Validator): def validate(something) -> None: if a: raise ErrorA() if c: raise ErrorC()Duplicating the logic for
aandcis a bad idea and will lead to bugs ifValidatorchanges.Split the method into separate checks:
class Validator: def validate(something) -> None: self.validate_a(something) self.validate_b(something) self.validate_c(something) def validate_a(something) -> None: if a: raise ErrorA() def validate_b(something) -> None: if b: raise ErrorB() def validate_c(something) -> None: if c: raise ErrorC() # In the caller module class CustomValidator(Validator): def validate(something) -> None: super().validate_a(something) super().validate_c(something)This is just a slightly better copy-paste. If some
validate_d()is added later, we have a bug inCustomValidator.Add some suppression logic by hand:
class Validator: def validate(something, *, suppress: list[Type[Exception]] = []) -> None: if a: self._raise(ErrorA(), suppress) if b: self._raise(ErrorB(), suppress) if c: self._raise(ErrorC(), suppress) def _raise(self, e: Exception, suppress: list[Type[Exception]]) -> None: with contextlib.suppress(*suppress): raise eThis is what I'm leaning towards at the moment. There's a new optional parameter and the
raisesyntax becomes kinda ugly, but this is an acceptable cost.Add flags that disable some checks:
class Validator: def validate(something, *, check_a: bool = True, check_b: bool = True, check_c: bool = True) -> None: if check_a and a: raise ErrorA() if check_b and b: raise ErrorB() if check_c and c: raise ErrorC()This is good, because it allows to granually control different checks even if they raise the same exception.
However, it feels verbose and will require additional maintainance as
Validatorchanges. I actually have more than three checks there.Yield exceptions by value:
class Validator: def validate(something) -> Iterator[Exception]: if a: yield ErrorA() if b: yield ErrorB() if c: yield ErrorC()This is bad, because it's a breaking change for existing callers and it makes propagating the exception (the typical use) way more verbose:
# Instead of # validator.validate(something) e = next(validator.validate(something), None) if e is not None: raise eEven if we keep everything backwards-compatible
class Validator: def validate(something) -> None: e = next(self.iter_errors(something), None) if e is not None: raise e def iter_errors(something) -> Iterator[Exception]: if a: yield ErrorA() if b: yield ErrorB() if c: yield ErrorC()The new suppressing caller still needs to write all this code:
exceptions = validator.iter_errors(something) e = next(exceptions, None) if isinstance(e, ErrorB): # Skip ErrorB, don't raise it. e = next(exceptions, None) if e is not None: raise eCompared to the previous two options:
validator.validate(something, suppress=[ErrorB])validator.validate(something, check_b=False)