-
Notifications
You must be signed in to change notification settings - Fork 1.6k
/
Copy pathxcresult_logs.py
executable file
·290 lines (220 loc) · 8.03 KB
/
xcresult_logs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
#!/usr/bin/env python
# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Prints logs from test runs captured in Apple .xcresult bundles.
USAGE: xcresult_logs.py -workspace <path> -scheme <scheme> [other flags...]
xcresult_logs.py finds and displays the log output associated with an xcodebuild
invocation. Pass your entire xcodebuild command-line as arguments to this script
and it will find the output associated with the most recent invocation.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from lib import command_trace
_logger = logging.getLogger('xcresult')
def main():
args = sys.argv[1:]
if not args:
sys.stdout.write(__doc__)
sys.exit(1)
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
flags = parse_xcodebuild_flags(args)
# If the result bundle path is specified in the xcodebuild flags, use that
# otherwise, deduce
xcresult_path = flags.get('-resultBundlePath')
if xcresult_path is None:
project = project_from_workspace_path(flags['-workspace'])
scheme = flags['-scheme']
xcresult_path = find_xcresult_path(project, scheme)
log_id = find_log_id(xcresult_path)
log = export_log(xcresult_path, log_id)
# Avoid a potential UnicodeEncodeError raised by sys.stdout.write() by
# doing a relaxed encoding ourselves.
if hasattr(sys.stdout, 'buffer'):
log_encoded = log.encode('utf8', errors='backslashreplace')
sys.stdout.flush()
sys.stdout.buffer.write(log_encoded)
else:
log_encoded = log.encode('ascii', errors='backslashreplace')
log_decoded = log_encoded.decode('ascii', errors='strict')
sys.stdout.write(log_decoded)
# Most flags on the xcodebuild command-line are uninteresting, so only pull
# flags with known behavior with names in this set.
INTERESTING_FLAGS = {
'-resultBundlePath',
'-scheme',
'-workspace',
}
def parse_xcodebuild_flags(args):
"""Parses the xcodebuild command-line.
Extracts flags like -workspace and -scheme that dictate the location of the
logs.
"""
result = {}
key = None
for arg in args:
if arg.startswith('-'):
if arg in INTERESTING_FLAGS:
key = arg
elif key is not None:
result[key] = arg
key = None
return result
def project_from_workspace_path(path):
"""Extracts the project name from a workspace path.
Args:
path: The path to a .xcworkspace file
Returns:
The project name from the basename of the path. For example, if path were
'Firestore/Example/Firestore.xcworkspace', returns 'Firestore'.
"""
root, ext = os.path.splitext(os.path.basename(path))
if ext == '.xcworkspace':
_logger.debug('Using project %s from workspace %s', root, path)
return root
raise ValueError('%s is not a valid workspace path' % path)
def find_xcresult_path(project, scheme):
"""Finds an xcresult bundle for the given project and scheme.
Args:
project: The project name, like 'Firestore'
scheme: The Xcode scheme that was tested
Returns:
The path to the newest xcresult bundle that matches.
"""
project_path = find_project_path(project)
bundle_dir = os.path.join(project_path, 'Logs/Test')
prefix = re.compile('([^-]*)-' + re.escape(scheme) + '-')
_logger.debug('Logging for xcresult bundles in %s', bundle_dir)
xcresult = find_newest_matching_prefix(bundle_dir, prefix)
if xcresult is None:
raise LookupError(
'Could not find xcresult bundle for %s in %s' % (scheme, bundle_dir))
_logger.debug('Found xcresult: %s', xcresult)
return xcresult
def find_project_path(project):
"""Finds the newest project output within Xcode's DerivedData.
Args:
project: A project name; the Foo in Foo.xcworkspace
Returns:
The path containing the newest project output.
"""
path = os.path.expanduser('~/Library/Developer/Xcode/DerivedData')
prefix = re.compile(re.escape(project) + '-')
# DerivedData has directories like Firestore-csljdukzqbozahdjizcvrfiufrkb. Use
# the most recent one if there are more than one such directory matching the
# project name.
result = find_newest_matching_prefix(path, prefix)
if result is None:
raise LookupError(
'Could not find project derived data for %s in %s' % (project, path))
_logger.debug('Using project derived data in %s', result)
return result
def find_newest_matching_prefix(path, prefix):
"""Lists the given directory and returns the newest entry matching prefix.
Args:
path: A directory to list
prefix: A regular expression that matches the filenames to consider
Returns:
The path to the newest entry in the directory whose basename starts with
the prefix.
"""
entries = os.listdir(path)
result = None
for entry in entries:
if prefix.match(entry):
fq_entry = os.path.join(path, entry)
if result is None:
result = fq_entry
else:
result_mtime = os.path.getmtime(result)
entry_mtime = os.path.getmtime(fq_entry)
if entry_mtime > result_mtime:
result = fq_entry
return result
def find_legacy_log_files(xcresult_path):
"""Finds the log files produced by Xcode 10 and below."""
result = []
for root, dirs, files in os.walk(xcresult_path, topdown=True):
for file in files:
if file.endswith('.txt'):
file = os.path.join(root, file)
result.append(file)
# Sort the files by creation time.
result.sort(key=lambda f: os.stat(f).st_ctime)
return result
def cat_files(files, output):
"""Reads the contents of all the files and copies them to the output.
Args:
files: A list of filenames
output: A file-like object in which all the data should be copied.
"""
for file in files:
with open(file, 'r') as fd:
shutil.copyfileobj(fd, output)
def find_log_id(xcresult_path):
"""Finds the id of the last action's logs.
Args:
xcresult_path: The path to an xcresult bundle.
Returns:
The id of the log output, suitable for use with xcresulttool get --id.
"""
parsed = xcresulttool_json('get', '--path', xcresult_path)
actions = parsed['actions']['_values']
action = actions[-1]
result = action['actionResult']['logRef']['id']['_value']
_logger.debug('Using log id %s', result)
return result
def export_log(xcresult_path, log_id):
"""Exports the log data with the given id from the xcresult bundle.
Args:
xcresult_path: The path to an xcresult bundle.
log_id: The id that names the log output (obtained by find_log_id)
Returns:
The logged output, as a string.
"""
contents = xcresulttool_json('get', '--path', xcresult_path, '--id', log_id)
result = []
collect_log_output(contents, result)
return ''.join(result)
def collect_log_output(activity_log, result):
"""Recursively collects emitted output from the activity log.
Args:
activity_log: Parsed JSON of an xcresult activity log.
result: An array into which all log data should be appended.
"""
output = activity_log.get('emittedOutput')
if output:
result.append(output['_value'])
else:
subsections = activity_log.get('subsections')
if subsections:
for subsection in subsections['_values']:
collect_log_output(subsection, result)
def xcresulttool(*args):
"""Runs xcresulttool and returns its output as a string."""
cmd = ['xcrun', 'xcresulttool']
cmd.extend(args)
command_trace.log(cmd)
return subprocess.check_output(cmd)
def xcresulttool_json(*args):
"""Runs xcresulttool and its output as parsed JSON."""
args = list(args) + ['--format', 'json']
contents = xcresulttool(*args)
return json.loads(contents)
if __name__ == '__main__':
main()