data-director-inventory 0.1.0a3__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,240 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+ .report.json
54
+ # Playwright traces from workbench/browser_tests (pytest-playwright --output)
55
+ test-results/
56
+ # The lock export the dependency audit reads (.github/workflows/audit.yml)
57
+ requirements-audit.txt
58
+
59
+ # Translations
60
+ *.mo
61
+ *.pot
62
+
63
+ # Django stuff:
64
+ *.log
65
+ local_settings.py
66
+ db.sqlite3
67
+ db.sqlite3-journal
68
+
69
+ # Flask stuff:
70
+ instance/
71
+ .webassets-cache
72
+
73
+ # Scrapy stuff:
74
+ .scrapy
75
+
76
+ # Sphinx documentation
77
+ docs/_build/
78
+
79
+ # PyBuilder
80
+ .pybuilder/
81
+ target/
82
+
83
+ # Jupyter Notebook
84
+ .ipynb_checkpoints
85
+
86
+ # IPython
87
+ profile_default/
88
+ ipython_config.py
89
+
90
+ # pyenv
91
+ # For a library or package, you might want to ignore these files since the code is
92
+ # intended to run in multiple environments; otherwise, check them in:
93
+ # .python-version
94
+
95
+ # pipenv
96
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
97
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
98
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
99
+ # install all needed dependencies.
100
+ # Pipfile.lock
101
+
102
+ # UV
103
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
104
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
105
+ # commonly ignored for libraries.
106
+ # uv.lock
107
+
108
+ # poetry
109
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
110
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
111
+ # commonly ignored for libraries.
112
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
113
+ # poetry.lock
114
+ # poetry.toml
115
+
116
+ # pdm
117
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
118
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
119
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
120
+ # pdm.lock
121
+ # pdm.toml
122
+ .pdm-python
123
+ .pdm-build/
124
+
125
+ # pixi
126
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
127
+ # pixi.lock
128
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
129
+ # in the .venv directory. It is recommended not to include this directory in version control.
130
+ .pixi
131
+
132
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
133
+ __pypackages__/
134
+
135
+ # Celery stuff
136
+ celerybeat-schedule
137
+ celerybeat.pid
138
+
139
+ # Redis
140
+ *.rdb
141
+ *.aof
142
+ *.pid
143
+
144
+ # RabbitMQ
145
+ mnesia/
146
+ rabbitmq/
147
+ rabbitmq-data/
148
+
149
+ # ActiveMQ
150
+ activemq-data/
151
+
152
+ # SageMath parsed files
153
+ *.sage.py
154
+
155
+ # Environments
156
+ .env
157
+ .envrc
158
+ # Local development settings and model keys (docs/specs/policy-and-identity.md).
159
+ config/local/
160
+ # Per-agent env files under agents/ hold API keys; the bare .env rule misses *.env names.
161
+ agents/**/*.env
162
+ agents/**/.env.*
163
+ .venv
164
+ env/
165
+ venv/
166
+ ENV/
167
+ env.bak/
168
+ venv.bak/
169
+
170
+ # Spyder project settings
171
+ .spyderproject
172
+ .spyproject
173
+
174
+ # Rope project settings
175
+ .ropeproject
176
+
177
+ # mkdocs documentation
178
+ /site
179
+
180
+ # mypy
181
+ .mypy_cache/
182
+ .dmypy.json
183
+ dmypy.json
184
+
185
+ # Pyre type checker
186
+ .pyre/
187
+
188
+ # pytype static type analyzer
189
+ .pytype/
190
+
191
+ # Cython debug symbols
192
+ cython_debug/
193
+
194
+ # PyCharm
195
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
196
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
197
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
198
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
199
+ # .idea/
200
+
201
+ # Abstra
202
+ # Abstra is an AI-powered process automation framework.
203
+ # Ignore directories containing user credentials, local state, and settings.
204
+ # Learn more at https://abstra.io/docs
205
+ .abstra/
206
+
207
+ # Visual Studio Code
208
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
209
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
210
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
211
+ # you could uncomment the following to ignore the entire vscode folder
212
+ # .vscode/
213
+ # Temporary file for partial code execution
214
+ tempCodeRunnerFile.py
215
+
216
+ # Ruff stuff:
217
+ .ruff_cache/
218
+
219
+ # PyPI configuration file
220
+ .pypirc
221
+
222
+ # Marimo
223
+ marimo/_static/
224
+ marimo/_lsp/
225
+ __marimo__/
226
+
227
+ # Streamlit
228
+ .streamlit/secrets.toml
229
+
230
+ # Inspect AI evaluation logs (docs/specs/evaluation.md)
231
+ logs/
232
+
233
+ # MkDocs build output
234
+ /site/
235
+
236
+ # A local copy of the app profile (app/config/profile.yaml.example) for editing.
237
+ app/config/profile.yaml
238
+
239
+ # Coding agents
240
+ .claude/worktrees/
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0-alpha.3](https://github.com/data-director-agent/data-director/compare/inventory-v0.1.0-alpha.2...inventory-v0.1.0-alpha.3) (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **app:** show an inventory in readable form ([e4461f1](https://github.com/data-director-agent/data-director/commit/e4461f1b6314bcdb938cc9a6f5d3f05b1809886c))
9
+ * **inventory:** add a TypeScript library for reading an inventory ([aacfbea](https://github.com/data-director-agent/data-director/commit/aacfbeafef963525a366fc318ef913925e61b378))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **inventory:** complete the package metadata for PyPI ([20918d7](https://github.com/data-director-agent/data-director/commit/20918d7da918d9742a303179c6cd3bbaf3ec3f09))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * describe the inventory library and the inventory dialog, and wire them into CI ([ced5cca](https://github.com/data-director-agent/data-director/commit/ced5ccac2af755851622639ea363f17877aaaf29))
20
+ * **inventory:** complete the package metadata for PyPI ([4c3f782](https://github.com/data-director-agent/data-director/commit/4c3f782bdefef97dfc7dd205ced16d80329e458b))
21
+
22
+ ## [0.1.0-alpha.2](https://github.com/data-director-agent/data-director/compare/inventory-v0.1.0-alpha.1...inventory-v0.1.0-alpha.2) (2026-11-06)
23
+
24
+
25
+ ### Features
26
+
27
+ * **inventory:** publish to TestPyPI and add a data-director-inventory command ([#562](https://github.com/data-director-agent/data-director/issues/562)) ([d89c15d](https://github.com/data-director-agent/data-director/commit/d89c15d0b1223bfe70ad7e2163d7fe4cb80f3ec2)), closes [#552](https://github.com/data-director-agent/data-director/issues/552)
28
+
29
+ ## [0.1.0-alpha.1](https://github.com/data-director-agent/data-director/compare/inventory-v0.1.0-alpha...inventory-v0.1.0-alpha.1) (2026-11-05)
30
+
31
+
32
+ ### Features
33
+
34
+ * **app:** attach a folder's inventory in the chat ([#463](https://github.com/data-director-agent/data-director/issues/463)) ([12470f8](https://github.com/data-director-agent/data-director/commit/12470f86a9c7fd7508d5e084c97f0d2895d9a573))
35
+ * **inventory:** inventory a dataset's files where they live ([#393](https://github.com/data-director-agent/data-director/issues/393)) ([2238ce6](https://github.com/data-director-agent/data-director/commit/2238ce657e8158931229f4339cafc83a857bbb03))
36
+
37
+
38
+ ### Bug Fixes
39
+
40
+ * **docs:** publish the inventory changelog ([#466](https://github.com/data-director-agent/data-director/issues/466)) ([e238600](https://github.com/data-director-agent/data-director/commit/e238600fadfe267f3af84a11d779088cf42b50d2))
41
+
42
+
43
+ ### Documentation
44
+
45
+ * replace decision records with living specs ([#434](https://github.com/data-director-agent/data-director/issues/434)) ([a19caba](https://github.com/data-director-agent/data-director/commit/a19caba60b1f1fc0049c0a68c29feb2354b97db5))
@@ -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,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,49 @@
1
+ # Data Director inventory
2
+
3
+ [![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)
4
+
5
+ `dd-inventory` describes a dataset's files where they live. It reads every file under a folder
6
+ and writes a `FileInventory`, a JSON document of the core contract, that gives for each file:
7
+
8
+ - its path relative to the folder;
9
+ - its size and its SHA-256 digest;
10
+ - its media type and every [PRONOM](https://www.nationalarchives.gov.uk/PRONOM/) identifier it
11
+ matched, found with [fido](https://github.com/openpreserve/fido), and whether the match was by
12
+ byte signature, container signature or extension only;
13
+ - for a CSV or TSV table, its fields as a [Frictionless](https://framework.frictionlessdata.io/)
14
+ Table Schema inferred from a sample of rows.
15
+
16
+ The inventory holds facts about the files and never their content. Research data are not
17
+ uploaded to Data Director: the researcher adds the inventory to their project instead
18
+ ([Projects](https://github.com/data-director-agent/data-director/blob/main/docs/specs/projects.md)). The digests let the files deposited
19
+ later be shown to be the files described.
20
+
21
+ ```console
22
+ $ uv run dd-inventory path/to/dataset -o inventory.json
23
+ ```
24
+
25
+ The package installs two names for the same command, `dd-inventory` and `data-director-inventory`,
26
+ 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)).
27
+
28
+ A researcher without a checkout runs it with `uvx` and attaches the file to a conversation in the
29
+ app; the user guide's [Making an inventory](https://data-director-agent.github.io/data-director/user-guide/making-an-inventory/)
30
+ says how.
31
+
32
+ Hidden files and folders (names beginning with `.`) are skipped, symbolic links are not
33
+ followed, and both are counted in `inventory_limitations`. If fido cannot be loaded, formats are
34
+ guessed from file extensions and the inventory says so.
35
+
36
+ To read an inventory in TypeScript, as the app does to show one to the researcher, use
37
+ [`@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
38
+ package.
39
+
40
+ The package does not depend on the contract or any other part of Data Director
41
+ ([System and boundaries](https://github.com/data-director-agent/data-director/blob/main/docs/specs/system.md)). Its tests check
42
+ what it writes against the contract's `FileInventory` schema.
43
+
44
+ | Module | What it does |
45
+ |---|---|
46
+ | `scan.py` | Walks the folder, digests each file and assembles the inventory. |
47
+ | `identify.py` | The one module that calls fido, with the extension fallback. |
48
+ | `tables.py` | Infers a table's fields with Frictionless. |
49
+ | `cli.py` | The `dd-inventory` command. |
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "data-director-inventory"
7
+ version = "0.1.0-alpha.3"
8
+ description = "Describe a dataset's files where they live: size, digest, PRONOM format and table fields, without uploading them"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Joe Heffer", email = "j.heffer@sheffield.ac.uk" }]
14
+ keywords = ["research data", "inventory", "FAIR", "PRONOM", "checksum", "data management", "Frictionless"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Science/Research",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Scientific/Engineering :: Information Analysis",
23
+ "Topic :: System :: Archiving",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ # No workspace member: the command runs without the contract (docs/specs/system.md). What it
28
+ # writes is a core `FileInventory`, which the tests check against the contract's schema.
29
+ dependencies = [
30
+ # fido 1.6.1's library defaults name a signature file its wheel does not ship; identify.py
31
+ # reads the shipped versions.xml instead. Check that still holds before allowing a newer fido.
32
+ "opf-fido==1.6.1",
33
+ "frictionless>=5.18,<6",
34
+ ]
35
+
36
+ [project.scripts]
37
+ dd-inventory = "dd_inventory.cli:main"
38
+ # So that `uvx data-director-inventory path/to/dataset` works: uvx runs the script named like the
39
+ # package.
40
+ data-director-inventory = "dd_inventory.cli:main"
41
+
42
+ [project.urls]
43
+ Homepage = "https://data-director-agent.github.io/data-director/"
44
+ Documentation = "https://data-director-agent.github.io/data-director/user-guide/making-an-inventory/"
45
+ Repository = "https://github.com/data-director-agent/data-director/tree/main/inventory"
46
+ Changelog = "https://github.com/data-director-agent/data-director/blob/main/inventory/CHANGELOG.md"
47
+ Issues = "https://github.com/data-director-agent/data-director/issues"
48
+
49
+ # What this package's tests import from the workspace (docs/specs/system.md, scripts/check_boundaries.py).
50
+ [dependency-groups]
51
+ dev = ["data-director-sdk"]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/dd_inventory"]
55
+
56
+ # inventory/browser is the TypeScript package for the browser (its README); it is not part of the
57
+ # Python distribution.
58
+ [tool.hatch.build.targets.sdist]
59
+ exclude = ["browser"]
@@ -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"]
@@ -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)
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ # Soil survey (test fixture)
2
+
3
+ Soil chemistry readings from three sites, made up for the inventory tests.
@@ -0,0 +1,4 @@
1
+ site,sampled_on,ph,nitrate_mg_kg
2
+ A,2026-03-01,6.4,12.5
3
+ B,2026-03-02,7.1,9.0
4
+ C,2026-03-03,5.8,15.25
@@ -0,0 +1,4 @@
1
+ site latitude longitude name
2
+ A 53.38 -1.47 Crookes
3
+ B 53.40 -1.50 Walkley
4
+ C 53.36 -1.49 Ecclesall
@@ -0,0 +1,21 @@
1
+ %PDF-1.4
2
+ 1 0 obj
3
+ << /Type /Catalog /Pages 2 0 R >>
4
+ endobj
5
+ 2 0 obj
6
+ << /Type /Pages /Kids [3 0 R] /Count 1 >>
7
+ endobj
8
+ 3 0 obj
9
+ << /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] >>
10
+ endobj
11
+ xref
12
+ 0 4
13
+ 0000000000 65535 f
14
+ 0000000009 00000 n
15
+ 0000000058 00000 n
16
+ 0000000115 00000 n
17
+ trailer
18
+ << /Size 4 /Root 1 0 R >>
19
+ startxref
20
+ 184
21
+ %%EOF
@@ -0,0 +1,200 @@
1
+ """The inventory command describes a dataset's files without their content
2
+ (docs/specs/projects.md)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import shutil
9
+ import zipfile
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import pytest
15
+
16
+ from dd_inventory import cli, identify, make_inventory, scan
17
+ from dd_inventory.identify import ExtensionIdentifier, FidoUnavailableError
18
+ from dd_inventory.tables import TableError
19
+ from dd_sdk.contract.classes import ClassSchema
20
+ from dd_sdk.contract.models import FileInventory
21
+
22
+ Inventory = dict[str, Any]
23
+ FIXTURE = Path(__file__).parent / "data" / "soil-survey"
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def inventory() -> Inventory:
28
+ return make_inventory(FIXTURE)
29
+
30
+
31
+ def _by_path(inventory: Inventory) -> dict[str, dict[str, Any]]:
32
+ return {f["file_path"]: f for f in inventory["inventory_files"]}
33
+
34
+
35
+ @pytest.fixture
36
+ def dataset(tmp_path: Path) -> Path:
37
+ root = tmp_path / "soil-survey"
38
+ shutil.copytree(FIXTURE, root)
39
+ return root
40
+
41
+
42
+ def test_the_inventory_is_a_core_file_inventory(inventory: Inventory) -> None:
43
+ assert ClassSchema.of(FileInventory).errors(inventory) == []
44
+ FileInventory.model_validate(inventory)
45
+ assert inventory["inventory_tool"] == "dd-inventory"
46
+ assert inventory["inventory_identified_with"].startswith("fido 1.6.1 with PRONOM signatures v")
47
+
48
+
49
+ def test_every_file_is_listed_by_relative_path_in_order_with_its_size_and_digest(
50
+ inventory: Inventory,
51
+ ) -> None:
52
+ files = inventory["inventory_files"]
53
+ paths = [f["file_path"] for f in files]
54
+ assert paths == sorted(paths)
55
+ assert set(paths) == {
56
+ "README.md",
57
+ "data/readings.csv",
58
+ "data/sites.tsv",
59
+ "docs/protocol.pdf",
60
+ "docs/site-map.png",
61
+ }
62
+ for f in files:
63
+ data = (FIXTURE / f["file_path"]).read_bytes()
64
+ assert f["file_size"] == len(data)
65
+ assert f["file_sha256"] == hashlib.sha256(data).hexdigest()
66
+
67
+
68
+ def test_no_file_content_and_no_absolute_path_is_written(inventory: Inventory) -> None:
69
+ text = json.dumps(inventory)
70
+ assert str(FIXTURE.resolve()) not in text
71
+ assert "Crookes" not in text # a value from sites.tsv
72
+ assert "nitrate" in text # a field name is a fact about the table, and is kept
73
+
74
+
75
+ def test_formats_with_a_byte_signature_are_identified_from_content(inventory: Inventory) -> None:
76
+ files = _by_path(inventory)
77
+ assert files["docs/protocol.pdf"]["file_pronom_ids"] == ["fmt/18"] # PDF 1.4
78
+ assert files["docs/protocol.pdf"]["file_identified_by"] == "signature"
79
+ assert files["docs/protocol.pdf"]["file_media_type"] == "application/pdf"
80
+ assert files["docs/site-map.png"]["file_identified_by"] == "signature"
81
+ assert files["data/readings.csv"]["file_pronom_ids"] == ["x-fmt/18"]
82
+ assert files["data/readings.csv"]["file_identified_by"] == "extension"
83
+
84
+
85
+ def test_an_ambiguous_match_keeps_every_candidate(dataset: Path) -> None:
86
+ (dataset / "old-notes.doc").write_bytes(b"not a Word document")
87
+ found = _by_path(make_inventory(dataset))["old-notes.doc"]
88
+ assert found["file_identified_by"] == "extension"
89
+ assert len(found["file_pronom_ids"]) > 1
90
+
91
+
92
+ def test_a_file_nothing_recognises_has_no_identifier(dataset: Path) -> None:
93
+ (dataset / "readings.xyzzy").write_bytes(b"\x00\x01\x02")
94
+ found = _by_path(make_inventory(dataset))["readings.xyzzy"]
95
+ assert found["file_pronom_ids"] == []
96
+ assert found["file_identified_by"] == "none"
97
+ assert "file_media_type" not in found
98
+
99
+
100
+ def test_a_table_has_its_fields_inferred(inventory: Inventory) -> None:
101
+ files = _by_path(inventory)
102
+ assert files["data/readings.csv"]["file_fields"] == [
103
+ {"name": "site", "field_type": "string"},
104
+ {"name": "sampled_on", "field_type": "date"},
105
+ {"name": "ph", "field_type": "number"},
106
+ {"name": "nitrate_mg_kg", "field_type": "number"},
107
+ ]
108
+ assert [f["name"] for f in files["data/sites.tsv"]["file_fields"]] == [
109
+ "site",
110
+ "latitude",
111
+ "longitude",
112
+ "name",
113
+ ]
114
+ assert "file_fields" not in files["docs/protocol.pdf"]
115
+
116
+
117
+ def test_a_table_whose_fields_cannot_be_inferred_is_listed_and_said_so(
118
+ dataset: Path, monkeypatch: pytest.MonkeyPatch
119
+ ) -> None:
120
+ def unreadable(path: Path) -> list[dict[str, Any]]:
121
+ raise TableError(f"{path.name}: not a table")
122
+
123
+ monkeypatch.setattr(scan, "table_fields", unreadable)
124
+ (dataset / "data" / "broken.csv").write_bytes(b"\xff\xfe\x00\x00" * 4)
125
+ made = make_inventory(dataset)
126
+ assert "data/broken.csv" in _by_path(made)
127
+ assert "file_fields" not in _by_path(made)["data/broken.csv"]
128
+ assert any("data/broken.csv" in note for note in made["inventory_limitations"])
129
+
130
+
131
+ def test_hidden_files_and_links_are_skipped_and_counted(dataset: Path) -> None:
132
+ (dataset / ".DS_Store").write_bytes(b"\x00")
133
+ (dataset / ".git").mkdir()
134
+ (dataset / ".git" / "config").write_text("[core]\n")
135
+ (dataset / "elsewhere.csv").symlink_to(FIXTURE / "data" / "readings.csv")
136
+ made = make_inventory(dataset)
137
+ paths = set(_by_path(made))
138
+ assert not {p for p in paths if p.startswith(".") or "/." in p}
139
+ assert "elsewhere.csv" not in paths
140
+ notes = " ".join(made["inventory_limitations"])
141
+ assert "2 hidden" in notes
142
+ assert "1 symbolic link" in notes
143
+
144
+
145
+ def test_without_fido_formats_are_guessed_from_extensions(
146
+ dataset: Path, monkeypatch: pytest.MonkeyPatch
147
+ ) -> None:
148
+ def unavailable() -> None:
149
+ raise FidoUnavailableError("fido could not be loaded: no signatures")
150
+
151
+ monkeypatch.setattr(identify, "FidoIdentifier", unavailable)
152
+ made = make_inventory(dataset, now=datetime(2026, 10, 30, tzinfo=UTC))
153
+ assert ClassSchema.of(FileInventory).errors(made) == []
154
+ assert made["inventory_identified_with"] == ExtensionIdentifier.description
155
+ assert made["inventory_made_at"] == "2026-10-30T00:00:00+00:00"
156
+ pdf = _by_path(made)["docs/protocol.pdf"]
157
+ assert pdf == {**pdf, "file_pronom_ids": [], "file_identified_by": "extension"}
158
+ assert pdf["file_media_type"] == "application/pdf"
159
+ assert "fido could not be loaded" in made["inventory_limitations"][0]
160
+
161
+
162
+ def test_the_command_writes_the_inventory_to_a_file(
163
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
164
+ ) -> None:
165
+ out = tmp_path / "inventory.json"
166
+ assert cli.main([str(FIXTURE), "-o", str(out)]) == cli.EXIT_OK
167
+ assert json.loads(out.read_text("utf-8"))["schema_class"] == "FileInventory"
168
+ assert capsys.readouterr().out == ""
169
+
170
+
171
+ def test_the_command_writes_to_standard_output_by_default(
172
+ capsys: pytest.CaptureFixture[str],
173
+ ) -> None:
174
+ assert cli.main([str(FIXTURE)]) == cli.EXIT_OK
175
+ assert len(json.loads(capsys.readouterr().out)["inventory_files"]) == 5
176
+
177
+
178
+ def test_the_command_refuses_a_root_that_is_not_a_folder(
179
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
180
+ ) -> None:
181
+ assert cli.main([str(tmp_path / "missing")]) == cli.EXIT_USAGE
182
+ assert "is not a folder" in capsys.readouterr().err
183
+ with pytest.raises(NotADirectoryError):
184
+ make_inventory(FIXTURE / "README.md")
185
+
186
+
187
+ WORD_CONTENT_TYPES = (
188
+ '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/'
189
+ 'content-types"><Override PartName="/word/document.xml" ContentType="application/'
190
+ 'vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
191
+ )
192
+
193
+
194
+ def test_a_zip_based_format_is_identified_by_its_container_signature(dataset: Path) -> None:
195
+ with zipfile.ZipFile(dataset / "docs" / "consent-form.docx", "w") as docx:
196
+ docx.writestr("[Content_Types].xml", WORD_CONTENT_TYPES)
197
+ docx.writestr("word/document.xml", '<?xml version="1.0"?><document/>')
198
+ found = _by_path(make_inventory(dataset))["docs/consent-form.docx"]
199
+ assert found["file_identified_by"] == "container"
200
+ assert found["file_pronom_ids"] == ["fmt/412"] # Microsoft Word for Windows 2007 onwards