annnet 0.0.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.
- annnet/__init__.py +89 -0
- annnet/_metadata.py +71 -0
- annnet/_version.py +1 -0
- annnet/adapters/GraphDir_Parquet_adapter.py +292 -0
- annnet/adapters/GraphML_adapter.py +115 -0
- annnet/adapters/SIF_adapter.py +502 -0
- annnet/adapters/__init__.py +44 -0
- annnet/adapters/_base.py +10 -0
- annnet/adapters/_proxy.py +18 -0
- annnet/adapters/_utils.py +64 -0
- annnet/adapters/dataframe_adapter.py +449 -0
- annnet/adapters/graphtool_adapter.py +1 -0
- annnet/adapters/igraph_adapter.py +1333 -0
- annnet/adapters/json_adapter.py +441 -0
- annnet/adapters/manager.py +57 -0
- annnet/adapters/networkx_adapter.py +1082 -0
- annnet/adapters/sbml_adapter.py +199 -0
- annnet/algorithms/__init__.py +3 -0
- annnet/algorithms/traversal.py +1 -0
- annnet/core/__init__.py +3 -0
- annnet/core/graph.py +7724 -0
- annnet/demo/__init__.py +1 -0
- annnet/demo/demo.py +84 -0
- annnet/io/__init__.py +4 -0
- annnet/io/csv.py +939 -0
- annnet/io/excel.py +96 -0
- annnet/io/io_annnet.py +539 -0
- annnet/io/json.py +0 -0
- annnet/io/registry.py +1 -0
- annnet/io/utils.py +1 -0
- annnet/utils/config.py +1 -0
- annnet/utils/plotting.py +544 -0
- annnet/utils/typing.py +120 -0
- annnet/utils/validation.py +67 -0
- annnet-0.0.1.dist-info/METADATA +241 -0
- annnet-0.0.1.dist-info/RECORD +39 -0
- annnet-0.0.1.dist-info/WHEEL +4 -0
- annnet-0.0.1.dist-info/entry_points.txt +3 -0
- annnet-0.0.1.dist-info/licenses/LICENSE +28 -0
annnet/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# annnet/__init__.py
|
|
2
|
+
"""annnet: single import, full API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from importlib.metadata import PackageNotFoundError
|
|
8
|
+
from importlib.metadata import version as _pkg_version
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Lazily exposed submodules (imported on first attribute access)
|
|
12
|
+
_lazy_submodules = {
|
|
13
|
+
# namespaces
|
|
14
|
+
"adapters": "annnet.adapters",
|
|
15
|
+
"io": "annnet.io",
|
|
16
|
+
"core": "annnet.core",
|
|
17
|
+
"algorithms": "annnet.algorithms",
|
|
18
|
+
"utils": "annnet.utils",
|
|
19
|
+
# adapter modules (direct convenience)
|
|
20
|
+
"graphml": "annnet.adapters.GraphML_adapter",
|
|
21
|
+
"sif": "annnet.adapters.SIF_adapter",
|
|
22
|
+
"sbml": "annnet.adapters.sbml_adapter",
|
|
23
|
+
"parquet": "annnet.adapters.GraphDir_Parquet_adapter",
|
|
24
|
+
"networkx": "annnet.adapters.networkx_adapter",
|
|
25
|
+
"igraph": "annnet.adapters.igraph_adapter",
|
|
26
|
+
"jsonio": "annnet.adapters.json_adapter",
|
|
27
|
+
"dataframe": "annnet.adapters.dataframe_adapter",
|
|
28
|
+
# io modules
|
|
29
|
+
"csvio": "annnet.io.csv",
|
|
30
|
+
"excelio": "annnet.io.excel",
|
|
31
|
+
"annnet": "annnet.io.io_annnet",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Curated top-level symbols (lazy). name -> (module, attribute)
|
|
35
|
+
_lazy_symbols: dict[str, tuple[str, str]] = {
|
|
36
|
+
# Core
|
|
37
|
+
"Graph": ("annnet.core.graph", "Graph"),
|
|
38
|
+
"graph": ("annnet.core.graph", "Graph"),
|
|
39
|
+
# Stdlib JSON I/O
|
|
40
|
+
"to_json": ("annnet.adapters.json_adapter", "to_json"),
|
|
41
|
+
"from_json": ("annnet.adapters.json_adapter", "from_json"),
|
|
42
|
+
# NetworkX adapter (optional dependency)
|
|
43
|
+
"to_nx": ("annnet.adapters.networkx_adapter", "to_nx"),
|
|
44
|
+
"from_nx": ("annnet.adapters.networkx_adapter", "from_nx"),
|
|
45
|
+
"from_nx_only": ("annnet.adapters.networkx_adapter", "from_nx_only"),
|
|
46
|
+
# GraphML
|
|
47
|
+
"to_graphml": ("annnet.adapters.GraphML_adapter", "to_graphml"),
|
|
48
|
+
"from_graphml": ("annnet.adapters.GraphML_adapter", "from_graphml"),
|
|
49
|
+
# SIF
|
|
50
|
+
"to_sif": ("annnet.adapters.SIF_adapter", "to_sif"),
|
|
51
|
+
"from_sif": ("annnet.adapters.SIF_adapter", "from_sif"),
|
|
52
|
+
# SBML (common direction)
|
|
53
|
+
"from_sbml": ("annnet.adapters.sbml_adapter", "from_sbml"),
|
|
54
|
+
# If you add export later: "to_sbml": ("annnet.adapters.sbml_adapter", "to_sbml"),
|
|
55
|
+
# Parquet GraphDir
|
|
56
|
+
"write_parquet_graphdir": (
|
|
57
|
+
"annnet.adapters.GraphDir_Parquet_adapter",
|
|
58
|
+
"write_parquet_graphdir",
|
|
59
|
+
),
|
|
60
|
+
"read_parquet_graphdir": (
|
|
61
|
+
"annnet.adapters.GraphDir_Parquet_adapter",
|
|
62
|
+
"read_parquet_graphdir",
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
__all__ = sorted(set(list(_lazy_submodules) + list(_lazy_symbols)))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str) -> Any: # PEP 562: lazy attribute resolution
|
|
70
|
+
if name in _lazy_submodules:
|
|
71
|
+
return import_module(_lazy_submodules[name])
|
|
72
|
+
if name in _lazy_symbols:
|
|
73
|
+
mod, attr = _lazy_symbols[name]
|
|
74
|
+
return getattr(import_module(mod), attr)
|
|
75
|
+
raise AttributeError(name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def __dir__() -> list[str]:
|
|
79
|
+
return sorted(list(globals().keys()) + list(__all__))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Version: prefer internal, then fall back to distribution metadata
|
|
83
|
+
try:
|
|
84
|
+
from ._version import __version__ # type: ignore
|
|
85
|
+
except Exception:
|
|
86
|
+
try:
|
|
87
|
+
__version__ = _pkg_version("annnet")
|
|
88
|
+
except PackageNotFoundError:
|
|
89
|
+
__version__ = "0.0.0"
|
annnet/_metadata.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# This file is part of the `annnet` Python module
|
|
5
|
+
#
|
|
6
|
+
# Copyright 2025
|
|
7
|
+
# Heidelberg University Hospital
|
|
8
|
+
#
|
|
9
|
+
# File author(s): Daniele Bottazzi (daniele.bottazzi@mail.polimi.it)
|
|
10
|
+
#
|
|
11
|
+
# Distributed under the MIT license
|
|
12
|
+
# See the file `LICENSE` or read a copy at
|
|
13
|
+
# https://opensource.org/license/mit
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""Package metadata (version, authors, etc)."""
|
|
17
|
+
|
|
18
|
+
__all__ = ["get_metadata"]
|
|
19
|
+
|
|
20
|
+
import importlib.metadata
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
|
|
24
|
+
import toml
|
|
25
|
+
|
|
26
|
+
_VERSION = "0.0.1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_metadata() -> dict:
|
|
30
|
+
"""Basic package metadata.
|
|
31
|
+
|
|
32
|
+
Retrieves package metadata from the current project directory or from
|
|
33
|
+
the installed package.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
here = pathlib.Path(__file__).parent
|
|
37
|
+
pyproj_toml = "pyproject.toml"
|
|
38
|
+
meta = {}
|
|
39
|
+
|
|
40
|
+
for project_dir in (here, here.parent):
|
|
41
|
+
toml_path = str(project_dir.joinpath(pyproj_toml).absolute())
|
|
42
|
+
|
|
43
|
+
if os.path.exists(toml_path):
|
|
44
|
+
pyproject = toml.load(toml_path)
|
|
45
|
+
|
|
46
|
+
meta = {
|
|
47
|
+
"name": pyproject["tool"]["poetry"]["name"],
|
|
48
|
+
"version": pyproject["tool"]["poetry"]["version"],
|
|
49
|
+
"author": pyproject["tool"]["poetry"]["authors"],
|
|
50
|
+
"license": pyproject["tool"]["poetry"]["license"],
|
|
51
|
+
"full_metadata": pyproject,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
if not meta:
|
|
57
|
+
try:
|
|
58
|
+
meta = {k.lower(): v for k, v in importlib.metadata.metadata(here.name).items()}
|
|
59
|
+
|
|
60
|
+
except importlib.metadata.PackageNotFoundError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
meta["version"] = meta.get("version", None) or _VERSION
|
|
64
|
+
|
|
65
|
+
return meta
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
metadata = get_metadata()
|
|
69
|
+
__version__ = metadata.get("version", None)
|
|
70
|
+
__author__ = metadata.get("author", None)
|
|
71
|
+
__license__ = "MIT"
|
annnet/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..core.graph import Graph
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _strip_nulls(d: dict):
|
|
15
|
+
# remove keys whose value is None or NaN
|
|
16
|
+
clean = {}
|
|
17
|
+
for k, v in list(d.items()):
|
|
18
|
+
if v is None:
|
|
19
|
+
continue
|
|
20
|
+
if isinstance(v, float) and math.isnan(v):
|
|
21
|
+
continue
|
|
22
|
+
clean[k] = v
|
|
23
|
+
return clean
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_directed_eid(graph, eid):
|
|
27
|
+
"""Best-effort directedness probe; default True."""
|
|
28
|
+
try:
|
|
29
|
+
return bool(getattr(graph, "edge_directed", {}).get(eid, True))
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
val = graph.get_edge_attribute(eid, "directed")
|
|
34
|
+
return bool(val) if val is not None else True
|
|
35
|
+
except Exception:
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _coerce_coeff_mapping(val):
|
|
40
|
+
"""Normalize various serialized forms into {vertex: {__value: float}|float}.
|
|
41
|
+
Accepts dict | list | list-of-dicts | list-of-pairs | JSON string.
|
|
42
|
+
"""
|
|
43
|
+
if val is None:
|
|
44
|
+
return {}
|
|
45
|
+
if isinstance(val, str):
|
|
46
|
+
try:
|
|
47
|
+
return _coerce_coeff_mapping(json.loads(val))
|
|
48
|
+
except Exception:
|
|
49
|
+
return {}
|
|
50
|
+
if isinstance(val, dict):
|
|
51
|
+
return val
|
|
52
|
+
if isinstance(val, (list, tuple)):
|
|
53
|
+
out = {}
|
|
54
|
+
for item in val:
|
|
55
|
+
if isinstance(item, dict):
|
|
56
|
+
if "vertex" in item and "__value" in item:
|
|
57
|
+
out[item["vertex"]] = {"__value": item["__value"]}
|
|
58
|
+
else:
|
|
59
|
+
for k, v in item.items():
|
|
60
|
+
out[k] = v
|
|
61
|
+
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
|
62
|
+
k, v = item
|
|
63
|
+
out[k] = v
|
|
64
|
+
return out
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _endpoint_coeff_map(edge_attrs, private_key, endpoint_set):
|
|
69
|
+
"""Return {vertex: float_coeff} for the given endpoint_set.
|
|
70
|
+
Reads from edge_attrs[private_key] which may be serialized in multiple shapes.
|
|
71
|
+
Missing endpoints default to 1.0.
|
|
72
|
+
"""
|
|
73
|
+
raw_mapping = (edge_attrs or {}).get(private_key, {})
|
|
74
|
+
mapping = _coerce_coeff_mapping(raw_mapping)
|
|
75
|
+
endpoints = list(endpoint_set or mapping.keys())
|
|
76
|
+
out = {}
|
|
77
|
+
for u in endpoints:
|
|
78
|
+
val = mapping.get(u, 1.0)
|
|
79
|
+
if isinstance(val, dict):
|
|
80
|
+
val = val.get("__value", 1.0)
|
|
81
|
+
try:
|
|
82
|
+
out[u] = float(val)
|
|
83
|
+
except Exception:
|
|
84
|
+
out[u] = 1.0
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_parquet_graphdir(graph: Graph, path):
|
|
89
|
+
"""Write lossless GraphDir:
|
|
90
|
+
vertices.parquet, edges.parquet, layers.parquet, edge_layers.parquet, manifest.json
|
|
91
|
+
Wide tables (attrs as columns). Hyperedges stored with 'kind' and head/tail/members lists.
|
|
92
|
+
"""
|
|
93
|
+
path = Path(path)
|
|
94
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
# vertices
|
|
97
|
+
v_rows = []
|
|
98
|
+
for v in graph.vertices():
|
|
99
|
+
row = {"vertex_id": v}
|
|
100
|
+
try:
|
|
101
|
+
attrs = graph.vertex_attributes.filter(
|
|
102
|
+
graph.vertex_attributes["vertex_id"] == v
|
|
103
|
+
).to_dicts()
|
|
104
|
+
if attrs:
|
|
105
|
+
d = dict(attrs[0])
|
|
106
|
+
d.pop("vertex_id", None)
|
|
107
|
+
row.update(d)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
v_rows.append(row)
|
|
111
|
+
pl.DataFrame(v_rows).write_parquet(path / "vertices.parquet", compression="zstd")
|
|
112
|
+
|
|
113
|
+
# edges
|
|
114
|
+
e_rows = []
|
|
115
|
+
for eidx in range(graph.number_of_edges()):
|
|
116
|
+
eid = graph.idx_to_edge[eidx]
|
|
117
|
+
S, T = graph.get_edge(eidx)
|
|
118
|
+
kind = graph.edge_kind.get(eid)
|
|
119
|
+
row = {
|
|
120
|
+
"edge_id": eid,
|
|
121
|
+
"kind": ("hyper" if kind == "hyper" else "binary"),
|
|
122
|
+
"directed": bool(_is_directed_eid(graph, eid)),
|
|
123
|
+
"weight": float(getattr(graph, "edge_weights", {}).get(eid, 1.0)),
|
|
124
|
+
}
|
|
125
|
+
try:
|
|
126
|
+
attrs = graph.edge_attributes.filter(graph.edge_attributes["edge_id"] == eid).to_dicts()
|
|
127
|
+
if attrs:
|
|
128
|
+
d = dict(attrs[0])
|
|
129
|
+
d.pop("edge_id", None)
|
|
130
|
+
row.update(d)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
if row["kind"] == "binary":
|
|
135
|
+
members = S | T
|
|
136
|
+
if len(members) == 1:
|
|
137
|
+
u = next(iter(members))
|
|
138
|
+
v = u
|
|
139
|
+
else:
|
|
140
|
+
u, v = sorted(members)
|
|
141
|
+
row.update({"source": u, "target": v})
|
|
142
|
+
else:
|
|
143
|
+
head_map = _endpoint_coeff_map(row, "__source_attr", S) or dict.fromkeys(S or [], 1.0)
|
|
144
|
+
tail_map = _endpoint_coeff_map(row, "__target_attr", T) or dict.fromkeys(T or [], 1.0)
|
|
145
|
+
row.update(
|
|
146
|
+
{
|
|
147
|
+
"head": list(head_map.keys()),
|
|
148
|
+
"tail": list(tail_map.keys()),
|
|
149
|
+
"members": list({*head_map.keys(), *tail_map.keys()})
|
|
150
|
+
if not row["directed"]
|
|
151
|
+
else None,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
e_rows.append(row)
|
|
155
|
+
pl.DataFrame(e_rows).write_parquet(path / "edges.parquet", compression="zstd")
|
|
156
|
+
|
|
157
|
+
# layers
|
|
158
|
+
L = []
|
|
159
|
+
try:
|
|
160
|
+
for lid in graph.list_layers(include_default=True):
|
|
161
|
+
L.append({"layer_id": lid})
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
pl.DataFrame(L).write_parquet(path / "layers.parquet", compression="zstd")
|
|
165
|
+
|
|
166
|
+
# edge_layers
|
|
167
|
+
EL = []
|
|
168
|
+
try:
|
|
169
|
+
for lid in graph.list_layers(include_default=True):
|
|
170
|
+
for eid in graph.get_layer_edges(lid):
|
|
171
|
+
rec = {"layer_id": lid, "edge_id": eid}
|
|
172
|
+
try:
|
|
173
|
+
w = graph.get_edge_layer_attr(lid, eid, "weight", default=None)
|
|
174
|
+
except Exception:
|
|
175
|
+
try:
|
|
176
|
+
w = graph.get_edge_layer_attr(lid, eid, "weight")
|
|
177
|
+
except Exception:
|
|
178
|
+
w = None
|
|
179
|
+
if w is not None:
|
|
180
|
+
rec["weight"] = float(w)
|
|
181
|
+
EL.append(rec)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
pl.DataFrame(EL).write_parquet(path / "edge_layers.parquet", compression="zstd")
|
|
185
|
+
|
|
186
|
+
# manifest.json (tiny)
|
|
187
|
+
manifest = {
|
|
188
|
+
"format_version": 1,
|
|
189
|
+
"counts": {"V": len(v_rows), "E": len(e_rows), "layers": len(L)},
|
|
190
|
+
"schema": {"edges.kind": ["binary", "hyper"]},
|
|
191
|
+
"provenance": {"package": "annnet"},
|
|
192
|
+
}
|
|
193
|
+
(path / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def read_parquet_graphdir(path) -> Graph:
|
|
197
|
+
"""Read GraphDir (lossless vs write_parquet_graphdir())."""
|
|
198
|
+
from ..core.graph import Graph
|
|
199
|
+
|
|
200
|
+
path = Path(path)
|
|
201
|
+
V = pl.read_parquet(path / "vertices.parquet")
|
|
202
|
+
E = pl.read_parquet(path / "edges.parquet")
|
|
203
|
+
L = (
|
|
204
|
+
pl.read_parquet(path / "layers.parquet", use_pyarrow=True)
|
|
205
|
+
if (path / "layers.parquet").exists()
|
|
206
|
+
else pl.DataFrame([])
|
|
207
|
+
)
|
|
208
|
+
EL = (
|
|
209
|
+
pl.read_parquet(path / "edge_layers.parquet", use_pyarrow=True)
|
|
210
|
+
if (path / "edge_layers.parquet").exists()
|
|
211
|
+
else pl.DataFrame([])
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
H = Graph()
|
|
215
|
+
# vertices
|
|
216
|
+
for rec in V.to_dicts():
|
|
217
|
+
vid = rec.pop("vertex_id")
|
|
218
|
+
H.add_vertex(vid)
|
|
219
|
+
if rec:
|
|
220
|
+
H.set_vertex_attrs(vid, **rec)
|
|
221
|
+
|
|
222
|
+
# edges
|
|
223
|
+
for rec in E.to_dicts():
|
|
224
|
+
eid = rec.pop("edge_id")
|
|
225
|
+
kind = rec.pop("kind")
|
|
226
|
+
directed = bool(rec.pop("directed", True))
|
|
227
|
+
w = float(rec.pop("weight", 1.0))
|
|
228
|
+
|
|
229
|
+
if kind == "binary":
|
|
230
|
+
# take endpoints and drop hyper-only columns if present
|
|
231
|
+
u = rec.pop("source", None)
|
|
232
|
+
v = rec.pop("target", None)
|
|
233
|
+
# these can exist as NULL because the DF is wide
|
|
234
|
+
rec.pop("head", None)
|
|
235
|
+
rec.pop("tail", None)
|
|
236
|
+
rec.pop("members", None)
|
|
237
|
+
|
|
238
|
+
if u is None or v is None:
|
|
239
|
+
# defensive: reconstruct from any leftover endpoint list (rare)
|
|
240
|
+
# if nothing found, skip cleanly
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
H.add_edge(u, v, edge_id=eid, edge_directed=directed)
|
|
244
|
+
|
|
245
|
+
else: # hyper
|
|
246
|
+
head = rec.pop("head", None) or []
|
|
247
|
+
tail = rec.pop("tail", None) or []
|
|
248
|
+
members = rec.pop("members", None) or []
|
|
249
|
+
if directed:
|
|
250
|
+
H.add_hyperedge(head=list(head), tail=list(tail), edge_id=eid, edge_directed=True)
|
|
251
|
+
else:
|
|
252
|
+
if not members:
|
|
253
|
+
members = list(set(head) | set(tail))
|
|
254
|
+
H.add_hyperedge(members=list(members), edge_id=eid, edge_directed=False)
|
|
255
|
+
|
|
256
|
+
# weight
|
|
257
|
+
H.edge_weights[eid] = w
|
|
258
|
+
|
|
259
|
+
# drop schema-nulls before attaching attrs (avoids head=None, etc.)
|
|
260
|
+
rec = _strip_nulls(rec)
|
|
261
|
+
if rec:
|
|
262
|
+
H.set_edge_attrs(eid, **rec)
|
|
263
|
+
|
|
264
|
+
# layers
|
|
265
|
+
for rec in L.to_dicts():
|
|
266
|
+
lid = rec.get("layer_id")
|
|
267
|
+
try:
|
|
268
|
+
if lid not in set(H.list_layers(include_default=True)):
|
|
269
|
+
H.add_layer(lid)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
# edge_layers
|
|
274
|
+
for rec in EL.to_dicts():
|
|
275
|
+
lid = rec.get("layer_id")
|
|
276
|
+
eid = rec.get("edge_id")
|
|
277
|
+
if lid is None or eid is None:
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
H.add_edge_to_layer(lid, eid)
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
if "weight" in rec:
|
|
284
|
+
try:
|
|
285
|
+
H.set_edge_layer_attrs(lid, eid, weight=float(rec["weight"]))
|
|
286
|
+
except Exception:
|
|
287
|
+
try:
|
|
288
|
+
H.set_edge_layer_attr(lid, eid, "weight", float(rec["weight"]))
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
return H
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..core.graph import Graph
|
|
13
|
+
from .networkx_adapter import from_nx, from_nx_only, to_nx
|
|
14
|
+
|
|
15
|
+
_BOOL = {"true": True, "false": False}
|
|
16
|
+
_NUM_RE = re.compile(r"^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sanitize_graphml_inplace(G):
|
|
20
|
+
"""Remove values GraphML can't encode and normalize types before write."""
|
|
21
|
+
|
|
22
|
+
def clean_dict(d: dict):
|
|
23
|
+
for k in list(d.keys()):
|
|
24
|
+
v = d[k]
|
|
25
|
+
if v is None or (isinstance(v, float) and math.isnan(v)):
|
|
26
|
+
del d[k]
|
|
27
|
+
continue
|
|
28
|
+
if isinstance(v, (str, int, float, bool)):
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
d[k] = json.dumps(v, ensure_ascii=False)
|
|
32
|
+
except Exception:
|
|
33
|
+
d[k] = str(v)
|
|
34
|
+
|
|
35
|
+
clean_dict(G.graph)
|
|
36
|
+
for _, data in G.nodes(data=True):
|
|
37
|
+
clean_dict(data)
|
|
38
|
+
for _, _, data in G.edges(data=True):
|
|
39
|
+
clean_dict(data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _restore_types_graphml_inplace(G):
|
|
43
|
+
"""Heuristically restore types after read_graphml."""
|
|
44
|
+
|
|
45
|
+
def coerce(s):
|
|
46
|
+
if not isinstance(s, str):
|
|
47
|
+
return s
|
|
48
|
+
t = s.strip()
|
|
49
|
+
low = t.lower()
|
|
50
|
+
if low in _BOOL:
|
|
51
|
+
return _BOOL[low]
|
|
52
|
+
if (t.startswith("{") and t.endswith("}")) or (t.startswith("[") and t.endswith("]")):
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(t)
|
|
55
|
+
except Exception:
|
|
56
|
+
return t
|
|
57
|
+
if _NUM_RE.match(t):
|
|
58
|
+
try:
|
|
59
|
+
if "." not in t and "e" not in low:
|
|
60
|
+
return int(t)
|
|
61
|
+
return float(t)
|
|
62
|
+
except Exception:
|
|
63
|
+
return t
|
|
64
|
+
return t
|
|
65
|
+
|
|
66
|
+
def fix_dict(d: dict):
|
|
67
|
+
for k in list(d.keys()):
|
|
68
|
+
d[k] = coerce(d[k])
|
|
69
|
+
|
|
70
|
+
fix_dict(G.graph)
|
|
71
|
+
for _, data in G.nodes(data=True):
|
|
72
|
+
fix_dict(data)
|
|
73
|
+
for _, _, data in G.edges(data=True):
|
|
74
|
+
fix_dict(data)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def to_graphml(graph, path, *, directed=True, hyperedge_mode="reify", public_only=False):
|
|
78
|
+
"""Export via NetworkX with reified hyperedges; sanitize attrs for GraphML.
|
|
79
|
+
Also writes a sidecar manifest for lossless re-import.
|
|
80
|
+
"""
|
|
81
|
+
G, manifest = to_nx(
|
|
82
|
+
graph, directed=directed, hyperedge_mode=hyperedge_mode, public_only=public_only
|
|
83
|
+
)
|
|
84
|
+
_sanitize_graphml_inplace(G)
|
|
85
|
+
nx.write_graphml(G, path)
|
|
86
|
+
# sidecar manifest
|
|
87
|
+
mpath = str(path) + ".manifest.json"
|
|
88
|
+
with open(mpath, "w", encoding="utf-8") as f:
|
|
89
|
+
json.dump(manifest, f, ensure_ascii=False)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def from_graphml(path, *, hyperedge="reified"):
|
|
93
|
+
"""Import via NetworkX; if a sidecar manifest is present, use it as SSOT.
|
|
94
|
+
Otherwise, fall back to from_nx_only with type restoration.
|
|
95
|
+
"""
|
|
96
|
+
G = nx.read_graphml(path)
|
|
97
|
+
_restore_types_graphml_inplace(G)
|
|
98
|
+
mpath = str(path) + ".manifest.json"
|
|
99
|
+
if os.path.exists(mpath):
|
|
100
|
+
with open(mpath, encoding="utf-8") as f:
|
|
101
|
+
manifest = json.load(f)
|
|
102
|
+
# Rebuild exactly from the manifest (lossless), ignoring GraphML-added noise
|
|
103
|
+
return from_nx(G, manifest, hyperedge=("reified" if hyperedge == "reified" else "none"))
|
|
104
|
+
# Fallback (no manifest available)
|
|
105
|
+
return from_nx_only(G, hyperedge=("reified" if hyperedge == "reified" else "none"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def to_gexf(graph: Graph, path, *, directed=True, hyperedge_mode="reify", public_only=False):
|
|
109
|
+
G, _m = to_nx(graph, directed=directed, hyperedge_mode=hyperedge_mode, public_only=public_only)
|
|
110
|
+
nx.write_gexf(G, path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def from_gexf(path, *, hyperedge="reified") -> Graph:
|
|
114
|
+
G = nx.read_gexf(path)
|
|
115
|
+
return from_nx_only(G, hyperedge=("reified" if hyperedge == "reified" else "none"))
|