diff --git a/pkgs/cli_config/analysis_options.yaml b/pkgs/cli_config/analysis_options.yaml index 55b8acbba..2d557097c 100644 --- a/pkgs/cli_config/analysis_options.yaml +++ b/pkgs/cli_config/analysis_options.yaml @@ -1,4 +1,4 @@ -include: package:lints/recommended.yaml +include: package:dart_flutter_team_lints/analysis_options.yaml analyzer: language: diff --git a/pkgs/cli_config/example/bin/cli_config_example.dart b/pkgs/cli_config/example/bin/cli_config_example.dart new file mode 100644 index 000000000..1b0b79363 --- /dev/null +++ b/pkgs/cli_config/example/bin/cli_config_example.dart @@ -0,0 +1,12 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:cli_config/cli_config.dart'; + +Future main(List args) async { + final config = await Config.fromArgs(args: args); + final myPath = + config.optionalPath('my_path', resolveUri: true, mustExist: false); + print(myPath?.path); +} diff --git a/pkgs/cli_config/example/pubspec.yaml b/pkgs/cli_config/example/pubspec.yaml new file mode 100644 index 000000000..30db61b60 --- /dev/null +++ b/pkgs/cli_config/example/pubspec.yaml @@ -0,0 +1,13 @@ +name: cli_config_example +description: An example for cli_config. + +repository: https://github.com/dart-lang/tools/tree/main/pkgs/cli_config/example + +publish_to: none + +environment: + sdk: ">=2.19.3 <4.0.0" + +dependencies: + cli_config: + path: ../ diff --git a/pkgs/cli_config/lib/cli_config.dart b/pkgs/cli_config/lib/cli_config.dart index 23a859805..4d1250bd3 100644 --- a/pkgs/cli_config/lib/cli_config.dart +++ b/pkgs/cli_config/lib/cli_config.dart @@ -6,4 +6,4 @@ /// and environment variables. library cli_config; -export 'src/cli_config.dart'; +export 'src/config.dart'; diff --git a/pkgs/cli_config/lib/src/cli_config.dart b/pkgs/cli_config/lib/src/cli_config.dart deleted file mode 100644 index 4e9351fdd..000000000 --- a/pkgs/cli_config/lib/src/cli_config.dart +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -int add(int a, int b) => a + b; diff --git a/pkgs/cli_config/lib/src/cli_parser.dart b/pkgs/cli_config/lib/src/cli_parser.dart new file mode 100644 index 000000000..9396a7aac --- /dev/null +++ b/pkgs/cli_config/lib/src/cli_parser.dart @@ -0,0 +1,53 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:args/args.dart'; + +class CliParser { + final ArgParser parser = () { + final parser = ArgParser(); + parser.addFlag( + 'help', + abbr: 'h', + help: 'Show this help.', + ); + parser.addMultiOption( + 'define', + abbr: 'D', + help: '''Define or override a config property from command line. +The same option can be passed multiple times. +Keys should only contain lower-case alphanumeric characters, underscores, +and '.'s''', + ); + parser.addOption( + 'config', + abbr: 'c', + help: '''Path to JSON or YAML config file. +Keys should only contain lower-case alphanumeric characters, and underscores. +Hierarchies should be maps.''', + ); + return parser; + }(); + + ArgResults parse(List args) => parser.parse(args); +} + +class DefinesParser { + static final _defineRegex = RegExp('([a-z_.]+)=(.+)'); + + Map> parse(List args) { + final defines = >{}; + for (final arg in args) { + final match = _defineRegex.matchAsPrefix(arg); + if (match == null || match.group(0) != arg) { + throw FormatException("Define '$arg' does not match expected pattern " + "'${_defineRegex.pattern}'."); + } + final key = match.group(1)!; + final value = match.group(2)!; + defines[key] = (defines[key] ?? [])..add(value); + } + return defines; + } +} diff --git a/pkgs/cli_config/lib/src/cli_source.dart b/pkgs/cli_config/lib/src/cli_source.dart new file mode 100644 index 000000000..8897fdf3e --- /dev/null +++ b/pkgs/cli_config/lib/src/cli_source.dart @@ -0,0 +1,95 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'config.dart'; +import 'source.dart'; + +class CliSource extends Source { + /// Configuration options passed in via CLI arguments. + /// + /// Options can be passed multiple times, so the values here are a list. + /// + /// Stored as a flat non-hierarchical structure, keys contain `.`. + final Map> _cli; + + /// If provided, used to resolve paths within [_cli]. + /// + /// Typically the current working directory at application start. + @override + final Uri? baseUri; + + CliSource(this._cli, this.baseUri); + + @override + String? optionalString(String key) { + final value = _cli[key]; + if (value == null) { + return null; + } + if (value.length > 1) { + throw FormatException( + "More than one value was passed for '$key' in the CLI defines." + ' Values passed: $value'); + } + return value.single; + } + + @override + List? stringList( + String key, { + String? splitPattern, + }) { + final cliValue = _cli[key]; + if (cliValue == null) { + return null; + } + if (splitPattern != null) { + return [for (final value in cliValue) ...value.split(splitPattern)]; + } + return cliValue; + } + + @override + bool? optionalBool(String key) { + final stringValue = optionalString(key); + if (stringValue != null) { + Source.throwIfUnexpectedValue(key, stringValue, Config.boolStrings.keys); + return Config.boolStrings[stringValue]!; + } + return null; + } + + @override + int? optionalInt(String key) { + final stringValue = optionalString(key); + if (stringValue != null) { + try { + return int.parse(stringValue); + } on FormatException catch (e) { + throw FormatException( + "Unexpected value '$stringValue' for key '$key'. Expected an int." + ' ${e.message}'); + } + } + return null; + } + + @override + double? optionalDouble(String key) { + final stringValue = optionalString(key); + if (stringValue != null) { + try { + return double.parse(stringValue); + } on FormatException catch (e) { + throw FormatException( + "Unexpected value '$stringValue' for key '$key'. Expected a double." + ' ${e.message}'); + } + } + return null; + } + + @override + String toString() => 'CliSource($_cli)'; +} diff --git a/pkgs/cli_config/lib/src/config.dart b/pkgs/cli_config/lib/src/config.dart new file mode 100644 index 000000000..ed41e6e00 --- /dev/null +++ b/pkgs/cli_config/lib/src/config.dart @@ -0,0 +1,544 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:core' as core show bool, double, int; +import 'dart:core' hide bool, double, int; +import 'dart:io'; + +import 'cli_parser.dart'; +import 'cli_source.dart'; +import 'environment_parser.dart'; +import 'environment_source.dart'; +import 'file_parser.dart'; +import 'file_source.dart'; +import 'source.dart'; + +/// A hierarchical configuration. +/// +/// Configuration can be provided from three sources: commandline arguments, +/// environment variables and configuration files. This configuration makes +/// these accessible via a uniform API. +/// +/// Configuration can be provided via the three sources as follows: +/// 1. commandline argument defines as `-Dsome_key=some_value`, +/// 2. environment variables as `SOME_KEY=some_value`, and +/// 3. config files as JSON or YAML as `{'some_key': 'some_value'}`. +/// +/// The default lookup behavior is that commandline argument defines take +/// precedence over environment variables, which take precedence over the +/// configuration file. +/// +/// If a single value is requested from this configuration, the first source +/// that can provide the value will provide it. For example +/// `config.string('some_key')` with `{'some_key': 'file_value'}` in the +/// config file and `-Dsome_key=cli_value` as commandline argument returns +/// `'cli_value'`. The implication is that you can not remove keys from the +/// configuration file, only overwrite or append them. +/// +/// If a list value is requested from this configuration, the values provided +/// by the various sources can be combined or not. For example +/// `config.stringList('some_key', combineAllConfigs: true)` returns +/// `['cli_value', 'file_value']`. +/// +/// The config is hierarchical in nature, using `.` as the hierarchy separator +/// for lookup and commandline defines. The hierarchy should be materialized in +/// the JSON or YAML configuration file. For environment variables `__` is used +/// as hierarchy separator. +/// +/// Hierarchical configuration can be provided via the three sources as follows: +/// 1. commandline argument defines as `-Dsome_key.some_nested_key=some_value`, +/// 2. environment variables as `SOME_KEY__SOME_NESTED_KEY=some_value`, and +/// 3. config files as JSON or YAML as +/// ```yaml +/// some_key: +/// some_nested_key: +/// some_value +/// ``` +/// +/// The config is opinionated on the format of the keys in the sources. +/// * Command-line argument keys should be lower-cased alphanumeric +/// characters or underscores, with `.` for hierarchy. +/// * Environment variables keys should be upper-cased alphanumeric +/// characters or underscores, with `__` for hierarchy. +/// * Config files keys should be lower-cased alphanumeric +/// characters or underscores. +/// +/// In the API they are made available lower-cased and with underscores, and +/// `.` as hierarchy separator. +class Config { + final CliSource _cliSource; + final EnvironmentSource _environmentSource; + final FileSource _fileSource; + + /// Config sources, ordered by precedence. + late final _sources = [_cliSource, _environmentSource, _fileSource]; + + Config._( + this._cliSource, + this._environmentSource, + this._fileSource, + ); + + /// Constructs a config by parsing the three sources. + /// + /// If provided, [commandLineDefines] must be a list of '='. + /// + /// If provided, [workingDirectory] is used to resolves paths inside + /// [commandLineDefines]. + /// + /// If provided, [environment] must be a map containing environment variables. + /// + /// If provided, [fileParsed] must be valid parsed YSON or YAML (maps, lists, + /// strings, integers, and booleans). + /// + /// If provided [fileSourceUri] is used to resolve paths inside + /// [fileParsed] and to provide better error messages on parsing the + /// configuration file. + factory Config({ + List commandLineDefines = const [], + Uri? workingDirectory, + Map environment = const {}, + Map fileParsed = const {}, + Uri? fileSourceUri, + }) { + // Parse config file. + final fileConfig = FileParser().parseMap(fileParsed); + + // Parse CLI argument defines. + final cliConfig = DefinesParser().parse(commandLineDefines); + + // Parse environment. + final environmentConfig = EnvironmentParser().parse(environment); + + return Config._( + CliSource(cliConfig, workingDirectory), + EnvironmentSource(environmentConfig), + FileSource(fileConfig, fileSourceUri), + ); + } + + /// Constructs a config by parsing the three sources. + /// + /// If provided, [commandLineDefines] must be a list of '='. + /// + /// If provided, [workingDirectory] is used to resolves paths inside + /// [commandLineDefines]. + /// + /// If provided, [environment] must be a map containing environment variables. + /// + /// If provided, [fileContents] must be valid JSON or YAML. + /// + /// If provided [fileSourceUri] is used to resolve paths inside + /// [fileContents] and to provide better error messages on parsing the + /// configuration file. + factory Config.fromConfigFileContents({ + List commandLineDefines = const [], + Uri? workingDirectory, + Map environment = const {}, + String? fileContents, + Uri? fileSourceUri, + }) { + // Parse config file. + final Map fileConfig; + if (fileContents != null) { + fileConfig = FileParser().parse( + fileContents, + sourceUrl: fileSourceUri, + ); + } else { + fileConfig = {}; + } + + // Parse CLI argument defines. + final cliConfig = DefinesParser().parse(commandLineDefines); + + // Parse environment. + final environmentConfig = EnvironmentParser().parse(environment); + + return Config._( + CliSource(cliConfig, workingDirectory), + EnvironmentSource(environmentConfig), + FileSource(fileConfig, fileSourceUri), + ); + } + + /// Constructs a config by parsing CLI arguments and loading the config file. + /// + /// The [args] must be commandline arguments. + /// + /// If provided, [environment] must be a map containing environment variables. + /// If not provided, [environment] defaults to [Platform.environment]. + /// + /// If provided, [workingDirectory] is used to resolves paths inside + /// [environment]. + /// If not provided, [workingDirectory] defaults to [Directory.current]. + /// + /// This async constructor is intended to be used directly in CLI files. + static Future fromArgs({ + required List args, + Map? environment, + Uri? workingDirectory, + }) async { + final results = CliParser().parse(args); + + // Load config file. + final configFile = results['config'] as String?; + String? fileContents; + Uri? fileSourceUri; + if (configFile != null) { + fileContents = await File(configFile).readAsString(); + fileSourceUri = Uri.file(configFile); + } + + return Config.fromConfigFileContents( + commandLineDefines: results['define'] as List, + workingDirectory: workingDirectory ?? Directory.current.uri, + environment: environment ?? Platform.environment, + fileContents: fileContents, + fileSourceUri: fileSourceUri, + ); + } + + /// Lookup a string value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// Throws if one of the configs does not contain the expected value type. + /// + /// If [validValues] is provided, throws if an unxpected value is provided. + String string(String key, {Iterable? validValues}) { + final value = optionalString(key, validValues: validValues); + _throwIfNull(key, value); + return value!; + } + + /// Lookup a nullable string value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// If [validValues] is provided, throws if an unxpected value is provided. + String? optionalString(String key, {Iterable? validValues}) { + String? value; + for (final source in _sources) { + value ??= source.optionalString(key); + } + if (validValues != null) { + Source.throwIfUnexpectedValue(key, value, validValues); + } + return value; + } + + /// Lookup a nullable string list in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// If [combineAllConfigs] combines results from cli, environment, and + /// config file. Otherwise, precedence rules apply. + /// + /// If provided, [splitCliPattern] splits cli defines. + /// For example: `-Dfoo=bar;baz` can be split on `;`. + /// If not provided, a list can still be provided with multiple cli defines. + /// For example: `-Dfoo=bar -Dfoo=baz`. + /// + /// If provided, [splitEnvironmentPattern] splits environment values. + List? stringList( + String key, { + core.bool combineAllConfigs = true, + String? splitCliPattern, + String? splitEnvironmentPattern, + }) { + List? result; + for (final entry in { + _cliSource: splitCliPattern, + _environmentSource: splitEnvironmentPattern, + _fileSource: null + }.entries) { + final source = entry.key; + final splitPattern = entry.value; + final value = source.stringList(key, splitPattern: splitPattern); + if (value != null) { + if (combineAllConfigs) { + (result ??= []).addAll(value); + } else { + return value; + } + } + } + return result; + } + + static const boolStrings = { + '0': false, + '1': true, + 'false': false, + 'FALSE': false, + 'no': false, + 'NO': false, + 'true': true, + 'TRUE': true, + 'yes': true, + 'YES': true, + }; + + /// Lookup a boolean value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// For cli defines and environment variables, the value must be one of + /// [boolStrings]. + /// For the config file, it must be a boolean. + /// + /// Throws if one of the configs does not contain the expected value type. + core.bool bool(String key) { + final value = optionalBool(key); + _throwIfNull(key, value); + return value!; + } + + /// Lookup an optional boolean value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// For cli defines and environment variables, the value must be one of + /// [boolStrings]. + /// For the config file, it must be a boolean or null. + core.bool? optionalBool(String key) { + core.bool? value; + for (final source in _sources) { + value ??= source.optionalBool(key); + } + return value; + } + + /// Lookup an integer value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// For cli defines and environment variables, the value must be parseble + /// by [int.parse]. + /// For the config file, it must be an integer. + core.int int(String key) { + final value = optionalInt(key); + _throwIfNull(key, value); + return value!; + } + + /// Lookup an optional integer value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// For cli defines and environment variables, the value must be parseble + /// by [int.parse]. + /// For the config file, it must be an integer or null. + core.int? optionalInt(String key) { + core.int? value; + for (final source in _sources) { + value ??= source.optionalInt(key); + } + return value; + } + + /// Lookup an double value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// For cli defines and environment variables, the value must be parseble + /// by [double.parse]. + /// For the config file, it must be an double. + core.double double(String key) { + final value = optionalDouble(key); + _throwIfNull(key, value); + return value!; + } + + /// Lookup an optional double value in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// For cli defines and environment variables, the value must be parseble + /// by [double.parse]. + /// For the config file, it must be an double or null. + core.double? optionalDouble(String key) { + core.double? value; + for (final source in _sources) { + value ??= source.optionalDouble(key); + } + return value; + } + + /// Lookup a path in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// Throws if one of the configs does not contain the expected value type. + /// + /// If [resolveUri], resolves the paths in a source relative to the base + /// uri of that source. The base uri for the config file is the path of the + /// file. The base uri for environment values is the current working + /// directory. + /// + /// If [mustExist], throws if the path doesn't resolve to a file or directory + /// on the file system. + /// + /// Throws if one of the configs does not contain the expected value type. + Uri path( + String key, { + core.bool resolveUri = true, + core.bool mustExist = false, + }) { + final value = + optionalPath(key, resolveUri: resolveUri, mustExist: mustExist); + _throwIfNull(key, value); + return value!; + } + + /// Lookup an optional path in this config. + /// + /// First tries CLI argument defines, then environment variables, and + /// finally the config file. + /// + /// Throws if one of the configs does not contain the expected value type. + /// + /// If [resolveUri], resolves the paths in a source relative to the base + /// uri of that source. The base uri for the config file is the path of the + /// file. The base uri for environment values is the current working + /// directory. + /// + /// If [mustExist], throws if the path doesn't resolve to a file or directory + /// on the file system. + Uri? optionalPath( + String key, { + core.bool resolveUri = true, + core.bool mustExist = false, + }) { + for (final source in _sources) { + final path = source.optionalString(key); + if (path != null) { + final value = _pathToUri( + path, + resolveUri: resolveUri, + baseUri: source.baseUri, + ); + if (mustExist) { + _throwIfNotExists(key, value); + } + return value; + } + } + return null; + } + + Uri _pathToUri( + String path, { + required core.bool resolveUri, + required Uri? baseUri, + }) { + if (resolveUri && baseUri != null) { + return baseUri.resolve(path); + } + return Source.fileSystemPathToUri(path); + } + + /// Lookup a list of paths in this config. + /// + /// If [combineAllConfigs] combines results from cli, environment, and + /// config file. Otherwise, precedence rules apply. + /// + /// If provided, [splitCliPattern] splits cli defines. + /// + /// If provided, [splitEnvironmentPattern] splits environment values. + /// + /// If [resolveUri], resolves the paths in a source relative to the base + /// uri of that source. The base uri for the config file is the path of the + /// file. The base uri for environment values is the current working + /// directory. + List? optionalPathList( + String key, { + core.bool combineAllConfigs = true, + String? splitCliPattern, + String? splitEnvironmentPattern, + core.bool resolveUri = true, + }) { + List? result; + for (final entry in { + _cliSource: splitCliPattern, + _environmentSource: splitEnvironmentPattern, + _fileSource: null + }.entries) { + final source = entry.key; + final splitPattern = entry.value; + final paths = source.stringList( + key, + splitPattern: splitPattern, + ); + if (paths != null) { + final value = [ + for (final path in paths) + _pathToUri( + path, + resolveUri: resolveUri, + baseUri: source.baseUri, + ) + ]; + if (combineAllConfigs) { + (result ??= []).addAll(value); + } else { + return value; + } + } + } + return result; + } + + /// Lookup a value of type [T] in this configuration. + /// + /// Does not support specialized options such as `splitPattern`. One must + /// use the specialized methods such as [stringList] for that. + /// + /// If sources cannot lookup type [T], they return null. + T valueOf(String key) { + T? value; + for (final source in _sources) { + value ??= source.optionalValueOf(key); + } + if (null is! T) { + _throwIfNull(key, value); + } + return value as T; + } + + void _throwIfNull(String key, Object? value) { + if (value == null) { + throw FormatException('No value was provided for required key: $key'); + } + } + + void _throwIfNotExists(String key, Uri value) { + if (!value.fileSystemEntity.existsSync()) { + throw FormatException("Path '$value' for key '$key' doesn't exist."); + } + } + + @override + String toString() => 'Config($_sources)'; +} + +extension on Uri { + FileSystemEntity get fileSystemEntity { + if (path.endsWith(Platform.pathSeparator)) { + return Directory.fromUri(this); + } + return File.fromUri(this); + } +} diff --git a/pkgs/cli_config/lib/src/environment_parser.dart b/pkgs/cli_config/lib/src/environment_parser.dart new file mode 100644 index 000000000..d91b8fa64 --- /dev/null +++ b/pkgs/cli_config/lib/src/environment_parser.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +class EnvironmentParser { + /// Parses an environment key into an config key. + /// + /// Environment keys can only contain alphanumeric characters and underscores. + /// Treats `__` as hierarchy separator, and replaces it with `.`. + /// + /// Often, environment variables are uppercased. + /// Replaces all uppercase characters with lowercase characters. + String parseKey(String key) => key.replaceAll('__', '.').toLowerCase(); + + Map parse(Map environment) => { + for (final entry in environment.entries) + parseKey(entry.key): entry.value, + }; +} diff --git a/pkgs/cli_config/lib/src/environment_source.dart b/pkgs/cli_config/lib/src/environment_source.dart new file mode 100644 index 000000000..fc158d190 --- /dev/null +++ b/pkgs/cli_config/lib/src/environment_source.dart @@ -0,0 +1,86 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:io'; + +import 'config.dart'; +import 'source.dart'; + +class EnvironmentSource extends Source { + /// Configuration options passed in via the [Platform.environment]. + /// + /// The keys have been transformed by [EnvironmentParser.parseKey]. + /// + /// Environment values are left intact. + /// + /// Stored as a flat non-hierarchical structure, keys contain `.`. + final Map _environment; + + EnvironmentSource(this._environment); + + @override + String? optionalString(String key) => _environment[key]; + + @override + List? stringList( + String key, { + String? splitPattern, + }) { + final envValue = _environment[key]; + if (envValue == null) { + return null; + } + if (splitPattern != null) { + return envValue.split(splitPattern); + } + return [envValue]; + } + + @override + bool? optionalBool(String key) { + final stringValue = optionalString(key); + if (stringValue != null) { + Source.throwIfUnexpectedValue(key, stringValue, Config.boolStrings.keys); + return Config.boolStrings[stringValue]!; + } + return null; + } + + @override + int? optionalInt(String key) { + final stringValue = optionalString(key); + if (stringValue != null) { + try { + return int.parse(stringValue); + } on FormatException catch (e) { + throw FormatException( + "Unexpected value '$stringValue' for key '$key'. Expected an int." + ' ${e.message}'); + } + } + return null; + } + + @override + double? optionalDouble(String key) { + final stringValue = optionalString(key); + if (stringValue != null) { + try { + return double.parse(stringValue); + } on FormatException catch (e) { + throw FormatException( + "Unexpected value '$stringValue' for key '$key'. Expected a double." + ' ${e.message}'); + } + } + return null; + } + + @override + String toString() => 'EnvironmentSource($_environment)'; + + /// Environment path are not resolved. + @override + Uri? get baseUri => null; +} diff --git a/pkgs/cli_config/lib/src/file_parser.dart b/pkgs/cli_config/lib/src/file_parser.dart new file mode 100644 index 000000000..3d54b585c --- /dev/null +++ b/pkgs/cli_config/lib/src/file_parser.dart @@ -0,0 +1,44 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:yaml/yaml.dart'; + +class FileParser { + Map parse( + String fileContents, { + Uri? sourceUrl, + }) { + final parsedYaml = loadYaml( + fileContents, + sourceUrl: sourceUrl, + ); + if (parsedYaml is! Map) { + throw FormatException('YAML config must be set of key value pairs.'); + } + return parseMap(parsedYaml); + } + + Map parseMap(Map input) => { + for (final entry in input.entries) + parseKey(entry.key as String): parseValue(entry.value as Object), + }; + + Object parseValue(Object value) { + if (value is Map) { + return parseMap(value); + } + return value; + } + + static final _keyRegex = RegExp('([a-z-_]+)'); + + String parseKey(String key) { + final match = _keyRegex.matchAsPrefix(key); + if (match == null || match.group(0) != key) { + throw FormatException("Define '$key' does not match expected pattern " + "'${_keyRegex.pattern}'."); + } + return key.replaceAll('-', '_'); + } +} diff --git a/pkgs/cli_config/lib/src/file_source.dart b/pkgs/cli_config/lib/src/file_source.dart new file mode 100644 index 000000000..e1e5348d9 --- /dev/null +++ b/pkgs/cli_config/lib/src/file_source.dart @@ -0,0 +1,68 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'source.dart'; + +class FileSource extends Source { + /// Configuration options passed in via a JSON or YAML configuration file. + /// + /// Stored as a partial hierarchical data structure. The values can be maps + /// in which subsequent parts of a key after a `.` can be resolved. + final Map _file; + + /// If provided, used to resolve paths within [_file]. + @override + final Uri? baseUri; + + FileSource(this._file, this.baseUri); + + @override + String? optionalString(String key) => optionalValueOf(key); + + @override + List? stringList( + String key, { + String? splitPattern, + }) { + assert(splitPattern == null); + return optionalValueOf>(key)?.cast(); + } + + @override + bool? optionalBool(String key) => optionalValueOf(key); + + @override + int? optionalInt(String key) => optionalValueOf(key); + + @override + double? optionalDouble(String key) => optionalValueOf(key); + + @override + T? optionalValueOf(String key) { + Object? cursor = _file; + var current = ''; + for (final keyPart in key.split('.')) { + if (cursor == null) { + return null; + } + if (cursor is! Map) { + throw FormatException( + "Unexpected value '$cursor' for key '$current' in config file. " + 'Expected a Map.'); + } else { + cursor = cursor[keyPart]; + } + current += '.$keyPart'; + } + if (cursor is! T?) { + throw FormatException( + "Unexpected value '$cursor' for key '$current' in config file. " + 'Expected a $T.'); + } + return cursor; + } + + @override + String toString() => 'FileSource(file: $_file, fileUri: $baseUri)'; +} diff --git a/pkgs/cli_config/lib/src/source.dart b/pkgs/cli_config/lib/src/source.dart new file mode 100644 index 000000000..5ad1fdbb0 --- /dev/null +++ b/pkgs/cli_config/lib/src/source.dart @@ -0,0 +1,65 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:io'; + +abstract class Source { + /// If provided, the uri used for resolving paths. + Uri? get baseUri; + + /// Lookup a nullable string value. + String? optionalString(String key); + + /// Lookup a nullable string list. + /// + /// If provided, [splitPattern] splits config. + List? stringList( + String key, { + String? splitPattern, + }); + + /// Lookup an optional boolean value. + bool? optionalBool(String key); + + /// Lookup an optional int value. + int? optionalInt(String key); + + /// Lookup an optional int value. + double? optionalDouble(String key); + + /// Lookup an optional value of type [T]. + /// + /// Does not support specialized options such as `splitPattern`. One must + /// use the specialized methods such as [stringList] for that. + /// + /// Returns `null` if the source cannot provide a value of type [T]. + T? optionalValueOf(String key) { + if (T == bool) { + return optionalBool(key) as T?; + } + if (T == String) { + return optionalString(key) as T?; + } + if (T == List) { + return stringList(key) as T?; + } + return null; + } + + static void throwIfUnexpectedValue( + String key, T value, Iterable validValues) { + if (!validValues.contains(value)) { + throw FormatException( + "Unexpected value '$value' for key '$key'. Expected one of: " + "${validValues.map((e) => "'$e'").join(', ')}."); + } + } + + static Uri fileSystemPathToUri(String path) { + if (path.endsWith(Platform.pathSeparator)) { + return Uri.directory(path); + } + return Uri.file(path); + } +} diff --git a/pkgs/cli_config/pubspec.yaml b/pkgs/cli_config/pubspec.yaml index e8724b8a8..71be3069e 100644 --- a/pkgs/cli_config/pubspec.yaml +++ b/pkgs/cli_config/pubspec.yaml @@ -12,5 +12,5 @@ dependencies: yaml: ^3.1.1 dev_dependencies: - lints: ^2.0.0 + dart_flutter_team_lints: ^1.0.0 test: ^1.21.0 diff --git a/pkgs/cli_config/test/cli_config_example_test.dart b/pkgs/cli_config/test/cli_config_example_test.dart new file mode 100644 index 000000000..adf71698c --- /dev/null +++ b/pkgs/cli_config/test/cli_config_example_test.dart @@ -0,0 +1,102 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:io'; + +import 'package:test/test.dart'; + +import 'helpers.dart'; + +void main() { + test('resolve command line paths relative to working directory', () async { + await inTempDir((tempUri) async { + final rootUri = Directory.current.uri; + final examplePackageUri = rootUri.resolve('example/'); + const entryPoint = 'bin/cli_config_example.dart'; + const pubSpec = 'pubspec.yaml'; + for (final filename in [entryPoint, pubSpec]) { + final targetUri = tempUri.resolve(filename); + await File.fromUri(targetUri).create(recursive: true); + await File.fromUri(examplePackageUri.resolve(filename)) + .copy(targetUri.path); + } + final pubspecFile = File.fromUri(tempUri.resolve(pubSpec)); + await pubspecFile.writeAsString( + (await pubspecFile.readAsString()) + .replaceAll('path: ../', 'path: ${rootUri.path}'), + ); + + final pubGetResult = await runProcess( + executable: Uri.file(Platform.resolvedExecutable), + arguments: ['pub', 'get'], + workingDirectory: tempUri, + ); + expect(pubGetResult.exitCode, 0); + + { + final commandLinePath = Uri.file('a/b/c/d.ext'); + final result = await runProcess( + executable: Uri.file(Platform.resolvedExecutable), + arguments: [ + tempUri.resolve(entryPoint).path, + '-Dmy_path=${commandLinePath.path}' + ], + workingDirectory: rootUri, + ); + final stdout = (result.stdout as String).trim(); + final resolvedPath = Uri.file(stdout); + expect(resolvedPath, rootUri.resolveUri(commandLinePath)); + } + + { + final commandLinePath = Uri.file('a/b/c/d.ext'); + final result = await runProcess( + executable: Uri.file(Platform.resolvedExecutable), + arguments: [ + tempUri.resolve(entryPoint).path, + '-Dmy_path=${commandLinePath.path}' + ], + workingDirectory: tempUri, + ); + final stdout = (result.stdout as String).trim(); + final resolvedPath = Uri.file(stdout); + expect(resolvedPath, tempUri.resolveUri(commandLinePath)); + } + + final pathInFile = Uri.file('a/b/c/d.ext'); + final configUri = tempUri.resolve('config.yaml'); + await File.fromUri(configUri).writeAsString(''' +my_path: ${pathInFile.path} +'''); + + { + final result = await runProcess( + executable: Uri.file(Platform.resolvedExecutable), + arguments: [ + tempUri.resolve(entryPoint).path, + '--config=${configUri.path}' + ], + workingDirectory: tempUri, + ); + final stdout = (result.stdout as String).trim(); + final resolvedPath = Uri.file(stdout); + expect(resolvedPath, tempUri.resolveUri(pathInFile)); + } + + { + final result = await runProcess( + executable: Uri.file(Platform.resolvedExecutable), + arguments: [ + tempUri.resolve(entryPoint).path, + '--config=${configUri.path}' + ], + workingDirectory: rootUri, + ); + final stdout = (result.stdout as String).trim(); + final resolvedPath = Uri.file(stdout); + expect(resolvedPath, tempUri.resolveUri(pathInFile)); + } + }); + }); +} diff --git a/pkgs/cli_config/test/cli_config_test.dart b/pkgs/cli_config/test/cli_config_test.dart index c1967a07d..a469b19a0 100644 --- a/pkgs/cli_config/test/cli_config_test.dart +++ b/pkgs/cli_config/test/cli_config_test.dart @@ -2,11 +2,460 @@ // for details. 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 'dart:io'; + import 'package:cli_config/cli_config.dart'; import 'package:test/test.dart'; +import 'helpers.dart'; + void main() { - test('dummy test', () { - expect(add(21, 21), 42); + test('stringList', () { + const path1 = 'path/in/cli_arguments/'; + const path2 = 'path/in/cli_arguments_2/'; + const path3 = 'path/in/environment/'; + const path4 = 'path/in/environment_2/'; + const path5 = 'path/in/config_file/'; + const path6 = 'path/in/config_file_2/'; + final config = Config.fromConfigFileContents( + commandLineDefines: [ + 'build.out_dir=$path1', + 'build.out_dir=$path2', + ], + environment: { + 'BUILD__OUT_DIR': '$path3:$path4', + }, + fileContents: jsonEncode( + { + 'build': { + 'out_dir': [ + path5, + path6, + ], + } + }, + ), + ); + + { + final result = config.stringList( + 'build.out_dir', + combineAllConfigs: true, + splitEnvironmentPattern: ':', + ); + expect(result, [path1, path2, path3, path4, path5, path6]); + } + + { + final result = config.stringList( + 'build.out_dir', + combineAllConfigs: false, + splitEnvironmentPattern: ':', + ); + expect(result, [path1, path2]); + } + }); + + test('optionalString cli precedence', () { + const path1 = 'path/in/cli_arguments/'; + const path2 = 'path/in/environment/'; + const path3 = 'path/in/config_file/'; + final config = Config.fromConfigFileContents( + commandLineDefines: [ + 'build.out_dir=$path1', + ], + environment: { + 'BUILD__OUT_DIR': path2, + }, + fileContents: jsonEncode( + { + 'build': { + 'out_dir': path3, + } + }, + ), + ); + + final result = config.optionalString( + 'build.out_dir', + ); + expect(result, path1); + }); + + test('optionalString environment precedence', () { + const path2 = 'path/in/environment/'; + const path3 = 'path/in/config_file/'; + final config = Config.fromConfigFileContents( + commandLineDefines: [], + environment: { + 'BUILD__OUT_DIR': path2, + }, + fileContents: jsonEncode( + { + 'build': { + 'out_dir': path3, + } + }, + ), + ); + + final result = config.optionalString( + 'build.out_dir', + ); + expect(result, path2); + }); + + test('optionalString config file', () { + const path3 = 'path/in/config_file/'; + final config = Config.fromConfigFileContents( + commandLineDefines: [], + environment: {}, + fileContents: jsonEncode( + { + 'build': { + 'out_dir': path3, + } + }, + ), + ); + + final result = config.optionalString( + 'build.out_dir', + ); + expect(result, path3); + }); + + test('optionalBool define', () { + final config = Config( + commandLineDefines: ['my_bool=true'], + ); + + expect(config.optionalBool('my_bool'), true); + }); + + test('optionalBool environment', () { + final config = Config( + environment: { + 'MY_BOOL': 'true', + }, + ); + + expect(config.optionalBool('my_bool'), true); + }); + + test('optionalBool file', () { + final config = Config.fromConfigFileContents( + fileContents: jsonEncode( + {'my_bool': true}, + ), + ); + + expect(config.optionalBool('my_bool'), true); + }); + + test('Read file and parse CLI args', () async { + final temp = await Directory.systemTemp.createTemp(); + final configFile = File.fromUri(temp.uri.resolve('config.yaml')); + await configFile.writeAsString(jsonEncode( + { + 'build': { + 'out_dir': 'path/in/config_file/', + } + }, + )); + final config = await Config.fromArgs( + args: [ + '--config', + configFile.path, + '-Dbuild.out_dir=path/in/cli_arguments/', + ], + environment: { + 'BUILD__OUT_DIR': 'path/in/environment', + }, + ); + + final result = config.optionalString('build.out_dir'); + expect(result, 'path/in/cli_arguments/'); + }); + + test('Resolve config file path relative to config file', () async { + final temp = await Directory.systemTemp.createTemp(); + final tempUri = temp.uri; + final configUri = tempUri.resolve('config.yaml'); + final configFile = File.fromUri(configUri); + const relativePath = 'path/in/config_file/'; + final resolvedPath = configUri.resolve(relativePath); + + await configFile.writeAsString(jsonEncode( + { + 'build': { + 'out_dir': relativePath, + } + }, + )); + final config = await Config.fromArgs( + args: [ + '--config', + configFile.path, + ], + ); + + final result = config.optionalPath('build.out_dir'); + expect(result!.path, resolvedPath.path); + }); + + test('provide pre-parsed config', () { + const path3 = 'path/in/config_file/'; + final config = Config( + commandLineDefines: [], + environment: {}, + fileParsed: { + 'build': { + 'out_dir': path3, + } + }, + ); + + final result = config.optionalString('build.out_dir'); + expect(result, path3); + }); + + test('path exists', () async { + await inTempDir((tempUri) async { + final tempFileUri = tempUri.resolve('file.ext'); + await File.fromUri(tempFileUri).create(); + final nonExistUri = tempUri.resolve('foo.ext'); + final config = Config( + commandLineDefines: [], + environment: {}, + fileParsed: { + 'build': { + 'out_dir': tempUri.path, + 'file': tempFileUri.path, + 'non_exist': nonExistUri.path + } + }, + ); + + final result = config.optionalPath('build.out_dir', mustExist: true); + expect(result, tempUri); + final result2 = config.optionalPath('build.file', mustExist: true); + expect(result2, tempFileUri); + expect( + () => config.optionalPath('build.non_exist', mustExist: true), + throwsFormatException, + ); + }); + }); + + test('wrong CLI key format', () { + expect( + () => Config(commandLineDefines: ['CAPITALIZED=value']), + throwsFormatException, + ); + }); + + test('CLI two values when expecting one', () { + final config = Config(commandLineDefines: ['key=value', 'key=value2']); + expect( + () => config.string('key'), + throwsFormatException, + ); + }); + + test('CLI split stringlist', () { + final config = Config(commandLineDefines: ['key=value;value2']); + final value = config.stringList('key', splitCliPattern: ';'); + expect(value, ['value', 'value2']); + }); + + test('CLI path', () { + final uri = Uri.file('some/path.ext'); + final config = Config(commandLineDefines: ['key=${uri.path}']); + final value = config.optionalPath('key'); + expect(value, uri); + }); + + test('CLI path list', () { + final uri = Uri.file('some/path.ext'); + final uri2 = Uri.file('some/directory/'); + final config = Config(commandLineDefines: ['key=${uri.path}:${uri2.path}']); + final value = config.optionalPathList('key', splitCliPattern: ':'); + expect(value, [uri, uri2]); + }); + + test('toString', () { + final config = Config( + commandLineDefines: ['key=foo'], + environment: {'key': 'bar'}, + fileParsed: {'key': 'baz'}, + ); + config.toString(); + }); + + test('Missing nonullable throws FormatException', () { + final config = Config.fromConfigFileContents(); + expect(() => config.bool('key'), throwsFormatException); + expect(() => config.string('key'), throwsFormatException); + expect(() => config.path('key'), throwsFormatException); + }); + + test('string not validValue throws FormatException', () { + final config = Config(environment: {'foo': 'bar'}); + expect( + () => config.string('foo', validValues: ['not_bar']), + throwsFormatException, + ); + }); + + test('valueOf file source', () { + final config = Config(fileParsed: { + 'key': {'some': 'map'} + }); + final value = config.valueOf>('key'); + expect(value, {'some': 'map'}); + }); + + test('valueOf command line source', () { + final config = Config(commandLineDefines: [ + 'string_key=value', + 'bool_key=true', + 'string_list_key=value1', + 'string_list_key=value2', + ]); + expect(config.valueOf('string_key'), 'value'); + expect(config.valueOf('bool_key'), true); + expect( + config.valueOf>('string_list_key'), + ['value1', 'value2'], + ); + }); + + test('environment split stringlist', () { + final config = Config(environment: {'key': 'value;value2'}); + final value = config.stringList('key', splitEnvironmentPattern: ';'); + expect(value, ['value', 'value2']); + }); + + test('environment non split stringlist', () { + final config = Config(environment: {'key': 'value'}); + final value = config.stringList('key'); + expect(value, ['value']); + }); + + test('environment path', () { + final uri = Uri.file('some/path.ext'); + final config = Config(environment: {'key': uri.path}); + final value = config.optionalPath('key'); + expect(value, uri); + }); + + test('environment path list', () { + final uri = Uri.file('some/path.ext'); + final uri2 = Uri.file('some/directory/'); + final config = Config(environment: {'key': '${uri.path}:${uri2.path}'}); + final value = config.optionalPathList('key', splitEnvironmentPattern: ':'); + expect(value, [uri, uri2]); + }); + + test('Unexpected config file contents', () { + expect(() => Config.fromConfigFileContents(fileContents: 'asdf'), + throwsFormatException); + expect(() => Config.fromConfigFileContents(fileContents: "['asdf']"), + throwsFormatException); + expect( + () => Config.fromConfigFileContents(fileContents: '''foo: + bar: + WRONGKEY: + 1 +'''), + throwsFormatException, + ); + }); + + test('file config try to access object as wrong type', () { + final config = Config.fromConfigFileContents(fileContents: '''foo: + bar: + true +'''); + expect(config.bool('foo.bar'), true); + expect(() => config.bool('foo.bar.baz'), throwsFormatException); + expect(() => config.string('foo.bar'), throwsFormatException); + }); + + test('file config path list unresolved', () { + final uri = Uri.file('some/path.ext'); + final uri2 = Uri.file('some/directory/'); + final config = Config(fileParsed: { + 'key': [uri.path, uri2.path] + }); + final value = config.optionalPathList('key', resolveUri: false); + expect(value, [uri, uri2]); + }); + + test('file config path list resolved', () { + final configUri = Uri.file('path/to/config.json'); + final uri = Uri.file('some/path.ext'); + final uri2 = Uri.file('some/directory/'); + final config = Config( + fileSourceUri: configUri, + fileParsed: { + 'key': [uri.path, uri2.path] + }, + ); + final value = config.optionalPathList('key', resolveUri: true); + expect(value, [configUri.resolveUri(uri), configUri.resolveUri(uri2)]); + }); + + test('resolveUri in working directory', () { + final systemTemp = Directory.systemTemp.uri; + final tempUri = systemTemp.resolve('x/y/z/'); + + final relativePath = Uri.file('a/b/c/d.ext'); + final absolutePath = tempUri.resolveUri(relativePath); + final config = Config( + commandLineDefines: ['path=${relativePath.path}'], + workingDirectory: tempUri, + ); + + expect(config.optionalPath('path', mustExist: false, resolveUri: true), + absolutePath); + }); + + test('ints', () { + final config = Config( + commandLineDefines: ['cl=1', 'not_parsable=asdf'], + environment: { + 'env': '2', + 'not_parsable2': 'asfd', + }, + fileParsed: {'file': 3}, + ); + + expect(config.int('cl'), 1); + expect(config.optionalInt('env'), 2); + expect(config.optionalInt('file'), 3); + expect(config.optionalInt('nothing'), null); + expect(() => config.optionalInt('not_parsable'), throwsFormatException); + expect(() => config.optionalInt('not_parsable2'), throwsFormatException); + }); + + test('doubles', () { + final config = Config( + commandLineDefines: ['cl=1.1', 'not_parsable=asdf'], + environment: { + 'env': '2.2', + 'not_parsable2': 'asfd', + }, + fileParsed: {'file': 3.3}, + ); + + expect(config.double('cl'), 1.1); + expect(config.optionalDouble('env'), 2.2); + expect(config.optionalDouble('file'), 3.3); + expect(config.optionalDouble('nothing'), null); + expect(() => config.optionalDouble('not_parsable'), throwsFormatException); + expect(() => config.optionalDouble('not_parsable2'), throwsFormatException); }); } diff --git a/pkgs/cli_config/test/helpers.dart b/pkgs/cli_config/test/helpers.dart new file mode 100644 index 000000000..9f2905205 --- /dev/null +++ b/pkgs/cli_config/test/helpers.dart @@ -0,0 +1,40 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. 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:io'; + +const keepTempKey = 'KEEP_TEMPORARY_DIRECTORIES'; + +Future inTempDir( + Future Function(Uri tempUri) fun, { + String? prefix, +}) async { + final tempDir = await Directory.systemTemp.createTemp(prefix); + try { + await fun(tempDir.uri); + } finally { + if (!Platform.environment.containsKey(keepTempKey) || + Platform.environment[keepTempKey]!.isEmpty) { + await tempDir.delete(recursive: true); + } + } +} + +Future runProcess({ + required Uri executable, + List arguments = const [], + required Uri workingDirectory, +}) async { + final result = await Process.run( + executable.path, + arguments, + workingDirectory: workingDirectory.path, + ); + if (result.exitCode != 0) { + print(result.stdout); + print(result.stderr); + print(result.exitCode); + } + return result; +}