data-director-inventory 0.1.0a3__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.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: data-director-inventory
3
+ Version: 0.1.0-alpha.3
4
+ Summary: Describe a dataset's files where they live: size, digest, PRONOM format and table fields, without uploading them
5
+ Project-URL: Homepage, https://data-director-agent.github.io/data-director/
6
+ Project-URL: Documentation, https://data-director-agent.github.io/data-director/user-guide/making-an-inventory/
7
+ Project-URL: Repository, https://github.com/data-director-agent/data-director/tree/main/inventory
8
+ Project-URL: Changelog, https://github.com/data-director-agent/data-director/blob/main/inventory/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/data-director-agent/data-director/issues
10
+ Author-email: Joe Heffer <j.heffer@sheffield.ac.uk>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: FAIR,Frictionless,PRONOM,checksum,data management,inventory,research data
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
21
+ Classifier: Topic :: System :: Archiving
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.14
24
+ Requires-Dist: frictionless<6,>=5.18
25
+ Requires-Dist: opf-fido==1.6.1
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Data Director inventory
29
+
30
+ [![CI](https://github.com/data-director-agent/data-director/actions/workflows/ci.yml/badge.svg)](https://github.com/data-director-agent/data-director/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/data-director-agent/data-director/graph/badge.svg?flag=inventory)](https://codecov.io/gh/data-director-agent/data-director?flags[0]=inventory)
31
+
32
+ `dd-inventory` describes a dataset's files where they live. It reads every file under a folder
33
+ and writes a `FileInventory`, a JSON document of the core contract, that gives for each file:
34
+
35
+ - its path relative to the folder;
36
+ - its size and its SHA-256 digest;
37
+ - its media type and every [PRONOM](https://www.nationalarchives.gov.uk/PRONOM/) identifier it
38
+ matched, found with [fido](https://github.com/openpreserve/fido), and whether the match was by
39
+ byte signature, container signature or extension only;
40
+ - for a CSV or TSV table, its fields as a [Frictionless](https://framework.frictionlessdata.io/)
41
+ Table Schema inferred from a sample of rows.
42
+
43
+ The inventory holds facts about the files and never their content. Research data are not
44
+ uploaded to Data Director: the researcher adds the inventory to their project instead
45
+ ([Projects](https://github.com/data-director-agent/data-director/blob/main/docs/specs/projects.md)). The digests let the files deposited
46
+ later be shown to be the files described.
47
+
48
+ ```console
49
+ $ uv run dd-inventory path/to/dataset -o inventory.json
50
+ ```
51
+
52
+ The package installs two names for the same command, `dd-inventory` and `data-director-inventory`,
53
+ so that `uvx data-director-inventory path/to/dataset` works. It is published to [PyPI](https://pypi.org/p/data-director-inventory) and, for trying a release first, TestPyPI ([RELEASING.md](https://github.com/data-director-agent/data-director/blob/main/RELEASING.md)).
54
+
55
+ A researcher without a checkout runs it with `uvx` and attaches the file to a conversation in the
56
+ app; the user guide's [Making an inventory](https://data-director-agent.github.io/data-director/user-guide/making-an-inventory/)
57
+ says how.
58
+
59
+ Hidden files and folders (names beginning with `.`) are skipped, symbolic links are not
60
+ followed, and both are counted in `inventory_limitations`. If fido cannot be loaded, formats are
61
+ guessed from file extensions and the inventory says so.
62
+
63
+ To read an inventory in TypeScript, as the app does to show one to the researcher, use
64
+ [`@data-director/inventory`](https://github.com/data-director-agent/data-director/blob/main/inventory/browser/README.md) in `browser/`, which is not part of this Python
65
+ package.
66
+
67
+ The package does not depend on the contract or any other part of Data Director
68
+ ([System and boundaries](https://github.com/data-director-agent/data-director/blob/main/docs/specs/system.md)). Its tests check
69
+ what it writes against the contract's `FileInventory` schema.
70
+
71
+ | Module | What it does |
72
+ |---|---|
73
+ | `scan.py` | Walks the folder, digests each file and assembles the inventory. |
74
+ | `identify.py` | The one module that calls fido, with the extension fallback. |
75
+ | `tables.py` | Infers a table's fields with Frictionless. |
76
+ | `cli.py` | The `dd-inventory` command. |
@@ -0,0 +1,11 @@
1
+ dd_inventory/__init__.py,sha256=hp_trp3Pam6GMuOOENTkEsSRQUD1lUfIXimLgsKLIRg,497
2
+ dd_inventory/cli.py,sha256=IpKNZpqRL8tmDEmup6pemmpDt5eT7quVR2y9PfmAjq4,1562
3
+ dd_inventory/identify.py,sha256=cw3ZGZX9S98e_Mh8EwMDONDVy3HTHwDuHkqG039UUs4,5249
4
+ dd_inventory/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ dd_inventory/scan.py,sha256=tabMZCclE802M7N7IFo1XnJHhxaPqdC2Ti9_gN08k9c,4279
6
+ dd_inventory/tables.py,sha256=g5yS9IsXWbndBKLl4BOlpaKCqAmIrHdRGKL33BcgB_I,1658
7
+ data_director_inventory-0.1.0a3.dist-info/METADATA,sha256=OAbPEgMwBj0lGsQn09OyhM3V-SADkTEDlTItx7ISfx8,4558
8
+ data_director_inventory-0.1.0a3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ data_director_inventory-0.1.0a3.dist-info/entry_points.txt,sha256=AkvceYfOpf-5a2II6BpKABtdaD4NTgHID9KdgmWRJBw,103
10
+ data_director_inventory-0.1.0a3.dist-info/licenses/LICENSE,sha256=6gZU8sLgglMXGyB_q9vTYivSiRPHrFMLadPc7ydtLbg,1084
11
+ data_director_inventory-0.1.0a3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ data-director-inventory = dd_inventory.cli:main
3
+ dd-inventory = dd_inventory.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The University of Sheffield
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ """Describe a dataset's files where they live, without sending them anywhere
2
+ (docs/specs/projects.md).
3
+
4
+ `dd-inventory ROOT` walks a folder and writes a `FileInventory`: for each file its path relative
5
+ to ROOT, its size, its SHA-256 digest, its format identified against PRONOM, and for a table its
6
+ fields. The inventory holds facts about the files and never their content.
7
+ """
8
+
9
+ from dd_inventory.scan import TOOL, make_inventory, tool_version
10
+
11
+ __all__ = ["TOOL", "make_inventory", "tool_version"]
dd_inventory/cli.py ADDED
@@ -0,0 +1,46 @@
1
+ """`dd-inventory ROOT [-o FILE]`: write the inventory of a dataset's files as JSON.
2
+
3
+ Run it where the data live. It reads every file under ROOT and sends nothing anywhere; the
4
+ researcher adds the JSON it writes to their project.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from dd_inventory.scan import make_inventory, tool_version
15
+
16
+ EXIT_OK, EXIT_USAGE = 0, 2
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(
21
+ prog="dd-inventory",
22
+ description=(
23
+ "Describe the files under ROOT (paths, sizes, SHA-256 digests, PRONOM formats and "
24
+ "table fields) as a Data Director FileInventory. No file content is written."
25
+ ),
26
+ )
27
+ parser.add_argument("root", type=Path, metavar="ROOT", help="the dataset's root folder")
28
+ parser.add_argument(
29
+ "-o", "--output", type=Path, help="write the inventory to this file, not standard output"
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"%(prog)s {tool_version()}")
32
+ args = parser.parse_args(argv)
33
+ if not args.root.is_dir():
34
+ parser.print_usage(sys.stderr)
35
+ sys.stderr.write(f"dd-inventory: {args.root} is not a folder\n")
36
+ return EXIT_USAGE
37
+ text = json.dumps(make_inventory(args.root), indent=2, ensure_ascii=False) + "\n"
38
+ if args.output:
39
+ args.output.write_text(text, encoding="utf-8")
40
+ else:
41
+ sys.stdout.write(text)
42
+ return EXIT_OK
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ """Identify a file's format against PRONOM with fido. The one module that knows fido.
2
+
3
+ fido reads the start and end of a file and matches them against PRONOM's byte signatures, then
4
+ its container signatures for ZIP and OLE2 files, and falls back to the file's extension. Each
5
+ step can match several formats, so every PRONOM identifier (PUID) it returns is kept, with the
6
+ step that found them.
7
+
8
+ Where fido cannot be loaded, `ExtensionIdentifier` guesses a media type from the extension and
9
+ gives no PUID (docs/specs/projects.md, dependencies).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import io
16
+ import mimetypes
17
+ import warnings
18
+ from collections.abc import Iterator
19
+ from dataclasses import dataclass, field
20
+ from importlib.metadata import version
21
+ from pathlib import Path
22
+ from typing import Any, Protocol
23
+ from xml.etree import ElementTree
24
+
25
+ # fido's step names are the core's `IdentificationMethod` values; an empty match is `none`.
26
+ SIGNATURE, CONTAINER, EXTENSION, NONE = "signature", "container", "extension", "none"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Identification:
31
+ method: str
32
+ pronom_ids: list[str] = field(default_factory=list)
33
+ media_type: str | None = None
34
+
35
+
36
+ class Identifier(Protocol):
37
+ # What identified the formats, recorded in the inventory.
38
+ description: str
39
+
40
+ def identify(self, path: Path) -> Identification: ...
41
+
42
+
43
+ class FidoUnavailableError(Exception):
44
+ """fido could not be loaded; the reason is recorded in the inventory's limitations."""
45
+
46
+
47
+ @contextlib.contextmanager
48
+ def _quiet_fido() -> Iterator[None]:
49
+ """fido tests an XML element's truth value, which Python 3.12 deprecates, and writes notices
50
+ (an empty file, an unreadable one) to stderr itself. Neither is the caller's to see."""
51
+ with warnings.catch_warnings(), contextlib.redirect_stderr(io.StringIO()):
52
+ warnings.simplefilter("ignore", DeprecationWarning)
53
+ yield
54
+
55
+
56
+ def guess_media_type(path: Path) -> str | None:
57
+ return mimetypes.guess_type(path.name, strict=False)[0]
58
+
59
+
60
+ class ExtensionIdentifier:
61
+ description = "file extensions only"
62
+
63
+ def identify(self, path: Path) -> Identification:
64
+ media_type = guess_media_type(path)
65
+ return Identification(EXTENSION if media_type else NONE, [], media_type)
66
+
67
+
68
+ class FidoIdentifier:
69
+ """fido's own steps, in the order its `identify_file` takes them, on a file this opens and
70
+ closes: `identify_file` leaves every file it reads open."""
71
+
72
+ def __init__(self) -> None:
73
+ try:
74
+ from fido import CONFIG_DIR
75
+ from fido.fido import Fido
76
+ from fido.package import OlePackage, ZipPackage
77
+ from fido.versions import get_local_versions
78
+
79
+ # The library's defaults name a signature file fido 1.6.1 does not ship; its CLI
80
+ # reads the files from versions.xml, and so does this.
81
+ versions = get_local_versions()
82
+ with _quiet_fido():
83
+ self._fido = Fido(
84
+ quiet=True,
85
+ format_files=[versions.pronom_signature, versions.fido_extension_signature],
86
+ )
87
+ self._containers = ElementTree.parse(
88
+ Path(CONFIG_DIR) / versions.pronom_container_signature
89
+ )
90
+ pronom = versions.pronom_version
91
+ except Exception as exc: # fido raises bare OSError, ParseError, ...
92
+ raise FidoUnavailableError(f"fido could not be loaded: {exc}") from exc
93
+ self._packages = {"zip": ("ZIP", ZipPackage), "ole": ("OLE2", OlePackage)}
94
+ self.description = f"fido {version('opf-fido')} with PRONOM signatures v{pronom}"
95
+
96
+ def identify(self, path: Path) -> Identification:
97
+ fido = self._fido
98
+ with _quiet_fido():
99
+ size = path.stat().st_size
100
+ with path.open("rb") as fh:
101
+ bof, eof, _read = fido.get_buffers(fh, size, seekable=True)
102
+ # An empty file matches RTF's signature, so fido goes by its extension instead.
103
+ matches = fido.match_formats(bof, eof) if size else []
104
+ package = self._packages.get(fido.container_type(matches))
105
+ if package is not None:
106
+ kind, cls = package
107
+ inside = fido.match_container(kind, cls, str(path), self._containers)
108
+ if inside:
109
+ return self._found(path, inside, CONTAINER)
110
+ if matches:
111
+ return self._found(path, matches, SIGNATURE)
112
+ return self._found(path, fido.match_extensions(str(path)), EXTENSION)
113
+
114
+ @staticmethod
115
+ def _found(path: Path, matches: list[tuple[Any, Any]], method: str) -> Identification:
116
+ puids = [p.text for f, _sig in matches if (p := f.find("puid")) is not None and p.text]
117
+ mimes = [m.text for f, _sig in matches if (m := f.find("mime")) is not None and m.text]
118
+ media_type = mimes[0] if mimes else guess_media_type(path)
119
+ return Identification(method if puids else NONE, puids, media_type)
120
+
121
+
122
+ def default_identifier() -> tuple[Identifier, str | None]:
123
+ """fido, or the extension fallback and why fido was not used."""
124
+ try:
125
+ return FidoIdentifier(), None
126
+ except FidoUnavailableError as exc:
127
+ return ExtensionIdentifier(), str(exc)
dd_inventory/py.typed ADDED
File without changes
dd_inventory/scan.py ADDED
@@ -0,0 +1,119 @@
1
+ """Walk a dataset's root folder and make its `FileInventory` (docs/specs/projects.md).
2
+
3
+ The inventory is a plain JSON document of the core contract class `FileInventory`; this package
4
+ does not need the contract to make one. Paths are relative to the root and sorted, so two runs
5
+ over the same files differ only in `inventory_made_at`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ from datetime import UTC, datetime
13
+ from importlib.metadata import version
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Any
16
+
17
+ from dd_inventory.identify import Identifier, default_identifier
18
+ from dd_inventory.tables import TableError, is_table, table_fields
19
+
20
+ TOOL = "dd-inventory"
21
+ SCHEMA_CLASS = "FileInventory"
22
+
23
+
24
+ def tool_version() -> str:
25
+ return version("data-director-inventory")
26
+
27
+
28
+ def sha256(path: Path) -> str:
29
+ with path.open("rb") as fh:
30
+ return hashlib.file_digest(fh, "sha256").hexdigest()
31
+
32
+
33
+ def _walk(root: Path) -> tuple[list[Path], list[str]]:
34
+ """Every regular file under `root`, and what was left out. Hidden files and links are not
35
+ followed: a link may point outside the dataset, and a hidden file is rarely part of it."""
36
+ found: list[Path] = []
37
+ hidden = links = 0
38
+ for folder, dirs, names in os.walk(root, followlinks=False):
39
+ here = Path(folder)
40
+ for d in list(dirs):
41
+ if d.startswith(".") or (here / d).is_symlink():
42
+ hidden += d.startswith(".")
43
+ links += not d.startswith(".")
44
+ dirs.remove(d)
45
+ for name in names:
46
+ path = here / name
47
+ if name.startswith("."):
48
+ hidden += 1
49
+ elif path.is_symlink():
50
+ links += 1
51
+ elif path.is_file():
52
+ found.append(path)
53
+ skipped = []
54
+ if hidden:
55
+ skipped.append(
56
+ f"{hidden} hidden file(s) or folder(s), whose names begin with '.', were skipped."
57
+ )
58
+ if links:
59
+ skipped.append(f"{links} symbolic link(s) were not followed.")
60
+ return sorted(found, key=lambda p: p.relative_to(root).as_posix()), skipped
61
+
62
+
63
+ def make_inventory(
64
+ root: Path, *, identifier: Identifier | None = None, now: datetime | None = None
65
+ ) -> dict[str, Any]:
66
+ """The `FileInventory` of every file under `root`, as a JSON-ready dict."""
67
+ root = root.resolve()
68
+ if not root.is_dir():
69
+ raise NotADirectoryError(root)
70
+ limitations: list[str] = []
71
+ if identifier is None:
72
+ identifier, unavailable = default_identifier()
73
+ if unavailable:
74
+ limitations.append(f"{unavailable}; formats were guessed from file extensions only.")
75
+ paths, skipped = _walk(root)
76
+ limitations += skipped
77
+ files = []
78
+ for path in paths:
79
+ try:
80
+ files.append(_describe(root, path, identifier, limitations))
81
+ except OSError as exc:
82
+ limitations.append(
83
+ f"{path.relative_to(root).as_posix()} could not be read: {exc.strerror}."
84
+ )
85
+ limitations.append(
86
+ "Table fields are inferred from a sample of rows; a later row may not fit the type given."
87
+ )
88
+ made_at = (now or datetime.now(UTC)).isoformat(timespec="seconds")
89
+ return {
90
+ "schema_class": SCHEMA_CLASS,
91
+ "inventory_files": files,
92
+ "inventory_tool": TOOL,
93
+ "inventory_tool_version": tool_version(),
94
+ "inventory_identified_with": identifier.description,
95
+ "inventory_made_at": made_at,
96
+ "inventory_limitations": limitations,
97
+ }
98
+
99
+
100
+ def _describe(
101
+ root: Path, path: Path, identifier: Identifier, limitations: list[str]
102
+ ) -> dict[str, Any]:
103
+ relative = PurePosixPath(path.relative_to(root).as_posix())
104
+ found = identifier.identify(path)
105
+ entry: dict[str, Any] = {
106
+ "file_path": str(relative),
107
+ "file_size": path.stat().st_size,
108
+ "file_sha256": sha256(path),
109
+ }
110
+ if found.media_type:
111
+ entry["file_media_type"] = found.media_type
112
+ entry["file_pronom_ids"] = found.pronom_ids
113
+ entry["file_identified_by"] = found.method
114
+ if is_table(path):
115
+ try:
116
+ entry["file_fields"] = table_fields(path)
117
+ except TableError as exc:
118
+ limitations.append(f"The fields of {relative} could not be inferred: {exc}")
119
+ return entry
dd_inventory/tables.py ADDED
@@ -0,0 +1,48 @@
1
+ """Infer a table's fields as a Frictionless Table Schema.
2
+
3
+ Only delimited text is described: CSV and TSV. Frictionless infers each field's type and format
4
+ from a sample of rows, so a type is a guess that later rows may contradict.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ # Rows Frictionless reads to infer types; its own default is 100.
13
+ SAMPLE_ROWS = 1000
14
+ TABLE_SUFFIXES = {".csv": ",", ".tsv": "\t", ".tab": "\t"}
15
+
16
+
17
+ class TableError(Exception):
18
+ """The file looked like a table but its fields could not be inferred."""
19
+
20
+
21
+ def is_table(path: Path) -> bool:
22
+ return path.suffix.lower() in TABLE_SUFFIXES
23
+
24
+
25
+ def table_fields(path: Path) -> list[dict[str, Any]]:
26
+ """The table's fields as `TableField` objects: name, field_type and, if any, field_format."""
27
+ from frictionless import Detector, Dialect, Resource, formats
28
+
29
+ control = formats.CsvControl(delimiter=TABLE_SUFFIXES[path.suffix.lower()])
30
+ try:
31
+ resource = Resource(
32
+ path=path.name,
33
+ basepath=str(path.parent),
34
+ format="csv",
35
+ dialect=Dialect(controls=[control]),
36
+ detector=Detector(sample_size=SAMPLE_ROWS),
37
+ )
38
+ resource.infer()
39
+ schema = resource.schema
40
+ except Exception as exc: # Frictionless raises its own errors and the reader's
41
+ raise TableError(f"{path.name}: {exc}") from exc
42
+ fields = []
43
+ for f in schema.fields:
44
+ entry: dict[str, Any] = {"name": f.name, "field_type": f.type}
45
+ if f.format and f.format != "default":
46
+ entry["field_format"] = f.format
47
+ fields.append(entry)
48
+ return fields