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,560 @@
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
+ """What `aria-debug-report` runs, built on `ProjectAriaDebugSharing`."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import getpass
21
+ import json
22
+ import logging
23
+ import sys
24
+ from collections.abc import AsyncIterator, Mapping, Sequence
25
+ from contextlib import asynccontextmanager
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import ClassVar, Final
29
+
30
+ from projectaria_debug_sharing.debug_sharing._internal.plain_files import (
31
+ open_plain_file,
32
+ )
33
+ from projectaria_debug_sharing.debug_sharing.collectors import Collector, FileCollector
34
+ from projectaria_debug_sharing.debug_sharing.facade import (
35
+ CollectorFailure,
36
+ ConsentDeclinedError,
37
+ Delivery,
38
+ DrainResult,
39
+ OfferedCollector,
40
+ ProjectAriaDebugSharing,
41
+ RequestConsent,
42
+ RESERVED_METADATA_KEYS,
43
+ )
44
+ from projectaria_debug_sharing.debug_sharing.flytrap import (
45
+ DEV_SERVER_ENV,
46
+ FlyTrapSubmissionError,
47
+ FlyTrapSubmitter,
48
+ NotLoggedInError,
49
+ REPORT_BUG_URL,
50
+ report_bug_url,
51
+ )
52
+ from projectaria_debug_sharing.debug_sharing.submitter import Submitter
53
+ from projectaria_login import (
54
+ AuthenticationConfig,
55
+ FileEncryptionKeyRequest,
56
+ KeyRequestPurpose,
57
+ ProjectAriaLogin,
58
+ )
59
+
60
+ CLI_LOGGER_NAME: Final[str] = "projectaria_debug_sharing.debug_sharing.cli"
61
+ """The logger whose lines reach the console without `--verbose`. Named
62
+ explicitly: as the binary's `__main__`, `cli`'s `__name__` falls outside the
63
+ package logger the console handler is attached to."""
64
+
65
+ logger: logging.Logger = logging.getLogger(CLI_LOGGER_NAME)
66
+
67
+
68
+ def debug_sharing_auth_config() -> AuthenticationConfig:
69
+ """The application `aria-debug-report` logs in as: ARK Debug Sharing.
70
+
71
+ Every value is one an installed client may carry. The FB part is the app's
72
+ client token, never its secret: device login accepts it, and the server
73
+ fetches the secret itself when it mints the ID token. The FRL client token
74
+ is Project Aria Login's until Debug Sharing's own is issued; only the
75
+ password login reads it.
76
+ """
77
+ return AuthenticationConfig(
78
+ fb_app_access_token="2004636393387720|867e3e56220e91334bd008e8607090f9",
79
+ client_token="FRL|1563499658844492|888ed02d3443510a67c96e63d4d71b7b",
80
+ client_application=29261831290084371,
81
+ )
82
+
83
+
84
+ class ReportInputError(Exception):
85
+ """The user mistyped something. Reported as one line, not a traceback."""
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ReportInput:
90
+ """What the user asked to send. Build it with `from_command_line`."""
91
+
92
+ description: str
93
+ attachments: tuple[Path, ...] = ()
94
+ metadata: Mapping[str, str] = field(default_factory=dict)
95
+
96
+ _JSON_FIELDS: ClassVar[frozenset[str]] = frozenset(
97
+ {"description", "attachments", "metadata"}
98
+ )
99
+ """The keys `--from-json` accepts. `ClassVar`, since a dataclass makes a
100
+ `Final` annotation a field."""
101
+
102
+ @classmethod
103
+ def from_command_line(
104
+ cls,
105
+ *,
106
+ description: str = "",
107
+ attachments: Sequence[Path] = (),
108
+ metadata: Sequence[tuple[str, str]] = (),
109
+ from_json: Path | None = None,
110
+ ) -> ReportInput:
111
+ """
112
+ Checks the `submit` flags, or reads `--from-json`, before anything logs
113
+ in, so a mistyped path fails before authentication starts.
114
+
115
+ Args:
116
+ description: `--description`.
117
+ attachments: Each `--attach`.
118
+ metadata: Each `--metadata KEY=VALUE`.
119
+ from_json: `--from-json`, which replaces the other three.
120
+
121
+ Returns:
122
+ The checked input; every attachment is a plain file that opens.
123
+
124
+ Raises:
125
+ ReportInputError: Anything the user mistyped, including flags that
126
+ `--from-json` would silently override, no description, a
127
+ metadata key the consent record owns, or an attachment that
128
+ cannot be read.
129
+ """
130
+ if from_json is not None:
131
+ if description or attachments or metadata:
132
+ raise ReportInputError(
133
+ "--from-json cannot be combined with --description, --attach "
134
+ "or --metadata"
135
+ )
136
+ return cls._from_json(from_json)
137
+ if not description:
138
+ raise ReportInputError("--description is required: say what went wrong")
139
+ return cls._checked(description, attachments, dict(metadata))
140
+
141
+ @classmethod
142
+ def _from_json(cls, path: Path) -> ReportInput:
143
+ fields = cls._read_json_object(path)
144
+ unknown = fields.keys() - cls._JSON_FIELDS
145
+ if unknown:
146
+ raise ReportInputError(
147
+ f"{path}: unknown field(s) {', '.join(sorted(unknown))}"
148
+ )
149
+ description = fields.get("description")
150
+ if not isinstance(description, str) or not description:
151
+ raise ReportInputError(f"{path}: description must be a non-empty string")
152
+ listed = fields.get("attachments", [])
153
+ if not isinstance(listed, list) or not all(isinstance(p, str) for p in listed):
154
+ raise ReportInputError(f"{path}: attachments must be a list of paths")
155
+ metadata = fields.get("metadata", {})
156
+ if not isinstance(metadata, dict) or not all(
157
+ isinstance(v, str) for v in metadata.values()
158
+ ):
159
+ raise ReportInputError(f"{path}: metadata must be an object of strings")
160
+ return cls._checked(description, [Path(p) for p in listed], metadata)
161
+
162
+ @staticmethod
163
+ def _read_json_object(path: Path) -> dict[str, object]:
164
+ try:
165
+ fields = json.loads(path.read_text(encoding="utf-8"))
166
+ except (OSError, ValueError) as e:
167
+ raise ReportInputError(f"{path} could not be read: {e}") from e
168
+ if not isinstance(fields, dict):
169
+ raise ReportInputError(f"{path} must contain a JSON object")
170
+ return fields
171
+
172
+ @classmethod
173
+ def _checked(
174
+ cls, description: str, attachments: Sequence[Path], metadata: dict[str, str]
175
+ ) -> ReportInput:
176
+ reserved = metadata.keys() & RESERVED_METADATA_KEYS
177
+ if reserved:
178
+ raise ReportInputError(
179
+ f"metadata key(s) {', '.join(sorted(reserved))} are reserved for "
180
+ "the sharer"
181
+ )
182
+ for path in attachments:
183
+ try:
184
+ open_plain_file(path).close()
185
+ except OSError as e:
186
+ raise ReportInputError(
187
+ f"could not attach {path}: {e.strerror or type(e).__name__}"
188
+ ) from e
189
+ return cls(
190
+ description=description, attachments=tuple(attachments), metadata=metadata
191
+ )
192
+
193
+
194
+ class DebugReportCommand:
195
+ """
196
+ Runs one `aria-debug-report` command through `ProjectAriaDebugSharing`:
197
+ files a report, which the sharer keeps if sending fails, or drains what is
198
+ kept.
199
+
200
+ Another tool's command line can run the same commands under its own
201
+ identity, with collectors of its own and a consent prompt. It says what
202
+ happened in its own words by overriding the `report_*` methods.
203
+ """
204
+
205
+ SOURCE_APP: Final[str] = "aria_debug_report"
206
+ """The name `aria-debug-report` files under, and the default `source_app`.
207
+ A tool that runs the command passes its own; no command line can set it,
208
+ so a report cannot claim another app."""
209
+
210
+ _CONTENT_TYPES: Final[Mapping[str, str]] = {
211
+ ".txt": "text/plain",
212
+ ".log": "text/plain",
213
+ ".json": "application/json",
214
+ }
215
+ """The type an attachment is sent as, by suffix; anything else is binary."""
216
+
217
+ def __init__(
218
+ self,
219
+ *,
220
+ source_app: str = SOURCE_APP,
221
+ auth_config: AuthenticationConfig | None = None,
222
+ distribution: str | None = None,
223
+ request_consent: RequestConsent | None = None,
224
+ spool_dir: Path | None = None,
225
+ submitter: Submitter | None = None,
226
+ ) -> None:
227
+ """
228
+ Args:
229
+ source_app: The tool filing the report.
230
+ auth_config: The application the login presents. Defaults to ARK
231
+ Debug Sharing's.
232
+ distribution: The tool's installed package, described in the
233
+ report's environment.
234
+ request_consent: Asks the user what may be collected, for
235
+ `submit(..., ask=True)`.
236
+ spool_dir: Where unsent reports are kept. Defaults to the spool
237
+ every Project Aria tool shares; the command line deliberately
238
+ offers no other location.
239
+ submitter: Where reports are sent. Defaults to FlyTrap, over the
240
+ saved Project Aria login, opened for each command.
241
+ """
242
+ self._source_app = source_app
243
+ self._auth_config = auth_config
244
+ self._distribution = distribution
245
+ self._request_consent = request_consent
246
+ self._spool_dir = spool_dir
247
+ self._submitter = submitter
248
+
249
+ async def submit(
250
+ self,
251
+ report_input: ReportInput,
252
+ *,
253
+ collectors: Sequence[Collector] = (),
254
+ report_id: str | None = None,
255
+ ask: bool = False,
256
+ ) -> int:
257
+ """
258
+ Files one report. Without `ask`, running the command is the user's
259
+ consent: naming a file on the command line, or a host's `--yes`, so
260
+ the report records everything offered as granted by the caller. The
261
+ console says only what happened and quotes the bug id; the error
262
+ itself, which can carry server detail, is logged at DEBUG for
263
+ `--verbose`.
264
+
265
+ Args:
266
+ report_input: The checked command line.
267
+ collectors: The calling tool's own collectors. One that fails is
268
+ reported with `report_collector_failure` and the report is
269
+ filed without it. They run with the attachments, so anything
270
+ one does while collecting has happened even if an attachment
271
+ then stops the report.
272
+ report_id: Correlates the report with another, such as the bug
273
+ report the glasses file; generated if None.
274
+ ask: Ask with the `request_consent` given at construction.
275
+
276
+ Returns:
277
+ 0 when filed, else 1, including when the user declines.
278
+
279
+ Raises:
280
+ ReportInputError: PROJECTARIA_DEV_SERVER is malformed, checked
281
+ before logging in; a collector's name is already taken; or an
282
+ attachment could not be read after all, in which case the
283
+ report is not sent.
284
+ ValueError: `ask` without a `request_consent`.
285
+ """
286
+ if ask and self._request_consent is None:
287
+ raise ValueError("ask=True needs a request_consent")
288
+ self._check_destination()
289
+ async with self._open_submitter() as submitter:
290
+ sharer = self._sharer(submitter)
291
+ paths = self._register_attachments(sharer, report_input.attachments)
292
+ self._register_host_collectors(sharer, collectors)
293
+ if not ask:
294
+ self.report_consent_given(self._offered(sharer))
295
+ try:
296
+ report, failures = await sharer.collect(
297
+ description=report_input.description,
298
+ metadata=report_input.metadata,
299
+ report_id=report_id,
300
+ granted=None if ask else sharer.offered_scopes,
301
+ )
302
+ except ConsentDeclinedError:
303
+ return self.report_cancelled()
304
+ self._report_failures(failures, paths)
305
+ delivery = await sharer.deliver(report)
306
+ return self.report_delivery(delivery)
307
+
308
+ @staticmethod
309
+ def _register_host_collectors(
310
+ sharer: ProjectAriaDebugSharing, collectors: Sequence[Collector]
311
+ ) -> None:
312
+ for collector in collectors:
313
+ try:
314
+ sharer.register(collector)
315
+ except ValueError as e:
316
+ # A name already taken, such as by an attachment's stem.
317
+ raise ReportInputError(str(e)) from e
318
+
319
+ @staticmethod
320
+ def _offered(sharer: ProjectAriaDebugSharing) -> tuple[OfferedCollector, ...]:
321
+ return tuple(
322
+ OfferedCollector(c.name, c.scope, c.disclosure) for c in sharer.collectors
323
+ )
324
+
325
+ def _report_failures(
326
+ self, failures: Sequence[CollectorFailure], paths: Mapping[str, Path]
327
+ ) -> None:
328
+ """
329
+ Says each of the tool's own failed collectors, then raises for the
330
+ first attachment that could not be read. The tool's failures come
331
+ first: they are part of what the user sees even when an attachment
332
+ stops the report.
333
+
334
+ Raises:
335
+ ReportInputError: An attachment could not be read.
336
+ """
337
+ attachment_failures = [f for f in failures if f.collector_name in paths]
338
+ for failure in failures:
339
+ if failure.collector_name not in paths:
340
+ self.report_collector_failure(failure)
341
+ if attachment_failures:
342
+ failure = attachment_failures[0]
343
+ raise ReportInputError(
344
+ f"could not attach {paths[failure.collector_name]}: {failure.error}"
345
+ )
346
+
347
+ async def drain(self) -> int:
348
+ """
349
+ Sends every kept report. Running it by hand is the user asking, so it
350
+ includes reports they were offered but never asked to send.
351
+
352
+ Returns:
353
+ 0 when nothing is left over, else 1, including when the kept
354
+ reports cannot be read. Never a count, which the process exit
355
+ status would wrap.
356
+
357
+ Raises:
358
+ ReportInputError: PROJECTARIA_DEV_SERVER is malformed. Checked
359
+ before logging in.
360
+ """
361
+ self._check_destination()
362
+ async with self._open_submitter() as submitter:
363
+ try:
364
+ result = await self._sharer(submitter).drain()
365
+ except OSError as e:
366
+ logger.error(
367
+ "Could not read the kept reports: %s",
368
+ e.strerror or type(e).__name__,
369
+ )
370
+ return 1
371
+ return self._report_drain(result)
372
+
373
+ def _sharer(self, submitter: Submitter) -> ProjectAriaDebugSharing:
374
+ return ProjectAriaDebugSharing(
375
+ source_app=self._source_app,
376
+ submitter=submitter,
377
+ request_consent=self._request_consent,
378
+ distribution=self._distribution,
379
+ spool_dir=self._spool_dir,
380
+ )
381
+
382
+ @classmethod
383
+ def _register_attachments(
384
+ cls, sharer: ProjectAriaDebugSharing, attachments: Sequence[Path]
385
+ ) -> dict[str, Path]:
386
+ """
387
+ Registers a `FileCollector` per attachment, named after the file's stem
388
+ and numbered when stems repeat, since the sharer refuses two collectors
389
+ with one name.
390
+
391
+ Returns:
392
+ Each collector's name, mapped to its file.
393
+ """
394
+ paths: dict[str, Path] = {}
395
+ for path in attachments:
396
+ name = path.stem
397
+ count = 1
398
+ while name in paths:
399
+ count += 1
400
+ name = f"{path.stem}-{count}"
401
+ paths[name] = path
402
+ sharer.register(
403
+ FileCollector(
404
+ name,
405
+ path,
406
+ disclosure=f"the file {path}",
407
+ content_type=cls._CONTENT_TYPES.get(
408
+ path.suffix.lower(), "application/octet-stream"
409
+ ),
410
+ )
411
+ )
412
+ return paths
413
+
414
+ def report_consent_given(self, offered: tuple[OfferedCollector, ...]) -> None:
415
+ """
416
+ Called when running the command was the consent, just before
417
+ collecting; collection can still fail after it, and then nothing is
418
+ sent. Says nothing by default: `aria-debug-report` sends only the files
419
+ its user named.
420
+
421
+ Args:
422
+ offered: Everything the report will collect.
423
+ """
424
+
425
+ def report_cancelled(self) -> int:
426
+ """
427
+ Says the user declined, when `submit` asked.
428
+
429
+ Returns:
430
+ The exit code.
431
+ """
432
+ logger.error("Report cancelled. Nothing was sent.")
433
+ return 1
434
+
435
+ def report_collector_failure(self, failure: CollectorFailure) -> None:
436
+ """
437
+ Says one of the calling tool's collectors failed. The report is filed
438
+ without it.
439
+
440
+ Args:
441
+ failure: Which collector, and why.
442
+ """
443
+ logger.warning(
444
+ "Could not collect %s: %s", failure.collector_name, failure.error
445
+ )
446
+
447
+ def report_delivery(self, delivery: Delivery) -> int:
448
+ """
449
+ Says what happened to the report. The error itself, which can quote the
450
+ server's response, goes to DEBUG only.
451
+
452
+ Args:
453
+ delivery: What happened.
454
+
455
+ Returns:
456
+ The exit code: 0 when filed, else 1.
457
+ """
458
+ if delivery.bug_id is not None:
459
+ logger.info("Filed as bug %s", delivery.bug_id)
460
+ return 0
461
+ logger.debug("Not filed: %s", delivery.error)
462
+ logged_out = isinstance(delivery.error, NotLoggedInError)
463
+ if logged_out:
464
+ logger.error(
465
+ "Not logged in to Project Aria, or the saved login could not be "
466
+ "unlocked, so the report was not sent."
467
+ )
468
+ if delivery.will_retry:
469
+ logger.error(
470
+ "The report could not be sent. Kept at %s; the next drain sends it.",
471
+ delivery.spooled,
472
+ )
473
+ if logged_out:
474
+ logger.error(
475
+ "Log in to Project Aria, then run: aria-debug-report drain"
476
+ )
477
+ else:
478
+ logger.error(
479
+ "To retry it now and see why it failed, run: "
480
+ "aria-debug-report drain --verbose"
481
+ )
482
+ return 1
483
+ if delivery.error is not None and not delivery.error.retryable:
484
+ logger.error("The report was not accepted, so it will not be retried.")
485
+ else:
486
+ logger.error("The report could not be sent, and could not be kept either.")
487
+ logger.error("Run with --verbose for details.")
488
+ return 1
489
+
490
+ @staticmethod
491
+ def _report_drain(result: DrainResult) -> int:
492
+ if not (result.sent or result.waiting or result.rejected):
493
+ logger.info("Nothing pending.")
494
+ return 0
495
+ logger.info(
496
+ "Filed %d, %d still waiting, %d will not be sent",
497
+ len(result.sent),
498
+ result.waiting,
499
+ result.rejected,
500
+ )
501
+ if not (result.waiting or result.rejected):
502
+ return 0
503
+ logger.error("Run with --verbose for details.")
504
+ return 1
505
+
506
+ @asynccontextmanager
507
+ async def _open_submitter(self) -> AsyncIterator[Submitter]:
508
+ """The given submitter, or FlyTrap over the saved login; there is no token flag."""
509
+ if self._submitter is not None:
510
+ yield self._submitter
511
+ return
512
+ async with ProjectAriaLogin(
513
+ self._auth_config or debug_sharing_auth_config(),
514
+ on_need_file_encryption_key=self.ask_for_login_passphrase,
515
+ ) as login:
516
+ yield FlyTrapSubmitter(login=login)
517
+
518
+ @staticmethod
519
+ async def ask_for_login_passphrase(
520
+ request: FileEncryptionKeyRequest,
521
+ ) -> str | None:
522
+ """
523
+ The passphrase protecting a saved login, for machines with no keyring.
524
+
525
+ Args:
526
+ request: What the login wants the key for.
527
+
528
+ Returns:
529
+ The typed passphrase. None without a terminal to ask at, and for a
530
+ new login, which this tool never saves: `projectaria_login login`
531
+ does.
532
+ """
533
+ if request.purpose is not KeyRequestPurpose.LOAD or not sys.stdin.isatty():
534
+ return None
535
+ try:
536
+ return await asyncio.to_thread(
537
+ getpass.getpass, "Project Aria login passphrase: "
538
+ )
539
+ except EOFError:
540
+ return None
541
+
542
+ @staticmethod
543
+ def _check_destination() -> None:
544
+ """
545
+ Warns on the console when PROJECTARIA_DEV_SERVER sends reports
546
+ somewhere other than production; the library says so only at DEBUG.
547
+
548
+ Raises:
549
+ ReportInputError: PROJECTARIA_DEV_SERVER is set but malformed.
550
+ Checked before logging in, so it is a usage error rather than
551
+ a failed send.
552
+ """
553
+ try:
554
+ url = report_bug_url()
555
+ except FlyTrapSubmissionError as e:
556
+ raise ReportInputError(str(e)) from e
557
+ if url != REPORT_BUG_URL:
558
+ logger.warning(
559
+ "Sending to %s, not production: %s is set", url, DEV_SERVER_ENV
560
+ )