fino 1.2.0 → 1.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fea3206069816874011b9d072929f796ad38c3c5645689fa75daf68638a0226
4
- data.tar.gz: e8f72e2779da8c910539001ff7c0f91b0d23d44a8b2f8975c984506d8023796e
3
+ metadata.gz: c47c47b3ab49c1eac2959185f67a6cc62e952936c7693f1e1d3d95843afaae88
4
+ data.tar.gz: da1f3fea78b8d9adbf58a3209c87e6c8acd717371dafe7618f9a2522e744fc0d
5
5
  SHA512:
6
- metadata.gz: bb7428b18c55226ed0c21f5de8d6cbfc219424a6986fd4ab208ae8ee4cd33fcb961fa3c112872c06b9c35a21d1148aafe1ff005e963d6c68facbf4eb4a5a5129
7
- data.tar.gz: fbbce319a638b28bf322f65683e998f83090d159c56b62861f9a3d0df4fce3cead018864fec4bb2c81f0a8de3313fd75747cbc2bcfa8316c707266952ddb1975
6
+ metadata.gz: bbadb8e1f7f954e7c1f0d9d1a0d7f6455236e67c6dfdaad87a02b9659b3a130f4c7be93cad53f9c486fad6cecd335a308e696ee82b91b1404f2a7fa31048c15c
7
+ data.tar.gz: '0797cdb045a602b6f9b79ce8f772f7085c857d9addb3d664d74f3a3790676afe046b4c5d81ad4d6b2865b4c244b6debc7bc2ac6ed87cb255adf66fea4a57d9be'
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Fino
2
2
 
3
- ⚠️ Fino is under active development. API changes are possible ⚠️
3
+ ⚠️ Fino in active development phase at wasn't properly battle tested in production just yet. Give us a star and stay tuned for Production test results and new features
4
4
 
5
5
  Fino is a dynamic settings engine for Ruby and Rails
6
6
 
@@ -23,10 +23,15 @@ Fino.configure do
23
23
  settings do
24
24
  setting :maintenance_mode, :boolean, default: false
25
25
 
26
+ setting :api_rate_limit,
27
+ :integer,
28
+ default: 1000,
29
+ description: "Maximum API requests per minute per user to prevent abuse"
30
+
26
31
  section :openai, label: "OpenAI" do
27
32
  setting :model,
28
33
  :string,
29
- default: "gpt-4o",
34
+ default: "gpt-5",
30
35
  description: "OpenAI model"
31
36
 
32
37
  setting :temperature,
@@ -51,7 +56,7 @@ end
51
56
  ### Work with settings
52
57
 
53
58
  ```ruby
54
- Fino.value(:model, at: :openai) #=> "gpt-4o"
59
+ Fino.value(:model, at: :openai) #=> "gpt-5"
55
60
  Fino.value(:temperature, at: :openai) #=> 0.7
56
61
 
57
62
  Fino.values(:model, :temperature, at: :openai) #=> ["gpt-4", 0.7]
@@ -60,12 +65,46 @@ Fino.set(model: "gpt-5", at: :openai)
60
65
  Fino.value(:model, at: :openai) #=> "gpt-5"
61
66
  ```
62
67
 
63
- ### Manage settings via UI
68
+ ### Overrides
64
69
 
65
70
  ```ruby
66
- gem "fino-rails"
71
+ Fino.value(:model, at: :openai) #=> "gpt-5"
72
+
73
+ Fino.set(model: "gpt-5", at: :openai, overrides: { "qa" => "our_local_model_not_to_pay_to_sam_altman" })
74
+
75
+ Fino.value(:model, at: :openai) #=> "gpt-5"
76
+ Fino.value(:model, at: :openai, for: "qa") #=> "our_local_model_not_to_pay_to_sam_altman"
77
+ ```
78
+
79
+ ### A/B testing
80
+
81
+ ```ruby
82
+ Fino.value(:model, at: :openai) #=> "gpt-5"
83
+
84
+ # "gpt-5" becomes the control variant value and a 20.0% variant is created with value "gpt-6"
85
+ Fino.set(model: "gpt-5", at: :openai, variants: { 20.0 => "gpt-6" })
86
+
87
+ Fino.setting(:model, at: :openai).experiment.variant(for: "user_1") #=> #<Fino::AbTesting::Variant percentage: 20.0, value: "gpt-6">
88
+
89
+ # Picked variant is sticked to the user
90
+ Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
91
+ Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
92
+
93
+ Fino.value(:model, at: :openai, for: "user_2") #=> "gpt-5"
67
94
  ```
