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.
@@ -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)