whiff-python 0.1.0__cp311-abi3-win_amd64.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.
- whiff/__init__.py +64 -0
- whiff/_native.pyd +0 -0
- whiff/_native.pyi +11 -0
- whiff/engine.py +385 -0
- whiff/errors.py +153 -0
- whiff/input.py +65 -0
- whiff/limiter.py +123 -0
- whiff/merge.py +381 -0
- whiff/options.py +238 -0
- whiff/plugins.py +315 -0
- whiff/privacy.py +185 -0
- whiff/py.typed +0 -0
- whiff/report.py +96 -0
- whiff/schemas/whiff-1.schema.json +1735 -0
- whiff/scratch.py +294 -0
- whiff_python-0.1.0.dist-info/METADATA +183 -0
- whiff_python-0.1.0.dist-info/RECORD +20 -0
- whiff_python-0.1.0.dist-info/WHEEL +4 -0
- whiff_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- whiff_python-0.1.0.dist-info/sboms/whiff_native.cyclonedx.json +5681 -0
whiff/scratch.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Opt-in local staging for path extraction.
|
|
2
|
+
|
|
3
|
+
``stage="local"`` copies a regular file into a private directory before the
|
|
4
|
+
native call opens it, then rewrites the report so it still describes the
|
|
5
|
+
original path. Times are second precision. Fields this module does not read
|
|
6
|
+
(birth time, allocated size, sparse flag, owner names, ACLs, extended
|
|
7
|
+
attributes) are omitted and named in ``diagnostics.warnings``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from whiff.errors import (
|
|
23
|
+
AccessError,
|
|
24
|
+
InvalidOptionError,
|
|
25
|
+
NativeError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
UnsupportedInputError,
|
|
28
|
+
WhiffError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_DEFAULT_MAX_BYTES = 1_073_741_824
|
|
32
|
+
_DIR_MODE = 0o700
|
|
33
|
+
_FILE_MODE = 0o600
|
|
34
|
+
|
|
35
|
+
_OMITTED = (
|
|
36
|
+
'stage="local": filesystem fields taken from the original at second precision; '
|
|
37
|
+
"created, allocated_bytes, sparse, owner names, acl, and xattrs omitted"
|
|
38
|
+
)
|
|
39
|
+
_UNREAD = 'stage="local": original file could not be re-read; filesystem fields describe the copy'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Staged:
|
|
44
|
+
original: str
|
|
45
|
+
staged: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scratch_dir() -> str:
|
|
49
|
+
return os.environ.get("WHIFF_SCRATCH_DIR") or str(Path(tempfile.gettempdir()) / "whiff-scratch")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def scratch_max_bytes() -> int:
|
|
53
|
+
raw = os.environ.get("WHIFF_SCRATCH_MAX_BYTES")
|
|
54
|
+
if raw is None:
|
|
55
|
+
return _configured_max
|
|
56
|
+
try:
|
|
57
|
+
value = int(raw)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return _configured_max
|
|
60
|
+
return value if value > 0 else _configured_max
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_configured_max = _DEFAULT_MAX_BYTES
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def configure(*, directory: str | None = None, max_bytes: int | None = None) -> None:
|
|
67
|
+
global _configured_max
|
|
68
|
+
if directory is not None:
|
|
69
|
+
os.environ["WHIFF_SCRATCH_DIR"] = directory
|
|
70
|
+
if max_bytes is not None:
|
|
71
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1:
|
|
72
|
+
raise InvalidOptionError("scratch_max_bytes must be a positive integer")
|
|
73
|
+
_configured_max = max_bytes
|
|
74
|
+
os.environ["WHIFF_SCRATCH_MAX_BYTES"] = str(max_bytes)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def stage(path: str, mode: str) -> Staged | None:
|
|
78
|
+
"""Copy ``path`` when ``mode`` is ``local``. Return None when staging is off."""
|
|
79
|
+
if mode == "never":
|
|
80
|
+
return None
|
|
81
|
+
if mode != "local":
|
|
82
|
+
raise InvalidOptionError(f"invalid stage: {mode!r}")
|
|
83
|
+
return _stage_local(path)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cleanup(tag: Staged | None) -> None:
|
|
87
|
+
if tag is None:
|
|
88
|
+
return
|
|
89
|
+
shutil.rmtree(os.path.dirname(tag.staged), ignore_errors=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def restore_report(report: dict[str, Any], tag: Staged | None) -> dict[str, Any]:
|
|
93
|
+
if tag is None:
|
|
94
|
+
return report
|
|
95
|
+
try:
|
|
96
|
+
info = os.stat(tag.original)
|
|
97
|
+
except OSError:
|
|
98
|
+
return _warn(_put(report, ("source", "path"), tag.original), _UNREAD)
|
|
99
|
+
updated = _put(report, ("source", "path"), tag.original)
|
|
100
|
+
updated = _restore_temporal(updated, info)
|
|
101
|
+
updated = _restore_system(updated, info)
|
|
102
|
+
updated = _restore_security(updated, info)
|
|
103
|
+
return _warn(updated, _OMITTED)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def restore_error(err: WhiffError, tag: Staged | None) -> WhiffError:
|
|
107
|
+
if tag is None:
|
|
108
|
+
return err
|
|
109
|
+
message = err.message.replace(tag.staged, tag.original)
|
|
110
|
+
err.message = message
|
|
111
|
+
err.path = tag.original
|
|
112
|
+
err.args = (err._format(),)
|
|
113
|
+
return err
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _stage_local(path: str) -> Staged:
|
|
117
|
+
try:
|
|
118
|
+
info = os.stat(path)
|
|
119
|
+
except FileNotFoundError as err:
|
|
120
|
+
raise NotFoundError(f"stat: no such file: {err.strerror}", path=path) from err
|
|
121
|
+
except PermissionError as err:
|
|
122
|
+
raise AccessError(f"stat: permission denied: {err.strerror}", path=path) from err
|
|
123
|
+
except OSError as err:
|
|
124
|
+
raise NativeError(f"stat: {err.strerror or err}", path=path) from err
|
|
125
|
+
if not stat.S_ISREG(info.st_mode):
|
|
126
|
+
kind = "directory" if stat.S_ISDIR(info.st_mode) else "non-regular file"
|
|
127
|
+
raise UnsupportedInputError(
|
|
128
|
+
f"scratch staging requires a regular file, got {kind}", path=path
|
|
129
|
+
)
|
|
130
|
+
limit = scratch_max_bytes()
|
|
131
|
+
if info.st_size > limit:
|
|
132
|
+
raise InvalidOptionError(
|
|
133
|
+
f"file size {info.st_size} exceeds scratch_max_bytes {limit}",
|
|
134
|
+
path=path,
|
|
135
|
+
details={"size": info.st_size, "scratch_max_bytes": limit},
|
|
136
|
+
)
|
|
137
|
+
root = _ensure_root(scratch_dir())
|
|
138
|
+
job = _job_dir(root)
|
|
139
|
+
try:
|
|
140
|
+
_verify_root(root, job)
|
|
141
|
+
staged = os.path.join(job, os.path.basename(path) or "input")
|
|
142
|
+
_copy(path, staged, info)
|
|
143
|
+
except Exception:
|
|
144
|
+
shutil.rmtree(job, ignore_errors=True)
|
|
145
|
+
raise
|
|
146
|
+
return Staged(original=path, staged=staged)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _ensure_root(root: str) -> str:
|
|
150
|
+
if os.path.islink(root):
|
|
151
|
+
raise NativeError("scratch_dir is a symlink, not a directory", path=root)
|
|
152
|
+
if os.path.exists(root):
|
|
153
|
+
if not os.path.isdir(root):
|
|
154
|
+
raise NativeError("scratch_dir is not a directory", path=root)
|
|
155
|
+
return root
|
|
156
|
+
os.makedirs(root, mode=_DIR_MODE, exist_ok=False)
|
|
157
|
+
os.chmod(root, _DIR_MODE)
|
|
158
|
+
return root
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _job_dir(root: str) -> str:
|
|
162
|
+
last_error: Exception | None = None
|
|
163
|
+
for _ in range(3):
|
|
164
|
+
path = os.path.join(root, "whiff-" + secrets.token_urlsafe(12))
|
|
165
|
+
try:
|
|
166
|
+
os.mkdir(path, _DIR_MODE)
|
|
167
|
+
except FileExistsError:
|
|
168
|
+
continue
|
|
169
|
+
except OSError as err:
|
|
170
|
+
last_error = err
|
|
171
|
+
break
|
|
172
|
+
os.chmod(path, _DIR_MODE)
|
|
173
|
+
return path
|
|
174
|
+
raise NativeError(
|
|
175
|
+
f"could not create a unique scratch job directory: {last_error}",
|
|
176
|
+
path=root,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _verify_root(root: str, job: str) -> None:
|
|
181
|
+
if os.name != "posix":
|
|
182
|
+
return
|
|
183
|
+
root_stat = os.lstat(root)
|
|
184
|
+
job_stat = os.lstat(job)
|
|
185
|
+
if root_stat.st_uid != job_stat.st_uid:
|
|
186
|
+
raise AccessError("scratch_dir is owned by another user", path=root)
|
|
187
|
+
if (root_stat.st_mode & 0o022) != 0 and (root_stat.st_mode & 0o1000) == 0:
|
|
188
|
+
raise AccessError("scratch_dir is writable by group or other users", path=root)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _copy(source: str, dest: str, info: os.stat_result) -> None:
|
|
192
|
+
shutil.copyfile(source, dest)
|
|
193
|
+
os.chmod(dest, _FILE_MODE)
|
|
194
|
+
os.utime(dest, (info.st_atime, info.st_mtime))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _restore_temporal(report: dict[str, Any], info: os.stat_result) -> dict[str, Any]:
|
|
198
|
+
def update(section: dict[str, Any]) -> dict[str, Any]:
|
|
199
|
+
section = dict(section)
|
|
200
|
+
section["modified"] = _rfc3339(info.st_mtime)
|
|
201
|
+
section["accessed"] = _rfc3339(info.st_atime)
|
|
202
|
+
section.pop("created", None)
|
|
203
|
+
if os.name == "posix":
|
|
204
|
+
section["changed"] = _rfc3339(info.st_ctime)
|
|
205
|
+
else:
|
|
206
|
+
section.pop("changed", None)
|
|
207
|
+
return section
|
|
208
|
+
|
|
209
|
+
return _update(report, ("temporal", "filesystem"), update)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _restore_system(report: dict[str, Any], info: os.stat_result) -> dict[str, Any]:
|
|
213
|
+
def update(section: dict[str, Any]) -> dict[str, Any]:
|
|
214
|
+
section = dict(section)
|
|
215
|
+
section["device"] = info.st_dev
|
|
216
|
+
section["inode"] = info.st_ino
|
|
217
|
+
section["links"] = info.st_nlink
|
|
218
|
+
section.pop("allocated_bytes", None)
|
|
219
|
+
section.pop("sparse", None)
|
|
220
|
+
return section
|
|
221
|
+
|
|
222
|
+
return _update(report, ("system",), update)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _restore_security(report: dict[str, Any], info: os.stat_result) -> dict[str, Any]:
|
|
226
|
+
def update(section: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
section = dict(section)
|
|
228
|
+
mode = info.st_mode & 0o7777
|
|
229
|
+
_put_if(section, "mode", "0" + format(mode, "o"))
|
|
230
|
+
_put_if(section, "permissions", _permissions(mode))
|
|
231
|
+
_put_if(section, "setuid", (mode & 0o4000) != 0)
|
|
232
|
+
_put_if(section, "setgid", (mode & 0o2000) != 0)
|
|
233
|
+
_put_if(section, "sticky", (mode & 0o1000) != 0)
|
|
234
|
+
if "owner" in section:
|
|
235
|
+
section["owner"] = {"uid": info.st_uid, "gid": info.st_gid}
|
|
236
|
+
section.pop("acl", None)
|
|
237
|
+
section.pop("xattrs", None)
|
|
238
|
+
return section
|
|
239
|
+
|
|
240
|
+
return _update(report, ("security",), update)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _put_if(section: dict[str, Any], key: str, value: object) -> None:
|
|
244
|
+
if key in section:
|
|
245
|
+
section[key] = value
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _permissions(mode: int) -> str:
|
|
249
|
+
bits = (0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001)
|
|
250
|
+
chars = "rwxrwxrwx"
|
|
251
|
+
return "".join(char if mode & bit else "-" for bit, char in zip(bits, chars, strict=True))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _rfc3339(timestamp: float) -> str:
|
|
255
|
+
return datetime.fromtimestamp(int(timestamp), UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _update(report: dict[str, Any], path: tuple[str, ...], fn) -> dict[str, Any]:
|
|
259
|
+
current = _get(report, path)
|
|
260
|
+
if not isinstance(current, dict):
|
|
261
|
+
return report
|
|
262
|
+
return _put(report, path, fn(current))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _warn(report: dict[str, Any], warning: str) -> dict[str, Any]:
|
|
266
|
+
diagnostics = report.get("diagnostics")
|
|
267
|
+
diagnostics = dict(diagnostics) if isinstance(diagnostics, dict) else {}
|
|
268
|
+
warnings = list(diagnostics.get("warnings") or [])
|
|
269
|
+
warnings.append(warning)
|
|
270
|
+
diagnostics["warnings"] = warnings
|
|
271
|
+
updated = dict(report)
|
|
272
|
+
updated["diagnostics"] = diagnostics
|
|
273
|
+
return updated
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _get(report: dict[str, Any], path: tuple[str, ...]) -> object:
|
|
277
|
+
current: object = report
|
|
278
|
+
for key in path:
|
|
279
|
+
if not isinstance(current, dict) or key not in current:
|
|
280
|
+
return None
|
|
281
|
+
current = current[key]
|
|
282
|
+
return current
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _put(report: dict[str, Any], path: tuple[str, ...], value: object) -> dict[str, Any]:
|
|
286
|
+
if len(path) == 1:
|
|
287
|
+
updated = dict(report)
|
|
288
|
+
updated[path[0]] = value
|
|
289
|
+
return updated
|
|
290
|
+
child = report.get(path[0])
|
|
291
|
+
child = dict(child) if isinstance(child, dict) else {}
|
|
292
|
+
updated = dict(report)
|
|
293
|
+
updated[path[0]] = _put(child, path[1:], value)
|
|
294
|
+
return updated
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whiff-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
+
Classifier: Programming Language :: Rust
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Dist: pytest>=8 ; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Summary: Identify a file or bytes and return a whiff/1 report.
|
|
16
|
+
Keywords: metadata,file-format,detection,whiff
|
|
17
|
+
Author: Sin Anima
|
|
18
|
+
License-Expression: MIT
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
21
|
+
Project-URL: Upstream, https://github.com/baoulo/whiff
|
|
22
|
+
|
|
23
|
+
# whiff
|
|
24
|
+
|
|
25
|
+
Python bindings for [whiff](https://github.com/baoulo/whiff). Point it at a file or at bytes and it returns one [`whiff/1`](https://github.com/baoulo/whiff/blob/main/docs/schema/whiff-1.schema.json) report: format, hashes, security and rights signals, content, and format-specific metadata.
|
|
26
|
+
|
|
27
|
+
Identification and extraction run in the Rust crate (0.8.0, the `common` feature set). Python extractors and analyzers can add fields afterwards. They do not run inside native extraction, and they do not run for embedded children.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import whiff
|
|
31
|
+
|
|
32
|
+
report = whiff.extract("invoice.pdf")
|
|
33
|
+
report["identification"]["format"] # "pdf"
|
|
34
|
+
|
|
35
|
+
report = whiff.extract(data, name="invoice.pdf", hashes=["sha256"])
|
|
36
|
+
view = whiff.llm("contract.pdf", tokens=800)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Empty optional fields are omitted. The vendored schema is `whiff.schema()`.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Import the package as `whiff`. The PyPI project `whiff` already belongs to an unrelated library, so this distribution is named `whiff-python`. A GitHub release tagged with the `version` in `pyproject.toml` (an optional `v` prefix is fine) publishes the wheels. From a checkout, with Rust 1.89 or later:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
maturin develop
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That builds the `common` formats. A source build of every collection is:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
maturin develop --features full
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`geo`, `science`, `data`, `cad`, `forensics`, and `games` are the individual cargo features. Wheels are built with `common` only.
|
|
56
|
+
|
|
57
|
+
## Extract
|
|
58
|
+
|
|
59
|
+
`extract` accepts a path, `bytes`, or a binary file object. A file object is read up to `max_read_bytes` and then treated as bytes. `name` (or `filename`, not both) drives extension detection for in-memory input.
|
|
60
|
+
|
|
61
|
+
Corrupt or partial files still return a report. Problems inside the file are listed in `diagnostics`. The call raises `WhiffError` when the path cannot be inspected or an option is invalid:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
try:
|
|
65
|
+
report = whiff.extract(path)
|
|
66
|
+
except whiff.NotFoundError:
|
|
67
|
+
...
|
|
68
|
+
except whiff.AccessError:
|
|
69
|
+
...
|
|
70
|
+
except whiff.InvalidOptionError as err:
|
|
71
|
+
print(err.message)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`NotFoundError` is also a `FileNotFoundError`. `AccessError` is also a `PermissionError`. IO messages include the full path.
|
|
75
|
+
|
|
76
|
+
`llm` returns upstream `Report::llm_view`. Python plugins do not run for it.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
results = whiff.extract_many(
|
|
80
|
+
paths,
|
|
81
|
+
max_concurrency=4,
|
|
82
|
+
timeout=300_000,
|
|
83
|
+
profile="minimal",
|
|
84
|
+
hashes="none",
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Results keep input order. With `on_error="collect"` (the default) each item is `(path, report)` or `(path, error)`. With `on_error="halt"` the first error is raised and `err.details["partial"]` holds the pairs completed before it. `timeout` is per path, in milliseconds. A timeout does not stop native work that has already started.
|
|
89
|
+
|
|
90
|
+
An `Engine` holds native options and is safe to share across threads:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
engine = whiff.Engine(profile="minimal", hashes="none")
|
|
94
|
+
engine.extract(path)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Native calls take a process-wide slot (default 8). A caller that waits longer than `native_wait_ms` (default 60000) raises `OverloadedError`. Change the limits with `whiff.configure(native_max_concurrency=4, native_wait_ms=10_000)`.
|
|
98
|
+
|
|
99
|
+
`stage="local"` copies a regular file into a private scratch directory before taking a slot, for slow or network filesystems. The copy is removed afterwards. The report still describes the original path. Times are second precision, and fields Python does not read (birth time, allocated size, owner names, ACLs, extended attributes) are omitted, with a `diagnostics.warnings` entry. Files larger than `scratch_max_bytes` (default 1 GiB) are rejected.
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
An option a function does not use is rejected.
|
|
104
|
+
|
|
105
|
+
| Option | Values | Default |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `profile` | `minimal`, `standard`, `full`, `llm` | `standard` |
|
|
108
|
+
| `hashes` | `all`, `none`, or a list of `sha256` / `blake3` | `all` |
|
|
109
|
+
| `privacy` | `strict`, `off` | `strict` |
|
|
110
|
+
| `only` / `skip` | extractor ids or groups | none |
|
|
111
|
+
| `format` | format id to force | detect |
|
|
112
|
+
| `provenance` | record which extractor set each field | `False` |
|
|
113
|
+
| `limits` | dict of native limits; see below | profile limits |
|
|
114
|
+
| `stage` | `never`, `local` | `never` |
|
|
115
|
+
| `name` / `filename` / `mime` | strings (`bytes` and file objects) | none |
|
|
116
|
+
| `tokens` | 1..100000 | `800` (`llm` only) |
|
|
117
|
+
| `mode` | `fallback`, `augment`, `override` | `fallback` |
|
|
118
|
+
| `extractors` / `analyzers` | plugin classes or instances | none |
|
|
119
|
+
| `strict` | plugin failure raises | `False` |
|
|
120
|
+
| `plugin_timeout_ms` | 1..600000 | `5000` |
|
|
121
|
+
| `override_fields` | dotted paths, for example `["content.title"]` | `[]` |
|
|
122
|
+
| `max_concurrency` / `on_error` / `timeout` | `extract_many` | `4` / `collect` / `300000` |
|
|
123
|
+
|
|
124
|
+
`limits` keys: `max_read_bytes` and `max_decompressed_bytes` (1..4 GiB), `hash_max_bytes` (0..4 GiB), `max_parse_bytes` (0..256 MiB), `max_text_bytes` (0..64 MiB), `max_depth` (0..8), `max_children` (0..500), `file_timeout_ms` and `extractor_timeout_ms` (1..600000).
|
|
125
|
+
|
|
126
|
+
`privacy="strict"` reduces personal data in native structured fields to counts and scrubs plugin patches. Upstream redacts secrets either way. `privacy="off"` leaves plugin patches unchanged.
|
|
127
|
+
|
|
128
|
+
## Plugins
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
class Trade(whiff.Extractor):
|
|
132
|
+
id = "myapp.trade"
|
|
133
|
+
version = 1
|
|
134
|
+
|
|
135
|
+
def supports(self, source, report) -> bool:
|
|
136
|
+
return source.extension == ".trade"
|
|
137
|
+
|
|
138
|
+
def extract(self, source, report) -> dict:
|
|
139
|
+
return {"format": {"myapp.trade": {"trade_id": "T-1001"}}}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
report = whiff.extract("positions/2026-10-28.trade", extractors=[Trade])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
An analyzer subclasses `whiff.Analyzer` and implements `analyze(report, source)`. Analyzers run after extractors. Their patches always merge in `augment` mode.
|
|
146
|
+
|
|
147
|
+
| Mode | When an extractor runs | Existing native values |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `fallback` | native extraction did not cover a readable file | kept, except `content.*` |
|
|
150
|
+
| `augment` | `supports` returns true | kept |
|
|
151
|
+
| `override` | `supports` returns true | replaced for paths in `override_fields` |
|
|
152
|
+
|
|
153
|
+
`whiff`, `integrity`, and `embedded` cannot be patched. `diagnostics` accepts only extra warnings. `security.risks` are appended with `source` set to `python:<id>`. `metadata` and `format` keys are namespaces. One plugin cannot replace a value another plugin wrote unless that path is declared.
|
|
154
|
+
|
|
155
|
+
A failed plugin is recorded in `diagnostics.extractors` with `status` `error`, and the call still returns the report. With `strict=True` the call raises. Plugins run on a worker thread. They are not a security boundary.
|
|
156
|
+
|
|
157
|
+
`whiff.merge` applies the same rules to a patch dict.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
cargo test
|
|
163
|
+
cargo clippy --all-targets -- -D warnings
|
|
164
|
+
maturin develop
|
|
165
|
+
ruff check src tests
|
|
166
|
+
pytest
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Golden reports are compared with `probes/rust_extract`, which links the same `whiff` 0.8.0 crate. Design notes: [docs/adr/0001-baseline.md](docs/adr/0001-baseline.md).
|
|
170
|
+
|
|
171
|
+
## Limitations
|
|
172
|
+
|
|
173
|
+
- `extract` of `bytes` copies the buffer once into native memory.
|
|
174
|
+
- There is no streaming reader. A file object is read up to the read cap.
|
|
175
|
+
- Python callbacks do not run for embedded children, and `llm` does not run them.
|
|
176
|
+
- `stage="local"` copies the whole file first.
|
|
177
|
+
- A native `file_timeout` can leave a Rust worker running after the call returns.
|
|
178
|
+
- IO error messages include the full path.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT. Upstream whiff is also MIT.
|
|
183
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
whiff/__init__.py,sha256=FlXzk5La2EAmFEpa63QUgrBw_GMXHCs6uFTweB2TDck,1412
|
|
2
|
+
whiff/_native.pyd,sha256=EEyljJWAfoBQWHUOX7_UZwSPFWRTdFgptOSaztH1QBk,27445760
|
|
3
|
+
whiff/_native.pyi,sha256=8DBhwUI3HBf3GCoPwjNNN3v_4VYbJlxzZlIwShGhBU0,470
|
|
4
|
+
whiff/engine.py,sha256=CLiDTHMhwy68kkg8IbIBl_JVr-ObPrEk4gpu3-IDOM4,14158
|
|
5
|
+
whiff/errors.py,sha256=ZSK50TZufBAhx3go5fd3BOC7pxYFqpoGXNwd25XL7gY,4996
|
|
6
|
+
whiff/input.py,sha256=88mnZgT4_7YkmSJ8YLCZh2SymeIG2ARD-u4aVi1krKY,1613
|
|
7
|
+
whiff/limiter.py,sha256=prTlHegotUxtarDugMecSn8Bs-BtsA9NEt-B5ALk57Q,4249
|
|
8
|
+
whiff/merge.py,sha256=1mutnMGCYM7qscy19yHWEC4eVYEW9yA7Aq97kN4yWwA,14397
|
|
9
|
+
whiff/options.py,sha256=DX0wYFaAbYq-UCDLW-bn_mA0Kvsji5pDOr5SeafeP5c,8313
|
|
10
|
+
whiff/plugins.py,sha256=eSu5CVEp8j5T7SeDwarvkWpMbtSTzaJMmQCxcQVHI5w,10677
|
|
11
|
+
whiff/privacy.py,sha256=Gk7YerZzfrfRHODR11NolpYsJbFcZOtDNd8K_m1iGFg,6094
|
|
12
|
+
whiff/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
whiff/report.py,sha256=GzZOyuxWHKtutGMyjEqEzSO0J8qHP2fCBdXBR4pILeA,2901
|
|
14
|
+
whiff/schemas/whiff-1.schema.json,sha256=5_dfDz8-7zn7nfrdnwQlYkwtETZ2PqANQEq49qWa2iI,46452
|
|
15
|
+
whiff/scratch.py,sha256=aWN5TF-lrYuNV7M-tocJ88aO0mfPTCItRQN8ZmHvPyM,10023
|
|
16
|
+
whiff_python-0.1.0.dist-info/METADATA,sha256=VfhfVX-1we6EciBJ_QpjjWCHmG31Wwu_KCxY1dgsciY,8209
|
|
17
|
+
whiff_python-0.1.0.dist-info/WHEEL,sha256=DRguzO_JHkJTcrXDweuHyM66sVwM00caBYZ3J_zMn0w,96
|
|
18
|
+
whiff_python-0.1.0.dist-info/licenses/LICENSE,sha256=tFTWErWxMCMtJ-_hJwDjOrCZXO30u6hYoYR_5dzUMF8,1087
|
|
19
|
+
whiff_python-0.1.0.dist-info/sboms/whiff_native.cyclonedx.json,sha256=1tXqSRMzGGsWb8qZvsp3zDKXVSQufJGXI71cSVbd9Ys,180490
|
|
20
|
+
whiff_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sin Anima
|
|
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.
|