From 3df3ba528e291416253205d5c4c91238a94d9489 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Mon, 3 Apr 2023 14:23:11 -0700 Subject: [PATCH] [tool] Add initial `update-dependency` command (#3632) [tool] Add initial `update-dependency` command --- script/tool/README.md | 20 + script/tool/lib/src/main.dart | 2 + .../lib/src/update_dependency_command.dart | 207 +++++++++++ .../test/update_dependency_command_test.dart | 343 ++++++++++++++++++ 4 files changed, 572 insertions(+) create mode 100644 script/tool/lib/src/update_dependency_command.dart create mode 100644 script/tool/test/update_dependency_command_test.dart diff --git a/script/tool/README.md b/script/tool/README.md index 4265fb4849..bffa2b66ee 100644 --- a/script/tool/README.md +++ b/script/tool/README.md @@ -135,6 +135,26 @@ the branch point from `upstream/main`. For more complex use cases where you want a different diff point, you can pass a different `--base-branch`, or use `--base-sha` to pick the exact diff point. +### Update a dependency + +`update-dependency` will update a pub dependency to a new version. + +For instance, to updated to version 3.0.0 of `some_package` in every package +that depends on it: + +```sh +cd +dart run script/tool/bin/flutter_plugin_tools.dart update-dependency \ + --pub-package=some_package \ + --version=3.0.0 \ +``` + +If a `--version` is not provided, the latest version from pub will be used. + +Currently this only updates the dependency itself in pubspec.yaml, but in the +future this will also update any generated code for packages that use code +generation (e.g., regenerating mocks when updating `mockito`). + ### Publish a Release **Releases are automated for `flutter/packages`.** diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index 25e26559d4..64f5135304 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -31,6 +31,7 @@ import 'pubspec_check_command.dart'; import 'readme_check_command.dart'; import 'remove_dev_dependencies_command.dart'; import 'test_command.dart'; +import 'update_dependency_command.dart'; import 'update_excerpts_command.dart'; import 'update_min_sdk_command.dart'; import 'update_release_info_command.dart'; @@ -77,6 +78,7 @@ void main(List args) { ..addCommand(ReadmeCheckCommand(packagesDir)) ..addCommand(RemoveDevDependenciesCommand(packagesDir)) ..addCommand(TestCommand(packagesDir)) + ..addCommand(UpdateDependencyCommand(packagesDir)) ..addCommand(UpdateExcerptsCommand(packagesDir)) ..addCommand(UpdateMinSdkCommand(packagesDir)) ..addCommand(UpdateReleaseInfoCommand(packagesDir)) diff --git a/script/tool/lib/src/update_dependency_command.dart b/script/tool/lib/src/update_dependency_command.dart new file mode 100644 index 0000000000..6fa1dae564 --- /dev/null +++ b/script/tool/lib/src/update_dependency_command.dart @@ -0,0 +1,207 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/file.dart'; +import 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'common/core.dart'; +import 'common/package_looping_command.dart'; +import 'common/pub_version_finder.dart'; +import 'common/repository_package.dart'; + +const int _exitIncorrectTargetDependency = 3; +const int _exitNoTargetVersion = 4; + +/// A command to update a dependency in packages. +/// +/// This is intended to expand over time to support any sort of dependency that +/// packages use, including pub packages and native dependencies, and should +/// include any tasks related to the dependency (e.g., regenerating files when +/// updating a dependency that is responsible for code generation). +class UpdateDependencyCommand extends PackageLoopingCommand { + /// Creates an instance of the version check command. + UpdateDependencyCommand( + Directory packagesDir, { + http.Client? httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + super(packagesDir) { + argParser.addOption( + _pubPackageFlag, + help: 'A pub package to update.', + ); + argParser.addOption( + _versionFlag, + help: 'The version to update to.\n\n' + '- For pub, defaults to the latest published version if not ' + 'provided. This can be any constraint that pubspec.yaml allows; a ' + 'specific version will be treated as the exact version for ' + 'dependencies that are alread pinned, or a ^ range for those that ' + 'are unpinned.', + ); + } + + static const String _pubPackageFlag = 'pub-package'; + static const String _versionFlag = 'version'; + + final PubVersionFinder _pubVersionFinder; + + late final String? _targetPubPackage; + late final String _targetVersion; + + @override + final String name = 'update-dependency'; + + @override + final String description = 'Updates a dependency in a package.'; + + @override + bool get hasLongOutput => false; + + @override + PackageLoopingType get packageLoopingType => + PackageLoopingType.includeAllSubpackages; + + @override + Future initializeRun() async { + const Set targetFlags = {_pubPackageFlag}; + final Set passedTargetFlags = + targetFlags.where((String flag) => argResults![flag] != null).toSet(); + if (passedTargetFlags.length != 1) { + printError( + 'Exactly one of the target flags must be provided: (${targetFlags.join(', ')})'); + throw ToolExit(_exitIncorrectTargetDependency); + } + _targetPubPackage = getNullableStringArg(_pubPackageFlag); + if (_targetPubPackage != null) { + final String? version = getNullableStringArg(_versionFlag); + if (version == null) { + final PubVersionFinderResponse response = await _pubVersionFinder + .getPackageVersion(packageName: _targetPubPackage!); + switch (response.result) { + case PubVersionFinderResult.success: + _targetVersion = response.versions.first.toString(); + break; + case PubVersionFinderResult.fail: + printError(''' +Error fetching $_targetPubPackage version from pub: ${response.httpResponse.statusCode}: +${response.httpResponse.body} +'''); + throw ToolExit(_exitNoTargetVersion); + case PubVersionFinderResult.noPackageFound: + printError('$_targetPubPackage does not exist on pub'); + throw ToolExit(_exitNoTargetVersion); + } + } else { + _targetVersion = version; + } + } + } + + @override + Future completeRun() async { + _pubVersionFinder.httpClient.close(); + } + + @override + Future runForPackage(RepositoryPackage package) async { + if (_targetPubPackage != null) { + return _runForPubDependency(package, _targetPubPackage!); + } + // TODO(stuartmorgan): Add othe dependency types here (e.g., maven). + + return PackageResult.fail(); + } + + /// Handles all of the updates for [package] when the target dependency is + /// a pub dependency. + Future _runForPubDependency( + RepositoryPackage package, String dependency) async { + final _PubDependencyInfo? dependencyInfo = + _getPubDependencyInfo(package, dependency); + if (dependencyInfo == null) { + return PackageResult.skip('Does not depend on $dependency'); + } else if (!dependencyInfo.hosted) { + return PackageResult.skip('$dependency in not a hosted dependency'); + } + + final String sectionKey = dependencyInfo.type == _PubDependencyType.dev + ? 'dev_dependencies' + : 'dependencies'; + final String versionString; + final VersionConstraint parsedConstraint = + VersionConstraint.parse(_targetVersion); + // If the provided string was a constraint, or if it's a specific + // version but the package has a pinned dependency, use it as-is. + if (dependencyInfo.pinned || + parsedConstraint is! VersionRange || + parsedConstraint.min != parsedConstraint.max) { + versionString = _targetVersion; + } else { + // Otherwise, it's a specific version; treat it as '^version'. + final Version minVersion = parsedConstraint.min!; + versionString = '^$minVersion'; + } + + print('${indentation}Updating to "$versionString"'); + if (versionString == dependencyInfo.constraintString) { + return PackageResult.skip('Already depends on $versionString'); + } + final YamlEditor editablePubspec = + YamlEditor(package.pubspecFile.readAsStringSync()); + editablePubspec.update( + [sectionKey, dependency], + versionString, + ); + package.pubspecFile.writeAsStringSync(editablePubspec.toString()); + + // TODO(stuartmorgan): Add additionally handling of known packages that + // do file generation (mockito, pigeon, etc.). + + return PackageResult.success(); + } + + /// Returns information about the current dependency of [package] on + /// the package named [dependencyName], or null if there is no dependency. + _PubDependencyInfo? _getPubDependencyInfo( + RepositoryPackage package, String dependencyName) { + final Pubspec pubspec = package.parsePubspec(); + + Dependency? dependency; + final _PubDependencyType type; + if (pubspec.dependencies.containsKey(dependencyName)) { + dependency = pubspec.dependencies[dependencyName]; + type = _PubDependencyType.normal; + } else if (pubspec.devDependencies.containsKey(dependencyName)) { + dependency = pubspec.devDependencies[dependencyName]; + type = _PubDependencyType.dev; + } else { + return null; + } + if (dependency != null && dependency is HostedDependency) { + final VersionConstraint version = dependency.version; + return _PubDependencyInfo( + type, + pinned: version is VersionRange && version.min == version.max, + hosted: true, + constraintString: version.toString(), + ); + } + return _PubDependencyInfo(type, pinned: false, hosted: false); + } +} + +class _PubDependencyInfo { + const _PubDependencyInfo(this.type, + {required this.pinned, required this.hosted, this.constraintString}); + final _PubDependencyType type; + final bool pinned; + final bool hosted; + final String? constraintString; +} + +enum _PubDependencyType { normal, dev } diff --git a/script/tool/test/update_dependency_command_test.dart b/script/tool/test/update_dependency_command_test.dart new file mode 100644 index 0000000000..5d99a2df2e --- /dev/null +++ b/script/tool/test/update_dependency_command_test.dart @@ -0,0 +1,343 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/update_dependency_command.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:test/test.dart'; + +import 'util.dart'; + +void main() { + FileSystem fileSystem; + late Directory packagesDir; + late CommandRunner runner; + Future Function(http.Request request)? mockHttpResponse; + + setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + final UpdateDependencyCommand command = UpdateDependencyCommand( + packagesDir, + httpClient: + MockClient((http.Request request) => mockHttpResponse!(request)), + ); + + runner = CommandRunner( + 'update_dependency_command', 'Test for update-dependency command.'); + runner.addCommand(command); + }); + + /// Adds a dummy 'dependencies:' entries for [dependency] to [package]. + void addDependency(RepositoryPackage package, String dependency, + {String version = '^1.0.0'}) { + final List lines = package.pubspecFile.readAsLinesSync(); + final int dependenciesStartIndex = lines.indexOf('dependencies:'); + assert(dependenciesStartIndex != -1); + lines.insert(dependenciesStartIndex + 1, ' $dependency: $version'); + package.pubspecFile.writeAsStringSync(lines.join('\n')); + } + + /// Adds a 'dev_dependencies:' section with an entry for [dependency] to + /// [package]. + void addDevDependency(RepositoryPackage package, String dependency, + {String version = '^1.0.0'}) { + final String originalContent = package.pubspecFile.readAsStringSync(); + package.pubspecFile.writeAsStringSync(''' +$originalContent + +dev_dependencies: + $dependency: $version +'''); + } + + test('throws if no target is provided', () async { + Error? commandError; + final List output = await runCapturingPrint( + runner, ['update-dependency'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Exactly one of the target flags must be provided:'), + ]), + ); + }); + + group('pub dependencies', () { + test('throws if no version is given for an unpublished target', () async { + mockHttpResponse = (http.Request request) async { + return http.Response('', 404); + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package' + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('target_package does not exist on pub'), + ]), + ); + }); + + test('skips if there is no dependency', () async { + createFakePackage('a_package', packagesDir, examples: []); + + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + expect( + output, + containsAllInOrder([ + contains('SKIPPING: Does not depend on target_package'), + ]), + ); + }); + + test('skips if the dependency is already the target version', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package', version: '^1.5.0'); + + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + expect( + output, + containsAllInOrder([ + contains('SKIPPING: Already depends on ^1.5.0'), + ]), + ); + }); + + test('logs updates', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package'); + + final List output = await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + expect( + output, + containsAllInOrder([ + contains('Updating to "^1.5.0"'), + ]), + ); + }); + + test('updates normal dependency', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package'); + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + final Dependency? dep = + package.parsePubspec().dependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '^1.5.0'); + }); + + test('updates dev dependency', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDevDependency(package, 'target_package'); + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + final Dependency? dep = + package.parsePubspec().devDependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '^1.5.0'); + }); + + test('updates dependency in example', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir); + final RepositoryPackage example = package.getExamples().first; + addDevDependency(example, 'target_package'); + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + final Dependency? dep = + example.parsePubspec().devDependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '^1.5.0'); + }); + + test('uses provided constraint as-is', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package'); + + const String providedConstraint = '>=1.6.0 <3.0.0'; + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + providedConstraint + ]); + + final Dependency? dep = + package.parsePubspec().dependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), providedConstraint); + }); + + test('uses provided version as lower bound for unpinned', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package'); + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + final Dependency? dep = + package.parsePubspec().dependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '^1.5.0'); + }); + + test('uses provided version as exact version for pinned', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package', version: '1.0.0'); + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + '--version', + '1.5.0' + ]); + + final Dependency? dep = + package.parsePubspec().dependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '1.5.0'); + }); + + test('uses latest pub version as lower bound for unpinned', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package'); + + const Map targetPackagePubResponse = { + 'name': 'a', + 'versions': [ + '0.0.1', + '1.0.0', + '1.5.0', + ], + }; + mockHttpResponse = (http.Request request) async { + if (request.url.pathSegments.last == 'target_package.json') { + return http.Response(json.encode(targetPackagePubResponse), 200); + } + return http.Response('', 500); + }; + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + ]); + + final Dependency? dep = + package.parsePubspec().dependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '^1.5.0'); + }); + + test('uses latest pub version as exact version for pinned', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir, examples: []); + addDependency(package, 'target_package', version: '1.0.0'); + + const Map targetPackagePubResponse = { + 'name': 'a', + 'versions': [ + '0.0.1', + '1.0.0', + '1.5.0', + ], + }; + mockHttpResponse = (http.Request request) async { + if (request.url.pathSegments.last == 'target_package.json') { + return http.Response(json.encode(targetPackagePubResponse), 200); + } + return http.Response('', 500); + }; + + await runCapturingPrint(runner, [ + 'update-dependency', + '--pub-package', + 'target_package', + ]); + + final Dependency? dep = + package.parsePubspec().dependencies['target_package']; + expect(dep, isA()); + expect((dep! as HostedDependency).version.toString(), '1.5.0'); + }); + }); +}