projectaria-debug-sharing 0.1.0__py3-none-any.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.
- projectaria_debug_sharing/__init__.py +22 -0
- projectaria_debug_sharing/debug_sharing/__init__.py +15 -0
- projectaria_debug_sharing/debug_sharing/_internal/__init__.py +13 -0
- projectaria_debug_sharing/debug_sharing/_internal/delivery.py +252 -0
- projectaria_debug_sharing/debug_sharing/_internal/file_access.py +130 -0
- projectaria_debug_sharing/debug_sharing/_internal/manifest.py +133 -0
- projectaria_debug_sharing/debug_sharing/_internal/plain_files.py +208 -0
- projectaria_debug_sharing/debug_sharing/_internal/report_command.py +560 -0
- projectaria_debug_sharing/debug_sharing/_internal/spool.py +673 -0
- projectaria_debug_sharing/debug_sharing/cli.py +183 -0
- projectaria_debug_sharing/debug_sharing/collectors.py +472 -0
- projectaria_debug_sharing/debug_sharing/command.py +36 -0
- projectaria_debug_sharing/debug_sharing/facade.py +435 -0
- projectaria_debug_sharing/debug_sharing/flytrap.py +327 -0
- projectaria_debug_sharing/debug_sharing/submitter.py +103 -0
- projectaria_debug_sharing/debug_sharing/types.py +172 -0
- projectaria_debug_sharing-0.1.0.dist-info/METADATA +30 -0
- projectaria_debug_sharing-0.1.0.dist-info/RECORD +22 -0
- projectaria_debug_sharing-0.1.0.dist-info/WHEEL +5 -0
- projectaria_debug_sharing-0.1.0.dist-info/entry_points.txt +2 -0
- projectaria_debug_sharing-0.1.0.dist-info/licenses/LICENSE +201 -0
- projectaria_debug_sharing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""`ProjectAriaDebugSharing`, the one object a tool builds to file a report."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Final
|
|
25
|
+
|
|
26
|
+
from projectaria_debug_sharing.debug_sharing._internal.delivery import ReportDelivery
|
|
27
|
+
from projectaria_debug_sharing.debug_sharing._internal.spool import ReportSpool
|
|
28
|
+
from projectaria_debug_sharing.debug_sharing.collectors import (
|
|
29
|
+
Collector,
|
|
30
|
+
EnvironmentCollector,
|
|
31
|
+
)
|
|
32
|
+
from projectaria_debug_sharing.debug_sharing.submitter import (
|
|
33
|
+
Delivery,
|
|
34
|
+
DrainResult,
|
|
35
|
+
SubmissionError,
|
|
36
|
+
Submitter,
|
|
37
|
+
)
|
|
38
|
+
from projectaria_debug_sharing.debug_sharing.types import (
|
|
39
|
+
Attachment,
|
|
40
|
+
CollectionScope,
|
|
41
|
+
CollectorFailure,
|
|
42
|
+
ConsentDeclinedError,
|
|
43
|
+
ConsentRequest,
|
|
44
|
+
ConsentSource,
|
|
45
|
+
DebugReport,
|
|
46
|
+
NoConsentBasisError,
|
|
47
|
+
OfferedCollector,
|
|
48
|
+
RequestConsent,
|
|
49
|
+
ShareOutcome,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"COLLECTOR_FAILURES_METADATA_KEY",
|
|
54
|
+
"CONSENT_METADATA_KEY",
|
|
55
|
+
"CONSENT_SOURCE_METADATA_KEY",
|
|
56
|
+
"CollectionScope",
|
|
57
|
+
"CollectorFailure",
|
|
58
|
+
"ConsentDeclinedError",
|
|
59
|
+
"ConsentRequest",
|
|
60
|
+
"Delivery",
|
|
61
|
+
"DrainResult",
|
|
62
|
+
"NoConsentBasisError",
|
|
63
|
+
"OfferedCollector",
|
|
64
|
+
"ProjectAriaDebugSharing",
|
|
65
|
+
"RESERVED_METADATA_KEYS",
|
|
66
|
+
"RequestConsent",
|
|
67
|
+
"ShareOutcome",
|
|
68
|
+
"SubmissionError",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
CONSENT_METADATA_KEY: Final[str] = "consent"
|
|
74
|
+
"""Report metadata naming the granted scopes, comma-separated."""
|
|
75
|
+
|
|
76
|
+
CONSENT_SOURCE_METADATA_KEY: Final[str] = "consent_source"
|
|
77
|
+
"""Report metadata naming the `ConsentSource` of that grant."""
|
|
78
|
+
|
|
79
|
+
COLLECTOR_FAILURES_METADATA_KEY: Final[str] = "collector_failures"
|
|
80
|
+
"""Report metadata naming each failed collector as `name:ExceptionType`,
|
|
81
|
+
comma-separated. Absent when every granted collector succeeded."""
|
|
82
|
+
|
|
83
|
+
RESERVED_METADATA_KEYS: Final[frozenset[str]] = frozenset(
|
|
84
|
+
{CONSENT_METADATA_KEY, CONSENT_SOURCE_METADATA_KEY, COLLECTOR_FAILURES_METADATA_KEY}
|
|
85
|
+
)
|
|
86
|
+
"""Metadata keys the sharer sets, which `collect` and `share` refuse from the
|
|
87
|
+
tool."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ProjectAriaDebugSharing:
|
|
91
|
+
"""
|
|
92
|
+
Assembles and submits a debug report. The tool supplies its collectors and
|
|
93
|
+
submitter; this owns what every tool shares: consent, correlation, running
|
|
94
|
+
collectors concurrently, partial failures, and keeping a report that could
|
|
95
|
+
not be sent until a later `drain`.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
source_app: str,
|
|
102
|
+
submitter: Submitter,
|
|
103
|
+
request_consent: RequestConsent | None = None,
|
|
104
|
+
distribution: str | None = None,
|
|
105
|
+
spool_dir: Path | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Args:
|
|
109
|
+
source_app: The tool filing reports.
|
|
110
|
+
submitter: Where reports are sent.
|
|
111
|
+
request_consent: Asks the user what may be collected. Without it,
|
|
112
|
+
every `collect` must say how consent was obtained (`granted=`).
|
|
113
|
+
distribution: The tool's installed package; adds an
|
|
114
|
+
`EnvironmentCollector` for it.
|
|
115
|
+
spool_dir: Where reports are kept until they are sent. Defaults to
|
|
116
|
+
the directory every Project Aria tool shares, so any of them
|
|
117
|
+
can send what another kept.
|
|
118
|
+
"""
|
|
119
|
+
self._source_app = source_app
|
|
120
|
+
self._submitter = submitter
|
|
121
|
+
self._request_consent = request_consent
|
|
122
|
+
self._spool = ReportSpool(spool_dir)
|
|
123
|
+
self._collectors: list[Collector] = []
|
|
124
|
+
if distribution is not None:
|
|
125
|
+
self._collectors.append(EnvironmentCollector(distribution=distribution))
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def collectors(self) -> tuple[Collector, ...]:
|
|
129
|
+
"""The registered collectors, in registration order."""
|
|
130
|
+
return tuple(self._collectors)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def offered_scopes(self) -> frozenset[CollectionScope]:
|
|
134
|
+
"""The consent categories the registered collectors read under."""
|
|
135
|
+
return frozenset(c.scope for c in self._collectors)
|
|
136
|
+
|
|
137
|
+
def register(self, collector: Collector) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Adds a collector. Order is kept; collection itself is concurrent.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: A collector with that name is already registered; its
|
|
143
|
+
attachment would collide with this one's.
|
|
144
|
+
"""
|
|
145
|
+
if any(c.name == collector.name for c in self._collectors):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"a collector named {collector.name!r} is already registered"
|
|
148
|
+
)
|
|
149
|
+
self._collectors.append(collector)
|
|
150
|
+
|
|
151
|
+
async def share(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
description: str,
|
|
155
|
+
metadata: Mapping[str, str] | None = None,
|
|
156
|
+
report_id: str | None = None,
|
|
157
|
+
) -> ShareOutcome:
|
|
158
|
+
"""
|
|
159
|
+
Asks for consent, collects, and submits.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
description: What went wrong, in the user's words.
|
|
163
|
+
metadata: Tool fields for the report. The `RESERVED_METADATA_KEYS`
|
|
164
|
+
are reserved.
|
|
165
|
+
report_id: Correlates this report with another; generated if None.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The bug id, report id and any collectors that failed.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: `metadata` uses a reserved key.
|
|
172
|
+
ConsentDeclinedError: The user declined.
|
|
173
|
+
NoConsentBasisError: There is no consent prompt.
|
|
174
|
+
SubmissionError: The report was not filed; it carries the report.
|
|
175
|
+
"""
|
|
176
|
+
report, failures = await self.collect(
|
|
177
|
+
description=description, metadata=metadata, report_id=report_id
|
|
178
|
+
)
|
|
179
|
+
bug_id = await self.submit(report)
|
|
180
|
+
return ShareOutcome(
|
|
181
|
+
bug_id=bug_id, report_id=report.report_id, failures=failures
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def submit(self, report: DebugReport) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Sends a report that `collect` produced.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
report: The report. It must carry the consent record `collect`
|
|
190
|
+
writes, so nothing reaches Meta without one.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The bug id.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
NoConsentBasisError: The report has no consent record.
|
|
197
|
+
SubmissionError: The report was not filed. It carries the report,
|
|
198
|
+
so the caller can keep it rather than lose the user's work.
|
|
199
|
+
"""
|
|
200
|
+
self._require_consent_record(report)
|
|
201
|
+
try:
|
|
202
|
+
bug_id = await self._submitter.submit(report)
|
|
203
|
+
except SubmissionError as e:
|
|
204
|
+
if e.report is None:
|
|
205
|
+
e.report = report
|
|
206
|
+
raise
|
|
207
|
+
logger.info("Submitted debug report %s as bug %s", report.report_id, bug_id)
|
|
208
|
+
return bug_id
|
|
209
|
+
|
|
210
|
+
async def deliver(self, report: DebugReport) -> Delivery:
|
|
211
|
+
"""
|
|
212
|
+
Sends a report that `collect` produced, and keeps it for `drain` if
|
|
213
|
+
that fails and a retry could succeed. A refused report is not kept. A
|
|
214
|
+
copy `keep` saved earlier is held back from drains during the send,
|
|
215
|
+
removed once the report is filed or refused, and put back if the send
|
|
216
|
+
is cancelled.
|
|
217
|
+
Cancelling a send with no earlier copy keeps nothing.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
report: The report. It must carry the consent record `collect`
|
|
221
|
+
writes.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The bug id; or the error, and where the report was kept.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
NoConsentBasisError: The report has no consent record. It is
|
|
228
|
+
neither sent nor kept.
|
|
229
|
+
"""
|
|
230
|
+
self._require_consent_record(report)
|
|
231
|
+
return await ReportDelivery(self, self._spool).deliver(report)
|
|
232
|
+
|
|
233
|
+
async def drain(self, *, requested_only: bool = False) -> DrainResult:
|
|
234
|
+
"""
|
|
235
|
+
Sends the reports kept for later, oldest first, deleting each once it
|
|
236
|
+
is filed. The spool may hold reports other tools kept. One with no
|
|
237
|
+
consent record is refused rather than sent, so it never stops a drain.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
requested_only: Send only reports the user asked to send, not ones
|
|
241
|
+
collected on their behalf. For a drain the user did not start.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The bug ids filed, and how many reports are still waiting or were
|
|
245
|
+
refused.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
OSError: The spool cannot be listed, or is a symlink, so whether any
|
|
249
|
+
report is waiting is unknown.
|
|
250
|
+
"""
|
|
251
|
+
delivery = ReportDelivery(
|
|
252
|
+
_ConsentCheckedSubmitter(self._submitter), self._spool
|
|
253
|
+
)
|
|
254
|
+
return await delivery.drain(requested_only=requested_only)
|
|
255
|
+
|
|
256
|
+
def keep(self, report: DebugReport, *, requested: bool = True) -> Path:
|
|
257
|
+
"""
|
|
258
|
+
Keeps a report for `drain` without sending it, for example while the
|
|
259
|
+
tool finishes work the report should mention. Keeping the same report
|
|
260
|
+
again replaces the copy. Until `deliver` claims it, the copy is an
|
|
261
|
+
ordinary kept report: a drain, in this process or another, may send it
|
|
262
|
+
first, and the report is then filed twice.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
report: The report. It must carry the consent record `collect`
|
|
266
|
+
writes.
|
|
267
|
+
requested: False for a report collected on the user's behalf rather
|
|
268
|
+
than at their request. It is kept for less time, and
|
|
269
|
+
`drain(requested_only=True)` skips it.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Where the report is kept.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
NoConsentBasisError: The report has no consent record.
|
|
276
|
+
ValueError: The report id is not usable as a directory name.
|
|
277
|
+
OSError: The report could not be written.
|
|
278
|
+
"""
|
|
279
|
+
self._require_consent_record(report)
|
|
280
|
+
return self._spool.save(report, requested=requested)
|
|
281
|
+
|
|
282
|
+
async def collect(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
description: str,
|
|
286
|
+
metadata: Mapping[str, str] | None = None,
|
|
287
|
+
report_id: str | None = None,
|
|
288
|
+
granted: frozenset[CollectionScope] | None = None,
|
|
289
|
+
) -> tuple[DebugReport, tuple[CollectorFailure, ...]]:
|
|
290
|
+
"""
|
|
291
|
+
Assembles a report without sending it. Only collectors whose scope was
|
|
292
|
+
granted run, and the report records the grant and where it came from.
|
|
293
|
+
A scope nobody was offered is never recorded.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
description: What went wrong, in the user's words.
|
|
297
|
+
metadata: Tool fields for the report. The `RESERVED_METADATA_KEYS`
|
|
298
|
+
are reserved.
|
|
299
|
+
report_id: Correlates this report with another; generated if None.
|
|
300
|
+
granted: Scopes the calling tool already has the user's consent
|
|
301
|
+
for, recorded as `ConsentSource.CALLER`. None asks through
|
|
302
|
+
`request_consent`, recorded as `ConsentSource.PROMPTED`.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
The report, and the collectors that failed.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
ValueError: `metadata` uses a reserved key. Checked before
|
|
309
|
+
asking, so the user is not prompted for a request that cannot
|
|
310
|
+
succeed.
|
|
311
|
+
ConsentDeclinedError: The user declined.
|
|
312
|
+
NoConsentBasisError: There is no prompt and no `granted`.
|
|
313
|
+
"""
|
|
314
|
+
metadata = dict(metadata or {})
|
|
315
|
+
if metadata.keys() & RESERVED_METADATA_KEYS:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"{sorted(RESERVED_METADATA_KEYS)} are reserved for the sharer"
|
|
318
|
+
)
|
|
319
|
+
offered = tuple((c, c.scope) for c in self._collectors)
|
|
320
|
+
if granted is None:
|
|
321
|
+
granted, source = await self._ask_for_consent(offered)
|
|
322
|
+
else:
|
|
323
|
+
source = ConsentSource.CALLER
|
|
324
|
+
granted = frozenset(granted) & frozenset(scope for _, scope in offered)
|
|
325
|
+
metadata[CONSENT_METADATA_KEY] = ",".join(sorted(s.value for s in granted))
|
|
326
|
+
metadata[CONSENT_SOURCE_METADATA_KEY] = source.value
|
|
327
|
+
|
|
328
|
+
attachments, failures = await self._run_consented_collectors(offered, granted)
|
|
329
|
+
if failures:
|
|
330
|
+
metadata[COLLECTOR_FAILURES_METADATA_KEY] = ",".join(
|
|
331
|
+
f"{f.collector_name}:{f.error_type}" for f in failures
|
|
332
|
+
)
|
|
333
|
+
report = DebugReport(
|
|
334
|
+
report_id=report_id or str(uuid.uuid4()),
|
|
335
|
+
description=description,
|
|
336
|
+
source_app=self._source_app,
|
|
337
|
+
attachments=attachments,
|
|
338
|
+
metadata=metadata,
|
|
339
|
+
)
|
|
340
|
+
return report, failures
|
|
341
|
+
|
|
342
|
+
async def _ask_for_consent(
|
|
343
|
+
self, offered: Sequence[tuple[Collector, CollectionScope]]
|
|
344
|
+
) -> tuple[frozenset[CollectionScope], ConsentSource]:
|
|
345
|
+
"""
|
|
346
|
+
Args:
|
|
347
|
+
offered: Each collector with its scope, read once per `collect` so
|
|
348
|
+
the prompt and the run cannot disagree about it.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
NoConsentBasisError: There is no prompt. Fails closed: granting
|
|
352
|
+
everything would let a tool that forgot its prompt upload
|
|
353
|
+
whatever its collectors reach, recordings included.
|
|
354
|
+
ConsentDeclinedError: The user declined.
|
|
355
|
+
"""
|
|
356
|
+
if self._request_consent is None:
|
|
357
|
+
raise NoConsentBasisError(
|
|
358
|
+
"No request_consent callback and no explicit `granted` set. "
|
|
359
|
+
"Pass one: a callback to ask the user, or granted= to record "
|
|
360
|
+
"consent the tool obtained itself."
|
|
361
|
+
)
|
|
362
|
+
request = ConsentRequest(
|
|
363
|
+
source_app=self._source_app,
|
|
364
|
+
offered=tuple(
|
|
365
|
+
OfferedCollector(name=c.name, scope=scope, disclosure=c.disclosure)
|
|
366
|
+
for c, scope in offered
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
granted = await self._request_consent(request)
|
|
370
|
+
if granted is None:
|
|
371
|
+
raise ConsentDeclinedError("User declined to share debug information")
|
|
372
|
+
return granted, ConsentSource.PROMPTED
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _require_consent_record(report: DebugReport) -> None:
|
|
376
|
+
"""
|
|
377
|
+
Raises:
|
|
378
|
+
NoConsentBasisError: The report has no consent record.
|
|
379
|
+
"""
|
|
380
|
+
if CONSENT_SOURCE_METADATA_KEY not in report.metadata:
|
|
381
|
+
raise NoConsentBasisError(
|
|
382
|
+
f"Report {report.report_id} has no consent record; build it with collect()"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
@staticmethod
|
|
386
|
+
async def _run_consented_collectors(
|
|
387
|
+
offered: Sequence[tuple[Collector, CollectionScope]],
|
|
388
|
+
granted: frozenset[CollectionScope],
|
|
389
|
+
) -> tuple[tuple[Attachment, ...], tuple[CollectorFailure, ...]]:
|
|
390
|
+
"""
|
|
391
|
+
Runs the granted collectors concurrently. One that raises is reported as
|
|
392
|
+
a failure; cancellation and other process-level exits are re-raised,
|
|
393
|
+
since they mean the process is going away, not that a collector is
|
|
394
|
+
broken.
|
|
395
|
+
"""
|
|
396
|
+
consented = [c for c, scope in offered if scope in granted]
|
|
397
|
+
results = await asyncio.gather(
|
|
398
|
+
*(c.collect() for c in consented), return_exceptions=True
|
|
399
|
+
)
|
|
400
|
+
attachments: list[Attachment] = []
|
|
401
|
+
failures: list[CollectorFailure] = []
|
|
402
|
+
for collector, result in zip(consented, results):
|
|
403
|
+
if isinstance(result, BaseException) and not isinstance(result, Exception):
|
|
404
|
+
raise result
|
|
405
|
+
if isinstance(result, Exception):
|
|
406
|
+
logger.warning(
|
|
407
|
+
"Collector %s failed: %s", collector.name, result, exc_info=result
|
|
408
|
+
)
|
|
409
|
+
failures.append(
|
|
410
|
+
CollectorFailure(
|
|
411
|
+
collector_name=collector.name,
|
|
412
|
+
error=str(result) or type(result).__name__,
|
|
413
|
+
error_type=type(result).__name__,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
continue
|
|
417
|
+
attachments.extend(result)
|
|
418
|
+
return tuple(attachments), tuple(failures)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class _ConsentCheckedSubmitter(Submitter):
|
|
422
|
+
"""
|
|
423
|
+
Refuses, rather than sends, a kept report with no consent record. A refusal
|
|
424
|
+
is marked and skipped, where `NoConsentBasisError` would stop the drain.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(self, submitter: Submitter) -> None:
|
|
428
|
+
self._submitter = submitter
|
|
429
|
+
|
|
430
|
+
async def submit(self, report: DebugReport) -> str:
|
|
431
|
+
if CONSENT_SOURCE_METADATA_KEY not in report.metadata:
|
|
432
|
+
raise SubmissionError(
|
|
433
|
+
f"Report {report.report_id} has no consent record", retryable=False
|
|
434
|
+
)
|
|
435
|
+
return await self._submitter.submit(report)
|