import collections import functools import string ALLOWED_CHARS = set(string.letters + string.digits + '_') SENTINEL = object() PARENT = {} def visitor_name(cls): node_type = getattr(cls, 'node_type', None) if node_type is None: raise ValueError(f'No node_type defined for {cls}') return f'visit_{node_type}' class NodeMeta(type): def __new__(mcs, name, bases, namespace, **kwargs): # Not sure this doesn't blow up. cls = super().__new__(mcs, name, bases, namespace, **kwargs) node_type = getattr(cls, 'node_type', SENTINEL) if node_type is not SENTINEL: if node_type is None: return cls if node_type in cls._types: raise ValueError(f'Duplicate node type: {node_type!r}') if not ALLOWED_CHARS.issuperset(node_type): raise ValueError(f'Impolite node type: {node_type!r}') cls._types.add(node_type) _visitor_name = visitor_name(cls) def dispatch(self, visitor): return getattr(visitor, _visitor_name)(self) setattr(cls.Visitor, _visitor_name, cls.Visitor.default_visit) else: cls.node_type = None cls._types = set() def dispatch(self, methods): raise NotImplementedError( 'Subclasses should auto-generate this.') class Visitor: def default_visit(self, node): raise NotImplementedError( f'No implementation for {node} on {self}.') @classmethod def bind(cls_, node_class, function=SENTINEL): if cls_ is cls.Visitor: raise ValueError('Cannot bind base Visitor class.') if function is SENTINEL: return functools.partial(cls_.bind, node_class) setattr(cls_, visitor_name(node_class), function) return cls_ @classmethod def undefined(cls_): if cls_ is cls.Visitor: raise ValueError( 'No point checking undefined on base Visitor ' 'class.') default = cls.Visitor.default_visit return {attr for attr in vars(cls.Visitor) if attr.startswith('visit_') and default == getattr(cls_, attr)} cls.Visitor = Visitor cls.dispatch = dispatch return cls class BusinessNode(metaclass=NodeMeta): __slots__ = () @property def parent(self): return PARENT.get((type(self), self)) class CachingMeta(type): def __get__(self, instance, owner): if instance is None: return functools.partial(self) else: return functools.partial(self, instance) class Caching(metaclass=CachingMeta): set_up = False def __new__(cls, instance, node): return instance.setdefault((type(node), node), super().__new__(cls)) def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) init = vars(cls).get('__init__') if init is not None: def __init__(self, *args, **kwargs): if self.set_up: return init(self, *args, **kwargs) self.set_up = True cls.__init__ = __init__ class StateVisitor(BusinessNode.Visitor, dict): __slots__ = () class UIVisitor(BusinessNode.Visitor, dict): __slots__ = 'state', def __init__(self, state): super().__init__() self.state = state class Choice(BusinessNode, collections.namedtuple('Choice', 'template')): node_type = 'choice' class ChoiceDisplay(BusinessNode, collections.namedtuple('ChoiceDisplay', 'choice')): node_type = 'choice_display' class ChoiceTaken(BusinessNode, collections.namedtuple('ChoiceTaken', 'choice')): node_type = 'choice_taken' class ChoiceListed(BusinessNode, collections.namedtuple('ChoiceListed', 'choice')): node_type = 'choice_listed' class ChoiceList(BusinessNode, collections.namedtuple('ChoiceList', 'choices initial_min initial_max')): node_type = 'choice_list' def __init__(self, choices, initial_min, initial_max): for choice in choices: key = type(choice), choice if PARENT.setdefault(key, self) != self: for choice in choices: if PARENT.get(key) is self: del PARENT[key] raise ValueError(f'Cannot double-parent a choice: {choice}') class DisplayList(BusinessNode, collections.namedtuple('DisplayList', 'factory _choices _sections')): node_type = 'display_list' def __init__(self, factory, _choices, sections): for choice in self.choices: key = type(choice), choice if PARENT.setdefault(key, self) != self: for choice in self.choices: if PARENT.get(key) is self: del PARENT[key] raise ValueError(f'Cannot double-parent a choice: {choice}') @property def choices(self): for choice in self._choices: yield self.factory(choice) @property def sections(self): # This is O(n^2), but n is small for section in self._sections: yield Section(self, section[0]) class Section(BusinessNode, collections.namedtuple('Section', 'display_list', 'name')): def __init__(self, display_list, name): self.section @property def predicate(self): return self.section[1] @property def section(self): for section in self.display_list._sections: if section[0] == self.name: return section raise ValueError( f'No section in {self.display_list} matching {self.name}') class StateBase(Caching): visible = False def __init__(self, instance, node): self.visitor = instance self.node = node @property def parent(self): if self.node.parent: return self.node.parent.dispatch(self.visitor) class BusinessButton(Caching): @property def is_valid(self): return self.enabled or not self.selected @property def visible(self): parent = self.parent return parent is not None and parent.visible class ChoiceButton(BusinessButton): @property def choice(self): return self.node.choice.dispatch(self.visitor) @StateVisitor.bind(Choice) class StateVisitor(Caching): is_valid = True selected = False @property def choice(self): return self.node @StateVisitor.bind(ChoiceDisplay) class StateVisitor(ChoiceButton): enabled = False selected = False @property def choice_display(self): return self.node @StateVisitor.bind(ChoiceTaken) class StateVisitor(ChoiceButton): @property def choice_taken(self): return self.node # Should this go on UI instead? @property def visible(self): return (super().visible and self.choice_taken.choice.dispatch(self.visitor).selected) @StateVisitor.bind(ChoiceListed) class StateVisitor(ChoiceButton): @property def choice_listed(self): return self.node @property def selected(self): return self.choice_listed.choice.dispatch(self.visitor).selected @property def enabled(self): parent = self.parent if parent: return self.selected or not parent.at_maximum return True @StateVisitor.bind(ChoiceList) class StateVisitor(Caching): def __init__(self, instance, node): super().__init__(instance, node) self.minimum = node.initial_min self.maximum = node.initial_max @property def is_valid(self): if self.maximum is not None and self.selected > self.maximum: return False return all(choice.is_valid for choice in self.choices) @property def choices(self): for choice in self.choice_list.choices: yield choice.dispatch(self.visitor) @property def selected(self): return sum(choice.selected for choice in self.choices) @property def choice_list(self): return self.node @property def at_minimum(self): return self.selected >= self.minimum @property def at_maximum(self): return self.maximum is not None and self.selected >= self.maximum @StateVisitor.bind(DisplayList) class StateVisitor(Caching): @property def display_list(self): return self.node @property def is_valid(self): return all(choice.is_valid for choice in self.choices) @property def choices(self): for choice in self.display_list.choices: yield choice.dispatch(self.visitor) @property def sections(self): for section in self.display_list.sections: yield section.dispatch(self.visitor) @StateVisitor.bind(Section) class StateVisitor(Caching): @property def section(self): return self.node @property def name(self): return self.section.name @property def choices(self): predicate = self.section.predicate for choice in self.section.display_list.choices: visited = choice.dispatch(self.visitor) if predicate(visited): yield visited