napistu 0.2.5.dev7__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. napistu/__main__.py +126 -96
  2. napistu/constants.py +35 -41
  3. napistu/context/__init__.py +10 -0
  4. napistu/context/discretize.py +462 -0
  5. napistu/context/filtering.py +387 -0
  6. napistu/gcs/__init__.py +1 -1
  7. napistu/identifiers.py +74 -15
  8. napistu/indices.py +68 -0
  9. napistu/ingestion/__init__.py +1 -1
  10. napistu/ingestion/bigg.py +47 -62
  11. napistu/ingestion/constants.py +18 -133
  12. napistu/ingestion/gtex.py +113 -0
  13. napistu/ingestion/hpa.py +147 -0
  14. napistu/ingestion/sbml.py +0 -97
  15. napistu/ingestion/string.py +2 -2
  16. napistu/matching/__init__.py +10 -0
  17. napistu/matching/constants.py +18 -0
  18. napistu/matching/interactions.py +518 -0
  19. napistu/matching/mount.py +529 -0
  20. napistu/matching/species.py +510 -0
  21. napistu/mcp/__init__.py +7 -4
  22. napistu/mcp/__main__.py +128 -72
  23. napistu/mcp/client.py +16 -25
  24. napistu/mcp/codebase.py +201 -145
  25. napistu/mcp/component_base.py +170 -0
  26. napistu/mcp/config.py +223 -0
  27. napistu/mcp/constants.py +45 -2
  28. napistu/mcp/documentation.py +253 -136
  29. napistu/mcp/documentation_utils.py +13 -48
  30. napistu/mcp/execution.py +372 -305
  31. napistu/mcp/health.py +47 -65
  32. napistu/mcp/profiles.py +10 -6
  33. napistu/mcp/server.py +161 -80
  34. napistu/mcp/tutorials.py +139 -87
  35. napistu/modify/__init__.py +1 -1
  36. napistu/modify/gaps.py +1 -1
  37. napistu/network/__init__.py +1 -1
  38. napistu/network/constants.py +101 -34
  39. napistu/network/data_handling.py +388 -0
  40. napistu/network/ig_utils.py +351 -0
  41. napistu/network/napistu_graph_core.py +354 -0
  42. napistu/network/neighborhoods.py +40 -40
  43. napistu/network/net_create.py +373 -309
  44. napistu/network/net_propagation.py +47 -19
  45. napistu/network/{net_utils.py → ng_utils.py} +124 -272
  46. napistu/network/paths.py +67 -51
  47. napistu/network/precompute.py +11 -11
  48. napistu/ontologies/__init__.py +10 -0
  49. napistu/ontologies/constants.py +129 -0
  50. napistu/ontologies/dogma.py +243 -0
  51. napistu/ontologies/genodexito.py +649 -0
  52. napistu/ontologies/mygene.py +369 -0
  53. napistu/ontologies/renaming.py +198 -0
  54. napistu/rpy2/__init__.py +229 -86
  55. napistu/rpy2/callr.py +47 -77
  56. napistu/rpy2/constants.py +24 -23
  57. napistu/rpy2/rids.py +61 -648
  58. napistu/sbml_dfs_core.py +587 -222
  59. napistu/scverse/__init__.py +15 -0
  60. napistu/scverse/constants.py +28 -0
  61. napistu/scverse/loading.py +727 -0
  62. napistu/utils.py +118 -10
  63. {napistu-0.2.5.dev7.dist-info → napistu-0.3.1.dist-info}/METADATA +8 -3
  64. napistu-0.3.1.dist-info/RECORD +133 -0
  65. tests/conftest.py +22 -0
  66. tests/test_context_discretize.py +56 -0
  67. tests/test_context_filtering.py +267 -0
  68. tests/test_identifiers.py +100 -0
  69. tests/test_indices.py +65 -0
  70. tests/{test_edgelist.py → test_ingestion_napistu_edgelist.py} +2 -2
  71. tests/test_matching_interactions.py +108 -0
  72. tests/test_matching_mount.py +305 -0
  73. tests/test_matching_species.py +394 -0
  74. tests/test_mcp_config.py +193 -0
  75. tests/test_mcp_documentation_utils.py +12 -3
  76. tests/test_mcp_server.py +156 -19
  77. tests/test_network_data_handling.py +397 -0
  78. tests/test_network_ig_utils.py +23 -0
  79. tests/test_network_neighborhoods.py +19 -0
  80. tests/test_network_net_create.py +459 -0
  81. tests/test_network_ng_utils.py +30 -0
  82. tests/test_network_paths.py +56 -0
  83. tests/{test_precomputed_distances.py → test_network_precompute.py} +8 -6
  84. tests/test_ontologies_genodexito.py +58 -0
  85. tests/test_ontologies_mygene.py +39 -0
  86. tests/test_ontologies_renaming.py +110 -0
  87. tests/test_rpy2_callr.py +79 -0
  88. tests/test_rpy2_init.py +151 -0
  89. tests/test_sbml.py +0 -31
  90. tests/test_sbml_dfs_core.py +134 -10
  91. tests/test_scverse_loading.py +778 -0
  92. tests/test_set_coverage.py +2 -2
  93. tests/test_utils.py +121 -1
  94. napistu/mechanism_matching.py +0 -1353
  95. napistu/rpy2/netcontextr.py +0 -467
  96. napistu-0.2.5.dev7.dist-info/RECORD +0 -98
  97. tests/test_igraph.py +0 -367
  98. tests/test_mechanism_matching.py +0 -784
  99. tests/test_net_utils.py +0 -149
  100. tests/test_netcontextr.py +0 -105
  101. tests/test_rpy2.py +0 -61
  102. /napistu/ingestion/{cpr_edgelist.py → napistu_edgelist.py} +0 -0
  103. {napistu-0.2.5.dev7.dist-info → napistu-0.3.1.dist-info}/WHEEL +0 -0
  104. {napistu-0.2.5.dev7.dist-info → napistu-0.3.1.dist-info}/entry_points.txt +0 -0
  105. {napistu-0.2.5.dev7.dist-info → napistu-0.3.1.dist-info}/licenses/LICENSE +0 -0
  106. {napistu-0.2.5.dev7.dist-info → napistu-0.3.1.dist-info}/top_level.txt +0 -0
  107. /tests/{test_obo.py → test_ingestion_obo.py} +0 -0
