diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -40,8 +40,4 @@ packages=find_packages('src', exclude=['contrib', 'docs', 'tests']), package_dir={'': 'src'}, - - extras_require={ - 'test': ['coverage', 'pytest'], - }, ) diff --git a/src/nobilis/lifepath.py b/src/nobilis/lifepath.py --- a/src/nobilis/lifepath.py +++ b/src/nobilis/lifepath.py @@ -1,5 +1,8 @@ import collections +import functools +from . import map_tuple +from . import mapping from . import visitor @@ -12,51 +15,133 @@ return type(item), item -class Parentable: +# This is horrendously brittle, but I don't feel like writing test cases right +# now. Basically, it needs to be used as a mixin AHEAD OF a namedtuple class +# with a first field of "hash". +class QuickHash: + + __slots__ = () + + def __new__(cls, *args, **kwargs): + my_hash = cls._pre_hash(*args, **kwargs) + return super().__new__(cls, my_hash, *args, **kwargs) + + @classmethod + def _pre_hash(cls, *args, **kwargs): + args = tuple(args) + fields = getattr(cls, '_fields', ('hash',))[1:] + keywords = fields[len(args):] + args += tuple((kwargs[keyword] for keyword in keywords)) + return hash(args) + + def __hash__(self): + return self.hash + + +QH = QuickHash + + +class Child(Business): __slots__ = () + def default_visit(self, node): + yield from () -P = Parentable + +CHILD = Child() + + +class Tree(QH, nt('Tree', 'hash root reverse_mapping')): + + __slots__ = () + + def __new__(cls, root): + reverse_mapping = {} + nodes_to_add = [root] + while nodes_to_add: + node = nodes_to_add.pop() + for child in CHILD.visit(node): + child_key = key(child) + if child_key in reverse_mapping: + raise ValueError(f'{root} is not a valid tree!') + else: + reverse_mapping[child_key] = node + nodes_to_add.append(child) + return super().__new__(cls, root, mapping.Map(reverse_mapping)) + + def parent(self, node): + return self.reverse_mapping.get(key(node)) @Business.mark -class Choice(P, nt('Choice', 'template')): - - __slots__ = () - - -@Business.mark -class ChoiceDisplay(P, nt('ChoiceDisplay', 'choice')): +class Choice(QH, nt('Choice', 'hash template')): __slots__ = () @Business.mark -class ChoiceTaken(P, nt('ChoiceTaken', 'choice')): +class ChoiceDisplay(QH, nt('ChoiceDisplay', 'hash choice')): __slots__ = () @Business.mark -class ChoiceListed(P, nt('ChoiceListed', 'choice')): +class ChoiceTaken(QH, nt('ChoiceTaken', 'hash choice')): + + __slots__ = () + + +@Business.mark +class ChoiceListed(QH, nt('ChoiceListed', 'hash choice')): + + __slots__ = () + + +@Business.mark +class ChoiceList(QH, nt('ChoiceList', 'hash choices initial_min initial_max')): __slots__ = () @Business.mark -class ChoiceList(P, nt('ChoiceList', 'choices initial_min initial_max')): +class DisplayList(QH, nt('DisplayList', 'hash factory choices_ sections_')): __slots__ = () + @property + def choices(self): + return map_tuple.MapTuple(self.factory, self.choices_) + + @property + def sections(self): + return map_tuple.MapTuple( + functools.partial(Section, self), range(len(self.sections_))) + @Business.mark -class DisplayList(P, nt('DisplayList', 'factory choices_ sections_')): +class Section(QH, nt('Section', 'hash display_list index')): __slots__ = () + @property + def _data(self): + return self.display_list.sections_[self.index] -@Business.mark -class Section(P, nt('Section', 'display_list name')): + @property + def name(self): + return self._data[0] + + @property + def predicate(self): + return self._data[1] + - __slots__ = () +@Child.bind(ChoiceList) +def Child(self, node, next_visitor): + yield from node.choices + + +@Child.bind(DisplayList) +def Child(self, node, next_visitor): + yield from node.choices diff --git a/src/nobilis/map_tuple.py b/src/nobilis/map_tuple.py new file mode 100644 --- /dev/null +++ b/src/nobilis/map_tuple.py @@ -0,0 +1,16 @@ +import collections + + +class MapTuple(collections.Sequence): + + __slots__ = '_function', '_sequence' + + def __init__(self, function, sequence): + self._function = function + self._sequence = sequence + + def __len__(self): + return len(self._sequence) + + def __getitem__(self, index): + return self._function(self._sequence[index]) diff --git a/src/nobilis/mapping.py b/src/nobilis/mapping.py new file mode 100644 --- /dev/null +++ b/src/nobilis/mapping.py @@ -0,0 +1,28 @@ +import collections + + +class Map(collections.abc.Mapping): + + __slots__ = '_dict', '_hash' + + def __init__(*args, **kwargs): + try: + self, *args = args + except ValueError: + raise TypeError + self._hash = None + self._dict = dict(*args, **kwargs) + + def __hash__(self): + if self._hash is None: + self._hash = hash(frozenset(self._dict.items())) + return self._hash + + def __getitem__(self, key): + return self._dict[key] + + def __iter__(self): + return iter(self._dict) + + def __len__(self): + return len(self._dict) diff --git a/src/nobilis/visitor.py b/src/nobilis/visitor.py --- a/src/nobilis/visitor.py +++ b/src/nobilis/visitor.py @@ -19,6 +19,8 @@ """Return a base Visitor class.""" class Visitor: + __slots__ = () + __classes = set() def __new__(cls, *args, **kwargs): diff --git a/tests/nobilis/conftest.py b/tests/nobilis/conftest.py --- a/tests/nobilis/conftest.py +++ b/tests/nobilis/conftest.py @@ -17,3 +17,15 @@ def lifepath(): import nobilis.lifepath return nobilis.lifepath + + +@pytest.fixture +def mapping(): + import nobilis.mapping + return nobilis.mapping + + +@pytest.fixture +def map_tuple(): + import nobilis.map_tuple + return nobilis.map_tuple diff --git a/tests/nobilis/test_lifepath.py b/tests/nobilis/test_lifepath.py --- a/tests/nobilis/test_lifepath.py +++ b/tests/nobilis/test_lifepath.py @@ -1,3 +1,6 @@ +import pytest + + def test_import(lifepath): assert lifepath @@ -5,3 +8,108 @@ def test_key(lifepath): assert lifepath.key(lifepath.Choice(None)) != lifepath.key( lifepath.ChoiceDisplay(None)) + + +def test_hash_amortization(lifepath): + # This is a little tricky. Basically, I don't want hashing a recursive type + # to hit the leaves repeatedly. Of course, a leaf has to be hashed at least + # once. But let's give this a shot. + class TrackingChoice(lifepath.Choice): + + hash_count = 0 + + def __hash__(self): + self.hash_count += 1 + return super().__hash__() + + leaf = TrackingChoice(None) + assert leaf.hash_count == 0 + root = lifepath.Choice(leaf) + assert leaf.hash_count <= 1 + for _ in range(3): + hash(root) + assert leaf.hash_count <= 1 + + +# So, my current plan for setting up the "parent" relationship is to lean on +# the visitors, in order to avoid putting more logic onto the classes +# themselves. Basically, each class defines its own concept of "children", and +# there's a visitor or a protocol that defines and enforces the reverse +# relationship. I think a non-visitor would be fine, since it simply has to +# consume the child visitor, which defaults to yield from (), and defines +# actual behavior for specific classes. + +# To speed up construction of the parent graph, each component could cache a +# mapping of its data, which could be pulled in directly. What's a good dict +# idiom for "update, but fail on collision"? + +# Here's something I think would make sense: rather than a global cache, the +# "parent" instances have an OrderedDict attribute that they share when +# creating other instances from themselves. The OrderedDict is accessed through +# synchronized functions, that pop and push the requested value. When it's +# larger than some threshold, the first few entries are removed. This is like +# an LRU, but with shared state rather than global state, so it's more +# testable. Have a wrapper class in its own module, just for managing this +# stuff. + +# Actually, given all that, I could just make it an instance attribute that's +# an LRU. That seems pretty simple. + +# But for now, I'll just construct the dictionary per-instance, no funny stuff. + + +def test_children(lifepath): + child = lifepath.Child() + + leaf = lifepath.Choice(None) + + assert tuple(child.visit(leaf)) == () + + for wrapper in (lifepath.ChoiceDisplay, lifepath.ChoiceTaken, + lifepath.ChoiceListed): + assert tuple(child.visit(wrapper(leaf))) == () + + assert tuple(child.visit(lifepath.ChoiceList((leaf,), 0, None))) == (leaf,) + + display_list = lifepath.DisplayList( + lifepath.ChoiceDisplay, (leaf,), (('name', None),)) + + assert tuple(child.visit(display_list)) == (lifepath.ChoiceDisplay(leaf),) + + +def test_tree(lifepath): + leaf = lifepath.Choice(None) + root = lifepath.ChoiceList((leaf,), 0, None) + tree = lifepath.Tree(root) + assert tree.parent(leaf) == root + + rooter = lifepath.ChoiceList((root,), 0, None) + + treeer = lifepath.Tree(rooter) + assert treeer.parent(root) == rooter + assert treeer.parent(leaf) == root + + dag = lifepath.ChoiceList((leaf, leaf), 0, None) + + with pytest.raises(ValueError): + print(lifepath.Tree(dag)) + + +def test_section(lifepath): + leaf = lifepath.Choice(None) + sentinel = object() + + display_list = lifepath.DisplayList( + lifepath.ChoiceDisplay, (leaf,), (('name', sentinel),)) + + section, = display_list.sections + + assert section.name == 'name' + assert section.predicate is sentinel + + +def test_state(lifepath): + # Okay, so, let's think how this has to go. + # Ideally, I wouldn't be relying on mutable state, so... + # state = lifepath.State() + pass diff --git a/tests/nobilis/test_map_tuple.py b/tests/nobilis/test_map_tuple.py new file mode 100644 --- /dev/null +++ b/tests/nobilis/test_map_tuple.py @@ -0,0 +1,12 @@ +def test_import(map_tuple): + assert map_tuple + + +def test_class(map_tuple): + assert map_tuple.MapTuple + + +def test_basic(map_tuple): + my_tuple = map_tuple.MapTuple(str.upper, ('abc',)) + assert len(my_tuple) == 1 + assert my_tuple[0] == 'ABC' diff --git a/tests/nobilis/test_mapping.py b/tests/nobilis/test_mapping.py new file mode 100644 --- /dev/null +++ b/tests/nobilis/test_mapping.py @@ -0,0 +1,25 @@ +import pytest + + +def test_import(mapping): + assert mapping + + +def test_map(mapping): + assert not mapping.Map() + assert not mapping.Map({}) + + assert mapping.Map() == mapping.Map({}) + + assert mapping.Map(self=1) == mapping.Map(self=1) + assert {mapping.Map()} + a_mapping = mapping.Map() + dct = {a_mapping: a_mapping} + assert dct[a_mapping] == a_mapping + + +def test_a_weird_edge(mapping): + with pytest.raises(TypeError): + print(dict.__init__()) + with pytest.raises(TypeError): + print(mapping.Map.__init__()) diff --git a/tests/nobilis/test_visitor.py b/tests/nobilis/test_visitor.py --- a/tests/nobilis/test_visitor.py +++ b/tests/nobilis/test_visitor.py @@ -147,3 +147,43 @@ return 'd' + next_visitor('e') assert Visitor2().visit(Test2()) == 'abcde' + + +def test_next_visitor_as_generator(visitor): + Visitor = visitor.make_visitor() + + @Visitor.mark + class Test1: + pass + + @Visitor.mark + class Test2(Test1): + pass + + class Visitor1(Visitor): + pass + + class Visitor2(Visitor1): + pass + + @Visitor2.bind(Test2) + def Visitor2(self, node, next_visitor): + yield 'a' + yield from next_visitor('f') + + @Visitor1.bind(Test2) + def Visitor1(self, node, next_visitor): + yield 'b' + yield from next_visitor('f') + + @Visitor2.bind(Test1) + def Visitor2(self, node, next_visitor): + yield 'c' + yield from next_visitor('f') + + @Visitor1.bind(Test1) + def Visitor1(self, node, next_visitor): + yield 'd' + yield from next_visitor('e') + + assert ''.join(Visitor2().visit(Test2())) == 'abcde'