-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathpackage_looping_command.dart
More file actions
577 lines (503 loc) · 20 KB
/
package_looping_command.dart
File metadata and controls
577 lines (503 loc) · 20 KB
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:file/file.dart';
import 'package:pub_semver/pub_semver.dart';
import 'core.dart';
import 'file_utils.dart';
import 'git_version_finder.dart';
import 'output_utils.dart';
import 'package_command.dart';
import 'repository_package.dart';
/// Enumeration options for package looping commands.
enum PackageLoopingType {
/// Only enumerates the top level packages, without including any of their
/// subpackages.
topLevelOnly,
/// Enumerates the top level packages and any example packages they contain.
includeExamples,
/// Enumerates all packages recursively, including both example and
/// non-example subpackages.
includeAllSubpackages,
}
/// Possible outcomes of a command run for a package.
enum RunState {
/// The command succeeded for the package.
succeeded,
/// The command was skipped for the package.
skipped,
/// The command was skipped for the package because it was explicitly excluded
/// in the command arguments.
excluded,
/// The command failed for the package.
failed,
}
/// The result of a [runForPackage] call.
class PackageResult {
/// A successful result.
PackageResult.success() : this._(RunState.succeeded);
/// A run that was skipped as explained in [reason].
PackageResult.skip(String reason)
: this._(RunState.skipped, <String>[reason]);
/// A run that was excluded by the command invocation.
PackageResult.exclude() : this._(RunState.excluded);
/// A run that failed.
///
/// If [errors] are provided, they will be listed in the summary, otherwise
/// the summary will simply show that the package failed.
PackageResult.fail([List<String> errors = const <String>[]])
: this._(RunState.failed, errors);
const PackageResult._(this.state, [this.details = const <String>[]]);
/// The state the package run completed with.
final RunState state;
/// Information about the result:
/// - For `succeeded`, this is empty.
/// - For `skipped`, it contains a single entry describing why the run was
/// skipped.
/// - For `failed`, it contains zero or more specific error details to be
/// shown in the summary.
final List<String> details;
}
/// An abstract base class for a command that iterates over a set of packages
/// controlled by a standard set of flags, running some actions on each package,
/// and collecting and reporting the success/failure of those actions.
abstract class PackageLoopingCommand extends PackageCommand {
/// Creates a command to operate on [packagesDir] with the given environment.
PackageLoopingCommand(
super.packagesDir, {
super.processRunner,
super.platform,
super.gitDir,
}) {
argParser.addOption(
_skipByFlutterVersionArg,
help:
'Skip any packages that require a Flutter version newer than '
'the provided version, or a Dart version newer than the '
'corresponding Dart version.',
);
}
static const String _skipByFlutterVersionArg =
'skip-if-not-supporting-flutter-version';
/// Packages that had at least one [logWarning] call.
final Set<PackageEnumerationEntry> _packagesWithWarnings =
<PackageEnumerationEntry>{};
/// Number of warnings that happened outside of a [runForPackage] call.
int _otherWarningCount = 0;
/// The package currently being run by [runForPackage].
PackageEnumerationEntry? _currentPackageEntry;
/// When running against a merge base, this is called before [initializeRun]
/// for every changed file, to see if that file is a file that is guaranteed
/// *not* to require running this command.
///
/// If every changed file returns true, then the command will be skipped.
/// Because this causes tests not to run, subclasses should be very
/// consevative about what returns true; for anything borderline it is much
/// better to err on the side of running tests unnecessarily than to risk
/// losing test coverage.
///
/// [path] is a POSIX-style path regardless of the host platforrm, and is
/// relative to the git repo root.
bool shouldIgnoreFile(String path) => false;
/// Called during [run] before any calls to [runForPackage]. This provides an
/// opportunity to fail early if the command can't be run (e.g., because the
/// arguments are invalid), and to set up any run-level state.
Future<void> initializeRun() async {}
/// Returns the packages to process. By default, this returns the packages
/// defined by the standard tooling flags and the [inculdeSubpackages] option,
/// but can be overridden for custom package enumeration.
///
/// Note: Consistent behavior across commands whenever possibel is a goal for
/// this tool, so this should be overridden only in rare cases.
Stream<PackageEnumerationEntry> getPackagesToProcess() async* {
switch (packageLoopingType) {
case PackageLoopingType.topLevelOnly:
yield* getTargetPackages(filterExcluded: false);
case PackageLoopingType.includeExamples:
await for (final PackageEnumerationEntry packageEntry
in getTargetPackages(filterExcluded: false)) {
yield packageEntry;
yield* Stream<PackageEnumerationEntry>.fromIterable(
packageEntry.package.getExamples().map(
(RepositoryPackage package) => PackageEnumerationEntry(
package,
excluded: packageEntry.excluded,
),
),
);
}
case PackageLoopingType.includeAllSubpackages:
yield* getTargetPackagesAndSubpackages(filterExcluded: false);
}
}
/// Runs the command for [package], returning a list of errors.
///
/// Errors may either be an empty string if there is no context that should
/// be included in the final error summary (e.g., a command that only has a
/// single failure mode), or strings that should be listed for that package
/// in the final summary. An empty list indicates success.
Future<PackageResult> runForPackage(RepositoryPackage package);
/// Called during [run] after all calls to [runForPackage]. This provides an
/// opportunity to do any cleanup of run-level state.
Future<void> completeRun() async {}
/// If [captureOutput], this is called just before exiting with all captured
/// [output].
Future<void> handleCapturedOutput(List<String> output) async {}
/// Whether or not the output (if any) of [runForPackage] is long, or short.
///
/// This changes the logging that happens at the start of each package's
/// run; long output gets a banner-style message to make it easier to find,
/// while short output gets a single-line entry.
///
/// When this is false, runForPackage output should be indented if possible,
/// to make the output structure easier to follow.
bool get hasLongOutput => true;
/// Whether to loop over top-level packages only, or some or all of their
/// sub-packages as well.
PackageLoopingType get packageLoopingType => PackageLoopingType.topLevelOnly;
/// The text to output at the start when reporting one or more failures.
/// This will be followed by a list of packages that reported errors, with
/// the per-package details if any.
///
/// This only needs to be overridden if the summary should provide extra
/// context.
String get failureListHeader => 'The following packages had errors:';
/// The text to output at the end when reporting one or more failures. This
/// will be printed immediately after the a list of packages that reported
/// errors.
///
/// This only needs to be overridden if the summary should provide extra
/// context.
String get failureListFooter => 'See above for full details.';
/// The summary string used for a successful run in the final overview output.
String get successSummaryMessage => 'ran';
/// If true, all printing (including the summary) will be redirected to a
/// buffer, and provided in a call to [handleCapturedOutput] at the end of
/// the run.
///
/// Capturing output will disable any colorizing of output from this base
/// class.
bool get captureOutput => false;
// ----------------------------------------
/// Logs that a warning occurred, and prints `warningMessage` in yellow.
///
/// Warnings are not surfaced in CI summaries, so this is only useful for
/// highlighting something when someone is already looking though the log
/// messages. DO NOT RELY on someone noticing a warning; instead, use it for
/// things that might be useful to someone debugging an unexpected result.
void logWarning(String warningMessage) {
printWarning(warningMessage);
if (_currentPackageEntry != null) {
_packagesWithWarnings.add(_currentPackageEntry!);
} else {
++_otherWarningCount;
}
}
/// Returns the relative path from [from] to [entity] in Posix style.
///
/// This should be used when, for example, printing package-relative paths in
/// status or error messages.
String getRelativePosixPath(
FileSystemEntity entity, {
required Directory from,
}) => relativePosixPath(entity, from: from, platformContext: path);
/// The suggested indentation for printed output.
String get indentation => hasLongOutput ? '' : ' ';
/// The base SHA used to calculate changed files.
///
/// This is guaranteed to be populated before [initializeRun] is called.
late String baseSha;
/// The repo-relative paths (using Posix separators) of all files changed
/// relative to [baseSha].
///
/// This is guaranteed to be populated before [initializeRun] is called.
late List<String> changedFiles;
// ----------------------------------------
@override
Future<void> run() async {
bool succeeded;
if (captureOutput) {
final output = <String>[];
final logSwitchSpecification = ZoneSpecification(
print: (Zone self, ZoneDelegate parent, Zone zone, String message) {
output.add(message);
},
);
succeeded = await runZoned<Future<bool>>(
_runInternal,
zoneSpecification: logSwitchSpecification,
);
await handleCapturedOutput(output);
} else {
succeeded = await _runInternal();
}
if (!succeeded) {
throw ToolExit(exitCommandFoundErrors);
}
}
Future<bool> _runInternal() async {
_packagesWithWarnings.clear();
_otherWarningCount = 0;
_currentPackageEntry = null;
final String minFlutterVersionArg = getStringArg(_skipByFlutterVersionArg);
final Version? minFlutterVersion = minFlutterVersionArg.isEmpty
? null
: Version.parse(minFlutterVersionArg);
final Version? minDartVersion = minFlutterVersion == null
? null
: getDartSdkForFlutterSdk(minFlutterVersion);
final runStart = DateTime.now();
// Populate the list of changed files for subclasses to use.
final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
baseSha = await gitVersionFinder.getBaseSha();
changedFiles = await gitVersionFinder.getChangedFiles();
// Check whether the command needs to run.
if (changedFiles.isNotEmpty && changedFiles.every(shouldIgnoreFile)) {
_printColorized(
'SKIPPING ALL PACKAGES: No changed files affect this command',
Styles.DARK_GRAY,
);
return true;
}
await initializeRun();
final List<PackageEnumerationEntry> targetPackages =
await getPackagesToProcess().toList();
final results = <PackageEnumerationEntry, PackageResult>{};
for (final entry in targetPackages) {
final packageStart = DateTime.now();
_currentPackageEntry = entry;
_printPackageHeading(entry, startTime: runStart);
// Command implementations should never see excluded packages; they are
// included at this level only for logging.
if (entry.excluded) {
results[entry] = PackageResult.exclude();
continue;
}
PackageResult result;
try {
result = await _runForPackageIfSupported(
entry.package,
minFlutterVersion: minFlutterVersion,
minDartVersion: minDartVersion,
);
} catch (e, stack) {
printError(e.toString());
printError(stack.toString());
result = PackageResult.fail(<String>['Unhandled exception']);
}
if (result.state == RunState.skipped) {
_printColorized(
'${indentation}SKIPPING: ${result.details.first}',
Styles.DARK_GRAY,
);
}
results[entry] = result;
// Only log an elapsed time for long output; for short output, comparing
// the relative timestamps of successive entries should be trivial.
if (shouldLogTiming && hasLongOutput) {
final Duration elapsedTime = DateTime.now().difference(packageStart);
_printColorized(
'\n[${entry.package.displayName} completed in '
'${elapsedTime.inMinutes}m ${elapsedTime.inSeconds % 60}s]',
Styles.DARK_GRAY,
);
}
}
_currentPackageEntry = null;
await completeRun();
print('\n');
// If there were any errors reported, summarize them and exit.
if (results.values.any(
(PackageResult result) => result.state == RunState.failed,
)) {
_printFailureSummary(targetPackages, results);
return false;
}
// Otherwise, print a summary of what ran for ease of auditing that all the
// expected tests ran.
_printRunSummary(targetPackages, results);
print('\n');
_printSuccess('No issues found!');
return true;
}
/// Returns the result of running [runForPackage] if the package is supported
/// by any run constraints, or a skip result if it is not.
Future<PackageResult> _runForPackageIfSupported(
RepositoryPackage package, {
Version? minFlutterVersion,
Version? minDartVersion,
}) async {
if (minFlutterVersion != null) {
final Pubspec pubspec = package.parsePubspec();
final VersionConstraint? flutterConstraint =
pubspec.environment['flutter'];
if (flutterConstraint != null &&
!flutterConstraint.allows(minFlutterVersion)) {
return PackageResult.skip(
'Does not support Flutter $minFlutterVersion',
);
}
}
if (minDartVersion != null) {
final Pubspec pubspec = package.parsePubspec();
final VersionConstraint? dartConstraint = pubspec.environment['sdk'];
if (dartConstraint != null && !dartConstraint.allows(minDartVersion)) {
return PackageResult.skip('Does not support Dart $minDartVersion');
}
}
return runForPackage(package);
}
void _printSuccess(String message) {
captureOutput ? print(message) : printSuccess(message);
}
void _printError(String message) {
captureOutput ? print(message) : printError(message);
}
/// Prints the status message indicating that the command is being run for
/// [package].
///
/// Something is always printed to make it easier to distinguish between
/// a command running for a package and producing no output, and a command
/// not having been run for a package.
void _printPackageHeading(
PackageEnumerationEntry entry, {
required DateTime startTime,
}) {
final String packageDisplayName = entry.package.displayName;
var heading = entry.excluded
? 'Not running for $packageDisplayName; excluded'
: 'Running for $packageDisplayName';
if (shouldLogTiming) {
final Duration relativeTime = DateTime.now().difference(startTime);
final String timeString = _formatDurationAsRelativeTime(relativeTime);
heading = hasLongOutput
? '$heading [@$timeString]'
: '[$timeString] $heading';
}
if (hasLongOutput) {
heading =
'''
============================================================
|| $heading
============================================================
''';
} else if (!entry.excluded) {
heading = '$heading...';
}
_printColorized(heading, entry.excluded ? Styles.DARK_GRAY : Styles.CYAN);
}
/// Prints a summary of packges run, packages skipped, and warnings.
void _printRunSummary(
List<PackageEnumerationEntry> packages,
Map<PackageEnumerationEntry, PackageResult> results,
) {
final Set<PackageEnumerationEntry> skippedPackages = results.entries
.where(
(MapEntry<PackageEnumerationEntry, PackageResult> entry) =>
entry.value.state == RunState.skipped,
)
.map(
(MapEntry<PackageEnumerationEntry, PackageResult> entry) => entry.key,
)
.toSet();
final int skipCount =
skippedPackages.length +
packages
.where((PackageEnumerationEntry package) => package.excluded)
.length;
// Split the warnings into those from packages that ran, and those that
// were skipped.
final Set<PackageEnumerationEntry> skippedPackagesWithWarnings =
_packagesWithWarnings.intersection(skippedPackages);
final int skippedWarningCount = skippedPackagesWithWarnings.length;
final int runWarningCount =
_packagesWithWarnings.length - skippedWarningCount;
final runWarningSummary = runWarningCount > 0
? ' ($runWarningCount with warnings)'
: '';
final skippedWarningSummary = runWarningCount > 0
? ' ($skippedWarningCount with warnings)'
: '';
print('------------------------------------------------------------');
if (hasLongOutput) {
_printPerPackageRunOverview(packages, skipped: skippedPackages);
}
print(
'Ran for ${packages.length - skipCount} package(s)$runWarningSummary',
);
if (skipCount > 0) {
print('Skipped $skipCount package(s)$skippedWarningSummary');
}
if (_otherWarningCount > 0) {
print('$_otherWarningCount warnings not associated with a package');
}
}
/// Prints a one-line-per-package overview of the run results for each
/// package.
void _printPerPackageRunOverview(
List<PackageEnumerationEntry> packageEnumeration, {
required Set<PackageEnumerationEntry> skipped,
}) {
print('Run overview:');
for (final entry in packageEnumeration) {
final bool hadWarning = _packagesWithWarnings.contains(entry);
Styles style;
String summary;
if (entry.excluded) {
summary = 'excluded';
style = Styles.DARK_GRAY;
} else if (skipped.contains(entry)) {
summary = 'skipped';
style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY;
} else {
summary = successSummaryMessage;
style = hadWarning ? Styles.YELLOW : Styles.GREEN;
}
if (hadWarning) {
summary += ' (with warning)';
}
if (!captureOutput) {
summary = colorizeString(summary, style);
}
print(' ${entry.package.displayName} - $summary');
}
print('');
}
/// Prints a summary of all of the failures from [results].
void _printFailureSummary(
List<PackageEnumerationEntry> packageEnumeration,
Map<PackageEnumerationEntry, PackageResult> results,
) {
const indentation = ' ';
_printError(failureListHeader);
for (final entry in packageEnumeration) {
final PackageResult result = results[entry]!;
if (result.state == RunState.failed) {
final String errorIndentation = indentation * 2;
var errorDetails = '';
if (result.details.isNotEmpty) {
errorDetails =
':\n$errorIndentation${result.details.join('\n$errorIndentation')}';
}
_printError('$indentation${entry.package.displayName}$errorDetails');
}
}
_printError(failureListFooter);
}
/// Prints [message] in [color] unless [captureOutput] is set, in which case
/// it is printed without color.
void _printColorized(String message, Styles color) {
if (captureOutput) {
print(message);
} else {
print(colorizeString(message, color));
}
}
/// Returns a duration [d] formatted as minutes:seconds. Does not use hours,
/// since time logging is primarily intended for CI, where durations should
/// always be less than an hour.
String _formatDurationAsRelativeTime(Duration d) {
return '${d.inMinutes}:${(d.inSeconds % 60).toString().padLeft(2, '0')}';
}
}