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 ADDED
@@ -0,0 +1,64 @@
1
+ """Identify a file or bytes and return a whiff/1 report.
2
+
3
+ The report is the JSON document produced by the Rust `whiff` crate: what the
4
+ file is, where it came from, which rights apply, and a compact view of its
5
+ content. This package wraps that crate. It does not reimplement format
6
+ extractors.
7
+ """
8
+
9
+ from whiff.engine import (
10
+ Engine,
11
+ __version__,
12
+ configure,
13
+ extract,
14
+ extract_many,
15
+ llm,
16
+ schema,
17
+ version,
18
+ )
19
+ from whiff.errors import (
20
+ AccessError,
21
+ AnalyzerError,
22
+ ConflictError,
23
+ ExtractorError,
24
+ InvalidOptionError,
25
+ InvalidReportError,
26
+ NativeError,
27
+ NotFoundError,
28
+ OverloadedError,
29
+ UnsupportedInputError,
30
+ WhiffError,
31
+ WhiffTimeoutError,
32
+ )
33
+ from whiff.input import Input
34
+ from whiff.merge import merge
35
+ from whiff.plugins import Analyzer, Extractor
36
+ from whiff.report import native_covered
37
+
38
+ __all__ = [
39
+ "AccessError",
40
+ "Analyzer",
41
+ "AnalyzerError",
42
+ "ConflictError",
43
+ "Engine",
44
+ "Extractor",
45
+ "ExtractorError",
46
+ "Input",
47
+ "InvalidOptionError",
48
+ "InvalidReportError",
49
+ "NativeError",
50
+ "NotFoundError",
51
+ "OverloadedError",
52
+ "UnsupportedInputError",
53
+ "WhiffError",
54
+ "WhiffTimeoutError",
55
+ "__version__",
56
+ "configure",
57
+ "extract",
58
+ "extract_many",
59
+ "llm",
60
+ "merge",
61
+ "native_covered",
62
+ "schema",
63
+ "version",
64
+ ]
whiff/_native.pyd ADDED
Binary file
whiff/_native.pyi ADDED
@@ -0,0 +1,11 @@
1
+ class NativeFault(Exception): ...
2
+
3
+ class Engine:
4
+ def __init__(self, options_json: str) -> None: ...
5
+ def extract_path(self, path: bytes) -> bytes: ...
6
+ def extract_bytes(self, data: bytes, name: str | None) -> bytes: ...
7
+ def llm_path(self, path: bytes, tokens: int) -> bytes: ...
8
+ def llm_bytes(self, data: bytes, name: str | None, tokens: int) -> bytes: ...
9
+
10
+ def version() -> bytes: ...
11
+ def default_limits(profile: str | None) -> bytes: ...
whiff/engine.py ADDED
@@ -0,0 +1,385 @@
1
+ """Public extraction entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Sequence
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from concurrent.futures import TimeoutError as FuturesTimeout
10
+ from typing import Any
11
+
12
+ from whiff import input as input_mod
13
+ from whiff import plugins, scratch
14
+ from whiff.errors import (
15
+ NativeError,
16
+ UnsupportedInputError,
17
+ WhiffError,
18
+ WhiffTimeoutError,
19
+ error_for,
20
+ )
21
+ from whiff.limiter import LIMITER
22
+ from whiff.options import native_payload, validate
23
+ from whiff.report import SCHEMA, decode
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ _NATIVE_KEYS = {
28
+ "profile",
29
+ "hashes",
30
+ "privacy",
31
+ "only",
32
+ "skip",
33
+ "format",
34
+ "provenance",
35
+ "limits",
36
+ }
37
+ _BATCH_TIMEOUT_MS = 300_000
38
+ _schema_cache: dict[str, Any] | None = None
39
+ _default_engine: Engine | None = None
40
+
41
+
42
+ def _extension():
43
+ try:
44
+ from whiff import _native
45
+ except ImportError as err:
46
+ raise NativeError(
47
+ "whiff native extension is not built; run `maturin develop` from the repository root"
48
+ ) from err
49
+ return _native
50
+
51
+
52
+ def _from_native(exc: BaseException, path: str | None) -> WhiffError:
53
+ args = getattr(exc, "args", ())
54
+ if len(args) == 1 and isinstance(args[0], tuple) and len(args[0]) == 2:
55
+ args = args[0]
56
+ if len(args) >= 2 and isinstance(args[0], str) and isinstance(args[1], str):
57
+ err = error_for(args[0], args[1], path=path)
58
+ else:
59
+ err = NativeError(str(exc), path=path)
60
+ if path is not None and err.path is None:
61
+ err.path = path
62
+ err.args = (err._format(),)
63
+ return err
64
+
65
+
66
+ class Engine:
67
+ """Reusable extractor. Safe to share across threads.
68
+
69
+ Keyword arguments are the native options (``profile``, ``hashes``,
70
+ ``privacy``, ``only``, ``skip``, ``format``, ``provenance``, ``limits``).
71
+ Per-call keywords override them.
72
+ """
73
+
74
+ def __init__(self, **kwargs: Any) -> None:
75
+ unknown = sorted(key for key in kwargs if key not in _NATIVE_KEYS)
76
+ if unknown:
77
+ from whiff.errors import InvalidOptionError
78
+
79
+ raise InvalidOptionError(f"unknown option(s): {unknown}", details={"unknown": unknown})
80
+ normalized = validate(kwargs, "path")
81
+ self._native_kwargs = {key: normalized[key] for key in kwargs}
82
+ self._cache_key: str | None = None
83
+ self._rust: Any = None
84
+
85
+ def extract(self, source: Any, **kwargs: Any) -> dict[str, Any]:
86
+ combined = {**self._native_kwargs, **kwargs}
87
+ if _is_bytes(source) or _is_file(source):
88
+ opts = validate(combined, "bytes")
89
+ return _extract_bytes(source, opts, self)
90
+ if _is_path(source):
91
+ opts = validate(combined, "path")
92
+ return _extract_path(source, opts, self)
93
+ raise UnsupportedInputError("path must be a path, bytes, or a binary file object")
94
+
95
+ def llm(self, source: Any, **kwargs: Any) -> dict[str, Any]:
96
+ combined = {**self._native_kwargs, **kwargs}
97
+ if _is_bytes(source) or _is_file(source):
98
+ opts = validate(combined, "llm_bytes")
99
+ return _llm_bytes(source, opts, self)
100
+ if _is_path(source):
101
+ opts = validate(combined, "llm_path")
102
+ return _llm_path(source, opts, self)
103
+ raise UnsupportedInputError("path must be a path, bytes, or a binary file object")
104
+
105
+ def extract_many(self, paths: Sequence[Any], **kwargs: Any) -> list[tuple[str, Any]]:
106
+ combined = {**self._native_kwargs, **kwargs}
107
+ return _extract_many(paths, combined, self)
108
+
109
+ def _rust_engine(self, opts: dict[str, Any]) -> Any:
110
+ payload = json.dumps(
111
+ native_payload(opts, include_tokens=False),
112
+ sort_keys=True,
113
+ separators=(",", ":"),
114
+ )
115
+ if payload != self._cache_key:
116
+ self._rust = _extension().Engine(payload)
117
+ self._cache_key = payload
118
+ return self._rust
119
+
120
+
121
+ def extract(source: Any, **kwargs: Any) -> dict[str, Any]:
122
+ """Extract a ``whiff/1`` report from a path, ``bytes``, or a binary file.
123
+
124
+ Problems inside a readable file are recorded in ``diagnostics`` and still
125
+ return a report. The call raises :class:`~whiff.errors.WhiffError` when
126
+ the path cannot be inspected or an option is invalid.
127
+ """
128
+ return _engine_for(kwargs).extract(source, **_without_native(kwargs))
129
+
130
+
131
+ def llm(source: Any, **kwargs: Any) -> dict[str, Any]:
132
+ """Compact ``Report::llm_view`` projection. Python plugins do not run."""
133
+ return _engine_for(kwargs).llm(source, **_without_native(kwargs))
134
+
135
+
136
+ def extract_many(paths: Sequence[Any], **kwargs: Any) -> list[tuple[str, Any]]:
137
+ """Extract each path, in input order.
138
+
139
+ ``on_error="collect"`` (default) returns ``(path, report)`` or
140
+ ``(path, error)``. ``on_error="halt"`` raises the first error and sets
141
+ ``details["partial"]`` to the pairs completed before it. ``timeout`` is
142
+ per path, in milliseconds (default 300000). Native work already running
143
+ is not cancelled when a path times out.
144
+ """
145
+ native = {key: value for key, value in kwargs.items() if key in _NATIVE_KEYS}
146
+ rest = {key: value for key, value in kwargs.items() if key not in _NATIVE_KEYS}
147
+ engine = Engine(**native) if native else _default()
148
+ return engine.extract_many(paths, **rest)
149
+
150
+
151
+ def schema() -> dict[str, Any]:
152
+ """Vendored ``whiff/1`` JSON Schema."""
153
+ global _schema_cache
154
+ if _schema_cache is None:
155
+ from whiff.merge import _SCHEMA_PATH
156
+
157
+ _schema_cache = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
158
+ return _schema_cache
159
+
160
+
161
+ def version() -> dict[str, Any]:
162
+ """Binding version, upstream crate, schema id, features, and limit caps."""
163
+ info = json.loads(bytes(_extension().version()))
164
+ info["version"] = __version__
165
+ info["schema"] = info.get("schema") or SCHEMA
166
+ return info
167
+
168
+
169
+ def configure(
170
+ *,
171
+ native_max_concurrency: int | None = None,
172
+ native_wait_ms: int | None = None,
173
+ scratch_dir: str | None = None,
174
+ scratch_max_bytes: int | None = None,
175
+ ) -> None:
176
+ """Set process-wide native concurrency and scratch-staging limits."""
177
+ if native_max_concurrency is not None or native_wait_ms is not None:
178
+ LIMITER.configure(max_concurrency=native_max_concurrency, wait_ms=native_wait_ms)
179
+ if scratch_dir is not None or scratch_max_bytes is not None:
180
+ scratch.configure(directory=scratch_dir, max_bytes=scratch_max_bytes)
181
+
182
+
183
+ def _default() -> Engine:
184
+ global _default_engine
185
+ if _default_engine is None:
186
+ _default_engine = Engine()
187
+ return _default_engine
188
+
189
+
190
+ def _engine_for(kwargs: dict[str, Any]) -> Engine:
191
+ native = {key: value for key, value in kwargs.items() if key in _NATIVE_KEYS}
192
+ if native:
193
+ return Engine(**native)
194
+ return _default()
195
+
196
+
197
+ def _without_native(kwargs: dict[str, Any]) -> dict[str, Any]:
198
+ return {key: value for key, value in kwargs.items() if key not in _NATIVE_KEYS}
199
+
200
+
201
+ def _extract_path(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
202
+ path = _display_path(source)
203
+ tag = scratch.stage(path, opts.get("stage", "never"))
204
+ try:
205
+ staged = tag.staged if tag is not None else path
206
+ try:
207
+ with LIMITER.slot():
208
+ raw = engine._rust_engine(opts).extract_path(os.fsencode(staged))
209
+ except WhiffError as err:
210
+ raise scratch.restore_error(err, tag) from None
211
+ except Exception as err:
212
+ if _is_native_fault(err):
213
+ raise scratch.restore_error(_from_native(err, path), tag) from None
214
+ raise
215
+ report = scratch.restore_report(decode(bytes(raw)), tag)
216
+ finally:
217
+ scratch.cleanup(tag)
218
+ return plugins.run(input_mod.from_path(path), report, opts)
219
+
220
+
221
+ def _extract_bytes(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
222
+ data, name = _bytes_of(source, opts)
223
+ mime = opts.get("mime")
224
+ try:
225
+ with LIMITER.slot():
226
+ raw = engine._rust_engine(opts).extract_bytes(data, name)
227
+ except WhiffError:
228
+ raise
229
+ except Exception as err:
230
+ if _is_native_fault(err):
231
+ raise _from_native(err, name) from None
232
+ raise
233
+ report = decode(bytes(raw))
234
+ return plugins.run(input_mod.from_binary(data, name=name, mime=mime), report, opts)
235
+
236
+
237
+ def _llm_path(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
238
+ path = _display_path(source)
239
+ tag = scratch.stage(path, opts.get("stage", "never"))
240
+ try:
241
+ staged = tag.staged if tag is not None else path
242
+ try:
243
+ with LIMITER.slot():
244
+ raw = engine._rust_engine(opts).llm_path(
245
+ os.fsencode(staged), opts.get("tokens", 800)
246
+ )
247
+ except WhiffError as err:
248
+ raise scratch.restore_error(err, tag) from None
249
+ except Exception as err:
250
+ if _is_native_fault(err):
251
+ raise scratch.restore_error(_from_native(err, path), tag) from None
252
+ raise
253
+ return scratch.restore_report(decode(bytes(raw)), tag)
254
+ finally:
255
+ scratch.cleanup(tag)
256
+
257
+
258
+ def _llm_bytes(source: Any, opts: dict[str, Any], engine: Engine) -> dict[str, Any]:
259
+ data, name = _bytes_of(source, opts)
260
+ try:
261
+ with LIMITER.slot():
262
+ raw = engine._rust_engine(opts).llm_bytes(data, name, opts.get("tokens", 800))
263
+ except WhiffError:
264
+ raise
265
+ except Exception as err:
266
+ if _is_native_fault(err):
267
+ raise _from_native(err, name) from None
268
+ raise
269
+ return decode(bytes(raw))
270
+
271
+
272
+ def _extract_many(
273
+ paths: Sequence[Any], opts_in: dict[str, Any], engine: Engine
274
+ ) -> list[tuple[str, Any]]:
275
+ if isinstance(paths, (str, bytes)) or not isinstance(paths, Sequence):
276
+ raise UnsupportedInputError("paths must be a list")
277
+ opts = validate(dict(opts_in), "extract_many")
278
+ timeout_ms = opts.get("timeout", _BATCH_TIMEOUT_MS)
279
+ timeout_s = None if timeout_ms is None else timeout_ms / 1000
280
+ workers = opts.get("max_concurrency", 4)
281
+ on_error = opts.get("on_error", "collect")
282
+ results: list[tuple[str, Any]] = []
283
+
284
+ def one(path: Any) -> Any:
285
+ try:
286
+ if not _is_path(path):
287
+ raise UnsupportedInputError("path must be a path")
288
+ single = {
289
+ key: value
290
+ for key, value in opts.items()
291
+ if key not in {"max_concurrency", "on_error", "timeout"}
292
+ }
293
+ return _extract_path(path, single, engine)
294
+ except WhiffError as err:
295
+ return err
296
+
297
+ with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="whiff") as pool:
298
+ futures = [pool.submit(one, path) for path in paths]
299
+ for path, future in zip(paths, futures, strict=True):
300
+ label = _display_path(path) if _is_path(path) else str(path)
301
+ try:
302
+ item = future.result(timeout=timeout_s)
303
+ except FuturesTimeout:
304
+ item = WhiffTimeoutError(
305
+ f"extraction exceeded {timeout_ms}ms",
306
+ path=label if _is_path(path) else None,
307
+ )
308
+ if isinstance(item, WhiffError):
309
+ if on_error == "halt":
310
+ item.details = {**item.details, "partial": list(results)}
311
+ if item.path is None and _is_path(path):
312
+ item.path = label
313
+ item.args = (item._format(),)
314
+ raise item
315
+ results.append((label, item))
316
+ return results
317
+
318
+
319
+ def _bytes_of(source: Any, opts: dict[str, Any]) -> tuple[bytes, str | None]:
320
+ name = opts.get("name") or opts.get("filename")
321
+ if _is_file(source):
322
+ if name is None:
323
+ raw_name = getattr(source, "name", None)
324
+ if isinstance(raw_name, str) and raw_name not in {"<stdin>", "<stdout>"}:
325
+ name = os.path.basename(raw_name) or None
326
+ cap = _read_cap(opts)
327
+ return _read_file(source, cap), name
328
+ data = _as_bytes(source)
329
+ if data is None:
330
+ raise UnsupportedInputError("expected bytes or a binary file object")
331
+ return data, name
332
+
333
+
334
+ def _read_cap(opts: dict[str, Any]) -> int:
335
+ limits = opts.get("limits") or {}
336
+ if "max_read_bytes" in limits:
337
+ return int(limits["max_read_bytes"])
338
+ raw = _extension().default_limits(opts.get("profile"))
339
+ return int(json.loads(bytes(raw))["max_read_bytes"])
340
+
341
+
342
+ def _read_file(source: Any, cap: int) -> bytes:
343
+ chunks: list[bytes] = []
344
+ remaining = cap
345
+ while remaining > 0:
346
+ chunk = source.read(remaining)
347
+ if not chunk:
348
+ break
349
+ if isinstance(chunk, str):
350
+ raise UnsupportedInputError("file object must be opened in binary mode")
351
+ if not isinstance(chunk, (bytes, bytearray, memoryview)):
352
+ raise UnsupportedInputError("file object must be opened in binary mode")
353
+ piece = bytes(chunk)
354
+ chunks.append(piece)
355
+ remaining -= len(piece)
356
+ return b"".join(chunks)
357
+
358
+
359
+ def _is_native_fault(err: BaseException) -> bool:
360
+ return type(err).__name__ == "NativeFault"
361
+
362
+
363
+ def _is_path(source: Any) -> bool:
364
+ return isinstance(source, (str, os.PathLike)) and not isinstance(source, bytes)
365
+
366
+
367
+ def _is_bytes(source: Any) -> bool:
368
+ return isinstance(source, (bytes, bytearray, memoryview))
369
+
370
+
371
+ def _is_file(source: Any) -> bool:
372
+ return not _is_bytes(source) and not _is_path(source) and hasattr(source, "read")
373
+
374
+
375
+ def _display_path(source: Any) -> str:
376
+ text = os.fspath(source)
377
+ if isinstance(text, bytes):
378
+ return os.fsdecode(text)
379
+ return text
380
+
381
+
382
+ def _as_bytes(source: Any) -> bytes | None:
383
+ if isinstance(source, (bytes, bytearray, memoryview)):
384
+ return bytes(source)
385
+ return None
whiff/errors.py ADDED
@@ -0,0 +1,153 @@
1
+ """Public exceptions.
2
+
3
+ IO failures subclass both :class:`WhiffError` and the matching builtin
4
+ (``FileNotFoundError`` or ``PermissionError``) so either handler catches them.
5
+ Messages for IO errors include the full path. Log the base name when paths
6
+ are sensitive.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ _SENSITIVE = {"bytes", "content", "binary"}
12
+
13
+
14
+ def _scrub(value: object) -> object:
15
+ if isinstance(value, dict):
16
+ return {key: _scrub(item) for key, item in value.items() if key not in _SENSITIVE}
17
+ if isinstance(value, list):
18
+ return [_scrub(item) for item in value]
19
+ return value
20
+
21
+
22
+ class WhiffError(Exception):
23
+ """Failure of a whole call. Problems inside a readable file are not errors.
24
+
25
+ ``reason`` is a stable string (``enoent``, ``invalid_option``, …).
26
+ ``details`` drops keys named ``bytes``, ``content``, or ``binary``.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ reason: str,
32
+ message: str,
33
+ *,
34
+ path: str | None = None,
35
+ details: dict | None = None,
36
+ ) -> None:
37
+ self.reason = reason
38
+ self.message = message
39
+ self.path = path
40
+ scrubbed = _scrub(details or {})
41
+ self.details: dict = scrubbed if isinstance(scrubbed, dict) else {}
42
+ super().__init__(self._format())
43
+
44
+ def _format(self) -> str:
45
+ base = f"{self.reason}: {self.message}"
46
+ if self.path:
47
+ return f"{base} (path: {self.path})"
48
+ return base
49
+
50
+
51
+ class NotFoundError(WhiffError, FileNotFoundError):
52
+ def __init__(
53
+ self, message: str, *, path: str | None = None, details: dict | None = None
54
+ ) -> None:
55
+ super().__init__("enoent", message, path=path, details=details)
56
+
57
+
58
+ class AccessError(WhiffError, PermissionError):
59
+ def __init__(
60
+ self, message: str, *, path: str | None = None, details: dict | None = None
61
+ ) -> None:
62
+ super().__init__("eacces", message, path=path, details=details)
63
+
64
+
65
+ class InvalidOptionError(WhiffError):
66
+ def __init__(
67
+ self, message: str, *, path: str | None = None, details: dict | None = None
68
+ ) -> None:
69
+ super().__init__("invalid_option", message, path=path, details=details)
70
+
71
+
72
+ class UnsupportedInputError(WhiffError):
73
+ def __init__(
74
+ self, message: str, *, path: str | None = None, details: dict | None = None
75
+ ) -> None:
76
+ super().__init__("unsupported_input", message, path=path, details=details)
77
+
78
+
79
+ class NativeError(WhiffError):
80
+ def __init__(
81
+ self, message: str, *, path: str | None = None, details: dict | None = None
82
+ ) -> None:
83
+ super().__init__("native_error", message, path=path, details=details)
84
+
85
+
86
+ class OverloadedError(WhiffError):
87
+ def __init__(
88
+ self, message: str, *, path: str | None = None, details: dict | None = None
89
+ ) -> None:
90
+ super().__init__("overloaded", message, path=path, details=details)
91
+
92
+
93
+ class InvalidReportError(WhiffError):
94
+ def __init__(
95
+ self, message: str, *, path: str | None = None, details: dict | None = None
96
+ ) -> None:
97
+ super().__init__("invalid_report", message, path=path, details=details)
98
+
99
+
100
+ class ExtractorError(WhiffError):
101
+ def __init__(
102
+ self, message: str, *, path: str | None = None, details: dict | None = None
103
+ ) -> None:
104
+ super().__init__("extractor_error", message, path=path, details=details)
105
+
106
+
107
+ class AnalyzerError(WhiffError):
108
+ def __init__(
109
+ self, message: str, *, path: str | None = None, details: dict | None = None
110
+ ) -> None:
111
+ super().__init__("analyzer_error", message, path=path, details=details)
112
+
113
+
114
+ class WhiffTimeoutError(WhiffError):
115
+ def __init__(
116
+ self, message: str, *, path: str | None = None, details: dict | None = None
117
+ ) -> None:
118
+ super().__init__("timeout", message, path=path, details=details)
119
+
120
+
121
+ class ConflictError(WhiffError):
122
+ def __init__(
123
+ self, message: str, *, path: str | None = None, details: dict | None = None
124
+ ) -> None:
125
+ super().__init__("conflict", message, path=path, details=details)
126
+
127
+
128
+ _BY_REASON: dict[str, type[WhiffError]] = {
129
+ "enoent": NotFoundError,
130
+ "eacces": AccessError,
131
+ "invalid_option": InvalidOptionError,
132
+ "unsupported_input": UnsupportedInputError,
133
+ "native_error": NativeError,
134
+ "overloaded": OverloadedError,
135
+ "invalid_report": InvalidReportError,
136
+ "extractor_error": ExtractorError,
137
+ "analyzer_error": AnalyzerError,
138
+ "timeout": WhiffTimeoutError,
139
+ "conflict": ConflictError,
140
+ }
141
+
142
+
143
+ def error_for(
144
+ reason: str,
145
+ message: str,
146
+ *,
147
+ path: str | None = None,
148
+ details: dict | None = None,
149
+ ) -> WhiffError:
150
+ cls = _BY_REASON.get(reason, NativeError)
151
+ if cls is NativeError and reason != "native_error":
152
+ return WhiffError(reason, message, path=path, details=details)
153
+ return cls(message, path=path, details=details)
whiff/input.py ADDED
@@ -0,0 +1,65 @@
1
+ """Description of one extraction input passed to Python plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Input:
12
+ """Path or in-memory payload. ``bytes`` is omitted from :meth:`present`."""
13
+
14
+ source: str
15
+ path: str | None = None
16
+ data: bytes | None = None
17
+ filename: str | None = None
18
+ extension: str | None = None
19
+ mime: str | None = None
20
+ size: int | None = None
21
+
22
+ def present(self) -> dict[str, object]:
23
+ return {
24
+ "source": self.source,
25
+ "path": self.path,
26
+ "filename": self.filename,
27
+ "extension": self.extension,
28
+ "mime": self.mime,
29
+ "size": self.size,
30
+ }
31
+
32
+
33
+ def from_path(path: str) -> Input:
34
+ filename = os.path.basename(path)
35
+ size: int | None
36
+ try:
37
+ size = os.stat(path).st_size
38
+ except OSError:
39
+ size = None
40
+ return Input(
41
+ source="path",
42
+ path=path,
43
+ filename=filename,
44
+ extension=_extension(filename),
45
+ size=size,
46
+ )
47
+
48
+
49
+ def from_binary(data: bytes, *, name: str | None = None, mime: str | None = None) -> Input:
50
+ filename = name or "input.bin"
51
+ return Input(
52
+ source="binary",
53
+ data=data,
54
+ filename=filename,
55
+ extension=_extension(filename),
56
+ mime=mime,
57
+ size=len(data),
58
+ )
59
+
60
+
61
+ def _extension(filename: str) -> str | None:
62
+ ext = Path(filename).suffix
63
+ if not ext:
64
+ return None
65
+ return ext.lower()