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,183 @@
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
+ """
16
+ `aria-debug-report` -- file a debug report, or send the ones already kept.
17
+
18
+ Deliberately small: this module is only the command line. `DebugReportCommand`
19
+ runs it through `ProjectAriaDebugSharing`, the same object any tool builds. It
20
+ knows nothing about any other tool: a producer that collects its own data keeps
21
+ a report with its own sharer and lets `drain` send it.
22
+
23
+ aria-debug-report submit --description "SLAM fails on startup" \\
24
+ --attach mps_cli.log
25
+ aria-debug-report submit --from-json report.json
26
+ aria-debug-report drain
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import asyncio
33
+ import logging
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Final
37
+
38
+ from projectaria_debug_sharing.debug_sharing._internal.report_command import (
39
+ CLI_LOGGER_NAME,
40
+ DebugReportCommand,
41
+ ReportInput,
42
+ ReportInputError,
43
+ )
44
+
45
+ logger: logging.Logger = logging.getLogger(CLI_LOGGER_NAME)
46
+
47
+ _EXAMPLES: Final[str] = """examples:
48
+ aria-debug-report submit --description "SLAM fails on startup" --attach mps_cli.log
49
+ aria-debug-report submit --from-json report.json
50
+ aria-debug-report drain"""
51
+
52
+ _PACKAGE_LOGGER: Final[str] = "projectaria_debug_sharing"
53
+ _CONSOLE_HANDLER: Final[str] = "aria-debug-report-console"
54
+
55
+
56
+ def parse_key_value(pair: str) -> tuple[str, str]:
57
+ """An argparse type, so a typo is a usage error rather than a traceback."""
58
+ key, sep, value = pair.partition("=")
59
+ if not sep or not key:
60
+ raise argparse.ArgumentTypeError(f"expected KEY=VALUE, got {pair!r}")
61
+ return key, value
62
+
63
+
64
+ def _parser() -> argparse.ArgumentParser:
65
+ parser = argparse.ArgumentParser(
66
+ prog="aria-debug-report",
67
+ description="File a Project Aria debug report, or send the ones already kept.",
68
+ epilog=_EXAMPLES,
69
+ formatter_class=argparse.RawDescriptionHelpFormatter,
70
+ )
71
+ common = argparse.ArgumentParser(add_help=False)
72
+ # SUPPRESS: a subcommand's own default would overwrite a --verbose given
73
+ # before the subcommand.
74
+ common.add_argument(
75
+ "--verbose",
76
+ action="store_true",
77
+ default=argparse.SUPPRESS,
78
+ help="Debug logging.",
79
+ )
80
+ parser.add_argument("--verbose", action="store_true", help="Debug logging.")
81
+ commands = parser.add_subparsers(dest="command", required=True)
82
+
83
+ submit_parser = commands.add_parser(
84
+ "submit", parents=[common], help="File one report now; keep it if that fails."
85
+ )
86
+ submit_parser.add_argument(
87
+ "--description",
88
+ default="",
89
+ help="What went wrong. Required without --from-json.",
90
+ )
91
+ submit_parser.add_argument(
92
+ "--attach",
93
+ type=Path,
94
+ action="append",
95
+ default=[],
96
+ metavar="F",
97
+ help="A file to include, such as a log. Repeatable.",
98
+ )
99
+ submit_parser.add_argument(
100
+ "--metadata",
101
+ action="append",
102
+ type=parse_key_value,
103
+ default=[],
104
+ metavar="KEY=VALUE",
105
+ help="An extra detail to include in the report. Repeatable.",
106
+ )
107
+ submit_parser.add_argument(
108
+ "--from-json",
109
+ type=Path,
110
+ metavar="F",
111
+ help="Read description, attachments and metadata from a file.",
112
+ )
113
+
114
+ commands.add_parser(
115
+ "drain", parents=[common], help="Send every report that is waiting."
116
+ )
117
+ return parser
118
+
119
+
120
+ def configure_logging(verbose: bool) -> logging.Handler:
121
+ """
122
+ Puts the CLI's own output on the console, and keeps it there. The
123
+ library's records, which can quote server responses, reach the console
124
+ only with `verbose`. Calling it again reuses the same handler rather than
125
+ printing every line twice.
126
+
127
+ Not `basicConfig`: a host that reconfigures the root logger with
128
+ `force=True` closes every handler root owns. A handler on our own logger
129
+ is not root's to close. Records at or above the package level still
130
+ propagate, so they reach the session log file as well.
131
+
132
+ Args:
133
+ verbose: Show the library's records, and DEBUG, on the console.
134
+
135
+ Returns:
136
+ The console handler.
137
+ """
138
+ package = logging.getLogger(_PACKAGE_LOGGER)
139
+ package.setLevel(logging.DEBUG if verbose else logging.INFO)
140
+ console = next(
141
+ (h for h in package.handlers if h.get_name() == _CONSOLE_HANDLER), None
142
+ )
143
+ if console is None:
144
+ console = logging.StreamHandler()
145
+ console.set_name(_CONSOLE_HANDLER)
146
+ console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
147
+ package.addHandler(console)
148
+ console.filters.clear()
149
+ if not verbose:
150
+ console.addFilter(lambda record: record.name == logger.name)
151
+ return console
152
+
153
+
154
+ def main() -> int:
155
+ """
156
+ Returns:
157
+ The exit code: 0 when everything was filed, 1 when something was not,
158
+ 2 for a usage error, 130 when interrupted.
159
+ """
160
+ args = _parser().parse_args()
161
+ configure_logging(args.verbose)
162
+ command = DebugReportCommand()
163
+ try:
164
+ if args.command == "drain":
165
+ return asyncio.run(command.drain())
166
+
167
+ report_input = ReportInput.from_command_line(
168
+ description=args.description,
169
+ attachments=args.attach,
170
+ metadata=args.metadata,
171
+ from_json=args.from_json,
172
+ )
173
+ return asyncio.run(command.submit(report_input))
174
+ except ReportInputError as e:
175
+ logger.error("%s", e)
176
+ return 2
177
+ except KeyboardInterrupt:
178
+ logger.error("Interrupted.")
179
+ return 130
180
+
181
+
182
+ if __name__ == "__main__":
183
+ sys.exit(main())
@@ -0,0 +1,472 @@
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 a report carries: the `Collector` protocol and the collectors every tool needs."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import importlib.metadata
21
+ import io
22
+ import json
23
+ import logging
24
+ import os
25
+ import platform
26
+ import shutil
27
+ import sys
28
+ import zipfile
29
+ from collections.abc import Sequence
30
+ from pathlib import Path
31
+ from typing import BinaryIO, Final, Protocol, runtime_checkable
32
+
33
+ from projectaria_commons.commons.constants import CONFIG_DIR
34
+ from projectaria_debug_sharing.debug_sharing._internal.plain_files import (
35
+ describe_entry,
36
+ list_own_plain_files,
37
+ open_directory,
38
+ open_plain_file,
39
+ open_plain_file_at,
40
+ zip_date_time,
41
+ )
42
+ from projectaria_debug_sharing.debug_sharing.types import Attachment, CollectionScope
43
+
44
+ logger: logging.Logger = logging.getLogger(__name__)
45
+
46
+ DEFAULT_MAX_ATTACHMENT_BYTES: Final[int] = 384 * 1024
47
+ """Cap on one collected file. A log grows with the session and researchers are
48
+ often on poor links, so the tail is kept: the failure prompting the report is at
49
+ the end."""
50
+
51
+ DEFAULT_MAX_ARCHIVE_BYTES: Final[int] = 4 * 1024 * 1024
52
+ """Cap on the files one directory archive reads, before compression."""
53
+
54
+ MAX_LISTED_ENTRIES: Final[int] = 200
55
+
56
+ _BYTES_PER_GIB: Final[int] = 1024**3
57
+
58
+
59
+ @runtime_checkable
60
+ class Collector(Protocol):
61
+ """Contributes attachments to a report, within one consent scope."""
62
+
63
+ @property
64
+ def name(self) -> str:
65
+ """Identifies the collector, and names the attachment it makes."""
66
+ ...
67
+
68
+ @property
69
+ def scope(self) -> CollectionScope:
70
+ """The consent category the collector reads under."""
71
+ ...
72
+
73
+ @property
74
+ def disclosure(self) -> str:
75
+ """What is collected, as shown to the user before anything is read."""
76
+ ...
77
+
78
+ async def collect(self) -> Sequence[Attachment]:
79
+ """
80
+ Returns:
81
+ The attachments, or none when there is nothing to collect.
82
+ """
83
+ ...
84
+
85
+
86
+ class FileCollector(Collector):
87
+ """Attaches one file from disk, for tools whose logs are already there."""
88
+
89
+ def __init__(
90
+ self,
91
+ name: str,
92
+ path: Path,
93
+ *,
94
+ disclosure: str,
95
+ scope: CollectionScope = CollectionScope.LOGS,
96
+ content_type: str = "text/plain",
97
+ max_bytes: int | None = DEFAULT_MAX_ATTACHMENT_BYTES,
98
+ optional: bool = False,
99
+ ) -> None:
100
+ """
101
+ Args:
102
+ name: The collector's and the attachment's name.
103
+ path: The file. Refused if it is a link or any non-plain file.
104
+ disclosure: What the user is told is collected.
105
+ scope: The consent category.
106
+ content_type: The attachment's type.
107
+ max_bytes: Cap on the attachment, truncation notice included; the
108
+ tail is kept. None for no cap.
109
+ optional: A missing file collects nothing instead of failing. Any
110
+ other problem, such as a directory at the path, still fails.
111
+
112
+ Raises:
113
+ ValueError: `max_bytes` is not positive.
114
+ """
115
+ if max_bytes is not None and max_bytes <= 0:
116
+ raise ValueError(f"max_bytes must be positive, got {max_bytes}")
117
+ self._name = name
118
+ self._path = path
119
+ self._disclosure = disclosure
120
+ self._scope = scope
121
+ self._content_type = content_type
122
+ self._max_bytes = max_bytes
123
+ self._optional = optional
124
+
125
+ @property
126
+ def name(self) -> str:
127
+ """The collector's name, also given to its attachment."""
128
+ return self._name
129
+
130
+ @property
131
+ def scope(self) -> CollectionScope:
132
+ """The consent category the file is read under."""
133
+ return self._scope
134
+
135
+ @property
136
+ def disclosure(self) -> str:
137
+ """What the user is told is collected."""
138
+ return self._disclosure
139
+
140
+ async def collect(self) -> Sequence[Attachment]:
141
+ """
142
+ Reads the file off the event loop.
143
+
144
+ Returns:
145
+ One attachment with the file's bytes, cut to their tail if over the
146
+ cap, or none if the file is optional and missing.
147
+
148
+ Raises:
149
+ FileNotFoundError: The file is missing and not optional.
150
+ OSError: The file is not a plain file, or cannot be read.
151
+ """
152
+ data = await asyncio.to_thread(self._read)
153
+ if data is None:
154
+ return []
155
+ return [
156
+ Attachment(
157
+ name=self._name,
158
+ data=data,
159
+ content_type=self._content_type,
160
+ filename=self._path.name,
161
+ )
162
+ ]
163
+
164
+ def _read(self) -> bytes | None:
165
+ try:
166
+ handle = open_plain_file(self._path)
167
+ except FileNotFoundError:
168
+ if self._optional:
169
+ return None
170
+ raise
171
+ with handle:
172
+ if self._max_bytes is None:
173
+ return handle.read()
174
+ return _read_within(handle, self._max_bytes, self._path.name)
175
+
176
+
177
+ class DirectoryCollector(Collector):
178
+ """
179
+ Attaches this user's files in one directory as a single zip, newest first,
180
+ so a cap drops the oldest runs. Fails on platforms without race-free
181
+ directory handles (Windows) rather than read a directory that can be
182
+ swapped underneath it.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ name: str,
188
+ path: Path,
189
+ *,
190
+ disclosure: str,
191
+ scope: CollectionScope = CollectionScope.LOGS,
192
+ filename: str | None = None,
193
+ max_bytes: int = DEFAULT_MAX_ARCHIVE_BYTES,
194
+ pattern: str = "*",
195
+ ) -> None:
196
+ """
197
+ Args:
198
+ name: The collector's and the attachment's name.
199
+ path: The directory. Refused if it is a link.
200
+ disclosure: What the user is told is collected.
201
+ scope: The consent category.
202
+ filename: The archive's name. Defaults to `<name>.zip`.
203
+ max_bytes: Cap on the bytes read from the directory. Older files
204
+ are left out once it is reached; the newest is kept, cut to its
205
+ tail if it alone is larger.
206
+ pattern: Only file names matching this glob are collected, such
207
+ as `*.log`, so a directory the user configured elsewhere
208
+ cannot pull in unrelated files.
209
+
210
+ Raises:
211
+ ValueError: `max_bytes` is not positive.
212
+ """
213
+ if max_bytes <= 0:
214
+ raise ValueError(f"max_bytes must be positive, got {max_bytes}")
215
+ self._name = name
216
+ self._path = path
217
+ self._disclosure = disclosure
218
+ self._scope = scope
219
+ self._filename = filename or f"{name}.zip"
220
+ self._max_bytes = max_bytes
221
+ self._pattern = pattern
222
+
223
+ @property
224
+ def name(self) -> str:
225
+ """The collector's name, also given to its attachment."""
226
+ return self._name
227
+
228
+ @property
229
+ def scope(self) -> CollectionScope:
230
+ """The consent category the directory is read under."""
231
+ return self._scope
232
+
233
+ @property
234
+ def disclosure(self) -> str:
235
+ """What the user is told is collected."""
236
+ return self._disclosure
237
+
238
+ async def collect(self) -> Sequence[Attachment]:
239
+ """
240
+ Runs off the event loop: listing and compressing inline would stall
241
+ every other task, including a cancellation waiting to land.
242
+
243
+ Returns:
244
+ One zip of the matching files, or none if the directory is missing,
245
+ is not a directory, or holds no matching file of this user's.
246
+
247
+ Raises:
248
+ OSError: The directory is a link, or the platform cannot read it
249
+ race-free.
250
+ """
251
+ data = await asyncio.to_thread(self._archive)
252
+ if data is None:
253
+ return []
254
+ return [
255
+ Attachment(
256
+ name=self._name,
257
+ data=data,
258
+ content_type="application/zip",
259
+ filename=self._filename,
260
+ )
261
+ ]
262
+
263
+ def _archive(self) -> bytes | None:
264
+ try:
265
+ dir_fd = open_directory(self._path)
266
+ except (FileNotFoundError, NotADirectoryError):
267
+ return None
268
+ try:
269
+ names = list_own_plain_files(dir_fd, self._pattern)
270
+ return self._build_archive(names, dir_fd) if names else None
271
+ finally:
272
+ os.close(dir_fd)
273
+
274
+ def _build_archive(self, names: Sequence[str], dir_fd: int) -> bytes:
275
+ """
276
+ A failed read adds nothing for that file, rather than a truncated
277
+ member, and does not stop the rest.
278
+ """
279
+ buffer = io.BytesIO()
280
+ archived = 0
281
+ budget = self._max_bytes
282
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
283
+ for name in names:
284
+ try:
285
+ opened = open_plain_file_at(name, dir_fd)
286
+ except OSError as e:
287
+ logger.warning("Skipping %s: %s", name, e.strerror or e)
288
+ continue
289
+ if opened is None:
290
+ logger.warning("Skipping %s: changed since it was listed", name)
291
+ continue
292
+ handle, info = opened
293
+ with handle:
294
+ if archived and info.st_size > budget:
295
+ break
296
+ try:
297
+ data = _read_within(handle, budget, name)
298
+ except OSError as e:
299
+ logger.warning("Skipping %s: %s", name, e.strerror or e)
300
+ continue
301
+ entry = zipfile.ZipInfo(name, zip_date_time(info.st_mtime))
302
+ entry.compress_type = zipfile.ZIP_DEFLATED
303
+ entry.external_attr = (info.st_mode & 0xFFFF) << 16
304
+ archive.writestr(entry, data)
305
+ archived += 1
306
+ budget -= len(data)
307
+
308
+ logger.info(
309
+ "Archived %d of %d file(s) from %s", archived, len(names), self._path.name
310
+ )
311
+ return buffer.getvalue()
312
+
313
+
314
+ class EnvironmentCollector(Collector):
315
+ """
316
+ Describes the machine every ARK tool runs on: tool and Python versions, OS,
317
+ CPU, disk, and the names in the Project Aria config folder. Never the
318
+ contents of a file, a host name or a user name.
319
+ """
320
+
321
+ def __init__(self, *, distribution: str, config_dir: Path = CONFIG_DIR) -> None:
322
+ """
323
+ Args:
324
+ distribution: The installed package whose version is reported.
325
+ config_dir: The Project Aria config folder to list.
326
+ """
327
+ self._distribution = distribution
328
+ self._config_dir = config_dir
329
+
330
+ @property
331
+ def name(self) -> str:
332
+ """Always "environment", which also names the JSON attachment."""
333
+ return "environment"
334
+
335
+ @property
336
+ def scope(self) -> CollectionScope:
337
+ """Always `CollectionScope.ENVIRONMENT`."""
338
+ return CollectionScope.ENVIRONMENT
339
+
340
+ @property
341
+ def disclosure(self) -> str:
342
+ """What the user is told is collected."""
343
+ return (
344
+ "the tool and Python versions, and this machine's OS, CPU and "
345
+ "disk, with a listing of your ~/.projectaria folder -- names, "
346
+ "kinds and sizes only, never the contents"
347
+ )
348
+
349
+ async def collect(self) -> Sequence[Attachment]:
350
+ """
351
+ Describes the machine off the event loop. A section that cannot be
352
+ read reports its error instead of failing the rest.
353
+
354
+ Returns:
355
+ One `environment.json` attachment.
356
+ """
357
+ payload = await asyncio.to_thread(self._describe_machine)
358
+ return [
359
+ Attachment(
360
+ name=self.name,
361
+ data=json.dumps(payload, indent=2, sort_keys=True).encode("utf-8"),
362
+ content_type="application/json",
363
+ filename=f"{self.name}.json",
364
+ )
365
+ ]
366
+
367
+ def _describe_machine(self) -> dict[str, object]:
368
+ return {
369
+ "tool": self._describe_tool(),
370
+ "os": self._describe_os(),
371
+ "python": self._describe_python(),
372
+ "hardware": self._describe_hardware(),
373
+ "aria_home": self._describe_config_dir(),
374
+ }
375
+
376
+ def _describe_tool(self) -> dict[str, str]:
377
+ """The version is "unknown" when running from a build tree, not a wheel."""
378
+ try:
379
+ version = importlib.metadata.version(self._distribution)
380
+ except importlib.metadata.PackageNotFoundError:
381
+ version = "unknown"
382
+ return {"distribution": self._distribution, "version": version}
383
+
384
+ def _describe_config_dir(self) -> dict[str, object]:
385
+ """
386
+ Names, kinds and sizes in the config folder, so support can see its
387
+ layout. An entry that cannot be read is listed with its error instead
388
+ of costing the rest; errors give `strerror`, never the path.
389
+ """
390
+ if not self._config_dir.is_dir():
391
+ return {"exists": False}
392
+ try:
393
+ children = sorted(self._config_dir.iterdir())
394
+ except OSError as e:
395
+ return {"exists": True, "error": e.strerror or type(e).__name__}
396
+ entries: list[dict[str, object]] = []
397
+ for path in children[:MAX_LISTED_ENTRIES]:
398
+ try:
399
+ entries.append(describe_entry(path))
400
+ except OSError as e:
401
+ entries.append(
402
+ {"name": path.name, "error": e.strerror or type(e).__name__}
403
+ )
404
+ return {
405
+ "exists": True,
406
+ "entries": entries,
407
+ "truncated": len(children) > MAX_LISTED_ENTRIES,
408
+ }
409
+
410
+ @staticmethod
411
+ def _describe_os() -> dict[str, str]:
412
+ """
413
+ `release` only: `platform.version()` and `platform.platform()` carry the
414
+ kernel build string, which on a self-built kernel names the build host.
415
+ """
416
+ return {
417
+ "system": platform.system(),
418
+ "release": platform.release(),
419
+ "machine": platform.machine(),
420
+ }
421
+
422
+ @staticmethod
423
+ def _describe_python() -> dict[str, str]:
424
+ return {
425
+ "version": sys.version,
426
+ "implementation": platform.python_implementation(),
427
+ }
428
+
429
+ @staticmethod
430
+ def _describe_hardware() -> dict[str, object]:
431
+ """
432
+ Disk space in whole GiB, enough to see a full disk without exact figures.
433
+ An error gives `strerror`, not `str()`, which would name the home directory.
434
+ """
435
+ info: dict[str, object] = {"cpu_count": os.cpu_count()}
436
+ try:
437
+ usage = shutil.disk_usage(Path.home())
438
+ except OSError as e:
439
+ info["disk_error"] = e.strerror or type(e).__name__
440
+ else:
441
+ info["disk_total_gib"] = round(usage.total / _BYTES_PER_GIB)
442
+ info["disk_free_gib"] = round(usage.free / _BYTES_PER_GIB)
443
+ return info
444
+
445
+
446
+ def _read_tail(handle: BinaryIO, max_bytes: int, name: str) -> bytes:
447
+ """
448
+ The end of the file behind a notice of what was dropped, within
449
+ `max_bytes` notice included. The size is clamped to the file's current
450
+ end, so a stat taken before a rotation shrank it still reads its tail.
451
+ """
452
+ size = min(os.fstat(handle.fileno()).st_size, handle.seek(0, os.SEEK_END))
453
+ kept = max(max_bytes - len(_truncation_notice(size)), 0)
454
+ handle.seek(max(size - kept, 0))
455
+ tail = handle.read(kept)
456
+ logger.info("Truncating %s, keeping the last %d of %d bytes", name, len(tail), size)
457
+ return (_truncation_notice(max(size - len(tail), 0)) + tail)[:max_bytes]
458
+
459
+
460
+ def _read_within(handle: BinaryIO, max_bytes: int, name: str) -> bytes:
461
+ """
462
+ At most `max_bytes`, found by reading one byte past the cap rather than
463
+ trusting an earlier stat that an active log has outgrown.
464
+ """
465
+ head = handle.read(max_bytes + 1)
466
+ if len(head) <= max_bytes:
467
+ return head
468
+ return _read_tail(handle, max_bytes, name)
469
+
470
+
471
+ def _truncation_notice(omitted: int) -> bytes:
472
+ return f"[truncated: {omitted} earlier bytes omitted]\n".encode("utf-8")
@@ -0,0 +1,36 @@
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
+ # pyre-strict
16
+
17
+ """
18
+ `aria-debug-report`'s commands, for another tool's command line to run as its
19
+ own, under its own identity and with collectors of its own.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from projectaria_debug_sharing.debug_sharing._internal.report_command import (
25
+ CLI_LOGGER_NAME,
26
+ DebugReportCommand,
27
+ ReportInput,
28
+ ReportInputError,
29
+ )
30
+
31
+ __all__ = [
32
+ "CLI_LOGGER_NAME",
33
+ "DebugReportCommand",
34
+ "ReportInput",
35
+ "ReportInputError",
36
+ ]