68
95
 
96
+ ## Rails integration
97
+
98
+ Fino easily integrates with Rails. Just add the gem to your Gemfile:
99
+
100
+ ```
101
+ gem "fino-rails", require: false
102
+ ```
103
+
104
+ to get built-in UI engine for your settings!
105
+
106
+ ### UI engine
107
+
69
108
  Mount Fino Rails engine in your `config/routes.rb`:
70
109
 
71
110
  ```ruby
@@ -74,4 +113,84 @@ Rails.application.routes.draw do
74
113
  end
75
114
  ```
76
115
 
77
- <img width="1229" height="641" alt="Screenshot 2025-09-04 at 16 01 51" src="https://github.com/user-attachments/assets/646df84c-c25b-4890-9637-c481e18c9bd4" />
116
+ ### Configuration
117
+
118
+ ```ruby
119
+ Rails.application.configure do
120
+ config.fino.instrument = true
121
+ config.fino.log = true
122
+ config.fino.cache_within_request = false
123
+ config.fino.preload_before_request = true
124
+ end
125
+ ```
126
+
127
+ <img width="1493" height="676" alt="Screenshot 2025-09-19 at 13 09 06" src="https://github.com/user-attachments/assets/19b6147a-e18c-41cf-aac7-99111efcc9d5" />
128
+
129
+ <img width="1775" height="845" alt="Screenshot 2025-09-19 at 13 09 33" src="https://github.com/user-attachments/assets/c0010abd-285d-43d0-ae5d-ce0edb781309" />
130
+
131
+ ## Performance tweaks
132
+
133
+ 1. In Memory cache
134
+
135
+ Fino provides in-memory settings caching functionality which will store settings received from adaper in memory for
136
+ a very quick access. As this kind of cache is not distributed between machines, but belongs to each process
137
+ separately, it's impossible to invalidate all at once, so be aware that setting update application time will depend
138
+ on cache TTL you configure
139
+
140
+ ```ruby
141
+ Fino.configure do
142
+ # ...
143
+ cache { Fino::Cache::Memory.new(expires_in: 3.seconds) }
144
+ # ...
145
+ end
146
+ ```
147
+
148
+ 2. Request scoped cache
149
+
150
+ When using Fino in Rails context it's possible to cache settings within request, in current thread storage. This is
151
+ safe way to cache settings as it's lifetime is limited, thus it is enabled by default
152
+
153
+ ```ruby
154
+ Rails.application.configure do
155
+ config.fino.cache_within_request = true
156
+ end
157
+ ```
158
+
159
+ 3. Preloading
160
+
161
+ In Rails context it is possible to tell Fino to preload multiple settings before processing request in a single
162
+ adapter call. Preloading is recommended for requests that use multiple different settings in their logic
163
+
164
+ ```ruby
165
+ # Preload all settings
166
+ Rails.application.configure do
167
+ config.fino.preload_before_request = true
168
+ end
169
+
170
+ # Preload specific subset of settings depending on request
171
+ Rails.application.configure do
172
+ config.fino.preload_before_request = ->(request) {
173
+ case request.path
174
+ when "request/using/all/settings"
175
+ true
176
+ when "request/not/using/settings"
177
+ false
178
+ when "request/using/specific/settings"
179
+ [
180
+ :api_rate_limit,
181
+ openai: [:model, :temperature]
182
+ ]
183
+ end
184
+ }
185
+ end
186
+ ```
187
+
188
+ ## Releasing
189
+
190
+ `rake release`
191
+
192
+ ## Contributing
193
+
194
+ 1. Fork it
195
+ 2. Do contribution
196
+ 6. Create Pull Request into this repo
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Fino::AbTesting::Experiment
4
+ include Fino::PrettyInspectable
5
+
6
+ TOTAL_PERCENTAGE = 100.0
7
+
8
+ attr_reader :setting_definition
9
+
10
+ def initialize(setting_definition)
11
+ @setting_definition = setting_definition
12
+ @user_variants = []
13
+ end
14
+
15
+ def <<(variant)
16
+ @user_variants << variant
17
+
18
+ @variants = nil
19
+ @value_by_variant_id = nil
20
+ end
21
+
22
+ def variants
23
+ return @variants if @variants
24
+ return @variants = [] if @user_variants.empty?
25
+
26
+ @variants = [
27
+ Fino::AbTesting::Variant.new(
28
+ percentage: TOTAL_PERCENTAGE - @user_variants.sum(&:percentage),
29
+ value: Fino::AbTesting::Variant::CONTROL_VALUE
30
+ ),
31
+ *@user_variants
32
+ ]
33
+ end
34
+
35
+ def value(for:)
36
+ variant = variant(for: binding.local_variable_get(:for))
37
+
38
+ value_by_variant_id.fetch(
39
+ variant.id,
40
+ Fino::AbTesting::Variant::CONTROL_VALUE
41
+ )
42
+ end
43
+
44
+ def variant(for:)
45
+ Fino::AbTesting::VariantPicker.new(setting_definition).call(
46
+ variants,
47
+ binding.local_variable_get(:for)
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def value_by_variant_id
54
+ @value_by_variant_id ||= variants.each_with_object({}) do |variant, memo|
55
+ memo[variant.id] = variant.value
56
+ end
57
+ end
58
+
59
+ def inspectable_attributes
60
+ {
61
+ variants: variants
62
+ }
63
+ end
64
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Fino::AbTesting::Variant
4
+ CONTROL_VALUE = Class.new do
5
+ def inspect
6
+ "Fino::AbTesting::Variant::CONTROL_VALUE"
7
+ end
8
+ end.new
9
+
10
+ include Fino::PrettyInspectable
11
+
12
+ attr_reader :id, :percentage, :value
13
+
14
+ def initialize(percentage:, value:)
15
+ @id = SecureRandom.uuid
16
+
17
+ @percentage = percentage.to_f
18
+ @value = value
19
+ end
20
+
21
+ private
22
+
23
+ def inspectable_attributes
24
+ {
25
+ percentage: percentage,
26
+ value: value
27
+ }
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ class Fino::AbTesting::VariantPicker
6
+ SCALING_FACTOR = 1_000
7
+
8
+ attr_reader :setting_definition
9
+
10
+ def initialize(setting_definition)
11
+ @setting_definition = setting_definition
12
+ end
13
+
14
+ def call(variants, scope)
15
+ return nil if variants.empty?
16
+
17
+ random = Zlib.crc32("#{setting_definition.key}#{scope}") % (100 * SCALING_FACTOR)
18
+ cumulative = 0
19
+
20
+ picked_variant = variants.sort_by(&:percentage).find do |variant|
21
+ cumulative += variant.percentage * SCALING_FACTOR
22
+ random <= cumulative
23
+ end
24
+
25
+ Fino.logger.debug { "Variant picked: #{picked_variant}" }
26
+
27
+ picked_variant
28
+ end
29
+ end
data/lib/fino/adapter.rb CHANGED
@@ -9,7 +9,19 @@ module Fino::Adapter
9
9
  raise NotImplementedError
10
10
  end
11
11
 
12
- def write(setting_definition, value)
12
+ def write(setting_definition, value, overrides, variants)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def fetch_value_from(raw_adapter_data)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def fetch_raw_overrides_from(raw_adapter_data)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def fetch_raw_variants_from(raw_adapter_data)
13
25
  raise NotImplementedError
14
26
  end
15
27
  end
@@ -9,15 +9,15 @@ class Fino::Cache::Memory
9
9
  end
10
10
 
11
11
  def exist?(key)
12
+ expire_if_ready
13
+
12
14
  hash.key?(key)
13
- ensure
14
- expire_if_needed
15
15
  end
16
16
 
17
17
  def read(key)
18
+ expire_if_ready
19
+
18
20
  hash[key]
19
- ensure
20
- expire_if_needed
21
21
  end
22
22
 
23
23
  def write(key, value)
@@ -25,14 +25,20 @@ class Fino::Cache::Memory
25
25
  end
26
26
 
27
27
  def fetch(key, &block)
28
+ raise ArgumentError, "no block provided to #{self.class.name}#fetch" unless block
29
+
30
+ expire_if_ready
31
+
28
32
  hash.fetch(key) do
29
33
  write(key, block.call)
30
34
  end
31
- ensure
32
- expire_if_needed
33
35
  end
34
36
 
35
- def fetch_multi(keys, &block)
37
+ def fetch_multi(*keys, &block)
38
+ raise ArgumentError, "no block provided to #{self.class.name}#fetch_multi" unless block
39
+
40
+ expire_if_ready
41
+
36
42
  missing_keys = keys - hash.keys
37
43
 
38
44
  if missing_keys.any?
@@ -44,19 +50,21 @@ class Fino::Cache::Memory
44
50
  end
45
51
 
46
52
  hash.values_at(*keys)
47
- ensure
48
- expire_if_needed
49
53
  end
50
54
 
51
55
  def delete(key)
52
56
  hash.delete(key)
53
57
  end
54
58
 
59
+ def clear
60
+ hash.clear
61
+ end
62
+
55
63
  private
56
64
 
57
65
  attr_reader :hash, :expirator
58
66
 
59
- def expire_if_needed
67
+ def expire_if_ready
60
68
  expirator&.when_ready do
61
69
  Fino.logger.debug { "Expiring all cache entries" }
62
70
  hash.clear
@@ -11,4 +11,13 @@ class Fino::Definition::Section
11
11
  def label
12
12
  options.fetch(:label, name.to_s.capitalize)
13
13
  end
14
+
15
+ def eql?(other)
16
+ self.class.eql?(other.class) && name == other.name
17
+ end
18
+ alias == eql?
19
+
20
+ def hash
21
+ name.hash
22
+ end
14
23
  end
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Fino::Definition::Setting
4
+ TYPE_CLASSES = [
5
+ Fino::Settings::String,
6
+ Fino::Settings::Integer,
7
+ Fino::Settings::Float,
8
+ Fino::Settings::Boolean
9
+ ].freeze
10
+
11
+ SETTING_TYPE_TO_TYPE_CLASS_MAPPING = TYPE_CLASSES.each_with_object({}) do |klass, hash|
12
+ hash[klass.type_identifier] = klass
13
+ end.freeze
14
+
4
15
  attr_reader :setting_name, :section_definition, :type, :options
5
16
 
6
17
  def initialize(type:, setting_name:, section_definition: nil, **options)
@@ -10,20 +21,10 @@ class Fino::Definition::Setting
10
21
  @options = options
11
22
  end
12
23
 
13
- def type_class # rubocop:disable Metrics/MethodLength
14
- @type_class ||=
15
- case type
16
- when :string
17
- Fino::Settings::String
18
- when :integer
19
- Fino::Settings::Integer
20
- when :float
21
- Fino::Settings::Float
22
- when :boolean
23
- Fino::Settings::Boolean
24
- else
25
- raise "Unknown type #{type}"
26
- end
24
+ def type_class
25
+ @type_class ||= SETTING_TYPE_TO_TYPE_CLASS_MAPPING.fetch(type) do
26
+ raise ArgumentError, "Unknown setting type #{type}"
27
+ end
27
28
  end
28
29
 
29
30
  def default
@@ -43,4 +44,13 @@ class Fino::Definition::Setting
43
44
  def key
44
45
  @key ||= path.reverse.join("/")
45
46
  end
47
+
48
+ def eql?(other)
49
+ self.class.eql?(other.class) && key == other.key
50
+ end
51
+ alias == eql?
52
+
53
+ def hash
54
+ key.hash
55
+ end
46
56
  end
@@ -23,6 +23,6 @@ class Fino::Expirator
23
23
  end
24
24
 
25
25
  def current_timestamp
26
- Time.now.to_i
26
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
27
27
  end
28
28
  end
data/lib/fino/library.rb CHANGED
@@ -34,27 +34,47 @@ class Fino::Library
34
34
  pipeline.read_multi(setting_definitions)
35
35
  end
36
36
 
37
- def set(**setting_names_to_values)
38
- at = setting_names_to_values.delete(:at)
37
+ def set(**data)
38
+ at = data.delete(:at)
39
+ raw_overrides = data.delete(:overrides) || {}
40
+ raw_variants = data.delete(:variants) || {}
39
41
 
40
- scope = setting_names_to_values.delete(:scope)
41
- context = { scope: scope }.compact
42
+ setting_name, raw_value = data.first
43
+ setting_definition = build_setting_definition(setting_name, at: at)
44
+ value = setting_definition.type_class.deserialize(raw_value)
42
45
 
43
- setting_names_to_values.each do |setting_name, value|
44
- setting_definition = build_setting_definition(setting_name, at: at)
46
+ overrides = raw_overrides.transform_values { |v| setting_definition.type_class.deserialize(v) }
45
47
 
46
- pipeline.write(
47
- setting_definition,
48
- setting_definition.type_class.deserialize(value),
49
- **context
48
+ experiment = Fino::AbTesting::Experiment.new(setting_definition)
49
+
50
+ raw_variants.map do |percentage, value|
51
+ experiment << Fino::AbTesting::Variant.new(
52
+ percentage: percentage,
53
+ value: setting_definition.type_class.deserialize(value)
50
54
  )
51
55
  end
56
+
57
+ pipeline.write(
58
+ setting_definition,
59
+ value,
60
+ overrides,
61
+ experiment.variants
62
+ )
52
63
  end
53
64
 
54
- def slice(**mapping)
55
- setting_definitions = mapping.each_with_object([]) do |(section_name, setting_names), memo|
56
- Array(setting_names).each do |setting_name|
57
- memo << build_setting_definition(setting_name, at: section_name)
65
+ def slice(*settings)
66
+ setting_definitions = settings.each_with_object([]) do |symbol_or_hash, memo|
67
+ case symbol_or_hash
68
+ when Symbol
69
+ memo << build_setting_definition(symbol_or_hash)
70
+ when Hash
71
+ symbol_or_hash.each do |section_name, setting_names|
72
+ Array(setting_names).each do |setting_name|
73
+ memo << build_setting_definition(setting_name, at: section_name)
74
+ end
75
+ end
76
+ else
77
+ raise ArgumentError, "Settings to preload should be either symbols or hashes"
58
78
  end
59
79
  end
60
80
 
@@ -66,7 +86,7 @@ class Fino::Library
66
86
  attr_reader :configuration
67
87
 
68
88
  def build_setting_definition(setting_name, at: nil)
69
- configuration.registry.setting_definition(setting_name.to_s, at&.to_s)
89
+ configuration.registry.setting_definition!(setting_name.to_s, at&.to_s)
70
90
  end
71
91
 
72
92
  def pipeline
@@ -15,15 +15,15 @@ class Fino::Pipe::Cache
15
15
  end
16
16
 
17
17
  def read_multi(setting_definitions)
18
- cache.fetch_multi(setting_definitions.map(&:key)) do |missing_keys|
18
+ cache.fetch_multi(*setting_definitions.map(&:key)) do |missing_keys|
19
19
  uncached_setting_definitions = setting_definitions.filter { |sd| missing_keys.include?(sd.key) }
20
20
 
21
21
  missing_keys.zip(pipe.read_multi(uncached_setting_definitions))
22
22
  end
23
23
  end
24
24
 
25
- def write(setting_definition, value, **context)
26
- pipe.write(setting_definition, value, **context)
25
+ def write(setting_definition, value, overrides, variants)
26
+ pipe.write(setting_definition, value, overrides, variants)
27
27
 
28
28
  cache.delete(setting_definition.key)
29
29
  end
@@ -17,8 +17,8 @@ class Fino::Pipe::Storage
17
17
  end
18
18
  end
19
19
 
20
- def write(setting_definition, value, **context)
21
- adapter.write(setting_definition, value, **context)
20
+ def write(setting_definition, value, overrides, variants)
21
+ adapter.write(setting_definition, value, overrides, variants)
22
22
  end
23
23
 
24
24
  private
@@ -27,12 +27,9 @@ class Fino::Pipe::Storage
27
27
 
28
28
  def to_setting(setting_definition, raw_adapter_data)
29
29
  raw_value = adapter.fetch_value_from(raw_adapter_data)
30
- scoped_raw_values = adapter.fetch_scoped_values_from(raw_adapter_data)
30
+ raw_overrides = adapter.fetch_raw_overrides_from(raw_adapter_data)
31
+ raw_variants = adapter.fetch_raw_variants_from(raw_adapter_data)
31
32
 
32
- setting_definition.type_class.build(
33
- setting_definition,
34
- raw_value,
35
- scoped_raw_values
36
- )
33
+ Fino::SettingBuilder.new(setting_definition).call(raw_value, raw_overrides, raw_variants)
37
34
  end
38
35
  end
data/lib/fino/pipe.rb CHANGED
@@ -13,7 +13,7 @@ module Fino::Pipe
13
13
  raise NotImplementedError
14
14
  end
15
15
 
16
- def write(setting_definition, value, **context)
16
+ def write(setting_definition, value, overrides, variants)
17
17
  raise NotImplementedError
18
18
  end
19
19
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fino::PrettyInspectable
4
+ def inspect
5
+ attributes = inspectable_attributes.map do |key, value|
6
+ "#{key}=#{value.inspect}"
7
+ end
8
+
9
+ "#<#{self.class.name} #{attributes.join(', ')}>"
10
+ end
11
+
12
+ def pretty_print(pp) # rubocop:disable Metrics/MethodLength
13
+ pp.object_group(self) do
14
+ pp.nest(1) do
15
+ pp.breakable
16
+ pp.seplist(inspectable_attributes, nil, :each) do |key, value|
17
+ pp.group do
18
+ pp.text key.to_s
19
+ pp.text ": "
20
+ pp.pp value
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def inspectable_attributes
30
+ raise NotImplementedError
31
+ end
32
+ end
data/lib/fino/registry.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Fino::Registry
4
+ DuplicateSetting = Class.new(Fino::Error)
5
+ UnknownSetting = Class.new(Fino::Error)
6
+
4
7
  class DSL
5
8
  class SectionDSL
6
9
  def initialize(section_definition, registry)
@@ -46,22 +49,24 @@ class Fino::Registry
46
49
  end
47
50
  end
48
51
 
49
- UnknownSetting = Class.new(Fino::Error)
50
-
51
52
  using Fino::Ext::Hash
52
53
 
53
54
  attr_reader :setting_definitions, :section_definitions
54
55
 
55
56
  def initialize
56
- @setting_definitions = []
57
+ @setting_definitions = Set.new
57
58
  @setting_definitions_by_path = {}
58
59
 
59
- @section_definitions = []
60
+ @section_definitions = Set.new
60
61
  @section_definitions_by_name = {}
61
62
  end
62
63
 
63
64
  def setting_definition(*path)
64
- @setting_definitions_by_path.dig(*path.compact.reverse).tap do |definition|
65
+ @setting_definitions_by_path.dig(*path.compact.reverse.map(&:to_s))
66
+ end
67
+
68
+ def setting_definition!(*path)
69
+ setting_definition(*path).tap do |definition|
65
70
  raise UnknownSetting, "Unknown setting: #{path.compact.join('.')}" unless definition
66
71
  end
67
72
  end
@@ -82,13 +87,16 @@ class Fino::Registry
82
87
  end
83
88
 
84
89
  def register(setting_definition)
85
- @setting_definitions << setting_definition
90
+ unless @setting_definitions.add?(setting_definition)
91
+ raise DuplicateSetting, "#{setting_definition.setting_name} is already registered at #{setting_definition.key}"
92
+ end
86
93
 
87
94
  @setting_definitions_by_path.deep_set(setting_definition, *setting_definition.path.map(&:to_s))
88
95
  end
89
96
 
90
97
  def register_section(section_definition)
91
- @section_definitions << section_definition
92
- @section_definitions_by_name.deep_set(section_definition, section_definition.name.to_s)
98
+ return unless @section_definitions.add?(section_definition)
99
+
100
+ @section_definitions_by_name[section_definition.name.to_s] = section_definition
93
101
  end
94
102
  end
data/lib/fino/setting.rb CHANGED
@@ -1,11 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fino::Setting
4
+ include Fino::PrettyInspectable
5
+
4
6
  def self.included(base)
5
7
  base.extend(ClassMethods)
6
8
  end
7
9
 
8
10
  module ClassMethods
11
+ def type_identifier=(identifier)
12
+ @type_identifier = identifier
13
+ end
14
+
15
+ def type_identifier
16
+ @type_identifier
17
+ end
18
+
9
19
  def serialize(value)
10
20
  raise NotImplementedError
11
21
  end
@@ -13,25 +23,28 @@ module Fino::Setting
13
23
  def deserialize(raw_value)
14
24
  raise NotImplementedError
15
25
  end
26
+ end
16
27
 
17
- def build(setting_definition, raw_value, scoped_raw_values)
18
- value = raw_value.equal?(Fino::EMPTINESS) ? setting_definition.options[:default] : deserialize(raw_value)
19
- scoped_values = scoped_raw_values.transform_values { |v| deserialize(v) }
28
+ attr_reader :definition, :global_value, :overrides, :experiment
20
29
 
21
- new(
22
- setting_definition,
23
- value,
24
- scoped_values
25
- )
26
- end
30
+ def initialize(definition, global_value, overrides = {}, experiment = nil)
31
+ @definition = definition
32
+ @global_value = global_value
33
+ @overrides = overrides
34
+ @experiment = experiment
27
35
  end
28
36
 
29
- attr_reader :definition
37
+ def value(**context)
38
+ return global_value unless (scope = context[:for])
30
39
 
31
- def initialize(definition, value, scoped_values = {})
32
- @definition = definition
33
- @value = value
34
- @scoped_values = scoped_values
40
+ overrides.fetch(scope.to_s) do
41
+ return global_value unless experiment
42
+
43
+ value = experiment.value(for: scope)
44
+ return global_value if value == Fino::AbTesting::Variant::CONTROL_VALUE
45
+
46
+ value
47
+ end
35
48
  end
36
49
 
37
50
  def name
@@ -42,18 +55,6 @@ module Fino::Setting
42
55
  definition.key
43
56
  end
44
57
 
45
- def value(scope: nil)
46
- scope ? scoped_values.fetch(scope.to_s, @value) : @value
47
- end
48
-
49
- def overriden_scopes
50
- scoped_values.keys
51
- end
52
-
53
- def scope_overrides
54
- scoped_values
55
- end
56
-
57
58
  def type
58
59
  definition.type
59
60
  end
@@ -78,18 +79,17 @@ module Fino::Setting
78
79
  definition.description
79
80
  end
80
81
 
81
- def inspect
82
- attributes = [
83
- "key=#{key.inspect}",
84
- "type=#{type_class.inspect}",
85
- "value=#{value.inspect}",
86
- "default=#{default.inspect}"
87
- ]
88
-
89
- "#<#{self.class.name} #{attributes.join(', ')}>"
90
- end
91
-
92
82
  private
93
83
 
94
- attr_reader :scoped_values
84
+ def inspectable_attributes
85
+ {
86
+ key: key,
87
+ type: type_class,
88
+ default: default,
89
+ global_value: global_value
90
+ }.tap do |attributes|
91
+ attributes[:overrides] = overrides if overrides.present?
92
+ attributes[:experiment] = experiment if experiment
93
+ end
94
+ end
95
95
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Fino::SettingBuilder
4
+ attr_reader :setting_definition
5
+
6
+ def initialize(setting_definition)
7
+ @setting_definition = setting_definition
8
+ end
9
+
10
+ def call(raw_value, raw_overrides, raw_variants)
11
+ global_value = deserialize_global_value(raw_value)
12
+ overrides = deserialize_overrides(raw_overrides)
13
+ experiment = deserialize_experiment(raw_variants)
14
+
15
+ setting_definition.type_class.new(
16
+ setting_definition,
17
+ global_value,
18
+ overrides,
19
+ experiment
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def deserialize_global_value(raw_value)
26
+ return setting_definition.options[:default] if raw_value.equal?(Fino::EMPTINESS)
27
+
28
+ deserialize(raw_value)
29
+ end
30
+
31
+ def deserialize_overrides(raw_overrides)
32
+ raw_overrides.transform_values { |v| deserialize(v) }
33
+ end
34
+
35
+ def deserialize_experiment(raw_variants)
36
+ return if raw_variants.empty?
37
+
38
+ Fino::AbTesting::Experiment.new(setting_definition).tap do |experiment|
39
+ raw_variants.each do |raw_variant|
40
+ experiment << Fino::AbTesting::Variant.new(
41
+ percentage: raw_variant.fetch(:percentage),
42
+ value: deserialize(raw_variant.fetch(:value))
43
+ )
44
+ end
45
+ end
46
+ end
47
+
48
+ def deserialize(value)
49
+ setting_definition.type_class.deserialize(value)
50
+ end
51
+ end
@@ -3,6 +3,8 @@
3
3
  class Fino::Settings::Boolean
4
4
  include Fino::Setting
5
5
 
6
+ self.type_identifier = :boolean
7
+
6
8
  class << self
7
9
  def serialize(value)
8
10
  value ? "1" : "0"
@@ -3,6 +3,8 @@
3
3
  class Fino::Settings::Float
4
4
  include Fino::Setting
5
5
 
6
+ self.type_identifier = :float
7
+
6
8
  class << self
7
9
  def serialize(value)
8
10
  value.to_s
@@ -3,6 +3,8 @@
3
3
  class Fino::Settings::Integer
4
4
  include Fino::Setting
5
5
 
6
+ self.type_identifier = :integer
7
+
6
8
  class << self
7
9
  def serialize(value)
8
10
  value.to_s
@@ -3,6 +3,8 @@
3
3
  class Fino::Settings::String
4
4
  include Fino::Setting
5
5
 
6
+ self.type_identifier = :string
7
+
6
8
  class << self
7
9
  def serialize(value)
8
10
  value
data/lib/fino/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fino
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
5
5
  REQUIRED_RUBY_VERSION = ">= 3.0.0"
6
6
  end
data/lib/fino.rb CHANGED
@@ -4,12 +4,30 @@ require "forwardable"
4
4
  require "zeitwerk"
5
5
 
6
6
  module Fino
7
- module Configurable
7
+ module Stateful
8
8
  def configure(&block)
9
9
  configuration.instance_eval(&block)
10
10
  end
11
11
 
12
- private
12
+ def reset!
13
+ Thread.current[:fino_library] = nil
14
+
15
+ @registry = nil
16
+ @configuration = nil
17
+ end
18
+
19
+ def reconfigure(&block)
20
+ reset!
21
+ configure(&block)
22
+ end
23
+
24
+ def library
25
+ Thread.current[:fino_library] ||= Fino::Library.new(configuration)
26
+ end
27
+
28
+ def registry
29
+ @registry ||= Fino::Registry.new
30
+ end
13
31
 
14
32
  def configuration
15
33
  @configuration ||= Fino::Configuration.new(registry)
@@ -27,28 +45,18 @@ module Fino
27
45
  :slice,
28
46
  :set
29
47
 
30
- module_function
31
-
32
48
  def library
33
49
  raise NotImplementedError
34
50
  end
35
51
  end
36
52
 
37
- extend Configurable
38
53
  extend SettingsAccessible
54
+ extend Stateful
39
55
 
40
56
  EMPTINESS = Object.new.freeze
41
57
 
42
58
  module_function
43
59
 
44
- def library
45
- Thread.current[:fino_library] ||= Fino::Library.new(configuration)
46
- end
47
-
48
- def registry
49
- @registry ||= Fino::Registry.new
50
- end
51
-
52
60
  def logger
53
61
  @logger ||= begin
54
62
  require "logger"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fino
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Egor Iskrenkov
@@ -32,6 +32,9 @@ files:
32
32
  - LICENSE
33
33
  - README.md
34
34
  - lib/fino.rb
35
+ - lib/fino/ab_testing/experiment.rb
36
+ - lib/fino/ab_testing/variant.rb
37
+ - lib/fino/ab_testing/variant_picker.rb
35
38
  - lib/fino/adapter.rb
36
39
  - lib/fino/cache.rb
37
40
  - lib/fino/cache/memory.rb
@@ -47,8 +50,10 @@ files:
47
50
  - lib/fino/pipe/cache.rb
48
51
  - lib/fino/pipe/storage.rb
49
52
  - lib/fino/pipeline.rb
53
+ - lib/fino/pretty_inspectable.rb
50
54
  - lib/fino/registry.rb
51
55
  - lib/fino/setting.rb
56
+ - lib/fino/setting_builder.rb
52
57
  - lib/fino/settings/boolean.rb
53
58
  - lib/fino/settings/float.rb
54
59
  - lib/fino/settings/integer.rb