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/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
|