Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow empty edges #565

Merged
merged 15 commits into from
Aug 20, 2024
34 changes: 24 additions & 10 deletions tests/core/test_dihypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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()
Expand Down
36 changes: 34 additions & 2 deletions tests/core/test_hypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -583,6 +585,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)
Expand All @@ -592,6 +603,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
Expand Down
35 changes: 16 additions & 19 deletions xgi/core/dihypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, False.
nwlandry marked this conversation as resolved.
Show resolved Hide resolved

Raises
------
Expand Down Expand Up @@ -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, False.
nwlandry marked this conversation as resolved.
Show resolved Hide resolved

See Also
--------
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -983,11 +983,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

Expand Down
30 changes: 21 additions & 9 deletions xgi/core/hypergraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
------
Expand All @@ -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
--------
Expand All @@ -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.
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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=False):
"""Remove a node from an existing edge.

Parameters
Expand All @@ -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.
nwlandry marked this conversation as resolved.
Show resolved Hide resolved

Raises
------
Expand Down Expand Up @@ -1168,7 +1173,7 @@ def remove_node_from_edge(self, edge, node):
except ValueError as e:
raise XGIError(f"Edge {edge} does not contain node {node}") from e

if not self._edge[edge]:
if not self._edge[edge] and remove_empty:
del self._edge[edge]
del self._edge_attr[edge]

Expand Down Expand Up @@ -1434,6 +1439,7 @@ def cleanup(
self,
isolates=False,
singletons=False,
empty_edges=False,
multiedges=False,
connected=True,
relabel=True,
Expand All @@ -1447,6 +1453,8 @@ def cleanup(
Whether isolated nodes are allowed, by default False.
singletons : bool, optional
Whether singleton edges are allowed, by default False.
empty_edges : bool, optional
Whether empty edges are allowed, by default False.
multiedges : bool, optional
Whether multiedges are allowed, by default False.
connected : bool, optional
Expand All @@ -1466,6 +1474,8 @@ def cleanup(
self.merge_duplicate_edges()
if not singletons:
self.remove_edges_from(self.edges.singletons())
if not empty_edges:
self.remove_edges_from(self.edges.empty())
if not isolates:
self.remove_nodes_from(self.nodes.isolates())
if connected:
Expand Down Expand Up @@ -1493,6 +1503,8 @@ def cleanup(
H.merge_duplicate_edges()
if not singletons:
H.remove_edges_from(H.edges.singletons())
if not empty_edges:
H.remove_edges_from(H.edges.empty())
if not isolates:
H.remove_nodes_from(H.nodes.isolates())
if connected:
Expand Down
8 changes: 0 additions & 8 deletions xgi/core/simplicialcomplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions xgi/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading