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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +17 -4
- data/lib/locallingo/active_record.rb +22 -3
- data/lib/locallingo/configuration.rb +38 -18
- data/lib/locallingo/manager.rb +3 -2
- data/lib/locallingo/providers/ruby_llm.rb +21 -26
- data/lib/locallingo/quality/static_rules.rb +6 -5
- data/lib/locallingo/quality/terminology.rb +1 -1
- data/lib/locallingo/record_translator.rb +3 -0
- data/lib/locallingo/rubocop.rb +1 -1
- data/lib/locallingo/settings.rb +12 -1
- data/lib/locallingo/version.rb +1 -1
- data/lib/locallingo.rb +46 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47b5cfaffa1567e536de600f6f963538606fc386a89b61e90ad9cc4dd8327132
|
|
4
|
+
data.tar.gz: 767fa1b9c49c66a0b5b5e1bd6140edbca50edade201f415c9bfde2c6f0448206
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
174
|
-
global `RubyLLM.config` is
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
merged = deep_merge(
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
144
|
+
file ? [load_yaml(file), file] : [{}, nil]
|
|
145
|
+
end
|
|
136
146
|
|
|
137
|
-
|
|
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 #{
|
|
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
|
-
|
|
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)
|
data/lib/locallingo/manager.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# `RubyLLM.config` is never
|
|
25
|
-
# (one per tenant) can run
|
|
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
|
-
|
|
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
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# (
|
|
96
|
-
#
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
@@ -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)`).
|
data/lib/locallingo/rubocop.rb
CHANGED
|
@@ -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
|
data/lib/locallingo/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
data/lib/locallingo/version.rb
CHANGED
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
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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 =
|
|
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
|