From 6e84e1ed3a94a922aff8b827ea84991d876cf4f9 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 31 Mar 2024 11:28:06 -0500 Subject: [PATCH] ENH: Cache graphs objects when converting to a backend (#7345) * Minimal PR to add cache dict to graphs and clear them * Cache backend graph conversions * Don't use cache when testing backends * Better caching * fix typo * Don't be silly; be optimistic. Also, update comments * Add warning when using a cached value * Make caching more robust - Enable caching to be disabled by setting `__networkx_cache__` to None (or delete it). - Improve warning to say how to manually clear cache. - Update some `_dispatchable` decorators that were discovered to mutate data. - Make the residual Graph an implementation detail and not dispatchable. - Improve dispatch tests that mutate input (but more still needs done). * Add config to control caching * Use `nx._clear_cache` to clear the cache * Add note about config being global * Disable cache for `maximum_branching` internal graph * DRY --- networkx/__init__.py | 2 +- .../approximation/traveling_salesman.py | 6 +- networkx/algorithms/centrality/group.py | 1 + networkx/algorithms/community/lukes.py | 1 + .../algorithms/connectivity/connectivity.py | 12 +- networkx/algorithms/connectivity/cuts.py | 14 +- .../algorithms/connectivity/disjoint_paths.py | 13 +- .../algorithms/connectivity/stoerwagner.py | 1 + networkx/algorithms/cycles.py | 2 +- networkx/algorithms/distance_measures.py | 2 + networkx/algorithms/flow/boykovkolmogorov.py | 9 +- networkx/algorithms/flow/dinitz_alg.py | 9 +- networkx/algorithms/flow/edmondskarp.py | 15 +- networkx/algorithms/flow/preflowpush.py | 9 +- .../algorithms/flow/shortestaugmentingpath.py | 9 +- networkx/algorithms/flow/utils.py | 1 + networkx/algorithms/planarity.py | 2 + networkx/algorithms/tree/branchings.py | 44 ++-- networkx/algorithms/tree/mst.py | 1 + networkx/classes/digraph.py | 11 + networkx/classes/function.py | 2 + networkx/classes/graph.py | 13 ++ networkx/classes/multidigraph.py | 2 + networkx/classes/multigraph.py | 4 + networkx/generators/stochastic.py | 1 + networkx/utils/__init__.py | 2 + networkx/utils/backends.py | 192 ++++++++++++++++-- networkx/utils/configs.py | 27 +++ networkx/utils/misc.py | 10 + networkx/utils/tests/test_config.py | 2 + 30 files changed, 304 insertions(+), 115 deletions(-) diff --git a/networkx/__init__.py b/networkx/__init__.py index 3b4f889d4db..145dbe8f007 100644 --- a/networkx/__init__.py +++ b/networkx/__init__.py @@ -17,7 +17,7 @@ from networkx.exception import * from networkx import utils -from networkx.utils.backends import _dispatchable, config +from networkx.utils import _clear_cache, _dispatchable, config from networkx import classes from networkx.classes import filters diff --git a/networkx/algorithms/approximation/traveling_salesman.py b/networkx/algorithms/approximation/traveling_salesman.py index 7501daf41f5..e8d94e3348a 100644 --- a/networkx/algorithms/approximation/traveling_salesman.py +++ b/networkx/algorithms/approximation/traveling_salesman.py @@ -340,7 +340,7 @@ def traveling_salesman_problem(G, weight="weight", nodes=None, cycle=True, metho @not_implemented_for("undirected") @py_random_state(2) -@nx._dispatchable(edge_attrs="weight") +@nx._dispatchable(edge_attrs="weight", mutates_input=True) def asadpour_atsp(G, weight="weight", seed=None, source=None): """ Returns an approximate solution to the traveling salesman problem. @@ -492,7 +492,7 @@ def asadpour_atsp(G, weight="weight", seed=None, source=None): return _shortcutting(circuit) -@nx._dispatchable(edge_attrs="weight", returns_graph=True) +@nx._dispatchable(edge_attrs="weight", mutates_input=True, returns_graph=True) def held_karp_ascent(G, weight="weight"): """ Minimizes the Held-Karp relaxation of the TSP for `G` @@ -767,6 +767,7 @@ def find_epsilon(k, d): for u, v, d in G.edges(data=True): d[weight] = original_edge_weights[(u, v)] + pi_dict[u] dir_ascent, k_d = direction_of_ascent() + nx._clear_cache(G) # k_d is no longer an individual 1-arborescence but rather a set of # minimal 1-arborescences at the maximum point of the polytope and should # be reflected as such @@ -777,6 +778,7 @@ def find_epsilon(k, d): for k in k_max: if len([n for n in k if k.degree(n) == 2]) == G.order(): # Tour found + # TODO: this branch does not restore original_edge_weights of G! return k.size(weight), k # Write the original edge weights back to G and every member of k_max at diff --git a/networkx/algorithms/centrality/group.py b/networkx/algorithms/centrality/group.py index 5819c357d03..66fd309ff9b 100644 --- a/networkx/algorithms/centrality/group.py +++ b/networkx/algorithms/centrality/group.py @@ -350,6 +350,7 @@ def prominent_group( else: nodes = list(G.nodes) DF_tree = nx.Graph() + DF_tree.__networkx_cache__ = None # Disable caching PB, sigma, D = _group_preprocessing(G, nodes, weight) betweenness = pd.DataFrame.from_dict(PB) if C is not None: diff --git a/networkx/algorithms/community/lukes.py b/networkx/algorithms/community/lukes.py index 389fb51ca63..08dd7cd52ff 100644 --- a/networkx/algorithms/community/lukes.py +++ b/networkx/algorithms/community/lukes.py @@ -175,6 +175,7 @@ def _concatenate_or_merge(partition_1, partition_2, x, i, ref_weight): t_G.nodes[inner][PKEY] = {} slot = safe_G.nodes[inner][node_weight] t_G.nodes[inner][PKEY][slot] = [{inner}] + nx._clear_cache(t_G) # CORE ALGORITHM ----------------------- while True: diff --git a/networkx/algorithms/connectivity/connectivity.py b/networkx/algorithms/connectivity/connectivity.py index ea96dbf2447..8ccca88d276 100644 --- a/networkx/algorithms/connectivity/connectivity.py +++ b/networkx/algorithms/connectivity/connectivity.py @@ -31,11 +31,7 @@ ] -@nx._dispatchable( - graphs={"G": 0, "auxiliary?": 4, "residual?": 5}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"auxiliary", "residual"}, -) +@nx._dispatchable(graphs={"G": 0, "auxiliary?": 4}, preserve_graph_attrs={"auxiliary"}) def local_node_connectivity( G, s, t, flow_func=None, auxiliary=None, residual=None, cutoff=None ): @@ -490,11 +486,7 @@ def all_pairs_node_connectivity(G, nbunch=None, flow_func=None): return all_pairs -@nx._dispatchable( - graphs={"G": 0, "auxiliary?": 4, "residual?": 5}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"residual"}, -) +@nx._dispatchable(graphs={"G": 0, "auxiliary?": 4}) def local_edge_connectivity( G, s, t, flow_func=None, auxiliary=None, residual=None, cutoff=None ): diff --git a/networkx/algorithms/connectivity/cuts.py b/networkx/algorithms/connectivity/cuts.py index e51c6843eb3..117004406af 100644 --- a/networkx/algorithms/connectivity/cuts.py +++ b/networkx/algorithms/connectivity/cuts.py @@ -22,12 +22,9 @@ @nx._dispatchable( - graphs={"G": 0, "auxiliary?": 4, "residual?": 5}, - preserve_edge_attrs={ - "auxiliary": {"capacity": float("inf")}, - "residual": {"capacity": float("inf")}, - }, - preserve_graph_attrs={"auxiliary", "residual"}, + graphs={"G": 0, "auxiliary?": 4}, + preserve_edge_attrs={"auxiliary": {"capacity": float("inf")}}, + preserve_graph_attrs={"auxiliary"}, ) def minimum_st_edge_cut(G, s, t, flow_func=None, auxiliary=None, residual=None): """Returns the edges of the cut-set of a minimum (s, t)-cut. @@ -162,10 +159,9 @@ def minimum_st_edge_cut(G, s, t, flow_func=None, auxiliary=None, residual=None): @nx._dispatchable( - graphs={"G": 0, "auxiliary?": 4, "residual?": 5}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, + graphs={"G": 0, "auxiliary?": 4}, preserve_node_attrs={"auxiliary": {"id": None}}, - preserve_graph_attrs={"auxiliary", "residual"}, + preserve_graph_attrs={"auxiliary"}, ) def minimum_st_node_cut(G, s, t, flow_func=None, auxiliary=None, residual=None): r"""Returns a set of nodes of minimum cardinality that disconnect source diff --git a/networkx/algorithms/connectivity/disjoint_paths.py b/networkx/algorithms/connectivity/disjoint_paths.py index b80aa9c7fb4..e4634e7dd0a 100644 --- a/networkx/algorithms/connectivity/disjoint_paths.py +++ b/networkx/algorithms/connectivity/disjoint_paths.py @@ -20,12 +20,8 @@ @nx._dispatchable( - graphs={"G": 0, "auxiliary?": 5, "residual?": 6}, - preserve_edge_attrs={ - "auxiliary": {"capacity": float("inf")}, - "residual": {"capacity": float("inf")}, - }, - preserve_graph_attrs={"residual"}, + graphs={"G": 0, "auxiliary?": 5}, + preserve_edge_attrs={"auxiliary": {"capacity": float("inf")}}, ) def edge_disjoint_paths( G, s, t, flow_func=None, cutoff=None, auxiliary=None, residual=None @@ -235,10 +231,9 @@ def edge_disjoint_paths( @nx._dispatchable( - graphs={"G": 0, "auxiliary?": 5, "residual?": 6}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, + graphs={"G": 0, "auxiliary?": 5}, preserve_node_attrs={"auxiliary": {"id": None}}, - preserve_graph_attrs={"auxiliary", "residual"}, + preserve_graph_attrs={"auxiliary"}, ) def node_disjoint_paths( G, s, t, flow_func=None, cutoff=None, auxiliary=None, residual=None diff --git a/networkx/algorithms/connectivity/stoerwagner.py b/networkx/algorithms/connectivity/stoerwagner.py index cd9d5acaf17..f6814b0034e 100644 --- a/networkx/algorithms/connectivity/stoerwagner.py +++ b/networkx/algorithms/connectivity/stoerwagner.py @@ -94,6 +94,7 @@ def stoer_wagner(G, weight="weight", heap=BinaryHeap): G = nx.Graph( (u, v, {"weight": e.get(weight, 1)}) for u, v, e in G.edges(data=True) if u != v ) + G.__networkx_cache__ = None # Disable caching for u, v, e in G.edges(data=True): if e["weight"] < 0: diff --git a/networkx/algorithms/cycles.py b/networkx/algorithms/cycles.py index 576c8b81f41..14660ed52e5 100644 --- a/networkx/algorithms/cycles.py +++ b/networkx/algorithms/cycles.py @@ -764,7 +764,7 @@ def _chordless_cycle_search(F, B, path, length_bound): @not_implemented_for("undirected") -@nx._dispatchable +@nx._dispatchable(mutates_input=True) def recursive_simple_cycles(G): """Find simple cycles (elementary circuits) of a directed graph. diff --git a/networkx/algorithms/distance_measures.py b/networkx/algorithms/distance_measures.py index 8215b470af3..20c1086d6d2 100644 --- a/networkx/algorithms/distance_measures.py +++ b/networkx/algorithms/distance_measures.py @@ -629,6 +629,8 @@ def barycenter(G, weight=None, attr=None, sp=None): barycenter_vertices = [v] elif barycentricity == smallest: barycenter_vertices.append(v) + if attr is not None: + nx._clear_cache(G) return barycenter_vertices diff --git a/networkx/algorithms/flow/boykovkolmogorov.py b/networkx/algorithms/flow/boykovkolmogorov.py index e1c9486f527..87290a928de 100644 --- a/networkx/algorithms/flow/boykovkolmogorov.py +++ b/networkx/algorithms/flow/boykovkolmogorov.py @@ -10,13 +10,7 @@ __all__ = ["boykov_kolmogorov"] -@nx._dispatchable( - graphs={"G": 0, "residual?": 4}, - edge_attrs={"capacity": float("inf")}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"residual"}, - returns_graph=True, -) +@nx._dispatchable(edge_attrs={"capacity": float("inf")}, returns_graph=True) def boykov_kolmogorov( G, s, t, capacity="capacity", residual=None, value_only=False, cutoff=None ): @@ -162,6 +156,7 @@ def boykov_kolmogorov( """ R = boykov_kolmogorov_impl(G, s, t, capacity, residual, cutoff) R.graph["algorithm"] = "boykov_kolmogorov" + nx._clear_cache(R) return R diff --git a/networkx/algorithms/flow/dinitz_alg.py b/networkx/algorithms/flow/dinitz_alg.py index 31c1a5e2a1c..bcc08fe4814 100644 --- a/networkx/algorithms/flow/dinitz_alg.py +++ b/networkx/algorithms/flow/dinitz_alg.py @@ -10,13 +10,7 @@ __all__ = ["dinitz"] -@nx._dispatchable( - graphs={"G": 0, "residual?": 4}, - edge_attrs={"capacity": float("inf")}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"residual"}, - returns_graph=True, -) +@nx._dispatchable(edge_attrs={"capacity": float("inf")}, returns_graph=True) def dinitz(G, s, t, capacity="capacity", residual=None, value_only=False, cutoff=None): """Find a maximum single-commodity flow using Dinitz' algorithm. @@ -141,6 +135,7 @@ def dinitz(G, s, t, capacity="capacity", residual=None, value_only=False, cutoff """ R = dinitz_impl(G, s, t, capacity, residual, cutoff) R.graph["algorithm"] = "dinitz" + nx._clear_cache(R) return R diff --git a/networkx/algorithms/flow/edmondskarp.py b/networkx/algorithms/flow/edmondskarp.py index 92d79f181f2..50063268355 100644 --- a/networkx/algorithms/flow/edmondskarp.py +++ b/networkx/algorithms/flow/edmondskarp.py @@ -8,12 +8,6 @@ __all__ = ["edmonds_karp"] -@nx._dispatchable( - graphs="R", - preserve_edge_attrs={"R": {"capacity": float("inf"), "flow": 0}}, - preserve_graph_attrs=True, - mutates_input=True, -) def edmonds_karp_core(R, s, t, cutoff): """Implementation of the Edmonds-Karp algorithm.""" R_nodes = R.nodes @@ -123,13 +117,7 @@ def edmonds_karp_impl(G, s, t, capacity, residual, cutoff): return R -@nx._dispatchable( - graphs={"G": 0, "residual?": 4}, - edge_attrs={"capacity": float("inf")}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"residual"}, - returns_graph=True, -) +@nx._dispatchable(edge_attrs={"capacity": float("inf")}, returns_graph=True) def edmonds_karp( G, s, t, capacity="capacity", residual=None, value_only=False, cutoff=None ): @@ -249,4 +237,5 @@ def edmonds_karp( """ R = edmonds_karp_impl(G, s, t, capacity, residual, cutoff) R.graph["algorithm"] = "edmonds_karp" + nx._clear_cache(R) return R diff --git a/networkx/algorithms/flow/preflowpush.py b/networkx/algorithms/flow/preflowpush.py index 5afa548060c..42cadc2e2db 100644 --- a/networkx/algorithms/flow/preflowpush.py +++ b/networkx/algorithms/flow/preflowpush.py @@ -288,13 +288,7 @@ def global_relabel(from_sink): return R -@nx._dispatchable( - graphs={"G": 0, "residual?": 4}, - edge_attrs={"capacity": float("inf")}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"residual"}, - returns_graph=True, -) +@nx._dispatchable(edge_attrs={"capacity": float("inf")}, returns_graph=True) def preflow_push( G, s, t, capacity="capacity", residual=None, global_relabel_freq=1, value_only=False ): @@ -427,4 +421,5 @@ def preflow_push( """ R = preflow_push_impl(G, s, t, capacity, residual, global_relabel_freq, value_only) R.graph["algorithm"] = "preflow_push" + nx._clear_cache(R) return R diff --git a/networkx/algorithms/flow/shortestaugmentingpath.py b/networkx/algorithms/flow/shortestaugmentingpath.py index c2583d16646..9f1193f1cbf 100644 --- a/networkx/algorithms/flow/shortestaugmentingpath.py +++ b/networkx/algorithms/flow/shortestaugmentingpath.py @@ -163,13 +163,7 @@ def relabel(u): return R -@nx._dispatchable( - graphs={"G": 0, "residual?": 4}, - edge_attrs={"capacity": float("inf")}, - preserve_edge_attrs={"residual": {"capacity": float("inf")}}, - preserve_graph_attrs={"residual"}, - returns_graph=True, -) +@nx._dispatchable(edge_attrs={"capacity": float("inf")}, returns_graph=True) def shortest_augmenting_path( G, s, @@ -302,4 +296,5 @@ def shortest_augmenting_path( """ R = shortest_augmenting_path_impl(G, s, t, capacity, residual, two_phase, cutoff) R.graph["algorithm"] = "shortest_augmenting_path" + nx._clear_cache(R) return R diff --git a/networkx/algorithms/flow/utils.py b/networkx/algorithms/flow/utils.py index dcb663f3b64..03f1d10f75a 100644 --- a/networkx/algorithms/flow/utils.py +++ b/networkx/algorithms/flow/utils.py @@ -102,6 +102,7 @@ def build_residual_network(G, capacity): raise nx.NetworkXError("MultiGraph and MultiDiGraph not supported (yet).") R = nx.DiGraph() + R.__networkx_cache__ = None # Disable caching R.add_nodes_from(G) inf = float("inf") diff --git a/networkx/algorithms/planarity.py b/networkx/algorithms/planarity.py index b8dcda60c83..17d0bec5a16 100644 --- a/networkx/algorithms/planarity.py +++ b/networkx/algorithms/planarity.py @@ -951,6 +951,7 @@ def remove_node(self, n): raise nx.NetworkXError( f"The node {n} is not in the planar embedding." ) from err + nx._clear_cache(self) def remove_nodes_from(self, nodes): """Remove multiple nodes. @@ -1233,6 +1234,7 @@ def remove_edge(self, u, v): raise nx.NetworkXError( f"The edge {u}-{v} is not in the planar embedding." ) from err + nx._clear_cache(self) def remove_edges_from(self, ebunch): """Remove all edges specified in ebunch. diff --git a/networkx/algorithms/tree/branchings.py b/networkx/algorithms/tree/branchings.py index 34593ea4100..6c0e349060d 100644 --- a/networkx/algorithms/tree/branchings.py +++ b/networkx/algorithms/tree/branchings.py @@ -369,6 +369,7 @@ def _init(self, attr, default, kind, style, preserve_attrs, seed, partition): # The object we manipulate at each step is a multidigraph. self.G = G = MultiDiGraph_EdgeKey() + self.G.__networkx_cache__ = None # Disable caching for key, (u, v, data) in enumerate(self.G_original.edges(data=True)): d = {attr: trans(data.get(attr, default))} @@ -743,11 +744,7 @@ def is_root(G, u, edgekeys): return H -@nx._dispatchable( - edge_attrs={"attr": "default", "partition": 0}, - preserve_edge_attrs="preserve_attrs", - returns_graph=True, -) +@nx._dispatchable(preserve_edge_attrs=True, returns_graph=True) def maximum_branching( G, attr="weight", @@ -826,6 +823,8 @@ def edmonds_remove_node(G, edge_index, n): G_original = G G = nx.MultiDiGraph() + G.__networkx_cache__ = None # Disable caching + # A dict to reliably track mutations to the edges using the key of the edge. G_edge_index = {} # Each edge is given an arbitrary numerical key @@ -1172,33 +1171,28 @@ def is_root(G, u, edgekeys): return H -@nx._dispatchable( - edge_attrs={"attr": "default", "partition": None}, - preserve_edge_attrs="preserve_attrs", - returns_graph=True, -) +@nx._dispatchable(preserve_edge_attrs=True, mutates_input=True, returns_graph=True) def minimum_branching( G, attr="weight", default=1, preserve_attrs=False, partition=None ): for _, _, d in G.edges(data=True): d[attr] = -d.get(attr, default) + nx._clear_cache(G) B = maximum_branching(G, attr, default, preserve_attrs, partition) for _, _, d in G.edges(data=True): d[attr] = -d.get(attr, default) + nx._clear_cache(G) for _, _, d in B.edges(data=True): d[attr] = -d.get(attr, default) + nx._clear_cache(B) return B -@nx._dispatchable( - edge_attrs={"attr": "default", "partition": None}, - preserve_edge_attrs="preserve_attrs", - returns_graph=True, -) +@nx._dispatchable(preserve_edge_attrs=True, mutates_input=True, returns_graph=True) def minimal_branching( G, /, *, attr="weight", default=1, preserve_attrs=False, partition=None ): @@ -1246,24 +1240,23 @@ def minimal_branching( # in order to prevent the edge weights from becoming negative during # computation d[attr] = max_weight + 1 + (max_weight - min_weight) - d.get(attr, default) + nx._clear_cache(G) B = maximum_branching(G, attr, default, preserve_attrs, partition) # Reverse the weight transformations for _, _, d in G.edges(data=True): d[attr] = max_weight + 1 + (max_weight - min_weight) - d.get(attr, default) + nx._clear_cache(G) for _, _, d in B.edges(data=True): d[attr] = max_weight + 1 + (max_weight - min_weight) - d.get(attr, default) + nx._clear_cache(B) return B -@nx._dispatchable( - edge_attrs={"attr": "default", "partition": None}, - preserve_edge_attrs="preserve_attrs", - returns_graph=True, -) +@nx._dispatchable(preserve_edge_attrs=True, mutates_input=True, returns_graph=True) def maximum_spanning_arborescence( G, attr="weight", default=1, preserve_attrs=False, partition=None ): @@ -1287,14 +1280,17 @@ def maximum_spanning_arborescence( for _, _, d in G.edges(data=True): d[attr] = d.get(attr, default) - min_weight + 1 - (min_weight - max_weight) + nx._clear_cache(G) B = maximum_branching(G, attr, default, preserve_attrs, partition) for _, _, d in G.edges(data=True): d[attr] = d.get(attr, default) + min_weight - 1 + (min_weight - max_weight) + nx._clear_cache(G) for _, _, d in B.edges(data=True): d[attr] = d.get(attr, default) + min_weight - 1 + (min_weight - max_weight) + nx._clear_cache(B) if not is_arborescence(B): raise nx.exception.NetworkXException("No maximum spanning arborescence in G.") @@ -1302,11 +1298,7 @@ def maximum_spanning_arborescence( return B -@nx._dispatchable( - edge_attrs={"attr": "default", "partition": None}, - preserve_edge_attrs="preserve_attrs", - returns_graph=True, -) +@nx._dispatchable(preserve_edge_attrs=True, mutates_input=True, returns_graph=True) def minimum_spanning_arborescence( G, attr="weight", default=1, preserve_attrs=False, partition=None ): @@ -1578,6 +1570,7 @@ def _write_partition(self, partition): d[self.partition_key] = partition.partition_dict[(u, v)] else: d[self.partition_key] = nx.EdgePartition.OPEN + nx._clear_cache(self.G) for n in self.G: included_count = 0 @@ -1601,3 +1594,4 @@ def _clear_partition(self, G): for u, v, d in G.edges(data=True): if self.partition_key in d: del d[self.partition_key] + nx._clear_cache(self.G) diff --git a/networkx/algorithms/tree/mst.py b/networkx/algorithms/tree/mst.py index 72c1980cb15..9e8ea3843f9 100644 --- a/networkx/algorithms/tree/mst.py +++ b/networkx/algorithms/tree/mst.py @@ -1027,6 +1027,7 @@ def __init__(self, G, weight="weight", minimum=True, ignore_nan=False): If `ignore_nan is True` then that edge is ignored instead. """ self.G = G.copy() + self.G.__networkx_cache__ = None # Disable caching self.weight = weight self.minimum = minimum self.ignore_nan = ignore_nan diff --git a/networkx/classes/digraph.py b/networkx/classes/digraph.py index 945643776b4..fc2374a2b4a 100644 --- a/networkx/classes/digraph.py +++ b/networkx/classes/digraph.py @@ -355,6 +355,7 @@ def __init__(self, incoming_graph_data=None, **attr): self._pred = self.adjlist_outer_dict_factory() # predecessor # Note: self._succ = self._adj # successor + self.__networkx_cache__ = {} # attempt to load graph with data if incoming_graph_data is not None: convert.to_networkx_graph(incoming_graph_data, create_using=self) @@ -465,6 +466,7 @@ def add_node(self, node_for_adding, **attr): attr_dict.update(attr) else: # update attr even if node already exists self._node[node_for_adding].update(attr) + nx._clear_cache(self) def add_nodes_from(self, nodes_for_adding, **attr): """Add multiple nodes. @@ -543,6 +545,7 @@ def add_nodes_from(self, nodes_for_adding, **attr): self._pred[n] = self.adjlist_inner_dict_factory() self._node[n] = self.node_attr_dict_factory() self._node[n].update(newdict) + nx._clear_cache(self) def remove_node(self, n): """Remove node n. @@ -585,6 +588,7 @@ def remove_node(self, n): for u in self._pred[n]: del self._succ[u][n] # remove all edges n-u in digraph del self._pred[n] # remove node from pred + nx._clear_cache(self) def remove_nodes_from(self, nodes): """Remove multiple nodes. @@ -639,6 +643,7 @@ def remove_nodes_from(self, nodes): del self._pred[n] # now remove node except KeyError: pass # silent failure on remove + nx._clear_cache(self) def add_edge(self, u_of_edge, v_of_edge, **attr): """Add an edge between u and v. @@ -709,6 +714,7 @@ def add_edge(self, u_of_edge, v_of_edge, **attr): datadict.update(attr) self._succ[u][v] = datadict self._pred[v][u] = datadict + nx._clear_cache(self) def add_edges_from(self, ebunch_to_add, **attr): """Add all the edges in ebunch_to_add. @@ -791,6 +797,7 @@ def add_edges_from(self, ebunch_to_add, **attr): datadict.update(dd) self._succ[u][v] = datadict self._pred[v][u] = datadict + nx._clear_cache(self) def remove_edge(self, u, v): """Remove the edge between u and v. @@ -824,6 +831,7 @@ def remove_edge(self, u, v): del self._pred[v][u] except KeyError as err: raise NetworkXError(f"The edge {u}-{v} not in graph.") from err + nx._clear_cache(self) def remove_edges_from(self, ebunch): """Remove all edges specified in ebunch. @@ -856,6 +864,7 @@ def remove_edges_from(self, ebunch): if u in self._succ and v in self._succ[u]: del self._succ[u][v] del self._pred[v][u] + nx._clear_cache(self) def has_successor(self, u, v): """Returns True if node u has successor v. @@ -1195,6 +1204,7 @@ def clear(self): self._pred.clear() self._node.clear() self.graph.clear() + nx._clear_cache(self) def clear_edges(self): """Remove all edges from the graph without altering nodes. @@ -1213,6 +1223,7 @@ def clear_edges(self): predecessor_dict.clear() for successor_dict in self._succ.values(): successor_dict.clear() + nx._clear_cache(self) def is_multigraph(self): """Returns True if graph is a multigraph, False otherwise.""" diff --git a/networkx/classes/function.py b/networkx/classes/function.py index 20aefa06680..f87b7897f83 100644 --- a/networkx/classes/function.py +++ b/networkx/classes/function.py @@ -663,6 +663,7 @@ def set_node_attributes(G, values, name=None): G.nodes[n].update(d) except KeyError: pass + nx._clear_cache(G) def get_node_attributes(G, name, default=None): @@ -836,6 +837,7 @@ def set_edge_attributes(G, values, name=None): G._adj[u][v].update(d) except KeyError: pass + nx._clear_cache(G) def get_edge_attributes(G, name, default=None): diff --git a/networkx/classes/graph.py b/networkx/classes/graph.py index 02d332be1a6..bf628ed625d 100644 --- a/networkx/classes/graph.py +++ b/networkx/classes/graph.py @@ -365,6 +365,7 @@ def __init__(self, incoming_graph_data=None, **attr): self.graph = self.graph_attr_dict_factory() # dictionary for graph attributes self._node = self.node_dict_factory() # empty node attribute dict self._adj = self.adjlist_outer_dict_factory() # empty adjacency dict + self.__networkx_cache__ = {} # attempt to load graph with data if incoming_graph_data is not None: convert.to_networkx_graph(incoming_graph_data, create_using=self) @@ -403,6 +404,7 @@ def name(self): @name.setter def name(self, s): self.graph["name"] = s + nx._clear_cache(self) def __str__(self): """Returns a short summary of the graph. @@ -559,6 +561,7 @@ def add_node(self, node_for_adding, **attr): attr_dict.update(attr) else: # update attr even if node already exists self._node[node_for_adding].update(attr) + nx._clear_cache(self) def add_nodes_from(self, nodes_for_adding, **attr): """Add multiple nodes. @@ -636,6 +639,7 @@ def add_nodes_from(self, nodes_for_adding, **attr): self._adj[n] = self.adjlist_inner_dict_factory() self._node[n] = self.node_attr_dict_factory() self._node[n].update(newdict) + nx._clear_cache(self) def remove_node(self, n): """Remove node n. @@ -676,6 +680,7 @@ def remove_node(self, n): for u in nbrs: del adj[u][n] # remove all edges n-u in graph del adj[n] # now remove node + nx._clear_cache(self) def remove_nodes_from(self, nodes): """Remove multiple nodes. @@ -728,6 +733,7 @@ def remove_nodes_from(self, nodes): del adj[n] except KeyError: pass + nx._clear_cache(self) @cached_property def nodes(self): @@ -957,6 +963,7 @@ def add_edge(self, u_of_edge, v_of_edge, **attr): datadict.update(attr) self._adj[u][v] = datadict self._adj[v][u] = datadict + nx._clear_cache(self) def add_edges_from(self, ebunch_to_add, **attr): """Add all the edges in ebunch_to_add. @@ -1037,6 +1044,7 @@ def add_edges_from(self, ebunch_to_add, **attr): datadict.update(dd) self._adj[u][v] = datadict self._adj[v][u] = datadict + nx._clear_cache(self) def add_weighted_edges_from(self, ebunch_to_add, weight="weight", **attr): """Add weighted edges in `ebunch_to_add` with specified weight attr @@ -1087,6 +1095,7 @@ def add_weighted_edges_from(self, ebunch_to_add, weight="weight", **attr): >>> G.add_weighted_edges_from(list((5, n, weight) for n in G.nodes)) """ self.add_edges_from(((u, v, {weight: d}) for u, v, d in ebunch_to_add), **attr) + nx._clear_cache(self) def remove_edge(self, u, v): """Remove the edge between u and v. @@ -1120,6 +1129,7 @@ def remove_edge(self, u, v): del self._adj[v][u] except KeyError as err: raise NetworkXError(f"The edge {u}-{v} is not in the graph") from err + nx._clear_cache(self) def remove_edges_from(self, ebunch): """Remove all edges specified in ebunch. @@ -1154,6 +1164,7 @@ def remove_edges_from(self, ebunch): del adj[u][v] if u != v: # self loop needs only one entry removed del adj[v][u] + nx._clear_cache(self) def update(self, edges=None, nodes=None): """Update the graph using nodes/edges/graphs as input. @@ -1526,6 +1537,7 @@ def clear(self): self._adj.clear() self._node.clear() self.graph.clear() + nx._clear_cache(self) def clear_edges(self): """Remove all edges from the graph without altering nodes. @@ -1541,6 +1553,7 @@ def clear_edges(self): """ for nbr_dict in self._adj.values(): nbr_dict.clear() + nx._clear_cache(self) def is_multigraph(self): """Returns True if graph is a multigraph, False otherwise.""" diff --git a/networkx/classes/multidigraph.py b/networkx/classes/multidigraph.py index 5a278aa967f..ad048cd5a36 100644 --- a/networkx/classes/multidigraph.py +++ b/networkx/classes/multidigraph.py @@ -508,6 +508,7 @@ def add_edge(self, u_for_edge, v_for_edge, key=None, **attr): keydict[key] = datadict self._succ[u][v] = keydict self._pred[v][u] = keydict + nx._clear_cache(self) return key def remove_edge(self, u, v, key=None): @@ -583,6 +584,7 @@ def remove_edge(self, u, v, key=None): # remove the key entries if last edge del self._succ[u][v] del self._pred[v][u] + nx._clear_cache(self) @cached_property def edges(self): diff --git a/networkx/classes/multigraph.py b/networkx/classes/multigraph.py index b21968000da..d1b263265e0 100644 --- a/networkx/classes/multigraph.py +++ b/networkx/classes/multigraph.py @@ -520,6 +520,7 @@ def add_edge(self, u_for_edge, v_for_edge, key=None, **attr): keydict[key] = datadict self._adj[u][v] = keydict self._adj[v][u] = keydict + nx._clear_cache(self) return key def add_edges_from(self, ebunch_to_add, **attr): @@ -616,6 +617,7 @@ def add_edges_from(self, ebunch_to_add, **attr): key = self.add_edge(u, v, key) self[u][v][key].update(ddd) keylist.append(key) + nx._clear_cache(self) return keylist def remove_edge(self, u, v, key=None): @@ -695,6 +697,7 @@ def remove_edge(self, u, v, key=None): del self._adj[u][v] if u != v: # check for selfloop del self._adj[v][u] + nx._clear_cache(self) def remove_edges_from(self, ebunch): """Remove all edges specified in ebunch. @@ -753,6 +756,7 @@ def remove_edges_from(self, ebunch): self.remove_edge(*e[:3]) except NetworkXError: pass + nx._clear_cache(self) def has_edge(self, u, v, key=None): """Returns True if the graph has an edge between nodes u and v. diff --git a/networkx/generators/stochastic.py b/networkx/generators/stochastic.py index e3ce97e50d0..f53e2315470 100644 --- a/networkx/generators/stochastic.py +++ b/networkx/generators/stochastic.py @@ -50,4 +50,5 @@ def stochastic_graph(G, copy=True, weight="weight"): d[weight] = 0 else: d[weight] = d.get(weight, 1) / degree[u] + nx._clear_cache(G) return G diff --git a/networkx/utils/__init__.py b/networkx/utils/__init__.py index 48f02c18873..96ef984a13f 100644 --- a/networkx/utils/__init__.py +++ b/networkx/utils/__init__.py @@ -4,3 +4,5 @@ from networkx.utils.union_find import * from networkx.utils.rcm import * from networkx.utils.heaps import * +from networkx.utils.backends import * +from networkx.utils.configs import * diff --git a/networkx/utils/backends.py b/networkx/utils/backends.py index d417d979331..32bdf1a883b 100644 --- a/networkx/utils/backends.py +++ b/networkx/utils/backends.py @@ -154,9 +154,13 @@ class BackendGraph: It will be called with the list of NetworkX tests discovered. Each item is a test object that can be marked as xfail if the backend does not support the test using ``item.add_marker(pytest.mark.xfail(reason=...))``. + +- A backend graph instance may have a ``G.__networkx_cache__`` dict to enable + caching, and care should be taken to clear the cache when appropriate. """ import inspect +import itertools import os import warnings from functools import partial @@ -166,7 +170,7 @@ class BackendGraph: from .decorators import argmap -__all__ = ["_dispatchable", "config"] +__all__ = ["_dispatchable"] def _do_nothing(): @@ -781,7 +785,7 @@ def _should_backend_run(self, backend_name, /, *args, **kwargs): and not isinstance(should_run, str) ) - def _convert_arguments(self, backend_name, args, kwargs): + def _convert_arguments(self, backend_name, args, kwargs, *, use_cache): """Convert graph arguments to the specified backend. Returns @@ -929,19 +933,19 @@ def _convert_arguments(self, backend_name, args, kwargs): # It should be safe to assume that we either have networkx graphs or backend graphs. # Future work: allow conversions between backends. - backend = _load_backend(backend_name) for gname in self.graphs: if gname in self.list_graphs: bound.arguments[gname] = [ - backend.convert_from_nx( + self._convert_graph( + backend_name, g, edge_attrs=edge_attrs, node_attrs=node_attrs, preserve_edge_attrs=preserve_edge_attrs, preserve_node_attrs=preserve_node_attrs, preserve_graph_attrs=preserve_graph_attrs, - name=self.name, graph_name=gname, + use_cache=use_cache, ) if getattr(g, "__networkx_backend__", "networkx") == "networkx" else g @@ -972,20 +976,146 @@ def _convert_arguments(self, backend_name, args, kwargs): else: preserve_graph = preserve_graph_attrs if getattr(graph, "__networkx_backend__", "networkx") == "networkx": - bound.arguments[gname] = backend.convert_from_nx( + bound.arguments[gname] = self._convert_graph( + backend_name, graph, edge_attrs=edges, node_attrs=nodes, preserve_edge_attrs=preserve_edges, preserve_node_attrs=preserve_nodes, preserve_graph_attrs=preserve_graph, - name=self.name, graph_name=gname, + use_cache=use_cache, ) bound_kwargs = bound.kwargs del bound_kwargs["backend"] return bound.args, bound_kwargs + def _convert_graph( + self, + backend_name, + graph, + *, + edge_attrs, + node_attrs, + preserve_edge_attrs, + preserve_node_attrs, + preserve_graph_attrs, + graph_name, + use_cache, + ): + if ( + use_cache + and (nx_cache := getattr(graph, "__networkx_cache__", None)) is not None + ): + cache = nx_cache.setdefault("backends", {}).setdefault(backend_name, {}) + # edge_attrs: dict | None + # node_attrs: dict | None + # preserve_edge_attrs: bool (False if edge_attrs is not None) + # preserve_node_attrs: bool (False if node_attrs is not None) + # preserve_graph_attrs: bool + key = edge_key, node_key, graph_key = ( + frozenset(edge_attrs.items()) + if edge_attrs is not None + else preserve_edge_attrs, + frozenset(node_attrs.items()) + if node_attrs is not None + else preserve_node_attrs, + preserve_graph_attrs, + ) + if cache: + warning_message = ( + f"Using cached graph for {backend_name!r} backend in " + f"call to {self.name}.\n\nFor the cache to be consistent " + "(i.e., correct), the input graph must not have been " + "manually mutated since the cached graph was created. " + "Examples of manually mutating the graph data structures " + "resulting in an inconsistent cache include:\n\n" + " >>> G[u][v][key] = val\n\n" + "and\n\n" + " >>> for u, v, d in G.edges(data=True):\n" + " ... d[key] = val\n\n" + "Using methods such as `G.add_edge(u, v, weight=val)` " + "will correctly clear the cache to keep it consistent. " + "You may also use `G.__networkx_cache__.clear()` to " + "manually clear the cache, or set `G.__networkx_cache__` " + "to None to disable caching for G. Enable or disable " + "caching via `nx.config.cache_converted_graphs` config." + ) + # Do a simple search for a cached graph with compatible data. + # For example, if we need a single attribute, then it's okay + # to use a cached graph that preserved all attributes. + # This looks for an exact match first. + for compat_key in itertools.product( + (edge_key, True) if edge_key is not True else (True,), + (node_key, True) if node_key is not True else (True,), + (graph_key, True) if graph_key is not True else (True,), + ): + if (rv := cache.get(compat_key)) is not None: + warnings.warn(warning_message) + return rv + if edge_key is not True and node_key is not True: + # Iterate over the items in `cache` to see if any are compatible. + # For example, if no edge attributes are needed, then a graph + # with any edge attribute will suffice. We use the same logic + # below (but switched) to clear unnecessary items from the cache. + # Use `list(cache.items())` to be thread-safe. + for (ekey, nkey, gkey), val in list(cache.items()): + if edge_key is False or ekey is True: + pass + elif ( + edge_key is True + or ekey is False + or not edge_key.issubset(ekey) + ): + continue + if node_key is False or nkey is True: + pass + elif ( + node_key is True + or nkey is False + or not node_key.issubset(nkey) + ): + continue + if graph_key and not gkey: + continue + warnings.warn(warning_message) + return val + + backend = _load_backend(backend_name) + rv = backend.convert_from_nx( + graph, + edge_attrs=edge_attrs, + node_attrs=node_attrs, + preserve_edge_attrs=preserve_edge_attrs, + preserve_node_attrs=preserve_node_attrs, + preserve_graph_attrs=preserve_graph_attrs, + name=self.name, + graph_name=graph_name, + ) + if use_cache and nx_cache is not None: + # Remove old cached items that are no longer necessary since they + # are dominated/subsumed/outdated by what was just calculated. + # This uses the same logic as above, but with keys switched. + cache[key] = rv # Set at beginning to be thread-safe + for cur_key in list(cache): + if cur_key == key: + continue + ekey, nkey, gkey = cur_key + if ekey is False or edge_key is True: + pass + elif ekey is True or edge_key is False or not ekey.issubset(edge_key): + continue + if nkey is False or node_key is True: + pass + elif nkey is True or node_key is False or not nkey.issubset(node_key): + continue + if gkey and not graph_key: + continue + cache.pop(cur_key, None) # Use pop instead of del to be thread-safe + + return rv + def _convert_and_call(self, backend_name, args, kwargs, *, fallback_to_nx=False): """Call this dispatchable function with a backend, converting graphs if necessary.""" backend = _load_backend(backend_name) @@ -999,7 +1129,7 @@ def _convert_and_call(self, backend_name, args, kwargs, *, fallback_to_nx=False) try: converted_args, converted_kwargs = self._convert_arguments( - backend_name, args, kwargs + backend_name, args, kwargs, use_cache=config.cache_converted_graphs ) result = getattr(backend, self.name)(*converted_args, **converted_kwargs) except (NotImplementedError, nx.NetworkXNotImplemented) as exc: @@ -1074,7 +1204,7 @@ def _convert_and_call_for_tests( kwargs2 = dict(kwargs2) try: converted_args, converted_kwargs = self._convert_arguments( - backend_name, args1, kwargs1 + backend_name, args1, kwargs1, use_cache=False ) result = getattr(backend, self.name)(*converted_args, **converted_kwargs) except (NotImplementedError, nx.NetworkXNotImplemented) as exc: @@ -1165,29 +1295,49 @@ def check_iterator(it): check_result(result) if self.name in { - "edmonds_karp_core", + "edmonds_karp", "barycenter", "contracted_edge", "contracted_nodes", "stochastic_graph", "relabel_nodes", + "maximum_branching", + "incremental_closeness_centrality", + "minimal_branching", + "minimum_spanning_arborescence", + "recursive_simple_cycles", + "connected_double_edge_swap", }: # Special-case algorithms that mutate input graphs bound = self.__signature__.bind(*converted_args, **converted_kwargs) bound.apply_defaults() bound2 = self.__signature__.bind(*args2, **kwargs2) bound2.apply_defaults() - if self.name == "edmonds_karp_core": - R1 = backend.convert_to_nx(bound.arguments["R"]) - R2 = bound2.arguments["R"] - for k, v in R1.edges.items(): - R2.edges[k]["flow"] = v["flow"] + if self.name in { + "minimal_branching", + "minimum_spanning_arborescence", + "recursive_simple_cycles", + "connected_double_edge_swap", + }: + G1 = backend.convert_to_nx(bound.arguments["G"]) + G2 = bound2.arguments["G"] + G2._adj = G1._adj + nx._clear_cache(G2) + elif self.name == "edmonds_karp": + R1 = backend.convert_to_nx(bound.arguments["residual"]) + R2 = bound2.arguments["residual"] + if R1 is not None and R2 is not None: + for k, v in R1.edges.items(): + R2.edges[k]["flow"] = v["flow"] + R2.graph.update(R1.graph) + nx._clear_cache(R2) elif self.name == "barycenter" and bound.arguments["attr"] is not None: G1 = backend.convert_to_nx(bound.arguments["G"]) G2 = bound2.arguments["G"] attr = bound.arguments["attr"] for k, v in G1.nodes.items(): G2.nodes[k][attr] = v[attr] + nx._clear_cache(G2) elif ( self.name in {"contracted_nodes", "contracted_edge"} and not bound.arguments["copy"] @@ -1196,12 +1346,18 @@ def check_iterator(it): G1 = backend.convert_to_nx(bound.arguments["G"]) G2 = bound2.arguments["G"] G2.__dict__.update(G1.__dict__) + nx._clear_cache(G2) elif self.name == "stochastic_graph" and not bound.arguments["copy"]: G1 = backend.convert_to_nx(bound.arguments["G"]) G2 = bound2.arguments["G"] for k, v in G1.edges.items(): G2.edges[k]["weight"] = v["weight"] - elif self.name == "relabel_nodes" and not bound.arguments["copy"]: + nx._clear_cache(G2) + elif ( + self.name == "relabel_nodes" + and not bound.arguments["copy"] + or self.name in {"incremental_closeness_centrality"} + ): G1 = backend.convert_to_nx(bound.arguments["G"]) G2 = bound2.arguments["G"] if G1 is G2: @@ -1216,7 +1372,9 @@ def check_iterator(it): if hasattr(G1, "_succ") and hasattr(G2, "_succ"): G2._succ.clear() G2._succ.update(G1._succ) - return G2 + nx._clear_cache(G2) + if self.name == "relabel_nodes": + return G2 return backend.convert_to_nx(result) converted_result = backend.convert_to_nx(result) diff --git a/networkx/utils/configs.py b/networkx/utils/configs.py index 8ccd81777b2..035b1ac96f4 100644 --- a/networkx/utils/configs.py +++ b/networkx/utils/configs.py @@ -1,4 +1,5 @@ import collections +import os import typing from dataclasses import dataclass @@ -187,14 +188,36 @@ class NetworkXConfig(Config): backend_priority : list of backend names Enable automatic conversion of graphs to backend graphs for algorithms implemented by the backend. Priority is given to backends listed earlier. + Default is empty list. backends : Config mapping of backend names to backend Config The keys of the Config mapping are names of all installed NetworkX backends, and the values are their configurations as Config mappings. + + cache_converted_graphs : bool + If True, then save converted graphs to the cache of the input graph. Graph + conversion may occur when automatically using a backend from `backend_priority` + or when using the `backend=` keyword argument to a function call. Caching can + improve performance by avoiding repeated conversions, but it uses more memory. + Care should be taken to not manually mutate a graph that has cached graphs; for + example, ``G[u][v][k] = val`` changes the graph, but does not clear the cache. + Using methods such as ``G.add_edge(u, v, weight=val)`` will clear the cache to + keep it consistent. ``G.__networkx_cache__.clear()`` manually clears the cache. + Default is False. + + Notes + ----- + Environment variables may be used to control some default configurations: + + - NETWORKX_BACKEND_PRIORITY: set `backend_priority` from comma-separated names. + - NETWORKX_CACHE_CONVERTED_GRAPHS: set `cache_converted_graphs` to True if nonempty. + + This is a global configuration. Use with caution when using from multiple threads. """ backend_priority: list[str] backends: Config + cache_converted_graphs: bool def _check_config(self, key, value): from .backends import backends @@ -219,10 +242,14 @@ def _check_config(self, key, value): if missing := {x for x in value if x not in backends}: missing = ", ".join(map(repr, sorted(missing))) raise ValueError(f"Unknown backend when setting {key!r}: {missing}") + elif key == "cache_converted_graphs": + if not isinstance(value, bool): + raise TypeError(f"{key!r} config must be True or False; got {value!r}") # Backend configuration will be updated in backends.py config = NetworkXConfig( backend_priority=[], backends=Config(), + cache_converted_graphs=bool(os.environ.get("NETWORKX_CACHE_CONVERTED_GRAPHS", "")), ) diff --git a/networkx/utils/misc.py b/networkx/utils/misc.py index b8de5e5aa28..096e46ab6ae 100644 --- a/networkx/utils/misc.py +++ b/networkx/utils/misc.py @@ -35,6 +35,7 @@ "nodes_equal", "edges_equal", "graphs_equal", + "_clear_cache", ] @@ -589,3 +590,12 @@ def graphs_equal(graph1, graph2): and graph1.nodes == graph2.nodes and graph1.graph == graph2.graph ) + + +def _clear_cache(G): + """Clear the cache of a graph (currently stores converted graphs). + + Caching is controlled via ``nx.config.cache_converted_graphs`` configuration. + """ + if cache := getattr(G, "__networkx_cache__", None): + cache.clear() diff --git a/networkx/utils/tests/test_config.py b/networkx/utils/tests/test_config.py index 5c9cc2f972c..47d52449679 100644 --- a/networkx/utils/tests/test_config.py +++ b/networkx/utils/tests/test_config.py @@ -128,6 +128,8 @@ def test_nxconfig(): nx.config.backends = Config(plausible_backend_name={}) with pytest.raises(ValueError, match="Unknown backend when setting"): nx.config.backends = Config(this_almost_certainly_is_not_a_backend=Config()) + with pytest.raises(TypeError, match="must be True or False"): + nx.config.cache_converted_graphs = "bad value" def test_not_strict():