locallingo 0.5.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b218e7422188db797e061b9cf1133b1b25a630e9e4c6237c04f0f7fa94dd2b4f
4
- data.tar.gz: 742d0794ade8f32f9346d4a02b61554b7cb81321569c5297f9d93c3f1639f4e3
3
+ metadata.gz: 47b5cfaffa1567e536de600f6f963538606fc386a89b61e90ad9cc4dd8327132
4
+ data.tar.gz: 767fa1b9c49c66a0b5b5e1bd6140edbca50edade201f415c9bfde2c6f0448206
5
5
  SHA512:
6
- metadata.gz: 48e0a2e2625a03fc0d78e9b5a9884efc4ec0ee7525533f150865d7bb391c473edd96f44bbca6974a24a6e564d4d08e644a0b36403a5c8a51b28a0943d54c1d1c
7
- data.tar.gz: 4f08c9b89ab57351c5099ff6e31c4c24fa6b33a16df8ec0b70cb0ba365e292d90ef0fbe047d4b5494b9c2c7abe0627657c6ce4b545fd4a4552150f7338fd159d
6
+ metadata.gz: fad2120d8032a4a019dda5f6c82cd2f597de9dd79f9bb4eab1b9026530c859316c232314325398f51e12d61fc043c95882960969d5be325a80e6676463b55862
7
+ data.tar.gz: ff18516ac297ff0612111d0326552e6e771a6e0299854c598f3ea5645e9baaf722dd80e2571fc68186f3225af3eb35a69dc496107904feedd2cfc9d7356b1e8a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.0]
8
+
9
+ ### Added
10
+ - `Locallingo::Configuration.parse(text, root_path:, package:)` resolves
11
+ config from YAML text (ERB included) instead of `.locallingo.yml`, merged
12
+ over the shipped defaults the same way. (#35)
13
+ - Concurrency conformance, development only (no new runtime dependency): CI
14
+ runs the suite on Ruby 4.0 too, and every pull request runs
15
+ conformance-kit 0.4's Conformance workflow: thread and fiber contention
16
+ examples, a Ractor canary of the pure core on Ruby 4.0, and a strict audit
17
+ of the gem's shared state. (#35)
18
+
19
+ ### Changed
20
+ - Settings are frozen after boot, with nothing to call: the default settings
21
+ are frozen at load, and `Locallingo.configure` (a Rails initializer, or the
22
+ CLI's `.locallingo.rb`) publishes them frozen, in one assignment, so a
23
+ concurrent reader sees the old settings or the new, never half of them.
24
+ Concurrent `configure` calls run one at a time, so neither drops the
25
+ other's keys. Change them by calling `configure` again: **writing to
26
+ `Locallingo.settings` directly now raises `FrozenError`.** String keys are
27
+ stored as frozen plain String copies (a String subclass or its ivars are not
28
+ kept), so the settings are Ractor-shareable. A callable
29
+ key is kept as it is and never makes `configure` raise, even when it closes
30
+ over app state (a logger, a vault client); it keeps the settings readable
31
+ on the main Ractor only. (#35)
32
+ - `translates_content` freezes its declaration at boot: the column and state
33
+ names and a String or Array `source_locale:`/`target_locales:` (as frozen
34
+ copies; the objects passed in are not frozen), and the drift. **Mutating them
35
+ through `locallingo_translatable` now raises `FrozenError`.** Callables
36
+ (`source_locale:`, `target_locales:`, `translatable:`, `hasher:`) are kept
37
+ as they are and never make the declaration raise. (#35)
38
+
39
+ ### Fixed
40
+ - Without a `RubyLLM::Context`, a translation or quality-review call no
41
+ longer writes the resolved key into the process-global `RubyLLM.config`;
42
+ each call runs on a context of its own. Two translators with different keys
43
+ in one process could race, and one chat with the other's key. (#35)
44
+ - `after_translate` hooks run via `system(command, chdir:)` instead of inside
45
+ `Dir.chdir`, which moved the working directory of the whole process while a
46
+ hook ran. (#35)
47
+ - The static quality checks and `Configuration` (its ERB included) run inside
48
+ a non-main Ractor on Ruby 4.0: nested constants are frozen, and ERB no
49
+ longer evaluates on `TOPLEVEL_BINDING` (templates still resolve constants
50
+ from the top level, as the app sees them). (#35)
51
+
7
52
  ## [Unreleased]
8
53
 
9
54
  ### Added
data/README.md CHANGED
@@ -46,7 +46,12 @@ end
46
46
  Put that in a `.locallingo.rb` file next to `.locallingo.yml` and the `lingo`
47
47
  CLI loads it on start — no Rails boot required. A key set via
48
48
  `Locallingo.configure` wins over one set through `RubyLLM.configure`, which
49
- wins over ENV. Locallingo never stores keys itself.
49
+ wins over ENV. Locallingo never stores keys itself. `configure` publishes the
50
+ settings frozen: change them by calling it again, never by writing to
51
+ `Locallingo.settings`. Nothing else needs calling at boot. With String keys
52
+ the settings are Ractor-shareable; a callable key is kept as it is (resolved on
53
+ every use, so it may close over a logger or a vault client), which keeps the
54
+ settings readable on the main Ractor only.
50
55
 
51
56
  ## Configuration
52
57
 
@@ -146,6 +151,10 @@ Locallingo::Manager.new(config:).validate # => [violations]
146
151
  Locallingo::QualityChecker.new(config:).check(locale: "en")
147
152
  ```
148
153
 
154
+ `Locallingo::Configuration.parse(yaml, root_path:, package:)` resolves the same
155
+ config from YAML text (ERB included) instead of reading `.locallingo.yml`. A
156
+ configuration loaded from the file reads it when it is built, never cached.
157
+
149
158
  ### Translating text that is not in locale files
150
159
 
151
160
  `Locallingo::Translator` runs the same prompt (rules, glossary, language guide),
@@ -170,9 +179,9 @@ result.translations # => { "title" => "Hallo", "intro" => "Etwas **Markdown**."
170
179
  result.failed # => keys that failed after retries
171
180
  ```
172
181
 
173
- - With a `context:`, every call uses that `RubyLLM::Context`'s config; the
174
- global `RubyLLM.config` is never written, so tenants can translate
175
- concurrently.
182
+ - Every call runs on a `RubyLLM::Context`: the `context:` given, else one
183
+ built for that call from the resolved key. The global `RubyLLM.config` is
184
+ never written, so tenants can translate concurrently.
176
185
  - `MissingCredentialsError` is raised before any call when no key is found.
177
186
  - `translate.max_batch_chars` (or `max_batch_chars:`) limits the source
178
187
  characters per batch (a single longer value is sent alone, not split); `translate.pause` (or `pause:`) is the sleep after each
@@ -237,6 +246,10 @@ Locallingo::RecordTranslator.new(translator:)
237
246
  for the next run (your fallback to the source locale applies meanwhile).
238
247
  - Writes use `update_columns`; pass `write: ->(record, attributes) { … }` to
239
248
  bump a lock version or run validations.
249
+ - `translates_content` freezes the declaration as it is made, at boot: the
250
+ column and state names, a String or Array locale option (as a frozen copy)
251
+ and the drift. Callables are kept as they are, so they may close over app
252
+ state; like the model itself, a declaration is read on the main Ractor.
240
253
 
241
254
  ## License
242
255
 
@@ -29,20 +29,39 @@ module Locallingo
29
29
  Definition = Data.define(:column, :state, :source_locale, :target_locales, :translatable, :drift)
30
30
 
31
31
  included do
32
- class_attribute :locallingo_translatable, instance_writer: false
32
+ # The model's declaration: written once by translates_content in its class
33
+ # body at boot (main Ractor), and only read after that.
34
+ class_attribute :locallingo_translatable, instance_writer: false # rubocop:disable ThreadSafety/ClassAndModuleAttributes
33
35
  end
34
36
 
35
37
  class_methods do
38
+ # The declaration is frozen as it is made, at boot: the column and state
39
+ # names and a String/Symbol/Array locale option (frozen copies; the
40
+ # caller's own objects are left alone), and the Drift. Callables (locale options,
41
+ # translatable:, hasher:) are kept as they are, so a declaration holding
42
+ # one is read on the main Ractor only, like the model itself.
36
43
  def translates_content(column, state:, source_locale:, target_locales:, translatable: nil, hasher: nil)
37
44
  self.locallingo_translatable = Definition.new(
38
- column: column.to_s, state: state.to_s, source_locale:, target_locales:,
45
+ column: Translatable.frozen_option(column.to_s), state: Translatable.frozen_option(state.to_s),
46
+ source_locale: Translatable.frozen_option(source_locale),
47
+ target_locales: Translatable.frozen_option(target_locales),
39
48
  translatable: translatable || ->(_path, _value) { true },
40
- drift: hasher ? Drift.new(hasher:) : Drift.new
49
+ drift: (hasher ? Drift.new(hasher:) : Drift.new).freeze
41
50
  )
42
51
  before_save :mark_hand_edits
43
52
  end
44
53
  end
45
54
 
55
+ # A frozen copy of a non-callable declaration option (a String, or an Array
56
+ # of them); a callable or an already frozen value comes back as it is.
57
+ def self.frozen_option(value)
58
+ case value
59
+ when Array then value.map { |item| frozen_option(item) }.freeze
60
+ when String then value.frozen? ? value : value.dup.freeze
61
+ else value
62
+ end
63
+ end
64
+
46
65
  def translation_source_locale = resolve_translation_option(locallingo_translatable.source_locale).to_s
47
66
 
48
67
  def translation_target_locales
@@ -14,9 +14,12 @@ module Locallingo
14
14
  #
15
15
  # Typed readers expose everything the Manager/QualityChecker/CLI need, so no
16
16
  # other class parses raw config hashes.
17
+ #
18
+ # The file is read when a Configuration is built (the CLI builds one per run)
19
+ # and never cached in the process, so an edit applies to the next run.
17
20
  class Configuration
18
21
  CONFIG_FILENAMES = %w[.locallingo.yml .locallingo.yaml].freeze
19
- DEFAULT_CONFIG_PATH = File.expand_path("../../config/locallingo.default.yml", __dir__)
22
+ DEFAULT_CONFIG_PATH = File.expand_path("../../config/locallingo.default.yml", __dir__).freeze
20
23
 
21
24
  attr_reader :root_path, :package, :data
22
25
 
@@ -26,10 +29,18 @@ module Locallingo
26
29
  new(root_path:, package:)
27
30
  end
28
31
 
29
- def initialize(root_path: Dir.pwd, package: nil, data: nil)
32
+ # Resolve config from YAML +text+ (what a `.locallingo.yml` holds, ERB
33
+ # included) instead of the file under +root_path+, merged over the shipped
34
+ # defaults the same way.
35
+ def self.parse(text, root_path: Dir.pwd, package: nil)
36
+ new(root_path:, package:, source: text)
37
+ end
38
+
39
+ # +source+ is YAML text read in place of the config file (see .parse).
40
+ def initialize(root_path: Dir.pwd, package: nil, data: nil, source: nil)
30
41
  @root_path = File.expand_path(root_path)
31
42
  @package = package
32
- @data = data || resolve
43
+ @data = data || resolve(source)
33
44
  end
34
45
 
35
46
  # --- locations -----------------------------------------------------------
@@ -113,9 +124,10 @@ module Locallingo
113
124
  def dig(*keys) = data.dig(*keys)
114
125
 
115
126
  # Merge: shipped defaults <- user `defaults:` <- selected `packages:` entry.
116
- def resolve
117
- merged = deep_merge(shipped_defaults, user_defaults)
118
- merged = deep_merge(merged, package_overrides) if package
127
+ def resolve(source)
128
+ document, origin = user_config(source)
129
+ merged = deep_merge(shipped_defaults, document.fetch("defaults", {}) || {})
130
+ merged = deep_merge(merged, package_overrides(document, origin)) if package && origin
119
131
  merged
120
132
  end
121
133
 
@@ -123,20 +135,19 @@ module Locallingo
123
135
  load_yaml(DEFAULT_CONFIG_PATH).fetch("defaults", {})
124
136
  end
125
137
 
126
- def user_defaults
127
- file = config_file
128
- return {} unless file
138
+ # The user's config and where it came from: the +source+ text, else the
139
+ # config file under root_path, else nothing.
140
+ def user_config(source)
141
+ return [parse_yaml(source), "the given YAML"] if source
129
142
 
130
- load_yaml(file).fetch("defaults", {}) || {}
131
- end
132
-
133
- def package_overrides
134
143
  file = config_file
135
- return {} unless file
144
+ file ? [load_yaml(file), file] : [{}, nil]
145
+ end
136
146
 
137
- packages = load_yaml(file).fetch("packages", []) || []
147
+ def package_overrides(document, origin)
148
+ packages = document.fetch("packages", []) || []
138
149
  entry = packages.find { |p| p["path"] == package }
139
- raise Error, "No package #{package.inspect} in #{file}" unless entry
150
+ raise Error, "No package #{package.inspect} in #{origin}" unless entry
140
151
 
141
152
  entry.except("path")
142
153
  end
@@ -147,10 +158,19 @@ module Locallingo
147
158
  .find { |path| File.exist?(path) }
148
159
  end
149
160
 
150
- def load_yaml(path)
151
- YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
161
+ def load_yaml(path) = parse_yaml(File.read(path))
162
+
163
+ def parse_yaml(text)
164
+ YAML.safe_load(ERB.new(text).result(erb_binding), aliases: true) || {}
152
165
  end
153
166
 
167
+ # ERB's default binding is TOPLEVEL_BINDING, which a non-main Ractor cannot
168
+ # reach; a blank object's binding evaluates the same templates. The string
169
+ # form, not a block: a block would resolve constants from inside
170
+ # Locallingo::Configuration (`Settings` would be Locallingo::Settings), the
171
+ # string resolves them from the top level, as TOPLEVEL_BINDING did.
172
+ def erb_binding = Object.new.instance_eval("binding", __FILE__, __LINE__)
173
+
154
174
  def deep_merge(base, override)
155
175
  base.merge(override) do |_key, base_val, override_val|
156
176
  if base_val.is_a?(Hash) && override_val.is_a?(Hash)
@@ -163,11 +163,12 @@ module Locallingo
163
163
  combined
164
164
  end
165
165
 
166
- # Run the configured after_translate hook commands (from the app root).
166
+ # Run the configured after_translate hook commands (from the app root, in
167
+ # the child process only: the process-wide cwd is never changed).
167
168
  def run_after_translate_hooks
168
169
  config.after_translate.each do |command|
169
170
  log("Running: #{command}")
170
- Dir.chdir(config.root_path) { system(command) }
171
+ system(command, chdir: config.root_path)
171
172
  end
172
173
  end
173
174
 
@@ -19,10 +19,11 @@ module Locallingo
19
19
  # that support caching (Anthropic, OpenAI-compatible) bill the repeated
20
20
  # prefix at the cached rate.
21
21
  #
22
- # Given a `RubyLLM::Context` (`RubyLLM.context { |c| c.anthropic_api_key = … }`),
23
- # every chat runs on that context's own configuration: the global
24
- # `RubyLLM.config` is never written, so translators with different keys
25
- # (one per tenant) can run concurrently.
22
+ # Every chat runs on a `RubyLLM::Context`: the one given
23
+ # (`RubyLLM.context { |c| c.anthropic_api_key = … }`), else a fresh one built
24
+ # for that chat from the resolved key. The global `RubyLLM.config` is never
25
+ # written, so translators with different keys (one per tenant) can run
26
+ # concurrently.
26
27
  class RubyLLM
27
28
  # Maps a RubyLLM provider symbol to the ENV var whose presence indicates
28
29
  # credentials are available, so we can fail fast with a clear message
@@ -82,30 +83,24 @@ module Locallingo
82
83
  private
83
84
 
84
85
  def new_chat(model)
85
- return context.chat(model:, provider:, assume_model_exists: true) if context
86
-
87
- configure_credentials!
88
- ::RubyLLM.chat(model:, provider:, assume_model_exists: true)
86
+ (context || chat_context).chat(model:, provider:, assume_model_exists: true)
89
87
  end
90
88
 
91
- # RubyLLM does not read provider API keys from ENV on its own, so a
92
- # standalone CLI run (no Rails initializer to call RubyLLM.configure)
93
- # would raise "Missing configuration for <provider>". Push the resolved
94
- # key into RubyLLM's config: an explicit `Locallingo.configure` key wins
95
- # (re-resolved every chat so callables stay live), then a key the host
96
- # app already set, then the ENV fallback.
97
- def configure_credentials!
98
- return unless CREDENTIAL_ENV.key?(provider)
99
-
100
- config = ::RubyLLM.config
101
- setting = key_setting
102
- return unless config.respond_to?(setting) && config.respond_to?("#{setting}=")
103
-
104
- key = settings_api_key
105
- return if key.nil? && !presence(config.public_send(setting)).nil?
106
-
107
- key ||= env_api_key
108
- config.public_send("#{setting}=", key) unless key.nil?
89
+ # A context of its own for one chat. `RubyLLM.context` copies the global
90
+ # config, so a key the host app set via RubyLLM.configure carries over;
91
+ # an explicit `Locallingo.configure` key wins over it (re-resolved every
92
+ # chat so callables stay live), and ENV fills in when neither is set
93
+ # (RubyLLM does not read provider keys from ENV on its own, so a
94
+ # standalone CLI run would otherwise raise "Missing configuration").
95
+ def chat_context
96
+ ::RubyLLM.context do |config|
97
+ setting = key_setting
98
+ next unless CREDENTIAL_ENV.key?(provider) && config.respond_to?("#{setting}=")
99
+
100
+ key = settings_api_key
101
+ key ||= env_api_key if presence(config.public_send(setting)).nil?
102
+ config.public_send("#{setting}=", key) unless key.nil?
103
+ end
109
104
  end
110
105
 
111
106
  # Key resolution across sources, in precedence order. Used by
@@ -11,6 +11,7 @@ module Locallingo
11
11
  "Can not" => "Cannot"
12
12
  }.freeze
13
13
 
14
+ # Every level frozen, so any Ractor can read the rules.
14
15
  RULES = {
15
16
  terminology: {
16
17
  /\bcan not\b/i => "Use 'cannot' (one word)",
@@ -18,11 +19,11 @@ module Locallingo
18
19
  "Use 'log in' (verb) not 'login' here",
19
20
  /\bclick\s+here\b/i => "Avoid 'click here' - use descriptive link text",
20
21
  /\bplease\b.*\bplease\b/i => "Multiple 'please' in same text - remove redundancy"
21
- },
22
+ }.freeze,
22
23
  placeholders: {
23
24
  /%\{[^}]*\s[^}]*\}/ => "Placeholder contains spaces - may cause issues",
24
25
  /%\{[A-Z]/ => "Placeholder starts with uppercase - convention is lowercase"
25
- },
26
+ }.freeze,
26
27
  clarity: {
27
28
  /\betc\.?\b/i => "Avoid 'etc.' - be specific or use 'and more'",
28
29
  /\bstuff\b/i => "Vague word 'stuff' - be more specific",
@@ -30,19 +31,19 @@ module Locallingo
30
31
  /\basap\b/i => "Avoid abbreviation 'ASAP' - use 'as soon as possible'",
31
32
  /\bfyi\b/i => "Avoid abbreviation 'FYI' - rephrase",
32
33
  /\s{2,}/ => "Multiple consecutive spaces"
33
- },
34
+ }.freeze,
34
35
  business: {
35
36
  /\bsorry\b/i => "Consider 'We apologize' for formal business tone",
36
37
  /\boops\b/i => "Informal - use professional error messaging",
37
38
  /\buh\s*oh\b/i => "Informal - use professional error messaging",
38
39
  /\bawesome\b/i => "Consider more professional alternatives",
39
40
  /\bcool\b(?!\s*down)/i => "Informal - consider 'great' or 'excellent'"
40
- },
41
+ }.freeze,
41
42
  accessibility: {
42
43
  /\bsee\s+below\b/i => "Screen reader unfriendly - describe the content",
43
44
  /\babove\b/i => "Positional reference - may not work for all users",
44
45
  /\bred\b.*\berror\b|\berror\b.*\bred\b/i => "Don't rely on color alone for meaning"
45
- }
46
+ }.freeze
46
47
  }.freeze
