nx-ladybug 0.1.0__tar.gz

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.
@@ -0,0 +1,65 @@
1
+ nx-arrow is distributed under the terms of either the MIT license or the
2
+ 3-clause BSD license, at your option (SPDX: BSD-3-Clause OR MIT).
3
+
4
+ MIT License
5
+
6
+ Copyright (c) 2025-2026 Ladybug Memory Inc.
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+
26
+ ---
27
+
28
+ Portions of this software are derived from NetworkX
29
+ (https://networkx.org/) and remain under its 3-clause BSD license:
30
+
31
+ ::
32
+
33
+ Copyright (c) 2004-2026, NetworkX Developers
34
+ Aric Hagberg <hagberg@lanl.gov>
35
+ Dan Schult <dschult@colgate.edu>
36
+ Pieter Swart <swart@lanl.gov>
37
+ All rights reserved.
38
+
39
+ Redistribution and use in source and binary forms, with or without
40
+ modification, are permitted provided that the following conditions are
41
+ met:
42
+
43
+ * Redistributions of source code must retain the above copyright
44
+ notice, this list of conditions and the following disclaimer.
45
+
46
+ * Redistributions in binary form must reproduce the above
47
+ copyright notice, this list of conditions and the following
48
+ disclaimer in the documentation and/or other materials provided
49
+ with the distribution.
50
+
51
+ * Neither the name of the NetworkX Developers nor the names of its
52
+ contributors may be used to endorse or promote products derived
53
+ from this software without specific prior written permission.
54
+
55
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
56
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
57
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
58
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
59
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
60
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
61
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
62
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
63
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
64
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
65
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: nx-ladybug
3
+ Version: 0.1.0
4
+ Summary: LadybugDB storage provider for nx-arrow columnar graphs
5
+ License-Expression: BSD-3-Clause OR MIT
6
+ Project-URL: Homepage, https://github.com/Ladybug-Memory/nx-ladybug
7
+ Project-URL: Source Code, https://github.com/Ladybug-Memory/nx-ladybug
8
+ Keywords: networks,graph theory,apache arrow,networkx,columnar,ladybugdb
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
16
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: nx-arrow>=0.1.0
21
+ Requires-Dist: ladybug>=0.20
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # nx-ladybug
27
+
28
+ LadybugDB storage provider for
29
+ [nx-arrow](https://github.com/Ladybug-Memory/nx-arrow) columnar graphs.
30
+ `nx-arrow` owns the Arrow-backed graph and the `arrow` NetworkX backend;
31
+ this package only adds persistence into
32
+ [LadybugDB](https://ladybugdb.com/). Build and analyze graphs with
33
+ `nx-arrow` everywhere — the only `nxladybug` import is the persist step.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install nx-arrow nx-ladybug
39
+ ```
40
+
41
+ For development:
42
+
43
+ ```bash
44
+ pip install -e ".[test]"
45
+ ```
46
+
47
+ ## Use
48
+
49
+ ```python
50
+ import networkx as nx
51
+ from nxladybug import LadybugStorageProvider, persist
52
+
53
+ g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
54
+
55
+ nx.out_degree_centrality(g, backend="arrow")
56
+ list(nx.topological_sort(g, backend="arrow"))
57
+ ```
58
+
59
+ ## Persist
60
+
61
+ `persist()` bulk-loads per-type frames through a storage provider — pick
62
+ LadybugDB or Parquet:
63
+
64
+ ```python
65
+ from nxarrow.parquet import ParquetStorageProvider
66
+ from nxladybug import LadybugStorageProvider, persist
67
+
68
+ # LadybugDB: one unified node table (NxNode) + one relationship table
69
+ # (NxEdge) + a meta table (NxMeta). Each frame is registered as an Arrow
70
+ # staging table and bulk-copied natively — COPY <table> FROM (MATCH ...) —
71
+ # so endpoints resolve through the node primary key inside the copy.
72
+ provider = LadybugStorageProvider("graph.lbdb") # or ":memory:"
73
+ persist(g, provider, graph_attrs=g.graph)
74
+ restored = provider.load_graph() # full round-trip
75
+
76
+ # ... or Parquet: one file per type plus a manifest.json
77
+ parquet = ParquetStorageProvider("graph_store")
78
+ persist(g, parquet, graph_attrs=g.graph)
79
+ restored = parquet.load_graph()
80
+ ```
81
+
82
+ The Ladybug provider drops and recreates its tables on every persist, so
83
+ re-persisting a smaller graph leaves no stale rows. `:memory:` databases
84
+ live as long as the provider; file paths reopen with
85
+ `LadybugStorageProvider.load(path)`.
86
+
87
+ Custom stores implement `store_nodes(frames)` / `store_edges(frames)` and
88
+ register with `nxarrow.register_storage_provider`.
89
+
90
+ ## Develop
91
+
92
+ ```bash
93
+ pytest
94
+ ```
@@ -0,0 +1,69 @@
1
+ # nx-ladybug
2
+
3
+ LadybugDB storage provider for
4
+ [nx-arrow](https://github.com/Ladybug-Memory/nx-arrow) columnar graphs.
5
+ `nx-arrow` owns the Arrow-backed graph and the `arrow` NetworkX backend;
6
+ this package only adds persistence into
7
+ [LadybugDB](https://ladybugdb.com/). Build and analyze graphs with
8
+ `nx-arrow` everywhere — the only `nxladybug` import is the persist step.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install nx-arrow nx-ladybug
14
+ ```
15
+
16
+ For development:
17
+
18
+ ```bash
19
+ pip install -e ".[test]"
20
+ ```
21
+
22
+ ## Use
23
+
24
+ ```python
25
+ import networkx as nx
26
+ from nxladybug import LadybugStorageProvider, persist
27
+
28
+ g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
29
+
30
+ nx.out_degree_centrality(g, backend="arrow")
31
+ list(nx.topological_sort(g, backend="arrow"))
32
+ ```
33
+
34
+ ## Persist
35
+
36
+ `persist()` bulk-loads per-type frames through a storage provider — pick
37
+ LadybugDB or Parquet:
38
+
39
+ ```python
40
+ from nxarrow.parquet import ParquetStorageProvider
41
+ from nxladybug import LadybugStorageProvider, persist
42
+
43
+ # LadybugDB: one unified node table (NxNode) + one relationship table
44
+ # (NxEdge) + a meta table (NxMeta). Each frame is registered as an Arrow
45
+ # staging table and bulk-copied natively — COPY <table> FROM (MATCH ...) —
46
+ # so endpoints resolve through the node primary key inside the copy.
47
+ provider = LadybugStorageProvider("graph.lbdb") # or ":memory:"
48
+ persist(g, provider, graph_attrs=g.graph)
49
+ restored = provider.load_graph() # full round-trip
50
+
51
+ # ... or Parquet: one file per type plus a manifest.json
52
+ parquet = ParquetStorageProvider("graph_store")
53
+ persist(g, parquet, graph_attrs=g.graph)
54
+ restored = parquet.load_graph()
55
+ ```
56
+
57
+ The Ladybug provider drops and recreates its tables on every persist, so
58
+ re-persisting a smaller graph leaves no stale rows. `:memory:` databases
59
+ live as long as the provider; file paths reopen with
60
+ `LadybugStorageProvider.load(path)`.
61
+
62
+ Custom stores implement `store_nodes(frames)` / `store_edges(frames)` and
63
+ register with `nxarrow.register_storage_provider`.
64
+
65
+ ## Develop
66
+
67
+ ```bash
68
+ pytest
69
+ ```
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = ["setuptools>=77"]
4
+
5
+ [project]
6
+ name = "nx-ladybug"
7
+ version = "0.1.0"
8
+ description = "LadybugDB storage provider for nx-arrow columnar graphs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "BSD-3-Clause OR MIT"
12
+ keywords = ["networks", "graph theory", "apache arrow", "networkx", "columnar", "ladybugdb"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Intended Audience :: Science/Research",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Scientific/Engineering :: Information Analysis",
21
+ "Topic :: Scientific/Engineering :: Mathematics",
22
+ ]
23
+ dependencies = [
24
+ "nx-arrow>=0.1.0",
25
+ "ladybug>=0.20",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/Ladybug-Memory/nx-ladybug"
30
+ "Source Code" = "https://github.com/Ladybug-Memory/nx-ladybug"
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest"]
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: nx-ladybug
3
+ Version: 0.1.0
4
+ Summary: LadybugDB storage provider for nx-arrow columnar graphs
5
+ License-Expression: BSD-3-Clause OR MIT
6
+ Project-URL: Homepage, https://github.com/Ladybug-Memory/nx-ladybug
7
+ Project-URL: Source Code, https://github.com/Ladybug-Memory/nx-ladybug
8
+ Keywords: networks,graph theory,apache arrow,networkx,columnar,ladybugdb
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
16
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: nx-arrow>=0.1.0
21
+ Requires-Dist: ladybug>=0.20
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # nx-ladybug
27
+
28
+ LadybugDB storage provider for
29
+ [nx-arrow](https://github.com/Ladybug-Memory/nx-arrow) columnar graphs.
30
+ `nx-arrow` owns the Arrow-backed graph and the `arrow` NetworkX backend;
31
+ this package only adds persistence into
32
+ [LadybugDB](https://ladybugdb.com/). Build and analyze graphs with
33
+ `nx-arrow` everywhere — the only `nxladybug` import is the persist step.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install nx-arrow nx-ladybug
39
+ ```
40
+
41
+ For development:
42
+
43
+ ```bash
44
+ pip install -e ".[test]"
45
+ ```
46
+
47
+ ## Use
48
+
49
+ ```python
50
+ import networkx as nx
51
+ from nxladybug import LadybugStorageProvider, persist
52
+
53
+ g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
54
+
55
+ nx.out_degree_centrality(g, backend="arrow")
56
+ list(nx.topological_sort(g, backend="arrow"))
57
+ ```
58
+
59
+ ## Persist
60
+
61
+ `persist()` bulk-loads per-type frames through a storage provider — pick
62
+ LadybugDB or Parquet:
63
+
64
+ ```python
65
+ from nxarrow.parquet import ParquetStorageProvider
66
+ from nxladybug import LadybugStorageProvider, persist
67
+
68
+ # LadybugDB: one unified node table (NxNode) + one relationship table
69
+ # (NxEdge) + a meta table (NxMeta). Each frame is registered as an Arrow
70
+ # staging table and bulk-copied natively — COPY <table> FROM (MATCH ...) —
71
+ # so endpoints resolve through the node primary key inside the copy.
72
+ provider = LadybugStorageProvider("graph.lbdb") # or ":memory:"
73
+ persist(g, provider, graph_attrs=g.graph)
74
+ restored = provider.load_graph() # full round-trip
75
+
76
+ # ... or Parquet: one file per type plus a manifest.json
77
+ parquet = ParquetStorageProvider("graph_store")
78
+ persist(g, parquet, graph_attrs=g.graph)
79
+ restored = parquet.load_graph()
80
+ ```
81
+
82
+ The Ladybug provider drops and recreates its tables on every persist, so
83
+ re-persisting a smaller graph leaves no stale rows. `:memory:` databases
84
+ live as long as the provider; file paths reopen with
85
+ `LadybugStorageProvider.load(path)`.
86
+
87
+ Custom stores implement `store_nodes(frames)` / `store_edges(frames)` and
88
+ register with `nxarrow.register_storage_provider`.
89
+
90
+ ## Develop
91
+
92
+ ```bash
93
+ pytest
94
+ ```
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/nx_ladybug.egg-info/PKG-INFO
5
+ src/nx_ladybug.egg-info/SOURCES.txt
6
+ src/nx_ladybug.egg-info/dependency_links.txt
7
+ src/nx_ladybug.egg-info/requires.txt
8
+ src/nx_ladybug.egg-info/top_level.txt
9
+ src/nxladybug/__init__.py
10
+ src/nxladybug/ladybug_store.py
11
+ tests/test_persist.py
@@ -0,0 +1,5 @@
1
+ nx-arrow>=0.1.0
2
+ ladybug>=0.20
3
+
4
+ [test]
5
+ pytest
@@ -0,0 +1 @@
1
+ nxladybug
@@ -0,0 +1,33 @@
1
+ """LadybugDB storage provider for :mod:`nxarrow` columnar graphs.
2
+
3
+ ``nx-arrow`` owns the Arrow-backed graph and the ``arrow`` NetworkX
4
+ backend; this package only adds persistence into LadybugDB. Build graphs
5
+ the usual way and choose a store at :func:`persist` time::
6
+
7
+ import networkx as nx
8
+ from nxarrow.parquet import ParquetStorageProvider
9
+ from nxladybug import LadybugStorageProvider, persist
10
+
11
+ g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
12
+ persist(g, LadybugStorageProvider("graph.lbdb"), graph_attrs=g.graph)
13
+ # ... or persist(g, ParquetStorageProvider("store"), graph_attrs=g.graph)
14
+ """
15
+
16
+ from nxarrow.backend import (
17
+ get_storage_provider,
18
+ list_storage_providers,
19
+ persist,
20
+ register_storage_provider,
21
+ unregister_storage_provider,
22
+ )
23
+
24
+ from .ladybug_store import LadybugStorageProvider
25
+
26
+ __all__ = [
27
+ "LadybugStorageProvider",
28
+ "persist",
29
+ "register_storage_provider",
30
+ "unregister_storage_provider",
31
+ "get_storage_provider",
32
+ "list_storage_providers",
33
+ ]
@@ -0,0 +1,419 @@
1
+ """LadybugDB storage provider for arrow-backed graphs.
2
+
3
+ Persists the per-type ``pyarrow.Table`` frames handed over by
4
+ :func:`nxarrow.backend.persist` into a LadybugDB database with one
5
+ unified node table and one unified relationship table::
6
+
7
+ NxNode(node, <attrs...>, node_type, _nx_order)
8
+ NxEdge(FROM NxNode TO NxNode, <attrs...>, edge_type, _nx_order)
9
+ NxMeta(key, value) # JSON-encoded graph-level attributes
10
+
11
+ Table names are configurable at construction. Every persist drops and
12
+ recreates the tables, so re-persisting a smaller graph leaves no stale
13
+ rows behind.
14
+
15
+ Each frame is bulk-loaded in two steps — Arrow memory registration into
16
+ a staging table, then a native bulk copy:
17
+
18
+ .. code-block:: python
19
+
20
+ conn.create_arrow_table(stage, frame) # register Arrow memory
21
+ conn.execute(f"COPY NxNode FROM " # bulk copy out of it
22
+ f"(MATCH (s:`{stage}`) RETURN s.`node`, ...)")
23
+
24
+ Column mapping is positional (staging ``RETURN`` order matches the
25
+ stored column order), mirroring the ``COPY <table> FROM $df`` convention
26
+ used by the LDBC bulk loader. Endpoints resolve through the node primary
27
+ key inside ``COPY``. Empty frames are skipped; a graph with no nodes or
28
+ no edges simply leaves that table out (loads treat a missing table as
29
+ empty).
30
+
31
+ Round-trip::
32
+
33
+ provider = LadybugStorageProvider(":memory:")
34
+ persist(graph, provider, graph_attrs=graph.graph)
35
+ restored = provider.load_graph()
36
+
37
+ ``load_graph`` reads the tables back with ``MATCH ... RETURN`` (ordered
38
+ by the internal sequence column, which is then dropped) and rebuilds an
39
+ ``ArrowDiGraph`` via :meth:`ArrowDiGraph.from_arrow`, so the restored
40
+ graph compares equal to the persisted one.
41
+
42
+ Fidelity notes. First, Arrow types map to Ladybug types: all integers to
43
+ ``INT64``, floats to ``DOUBLE``, booleans to ``BOOL``, strings to
44
+ ``STRING``, dates to ``DATE`` and timestamps to ``TIMESTAMP``; anything
45
+ else (times, durations, binaries, nested types, ...) is cast to
46
+ ``STRING`` before the copy, which stringifies time keys in particular.
47
+ Second, the node key column (``node``) becomes the Ladybug primary key,
48
+ so key values must be unique, as they always are in a graph. Third,
49
+ ``graph_attrs`` must be JSON-serializable; it is forwarded through
50
+ :func:`persist` keyword arguments and stored as one JSON document in the
51
+ meta table. Fourth, ``_nx_order`` is a reserved column name used for the
52
+ internal insertion sequence; graphs using it as an attribute are refused
53
+ with a clear error.
54
+ """
55
+
56
+ import json
57
+ import uuid
58
+
59
+ import pyarrow as pa
60
+
61
+ from nxarrow.graph import ArrowDiGraph
62
+
63
+ __all__ = ["LadybugStorageProvider"]
64
+
65
+ DEFAULT_NODE_TABLE = "NxNode"
66
+ DEFAULT_EDGE_TABLE = "NxEdge"
67
+ DEFAULT_META_TABLE = "NxMeta"
68
+
69
+ #: Reserved column holding the global insertion sequence. Lets loads
70
+ #: restore exact node/edge order via ``ORDER BY`` regardless of storage
71
+ #: order; it is stripped from the adopted tables so it never surfaces as
72
+ #: an attribute.
73
+ ORDER_COL = "_nx_order"
74
+
75
+
76
+ def _quote(identifier):
77
+ """Quote an identifier with backticks (Ladybug escape: double them)."""
78
+ return "`" + str(identifier).replace("`", "``") + "`"
79
+
80
+
81
+ def _literal(value):
82
+ """Quote a string literal with single quotes (escape: double them)."""
83
+ return "'" + str(value).replace("'", "''") + "'"
84
+
85
+
86
+ def _ladybug_type(dtype):
87
+ """Map an Arrow type to a Ladybug column type (None: cast to STRING)."""
88
+ if pa.types.is_dictionary(dtype):
89
+ return _ladybug_type(dtype.value_type)
90
+ if pa.types.is_integer(dtype) or pa.types.is_unsigned_integer(dtype):
91
+ return "INT64"
92
+ if pa.types.is_floating(dtype):
93
+ return "DOUBLE"
94
+ if pa.types.is_boolean(dtype):
95
+ return "BOOL"
96
+ if pa.types.is_string(dtype) or pa.types.is_large_string(dtype):
97
+ return "STRING"
98
+ if pa.types.is_date(dtype):
99
+ return "DATE"
100
+ if pa.types.is_timestamp(dtype):
101
+ return "TIMESTAMP"
102
+ return None
103
+
104
+
105
+ def _stage_frame(table):
106
+ """Cast unmapped columns to STRING so every column has a Ladybug type."""
107
+ names = table.column_names
108
+ if ORDER_COL in names:
109
+ raise ValueError(
110
+ f"attribute name {ORDER_COL!r} is reserved for Ladybug persistence; "
111
+ "rename the attribute before persisting"
112
+ )
113
+ out = table
114
+ for field in table.schema:
115
+ if _ladybug_type(field.type) is None:
116
+ idx = out.schema.get_field_index(field.name)
117
+ out = out.set_column(
118
+ idx, field.name, out.column(field.name).cast(pa.string())
119
+ )
120
+ return out
121
+
122
+
123
+ def _unify(tables):
124
+ """Concatenate tables with heterogeneous columns, filling gaps with nulls."""
125
+ if len(tables) == 1:
126
+ return tables[0]
127
+ schema = pa.unify_schemas([t.schema for t in tables])
128
+ return pa.concat_tables([t.cast(schema) for t in tables])
129
+
130
+
131
+ class LadybugStorageProvider:
132
+ """Store per-type Arrow frames in LadybugDB node/relationship tables.
133
+
134
+ Parameters
135
+ ----------
136
+ db_path:
137
+ LadybugDB database path, or ``":memory:"`` (default) for a
138
+ transient database that lives as long as this provider. File
139
+ paths persist across processes; reopen with
140
+ :meth:`LadybugStorageProvider.load`.
141
+ node_table, edge_table, meta_table:
142
+ Ladybug table names for nodes, edges and the graph-attrs sidecar.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ db_path=":memory:",
148
+ node_table=DEFAULT_NODE_TABLE,
149
+ edge_table=DEFAULT_EDGE_TABLE,
150
+ meta_table=DEFAULT_META_TABLE,
151
+ ):
152
+ self.db_path = str(db_path)
153
+ self.node_table = node_table
154
+ self.edge_table = edge_table
155
+ self.meta_table = meta_table
156
+ self._db = None
157
+ self._conn = None
158
+
159
+ # -- connection lifecycle --
160
+
161
+ @property
162
+ def conn(self):
163
+ """The live Ladybug connection, opened on first use."""
164
+ if self._conn is None:
165
+ import ladybug as lb
166
+
167
+ self._db = lb.Database(self.db_path)
168
+ self._conn = lb.Connection(self._db)
169
+ return self._conn
170
+
171
+ def close(self):
172
+ """Close the connection (and database). The provider is unusable after."""
173
+ if self._conn is not None:
174
+ try:
175
+ self._conn.close()
176
+ finally:
177
+ self._conn = None
178
+ if self._db is not None:
179
+ try:
180
+ self._db.close()
181
+ finally:
182
+ self._db = None
183
+
184
+ def __enter__(self):
185
+ return self
186
+
187
+ def __exit__(self, *exc):
188
+ self.close()
189
+ return False
190
+
191
+ # -- storage provider protocol (see nxarrow.backend.persist) --
192
+
193
+ def store_nodes(self, frames, graph_attrs=None, **kwargs):
194
+ """Bulk-load node frames into the node table. Returns ``{table: rows}``."""
195
+ rows = [t.num_rows for t in frames.values()]
196
+ total = sum(rows)
197
+ conn = self.conn
198
+ # Drop the edge table first: it references the node table, and both
199
+ # are rebuilt on every persist so no stale rows survive re-persist.
200
+ conn.execute(f"DROP TABLE IF EXISTS {_quote(self.edge_table)}")
201
+ conn.execute(f"DROP TABLE IF EXISTS {_quote(self.node_table)}")
202
+ if total:
203
+ unified = _unify([_stage_frame(t) for t in frames.values()])
204
+ self._create_node_table(unified)
205
+ offset = 0
206
+ for table in frames.values():
207
+ offset = self._copy_node_frame(table, unified.schema, offset)
208
+ if graph_attrs is not None:
209
+ self._store_meta(dict(graph_attrs))
210
+ return {self.node_table: total} if total else {}
211
+
212
+ def store_edges(self, frames, graph_attrs=None, **kwargs):
213
+ """Bulk-load edge frames into the relationship table. Returns ``{table: rows}``."""
214
+ rows = [t.num_rows for t in frames.values()]
215
+ total = sum(rows)
216
+ conn = self.conn
217
+ conn.execute(f"DROP TABLE IF EXISTS {_quote(self.edge_table)}")
218
+ if total:
219
+ self._ensure_node_table_for_edges(frames)
220
+ unified = _unify([_stage_frame(t) for t in frames.values()])
221
+ self._create_edge_table(unified)
222
+ offset = 0
223
+ for table in frames.values():
224
+ offset = self._copy_edge_frame(table, unified.schema, offset)
225
+ if graph_attrs is not None:
226
+ self._store_meta(dict(graph_attrs))
227
+ return {self.edge_table: total} if total else {}
228
+
229
+ # -- loading --
230
+
231
+ def load_graph(self):
232
+ """Rebuild the persisted graph (tables, types, attrs, graph attrs)."""
233
+ nodes = self._load_table(self.node_table, endpoint=False)
234
+ edges = self._load_table(self.edge_table, endpoint=True)
235
+ graph = ArrowDiGraph.from_arrow(nodes, edges)
236
+ graph.graph.update(self._load_meta())
237
+ return graph
238
+
239
+ @classmethod
240
+ def load(cls, db_path, **kwargs):
241
+ """Open the database at ``db_path`` and rebuild the persisted graph."""
242
+ return cls(db_path, **kwargs).load_graph()
243
+
244
+ # -- DDL --
245
+
246
+ def _create_node_table(self, unified):
247
+ cols = [
248
+ f"{_quote(f.name)} {_ladybug_type(f.type)}" for f in unified.schema
249
+ ]
250
+ cols.append(f"{_quote(ORDER_COL)} INT64")
251
+ cols.append(f"PRIMARY KEY ({_quote('node')})")
252
+ self.conn.execute(
253
+ f"CREATE NODE TABLE {_quote(self.node_table)} ({', '.join(cols)})"
254
+ )
255
+
256
+ def _create_edge_table(self, unified):
257
+ props = [
258
+ f"{_quote(f.name)} {_ladybug_type(f.type)}"
259
+ for f in unified.schema
260
+ if f.name not in ("source", "target")
261
+ ]
262
+ props.append(f"{_quote(ORDER_COL)} INT64")
263
+ self.conn.execute(
264
+ f"CREATE REL TABLE {_quote(self.edge_table)} "
265
+ f"(FROM {_quote(self.node_table)} TO {_quote(self.node_table)}"
266
+ + (f", {', '.join(props)}" if props else "")
267
+ + ")"
268
+ )
269
+
270
+ def _ensure_node_table_for_edges(self, frames):
271
+ """Create a bare node table so a standalone edge store has a TO/FROM target.
272
+
273
+ :func:`persist` always stores nodes first, so this only triggers on
274
+ direct ``store_edges`` calls. The key type is inferred from the
275
+ ``source`` column of the unified edge frames.
276
+ """
277
+ names = {row[0] for row in self._table_names()}
278
+ if self.node_table in names:
279
+ return
280
+ unified = _unify([_stage_frame(t) for t in frames.values()])
281
+ key_type = _ladybug_type(unified.schema.field("source").type) or "STRING"
282
+ self.conn.execute(
283
+ f"CREATE NODE TABLE {_quote(self.node_table)} "
284
+ f"({_quote('node')} {key_type}, PRIMARY KEY ({_quote('node')}))"
285
+ )
286
+
287
+ # -- COPY --
288
+
289
+ def _copy_frame(self, dest_table, dest_cols, frame):
290
+ """Register ``frame`` as a staging table and bulk-copy it into ``dest_table``.
291
+
292
+ Column mapping is positional: the ``RETURN`` list follows
293
+ ``dest_cols`` order, matching the stored column order.
294
+ """
295
+ stage = f"_nx_stage_{uuid.uuid4().hex}"
296
+ conn = self.conn
297
+ conn.create_arrow_table(stage, frame)
298
+ try:
299
+ aliased = "s"
300
+ returns = ", ".join(
301
+ f"{_quote(aliased)}.{_quote(c)}" for c in dest_cols
302
+ )
303
+ conn.execute(
304
+ f"COPY {_quote(dest_table)} FROM "
305
+ f"(MATCH ({_quote(aliased)}:{_quote(stage)}) RETURN {returns})"
306
+ )
307
+ finally:
308
+ try:
309
+ conn.drop_arrow_table(stage)
310
+ except Exception:
311
+ pass
312
+
313
+ def _copy_node_frame(self, table, schema, offset):
314
+ if table.num_rows == 0:
315
+ return offset
316
+ staged = _stage_frame(table.cast(schema))
317
+ order = pa.array(
318
+ range(offset, offset + table.num_rows), type=pa.int64()
319
+ )
320
+ staged = staged.append_column(ORDER_COL, order)
321
+ self._copy_frame(self.node_table, [*schema.names, ORDER_COL], staged)
322
+ return offset + table.num_rows
323
+
324
+ def _copy_edge_frame(self, table, schema, offset):
325
+ if table.num_rows == 0:
326
+ return offset
327
+ staged = _stage_frame(table.cast(schema))
328
+ order = pa.array(
329
+ range(offset, offset + table.num_rows), type=pa.int64()
330
+ )
331
+ staged = staged.append_column(ORDER_COL, order)
332
+ self._copy_frame(self.edge_table, [*schema.names, ORDER_COL], staged)
333
+ return offset + table.num_rows
334
+
335
+ # -- graph attrs sidecar --
336
+
337
+ def _store_meta(self, graph_attrs):
338
+ import pyarrow as pa
339
+
340
+ conn = self.conn
341
+ conn.execute(f"DROP TABLE IF EXISTS {_quote(self.meta_table)}")
342
+ conn.execute(
343
+ f"CREATE NODE TABLE {_quote(self.meta_table)} "
344
+ f"({_quote('key')} STRING, {_quote('value')} STRING, "
345
+ f"PRIMARY KEY ({_quote('key')}))"
346
+ )
347
+ frame = pa.table(
348
+ {
349
+ "key": pa.array(["graph"], type=pa.string()),
350
+ "value": pa.array(
351
+ [json.dumps(graph_attrs)], type=pa.string()
352
+ ),
353
+ }
354
+ )
355
+ self._copy_frame(self.meta_table, ["key", "value"], frame)
356
+
357
+ def _load_meta(self):
358
+ if self.meta_table not in {row[0] for row in self._table_names()}:
359
+ return {}
360
+ result = self.conn.execute(
361
+ f"MATCH (m:{_quote(self.meta_table)}) "
362
+ f"WHERE m.{_quote('key')} = 'graph' "
363
+ f"RETURN m.{_quote('value')} AS {_quote('value')}"
364
+ ).get_as_arrow()
365
+ if result.num_rows == 0:
366
+ return {}
367
+ return json.loads(result.column("value")[0].as_py())
368
+
369
+ # -- reads --
370
+
371
+ def _table_names(self):
372
+ result = self.conn.execute("CALL show_tables() RETURN *").get_as_arrow()
373
+ return list(
374
+ zip(result.column("name").to_pylist(), result.column("type").to_pylist())
375
+ )
376
+
377
+ def _table_columns(self, table):
378
+ """Stored column names of ``table`` in DDL order.
379
+
380
+ Relationship endpoints (``FROM``/``TO``) are not listed by
381
+ ``table_info`` — only real properties are — so no filtering for
382
+ them is needed.
383
+ """
384
+ result = self.conn.execute(
385
+ f"CALL table_info({_literal(table)}) RETURN *"
386
+ ).get_as_arrow()
387
+ return result.column("name").to_pylist()
388
+
389
+ def _load_table(self, table, endpoint):
390
+ if table not in {name for name, _ in self._table_names()}:
391
+ return None
392
+ columns = self._table_columns(table)
393
+ ordered = ORDER_COL in columns
394
+ if endpoint:
395
+ node, edge = _quote(self.node_table), _quote(table)
396
+ src, tgt = _quote("source"), _quote("target")
397
+ returns = [
398
+ f"a.{_quote('node')} AS {src}",
399
+ f"b.{_quote('node')} AS {tgt}",
400
+ ]
401
+ returns += [
402
+ f"e.{_quote(c)} AS {_quote(c)}"
403
+ for c in columns
404
+ if c != ORDER_COL
405
+ ]
406
+ query = (
407
+ f"MATCH (a:{node})-[e:{edge}]->(b:{node}) "
408
+ f"RETURN {', '.join(returns)}"
409
+ )
410
+ if ordered:
411
+ query += f" ORDER BY e.{_quote(ORDER_COL)}"
412
+ else:
413
+ returns = [f"n.{_quote(c)} AS {_quote(c)}" for c in columns if c != ORDER_COL]
414
+ query = f"MATCH (n:{_quote(table)}) RETURN {', '.join(returns)}"
415
+ if ordered:
416
+ query += f" ORDER BY n.{_quote(ORDER_COL)}"
417
+ result = self.conn.execute(query).get_as_arrow()
418
+ # get_as_arrow may chunk columns; combine for clean tables.
419
+ return result.combine_chunks() if result.num_columns else None
@@ -0,0 +1,253 @@
1
+ """Persistence tests: same ``arrow``-backend graphs, parquet or Ladybug store."""
2
+
3
+ import datetime
4
+
5
+ import pytest
6
+
7
+ pa = pytest.importorskip("pyarrow")
8
+ pytest.importorskip("ladybug")
9
+
10
+ import networkx as nx
11
+ from nxarrow.backend import persist
12
+ from nxarrow.graph import ArrowDiGraph
13
+ from nxarrow.parquet import ParquetStorageProvider
14
+
15
+ from nxladybug import LadybugStorageProvider
16
+
17
+
18
+ @pytest.fixture
19
+ def typed_graph():
20
+ g = nx.DiGraph(backend="arrow")
21
+ g.add_node("alice", node_type="user", age=30)
22
+ g.add_node("bob", node_type="user", age=25)
23
+ g.add_node("post1", node_type="post", text="hello")
24
+ g.add_edge("alice", "post1", edge_type="authored")
25
+ g.add_edge("bob", "post1", edge_type="liked", weight=0.9)
26
+ g.add_edge("alice", "bob", edge_type="follows")
27
+ return g
28
+
29
+
30
+ def _assert_graphs_equal(original, restored):
31
+ assert restored.node_types == original.node_types
32
+ assert restored.edge_types == original.edge_types
33
+ assert restored.nodes_table().num_rows == original.nodes_table().num_rows
34
+ assert restored.edges_table().num_rows == original.edges_table().num_rows
35
+ for node in original.nodes:
36
+ assert restored.has_node(node)
37
+ assert restored.node_attrs(node) == original.node_attrs(node)
38
+ for u, v in original.edges:
39
+ assert restored.has_edge(u, v)
40
+ assert restored.edge_attrs(u, v) == original.edge_attrs(u, v)
41
+ assert restored.graph == original.graph
42
+
43
+
44
+ def _row_count(provider, query):
45
+ return provider.conn.execute(query).get_as_arrow().column(0)[0].as_py()
46
+
47
+
48
+ # -- Ladybug store --
49
+
50
+
51
+ def test_ladybug_provider_roundtrip(typed_graph):
52
+ typed_graph.graph["name"] = "roundtrip"
53
+ provider = LadybugStorageProvider()
54
+ try:
55
+ counts = persist(typed_graph, provider, graph_attrs=typed_graph.graph)
56
+ assert counts == {provider.node_table: 3, provider.edge_table: 3}
57
+ names = {name for name, _ in provider._table_names()}
58
+ assert {provider.node_table, provider.edge_table, provider.meta_table} <= names
59
+
60
+ restored = provider.load_graph()
61
+ _assert_graphs_equal(typed_graph, restored)
62
+ # insertion order survives the round-trip via the sequence column
63
+ assert restored.nodes == typed_graph.nodes
64
+ assert restored.edges == typed_graph.edges
65
+ finally:
66
+ provider.close()
67
+
68
+
69
+ def test_ladybug_provider_roundtrip_untyped_and_empty():
70
+ provider = LadybugStorageProvider()
71
+ try:
72
+ g = nx.DiGraph(backend="arrow")
73
+ g.add_edge("a", "b", weight=1.5)
74
+ persist(g, provider)
75
+ restored = provider.load_graph()
76
+ _assert_graphs_equal(g, restored)
77
+ assert restored.nodes_table().column_names == ["node"]
78
+
79
+ persist(nx.DiGraph(backend="arrow"), provider)
80
+ empty = provider.load_graph()
81
+ assert empty.number_of_nodes() == 0
82
+ assert empty.number_of_edges() == 0
83
+ finally:
84
+ provider.close()
85
+
86
+
87
+ def test_ladybug_provider_repersist_drops_stale_rows(typed_graph):
88
+ provider = LadybugStorageProvider()
89
+ try:
90
+ persist(typed_graph, provider)
91
+ assert _row_count(provider, "MATCH (n:NxNode) RETURN count(*)") == 3
92
+ assert _row_count(provider, "MATCH ()-[e:NxEdge]->() RETURN count(*)") == 3
93
+
94
+ typed_graph.remove_edge("alice", "bob") # drops the only "follows" edge
95
+ typed_graph.remove_edge("bob", "post1") # drops the only "liked" edge
96
+ persist(typed_graph, provider)
97
+ assert _row_count(provider, "MATCH ()-[e:NxEdge]->() RETURN count(*)") == 1
98
+ _assert_graphs_equal(typed_graph, provider.load_graph())
99
+ finally:
100
+ provider.close()
101
+
102
+
103
+ def test_ladybug_provider_file_backed_reopen(tmp_path, typed_graph):
104
+ typed_graph.graph["name"] = "file-roundtrip"
105
+ db_path = tmp_path / "graph.lbdb"
106
+ provider = LadybugStorageProvider(db_path)
107
+ persist(typed_graph, provider, graph_attrs=typed_graph.graph)
108
+ provider.close()
109
+
110
+ restored = LadybugStorageProvider.load(db_path)
111
+ _assert_graphs_equal(typed_graph, restored)
112
+ assert restored.nodes == typed_graph.nodes
113
+
114
+
115
+ def test_ladybug_roundtrip_preserves_int_keys():
116
+ g = nx.DiGraph(backend="arrow")
117
+ g.add_edge(0, 1, weight=2.5)
118
+ g.add_edge(1, 2)
119
+ provider = LadybugStorageProvider()
120
+ try:
121
+ persist(g, provider)
122
+ restored = provider.load_graph()
123
+ assert restored.nodes == [0, 1, 2]
124
+ assert restored.edges == [(0, 1), (1, 2)]
125
+ assert restored.edge_attrs(0, 1) == {"weight": 2.5}
126
+ assert restored.nodes_table().schema.field("node").type == pa.int64()
127
+ finally:
128
+ provider.close()
129
+
130
+
131
+ def test_ladybug_roundtrip_native_key_types():
132
+ cases = [
133
+ ([0, 1, 2], pa.int64()),
134
+ ([0.5, 1.5, 2.5], pa.float64()),
135
+ (["a", "b", "c"], pa.string()),
136
+ (
137
+ [
138
+ datetime.date(2024, 1, 1),
139
+ datetime.date(2024, 1, 2),
140
+ datetime.date(2024, 1, 3),
141
+ ],
142
+ pa.date32(),
143
+ ),
144
+ (
145
+ [
146
+ datetime.datetime(2024, 1, 1, 1, 2),
147
+ datetime.datetime(2024, 1, 2),
148
+ datetime.datetime(2024, 1, 3, 4, 5),
149
+ ],
150
+ pa.timestamp("us"),
151
+ ),
152
+ ]
153
+ for keys, expected_type in cases:
154
+ g = nx.DiGraph(backend="arrow")
155
+ g.add_edge(keys[0], keys[1], weight=1.0)
156
+ g.add_edge(keys[1], keys[2])
157
+ provider = LadybugStorageProvider()
158
+ try:
159
+ persist(g, provider)
160
+ restored = provider.load_graph()
161
+ assert restored.nodes == keys
162
+ assert restored.edges == [(keys[0], keys[1]), (keys[1], keys[2])]
163
+ assert restored.edge_attrs(keys[0], keys[1]) == {"weight": 1.0}
164
+ assert restored.nodes_table().schema.field("node").type == expected_type
165
+ finally:
166
+ provider.close()
167
+
168
+
169
+ def test_ladybug_lazy_persist_roundtrip(typed_graph):
170
+ # index-free adopted tables persist without materializing the index
171
+ nodes, edges = typed_graph.nodes_table(), typed_graph.edges_table()
172
+ lazy = ArrowDiGraph.from_arrow(nodes, edges, index=False)
173
+ provider = LadybugStorageProvider()
174
+ try:
175
+ counts = persist(lazy, provider)
176
+ assert lazy._index_built is False
177
+ assert sum(counts.values()) == 6
178
+ _assert_graphs_equal(typed_graph, provider.load_graph())
179
+ finally:
180
+ provider.close()
181
+
182
+
183
+ # -- Parquet store (same graphs, alternative choice at persist() time) --
184
+
185
+
186
+ def test_parquet_provider_roundtrip(tmp_path, typed_graph):
187
+ typed_graph.graph["name"] = "roundtrip"
188
+ provider = ParquetStorageProvider(tmp_path / "store")
189
+ counts = persist(typed_graph, provider, graph_attrs=typed_graph.graph)
190
+ assert sum(counts.values()) == 6
191
+ assert (tmp_path / "store" / "manifest.json").exists()
192
+
193
+ restored = provider.load_graph()
194
+ _assert_graphs_equal(typed_graph, restored)
195
+ # per-type files: 2 node types + 3 edge types
196
+ assert len(list((tmp_path / "store" / "nodes").glob("*.parquet"))) == 2
197
+ assert len(list((tmp_path / "store" / "edges").glob("*.parquet"))) == 3
198
+
199
+
200
+ def test_parquet_provider_roundtrip_untyped_and_empty(tmp_path):
201
+ provider = ParquetStorageProvider(tmp_path / "store")
202
+ g = nx.DiGraph(backend="arrow")
203
+ g.add_edge("a", "b", weight=1.5)
204
+ persist(g, provider)
205
+ restored = provider.load_graph()
206
+ _assert_graphs_equal(g, restored)
207
+ assert restored.nodes_table().column_names == ["node"]
208
+
209
+ persist(nx.DiGraph(backend="arrow"), provider)
210
+ empty = provider.load_graph()
211
+ assert empty.number_of_nodes() == 0
212
+ assert empty.number_of_edges() == 0
213
+
214
+
215
+ def test_parquet_provider_repersist_drops_stale_files(tmp_path, typed_graph):
216
+ provider = ParquetStorageProvider(tmp_path / "store")
217
+ persist(typed_graph, provider)
218
+ assert len(list((tmp_path / "store" / "edges").glob("*.parquet"))) == 3
219
+
220
+ typed_graph.remove_edge("alice", "bob") # drops the only "follows" edge
221
+ typed_graph.remove_edge("bob", "post1") # drops the only "liked" edge
222
+ persist(typed_graph, provider)
223
+ assert len(list((tmp_path / "store" / "edges").glob("*.parquet"))) == 1
224
+ _assert_graphs_equal(typed_graph, provider.load_graph())
225
+
226
+
227
+ def test_parquet_roundtrip_preserves_int_keys(tmp_path):
228
+ g = nx.DiGraph(backend="arrow")
229
+ g.add_edge(0, 1, weight=2.5)
230
+ g.add_edge(1, 2)
231
+ provider = ParquetStorageProvider(tmp_path / "store")
232
+ persist(g, provider)
233
+ restored = provider.load_graph()
234
+ assert restored.nodes == [0, 1, 2]
235
+ assert restored.edges == [(0, 1), (1, 2)]
236
+ assert restored.edge_attrs(0, 1) == {"weight": 2.5}
237
+ assert restored.nodes_table().schema.field("node").type == pa.int64()
238
+
239
+
240
+ # -- the choice itself: one graph, either store --
241
+
242
+
243
+ def test_same_graph_persists_to_both_stores(tmp_path, typed_graph):
244
+ typed_graph.graph["name"] = "either-store"
245
+ ladybug = LadybugStorageProvider()
246
+ parquet = ParquetStorageProvider(tmp_path / "store")
247
+ try:
248
+ persist(typed_graph, ladybug, graph_attrs=typed_graph.graph)
249
+ persist(typed_graph, parquet, graph_attrs=typed_graph.graph)
250
+ _assert_graphs_equal(typed_graph, ladybug.load_graph())
251
+ _assert_graphs_equal(typed_graph, parquet.load_graph())
252
+ finally:
253
+ ladybug.close()