If you have developed Rails applications you might be familiar with its configuration system but if your are working on a CLI utility you might miss part of that convenience.
Let me share with you a simple system that gets you set with just a couple of files:
The hook
somewhere close to the first lines of code you run you have something like this:
options = Parser.parse_options Config.setup(**options)
I do this just after setting zeitwerk up.
the you need 2 files:
The Parser
This is based on rubys stdlib's OptionParser class.
require "optparse" module Parser extend self def parse_options options = {} OptionParser.new do |parser| load(parser, options) progress(parser, options) memory(parser, options) #... end.parse! options end def load(parser, options) parser.on("-l", "--load", "Load data files") do |l| options[:load_files] = l end end def progress(parser, options) parser.on("-p", "--progress", "Show progress bar for long processes") do |p| options[:progress] = p end end def memory(parser, options) parser.on("-M", "--memory", "Use in-memory db") do |m| options[:memory] = m end end #... end
This is very reusable because it is initializing options as an empty hash, a returning a hash with only the options that have been set up.
If you want to show the default value on some option you can use #{Config::DEFAULTS[:option_name]}
.
But if you are not using the next part, you can just use this and have a hash of options to store where you like.
The Config Singleton
require "singleton" # Global configuration object with automatic defaults # # Usage: # Configure with: Config.setup(load_files: true, ...) # Access with: Config.value(:load_files) # class Config include Singleton DEFAULTS = { load_files: false, progress: false, memory: false, option_name: "default_value", }.freeze Options = Data.define(*DEFAULTS.keys) def self.setup(**) = instance.setup(**) def self.value(key) raise ArgumentError, "Unknown configuration key: #{key}" unless DEFAULTS.key?(key) instance.config.public_send(key) end def config = @config ||= Options.new(*DEFAULTS.values) def setup(**kwargs) updated_values = DEFAULTS.merge(kwargs) @config = Options.new(*updated_values.values_at(*DEFAULTS.keys)) end end
First we declare DEFAULTS
as a hash with the keys being our option names and the values being the default values if the option is not explicitly configures by the user.
Then we define the Value Object with
Options = Data.define(*DEFAULTS.keys)
This takes advantage of Ruby's Data.define
functionality. You can see the documentation here, but the TL;DR it is a Struct
like immutable object.
The we have this slightly cryptic:
def self.setup(**) = instance.setup(**)
This means, if you call Config.setup
on the class, we are going to call it on the (singular) instance that is keeping the state.
Then you have a class method for reading a config value:
def self.value(key) raise ArgumentError, "Unknown configuration key: #{key}" unless DEFAULTS.key?(key) instance.config.send(key) end
It uses DEFAULTS
as an allow list to prevent the dynamic send
we do on the Data instance from posing any security risk on malicious config keys.
We would use instance.config.to_h[key]
instead.
The we have
def config = @config ||= Options.new(*DEFAULTS.values)
which is memoizing the value object on the Singleton instance.
And then the trickiest part:
def setup(**kwargs) updated_values = DEFAULTS.merge(kwargs) @config = Options.new(*updated_values.values_at(*DEFAULTS.keys)) end
This is the method that allows you to update a config value (or several). Because Data.define
object are immutable, when you have a new config, you create a new one based on the existing one plus your changes.
If you choose to use a hash instead of a Data.define, you could use merge!
here.
This is the method that we were calling from "the hook" with the options hash returned from the Parser class.
With just this now you have a global Config
object you can access anywhere in your app.
If you want extra ergonomics, you can have a concern like this:
module Configurable def self.included(base) base.extend Methods # for class methods base.include Methods # for instance methods end # Methods module to allow including and extending module Methods # Convenience method for configuration values def config(key, setup_config: ::Setup::Config) setup_config.value(key) end end end
And then in any of you classes do something like:
class MyClass include Configurable def my_method if config :option_name puts "optional behavior" end end
I hope you find it useful.
Let me know if you use it on any projects.
Top comments (0)