47
48
 
48
49
  module_function
@@ -36,7 +36,7 @@ module Locallingo
36
36
  "cdd" => nil
37
37
  ).freeze
38
38
 
39
- BUILTINS = { "business" => BUSINESS, "banking" => BANKING, "none" => {} }.freeze
39
+ BUILTINS = { "business" => BUSINESS, "banking" => BANKING, "none" => {}.freeze }.freeze
40
40
 
41
41
  attr_reader :terms
42
42
 
@@ -18,7 +18,10 @@ module Locallingo
18
18
  # Records must be persisted and of one model (ids are the key prefix). This
19
19
  # class never loads ActiveRecord itself; records only need the concern.
20
20
  class RecordTranslator
21
+ # A capture-free lambda, made shareable where the engine has Ractors, so
22
+ # the constant is readable from any Ractor.
21
23
  DEFAULT_WRITE = ->(record, attributes) { record.update_columns(attributes) }
24
+ .then { |write| defined?(::Ractor) ? ::Ractor.make_shareable(write) : write }
22
25
 
23
26
  # +write+ is called as `write.call(record, attributes)` inside the lock,
24
27
  # e.g. to bump a lock version or run validations (`record.update!(attributes)`).
@@ -16,6 +16,6 @@ require_relative "../rubocop/cop/locallingo/strftime_in_view"
16
16
  module Locallingo
