From 3db62912330c8737bb8452811dc0da59bd9f3f3f Mon Sep 17 00:00:00 2001 From: Christer Date: Wed, 26 Aug 2026 17:33:26 +0200 Subject: [PATCH 1/3] feat(config): Support multiple configuration sources for an option Fixes #127 --- packages/config/README.md | 19 ++- .../config/lib/src/config/config_parser.dart | 7 + .../config/lib/src/config/configuration.dart | 2 +- .../lib/src/config/file_system_options.dart | 2 + .../config/lib/src/config/option_types.dart | 9 ++ packages/config/lib/src/config/options.dart | 47 ++++++- .../test/config/config_source_test.dart | 113 ++++++++++++++++ .../test/config/configuration_test.dart | 122 ++++++++++++++++++ 8 files changed, 312 insertions(+), 9 deletions(-) diff --git a/packages/config/README.md b/packages/config/README.md index 409843a..e4a3b8e 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -200,7 +200,8 @@ The configuration library resolves each option value in a specific order, with e 3. **Configuration files** - Values from configuration files (e.g. YAML/JSON) - - Lookup key is specified using `configKey` + - Lookup key is specified using `configKey` or `configKeys` + (multiple keys are tried in order of precedence) 4. **Custom value providers** - Values from custom callbacks @@ -349,6 +350,20 @@ YAML file and the JSON pointer syntax is used. )); ``` +An option can look up values from multiple configuration sources by specifying +`configKeys` in order of precedence. The first key that yields a value is used: + +```dart + dir(DirOption( + argName: 'dir', + configKeys: ['local:/dir', 'settings:/my_dir'], + helpText: 'the local directory', + )), +``` + +`configKey` can still be used for a single source. If both are specified, +`configKey` is tried first, then each key in `configKeys`. + See the full example in [example/config_file_example.dart](example/config_file_example.dart). ### Multiple configuration sources @@ -356,7 +371,7 @@ See the full example in [example/config_file_example.dart](example/config_file_e By using the `MultiDomainConfigBroker`, configuration sources from multiple providers can be used, called configuration *domains*. -They are distinguished by the format used in the configKey, +They are distinguished by the format used in the config key, which needs to specify a so-called *qualified key* - qualifying the key with the domain it is found in. diff --git a/packages/config/lib/src/config/config_parser.dart b/packages/config/lib/src/config/config_parser.dart index 0c34102..84323b6 100644 --- a/packages/config/lib/src/config/config_parser.dart +++ b/packages/config/lib/src/config/config_parser.dart @@ -57,6 +57,7 @@ class ConfigParser implements ArgParser { final List aliases = const [], final String? envName, final String? configKey, + final List? configKeys, final bool? Function(Configuration cfg)? fromCustom, final bool Function()? fromDefault, final String? valueHelp, @@ -71,6 +72,7 @@ class ConfigParser implements ArgParser { argAbbrev: abbr, envName: envName, configKey: configKey, + configKeys: configKeys, fromCustom: fromCustom, fromDefault: fromDefault, defaultsTo: defaultsTo, @@ -106,6 +108,7 @@ class ConfigParser implements ArgParser { final int? argPos, final String? envName, final String? configKey, + final List? configKeys, final String? Function(Configuration cfg)? fromCustom, final String Function()? fromDefault, final OptionGroup? group, @@ -119,6 +122,7 @@ class ConfigParser implements ArgParser { argPos: argPos, envName: envName, configKey: configKey, + configKeys: configKeys, fromCustom: fromCustom, fromDefault: fromDefault, defaultsTo: defaultsTo, @@ -153,6 +157,7 @@ class ConfigParser implements ArgParser { final List aliases = const [], final String? envName, final String? configKey, + final List? configKeys, final List? Function(Configuration cfg)? fromCustom, final List Function()? fromDefault, final OptionGroup? group, @@ -167,6 +172,7 @@ class ConfigParser implements ArgParser { argAbbrev: abbr, envName: envName, configKey: configKey, + configKeys: configKeys, fromCustom: fromCustom, fromDefault: fromDefault, defaultsTo: defaultsTo?.toList(), @@ -188,6 +194,7 @@ class ConfigParser implements ArgParser { argAbbrev: abbr, envName: envName, configKey: configKey, + configKeys: configKeys, fromCustom: fromCustom, fromDefault: fromDefault, defaultsTo: defaultsTo?.toList(), diff --git a/packages/config/lib/src/config/configuration.dart b/packages/config/lib/src/config/configuration.dart index e1d9f58..dc00deb 100644 --- a/packages/config/lib/src/config/configuration.dart +++ b/packages/config/lib/src/config/configuration.dart @@ -194,7 +194,7 @@ class Configuration { (argName != null && o.option.argName == argName) || (argPos != null && o.option.argPos == argPos) || (envName != null && o.option.envName == envName) || - (configKey != null && o.option.configKey == configKey); + (configKey != null && o.option.allConfigKeys.contains(configKey)); }); } diff --git a/packages/config/lib/src/config/file_system_options.dart b/packages/config/lib/src/config/file_system_options.dart index 22a35c4..58585b8 100644 --- a/packages/config/lib/src/config/file_system_options.dart +++ b/packages/config/lib/src/config/file_system_options.dart @@ -40,6 +40,7 @@ class DirOption extends ConfigOptionBase { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -109,6 +110,7 @@ class FileOption extends ConfigOptionBase { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, diff --git a/packages/config/lib/src/config/option_types.dart b/packages/config/lib/src/config/option_types.dart index 7dabdc6..ddad83d 100644 --- a/packages/config/lib/src/config/option_types.dart +++ b/packages/config/lib/src/config/option_types.dart @@ -21,6 +21,7 @@ class StringOption extends ConfigOptionBase { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -46,6 +47,7 @@ class MultiStringOption extends MultiOption { super.argAbbrev, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -68,6 +70,7 @@ class MultiStringOption extends MultiOption { super.argAbbrev, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -134,6 +137,7 @@ class EnumOption extends ConfigOptionBase { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -173,6 +177,7 @@ class ComparableValueOption extends ConfigOptionBase { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -230,6 +235,7 @@ class IntOption extends ComparableValueOption { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -279,6 +285,7 @@ class DateTimeOption extends ComparableValueOption { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -397,6 +404,7 @@ class DurationOption extends ComparableValueOption { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -422,6 +430,7 @@ class DurationOption extends ComparableValueOption { super.argPos, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index a39ab9c..1adcb8d 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -108,7 +108,8 @@ abstract class ValueParser { /// 1. Named command line argument /// 2. Positional command line argument /// 3. Environment variable -/// 4. By lookup key in configuration sources (such as files) +/// 4. By lookup key in configuration sources (such as files). +/// If multiple keys are specified, they are tried in order. /// 5. A custom callback function /// 6. Default value /// @@ -185,7 +186,19 @@ abstract class ConfigOptionBase implements OptionDefinition { final String? argAbbrev; final int? argPos; final String? envName; + + /// A single configuration source lookup key. + /// + /// For multiple sources, use [configKeys] instead, or specify both: + /// [configKey] is tried first, then each key in [configKeys]. final String? configKey; + + /// Configuration source lookup keys, in order of precedence. + /// + /// Keys are tried in order until a value is found. + /// If [configKey] is also set, it is tried first. + final List? configKeys; + final V? Function(Configuration cfg)? fromCustom; final V Function()? fromDefault; final V? defaultsTo; @@ -208,6 +221,7 @@ abstract class ConfigOptionBase implements OptionDefinition { this.argPos, this.envName, this.configKey, + this.configKeys, this.fromCustom, this.fromDefault, this.defaultsTo, @@ -221,6 +235,15 @@ abstract class ConfigOptionBase implements OptionDefinition { this.hide = false, }); + /// Configuration lookup keys in order of precedence. + /// + /// Includes [configKey] if set, followed by [configKeys] if set. + List get allConfigKeys { + final key = configKey; + final keys = configKeys; + return [if (key != null) key, ...?keys]; + } + V? defaultValue() { final df = fromDefault; return (df != null ? df() : defaultsTo); @@ -310,8 +333,12 @@ abstract class ConfigOptionBase implements OptionDefinition { if (argPos != null) { return 'positional argument $argPos'; } - if (configKey != null) { - return 'configuration key `$configKey`'; + final keys = allConfigKeys; + if (keys.isNotEmpty) { + if (keys.length == 1) { + return 'configuration key `${keys.single}`'; + } + return 'configuration keys ${keys.map((final k) => '`$k`').join(', ')}'; } return _unnamedOptionString; } @@ -439,9 +466,15 @@ abstract class ConfigOptionBase implements OptionDefinition { final Configuration cfg, final ConfigurationBroker? configBroker, ) { - final key = configKey; - if (configBroker == null || key == null) return null; - final value = configBroker.valueOrNull(key, cfg); + if (configBroker == null) return null; + final keys = allConfigKeys; + if (keys.isEmpty) return null; + + Object? value; + for (final key in keys) { + value = configBroker.valueOrNull(key, cfg); + if (value != null) break; + } if (value == null) return null; if (value is String) { return OptionResolutionImpl( @@ -536,6 +569,7 @@ class FlagOption extends ConfigOptionBase { super.argAbbrev, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, @@ -628,6 +662,7 @@ class MultiOption extends ConfigOptionBase> { super.argAbbrev, super.envName, super.configKey, + super.configKeys, super.fromCustom, super.fromDefault, super.defaultsTo, diff --git a/packages/config/test/config/config_source_test.dart b/packages/config/test/config/config_source_test.dart index 06be532..ff0b950 100644 --- a/packages/config/test/config/config_source_test.dart +++ b/packages/config/test/config/config_source_test.dart @@ -255,4 +255,117 @@ projectId:123 ); }); }); + + group( + 'Given a MultiDomainConfigBroker with an option that has multiple configKeys', + () { + const yamlContentOpt = StringOption( + argName: 'yaml-content', + ); + const jsonContentOpt = StringOption( + argName: 'json-content', + ); + const projectIdOpt = StringOption( + configKeys: [ + 'yamlOption:/project/projectId', + 'jsonOption:/project/projectId', + ], + ); + final options = [ + yamlContentOpt, + jsonContentOpt, + projectIdOpt, + ]; + + late ConfigurationBroker configSource; + + setUp(() { + configSource = MultiDomainConfigBroker.prefix({ + 'yamlOption': OptionContentConfigProvider( + contentOption: yamlContentOpt, + format: ConfigEncoding.yaml, + ), + 'jsonOption': OptionContentConfigProvider( + contentOption: jsonContentOpt, + format: ConfigEncoding.json, + ), + }); + }); + + test( + 'when both domains have a value ' + 'then the first config key is used', () async { + final config = Configuration.resolveNoExcept( + options: options, + args: [ + '--yaml-content', + ''' +project: + projectId: 'yaml-id' +''', + '--json-content', + ''' +{ + "project": { + "projectId": "json-id" + } +} +''', + ], + configBroker: configSource, + ); + + expect(config.errors, isEmpty); + expect(config.optionalValue(projectIdOpt), equals('yaml-id')); + }); + + test( + 'when only the second domain has a value ' + 'then the second config key is used', () async { + final config = Configuration.resolveNoExcept( + options: options, + args: [ + '--json-content', + ''' +{ + "project": { + "projectId": "json-id" + } +} +''', + ], + configBroker: configSource, + ); + + expect(config.errors, isEmpty); + expect(config.optionalValue(projectIdOpt), equals('json-id')); + }); + + test( + 'when neither domain has a value ' + 'then the option has no value', () async { + final config = Configuration.resolveNoExcept( + options: options, + args: [ + '--yaml-content', + ''' +project: + name: demo +''', + '--json-content', + ''' +{ + "project": { + "name": "demo" + } +} +''', + ], + configBroker: configSource, + ); + + expect(config.errors, isEmpty); + expect(config.optionalValue(projectIdOpt), isNull); + }); + }); } diff --git a/packages/config/test/config/configuration_test.dart b/packages/config/test/config/configuration_test.dart index 3611f10..db32801 100644 --- a/packages/config/test/config/configuration_test.dart +++ b/packages/config/test/config/configuration_test.dart @@ -267,6 +267,128 @@ void main() async { }); }); + group('Given a configuration option with multiple configKeys', () { + const projectIdOpt = StringOption( + configKeys: ['primary:/projectId', 'fallback:/projectId'], + ); + + test('then lookupConfigKeys lists the keys in order', () async { + expect( + projectIdOpt.allConfigKeys, + equals(['primary:/projectId', 'fallback:/projectId']), + ); + }); + + test('then qualifiedString mentions all keys', () async { + expect( + projectIdOpt.qualifiedString(), + equals( + 'configuration keys `primary:/projectId`, `fallback:/projectId`', + ), + ); + }); + + test('when both keys have values then the first key is used', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({ + 'primary:/projectId': 'primaryValue', + 'fallback:/projectId': 'fallbackValue', + }), + ); + expect(config.optionalValue(projectIdOpt), equals('primaryValue')); + }); + + test( + 'when only the second key has a value ' + 'then the second key is used', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({ + 'fallback:/projectId': 'fallbackValue', + }), + ); + expect(config.optionalValue(projectIdOpt), equals('fallbackValue')); + }); + + test('when neither key has a value then the option has no value', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({}), + ); + expect(config.optionalValue(projectIdOpt), isNull); + }); + + test( + 'when finding the option via the first config key ' + 'then it succeeds', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({ + 'primary:/projectId': 'primaryValue', + }), + ); + expect( + config.findValueOf(configKey: 'primary:/projectId'), + equals('primaryValue'), + ); + }); + + test( + 'when finding the option via the second config key ' + 'then it succeeds', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({ + 'fallback:/projectId': 'fallbackValue', + }), + ); + expect( + config.findValueOf(configKey: 'fallback:/projectId'), + equals('fallbackValue'), + ); + }); + }); + + group('Given a configuration option with both configKey and configKeys', () { + const projectIdOpt = StringOption( + configKey: 'primary:/projectId', + configKeys: ['fallback:/projectId'], + ); + + test('then lookupConfigKeys starts with configKey', () async { + expect( + projectIdOpt.allConfigKeys, + equals(['primary:/projectId', 'fallback:/projectId']), + ); + }); + + test( + 'when configKey has a value ' + 'then it takes precedence over configKeys', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({ + 'primary:/projectId': 'primaryValue', + 'fallback:/projectId': 'fallbackValue', + }), + ); + expect(config.optionalValue(projectIdOpt), equals('primaryValue')); + }); + + test( + 'when configKey is missing ' + 'then configKeys are tried in order', () async { + final config = Configuration.resolveNoExcept( + options: [projectIdOpt], + configBroker: _TestConfigBroker({ + 'fallback:/projectId': 'fallbackValue', + }), + ); + expect(config.optionalValue(projectIdOpt), equals('fallbackValue')); + }); + }); + group('Given a configuration flag option', () { const verboseFlag = FlagOption( argName: 'verbose', From facbd274ade37fd02d7c421cd464407eb21b2987 Mon Sep 17 00:00:00 2001 From: Christer Date: Wed, 26 Aug 2026 17:35:20 +0200 Subject: [PATCH 2/3] chore(config): Publish config 0.9.1 --- packages/config/CHANGELOG.md | 4 ++++ packages/config/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index b2dce33..d6058bd 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.1 + + - **FEAT**(config): Support multiple configuration sources for an option (#127). + ## 0.9.0 - Bumped minimum Dart SDK version to 3.6 diff --git a/packages/config/pubspec.yaml b/packages/config/pubspec.yaml index 2858afa..dc0e160 100644 --- a/packages/config/pubspec.yaml +++ b/packages/config/pubspec.yaml @@ -1,5 +1,5 @@ name: config -version: 0.9.0 +version: 0.9.1 description: A package for parsing command-line arguments and configuration files. repository: https://github.com/serverpod/cli_tools issue_tracker: https://github.com/serverpod/cli_tools/issues From eb9d83d73b0b341b521c1f80ee066269fb9e1dfc Mon Sep 17 00:00:00 2001 From: Christer Date: Wed, 26 Aug 2026 20:56:00 +0200 Subject: [PATCH 3/3] chore(config): Typo in README --- packages/config/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/config/README.md b/packages/config/README.md index e4a3b8e..4837be9 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -351,7 +351,8 @@ YAML file and the JSON pointer syntax is used. ``` An option can look up values from multiple configuration sources by specifying -`configKeys` in order of precedence. The first key that yields a value is used: +`configKeys` in order of precedence. +The first key that yields a value is used: ```dart dir(DirOption(