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/limiter.py ADDED
@@ -0,0 +1,123 @@
1
+ """Process-wide semaphore around native extraction.
2
+
3
+ Waiters are served in FIFO order. A waiter that does not get a slot within
4
+ ``native_wait_ms`` raises :class:`~whiff.errors.OverloadedError`. A slot held
5
+ by a thread is released when that thread leaves the ``with`` block, including
6
+ on exception. A native ``file_timeout`` can still leave a Rust worker running
7
+ after the call returns; this limiter cannot stop it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from collections import deque
14
+ from types import TracebackType
15
+
16
+ from whiff.errors import InvalidOptionError, OverloadedError
17
+
18
+ _DEFAULT_MAX = 8
19
+ _DEFAULT_WAIT_MS = 60_000
20
+
21
+
22
+ class _Ticket:
23
+ def __init__(self) -> None:
24
+ self.event = threading.Event()
25
+ self.granted = False
26
+
27
+
28
+ class Limiter:
29
+ def __init__(
30
+ self, max_concurrency: int = _DEFAULT_MAX, wait_ms: int = _DEFAULT_WAIT_MS
31
+ ) -> None:
32
+ self._max = max_concurrency
33
+ self._wait_ms = wait_ms
34
+ self._held = 0
35
+ self._waiters: deque[_Ticket] = deque()
36
+ self._lock = threading.Lock()
37
+
38
+ def configure(self, *, max_concurrency: int | None = None, wait_ms: int | None = None) -> None:
39
+ with self._lock:
40
+ if self._held or self._waiters:
41
+ raise InvalidOptionError(
42
+ "cannot reconfigure the native limiter while calls are in flight"
43
+ )
44
+ if max_concurrency is not None:
45
+ if isinstance(max_concurrency, bool) or not isinstance(max_concurrency, int):
46
+ raise InvalidOptionError("native_max_concurrency must be an integer in 1..128")
47
+ if not 1 <= max_concurrency <= 128:
48
+ raise InvalidOptionError("native_max_concurrency must be an integer in 1..128")
49
+ self._max = max_concurrency
50
+ if wait_ms is not None:
51
+ if isinstance(wait_ms, bool) or not isinstance(wait_ms, int):
52
+ raise InvalidOptionError("native_wait_ms must be an integer in 1..3600000")
53
+ if not 1 <= wait_ms <= 3_600_000:
54
+ raise InvalidOptionError("native_wait_ms must be an integer in 1..3600000")
55
+ self._wait_ms = wait_ms
56
+
57
+ def slot(self) -> _Hold:
58
+ return _Hold(self)
59
+
60
+ def stats(self) -> dict[str, int]:
61
+ with self._lock:
62
+ return {
63
+ "current": self._held,
64
+ "max": self._max,
65
+ "waiting": len(self._waiters),
66
+ "wait_ms": self._wait_ms,
67
+ }
68
+
69
+ def _acquire(self) -> None:
70
+ ticket: _Ticket | None = None
71
+ with self._lock:
72
+ if self._held < self._max and not self._waiters:
73
+ self._held += 1
74
+ return
75
+ ticket = _Ticket()
76
+ self._waiters.append(ticket)
77
+ wait_s = self._wait_ms / 1000
78
+ max_slots = self._max
79
+ wait_ms = self._wait_ms
80
+ assert ticket is not None
81
+ if ticket.event.wait(wait_s):
82
+ return
83
+ with self._lock:
84
+ if ticket.granted:
85
+ # Won the race with a release that granted the slot as we timed out.
86
+ return
87
+ try:
88
+ self._waiters.remove(ticket)
89
+ except ValueError:
90
+ pass
91
+ raise OverloadedError(
92
+ f"no native slot within {wait_ms}ms",
93
+ details={"max": max_slots, "wait_ms": wait_ms},
94
+ )
95
+
96
+ def _release(self) -> None:
97
+ with self._lock:
98
+ while self._waiters:
99
+ ticket = self._waiters.popleft()
100
+ ticket.granted = True
101
+ ticket.event.set()
102
+ return
103
+ if self._held:
104
+ self._held -= 1
105
+
106
+
107
+ class _Hold:
108
+ def __init__(self, limiter: Limiter) -> None:
109
+ self._limiter = limiter
110
+
111
+ def __enter__(self) -> None:
112
+ self._limiter._acquire()
113
+
114
+ def __exit__(
115
+ self,
116
+ exc_type: type[BaseException] | None,
117
+ exc: BaseException | None,
118
+ tb: TracebackType | None,
119
+ ) -> None:
120
+ self._limiter._release()
121
+
122
+
123
+ LIMITER = Limiter()
whiff/merge.py ADDED
@@ -0,0 +1,381 @@
1
+ """Merge a plugin patch into a ``whiff/1`` report.
2
+
3
+ Section rules match the Elixir binding:
4
+
5
+ * ``whiff`` and ``integrity`` cannot be patched.
6
+ * ``embedded`` cannot be patched.
7
+ * ``diagnostics`` accepts only ``warnings``, prefixed with ``python:<id>:``.
8
+ * ``security.risks`` are appended with ``source`` set to ``python:<id>``.
9
+ ``secrets`` and ``personal_data`` cannot be patched. Other security keys
10
+ may be added and never replace a native value.
11
+ * ``metadata`` and ``format`` keys are namespaces.
12
+ * ``content.keywords`` are appended and de-duplicated.
13
+ * An identical value is never a conflict. ``fallback`` replaces native
14
+ ``content`` leaves. ``override`` replaces a leaf only when its dotted path
15
+ is listed in ``override_fields``. A value written by an earlier plugin in
16
+ the same call is a conflict unless declared.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from whiff.errors import ConflictError, InvalidReportError
26
+
27
+ _MAX_DEPTH = 8
28
+ _MAX_STRING = 64 * 1024
29
+ _MAX_MAP_KEYS = 256
30
+ _MAX_NODES = 10_000
31
+ _MAX_TOTAL_BYTES = 1024 * 1024
32
+
33
+ _TREE = {"source", "identification", "system", "temporal", "rights", "physical"}
34
+ _NAMESPACED = {"metadata", "format"}
35
+ _RISK_KEYS = {"kind", "category", "severity", "detail", "source"}
36
+
37
+ _SCHEMA_PATH = Path(__file__).with_name("schemas") / "whiff-1.schema.json"
38
+
39
+
40
+ def _consts(name: str) -> frozenset[str]:
41
+ document = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
42
+ return frozenset(item["const"] for item in document["$defs"][name]["oneOf"])
43
+
44
+
45
+ _RISK_CATEGORIES = _consts("RiskCategory")
46
+ _SEVERITIES = _consts("Severity")
47
+
48
+
49
+ def merge(
50
+ report: dict[str, Any],
51
+ patch: dict[str, Any],
52
+ *,
53
+ mode: str = "augment",
54
+ override_fields: list[str] | tuple[str, ...] = (),
55
+ plugin_id: str = "plugin",
56
+ provenance: bool = False,
57
+ owned: set[str] | frozenset[str] | None = None,
58
+ ) -> dict[str, Any]:
59
+ merged, _written = merge_tracked(
60
+ report,
61
+ patch,
62
+ mode=mode,
63
+ override_fields=override_fields,
64
+ plugin_id=plugin_id,
65
+ provenance=provenance,
66
+ owned=owned,
67
+ )
68
+ return merged
69
+
70
+
71
+ def merge_tracked(
72
+ report: dict[str, Any],
73
+ patch: dict[str, Any],
74
+ *,
75
+ mode: str = "augment",
76
+ override_fields: list[str] | tuple[str, ...] = (),
77
+ plugin_id: str = "plugin",
78
+ provenance: bool = False,
79
+ owned: set[str] | frozenset[str] | None = None,
80
+ ) -> tuple[dict[str, Any], list[str]]:
81
+ if not isinstance(report, dict) or not isinstance(patch, dict):
82
+ raise InvalidReportError("merge requires dict report and patch")
83
+ validate_patch(patch)
84
+ ctx = _Ctx(
85
+ mode=mode,
86
+ fields=set(override_fields),
87
+ plugin_id=plugin_id,
88
+ owned=set(owned or ()),
89
+ )
90
+ merged, written = _apply(report, patch, ctx)
91
+ if provenance:
92
+ merged = _provenance(merged, written, plugin_id)
93
+ return merged, written
94
+
95
+
96
+ def validate_patch(patch: object) -> None:
97
+ _walk_size(patch, 0, 0, 0)
98
+
99
+
100
+ class _Ctx:
101
+ def __init__(self, mode: str, fields: set[str], plugin_id: str, owned: set[str]) -> None:
102
+ self.mode = mode
103
+ self.fields = fields
104
+ self.plugin_id = plugin_id
105
+ self.owned = owned
106
+
107
+
108
+ def _walk_size(value: object, depth: int, nodes: int, byte_count: int) -> tuple[int, int]:
109
+ if depth > _MAX_DEPTH:
110
+ raise InvalidReportError(f"plugin patch exceeds max depth {_MAX_DEPTH}")
111
+ if nodes >= _MAX_NODES:
112
+ raise InvalidReportError(f"plugin patch exceeds {_MAX_NODES} values")
113
+ if isinstance(value, dict):
114
+ if len(value) > _MAX_MAP_KEYS:
115
+ raise InvalidReportError(f"plugin patch map exceeds {_MAX_MAP_KEYS} keys")
116
+ if any(not isinstance(key, str) for key in value):
117
+ raise InvalidReportError("plugin patch keys must be strings")
118
+ key_bytes = sum(len(key.encode("utf-8")) for key in value)
119
+ nodes += 1
120
+ byte_count += key_bytes
121
+ for item in value.values():
122
+ nodes, byte_count = _walk_size(item, depth + 1, nodes, byte_count)
123
+ return nodes, byte_count
124
+ if isinstance(value, list):
125
+ nodes += 1
126
+ for item in value:
127
+ nodes, byte_count = _walk_size(item, depth + 1, nodes, byte_count)
128
+ return nodes, byte_count
129
+ if isinstance(value, str):
130
+ size = len(value.encode("utf-8"))
131
+ if size > _MAX_STRING:
132
+ raise InvalidReportError(f"plugin patch string exceeds {_MAX_STRING} bytes")
133
+ byte_count += size
134
+ if byte_count > _MAX_TOTAL_BYTES:
135
+ raise InvalidReportError(f"plugin patch exceeds {_MAX_TOTAL_BYTES} string bytes")
136
+ return nodes + 1, byte_count
137
+ if value is None or isinstance(value, bool) or isinstance(value, (int, float)):
138
+ return nodes + 1, byte_count
139
+ raise InvalidReportError("plugin patch contains an unsupported value")
140
+
141
+
142
+ def _apply(
143
+ report: dict[str, Any], patch: dict[str, Any], ctx: _Ctx
144
+ ) -> tuple[dict[str, Any], list[str]]:
145
+ written: list[str] = []
146
+ for section in sorted(patch):
147
+ report, new = _section(report, section, patch[section], ctx)
148
+ written.extend(new)
149
+ return report, written
150
+
151
+
152
+ def _section(
153
+ report: dict[str, Any], name: str, value: object, ctx: _Ctx
154
+ ) -> tuple[dict[str, Any], list[str]]:
155
+ if name == "embedded":
156
+ raise InvalidReportError("embedded patches are not allowed")
157
+ if name in {"whiff", "integrity"}:
158
+ raise ConflictError(f"cannot patch protected section `{name}`")
159
+ if not isinstance(value, dict):
160
+ raise InvalidReportError(f"patch section `{name}` must be a dict")
161
+ if name == "diagnostics":
162
+ return _diagnostics(report, value, ctx)
163
+ if name == "security":
164
+ return _security(report, value, ctx)
165
+ if name == "content":
166
+ return _tree(report, ["content"], value, ctx)
167
+ if name in _NAMESPACED:
168
+ return _namespaced(report, name, value, ctx)
169
+ if name in _TREE:
170
+ return _tree(report, [name], value, ctx)
171
+ raise InvalidReportError(f"unsupported patch section `{name}`")
172
+
173
+
174
+ def _diagnostics(
175
+ report: dict[str, Any], value: dict[str, Any], ctx: _Ctx
176
+ ) -> tuple[dict[str, Any], list[str]]:
177
+ extra = [key for key in value if key != "warnings"]
178
+ if extra:
179
+ raise ConflictError(f"plugins may only add diagnostics.warnings (got {extra!r})")
180
+ warnings = value.get("warnings", [])
181
+ if not isinstance(warnings, list) or not all(isinstance(item, str) for item in warnings):
182
+ raise InvalidReportError("diagnostics.warnings must be a list of strings")
183
+ prefixed = [f"python:{ctx.plugin_id}: {item}" for item in warnings]
184
+ existing = _get(report, ["diagnostics", "warnings"])
185
+ current = (
186
+ list(existing) if isinstance(existing, list) else ([] if existing is None else [existing])
187
+ )
188
+ return _put(report, ["diagnostics", "warnings"], current + prefixed), []
189
+
190
+
191
+ def _security(
192
+ report: dict[str, Any], value: dict[str, Any], ctx: _Ctx
193
+ ) -> tuple[dict[str, Any], list[str]]:
194
+ blocked = [key for key in value if key in {"secrets", "personal_data"}]
195
+ if blocked:
196
+ raise ConflictError(f"cannot patch native security fields: {blocked!r}")
197
+ risks = value.get("risks", None) if "risks" in value else None
198
+ rest = {key: item for key, item in value.items() if key != "risks"}
199
+ report, written = _append_risks(report, risks, ctx)
200
+ report, more = _security_fields(report, rest)
201
+ return report, written + more
202
+
203
+
204
+ def _append_risks(
205
+ report: dict[str, Any], risks: object, ctx: _Ctx
206
+ ) -> tuple[dict[str, Any], list[str]]:
207
+ if risks is None:
208
+ return report, []
209
+ if not isinstance(risks, list):
210
+ raise InvalidReportError("security.risks must be a list")
211
+ stamped: list[dict[str, Any]] = []
212
+ for risk in risks:
213
+ _validate_risk(risk)
214
+ assert isinstance(risk, dict)
215
+ stamped.append({**risk, "source": f"python:{ctx.plugin_id}"})
216
+ if not stamped:
217
+ return report, []
218
+ existing = _get(report, ["security", "risks"])
219
+ current = (
220
+ list(existing) if isinstance(existing, list) else ([] if existing is None else [existing])
221
+ )
222
+ return _put(report, ["security", "risks"], current + stamped), ["security.risks"]
223
+
224
+
225
+ def _validate_risk(risk: object) -> None:
226
+ if not isinstance(risk, dict):
227
+ raise InvalidReportError("each security.risks entry must be a dict")
228
+ unknown = [key for key in risk if key not in _RISK_KEYS]
229
+ if unknown:
230
+ raise InvalidReportError(f"risk has unknown keys: {unknown!r}")
231
+ kind = risk.get("kind")
232
+ if not isinstance(kind, str) or not kind:
233
+ raise InvalidReportError("risk.kind must be a non-empty string")
234
+ if risk.get("category") not in _RISK_CATEGORIES:
235
+ raise InvalidReportError(f"risk.category must be one of {sorted(_RISK_CATEGORIES)}")
236
+ if risk.get("severity") not in _SEVERITIES:
237
+ raise InvalidReportError(f"risk.severity must be one of {sorted(_SEVERITIES)}")
238
+ detail = risk.get("detail")
239
+ if detail is not None and not isinstance(detail, str):
240
+ raise InvalidReportError("risk.detail must be a string")
241
+
242
+
243
+ def _security_fields(
244
+ report: dict[str, Any], fields: dict[str, Any]
245
+ ) -> tuple[dict[str, Any], list[str]]:
246
+ written: list[str] = []
247
+ for key in sorted(fields):
248
+ path = ["security", key]
249
+ dotted = ".".join(path)
250
+ if not _has(report, path):
251
+ report = _put(report, path, fields[key])
252
+ written.append(dotted)
253
+ elif _get(report, path) != fields[key]:
254
+ raise ConflictError(f"cannot replace native `{dotted}`")
255
+ return report, written
256
+
257
+
258
+ def _namespaced(
259
+ report: dict[str, Any], section: str, value: dict[str, Any], ctx: _Ctx
260
+ ) -> tuple[dict[str, Any], list[str]]:
261
+ written: list[str] = []
262
+ for key in sorted(value):
263
+ path = [section, key]
264
+ dotted = ".".join(path)
265
+ if not _has(report, path):
266
+ report = _put(report, path, value[key])
267
+ written.extend(_leaf_paths(value[key], dotted))
268
+ elif _get(report, path) == value[key]:
269
+ continue
270
+ elif ctx.mode == "override" and dotted in ctx.fields:
271
+ report = _put(report, path, value[key])
272
+ written.extend(_leaf_paths(value[key], dotted))
273
+ else:
274
+ raise ConflictError(f"namespace collision on `{dotted}`")
275
+ return report, written
276
+
277
+
278
+ def _tree(
279
+ report: dict[str, Any], prefix: list[str], value: dict[str, Any], ctx: _Ctx
280
+ ) -> tuple[dict[str, Any], list[str]]:
281
+ written: list[str] = []
282
+ for key in sorted(value):
283
+ report, new = _tree_key(report, prefix + [key], value[key], ctx)
284
+ written.extend(new)
285
+ return report, written
286
+
287
+
288
+ def _tree_key(
289
+ report: dict[str, Any], path: list[str], value: object, ctx: _Ctx
290
+ ) -> tuple[dict[str, Any], list[str]]:
291
+ if path == ["content", "keywords"] and isinstance(value, list):
292
+ existing = _get(report, path)
293
+ current = (
294
+ list(existing)
295
+ if isinstance(existing, list)
296
+ else ([] if existing is None else [existing])
297
+ )
298
+ merged = list(current)
299
+ for item in value:
300
+ if item not in merged:
301
+ merged.append(item)
302
+ if merged == current:
303
+ return report, []
304
+ return _put(report, path, merged), ["content.keywords"]
305
+
306
+ if not _has(report, path):
307
+ dotted = ".".join(path)
308
+ return _put(report, path, value), _leaf_paths(value, dotted)
309
+
310
+ existing = _get(report, path)
311
+ if isinstance(existing, dict) and isinstance(value, dict):
312
+ return _tree(report, path, value, ctx)
313
+ if existing == value:
314
+ return report, []
315
+ return _replace_leaf(report, path, value, ctx)
316
+
317
+
318
+ def _replace_leaf(
319
+ report: dict[str, Any], path: list[str], value: object, ctx: _Ctx
320
+ ) -> tuple[dict[str, Any], list[str]]:
321
+ dotted = ".".join(path)
322
+ declared = ctx.mode == "override" and dotted in ctx.fields
323
+ owned = any(item == dotted or item.startswith(dotted + ".") for item in ctx.owned)
324
+ if declared:
325
+ return _put(report, path, value), _leaf_paths(value, dotted)
326
+ if owned:
327
+ raise ConflictError(f"`{dotted}` was already set by another plugin")
328
+ if ctx.mode == "override":
329
+ raise ConflictError(f"override of `{dotted}` not declared in override_fields")
330
+ if ctx.mode == "fallback" and path[0] == "content":
331
+ return _put(report, path, value), _leaf_paths(value, dotted)
332
+ return report, []
333
+
334
+
335
+ def _provenance(report: dict[str, Any], written: list[str], plugin_id: str) -> dict[str, Any]:
336
+ if not written:
337
+ return report
338
+ existing = _get(report, ["diagnostics", "provenance"])
339
+ current = dict(existing) if isinstance(existing, dict) else {}
340
+ new = {path: f"python:{plugin_id}" for path in written}
341
+ new.update(current)
342
+ return _put(report, ["diagnostics", "provenance"], new)
343
+
344
+
345
+ def _leaf_paths(value: object, prefix: str) -> list[str]:
346
+ if isinstance(value, dict) and value:
347
+ out: list[str] = []
348
+ for key in sorted(value):
349
+ out.extend(_leaf_paths(value[key], f"{prefix}.{key}"))
350
+ return out
351
+ return [prefix]
352
+
353
+
354
+ def _has(report: dict[str, Any], path: list[str]) -> bool:
355
+ current: object = report
356
+ for key in path:
357
+ if not isinstance(current, dict) or key not in current:
358
+ return False
359
+ current = current[key]
360
+ return True
361
+
362
+
363
+ def _get(report: dict[str, Any], path: list[str]) -> object:
364
+ current: object = report
365
+ for key in path:
366
+ if not isinstance(current, dict):
367
+ return None
368
+ current = current.get(key)
369
+ return current
370
+
371
+
372
+ def _put(report: dict[str, Any], path: list[str], value: object) -> dict[str, Any]:
373
+ if len(path) == 1:
374
+ updated = dict(report)
375
+ updated[path[0]] = value
376
+ return updated
377
+ child = report.get(path[0])
378
+ child_dict = dict(child) if isinstance(child, dict) else {}
379
+ updated = dict(report)
380
+ updated[path[0]] = _put(child_dict, path[1:], value)
381
+ return updated