17
17
  # Absolute path to the cop defaults, for `inherit_gem: locallingo: <path>`.
18
18
  module RuboCop
19
- CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__)
19
+ CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__).freeze
20
20
  end
21
21
  end
@@ -12,10 +12,21 @@ module Locallingo
12
12
  #
13
13
  # Callables are resolved lazily on every use (never memoized), so keys can
14
14
  # come from sources that aren't ready at configure time or that rotate.
15
+ #
16
+ # A String key is stored as a frozen plain String copy, so settings frozen by
17
+ # `Locallingo.configure` are Ractor-shareable; a callable is kept as is.
15
18
  class Settings
16
19
  PROVIDERS = %i[openai anthropic gemini deepseek openrouter].freeze
17
20
 
18
- attr_accessor(*PROVIDERS.map { |name| :"#{name}_api_key" })
21
+ attr_reader(*PROVIDERS.map { |name| :"#{name}_api_key" })
22
+
23
+ PROVIDERS.each do |name|
24
+ define_method(:"#{name}_api_key=") do |value|
25
+ # A plain String copy: a subclass or its ivars could carry mutable state.
26
+ value = String.new(value).freeze if value.is_a?(String)
27
+ instance_variable_set(:"@#{name}_api_key", value)
28
+ end
29
+ end
19
30
 
