Add support for pubspec overrides file (#3215)
This change adds support for a new file pubspec_overrides.yaml to override parts of pubspec.yaml. Overrides are only active for the get and upgrade commands.
An overrides file has the same structure as pubspec.yaml, but only supports a subset of its fields. Currently, only the following top-level field override pubspec:
dependency_overrides
All other fields in the overrides file causes an error.
A dependency_overrides, in the overrides file, completely replace a dependency_overrides in pubspec.yaml. The two files are merged in a way that preserves source references to provide correct error messages.
When overrides are active, a warning is logged.
Fixes #2161 .
diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart
index cc75b2d..bce47e1 100644
--- a/lib/src/pubspec.dart
+++ b/lib/src/pubspec.dart
@@ -69,12 +69,27 @@
// initialization can throw a [PubspecException], that error should also be
// exposed through [allErrors].
+ /// The fields of [pubspecOverridesFilename]. `null` if no such file exists or has
+ /// to be considered.
+ final YamlMap? _overridesFileFields;
+
+ String? get _packageName => fields['name'] != null ? name : null;
+
+ /// The name of the manifest file.
+ static const pubspecYamlFilename = 'pubspec.yaml';
+
+ /// The filename of the pubspec overrides file.
+ ///
+ /// This file can contain dependency_overrides that override those in
+ /// pubspec.yaml.
+ static const pubspecOverridesFilename = 'pubspec_overrides.yaml';
+
/// The registry of sources to use when parsing [dependencies] and
/// [devDependencies].
///
/// This will be null if this was created using [new Pubspec] or [new
/// Pubspec.empty].
- final SourceRegistry? _sources;
+ final SourceRegistry _sources;
/// The location from which the pubspec was loaded.
///
@@ -83,14 +98,27 @@
Uri? get _location => fields.span.sourceUrl;
/// The additional packages this package depends on.
- Map<String, PackageRange> get dependencies => _dependencies ??=
- _parseDependencies('dependencies', fields.nodes['dependencies']);
+ Map<String, PackageRange> get dependencies =>
+ _dependencies ??= _parseDependencies(
+ 'dependencies',
+ fields.nodes['dependencies'],
+ _sources,
+ languageVersion,
+ _packageName,
+ _location);
Map<String, PackageRange>? _dependencies;
/// The packages this package depends on when it is the root package.
- Map<String, PackageRange> get devDependencies => _devDependencies ??=
- _parseDependencies('dev_dependencies', fields.nodes['dev_dependencies']);
+ Map<String, PackageRange> get devDependencies =>
+ _devDependencies ??= _parseDependencies(
+ 'dev_dependencies',
+ fields.nodes['dev_dependencies'],
+ _sources,
+ languageVersion,
+ _packageName,
+ _location,
+ );
Map<String, PackageRange>? _devDependencies;
@@ -99,9 +127,41 @@
///
/// Dependencies here will replace any dependency on a package with the same
/// name anywhere in the dependency graph.
- Map<String, PackageRange> get dependencyOverrides =>
- _dependencyOverrides ??= _parseDependencies(
- 'dependency_overrides', fields.nodes['dependency_overrides']);
+ ///
+ /// These can occur both in the pubspec.yaml file and the [pubspecOverridesFilename].
+ Map<String, PackageRange> get dependencyOverrides {
+ if (_dependencyOverrides != null) return _dependencyOverrides!;
+ final pubspecOverridesFields = _overridesFileFields;
+ if (pubspecOverridesFields != null) {
+ pubspecOverridesFields.nodes.forEach((key, _) {
+ if (!const {'dependency_overrides'}.contains(key.value)) {
+ throw PubspecException(
+ 'pubspec_overrides.yaml only supports the `dependency_overrides` field.',
+ key.span,
+ );
+ }
+ });
+ if (pubspecOverridesFields.containsKey('dependency_overrides')) {
+ _dependencyOverrides = _parseDependencies(
+ 'dependency_overrides',
+ pubspecOverridesFields.nodes['dependency_overrides'],
+ _sources,
+ languageVersion,
+ _packageName,
+ _location,
+ fileType: _FileType.pubspecOverrides,
+ );
+ }
+ }
+ return _dependencyOverrides ??= _parseDependencies(
+ 'dependency_overrides',
+ fields.nodes['dependency_overrides'],
+ _sources,
+ languageVersion,
+ _packageName,
+ _location,
+ );
+ }
Map<String, PackageRange>? _dependencyOverrides;
@@ -140,7 +200,13 @@
});
var dependencies = _parseDependencies(
- 'dependencies', specNode.nodes['dependencies']);
+ 'dependencies',
+ specNode.nodes['dependencies'],
+ _sources,
+ languageVersion,
+ _packageName,
+ _location,
+ );
var sdkConstraints = _parseEnvironment(specNode);
@@ -250,7 +316,8 @@
}
var constraints = {
- 'dart': _parseVersionConstraint(yaml.nodes['sdk'],
+ 'dart': _parseVersionConstraint(
+ yaml.nodes['sdk'], _packageName, _FileType.pubspec,
defaultUpperBoundConstraint: _includeDefaultSdkConstraint
? _defaultUpperBoundSdkConstraint
: null)
@@ -263,10 +330,11 @@
}
if (name.value == 'sdk') return;
- constraints[name.value as String] = _parseVersionConstraint(constraint,
- // Flutter constraints get special treatment, as Flutter won't be
- // using semantic versioning to mark breaking releases.
- ignoreUpperBound: name.value == 'flutter');
+ constraints[name.value as String] =
+ _parseVersionConstraint(constraint, _packageName, _FileType.pubspec,
+ // Flutter constraints get special treatment, as Flutter won't be
+ // using semantic versioning to mark breaking releases.
+ ignoreUpperBound: name.value == 'flutter');
});
return constraints;
@@ -280,10 +348,14 @@
///
/// If [expectedName] is passed and the pubspec doesn't have a matching name
/// field, this will throw a [PubspecException].
+ ///
+ /// If [allowOverridesFile] is `true` [pubspecOverridesFilename] is loaded and
+ /// is allowed to override dependency_overrides from `pubspec.yaml`.
factory Pubspec.load(String packageDir, SourceRegistry sources,
- {String? expectedName}) {
- var pubspecPath = path.join(packageDir, 'pubspec.yaml');
- var pubspecUri = path.toUri(pubspecPath);
+ {String? expectedName, bool allowOverridesFile = false}) {
+ var pubspecPath = path.join(packageDir, pubspecYamlFilename);
+ var overridesPath = path.join(packageDir, pubspecOverridesFilename);
+
if (!fileExists(pubspecPath)) {
throw FileException(
// Make the package dir absolute because for the entrypoint it'll just
@@ -292,20 +364,31 @@
'"${canonicalize(packageDir)}".',
pubspecPath);
}
+ String? overridesFileContents =
+ allowOverridesFile && fileExists(overridesPath)
+ ? readTextFile(overridesPath)
+ : null;
- return Pubspec.parse(readTextFile(pubspecPath), sources,
- expectedName: expectedName, location: pubspecUri);
+ return Pubspec.parse(
+ readTextFile(pubspecPath),
+ sources,
+ expectedName: expectedName,
+ location: path.toUri(pubspecPath),
+ overridesFileContents: overridesFileContents,
+ overridesLocation: path.toUri(overridesPath),
+ );
}
- Pubspec(String name,
- {Version? version,
- Iterable<PackageRange>? dependencies,
- Iterable<PackageRange>? devDependencies,
- Iterable<PackageRange>? dependencyOverrides,
- Map? fields,
- SourceRegistry? sources,
- Map<String, VersionConstraint>? sdkConstraints})
- : _dependencies = dependencies == null
+ Pubspec(
+ String name, {
+ Version? version,
+ Iterable<PackageRange>? dependencies,
+ Iterable<PackageRange>? devDependencies,
+ Iterable<PackageRange>? dependencyOverrides,
+ Map? fields,
+ SourceRegistry? sources,
+ Map<String, VersionConstraint>? sdkConstraints,
+ }) : _dependencies = dependencies == null
? null
: Map.fromIterable(dependencies, key: (range) => range.name),
_devDependencies = devDependencies == null
@@ -317,22 +400,13 @@
_sdkConstraints = sdkConstraints ??
UnmodifiableMapView({'dart': VersionConstraint.any}),
_includeDefaultSdkConstraint = false,
- _sources = sources,
+ _sources = sources ?? SourceRegistry(),
+ _overridesFileFields = null,
super(
fields == null ? YamlMap() : YamlMap.wrap(fields),
name: name,
version: version,
);
- Pubspec.empty()
- : _sources = null,
- _dependencies = {},
- _devDependencies = {},
- _sdkConstraints = {'dart': VersionConstraint.any},
- _includeDefaultSdkConstraint = false,
- super(
- YamlMap(),
- version: Version.none,
- );
/// Returns a Pubspec object for an already-parsed map representing its
/// contents.
@@ -342,8 +416,9 @@
///
/// [location] is the location from which this pubspec was loaded.
Pubspec.fromMap(Map fields, this._sources,
- {String? expectedName, Uri? location})
- : _includeDefaultSdkConstraint = true,
+ {YamlMap? overridesFields, String? expectedName, Uri? location})
+ : _overridesFileFields = overridesFields,
+ _includeDefaultSdkConstraint = true,
super(fields is YamlMap
? fields
: YamlMap.wrap(fields, sourceUrl: location)) {
@@ -358,31 +433,49 @@
this.fields.nodes['name']!.span);
}
- /// Parses the pubspec stored at [filePath] whose text is [contents].
+ /// Parses the pubspec stored at [location] whose text is [contents].
///
/// If the pubspec doesn't define a version for itself, it defaults to
/// [Version.none].
- factory Pubspec.parse(String contents, SourceRegistry sources,
- {String? expectedName, Uri? location}) {
- YamlNode pubspecNode;
+ factory Pubspec.parse(
+ String contents,
+ SourceRegistry sources, {
+ String? expectedName,
+ Uri? location,
+ String? overridesFileContents,
+ Uri? overridesLocation,
+ }) {
+ late final YamlMap pubspecMap;
+ YamlMap? overridesFileMap;
try {
- pubspecNode = loadYamlNode(contents, sourceUrl: location);
+ pubspecMap = _ensureMap(loadYamlNode(contents, sourceUrl: location));
+ if (overridesFileContents != null) {
+ overridesFileMap = _ensureMap(
+ loadYamlNode(overridesFileContents, sourceUrl: overridesLocation));
+ }
} on YamlException catch (error) {
throw PubspecException(error.message, error.span);
}
- Map pubspecMap;
- if (pubspecNode is YamlScalar && pubspecNode.value == null) {
- pubspecMap = YamlMap(sourceUrl: location);
- } else if (pubspecNode is YamlMap) {
- pubspecMap = pubspecNode;
- } else {
- throw PubspecException(
- 'The pubspec must be a YAML mapping.', pubspecNode.span);
- }
-
return Pubspec.fromMap(pubspecMap, sources,
- expectedName: expectedName, location: location);
+ overridesFields: overridesFileMap,
+ expectedName: expectedName,
+ location: location);
+ }
+
+ /// Ensures that [node] is a mapping.
+ ///
+ /// If [node] is already a map it is returned.
+ /// If [node] is yaml-null an empty map is returned.
+ /// Otherwise an exception is thrown.
+ static YamlMap _ensureMap(YamlNode node) {
+ if (node is YamlScalar && node.value == null) {
+ return YamlMap(sourceUrl: node.span.sourceUrl);
+ } else if (node is YamlMap) {
+ return node;
+ } else {
+ throw PubspecException('The pubspec must be a YAML mapping.', node.span);
+ }
}
/// Returns a list of most errors in this pubspec.
@@ -409,29 +502,39 @@
_collectError(_ensureEnvironment);
return errors;
}
+}
- /// Parses the dependency field named [field], and returns the corresponding
- /// map of dependency names to dependencies.
- Map<String, PackageRange> _parseDependencies(String field, YamlNode? node) {
- var dependencies = <String, PackageRange>{};
+/// Parses the dependency field named [field], and returns the corresponding
+/// map of dependency names to dependencies.
+Map<String, PackageRange> _parseDependencies(
+ String field,
+ YamlNode? node,
+ SourceRegistry sources,
+ LanguageVersion languageVersion,
+ String? packageName,
+ Uri? location, {
+ _FileType fileType = _FileType.pubspec,
+}) {
+ var dependencies = <String, PackageRange>{};
- // Allow an empty dependencies key.
- if (node == null || node.value == null) return dependencies;
+ // Allow an empty dependencies key.
+ if (node == null || node.value == null) return dependencies;
- if (node is! YamlMap) {
- _error('"$field" field must be a map.', node.span);
- }
+ if (node is! YamlMap) {
+ _error('"$field" field must be a map.', node.span);
+ }
- var nonStringNode = node.nodes.keys
- .firstWhere((e) => e.value is! String, orElse: () => null);
- if (nonStringNode != null) {
- _error('A dependency name must be a string.', nonStringNode.span);
- }
+ var nonStringNode =
+ node.nodes.keys.firstWhere((e) => e.value is! String, orElse: () => null);
+ if (nonStringNode != null) {
+ _error('A dependency name must be a string.', nonStringNode.span);
+ }
- node.nodes.forEach((nameNode, specNode) {
+ node.nodes.forEach(
+ (nameNode, specNode) {
var name = nameNode.value;
var spec = specNode.value;
- if (fields['name'] != null && name == this.name) {
+ if (packageName != null && name == packageName) {
_error('A package may not list itself as a dependency.', nameNode.span);
}
@@ -441,10 +544,11 @@
VersionConstraint versionConstraint = VersionRange();
var features = const <String, FeatureDependency>{};
if (spec == null) {
- sourceName = _sources!.defaultSource.name;
+ sourceName = sources.defaultSource.name;
} else if (spec is String) {
- sourceName = _sources!.defaultSource.name;
- versionConstraint = _parseVersionConstraint(specNode);
+ sourceName = sources.defaultSource.name;
+ versionConstraint =
+ _parseVersionConstraint(specNode, packageName, fileType);
} else if (spec is Map) {
// Don't write to the immutable YAML map.
spec = Map.from(spec);
@@ -452,7 +556,11 @@
if (spec.containsKey('version')) {
spec.remove('version');
- versionConstraint = _parseVersionConstraint(specMap.nodes['version']);
+ versionConstraint = _parseVersionConstraint(
+ specMap.nodes['version'],
+ packageName,
+ fileType,
+ );
}
if (spec.containsKey('features')) {
@@ -483,151 +591,82 @@
// Let the source validate the description.
var ref = _wrapFormatException('description', descriptionNode?.span, () {
String? pubspecPath;
- var location = _location;
if (location != null && _isFileUri(location)) {
- pubspecPath = path.fromUri(_location);
+ pubspecPath = path.fromUri(location);
}
- return _sources![sourceName]!.parseRef(
+ return sources[sourceName]!.parseRef(
name,
descriptionNode?.value,
containingPath: pubspecPath,
languageVersion: languageVersion,
);
- }, targetPackage: name);
+ }, packageName, fileType, targetPackage: name);
dependencies[name] =
ref.withConstraint(versionConstraint).withFeatures(features);
- });
+ },
+ );
- return dependencies;
+ return dependencies;
+}
+
+/// Parses [node] to a map from feature names to whether those features are
+/// enabled.
+Map<String, FeatureDependency> _parseDependencyFeatures(YamlNode? node) {
+ if (node?.value == null) return const {};
+ if (node is! YamlMap) _error('Features must be a map.', node!.span);
+
+ return mapMap(node.nodes,
+ key: (dynamic nameNode, dynamic _) => _validateFeatureName(nameNode),
+ value: (dynamic _, dynamic valueNode) {
+ var value = valueNode.value;
+ if (value is bool) {
+ return value ? FeatureDependency.required : FeatureDependency.unused;
+ } else if (value is String && value == 'if available') {
+ return FeatureDependency.ifAvailable;
+ } else {
+ _error('Features must be true, false, or "if available".',
+ valueNode.span);
+ }
+ });
+}
+
+/// Verifies that [node] is a string and a valid feature name, and returns it
+/// if so.
+String _validateFeatureName(YamlNode node) {
+ var name = node.value;
+ if (name is! String) {
+ _error('A feature name must be a string.', node.span);
+ } else if (!packageNameRegExp.hasMatch(name)) {
+ _error('A feature name must be a valid Dart identifier.', node.span);
}
- /// Parses [node] to a [VersionConstraint].
- ///
- /// If or [defaultUpperBoundConstraint] is specified then it will be set as
- /// the max constraint if the original constraint doesn't have an upper
- /// bound and it is compatible with [defaultUpperBoundConstraint].
- ///
- /// If [ignoreUpperBound] the max constraint is ignored.
- VersionConstraint _parseVersionConstraint(YamlNode? node,
- {VersionConstraint? defaultUpperBoundConstraint,
- bool ignoreUpperBound = false}) {
- if (node?.value == null) {
- return defaultUpperBoundConstraint ?? VersionConstraint.any;
- }
- if (node!.value is! String) {
- _error('A version constraint must be a string.', node.span);
- }
+ return name;
+}
- return _wrapFormatException('version constraint', node.span, () {
- var constraint = VersionConstraint.parse(node.value);
- if (defaultUpperBoundConstraint != null &&
- constraint is VersionRange &&
- constraint.max == null &&
- defaultUpperBoundConstraint.allowsAny(constraint)) {
- constraint = VersionConstraint.intersection(
- [constraint, defaultUpperBoundConstraint]);
- }
- if (ignoreUpperBound && constraint is VersionRange) {
- return VersionRange(
- min: constraint.min, includeMin: constraint.includeMin);
- }
- return constraint;
- });
- }
-
- /// Parses [node] to a map from feature names to whether those features are
- /// enabled.
- Map<String, FeatureDependency> _parseDependencyFeatures(YamlNode? node) {
- if (node?.value == null) return const {};
- if (node is! YamlMap) _error('Features must be a map.', node!.span);
-
- return mapMap(node.nodes,
- key: (dynamic nameNode, dynamic _) => _validateFeatureName(nameNode),
- value: (dynamic _, dynamic valueNode) {
- var value = valueNode.value;
- if (value is bool) {
- return value
- ? FeatureDependency.required
- : FeatureDependency.unused;
- } else if (value is String && value == 'if available') {
- return FeatureDependency.ifAvailable;
- } else {
- _error('Features must be true, false, or "if available".',
- valueNode.span);
- }
- });
- }
-
- /// Verifies that [node] is a string and a valid feature name, and returns it
- /// if so.
- String _validateFeatureName(YamlNode node) {
- var name = node.value;
- if (name is! String) {
- _error('A feature name must be a string.', node.span);
- } else if (!packageNameRegExp.hasMatch(name)) {
- _error('A feature name must be a valid Dart identifier.', node.span);
- }
-
- return name;
- }
-
- /// Verifies that [node] is a list of strings and returns it.
- ///
- /// If [validate] is passed, it's called for each string in [node].
- List<String> _parseStringList(YamlNode? node,
- {void Function(String value, SourceSpan)? validate}) {
- var list = _parseList(node);
- for (var element in list.nodes) {
- var value = element.value;
- if (value is String) {
- if (validate != null) validate(value, element.span);
- } else {
- _error('Must be a string.', element.span);
- }
- }
- return list.cast<String>();
- }
-
- /// Verifies that [node] is a list and returns it.
- YamlList _parseList(YamlNode? node) {
- if (node == null || node.value == null) return YamlList();
- if (node is YamlList) return node;
- _error('Must be a list.', node.span);
- }
-
- /// Runs [fn] and wraps any [FormatException] it throws in a
- /// [PubspecException].
- ///
- /// [description] should be a noun phrase that describes whatever's being
- /// parsed or processed by [fn]. [span] should be the location of whatever's
- /// being processed within the pubspec.
- ///
- /// If [targetPackage] is provided, the value is used to describe the
- /// dependency that caused the problem.
- T _wrapFormatException<T>(
- String description, SourceSpan? span, T Function() fn,
- {String? targetPackage}) {
- try {
- return fn();
- } on FormatException catch (e) {
- // If we already have a pub exception with a span, re-use that
- if (e is PubspecException) rethrow;
-
- var msg = 'Invalid $description';
- if (targetPackage != null) {
- msg = '$msg in the "$name" pubspec on the "$targetPackage" dependency';
- }
- msg = '$msg: ${e.message}';
- _error(msg, span);
+/// Verifies that [node] is a list of strings and returns it.
+///
+/// If [validate] is passed, it's called for each string in [node].
+List<String> _parseStringList(YamlNode? node,
+ {void Function(String value, SourceSpan)? validate}) {
+ var list = _parseList(node);
+ for (var element in list.nodes) {
+ var value = element.value;
+ if (value is String) {
+ if (validate != null) validate(value, element.span);
+ } else {
+ _error('Must be a string.', element.span);
}
}
+ return list.cast<String>();
+}
- /// Throws a [PubspecException] with the given message.
- Never _error(String message, SourceSpan? span) {
- throw PubspecException(message, span);
- }
+/// Verifies that [node] is a list and returns it.
+YamlList _parseList(YamlNode? node) {
+ if (node == null || node.value == null) return YamlList();
+ if (node is YamlList) return node;
+ _error('Must be a list.', node.span);
}
/// Returns whether [uri] is a file URI.
@@ -635,3 +674,86 @@
/// This is slightly more complicated than just checking if the scheme is
/// 'file', since relative URIs also refer to the filesystem on the VM.
bool _isFileUri(Uri uri) => uri.scheme == 'file' || uri.scheme == '';
+
+/// Parses [node] to a [VersionConstraint].
+///
+/// If or [defaultUpperBoundConstraint] is specified then it will be set as
+/// the max constraint if the original constraint doesn't have an upper
+/// bound and it is compatible with [defaultUpperBoundConstraint].
+///
+/// If [ignoreUpperBound] the max constraint is ignored.
+VersionConstraint _parseVersionConstraint(
+ YamlNode? node, String? packageName, _FileType fileType,
+ {VersionConstraint? defaultUpperBoundConstraint,
+ bool ignoreUpperBound = false}) {
+ if (node?.value == null) {
+ return defaultUpperBoundConstraint ?? VersionConstraint.any;
+ }
+ if (node!.value is! String) {
+ _error('A version constraint must be a string.', node.span);
+ }
+
+ return _wrapFormatException('version constraint', node.span, () {
+ var constraint = VersionConstraint.parse(node.value);
+ if (defaultUpperBoundConstraint != null &&
+ constraint is VersionRange &&
+ constraint.max == null &&
+ defaultUpperBoundConstraint.allowsAny(constraint)) {
+ constraint = VersionConstraint.intersection(
+ [constraint, defaultUpperBoundConstraint]);
+ }
+ if (ignoreUpperBound && constraint is VersionRange) {
+ return VersionRange(
+ min: constraint.min, includeMin: constraint.includeMin);
+ }
+ return constraint;
+ }, packageName, fileType);
+}
+
+/// Runs [fn] and wraps any [FormatException] it throws in a
+/// [PubspecException].
+///
+/// [description] should be a noun phrase that describes whatever's being
+/// parsed or processed by [fn]. [span] should be the location of whatever's
+/// being processed within the pubspec.
+///
+/// If [targetPackage] is provided, the value is used to describe the
+/// dependency that caused the problem.
+T _wrapFormatException<T>(String description, SourceSpan? span, T Function() fn,
+ String? packageName, _FileType fileType,
+ {String? targetPackage}) {
+ try {
+ return fn();
+ } on FormatException catch (e) {
+ // If we already have a pub exception with a span, re-use that
+ if (e is PubspecException) rethrow;
+
+ var msg = 'Invalid $description';
+ final typeName = _fileTypeName(fileType);
+ if (targetPackage != null) {
+ msg = '$msg in the "$packageName" $typeName on the "$targetPackage" '
+ 'dependency';
+ }
+ msg = '$msg: ${e.message}';
+ _error(msg, span);
+ }
+}
+
+/// Throws a [PubspecException] with the given message.
+Never _error(String message, SourceSpan? span) {
+ throw PubspecException(message, span);
+}
+
+enum _FileType {
+ pubspec,
+ pubspecOverrides,
+}
+
+String _fileTypeName(_FileType type) {
+ switch (type) {
+ case _FileType.pubspec:
+ return 'pubspec';
+ case _FileType.pubspecOverrides:
+ return 'pubspec override';
+ }
+}