unfig 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3ff007a57b57a0e32f859b15cbdfb268353a34448499426561c04b4c2f0ca085
4
+ data.tar.gz: bc50b16f98ab7f74c8cf69fbda12f9af7b975107edc678fb41e383999dbf6b71
5
+ SHA512:
6
+ metadata.gz: 12c22f5cfc6d564b46d84482bd0272680b41fa1cd461386009afb51833938f98d5305ead5195cd72d9f1f381843d489ef55834270f46d6a74bf6fd6dc29abdbe
7
+ data.tar.gz: f2230b52d0a0c6f4b08e98f03ee9733a25532b16c705704d38b4618fd95d6d794d1a3a812a7065570435f48e41d9e67c403a2430254136361c931a52a4af9ad6
@@ -0,0 +1,37 @@
1
+ name: Linters
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ Linting:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+
11
+ - name: Set up ruby
12
+ uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: 3.3
15
+
16
+ - name: Cache gems
17
+ uses: actions/cache@v3
18
+ with:
19
+ path: vendor/bundle
20
+ key: ${{ runner.os }}-linters-${{ hashFiles('Gemfile.log') }}
21
+ restore-keys:
22
+ ${{ runner.os }}-linters-
23
+
24
+ - name: Install gems
25
+ run: bundle install --jobs 4 --retry 3
26
+
27
+ - name: Run StandardRB
28
+ run: bundle exec standardrb
29
+
30
+ - name: Run rubocop (complexity checks)
31
+ run: bundle exec rubocop --parallel
32
+
33
+ - name: Run markdownlint
34
+ run: bundle exec mdl .
35
+
36
+
37
+
@@ -0,0 +1,33 @@
1
+ name: RSpec
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ RSpec:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ ruby-version: ['3.2', '3.3', '3.4', 'head']
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: ${{ matrix.ruby-version }}
20
+
21
+ - name: Cache gems
22
+ uses: actions/cache@v3
23
+ with:
24
+ path: vendor/bundle
25
+ key: ${{ runner.os }}-rspec-${{ matrix.ruby-version }}-${{ hashFiles('Gemfile.lock') }}
26
+ restore-keys:
27
+ ${{ runner.os }}-rspec-${{ matrix.ruby-version }}-
28
+
29
+ - name: Install gems
30
+ run: bundle install --jobs 4 --retry 3
31
+
32
+ - name: Run RSpec
33
+ run: SIMPLECOV=true bundle exec rspec
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ .ruby-version
2
+ .ruby-gemset
3
+ Gemfile.lock
4
+ *.gem
5
+ coverage/
6
+ tmp/*
7
+ .DS_Store
data/.mdl_rules.rb ADDED
@@ -0,0 +1,2 @@
1
+ all
2
+ rule "MD013", ignore_code_blocks: true
data/.mdlrc ADDED
@@ -0,0 +1,2 @@
1
+ style File.expand_path("../.mdl_rules.rb", __FILE__)
2
+ git_recurse true
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,21 @@
1
+ ---
2
+ AllCops:
3
+ SuggestExtensions: false
4
+ DisabledByDefault: true
5
+
6
+ Metrics/AbcSize:
7
+ Max: 15
8
+ Metrics/CyclomaticComplexity:
9
+ Max: 8
10
+ Metrics/PerceivedComplexity:
11
+ Max: 7
12
+
13
+ Metrics/ClassLength:
14
+ CountComments: false
15
+ Max: 150
16
+ Metrics/MethodLength:
17
+ CountComments: false
18
+ Max: 15
19
+ Metrics/ParameterLists:
20
+ Max: 5
21
+ CountKeywordArgs: true
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ ---
2
+ format: progress
3
+ ruby_version: 3.2
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://gem.coop"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Unfig
2
+
3
+ We've written code that merges/cascades some combination of defaults, config-files,
4
+ environment-variables, and cli-supplied parameters *too many times*. In one of my
5
+ gems, _half of the code_ is concerned with managing those various ways to supply
6
+ its 20+ control parameters. That's ridiculous.
7
+
8
+ The intent of `unfig` is to consolidate all of that capability, so that we
9
+ just need to specify what configuration exists (and optionally how it can be
10
+ supplied) in a straightforward way, and stop worrying about it.
11
+
12
+ ## Usage Example (currently tentative)
13
+
14
+ Let's invoke Unfig like this, as an example:
15
+
16
+ ```ruby
17
+ require "unfig"
18
+
19
+ @options = Unfig.load_options(
20
+ config: File.expand_path("~/.mygem.yml"),
21
+ values: {
22
+ verbose: {
23
+ description: "Expose much more information in the logs",
24
+ type: "boolean",
25
+ default: false,
26
+ },
27
+ parallel: {
28
+ description: "Run in parallel instead of serially",
29
+ type: "boolean",
30
+ default: false,
31
+ }
32
+ }
33
+ )
34
+ ```
35
+
36
+ Now I'll have an `@options` object (a Hash, since we didn't specify), and the
37
+ user can supply either of those options on the command-line, in the environment,
38
+ or in a config-file in their home directory.
39
+
40
+ It's more configurable than that of course. At the top-level, it accepts these
41
+ options:
42
+
43
+ * `argv` - Usually this is `ARGV` (the default), but if you want to interact
44
+ with the separately, you could supply `argv: ARGV.dup`, or just an array
45
+ of arguments.
46
+ * `env` - Usually this is `ENV` (the default), but if you want, you can just
47
+ pass an arbitrary Hash(String => String) here instead.
48
+ * `config` - where to search for the (yaml) config file that might specify some
49
+ of these parameters. Optional - if not supplied, no config-file will be used.
50
+ * `banner` - what to say on the command-line when help is invoked (usually a
51
+ usage example). Optional - if not supplied, no banner is present.
52
+ * `format` - what sort of object comes out of the `load` call; one of
53
+ `[:hash, :struct, :openstruct]`. If not supplied, defaults to `:hash`.
54
+ * `params` - the parameter configurations; keys are parameter names (symbol or
55
+ string), and values are parameter configs.
56
+
57
+ And for each parameter, we have these options:
58
+
59
+ * `name` - this is the key in the params hash. It can be a String or Symbol,
60
+ it must start with a letter, and it may contain only letters, numbers, and
61
+ underscores. No more than 64 characters long.
62
+ * `description` - this will be used as the description of the parameter on the
63
+ cli - it is required, must be a non-blank string, and may contain no newlines.
64
+ * `type` - this is the type to cast the supplied values into - it is required,
65
+ and must be one of "boolean", "string", "integer", or "float" (a String).
66
+ * `multi` - this specifies whether the parameter can be "multi-valued" (default
67
+ is false). If true, supplying the flag more than once, or supplying an array
68
+ of values in the config file, or (more awkwardly) setting multiple environment
69
+ variables with numeric suffixes (`FOO_0` through `FOO_9` for an option `foo`)
70
+ will allow you to supply multiple values, and the parameter will return an
71
+ Array (whether you actually do so or not).
72
+ * `enabled` - which configuration methods are supported for the parameter. By
73
+ default all of them will be, but you can for example exclude `verbose` from
74
+ being supplied via the config file if you want to, or only support the _long_
75
+ flag (`--verbose` works but not `-v`).
76
+ * `default` - what value should this parameter have if they don't supply one?
77
+ If you supplied `multi: true`, this is required to be an array of defaults;
78
+ in either case, the _type_ of the default will be validated against the
79
+ supplied `type`.
80
+ * `long`/`short` - these can override the default long/short cli flags. This is
81
+ particularly important for `short`, since two parameters that start with the
82
+ same letter will automatically collide unless it's supplied for one of them.
83
+ The default values are (a) the parameter-name, but with underscores mapped to
84
+ dashes and (b) the first letter of the parameter name.
85
+ * `env` - the name of the environment variable to consult. By default, it's the
86
+ parameter name up-cased, but like.. if your parameter is `user`, you may need
87
+ to look at `MYGEM_USER` instead for example (unless you _want_ the unix USER).
88
+
89
+ The configurations will be loaded from all four sources, in this priority order:
90
+
91
+ * cli flags - if the user supplies these, they are likely intending to override
92
+ default or configured behaviors.
93
+ * environment variables - these are a typical way to configure a machine with
94
+ its intended behavior, though specifying a parameter in a *personal* config
95
+ file _and_ an environment variable is infrequent, sometimes config files are
96
+ committed to a repository, and an individual may want to consistently override
97
+ values supplied in that way.
98
+ * config file - this could be a personal config (in your home directory) or a
99
+ provided config (committed to your rails app's config/ directory, for example).
100
+ * default value - supplied when invoking Unfig in the script.
@@ -0,0 +1,94 @@
1
+ module Unfig
2
+ class ArgvLoader
3
+ def initialize(params:, argv:)
4
+ @params = params
5
+ @argv = argv
6
+ @options = {}
7
+ @stop_early = false
8
+ end
9
+
10
+ def read
11
+ return @_read if defined?(@_read)
12
+ option_parser.parse!(argv)
13
+ @_read = @stop_early ? nil : @options
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :params, :argv
19
+
20
+ def short_enabled?(p) = p.enabled.include?("short")
21
+
22
+ def long_enabled?(p) = p.enabled.include?("long")
23
+
24
+ def enabled?(p) = short_enabled?(p) || long_enabled?(p)
25
+
26
+ def option_parser
27
+ @_option_parser ||= OptionParser.new do |opts|
28
+ opts.banner = params.banner if params.banner
29
+ add_help_option(opts)
30
+
31
+ params.params.each do |p|
32
+ next unless enabled?(p)
33
+
34
+ add_option_for(opts, p)
35
+ end
36
+ end
37
+ end
38
+
39
+ def short_arg(p)
40
+ if p.type == "boolean"
41
+ "-#{p.short}"
42
+ else
43
+ "-#{p.short}#{p.name.upcase}"
44
+ end
45
+ end
46
+
47
+ def long_arg(p)
48
+ if p.type == "boolean"
49
+ "--[no-]#{p.long}"
50
+ else
51
+ "--#{p.long}=#{p.name.upcase}"
52
+ end
53
+ end
54
+
55
+ def type_arg(p)
56
+ case p.type
57
+ when "string" then String
58
+ when "boolean" then TrueClass
59
+ when "integer" then Integer
60
+ when "float" then Numeric
61
+ else
62
+ raise Invalid, "ArgvLoader does not know how to handle type '#{p.type}'"
63
+ end
64
+ end
65
+
66
+ def option_args(p)
67
+ args = []
68
+ args << short_arg(p) if short_enabled?(p)
69
+ args << long_arg(p) if long_enabled?(p)
70
+ args << type_arg(p)
71
+ args << p.description
72
+ end
73
+
74
+ def add_option_for(opts, p)
75
+ opts.on(*option_args(p)) do |value|
76
+ if p.multi?
77
+ @options[p.name] ||= []
78
+ @options[p.name] << value
79
+ elsif @options.key?(p.name)
80
+ raise FlagError, "Cannot supply #{p.name} more than once"
81
+ else
82
+ @options[p.name] = value
83
+ end
84
+ end
85
+ end
86
+
87
+ def add_help_option(opts)
88
+ opts.on_tail("-h", "--help", "Print this help information") do
89
+ warn(opts)
90
+ @stop_early = true
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,25 @@
1
+ module Unfig
2
+ class EnvLoader
3
+ def initialize(params:, env:)
4
+ @params = params
5
+ @env = env
6
+ end
7
+
8
+ def read
9
+ return @_read if defined?(@_read)
10
+
11
+ @_read = {}
12
+ params.params.each do |p|
13
+ next unless p.enabled.include?("env")
14
+
15
+ reader = EnvReader.new(param: p, env: env)
16
+ @_read[p.name] = reader.value if reader.supplied?
17
+ end
18
+ @_read
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :params, :env
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ module Unfig
2
+ class EnvReader
3
+ TRUTHY_STRINGS = %w[true yes on enable allow t y 1 ok okay].to_set.freeze
4
+ FALSEY_STRINGS = %w[false no off disabled disable deny f n 0 nope].to_set.freeze
5
+
6
+ def initialize(param:, env:)
7
+ @param = param
8
+ @env = env
9
+ end
10
+
11
+ def supplied? = multi? ? multi_keys_supplied? : key_supplied?
12
+
13
+ def value
14
+ if multi?
15
+ supplied? ? multi_values : []
16
+ else
17
+ supplied? ? single_value : nil
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :param, :env
24
+
25
+ def multi? = param.multi?
26
+
27
+ def multi_keys = @_multi_keys ||= [param.env] + (0..9).map { |n| "#{param.env}_#{n}" }
28
+
29
+ def supplied_multi_keys = multi_keys.select { |k| env.key?(k) }
30
+
31
+ def multi_keys_supplied?
32
+ return @_multi_keys_supplied if defined?(@_multi_keys_supplied)
33
+
34
+ @_multi_keys_supplied = multi_keys.any? { |key| env.key?(key) }
35
+ end
36
+
37
+ def multi_values = supplied_multi_keys.map { |k| cast_env(k, env.fetch(k)) }
38
+
39
+ def single_value = cast_env(param.env, env.fetch(param.env))
40
+
41
+ def key_supplied? = env.key?(param.env)
42
+
43
+ def cast_env(name, uncast)
44
+ case param.type
45
+ when "string" then uncast
46
+ when "boolean" then cast_to_boolean(name, uncast)
47
+ when "integer" then cast_to_integer(name, uncast)
48
+ when "float" then cast_to_float(name, uncast)
49
+ else
50
+ raise Invalid, "Unfig::EnvLoader does not know how to handle parameter type '#{param.type}'"
51
+ end
52
+ end
53
+
54
+ def cast_to_boolean(name, s)
55
+ return true if TRUTHY_STRINGS.include?(s.strip.downcase)
56
+ return false if FALSEY_STRINGS.include?(s.strip.downcase)
57
+ raise InvalidBooleanText, "ENV['#{name}'] had unexpected content for a boolean: '#{s}'"
58
+ end
59
+
60
+ def cast_to_integer(name, s)
61
+ return s.to_i if /\A-?\d+\z/.match?(s.strip)
62
+
63
+ raise InvalidIntegerText, "ENV['#{name}'] had unexpected content for an integer: '#{s}'"
64
+ end
65
+
66
+ def cast_to_float(name, s)
67
+ return s.to_f if /\A-?\d+(\.\d+)?\z/.match?(s.strip)
68
+
69
+ raise InvalidFloatingPointText, "ENV['#{name}'] had unexpected content for a float: '#{s}'"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,76 @@
1
+ module Unfig
2
+ class FileLoader
3
+ def initialize(params:, path:)
4
+ @params = params
5
+ @path = path
6
+ end
7
+
8
+ def read
9
+ return @_read if defined?(@_read)
10
+
11
+ @_read = {}
12
+ params.params.each do |p|
13
+ next unless p.enabled.include?("file")
14
+ @_read[p.name] = read_for(p) if data.key?(p.name)
15
+ end
16
+ @_read
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :params, :path
22
+
23
+ def text = File.read(path)
24
+
25
+ def data = @_data ||= YAML.safe_load(text)
26
+
27
+ def read_for(p)
28
+ data[p.name].tap do |value|
29
+ p.multi? ? validate_types!(p, value) : validate_type!(p, value)
30
+ end
31
+ end
32
+
33
+ def validate_type!(p, value)
34
+ case p.type
35
+ when "string" then validate_string!(p.name, value)
36
+ when "boolean" then validate_boolean!(p.name, value)
37
+ when "integer" then validate_integer!(p.name, value)
38
+ when "float" then validate_float!(p.name, value)
39
+ else
40
+ raise Invalid, "Unfig::FileLoader does not know how to handle the parameter type '#{p.type}'"
41
+ end
42
+ end
43
+
44
+ def validate_types!(p, value)
45
+ if value.is_a?(Array)
46
+ value.each { |v| validate_type!(p, v) }
47
+ else
48
+ raise(InvalidYamlValue, "FileLoader expected an array for #{p.name}, because it's multi-valued")
49
+ end
50
+ end
51
+
52
+ def validate_string!(name, value)
53
+ return if value.nil? || value.is_a?(String)
54
+
55
+ raise InvalidYamlValue, "Unfig::FileLoader expected a string for #{name}, but got #{value.class}"
56
+ end
57
+
58
+ def validate_boolean!(name, value)
59
+ return if [nil, true, false].include?(value)
60
+
61
+ raise InvalidYamlValue, "Unfig::FileLoader expected a boolean for #{name}, but got #{value.class}"
62
+ end
63
+
64
+ def validate_integer!(name, value)
65
+ return if value.nil? || value.is_a?(Integer)
66
+
67
+ raise InvalidYamlValue, "Unfig::FileLoader expected an integer for #{name}, but got #{value.class}"
68
+ end
69
+
70
+ def validate_float!(name, value)
71
+ return if value.nil? || value.is_a?(Numeric)
72
+
73
+ raise InvalidYamlValue, "Unfig::FileLoader expected a float for #{name}, but got #{value.class}"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,68 @@
1
+ module Unfig
2
+ class Loader
3
+ def initialize(values:, argv: UNSUPPLIED, env: UNSUPPLIED, config: UNSUPPLIED, **options)
4
+ @argv = argv
5
+ @env = env
6
+ @config = config
7
+ @values = values
8
+ @format = options.fetch(:format, :hash)
9
+ @banner = options.fetch(:banner, nil)
10
+ end
11
+
12
+ def read
13
+ @_read ||=
14
+ case format.to_s
15
+ when "hash" then hashed_values
16
+ when "struct" then struct_values
17
+ when "openstruct" then openstruct_values
18
+ else
19
+ raise ArgumentError, "Unfig::Loader cannot return results in the format '#{format}'"
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :config, :values, :format, :banner
26
+
27
+ def argv = (@argv == UNSUPPLIED) ? ARGV : @argv
28
+
29
+ def env = (@env == UNSUPPLIED) ? ENV.to_h : @env
30
+
31
+ def params = @_params ||= ParamsConfig.new(banner:, params: values)
32
+
33
+ def loaded_argv
34
+ return @_loaded_argv if defined?(@_loaded_argv)
35
+ @_loaded_argv = argv.nil? ? {} : ArgvLoader.new(params:, argv:).read
36
+ end
37
+
38
+ def loaded_env
39
+ return @_loaded_env if defined?(@_loaded_env)
40
+ @_loaded_env = env.nil? ? {} : EnvLoader.new(params:, env:).read
41
+ end
42
+
43
+ def loaded_file
44
+ return @_loaded_file if defined?(@_loaded_file)
45
+ @_loaded_file = (config == UNSUPPLIED || config.nil?) ? {} : FileLoader.new(params:, path: config).read
46
+ end
47
+
48
+ def defaults = @_defaults ||= params.params.map { |p| [p.name, p.default] }.to_h
49
+
50
+ def merged_values = @_merged_values ||= defaults.merge(loaded_file).merge(loaded_env).merge(loaded_argv)
51
+
52
+ def hashed_values = merged_values.transform_keys(&:to_sym)
53
+
54
+ def struct_values
55
+ struct_keys = params.params.map(&:name).map(&:to_sym)
56
+ struct = Struct.new(*struct_keys, keyword_init: true)
57
+ struct.new(**merged_values)
58
+ end
59
+
60
+ def openstruct_values
61
+ # Don't require ostruct unless they try to use it (we don't want to make it one of _our_
62
+ # dependencies, but if they try to use it without making it one of _theirs_, ruby will
63
+ # inform them)
64
+ require "ostruct"
65
+ OpenStruct.new(merged_values)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,70 @@
1
+ module Unfig
2
+ # ParamConfig wraps the configuration of an individual param value, and all of
3
+ # the ways it can be supplied (a long flag, a short flag, a config-file entry,
4
+ # or an environment variable).
5
+ #
6
+ # It enforces the allowed values on each piece of configuration, constructs
7
+ # defaults for several of them (based on the name), and exposes which of those
8
+ # methods are _enabled_ for that variable (you can decide that `--verbose` is
9
+ # not suppliable through the config file for example). Each parameter can also
10
+ # be designated as `multi?`, meaning that it can be supplied multiple times
11
+ # (or supplied as an array, in the config-file case), and produces an array of
12
+ # values in any case.
13
+ class ParamConfig
14
+ KNOWN_ENABLED_VALUES = ["long", "short", "env", "file"].to_set.freeze
15
+
16
+ def self.load(params_data) = params_data.map { |key, value| new(key, value) }
17
+
18
+ def initialize(name, data)
19
+ @name = name
20
+ @data = data.transform_keys(&:to_sym)
21
+ ParamValidator.new(@name, @data).validate!
22
+ end
23
+
24
+ def name = @name.to_s
25
+
26
+ def description = data.fetch(:description)
27
+
28
+ def type = data.fetch(:type)
29
+
30
+ def default = data.fetch(:default)
31
+
32
+ def multi? = data.fetch(:multi, false)
33
+
34
+ def enabled
35
+ if data.key?(:enabled)
36
+ data.fetch(:enabled)
37
+ else
38
+ KNOWN_ENABLED_VALUES.to_a.sort
39
+ end
40
+ end
41
+
42
+ def long
43
+ if data.key?(:long)
44
+ data.fetch(:long)
45
+ else
46
+ name.tr("_", "-").downcase
47
+ end
48
+ end
49
+
50
+ def short
51
+ if data.key?(:short)
52
+ data.fetch(:short)
53
+ else
54
+ name[0]
55
+ end
56
+ end
57
+
58
+ def env
59
+ if data.key?(:env)
60
+ data.fetch(:env, nil)
61
+ else
62
+ name.upcase
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :data
69
+ end
70
+ end
@@ -0,0 +1,157 @@
1
+ module Unfig
2
+ class ParamValidator
3
+ MAX_NAME = 64
4
+ MAX_LONG_FLAG = 64
5
+ MAX_ENV_LENGTH = 64
6
+ KNOWN_TYPES = ["boolean", "string", "integer", "float"].to_set.freeze
7
+ KNOWN_ENABLEMENTS = ParamConfig::KNOWN_ENABLED_VALUES.dup.freeze
8
+
9
+ def initialize(name, data)
10
+ @name = name
11
+ @data = data
12
+ end
13
+
14
+ def validate!
15
+ validate_name!
16
+ validate_description!
17
+ validate_type!
18
+ validate_multi!
19
+ validate_enabled!
20
+ validate_default!
21
+ validate_long!
22
+ validate_short!
23
+ validate_env!
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :name, :data
29
+
30
+ # accessors
31
+
32
+ [:description, :type, :multi, :enabled, :default, :long, :short, :env].each do |key|
33
+ define_method(key) { data[key] }
34
+ end
35
+
36
+ # helpers
37
+
38
+ def invalid!(msg) = raise(Invalid, "Param '#{name}': #{msg}")
39
+
40
+ def missing?(key) = !data.key?(key)
41
+
42
+ def nonstring?(s) = !s.is_a?(String)
43
+
44
+ def nonstringlike?(s) = !(s.is_a?(String) || s.is_a?(Symbol))
45
+
46
+ def blank?(s) = !/\S/.match?(s)
47
+
48
+ def whitespace?(s) = /\s/.match?(s)
49
+
50
+ def multi_line?(s) = /\n/.match?(s)
51
+
52
+ def alphanumeric?(s) = /\A[a-zA-Z0-9_]+\z/.match?(s)
53
+
54
+ def boolean?(v) = [true, false].include?(v)
55
+
56
+ def array?(v) = v.is_a?(Array)
57
+
58
+ def known_type?(t) = KNOWN_TYPES.include?(t)
59
+
60
+ def known_enablement?(e) = KNOWN_ENABLEMENTS.include?(e)
61
+
62
+ def unrecognized_enablements = enabled.reject { |e| known_enablement?(e) }.sort
63
+
64
+ def types_list = KNOWN_TYPES.to_a.sort.map(&:to_s).join(", ")
65
+
66
+ # validators
67
+
68
+ def validate_name!
69
+ invalid!("Name is not a string") if nonstringlike?(name)
70
+ invalid!("Name may contain only alphanumerics and underscores") unless alphanumeric?(name)
71
+ invalid!("Name contains more than #{MAX_NAME} characters") if name.length > MAX_NAME
72
+ end
73
+
74
+ def validate_description!
75
+ invalid!("Description must be supplied") if missing?(:description)
76
+ invalid!("Description must be supplied as a string") if nonstring?(description)
77
+ invalid!("Description must not be blank") if blank?(description)
78
+ invalid!("Description may not include newlines") if multi_line?(description)
79
+ end
80
+
81
+ def validate_type!
82
+ invalid!("Type was not supplied") if missing?(:type)
83
+ invalid!("Type must be supplied as a string") if nonstring?(type)
84
+ invalid!("Type not recognized - expected #{types_list}") unless known_type?(type)
85
+ end
86
+
87
+ def validate_multi!
88
+ return unless data.key?(:multi)
89
+
90
+ invalid!("Multi must be a boolean") unless boolean?(multi)
91
+ end
92
+
93
+ def validate_enabled!
94
+ return if missing?(:enabled)
95
+
96
+ invalid!("Enabled must be an array") unless array?(enabled)
97
+ invalid!("Enabled must not be empty") if enabled.empty?
98
+
99
+ if unrecognized_enablements.any?
100
+ invalid!("Enabled includes unrecognized values: #{unrecognized_enablements.join(", ")}")
101
+ end
102
+ end
103
+
104
+ def validate_multi_default!
105
+ invalid!("Multi-valued, but default is not an Array") unless array?(default)
106
+
107
+ if default.any? { |entry| !correct_default_type?(type, entry) }
108
+ invalid!("Default includes non-#{type} values")
109
+ end
110
+ end
111
+
112
+ def validate_single_default!
113
+ return if correct_default_type?(type, default)
114
+ invalid!("Default is not a #{type}")
115
+ end
116
+
117
+ def validate_default!
118
+ invalid!("Default not supplied") if missing?(:default)
119
+ return if default.nil?
120
+
121
+ multi ? validate_multi_default! : validate_single_default!
122
+ end
123
+
124
+ def correct_default_type?(type, value)
125
+ case type
126
+ when "boolean" then [true, false].include?(value)
127
+ when "string" then value.is_a?(String)
128
+ when "integer" then value.is_a?(Integer)
129
+ when "float" then value.is_a?(Numeric)
130
+ end
131
+ end
132
+
133
+ def validate_long!
134
+ return if missing?(:long)
135
+
136
+ invalid!("Long flag is not a string") if nonstring?(long)
137
+ invalid!("Long flag includes whitespace") if whitespace?(long)
138
+ invalid!("Long flag is over #{MAX_LONG_FLAG} characters") if long.length > MAX_LONG_FLAG
139
+ end
140
+
141
+ def validate_short!
142
+ return if missing?(:short)
143
+
144
+ invalid!("Short flag is not a string") if nonstring?(short)
145
+ invalid!("Short flag must be a single letter or digit") unless /\A[a-z0-9]\z/i.match?(short)
146
+ end
147
+
148
+ def validate_env!
149
+ return if missing?(:env)
150
+
151
+ invalid!("ENV name is not a string") if nonstring?(env)
152
+ invalid!("ENV name may only contain alphanumerics and underscores") unless alphanumeric?(env)
153
+ invalid!("ENV name must begin with a letter") unless /\A[a-z]/i.match?(env)
154
+ invalid!("ENV name is over #{MAX_ENV_LENGTH} characters") if env.length > MAX_ENV_LENGTH
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,63 @@
1
+ module Unfig
2
+ # ParamsConfig wraps the configuration of _all_ of the possible parameters,
3
+ # and enforces uniqueness validations - you can't have two parameters with
4
+ # the same flag, or the same name; they'll collide in the implementation.
5
+ class ParamsConfig
6
+ def initialize(data)
7
+ raise(Invalid, "Params-config must be a Hash") unless data.is_a?(Hash)
8
+ raise(Invalid, "Params-config must supply some params (as a Hash)") unless data[:params].is_a?(Hash)
9
+ @data = data
10
+ end
11
+
12
+ def params
13
+ return @_params if defined?(@_params)
14
+ validate!
15
+ @_params = built
16
+ end
17
+
18
+ def banner = data[:banner]
19
+
20
+ private
21
+
22
+ attr_reader :data
23
+
24
+ def built = @_built ||= data[:params].map { |k, v| ParamConfig.new(k, v) }
25
+
26
+ def validate!
27
+ validate_no_duplicate_names!
28
+ validate_no_duplicate_long_flags!
29
+ validate_no_duplicate_short_flags!
30
+ validate_no_duplicate_envs!
31
+ end
32
+
33
+ def repeats(items) = items.tally.select { |_k, v| v > 1 }.keys.sort
34
+
35
+ def validate_no_duplicate_names!
36
+ dups = repeats(built.map(&:name))
37
+ return if dups.none?
38
+
39
+ raise Invalid, "Duplicate parameter names: #{dups.join(", ")}"
40
+ end
41
+
42
+ def validate_no_duplicate_long_flags!
43
+ dups = repeats(built.map(&:long))
44
+ return if dups.none?
45
+
46
+ raise Invalid, "Duplicate long-flags: #{dups.join(", ")}"
47
+ end
48
+
49
+ def validate_no_duplicate_short_flags!
50
+ dups = repeats(built.map(&:short))
51
+ return if dups.none?
52
+
53
+ raise Invalid, "Duplicate short-flags: #{dups.join(", ")}"
54
+ end
55
+
56
+ def validate_no_duplicate_envs!
57
+ dups = repeats(built.map(&:env))
58
+ return if dups.none?
59
+
60
+ raise Invalid, "Duplicate env-names: #{dups.join(", ")}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,3 @@
1
+ module Unfig
2
+ VERSION = "0.1.0"
3
+ end
data/lib/unfig.rb ADDED
@@ -0,0 +1,29 @@
1
+ require "yaml"
2
+ require "optparse"
3
+
4
+ module Unfig
5
+ UNSUPPLIED = Object.new.freeze
6
+
7
+ Error = Class.new(StandardError)
8
+ Invalid = Class.new(Error)
9
+
10
+ InvalidEnv = Class.new(Error)
11
+ InvalidBooleanText = Class.new(InvalidEnv)
12
+ InvalidIntegerText = Class.new(InvalidEnv)
13
+ InvalidFloatingPointText = Class.new(InvalidEnv)
14
+
15
+ InvalidYamlValue = Class.new(Error)
16
+ FlagError = Class.new(Error)
17
+
18
+ def self.load_options(**) = Loader.new(**).read
19
+ end
20
+
21
+ require_relative "unfig/version"
22
+ require_relative "unfig/param_config"
23
+ require_relative "unfig/param_validator"
24
+ require_relative "unfig/params_config"
25
+ require_relative "unfig/env_reader"
26
+ require_relative "unfig/env_loader"
27
+ require_relative "unfig/file_loader"
28
+ require_relative "unfig/argv_loader"
29
+ require_relative "unfig/loader"
data/unfig.gemspec ADDED
@@ -0,0 +1,45 @@
1
+ require_relative "lib/unfig/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "unfig"
5
+ spec.version = Unfig::VERSION
6
+ spec.authors = ["Eric Mueller"]
7
+ spec.email = ["nevinera@gmail.com"]
8
+
9
+ spec.summary = "Build CLIs that are configured via args, file, and/or environment"
10
+ spec.description = <<~DESC
11
+ We've written code that merges/cascades default configuration, config-files,
12
+ environment variables, and cli-passed arguments _too many times_. This gem
13
+ intends to distill that into a configuration config-file describing those
14
+ controls and relationships.
15
+ DESC
16
+ spec.homepage = "https://github.com/nevinera/unfig"
17
+ spec.license = "MIT"
18
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
19
+
20
+ spec.metadata["homepage_uri"] = spec.homepage
21
+ spec.metadata["source_code_uri"] = spec.homepage
22
+
23
+ spec.require_paths = ["lib"]
24
+ spec.bindir = "bin"
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`
27
+ .split("\x0")
28
+ .reject { |f| f.start_with?("spec") }
29
+ end
30
+ spec.executables = Dir.chdir(File.expand_path(__dir__)) do
31
+ `git ls-files -z bin/`
32
+ .split("\x0")
33
+ .map { |path| path.sub(/^bin\//, "") }
34
+ end
35
+
36
+ spec.add_development_dependency "rspec", "~> 3.13"
37
+ spec.add_development_dependency "rspec-its", "~> 1.3"
38
+ spec.add_development_dependency "rspec-collection_matchers", "~> 1.2.1"
39
+ spec.add_development_dependency "simplecov", "~> 0.22.0"
40
+ spec.add_development_dependency "pry", "~> 0.14"
41
+ spec.add_development_dependency "standard", ">= 1.35.1"
42
+ spec.add_development_dependency "rubocop", ">= 1.62"
43
+ spec.add_development_dependency "mdl", "~> 0.12"
44
+ spec.add_development_dependency "ostruct"
45
+ end
metadata ADDED
@@ -0,0 +1,196 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: unfig
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Mueller
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-its
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-collection_matchers
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.2.1
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.2.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.22.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.22.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.14'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.14'
83
+ - !ruby/object:Gem::Dependency
84
+ name: standard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 1.35.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 1.35.1
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '1.62'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '1.62'
111
+ - !ruby/object:Gem::Dependency
112
+ name: mdl
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.12'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.12'
125
+ - !ruby/object:Gem::Dependency
126
+ name: ostruct
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: |
140
+ We've written code that merges/cascades default configuration, config-files,
141
+ environment variables, and cli-passed arguments _too many times_. This gem
142
+ intends to distill that into a configuration config-file describing those
143
+ controls and relationships.
144
+ email:
145
+ - nevinera@gmail.com
146
+ executables: []
147
+ extensions: []
148
+ extra_rdoc_files: []
149
+ files:
150
+ - ".github/workflows/linters.yml"
151
+ - ".github/workflows/rspec.yml"
152
+ - ".gitignore"
153
+ - ".mdl_rules.rb"
154
+ - ".mdlrc"
155
+ - ".rspec"
156
+ - ".rubocop.yml"
157
+ - ".standard.yml"
158
+ - Gemfile
159
+ - README.md
160
+ - lib/unfig.rb
161
+ - lib/unfig/argv_loader.rb
162
+ - lib/unfig/env_loader.rb
163
+ - lib/unfig/env_reader.rb
164
+ - lib/unfig/file_loader.rb
165
+ - lib/unfig/loader.rb
166
+ - lib/unfig/param_config.rb
167
+ - lib/unfig/param_validator.rb
168
+ - lib/unfig/params_config.rb
169
+ - lib/unfig/version.rb
170
+ - unfig.gemspec
171
+ homepage: https://github.com/nevinera/unfig
172
+ licenses:
173
+ - MIT
174
+ metadata:
175
+ homepage_uri: https://github.com/nevinera/unfig
176
+ source_code_uri: https://github.com/nevinera/unfig
177
+ post_install_message:
178
+ rdoc_options: []
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: 3.2.0
186
+ required_rubygems_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubygems_version: 3.4.19
193
+ signing_key:
194
+ specification_version: 4
195
+ summary: Build CLIs that are configured via args, file, and/or environment
196
+ test_files: []