diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 89dec09..31b7f31 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: [jodosha, solnic, timriley] +github: hanami diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4694cc4..fc27ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ on: - "project.yml" pull_request: branches: - - master + - main create: jobs: @@ -26,18 +26,18 @@ jobs: fail-fast: false matrix: ruby: + - "3.1" - "3.0" - "2.7" - - "2.6" include: - - ruby: "3.0" + - ruby: "3.1" coverage: "true" env: COVERAGE: ${{matrix.coverage}} COVERAGE_TOKEN: ${{secrets.CODACY_PROJECT_TOKEN}} steps: - name: Checkout - uses: actions/checkout@v1 + uses: actions/checkout@v2 - name: Install package dependencies run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS" - name: Set up Ruby @@ -47,12 +47,6 @@ jobs: bundler-cache: true - name: Run all tests run: bundle exec rake - - name: Run codacy-coverage-reporter - uses: codacy/codacy-coverage-reporter-action@master - if: env.COVERAGE == 'true' && env.COVERAGE_TOKEN != '' - with: - project-token: ${{secrets.CODACY_PROJECT_TOKEN}} - coverage-reports: coverage/coverage.xml release: runs-on: ubuntu-latest if: contains(github.ref, 'tags') && github.event_name == 'create' @@ -61,13 +55,13 @@ jobs: GITHUB_LOGIN: dry-bot GITHUB_TOKEN: ${{secrets.GH_PAT}} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install package dependencies run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS" - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 2.6 + ruby-version: 2.7 - name: Install dependencies run: gem install ossy --no-document - name: Trigger release workflow diff --git a/.github/workflows/docsite.yml b/.github/workflows/docsite.yml index 3b4ade8..4928420 100644 --- a/.github/workflows/docsite.yml +++ b/.github/workflows/docsite.yml @@ -1,4 +1,4 @@ -# this file is managed by dry-rb/devtools project +# This file is synced from dry-rb/template-gem repo name: docsite @@ -8,7 +8,7 @@ on: - docsite/** - .github/workflows/docsite.yml branches: - - master + - main - release-** tags: @@ -24,7 +24,7 @@ jobs: - name: Set up Ruby uses: actions/setup-ruby@v1 with: - ruby-version: "2.6.x" + ruby-version: "2.7.x" - name: Set up git user run: | git config --local user.email "dry-bot@dry-rb.org" @@ -51,7 +51,7 @@ jobs: git push --all "https://dry-bot:${{secrets.GH_PAT}}@github.com/$GITHUB_REPOSITORY.git" - git checkout master + git checkout main else echo "no need to update branches" fi diff --git a/.github/workflows/sync_configs.yml b/.github/workflows/sync_configs.yml index f4a85be..1fe1407 100644 --- a/.github/workflows/sync_configs.yml +++ b/.github/workflows/sync_configs.yml @@ -6,7 +6,7 @@ on: repository_dispatch: push: branches: - - "master" + - "main" jobs: main: @@ -17,7 +17,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GH_PAT }} steps: - name: Checkout ${{github.repository}} - uses: actions/checkout@v1 + uses: actions/checkout@v2 - name: Checkout devtools uses: actions/checkout@v2 with: @@ -30,7 +30,7 @@ jobs: - name: Set up Ruby uses: actions/setup-ruby@v1 with: - ruby-version: "2.6" + ruby-version: "2.7" - name: Install dependencies run: gem install ossy --no-document - name: Update changelog.yml from commit @@ -43,5 +43,5 @@ jobs: git commit -m "[devtools] sync" || echo "nothing to commit" - name: Push changes run: | - git pull --rebase origin master - git push https://dry-bot:${{secrets.GH_PAT}}@github.com/${{github.repository}}.git HEAD:master + git pull --rebase origin main + git push https://dry-bot:${{secrets.GH_PAT}}@github.com/${{github.repository}}.git HEAD:main diff --git a/.rubocop.yml b/.rubocop.yml index f33872c..7d84ef1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,9 +1,10 @@ # This is a config synced from dry-rb/template-gem repo AllCops: - TargetRubyVersion: 2.6 - NewCops: enable + TargetRubyVersion: 2.7 + NewCops: disable Exclude: + - benchmarks/*.rb - spec/support/coverage.rb - spec/support/warnings.rb - spec/support/rspec_options.rb @@ -56,6 +57,10 @@ Lint/SuppressedException: Exclude: - "spec/spec_helper.rb" +Lint/LiteralAsCondition: + Exclude: + - "spec/**/*.rb" + Naming/PredicateName: Enabled: false @@ -67,9 +72,7 @@ Naming/MethodName: Enabled: false Naming/MethodParameterName: - Enabled: true - Exclude: - - "spec/**/*.rb" + Enabled: false Naming/MemoizedInstanceVariableName: Enabled: false @@ -134,6 +137,9 @@ Style/EachWithObject: Style/FormatString: Enabled: false +Style/FormatStringToken: + Enabled: false + Style/GuardClause: Enabled: false @@ -174,10 +180,58 @@ Style/MultipleComparison: Style/Next: Enabled: false +Style/AccessorGrouping: + Enabled: false + +Style/EmptyLiteral: + Enabled: false + +Style/Semicolon: + Exclude: + - "spec/**/*.rb" + +Style/HashAsLastArrayItem: + Exclude: + - "spec/**/*.rb" + +Style/CaseEquality: + Exclude: + - "lib/dry/monads/**/*.rb" + - "lib/dry/struct/**/*.rb" + - "lib/dry/types/**/*.rb" + - "spec/**/*.rb" + +Style/ExplicitBlockArgument: + Exclude: + - "lib/dry/types/**/*.rb" + +Style/CombinableLoops: + Enabled: false + +Style/EmptyElse: + Enabled: false + +Style/DoubleNegation: + Enabled: false + +Style/MultilineBlockChain: + Enabled: false + +Style/NumberedParametersLimit: + Max: 2 + Lint/UnusedBlockArgument: Exclude: - "spec/**/*.rb" +Lint/Debugger: + Exclude: + - "bin/console" + +Lint/BinaryOperatorWithIdenticalOperands: + Exclude: + - "spec/**/*.rb" + Metrics/ParameterLists: Exclude: - "spec/**/*.rb" @@ -186,6 +240,23 @@ Lint/EmptyBlock: Exclude: - "spec/**/*.rb" +Lint/UselessMethodDefinition: + Exclude: + - "spec/**/*.rb" + +Lint/SelfAssignment: + Enabled: false + +Lint/EmptyClass: + Enabled: false + Naming/ConstantName: Exclude: - "spec/**/*.rb" + +Naming/VariableNumber: + Exclude: + - "spec/**/*.rb" + +Naming/BinaryOperatorParameterName: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 160f8d9..3ddcbd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,81 @@ -## unreleased +## 0.15.0 2022-04-21 -### Fixed - -- Fix `ArgumentError` for classes including `Dry::Configurable` whose `initializer` has required kwargs. (#113 by @timriley) -- Remove implicit `to_hash` conversion from `Config`. (#114 by @timriley) - ### Changed -- Deprecate constructor as a block. Providing `constructor:` kwarg is now the only accepted way. (#111 by @waiting-for-dev & @timriley) -- Use `default:` kwarg instead of second positional argument for the default value. (#112 by @waiting-for-dev & @timriley) +- The `finalize!` method (as class or instance method, depending on whether you extend or include `Dry::Configurable` respectively) now accepts a boolean `freeze_values:` argument, which if true, will recursively freeze all config values in addition to the `config` itself. (#105 by @ojab) -[Compare v0.12.1...master](https://github.com/dry-rb/dry-configurable/compare/v0.12.1...master) + ```ruby + class MyConfigurable + include Dry::Configurable + + setting :db, default: "postgre" + end + + my_obj = MyConfigurable.new + my_obj.finalize!(freeze_values: true) + my_obj.config.db << "sql" # Will raise FrozenError + ``` +- `Dry::Configurable::Config#update` will set hashes as values for non-nested settings (#131 by @ojab) + + ```ruby + class MyConfigurable + extend Dry::Configurable + + setting :sslcert, constructor: ->(v) { v&.values_at(:pem, :pass)&.join } + end + + MyConfigurable.config.update(sslcert: {pem: "cert", pass: "qwerty"}) + MyConfigurable.config.sslcert # => "certqwerty" + ``` +- `Dry::Configurable::Config#update` will accept any values implicitly convertible to hash via `#to_hash` (#133 by @timriley) + +[Compare v0.14.0...v0.15.0](https://github.com/dry-rb/dry-configurable/compare/v0.14.0...v0.15.0) + +## 0.14.0 2022-01-14 + + +### Changed + +- Settings defined after an access to `config` will still be made available on that `config`. (#130 by @timriley) +- Cloneable settings are cloned immediately upon assignment. (#130 by @timriley) +- Changes to config values in parent classes after subclasses have already been created will not be propogated to those subclasses. Subclasses created _after_ config values have been changed in the parent _will_ receive those config values. (#130 by @timriley) + +[Compare v0.13.0...v0.14.0](https://github.com/dry-rb/dry-configurable/compare/v0.13.0...v0.14.0) + +## 0.13.0 2021-09-12 + + +### Added + +- Added flags to determine whether to warn on the API usage deprecated in this release (see "Changed" section below). Set these to `false` to suppress the warnings. (#124 by @timriley) + + ```ruby + Dry::Configurable.warn_on_setting_constructor_block false + Dry::Configurable.warn_on_setting_positional_default false + ``` + +### Fixed + +- Fixed `ArgumentError` for classes including `Dry::Configurable` whose `initializer` has required kwargs. (#113 by @timriley) + +### Changed + +- Deprecated the setting constructor provided as a block. Provide it via the `constructor:` keyword argument instead. (#111 by @waiting-for-dev & @timriley) + + ```ruby + setting :path, constructor: -> path { Pathname(path) } + ``` +- Deprecated the setting default provided as the second positional argument. Provide it via the `default:` keyword argument instead. (#112 and #121 by @waiting-for-dev & @timriley) + + ```ruby + setting :path, default: "some/default/path" + ``` +- [BREAKING] Removed implicit `to_hash` conversion from `Config`. (#114 by @timriley) + +[Compare v0.12.1...v0.13.0](https://github.com/dry-rb/dry-configurable/compare/v0.12.1...v0.13.0) ## 0.12.1 2021-02-15 diff --git a/CHANGELOG.old.md b/CHANGELOG.old.md deleted file mode 100644 index ddc7a54..0000000 --- a/CHANGELOG.old.md +++ /dev/null @@ -1,94 +0,0 @@ -## 0.9.0 - 2019-11-06 - -## Fixed - -- Support for reserved names in settings. Some Kernel methods (`public_send` and `class` specifically) are not available if you use access settings via method call. Same for methods of the `Config` class. You can still access them with `[]` and `[]=`. Ruby keywords are fully supported. Invalid names containing special symbols (including `!` and `?`) are rejected. Note that these changes don't affect the `reader` option, if you define a setting named `:class` and pass `reader: true` ... well ... (flash-gordon) -- Settings can be redefined in subclasses without a warning about overriding exsting methods (flash-gordon) -- Fix warnings about using keyword arguments in 2.7 (koic) - -[Compare v0.8.3...v0.9.0](https://github.com/dry-rb/dry-configurable/compare/v0.8.3...v0.9.0) - -## 0.8.3 - 2019-05-29 - -## Fixed - -- `Configurable#dup` and `Configurable#clone` make a copy of instance-level config so that it doesn't get mutated/shared across instances (flash-gordon) - -[Compare v0.8.2...v0.8.3](https://github.com/dry-rb/dry-configurable/compare/v0.8.2...v0.8.3) - -## 0.8.2 - 2019-02-25 - -## Fixed - -- Test interface support for modules ([Neznauy](https://github.com/Neznauy)) - -[Compare v0.8.1...v0.8.2](https://github.com/dry-rb/dry-configurable/compare/v0.8.1...v0.8.2) - -## 0.8.1 - 2019-02-06 - -## Fixed - -- `.configure` doesn't require a block, this makes the behavior consistent with the previous versions ([flash-gordon](https://github.com/flash-gordon)) - -[Compare v0.8.0...v0.8.1](https://github.com/dry-rb/dry-configurable/compare/v0.8.0...v0.8.1) - -## 0.8.0 - 2019-02-05 - -## Fixed - -- A number of bugs related to inheriting settings from parent class were fixed. Ideally, new behavior will be :100: predictable but if you observe any anomaly, please report ([flash-gordon](https://github.com/flash-gordon)) - -## Added - -- Support for instance-level configuration landed. For usage, `include` the module instead of extending ([flash-gordon](https://github.com/flash-gordon)) - - ```ruby - class App - include Dry::Configurable - - setting :database - end - - production = App.new - production.config.database = ENV['DATABASE_URL'] - production.finalize! - - development = App.new - development.config.database = 'jdbc:sqlite:memory' - development.finalize! - ``` - -- Config values can be set from a hash with `.update`. Nested settings are supported ([flash-gordon](https://github.com/flash-gordon)) - - ```ruby - class App - extend Dry::Configurable - - setting :db do - setting :host - setting :port - end - - config.update(YAML.load(File.read("config.yml"))) - end - ``` - -## Changed - -- [BREAKING] Minimal supported Ruby version is set to 2.3 ([flash-gordon](https://github.com/flash-gordon)) - -[Compare v0.7.0...v0.8.0](https://github.com/dry-rb/dry-configurable/compare/v0.7.0...v0.8.0) - -## 0.7.0 - 2017-04-25 - -## Added - -- Introduce `Configurable.finalize!` which freezes config and its dependencies ([qcam](https://github.com/qcam)) - -## Fixed - -- Allow for boolean false as default setting value ([yuszuv](https://github.com/yuszuv)) -- Convert nested configs to nested hashes with `Config#to_h` ([saverio-kantox](https://github.com/saverio-kantox)) -- Disallow modification on frozen config ([qcam](https://github.com/qcam)) - -[Compare v0.6.2...v0.7.0](https://github.com/dry-rb/dry-configurable/compare/v0.6.2...v0.7.0) diff --git a/Gemfile b/Gemfile index 0ccff33..8c6e685 100644 --- a/Gemfile +++ b/Gemfile @@ -1,16 +1,16 @@ # frozen_string_literal: true -source 'https://rubygems.org' +source "https://rubygems.org" -eval_gemfile 'Gemfile.devtools' +eval_gemfile "Gemfile.devtools" gemspec group :benchmarks do - gem 'benchmark-ips' + gem "benchmark-ips" end group :tools do - gem 'hotch' - gem 'pry-byebug', platform: :mri + gem "hotch", platform: :mri + gem "pry-byebug", platform: :mri end diff --git a/Gemfile.devtools b/Gemfile.devtools index 673e2c0..b235923 100644 --- a/Gemfile.devtools +++ b/Gemfile.devtools @@ -13,6 +13,5 @@ group :test do end group :tools do - # this is the same version that we use on codacy - gem "rubocop", "1.16.0" + gem "rubocop", "~> 1.27.0" end diff --git a/README.md b/README.md index ea99e34..4ad1e76 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![CI Status](https://github.com/dry-rb/dry-configurable/workflows/ci/badge.svg)][actions] [![Codacy Badge](https://api.codacy.com/project/badge/Grade/0276a97990e04eb0ac722b3e7f3620b5)][codacy] [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/0276a97990e04eb0ac722b3e7f3620b5)][codacy] -[![Inline docs](http://inch-ci.org/github/dry-rb/dry-configurable.svg?branch=master)][inchpages] +[![Inline docs](http://inch-ci.org/github/dry-rb/dry-configurable.svg?branch=main)][inchpages] ## Links @@ -22,8 +22,8 @@ This library officially supports the following Ruby versions: -* MRI `>= 2.6.0` -* ~~jruby~~ `>= 9.3` (we are waiting for [2.6 support](https://github.com/jruby/jruby/issues/6161)) +* MRI `>= 2.7.0` +* jruby `>= 9.3` (postponed until 2.7 is supported) ## License diff --git a/Rakefile b/Rakefile index ad52f83..be26d08 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'rspec/core/rake_task' +require "rspec/core/rake_task" -desc 'Run all specs in spec directory' +desc "Run all specs in spec directory" RSpec::Core::RakeTask.new(:spec) task default: :spec diff --git a/bin/console b/bin/console index 936f50e..0afe571 100755 --- a/bin/console +++ b/bin/console @@ -1,13 +1,13 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'bundler/setup' -require 'dry/configurable' +require "bundler/setup" +require "dry/configurable" begin - require 'pry-byebug' + require "pry-byebug" Pry.start rescue LoadError - require 'irb' + require "irb" IRB.start end diff --git a/changelog.yml b/changelog.yml index b703f53..c9395cf 100644 --- a/changelog.yml +++ b/changelog.yml @@ -1,17 +1,74 @@ --- -- version: unreleased +- version: 0.15.0 summary: - date: - fixed: - - 'Fix `ArgumentError` for classes including `Dry::Configurable` whose - `initializer` has required kwargs. (#113 by @timriley)' - - 'Remove implicit `to_hash` conversion from `Config`. (#114 by @timriley)' - added: + date: '2022-04-21' changed: - - 'Deprecate constructor as a block. Providing `constructor:` kwarg is now - the only accepted way. (#111 by @waiting-for-dev & @timriley)' - - 'Use `default:` kwarg instead of second positional argument for the - default value. (#112 by @waiting-for-dev & @timriley)' + - |- + The `finalize!` method (as class or instance method, depending on whether you extend or include `Dry::Configurable` respectively) now accepts a boolean `freeze_values:` argument, which if true, will recursively freeze all config values in addition to the `config` itself. (#105 by @ojab) + + ```ruby + class MyConfigurable + include Dry::Configurable + + setting :db, default: "postgre" + end + + my_obj = MyConfigurable.new + my_obj.finalize!(freeze_values: true) + my_obj.config.db << "sql" # Will raise FrozenError + ``` + - |- + `Dry::Configurable::Config#update` will set hashes as values for non-nested settings (#131 by @ojab) + + ```ruby + class MyConfigurable + extend Dry::Configurable + + setting :sslcert, constructor: ->(v) { v&.values_at(:pem, :pass)&.join } + end + + MyConfigurable.config.update(sslcert: {pem: "cert", pass: "qwerty"}) + MyConfigurable.config.sslcert # => "certqwerty" + ``` + - |- + `Dry::Configurable::Config#update` will accept any values implicitly convertible to hash via `#to_hash` (#133 by @timriley) +- version: 0.14.0 + summary: + date: '2022-01-14' + changed: + - |- + Settings defined after an access to `config` will still be made available on that `config`. (#130 by @timriley) + - |- + Cloneable settings are cloned immediately upon assignment. (#130 by @timriley) + - |- + Changes to config values in parent classes after subclasses have already been created will not be propogated to those subclasses. Subclasses created _after_ config values have been changed in the parent _will_ receive those config values. (#130 by @timriley) +- version: 0.13.0 + summary: + date: '2021-09-12' + fixed: + - Fixed `ArgumentError` for classes including `Dry::Configurable` whose `initializer` has required kwargs. (#113 by @timriley) + added: + - |- + Added flags to determine whether to warn on the API usage deprecated in this release (see "Changed" section below). Set these to `false` to suppress the warnings. (#124 by @timriley) + + ```ruby + Dry::Configurable.warn_on_setting_constructor_block false + Dry::Configurable.warn_on_setting_positional_default false + ``` + changed: + - |- + Deprecated the setting constructor provided as a block. Provide it via the `constructor:` keyword argument instead. (#111 by @waiting-for-dev & @timriley) + + ```ruby + setting :path, constructor: -> path { Pathname(path) } + ``` + - |- + Deprecated the setting default provided as the second positional argument. Provide it via the `default:` keyword argument instead. (#112 and #121 by @waiting-for-dev & @timriley) + + ```ruby + setting :path, default: "some/default/path" + ``` + - '[BREAKING] Removed implicit `to_hash` conversion from `Config`. (#114 by @timriley)' - version: 0.12.1 summary: date: '2021-02-15' diff --git a/docsite/source/index.html.md b/docsite/source/index.html.md index f5969ec..7d45542 100644 --- a/docsite/source/index.html.md +++ b/docsite/source/index.html.md @@ -26,17 +26,17 @@ class App # Pass a block for nested configuration (works to any depth) setting :database do # Can pass a default value - setting :dsn, 'sqlite:memory' + setting :dsn, default: 'sqlite:memory' end # Defaults to nil if no default value is given setting :adapter # Construct values - setting(:path, 'test') { |value| Pathname(value) } + setting :path, default: 'test', constructor: proc { |value| Pathname(value) } # Passing the reader option as true will create attr_reader method for the class - setting :pool, 5, reader: true + setting :pool, default: 5, reader: true # Passing the reader attributes works with nested configuration setting :uploader, reader: true do - setting :bucket, 'dev' + setting :bucket, default: 'dev' end end diff --git a/dry-configurable.gemspec b/dry-configurable.gemspec index bd3b621..0b6caa3 100644 --- a/dry-configurable.gemspec +++ b/dry-configurable.gemspec @@ -22,11 +22,11 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.metadata["allowed_push_host"] = "https://rubygems.org" - spec.metadata["changelog_uri"] = "https://github.com/dry-rb/dry-configurable/blob/master/CHANGELOG.md" + spec.metadata["changelog_uri"] = "https://github.com/dry-rb/dry-configurable/blob/main/CHANGELOG.md" spec.metadata["source_code_uri"] = "https://github.com/dry-rb/dry-configurable" spec.metadata["bug_tracker_uri"] = "https://github.com/dry-rb/dry-configurable/issues" - spec.required_ruby_version = ">= 2.6.0" + spec.required_ruby_version = ">= 2.7.0" # to update dependencies edit project.yml spec.add_runtime_dependency "concurrent-ruby", "~> 1.0" diff --git a/lib/dry/configurable/class_methods.rb b/lib/dry/configurable/class_methods.rb index 8edad27..ff7edf4 100644 --- a/lib/dry/configurable/class_methods.rb +++ b/lib/dry/configurable/class_methods.rb @@ -13,12 +13,11 @@ module Dry include Methods # @api private - def inherited(klass) + def inherited(subclass) super - parent_settings = (respond_to?(:config) ? config._settings : _settings) - - klass.instance_variable_set("@_settings", parent_settings) + subclass.instance_variable_set("@_settings", _settings.dup) + subclass.instance_variable_set("@_config", config.dup) if respond_to?(:config) end # Add a setting to the configuration @@ -71,12 +70,17 @@ module Dry # # @api public def config + # The _settings provided to the Config remain shared between the class and the + # Config. This allows settings defined _after_ accessing the config to become + # available in subsequent accesses to the config. The config is duped when + # subclassing to ensure it remains distinct between subclasses and parent classes + # (see `.inherited` above). @config ||= Config.new(_settings) end # @api private def __config_dsl__ - @dsl ||= DSL.new + @__config_dsl__ ||= DSL.new end # @api private diff --git a/lib/dry/configurable/config.rb b/lib/dry/configurable/config.rb index ba320d4..a8bdc31 100644 --- a/lib/dry/configurable/config.rb +++ b/lib/dry/configurable/config.rb @@ -23,7 +23,7 @@ module Dry # @api private def initialize(settings) - @_settings = settings.dup + @_settings = settings @_resolved = Concurrent::Map.new end @@ -50,16 +50,19 @@ module Dry # Update config with new values # - # @param values [Hash] A hash with new values + # @param values [Hash, #to_hash] A hash with new values # # @return [Config] # # @api public def update(values) values.each do |key, value| - case value - when Hash - self[key].update(value) + if self[key].is_a?(self.class) + unless value.respond_to?(:to_hash) + raise ArgumentError, "#{value.inspect} is not a valid setting value" + end + + self[key].update(value.to_hash) else self[key] = value end @@ -81,8 +84,8 @@ module Dry alias_method :to_h, :values # @api private - def finalize! - _settings.freeze + def finalize!(freeze_values: false) + _settings.finalize!(freeze_values: freeze_values) freeze end diff --git a/lib/dry/configurable/dsl.rb b/lib/dry/configurable/dsl.rb index db7a485..34c7d07 100644 --- a/lib/dry/configurable/dsl.rb +++ b/lib/dry/configurable/dsl.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "dry/configurable/constants" +require "dry/configurable/flags" require "dry/configurable/setting" require "dry/configurable/settings" require "dry/configurable/compiler" @@ -27,33 +28,105 @@ module Dry instance_exec(&block) if block end - # Register a new setting node and compile it into a setting object + # Registers a new setting node and compile it into a setting object # # @see ClassMethods.setting - # @api public + # @api private # @return Setting - def setting(name, default = Undefined, **options, &block) + def setting(name, default = Undefined, **options, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity unless VALID_NAME.match?(name.to_s) raise ArgumentError, "#{name} is not a valid setting name" end if default != Undefined - Dry::Core::Deprecations.announce( - "default value as positional argument to settings", - "Provide a `default:` keyword argument instead", - tag: "dry-configurable", - uplevel: 2 - ) + if Dry::Configurable.warn_on_setting_positional_default + Dry::Core::Deprecations.announce( + "default value as positional argument to settings", + "Provide a `default:` keyword argument instead", + tag: "dry-configurable", + uplevel: 2 + ) + end + options = options.merge(default: default) end + if RUBY_VERSION < "3.0" && + default == Undefined && + (valid_opts, invalid_opts = valid_and_invalid_options(options)) && + invalid_opts.any? && + valid_opts.none? + # In Ruby 2.6 and 2.7, when a hash is given as the second positional argument + # (i.e. the hash is intended to be the setting's default value), and there are + # no other keyword arguments given, the hash is assigned to the `options` + # variable instead of `default`. + # + # For example, for this setting: + # + # setting :hash_setting, {my_hash: true} + # + # We'll have a `default` of `Undefined` and an `options` of `{my_hash: true}` + # + # If any additional keyword arguments are provided, e.g.: + # + # setting :hash_setting, {my_hash: true}, reader: true + # + # Then we'll have a `default` of `{my_hash: true}` and an `options` of `{reader: + # true}`, which is what we want. + # + # To work around that first case and ensure our (deprecated) backwards + # compatibility holds for Ruby 2.6 and 2.7, we extract all invalid options from + # `options`, and if there are no remaining valid options (i.e. if there were no + # keyword arguments given), then we can infer the invalid options to be a + # default hash value for the setting. + # + # This approach also preserves the behavior of raising an ArgumentError when a + # distinct hash is _not_ intentionally provided as the second positional + # argument (i.e. it's not enclosed in braces), and instead invalid keyword + # arguments are given alongside valid ones. So this setting: + # + # setting :some_setting, invalid_option: true, reader: true + # + # Would raise an ArgumentError as expected. + # + # However, the one case we can't catch here is when invalid options are supplied + # without hash literal braces, but there are no other keyword arguments + # supplied. In this case, a setting like: + # + # setting :hash_setting, my_hash: true + # + # Is parsed identically to the first case described above: + # + # setting :hash_setting, {my_hash: true} + # + # So in both of these cases, the default value will become `{my_hash: true}`. We + # consider this unlikely to be a problem in practice, since users are not likely + # to be providing invalid options to `setting` and expecting them to be ignored. + # Additionally, the deprecation messages will make the new behavior obvious, and + # encourage the users to upgrade their setting definitions. + + if Dry::Configurable.warn_on_setting_positional_default + Dry::Core::Deprecations.announce( + "default value as positional argument to settings", + "Provide a `default:` keyword argument instead", + tag: "dry-configurable", + uplevel: 2 + ) + end + + options = {default: invalid_opts} + end + if block && !block.arity.zero? - Dry::Core::Deprecations.announce( - "passing a constructor as a block", - "Provide a `constructor:` keyword argument instead", - tag: "dry-configurable", - uplevel: 2 - ) + if Dry::Configurable.warn_on_setting_constructor_block + Dry::Core::Deprecations.announce( + "passing a constructor as a block", + "Provide a `constructor:` keyword argument instead", + tag: "dry-configurable", + uplevel: 2 + ) + end + options = options.merge(constructor: block) block = nil end @@ -77,8 +150,15 @@ module Dry return if options.none? invalid_keys = options.keys - Setting::OPTIONS + raise ArgumentError, "Invalid options: #{invalid_keys.inspect}" unless invalid_keys.empty? end + + # Returns a tuple of valid and invalid options hashes derived from the options hash + # given to the setting + def valid_and_invalid_options(options) + options.partition { |k, _| Setting::OPTIONS.include?(k) }.map(&:to_h) + end end end end diff --git a/lib/dry/configurable/flags.rb b/lib/dry/configurable/flags.rb new file mode 100644 index 0000000..3829e18 --- /dev/null +++ b/lib/dry/configurable/flags.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "dry/core/class_attributes" + +module Dry + module Configurable + extend Core::ClassAttributes + + # Set to false to suppress deprecation warning when a setting default is provided as a + # positional argument + defines :warn_on_setting_positional_default + warn_on_setting_positional_default true + + # Set to false to suppress deprecation warning when a setting constructor is provided + # as a block + defines :warn_on_setting_constructor_block + warn_on_setting_constructor_block true + end +end diff --git a/lib/dry/configurable/instance_methods.rb b/lib/dry/configurable/instance_methods.rb index c86085a..4870cb4 100644 --- a/lib/dry/configurable/instance_methods.rb +++ b/lib/dry/configurable/instance_methods.rb @@ -12,7 +12,11 @@ module Dry module Initializer # @api private def initialize(*) + # Dup settings at time of initializing to ensure setting values are specific to + # this instance. This does mean that any settings defined on the class _after_ + # initialization will not be available on the instance. @config = Config.new(self.class._settings.dup) + super end ruby2_keywords(:initialize) if respond_to?(:ruby2_keywords, true) @@ -34,9 +38,7 @@ module Dry # Finalize the config and freeze the object # # @api public - def finalize! - return self if frozen? - + def finalize!(freeze_values: false) super freeze end diff --git a/lib/dry/configurable/methods.rb b/lib/dry/configurable/methods.rb index c6bcff8..ec07bdf 100644 --- a/lib/dry/configurable/methods.rb +++ b/lib/dry/configurable/methods.rb @@ -21,10 +21,8 @@ module Dry # @return [Dry::Configurable::Config] # # @api public - def finalize! - return self if config.frozen? - - config.finalize! + def finalize!(freeze_values: false) + config.finalize!(freeze_values: freeze_values) self end end diff --git a/lib/dry/configurable/setting.rb b/lib/dry/configurable/setting.rb index dd36f02..5c1ada7 100644 --- a/lib/dry/configurable/setting.rb +++ b/lib/dry/configurable/setting.rb @@ -62,9 +62,17 @@ module Dry def initialize(name, input: Undefined, default: Undefined, **options) @name = name @writer_name = :"#{name}=" + @options = options + + # Setting collections (see `Settings`) are shared between the configurable class + # and its `config` object, so for cloneable individual settings, we duplicate + # their _values_ as early as possible to ensure no impact from unintended mutation @input = input @default = default - @options = options + if cloneable? + @input = input.dup + @default = default.dup + end evaluate if input_defined? end @@ -76,8 +84,12 @@ module Dry # @api private def value - @value ||= evaluate + return @value if evaluated? + + @value = constructor[Undefined.coalesce(input, default, nil)] end + alias_method :evaluate, :value + private :evaluate # @api private def evaluated? @@ -94,6 +106,16 @@ module Dry with(input: Undefined) end + # @api private + def finalize!(freeze_values: false) + if value.is_a?(Config) + value.finalize!(freeze_values: freeze_values) + elsif freeze_values + value.freeze + end + freeze + end + # @api private def with(new_opts) self.class.new(name, input: input, default: default, **options, **new_opts) @@ -141,11 +163,6 @@ module Dry @value = source.value.dup if source.evaluated? end end - - # @api private - def evaluate - @value = constructor[Undefined.coalesce(input, default, nil)] - end end end end diff --git a/lib/dry/configurable/settings.rb b/lib/dry/configurable/settings.rb index 3784e17..bab8334 100644 --- a/lib/dry/configurable/settings.rb +++ b/lib/dry/configurable/settings.rb @@ -54,6 +54,12 @@ module Dry self.class.new(map(&:pristine)) end + # @api private + def finalize!(freeze_values: false) + each { |element| element.finalize!(freeze_values: freeze_values) } + freeze + end + private # @api private diff --git a/lib/dry/configurable/version.rb b/lib/dry/configurable/version.rb index c009756..616ac55 100755 --- a/lib/dry/configurable/version.rb +++ b/lib/dry/configurable/version.rb @@ -3,6 +3,6 @@ module Dry module Configurable # @api public - VERSION = "0.13.0" + VERSION = "0.15.0" end end diff --git a/spec/integration/dry/configurable/config_spec.rb b/spec/integration/dry/configurable/config_spec.rb index ded560e..a375063 100644 --- a/spec/integration/dry/configurable/config_spec.rb +++ b/spec/integration/dry/configurable/config_spec.rb @@ -35,6 +35,27 @@ RSpec.describe Dry::Configurable::Config do expect(klass.config.db.user).to eql("jane") expect(klass.config.db.pass).to eql("supersecret") end + + it "runs constructors" do + klass.setting :db do + setting :user, default: "root", constructor: ->(v) { v.upcase } + setting :sslcert, constructor: ->(v) { v&.values_at(:pem, :pass)&.join } + end + + klass.config.update(db: {user: "jane", sslcert: {pem: "cert", pass: "qwerty"}}) + + expect(klass.config.db.user).to eql("JANE") + expect(klass.config.db.sslcert).to eql("certqwerty") + end + + it "raises ArgumentError when setting value is not a Hash" do + klass.setting :db do + setting :user + end + + expect { klass.config.update(db: "string") } + .to raise_error(ArgumentError, '"string" is not a valid setting value') + end end describe "#to_h" do diff --git a/spec/integration/dry/configurable/setting_spec.rb b/spec/integration/dry/configurable/setting_spec.rb index cb98112..82ca90c 100644 --- a/spec/integration/dry/configurable/setting_spec.rb +++ b/spec/integration/dry/configurable/setting_spec.rb @@ -147,7 +147,7 @@ RSpec.describe Dry::Configurable, ".setting" do context "with a ruby keyword" do before do - klass.setting :if, true + klass.setting :if, default: true end it "works" do @@ -185,6 +185,15 @@ RSpec.describe Dry::Configurable, ".setting" do include_context "configurable behavior" + specify "settings defined after accessing config are still available in the config" do + klass.setting :before, default: "defined before" + klass.config + klass.setting :after, default: "defined after" + + expect(klass.config.before).to eq "defined before" + expect(klass.config.after).to eq "defined after" + end + context "with a subclass" do let(:subclass) do Class.new(klass) @@ -205,7 +214,7 @@ RSpec.describe Dry::Configurable, ".setting" do it "allows defining more settings" do klass.setting :db, default: "sqlite" - subclass.setting :username, "root" + subclass.setting :username, default: "root" subclass.setting :password subclass.config.password = "secret" @@ -225,7 +234,7 @@ RSpec.describe Dry::Configurable, ".setting" do expect(subclass.settings).to eql(Set[:db]) end - it "configured parent copies config to the child" do + specify "configuring the parent before subclassing copies the config to the child" do klass.setting :db object.config.db = "mariadb" @@ -233,6 +242,16 @@ RSpec.describe Dry::Configurable, ".setting" do expect(subclass.config.db).to eql("mariadb") end + specify "configuring the parent after subclassing does not copy the config to the child" do + klass.setting :db + + subclass = Class.new(klass) + + object.config.db = "mariadb" + + expect(subclass.config.db).to be nil + end + it "not configured parent does not set child config" do klass.setting :db @@ -308,13 +327,13 @@ RSpec.describe Dry::Configurable, ".setting" do end it "defines a constructor that sets the config" do - klass.setting :db, "sqlite" + klass.setting :db, default: "sqlite" expect(object.config.db).to eql("sqlite") end it "creates distinct setting values across instances" do - klass.setting(:path, "test", constructor: ->(m) { Pathname(m) }) + klass.setting(:path, default: "test", constructor: ->(m) { Pathname(m) }) new_object = klass.new @@ -323,13 +342,29 @@ RSpec.describe Dry::Configurable, ".setting" do expect(object.config.path).not_to be(new_object.config.path) end + it "makes only settings defined before instantiation available" do + klass.setting :before, default: "defined before" + + object_1 = klass.new + + klass.setting :after, default: "defined after" + + object_2 = klass.new + + expect(object_1.config.before).to eq "defined before" + expect(object_1.config).not_to respond_to(:after) + + expect(object_2.config.before).to eq "defined before" + expect(object_2.config.after).to eq "defined after" + end + shared_examples "copying" do before do klass.setting :env klass.setting :db do - setting :user, "root" - setting :pass, "secret" + setting :user, default: "root".dup + setting :pass, default: "secret" end end @@ -365,7 +400,7 @@ RSpec.describe Dry::Configurable, ".setting" do end it "can be configured" do - klass.setting :db, "sqlite" + klass.setting :db, default: "sqlite" object.configure do |config| config.db = "mariadb" @@ -375,13 +410,35 @@ RSpec.describe Dry::Configurable, ".setting" do end it "can be finalized" do - klass.setting :db, "sqlite" + klass.setting :kafka, default: "kafka://127.0.0.1:9092" object.finalize! # becomes a no-op object.finalize! expect(object).to be_frozen + expect(object.config.db).to be_frozen + expect(object.config.db.user).not_to be_frozen + + object.config.db.user << "foo" + expect(object.config.db.user).to eq("rootfoo") + + # does not allow configure block anymore + expect { object.configure {} }.to raise_error(Dry::Configurable::FrozenConfig) + end + + it "can be finalized with freezing values" do + klass.setting :kafka, "kafka://127.0.0.1:9092" + + object.finalize!(freeze_values: true) + # becomes a no-op + object.finalize!(freeze_values: true) + + expect(object).to be_frozen + expect(object.config.db).to be_frozen + expect(object.config.db.user).to be_frozen + + expect { object.config.db.user << "foo" }.to raise_error(FrozenError) # does not allow configure block anymore expect { object.configure {} }.to raise_error(Dry::Configurable::FrozenConfig) @@ -389,7 +446,7 @@ RSpec.describe Dry::Configurable, ".setting" do it "defines a reader shortcut for nested config" do klass.setting :dsn, reader: true do - setting :pool, 5 + setting :pool, default: 5 end expect(object.dsn.pool).to be(5) @@ -398,10 +455,10 @@ RSpec.describe Dry::Configurable, ".setting" do context "Test Interface" do describe "reset_config" do it "resets configuration to default values" do - klass.setting :dsn, nil + klass.setting :dsn, default: nil klass.setting :pool do - setting :size, nil + setting :size, default: nil end object.enable_test_interface diff --git a/spec/unit/dry/configurable/dsl_spec.rb b/spec/unit/dry/configurable/dsl_spec.rb index bc93f81..f467a8d 100644 --- a/spec/unit/dry/configurable/dsl_spec.rb +++ b/spec/unit/dry/configurable/dsl_spec.rb @@ -29,7 +29,67 @@ RSpec.describe Dry::Configurable::DSL do expect(setting.name).to be(:user) expect(setting.value).to eql("root") logger.rewind - expect(logger.string).to match(/#{FileUtils.pwd}.*default value as positional argument to settings is deprecated/) + expect(logger.string).to match(/default value as positional argument to settings is deprecated/) + end + + it "compiles when giving a default as positional argument, and suppresses the warning when flagged off" do + Dry::Configurable.warn_on_setting_positional_default false + + logger = StringIO.new + Dry::Core::Deprecations.set_logger!(logger) + setting = dsl.setting :user, "root" + + expect(setting.name).to be(:user) + expect(setting.value).to eql("root") + logger.rewind + expect(logger.string).to be_empty + + Dry::Configurable.warn_on_setting_positional_default true + end + + it "compiles but deprecates giving a defalt hash value as a positional argument (without any keyword args)" do + # This test is necessary for behavior specific to Ruby 2.6 and 2.7 + + logger = StringIO.new + Dry::Core::Deprecations.set_logger!(logger) + + setting = dsl.setting :default_options, {foo: "bar"} + + expect(setting.name).to be(:default_options) + expect(setting.value).to eq(foo: "bar") + logger.rewind + expect(logger.string).to match(/default value as positional argument to settings is deprecated/) + + if RUBY_VERSION < "3.0" + logger = StringIO.new + Dry::Core::Deprecations.set_logger!(logger) + + setting = dsl.setting :default_options, foo: "bar" + + expect(setting.name).to be(:default_options) + expect(setting.value).to eq(foo: "bar") + logger.rewind + expect(logger.string).to match(/default value as positional argument to settings is deprecated/) + end + end + + it "compiles but deprecates giving a defalt hash value as a positional argument (with keyword args)" do + # This test is necessary for behavior specific to Ruby 2.6 and 2.7 + + logger = StringIO.new + Dry::Core::Deprecations.set_logger!(logger) + setting = dsl.setting :default_options, {foo: "bar"}, reader: true + + expect(setting.name).to be(:default_options) + expect(setting.value).to eq(foo: "bar") + logger.rewind + expect(logger.string).to match(/default value as positional argument to settings is deprecated/) + end + + it "does not infer a default hash value when non-valid keyword arguments are mixed in with valid keyword arguments" do + # This test is necessary for behavior specific to Ruby 2.6 and 2.7 + + expect { dsl.setting :default_options, foo: "bar", reader: true }.to raise_error ArgumentError, "Invalid options: [:foo]" end it "compiles a setting with a reader set" do @@ -71,7 +131,23 @@ RSpec.describe Dry::Configurable::DSL do expect(setting.name).to be(:dsn) expect(setting.value).to eql("jdbc:sqlite") logger.rewind - expect(logger.string).to match(/#{FileUtils.pwd}.*constructor as a block is deprecated/) + expect(logger.string).to match(/constructor as a block is deprecated/) + end + + it "supports but deprecates giving a constructor as a block, and suppresses the warning when flagged off" do + Dry::Configurable.warn_on_setting_constructor_block false + + logger = StringIO.new + Dry::Core::Deprecations.set_logger!(logger) + + setting = dsl.setting(:dsn, default: "sqlite") { |value| "jdbc:#{value}" } + + expect(setting.name).to be(:dsn) + expect(setting.value).to eql("jdbc:sqlite") + logger.rewind + expect(logger.string).to be_empty + + Dry::Configurable.warn_on_setting_constructor_block true end it "compiles a nested list of settings" do