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,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
|
+
]
|