20
31
  # The usable key for +provider+: callables are called, results stripped,
21
32
  # and blank or unknown-provider values come back as nil.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Locallingo
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/locallingo.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
3
4
  require_relative "locallingo/version"
4
5
  require_relative "locallingo/settings"
5
6
  require_relative "locallingo/configuration"
@@ -42,20 +43,59 @@ module Locallingo
42
43
  Configuration.load(root_path:, package:)
43
44
  end
44
45
 
45
- # Code-level settings (provider credentials) — see Locallingo::Settings.
46
- def self.settings
47
- @settings ||= Settings.new
46
+ # Boot-time state, finalized: always a frozen Settings, so any thread or
47
+ # Ractor reads finished settings and only `configure` replaces them. With
48
+ # String keys (or none) it is Ractor-shareable; a callable key is not, so it
49
+ # keeps the settings readable on the main Ractor only.
50
+ #
51
+ # rubocop:disable ThreadSafety/ClassInstanceVariable -- @settings is written only by configure and reset_settings!, at boot on the main Ractor, and always holds a frozen Settings; @configure_lock (engines without Ractor only) is set once, at load
52
+ @settings = Settings.new.freeze
53
+
54
+ # Serializes configure: one Monitor per Ractor, in Ractor-local storage (only
55
+ # the main Ractor can publish @settings, so in practice the main Ractor's).
56
+ # The main Ractor's is made here, at load, never lazily. A Monitor, so a
57
+ # configure nested in a configure block does not deadlock. Engines without
58
+ # Ractor keep it in @configure_lock.
59
+ if defined?(::Ractor)
60
+ ::Ractor.current[:locallingo_configure_lock] = Monitor.new
61
+ else
62
+ @configure_lock = Monitor.new
48
63
  end
