vcti-tree-exporter-csv 2.0.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,8 @@
1
+ Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
2
+ All Rights Reserved.
3
+
4
+ This software is proprietary and confidential. Unauthorized copying,
5
+ distribution, or use of this software, via any medium, is strictly
6
+ prohibited. Access is granted only to authorized VCollab developers
7
+ and individuals explicitly authorized by Visual Collaboration
8
+ Technologies Inc.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-tree-exporter-csv
3
+ Version: 2.0.0
4
+ Summary: CSV exporter plugin for vcti-tree-exporter: writes a vcti tree as a directory tree of CSV tables mirroring the hierarchy, with each node's attributes in a JSON sidecar.
5
+ Author: Visual Collaboration Technologies Inc.
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://github.com/vcollab/vcti-python-tree-exporter-csv
8
+ Project-URL: Repository, https://github.com/vcollab/vcti-python-tree-exporter-csv
9
+ Project-URL: Changelog, https://github.com/vcollab/vcti-python-tree-exporter-csv/blob/main/CHANGELOG.md
10
+ Keywords: tree,export,csv,serialization,exporter,plugin
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: <3.15,>=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: vcti-tree-exporter>=2.0.0
23
+ Requires-Dist: vcti-tree>=2.0.0
24
+ Requires-Dist: vcti-datanode>=2.0.0
25
+ Requires-Dist: numpy>=1.24
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest; extra == "test"
28
+ Requires-Dist: pytest-cov; extra == "test"
29
+ Provides-Extra: lint
30
+ Requires-Dist: ruff; extra == "lint"
31
+ Provides-Extra: typecheck
32
+ Requires-Dist: mypy; extra == "typecheck"
33
+ Dynamic: license-file
34
+
35
+ # CSV Tree Exporter
36
+
37
+ CSV exporter plugin for [`vcti-tree-exporter`](https://github.com/vcollab/vcti-python-tree-exporter): writes a vcti tree as a directory tree of CSV tables.
38
+
39
+ `CsvExporter` serializes any [`vcti-tree`](https://github.com/vcollab/vcti-python-tree)
40
+ tree whose node payloads are
41
+ [`vcti-datanode`](https://github.com/vcollab/vcti-python-datanode) `DataNode`s
42
+ as a directory of CSV tables mirroring the hierarchy, and ships a
43
+ `get_exporter_descriptor()` factory so a consumer registers it in a catalog and
44
+ resolves it by id (`"csv"`) or by attribute lookup.
45
+
46
+ CSV suits **light 2-D tables of settings and metadata** that load straight into
47
+ pandas — not heavy result arrays. Use HDF5 or NPZ for complete numeric data.
48
+
49
+ ## Tree → CSV mapping
50
+
51
+ The output path is a directory:
52
+
53
+ ```
54
+ out/
55
+ _attributes.json # the root node's attributes
56
+ info.attributes.json # a metadata-only leaf -> sidecar only
57
+ materials/ # a group node -> sub-directory
58
+ props.csv # a structured-array leaf -> table with a header row
59
+ props.attributes.json # its attributes
60
+ vec.csv # a plain 1-D array -> one value per row
61
+ grid.csv # a plain 2-D array -> rows
62
+ ```
63
+
64
+ | Tree node | On disk |
65
+ |---|---|
66
+ | node with children | sub-directory named after the node |
67
+ | data leaf | `<name>.csv` (structured → header + rows; plain 1-D/2-D → rows) |
68
+ | a node's `attributes` | `<name>.attributes.json` (leaf) or `_attributes.json` (group) |
69
+ | a group node's own array | `_data.csv` inside its directory |
70
+
71
+ Names are sanitized to filesystem-safe segments and de-collided case-insensitively
72
+ (`Mat`, `mat` → `Mat`, `mat~1`) so nothing is silently overwritten. An array with
73
+ more than two dimensions raises `UnrepresentableNodeError`. CSV does not preserve
74
+ dtype and the export is one-way.
75
+
76
+ ## Usage
77
+
78
+ ```python
79
+ from pathlib import Path
80
+ from vcti.tree.exporter.core import build_registry, get_exporter
81
+ from vcti.tree.exporter.csv import get_exporter_descriptor
82
+
83
+ registry = build_registry([get_exporter_descriptor()]) # plus any other format plugins
84
+ get_exporter(registry, "csv").export(tree, Path("out_dir"), overwrite=True)
85
+ ```
86
+
87
+ `overwrite=True` replaces the existing output directory — point it at a
88
+ dedicated path.
89
+
90
+ ## Dependencies
91
+
92
+ - [vcti-tree-exporter](https://github.com/vcollab/vcti-python-tree-exporter) — the `Exporter` protocol and the `ExporterDescriptor` catalog.
93
+ - [vcti-tree](https://github.com/vcollab/vcti-python-tree) / [vcti-datanode](https://github.com/vcollab/vcti-python-datanode) — the tree and payload types.
94
+ - numpy. (CSV writing itself uses only the standard library.)
@@ -0,0 +1,60 @@
1
+ # CSV Tree Exporter
2
+
3
+ CSV exporter plugin for [`vcti-tree-exporter`](https://github.com/vcollab/vcti-python-tree-exporter): writes a vcti tree as a directory tree of CSV tables.
4
+
5
+ `CsvExporter` serializes any [`vcti-tree`](https://github.com/vcollab/vcti-python-tree)
6
+ tree whose node payloads are
7
+ [`vcti-datanode`](https://github.com/vcollab/vcti-python-datanode) `DataNode`s
8
+ as a directory of CSV tables mirroring the hierarchy, and ships a
9
+ `get_exporter_descriptor()` factory so a consumer registers it in a catalog and
10
+ resolves it by id (`"csv"`) or by attribute lookup.
11
+
12
+ CSV suits **light 2-D tables of settings and metadata** that load straight into
13
+ pandas — not heavy result arrays. Use HDF5 or NPZ for complete numeric data.
14
+
15
+ ## Tree → CSV mapping
16
+
17
+ The output path is a directory:
18
+
19
+ ```
20
+ out/
21
+ _attributes.json # the root node's attributes
22
+ info.attributes.json # a metadata-only leaf -> sidecar only
23
+ materials/ # a group node -> sub-directory
24
+ props.csv # a structured-array leaf -> table with a header row
25
+ props.attributes.json # its attributes
26
+ vec.csv # a plain 1-D array -> one value per row
27
+ grid.csv # a plain 2-D array -> rows
28
+ ```
29
+
30
+ | Tree node | On disk |
31
+ |---|---|
32
+ | node with children | sub-directory named after the node |
33
+ | data leaf | `<name>.csv` (structured → header + rows; plain 1-D/2-D → rows) |
34
+ | a node's `attributes` | `<name>.attributes.json` (leaf) or `_attributes.json` (group) |
35
+ | a group node's own array | `_data.csv` inside its directory |
36
+
37
+ Names are sanitized to filesystem-safe segments and de-collided case-insensitively
38
+ (`Mat`, `mat` → `Mat`, `mat~1`) so nothing is silently overwritten. An array with
39
+ more than two dimensions raises `UnrepresentableNodeError`. CSV does not preserve
40
+ dtype and the export is one-way.
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from pathlib import Path
46
+ from vcti.tree.exporter.core import build_registry, get_exporter
47
+ from vcti.tree.exporter.csv import get_exporter_descriptor
48
+
49
+ registry = build_registry([get_exporter_descriptor()]) # plus any other format plugins
50
+ get_exporter(registry, "csv").export(tree, Path("out_dir"), overwrite=True)
51
+ ```
52
+
53
+ `overwrite=True` replaces the existing output directory — point it at a
54
+ dedicated path.
55
+
56
+ ## Dependencies
57
+
58
+ - [vcti-tree-exporter](https://github.com/vcollab/vcti-python-tree-exporter) — the `Exporter` protocol and the `ExporterDescriptor` catalog.
59
+ - [vcti-tree](https://github.com/vcollab/vcti-python-tree) / [vcti-datanode](https://github.com/vcollab/vcti-python-datanode) — the tree and payload types.
60
+ - numpy. (CSV writing itself uses only the standard library.)
@@ -0,0 +1,82 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vcti-tree-exporter-csv"
7
+ version = "2.0.0"
8
+ description = "CSV exporter plugin for vcti-tree-exporter: writes a vcti tree as a directory tree of CSV tables mirroring the hierarchy, with each node's attributes in a JSON sidecar."
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Visual Collaboration Technologies Inc."}
12
+ ]
13
+ requires-python = ">=3.12,<3.15"
14
+ license = "LicenseRef-Proprietary"
15
+ license-files = ["LICENSE"]
16
+ keywords = ["tree", "export", "csv", "serialization", "exporter", "plugin"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Operating System :: OS Independent",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "vcti-tree-exporter>=2.0.0",
29
+ "vcti-tree>=2.0.0",
30
+ "vcti-datanode>=2.0.0",
31
+ "numpy>=1.24",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/vcollab/vcti-python-tree-exporter-csv"
36
+ Repository = "https://github.com/vcollab/vcti-python-tree-exporter-csv"
37
+ Changelog = "https://github.com/vcollab/vcti-python-tree-exporter-csv/blob/main/CHANGELOG.md"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+ include = ["vcti.tree.exporter.csv", "vcti.tree.exporter.csv.*"]
42
+
43
+ [tool.setuptools.package-data]
44
+ "vcti.tree.exporter.csv" = ["py.typed"]
45
+
46
+ [project.optional-dependencies]
47
+ test = ["pytest", "pytest-cov"]
48
+ lint = ["ruff"]
49
+ typecheck = ["mypy"]
50
+
51
+ [tool.setuptools]
52
+ zip-safe = true
53
+
54
+ [tool.pytest.ini_options]
55
+ addopts = "--cov=vcti.tree.exporter.csv --cov-report=term-missing --cov-fail-under=95"
56
+
57
+ [tool.mypy]
58
+ python_version = "3.12"
59
+ strict = true
60
+ files = ["src"]
61
+ namespace_packages = true
62
+ explicit_package_bases = true
63
+ mypy_path = ["src"]
64
+
65
+ [tool.coverage.run]
66
+ branch = true
67
+
68
+ [tool.coverage.report]
69
+ exclude_also = [
70
+ "raise NotImplementedError",
71
+ "if TYPE_CHECKING:",
72
+ "if __name__ == .__main__.:",
73
+ "@(abc\\.)?abstractmethod",
74
+ "\\.\\.\\.",
75
+ ]
76
+
77
+ [tool.ruff]
78
+ target-version = "py312"
79
+ line-length = 99
80
+
81
+ [tool.ruff.lint]
82
+ select = ["E", "F", "W", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """vcti.tree.exporter.csv — CSV exporter plugin for vcti-tree-exporter.
4
+
5
+ Provides :class:`CsvExporter`, which serializes a ``vcti.tree`` tree of
6
+ ``vcti.datanode.DataNode`` payloads as a directory tree of CSV tables mirroring
7
+ the hierarchy, with each node's attributes in a JSON sidecar, and
8
+ :func:`get_exporter_descriptor`, the catalog factory a consumer registers with
9
+ ``vcti.tree.exporter.core.build_registry``.
10
+ """
11
+
12
+ from importlib.metadata import version
13
+
14
+ from .exporter import CsvExporter, get_exporter_descriptor
15
+
16
+ __version__ = version("vcti-tree-exporter-csv")
17
+
18
+ __all__ = [
19
+ "CsvExporter",
20
+ "__version__",
21
+ "get_exporter_descriptor",
22
+ ]
@@ -0,0 +1,232 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """CSV exporter — write a vcti tree as a directory tree of CSV tables.
4
+
5
+ Layout: the output path is a directory mirroring the tree. A node with children
6
+ is a sub-directory; a data leaf is a ``<name>.csv`` table. Each node's free-form
7
+ ``attributes`` are written next to it as a JSON sidecar — ``<name>.attributes.json``
8
+ for a leaf, and the reserved ``_attributes.json`` / ``_data.csv`` inside a
9
+ directory for a group node's own attributes/array.
10
+
11
+ CSV suits 2-D tables of settings and metadata that load straight into pandas.
12
+ It does not preserve dtype, and an array with more than two dimensions (or a
13
+ structured array that is not 1-D) raises ``UnrepresentableNodeError``. The
14
+ export is one-way (no matching importer).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import csv
20
+ import json
21
+ import re
22
+ import shutil
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ import numpy as np
27
+ from numpy.typing import NDArray
28
+ from vcti.datanode import DataNode
29
+ from vcti.tree.exporter.core import (
30
+ EXTENSION_ATTR,
31
+ FORMAT_ATTR,
32
+ ExporterDescriptor,
33
+ UnrepresentableNodeError,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from vcti.tree.core import ReadOnlyTree
38
+
39
+ #: Fixed, well-known id for the CSV exporter descriptor.
40
+ EXPORTER_ID = "csv"
41
+
42
+ #: Reserved file names for a group/root node's own array and attributes.
43
+ DATA_FILE = "_data.csv"
44
+ ATTRS_FILE = "_attributes.json"
45
+
46
+ _UNSAFE = re.compile(r"[^A-Za-z0-9._-]")
47
+
48
+
49
+ def _json_default(value: Any) -> Any:
50
+ """Coerce a value JSON cannot serialize natively into a JSON-safe form."""
51
+ if isinstance(value, bytes):
52
+ return value.decode("utf-8", errors="replace").rstrip("\x00")
53
+ if isinstance(value, np.generic):
54
+ return value.item()
55
+ if isinstance(value, np.ndarray):
56
+ return value.tolist()
57
+ return str(value)
58
+
59
+
60
+ def _csv_value(value: Any) -> Any:
61
+ """Coerce a single cell value into a CSV-friendly form.
62
+
63
+ ``csv.writer`` already stringifies numpy scalars sensibly; only ``bytes``
64
+ need decoding so they are not written as a ``b'...'`` repr.
65
+ """
66
+ if isinstance(value, bytes):
67
+ return value.decode("utf-8", errors="replace").rstrip("\x00")
68
+ return value
69
+
70
+
71
+ def _safe_name(name: str) -> str:
72
+ """Sanitize a node name into a filesystem-safe path segment.
73
+
74
+ Replaces characters outside ``[A-Za-z0-9._-]`` and strips leading/trailing
75
+ dots and spaces, so a name such as ``".."`` or ``"../x"`` cannot escape the
76
+ output directory (path traversal) and Windows trailing-dot/space trimming
77
+ cannot silently collide two names.
78
+ """
79
+ safe = _UNSAFE.sub("_", name).strip(". ")
80
+ return safe or "node"
81
+
82
+
83
+ def _write_attrs(attrs: dict[str, Any], path: Path) -> None:
84
+ with path.open("w", encoding="utf-8") as f:
85
+ json.dump(attrs, f, indent=2, ensure_ascii=False, default=_json_default)
86
+
87
+
88
+ def _write_csv(array: NDArray[Any], path: Path) -> None:
89
+ """Write a 1-D/2-D (or structured 1-D) array as a CSV table."""
90
+ names = array.dtype.names
91
+ if array.ndim > 2 or (names is not None and array.ndim > 1):
92
+ raise UnrepresentableNodeError(
93
+ f"array with shape {array.shape} and dtype {array.dtype} "
94
+ "cannot be written as a 2-D CSV table"
95
+ )
96
+ with path.open("w", newline="", encoding="utf-8") as f:
97
+ writer = csv.writer(f)
98
+ if names is not None:
99
+ writer.writerow(names)
100
+ for record in np.atleast_1d(array):
101
+ writer.writerow([_csv_value(v) for v in record])
102
+ elif array.ndim == 2:
103
+ for row in array:
104
+ writer.writerow([_csv_value(v) for v in row])
105
+ else: # 0-D or 1-D plain
106
+ for value in np.atleast_1d(array):
107
+ writer.writerow([_csv_value(value)])
108
+
109
+
110
+ class CsvExporter:
111
+ """Serialize a vcti tree as a directory tree of CSV tables.
112
+
113
+ Satisfies the ``vcti.tree.exporter.core.Exporter`` protocol structurally. Format
114
+ name and (empty) default extension live on its descriptor
115
+ (:func:`get_exporter_descriptor`), not on this class.
116
+ """
117
+
118
+ def export(
119
+ self,
120
+ tree: ReadOnlyTree[DataNode, Any],
121
+ path: Path,
122
+ *,
123
+ overwrite: bool = False,
124
+ ) -> None:
125
+ path = Path(path)
126
+ if path.exists():
127
+ if not overwrite:
128
+ raise FileExistsError(f"Output path already exists: {path}")
129
+ self._remove(path)
130
+
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ path.mkdir()
133
+ self._render_dir(tree, tree.root_handle, path)
134
+
135
+ @staticmethod
136
+ def _remove(path: Path) -> None:
137
+ if path.is_dir():
138
+ shutil.rmtree(path)
139
+ else:
140
+ path.unlink()
141
+
142
+ def _render_dir(
143
+ self,
144
+ tree: ReadOnlyTree[DataNode, Any],
145
+ handle: Any,
146
+ directory: Path,
147
+ ) -> None:
148
+ """Write a group/root node's own content into ``directory``, then children."""
149
+ payload = tree.payload(handle)
150
+ if isinstance(payload, DataNode):
151
+ self._write_own(payload, directory)
152
+
153
+ used: set[str] = {"_data", "_attributes"} # reserved stems in this directory
154
+ for index, child in enumerate(tree.children(handle)):
155
+ self._render_child(tree, child, directory, index, used)
156
+
157
+ def _write_own(self, payload: DataNode, directory: Path) -> None:
158
+ """Write a group/root node's reserved ``_attributes.json`` / ``_data.csv``."""
159
+ attrs = dict(payload.attributes)
160
+ if attrs:
161
+ _write_attrs(attrs, directory / ATTRS_FILE)
162
+ array = self._take_array(payload)
163
+ if array is not None:
164
+ _write_csv(array, directory / DATA_FILE)
165
+
166
+ def _render_child(
167
+ self,
168
+ tree: ReadOnlyTree[DataNode, Any],
169
+ handle: Any,
170
+ parent_dir: Path,
171
+ index: int,
172
+ used: set[str],
173
+ ) -> None:
174
+ payload = tree.payload(handle)
175
+ if not isinstance(payload, DataNode):
176
+ return
177
+
178
+ stem = _unique_stem(_safe_name(payload.name or f"node_{index}"), used)
179
+ children = list(tree.children(handle))
180
+
181
+ if children:
182
+ subdir = parent_dir / stem
183
+ subdir.mkdir()
184
+ self._render_dir(tree, handle, subdir)
185
+ return
186
+
187
+ attrs = dict(payload.attributes)
188
+ array = self._take_array(payload)
189
+ if array is not None:
190
+ _write_csv(array, parent_dir / f"{stem}.csv")
191
+ if attrs:
192
+ _write_attrs(attrs, parent_dir / f"{stem}.attributes.json")
193
+ elif attrs:
194
+ _write_attrs(attrs, parent_dir / f"{stem}.attributes.json")
195
+
196
+ @staticmethod
197
+ def _take_array(payload: DataNode) -> NDArray[Any] | None:
198
+ """Materialise the node's array, releasing a lazy source we loaded."""
199
+ was_loaded = payload.is_loaded
200
+ array = payload.load()
201
+ if array is not None and not was_loaded:
202
+ payload.unload()
203
+ return array
204
+
205
+
206
+ def _unique_stem(name: str, used: set[str]) -> str:
207
+ """Return ``name`` (or a ``name~N`` variant) unused in this directory.
208
+
209
+ Comparison is case-insensitive so two siblings differing only in case do
210
+ not collide on a case-insensitive filesystem.
211
+ """
212
+ candidate = name
213
+ n = 1
214
+ while candidate.lower() in used:
215
+ candidate = f"{name}~{n}"
216
+ n += 1
217
+ used.add(candidate.lower())
218
+ return candidate
219
+
220
+
221
+ def get_exporter_descriptor() -> ExporterDescriptor:
222
+ """Create the :class:`ExporterDescriptor` for the CSV exporter."""
223
+ return ExporterDescriptor(
224
+ id=EXPORTER_ID,
225
+ name="CSV Exporter",
226
+ exporter=CsvExporter(),
227
+ description="Writes a vcti tree as a directory of CSV tables with JSON sidecars.",
228
+ attributes={
229
+ FORMAT_ATTR: "csv",
230
+ EXTENSION_ATTR: "", # writes a directory, not a single file
231
+ },
232
+ )
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-tree-exporter-csv
3
+ Version: 2.0.0
4
+ Summary: CSV exporter plugin for vcti-tree-exporter: writes a vcti tree as a directory tree of CSV tables mirroring the hierarchy, with each node's attributes in a JSON sidecar.
5
+ Author: Visual Collaboration Technologies Inc.
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://github.com/vcollab/vcti-python-tree-exporter-csv
8
+ Project-URL: Repository, https://github.com/vcollab/vcti-python-tree-exporter-csv
9
+ Project-URL: Changelog, https://github.com/vcollab/vcti-python-tree-exporter-csv/blob/main/CHANGELOG.md
10
+ Keywords: tree,export,csv,serialization,exporter,plugin
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: <3.15,>=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: vcti-tree-exporter>=2.0.0
23
+ Requires-Dist: vcti-tree>=2.0.0
24
+ Requires-Dist: vcti-datanode>=2.0.0
25
+ Requires-Dist: numpy>=1.24
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest; extra == "test"
28
+ Requires-Dist: pytest-cov; extra == "test"
29
+ Provides-Extra: lint
30
+ Requires-Dist: ruff; extra == "lint"
31
+ Provides-Extra: typecheck
32
+ Requires-Dist: mypy; extra == "typecheck"
33
+ Dynamic: license-file
34
+
35
+ # CSV Tree Exporter
36
+
37
+ CSV exporter plugin for [`vcti-tree-exporter`](https://github.com/vcollab/vcti-python-tree-exporter): writes a vcti tree as a directory tree of CSV tables.
38
+
39
+ `CsvExporter` serializes any [`vcti-tree`](https://github.com/vcollab/vcti-python-tree)
40
+ tree whose node payloads are
41
+ [`vcti-datanode`](https://github.com/vcollab/vcti-python-datanode) `DataNode`s
42
+ as a directory of CSV tables mirroring the hierarchy, and ships a
43
+ `get_exporter_descriptor()` factory so a consumer registers it in a catalog and
44
+ resolves it by id (`"csv"`) or by attribute lookup.
45
+
46
+ CSV suits **light 2-D tables of settings and metadata** that load straight into
47
+ pandas — not heavy result arrays. Use HDF5 or NPZ for complete numeric data.
48
+
49
+ ## Tree → CSV mapping
50
+
51
+ The output path is a directory:
52
+
53
+ ```
54
+ out/
55
+ _attributes.json # the root node's attributes
56
+ info.attributes.json # a metadata-only leaf -> sidecar only
57
+ materials/ # a group node -> sub-directory
58
+ props.csv # a structured-array leaf -> table with a header row
59
+ props.attributes.json # its attributes
60
+ vec.csv # a plain 1-D array -> one value per row
61
+ grid.csv # a plain 2-D array -> rows
62
+ ```
63
+
64
+ | Tree node | On disk |
65
+ |---|---|
66
+ | node with children | sub-directory named after the node |
67
+ | data leaf | `<name>.csv` (structured → header + rows; plain 1-D/2-D → rows) |
68
+ | a node's `attributes` | `<name>.attributes.json` (leaf) or `_attributes.json` (group) |
69
+ | a group node's own array | `_data.csv` inside its directory |
70
+
71
+ Names are sanitized to filesystem-safe segments and de-collided case-insensitively
72
+ (`Mat`, `mat` → `Mat`, `mat~1`) so nothing is silently overwritten. An array with
73
+ more than two dimensions raises `UnrepresentableNodeError`. CSV does not preserve
74
+ dtype and the export is one-way.
75
+
76
+ ## Usage
77
+
78
+ ```python
79
+ from pathlib import Path
80
+ from vcti.tree.exporter.core import build_registry, get_exporter
81
+ from vcti.tree.exporter.csv import get_exporter_descriptor
82
+
83
+ registry = build_registry([get_exporter_descriptor()]) # plus any other format plugins
84
+ get_exporter(registry, "csv").export(tree, Path("out_dir"), overwrite=True)
85
+ ```
86
+
87
+ `overwrite=True` replaces the existing output directory — point it at a
88
+ dedicated path.
89
+
90
+ ## Dependencies
91
+
92
+ - [vcti-tree-exporter](https://github.com/vcollab/vcti-python-tree-exporter) — the `Exporter` protocol and the `ExporterDescriptor` catalog.
93
+ - [vcti-tree](https://github.com/vcollab/vcti-python-tree) / [vcti-datanode](https://github.com/vcollab/vcti-python-datanode) — the tree and payload types.
94
+ - numpy. (CSV writing itself uses only the standard library.)
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/vcti/tree/exporter/csv/__init__.py
5
+ src/vcti/tree/exporter/csv/exporter.py
6
+ src/vcti/tree/exporter/csv/py.typed
7
+ src/vcti_tree_exporter_csv.egg-info/PKG-INFO
8
+ src/vcti_tree_exporter_csv.egg-info/SOURCES.txt
9
+ src/vcti_tree_exporter_csv.egg-info/dependency_links.txt
10
+ src/vcti_tree_exporter_csv.egg-info/requires.txt
11
+ src/vcti_tree_exporter_csv.egg-info/top_level.txt
12
+ src/vcti_tree_exporter_csv.egg-info/zip-safe
13
+ tests/test_exporter.py
14
+ tests/test_version.py
@@ -0,0 +1,14 @@
1
+ vcti-tree-exporter>=2.0.0
2
+ vcti-tree>=2.0.0
3
+ vcti-datanode>=2.0.0
4
+ numpy>=1.24
5
+
6
+ [lint]
7
+ ruff
8
+
9
+ [test]
10
+ pytest
11
+ pytest-cov
12
+
13
+ [typecheck]
14
+ mypy
@@ -0,0 +1,230 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Tests for the CSV exporter."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import csv
8
+ import json
9
+
10
+ import numpy as np
11
+ import pytest
12
+ from vcti.datanode import DataNode, EagerDataSource, LazyDataSource
13
+ from vcti.lookup import Rule
14
+ from vcti.tree.core import DictTree
15
+ from vcti.tree.exporter.core import (
16
+ EXTENSION_ATTR,
17
+ FORMAT_ATTR,
18
+ Exporter,
19
+ UnrepresentableNodeError,
20
+ build_registry,
21
+ get_exporter,
22
+ )
23
+
24
+ from vcti.tree.exporter.csv import CsvExporter, get_exporter_descriptor
25
+
26
+
27
+ def _data(array: np.ndarray, *, name: str) -> DataNode:
28
+ return DataNode(name=name, data_source=EagerDataSource(array))
29
+
30
+
31
+ def _read_csv(path):
32
+ with path.open(newline="", encoding="utf-8") as f:
33
+ return list(csv.reader(f))
34
+
35
+
36
+ def _read_json(path):
37
+ with path.open(encoding="utf-8") as f:
38
+ return json.load(f)
39
+
40
+
41
+ def _sample_tree() -> DictTree:
42
+ tree: DictTree = DictTree(DataNode(name="model", source_attributes={"solver": "abaqus"}))
43
+ root = tree.root_handle
44
+
45
+ # metadata-only leaf -> sidecar only
46
+ tree.create_child(root, DataNode(name="info", source_attributes={"count": np.int64(7)}))
47
+
48
+ # group with a structured-table leaf carrying attributes
49
+ materials = tree.create_child(root, DataNode(name="materials"))
50
+ table = np.array([("Steel", 210.0), ("Al", 70.0)], dtype=[("name", "U8"), ("E", "f8")])
51
+ tree.create_child(
52
+ materials,
53
+ DataNode(
54
+ name="props",
55
+ data_source=EagerDataSource(table),
56
+ source_attributes={"unit": "GPa"},
57
+ ),
58
+ )
59
+
60
+ # plain 1-D and 2-D leaves at the root
61
+ tree.create_child(root, _data(np.array([1.0, 2.0, 3.0]), name="vec"))
62
+ tree.create_child(root, _data(np.arange(6).reshape(2, 3), name="grid"))
63
+
64
+ # empty leaf writes nothing; non-DataNode payload is skipped
65
+ tree.create_child(root, DataNode(name="empty"))
66
+ tree.create_child(root, "not-a-datanode")
67
+ return tree
68
+
69
+
70
+ def _export(tmp_path, tree, name="out"):
71
+ out = tmp_path / name
72
+ CsvExporter().export(tree, out)
73
+ return out
74
+
75
+
76
+ def test_protocol_conformance():
77
+ assert isinstance(CsvExporter(), Exporter)
78
+
79
+
80
+ def test_descriptor_factory_records_metadata():
81
+ desc = get_exporter_descriptor()
82
+ assert desc.id == "csv"
83
+ assert isinstance(desc.exporter, CsvExporter)
84
+ assert desc.attributes[FORMAT_ATTR] == "csv"
85
+ assert desc.attributes[EXTENSION_ATTR] == "" # directory output
86
+
87
+
88
+ def test_root_attributes_and_directory_layout(tmp_path):
89
+ out = _export(tmp_path, _sample_tree())
90
+ # root attributes -> reserved sidecar in the top directory
91
+ assert _read_json(out / "_attributes.json") == {"solver": "abaqus"}
92
+ # group node -> sub-directory
93
+ assert (out / "materials").is_dir()
94
+ # empty leaf produced nothing
95
+ assert not (out / "empty.csv").exists()
96
+ assert not (out / "empty.attributes.json").exists()
97
+
98
+
99
+ def test_metadata_only_leaf_writes_sidecar_only(tmp_path):
100
+ out = _export(tmp_path, _sample_tree())
101
+ assert not (out / "info.csv").exists()
102
+ assert _read_json(out / "info.attributes.json") == {"count": 7}
103
+
104
+
105
+ def test_structured_table_has_header_and_sidecar(tmp_path):
106
+ out = _export(tmp_path, _sample_tree())
107
+ rows = _read_csv(out / "materials" / "props.csv")
108
+ assert rows[0] == ["name", "E"] # field-name header
109
+ assert rows[1] == ["Steel", "210.0"]
110
+ assert _read_json(out / "materials" / "props.attributes.json") == {"unit": "GPa"}
111
+
112
+
113
+ def test_plain_1d_and_2d_arrays(tmp_path):
114
+ out = _export(tmp_path, _sample_tree())
115
+ assert _read_csv(out / "vec.csv") == [["1.0"], ["2.0"], ["3.0"]]
116
+ assert _read_csv(out / "grid.csv") == [["0", "1", "2"], ["3", "4", "5"]]
117
+
118
+
119
+ def test_node_with_both_data_and_children(tmp_path):
120
+ tree: DictTree = DictTree(DataNode(name="root"))
121
+ parent = tree.create_child(
122
+ tree.root_handle,
123
+ DataNode(name="parent", data_source=EagerDataSource(np.array([9]))),
124
+ )
125
+ tree.create_child(parent, DataNode(name="kid", source_attributes={"k": 1}))
126
+ out = _export(tmp_path, tree)
127
+ # the group's own array lands in the reserved _data.csv inside its directory
128
+ assert _read_csv(out / "parent" / "_data.csv") == [["9"]]
129
+ assert _read_json(out / "parent" / "kid.attributes.json") == {"k": 1}
130
+
131
+
132
+ def test_attribute_values_are_json_encoded(tmp_path):
133
+ tree: DictTree = DictTree(DataNode(name="root"))
134
+ tree.create_child(
135
+ tree.root_handle,
136
+ DataNode(
137
+ name="n",
138
+ source_attributes={
139
+ "label": b"ABAQUS", # bytes -> decoded
140
+ "vec": np.array([1.0, 2.0]), # ndarray -> list
141
+ "tags": {"a", "b"}, # set -> str() fallback
142
+ },
143
+ ),
144
+ )
145
+ out = _export(tmp_path, tree)
146
+ attrs = _read_json(out / "n.attributes.json")
147
+ assert attrs["label"] == "ABAQUS"
148
+ assert attrs["vec"] == [1.0, 2.0]
149
+ assert isinstance(attrs["tags"], str)
150
+
151
+
152
+ def test_bytes_cells_are_decoded(tmp_path):
153
+ tree: DictTree = DictTree(DataNode(name="root"))
154
+ tree.create_child(tree.root_handle, _data(np.array([b"mm", b"t"]), name="units"))
155
+ out = _export(tmp_path, tree)
156
+ assert _read_csv(out / "units.csv") == [["mm"], ["t"]]
157
+
158
+
159
+ def test_traversal_names_are_neutralised(tmp_path):
160
+ tree: DictTree = DictTree(DataNode(name="root"))
161
+ tree.create_child(tree.root_handle, _data(np.array([1]), name=".."))
162
+ tree.create_child(tree.root_handle, _data(np.array([2]), name="../escape"))
163
+ out = _export(tmp_path, tree)
164
+
165
+ # nothing escaped the output directory
166
+ assert list(out.parent.iterdir()) == [out]
167
+ assert sorted(p.name for p in out.glob("*.csv")) == ["_escape.csv", "node.csv"]
168
+
169
+
170
+ def test_non_datanode_root_writes_empty_directory(tmp_path):
171
+ tree: DictTree = DictTree("not-a-datanode")
172
+ out = _export(tmp_path, tree)
173
+ assert out.is_dir()
174
+ assert list(out.iterdir()) == []
175
+
176
+
177
+ def test_more_than_2d_raises(tmp_path):
178
+ tree: DictTree = DictTree(DataNode(name="root"))
179
+ tree.create_child(tree.root_handle, _data(np.zeros((2, 2, 2)), name="cube"))
180
+ with pytest.raises(UnrepresentableNodeError, match="2-D CSV table"):
181
+ CsvExporter().export(tree, tmp_path / "out")
182
+
183
+
184
+ def test_name_sanitised_and_case_collision_deduped(tmp_path):
185
+ tree: DictTree = DictTree(DataNode(name="root"))
186
+ tree.create_child(tree.root_handle, _data(np.array([1]), name="a/b")) # unsafe '/'
187
+ tree.create_child(tree.root_handle, _data(np.array([2]), name="Mat"))
188
+ tree.create_child(tree.root_handle, _data(np.array([3]), name="mat")) # case-collides
189
+ out = _export(tmp_path, tree)
190
+ names = sorted(p.name for p in out.glob("*.csv"))
191
+ assert names == ["Mat.csv", "a_b.csv", "mat~1.csv"]
192
+
193
+
194
+ def test_lazy_node_materialised_then_released(tmp_path):
195
+ tree: DictTree = DictTree(DataNode(name="root"))
196
+ node = DataNode(name="lazy", data_source=LazyDataSource(lambda: np.array([5, 6]), shape=(2,)))
197
+ tree.create_child(tree.root_handle, node)
198
+ assert not node.is_loaded
199
+ out = _export(tmp_path, tree)
200
+ assert _read_csv(out / "lazy.csv") == [["5"], ["6"]]
201
+ assert not node.is_loaded # restored
202
+
203
+
204
+ def test_overwrite_behavior(tmp_path):
205
+ tree = _sample_tree()
206
+ out = tmp_path / "out"
207
+ CsvExporter().export(tree, out)
208
+ with pytest.raises(FileExistsError):
209
+ CsvExporter().export(tree, out)
210
+ CsvExporter().export(tree, out, overwrite=True) # replaces the directory
211
+
212
+
213
+ def test_overwrite_replaces_a_file_at_the_path(tmp_path):
214
+ out = tmp_path / "out"
215
+ out.write_text("i am a file")
216
+ CsvExporter().export(_sample_tree(), out, overwrite=True)
217
+ assert out.is_dir()
218
+
219
+
220
+ def test_creates_parent_directories(tmp_path):
221
+ out = tmp_path / "nested" / "deep" / "out"
222
+ CsvExporter().export(_sample_tree(), out)
223
+ assert (out / "_attributes.json").exists()
224
+
225
+
226
+ def test_registry_integration():
227
+ registry = build_registry([get_exporter_descriptor()])
228
+ assert isinstance(get_exporter(registry, "csv"), CsvExporter)
229
+ (desc,) = registry.lookup.filter([Rule(FORMAT_ATTR, "==", "csv")])
230
+ assert desc.id == "csv"
@@ -0,0 +1,15 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Version tests for vcti-tree-exporter-csv."""
4
+
5
+ import re
6
+
7
+ import vcti.tree.exporter.csv
8
+
9
+
10
+ class TestVersion:
11
+ def test_version_exists(self):
12
+ assert hasattr(vcti.tree.exporter.csv, "__version__")
13
+
14
+ def test_version_is_valid_semver(self):
15
+ assert re.match(r"^\d+\.\d+\.\d+", vcti.tree.exporter.csv.__version__)