From b93094b89000498f94313a5cf99ba267800516ce Mon Sep 17 00:00:00 2001 From: Josh Bedwell Date: Sat, 27 Apr 2024 17:21:30 -0500 Subject: [PATCH 1/5] Made progress on consolidating all models. --- django_hierarchical_models/models/__init__.py | 13 +- django_hierarchical_models/models/alm.py | 66 - .../models/exceptions.py | 19 - .../models/hierarchical_model.py | 76 ++ .../models/interface.py | 436 ------- django_hierarchical_models/models/nsm.py | 339 ------ django_hierarchical_models/models/pem.py | 81 -- tests/alm_test.py | 21 - tests/hm_test.py | 858 +++++++++++++ tests/interface_test.py | 1072 ----------------- tests/models.py | 30 +- tests/nsm_test.py | 381 ------ 12 files changed, 940 insertions(+), 2452 deletions(-) delete mode 100644 django_hierarchical_models/models/alm.py create mode 100644 django_hierarchical_models/models/hierarchical_model.py delete mode 100644 django_hierarchical_models/models/interface.py delete mode 100644 django_hierarchical_models/models/nsm.py delete mode 100644 django_hierarchical_models/models/pem.py delete mode 100644 tests/alm_test.py create mode 100644 tests/hm_test.py delete mode 100644 tests/interface_test.py delete mode 100644 tests/nsm_test.py diff --git a/django_hierarchical_models/models/__init__.py b/django_hierarchical_models/models/__init__.py index 871e747..1226d84 100644 --- a/django_hierarchical_models/models/__init__.py +++ b/django_hierarchical_models/models/__init__.py @@ -1,14 +1,9 @@ -from django_hierarchical_models.models.alm import AdjacencyListModel -from django_hierarchical_models.models.interface import HierarchicalModelInterface +from django_hierarchical_models.models.exceptions import CycleException +from django_hierarchical_models.models.hierarchical_model import HierarchicalModel from django_hierarchical_models.models.node import Node -from django_hierarchical_models.models.nsm import NestedSetModel -from django_hierarchical_models.models.pem import PathEnumerationModel __all__ = ( - "AdjacencyListModel", - "NestedSetModel", - "PathEnumerationModel", - "HierarchicalModelInterface", + "HierarchicalModel", "Node", - "exceptions", + "CycleException", ) diff --git a/django_hierarchical_models/models/alm.py b/django_hierarchical_models/models/alm.py deleted file mode 100644 index 39a5ee4..0000000 --- a/django_hierarchical_models/models/alm.py +++ /dev/null @@ -1,66 +0,0 @@ -from typing import TypeVar - -from django.db import models -from django.db.models import QuerySet - -from django_hierarchical_models.models.interface import HierarchicalModelInterface - -T = TypeVar("T", bound="AdjacencyListModel") - - -class AdjacencyListModel(HierarchicalModelInterface): - """Adjacency List Model implementation of HierarchicalModel. - - Class description here. - - """ - - # ------------------------ class members -------------------------------- # - - _parent = models.ForeignKey( - "self", on_delete=models.SET_NULL, blank=True, null=True - ) - - # ------------------------ builtin methods ------------------------------ # - - def __init__(self, *args, **kwargs): - if len(args) == 0 and "parent" in kwargs: - kwargs["_parent"] = kwargs.pop("parent") - super().__init__(*args, **kwargs) - - # ------------------------ override models.Model ------------------------ # - - class Meta: - abstract = True - - # ------------------------ override HierarchicalModel ------------------- # - - def parent(self: T) -> T | None: - self.refresh_from_db(fields=("_parent",)) - return self._parent # type: ignore - - def is_child_of(self: T, parent: T) -> bool: - self_parent = self._parent - while self_parent is not None: - if self_parent == parent: - return True - self_parent = self_parent._parent - return False - - def _set_parent(self: T, parent: T | None): - self._parent = parent - self.save(update_fields=["_parent"]) - - def set_parent_unchecked(self: T, parent: T | None): - """Sets the parent of this instance without checking for cycles.""" - self._set_parent(parent) - - def direct_children(self: T) -> QuerySet[T]: - return self._manager.filter(_parent=self) - - def root(self: T) -> T: - root = self - self.refresh_from_db(fields=("_parent",)) - while root._parent is not None: - root = root._parent # type: ignore - return root diff --git a/django_hierarchical_models/models/exceptions.py b/django_hierarchical_models/models/exceptions.py index b1a89e1..9e9d2c9 100644 --- a/django_hierarchical_models/models/exceptions.py +++ b/django_hierarchical_models/models/exceptions.py @@ -1,22 +1,3 @@ -class AlreadyHasParentException(Exception): - def __init__(self, child, *args): - super().__init__(*args) - self.child = child - - def __str__(self): - return f"{self.child} already has a parent" - - -class NotAChildException(Exception): - def __init__(self, parent, child, *args): - super().__init__(*args) - self.parent = parent - self.child = child - - def __str__(self): - return f"{self.child} is not a child of {self.parent}" - - class CycleException(Exception): def __init__(self, parent, child, *args): super().__init__(*args) diff --git a/django_hierarchical_models/models/hierarchical_model.py b/django_hierarchical_models/models/hierarchical_model.py new file mode 100644 index 0000000..5db05b2 --- /dev/null +++ b/django_hierarchical_models/models/hierarchical_model.py @@ -0,0 +1,76 @@ +from typing import TypeVar + +from django.db import models +from django.db.models import Manager, QuerySet + +from django_hierarchical_models.models.exceptions import CycleException +from django_hierarchical_models.models.node import Node + +T = TypeVar("T", bound="HierarchicalModel") + + +class HierarchicalModel(models.Model): + + _parent = models.ForeignKey( + "self", on_delete=models.SET_NULL, blank=True, null=True + ) + + def __init__(self, *args, **kwargs): + if len(args) == 0 and "parent" in kwargs: + kwargs["_parent"] = kwargs.pop("parent") + super().__init__(*args, **kwargs) + + class Meta: + abstract = True + + def parent(self: T) -> T | None: + return self._parent + + def set_parent(self: T, parent: T | None, unchecked: bool = False): + if ( + not unchecked + and parent is not None + and (parent == self or parent.is_child_of(self)) + ): + raise CycleException(parent, self) + self._parent = parent + self.save(update_fields=("_parent",)) + + def is_child_of(self: T, parent: T) -> bool: + ancestor = self._parent + while ancestor is not None: + if ancestor == parent: + return True + ancestor = ancestor._parent + return False + + def root(self: T) -> T: + root = self + while root._parent is not None: + root = root._parent + return root + + def ancestors( + self: T, + max_level: int | None = None, + ) -> list[T]: # TODO more generic type hint? also this can be cleaned up + if max_level is None: + max_level = -1 + ancestors = [] + ancestor = self._parent + while ancestor is not None and max_level != 0: + ancestors.append(ancestor) + ancestor = ancestor._parent + max_level -= 1 + return ancestors + + def direct_children( + self: T, + object_manager: Manager[T] | None = None, + ) -> QuerySet[T]: + if object_manager is None: + object_manager = self.__class__._default_manager + return object_manager.filter(_parent=self) + + def children(self: T) -> Node[T]: + raise NotImplementedError() diff --git a/django_hierarchical_models/models/interface.py b/django_hierarchical_models/models/interface.py deleted file mode 100644 index 5daf52d..0000000 --- a/django_hierarchical_models/models/interface.py +++ /dev/null @@ -1,436 +0,0 @@ -from __future__ import annotations - -from collections import deque -from collections.abc import Callable -from typing import TypeVar - -from django.db import models -from django.db.models import QuerySet -from django.db.models.manager import BaseManager - -from django_hierarchical_models.models.exceptions import ( - AlreadyHasParentException, - CycleException, - NotAChildException, -) -from django_hierarchical_models.models.node import Node - -T = TypeVar("T", bound="HierarchicalModelInterface") - - -class HierarchicalModelInterface(models.Model): - """Django Model with support for hierarchical data. - - This interface is implemented multiple times with different tradeoffs. - Because of the advantages of some implementations, they might have methods - available which are not available on the interface. - """ - - # ------------------------ override models.Model ------------------------ # - - class Meta: - abstract = True - - # ------------------------ public abstract methods ---------------------- # - - def parent(self: T) -> T | None: - """The parent of the HierarchicalModel instance. - - Can trigger a `refresh_from_db` for certain internal fields. - - Returns: - Parent instance, or None if there is no parent. - """ - raise NotImplementedError() - - def is_child_of(self: T, parent: T) -> bool: - """Checks if the instance is at any level a child to the parent.""" - raise NotImplementedError() - - def direct_children(self: T) -> QuerySet[T]: - """Gets all the direct descendants of a model. - - If there are no children the QuerySet will be emtpy. - - In the course of finding all direct children, some models may have to - evaluate QuerySets, others might not. - - Returns: - An unordered QuerySet of all direct children of this model. - """ - raise NotImplementedError() - - # ------------------------ public class methods ------------------------- # - # the following models are default implementations which might be - # overridden by different HierarchicalModel implementations - - def set_parent(self: T, parent: T | None): - """Assigns the given model as the parent. - - Due to the trouble that cycles pose to these data structures, a check - is made if the parent is already a child to this model in any way. - - Some models in which it is possible to represent a cycle have a - .set_parent_unchecked() method which will skip this step. That method - should be used with great care since it can be extremely difficult to - repair models that have cycles. - - Raises: - CycleException: A cycle would be formed by this operation - skipped. - """ - if parent is not None and (parent == self or parent.is_child_of(self)): - raise CycleException(parent, self) - self._set_parent(parent) - - def create_child( - self: T, create_method: Callable[..., T] | None = None, **kwargs - ) -> T: - """A convenience method for creating a child model. - - Adds parent=self to the creation of the instance. The instance will be - saved, unless a different create_method is passed. - - Args: - create_method: Defaults to .objects.create() - kwargs: Regular kwargs for creating an instance of this model. - - Returns: - New model instance. - """ - if create_method is None: - create_method = self._manager.create - return create_method(parent=self, **kwargs) - - def add_child(self: T, child: T, check_has_parent: bool = False): - """Adds child to the children of this instance. - - Args: - child: The child to be added to this instance. - check_has_parent: If true, an exception is raised when the child - already has a parent. - - Raises: - AlreadyHasParentException: child already has a parent. - """ - if check_has_parent and child.parent() is not None: - raise AlreadyHasParentException(child) - child.set_parent(self) - - def remove_child(self: T, child: T, check_is_child: bool = False): - """Removes the child from the instance's children. - - Args: - child: A direct child to be removed (no grandchildren). - check_is_child: Throw an exception if the child is not a direct - child. - - Raises: - NotAChildException: The given child is not a child of this instance. - """ - if child.parent() == self: - child._set_parent(None) - elif check_is_child: - raise NotAChildException(self, child) - - def ancestors(self: T, max_level: int | None = None) -> list[T]: - """Returns an ordered list of the model's ancestors. - - Args: - max_level: If a max level is given, the function will stop after - that many levels. - - Returns: - An ordered list of the instance's ancestors, starting with the - closest on the left side of the list, and the root at the right. - """ - - parent = self.parent() - if parent is None or (max_level is not None and max_level <= 0): - return [] - if max_level is not None: - max_level -= 1 - return [parent] + parent.ancestors(max_level=max_level) - - def root(self: T) -> T: - """Gives the root of the node. - - Returns: - Returns the first parent with no parent. - """ - parent = self.parent() - if parent is None: - return self - return parent.root() - - def children( - self: T, - max_generations: int | None = None, - max_siblings: int | None = None, - max_total: int | None = None, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None = None, - ) -> Node[T]: - """A structured children representation. - - This function performs a breadth first search of the children of the - given model instance. This search can be highly configured by the - parameters and transform. - - Args: - max_generations: If provided, the maximum depth to search for - children. - max_siblings: If provided, the maximum number of children to evaluate - from each node. - max_total: If provided, the maximum nodes that will be evaluated. - - Returns: - A Node structure, each containing an ordered list of the children - of that Node. - """ - root = Node[T](self) - if ( - (max_generations is not None and max_generations < 1) - or (max_siblings is not None and max_siblings < 1) - or (max_total is not None and max_total < 2) - ): - return root - self._child_finder( - root, - max_generations or -1, - max_siblings or -1, - max_total or -1, - sibling_transform, - ) - return root - - # ------------------------ private class methods ------------------------ # - - def _child_finder( - self: T, - root: Node[T], - max_generations: int, - max_siblings: int, - max_total: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, - ): - """Evaluates the children of the given node. - - If a function were to be overridden for finding children, this would be - the one. - - This version uses the optional parameters get a function from a - dispatch table. The functions in this dispatch table use a queue to do - a BFS on the given root node. - """ - f = _dispatch_table[ - ( - max_generations > -1, - max_siblings > -1, - max_total > -1, - ) - ] - f(root, max_generations, max_siblings, max_total, sibling_transform) - - @property - def _manager(self: T) -> BaseManager[T]: - """Convenience method to get the object manager at runtime.""" - return self.__class__._default_manager - - # ------------------------ private abstract methods --------------------- # - - def _set_parent(self: T, parent: T | None): - """The actual mechanism of setting the parent. - - No checks for cycles or anything happen in this function. - - Args: - parent: The parent to be set to. - """ - raise NotImplementedError() - - # ------------------------ dispatch functions --------------------------- # - - -def _no_no_no( - root: Node[T], - _: int, - __: int, - ___: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T]]] = deque() - queue.append((Node(None), root)) # type: ignore - while queue: - parent_node, node = queue.popleft() - parent_node.children.append(node) - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children: - child_node = Node[T](child) - queue.append((node, child_node)) - - -def _yes_no_no( - root: Node[T], - max_generations: int, - _: int, - __: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T], int]] = deque() - queue.append((Node(None), root, 0)) # type: ignore - while queue: - parent_node, node, generation = queue.popleft() - parent_node.children.append(node) - if generation < max_generations: - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children: - child_node = Node[T](child) - queue.append((node, child_node, generation + 1)) - - -def _no_yes_no( - root: Node[T], - _: int, - max_siblings: int, - __: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T]]] = deque() - queue.append((Node(None), root)) # type: ignore - while queue: - parent_node, node = queue.popleft() - parent_node.children.append(node) - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children[:max_siblings]: - child_node = Node[T](child) - queue.append((node, child_node)) - - -def _no_no_yes( - root: Node[T], - _: int, - __: int, - max_total: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T]]] = deque() - queue.append((Node(None), root)) # type: ignore - max_total -= 1 - while queue: - parent_node, node = queue.popleft() - parent_node.children.append(node) - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children[:max_total]: - child_node = Node[T](child) - queue.append((node, child_node)) - max_total -= 1 - - -def _yes_yes_no( - root: Node[T], - max_generations: int, - max_siblings: int, - _: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T], int]] = deque() - queue.append((Node(None), root, 0)) # type: ignore - while queue: - parent_node, node, generation = queue.popleft() - parent_node.children.append(node) - if generation < max_generations: - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children[:max_siblings]: - child_node = Node[T](child) - queue.append((node, child_node, generation + 1)) - - -def _yes_no_yes( - root: Node[T], - max_generations: int, - _: int, - max_total: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T], int]] = deque() - queue.append((Node(None), root, 0)) # type: ignore - max_total -= 1 - while queue: - parent_node, node, generation = queue.popleft() - parent_node.children.append(node) - if generation < max_generations: - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children[:max_total]: - child_node = Node[T](child) - queue.append((node, child_node, generation + 1)) - max_total -= 1 - - -def _no_yes_yes( - root: Node[T], - _: int, - max_siblings: int, - max_total: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T]]] = deque() - queue.append((Node(None), root)) # type: ignore - max_total -= 1 - while queue: - parent_node, node = queue.popleft() - parent_node.children.append(node) - num_siblings = min(max_siblings, max_total) - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children[:num_siblings]: - child_node = Node[T](child) - queue.append((node, child_node)) - max_total -= 1 - - -def _yes_yes_yes( - root: Node[T], - max_generations: int, - max_siblings: int, - max_total: int, - sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None, -): - queue: deque[tuple[Node[T], Node[T], int]] = deque() - queue.append((Node(None), root, 0)) # type: ignore - max_total -= 1 - while queue: - parent_node, node, generation = queue.popleft() - parent_node.children.append(node) - if generation < max_generations: - num_siblings = min(max_siblings, max_total) - children = node.instance.direct_children() - if sibling_transform is not None: - children = sibling_transform(children) - for child in children[:num_siblings]: - child_node = Node[T](child) - queue.append((node, child_node, generation + 1)) - max_total -= 1 - - -_dispatch_table = { - (False, False, False): _no_no_no, - (True, False, False): _yes_no_no, - (False, True, False): _no_yes_no, - (False, False, True): _no_no_yes, - (True, True, False): _yes_yes_no, - (True, False, True): _yes_no_yes, - (False, True, True): _no_yes_yes, - (True, True, True): _yes_yes_yes, -} diff --git a/django_hierarchical_models/models/nsm.py b/django_hierarchical_models/models/nsm.py deleted file mode 100644 index 207767b..0000000 --- a/django_hierarchical_models/models/nsm.py +++ /dev/null @@ -1,339 +0,0 @@ -from typing import TypeVar - -from django.db import models -from django.db.models import F, Max, QuerySet - -from django_hierarchical_models.models.interface import HierarchicalModelInterface - -T = TypeVar("T", bound="NestedSetModel") - - -class NestedSetModel(HierarchicalModelInterface): - """Nested Set Model implementation of HierarchicalModel. - - Each model has two integer fields, a left and a right. In NSM, if a model's - left value is lower than another, and it's right value is higher, the - second model is encapsulated by the first, and it is a child. - - This implementation is very efficient for querying, but not so efficient - for editing. It is possible for every single left and right value in the - database to be decremented by one operation. This is countered by the - excellent query performance of this model. - - Attributes: - _left: holds the left bound for this instance. - _right: holds the right bound for this instance. - """ - - # ------------------------ class members -------------------------------- # - - _left = models.PositiveIntegerField() - _right = models.PositiveIntegerField() - - # ------------------------ builtin methods ------------------------------ # - - def __init__(self, *args, **kwargs): - if len(args) == 0: - if "parent" in kwargs: - parent = kwargs.pop("parent") - parent.refresh_from_db(fields=("_left", "_right")) - kwargs["_left"] = parent._right - kwargs["_right"] = parent._right + 1 - right_of_parent = self._manager.filter(_left__gt=parent._right) - self._shift_chunk(right_of_parent, 2, 2) - parent._right += 2 - parent.save(update_fields=["_right"]) - else: - right_most_value = self._manager.aggregate(Max("_right"))["_right__max"] - if right_most_value is None: - right_most_value = -1 - kwargs["_left"] = right_most_value + 1 - kwargs["_right"] = right_most_value + 2 - super().__init__(*args, **kwargs) - - # ------------------------ override models.Model ------------------------ # - - class Meta: - abstract = True - - def delete(self, using=None, keep_parents=False): - """Necessary to "free up" space.""" - root = self.root() - dist_to_root = root._right - self._left - children_space = self._right - self._left - 1 - - parents_chunk = self._manager.filter( - _left__lt=self._left, _right__gt=self._right - ) - num_parents = len(parents_chunk) - - siblings_chunk = self._manager.filter( - _left__gt=self._right, _right__lt=root._right - ) - to_right_chunk = self._manager.filter(_left__gt=self._right) - children = ( - child.pk - for child in self._manager.filter( - _left__gt=self._left, _right__lt=self._right - ) - ) - - self._shift_chunk(siblings_chunk, -2 - children_space, -2 - children_space) - self._shift_chunk(parents_chunk, 0, -2 - children_space) - self._shift_chunk(to_right_chunk, -2, -2) - - children_chunk = self._manager.filter(pk__in=children) - self._shift_chunk( - children_chunk, - dist_to_root - num_parents - 1 - children_space, - dist_to_root - num_parents - 1 - children_space, - ) - - super().delete(using=using, keep_parents=keep_parents) - - # ------------------------ override HierarchicalModel ------------------- # - - def parent(self: T) -> T | None: - """The parent of the AdjacencyModel instance. - - Triggers a partial refresh_from_db() - - Queries for models with lower _left and higher _right values, then - returns the one with the highest _left value, meaning the one closest - to this instance. - - Returns: - Parent instance, or None if there is no parent. - """ - - self.refresh_from_db(fields=("_left", "_right")) - return ( - self._manager.filter(_left__lt=self._left, _right__gt=self._right) - .order_by("-_left") - .first() - ) - - def is_child_of(self: T, parent: T) -> bool: - """Checks if the instance is at any level a child to the parent. - - The only test to do where is whether the parent's _left and _right are - smaller and larger than the values of this instance. - """ - - self.refresh_from_db(fields=("_left",)) - parent.refresh_from_db(fields=("_left", "_right")) - return parent._left < self._left < parent._right - - def _set_parent(self: T, parent: T | None): - """Assigns the given model as the parent. - - This method tries to find the direction to shift models that will result - in the least database updates. After that, it is a matter of which - models are having their _left and _right members shifted by how much. - - Due to the trouble that cycles pose to these data structures, a check - is made if the parent is already a child to this model in any way. - - Some models in which it is possible to represent a cycle have a - .set_parent_unchecked() method which will skip this step. That method - should be used with great care since it can be extremely difficult to - repair models that have cycles. - - Raises: - CycleException: A cycle would be formed by this operation - skipped. - """ - if parent == self.parent(): - # left and right were updated by the call to parent() - return - - if parent is not None: - parent.refresh_from_db(fields=("_left", "_right")) - - self_chunk_size = self._right - self._left + 1 - self_chunk_items = ( - item.pk - for item in self._manager.filter( - _left__gte=self._left, _right__lte=self._right - ) - ) - - self_shift: tuple[int, int] - between_shift: tuple[QuerySet[T], int, int] - skin_shift: tuple[QuerySet[T], int, int] - - if parent is None: - # need to be orphaned - root = self.root() - dist_to_left = self._left - root._left - dist_to_right = root._right - self._right - if dist_to_left < dist_to_right: - self_shift = -dist_to_left, -dist_to_left - between_shift = ( - self._manager.filter(_left__gt=root._left, _right__lt=self._left), - self_chunk_size, - self_chunk_size, - ) - skin_shift = ( - self._manager.filter( - _left__gte=root._left, - _left__lt=self._left, - _right__gt=self._left, - ), - self_chunk_size, - 0, - ) - else: - self_shift = dist_to_right, dist_to_right - between_shift = ( - self._manager.filter(_left__gt=self._right, _right__lt=root._right), - -self_chunk_size, - -self_chunk_size, - ) - skin_shift = ( - self._manager.filter( - _right__gt=self._right, - _right__lte=root._right, - _left__lt=self._right, - ), - 0, - -self_chunk_size, - ) - elif self._left > parent._right: - # on the right of the parent - self_shift = -(self._left - parent._right), -(self._left - parent._right) - between_shift = ( - self._manager.filter(_left__gt=parent._right, _right__lt=self._left), - self_chunk_size, - self_chunk_size, - ) - skin_shift = ( - self._manager.filter( - _right__lt=self._left, - _right__gte=parent._right, - _left__lt=parent._right, - ), - 0, - self_chunk_size, - ) - elif self._right < parent._left: - # on the left of the parent - self_shift = parent._left - self._right, parent._left - self._right - between_shift = ( - self._manager.filter(_left__gt=self._right, _right__lt=parent._left), - -self_chunk_size, - -self_chunk_size, - ) - skin_shift = ( - self._manager.filter( - _left__gt=self._right, - _left__lte=parent._left, - _right__gt=parent._left, - ), - -self_chunk_size, - 0, - ) - else: - # already grandchild of parent - dist_to_left = self._left - parent._left - dist_to_right = parent._right - self._right - if dist_to_left < dist_to_right: - self_shift = -dist_to_left + 1, -dist_to_left + 1 - between_shift = ( - self._manager.filter(_left__gt=parent._left, _right__lt=self._left), - self_chunk_size, - self_chunk_size, - ) - skin_shift = ( - self._manager.filter( - _left__gt=parent._left, - _left__lt=self._left, - _right__gt=self._left, - ), - self_chunk_size, - 0, - ) - else: - self_shift = dist_to_right - 1, dist_to_right - 1 - between_shift = ( - self._manager.filter( - _right__lt=parent._right, _left__gt=self._right - ), - -self_chunk_size, - -self_chunk_size, - ) - skin_shift = ( - self._manager.filter( - _right__lt=parent._right, - _right__gt=self._right, - _left__lt=self._right, - ), - 0, - -self_chunk_size, - ) - - self._shift_chunk(*between_shift) - self._shift_chunk(*skin_shift) - self_chunk = self._manager.filter(pk__in=self_chunk_items) - self._shift_chunk(self_chunk, *self_shift) - - def ancestors(self: T, max_level: int | None = None) -> list[T]: - self.refresh_from_db(fields=("_left", "_right")) - ancestors = self._manager.filter( - _left__lt=self._left, _right__gt=self._right - ).order_by("-_left") - if max_level is not None: - ancestors = ancestors[:max_level] - return list(ancestors) - - def direct_children(self: T) -> QuerySet[T]: - """Gets all the direct descendants of a model. - - If there are no children the QuerySet will be emtpy. - - This method first queries for models that are within the bounds of this - instance. After that they are ordered by smallest and the first is - selected. The next child *after* the _right value of that child is - chosen. This skips the grandchildren contained in those children. - - Returns: - An unordered QuerySet of all direct children of this model. - """ - self.refresh_from_db(fields=("_left", "_right")) - children_chunk = self._manager.filter( - _left__gt=self._left, _right__lt=self._right - ).order_by("_left") - direct_children = [] - - right_value = -1 - for child in children_chunk: - if child._left > right_value: - direct_children.append(child.pk) - right_value = child._right - return self._manager.filter(pk__in=direct_children) - - def root(self: T) -> T: - self.refresh_from_db(fields=("_left", "_right")) - parents_query = self._manager.filter( - _left__lt=self._left, _right__gt=self._right - ) - root = parents_query.order_by("_left").first() - return root if root is not None else self - - # ------------------------ public class methods ------------------------- # - - def num_children(self) -> int: - self.refresh_from_db(fields=("_left", "_right")) - return (self._right - self._left) // 2 - - # ------------------------ private class methods ------------------------ # - - def _shift_chunk(self: T, chunk: QuerySet[T], left_shift: int, right_shift: int): - if left_shift != 0: - if right_shift != 0: - chunk.update( - _left=F("_left") + left_shift, _right=F("_right") + right_shift - ) - else: - chunk.update(_left=F("_left") + left_shift) - elif right_shift != 0: - chunk.update(_right=F("_right") + right_shift) diff --git a/django_hierarchical_models/models/pem.py b/django_hierarchical_models/models/pem.py deleted file mode 100644 index 65d9ce0..0000000 --- a/django_hierarchical_models/models/pem.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import TypeVar - -from django.db import connection, models -from django.db.models import QuerySet - -from django_hierarchical_models.models.interface import HierarchicalModelInterface - -T = TypeVar("T", bound="PathEnumerationModel") - - -class PathEnumerationModel(HierarchicalModelInterface): - - # ------------------------ class members -------------------------------- # - - _ancestors = models.JSONField(default=list) - - # ------------------------ builtin methods ------------------------------ # - - def __new__(cls, *args, **kwargs): - if ( - not connection.features.supports_json_field - or not connection.features.supports_json_field_contains - ): - raise NotImplementedError( - f"This database configuration is missing required JSONField features" - f"for PathEnumerationModel. SQLite and Oracle do not support these" - f"features (your vendor: {connection.vendor})." - ) - - # This check only needs to be ran once - cls.__new__ = lambda a, *b, **c: super().__new__(a) - return super().__new__(cls) - - def __init__(self, *args, **kwargs): - if len(args) == 0 and "parent" in kwargs: - parent = kwargs.pop("parent") - ancestors = parent._ancestors.copy() - ancestors.insert(0, parent.pk) - kwargs["_ancestors"] = ancestors - super().__init__(*args, **kwargs) - - # ------------------------ override models.Model ------------------------ # - - class Meta: - abstract = True - - def delete(self, using=None, keep_parents=False): - children = self._manager.filter(_ancestors__contains=self.pk) - for child in children: - child._ancestors = child._ancestors[: child._ancestors.index(self.pk)] - child.save(update_fields=("_ancestors",)) - super().delete(using=using, keep_parents=keep_parents) - - # ------------------------ override HierarchicalModel ------------------- # - - def parent(self: T) -> T | None: - self.refresh_from_db(fields=("_ancestors",)) - if len(self._ancestors) == 0: - return None - return self._manager.get(pk=self._ancestors[0]) - - def is_child_of(self: T, parent: T) -> bool: - self.refresh_from_db(fields=("_ancestors",)) - return parent.pk in self._ancestors - - def direct_children(self: T) -> QuerySet[T]: - return self._manager.filter(_ancestors__0=self.pk) - - def root(self: T) -> T: - self.refresh_from_db(fields=("_ancestors",)) - if len(self._ancestors) == 0: - return self - return self._manager.get(pk=self._ancestors[-1]) - - def _set_parent(self: T, parent: T | None): - if parent is None: - self._ancestors = [] - else: - parent.refresh_from_db(fields=("_ancestors",)) - self._ancestors = [parent.pk] + parent._ancestors - self.save(update_fields=("_ancestors",)) diff --git a/tests/alm_test.py b/tests/alm_test.py deleted file mode 100644 index 34a19fe..0000000 --- a/tests/alm_test.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.test import TestCase - -from django_hierarchical_models.models.exceptions import CycleException -from tests.models import ALMTestModel - - -class ALMTests(TestCase): - def test_set_parent_unchecked(self): - n1 = ALMTestModel.objects.create(num=1) - n2 = n1.create_child(num=2) - self.assertIsNone(n1.parent()) - self.assertEqual(n2.parent(), n1) - with self.assertRaises(CycleException) as cm: - n1.set_parent(n2) - self.assertEqual(cm.exception.child, n1) - self.assertEqual(cm.exception.parent, n2) - self.assertIsNone(n1.parent()) - self.assertEqual(n2.parent(), n1) - n1.set_parent_unchecked(n2) - self.assertEqual(n1.parent(), n2) - self.assertEqual(n2.parent(), n1) diff --git a/tests/hm_test.py b/tests/hm_test.py new file mode 100644 index 0000000..84a85ac --- /dev/null +++ b/tests/hm_test.py @@ -0,0 +1,858 @@ +import copy + +from django.test import TestCase + +from django_hierarchical_models.models import Node +from django_hierarchical_models.models.exceptions import CycleException +from tests.models import ExampleModel + + +def create(num: int, **kwargs) -> ExampleModel: + return ExampleModel.objects.create(num=num, **kwargs) + + +class HierarchicalModelSimpleTests(TestCase): + def test_parent(self): + parent = create(1) + child = create(2, parent=parent) + self.assertIsNone(parent.parent()) + self.assertEqual(child.parent(), parent) + + def test_set_parent(self): + parent = create(1) + child = create(2) + self.assertIsNone(parent.parent()) + self.assertIsNone(child.parent()) + child.num = 3 + child.set_parent(parent) + self.assertIsNone(parent.parent()) + self.assertEqual(child.parent(), parent) + self.assertEqual(child.num, 3) + child.refresh_from_db() + self.assertEqual(child.parent(), parent) + self.assertEqual(child.num, 2) + + def test_direct_children(self): + parent = create(1) + child = create(2, parent=parent) + _ = create(3) + self.assertQuerySetEqual(parent.direct_children(), (child,)) + self.assertQuerySetEqual(child.direct_children(), ()) + + def test_delete(self): + parent = create(1) + child = create(2) + child.set_parent(parent) + self.assertIsNone(parent.parent()) + self.assertEqual(child.parent(), parent) + parent.delete() # TODO need to decide what the defined behavior is here + self.assertIsNone(parent.parent()) + self.assertIsNone(child.parent()) + + def test_create_cycle(self): + parent = create(1) + child = create(2, parent=parent) + with self.assertRaises(CycleException) as cm: + parent.set_parent(child) + self.assertEqual(cm.exception.child, parent) + self.assertEqual(cm.exception.parent, child) + + def test_ancestors(self): + parent = create(1) + child = create(2, parent=parent) + self.assertListEqual(child.ancestors(), [parent]) + + def test_root(self): + n1 = create(1) + self.assertEqual(n1.root(), n1) + n2 = create(2) + n1.set_parent(n2) + self.assertEqual(n1.root(), n2) + self.assertEqual(n2.root(), n2) + n3 = create(3) + n2.set_parent(n3) + self.assertEqual(n1.root(), n3) + self.assertEqual(n2.root(), n3) + self.assertEqual(n3.root(), n3) + + def test_children(self): + n1 = create(1) + n2 = create(2, parent=n1) + n3 = create(3, parent=n1) + expected_children = Node[ExampleModel]( + n1, + [Node[ExampleModel](n2), Node[ExampleModel](n3)], + ) + self.assertEqual( + n1.children(), + expected_children, + ) + + def test_is_child(self): + n1 = create(1) + n2 = create(2) + self.assertFalse(n1.is_child_of(n2)) + self.assertFalse(n2.is_child_of(n1)) + n2.set_parent(n1) + self.assertFalse(n1.is_child_of(n2)) + self.assertTrue(n2.is_child_of(n1)) + + +class HierarchicalModelAdvancedTests(TestCase): + def setUp(self): + self.n1 = create(1) + self.n2 = create(2, parent=self.n1) + self.n3 = create(3, parent=self.n1) + self.n4 = create(4, parent=self.n1) + self.n5 = create(5, parent=self.n2) + self.n6 = create(6, parent=self.n2) + self.n7 = create(7, parent=self.n2) + self.n8 = create(8, parent=self.n3) + self.n9 = create(9, parent=self.n6) + self.n10 = create(10, parent=self.n8) + self.n11 = create(11, parent=self.n10) + self.n12 = create(12) + self.n13 = create(13, parent=self.n12) + self.n14 = create(14, parent=self.n13) + self.n15 = create(15) + self.n16 = create(16, parent=self.n15) + self.n17 = create(17, parent=self.n15) + self.n18 = create(18) + self.n19 = create(19) + self.n20 = create(20) + self.n21 = create(21, parent=self.n20) + self.n22 = create(22, parent=self.n20) + self.n23 = create(23, parent=self.n20) + self.n24 = create(24, parent=self.n20) + self.n25 = create(25, parent=self.n20) + self.n26 = create(26, parent=self.n20) + self.n27 = create(27, parent=self.n23) + self.n28 = create(28, parent=self.n23) + self.n29 = create(29, parent=self.n23) + self.n30 = create(30, parent=self.n23) + self.n31 = create(31, parent=self.n23) + self.n32 = create(32, parent=self.n23) + + def test_parent(self): + for node, parent in ( + (self.n1, None), + (self.n2, self.n1), + (self.n3, self.n1), + (self.n4, self.n1), + (self.n5, self.n2), + (self.n6, self.n2), + (self.n7, self.n2), + (self.n8, self.n3), + (self.n9, self.n6), + (self.n10, self.n8), + (self.n11, self.n10), + (self.n12, None), + (self.n13, self.n12), + (self.n14, self.n13), + (self.n15, None), + (self.n16, self.n15), + (self.n17, self.n15), + (self.n18, None), + (self.n19, None), + (self.n20, None), + (self.n21, self.n20), + (self.n22, self.n20), + (self.n23, self.n20), + (self.n24, self.n20), + (self.n25, self.n20), + (self.n26, self.n20), + (self.n27, self.n23), + (self.n28, self.n23), + (self.n29, self.n23), + (self.n30, self.n23), + (self.n31, self.n23), + (self.n32, self.n23), + ): + self.assertEqual(node.parent(), parent) + + def test_set_parent(self): + self.n2.set_parent(None) + self.assertIsNone(self.n2.parent()) + self.assertEqual(self.n5.parent(), self.n2) + self.assertEqual(self.n6.parent(), self.n2) + self.assertEqual(self.n7.parent(), self.n2) + + self.n11.set_parent(self.n13) + self.assertEqual(self.n11.parent(), self.n13) + self.assertEqual(self.n13.parent(), self.n12) + self.assertEqual(self.n14.parent(), self.n13) + + self.n20.set_parent(self.n18) + self.assertEqual(self.n20.parent(), self.n18) + self.assertIsNone(self.n18.parent()) + self.assertEqual(self.n21.parent(), self.n20) + self.assertEqual(self.n22.parent(), self.n20) + self.assertEqual(self.n23.parent(), self.n20) + self.assertEqual(self.n24.parent(), self.n20) + self.assertEqual(self.n25.parent(), self.n20) + self.assertEqual(self.n26.parent(), self.n20) + + def test_direct_children(self): + for node in ( + self.n4, + self.n5, + self.n7, + self.n9, + self.n11, + self.n14, + self.n16, + self.n17, + self.n18, + self.n19, + self.n21, + self.n22, + self.n24, + self.n25, + self.n26, + self.n27, + self.n28, + self.n29, + self.n30, + self.n31, + self.n32, + ): + self.assertQuerySetEqual(node.direct_children(), ()) + + self.assertQuerySetEqual( + self.n1.direct_children(), (self.n2, self.n3, self.n4), ordered=False + ) + self.assertQuerySetEqual( + self.n2.direct_children(), (self.n5, self.n6, self.n7), ordered=False + ) + self.assertQuerySetEqual(self.n3.direct_children(), (self.n8,)) + self.assertQuerySetEqual(self.n6.direct_children(), (self.n9,)) + self.assertQuerySetEqual(self.n8.direct_children(), (self.n10,)) + self.assertQuerySetEqual(self.n10.direct_children(), (self.n11,)) + self.assertQuerySetEqual(self.n12.direct_children(), (self.n13,)) + self.assertQuerySetEqual(self.n13.direct_children(), (self.n14,)) + self.assertQuerySetEqual( + self.n15.direct_children(), (self.n16, self.n17), ordered=False + ) + self.assertQuerySetEqual( + self.n20.direct_children(), + (self.n21, self.n22, self.n23, self.n24, self.n25, self.n26), + ordered=False, + ) + self.assertQuerySetEqual( + self.n23.direct_children(), + (self.n27, self.n28, self.n29, self.n30, self.n31, self.n32), + ordered=False, + ) + + def test_delete(self): + self.n3.delete() + for node, parent in ( + (self.n1, None), + (self.n2, self.n1), + (self.n4, self.n1), + (self.n5, self.n2), + (self.n6, self.n2), + (self.n7, self.n2), + (self.n8, None), + (self.n9, self.n6), + (self.n10, self.n8), + (self.n11, self.n10), + (self.n12, None), + (self.n13, self.n12), + (self.n14, self.n13), + (self.n15, None), + (self.n16, self.n15), + (self.n17, self.n15), + (self.n18, None), + (self.n19, None), + (self.n20, None), + (self.n21, self.n20), + (self.n22, self.n20), + (self.n23, self.n20), + (self.n24, self.n20), + (self.n25, self.n20), + (self.n26, self.n20), + (self.n27, self.n23), + (self.n28, self.n23), + (self.n29, self.n23), + (self.n30, self.n23), + (self.n31, self.n23), + (self.n32, self.n23), + ): + self.assertEqual(node.parent(), parent) + + self.n13.delete() + for node, parent in ( + (self.n1, None), + (self.n2, self.n1), + (self.n4, self.n1), + (self.n5, self.n2), + (self.n6, self.n2), + (self.n7, self.n2), + (self.n8, None), + (self.n9, self.n6), + (self.n10, self.n8), + (self.n11, self.n10), + (self.n12, None), + (self.n14, None), + (self.n15, None), + (self.n16, self.n15), + (self.n17, self.n15), + (self.n18, None), + (self.n19, None), + (self.n20, None), + (self.n21, self.n20), + (self.n22, self.n20), + (self.n23, self.n20), + (self.n24, self.n20), + (self.n25, self.n20), + (self.n26, self.n20), + (self.n27, self.n23), + (self.n28, self.n23), + (self.n29, self.n23), + (self.n30, self.n23), + (self.n31, self.n23), + (self.n32, self.n23), + ): + self.assertEqual(node.parent(), parent) + + self.n20.delete() + for node, parent in ( + (self.n1, None), + (self.n2, self.n1), + (self.n4, self.n1), + (self.n5, self.n2), + (self.n6, self.n2), + (self.n7, self.n2), + (self.n8, None), + (self.n9, self.n6), + (self.n10, self.n8), + (self.n11, self.n10), + (self.n12, None), + (self.n14, None), + (self.n15, None), + (self.n16, self.n15), + (self.n17, self.n15), + (self.n18, None), + (self.n19, None), + (self.n21, None), + (self.n22, None), + (self.n23, None), + (self.n24, None), + (self.n25, None), + (self.n26, None), + (self.n27, self.n23), + (self.n28, self.n23), + (self.n29, self.n23), + (self.n30, self.n23), + (self.n31, self.n23), + (self.n32, self.n23), + ): + self.assertEqual(node.parent(), parent) + + self.n6.delete() + for node, parent in ( + (self.n1, None), + (self.n2, self.n1), + (self.n4, self.n1), + (self.n5, self.n2), + (self.n7, self.n2), + (self.n8, None), + (self.n9, None), + (self.n10, self.n8), + (self.n11, self.n10), + (self.n12, None), + (self.n14, None), + (self.n15, None), + (self.n16, self.n15), + (self.n17, self.n15), + (self.n18, None), + (self.n19, None), + (self.n21, None), + (self.n22, None), + (self.n23, None), + (self.n24, None), + (self.n25, None), + (self.n26, None), + (self.n27, self.n23), + (self.n28, self.n23), + (self.n29, self.n23), + (self.n30, self.n23), + (self.n31, self.n23), + (self.n32, self.n23), + ): + self.assertEqual(node.parent(), parent) + + def test_create_cycle(self): + with self.assertRaises(CycleException) as cm: + self.n1.set_parent(self.n10) + self.assertEqual(cm.exception.child, self.n1) + self.assertEqual(cm.exception.parent, self.n10) + + with self.assertRaises(CycleException) as cm: + self.n12.set_parent(self.n14) + self.assertEqual(cm.exception.child, self.n12) + self.assertEqual(cm.exception.parent, self.n14) + + with self.assertRaises(CycleException) as cm: + self.n18.set_parent(self.n18) + self.assertEqual(cm.exception.child, self.n18) + self.assertEqual(cm.exception.parent, self.n18) + + with self.assertRaises(CycleException) as cm: + self.n20.set_parent(self.n21) + self.assertEqual(cm.exception.child, self.n20) + self.assertEqual(cm.exception.parent, self.n21) + + def test_ancestors(self): + for node, ancestors in ( + (self.n1, []), + (self.n2, [self.n1]), + (self.n3, [self.n1]), + (self.n4, [self.n1]), + (self.n5, [self.n2, self.n1]), + (self.n6, [self.n2, self.n1]), + (self.n7, [self.n2, self.n1]), + (self.n8, [self.n3, self.n1]), + (self.n9, [self.n6, self.n2, self.n1]), + (self.n10, [self.n8, self.n3, self.n1]), + (self.n11, [self.n10, self.n8, self.n3, self.n1]), + (self.n12, []), + (self.n13, [self.n12]), + (self.n14, [self.n13, self.n12]), + (self.n15, []), + (self.n16, [self.n15]), + (self.n17, [self.n15]), + (self.n19, []), + (self.n20, []), + (self.n21, [self.n20]), + (self.n22, [self.n20]), + (self.n23, [self.n20]), + (self.n24, [self.n20]), + (self.n25, [self.n20]), + (self.n26, [self.n20]), + (self.n27, [self.n23, self.n20]), + (self.n28, [self.n23, self.n20]), + (self.n29, [self.n23, self.n20]), + (self.n30, [self.n23, self.n20]), + (self.n31, [self.n23, self.n20]), + (self.n32, [self.n23, self.n20]), + ): + self.assertListEqual(node.ancestors(), ancestors) + + def test_ancestor_options(self): + self.assertListEqual(self.n11.ancestors(max_level=0), []) + self.assertListEqual(self.n11.ancestors(max_level=1), [self.n10]) + self.assertListEqual(self.n11.ancestors(max_level=2), [self.n10, self.n8]) + self.assertListEqual( + self.n11.ancestors(max_level=3), [self.n10, self.n8, self.n3] + ) + self.assertListEqual( + self.n11.ancestors(max_level=4), [self.n10, self.n8, self.n3, self.n1] + ) + self.assertListEqual( + self.n11.ancestors(max_level=5), [self.n10, self.n8, self.n3, self.n1] + ) + + def test_root(self): + for node, root in ( + (self.n1, self.n1), + (self.n2, self.n1), + (self.n3, self.n1), + (self.n4, self.n1), + (self.n5, self.n1), + (self.n6, self.n1), + (self.n7, self.n1), + (self.n8, self.n1), + (self.n9, self.n1), + (self.n10, self.n1), + (self.n11, self.n1), + (self.n12, self.n12), + (self.n13, self.n12), + (self.n14, self.n12), + (self.n15, self.n15), + (self.n16, self.n15), + (self.n17, self.n15), + (self.n18, self.n18), + (self.n19, self.n19), + (self.n20, self.n20), + (self.n21, self.n20), + (self.n22, self.n20), + (self.n23, self.n20), + (self.n24, self.n20), + (self.n25, self.n20), + (self.n26, self.n20), + (self.n27, self.n20), + (self.n28, self.n20), + (self.n29, self.n20), + (self.n30, self.n20), + (self.n31, self.n20), + (self.n32, self.n20), + ): + self.assertEqual(node.root(), root) + + def test_is_child(self): + for parent, child in ( + (self.n1, self.n9), + (self.n2, self.n9), + (self.n6, self.n9), + (self.n13, self.n14), + (self.n12, self.n14), + (self.n12, self.n13), + (self.n20, self.n26), + (self.n23, self.n30), + (self.n20, self.n30), + ): + self.assertTrue(child.is_child_of(parent)) + + for parent, child in ( + (self.n3, self.n9), + (self.n5, self.n9), + (self.n14, self.n13), + (self.n14, self.n12), + (self.n13, self.n12), + (self.n26, self.n20), + (self.n24, self.n30), + (self.n31, self.n30), + (self.n1, self.n1), + (self.n16, self.n16), + (self.n32, self.n32), + ): + self.assertFalse(child.is_child_of(parent)) + + +class HierarchicalModelTestOld: + + def test_advanced_children(self): + mn1 = Node[self.model_class](self.n1) + mn2 = Node[self.model_class](self.n2) + mn3 = Node[self.model_class](self.n3) + mn4 = Node[self.model_class](self.n4) + mn5 = Node[self.model_class](self.n5) + mn6 = Node[self.model_class](self.n6) + mn7 = Node[self.model_class](self.n7) + mn8 = Node[self.model_class](self.n8) + mn9 = Node[self.model_class](self.n9) + mn10 = Node[self.model_class](self.n10) + mn11 = Node[self.model_class](self.n11) + mn12 = Node[self.model_class](self.n12) + mn13 = Node[self.model_class](self.n13) + mn14 = Node[self.model_class](self.n14) + mn15 = Node[self.model_class](self.n15) + mn16 = Node[self.model_class](self.n16) + mn17 = Node[self.model_class](self.n17) + mn18 = Node[self.model_class](self.n18) + mn19 = Node[self.model_class](self.n19) + mn20 = Node[self.model_class](self.n20) + mn21 = Node[self.model_class](self.n21) + mn22 = Node[self.model_class](self.n22) + mn23 = Node[self.model_class](self.n23) + mn24 = Node[self.model_class](self.n24) + mn25 = Node[self.model_class](self.n25) + mn26 = Node[self.model_class](self.n26) + mn27 = Node[self.model_class](self.n27) + mn28 = Node[self.model_class](self.n28) + mn29 = Node[self.model_class](self.n29) + mn30 = Node[self.model_class](self.n30) + mn31 = Node[self.model_class](self.n31) + mn32 = Node[self.model_class](self.n32) + + mn1.children = [mn2, mn3, mn4] + mn2.children = [mn5, mn6, mn7] + mn3.children = [mn8] + mn6.children = [mn9] + mn8.children = [mn10] + mn10.children = [mn11] + + mn12.children = [mn13] + mn13.children = [mn14] + + mn15.children = [mn16, mn17] + + mn20.children = [mn21, mn22, mn23, mn24, mn25, mn26] + mn23.children = [mn27, mn28, mn29, mn30, mn31, mn32] + + self.assertEqual( + self.n1.children(sibling_transform=lambda x: x.order_by("num")), mn1 + ) + self.assertEqual( + self.n2.children(sibling_transform=lambda x: x.order_by("num")), mn2 + ) + self.assertEqual( + self.n3.children(sibling_transform=lambda x: x.order_by("num")), mn3 + ) + self.assertEqual( + self.n4.children(sibling_transform=lambda x: x.order_by("num")), mn4 + ) + self.assertEqual( + self.n5.children(sibling_transform=lambda x: x.order_by("num")), mn5 + ) + self.assertEqual( + self.n6.children(sibling_transform=lambda x: x.order_by("num")), mn6 + ) + self.assertEqual( + self.n7.children(sibling_transform=lambda x: x.order_by("num")), mn7 + ) + self.assertEqual( + self.n8.children(sibling_transform=lambda x: x.order_by("num")), mn8 + ) + self.assertEqual( + self.n9.children(sibling_transform=lambda x: x.order_by("num")), mn9 + ) + self.assertEqual( + self.n10.children(sibling_transform=lambda x: x.order_by("num")), mn10 + ) + self.assertEqual( + self.n11.children(sibling_transform=lambda x: x.order_by("num")), mn11 + ) + self.assertEqual( + self.n12.children(sibling_transform=lambda x: x.order_by("num")), mn12 + ) + self.assertEqual( + self.n13.children(sibling_transform=lambda x: x.order_by("num")), mn13 + ) + self.assertEqual( + self.n14.children(sibling_transform=lambda x: x.order_by("num")), mn14 + ) + self.assertEqual( + self.n15.children(sibling_transform=lambda x: x.order_by("num")), mn15 + ) + self.assertEqual( + self.n16.children(sibling_transform=lambda x: x.order_by("num")), mn16 + ) + self.assertEqual( + self.n17.children(sibling_transform=lambda x: x.order_by("num")), mn17 + ) + self.assertEqual( + self.n18.children(sibling_transform=lambda x: x.order_by("num")), mn18 + ) + self.assertEqual( + self.n19.children(sibling_transform=lambda x: x.order_by("num")), mn19 + ) + self.assertEqual( + self.n20.children(sibling_transform=lambda x: x.order_by("num")), mn20 + ) + self.assertEqual( + self.n21.children(sibling_transform=lambda x: x.order_by("num")), mn21 + ) + self.assertEqual( + self.n22.children(sibling_transform=lambda x: x.order_by("num")), mn22 + ) + self.assertEqual( + self.n23.children(sibling_transform=lambda x: x.order_by("num")), mn23 + ) + self.assertEqual( + self.n24.children(sibling_transform=lambda x: x.order_by("num")), mn24 + ) + self.assertEqual( + self.n25.children(sibling_transform=lambda x: x.order_by("num")), mn25 + ) + self.assertEqual( + self.n26.children(sibling_transform=lambda x: x.order_by("num")), mn26 + ) + self.assertEqual( + self.n27.children(sibling_transform=lambda x: x.order_by("num")), mn27 + ) + self.assertEqual( + self.n28.children(sibling_transform=lambda x: x.order_by("num")), mn28 + ) + self.assertEqual( + self.n29.children(sibling_transform=lambda x: x.order_by("num")), mn29 + ) + self.assertEqual( + self.n30.children(sibling_transform=lambda x: x.order_by("num")), mn30 + ) + self.assertEqual( + self.n31.children(sibling_transform=lambda x: x.order_by("num")), mn31 + ) + self.assertEqual( + self.n32.children(sibling_transform=lambda x: x.order_by("num")), mn32 + ) + + def test_advanced_children_options(self): + mn1 = Node[self.model_class](self.n1) + mn2 = Node[self.model_class](self.n2) + mn3 = Node[self.model_class](self.n3) + mn4 = Node[self.model_class](self.n4) + mn5 = Node[self.model_class](self.n5) + mn6 = Node[self.model_class](self.n6) + mn7 = Node[self.model_class](self.n7) + mn8 = Node[self.model_class](self.n8) + mn9 = Node[self.model_class](self.n9) + mn10 = Node[self.model_class](self.n10) + mn11 = Node[self.model_class](self.n11) + mn12 = Node[self.model_class](self.n12) + mn13 = Node[self.model_class](self.n13) + mn14 = Node[self.model_class](self.n14) + mn15 = Node[self.model_class](self.n15) + mn16 = Node[self.model_class](self.n16) + mn17 = Node[self.model_class](self.n17) + mn20 = Node[self.model_class](self.n20) + mn21 = Node[self.model_class](self.n21) + mn22 = Node[self.model_class](self.n22) + mn23 = Node[self.model_class](self.n23) + mn24 = Node[self.model_class](self.n24) + mn25 = Node[self.model_class](self.n25) + mn26 = Node[self.model_class](self.n26) + mn27 = Node[self.model_class](self.n27) + mn28 = Node[self.model_class](self.n28) + mn29 = Node[self.model_class](self.n29) + mn30 = Node[self.model_class](self.n30) + mn31 = Node[self.model_class](self.n31) + mn32 = Node[self.model_class](self.n32) + + mn1.children = [mn2, mn3, mn4] + mn2.children = [mn5, mn6, mn7] + mn3.children = [mn8] + mn6.children = [mn9] + mn8.children = [mn10] + mn10.children = [mn11] + + mn12.children = [mn13] + mn13.children = [mn14] + + mn15.children = [mn16, mn17] + + mn20.children = [mn21, mn22, mn23, mn24, mn25, mn26] + mn23.children = [mn27, mn28, mn29, mn30, mn31, mn32] + + t1 = copy.copy(mn1) + t1.children[0].children[1].children = [] + t1.children[1].children[0].children = [] + + self.assertEqual( + self.n1.children( + max_generations=2, sibling_transform=lambda x: x.order_by("num") + ), + t1, + ) + + t2 = copy.copy(mn1) + t2.children = t2.children[:2] + t2.children[0].children = t2.children[0].children[:2] + + self.assertEqual( + self.n1.children( + max_siblings=2, sibling_transform=lambda x: x.order_by("num") + ), + t2, + ) + + t3 = copy.copy(mn1) + t3.children[0].children = t3.children[0].children[:2] + t3.children[0].children[1].children = [] + t3.children[1].children = [] + + self.assertEqual( + self.n1.children( + max_total=6, sibling_transform=lambda x: x.order_by("num") + ), + t3, + ) + + t4 = copy.copy(mn1) + t4.children = t4.children[:2] + t4.children[0].children = t4.children[0].children[:2] + t4.children[1].children[0].children[0].children = [] + + self.assertEqual( + self.n1.children( + max_generations=3, + max_siblings=2, + sibling_transform=lambda x: x.order_by("num"), + ), + t4, + ) + + t5 = copy.copy(mn20) + t5.children = t5.children[:3] + t5.children[2].children = [] + + self.assertEqual( + self.n20.children( + max_generations=1, + max_siblings=3, + sibling_transform=lambda x: x.order_by("num"), + ), + t5, + ) + + t6 = copy.copy(mn20) + t6.children = t6.children[:4] + t6.children[2].children = t6.children[2].children[:1] + + self.assertEqual( + self.n20.children( + max_siblings=4, + max_total=6, + sibling_transform=lambda x: x.order_by("num"), + ), + t6, + ) + + t7 = copy.copy(mn1) + t7.children = t7.children[:2] + t7.children[0].children = t7.children[0].children[:2] + t7.children[1].children[0].children = [] + + self.assertEqual( + self.n1.children( + max_generations=3, + max_siblings=2, + max_total=7, + sibling_transform=lambda x: x.order_by("num"), + ), + t7, + ) + + self.assertEqual( + self.n15.children( + max_generations=4, sibling_transform=lambda x: x.order_by("num") + ), + mn15, + ) + self.assertEqual( + self.n15.children( + max_siblings=5, sibling_transform=lambda x: x.order_by("num") + ), + mn15, + ) + self.assertEqual( + self.n15.children( + max_total=10, sibling_transform=lambda x: x.order_by("num") + ), + mn15, + ) + self.assertEqual( + self.n15.children( + max_generations=4, + max_siblings=5, + sibling_transform=lambda x: x.order_by("num"), + ), + mn15, + ) + self.assertEqual( + self.n15.children( + max_generations=4, + max_total=10, + sibling_transform=lambda x: x.order_by("num"), + ), + mn15, + ) + self.assertEqual( + self.n15.children( + max_siblings=5, + max_total=10, + sibling_transform=lambda x: x.order_by("num"), + ), + mn15, + ) + self.assertEqual( + self.n15.children( + max_generations=4, + max_siblings=5, + max_total=10, + sibling_transform=lambda x: x.order_by("num"), + ), + mn15, + ) diff --git a/tests/interface_test.py b/tests/interface_test.py deleted file mode 100644 index 4cdb781..0000000 --- a/tests/interface_test.py +++ /dev/null @@ -1,1072 +0,0 @@ -import copy -import os - -import pytest -from django.conf import settings -from django.db.models.manager import BaseManager -from django.test import TestCase -from parameterized import parameterized_class # type: ignore - -from django_hierarchical_models.models import Node -from django_hierarchical_models.models.exceptions import ( - AlreadyHasParentException, - CycleException, - NotAChildException, -) -from django_hierarchical_models.models.interface import HierarchicalModelInterface -from tests.models import ALMTestModel, NSMTestModel, NumberModelMixin, PEMTestModel - - -class HierarchicalTestModel(NumberModelMixin, HierarchicalModelInterface): - objects: BaseManager - - -@parameterized_class( - ("model_class",), ((ALMTestModel,), (NSMTestModel,), (PEMTestModel,)) -) -class HierarchicalModelInterfaceTester(TestCase): - model_class: HierarchicalTestModel - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # don't want to skip tests if intended to use postgres - if self.model_class == PEMTestModel and "POSTGRES_DB" not in os.environ: - db_engine = settings.DATABASES["default"]["ENGINE"] - - if "sqlite" in db_engine or "oracle" in db_engine: - self.__class__.__unittest_skip__ = True - self.__class__.__unittest_skip_why__ = ( - "PathEnumerationModel tests" - "skipped due to sqlite or oracle db engine." - ) - - def create(self, num: int, **kwargs) -> HierarchicalModelInterface: - # convenience method to save num= in create calls - return self.model_class.objects.create(num=num, **kwargs) - - def setup_advanced_nodes(self): - self.n1 = self.create(1) - self.n2 = self.create(2) - self.n3 = self.create(3) - self.n4 = self.create(4) - self.n5 = self.create(5) - self.n6 = self.create(6) - self.n7 = self.create(7) - self.n8 = self.create(8) - self.n9 = self.create(9) - self.n10 = self.create(10) - self.n11 = self.create(11) - self.n12 = self.create(12) - self.n13 = self.create(13) - self.n14 = self.create(14) - self.n15 = self.create(15) - self.n16 = self.create(16) - self.n17 = self.create(17) - self.n18 = self.create(18) - self.n19 = self.create(19) - self.n20 = self.create(20) - self.n21 = self.create(21) - self.n22 = self.create(22) - self.n23 = self.create(23) - self.n24 = self.create(24) - self.n25 = self.create(25) - self.n26 = self.create(26) - self.n27 = self.create(27) - self.n28 = self.create(28) - self.n29 = self.create(29) - self.n30 = self.create(30) - self.n31 = self.create(31) - self.n32 = self.create(32) - - self.n2.set_parent(self.n1) - self.n3.set_parent(self.n1) - self.n4.set_parent(self.n1) - - self.n5.set_parent(self.n2) - self.n6.set_parent(self.n2) - self.n7.set_parent(self.n2) - - self.n8.set_parent(self.n3) - - self.n9.set_parent(self.n6) - - self.n10.set_parent(self.n8) - - self.n11.set_parent(self.n10) - - self.n13.set_parent(self.n12) - - self.n14.set_parent(self.n13) - - self.n16.set_parent(self.n15) - self.n17.set_parent(self.n15) - - self.n21.set_parent(self.n20) - self.n22.set_parent(self.n20) - self.n23.set_parent(self.n20) - self.n24.set_parent(self.n20) - self.n25.set_parent(self.n20) - self.n26.set_parent(self.n20) - - self.n27.set_parent(self.n23) - self.n28.set_parent(self.n23) - self.n29.set_parent(self.n23) - self.n30.set_parent(self.n23) - self.n31.set_parent(self.n23) - self.n32.set_parent(self.n23) - - def test_basic_parent(self): - parent = self.create(1) - child = self.create(2, parent=parent) - self.assertIsNone(parent.parent()) - self.assertEqual(child.parent(), parent) - - def test_basic_set_parent(self): - parent = self.create(1) - child = self.create(2) - self.assertIsNone(parent.parent()) - self.assertIsNone(child.parent()) - child.num = 3 - child.set_parent(parent) - self.assertIsNone(parent.parent()) - self.assertEqual(child.parent(), parent) - self.assertEqual(child.num, 3) - child.refresh_from_db() - self.assertEqual(child.parent(), parent) - self.assertEqual(child.num, 2) - - def test_basic_direct_children(self): - parent = self.create(1) - child = self.create(2, parent=parent) - self.assertQuerySetEqual(parent.direct_children(), {child}) - - def test_basic_child_transform(self): - n1 = self.create(1) - n2 = n1.create_child(num=2) - n3 = n1.create_child(num=3) - self.assertQuerySetEqual(n1.direct_children().order_by("num"), [n2, n3]) - self.assertQuerySetEqual(n1.direct_children().order_by("-num"), [n3, n2]) - - def test_basic_create_child(self): - parent = self.create(1) - child = parent.create_child(num=2) - self.assertIsNone(parent.parent()) - self.assertEqual(child.parent(), parent) - - def test_basic_delete(self): - parent = self.create(1) - child = self.create(2) - child.set_parent(parent) - self.assertIsNone(parent.parent()) - self.assertEqual(child.parent(), parent) - parent.delete() - self.assertIsNone(child.parent()) - - def test_basic_create_cycle(self): - parent = self.create(1) - child = parent.create_child(num=2) - with self.assertRaises(CycleException) as cm: - parent.set_parent(child) - self.assertEqual(cm.exception.child, parent) - self.assertEqual(cm.exception.parent, child) - - def test_basic_add_child(self): - n1 = self.create(1) - n2 = self.create(2) - n1.add_child(n2) - self.assertEqual(n2.parent(), n1) - n3 = self.create(3) - with self.assertRaises(AlreadyHasParentException) as cm: - n3.add_child(n2, check_has_parent=True) - self.assertEqual(cm.exception.child, n2) - self.assertEqual(n2.parent(), n1) - n3.add_child(n2) - self.assertEqual(n2.parent(), n3) - - def test_basic_remove_child(self): - parent = self.create(1) - child = self.create(2, parent=parent) - parent.remove_child(child) - self.assertIsNone(child.parent()) - with self.assertRaises(NotAChildException) as cm: - parent.remove_child(child, check_is_child=True) - self.assertEqual(cm.exception.child, child) - self.assertEqual(cm.exception.parent, parent) - self.assertIsNone(child.parent()) - parent.remove_child(child) - self.assertIsNone(child.parent()) - - def test_basic_ancestors(self): - parent = self.create(1) - child = self.create(2, parent=parent) - self.assertListEqual(child.ancestors(), [parent]) - - @pytest.mark.skip("This test doesn't make sense for some models") - def test_basic_root(self): - n1 = self.create(1) - self.assertEqual(n1.root(), n1) - n2 = self.create(2) - n1.set_parent(n2) - self.assertEqual(n1.root(), n2) - n3 = self.create(3) - n2.set_parent(n3) - self.assertEqual(n1.root(), n3) - - def test_basic_children(self): - n1 = self.create(1) - n2 = n1.create_child(num=2) - n3 = n1.create_child(num=3) - expected_children = Node[self.model_class]( - n1, - [Node[self.model_class](n2), Node[self.model_class](n3)], - ) - self.assertEqual( - n1.children(sibling_transform=lambda x: x.order_by("num")), - expected_children, - ) - - def test_advanced_parent(self): - self.setup_advanced_nodes() - - self.assertIsNone(self.n1.parent()) - self.assertEqual(self.n2.parent(), self.n1) - self.assertEqual(self.n3.parent(), self.n1) - self.assertEqual(self.n4.parent(), self.n1) - self.assertEqual(self.n5.parent(), self.n2) - self.assertEqual(self.n6.parent(), self.n2) - self.assertEqual(self.n7.parent(), self.n2) - self.assertEqual(self.n8.parent(), self.n3) - self.assertEqual(self.n9.parent(), self.n6) - self.assertEqual(self.n10.parent(), self.n8) - self.assertEqual(self.n11.parent(), self.n10) - self.assertIsNone(self.n12.parent()) - self.assertEqual(self.n13.parent(), self.n12) - self.assertEqual(self.n14.parent(), self.n13) - self.assertIsNone(self.n15.parent()) - self.assertEqual(self.n16.parent(), self.n15) - self.assertEqual(self.n17.parent(), self.n15) - self.assertIsNone(self.n18.parent()) - self.assertIsNone(self.n19.parent()) - self.assertIsNone(self.n20.parent()) - self.assertEqual(self.n21.parent(), self.n20) - self.assertEqual(self.n22.parent(), self.n20) - self.assertEqual(self.n23.parent(), self.n20) - self.assertEqual(self.n24.parent(), self.n20) - self.assertEqual(self.n25.parent(), self.n20) - self.assertEqual(self.n26.parent(), self.n20) - self.assertEqual(self.n27.parent(), self.n23) - self.assertEqual(self.n28.parent(), self.n23) - self.assertEqual(self.n29.parent(), self.n23) - self.assertEqual(self.n30.parent(), self.n23) - self.assertEqual(self.n31.parent(), self.n23) - self.assertEqual(self.n32.parent(), self.n23) - - def test_advanced_set_parent(self): - self.setup_advanced_nodes() - - self.n2.set_parent(None) - self.assertIsNone(self.n2.parent()) - self.assertEqual(self.n5.parent(), self.n2) - self.assertEqual(self.n6.parent(), self.n2) - self.assertEqual(self.n7.parent(), self.n2) - - self.n11.set_parent(self.n13) - self.assertEqual(self.n11.parent(), self.n13) - self.assertEqual(self.n13.parent(), self.n12) - self.assertEqual(self.n14.parent(), self.n13) - - self.n20.set_parent(self.n18) - self.assertEqual(self.n20.parent(), self.n18) - self.assertIsNone(self.n18.parent()) - self.assertEqual(self.n21.parent(), self.n20) - self.assertEqual(self.n22.parent(), self.n20) - self.assertEqual(self.n23.parent(), self.n20) - self.assertEqual(self.n24.parent(), self.n20) - self.assertEqual(self.n25.parent(), self.n20) - self.assertEqual(self.n26.parent(), self.n20) - - def test_advanced_direct_children(self): - self.setup_advanced_nodes() - - self.assertQuerySetEqual( - self.n1.direct_children(), [self.n2, self.n3, self.n4], ordered=False - ) - self.assertQuerySetEqual( - self.n2.direct_children(), [self.n5, self.n6, self.n7], ordered=False - ) - self.assertQuerySetEqual(self.n3.direct_children(), [self.n8]) - self.assertQuerySetEqual(self.n4.direct_children(), []) - self.assertQuerySetEqual(self.n5.direct_children(), []) - self.assertQuerySetEqual(self.n6.direct_children(), [self.n9]) - self.assertQuerySetEqual(self.n7.direct_children(), []) - self.assertQuerySetEqual(self.n8.direct_children(), [self.n10]) - self.assertQuerySetEqual(self.n9.direct_children(), []) - self.assertQuerySetEqual(self.n10.direct_children(), [self.n11]) - self.assertQuerySetEqual(self.n11.direct_children(), []) - self.assertQuerySetEqual(self.n12.direct_children(), [self.n13]) - self.assertQuerySetEqual(self.n13.direct_children(), [self.n14]) - self.assertQuerySetEqual(self.n14.direct_children(), []) - self.assertQuerySetEqual( - self.n15.direct_children(), [self.n16, self.n17], ordered=False - ) - self.assertQuerySetEqual(self.n16.direct_children(), []) - self.assertQuerySetEqual(self.n17.direct_children(), []) - self.assertQuerySetEqual(self.n18.direct_children(), []) - self.assertQuerySetEqual(self.n19.direct_children(), []) - self.assertQuerySetEqual( - self.n20.direct_children(), - [self.n21, self.n22, self.n23, self.n24, self.n25, self.n26], - ordered=False, - ) - self.assertQuerySetEqual(self.n21.direct_children(), []) - self.assertQuerySetEqual(self.n22.direct_children(), []) - self.assertQuerySetEqual( - self.n23.direct_children(), - [self.n27, self.n28, self.n29, self.n30, self.n31, self.n32], - ordered=False, - ) - self.assertQuerySetEqual(self.n24.direct_children(), []) - self.assertQuerySetEqual(self.n25.direct_children(), []) - self.assertQuerySetEqual(self.n26.direct_children(), []) - self.assertQuerySetEqual(self.n27.direct_children(), []) - self.assertQuerySetEqual(self.n28.direct_children(), []) - self.assertQuerySetEqual(self.n29.direct_children(), []) - self.assertQuerySetEqual(self.n30.direct_children(), []) - self.assertQuerySetEqual(self.n31.direct_children(), []) - self.assertQuerySetEqual(self.n32.direct_children(), []) - - def test_advanced_child_transform(self): - self.setup_advanced_nodes() - - self.assertQuerySetEqual( - self.n1.direct_children().order_by("-num"), - [self.n4, self.n3, self.n2], - ) - self.assertQuerySetEqual( - self.n2.direct_children().filter(num__gt=5).order_by("num"), - [self.n6, self.n7], - ) - self.assertQuerySetEqual( - self.n20.direct_children().filter(num__gt=21, num__lt=26), - [self.n22, self.n23, self.n24, self.n25], - ordered=False, - ) - - def test_advanced_create_child(self): - self.setup_advanced_nodes() - - n33 = self.n1.create_child(num=33) - self.assertEqual(n33.parent(), self.n1) - self.assertQuerySetEqual( - self.n1.direct_children(), [self.n2, self.n3, self.n4, n33], ordered=False - ) - self.assertEqual(self.n2.parent(), self.n1) - self.assertEqual(self.n3.parent(), self.n1) - self.assertEqual(self.n4.parent(), self.n1) - - n34 = self.n1.create_child( - create_method=self.model_class.objects.create, num=34 - ) - self.assertEqual(n34.parent(), self.n1) - self.assertQuerySetEqual( - self.n1.direct_children(), - [self.n2, self.n3, self.n4, n33, n34], - ordered=False, - ) - - def test_advanced_delete(self): - self.setup_advanced_nodes() - - self.n3.delete() - for node, parent in ( - (self.n1, None), - (self.n2, self.n1), - (self.n4, self.n1), - (self.n5, self.n2), - (self.n6, self.n2), - (self.n7, self.n2), - (self.n8, None), - (self.n9, self.n6), - (self.n10, self.n8), - (self.n11, self.n10), - (self.n12, None), - (self.n13, self.n12), - (self.n14, self.n13), - (self.n15, None), - (self.n16, self.n15), - (self.n17, self.n15), - (self.n18, None), - (self.n19, None), - (self.n20, None), - (self.n21, self.n20), - (self.n22, self.n20), - (self.n23, self.n20), - (self.n24, self.n20), - (self.n25, self.n20), - (self.n26, self.n20), - (self.n27, self.n23), - (self.n28, self.n23), - (self.n29, self.n23), - (self.n30, self.n23), - (self.n31, self.n23), - (self.n32, self.n23), - ): - node.refresh_from_db() - print(node) - self.assertEqual(node.parent(), parent) - - self.n13.delete() - for node, parent in ( - (self.n1, None), - (self.n2, self.n1), - (self.n4, self.n1), - (self.n5, self.n2), - (self.n6, self.n2), - (self.n7, self.n2), - (self.n8, None), - (self.n9, self.n6), - (self.n10, self.n8), - (self.n11, self.n10), - (self.n12, None), - (self.n14, None), - (self.n15, None), - (self.n16, self.n15), - (self.n17, self.n15), - (self.n18, None), - (self.n19, None), - (self.n20, None), - (self.n21, self.n20), - (self.n22, self.n20), - (self.n23, self.n20), - (self.n24, self.n20), - (self.n25, self.n20), - (self.n26, self.n20), - (self.n27, self.n23), - (self.n28, self.n23), - (self.n29, self.n23), - (self.n30, self.n23), - (self.n31, self.n23), - (self.n32, self.n23), - ): - self.assertEqual(node.parent(), parent) - - self.n20.delete() - for node, parent in ( - (self.n1, None), - (self.n2, self.n1), - (self.n4, self.n1), - (self.n5, self.n2), - (self.n6, self.n2), - (self.n7, self.n2), - (self.n8, None), - (self.n9, self.n6), - (self.n10, self.n8), - (self.n11, self.n10), - (self.n12, None), - (self.n14, None), - (self.n15, None), - (self.n16, self.n15), - (self.n17, self.n15), - (self.n18, None), - (self.n19, None), - (self.n21, None), - (self.n22, None), - (self.n23, None), - (self.n24, None), - (self.n25, None), - (self.n26, None), - (self.n27, self.n23), - (self.n28, self.n23), - (self.n29, self.n23), - (self.n30, self.n23), - (self.n31, self.n23), - (self.n32, self.n23), - ): - self.assertEqual(node.parent(), parent) - - self.n6.delete() - for node, parent in ( - (self.n1, None), - (self.n2, self.n1), - (self.n4, self.n1), - (self.n5, self.n2), - (self.n7, self.n2), - (self.n8, None), - (self.n9, None), - (self.n10, self.n8), - (self.n11, self.n10), - (self.n12, None), - (self.n14, None), - (self.n15, None), - (self.n16, self.n15), - (self.n17, self.n15), - (self.n18, None), - (self.n19, None), - (self.n21, None), - (self.n22, None), - (self.n23, None), - (self.n24, None), - (self.n25, None), - (self.n26, None), - (self.n27, self.n23), - (self.n28, self.n23), - (self.n29, self.n23), - (self.n30, self.n23), - (self.n31, self.n23), - (self.n32, self.n23), - ): - self.assertEqual(node.parent(), parent) - - def test_advanced_create_cycle(self): - self.setup_advanced_nodes() - - with self.assertRaises(CycleException) as cm: - self.n1.set_parent(self.n10) - self.assertEqual(cm.exception.child, self.n1) - self.assertEqual(cm.exception.parent, self.n10) - - with self.assertRaises(CycleException) as cm: - self.n12.set_parent(self.n14) - self.assertEqual(cm.exception.child, self.n12) - self.assertEqual(cm.exception.parent, self.n14) - - with self.assertRaises(CycleException) as cm: - self.n18.set_parent(self.n18) - self.assertEqual(cm.exception.child, self.n18) - self.assertEqual(cm.exception.parent, self.n18) - - with self.assertRaises(CycleException) as cm: - self.n20.set_parent(self.n21) - self.assertEqual(cm.exception.child, self.n20) - self.assertEqual(cm.exception.parent, self.n21) - - def test_advanced_add_child(self): - self.setup_advanced_nodes() - - self.n1.add_child(self.n18) - self.assertEqual(self.n18.parent(), self.n1) - self.assertQuerySetEqual( - self.n1.direct_children(), - [self.n2, self.n3, self.n4, self.n18], - ordered=False, - ) - self.assertQuerySetEqual(self.n18.direct_children(), []) - self.n12.add_child(self.n15) - self.assertEqual(self.n15.parent(), self.n12) - self.assertQuerySetEqual( - self.n12.direct_children(), [self.n13, self.n15], ordered=False - ) - self.assertQuerySetEqual( - self.n15.direct_children(), [self.n16, self.n17], ordered=False - ) - with self.assertRaises(AlreadyHasParentException) as cm: - self.n19.add_child(self.n21, check_has_parent=True) - self.assertEqual(cm.exception.child, self.n21) - self.assertEqual(self.n21.parent(), self.n20) - self.assertQuerySetEqual(self.n19.direct_children(), []) - self.n19.add_child(self.n21) - self.assertEqual(self.n21.parent(), self.n19) - self.assertQuerySetEqual(self.n19.direct_children(), [self.n21]) - self.assertQuerySetEqual(self.n21.direct_children(), []) - self.assertQuerySetEqual( - self.n20.direct_children(), - [self.n22, self.n23, self.n24, self.n25, self.n26], - ordered=False, - ) - - def test_advanced_remove_child(self): - self.setup_advanced_nodes() - - self.n1.remove_child(self.n3) - self.assertIsNone(self.n3.parent()) - self.assertQuerySetEqual( - self.n1.direct_children(), [self.n2, self.n4], ordered=False - ) - self.assertQuerySetEqual(self.n3.direct_children(), [self.n8]) - self.n20.remove_child(self.n23) - self.assertIsNone(self.n23.parent()) - self.assertQuerySetEqual( - self.n20.direct_children(), - [self.n21, self.n22, self.n24, self.n25, self.n26], - ordered=False, - ) - self.assertQuerySetEqual( - self.n23.direct_children(), - [self.n27, self.n28, self.n29, self.n30, self.n31, self.n32], - ordered=False, - ) - with self.assertRaises(NotAChildException) as cm: - self.n18.remove_child(self.n12, check_is_child=True) - self.assertEqual(cm.exception.child, self.n12) - self.assertEqual(cm.exception.parent, self.n18) - self.assertIsNone(self.n12.parent()) - self.assertQuerySetEqual(self.n18.direct_children(), []) - self.assertQuerySetEqual(self.n12.direct_children(), [self.n13]) - with self.assertRaises(NotAChildException) as cm: - self.n18.remove_child(self.n14, check_is_child=True) - self.assertEqual(cm.exception.child, self.n14) - self.assertEqual(cm.exception.parent, self.n18) - self.assertEqual(self.n14.parent(), self.n13) - self.assertQuerySetEqual(self.n18.direct_children(), []) - self.assertQuerySetEqual(self.n14.direct_children(), []) - self.n6.remove_child(self.n10) - self.assertEqual(self.n10.parent(), self.n8) - self.assertQuerySetEqual(self.n6.direct_children(), [self.n9]) - self.assertQuerySetEqual(self.n8.direct_children(), [self.n10]) - self.assertQuerySetEqual(self.n10.direct_children(), [self.n11]) - - def test_advanced_ancestors(self): - self.setup_advanced_nodes() - - self.assertListEqual(self.n1.ancestors(), []) - self.assertListEqual(self.n2.ancestors(), [self.n1]) - self.assertListEqual(self.n3.ancestors(), [self.n1]) - self.assertListEqual(self.n4.ancestors(), [self.n1]) - self.assertListEqual(self.n5.ancestors(), [self.n2, self.n1]) - self.assertListEqual(self.n6.ancestors(), [self.n2, self.n1]) - self.assertListEqual(self.n7.ancestors(), [self.n2, self.n1]) - self.assertListEqual(self.n8.ancestors(), [self.n3, self.n1]) - self.assertListEqual(self.n9.ancestors(), [self.n6, self.n2, self.n1]) - self.assertListEqual(self.n10.ancestors(), [self.n8, self.n3, self.n1]) - self.assertListEqual( - self.n11.ancestors(), [self.n10, self.n8, self.n3, self.n1] - ) - self.assertListEqual(self.n12.ancestors(), []) - self.assertListEqual(self.n13.ancestors(), [self.n12]) - self.assertListEqual(self.n14.ancestors(), [self.n13, self.n12]) - self.assertListEqual(self.n15.ancestors(), []) - self.assertListEqual(self.n16.ancestors(), [self.n15]) - self.assertListEqual(self.n17.ancestors(), [self.n15]) - self.assertListEqual(self.n18.ancestors(), []) - self.assertListEqual(self.n19.ancestors(), []) - self.assertListEqual(self.n20.ancestors(), []) - self.assertListEqual(self.n21.ancestors(), [self.n20]) - self.assertListEqual(self.n22.ancestors(), [self.n20]) - self.assertListEqual(self.n23.ancestors(), [self.n20]) - self.assertListEqual(self.n24.ancestors(), [self.n20]) - self.assertListEqual(self.n25.ancestors(), [self.n20]) - self.assertListEqual(self.n26.ancestors(), [self.n20]) - self.assertListEqual(self.n27.ancestors(), [self.n23, self.n20]) - self.assertListEqual(self.n28.ancestors(), [self.n23, self.n20]) - self.assertListEqual(self.n29.ancestors(), [self.n23, self.n20]) - self.assertListEqual(self.n30.ancestors(), [self.n23, self.n20]) - self.assertListEqual(self.n31.ancestors(), [self.n23, self.n20]) - self.assertListEqual(self.n32.ancestors(), [self.n23, self.n20]) - - def test_advanced_ancestors_options(self): - self.setup_advanced_nodes() - - self.assertListEqual(self.n11.ancestors(max_level=0), []) - self.assertListEqual(self.n11.ancestors(max_level=1), [self.n10]) - self.assertListEqual(self.n11.ancestors(max_level=2), [self.n10, self.n8]) - self.assertListEqual( - self.n11.ancestors(max_level=3), [self.n10, self.n8, self.n3] - ) - self.assertListEqual( - self.n11.ancestors(max_level=4), [self.n10, self.n8, self.n3, self.n1] - ) - self.assertListEqual( - self.n11.ancestors(max_level=5), [self.n10, self.n8, self.n3, self.n1] - ) - - def test_advanced_root(self): - self.setup_advanced_nodes() - - self.assertEqual(self.n1.root(), self.n1) - self.assertEqual(self.n2.root(), self.n1) - self.assertEqual(self.n3.root(), self.n1) - self.assertEqual(self.n4.root(), self.n1) - self.assertEqual(self.n5.root(), self.n1) - self.assertEqual(self.n6.root(), self.n1) - self.assertEqual(self.n7.root(), self.n1) - self.assertEqual(self.n8.root(), self.n1) - self.assertEqual(self.n9.root(), self.n1) - self.assertEqual(self.n10.root(), self.n1) - self.assertEqual(self.n11.root(), self.n1) - self.assertEqual(self.n12.root(), self.n12) - self.assertEqual(self.n13.root(), self.n12) - self.assertEqual(self.n14.root(), self.n12) - self.assertEqual(self.n15.root(), self.n15) - self.assertEqual(self.n16.root(), self.n15) - self.assertEqual(self.n17.root(), self.n15) - self.assertEqual(self.n18.root(), self.n18) - self.assertEqual(self.n19.root(), self.n19) - self.assertEqual(self.n20.root(), self.n20) - self.assertEqual(self.n21.root(), self.n20) - self.assertEqual(self.n22.root(), self.n20) - self.assertEqual(self.n23.root(), self.n20) - self.assertEqual(self.n24.root(), self.n20) - self.assertEqual(self.n25.root(), self.n20) - self.assertEqual(self.n26.root(), self.n20) - self.assertEqual(self.n27.root(), self.n20) - self.assertEqual(self.n28.root(), self.n20) - self.assertEqual(self.n29.root(), self.n20) - self.assertEqual(self.n30.root(), self.n20) - self.assertEqual(self.n31.root(), self.n20) - self.assertEqual(self.n32.root(), self.n20) - - def test_advanced_children(self): - self.setup_advanced_nodes() - - mn1 = Node[self.model_class](self.n1) - mn2 = Node[self.model_class](self.n2) - mn3 = Node[self.model_class](self.n3) - mn4 = Node[self.model_class](self.n4) - mn5 = Node[self.model_class](self.n5) - mn6 = Node[self.model_class](self.n6) - mn7 = Node[self.model_class](self.n7) - mn8 = Node[self.model_class](self.n8) - mn9 = Node[self.model_class](self.n9) - mn10 = Node[self.model_class](self.n10) - mn11 = Node[self.model_class](self.n11) - mn12 = Node[self.model_class](self.n12) - mn13 = Node[self.model_class](self.n13) - mn14 = Node[self.model_class](self.n14) - mn15 = Node[self.model_class](self.n15) - mn16 = Node[self.model_class](self.n16) - mn17 = Node[self.model_class](self.n17) - mn18 = Node[self.model_class](self.n18) - mn19 = Node[self.model_class](self.n19) - mn20 = Node[self.model_class](self.n20) - mn21 = Node[self.model_class](self.n21) - mn22 = Node[self.model_class](self.n22) - mn23 = Node[self.model_class](self.n23) - mn24 = Node[self.model_class](self.n24) - mn25 = Node[self.model_class](self.n25) - mn26 = Node[self.model_class](self.n26) - mn27 = Node[self.model_class](self.n27) - mn28 = Node[self.model_class](self.n28) - mn29 = Node[self.model_class](self.n29) - mn30 = Node[self.model_class](self.n30) - mn31 = Node[self.model_class](self.n31) - mn32 = Node[self.model_class](self.n32) - - mn1.children = [mn2, mn3, mn4] - mn2.children = [mn5, mn6, mn7] - mn3.children = [mn8] - mn6.children = [mn9] - mn8.children = [mn10] - mn10.children = [mn11] - - mn12.children = [mn13] - mn13.children = [mn14] - - mn15.children = [mn16, mn17] - - mn20.children = [mn21, mn22, mn23, mn24, mn25, mn26] - mn23.children = [mn27, mn28, mn29, mn30, mn31, mn32] - - self.assertEqual( - self.n1.children(sibling_transform=lambda x: x.order_by("num")), mn1 - ) - self.assertEqual( - self.n2.children(sibling_transform=lambda x: x.order_by("num")), mn2 - ) - self.assertEqual( - self.n3.children(sibling_transform=lambda x: x.order_by("num")), mn3 - ) - self.assertEqual( - self.n4.children(sibling_transform=lambda x: x.order_by("num")), mn4 - ) - self.assertEqual( - self.n5.children(sibling_transform=lambda x: x.order_by("num")), mn5 - ) - self.assertEqual( - self.n6.children(sibling_transform=lambda x: x.order_by("num")), mn6 - ) - self.assertEqual( - self.n7.children(sibling_transform=lambda x: x.order_by("num")), mn7 - ) - self.assertEqual( - self.n8.children(sibling_transform=lambda x: x.order_by("num")), mn8 - ) - self.assertEqual( - self.n9.children(sibling_transform=lambda x: x.order_by("num")), mn9 - ) - self.assertEqual( - self.n10.children(sibling_transform=lambda x: x.order_by("num")), mn10 - ) - self.assertEqual( - self.n11.children(sibling_transform=lambda x: x.order_by("num")), mn11 - ) - self.assertEqual( - self.n12.children(sibling_transform=lambda x: x.order_by("num")), mn12 - ) - self.assertEqual( - self.n13.children(sibling_transform=lambda x: x.order_by("num")), mn13 - ) - self.assertEqual( - self.n14.children(sibling_transform=lambda x: x.order_by("num")), mn14 - ) - self.assertEqual( - self.n15.children(sibling_transform=lambda x: x.order_by("num")), mn15 - ) - self.assertEqual( - self.n16.children(sibling_transform=lambda x: x.order_by("num")), mn16 - ) - self.assertEqual( - self.n17.children(sibling_transform=lambda x: x.order_by("num")), mn17 - ) - self.assertEqual( - self.n18.children(sibling_transform=lambda x: x.order_by("num")), mn18 - ) - self.assertEqual( - self.n19.children(sibling_transform=lambda x: x.order_by("num")), mn19 - ) - self.assertEqual( - self.n20.children(sibling_transform=lambda x: x.order_by("num")), mn20 - ) - self.assertEqual( - self.n21.children(sibling_transform=lambda x: x.order_by("num")), mn21 - ) - self.assertEqual( - self.n22.children(sibling_transform=lambda x: x.order_by("num")), mn22 - ) - self.assertEqual( - self.n23.children(sibling_transform=lambda x: x.order_by("num")), mn23 - ) - self.assertEqual( - self.n24.children(sibling_transform=lambda x: x.order_by("num")), mn24 - ) - self.assertEqual( - self.n25.children(sibling_transform=lambda x: x.order_by("num")), mn25 - ) - self.assertEqual( - self.n26.children(sibling_transform=lambda x: x.order_by("num")), mn26 - ) - self.assertEqual( - self.n27.children(sibling_transform=lambda x: x.order_by("num")), mn27 - ) - self.assertEqual( - self.n28.children(sibling_transform=lambda x: x.order_by("num")), mn28 - ) - self.assertEqual( - self.n29.children(sibling_transform=lambda x: x.order_by("num")), mn29 - ) - self.assertEqual( - self.n30.children(sibling_transform=lambda x: x.order_by("num")), mn30 - ) - self.assertEqual( - self.n31.children(sibling_transform=lambda x: x.order_by("num")), mn31 - ) - self.assertEqual( - self.n32.children(sibling_transform=lambda x: x.order_by("num")), mn32 - ) - - def test_advanced_children_options(self): - self.setup_advanced_nodes() - - mn1 = Node[self.model_class](self.n1) - mn2 = Node[self.model_class](self.n2) - mn3 = Node[self.model_class](self.n3) - mn4 = Node[self.model_class](self.n4) - mn5 = Node[self.model_class](self.n5) - mn6 = Node[self.model_class](self.n6) - mn7 = Node[self.model_class](self.n7) - mn8 = Node[self.model_class](self.n8) - mn9 = Node[self.model_class](self.n9) - mn10 = Node[self.model_class](self.n10) - mn11 = Node[self.model_class](self.n11) - mn12 = Node[self.model_class](self.n12) - mn13 = Node[self.model_class](self.n13) - mn14 = Node[self.model_class](self.n14) - mn15 = Node[self.model_class](self.n15) - mn16 = Node[self.model_class](self.n16) - mn17 = Node[self.model_class](self.n17) - mn20 = Node[self.model_class](self.n20) - mn21 = Node[self.model_class](self.n21) - mn22 = Node[self.model_class](self.n22) - mn23 = Node[self.model_class](self.n23) - mn24 = Node[self.model_class](self.n24) - mn25 = Node[self.model_class](self.n25) - mn26 = Node[self.model_class](self.n26) - mn27 = Node[self.model_class](self.n27) - mn28 = Node[self.model_class](self.n28) - mn29 = Node[self.model_class](self.n29) - mn30 = Node[self.model_class](self.n30) - mn31 = Node[self.model_class](self.n31) - mn32 = Node[self.model_class](self.n32) - - mn1.children = [mn2, mn3, mn4] - mn2.children = [mn5, mn6, mn7] - mn3.children = [mn8] - mn6.children = [mn9] - mn8.children = [mn10] - mn10.children = [mn11] - - mn12.children = [mn13] - mn13.children = [mn14] - - mn15.children = [mn16, mn17] - - mn20.children = [mn21, mn22, mn23, mn24, mn25, mn26] - mn23.children = [mn27, mn28, mn29, mn30, mn31, mn32] - - t1 = copy.copy(mn1) - t1.children[0].children[1].children = [] - t1.children[1].children[0].children = [] - - self.assertEqual( - self.n1.children( - max_generations=2, sibling_transform=lambda x: x.order_by("num") - ), - t1, - ) - - t2 = copy.copy(mn1) - t2.children = t2.children[:2] - t2.children[0].children = t2.children[0].children[:2] - - self.assertEqual( - self.n1.children( - max_siblings=2, sibling_transform=lambda x: x.order_by("num") - ), - t2, - ) - - t3 = copy.copy(mn1) - t3.children[0].children = t3.children[0].children[:2] - t3.children[0].children[1].children = [] - t3.children[1].children = [] - - self.assertEqual( - self.n1.children( - max_total=6, sibling_transform=lambda x: x.order_by("num") - ), - t3, - ) - - t4 = copy.copy(mn1) - t4.children = t4.children[:2] - t4.children[0].children = t4.children[0].children[:2] - t4.children[1].children[0].children[0].children = [] - - self.assertEqual( - self.n1.children( - max_generations=3, - max_siblings=2, - sibling_transform=lambda x: x.order_by("num"), - ), - t4, - ) - - t5 = copy.copy(mn20) - t5.children = t5.children[:3] - t5.children[2].children = [] - - self.assertEqual( - self.n20.children( - max_generations=1, - max_siblings=3, - sibling_transform=lambda x: x.order_by("num"), - ), - t5, - ) - - t6 = copy.copy(mn20) - t6.children = t6.children[:4] - t6.children[2].children = t6.children[2].children[:1] - - self.assertEqual( - self.n20.children( - max_siblings=4, - max_total=6, - sibling_transform=lambda x: x.order_by("num"), - ), - t6, - ) - - t7 = copy.copy(mn1) - t7.children = t7.children[:2] - t7.children[0].children = t7.children[0].children[:2] - t7.children[1].children[0].children = [] - - self.assertEqual( - self.n1.children( - max_generations=3, - max_siblings=2, - max_total=7, - sibling_transform=lambda x: x.order_by("num"), - ), - t7, - ) - - self.assertEqual( - self.n15.children( - max_generations=4, sibling_transform=lambda x: x.order_by("num") - ), - mn15, - ) - self.assertEqual( - self.n15.children( - max_siblings=5, sibling_transform=lambda x: x.order_by("num") - ), - mn15, - ) - self.assertEqual( - self.n15.children( - max_total=10, sibling_transform=lambda x: x.order_by("num") - ), - mn15, - ) - self.assertEqual( - self.n15.children( - max_generations=4, - max_siblings=5, - sibling_transform=lambda x: x.order_by("num"), - ), - mn15, - ) - self.assertEqual( - self.n15.children( - max_generations=4, - max_total=10, - sibling_transform=lambda x: x.order_by("num"), - ), - mn15, - ) - self.assertEqual( - self.n15.children( - max_siblings=5, - max_total=10, - sibling_transform=lambda x: x.order_by("num"), - ), - mn15, - ) - self.assertEqual( - self.n15.children( - max_generations=4, - max_siblings=5, - max_total=10, - sibling_transform=lambda x: x.order_by("num"), - ), - mn15, - ) - - def test_advanced_is_child(self): - self.setup_advanced_nodes() - - self.assertTrue(self.n9.is_child_of(self.n1)) - self.assertTrue(self.n9.is_child_of(self.n2)) - self.assertTrue(self.n9.is_child_of(self.n6)) - self.assertFalse(self.n9.is_child_of(self.n3)) - self.assertFalse(self.n9.is_child_of(self.n5)) - - self.assertTrue(self.n14.is_child_of(self.n13)) - self.assertTrue(self.n14.is_child_of(self.n12)) - self.assertTrue(self.n13.is_child_of(self.n12)) - self.assertFalse(self.n13.is_child_of(self.n14)) - self.assertFalse(self.n12.is_child_of(self.n14)) - self.assertFalse(self.n12.is_child_of(self.n13)) - - self.assertTrue(self.n26.is_child_of(self.n20)) - self.assertFalse(self.n20.is_child_of(self.n26)) - self.assertTrue(self.n30.is_child_of(self.n23)) - self.assertTrue(self.n30.is_child_of(self.n20)) - self.assertFalse(self.n30.is_child_of(self.n24)) - self.assertFalse(self.n30.is_child_of(self.n31)) - - self.assertFalse(self.n1.is_child_of(self.n1)) - self.assertFalse(self.n16.is_child_of(self.n16)) - self.assertFalse(self.n32.is_child_of(self.n32)) diff --git a/tests/models.py b/tests/models.py index f5d509f..9387899 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,36 +1,10 @@ from django.db import models -from django_hierarchical_models.models import ( - AdjacencyListModel, - NestedSetModel, - PathEnumerationModel, -) +from django_hierarchical_models.models import HierarchicalModel -class NumberModelMixin(models.Model): +class ExampleModel(HierarchicalModel): num = models.IntegerField() - class Meta: - abstract = True - def __str__(self): return str(self.num) - - -class ALMTestModel(NumberModelMixin, AdjacencyListModel): - pass - - -class NSMTestModel(NumberModelMixin, NestedSetModel): - class Meta: - indexes = [ - models.Index(fields=["_left"]), - models.Index(fields=["_right"]), - ] - - def __str__(self): - return f"({self.num}|{self._left}|{self._right})" - - -class PEMTestModel(NumberModelMixin, PathEnumerationModel): - pass diff --git a/tests/nsm_test.py b/tests/nsm_test.py deleted file mode 100644 index 7b89aa0..0000000 --- a/tests/nsm_test.py +++ /dev/null @@ -1,381 +0,0 @@ -from django.test import TestCase - -from tests.models import NSMTestModel - - -class NSMTests(TestCase): - - def assert_chunk(self, node: NSMTestModel, left: int, right: int): - self.assertEqual(node._left, left, f"{node}._left != {left}") - self.assertEqual(node._right, right, f"{node}._right != {right}") - - def test_child_from_right(self): - n1 = NSMTestModel.objects.create(num=1) - self.assert_chunk(n1, 0, 1) - self.assertIsNone(n1.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertEqual(n1.root(), n1) - - self.assertQuerySetEqual(n1.direct_children(), []) - n2 = NSMTestModel.objects.create(num=2) - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 3) - self.assertIsNone(n1.parent()) - self.assertIsNone(n2.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), []) - self.assertEqual(n1.root(), n1) - self.assertEqual(n2.root(), n2) - - n3 = NSMTestModel.objects.create(num=3) - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 3) - self.assert_chunk(n3, 4, 5) - self.assertIsNone(n1.parent()) - self.assertIsNone(n2.parent()) - self.assertIsNone(n3.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), []) - self.assertQuerySetEqual(n3.direct_children(), []) - self.assertEqual(n1.root(), n1) - self.assertEqual(n2.root(), n2) - self.assertEqual(n3.root(), n3) - - n3.set_parent(n2) - n1.refresh_from_db() - n2.refresh_from_db() - n3.refresh_from_db() - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 5) - self.assert_chunk(n3, 3, 4) - self.assertIsNone(n1.parent()) - self.assertIsNone(n2.parent()) - self.assertEqual(n3.parent(), n2) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), [n3]) - self.assertQuerySetEqual(n3.direct_children(), []) - self.assertEqual(n1.root(), n1) - self.assertEqual(n2.root(), n2) - self.assertEqual(n3.root(), n2) - - n2.set_parent(n1) - n1.refresh_from_db() - n2.refresh_from_db() - n3.refresh_from_db() - self.assert_chunk(n1, 0, 5) - self.assert_chunk(n2, 1, 4) - self.assert_chunk(n3, 2, 3) - self.assertIsNone(n1.parent()) - self.assertEqual(n2.parent(), n1) - self.assertEqual(n3.parent(), n2) - self.assertQuerySetEqual(n1.direct_children(), [n2]) - self.assertQuerySetEqual(n2.direct_children(), [n3]) - self.assertQuerySetEqual(n3.direct_children(), []) - self.assertEqual(n1.root(), n1) - self.assertEqual(n2.root(), n1) - self.assertEqual(n3.root(), n1) - - def test_child_from_left(self): - n1 = NSMTestModel.objects.create(num=1) - self.assert_chunk(n1, 0, 1) - self.assertIsNone(n1.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertEqual(n1.root(), n1) - - n2 = NSMTestModel.objects.create(num=2) - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 3) - self.assertIsNone(n1.parent()) - self.assertIsNone(n2.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), []) - self.assertEqual(n1.root(), n1) - self.assertEqual(n2.root(), n2) - - n3 = NSMTestModel.objects.create(num=3) - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 3) - self.assert_chunk(n3, 4, 5) - self.assertIsNone(n1.parent()) - self.assertIsNone(n2.parent()) - self.assertIsNone(n3.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), []) - self.assertQuerySetEqual(n3.direct_children(), []) - self.assertEqual(n1.root(), n1) - self.assertEqual(n2.root(), n2) - self.assertEqual(n3.root(), n3) - - n1.set_parent(n2) - n1.refresh_from_db() - n2.refresh_from_db() - n3.refresh_from_db() - self.assert_chunk(n1, 1, 2) - self.assert_chunk(n2, 0, 3) - self.assert_chunk(n3, 4, 5) - self.assertEqual(n1.parent(), n2) - self.assertIsNone(n2.parent()) - self.assertIsNone(n3.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), [n1]) - self.assertQuerySetEqual(n3.direct_children(), []) - self.assertEqual(n1.root(), n2) - self.assertEqual(n2.root(), n2) - self.assertEqual(n3.root(), n3) - - n2.set_parent(n3) - n1.refresh_from_db() - n2.refresh_from_db() - n3.refresh_from_db() - self.assert_chunk(n1, 2, 3) - self.assert_chunk(n2, 1, 4) - self.assert_chunk(n3, 0, 5) - self.assertEqual(n1.parent(), n2) - self.assertEqual(n2.parent(), n3) - self.assertIsNone(n3.parent()) - self.assertQuerySetEqual(n1.direct_children(), []) - self.assertQuerySetEqual(n2.direct_children(), [n1]) - self.assertQuerySetEqual(n3.direct_children(), [n2]) - self.assertEqual(n1.root(), n3) - self.assertEqual(n2.root(), n3) - self.assertEqual(n3.root(), n3) - - def test_consolidate(self): - n1 = NSMTestModel.objects.create(num=1) - n2 = NSMTestModel.objects.create(num=2) - n3 = NSMTestModel.objects.create(num=3) - n4 = NSMTestModel.objects.create(num=4) - n5 = NSMTestModel.objects.create(num=5) - n6 = NSMTestModel.objects.create(num=6) - n7 = NSMTestModel.objects.create(num=7) - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 3) - self.assert_chunk(n3, 4, 5) - self.assert_chunk(n4, 6, 7) - self.assert_chunk(n5, 8, 9) - self.assert_chunk(n6, 10, 11) - self.assert_chunk(n7, 12, 13) - n7.set_parent(n2) - for node in (n1, n2, n3, n4, n5, n6, n7): - node.refresh_from_db() - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 5) - self.assert_chunk(n3, 6, 7) - self.assert_chunk(n4, 8, 9) - self.assert_chunk(n5, 10, 11) - self.assert_chunk(n6, 12, 13) - self.assert_chunk(n7, 3, 4) - n4.set_parent(n5) - for node in (n1, n2, n3, n4, n5, n6, n7): - node.refresh_from_db() - self.assert_chunk(n1, 0, 1) - self.assert_chunk(n2, 2, 5) - self.assert_chunk(n3, 6, 7) - self.assert_chunk(n4, 9, 10) - self.assert_chunk(n5, 8, 11) - self.assert_chunk(n6, 12, 13) - self.assert_chunk(n7, 3, 4) - n1.set_parent(n5) - for node in (n1, n2, n3, n4, n5, n6, n7): - node.refresh_from_db() - self.assert_chunk(n1, 7, 8) - self.assert_chunk(n2, 0, 3) - self.assert_chunk(n3, 4, 5) - self.assert_chunk(n4, 9, 10) - self.assert_chunk(n5, 6, 11) - self.assert_chunk(n6, 12, 13) - self.assert_chunk(n7, 1, 2) - n5.set_parent(n2) - for node in (n1, n2, n3, n4, n5, n6, n7): - node.refresh_from_db() - self.assert_chunk(n1, 4, 5) - self.assert_chunk(n2, 0, 9) - self.assert_chunk(n3, 10, 11) - self.assert_chunk(n4, 6, 7) - self.assert_chunk(n5, 3, 8) - self.assert_chunk(n6, 12, 13) - self.assert_chunk(n7, 1, 2) - n3.set_parent(n1) - for node in (n1, n2, n3, n4, n5, n6, n7): - node.refresh_from_db() - self.assert_chunk(n1, 4, 7) - self.assert_chunk(n2, 0, 11) - self.assert_chunk(n3, 5, 6) - self.assert_chunk(n4, 8, 9) - self.assert_chunk(n5, 3, 10) - self.assert_chunk(n6, 12, 13) - self.assert_chunk(n7, 1, 2) - n2.set_parent(n6) - for node in (n1, n2, n3, n4, n5, n6, n7): - node.refresh_from_db() - self.assert_chunk(n1, 5, 8) - self.assert_chunk(n2, 1, 12) - self.assert_chunk(n3, 6, 7) - self.assert_chunk(n4, 9, 10) - self.assert_chunk(n5, 4, 11) - self.assert_chunk(n6, 0, 13) - self.assert_chunk(n7, 2, 3) - - def test_deconsolidate(self): - # ASSUMES CONSOLIDATE IS PASSING - n1 = NSMTestModel.objects.create(num=1) - n2 = NSMTestModel.objects.create(num=2) - n3 = NSMTestModel.objects.create(num=3) - n4 = NSMTestModel.objects.create(num=4) - n5 = NSMTestModel.objects.create(num=5) - n6 = NSMTestModel.objects.create(num=6) - n7 = NSMTestModel.objects.create(num=7) - n7.set_parent(n2) - n4.set_parent(n5) - n1.set_parent(n5) - n5.set_parent(n2) - n3.set_parent(n1) - n2.set_parent(n6) - - # check that consolidation passed - for chunk in ( - (n1, 5, 8), - (n2, 1, 12), - (n3, 6, 7), - (n4, 9, 10), - (n5, 4, 11), - (n6, 0, 13), - (n7, 2, 3), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - n4.set_parent(None) - for chunk in ( - (n1, 5, 8), - (n2, 1, 10), - (n3, 6, 7), - (n4, 12, 13), - (n5, 4, 9), - (n6, 0, 11), - (n7, 2, 3), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - n2.set_parent(None) - for chunk in ( - (n1, 6, 9), - (n2, 2, 11), - (n3, 7, 8), - (n4, 12, 13), - (n5, 5, 10), - (n6, 0, 1), - (n7, 3, 4), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - n3.set_parent(None) - for chunk in ( - (n1, 6, 7), - (n2, 2, 9), - (n3, 10, 11), - (n4, 12, 13), - (n5, 5, 8), - (n6, 0, 1), - (n7, 3, 4), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - n7.set_parent(None) - for chunk in ( - (n1, 6, 7), - (n2, 4, 9), - (n3, 10, 11), - (n4, 12, 13), - (n5, 5, 8), - (n6, 0, 1), - (n7, 2, 3), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - n5.set_parent(None) - for chunk in ( - (n1, 7, 8), - (n2, 4, 5), - (n3, 10, 11), - (n4, 12, 13), - (n5, 6, 9), - (n6, 0, 1), - (n7, 2, 3), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - n1.set_parent(None) - for chunk in ( - (n1, 8, 9), - (n2, 4, 5), - (n3, 10, 11), - (n4, 12, 13), - (n5, 6, 7), - (n6, 0, 1), - (n7, 2, 3), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - # nothing happens here, everything is already orphaned - n6.set_parent(None) - for chunk in ( - (n1, 8, 9), - (n2, 4, 5), - (n3, 10, 11), - (n4, 12, 13), - (n5, 6, 7), - (n6, 0, 1), - (n7, 2, 3), - ): - chunk[0].refresh_from_db() - self.assert_chunk(*chunk) - - def test_num_children(self): - n1 = NSMTestModel.objects.create(num=1) - n2 = NSMTestModel.objects.create(num=2) - n3 = NSMTestModel.objects.create(num=3) - n4 = NSMTestModel.objects.create(num=4) - n5 = NSMTestModel.objects.create(num=5) - n6 = NSMTestModel.objects.create(num=6) - n7 = NSMTestModel.objects.create(num=7) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (0, 0, 0, 0, 0, 0, 0) - ): - self.assertEqual(node.num_children(), num_children) - n7.set_parent(n2) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (0, 1, 0, 0, 0, 0, 0) - ): - self.assertEqual(node.num_children(), num_children) - n4.set_parent(n5) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (0, 1, 0, 0, 1, 0, 0) - ): - self.assertEqual(node.num_children(), num_children) - n1.set_parent(n5) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (0, 1, 0, 0, 2, 0, 0) - ): - self.assertEqual(node.num_children(), num_children) - n5.set_parent(n2) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (0, 4, 0, 0, 2, 0, 0) - ): - self.assertEqual(node.num_children(), num_children) - n3.set_parent(n1) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (1, 5, 0, 0, 3, 0, 0) - ): - self.assertEqual(node.num_children(), num_children) - n2.set_parent(n6) - for node, num_children in zip( - (n1, n2, n3, n4, n5, n6, n7), (1, 5, 0, 0, 3, 6, 0) - ): - self.assertEqual(node.num_children(), num_children) From 127d6c940a9036114e28b7fb7650b20cfd6c7409 Mon Sep 17 00:00:00 2001 From: Josh Bedwell Date: Sun, 28 Apr 2024 09:23:42 -0500 Subject: [PATCH 2/5] Got functionality working --- .../models/hierarchical_model.py | 30 +- django_hierarchical_models/models/node.py | 23 +- tests/hm_test.py | 261 +++++++++++++----- 3 files changed, 225 insertions(+), 89 deletions(-) diff --git a/django_hierarchical_models/models/hierarchical_model.py b/django_hierarchical_models/models/hierarchical_model.py index 5db05b2..a30d02b 100644 --- a/django_hierarchical_models/models/hierarchical_model.py +++ b/django_hierarchical_models/models/hierarchical_model.py @@ -1,3 +1,5 @@ +from collections import deque +from collections.abc import Callable from typing import TypeVar from django.db import models @@ -53,7 +55,7 @@ def root(self: T) -> T: def ancestors( self: T, max_level: int | None = None, - ) -> list[T]: # TODO more generic type hint? also this can be cleaned up + ) -> list[T]: if max_level is None: max_level = -1 ancestors = [] @@ -72,5 +74,27 @@ def direct_children( object_manager = self.__class__._default_manager return object_manager.filter(_parent=self) - def children(self: T) -> Node[T]: - raise NotImplementedError() + def children( + self: T, + max_generations: int | None = None, + max_siblings: int | None = None, + max_total: int | None = None, + sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None = None, + ) -> Node[T]: + if max_total is None: + max_total = -1 + root = Node[T](self) + queue = deque([(Node[T](None), root, 0)]) + while queue and max_total != 0: + parent, node, generation = queue.popleft() + parent.children.append(node) + max_total -= 1 + if max_generations is None or generation < max_generations: + children = node.instance.direct_children() + if sibling_transform is not None: + children = sibling_transform(children) + if max_siblings is not None: + children = children[:max_siblings] + for child in children: + queue.append((node, Node[T](child), generation + 1)) + return root diff --git a/django_hierarchical_models/models/node.py b/django_hierarchical_models/models/node.py index b9590ae..5620187 100644 --- a/django_hierarchical_models/models/node.py +++ b/django_hierarchical_models/models/node.py @@ -1,31 +1,16 @@ from __future__ import annotations import copy +from dataclasses import dataclass, field from typing import Generic, TypeVar T = TypeVar("T") +@dataclass class Node(Generic[T]): - def __init__( - self, - instance: T, - children: list[Node[T]] | None = None, - ): - self.instance = instance - self.children = children if children is not None else [] - - def __eq__(self, other): - if not isinstance(other, Node): - return False - if self.instance != other.instance: - return False - if len(self.children) != len(other.children): - return False - return all( - self_child == other_child - for self_child, other_child in zip(self.children, other.children) - ) + instance: T + children: list[Node[T]] = field(default_factory=list) def __copy__(self): return Node(self.instance, [copy.copy(child) for child in self.children]) diff --git a/tests/hm_test.py b/tests/hm_test.py index 84a85ac..534f27d 100644 --- a/tests/hm_test.py +++ b/tests/hm_test.py @@ -45,8 +45,8 @@ def test_delete(self): child.set_parent(parent) self.assertIsNone(parent.parent()) self.assertEqual(child.parent(), parent) - parent.delete() # TODO need to decide what the defined behavior is here - self.assertIsNone(parent.parent()) + parent.delete() + child.refresh_from_db() self.assertIsNone(child.parent()) def test_create_cycle(self): @@ -246,6 +246,40 @@ def test_direct_children(self): def test_delete(self): self.n3.delete() + for node in ( + self.n1, + self.n2, + self.n4, + self.n5, + self.n6, + self.n7, + self.n8, + self.n9, + self.n10, + self.n11, + self.n12, + self.n13, + self.n14, + self.n15, + self.n16, + self.n17, + self.n18, + self.n19, + self.n20, + self.n21, + self.n22, + self.n23, + self.n24, + self.n25, + self.n26, + self.n27, + self.n28, + self.n29, + self.n30, + self.n31, + self.n32, + ): + node.refresh_from_db() for node, parent in ( (self.n1, None), (self.n2, self.n1), @@ -282,6 +316,39 @@ def test_delete(self): self.assertEqual(node.parent(), parent) self.n13.delete() + for node in ( + self.n1, + self.n2, + self.n4, + self.n5, + self.n6, + self.n7, + self.n8, + self.n9, + self.n10, + self.n11, + self.n12, + self.n14, + self.n15, + self.n16, + self.n17, + self.n18, + self.n19, + self.n20, + self.n21, + self.n22, + self.n23, + self.n24, + self.n25, + self.n26, + self.n27, + self.n28, + self.n29, + self.n30, + self.n31, + self.n32, + ): + node.refresh_from_db() for node, parent in ( (self.n1, None), (self.n2, self.n1), @@ -317,6 +384,38 @@ def test_delete(self): self.assertEqual(node.parent(), parent) self.n20.delete() + for node in ( + self.n1, + self.n2, + self.n4, + self.n5, + self.n6, + self.n7, + self.n8, + self.n9, + self.n10, + self.n11, + self.n12, + self.n14, + self.n15, + self.n16, + self.n17, + self.n18, + self.n19, + self.n21, + self.n22, + self.n23, + self.n24, + self.n25, + self.n26, + self.n27, + self.n28, + self.n29, + self.n30, + self.n31, + self.n32, + ): + node.refresh_from_db() for node, parent in ( (self.n1, None), (self.n2, self.n1), @@ -351,6 +450,37 @@ def test_delete(self): self.assertEqual(node.parent(), parent) self.n6.delete() + for node in ( + self.n1, + self.n2, + self.n4, + self.n5, + self.n7, + self.n8, + self.n9, + self.n10, + self.n11, + self.n12, + self.n14, + self.n15, + self.n16, + self.n17, + self.n18, + self.n19, + self.n21, + self.n22, + self.n23, + self.n24, + self.n25, + self.n26, + self.n27, + self.n28, + self.n29, + self.n30, + self.n31, + self.n32, + ): + node.refresh_from_db() for node, parent in ( (self.n1, None), (self.n2, self.n1), @@ -520,42 +650,39 @@ def test_is_child(self): ): self.assertFalse(child.is_child_of(parent)) - -class HierarchicalModelTestOld: - def test_advanced_children(self): - mn1 = Node[self.model_class](self.n1) - mn2 = Node[self.model_class](self.n2) - mn3 = Node[self.model_class](self.n3) - mn4 = Node[self.model_class](self.n4) - mn5 = Node[self.model_class](self.n5) - mn6 = Node[self.model_class](self.n6) - mn7 = Node[self.model_class](self.n7) - mn8 = Node[self.model_class](self.n8) - mn9 = Node[self.model_class](self.n9) - mn10 = Node[self.model_class](self.n10) - mn11 = Node[self.model_class](self.n11) - mn12 = Node[self.model_class](self.n12) - mn13 = Node[self.model_class](self.n13) - mn14 = Node[self.model_class](self.n14) - mn15 = Node[self.model_class](self.n15) - mn16 = Node[self.model_class](self.n16) - mn17 = Node[self.model_class](self.n17) - mn18 = Node[self.model_class](self.n18) - mn19 = Node[self.model_class](self.n19) - mn20 = Node[self.model_class](self.n20) - mn21 = Node[self.model_class](self.n21) - mn22 = Node[self.model_class](self.n22) - mn23 = Node[self.model_class](self.n23) - mn24 = Node[self.model_class](self.n24) - mn25 = Node[self.model_class](self.n25) - mn26 = Node[self.model_class](self.n26) - mn27 = Node[self.model_class](self.n27) - mn28 = Node[self.model_class](self.n28) - mn29 = Node[self.model_class](self.n29) - mn30 = Node[self.model_class](self.n30) - mn31 = Node[self.model_class](self.n31) - mn32 = Node[self.model_class](self.n32) + mn1 = Node[ExampleModel](self.n1) + mn2 = Node[ExampleModel](self.n2) + mn3 = Node[ExampleModel](self.n3) + mn4 = Node[ExampleModel](self.n4) + mn5 = Node[ExampleModel](self.n5) + mn6 = Node[ExampleModel](self.n6) + mn7 = Node[ExampleModel](self.n7) + mn8 = Node[ExampleModel](self.n8) + mn9 = Node[ExampleModel](self.n9) + mn10 = Node[ExampleModel](self.n10) + mn11 = Node[ExampleModel](self.n11) + mn12 = Node[ExampleModel](self.n12) + mn13 = Node[ExampleModel](self.n13) + mn14 = Node[ExampleModel](self.n14) + mn15 = Node[ExampleModel](self.n15) + mn16 = Node[ExampleModel](self.n16) + mn17 = Node[ExampleModel](self.n17) + mn18 = Node[ExampleModel](self.n18) + mn19 = Node[ExampleModel](self.n19) + mn20 = Node[ExampleModel](self.n20) + mn21 = Node[ExampleModel](self.n21) + mn22 = Node[ExampleModel](self.n22) + mn23 = Node[ExampleModel](self.n23) + mn24 = Node[ExampleModel](self.n24) + mn25 = Node[ExampleModel](self.n25) + mn26 = Node[ExampleModel](self.n26) + mn27 = Node[ExampleModel](self.n27) + mn28 = Node[ExampleModel](self.n28) + mn29 = Node[ExampleModel](self.n29) + mn30 = Node[ExampleModel](self.n30) + mn31 = Node[ExampleModel](self.n31) + mn32 = Node[ExampleModel](self.n32) mn1.children = [mn2, mn3, mn4] mn2.children = [mn5, mn6, mn7] @@ -670,36 +797,36 @@ def test_advanced_children(self): ) def test_advanced_children_options(self): - mn1 = Node[self.model_class](self.n1) - mn2 = Node[self.model_class](self.n2) - mn3 = Node[self.model_class](self.n3) - mn4 = Node[self.model_class](self.n4) - mn5 = Node[self.model_class](self.n5) - mn6 = Node[self.model_class](self.n6) - mn7 = Node[self.model_class](self.n7) - mn8 = Node[self.model_class](self.n8) - mn9 = Node[self.model_class](self.n9) - mn10 = Node[self.model_class](self.n10) - mn11 = Node[self.model_class](self.n11) - mn12 = Node[self.model_class](self.n12) - mn13 = Node[self.model_class](self.n13) - mn14 = Node[self.model_class](self.n14) - mn15 = Node[self.model_class](self.n15) - mn16 = Node[self.model_class](self.n16) - mn17 = Node[self.model_class](self.n17) - mn20 = Node[self.model_class](self.n20) - mn21 = Node[self.model_class](self.n21) - mn22 = Node[self.model_class](self.n22) - mn23 = Node[self.model_class](self.n23) - mn24 = Node[self.model_class](self.n24) - mn25 = Node[self.model_class](self.n25) - mn26 = Node[self.model_class](self.n26) - mn27 = Node[self.model_class](self.n27) - mn28 = Node[self.model_class](self.n28) - mn29 = Node[self.model_class](self.n29) - mn30 = Node[self.model_class](self.n30) - mn31 = Node[self.model_class](self.n31) - mn32 = Node[self.model_class](self.n32) + mn1 = Node[ExampleModel](self.n1) + mn2 = Node[ExampleModel](self.n2) + mn3 = Node[ExampleModel](self.n3) + mn4 = Node[ExampleModel](self.n4) + mn5 = Node[ExampleModel](self.n5) + mn6 = Node[ExampleModel](self.n6) + mn7 = Node[ExampleModel](self.n7) + mn8 = Node[ExampleModel](self.n8) + mn9 = Node[ExampleModel](self.n9) + mn10 = Node[ExampleModel](self.n10) + mn11 = Node[ExampleModel](self.n11) + mn12 = Node[ExampleModel](self.n12) + mn13 = Node[ExampleModel](self.n13) + mn14 = Node[ExampleModel](self.n14) + mn15 = Node[ExampleModel](self.n15) + mn16 = Node[ExampleModel](self.n16) + mn17 = Node[ExampleModel](self.n17) + mn20 = Node[ExampleModel](self.n20) + mn21 = Node[ExampleModel](self.n21) + mn22 = Node[ExampleModel](self.n22) + mn23 = Node[ExampleModel](self.n23) + mn24 = Node[ExampleModel](self.n24) + mn25 = Node[ExampleModel](self.n25) + mn26 = Node[ExampleModel](self.n26) + mn27 = Node[ExampleModel](self.n27) + mn28 = Node[ExampleModel](self.n28) + mn29 = Node[ExampleModel](self.n29) + mn30 = Node[ExampleModel](self.n30) + mn31 = Node[ExampleModel](self.n31) + mn32 = Node[ExampleModel](self.n32) mn1.children = [mn2, mn3, mn4] mn2.children = [mn5, mn6, mn7] From 4540b6198509c15ba36b167e25043d35463ddf22 Mon Sep 17 00:00:00 2001 From: Josh Bedwell Date: Sun, 28 Apr 2024 11:15:29 -0500 Subject: [PATCH 3/5] Fixed mypy Fixed benchmark --- .../models/hierarchical_model.py | 17 +-- tests/benchmark.py | 113 ++---------------- 2 files changed, 17 insertions(+), 113 deletions(-) diff --git a/django_hierarchical_models/models/hierarchical_model.py b/django_hierarchical_models/models/hierarchical_model.py index a30d02b..fb0e86a 100644 --- a/django_hierarchical_models/models/hierarchical_model.py +++ b/django_hierarchical_models/models/hierarchical_model.py @@ -3,7 +3,8 @@ from typing import TypeVar from django.db import models -from django.db.models import Manager, QuerySet +from django.db.models import QuerySet +from django.db.models.manager import BaseManager from django_hierarchical_models.models.exceptions import CycleException from django_hierarchical_models.models.node import Node @@ -26,7 +27,7 @@ class Meta: abstract = True def parent(self: T) -> T | None: - return self._parent + return self._parent # type: ignore def set_parent(self: T, parent: T | None, unchecked: bool = False): if ( @@ -49,7 +50,7 @@ def is_child_of(self: T, parent: T) -> bool: def root(self: T) -> T: root = self while root._parent is not None: - root = root._parent + root = root._parent # type: ignore return root def ancestors( @@ -59,16 +60,17 @@ def ancestors( if max_level is None: max_level = -1 ancestors = [] - ancestor = self._parent + ancestor: T | None + ancestor = self._parent # type: ignore while ancestor is not None and max_level != 0: ancestors.append(ancestor) - ancestor = ancestor._parent + ancestor = ancestor._parent # type: ignore max_level -= 1 return ancestors def direct_children( self: T, - object_manager: Manager[T] | None = None, + object_manager: BaseManager[T] | None = None, ) -> QuerySet[T]: if object_manager is None: object_manager = self.__class__._default_manager @@ -84,7 +86,8 @@ def children( if max_total is None: max_total = -1 root = Node[T](self) - queue = deque([(Node[T](None), root, 0)]) + queue: deque[tuple[Node[T], Node[T], int]] + queue = deque([(Node[T](None), root, 0)]) # type: ignore while queue and max_total != 0: parent, node, generation = queue.popleft() parent.children.append(node) diff --git a/tests/benchmark.py b/tests/benchmark.py index 24b1f94..808ad51 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -2,98 +2,10 @@ from django.test import TestCase -from django_hierarchical_models.models import HierarchicalModelInterface -from tests.models import ALMTestModel, NSMTestModel, PEMTestModel - - -class EditBenchmark(TestCase): - model_class = HierarchicalModelInterface - - def setUp(self): - self.instances = [self.model_class.objects.create(num=i) for i in range(1000)] - - def test_create_large(self): - for i in range(5000): - self.model_class.objects.create(num=i) - - def test_create_child(self): - for i, instance in enumerate(self.instances): - instance.create_child(num=i + len(self.instances)) - - def test_set_parents(self): - for child, parent in pairwise(self.instances): - child.set_parent(parent) - for parent, child in pairwise(self.instances[::-1]): - child.set_parent(parent) - for instance in self.instances: - instance.set_parent(None) - - for i in range(len(self.instances) // 2): - child, parent = ( - self.instances[i], - self.instances[i + len(self.instances) // 2], - ) - child.set_parent(parent) - - def test_delete(self): - for instance in self.instances[::2]: - instance.delete() - - for instance in self.instances[1::2]: - instance.delete() - - def test_delete_parents(self): - for i in range(len(self.instances) // 2): - child, parent = self.instances[i], self.instances[-i - 1] - child.set_parent(parent) - - for instance in self.instances[::2]: - instance.delete() - - for instance in self.instances[1::2]: - instance.delete() - - def test_add_child(self): - for instance in self.instances[:-1]: - self.instances[-1].add_child(instance) - - def test_remove_child(self): - for instance in self.instances[:-1]: - self.instances[-1].add_child(instance) - - for instance in self.instances[:-1]: - self.instances[-1].remove_child(instance) - - -class ALMEditBenchmark(EditBenchmark): - model_class = ALMTestModel - - def test_set_parents_unchecked(self): - for child, parent in pairwise(self.instances): - child.set_parent_unchecked(parent) - for parent, child in pairwise(self.instances[::-1]): - child.set_parent_unchecked(parent) - for instance in self.instances: - instance.set_parent_unchecked(None) - - for i in range(len(self.instances) // 2): - child, parent = ( - self.instances[i], - self.instances[i + len(self.instances) // 2], - ) - child.set_parent_unchecked(parent) - - -class NSMEditBenchmark(EditBenchmark): - model_class = NSMTestModel - - -class PEMEditBenchmark(EditBenchmark): - model_class = PEMTestModel +from tests.models import ExampleModel class QueryBenchmark(TestCase): - model_class = HierarchicalModelInterface n: int density: int @@ -102,10 +14,12 @@ def setUpTestData(cls): cls.instances = [] for i in range(cls.n): if i % cls.density == 0: - cls.instances.append(cls.model_class.objects.create(num=i)) + cls.instances.append(ExampleModel.objects.create(num=i)) else: cls.instances.append( - cls.instances[(i * 31) % len(cls.instances)].create_child(num=i) + ExampleModel.objects.create( + num=i, parent=cls.instances[(i * 31) % len(cls.instances)] + ) ) def test_get_parent(self): @@ -134,19 +48,6 @@ def test_is_child_of(self): _ = instance_2.is_child_of(instance_1) -class ALMQueryBenchmark(QueryBenchmark): - model_class = ALMTestModel - n = 50000 - density = 10 - - -class NSMQueryBenchmark(QueryBenchmark): - model_class = NSMTestModel - n = 1000 - density = 3 - - -class PEMQueryBenchmark(QueryBenchmark): - model_class = PEMTestModel - n = 50000 +class ATest(QueryBenchmark): + n = 1000000 density = 10 From abb04c6675ca7a214a83111c263c677d5c9c8aeb Mon Sep 17 00:00:00 2001 From: Josh Bedwell Date: Sun, 28 Apr 2024 12:00:51 -0500 Subject: [PATCH 4/5] Added docstrings. --- .../models/exceptions.py | 10 +++ .../models/hierarchical_model.py | 81 +++++++++++++++++++ django_hierarchical_models/models/node.py | 13 ++- tests/benchmark.py | 1 - 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/django_hierarchical_models/models/exceptions.py b/django_hierarchical_models/models/exceptions.py index 9e9d2c9..233ee37 100644 --- a/django_hierarchical_models/models/exceptions.py +++ b/django_hierarchical_models/models/exceptions.py @@ -1,4 +1,14 @@ class CycleException(Exception): + """Raised when setting a model parent would cause a cycle. + + While representable, cycles can cause infinite loops in many of the + HierarchicalModel methods. + + Attributes: + parent: The instance which would have been the parent. + child: The instance whose parent would have been updated. + """ + def __init__(self, parent, child, *args): super().__init__(*args) self.parent = parent diff --git a/django_hierarchical_models/models/hierarchical_model.py b/django_hierarchical_models/models/hierarchical_model.py index fb0e86a..6584681 100644 --- a/django_hierarchical_models/models/hierarchical_model.py +++ b/django_hierarchical_models/models/hierarchical_model.py @@ -13,12 +13,18 @@ class HierarchicalModel(models.Model): + """An abstract Django model supporting hierarchical data.""" _parent = models.ForeignKey( "self", on_delete=models.SET_NULL, blank=True, null=True ) def __init__(self, *args, **kwargs): + """Initialize with an optional "parent" kwarg. + + Keyword Args: + parent: Parent model of this instance. + """ if len(args) == 0 and "parent" in kwargs: kwargs["_parent"] = kwargs.pop("parent") super().__init__(*args, **kwargs) @@ -27,9 +33,26 @@ class Meta: abstract = True def parent(self: T) -> T | None: + """The parent of this instance. + + Returns: + The instance of the parent, or None if this instance has no parent. + """ return self._parent # type: ignore def set_parent(self: T, parent: T | None, unchecked: bool = False): + """Set the parent of this instance. + + Args: + parent: The new parent of this instance, or None to make this + instance an orphan. + unchecked: If true, there will be no check for cycles. Default + False. + + Raises: + CycleException: This operation would create a cycle. + """ + if ( not unchecked and parent is not None @@ -40,6 +63,16 @@ def set_parent(self: T, parent: T | None, unchecked: bool = False): self.save(update_fields=("_parent",)) def is_child_of(self: T, parent: T) -> bool: + """Checks if this instance is a child of parent. + + Args: + parent: Potential parent instance. + + Returns: + True if the instance is a child of parent at any level. Returns + false when checked against itself. + """ + ancestor = self._parent while ancestor is not None: if ancestor == parent: @@ -48,6 +81,12 @@ def is_child_of(self: T, parent: T) -> bool: return False def root(self: T) -> T: + """Root of this instance. + + Returns: + The top level root of this instance. Will return self if an orphan. + """ + root = self while root._parent is not None: root = root._parent # type: ignore @@ -57,6 +96,16 @@ def ancestors( self: T, max_level: int | None = None, ) -> list[T]: + """Ancestors of this instance. + + Args: + max_level: Optional maximum number of ancestors. + + Returns: + An ordered list of ancestors instances, with the closest ancestor + at the lowest index of the list. + """ + if max_level is None: max_level = -1 ancestors = [] @@ -72,6 +121,17 @@ def direct_children( self: T, object_manager: BaseManager[T] | None = None, ) -> QuerySet[T]: + """The direct children of this instance. + + Args: + object_manager: An optional object manager to use to query. Uses + model's default object manager when none is provided. + + Returns: + An unordered QuerySet containing all the direct children of this + instance. + """ + if object_manager is None: object_manager = self.__class__._default_manager return object_manager.filter(_parent=self) @@ -83,6 +143,27 @@ def children( max_total: int | None = None, sibling_transform: Callable[[QuerySet[T]], QuerySet[T]] | None = None, ) -> Node[T]: + """Get all children of this instance. + + Returns all children of this instance (or set limited by optional + parameters), structured as instances of Node. + + Args: + max_generations: Optional maximum number of generations to find, + eg. 1 would only find direct children. + max_siblings: Optional maximum number of siblings to take from each + instance. + max_total: Optional maximum total number of instances to find. + sibling_transform: A callable applied to each sibling query. This + will affect the order the children will appear in each returned + node. It is applied before the number of siblings is limited, if + applicable, so it will also determine which siblings are taken. + + Returns: + An instance of Node, containing a reference to this instance, and + an ordered list of Nodes for the children taken for this instance. + """ + if max_total is None: max_total = -1 root = Node[T](self) diff --git a/django_hierarchical_models/models/node.py b/django_hierarchical_models/models/node.py index 5620187..3095e85 100644 --- a/django_hierarchical_models/models/node.py +++ b/django_hierarchical_models/models/node.py @@ -9,18 +9,25 @@ @dataclass class Node(Generic[T]): + """Structured representation of an instance's children. + + Attributes: + instance: The HierarchicalModel instance for this node. + children: An ordered list of Nodes for the children of this instance. + """ + instance: T children: list[Node[T]] = field(default_factory=list) def __copy__(self): return Node(self.instance, [copy.copy(child) for child in self.children]) - def _p(self, s, indent=0, dash=False): + def _child_printer(self, s, indent=0, dash=False): s[0] += f"\n{' ' * indent}{'- ' if dash else ''}{self.instance}" for child in self.children: - child._p(s, indent + 2, True) + child._child_printer(s, indent + 2, True) def __str__(self): s = [""] - self._p(s) + self._child_printer(s) return s[0] diff --git a/tests/benchmark.py b/tests/benchmark.py index 808ad51..1e3a786 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -45,7 +45,6 @@ def test_get_root(self): def test_is_child_of(self): for instance_1, instance_2 in pairwise(self.instances): _ = instance_1.is_child_of(instance_2) - _ = instance_2.is_child_of(instance_1) class ATest(QueryBenchmark): From 9479ee26027a114691027f38ab65267f669f4420 Mon Sep 17 00:00:00 2001 From: Josh Bedwell Date: Sun, 28 Apr 2024 13:44:55 -0500 Subject: [PATCH 5/5] Got documentation up to date. --- README.md | 119 +++++++++++++++------ poetry.lock | 255 +++++++++++++++++++++------------------------ pyproject.toml | 3 +- tests/benchmark.py | 25 +++++ tests/hm_test.py | 33 ++++++ 5 files changed, 266 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 892f60e..d8ffbf7 100644 --- a/README.md +++ b/README.md @@ -6,50 +6,103 @@ [![Supported Python versions](https://img.shields.io/pypi/pyversions/django-hierarchical-models.svg)](https://pypi.python.org/pypi/django-hierarchical-models/) [![Supported Django versions](https://img.shields.io/pypi/djversions/django-hierarchical-models.svg)](https://pypi.python.org/pypi/django-hierarchical-models/) -This package provides several implementations Django models which support hierarchical -data. Efficiently modeling hierarchical, or tree like data, in a relational database -can be non-trivial. The following models implement the same interface, but there have -different tradeoffs. +This package provides an abstract Django model which supports hierarchical data. The +implementation is an adjacency list, which is rather naive, but actually has higher +performance in this scenario than other implementations such as path enumeration or +nested sets because those implementations store more data with each instance which must +be updated before almost every operation, effectively doubling (or more) database +queries and killing performance. The performance of this implementation actually holds +up pretty well at large numbers of instances. -`models.HierarchicalModelInterface` +## Usage -This abstract model defines the shared functionality of all hierarchical models. Some -models may implement additional methods which are cheap for their implementation. +```python +from django.db import models +from django_hierarchical_models.models import HierarchicalModel -`models.AdjacencyListModel` +class MyModel(HierarchicalModel): + name = models.CharField(max_length=100) -This is the most trivial implementation, using a single `_parent` foreign key field. -Edits are efficient in this model, but queries for children/parents can be very -expensive. +... -`models.PathEnumerationModel` +child = MyModel.objects.create(name="Betty") +child.parent() # None -This model uses a `_ancestors` json field to store the path to its root. This model -has middle ground efficiency for edits and queries. **NOTE:** This model requires -database features that are not available in Oracle or SQLite backends. An exception -will be raised if you attempt to use this model with an unsupported backend. +parent = MyModel.objects.create(name="Simon") +child.set_parent(parent) +child.parent() # -`models.NestedSetModel` +child.root() # +parent.root() # -This model uses `_left` and `_right` integer fields to determine which instances it is -parent/child to. Queries can be very efficient in this model, but edits can be very -expensive, possibly even requiring updates to `_left` and `_right` fields of ever model -instance for a single edit. +parent.direct_children() # [] -`models.Node` +child.is_child_of(parent) # True +parent.is_child_of(child) # False +``` -Calls to `HierarchicalModel.children()` return this type, which has `instance` and -`children` members, with the children being additional instances of `Node`. +## Refreshing from database -#### Benchmarks +External changes to an instance's parent are not automatically reflected in the +instance. This leads to the following behavior: -These benchmarks are to illustrate the relative performance of the different models. As -of now they are kind of whacked out. These tests were run with Postgres. +```python +instance_1 = MyModel.objects.create(name="Betty") +instance_2 = MyModel.objects.create(parent=instance_1, name="Simon") +insstance_2.parent() # -| Model | Query Ancestors | Query Parent | Query Children | Query Direct Children | Create | Create Child | Delete | Delete Parent | Add Child | Remove Child | Set Parent | Set Parent Unchecked* | -|------------------|-----------------|--------------|----------------|-----------------------|--------|--------------|--------|---------------|-----------|--------------|------------|-----------------------| -| Adjacency List | 9.56 | 2.76 | 5.17 | 0.96 | 0.98 | 0.33 | 0.72 | 0.95 | 0.69 | 1.64 | 133.60 | 0.93 | -| Path Enumeration | 9.17 | 2.67 | 29.71 | 0.84 | 0.99 | 0.32 | 0.75 | 1.26 | 0.94 | 1.79 | 2.98 | | -| Nested Set | 169.86 | 118.97 | 211.75 | 107.30 | 3.31 | 59.19 | 113.79 | 175.11 | 71.13 | 293.61 | 572.47 | | +instance_1.delete() -\* function only available on Adjacency List Model +instance_2.parent() # + +instance_2.refresh_from_db() + +instance_2.parent() # None +``` + +```python +instance_1 = MyModel.objects.create(name="Betty") +instance_2 = MyModel.objects.create(parent=instance_1, name="Simon") +instance_3 = MyModel.objects.create(parent=instance_2, name="Finn") +instance_3_copy = MyModel.objects.get(pk=instance_3.pk) + +instance_1.root() # +instance_2.root() # +instance_3.root() # +instance_3_copy.root() # + +instance_2.set_parent(None) + +instance_1.root() # +instance_2.root() # +instance_3.root() # +instance_3_copy.root() # + +instance_3_copy.refresh_from_db() + +instance_1.root() # +instance_2.root() # +instance_3.root() # +instance_3_copy.root() # +``` + +Moral of the story, if your instance's parent might have been edited/deleted, +you will want to refresh your instance for that change to be reflected. + +## Benchmarks + +The following benchmarks demonstrate that the query performance of the model stays the +same from 10,000 to 1,000,000 models. These tests were done with Postgres. The results +are in the form `total time (s) / per instance (ms)`. Eventually the query performance +of this model should scale down with the total number of instances in the database, +but it appears up to these scales those effects are insignificant compared to other +overhead. + +| n | Chance Child | Query Parent | Query Root | Is Child Of | Query Ancestors | Query Direct Children | Query Children | +|-----------|--------------|---------------|---------------|---------------|-----------------|-----------------------|----------------| +| 10,000 | 50% | 0.29 / 0.029 | 0.27 / 0.027 | 0.27 / 0.027 | 0.29 / 0.029 | 0.78 / 0.078 | 3.85 / 0.385 | +| 10,000 | 90% | 0.30 / 0.030 | 0.39 / 0.039 | 0.31 / 0.031 | 0.30 / 0.030 | 0.87 / 0.087 | 5.07 / 0.507 | +| 100,000 | 50% | 3.46 / 0.035 | 3.12 / 0.031 | 3.55 / 0.036 | 3.09 / 0.031 | 8.24 / 0.082 | 37.89 / 0.380 | +| 100,000 | 90% | 4.10 / 0.041 | 3.48 / 0.035 | 3.88 / 0.039 | 3.55 / 0.036 | 8.89 / 0.089 | 48.30 / 0.483 | +| 1,000,000 | 50% | 32.39 / 0.032 | 34.53 / 0.035 | 35.41 / 0.035 | 32.16 / 0.032 | 86.05 / 0.086 | 385.62 / 0.386 | +| 1,000,000 | 90% | 34.87 / 0.035 | 38.59 / 0.039 | 38.93 / 0.039 | 36.51 / 0.037 | 87.49 / 0.087 | 490.65 / 0.491 | diff --git a/poetry.lock b/poetry.lock index efeb547..71da88f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -19,33 +19,33 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "black" -version = "24.4.0" +version = "24.4.2" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436"}, - {file = "black-24.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf"}, - {file = "black-24.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad"}, - {file = "black-24.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb"}, - {file = "black-24.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8"}, - {file = "black-24.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745"}, - {file = "black-24.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070"}, - {file = "black-24.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397"}, - {file = "black-24.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"}, - {file = "black-24.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33"}, - {file = "black-24.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965"}, - {file = "black-24.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd"}, - {file = "black-24.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1"}, - {file = "black-24.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8"}, - {file = "black-24.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d"}, - {file = "black-24.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3"}, - {file = "black-24.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665"}, - {file = "black-24.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6"}, - {file = "black-24.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e"}, - {file = "black-24.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702"}, - {file = "black-24.4.0-py3-none-any.whl", hash = "sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e"}, - {file = "black-24.4.0.tar.gz", hash = "sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641"}, + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, ] [package.dependencies] @@ -123,63 +123,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.4" +version = "7.5.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, - {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, - {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, - {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, - {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, - {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, - {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, - {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, - {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, - {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, - {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, - {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, - {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, - {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, - {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, - {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, - {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, - {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, - {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, - {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, - {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, - {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, - {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, - {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, - {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, - {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, - {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, - {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, + {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"}, + {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbfe6389c5522b99768a93d89aca52ef92310a96b99782973b9d11e80511f932"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39793731182c4be939b4be0cdecde074b833f6171313cf53481f869937129ed3"}, + {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a5dbe1ba1bf38d6c63b6d2c42132d45cbee6d9f0c51b52c59aa4afba057517"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:357754dcdfd811462a725e7501a9b4556388e8ecf66e79df6f4b988fa3d0b39a"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a81eb64feded34f40c8986869a2f764f0fe2db58c0530d3a4afbcde50f314880"}, + {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51431d0abbed3a868e967f8257c5faf283d41ec882f58413cf295a389bb22e58"}, + {file = "coverage-7.5.0-cp310-cp310-win32.whl", hash = "sha256:f609ebcb0242d84b7adeee2b06c11a2ddaec5464d21888b2c8255f5fd6a98ae4"}, + {file = "coverage-7.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:6782cd6216fab5a83216cc39f13ebe30adfac2fa72688c5a4d8d180cd52e8f6a"}, + {file = "coverage-7.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e768d870801f68c74c2b669fc909839660180c366501d4cc4b87efd6b0eee375"}, + {file = "coverage-7.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84921b10aeb2dd453247fd10de22907984eaf80901b578a5cf0bb1e279a587cb"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:710c62b6e35a9a766b99b15cdc56d5aeda0914edae8bb467e9c355f75d14ee95"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c379cdd3efc0658e652a14112d51a7668f6bfca7445c5a10dee7eabecabba19d"}, + {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea9d3ca80bcf17edb2c08a4704259dadac196fe5e9274067e7a20511fad1743"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:41327143c5b1d715f5f98a397608f90ab9ebba606ae4e6f3389c2145410c52b1"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:565b2e82d0968c977e0b0f7cbf25fd06d78d4856289abc79694c8edcce6eb2de"}, + {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf3539007202ebfe03923128fedfdd245db5860a36810136ad95a564a2fdffff"}, + {file = "coverage-7.5.0-cp311-cp311-win32.whl", hash = "sha256:bf0b4b8d9caa8d64df838e0f8dcf68fb570c5733b726d1494b87f3da85db3a2d"}, + {file = "coverage-7.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c6384cc90e37cfb60435bbbe0488444e54b98700f727f16f64d8bfda0b84656"}, + {file = "coverage-7.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fed7a72d54bd52f4aeb6c6e951f363903bd7d70bc1cad64dd1f087980d309ab9"}, + {file = "coverage-7.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cbe6581fcff7c8e262eb574244f81f5faaea539e712a058e6707a9d272fe5b64"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad97ec0da94b378e593ef532b980c15e377df9b9608c7c6da3506953182398af"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd4bacd62aa2f1a1627352fe68885d6ee694bdaebb16038b6e680f2924a9b2cc"}, + {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf032b6c105881f9d77fa17d9eebe0ad1f9bfb2ad25777811f97c5362aa07f2"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ba01d9ba112b55bfa4b24808ec431197bb34f09f66f7cb4fd0258ff9d3711b1"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f0bfe42523893c188e9616d853c47685e1c575fe25f737adf473d0405dcfa7eb"}, + {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a9a7ef30a1b02547c1b23fa9a5564f03c9982fc71eb2ecb7f98c96d7a0db5cf2"}, + {file = "coverage-7.5.0-cp312-cp312-win32.whl", hash = "sha256:3c2b77f295edb9fcdb6a250f83e6481c679335ca7e6e4a955e4290350f2d22a4"}, + {file = "coverage-7.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:427e1e627b0963ac02d7c8730ca6d935df10280d230508c0ba059505e9233475"}, + {file = "coverage-7.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dd88fce54abbdbf4c42fb1fea0e498973d07816f24c0e27a1ecaf91883ce69e"}, + {file = "coverage-7.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a898c11dca8f8c97b467138004a30133974aacd572818c383596f8d5b2eb04a9"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07dfdd492d645eea1bd70fb1d6febdcf47db178b0d99161d8e4eed18e7f62fe7"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3d117890b6eee85887b1eed41eefe2e598ad6e40523d9f94c4c4b213258e4a4"}, + {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6afd2e84e7da40fe23ca588379f815fb6dbbb1b757c883935ed11647205111cb"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a9960dd1891b2ddf13a7fe45339cd59ecee3abb6b8326d8b932d0c5da208104f"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ced268e82af993d7801a9db2dbc1d2322e786c5dc76295d8e89473d46c6b84d4"}, + {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7c211f25777746d468d76f11719e64acb40eed410d81c26cefac641975beb88"}, + {file = "coverage-7.5.0-cp38-cp38-win32.whl", hash = "sha256:262fffc1f6c1a26125d5d573e1ec379285a3723363f3bd9c83923c9593a2ac25"}, + {file = "coverage-7.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:eed462b4541c540d63ab57b3fc69e7d8c84d5957668854ee4e408b50e92ce26a"}, + {file = "coverage-7.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0194d654e360b3e6cc9b774e83235bae6b9b2cac3be09040880bb0e8a88f4a1"}, + {file = "coverage-7.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33c020d3322662e74bc507fb11488773a96894aa82a622c35a5a28673c0c26f5"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbdf2cae14a06827bec50bd58e49249452d211d9caddd8bd80e35b53cb04631"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3235d7c781232e525b0761730e052388a01548bd7f67d0067a253887c6e8df46"}, + {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2de4e546f0ec4b2787d625e0b16b78e99c3e21bc1722b4977c0dddf11ca84e"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0e206259b73af35c4ec1319fd04003776e11e859936658cb6ceffdeba0f5be"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2055c4fb9a6ff624253d432aa471a37202cd8f458c033d6d989be4499aed037b"}, + {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075299460948cd12722a970c7eae43d25d37989da682997687b34ae6b87c0ef0"}, + {file = "coverage-7.5.0-cp39-cp39-win32.whl", hash = "sha256:280132aada3bc2f0fac939a5771db4fbb84f245cb35b94fae4994d4c1f80dae7"}, + {file = "coverage-7.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:c58536f6892559e030e6924896a44098bc1290663ea12532c78cef71d0df8493"}, + {file = "coverage-7.5.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:2b57780b51084d5223eee7b59f0d4911c31c16ee5aa12737c7a02455829ff067"}, + {file = "coverage-7.5.0.tar.gz", hash = "sha256:cf62d17310f34084c59c01e027259076479128d11e4661bb6c9acb38c5e19bb8"}, ] [package.dependencies] @@ -354,38 +354,38 @@ files = [ [[package]] name = "mypy" -version = "1.9.0" +version = "1.10.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, - {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, - {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, - {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, - {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, - {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, - {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, - {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, - {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, - {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, - {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, - {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, - {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, - {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, - {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, - {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, - {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, - {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, - {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, - {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, - {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, - {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, - {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, + {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, + {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, + {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, + {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, + {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, + {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, + {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, + {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, + {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, + {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, + {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, + {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, + {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, + {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, + {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, + {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, + {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, + {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, + {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, + {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, + {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, + {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, ] [package.dependencies] @@ -435,20 +435,6 @@ files = [ {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] -[[package]] -name = "parameterized" -version = "0.9.0" -description = "Parameterized testing with any Python test framework" -optional = false -python-versions = ">=3.7" -files = [ - {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, - {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, -] - -[package.extras] -dev = ["jinja2"] - [[package]] name = "pathspec" version = "0.12.1" @@ -462,18 +448,19 @@ files = [ [[package]] name = "platformdirs" -version = "4.2.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "4.2.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, - {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, + {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, + {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] [[package]] name = "pluggy" @@ -632,13 +619,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytes [[package]] name = "pytest" -version = "8.1.1" +version = "8.2.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, - {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, + {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, + {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, ] [package.dependencies] @@ -646,11 +633,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.4,<2.0" +pluggy = ">=1.5,<2.0" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" @@ -792,13 +779,13 @@ files = [ [[package]] name = "tox" -version = "4.14.2" +version = "4.15.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.14.2-py3-none-any.whl", hash = "sha256:2900c4eb7b716af4a928a7fdc2ed248ad6575294ed7cfae2ea41203937422847"}, - {file = "tox-4.14.2.tar.gz", hash = "sha256:0defb44f6dafd911b61788325741cc6b2e12ea71f987ac025ad4d649f1f1a104"}, + {file = "tox-4.15.0-py3-none-any.whl", hash = "sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea"}, + {file = "tox-4.15.0.tar.gz", hash = "sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6"}, ] [package.dependencies] @@ -863,13 +850,13 @@ files = [ [[package]] name = "virtualenv" -version = "20.25.3" +version = "20.26.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.3-py3-none-any.whl", hash = "sha256:8aac4332f2ea6ef519c648d0bc48a5b1d324994753519919bddbb1aff25a104e"}, - {file = "virtualenv-20.25.3.tar.gz", hash = "sha256:7bb554bbdfeaacc3349fa614ea5bff6ac300fc7c335e9facf3a3bcfc703f45be"}, + {file = "virtualenv-20.26.0-py3-none-any.whl", hash = "sha256:0846377ea76e818daaa3e00a4365c018bc3ac9760cbb3544de542885aad61fb3"}, + {file = "virtualenv-20.26.0.tar.gz", hash = "sha256:ec25a9671a5102c8d2657f62792a27b48f016664c6873f6beed3800008577210"}, ] [package.dependencies] @@ -884,4 +871,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "ddb1d2c3ebd3a8c125f3cca5cb6e4a47ed6b7fd0bbf9ec1ab72f514a35c108da" +content-hash = "4f1c456604e9dd402b1dad79db5e358b6ab2a5c9fa04bd0341c44893bffd064b" diff --git a/pyproject.toml b/pyproject.toml index e24ef17..6e85a8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-hierarchical-models" -version = "1.2.0" +version = "2.0.0" description = "Adds hierarchical models to Django" authors = ["Josh Bedwell "] license = "MIT" @@ -28,7 +28,6 @@ isort = "^5.13.2" psycopg2-binary = "^2.9.9" mypy = "^1.9.0" django-stubs = "^4.2.7" -parameterized = "^0.9.0" pytest-cov = "^5.0.0" [build-system] diff --git a/tests/benchmark.py b/tests/benchmark.py index 1e3a786..3e9c8be 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -48,5 +48,30 @@ def test_is_child_of(self): class ATest(QueryBenchmark): + n = 10000 + density = 2 + + +class BTest(QueryBenchmark): + n = 10000 + density = 10 + + +class CTest(QueryBenchmark): + n = 100000 + density = 2 + + +class DTest(QueryBenchmark): + n = 100000 + density = 10 + + +class ETest(QueryBenchmark): + n = 1000000 + density = 2 + + +class FTest(QueryBenchmark): n = 1000000 density = 10 diff --git a/tests/hm_test.py b/tests/hm_test.py index 534f27d..cbebb62 100644 --- a/tests/hm_test.py +++ b/tests/hm_test.py @@ -97,6 +97,39 @@ def test_is_child(self): self.assertFalse(n1.is_child_of(n2)) self.assertTrue(n2.is_child_of(n1)) + def test_delete_refresh_behavior(self): + n1 = create(1) + n2 = create(2, parent=n1) + self.assertEqual(n2.parent(), n1) + + n1.delete() + + self.assertEqual(n2.parent(), n1) + + n2.refresh_from_db() + + self.assertIsNone(n2.parent()) + + def test_root_refresh_behavior(self): + n1 = create(1) + n2 = create(2, parent=n1) + n3 = create(3, parent=n2) + n3_copy = ExampleModel.objects.get(pk=n3.pk) + + self.assertEqual(n2.parent(), n1) + self.assertEqual(n3.root(), n1) + self.assertEqual(n3_copy.root(), n1) + + n2.set_parent(None) + + self.assertIsNone(n2.parent()) + + self.assertEqual(n3.root(), n2) + + self.assertEqual(n3_copy.root(), n1) + n3_copy.refresh_from_db() + self.assertEqual(n3_copy.root(), n2) + class HierarchicalModelAdvancedTests(TestCase): def setUp(self):