49
64
 
65
+ # Code-level settings (provider credentials) — see Locallingo::Settings.
66
+ def self.settings = @settings
67
+
50
68
  # Configure the gem from Ruby — the credentials path for apps whose keys
51
69
  # don't live in ENV (call it from a Rails initializer or a `.locallingo.rb`
52
70
  # setup file next to `.locallingo.yml`; the CLI loads the latter on start).
71
+ # Yields a copy of the current settings, then publishes it frozen in one
72
+ # assignment: a concurrent reader sees the old settings or the new, never
73
+ # half of them. Concurrent calls run one at a time, so neither drops the
74
+ # other's keys. Calling it again keeps the keys it does not set.
53
75
  def self.configure
54
- yield settings
55
- settings
76
+ configure_lock.synchronize do
77
+ draft = settings.dup
78
+ yield draft
79
+ @settings = draft.freeze
80
+ end
56
81
  end
57
82
 
83
+ # Back to frozen, blank defaults (for tests).
58
84
  def self.reset_settings!
59
- @settings = nil
85
+ @settings = Settings.new.freeze
86
+ end
87
+
88
+ # Another Ractor's lock is made on first use: atomically on Ruby 3.4+; below,
89
+ # two of its threads could each make one, which is harmless because a
90
+ # non-main Ractor cannot publish settings anyway.
91
+ def self.configure_lock
92
+ return @configure_lock unless defined?(::Ractor)
93
+
94
+ key = :locallingo_configure_lock
95
+ return ::Ractor.store_if_absent(key) { Monitor.new } if ::Ractor.respond_to?(:store_if_absent)
96
+
97
+ ::Ractor.current[key] ||= Monitor.new
60
98
  end
99
+ private_class_method :configure_lock
100
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
61
101
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: locallingo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson