diff --git a/bauhaus/constraint_builder.py b/bauhaus/constraint_builder.py index de86bf1..4d048a6 100644 --- a/bauhaus/constraint_builder.py +++ b/bauhaus/constraint_builder.py @@ -2,6 +2,7 @@ from itertools import product, combinations from .utils import ismethod, classname, flatten from .utils import unpack_variables as unpack +from .errors import * import warnings from collections import defaultdict @@ -172,33 +173,21 @@ def build(self, propositions) -> 'NNF': # retrieve dictionary of inputs inputs = self.get_implication_inputs(propositions) if not any(inputs.values()) and not right_vars: - raise ValueError(f"The '{self}' cannot be built" - " as it is decorating a class and" - " the right implication variables are not" - " provided. If it is decorating a method," - " ensure that the method's return is" - " valid for bauhaus or for the nnf library." - " Check your decorator signature and set" - " the 'right' keyword argument to such a value.") + raise ImplicationConstraintRightConditionBuildError(self) constraints = [] for input_set in self.partition(inputs): - constraints.append(self._constraint(self, - input_set, - left_vars, - right_vars)) + constraints.append(self._constraint(self, input_set, left_vars, right_vars)) return And(constraints) inputs = self.get_inputs(propositions) if not inputs: - raise ValueError(inputs) + raise EmptyInputsError(self) constraints = [] for input_set in self.partition(inputs): if self._constraint is _ConstraintBuilder.at_most_k: - constraints.append(self._constraint(self, - input_set, - k=self._k)) + constraints.append(self._constraint(self, input_set, k=self._k)) else: constraints.append(self._constraint(self, input_set)) return And(constraints) @@ -211,7 +200,7 @@ def get_inputs(self, propositions) -> list: Encoding.propositions and return its instances. If the ConstraintBuilder does not have the '_func' attribute, - then it was invoked as a function call. + then it was directly added from a 'add_constraint' function We gather and validate its arguments (self._vars). Arguments @@ -225,14 +214,14 @@ def get_inputs(self, propositions) -> list: """ - # Constraint from function + # Constraint from direct constraint addition if not self._func: return unpack(self._vars, propositions) # Constraint from decorator else: ret = unpack([self._func], propositions) if not ret: - raise ValueError(f"The {self} resulted in an empty {ret}") + raise EmptyPropositionalVariablesFromDecorationError(self) return ret def get_implication_inputs(self, propositions) -> dict: @@ -324,7 +313,7 @@ def at_least_one(self, inputs: list) -> NNF: """ if not inputs: - raise ValueError(f"Inputs are empty for {self}") + raise EmptyInputsError(self) return Or(inputs) @@ -342,7 +331,7 @@ def at_most_one(self, inputs: list) -> NNF: """ if not inputs: - raise ValueError(f"Inputs are empty for {self}") + raise EmptyInputsError(self) clauses = [] for var in inputs: @@ -366,20 +355,19 @@ def at_most_k(self, inputs: list, k: int) -> NNF: nnf.NNF """ + if not inputs: + raise EmptyInputsError(self) if not 1 <= k <= len(inputs): - raise ValueError(f"The provided k={k} is greater" - " than the number of propositional" - f" variables (i.e. {len(inputs)} variables)" - f" for {self}.") + raise InvalidConstraintSizeK(self, k, len(inputs)) elif k == 1: return _ConstraintBuilder.at_most_one(inputs) if k >= len(inputs): - warnings.warn(f"The provided k={k} for building the at most K" - " constraint is greater than or equal to" - f" the number of variables, which is {len(inputs)}." - f" We're setting k = {len(inputs) - 1} as a result.") + warnings.warn(f"The provided k={k} for building the at most K \ + constraint is greater than or equal to the number \ + of variables, which is {len(inputs)}. We're setting \ + k = {len(inputs) - 1} as a result.") k = len(inputs) - 1 - + clauses = set() # avoid adding duplicate clauses inputs = list(map(lambda var: ~var, inputs)) # combinations from choosing k from n inputs for 1 <= k NNF: nnf.NNF: And(at_most_one, at_least_one) """ + if not inputs: + raise EmptyInputsError(self) + at_most_one = _ConstraintBuilder.at_most_one(self, inputs) at_least_one = _ConstraintBuilder.at_least_one(self, inputs) if not(at_most_one and at_least_one): - raise ValueError + raise EmptyUnderlyingConstraintError(at_least_one, at_most_one, inputs) return And({at_most_one, at_least_one}) def implies_all(self, inputs: dict, left: list, right: list) -> NNF: @@ -465,6 +456,6 @@ def none_of(self, inputs: list) -> NNF: """ if not inputs: - raise ValueError(f"Inputs are empty for {self}") + raise EmptyInputsError(self) return Or(inputs).negate() diff --git a/bauhaus/core.py b/bauhaus/core.py index a11ed5d..3b643ee 100644 --- a/bauhaus/core.py +++ b/bauhaus/core.py @@ -7,6 +7,7 @@ import warnings from .constraint_builder import _ConstraintBuilder as cbuilder from .utils import flatten, ismethod, classname +from .errors import * class Encoding: @@ -60,8 +61,8 @@ def __repr__(self) -> str: f" propositions::{self.propositions.keys()} \n" f" constraints::{self.constraints}") - def purge_propositions(self): - """ Purges the propositional variables of an Encoding object """ + def clear_propositions(self): + """ Clears the propositional variables of an Encoding object """ self.propositions = defaultdict(weakref.WeakValueDictionary) def clear_constraints(self): @@ -80,8 +81,8 @@ def add_constraint(self, constraint: nnf.NNF): cons : NNF Constraint to be added. """ - assert self._custom_constraints is not None, \ - "Error: You can't add custom_constraints when objects have overloaded one of the boolean operators." + if self._custom_constraints is not None: + raise CustomConstraintOperatorOverloadError(constraint) self._custom_constraints.add(constraint) def disable_custom_constraints(self): @@ -105,16 +106,10 @@ def compile(self, CNF=True) -> 'nnf.NNF': """ if not self.constraints and not self._custom_constraints: - raise ValueError(f"Constraints in {self} are empty." - " This can happen if no objects from" - " decorated classes are instantiated," - " if no classes/methods are decorated" - " with @constraint or no function" - " calls of the form constraint.add_method") + raise EmptyConstraintsError(self) + if not self.propositions.values(): - raise ValueError(f"Constraints in {self} are empty." - " This can happen if no objects from" - " decorated classes are instantiated.") + raise EmptyPropositionalVariablesError(self) theory = [] self.clear_debug_constraints() @@ -425,18 +420,12 @@ def _is_valid_grouby(decorated_class, parameter): True if a valid groupby, and raises an Exception if not. """ - classname = classname(decorated_class) + clsname = classname(decorated_class) if ismethod(decorated_class): - raise Exception("You can only use groupby on a class and not a method" - f", as you have tried on {decorated_class.__qualname__}." - f" Try using groupby on the {classname}" - " class instead.") + raise GroupbyOnMethodError(decorated_class.__qualname__, clsname) if not (isinstance(parameter, str) or callable(parameter)): value_type = type(parameter).__name__ - raise ValueError(f"The provided groupby value, {parameter}," - f" is of type {value_type}. To use groupby," - f" a function or object attribute (string) must be provided" - f" to partition the {classname} objects.") + raise GroupbyWithIncorrectTypeError(parameter, value_type, clsname) return True @@ -486,12 +475,7 @@ def _constraint_by_function(cls, encoding.constraints.add(constraint) return else: - raise ValueError("Some or more of your provided" - f" arguments for the {constraint_type.__name__}" - " constraint were empty or invalid. Your" - " provided arguments were: \n" - f" args: {args}, " - f" left: {left}, right: {right}") + raise ConstraintCreationError(constraint_type.__name__, args, left, right) @classmethod def _decorate(cls, @@ -817,10 +801,7 @@ def add_implies_all(encoding: Encoding, left, right): """ if not (left and right): - raise ValueError(f"You are trying to create an implies all" - " constraint without providing either the" - " left or right sides of the implication.\n" - f" Your left: {left} and right: {right}") + raise ImplicationConstraintCreationError(left, right) left = tuple(flatten([left])) right = tuple(flatten([right])) return constraint._constraint_by_function(encoding, diff --git a/bauhaus/errors.py b/bauhaus/errors.py new file mode 100644 index 0000000..aa76aee --- /dev/null +++ b/bauhaus/errors.py @@ -0,0 +1,201 @@ +""" +Errors in Bauhaus follow two principles: + +1. Catch user errors early and anticipate common mistakes. +Do user input validation as soon as possible. +Actively keep track of common mistakes that people make, +and either solve them by simplifying your API, +adding targeted error messages for these mistakes, +or having a "solutions to common issues" page in your docs. + +2. Provide detailed feedback messages upon user error. +A good error message should answer: what happened, in what context? +What did the software expect? How can the user fix it? +They should be contextual, informative, and actionable. +Every error message that transparently provides the user +with the solution to their problem means one less support ticket, +multiplied by how many times users run into the same issue. + +Credit: https://blog.keras.io/user-experience-design-for-apis.html + +""" + +class Error(Exception): + """ Base class for exceptions in Bauhaus """ + pass + +""" core.py """ +class CustomConstraintOperatorOverloadError(Error): + + def __init__(self, constraint): + self.constraint = constraint + + def __str__(self) -> str: + return "You cannot add custom_constraints when \ + objects have overloaded one of the boolean operators." + +class EmptyConstraintsError(Error): + + def __init__(self, encoding): + self.encoding = encoding + + def __str__(self) -> str: + return f"Constraints in {self.encoding} are empty. This can happen if none of the \ + decorated classes are instantiated and if no classes or methods are decorated \ + with @constraint, no direct constraint addition, and no custom constraints." + +class EmptyPropositionalVariablesError(Error): + + def __init__(self, encoding): + self.encoding = encoding + + def __str__(self) -> str: + return f"Propositional variables in {self.encoding} are empty. \ + This can happen if decorated classes are not instantiated." + +class GroupbyOnMethodError(Error): + + def __init__(self, method_name, class_name): + self.method_name = method_name + self.class_name = class_name + + def __str__(self) -> str: + return f"You can only use groupby on a class and not a method, \ + as you have tried on {self.method_name}. \ + Try using groupby on the {self.class_name} class instead." + +class GroupbyWithIncorrectTypeError(Error): + + def __init__(self, parameter, value_type, class_name): + self.parameter = parameter + self.value_type = value_type + self.class_name = class_name + + def __str__(self) -> str: + return f"The provided groupby value, {self.parameter}, \ + is of type {self.value_type}. To use groupby, \ + a function or object attribute of type string must be provided \ + to partition the {self.class_name} objects." + +class EncodingObjectError(Error): + pass + +class ConstraintCreationError(Error): + + def __init__(self, constraint, args, left, right): + self.constraint = constraint + self.args = args + self.left = left + self.right = right + + def __str__(self) -> str: + return f"Some or more of your provided arguments for the {self.constraint} \ + constraint were empty or invalid. Your provided arguments were: \ + args: {self.args}, \ + left: {self.left}, right: {self.right}" + +class InvalidAtMostKConstraint(Error): + pass + +class ImplicationConstraintCreationError(Error): + + def __init__(self, left, right): + self.left = left + self.right = right + + def __str__(self): + message = "You are trying to create an implies all \ + constraint without providing either the \ + left or right sides of the implication.\n" + return message + f'Your left: {self.left} and right: {self.right}' + +""" constraint_builder.py """ + +class ImplicationConstraintRightConditionBuildError(Error): + + def __init__(self, constraint_builder): + self.constraint_builder = constraint_builder + + def __str__(self) -> str: + return f"The '{self}' cannot be built as it is decorating a class and \ + the right implication variables are not provided. \ + If it is decorating a method, ensure that the method's return is \ + valid for bauhaus or for the nnf library. Check your decorator signature and set \ + the 'right' keyword argument to such a value." + +class EmptyPropositionalVariablesFromDecorationError(Error): + + def __init__(self, constraint_builder) -> None: + self.constraint_builder = constraint_builder + + def __str__(self) -> str: + return f"In the process of retrieving propositional variables \ + to build the constraint {self}, we found none. Since this \ + constraint is created from a decorator, check that you have \ + instantiated the decorated class {self._func}. Before compiling \ + the theory, you can check the propositional variables are created \ + after instantiation via: \ + -- \ + print(encoding.propositions) \ + -- \ + " + +class EmptyInputsError(Error): + + def __init__(self, constraint_builder) -> None: + self.constraint_builder = constraint_builder + + def __str__(self) -> str: + return f"The propositional variables are empty for {self.constraint_builder}" + +class InvalidConstraintSizeK(Error): + + def __init__(self, constraint_builder, k, inputs_length) -> None: + self.constraint_builder = constraint_builder + self.k = k + self.inputs_length = inputs_length + + def __str__(self) -> str: + return f"The provided k={self.k} is greater than the number of propositional \ + variables (i.e. {self.inputs_length} variables) for {self.constraint_builder}." + +class ConstraintBuildError(Error): + + def __init__(self, msg) -> None: + self.msg = msg + + def __str__(self) -> str: + return self.msg + +class EmptyUnderlyingConstraintError(Error): + """ Specifically for the Exactly One constraint """ + + def __init__(self, at_most_one, at_least_one, inputs) -> None: + self.at_most_one = at_most_one + self.at_least_one = at_least_one + self.inputs = inputs + + def __str__(self) -> str: + return f"The exactly one constraint is built by wrapping \ + a logical 'nnf.And' around the AtMostOne and AtLeastOne \ + constraints. One or both of these constraints are empty, therefore \ + building this constraint resulted in an error. \ + -- \ + AtMostOne: {self.at_most_one} \ + AtLeastOne: {self.at_least_one} \ + Propositional Variables: {self.inputs} \ + --" + +######### utils.py ######### + +class ConversionToNNFVariableError(Error): + + def __init__(self, input, e) -> None: + self.input = input + self.e = e + + def __str__(self) -> str: + return f"Provided input {self.input} is not of an annotated class or method, \ + instance of such as class or method, or of type nnf.Var. \ + Attempted conversion of {self.input} to nnf.Var also failed and \ + yielded the following error message: {self.e}" \ No newline at end of file diff --git a/bauhaus/utils.py b/bauhaus/utils.py index ba881fc..73e3f98 100644 --- a/bauhaus/utils.py +++ b/bauhaus/utils.py @@ -1,5 +1,6 @@ import sys import inspect +from .errors import * from nnf import Var """Utilities for bauhaus library.""" @@ -59,6 +60,7 @@ def flatten(object): Returns object immediately if not a collections object. """ + # TODO: replace instance type check with collections.abc.Iterable if not isinstance(object, (list, tuple, set)): return object for item in object: @@ -136,6 +138,7 @@ def unpack_variables(T, propositions) -> list: elif isinstance(var, Var): inputs.add(var) + # TODO: replace instance type check with collections.abc.Iterable elif isinstance(var, (list, tuple, set)): inputs.update(unpack_variables(var, propositions)) @@ -144,8 +147,6 @@ def unpack_variables(T, propositions) -> list: # convert to nnf.Var inputs.add(Var(var)) except Exception as e: - raise ValueError(f"Provided input {var} is not of an annotated class or method," - " instance of such as class or method, or of type nnf.Var." - " Attempted conversion of {var} to nnf.Var also failed and" - f" yielded the following error message: {e}") + raise ConversionToNNFVariableError(var, e) + return list(inputs)