diff --git a/tests/core/test_dihypergraph.py b/tests/core/test_dihypergraph.py index c9c686f6..f685ab6b 100644 --- a/tests/core/test_dihypergraph.py +++ b/tests/core/test_dihypergraph.py @@ -127,15 +127,12 @@ def test_add_edge_accepts_different_types(): assert H.edges.head(dtype=dict) == {0: {4}} -def test_add_edge_raises_with_empty_edges(): +def test_add_empty_edges(): H = xgi.DiHypergraph() - for edge in [[], {}, iter([])]: - with pytest.raises(XGIError): - H.add_edge(edge) - for edge in [[[], []], (set(), set())]: - with pytest.raises(XGIError): - H.add_edge(edge) + H.add_edge(edge) + + assert H.edges.size.asdict() == {0: 0, 1: 0} def test_add_edge_rejects_set(): @@ -415,7 +412,14 @@ def test_remove_node_weak(diedgelist1, diedgelist2): H.remove_node(3) H.remove_node(4) - assert 0 not in H.edges + # test keeping empty edges + H = xgi.DiHypergraph(diedgelist1) + H.remove_node(1) + H.remove_node(2) + H.remove_node(3) + H.remove_node(4, remove_empty=False) + assert 0 in H.edges + assert H.edges.size[0] == 0 # test multiple edge removal with a single node. H = xgi.DiHypergraph(diedgelist2) @@ -456,9 +460,9 @@ def test_remove_node_strong(diedgelist1): # node in both head and tail assert 6 in H H.remove_node(6, strong=True) - # assert 6 not in H + assert 6 not in H - # assert 1 not in H.edges + assert 1 not in H.edges def test_remove_nodes_from(diedgelist1): @@ -470,6 +474,16 @@ def test_remove_nodes_from(diedgelist1): with pytest.warns(Warning): H.remove_nodes_from([1, 2, 3]) + H = xgi.DiHypergraph(diedgelist1) + + H.remove_nodes_from([1, 5], strong=True) + assert len(H.edges) == 0 + + H = xgi.DiHypergraph(diedgelist1) + H.remove_nodes_from([1, 2, 3, 4], remove_empty=False) + assert 0 in H.edges + assert H.edges.size[0] == 0 + def test_pickle(diedgelist1): _, filename = tempfile.mkstemp() diff --git a/tests/core/test_hypergraph.py b/tests/core/test_hypergraph.py index dfdaf653..3f55e159 100644 --- a/tests/core/test_hypergraph.py +++ b/tests/core/test_hypergraph.py @@ -181,10 +181,12 @@ def test_add_edge(): assert {1, 2, 3} == H.edges.members(0) assert H.edges.members(dtype=dict) == {0: {1, 2, 3}} + # test adding empty edges H = xgi.Hypergraph() for edge in [[], set(), iter([])]: - with pytest.raises(XGIError): - H.add_edge(edge) + H.add_edge(edge) + + assert H.edges.size.asdict() == {0: 0, 1: 0, 2: 0} # check that uid works correctly H1 = xgi.Hypergraph() @@ -236,6 +238,41 @@ def test_add_node_to_edge(): } +def test_remove_node_from_edge(edgelist1): + H = xgi.Hypergraph(edgelist1) + + # test non-existent node + with pytest.raises(XGIError): + H.remove_node_from_edge(1, 1000) + + # test non-existent edge + with pytest.raises(XGIError): + H.remove_node_from_edge(1000, 1) + + # test node which exists, but not in the edge + with pytest.raises(XGIError): + H.remove_node_from_edge(1, 1) + + H.remove_node_from_edge(0, 1) + assert 1 not in H.edges.members(0) + + with pytest.raises(XGIError): + H.remove_node_from_edge(0, 1) + + H.remove_node_from_edge(0, 2) + H.remove_node_from_edge(0, 3) + + assert 0 not in H.edges + + # test leaving empty edges + H = xgi.Hypergraph(edgelist1) + H.remove_node_from_edge(0, 1) + H.remove_node_from_edge(0, 2) + H.remove_node_from_edge(0, 3, remove_empty=False) + assert 0 in H.edges + assert H.edges.members(0) == set() + + def test_add_edges_from_iterable_of_members(): edges = [{0, 1}, {1, 2}, {2, 3, 4}] H = xgi.Hypergraph() @@ -583,6 +620,15 @@ def test_remove_node_weak(edgelist1): with pytest.raises(IDNotFound): H.remove_node(10) + # test keeping empty edges + H = xgi.Hypergraph(edgelist1) + H.remove_node(1) + H.remove_node(2) + H.remove_node(3, remove_empty=False) + H.remove_node(4, remove_empty=False) + assert 0 in H.edges and 1 in H.edges + assert H.edges.size[0] == 0 and H.edges.size[1] == 0 + def test_remove_node_strong(edgelist1): H = xgi.Hypergraph(edgelist1) @@ -592,6 +638,27 @@ def test_remove_node_strong(edgelist1): assert 0 not in H.edges +def test_remove_nodes_from(edgelist1): + H = xgi.Hypergraph(edgelist1) + + H.remove_nodes_from([1, 2, 3]) + assert 1 not in H and 2 not in H and 3 not in H + assert 0 not in H.edges + + with pytest.warns(Warning): + H.remove_nodes_from([1, 2, 3]) + + H = xgi.Hypergraph(edgelist1) + + H.remove_nodes_from([1, 4], strong=True) + assert 0 not in H.edges and 1 not in H.edges + + H = xgi.Hypergraph(edgelist1) + H.remove_nodes_from([1, 2, 3, 4], remove_empty=False) + assert 0 in H.edges and 1 in H.edges + assert H.edges.size[0] == 0 and H.edges.size[1] == 0 + + def test_issue_445(edgelist1): H = xgi.Hypergraph(edgelist1) assert 1 in H diff --git a/xgi/core/dihypergraph.py b/xgi/core/dihypergraph.py index 2c70879e..1542c351 100644 --- a/xgi/core/dihypergraph.py +++ b/xgi/core/dihypergraph.py @@ -363,7 +363,7 @@ def add_nodes_from(self, nodes_for_adding, **attr): self._node_attr[n] = self._node_attr_dict_factory() self._node_attr[n].update(newdict) - def remove_node(self, n, strong=False): + def remove_node(self, n, strong=False, remove_empty=True): """Remove a single node. The removal may be weak (default) or strong. In weak removal, the node is @@ -375,9 +375,11 @@ def remove_node(self, n, strong=False): ---------- n : node A node in the dihypergraph - strong : bool (default False) Whether to execute weak or strong removal. + remove_empty : bool, optional + Whether to remove empty edges (0 members in both head and tail). + By default, True. Raises ------ @@ -406,17 +408,26 @@ def remove_node(self, n, strong=False): # remove empty edges for edge in edge_neighbors["in"].union(edge_neighbors["out"]): - if not self._edge[edge]["in"] and not self._edge[edge]["out"]: + if ( + not self._edge[edge]["in"] + and not self._edge[edge]["out"] + and remove_empty + ): del self._edge[edge] del self._edge_attr[edge] - def remove_nodes_from(self, nodes): + def remove_nodes_from(self, nodes, strong=False, remove_empty=True): """Remove multiple nodes. Parameters ---------- nodes : iterable An iterable of nodes. + strong : bool (default False) + Whether to execute weak or strong removal. + remove_empty : bool, optional + Whether to remove empty edges (0 members in both head and tail). + By default, True. See Also -------- @@ -427,7 +438,7 @@ def remove_nodes_from(self, nodes): if n not in self._node: warn(f"Node {n} not in dihypergraph") continue - self.remove_node(n) + self.remove_node(n, strong=strong, remove_empty=remove_empty) def set_node_attributes(self, values, name=None): """Sets node attributes from a given value or dictionary of values. @@ -506,11 +517,6 @@ def add_edge(self, members, id=None, **attr): **attr : dict, optional Attributes of the new edge. - Raises - ----- - XGIError - If `members` is empty or is not a list or tuple. - See Also -------- add_edges_from : Add a collection of edges. @@ -525,18 +531,12 @@ def add_edge(self, members, id=None, **attr): >>> DH.add_edge(([1, 2, 3], [2, 3, 4])) >>> DH.add_edge(([3, 4], set()), id='myedge') """ - if not members: - raise XGIError("Cannot add an empty edge") - if isinstance(members, (tuple, list)): tail = members[0] head = members[1] else: raise XGIError("Directed edge must be a list or tuple!") - if not head and not tail: - raise XGIError("Cannot add an empty edge") - uid = next(self._edge_uid) if id is None else id if id in self._edge.keys(): # check that uid is not present yet @@ -969,11 +969,8 @@ def freeze(self): self.remove_nodes_from = frozen self.add_edge = frozen self.add_edges_from = frozen - self.add_weighted_edges_from = frozen self.remove_edge = frozen self.remove_edges_from = frozen - self.add_node_to_edge = frozen - self.remove_node_from_edge = frozen self.clear = frozen self.frozen = True diff --git a/xgi/core/hypergraph.py b/xgi/core/hypergraph.py index c0e2ff01..a712235c 100644 --- a/xgi/core/hypergraph.py +++ b/xgi/core/hypergraph.py @@ -405,7 +405,7 @@ def add_nodes_from(self, nodes_for_adding, **attr): self._node_attr[n] = self._node_attr_dict_factory() self._node_attr[n].update(newdict) - def remove_node(self, n, strong=False): + def remove_node(self, n, strong=False, remove_empty=True): """Remove a single node. The removal may be weak (default) or strong. In weak removal, the node is @@ -417,9 +417,10 @@ def remove_node(self, n, strong=False): ---------- n : node A node in the hypergraph - strong : bool, optional Whether to execute weak or strong removal. By default, False. + remove_empty : bool, optional + Whether to remove empty edges. By default, True. Raises ------ @@ -445,17 +446,21 @@ def remove_node(self, n, strong=False): else: # weak removal for edge in edge_neighbors: self._edge[edge].remove(n) - if not self._edge[edge]: + if not self._edge[edge] and remove_empty: del self._edge[edge] del self._edge_attr[edge] - def remove_nodes_from(self, nodes): + def remove_nodes_from(self, nodes, strong=False, remove_empty=True): """Remove multiple nodes. Parameters ---------- nodes : iterable An iterable of nodes. + strong : bool, optional + Whether to execute weak or strong removal. By default, False. + remove_empty : bool, optional + Whether to remove empty edges. By default, True. See Also -------- @@ -466,7 +471,7 @@ def remove_nodes_from(self, nodes): if n not in self: warn(f"Node {n} not in hypergraph") continue - self.remove_node(n) + self.remove_node(n, strong=strong, remove_empty=remove_empty) def set_node_attributes(self, values, name=None): """Sets node attributes from a given value or dictionary of values. @@ -577,8 +582,6 @@ def add_edge(self, members, id=None, **attr): """ members = set(members) - if not members: - raise XGIError("Cannot add an empty edge") if id in self._edge.keys(): # check that uid is not present yet warn(f"uid {id} already exists, cannot add edge {members}") @@ -1127,7 +1130,7 @@ def remove_edges_from(self, ebunch): del self._edge[id] del self._edge_attr[id] - def remove_node_from_edge(self, edge, node): + def remove_node_from_edge(self, edge, node, remove_empty=True): """Remove a node from an existing edge. Parameters @@ -1136,6 +1139,8 @@ def remove_node_from_edge(self, edge, node): The edge ID node : hashable The node ID + remove_empty : bool, optional + Whether empty edges are removed. By default, True. Raises ------ @@ -1154,21 +1159,18 @@ def remove_node_from_edge(self, edge, node): removed. """ - try: - self._node[node].remove(edge) - except KeyError as e: - raise XGIError(f"Node {node} not in the hypergraph") from e - except ValueError as e: - raise XGIError(f"Node {node} not in edge {edge}") from e - - try: + if edge not in self._edge: + raise XGIError(f"Edge {edge} not in the hypergraph") + elif node not in self._node: + raise XGIError(f"Node {node} not in the hypergraph") + elif node not in self._edge[edge]: + raise XGIError(f"Edge {edge} does not contain node {node}") + else: self._edge[edge].remove(node) - except KeyError as e: - raise XGIError(f"Edge {edge} not in the hypergraph") from e - except ValueError as e: - raise XGIError(f"Edge {edge} does not contain node {node}") from e - if not self._edge[edge]: + self._node[node].remove(edge) + + if not self._edge[edge] and remove_empty: del self._edge[edge] del self._edge_attr[edge] diff --git a/xgi/core/simplicialcomplex.py b/xgi/core/simplicialcomplex.py index ab9417ce..61980992 100644 --- a/xgi/core/simplicialcomplex.py +++ b/xgi/core/simplicialcomplex.py @@ -268,11 +268,6 @@ def add_simplex(self, members, id=None, **attr): **attr : dict, optional Attributes of the new simplex. - Raises - ----- - XGIError - If `members` is empty. - See Also -------- add_simplices_from : Add a collection of simplices. @@ -313,9 +308,6 @@ def add_simplex(self, members, id=None, **attr): except TypeError: raise XGIError("The simplex cannot be cast to a frozenset.") - if not members: - raise XGIError("Cannot add an empty edge") - if self.has_simplex(members): return diff --git a/xgi/core/views.py b/xgi/core/views.py index c0bb88a9..e03a030a 100644 --- a/xgi/core/views.py +++ b/xgi/core/views.py @@ -721,6 +721,20 @@ def singletons(self): """ return self.filterby("size", 1) + def empty(self): + """Edges that contain no nodes. + + Returns + ------- + EdgeView containing the empty edges. + + See Also + -------- + :meth:`NodeView.isolates` + + """ + return self.filterby("size", 0) + def maximal(self, strict=False): """Returns the maximal edges as an EdgeView.