about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/networkx/algorithms/operators
diff options
context:
space:
mode:
authorS. Solomon Darnell2025-03-28 21:52:21 -0500
committerS. Solomon Darnell2025-03-28 21:52:21 -0500
commit4a52a71956a8d46fcb7294ac71734504bb09bcc2 (patch)
treeee3dc5af3b6313e921cd920906356f5d4febc4ed /.venv/lib/python3.12/site-packages/networkx/algorithms/operators
parentcc961e04ba734dd72309fb548a2f97d67d578813 (diff)
downloadgn-ai-master.tar.gz
two version of R2R are here HEAD master
Diffstat (limited to '.venv/lib/python3.12/site-packages/networkx/algorithms/operators')
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/__init__.py4
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/all.py321
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/binary.py450
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/product.py633
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/__init__.py0
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_all.py328
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_binary.py453
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_product.py491
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_unary.py55
-rw-r--r--.venv/lib/python3.12/site-packages/networkx/algorithms/operators/unary.py77
10 files changed, 2812 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/__init__.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/__init__.py
new file mode 100644
index 00000000..0ebc6ab9
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/__init__.py
@@ -0,0 +1,4 @@
+from networkx.algorithms.operators.all import *
+from networkx.algorithms.operators.binary import *
+from networkx.algorithms.operators.product import *
+from networkx.algorithms.operators.unary import *
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/all.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/all.py
new file mode 100644
index 00000000..549d335d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/all.py
@@ -0,0 +1,321 @@
+"""Operations on many graphs."""
+
+from itertools import chain, repeat
+
+import networkx as nx
+
+__all__ = ["union_all", "compose_all", "disjoint_union_all", "intersection_all"]
+
+
+@nx._dispatchable(graphs="[graphs]", preserve_all_attrs=True, returns_graph=True)
+def union_all(graphs, rename=()):
+    """Returns the union of all graphs.
+
+    The graphs must be disjoint, otherwise an exception is raised.
+
+    Parameters
+    ----------
+    graphs : iterable
+       Iterable of NetworkX graphs
+
+    rename : iterable , optional
+       Node names of graphs can be changed by specifying the tuple
+       rename=('G-','H-') (for example).  Node "u" in G is then renamed
+       "G-u" and "v" in H is renamed "H-v". Infinite generators (like itertools.count)
+       are also supported.
+
+    Returns
+    -------
+    U : a graph with the same type as the first graph in list
+
+    Raises
+    ------
+    ValueError
+       If `graphs` is an empty list.
+
+    NetworkXError
+        In case of mixed type graphs, like MultiGraph and Graph, or directed and undirected graphs.
+
+    Notes
+    -----
+    For operating on mixed type graphs, they should be converted to the same type.
+    >>> G = nx.Graph()
+    >>> H = nx.DiGraph()
+    >>> GH = union_all([nx.DiGraph(G), H])
+
+    To force a disjoint union with node relabeling, use
+    disjoint_union_all(G,H) or convert_node_labels_to integers().
+
+    Graph, edge, and node attributes are propagated to the union graph.
+    If a graph attribute is present in multiple graphs, then the value
+    from the last graph in the list with that attribute is used.
+
+    Examples
+    --------
+    >>> G1 = nx.Graph([(1, 2), (2, 3)])
+    >>> G2 = nx.Graph([(4, 5), (5, 6)])
+    >>> result_graph = nx.union_all([G1, G2])
+    >>> result_graph.nodes()
+    NodeView((1, 2, 3, 4, 5, 6))
+    >>> result_graph.edges()
+    EdgeView([(1, 2), (2, 3), (4, 5), (5, 6)])
+
+    See Also
+    --------
+    union
+    disjoint_union_all
+    """
+    R = None
+    seen_nodes = set()
+
+    # rename graph to obtain disjoint node labels
+    def add_prefix(graph, prefix):
+        if prefix is None:
+            return graph
+
+        def label(x):
+            return f"{prefix}{x}"
+
+        return nx.relabel_nodes(graph, label)
+
+    rename = chain(rename, repeat(None))
+    graphs = (add_prefix(G, name) for G, name in zip(graphs, rename))
+
+    for i, G in enumerate(graphs):
+        G_nodes_set = set(G.nodes)
+        if i == 0:
+            # Union is the same type as first graph
+            R = G.__class__()
+        elif G.is_directed() != R.is_directed():
+            raise nx.NetworkXError("All graphs must be directed or undirected.")
+        elif G.is_multigraph() != R.is_multigraph():
+            raise nx.NetworkXError("All graphs must be graphs or multigraphs.")
+        elif not seen_nodes.isdisjoint(G_nodes_set):
+            raise nx.NetworkXError(
+                "The node sets of the graphs are not disjoint.\n"
+                "Use `rename` to specify prefixes for the graphs or use\n"
+                "disjoint_union(G1, G2, ..., GN)."
+            )
+
+        seen_nodes |= G_nodes_set
+        R.graph.update(G.graph)
+        R.add_nodes_from(G.nodes(data=True))
+        R.add_edges_from(
+            G.edges(keys=True, data=True) if G.is_multigraph() else G.edges(data=True)
+        )
+
+    if R is None:
+        raise ValueError("cannot apply union_all to an empty list")
+
+    return R
+
+
+@nx._dispatchable(graphs="[graphs]", preserve_all_attrs=True, returns_graph=True)
+def disjoint_union_all(graphs):
+    """Returns the disjoint union of all graphs.
+
+    This operation forces distinct integer node labels starting with 0
+    for the first graph in the list and numbering consecutively.
+
+    Parameters
+    ----------
+    graphs : iterable
+       Iterable of NetworkX graphs
+
+    Returns
+    -------
+    U : A graph with the same type as the first graph in list
+
+    Raises
+    ------
+    ValueError
+       If `graphs` is an empty list.
+
+    NetworkXError
+        In case of mixed type graphs, like MultiGraph and Graph, or directed and undirected graphs.
+
+    Examples
+    --------
+    >>> G1 = nx.Graph([(1, 2), (2, 3)])
+    >>> G2 = nx.Graph([(4, 5), (5, 6)])
+    >>> U = nx.disjoint_union_all([G1, G2])
+    >>> list(U.nodes())
+    [0, 1, 2, 3, 4, 5]
+    >>> list(U.edges())
+    [(0, 1), (1, 2), (3, 4), (4, 5)]
+
+    Notes
+    -----
+    For operating on mixed type graphs, they should be converted to the same type.
+
+    Graph, edge, and node attributes are propagated to the union graph.
+    If a graph attribute is present in multiple graphs, then the value
+    from the last graph in the list with that attribute is used.
+    """
+
+    def yield_relabeled(graphs):
+        first_label = 0
+        for G in graphs:
+            yield nx.convert_node_labels_to_integers(G, first_label=first_label)
+            first_label += len(G)
+
+    R = union_all(yield_relabeled(graphs))
+
+    return R
+
+
+@nx._dispatchable(graphs="[graphs]", preserve_all_attrs=True, returns_graph=True)
+def compose_all(graphs):
+    """Returns the composition of all graphs.
+
+    Composition is the simple union of the node sets and edge sets.
+    The node sets of the supplied graphs need not be disjoint.
+
+    Parameters
+    ----------
+    graphs : iterable
+       Iterable of NetworkX graphs
+
+    Returns
+    -------
+    C : A graph with the same type as the first graph in list
+
+    Raises
+    ------
+    ValueError
+       If `graphs` is an empty list.
+
+    NetworkXError
+        In case of mixed type graphs, like MultiGraph and Graph, or directed and undirected graphs.
+
+    Examples
+    --------
+    >>> G1 = nx.Graph([(1, 2), (2, 3)])
+    >>> G2 = nx.Graph([(3, 4), (5, 6)])
+    >>> C = nx.compose_all([G1, G2])
+    >>> list(C.nodes())
+    [1, 2, 3, 4, 5, 6]
+    >>> list(C.edges())
+    [(1, 2), (2, 3), (3, 4), (5, 6)]
+
+    Notes
+    -----
+    For operating on mixed type graphs, they should be converted to the same type.
+
+    Graph, edge, and node attributes are propagated to the union graph.
+    If a graph attribute is present in multiple graphs, then the value
+    from the last graph in the list with that attribute is used.
+    """
+    R = None
+
+    # add graph attributes, H attributes take precedent over G attributes
+    for i, G in enumerate(graphs):
+        if i == 0:
+            # create new graph
+            R = G.__class__()
+        elif G.is_directed() != R.is_directed():
+            raise nx.NetworkXError("All graphs must be directed or undirected.")
+        elif G.is_multigraph() != R.is_multigraph():
+            raise nx.NetworkXError("All graphs must be graphs or multigraphs.")
+
+        R.graph.update(G.graph)
+        R.add_nodes_from(G.nodes(data=True))
+        R.add_edges_from(
+            G.edges(keys=True, data=True) if G.is_multigraph() else G.edges(data=True)
+        )
+
+    if R is None:
+        raise ValueError("cannot apply compose_all to an empty list")
+
+    return R
+
+
+@nx._dispatchable(graphs="[graphs]", returns_graph=True)
+def intersection_all(graphs):
+    """Returns a new graph that contains only the nodes and the edges that exist in
+    all graphs.
+
+    Parameters
+    ----------
+    graphs : iterable
+       Iterable of NetworkX graphs
+
+    Returns
+    -------
+    R : A new graph with the same type as the first graph in list
+
+    Raises
+    ------
+    ValueError
+       If `graphs` is an empty list.
+
+    NetworkXError
+        In case of mixed type graphs, like MultiGraph and Graph, or directed and undirected graphs.
+
+    Notes
+    -----
+    For operating on mixed type graphs, they should be converted to the same type.
+
+    Attributes from the graph, nodes, and edges are not copied to the new
+    graph.
+
+    The resulting graph can be updated with attributes if desired. For example, code which adds the minimum attribute for each node across all graphs could work.
+    >>> g = nx.Graph()
+    >>> g.add_node(0, capacity=4)
+    >>> g.add_node(1, capacity=3)
+    >>> g.add_edge(0, 1)
+
+    >>> h = g.copy()
+    >>> h.nodes[0]["capacity"] = 2
+
+    >>> gh = nx.intersection_all([g, h])
+
+    >>> new_node_attr = {
+    ...     n: min(*(anyG.nodes[n].get("capacity", float("inf")) for anyG in [g, h]))
+    ...     for n in gh
+    ... }
+    >>> nx.set_node_attributes(gh, new_node_attr, "new_capacity")
+    >>> gh.nodes(data=True)
+    NodeDataView({0: {'new_capacity': 2}, 1: {'new_capacity': 3}})
+
+    Examples
+    --------
+    >>> G1 = nx.Graph([(1, 2), (2, 3)])
+    >>> G2 = nx.Graph([(2, 3), (3, 4)])
+    >>> R = nx.intersection_all([G1, G2])
+    >>> list(R.nodes())
+    [2, 3]
+    >>> list(R.edges())
+    [(2, 3)]
+
+    """
+    R = None
+
+    for i, G in enumerate(graphs):
+        G_nodes_set = set(G.nodes)
+        G_edges_set = set(G.edges)
+        if not G.is_directed():
+            if G.is_multigraph():
+                G_edges_set.update((v, u, k) for u, v, k in list(G_edges_set))
+            else:
+                G_edges_set.update((v, u) for u, v in list(G_edges_set))
+        if i == 0:
+            # create new graph
+            R = G.__class__()
+            node_intersection = G_nodes_set
+            edge_intersection = G_edges_set
+        elif G.is_directed() != R.is_directed():
+            raise nx.NetworkXError("All graphs must be directed or undirected.")
+        elif G.is_multigraph() != R.is_multigraph():
+            raise nx.NetworkXError("All graphs must be graphs or multigraphs.")
+        else:
+            node_intersection &= G_nodes_set
+            edge_intersection &= G_edges_set
+
+    if R is None:
+        raise ValueError("cannot apply intersection_all to an empty list")
+
+    R.add_nodes_from(node_intersection)
+    R.add_edges_from(edge_intersection)
+
+    return R
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/binary.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/binary.py
new file mode 100644
index 00000000..08907bf6
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/binary.py
@@ -0,0 +1,450 @@
+"""
+Operations on graphs including union, intersection, difference.
+"""
+
+import networkx as nx
+
+__all__ = [
+    "union",
+    "compose",
+    "disjoint_union",
+    "intersection",
+    "difference",
+    "symmetric_difference",
+    "full_join",
+]
+_G_H = {"G": 0, "H": 1}
+
+
+@nx._dispatchable(graphs=_G_H, preserve_all_attrs=True, returns_graph=True)
+def union(G, H, rename=()):
+    """Combine graphs G and H. The names of nodes must be unique.
+
+    A name collision between the graphs will raise an exception.
+
+    A renaming facility is provided to avoid name collisions.
+
+
+    Parameters
+    ----------
+    G, H : graph
+       A NetworkX graph
+
+    rename : iterable , optional
+       Node names of G and H can be changed by specifying the tuple
+       rename=('G-','H-') (for example).  Node "u" in G is then renamed
+       "G-u" and "v" in H is renamed "H-v".
+
+    Returns
+    -------
+    U : A union graph with the same type as G.
+
+    See Also
+    --------
+    compose
+    :func:`~networkx.Graph.update`
+    disjoint_union
+
+    Notes
+    -----
+    To combine graphs that have common nodes, consider compose(G, H)
+    or the method, Graph.update().
+
+    disjoint_union() is similar to union() except that it avoids name clashes
+    by relabeling the nodes with sequential integers.
+
+    Edge and node attributes are propagated from G and H to the union graph.
+    Graph attributes are also propagated, but if they are present in both G and H,
+    then the value from H is used.
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2), (1, 2)])
+    >>> H = nx.Graph([(0, 1), (0, 3), (1, 3), (1, 2)])
+    >>> U = nx.union(G, H, rename=("G", "H"))
+    >>> U.nodes
+    NodeView(('G0', 'G1', 'G2', 'H0', 'H1', 'H3', 'H2'))
+    >>> U.edges
+    EdgeView([('G0', 'G1'), ('G0', 'G2'), ('G1', 'G2'), ('H0', 'H1'), ('H0', 'H3'), ('H1', 'H3'), ('H1', 'H2')])
+
+
+    """
+    return nx.union_all([G, H], rename)
+
+
+@nx._dispatchable(graphs=_G_H, preserve_all_attrs=True, returns_graph=True)
+def disjoint_union(G, H):
+    """Combine graphs G and H. The nodes are assumed to be unique (disjoint).
+
+    This algorithm automatically relabels nodes to avoid name collisions.
+
+    Parameters
+    ----------
+    G,H : graph
+       A NetworkX graph
+
+    Returns
+    -------
+    U : A union graph with the same type as G.
+
+    See Also
+    --------
+    union
+    compose
+    :func:`~networkx.Graph.update`
+
+    Notes
+    -----
+    A new graph is created, of the same class as G.  It is recommended
+    that G and H be either both directed or both undirected.
+
+    The nodes of G are relabeled 0 to len(G)-1, and the nodes of H are
+    relabeled len(G) to len(G)+len(H)-1.
+
+    Renumbering forces G and H to be disjoint, so no exception is ever raised for a name collision.
+    To preserve the check for common nodes, use union().
+
+    Edge and node attributes are propagated from G and H to the union graph.
+    Graph attributes are also propagated, but if they are present in both G and H,
+    then the value from H is used.
+
+    To combine graphs that have common nodes, consider compose(G, H)
+    or the method, Graph.update().
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2), (1, 2)])
+    >>> H = nx.Graph([(0, 3), (1, 2), (2, 3)])
+    >>> G.nodes[0]["key1"] = 5
+    >>> H.nodes[0]["key2"] = 10
+    >>> U = nx.disjoint_union(G, H)
+    >>> U.nodes(data=True)
+    NodeDataView({0: {'key1': 5}, 1: {}, 2: {}, 3: {'key2': 10}, 4: {}, 5: {}, 6: {}})
+    >>> U.edges
+    EdgeView([(0, 1), (0, 2), (1, 2), (3, 4), (4, 6), (5, 6)])
+    """
+    return nx.disjoint_union_all([G, H])
+
+
+@nx._dispatchable(graphs=_G_H, returns_graph=True)
+def intersection(G, H):
+    """Returns a new graph that contains only the nodes and the edges that exist in
+    both G and H.
+
+    Parameters
+    ----------
+    G,H : graph
+       A NetworkX graph. G and H can have different node sets but must be both graphs or both multigraphs.
+
+    Raises
+    ------
+    NetworkXError
+        If one is a MultiGraph and the other one is a graph.
+
+    Returns
+    -------
+    GH : A new graph with the same type as G.
+
+    Notes
+    -----
+    Attributes from the graph, nodes, and edges are not copied to the new
+    graph.  If you want a new graph of the intersection of G and H
+    with the attributes (including edge data) from G use remove_nodes_from()
+    as follows
+
+    >>> G = nx.path_graph(3)
+    >>> H = nx.path_graph(5)
+    >>> R = G.copy()
+    >>> R.remove_nodes_from(n for n in G if n not in H)
+    >>> R.remove_edges_from(e for e in G.edges if e not in H.edges)
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2), (1, 2)])
+    >>> H = nx.Graph([(0, 3), (1, 2), (2, 3)])
+    >>> R = nx.intersection(G, H)
+    >>> R.nodes
+    NodeView((0, 1, 2))
+    >>> R.edges
+    EdgeView([(1, 2)])
+    """
+    return nx.intersection_all([G, H])
+
+
+@nx._dispatchable(graphs=_G_H, returns_graph=True)
+def difference(G, H):
+    """Returns a new graph that contains the edges that exist in G but not in H.
+
+    The node sets of H and G must be the same.
+
+    Parameters
+    ----------
+    G,H : graph
+       A NetworkX graph. G and H must have the same node sets.
+
+    Returns
+    -------
+    D : A new graph with the same type as G.
+
+    Notes
+    -----
+    Attributes from the graph, nodes, and edges are not copied to the new
+    graph.  If you want a new graph of the difference of G and H with
+    the attributes (including edge data) from G use remove_nodes_from()
+    as follows:
+
+    >>> G = nx.path_graph(3)
+    >>> H = nx.path_graph(5)
+    >>> R = G.copy()
+    >>> R.remove_nodes_from(n for n in G if n in H)
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2), (1, 2), (1, 3)])
+    >>> H = nx.Graph([(0, 1), (1, 2), (0, 3)])
+    >>> R = nx.difference(G, H)
+    >>> R.nodes
+    NodeView((0, 1, 2, 3))
+    >>> R.edges
+    EdgeView([(0, 2), (1, 3)])
+    """
+    # create new graph
+    if not G.is_multigraph() == H.is_multigraph():
+        raise nx.NetworkXError("G and H must both be graphs or multigraphs.")
+    R = nx.create_empty_copy(G, with_data=False)
+
+    if set(G) != set(H):
+        raise nx.NetworkXError("Node sets of graphs not equal")
+
+    if G.is_multigraph():
+        edges = G.edges(keys=True)
+    else:
+        edges = G.edges()
+    for e in edges:
+        if not H.has_edge(*e):
+            R.add_edge(*e)
+    return R
+
+
+@nx._dispatchable(graphs=_G_H, returns_graph=True)
+def symmetric_difference(G, H):
+    """Returns new graph with edges that exist in either G or H but not both.
+
+    The node sets of H and G must be the same.
+
+    Parameters
+    ----------
+    G,H : graph
+       A NetworkX graph.  G and H must have the same node sets.
+
+    Returns
+    -------
+    D : A new graph with the same type as G.
+
+    Notes
+    -----
+    Attributes from the graph, nodes, and edges are not copied to the new
+    graph.
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2), (1, 2), (1, 3)])
+    >>> H = nx.Graph([(0, 1), (1, 2), (0, 3)])
+    >>> R = nx.symmetric_difference(G, H)
+    >>> R.nodes
+    NodeView((0, 1, 2, 3))
+    >>> R.edges
+    EdgeView([(0, 2), (0, 3), (1, 3)])
+    """
+    # create new graph
+    if not G.is_multigraph() == H.is_multigraph():
+        raise nx.NetworkXError("G and H must both be graphs or multigraphs.")
+    R = nx.create_empty_copy(G, with_data=False)
+
+    if set(G) != set(H):
+        raise nx.NetworkXError("Node sets of graphs not equal")
+
+    gnodes = set(G)  # set of nodes in G
+    hnodes = set(H)  # set of nodes in H
+    nodes = gnodes.symmetric_difference(hnodes)
+    R.add_nodes_from(nodes)
+
+    if G.is_multigraph():
+        edges = G.edges(keys=True)
+    else:
+        edges = G.edges()
+    # we could copy the data here but then this function doesn't
+    # match intersection and difference
+    for e in edges:
+        if not H.has_edge(*e):
+            R.add_edge(*e)
+
+    if H.is_multigraph():
+        edges = H.edges(keys=True)
+    else:
+        edges = H.edges()
+    for e in edges:
+        if not G.has_edge(*e):
+            R.add_edge(*e)
+    return R
+
+
+@nx._dispatchable(graphs=_G_H, preserve_all_attrs=True, returns_graph=True)
+def compose(G, H):
+    """Compose graph G with H by combining nodes and edges into a single graph.
+
+    The node sets and edges sets do not need to be disjoint.
+
+    Composing preserves the attributes of nodes and edges.
+    Attribute values from H take precedent over attribute values from G.
+
+    Parameters
+    ----------
+    G, H : graph
+       A NetworkX graph
+
+    Returns
+    -------
+    C: A new graph with the same type as G
+
+    See Also
+    --------
+    :func:`~networkx.Graph.update`
+    union
+    disjoint_union
+
+    Notes
+    -----
+    It is recommended that G and H be either both directed or both undirected.
+
+    For MultiGraphs, the edges are identified by incident nodes AND edge-key.
+    This can cause surprises (i.e., edge `(1, 2)` may or may not be the same
+    in two graphs) if you use MultiGraph without keeping track of edge keys.
+
+    If combining the attributes of common nodes is not desired, consider union(),
+    which raises an exception for name collisions.
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2)])
+    >>> H = nx.Graph([(0, 1), (1, 2)])
+    >>> R = nx.compose(G, H)
+    >>> R.nodes
+    NodeView((0, 1, 2))
+    >>> R.edges
+    EdgeView([(0, 1), (0, 2), (1, 2)])
+
+    By default, the attributes from `H` take precedent over attributes from `G`.
+    If you prefer another way of combining attributes, you can update them after the compose operation:
+
+    >>> G = nx.Graph([(0, 1, {"weight": 2.0}), (3, 0, {"weight": 100.0})])
+    >>> H = nx.Graph([(0, 1, {"weight": 10.0}), (1, 2, {"weight": -1.0})])
+    >>> nx.set_node_attributes(G, {0: "dark", 1: "light", 3: "black"}, name="color")
+    >>> nx.set_node_attributes(H, {0: "green", 1: "orange", 2: "yellow"}, name="color")
+    >>> GcomposeH = nx.compose(G, H)
+
+    Normally, color attribute values of nodes of GcomposeH come from H. We can workaround this as follows:
+
+    >>> node_data = {
+    ...     n: G.nodes[n]["color"] + " " + H.nodes[n]["color"]
+    ...     for n in G.nodes & H.nodes
+    ... }
+    >>> nx.set_node_attributes(GcomposeH, node_data, "color")
+    >>> print(GcomposeH.nodes[0]["color"])
+    dark green
+
+    >>> print(GcomposeH.nodes[3]["color"])
+    black
+
+    Similarly, we can update edge attributes after the compose operation in a way we prefer:
+
+    >>> edge_data = {
+    ...     e: G.edges[e]["weight"] * H.edges[e]["weight"] for e in G.edges & H.edges
+    ... }
+    >>> nx.set_edge_attributes(GcomposeH, edge_data, "weight")
+    >>> print(GcomposeH.edges[(0, 1)]["weight"])
+    20.0
+
+    >>> print(GcomposeH.edges[(3, 0)]["weight"])
+    100.0
+    """
+    return nx.compose_all([G, H])
+
+
+@nx._dispatchable(graphs=_G_H, preserve_all_attrs=True, returns_graph=True)
+def full_join(G, H, rename=(None, None)):
+    """Returns the full join of graphs G and H.
+
+    Full join is the union of G and H in which all edges between
+    G and H are added.
+    The node sets of G and H must be disjoint,
+    otherwise an exception is raised.
+
+    Parameters
+    ----------
+    G, H : graph
+       A NetworkX graph
+
+    rename : tuple , default=(None, None)
+       Node names of G and H can be changed by specifying the tuple
+       rename=('G-','H-') (for example).  Node "u" in G is then renamed
+       "G-u" and "v" in H is renamed "H-v".
+
+    Returns
+    -------
+    U : The full join graph with the same type as G.
+
+    Notes
+    -----
+    It is recommended that G and H be either both directed or both undirected.
+
+    If G is directed, then edges from G to H are added as well as from H to G.
+
+    Note that full_join() does not produce parallel edges for MultiGraphs.
+
+    The full join operation of graphs G and H is the same as getting
+    their complement, performing a disjoint union, and finally getting
+    the complement of the resulting graph.
+
+    Graph, edge, and node attributes are propagated from G and H
+    to the union graph.  If a graph attribute is present in both
+    G and H the value from H is used.
+
+    Examples
+    --------
+    >>> G = nx.Graph([(0, 1), (0, 2)])
+    >>> H = nx.Graph([(3, 4)])
+    >>> R = nx.full_join(G, H, rename=("G", "H"))
+    >>> R.nodes
+    NodeView(('G0', 'G1', 'G2', 'H3', 'H4'))
+    >>> R.edges
+    EdgeView([('G0', 'G1'), ('G0', 'G2'), ('G0', 'H3'), ('G0', 'H4'), ('G1', 'H3'), ('G1', 'H4'), ('G2', 'H3'), ('G2', 'H4'), ('H3', 'H4')])
+
+    See Also
+    --------
+    union
+    disjoint_union
+    """
+    R = union(G, H, rename)
+
+    def add_prefix(graph, prefix):
+        if prefix is None:
+            return graph
+
+        def label(x):
+            return f"{prefix}{x}"
+
+        return nx.relabel_nodes(graph, label)
+
+    G = add_prefix(G, rename[0])
+    H = add_prefix(H, rename[1])
+
+    for i in G:
+        for j in H:
+            R.add_edge(i, j)
+    if R.is_directed():
+        for i in H:
+            for j in G:
+                R.add_edge(i, j)
+
+    return R
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/product.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/product.py
new file mode 100644
index 00000000..28ca78bf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/product.py
@@ -0,0 +1,633 @@
+"""
+Graph products.
+"""
+
+from itertools import product
+
+import networkx as nx
+from networkx.utils import not_implemented_for
+
+__all__ = [
+    "tensor_product",
+    "cartesian_product",
+    "lexicographic_product",
+    "strong_product",
+    "power",
+    "rooted_product",
+    "corona_product",
+    "modular_product",
+]
+_G_H = {"G": 0, "H": 1}
+
+
+def _dict_product(d1, d2):
+    return {k: (d1.get(k), d2.get(k)) for k in set(d1) | set(d2)}
+
+
+# Generators for producing graph products
+def _node_product(G, H):
+    for u, v in product(G, H):
+        yield ((u, v), _dict_product(G.nodes[u], H.nodes[v]))
+
+
+def _directed_edges_cross_edges(G, H):
+    if not G.is_multigraph() and not H.is_multigraph():
+        for u, v, c in G.edges(data=True):
+            for x, y, d in H.edges(data=True):
+                yield (u, x), (v, y), _dict_product(c, d)
+    if not G.is_multigraph() and H.is_multigraph():
+        for u, v, c in G.edges(data=True):
+            for x, y, k, d in H.edges(data=True, keys=True):
+                yield (u, x), (v, y), k, _dict_product(c, d)
+    if G.is_multigraph() and not H.is_multigraph():
+        for u, v, k, c in G.edges(data=True, keys=True):
+            for x, y, d in H.edges(data=True):
+                yield (u, x), (v, y), k, _dict_product(c, d)
+    if G.is_multigraph() and H.is_multigraph():
+        for u, v, j, c in G.edges(data=True, keys=True):
+            for x, y, k, d in H.edges(data=True, keys=True):
+                yield (u, x), (v, y), (j, k), _dict_product(c, d)
+
+
+def _undirected_edges_cross_edges(G, H):
+    if not G.is_multigraph() and not H.is_multigraph():
+        for u, v, c in G.edges(data=True):
+            for x, y, d in H.edges(data=True):
+                yield (v, x), (u, y), _dict_product(c, d)
+    if not G.is_multigraph() and H.is_multigraph():
+        for u, v, c in G.edges(data=True):
+            for x, y, k, d in H.edges(data=True, keys=True):
+                yield (v, x), (u, y), k, _dict_product(c, d)
+    if G.is_multigraph() and not H.is_multigraph():
+        for u, v, k, c in G.edges(data=True, keys=True):
+            for x, y, d in H.edges(data=True):
+                yield (v, x), (u, y), k, _dict_product(c, d)
+    if G.is_multigraph() and H.is_multigraph():
+        for u, v, j, c in G.edges(data=True, keys=True):
+            for x, y, k, d in H.edges(data=True, keys=True):
+                yield (v, x), (u, y), (j, k), _dict_product(c, d)
+
+
+def _edges_cross_nodes(G, H):
+    if G.is_multigraph():
+        for u, v, k, d in G.edges(data=True, keys=True):
+            for x in H:
+                yield (u, x), (v, x), k, d
+    else:
+        for u, v, d in G.edges(data=True):
+            for x in H:
+                if H.is_multigraph():
+                    yield (u, x), (v, x), None, d
+                else:
+                    yield (u, x), (v, x), d
+
+
+def _nodes_cross_edges(G, H):
+    if H.is_multigraph():
+        for x in G:
+            for u, v, k, d in H.edges(data=True, keys=True):
+                yield (x, u), (x, v), k, d
+    else:
+        for x in G:
+            for u, v, d in H.edges(data=True):
+                if G.is_multigraph():
+                    yield (x, u), (x, v), None, d
+                else:
+                    yield (x, u), (x, v), d
+
+
+def _edges_cross_nodes_and_nodes(G, H):
+    if G.is_multigraph():
+        for u, v, k, d in G.edges(data=True, keys=True):
+            for x in H:
+                for y in H:
+                    yield (u, x), (v, y), k, d
+    else:
+        for u, v, d in G.edges(data=True):
+            for x in H:
+                for y in H:
+                    if H.is_multigraph():
+                        yield (u, x), (v, y), None, d
+                    else:
+                        yield (u, x), (v, y), d
+
+
+def _init_product_graph(G, H):
+    if G.is_directed() != H.is_directed():
+        msg = "G and H must be both directed or both undirected"
+        raise nx.NetworkXError(msg)
+    if G.is_multigraph() or H.is_multigraph():
+        GH = nx.MultiGraph()
+    else:
+        GH = nx.Graph()
+    if G.is_directed():
+        GH = GH.to_directed()
+    return GH
+
+
+@nx._dispatchable(graphs=_G_H, preserve_node_attrs=True, returns_graph=True)
+def tensor_product(G, H):
+    r"""Returns the tensor product of G and H.
+
+    The tensor product $P$ of the graphs $G$ and $H$ has a node set that
+    is the Cartesian product of the node sets, $V(P)=V(G) \times V(H)$.
+    $P$ has an edge $((u,v), (x,y))$ if and only if $(u,x)$ is an edge in $G$
+    and $(v,y)$ is an edge in $H$.
+
+    Tensor product is sometimes also referred to as the categorical product,
+    direct product, cardinal product or conjunction.
+
+
+    Parameters
+    ----------
+    G, H: graphs
+     Networkx graphs.
+
+    Returns
+    -------
+    P: NetworkX graph
+     The tensor product of G and H. P will be a multi-graph if either G
+     or H is a multi-graph, will be a directed if G and H are directed,
+     and undirected if G and H are undirected.
+
+    Raises
+    ------
+    NetworkXError
+     If G and H are not both directed or both undirected.
+
+    Notes
+    -----
+    Node attributes in P are two-tuple of the G and H node attributes.
+    Missing attributes are assigned None.
+
+    Examples
+    --------
+    >>> G = nx.Graph()
+    >>> H = nx.Graph()
+    >>> G.add_node(0, a1=True)
+    >>> H.add_node("a", a2="Spam")
+    >>> P = nx.tensor_product(G, H)
+    >>> list(P)
+    [(0, 'a')]
+
+    Edge attributes and edge keys (for multigraphs) are also copied to the
+    new product graph
+    """
+    GH = _init_product_graph(G, H)
+    GH.add_nodes_from(_node_product(G, H))
+    GH.add_edges_from(_directed_edges_cross_edges(G, H))
+    if not GH.is_directed():
+        GH.add_edges_from(_undirected_edges_cross_edges(G, H))
+    return GH
+
+
+@nx._dispatchable(graphs=_G_H, preserve_node_attrs=True, returns_graph=True)
+def cartesian_product(G, H):
+    r"""Returns the Cartesian product of G and H.
+
+    The Cartesian product $P$ of the graphs $G$ and $H$ has a node set that
+    is the Cartesian product of the node sets, $V(P)=V(G) \times V(H)$.
+    $P$ has an edge $((u,v),(x,y))$ if and only if either $u$ is equal to $x$
+    and both $v$ and $y$ are adjacent in $H$ or if $v$ is equal to $y$ and
+    both $u$ and $x$ are adjacent in $G$.
+
+    Parameters
+    ----------
+    G, H: graphs
+     Networkx graphs.
+
+    Returns
+    -------
+    P: NetworkX graph
+     The Cartesian product of G and H. P will be a multi-graph if either G
+     or H is a multi-graph. Will be a directed if G and H are directed,
+     and undirected if G and H are undirected.
+
+    Raises
+    ------
+    NetworkXError
+     If G and H are not both directed or both undirected.
+
+    Notes
+    -----
+    Node attributes in P are two-tuple of the G and H node attributes.
+    Missing attributes are assigned None.
+
+    Examples
+    --------
+    >>> G = nx.Graph()
+    >>> H = nx.Graph()
+    >>> G.add_node(0, a1=True)
+    >>> H.add_node("a", a2="Spam")
+    >>> P = nx.cartesian_product(G, H)
+    >>> list(P)
+    [(0, 'a')]
+
+    Edge attributes and edge keys (for multigraphs) are also copied to the
+    new product graph
+    """
+    GH = _init_product_graph(G, H)
+    GH.add_nodes_from(_node_product(G, H))
+    GH.add_edges_from(_edges_cross_nodes(G, H))
+    GH.add_edges_from(_nodes_cross_edges(G, H))
+    return GH
+
+
+@nx._dispatchable(graphs=_G_H, preserve_node_attrs=True, returns_graph=True)
+def lexicographic_product(G, H):
+    r"""Returns the lexicographic product of G and H.
+
+    The lexicographical product $P$ of the graphs $G$ and $H$ has a node set
+    that is the Cartesian product of the node sets, $V(P)=V(G) \times V(H)$.
+    $P$ has an edge $((u,v), (x,y))$ if and only if $(u,v)$ is an edge in $G$
+    or $u==v$ and $(x,y)$ is an edge in $H$.
+
+    Parameters
+    ----------
+    G, H: graphs
+     Networkx graphs.
+
+    Returns
+    -------
+    P: NetworkX graph
+     The Cartesian product of G and H. P will be a multi-graph if either G
+     or H is a multi-graph. Will be a directed if G and H are directed,
+     and undirected if G and H are undirected.
+
+    Raises
+    ------
+    NetworkXError
+     If G and H are not both directed or both undirected.
+
+    Notes
+    -----
+    Node attributes in P are two-tuple of the G and H node attributes.
+    Missing attributes are assigned None.
+
+    Examples
+    --------
+    >>> G = nx.Graph()
+    >>> H = nx.Graph()
+    >>> G.add_node(0, a1=True)
+    >>> H.add_node("a", a2="Spam")
+    >>> P = nx.lexicographic_product(G, H)
+    >>> list(P)
+    [(0, 'a')]
+
+    Edge attributes and edge keys (for multigraphs) are also copied to the
+    new product graph
+    """
+    GH = _init_product_graph(G, H)
+    GH.add_nodes_from(_node_product(G, H))
+    # Edges in G regardless of H designation
+    GH.add_edges_from(_edges_cross_nodes_and_nodes(G, H))
+    # For each x in G, only if there is an edge in H
+    GH.add_edges_from(_nodes_cross_edges(G, H))
+    return GH
+
+
+@nx._dispatchable(graphs=_G_H, preserve_node_attrs=True, returns_graph=True)
+def strong_product(G, H):
+    r"""Returns the strong product of G and H.
+
+    The strong product $P$ of the graphs $G$ and $H$ has a node set that
+    is the Cartesian product of the node sets, $V(P)=V(G) \times V(H)$.
+    $P$ has an edge $((u,x), (v,y))$ if any of the following conditions
+    are met:
+
+    - $u=v$ and $(x,y)$ is an edge in $H$
+    - $x=y$ and $(u,v)$ is an edge in $G$
+    - $(u,v)$ is an edge in $G$ and $(x,y)$ is an edge in $H$
+
+    Parameters
+    ----------
+    G, H: graphs
+     Networkx graphs.
+
+    Returns
+    -------
+    P: NetworkX graph
+     The Cartesian product of G and H. P will be a multi-graph if either G
+     or H is a multi-graph. Will be a directed if G and H are directed,
+     and undirected if G and H are undirected.
+
+    Raises
+    ------
+    NetworkXError
+     If G and H are not both directed or both undirected.
+
+    Notes
+    -----
+    Node attributes in P are two-tuple of the G and H node attributes.
+    Missing attributes are assigned None.
+
+    Examples
+    --------
+    >>> G = nx.Graph()
+    >>> H = nx.Graph()
+    >>> G.add_node(0, a1=True)
+    >>> H.add_node("a", a2="Spam")
+    >>> P = nx.strong_product(G, H)
+    >>> list(P)
+    [(0, 'a')]
+
+    Edge attributes and edge keys (for multigraphs) are also copied to the
+    new product graph
+    """
+    GH = _init_product_graph(G, H)
+    GH.add_nodes_from(_node_product(G, H))
+    GH.add_edges_from(_nodes_cross_edges(G, H))
+    GH.add_edges_from(_edges_cross_nodes(G, H))
+    GH.add_edges_from(_directed_edges_cross_edges(G, H))
+    if not GH.is_directed():
+        GH.add_edges_from(_undirected_edges_cross_edges(G, H))
+    return GH
+
+
+@not_implemented_for("directed")
+@not_implemented_for("multigraph")
+@nx._dispatchable(returns_graph=True)
+def power(G, k):
+    """Returns the specified power of a graph.
+
+    The $k$th power of a simple graph $G$, denoted $G^k$, is a
+    graph on the same set of nodes in which two distinct nodes $u$ and
+    $v$ are adjacent in $G^k$ if and only if the shortest path
+    distance between $u$ and $v$ in $G$ is at most $k$.
+
+    Parameters
+    ----------
+    G : graph
+        A NetworkX simple graph object.
+
+    k : positive integer
+        The power to which to raise the graph `G`.
+
+    Returns
+    -------
+    NetworkX simple graph
+        `G` to the power `k`.
+
+    Raises
+    ------
+    ValueError
+        If the exponent `k` is not positive.
+
+    NetworkXNotImplemented
+        If `G` is not a simple graph.
+
+    Examples
+    --------
+    The number of edges will never decrease when taking successive
+    powers:
+
+    >>> G = nx.path_graph(4)
+    >>> list(nx.power(G, 2).edges)
+    [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3)]
+    >>> list(nx.power(G, 3).edges)
+    [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
+
+    The `k` th power of a cycle graph on *n* nodes is the complete graph
+    on *n* nodes, if `k` is at least ``n // 2``:
+
+    >>> G = nx.cycle_graph(5)
+    >>> H = nx.complete_graph(5)
+    >>> nx.is_isomorphic(nx.power(G, 2), H)
+    True
+    >>> G = nx.cycle_graph(8)
+    >>> H = nx.complete_graph(8)
+    >>> nx.is_isomorphic(nx.power(G, 4), H)
+    True
+
+    References
+    ----------
+    .. [1] J. A. Bondy, U. S. R. Murty, *Graph Theory*. Springer, 2008.
+
+    Notes
+    -----
+    This definition of "power graph" comes from Exercise 3.1.6 of
+    *Graph Theory* by Bondy and Murty [1]_.
+
+    """
+    if k <= 0:
+        raise ValueError("k must be a positive integer")
+    H = nx.Graph()
+    H.add_nodes_from(G)
+    # update BFS code to ignore self loops.
+    for n in G:
+        seen = {}  # level (number of hops) when seen in BFS
+        level = 1  # the current level
+        nextlevel = G[n]
+        while nextlevel:
+            thislevel = nextlevel  # advance to next level
+            nextlevel = {}  # and start a new list (fringe)
+            for v in thislevel:
+                if v == n:  # avoid self loop
+                    continue
+                if v not in seen:
+                    seen[v] = level  # set the level of vertex v
+                    nextlevel.update(G[v])  # add neighbors of v
+            if k <= level:
+                break
+            level += 1
+        H.add_edges_from((n, nbr) for nbr in seen)
+    return H
+
+
+@not_implemented_for("multigraph")
+@nx._dispatchable(graphs=_G_H, returns_graph=True)
+def rooted_product(G, H, root):
+    """Return the rooted product of graphs G and H rooted at root in H.
+
+    A new graph is constructed representing the rooted product of
+    the inputted graphs, G and H, with a root in H.
+    A rooted product duplicates H for each nodes in G with the root
+    of H corresponding to the node in G. Nodes are renamed as the direct
+    product of G and H. The result is a subgraph of the cartesian product.
+
+    Parameters
+    ----------
+    G,H : graph
+       A NetworkX graph
+    root : node
+       A node in H
+
+    Returns
+    -------
+    R : The rooted product of G and H with a specified root in H
+
+    Notes
+    -----
+    The nodes of R are the Cartesian Product of the nodes of G and H.
+    The nodes of G and H are not relabeled.
+    """
+    if root not in H:
+        raise nx.NodeNotFound("root must be a vertex in H")
+
+    R = nx.Graph()
+    R.add_nodes_from(product(G, H))
+
+    R.add_edges_from(((e[0], root), (e[1], root)) for e in G.edges())
+    R.add_edges_from(((g, e[0]), (g, e[1])) for g in G for e in H.edges())
+
+    return R
+
+
+@not_implemented_for("directed")
+@not_implemented_for("multigraph")
+@nx._dispatchable(graphs=_G_H, returns_graph=True)
+def corona_product(G, H):
+    r"""Returns the Corona product of G and H.
+
+    The corona product of $G$ and $H$ is the graph $C = G \circ H$ obtained by
+    taking one copy of $G$, called the center graph, $|V(G)|$ copies of $H$,
+    called the outer graph, and making the $i$-th vertex of $G$ adjacent to
+    every vertex of the $i$-th copy of $H$, where $1 ≤ i ≤ |V(G)|$.
+
+    Parameters
+    ----------
+    G, H: NetworkX graphs
+        The graphs to take the carona product of.
+        `G` is the center graph and `H` is the outer graph
+
+    Returns
+    -------
+    C: NetworkX graph
+        The Corona product of G and H.
+
+    Raises
+    ------
+    NetworkXError
+        If G and H are not both directed or both undirected.
+
+    Examples
+    --------
+    >>> G = nx.cycle_graph(4)
+    >>> H = nx.path_graph(2)
+    >>> C = nx.corona_product(G, H)
+    >>> list(C)
+    [0, 1, 2, 3, (0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (2, 1), (3, 0), (3, 1)]
+    >>> print(C)
+    Graph with 12 nodes and 16 edges
+
+    References
+    ----------
+    [1] M. Tavakoli, F. Rahbarnia, and A. R. Ashrafi,
+        "Studying the corona product of graphs under some graph invariants,"
+        Transactions on Combinatorics, vol. 3, no. 3, pp. 43–49, Sep. 2014,
+        doi: 10.22108/toc.2014.5542.
+    [2] A. Faraji, "Corona Product in Graph Theory," Ali Faraji, May 11, 2021.
+        https://blog.alifaraji.ir/math/graph-theory/corona-product.html (accessed Dec. 07, 2021).
+    """
+    GH = _init_product_graph(G, H)
+    GH.add_nodes_from(G)
+    GH.add_edges_from(G.edges)
+
+    for G_node in G:
+        # copy nodes of H in GH, call it H_i
+        GH.add_nodes_from((G_node, v) for v in H)
+
+        # copy edges of H_i based on H
+        GH.add_edges_from(
+            ((G_node, e0), (G_node, e1), d) for e0, e1, d in H.edges.data()
+        )
+
+        # creating new edges between H_i and a G's node
+        GH.add_edges_from((G_node, (G_node, H_node)) for H_node in H)
+
+    return GH
+
+
+@nx._dispatchable(
+    graphs=_G_H, preserve_edge_attrs=True, preserve_node_attrs=True, returns_graph=True
+)
+def modular_product(G, H):
+    r"""Returns the Modular product of G and H.
+
+    The modular product of `G` and `H` is the graph $M = G \nabla H$,
+    consisting of the node set $V(M) = V(G) \times V(H)$ that is the Cartesian
+    product of the node sets of `G` and `H`. Further, M contains an edge ((u, v), (x, y)):
+
+    - if u is adjacent to x in `G` and v is adjacent to y in `H`, or
+    - if u is not adjacent to x in `G` and v is not adjacent to y in `H`.
+
+    More formally::
+
+        E(M) = {((u, v), (x, y)) | ((u, x) in E(G) and (v, y) in E(H)) or
+                                   ((u, x) not in E(G) and (v, y) not in E(H))}
+
+    Parameters
+    ----------
+    G, H: NetworkX graphs
+        The graphs to take the modular product of.
+
+    Returns
+    -------
+    M: NetworkX graph
+        The Modular product of `G` and `H`.
+
+    Raises
+    ------
+    NetworkXNotImplemented
+        If `G` is not a simple graph.
+
+    Examples
+    --------
+    >>> G = nx.cycle_graph(4)
+    >>> H = nx.path_graph(2)
+    >>> M = nx.modular_product(G, H)
+    >>> list(M)
+    [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (2, 1), (3, 0), (3, 1)]
+    >>> print(M)
+    Graph with 8 nodes and 8 edges
+
+    Notes
+    -----
+    The *modular product* is defined in [1]_ and was first
+    introduced as the *weak modular product*.
+
+    The modular product reduces the problem of counting isomorphic subgraphs
+    in `G` and `H` to the problem of counting cliques in M. The subgraphs of
+    `G` and `H` that are induced by the nodes of a clique in M are
+    isomorphic [2]_ [3]_.
+
+    References
+    ----------
+    .. [1] R. Hammack, W. Imrich, and S. Klavžar,
+        "Handbook of Product Graphs", CRC Press, 2011.
+
+    .. [2] H. G. Barrow and R. M. Burstall,
+        "Subgraph isomorphism, matching relational structures and maximal
+        cliques", Information Processing Letters, vol. 4, issue 4, pp. 83-84,
+        1976, https://doi.org/10.1016/0020-0190(76)90049-1.
+
+    .. [3] V. G. Vizing, "Reduction of the problem of isomorphism and isomorphic
+        entrance to the task of finding the nondensity of a graph." Proc. Third
+        All-Union Conference on Problems of Theoretical Cybernetics. 1974.
+    """
+    if G.is_directed() or H.is_directed():
+        raise nx.NetworkXNotImplemented(
+            "Modular product not implemented for directed graphs"
+        )
+    if G.is_multigraph() or H.is_multigraph():
+        raise nx.NetworkXNotImplemented(
+            "Modular product not implemented for multigraphs"
+        )
+
+    GH = _init_product_graph(G, H)
+    GH.add_nodes_from(_node_product(G, H))
+
+    for u, v, c in G.edges(data=True):
+        for x, y, d in H.edges(data=True):
+            GH.add_edge((u, x), (v, y), **_dict_product(c, d))
+            GH.add_edge((v, x), (u, y), **_dict_product(c, d))
+
+    G = nx.complement(G)
+    H = nx.complement(H)
+
+    for u, v, c in G.edges(data=True):
+        for x, y, d in H.edges(data=True):
+            GH.add_edge((u, x), (v, y), **_dict_product(c, d))
+            GH.add_edge((v, x), (u, y), **_dict_product(c, d))
+
+    return GH
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/__init__.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/__init__.py
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_all.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_all.py
new file mode 100644
index 00000000..8ec29c15
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_all.py
@@ -0,0 +1,328 @@
+import pytest
+
+import networkx as nx
+from networkx.utils import edges_equal
+
+
+def test_union_all_attributes():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+
+    j = g.copy()
+    j.graph["name"] = "j"
+    j.graph["attr"] = "attr"
+    j.nodes[0]["x"] = 7
+
+    ghj = nx.union_all([g, h, j], rename=("g", "h", "j"))
+    assert set(ghj.nodes()) == {"h0", "h1", "g0", "g1", "j0", "j1"}
+    for n in ghj:
+        graph, node = n
+        assert ghj.nodes[n] == eval(graph).nodes[int(node)]
+
+    assert ghj.graph["attr"] == "attr"
+    assert ghj.graph["name"] == "j"  # j graph attributes take precedent
+
+
+def test_intersection_all():
+    G = nx.Graph()
+    H = nx.Graph()
+    R = nx.Graph(awesome=True)
+    G.add_nodes_from([1, 2, 3, 4])
+    G.add_edge(1, 2)
+    G.add_edge(2, 3)
+    H.add_nodes_from([1, 2, 3, 4])
+    H.add_edge(2, 3)
+    H.add_edge(3, 4)
+    R.add_nodes_from([1, 2, 3, 4])
+    R.add_edge(2, 3)
+    R.add_edge(4, 1)
+    I = nx.intersection_all([G, H, R])
+    assert set(I.nodes()) == {1, 2, 3, 4}
+    assert sorted(I.edges()) == [(2, 3)]
+    assert I.graph == {}
+
+
+def test_intersection_all_different_node_sets():
+    G = nx.Graph()
+    H = nx.Graph()
+    R = nx.Graph()
+    G.add_nodes_from([1, 2, 3, 4, 6, 7])
+    G.add_edge(1, 2)
+    G.add_edge(2, 3)
+    G.add_edge(6, 7)
+    H.add_nodes_from([1, 2, 3, 4])
+    H.add_edge(2, 3)
+    H.add_edge(3, 4)
+    R.add_nodes_from([1, 2, 3, 4, 8, 9])
+    R.add_edge(2, 3)
+    R.add_edge(4, 1)
+    R.add_edge(8, 9)
+    I = nx.intersection_all([G, H, R])
+    assert set(I.nodes()) == {1, 2, 3, 4}
+    assert sorted(I.edges()) == [(2, 3)]
+
+
+def test_intersection_all_attributes():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+
+    gh = nx.intersection_all([g, h])
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == sorted(g.edges())
+
+
+def test_intersection_all_attributes_different_node_sets():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    g.add_node(2)
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+
+    gh = nx.intersection_all([g, h])
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == sorted(g.edges())
+
+
+def test_intersection_all_multigraph_attributes():
+    g = nx.MultiGraph()
+    g.add_edge(0, 1, key=0)
+    g.add_edge(0, 1, key=1)
+    g.add_edge(0, 1, key=2)
+    h = nx.MultiGraph()
+    h.add_edge(0, 1, key=0)
+    h.add_edge(0, 1, key=3)
+    gh = nx.intersection_all([g, h])
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == [(0, 1)]
+    assert sorted(gh.edges(keys=True)) == [(0, 1, 0)]
+
+
+def test_intersection_all_multigraph_attributes_different_node_sets():
+    g = nx.MultiGraph()
+    g.add_edge(0, 1, key=0)
+    g.add_edge(0, 1, key=1)
+    g.add_edge(0, 1, key=2)
+    g.add_edge(1, 2, key=1)
+    g.add_edge(1, 2, key=2)
+    h = nx.MultiGraph()
+    h.add_edge(0, 1, key=0)
+    h.add_edge(0, 1, key=2)
+    h.add_edge(0, 1, key=3)
+    gh = nx.intersection_all([g, h])
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == [(0, 1), (0, 1)]
+    assert sorted(gh.edges(keys=True)) == [(0, 1, 0), (0, 1, 2)]
+
+
+def test_intersection_all_digraph():
+    g = nx.DiGraph()
+    g.add_edges_from([(1, 2), (2, 3)])
+    h = nx.DiGraph()
+    h.add_edges_from([(2, 1), (2, 3)])
+    gh = nx.intersection_all([g, h])
+    assert sorted(gh.edges()) == [(2, 3)]
+
+
+def test_union_all_and_compose_all():
+    K3 = nx.complete_graph(3)
+    P3 = nx.path_graph(3)
+
+    G1 = nx.DiGraph()
+    G1.add_edge("A", "B")
+    G1.add_edge("A", "C")
+    G1.add_edge("A", "D")
+    G2 = nx.DiGraph()
+    G2.add_edge("1", "2")
+    G2.add_edge("1", "3")
+    G2.add_edge("1", "4")
+
+    G = nx.union_all([G1, G2])
+    H = nx.compose_all([G1, G2])
+    assert edges_equal(G.edges(), H.edges())
+    assert not G.has_edge("A", "1")
+    pytest.raises(nx.NetworkXError, nx.union, K3, P3)
+    H1 = nx.union_all([H, G1], rename=("H", "G1"))
+    assert sorted(H1.nodes()) == [
+        "G1A",
+        "G1B",
+        "G1C",
+        "G1D",
+        "H1",
+        "H2",
+        "H3",
+        "H4",
+        "HA",
+        "HB",
+        "HC",
+        "HD",
+    ]
+
+    H2 = nx.union_all([H, G2], rename=("H", ""))
+    assert sorted(H2.nodes()) == [
+        "1",
+        "2",
+        "3",
+        "4",
+        "H1",
+        "H2",
+        "H3",
+        "H4",
+        "HA",
+        "HB",
+        "HC",
+        "HD",
+    ]
+
+    assert not H1.has_edge("NB", "NA")
+
+    G = nx.compose_all([G, G])
+    assert edges_equal(G.edges(), H.edges())
+
+    G2 = nx.union_all([G2, G2], rename=("", "copy"))
+    assert sorted(G2.nodes()) == [
+        "1",
+        "2",
+        "3",
+        "4",
+        "copy1",
+        "copy2",
+        "copy3",
+        "copy4",
+    ]
+
+    assert sorted(G2.neighbors("copy4")) == []
+    assert sorted(G2.neighbors("copy1")) == ["copy2", "copy3", "copy4"]
+    assert len(G) == 8
+    assert nx.number_of_edges(G) == 6
+
+    E = nx.disjoint_union_all([G, G])
+    assert len(E) == 16
+    assert nx.number_of_edges(E) == 12
+
+    E = nx.disjoint_union_all([G1, G2])
+    assert sorted(E.nodes()) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
+
+    G1 = nx.DiGraph()
+    G1.add_edge("A", "B")
+    G2 = nx.DiGraph()
+    G2.add_edge(1, 2)
+    G3 = nx.DiGraph()
+    G3.add_edge(11, 22)
+    G4 = nx.union_all([G1, G2, G3], rename=("G1", "G2", "G3"))
+    assert sorted(G4.nodes()) == ["G1A", "G1B", "G21", "G22", "G311", "G322"]
+
+
+def test_union_all_multigraph():
+    G = nx.MultiGraph()
+    G.add_edge(1, 2, key=0)
+    G.add_edge(1, 2, key=1)
+    H = nx.MultiGraph()
+    H.add_edge(3, 4, key=0)
+    H.add_edge(3, 4, key=1)
+    GH = nx.union_all([G, H])
+    assert set(GH) == set(G) | set(H)
+    assert set(GH.edges(keys=True)) == set(G.edges(keys=True)) | set(H.edges(keys=True))
+
+
+def test_input_output():
+    l = [nx.Graph([(1, 2)]), nx.Graph([(3, 4)], awesome=True)]
+    U = nx.disjoint_union_all(l)
+    assert len(l) == 2
+    assert U.graph["awesome"]
+    C = nx.compose_all(l)
+    assert len(l) == 2
+    l = [nx.Graph([(1, 2)]), nx.Graph([(1, 2)])]
+    R = nx.intersection_all(l)
+    assert len(l) == 2
+
+
+def test_mixed_type_union():
+    with pytest.raises(nx.NetworkXError):
+        G = nx.Graph()
+        H = nx.MultiGraph()
+        I = nx.Graph()
+        U = nx.union_all([G, H, I])
+    with pytest.raises(nx.NetworkXError):
+        X = nx.Graph()
+        Y = nx.DiGraph()
+        XY = nx.union_all([X, Y])
+
+
+def test_mixed_type_disjoint_union():
+    with pytest.raises(nx.NetworkXError):
+        G = nx.Graph()
+        H = nx.MultiGraph()
+        I = nx.Graph()
+        U = nx.disjoint_union_all([G, H, I])
+    with pytest.raises(nx.NetworkXError):
+        X = nx.Graph()
+        Y = nx.DiGraph()
+        XY = nx.disjoint_union_all([X, Y])
+
+
+def test_mixed_type_intersection():
+    with pytest.raises(nx.NetworkXError):
+        G = nx.Graph()
+        H = nx.MultiGraph()
+        I = nx.Graph()
+        U = nx.intersection_all([G, H, I])
+    with pytest.raises(nx.NetworkXError):
+        X = nx.Graph()
+        Y = nx.DiGraph()
+        XY = nx.intersection_all([X, Y])
+
+
+def test_mixed_type_compose():
+    with pytest.raises(nx.NetworkXError):
+        G = nx.Graph()
+        H = nx.MultiGraph()
+        I = nx.Graph()
+        U = nx.compose_all([G, H, I])
+    with pytest.raises(nx.NetworkXError):
+        X = nx.Graph()
+        Y = nx.DiGraph()
+        XY = nx.compose_all([X, Y])
+
+
+def test_empty_union():
+    with pytest.raises(ValueError):
+        nx.union_all([])
+
+
+def test_empty_disjoint_union():
+    with pytest.raises(ValueError):
+        nx.disjoint_union_all([])
+
+
+def test_empty_compose_all():
+    with pytest.raises(ValueError):
+        nx.compose_all([])
+
+
+def test_empty_intersection_all():
+    with pytest.raises(ValueError):
+        nx.intersection_all([])
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_binary.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_binary.py
new file mode 100644
index 00000000..c907cd6f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_binary.py
@@ -0,0 +1,453 @@
+import os
+
+import pytest
+
+import networkx as nx
+from networkx.utils import edges_equal
+
+
+def test_union_attributes():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+
+    gh = nx.union(g, h, rename=("g", "h"))
+    assert set(gh.nodes()) == {"h0", "h1", "g0", "g1"}
+    for n in gh:
+        graph, node = n
+        assert gh.nodes[n] == eval(graph).nodes[int(node)]
+
+    assert gh.graph["attr"] == "attr"
+    assert gh.graph["name"] == "h"  # h graph attributes take precedent
+
+
+def test_intersection():
+    G = nx.Graph()
+    H = nx.Graph()
+    G.add_nodes_from([1, 2, 3, 4])
+    G.add_edge(1, 2)
+    G.add_edge(2, 3)
+    H.add_nodes_from([1, 2, 3, 4])
+    H.add_edge(2, 3)
+    H.add_edge(3, 4)
+    I = nx.intersection(G, H)
+    assert set(I.nodes()) == {1, 2, 3, 4}
+    assert sorted(I.edges()) == [(2, 3)]
+
+
+def test_intersection_node_sets_different():
+    G = nx.Graph()
+    H = nx.Graph()
+    G.add_nodes_from([1, 2, 3, 4, 7])
+    G.add_edge(1, 2)
+    G.add_edge(2, 3)
+    H.add_nodes_from([1, 2, 3, 4, 5, 6])
+    H.add_edge(2, 3)
+    H.add_edge(3, 4)
+    H.add_edge(5, 6)
+    I = nx.intersection(G, H)
+    assert set(I.nodes()) == {1, 2, 3, 4}
+    assert sorted(I.edges()) == [(2, 3)]
+
+
+def test_intersection_attributes():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+    gh = nx.intersection(g, h)
+
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == sorted(g.edges())
+
+
+def test_intersection_attributes_node_sets_different():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_node(2, x=3)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+    h.remove_node(2)
+
+    gh = nx.intersection(g, h)
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == sorted(g.edges())
+
+
+def test_intersection_multigraph_attributes():
+    g = nx.MultiGraph()
+    g.add_edge(0, 1, key=0)
+    g.add_edge(0, 1, key=1)
+    g.add_edge(0, 1, key=2)
+    h = nx.MultiGraph()
+    h.add_edge(0, 1, key=0)
+    h.add_edge(0, 1, key=3)
+    gh = nx.intersection(g, h)
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == [(0, 1)]
+    assert sorted(gh.edges(keys=True)) == [(0, 1, 0)]
+
+
+def test_intersection_multigraph_attributes_node_set_different():
+    g = nx.MultiGraph()
+    g.add_edge(0, 1, key=0)
+    g.add_edge(0, 1, key=1)
+    g.add_edge(0, 1, key=2)
+    g.add_edge(0, 2, key=2)
+    g.add_edge(0, 2, key=1)
+    h = nx.MultiGraph()
+    h.add_edge(0, 1, key=0)
+    h.add_edge(0, 1, key=3)
+    gh = nx.intersection(g, h)
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == [(0, 1)]
+    assert sorted(gh.edges(keys=True)) == [(0, 1, 0)]
+
+
+def test_difference():
+    G = nx.Graph()
+    H = nx.Graph()
+    G.add_nodes_from([1, 2, 3, 4])
+    G.add_edge(1, 2)
+    G.add_edge(2, 3)
+    H.add_nodes_from([1, 2, 3, 4])
+    H.add_edge(2, 3)
+    H.add_edge(3, 4)
+    D = nx.difference(G, H)
+    assert set(D.nodes()) == {1, 2, 3, 4}
+    assert sorted(D.edges()) == [(1, 2)]
+    D = nx.difference(H, G)
+    assert set(D.nodes()) == {1, 2, 3, 4}
+    assert sorted(D.edges()) == [(3, 4)]
+    D = nx.symmetric_difference(G, H)
+    assert set(D.nodes()) == {1, 2, 3, 4}
+    assert sorted(D.edges()) == [(1, 2), (3, 4)]
+
+
+def test_difference2():
+    G = nx.Graph()
+    H = nx.Graph()
+    G.add_nodes_from([1, 2, 3, 4])
+    H.add_nodes_from([1, 2, 3, 4])
+    G.add_edge(1, 2)
+    H.add_edge(1, 2)
+    G.add_edge(2, 3)
+    D = nx.difference(G, H)
+    assert set(D.nodes()) == {1, 2, 3, 4}
+    assert sorted(D.edges()) == [(2, 3)]
+    D = nx.difference(H, G)
+    assert set(D.nodes()) == {1, 2, 3, 4}
+    assert sorted(D.edges()) == []
+    H.add_edge(3, 4)
+    D = nx.difference(H, G)
+    assert set(D.nodes()) == {1, 2, 3, 4}
+    assert sorted(D.edges()) == [(3, 4)]
+
+
+def test_difference_attributes():
+    g = nx.Graph()
+    g.add_node(0, x=4)
+    g.add_node(1, x=5)
+    g.add_edge(0, 1, size=5)
+    g.graph["name"] = "g"
+
+    h = g.copy()
+    h.graph["name"] = "h"
+    h.graph["attr"] = "attr"
+    h.nodes[0]["x"] = 7
+
+    gh = nx.difference(g, h)
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == []
+    # node and graph data should not be copied over
+    assert gh.nodes.data() != g.nodes.data()
+    assert gh.graph != g.graph
+
+
+def test_difference_multigraph_attributes():
+    g = nx.MultiGraph()
+    g.add_edge(0, 1, key=0)
+    g.add_edge(0, 1, key=1)
+    g.add_edge(0, 1, key=2)
+    h = nx.MultiGraph()
+    h.add_edge(0, 1, key=0)
+    h.add_edge(0, 1, key=3)
+    gh = nx.difference(g, h)
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == [(0, 1), (0, 1)]
+    assert sorted(gh.edges(keys=True)) == [(0, 1, 1), (0, 1, 2)]
+
+
+def test_difference_raise():
+    G = nx.path_graph(4)
+    H = nx.path_graph(3)
+    pytest.raises(nx.NetworkXError, nx.difference, G, H)
+    pytest.raises(nx.NetworkXError, nx.symmetric_difference, G, H)
+
+
+def test_symmetric_difference_multigraph():
+    g = nx.MultiGraph()
+    g.add_edge(0, 1, key=0)
+    g.add_edge(0, 1, key=1)
+    g.add_edge(0, 1, key=2)
+    h = nx.MultiGraph()
+    h.add_edge(0, 1, key=0)
+    h.add_edge(0, 1, key=3)
+    gh = nx.symmetric_difference(g, h)
+    assert set(gh.nodes()) == set(g.nodes())
+    assert set(gh.nodes()) == set(h.nodes())
+    assert sorted(gh.edges()) == 3 * [(0, 1)]
+    assert sorted(sorted(e) for e in gh.edges(keys=True)) == [
+        [0, 1, 1],
+        [0, 1, 2],
+        [0, 1, 3],
+    ]
+
+
+def test_union_and_compose():
+    K3 = nx.complete_graph(3)
+    P3 = nx.path_graph(3)
+
+    G1 = nx.DiGraph()
+    G1.add_edge("A", "B")
+    G1.add_edge("A", "C")
+    G1.add_edge("A", "D")
+    G2 = nx.DiGraph()
+    G2.add_edge("1", "2")
+    G2.add_edge("1", "3")
+    G2.add_edge("1", "4")
+
+    G = nx.union(G1, G2)
+    H = nx.compose(G1, G2)
+    assert edges_equal(G.edges(), H.edges())
+    assert not G.has_edge("A", 1)
+    pytest.raises(nx.NetworkXError, nx.union, K3, P3)
+    H1 = nx.union(H, G1, rename=("H", "G1"))
+    assert sorted(H1.nodes()) == [
+        "G1A",
+        "G1B",
+        "G1C",
+        "G1D",
+        "H1",
+        "H2",
+        "H3",
+        "H4",
+        "HA",
+        "HB",
+        "HC",
+        "HD",
+    ]
+
+    H2 = nx.union(H, G2, rename=("H", ""))
+    assert sorted(H2.nodes()) == [
+        "1",
+        "2",
+        "3",
+        "4",
+        "H1",
+        "H2",
+        "H3",
+        "H4",
+        "HA",
+        "HB",
+        "HC",
+        "HD",
+    ]
+
+    assert not H1.has_edge("NB", "NA")
+
+    G = nx.compose(G, G)
+    assert edges_equal(G.edges(), H.edges())
+
+    G2 = nx.union(G2, G2, rename=("", "copy"))
+    assert sorted(G2.nodes()) == [
+        "1",
+        "2",
+        "3",
+        "4",
+        "copy1",
+        "copy2",
+        "copy3",
+        "copy4",
+    ]
+
+    assert sorted(G2.neighbors("copy4")) == []
+    assert sorted(G2.neighbors("copy1")) == ["copy2", "copy3", "copy4"]
+    assert len(G) == 8
+    assert nx.number_of_edges(G) == 6
+
+    E = nx.disjoint_union(G, G)
+    assert len(E) == 16
+    assert nx.number_of_edges(E) == 12
+
+    E = nx.disjoint_union(G1, G2)
+    assert sorted(E.nodes()) == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
+
+    G = nx.Graph()
+    H = nx.Graph()
+    G.add_nodes_from([(1, {"a1": 1})])
+    H.add_nodes_from([(1, {"b1": 1})])
+    R = nx.compose(G, H)
+    assert R.nodes == {1: {"a1": 1, "b1": 1}}
+
+
+def test_union_multigraph():
+    G = nx.MultiGraph()
+    G.add_edge(1, 2, key=0)
+    G.add_edge(1, 2, key=1)
+    H = nx.MultiGraph()
+    H.add_edge(3, 4, key=0)
+    H.add_edge(3, 4, key=1)
+    GH = nx.union(G, H)
+    assert set(GH) == set(G) | set(H)
+    assert set(GH.edges(keys=True)) == set(G.edges(keys=True)) | set(H.edges(keys=True))
+
+
+def test_disjoint_union_multigraph():
+    G = nx.MultiGraph()
+    G.add_edge(0, 1, key=0)
+    G.add_edge(0, 1, key=1)
+    H = nx.MultiGraph()
+    H.add_edge(2, 3, key=0)
+    H.add_edge(2, 3, key=1)
+    GH = nx.disjoint_union(G, H)
+    assert set(GH) == set(G) | set(H)
+    assert set(GH.edges(keys=True)) == set(G.edges(keys=True)) | set(H.edges(keys=True))
+
+
+def test_compose_multigraph():
+    G = nx.MultiGraph()
+    G.add_edge(1, 2, key=0)
+    G.add_edge(1, 2, key=1)
+    H = nx.MultiGraph()
+    H.add_edge(3, 4, key=0)
+    H.add_edge(3, 4, key=1)
+    GH = nx.compose(G, H)
+    assert set(GH) == set(G) | set(H)
+    assert set(GH.edges(keys=True)) == set(G.edges(keys=True)) | set(H.edges(keys=True))
+    H.add_edge(1, 2, key=2)
+    GH = nx.compose(G, H)
+    assert set(GH) == set(G) | set(H)
+    assert set(GH.edges(keys=True)) == set(G.edges(keys=True)) | set(H.edges(keys=True))
+
+
+def test_full_join_graph():
+    # Simple Graphs
+    G = nx.Graph()
+    G.add_node(0)
+    G.add_edge(1, 2)
+    H = nx.Graph()
+    H.add_edge(3, 4)
+
+    U = nx.full_join(G, H)
+    assert set(U) == set(G) | set(H)
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H)
+
+    # Rename
+    U = nx.full_join(G, H, rename=("g", "h"))
+    assert set(U) == {"g0", "g1", "g2", "h3", "h4"}
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H)
+
+    # Rename graphs with string-like nodes
+    G = nx.Graph()
+    G.add_node("a")
+    G.add_edge("b", "c")
+    H = nx.Graph()
+    H.add_edge("d", "e")
+
+    U = nx.full_join(G, H, rename=("g", "h"))
+    assert set(U) == {"ga", "gb", "gc", "hd", "he"}
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H)
+
+    # DiGraphs
+    G = nx.DiGraph()
+    G.add_node(0)
+    G.add_edge(1, 2)
+    H = nx.DiGraph()
+    H.add_edge(3, 4)
+
+    U = nx.full_join(G, H)
+    assert set(U) == set(G) | set(H)
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H) * 2
+
+    # DiGraphs Rename
+    U = nx.full_join(G, H, rename=("g", "h"))
+    assert set(U) == {"g0", "g1", "g2", "h3", "h4"}
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H) * 2
+
+
+def test_full_join_multigraph():
+    # MultiGraphs
+    G = nx.MultiGraph()
+    G.add_node(0)
+    G.add_edge(1, 2)
+    H = nx.MultiGraph()
+    H.add_edge(3, 4)
+
+    U = nx.full_join(G, H)
+    assert set(U) == set(G) | set(H)
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H)
+
+    # MultiGraphs rename
+    U = nx.full_join(G, H, rename=("g", "h"))
+    assert set(U) == {"g0", "g1", "g2", "h3", "h4"}
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H)
+
+    # MultiDiGraphs
+    G = nx.MultiDiGraph()
+    G.add_node(0)
+    G.add_edge(1, 2)
+    H = nx.MultiDiGraph()
+    H.add_edge(3, 4)
+
+    U = nx.full_join(G, H)
+    assert set(U) == set(G) | set(H)
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H) * 2
+
+    # MultiDiGraphs rename
+    U = nx.full_join(G, H, rename=("g", "h"))
+    assert set(U) == {"g0", "g1", "g2", "h3", "h4"}
+    assert len(U) == len(G) + len(H)
+    assert len(U.edges()) == len(G.edges()) + len(H.edges()) + len(G) * len(H) * 2
+
+
+def test_mixed_type_union():
+    G = nx.Graph()
+    H = nx.MultiGraph()
+    pytest.raises(nx.NetworkXError, nx.union, G, H)
+    pytest.raises(nx.NetworkXError, nx.disjoint_union, G, H)
+    pytest.raises(nx.NetworkXError, nx.intersection, G, H)
+    pytest.raises(nx.NetworkXError, nx.difference, G, H)
+    pytest.raises(nx.NetworkXError, nx.symmetric_difference, G, H)
+    pytest.raises(nx.NetworkXError, nx.compose, G, H)
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_product.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_product.py
new file mode 100644
index 00000000..8ee54b93
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_product.py
@@ -0,0 +1,491 @@
+import pytest
+
+import networkx as nx
+from networkx.utils import edges_equal
+
+
+def test_tensor_product_raises():
+    with pytest.raises(nx.NetworkXError):
+        P = nx.tensor_product(nx.DiGraph(), nx.Graph())
+
+
+def test_tensor_product_null():
+    null = nx.null_graph()
+    empty10 = nx.empty_graph(10)
+    K3 = nx.complete_graph(3)
+    K10 = nx.complete_graph(10)
+    P3 = nx.path_graph(3)
+    P10 = nx.path_graph(10)
+    # null graph
+    G = nx.tensor_product(null, null)
+    assert nx.is_isomorphic(G, null)
+    # null_graph X anything = null_graph and v.v.
+    G = nx.tensor_product(null, empty10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(null, K3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(null, K10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(null, P3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(null, P10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(empty10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(K3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(K10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(P3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.tensor_product(P10, null)
+    assert nx.is_isomorphic(G, null)
+
+
+def test_tensor_product_size():
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    K5 = nx.complete_graph(5)
+
+    G = nx.tensor_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.tensor_product(K3, K5)
+    assert nx.number_of_nodes(G) == 3 * 5
+
+
+def test_tensor_product_combinations():
+    # basic smoke test, more realistic tests would be useful
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    G = nx.tensor_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.tensor_product(P5, nx.MultiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.tensor_product(nx.MultiGraph(P5), K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.tensor_product(nx.MultiGraph(P5), nx.MultiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+
+    G = nx.tensor_product(nx.DiGraph(P5), nx.DiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+
+
+def test_tensor_product_classic_result():
+    K2 = nx.complete_graph(2)
+    G = nx.petersen_graph()
+    G = nx.tensor_product(G, K2)
+    assert nx.is_isomorphic(G, nx.desargues_graph())
+
+    G = nx.cycle_graph(5)
+    G = nx.tensor_product(G, K2)
+    assert nx.is_isomorphic(G, nx.cycle_graph(10))
+
+    G = nx.tetrahedral_graph()
+    G = nx.tensor_product(G, K2)
+    assert nx.is_isomorphic(G, nx.cubical_graph())
+
+
+def test_tensor_product_random():
+    G = nx.erdos_renyi_graph(10, 2 / 10.0)
+    H = nx.erdos_renyi_graph(10, 2 / 10.0)
+    GH = nx.tensor_product(G, H)
+
+    for u_G, u_H in GH.nodes():
+        for v_G, v_H in GH.nodes():
+            if H.has_edge(u_H, v_H) and G.has_edge(u_G, v_G):
+                assert GH.has_edge((u_G, u_H), (v_G, v_H))
+            else:
+                assert not GH.has_edge((u_G, u_H), (v_G, v_H))
+
+
+def test_cartesian_product_multigraph():
+    G = nx.MultiGraph()
+    G.add_edge(1, 2, key=0)
+    G.add_edge(1, 2, key=1)
+    H = nx.MultiGraph()
+    H.add_edge(3, 4, key=0)
+    H.add_edge(3, 4, key=1)
+    GH = nx.cartesian_product(G, H)
+    assert set(GH) == {(1, 3), (2, 3), (2, 4), (1, 4)}
+    assert {(frozenset([u, v]), k) for u, v, k in GH.edges(keys=True)} == {
+        (frozenset([u, v]), k)
+        for u, v, k in [
+            ((1, 3), (2, 3), 0),
+            ((1, 3), (2, 3), 1),
+            ((1, 3), (1, 4), 0),
+            ((1, 3), (1, 4), 1),
+            ((2, 3), (2, 4), 0),
+            ((2, 3), (2, 4), 1),
+            ((2, 4), (1, 4), 0),
+            ((2, 4), (1, 4), 1),
+        ]
+    }
+
+
+def test_cartesian_product_raises():
+    with pytest.raises(nx.NetworkXError):
+        P = nx.cartesian_product(nx.DiGraph(), nx.Graph())
+
+
+def test_cartesian_product_null():
+    null = nx.null_graph()
+    empty10 = nx.empty_graph(10)
+    K3 = nx.complete_graph(3)
+    K10 = nx.complete_graph(10)
+    P3 = nx.path_graph(3)
+    P10 = nx.path_graph(10)
+    # null graph
+    G = nx.cartesian_product(null, null)
+    assert nx.is_isomorphic(G, null)
+    # null_graph X anything = null_graph and v.v.
+    G = nx.cartesian_product(null, empty10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(null, K3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(null, K10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(null, P3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(null, P10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(empty10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(K3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(K10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(P3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.cartesian_product(P10, null)
+    assert nx.is_isomorphic(G, null)
+
+
+def test_cartesian_product_size():
+    # order(GXH)=order(G)*order(H)
+    K5 = nx.complete_graph(5)
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    G = nx.cartesian_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    assert nx.number_of_edges(G) == nx.number_of_edges(P5) * nx.number_of_nodes(
+        K3
+    ) + nx.number_of_edges(K3) * nx.number_of_nodes(P5)
+    G = nx.cartesian_product(K3, K5)
+    assert nx.number_of_nodes(G) == 3 * 5
+    assert nx.number_of_edges(G) == nx.number_of_edges(K5) * nx.number_of_nodes(
+        K3
+    ) + nx.number_of_edges(K3) * nx.number_of_nodes(K5)
+
+
+def test_cartesian_product_classic():
+    # test some classic product graphs
+    P2 = nx.path_graph(2)
+    P3 = nx.path_graph(3)
+    # cube = 2-path X 2-path
+    G = nx.cartesian_product(P2, P2)
+    G = nx.cartesian_product(P2, G)
+    assert nx.is_isomorphic(G, nx.cubical_graph())
+
+    # 3x3 grid
+    G = nx.cartesian_product(P3, P3)
+    assert nx.is_isomorphic(G, nx.grid_2d_graph(3, 3))
+
+
+def test_cartesian_product_random():
+    G = nx.erdos_renyi_graph(10, 2 / 10.0)
+    H = nx.erdos_renyi_graph(10, 2 / 10.0)
+    GH = nx.cartesian_product(G, H)
+
+    for u_G, u_H in GH.nodes():
+        for v_G, v_H in GH.nodes():
+            if (u_G == v_G and H.has_edge(u_H, v_H)) or (
+                u_H == v_H and G.has_edge(u_G, v_G)
+            ):
+                assert GH.has_edge((u_G, u_H), (v_G, v_H))
+            else:
+                assert not GH.has_edge((u_G, u_H), (v_G, v_H))
+
+
+def test_lexicographic_product_raises():
+    with pytest.raises(nx.NetworkXError):
+        P = nx.lexicographic_product(nx.DiGraph(), nx.Graph())
+
+
+def test_lexicographic_product_null():
+    null = nx.null_graph()
+    empty10 = nx.empty_graph(10)
+    K3 = nx.complete_graph(3)
+    K10 = nx.complete_graph(10)
+    P3 = nx.path_graph(3)
+    P10 = nx.path_graph(10)
+    # null graph
+    G = nx.lexicographic_product(null, null)
+    assert nx.is_isomorphic(G, null)
+    # null_graph X anything = null_graph and v.v.
+    G = nx.lexicographic_product(null, empty10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(null, K3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(null, K10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(null, P3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(null, P10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(empty10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(K3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(K10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(P3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.lexicographic_product(P10, null)
+    assert nx.is_isomorphic(G, null)
+
+
+def test_lexicographic_product_size():
+    K5 = nx.complete_graph(5)
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    G = nx.lexicographic_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.lexicographic_product(K3, K5)
+    assert nx.number_of_nodes(G) == 3 * 5
+
+
+def test_lexicographic_product_combinations():
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    G = nx.lexicographic_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.lexicographic_product(nx.MultiGraph(P5), K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.lexicographic_product(P5, nx.MultiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.lexicographic_product(nx.MultiGraph(P5), nx.MultiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+
+    # No classic easily found classic results for lexicographic product
+
+
+def test_lexicographic_product_random():
+    G = nx.erdos_renyi_graph(10, 2 / 10.0)
+    H = nx.erdos_renyi_graph(10, 2 / 10.0)
+    GH = nx.lexicographic_product(G, H)
+
+    for u_G, u_H in GH.nodes():
+        for v_G, v_H in GH.nodes():
+            if G.has_edge(u_G, v_G) or (u_G == v_G and H.has_edge(u_H, v_H)):
+                assert GH.has_edge((u_G, u_H), (v_G, v_H))
+            else:
+                assert not GH.has_edge((u_G, u_H), (v_G, v_H))
+
+
+def test_strong_product_raises():
+    with pytest.raises(nx.NetworkXError):
+        P = nx.strong_product(nx.DiGraph(), nx.Graph())
+
+
+def test_strong_product_null():
+    null = nx.null_graph()
+    empty10 = nx.empty_graph(10)
+    K3 = nx.complete_graph(3)
+    K10 = nx.complete_graph(10)
+    P3 = nx.path_graph(3)
+    P10 = nx.path_graph(10)
+    # null graph
+    G = nx.strong_product(null, null)
+    assert nx.is_isomorphic(G, null)
+    # null_graph X anything = null_graph and v.v.
+    G = nx.strong_product(null, empty10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(null, K3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(null, K10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(null, P3)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(null, P10)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(empty10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(K3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(K10, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(P3, null)
+    assert nx.is_isomorphic(G, null)
+    G = nx.strong_product(P10, null)
+    assert nx.is_isomorphic(G, null)
+
+
+def test_strong_product_size():
+    K5 = nx.complete_graph(5)
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    G = nx.strong_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.strong_product(K3, K5)
+    assert nx.number_of_nodes(G) == 3 * 5
+
+
+def test_strong_product_combinations():
+    P5 = nx.path_graph(5)
+    K3 = nx.complete_graph(3)
+    G = nx.strong_product(P5, K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.strong_product(nx.MultiGraph(P5), K3)
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.strong_product(P5, nx.MultiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+    G = nx.strong_product(nx.MultiGraph(P5), nx.MultiGraph(K3))
+    assert nx.number_of_nodes(G) == 5 * 3
+
+    # No classic easily found classic results for strong product
+
+
+def test_strong_product_random():
+    G = nx.erdos_renyi_graph(10, 2 / 10.0)
+    H = nx.erdos_renyi_graph(10, 2 / 10.0)
+    GH = nx.strong_product(G, H)
+
+    for u_G, u_H in GH.nodes():
+        for v_G, v_H in GH.nodes():
+            if (
+                (u_G == v_G and H.has_edge(u_H, v_H))
+                or (u_H == v_H and G.has_edge(u_G, v_G))
+                or (G.has_edge(u_G, v_G) and H.has_edge(u_H, v_H))
+            ):
+                assert GH.has_edge((u_G, u_H), (v_G, v_H))
+            else:
+                assert not GH.has_edge((u_G, u_H), (v_G, v_H))
+
+
+def test_graph_power_raises():
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.power(nx.MultiDiGraph(), 2)
+
+
+def test_graph_power():
+    # wikipedia example for graph power
+    G = nx.cycle_graph(7)
+    G.add_edge(6, 7)
+    G.add_edge(7, 8)
+    G.add_edge(8, 9)
+    G.add_edge(9, 2)
+    H = nx.power(G, 2)
+
+    assert edges_equal(
+        list(H.edges()),
+        [
+            (0, 1),
+            (0, 2),
+            (0, 5),
+            (0, 6),
+            (0, 7),
+            (1, 9),
+            (1, 2),
+            (1, 3),
+            (1, 6),
+            (2, 3),
+            (2, 4),
+            (2, 8),
+            (2, 9),
+            (3, 4),
+            (3, 5),
+            (3, 9),
+            (4, 5),
+            (4, 6),
+            (5, 6),
+            (5, 7),
+            (6, 7),
+            (6, 8),
+            (7, 8),
+            (7, 9),
+            (8, 9),
+        ],
+    )
+
+
+def test_graph_power_negative():
+    with pytest.raises(ValueError):
+        nx.power(nx.Graph(), -1)
+
+
+def test_rooted_product_raises():
+    with pytest.raises(nx.NodeNotFound):
+        nx.rooted_product(nx.Graph(), nx.path_graph(2), 10)
+
+
+def test_rooted_product():
+    G = nx.cycle_graph(5)
+    H = nx.Graph()
+    H.add_edges_from([("a", "b"), ("b", "c"), ("b", "d")])
+    R = nx.rooted_product(G, H, "a")
+    assert len(R) == len(G) * len(H)
+    assert R.size() == G.size() + len(G) * H.size()
+
+
+def test_corona_product():
+    G = nx.cycle_graph(3)
+    H = nx.path_graph(2)
+    C = nx.corona_product(G, H)
+    assert len(C) == (len(G) * len(H)) + len(G)
+    assert C.size() == G.size() + len(G) * H.size() + len(G) * len(H)
+
+
+def test_modular_product():
+    G = nx.path_graph(3)
+    H = nx.path_graph(4)
+    M = nx.modular_product(G, H)
+    assert len(M) == len(G) * len(H)
+
+    assert edges_equal(
+        list(M.edges()),
+        [
+            ((0, 0), (1, 1)),
+            ((0, 0), (2, 2)),
+            ((0, 0), (2, 3)),
+            ((0, 1), (1, 0)),
+            ((0, 1), (1, 2)),
+            ((0, 1), (2, 3)),
+            ((0, 2), (1, 1)),
+            ((0, 2), (1, 3)),
+            ((0, 2), (2, 0)),
+            ((0, 3), (1, 2)),
+            ((0, 3), (2, 0)),
+            ((0, 3), (2, 1)),
+            ((1, 0), (2, 1)),
+            ((1, 1), (2, 0)),
+            ((1, 1), (2, 2)),
+            ((1, 2), (2, 1)),
+            ((1, 2), (2, 3)),
+            ((1, 3), (2, 2)),
+        ],
+    )
+
+
+def test_modular_product_raises():
+    G = nx.Graph([(0, 1), (1, 2), (2, 0)])
+    H = nx.Graph([(0, 1), (1, 2), (2, 0)])
+    DG = nx.DiGraph([(0, 1), (1, 2), (2, 0)])
+    DH = nx.DiGraph([(0, 1), (1, 2), (2, 0)])
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.modular_product(G, DH)
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.modular_product(DG, H)
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.modular_product(DG, DH)
+
+    MG = nx.MultiGraph([(0, 1), (1, 2), (2, 0), (0, 1)])
+    MH = nx.MultiGraph([(0, 1), (1, 2), (2, 0), (0, 1)])
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.modular_product(G, MH)
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.modular_product(MG, H)
+    with pytest.raises(nx.NetworkXNotImplemented):
+        nx.modular_product(MG, MH)
+    with pytest.raises(nx.NetworkXNotImplemented):
+        # check multigraph with no multiedges
+        nx.modular_product(nx.MultiGraph(G), H)
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_unary.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_unary.py
new file mode 100644
index 00000000..d68e55cd
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/tests/test_unary.py
@@ -0,0 +1,55 @@
+import pytest
+
+import networkx as nx
+
+
+def test_complement():
+    null = nx.null_graph()
+    empty1 = nx.empty_graph(1)
+    empty10 = nx.empty_graph(10)
+    K3 = nx.complete_graph(3)
+    K5 = nx.complete_graph(5)
+    K10 = nx.complete_graph(10)
+    P2 = nx.path_graph(2)
+    P3 = nx.path_graph(3)
+    P5 = nx.path_graph(5)
+    P10 = nx.path_graph(10)
+    # complement of the complete graph is empty
+
+    G = nx.complement(K3)
+    assert nx.is_isomorphic(G, nx.empty_graph(3))
+    G = nx.complement(K5)
+    assert nx.is_isomorphic(G, nx.empty_graph(5))
+    # for any G, G=complement(complement(G))
+    P3cc = nx.complement(nx.complement(P3))
+    assert nx.is_isomorphic(P3, P3cc)
+    nullcc = nx.complement(nx.complement(null))
+    assert nx.is_isomorphic(null, nullcc)
+    b = nx.bull_graph()
+    bcc = nx.complement(nx.complement(b))
+    assert nx.is_isomorphic(b, bcc)
+
+
+def test_complement_2():
+    G1 = nx.DiGraph()
+    G1.add_edge("A", "B")
+    G1.add_edge("A", "C")
+    G1.add_edge("A", "D")
+    G1C = nx.complement(G1)
+    assert sorted(G1C.edges()) == [
+        ("B", "A"),
+        ("B", "C"),
+        ("B", "D"),
+        ("C", "A"),
+        ("C", "B"),
+        ("C", "D"),
+        ("D", "A"),
+        ("D", "B"),
+        ("D", "C"),
+    ]
+
+
+def test_reverse1():
+    # Other tests for reverse are done by the DiGraph and MultiDigraph.
+    G1 = nx.Graph()
+    pytest.raises(nx.NetworkXError, nx.reverse, G1)
diff --git a/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/unary.py b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/unary.py
new file mode 100644
index 00000000..79e44d1c
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/networkx/algorithms/operators/unary.py
@@ -0,0 +1,77 @@
+"""Unary operations on graphs"""
+
+import networkx as nx
+
+__all__ = ["complement", "reverse"]
+
+
+@nx._dispatchable(returns_graph=True)
+def complement(G):
+    """Returns the graph complement of G.
+
+    Parameters
+    ----------
+    G : graph
+       A NetworkX graph
+
+    Returns
+    -------
+    GC : A new graph.
+
+    Notes
+    -----
+    Note that `complement` does not create self-loops and also
+    does not produce parallel edges for MultiGraphs.
+
+    Graph, node, and edge data are not propagated to the new graph.
+
+    Examples
+    --------
+    >>> G = nx.Graph([(1, 2), (1, 3), (2, 3), (3, 4), (3, 5)])
+    >>> G_complement = nx.complement(G)
+    >>> G_complement.edges()  # This shows the edges of the complemented graph
+    EdgeView([(1, 4), (1, 5), (2, 4), (2, 5), (4, 5)])
+
+    """
+    R = G.__class__()
+    R.add_nodes_from(G)
+    R.add_edges_from(
+        ((n, n2) for n, nbrs in G.adjacency() for n2 in G if n2 not in nbrs if n != n2)
+    )
+    return R
+
+
+@nx._dispatchable(returns_graph=True)
+def reverse(G, copy=True):
+    """Returns the reverse directed graph of G.
+
+    Parameters
+    ----------
+    G : directed graph
+        A NetworkX directed graph
+    copy : bool
+        If True, then a new graph is returned. If False, then the graph is
+        reversed in place.
+
+    Returns
+    -------
+    H : directed graph
+        The reversed G.
+
+    Raises
+    ------
+    NetworkXError
+        If graph is undirected.
+
+    Examples
+    --------
+    >>> G = nx.DiGraph([(1, 2), (1, 3), (2, 3), (3, 4), (3, 5)])
+    >>> G_reversed = nx.reverse(G)
+    >>> G_reversed.edges()
+    OutEdgeView([(2, 1), (3, 1), (3, 2), (4, 3), (5, 3)])
+
+    """
+    if not G.is_directed():
+        raise nx.NetworkXError("Cannot reverse an undirected graph.")
+    else:
+        return G.reverse(copy=copy)