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 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"))