@@ -0,0 +1,351 @@
1
+ """
2
+ General utilities for working with igraph.Graph objects.
3
+
4
+ This module contains utilities that can be broadly applied to any igraph.Graph
5
+ object, not specific to NapistuGraph subclasses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import random
12
+ from typing import Any, Optional, Sequence
13
+
14
+ import igraph as ig
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_graph_summary(graph: ig.Graph) -> dict[str, Any]:
22
+ """
23
+ Calculate common summary statistics for an igraph network.
24
+
25
+ Parameters
26
+ ----------
27
+ graph : ig.Graph
28
+ The input network.
29
+
30
+ Returns
31
+ -------
32
+ dict
33
+ A dictionary of summary statistics with the following keys:
34
+ - n_edges (int): number of edges
35
+ - n_vertices (int): number of vertices
36
+ - n_components (int): number of weakly connected components
37
+ - stats_component_sizes (dict): summary statistics for the component sizes
38
+ - top10_large_components (list[dict]): the top 10 largest components with 10 example vertices
39
+ - top10_smallest_components (list[dict]): the top 10 smallest components with 10 example vertices
40
+ - average_path_length (float): the average shortest path length between all vertices
41
+ - top10_betweenness (list[dict]): the top 10 vertices by betweenness centrality
42
+ - top10_harmonic_centrality (list[dict]): the top 10 vertices by harmonic centrality
43
+ """
44
+ stats = {}
45
+ stats["n_edges"] = graph.ecount()
46
+ stats["n_vertices"] = graph.vcount()
47
+ components = graph.components(mode="weak")
48
+ stats["n_components"] = len(components)
49
+ component_sizes = [len(c) for c in components]
50
+ stats["stats_component_sizes"] = pd.Series(component_sizes).describe().to_dict()
51
+
52
+ # get the top 10 largest components and 10 example nodes
53
+ stats["top10_large_components"] = _get_top_n_component_stats(
54
+ graph, components, component_sizes, n=10, ascending=False
55
+ )
56
+ stats["top10_smallest_components"] = _get_top_n_component_stats(
57
+ graph, components, component_sizes, n=10, ascending=True
58
+ )
59
+
60
+ stats["average_path_length"] = graph.average_path_length()
61
+
62
+ # Top 10 by betweenness and harmonic centrality
63
+ betweenness = graph.betweenness()
64
+ stats["top10_betweenness"] = _get_top_n_nodes(
65
+ graph, betweenness, "betweenness", n=10
66
+ )
67
+ harmonic = graph.harmonic_centrality()
68
+ stats["top10_harmonic_centrality"] = _get_top_n_nodes(
69
+ graph, harmonic, "harmonic_centrality", n=10
70
+ )
71
+
72
+ return stats
73
+
74
+
75
+ def filter_to_largest_subgraph(graph: ig.Graph) -> ig.Graph:
76
+ """
77
+ Filter an igraph to its largest weakly connected component.
78
+
79
+ Parameters
80
+ ----------
81
+ graph : ig.Graph
82
+ The input network.
83
+
84
+ Returns
85
+ -------
86
+ ig.Graph
87
+ The largest weakly connected component.
88
+ """
89
+ component_members = graph.components(mode="weak")
90
+ component_sizes = [len(x) for x in component_members]
91
+
92
+ top_component_members = [
93
+ m
94
+ for s, m in zip(component_sizes, component_members)
95
+ if s == max(component_sizes)
96
+ ][0]
97
+
98
+ largest_subgraph = graph.induced_subgraph(top_component_members)
99
+ return largest_subgraph
100
+
101
+
102
+ def graph_to_pandas_dfs(graph: ig.Graph) -> tuple[pd.DataFrame, pd.DataFrame]:
103
+ """
104
+ Convert an igraph to Pandas DataFrames for vertices and edges.
105
+
106
+ Parameters
107
+ ----------
108
+ graph : ig.Graph
109
+ An igraph network.
110
+
111
+ Returns
112
+ -------
113
+ vertices : pandas.DataFrame
114
+ A table with one row per vertex.
115
+ edges : pandas.DataFrame
116
+ A table with one row per edge.
117
+ """
118
+ vertices = pd.DataFrame(
119
+ [{**{"index": v.index}, **v.attributes()} for v in graph.vs]
120
+ )
121
+ edges = pd.DataFrame(
122
+ [
123
+ {**{"source": e.source, "target": e.target}, **e.attributes()}
124
+ for e in graph.es
125
+ ]
126
+ )
127
+ return vertices, edges
128
+
129
+
130
+ def create_induced_subgraph(
131
+ graph: ig.Graph,
132
+ vertices: Optional[list[str]] = None,
133
+ n_vertices: int = 5000,
134
+ ) -> ig.Graph:
135
+ """
136
+ Create a subgraph from an igraph including a set of vertices and their connections.
137
+
138
+ Parameters
139
+ ----------
140
+ graph : ig.Graph
141
+ The input network.
142
+ vertices : list, optional
143
+ List of vertex names to include. If None, a random sample is selected.
144
+ n_vertices : int, optional
145
+ Number of vertices to sample if `vertices` is None. Default is 5000.
146
+
147
+ Returns
148
+ -------
149
+ ig.Graph
150
+ The induced subgraph.
151
+ """
152
+ if vertices is not None:
153
+ selected_vertices = vertices
154
+ else:
155
+ # Assume vertices have a 'name' attribute, fallback to indices
156
+ if "name" in graph.vs.attributes():
157
+ vertex_names = graph.vs["name"]
158
+ else:
159
+ vertex_names = list(range(graph.vcount()))
160
+ selected_vertices = random.sample(
161
+ vertex_names, min(n_vertices, len(vertex_names))
162
+ )
163
+
164
+ subgraph = graph.induced_subgraph(selected_vertices)
165
+ return subgraph
166
+
167
+
168
+ def validate_edge_attributes(graph: ig.Graph, edge_attributes: list[str]) -> None:
169
+ """
170
+ Validate that all required edge attributes exist in an igraph.
171
+
172
+ Parameters
173
+ ----------
174
+ graph : ig.Graph
175
+ The network.
176
+ edge_attributes : list of str
177
+ List of edge attribute names to check.
178
+
179
+ Returns
180
+ -------
181
+ None
182
+
183
+ Raises
184
+ ------
185
+ TypeError
186
+ If "edge_attributes" is not a list or str.
187
+ ValueError
188
+ If any required edge attribute is missing from the graph.
189
+ """
190
+ if isinstance(edge_attributes, list):
191
+ attrs = edge_attributes
192
+ elif isinstance(edge_attributes, str):
193
+ attrs = [edge_attributes]
194
+ else:
195
+ raise TypeError('"edge_attributes" must be a list or str')
196
+
197
+ available_attributes = graph.es[0].attributes().keys() if graph.ecount() > 0 else []
198
+ missing_attributes = set(attrs).difference(available_attributes)
199
+ n_missing_attrs = len(missing_attributes)
200
+
201
+ if n_missing_attrs > 0:
202
+ raise ValueError(
203
+ f"{n_missing_attrs} edge attributes were missing ({', '.join(missing_attributes)}). "
204
+ f"The available edge attributes are {', '.join(available_attributes)}"
205
+ )
206
+
207
+ return None
208
+
209
+
210
+ def validate_vertex_attributes(graph: ig.Graph, vertex_attributes: list[str]) -> None:
211
+ """
212
+ Validate that all required vertex attributes exist in an igraph.
213
+
214
+ Parameters
215
+ ----------
216
+ graph : ig.Graph
217
+ The network.
218
+ vertex_attributes : list of str
219
+ List of vertex attribute names to check.
220
+
221
+ Returns
222
+ -------
223
+ None
224
+
225
+ Raises
226
+ ------
227
+ TypeError
228
+ If "vertex_attributes" is not a list or str.
229
+ ValueError
230
+ If any required vertex attribute is missing from the graph.
231
+ """
232
+ if isinstance(vertex_attributes, list):
233
+ attrs = vertex_attributes
234
+ elif isinstance(vertex_attributes, str):
235
+ attrs = [vertex_attributes]
236
+ else:
237
+ raise TypeError('"vertex_attributes" must be a list or str')
238
+
239
+ available_attributes = graph.vs[0].attributes().keys() if graph.vcount() > 0 else []
240
+ missing_attributes = set(attrs).difference(available_attributes)
241
+ n_missing_attrs = len(missing_attributes)
242
+
243
+ if n_missing_attrs > 0:
244
+ raise ValueError(
245
+ f"{n_missing_attrs} vertex attributes were missing ({', '.join(missing_attributes)}). "
246
+ f"The available vertex attributes are {', '.join(available_attributes)}"
247
+ )
248
+
249
+ return None
250
+
251
+
252
+ # Internal utility functions
253
+
254
+
255
+ def _get_top_n_idx(arr: Sequence, n: int, ascending: bool = False) -> Sequence[int]:
256
+ """Returns the indices of the top n values in an array
257
+
258
+ Args:
259
+ arr (Sequence): An array of values
260
+ n (int): The number of top values to return
261
+ ascending (bool, optional): Whether to return the top or bottom n values. Defaults to False.
262
+
263
+ Returns:
264
+ Sequence[int]: The indices of the top n values
265
+ """
266
+ order = np.argsort(arr)
267
+ if ascending:
268
+ return order[:n] # type: ignore
269
+ else:
270
+ return order[-n:][::-1] # type: ignore
271
+
272
+
273
+ def _get_top_n_objects(
274
+ object_vals: Sequence, objects: Sequence, n: int = 10, ascending: bool = False
275
+ ) -> list:
276
+ """Get the top N objects based on a ranking measure."""
277
+ idxs = _get_top_n_idx(object_vals, n, ascending=ascending)
278
+ top_objects = [objects[idx] for idx in idxs]
279
+ return top_objects
280
+
281
+
282
+ def _get_top_n_component_stats(
283
+ graph: ig.Graph,
284
+ components,
285
+ component_sizes: Sequence[int],
286
+ n: int = 10,
287
+ ascending: bool = False,
288
+ ) -> list[dict[str, Any]]:
289
+ """
290
+ Summarize the top N components' network properties.
291
+
292
+ Parameters
293
+ ----------
294
+ graph : ig.Graph
295
+ The network.
296
+ components : list
297
+ List of components (as lists of vertex indices).
298
+ component_sizes : Sequence[int]
299
+ Sizes of each component.
300
+ n : int, optional
301
+ Number of top components to return. Default is 10.
302
+ ascending : bool, optional
303
+ If True, return smallest components; otherwise, largest. Default is False.
304
+
305
+ Returns
306
+ -------
307
+ list of dict
308
+ Each dict contains:
309
+ - 'n': size of the component
310
+ - 'examples': up to 10 example vertex attribute dicts from the component
311
+ """
312
+ top_components = _get_top_n_objects(component_sizes, components, n, ascending)
313
+ top_component_stats = [
314
+ {"n": len(c), "examples": [graph.vs[n].attributes() for n in c[:10]]}
315
+ for c in top_components
316
+ ]
317
+ return top_component_stats
318
+
319
+
320
+ def _get_top_n_nodes(
321
+ graph: ig.Graph,
322
+ vals: Sequence,
323
+ val_name: str,
324
+ n: int = 10,
325
+ ascending: bool = False,
326
+ ) -> list[dict[str, Any]]:
327
+ """
328
+ Get the top N nodes by a node attribute.
329
+
330
+ Parameters
331
+ ----------
332
+ graph : ig.Graph
333
+ The network.
334
+ vals : Sequence
335
+ Sequence of node attribute values.
336
+ val_name : str
337
+ Name of the attribute.
338
+ n : int, optional
339
+ Number of top nodes to return. Default is 10.
340
+ ascending : bool, optional
341
+ If True, return nodes with smallest values; otherwise, largest. Default is False.
342
+
343
+ Returns
344
+ -------
345
+ list of dict
346
+ Each dict contains the value and the node's attributes.
347
+ """
348
+ top_idxs = _get_top_n_idx(vals, n, ascending=ascending)
349
+ top_node_attrs = [graph.vs[idx].attributes() for idx in top_idxs]
350
+ top_vals = [vals[idx] for idx in top_idxs]
351
+ return [{val_name: val, **node} for val, node in zip(top_vals, top_node_attrs)]
@@ -0,0 +1,354 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import logging
5
+ from typing import Any, Optional
6
+
7
+ import igraph as ig
8
+ import pandas as pd
9
+
10
+ from napistu.network.constants import (
11
+ NAPISTU_GRAPH_EDGES,
12
+ EDGE_REVERSAL_ATTRIBUTE_MAPPING,
13
+ EDGE_DIRECTION_MAPPING,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class NapistuGraph(ig.Graph):
20
+ """
21
+ A subclass of igraph.Graph with additional functionality for molecular network analysis.
22
+
23
+ This class extends igraph.Graph with domain-specific methods and metadata tracking
24
+ for biological pathway and molecular interaction networks. All standard igraph
25
+ methods are available, plus additional functionality for edge reversal and
26
+ metadata management.
27
+
28
+ Parameters
29
+ ----------
30
+ *args : tuple
31
+ Positional arguments passed to igraph.Graph constructor
32
+ **kwargs : dict
33
+ Keyword arguments passed to igraph.Graph constructor
34
+
35
+ Attributes
36
+ ----------
37
+ is_reversed : bool
38
+ Whether the graph edges have been reversed from their original direction
39
+ graph_type : str or None
40
+ Type of graph (e.g., 'bipartite', 'regulatory', 'surrogate')
41
+ weighting_strategy : str or None
42
+ Strategy used for edge weighting (e.g., 'topology', 'mixed', 'calibrated')
43
+
44
+ Methods
45
+ -------
46
+ from_igraph(graph, **metadata)
47
+ Create a NapistuGraph from an existing igraph.Graph
48
+ reverse_edges()
49
+ Reverse all edges in the graph in-place
50
+ set_metadata(**kwargs)
51
+ Set metadata for the graph in-place
52
+ get_metadata(key=None)
53
+ Get metadata from the graph
54
+ copy()
55
+ Create a deep copy of the NapistuGraph
56
+
57
+ Examples
58
+ --------
59
+ Create a NapistuGraph from scratch:
60
+
61
+ >>> ng = NapistuGraph(directed=True)
62
+ >>> ng.add_vertices(3)
63
+ >>> ng.add_edges([(0, 1), (1, 2)])
64
+
65
+ Convert from existing igraph:
66
+
67
+ >>> import igraph as ig
68
+ >>> g = ig.Graph.Erdos_Renyi(10, 0.3)
69
+ >>> ng = NapistuGraph.from_igraph(g, graph_type='random')
70
+
71
+ Reverse edges and check state:
72
+
73
+ >>> ng.reverse_edges()
74
+ >>> print(ng.is_reversed)
75
+ True
76
+
77
+ Set and retrieve metadata:
78
+
79
+ >>> ng.set_metadata(experiment_id='exp_001', date='2024-01-01')
80
+ >>> print(ng.get_metadata('experiment_id'))
81
+ 'exp_001'
82
+
83
+ Notes
84
+ -----
85
+ NapistuGraph inherits from igraph.Graph, so all standard igraph methods
86
+ (degree, shortest_paths, betweenness, etc.) are available. The additional
87
+ functionality is designed specifically for molecular network analysis.
88
+
89
+ Edge reversal swaps 'from'/'to' attributes, negates stoichiometry values,
90
+ and updates direction metadata according to predefined mapping rules.
91
+ """
92
+
93
+ def __init__(self, *args, **kwargs):
94
+ """
95
+ Initialize a NapistuGraph.
96
+
97
+ Accepts all the same arguments as igraph.Graph constructor.
98
+ """
99
+ super().__init__(*args, **kwargs)
100
+
101
+ # Initialize metadata
102
+ self._metadata = {
103
+ "is_reversed": False,
104
+ "graph_type": None,
105
+ "weighting_strategy": None,
106
+ "creation_params": {},
107
+ }
108
+
109
+ @classmethod
110
+ def from_igraph(cls, graph: ig.Graph, **metadata) -> "NapistuGraph":
111
+ """
112
+ Create a NapistuGraph from an existing igraph.Graph.
113
+
114
+ Parameters
115
+ ----------
116
+ graph : ig.Graph
117
+ The igraph to convert
118
+ **metadata : dict
119
+ Additional metadata to store with the graph
120
+
121
+ Returns
122
+ -------
123
+ NapistuGraph
124
+ A new NapistuGraph instance
125
+ """
126
+ # Create new instance with same structure
127
+ new_graph = cls(
128
+ n=graph.vcount(),
129
+ edges=[(e.source, e.target) for e in graph.es],
130
+ directed=graph.is_directed(),
131
+ )
132
+
133
+ # Copy all vertex attributes
134
+ for attr in graph.vs.attributes():
135
+ new_graph.vs[attr] = graph.vs[attr]
136
+
137
+ # Copy all edge attributes
138
+ for attr in graph.es.attributes():
139
+ new_graph.es[attr] = graph.es[attr]
140
+
141
+ # Copy graph attributes
142
+ for attr in graph.attributes():
143
+ new_graph[attr] = graph[attr]
144
+
145
+ # Set metadata
146
+ new_graph._metadata.update(metadata)
147
+
148
+ return new_graph
149
+
150
+ def reverse_edges(self) -> None:
151
+ """
152
+ Reverse all edges in the graph.
153
+
154
+ This swaps edge directions and updates all associated attributes
155
+ according to the edge reversal mapping utilities. Modifies the graph in-place.
156
+
157
+ Returns
158
+ -------
159
+ None
160
+ """
161
+ # Get current edge dataframe
162
+ edges_df = self.get_edge_dataframe()
163
+
164
+ # Apply systematic attribute swapping using utilities
165
+ reversed_edges_df = _apply_edge_reversal_mapping(edges_df)
166
+
167
+ # Handle special cases using utilities
168
+ reversed_edges_df = _handle_special_reversal_cases(reversed_edges_df)
169
+
170
+ # Update edge attributes
171
+ for attr in reversed_edges_df.columns:
172
+ if attr in self.es.attributes():
173
+ self.es[attr] = reversed_edges_df[attr].values
174
+
175
+ # Update metadata
176
+ self._metadata["is_reversed"] = not self._metadata["is_reversed"]
177
+
178
+ logger.info(
179
+ f"Reversed graph edges. Current state: reversed={self._metadata['is_reversed']}"
180
+ )
181
+
182
+ return None
183
+
184
+ @property
185
+ def is_reversed(self) -> bool:
186
+ """Check if the graph has been reversed."""
187
+ return self._metadata["is_reversed"]
188
+
189
+ @property
190
+ def graph_type(self) -> Optional[str]:
191
+ """Get the graph type (bipartite, regulatory, etc.)."""
192
+ return self._metadata["graph_type"]
193
+
194
+ @property
195
+ def weighting_strategy(self) -> Optional[str]:
196
+ """Get the weighting strategy used."""
197
+ return self._metadata["weighting_strategy"]
198
+
199
+ def set_metadata(self, **kwargs) -> None:
200
+ """
201
+ Set metadata for the graph.
202
+
203
+ Modifies the graph's metadata in-place.
204
+
205
+ Parameters
206
+ ----------
207
+ **kwargs : dict
208
+ Metadata key-value pairs to set
209
+ """
210
+ self._metadata.update(kwargs)
211
+
212
+ return None
213
+
214
+ def get_metadata(self, key: Optional[str] = None) -> Any:
215
+ """
216
+ Get metadata from the graph.
217
+
218
+ Parameters
219
+ ----------
220
+ key : str, optional
221
+ Specific metadata key to retrieve. If None, returns all metadata.
222
+
223
+ Returns
224
+ -------
225
+ Any
226
+ The requested metadata value, or all metadata if key is None
227
+ """
228
+ if key is None:
229
+ return self._metadata.copy()
230
+ return self._metadata.get(key)
231
+
232
+ def copy(self) -> "NapistuGraph":
233
+ """
234
+ Create a deep copy of the NapistuGraph.
235
+
236
+ Returns
237
+ -------
238
+ NapistuGraph
239
+ A deep copy of this graph including metadata
240
+ """
241
+ # Use igraph's copy method to get the graph structure and attributes
242
+ new_graph = super().copy()
243
+
244
+ # Convert to NapistuGraph and copy metadata
245
+ napistu_copy = NapistuGraph.from_igraph(new_graph)
246
+ napistu_copy._metadata = copy.deepcopy(self._metadata)
247
+
248
+ return napistu_copy
249
+
250
+ def __str__(self) -> str:
251
+ """String representation including metadata."""
252
+ base_str = super().__str__()
253
+ metadata_str = (
254
+ f"Reversed: {self.is_reversed}, "
255
+ f"Type: {self.graph_type}, "
256
+ f"Weighting: {self.weighting_strategy}"
257
+ )
258
+ return f"{base_str}\nNapistuGraph metadata: {metadata_str}"
259
+
260
+ def __repr__(self) -> str:
261
+ """Detailed representation."""
262
+ return self.__str__()
263
+
264
+
265
+ def _apply_edge_reversal_mapping(edges_df: pd.DataFrame) -> pd.DataFrame:
266
+ """
267
+ Apply systematic attribute mapping for edge reversal.
268
+
269
+ This function swaps paired attributes according to EDGE_REVERSAL_ATTRIBUTE_MAPPING.
270
+ For example, 'from' becomes 'to', 'weights' becomes 'upstream_weights', etc.
271
+
272
+ Parameters
273
+ ----------
274
+ edges_df : pd.DataFrame
275
+ Current edge attributes
276
+
277
+ Returns
278
+ -------
279
+ pd.DataFrame
280
+ Edge dataframe with swapped attributes
281
+
282
+ Warnings
283
+ --------
284
+ Logs warnings when expected attribute pairs are missing
285
+ """
286
+ # Find which attributes have pairs in the mapping
287
+ available_attrs = set(edges_df.columns)
288
+
289
+ # Find pairs where both attributes exist
290
+ valid_mapping = {}
291
+ missing_pairs = []
292
+
293
+ for source_attr, target_attr in EDGE_REVERSAL_ATTRIBUTE_MAPPING.items():
294
+ if source_attr in available_attrs:
295
+ if target_attr in available_attrs:
296
+ valid_mapping[source_attr] = target_attr
297
+ else:
298
+ missing_pairs.append(f"{source_attr} -> {target_attr}")
299
+
300
+ # Warn about attributes that can't be swapped
301
+ if missing_pairs:
302
+ logger.warning(
303
+ f"The following edge attributes cannot be swapped during reversal "
304
+ f"because their paired attribute is missing: {', '.join(missing_pairs)}"
305
+ )
306
+
307
+ return edges_df.rename(columns=valid_mapping)
308
+
309
+
310
+ def _handle_special_reversal_cases(edges_df: pd.DataFrame) -> pd.DataFrame:
311
+ """
312
+ Handle special cases that need more than simple attribute swapping.
313
+
314
+ This includes:
315
+ - Flipping stoichiometry signs (* -1)
316
+ - Mapping direction enums (forward <-> reverse)
317
+
318
+ Parameters
319
+ ----------
320
+ edges_df : pd.DataFrame
321
+ Edge dataframe after basic attribute swapping
322
+
323
+ Returns
324
+ -------
325
+ pd.DataFrame
326
+ Edge dataframe with special cases handled
327
+
328
+ Warnings
329
+ --------
330
+ Logs warnings when expected attributes are missing
331
+ """
332
+ result_df = edges_df.copy()
333
+
334
+ # Handle stoichiometry sign flip
335
+ if NAPISTU_GRAPH_EDGES.STOICHIOMETRY in result_df.columns:
336
+ result_df[NAPISTU_GRAPH_EDGES.STOICHIOMETRY] *= -1
337
+ else:
338
+ logger.warning(
339
+ f"Missing expected '{NAPISTU_GRAPH_EDGES.STOICHIOMETRY}' attribute during edge reversal. "
340
+ "Stoichiometry signs will not be flipped."
341
+ )
342
+
343
+ # Handle direction enum mapping
344
+ if NAPISTU_GRAPH_EDGES.DIRECTION in result_df.columns:
345
+ result_df[NAPISTU_GRAPH_EDGES.DIRECTION] = result_df[
346
+ NAPISTU_GRAPH_EDGES.DIRECTION
347
+ ].map(EDGE_DIRECTION_MAPPING)
348
+ else:
349
+ logger.warning(
350
+ f"Missing expected '{NAPISTU_GRAPH_EDGES.DIRECTION}' attribute during edge reversal. "
351
+ "Direction metadata will not be updated."
352
+ )
353
+
354
+ return result_df