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.
- data_director_inventory-0.1.0a3.dist-info/METADATA +76 -0
- data_director_inventory-0.1.0a3.dist-info/RECORD +11 -0
- data_director_inventory-0.1.0a3.dist-info/WHEEL +4 -0
- data_director_inventory-0.1.0a3.dist-info/entry_points.txt +3 -0
- data_director_inventory-0.1.0a3.dist-info/licenses/LICENSE +21 -0
- dd_inventory/__init__.py +11 -0
- dd_inventory/cli.py +46 -0
- dd_inventory/identify.py +127 -0
- dd_inventory/py.typed +0 -0
- dd_inventory/scan.py +119 -0
- dd_inventory/tables.py +48 -0
|
@@ -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
|
+
[](https://github.com/data-director-agent/data-director/actions/workflows/ci.yml) [](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,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.
|
dd_inventory/__init__.py
ADDED
|
@@ -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())
|
dd_inventory/identify.py
ADDED
|
@@ -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
|