translation_diff 1.0.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 +7 -0
- data/.github/workflows/ci.yml +84 -0
- data/.github/workflows/release.yml +28 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +39 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +723 -0
- data/Gemfile +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +41 -0
- data/data/languages/azure.json +285 -0
- data/data/languages/deepl.json +220 -0
- data/data/languages/google.json +399 -0
- data/data/languages/modernmt.json +413 -0
- data/docs/caching.md +185 -0
- data/docs/configuration.md +205 -0
- data/docs/contracts.md +187 -0
- data/docs/development.md +34 -0
- data/docs/errors.md +92 -0
- data/docs/how-it-works.md +145 -0
- data/docs/instrumentation.md +96 -0
- data/docs/languages.md +94 -0
- data/docs/providers.md +379 -0
- data/docs/sql-cache.md +366 -0
- data/lib/generators/translation_diff/install_generator.rb +15 -0
- data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
- data/lib/translation_diff/active_record/support.rb +34 -0
- data/lib/translation_diff/active_record.rb +3 -0
- data/lib/translation_diff/batch.rb +98 -0
- data/lib/translation_diff/call_preparation.rb +47 -0
- data/lib/translation_diff/capabilities.rb +9 -0
- data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
- data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
- data/lib/translation_diff/configuration/option_table.rb +38 -0
- data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
- data/lib/translation_diff/configuration.rb +135 -0
- data/lib/translation_diff/context.rb +23 -0
- data/lib/translation_diff/dispatcher.rb +57 -0
- data/lib/translation_diff/document.rb +23 -0
- data/lib/translation_diff/errors.rb +44 -0
- data/lib/translation_diff/fragment.rb +33 -0
- data/lib/translation_diff/http_provider.rb +128 -0
- data/lib/translation_diff/instrumentation.rb +25 -0
- data/lib/translation_diff/languages/refresh.rb +70 -0
- data/lib/translation_diff/languages/set.rb +53 -0
- data/lib/translation_diff/languages.rb +30 -0
- data/lib/translation_diff/leaves.rb +22 -0
- data/lib/translation_diff/markup.rb +85 -0
- data/lib/translation_diff/passage.rb +149 -0
- data/lib/translation_diff/preview.rb +3 -0
- data/lib/translation_diff/previewer.rb +78 -0
- data/lib/translation_diff/provider.rb +91 -0
- data/lib/translation_diff/providers/amazon.rb +126 -0
- data/lib/translation_diff/providers/azure.rb +78 -0
- data/lib/translation_diff/providers/deepl.rb +88 -0
- data/lib/translation_diff/providers/google.rb +64 -0
- data/lib/translation_diff/providers/libretranslate.rb +65 -0
- data/lib/translation_diff/providers/modernmt.rb +74 -0
- data/lib/translation_diff/providers/null.rb +20 -0
- data/lib/translation_diff/providers.rb +69 -0
- data/lib/translation_diff/railtie.rb +12 -0
- data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
- data/lib/translation_diff/rate_limiters/redis.rb +59 -0
- data/lib/translation_diff/rate_limiters.rb +9 -0
- data/lib/translation_diff/redaction.rb +45 -0
- data/lib/translation_diff/registry.rb +31 -0
- data/lib/translation_diff/segment.rb +32 -0
- data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
- data/lib/translation_diff/segmenters/simple.rb +122 -0
- data/lib/translation_diff/segmenters.rb +4 -0
- data/lib/translation_diff/sentence_cache.rb +76 -0
- data/lib/translation_diff/stores/active_record.rb +106 -0
- data/lib/translation_diff/stores/memory.rb +34 -0
- data/lib/translation_diff/stores/redis.rb +49 -0
- data/lib/translation_diff/stores.rb +9 -0
- data/lib/translation_diff/tasks/translation_diff.rake +21 -0
- data/lib/translation_diff/translation/request.rb +8 -0
- data/lib/translation_diff/translation/response.rb +35 -0
- data/lib/translation_diff/translation/usage.rb +8 -0
- data/lib/translation_diff/translator.rb +103 -0
- data/lib/translation_diff/version.rb +3 -0
- data/lib/translation_diff.rb +93 -0
- data/translation_diff.gemspec +56 -0
- metadata +243 -0
data/docs/providers.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Providers
|
|
2
|
+
|
|
3
|
+
A translation cache that helps translate only changes between revisions of
|
|
4
|
+
long texts. It ships with six providers -- DeepL, Google Cloud Translation,
|
|
5
|
+
Azure AI Translator, ModernMT, LibreTranslate and Amazon Translate -- but any
|
|
6
|
+
translation service can be plugged in by subclassing a small base class.
|
|
7
|
+
|
|
8
|
+
See the provider table in the [README](../README.md#providers).
|
|
9
|
+
|
|
10
|
+
Language pairs are checked against shipped vendor data before every call, and
|
|
11
|
+
a provider with no shipped data refuses nothing. Data ships for **DeepL,
|
|
12
|
+
Google, Azure and ModernMT**; **Amazon** and **LibreTranslate** ship none --
|
|
13
|
+
see [Languages](languages.md).
|
|
14
|
+
|
|
15
|
+
`Configuration#inspect` and `Provider#inspect` print `[FILTERED]` in place of
|
|
16
|
+
every credential option below -- `deepl_api_key`, `azure_api_key`,
|
|
17
|
+
`amazon_secret_access_key`, and so on -- so a Rails error page and a stray
|
|
18
|
+
`p config` in a console cannot leak one; `pp` and `p` both go through the
|
|
19
|
+
overridden `inspect`. That guarantee stops at `inspect`, though: an error
|
|
20
|
+
reporter that serialises object state instead of calling `inspect` is not
|
|
21
|
+
covered, and neither is `p provider.connection`, which prints Faraday's own
|
|
22
|
+
headers, `Authorization` included, untouched.
|
|
23
|
+
|
|
24
|
+
Options that carry no credential at all, like `azure_region` or
|
|
25
|
+
`cache_namespace`, stay visible in full. Any option whose value parses as a
|
|
26
|
+
URI carrying userinfo -- `redis_url` among them -- has just that userinfo
|
|
27
|
+
redacted: `rediss://default:AbCdEf-TOKEN@cache.example.upstash.io:6379`
|
|
28
|
+
prints as `rediss://default:[FILTERED]@cache.example.upstash.io:6379`. The
|
|
29
|
+
scheme, host, port and path stay visible, since that's what you need to
|
|
30
|
+
debug against -- only the credential Heroku, Upstash, Redis Cloud and Aiven
|
|
31
|
+
all put in the userinfo is hidden. A value that isn't a URI, or a URI with no
|
|
32
|
+
userinfo, is left unchanged, and a malformed value never raises out of
|
|
33
|
+
`inspect`.
|
|
34
|
+
|
|
35
|
+
## Configuring each provider
|
|
36
|
+
|
|
37
|
+
Every example below is complete: set the options shown and
|
|
38
|
+
`TranslationDiff.translate` works. The option names, and which of them are
|
|
39
|
+
required, come from the provider itself -- see [Configuration
|
|
40
|
+
options](configuration.md#configuration-options) for the full list and the environment
|
|
41
|
+
variables each one falls back to.
|
|
42
|
+
|
|
43
|
+
**DeepL** is the default, so `config.provider` may be omitted. A key ending
|
|
44
|
+
in `:fx` is a free-plan key and selects the free host on its own.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
TranslationDiff.configure do |config|
|
|
48
|
+
config.provider = :deepl
|
|
49
|
+
config.deepl_api_key = ENV["DEEPL_AUTH_KEY"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
TranslationDiff.translate("Hello there. Second sentence.", from: "en", to: "ru")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Google Cloud Translation** needs an API key and nothing else -- no project,
|
|
56
|
+
no service account.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
TranslationDiff.configure do |config|
|
|
60
|
+
config.provider = :google
|
|
61
|
+
config.google_api_key = ENV["TRANSLATE_KEY"]
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Azure AI Translator** wants the region as well when the key belongs to a
|
|
66
|
+
multi-service Cognitive Services resource; a single-service Translator
|
|
67
|
+
resource needs no region.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
TranslationDiff.configure do |config|
|
|
71
|
+
config.provider = :azure
|
|
72
|
+
config.azure_api_key = ENV["AZURE_TRANSLATOR_KEY"]
|
|
73
|
+
config.azure_region = "westeurope"
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**ModernMT** takes a key and can be pointed at an adaptive memory per call,
|
|
78
|
+
since every unrecognised keyword reaches the provider untouched.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
TranslationDiff.configure do |config|
|
|
82
|
+
config.provider = :modernmt
|
|
83
|
+
config.modernmt_api_key = ENV["MMT_API_KEY"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
TranslationDiff.translate(text, from: "en", to: "ru", hints: "1234")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**LibreTranslate** inverts the usual arrangement: the base URL is required
|
|
90
|
+
because every instance is someone's own, and the key is optional because most
|
|
91
|
+
instances ask for none. It is also the only provider here you can run
|
|
92
|
+
yourself, which makes it the one to develop against.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
TranslationDiff.configure do |config|
|
|
96
|
+
config.provider = :libretranslate
|
|
97
|
+
config.libretranslate_api_base = "http://localhost:5000"
|
|
98
|
+
config.libretranslate_api_key = ENV["LIBRETRANSLATE_KEY"] # optional
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
docker run -d --rm -p 5000:5000 libretranslate/libretranslate --load-only en,ru
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Amazon Translate** is signed rather than keyed, so it takes credentials and
|
|
107
|
+
a region. There is no environment fallback: this library does not implement
|
|
108
|
+
the AWS credential chain, so `AWS_ACCESS_KEY_ID` and friends are not read --
|
|
109
|
+
pass them explicitly.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
TranslationDiff.configure do |config|
|
|
113
|
+
config.provider = :amazon
|
|
114
|
+
config.amazon_access_key_id = ENV.fetch("AWS_ACCESS_KEY_ID")
|
|
115
|
+
config.amazon_secret_access_key = ENV.fetch("AWS_SECRET_ACCESS_KEY")
|
|
116
|
+
config.amazon_region = "eu-central-1"
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Add `gem "aws-sigv4"` to your Gemfile for this one. It is Amazon's own
|
|
121
|
+
signing library and nothing more -- no clients, no service models -- and it
|
|
122
|
+
is required lazily, so an application on any other provider never installs it.
|
|
123
|
+
|
|
124
|
+
**Null** translates nothing and returns what it was given. It exists so a
|
|
125
|
+
pipeline can be wired up, and its cache and instrumentation exercised, before
|
|
126
|
+
anyone has paid for a key.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
TranslationDiff.configure { |config| config.provider = :null }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Switching providers
|
|
133
|
+
|
|
134
|
+
Different providers can be used side by side without disturbing the global
|
|
135
|
+
configuration -- `TranslationDiff.context` yields an isolated copy, and
|
|
136
|
+
`provider:` overrides one call:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
formal = TranslationDiff.context do |config|
|
|
140
|
+
config.provider = :deepl
|
|
141
|
+
config.deepl_api_key = ENV["DEEPL_AUTH_KEY"]
|
|
142
|
+
end
|
|
143
|
+
formal.translate(contract, from: "en", to: "de", formality: :more)
|
|
144
|
+
|
|
145
|
+
TranslationDiff.translate(blog_post, from: "en", to: "de", provider: :google)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Both read and write the same cache, keyed per provider, so switching one
|
|
149
|
+
never serves you the other's translations.
|
|
150
|
+
|
|
151
|
+
## Capabilities, in full
|
|
152
|
+
|
|
153
|
+
**Every keyword other than `from:`, `to:`, `provider:`, `config:` and
|
|
154
|
+
`assume_supported:` is forwarded to the provider, and every provider applies
|
|
155
|
+
them the same way: its own defaults first, then your options, then the
|
|
156
|
+
fields the request cannot do without.** So `formality: :less` overrides a
|
|
157
|
+
default, and a keyword colliding with the language pair or the texts
|
|
158
|
+
themselves is overridden rather than obeyed. `assume_supported:` is reserved
|
|
159
|
+
because it is this library's own decision -- whether to skip language
|
|
160
|
+
validation for this call -- not a vendor's, and it must never reach a
|
|
161
|
+
payload.
|
|
162
|
+
|
|
163
|
+
**`usage.billed_characters` is `nil` when the provider said nothing about
|
|
164
|
+
billing and a number -- `0` included -- when it said something.** Three of
|
|
165
|
+
the six built-in providers report billing that way -- DeepL, Azure and
|
|
166
|
+
ModernMT; the other three, Google, LibreTranslate and Amazon, always answer
|
|
167
|
+
`nil`.
|
|
168
|
+
|
|
169
|
+
**Language codes are normalised per vendor, so switching provider needs no
|
|
170
|
+
other change.** A bare code (`"EN"`, `:ru`) is cased the way the vendor
|
|
171
|
+
documents it -- DeepL takes upper case, every other provider here takes lower
|
|
172
|
+
case -- whichever casing you wrote. A code carrying a script or region subtag
|
|
173
|
+
(`"zh-Hans"`, `"pt-BR"`) is passed through untouched, because the casing of a
|
|
174
|
+
subtag is its own. A provider of your own gets the same rule from
|
|
175
|
+
`TranslationDiff::Provider#language`; declare `def self.language_case =
|
|
176
|
+
:upcase` if your vendor wants upper case.
|
|
177
|
+
|
|
178
|
+
"Request size" is what `TranslationDiff::Batch` measures: the URL-escaped form of each
|
|
179
|
+
string (`CGI.escape(text).size`), which is never smaller than its UTF-8 byte
|
|
180
|
+
count. "HTML support" names the provider option that turns HTML handling on
|
|
181
|
+
-- every vendor spells it differently, which is exactly what
|
|
182
|
+
`Capabilities#html` is for. A provider whose "Detects language" column says
|
|
183
|
+
no makes `from:` required; passing it makes every provider's `#detect` call
|
|
184
|
+
unnecessary regardless of whether it has one.
|
|
185
|
+
|
|
186
|
+
### Very long texts
|
|
187
|
+
|
|
188
|
+
Every provider limits how large a single request or a single batch can be,
|
|
189
|
+
declared through `TranslationDiff::Capabilities#max_request_size` and
|
|
190
|
+
`#max_batch_size`; if your text is longer than that, TranslationDiff splits
|
|
191
|
+
it into multiple requests automatically. See the [provider
|
|
192
|
+
table](../README.md#providers) for each built-in provider's actual numbers -- DeepL, for
|
|
193
|
+
example, caps requests at 1,700 escaped characters and batches at 50
|
|
194
|
+
sentences.
|
|
195
|
+
|
|
196
|
+
### Provider caveats
|
|
197
|
+
|
|
198
|
+
**Amazon translates one text per call and honours no `notranslate`.** There
|
|
199
|
+
is no batch form of `TranslateText`, so a hundred sentences are a hundred
|
|
200
|
+
requests -- slow, but correct, and `Capabilities#max_batch_size` reflects
|
|
201
|
+
it. Amazon also has no HTML mode: a `notranslate` span reaches it as plain
|
|
202
|
+
text and is translated like everything else, tags and all. Both facts are
|
|
203
|
+
worth weighing before your bill and your brand names arrive, not after.
|
|
204
|
+
|
|
205
|
+
**LibreTranslate does not honour `notranslate` either -- measured, not
|
|
206
|
+
assumed.** Its HTML format preserves markup, but probing a real instance
|
|
207
|
+
(`docker run libretranslate/libretranslate --load-only en,ru`) with
|
|
208
|
+
`<span class="notranslate">Bold Mountain</span> is a good place.` came back
|
|
209
|
+
with the span tag intact and its content translated anyway -- "Bold
|
|
210
|
+
Mountain" became "Смелая гора". The tags survive; what they were meant to
|
|
211
|
+
protect does not.
|
|
212
|
+
|
|
213
|
+
ModernMT's `notranslate: false` is the conservative default rather than a
|
|
214
|
+
measurement: it documents an HTML format but says nothing about
|
|
215
|
+
`class="notranslate"`, and no key was available to probe it. A capability
|
|
216
|
+
that under-promises costs a warning; one that over-promises costs a
|
|
217
|
+
customer's protected content reaching a competitor's brand voice.
|
|
218
|
+
|
|
219
|
+
### Writing a provider
|
|
220
|
+
|
|
221
|
+
Any translation service can be a provider -- no change to this gem's own
|
|
222
|
+
code is required. Subclass `TranslationDiff::HTTPProvider` for a REST
|
|
223
|
+
service; it owns the Faraday connection, retries, timeouts and turns HTTP
|
|
224
|
+
status codes into this library's error hierarchy, and asks only for three
|
|
225
|
+
seams per operation: the URL, how to render a request, how to parse a
|
|
226
|
+
reply. Subclass `TranslationDiff::Provider` directly for anything that
|
|
227
|
+
reaches its service some other way -- signed requests, another gem, an
|
|
228
|
+
LLM client -- and implement `#translate` outright, the way
|
|
229
|
+
`TranslationDiff::Providers::Amazon` does.
|
|
230
|
+
|
|
231
|
+
Registering a provider also declares the options it needs, so
|
|
232
|
+
`config.acme_api_key` below does not exist until `AcmeProvider` is
|
|
233
|
+
registered:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
class AcmeProvider < TranslationDiff::HTTPProvider
|
|
237
|
+
# Declares this provider's own configuration options.
|
|
238
|
+
# TranslationDiff::Providers.register adds each one to
|
|
239
|
+
# TranslationDiff::Configuration as a side effect.
|
|
240
|
+
def self.configuration_options = %i[acme_api_key]
|
|
241
|
+
def self.configuration_requirements = %i[acme_api_key]
|
|
242
|
+
|
|
243
|
+
# What this provider can do, checked once by the pipeline for chunking,
|
|
244
|
+
# detection and cache-key safety.
|
|
245
|
+
def self.capabilities
|
|
246
|
+
TranslationDiff::Capabilities.new(
|
|
247
|
+
max_request_size: 10_000, max_batch_size: 100, max_text_size: nil,
|
|
248
|
+
html: :format, notranslate: true, detects_language: true,
|
|
249
|
+
reports_billing: false
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def api_base = config.acme_api_base || "https://api.acme.example/v1"
|
|
254
|
+
def headers = { "Authorization" => "Bearer #{config.acme_api_key}" }
|
|
255
|
+
def translate_url = "translate"
|
|
256
|
+
|
|
257
|
+
# The three seams: build the request body, decode the reply.
|
|
258
|
+
def render_translate_payload(request)
|
|
259
|
+
{ format: "HTML", texts: request.texts, targetLanguageCode: request.to.to_s }
|
|
260
|
+
.tap { |body| body[:sourceLanguageCode] = request.from.to_s unless request.from.nil? }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def parse_translate_response(body, _headers, request)
|
|
264
|
+
translations = Array(body["translations"])
|
|
265
|
+
|
|
266
|
+
TranslationDiff::Translation::Response.build(
|
|
267
|
+
request: request,
|
|
268
|
+
texts: translations.map { |t| t["text"] },
|
|
269
|
+
detected_source: translations.first&.dig("detectedLanguageCode")&.downcase
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def detect_url = "translate/v2/detect"
|
|
274
|
+
|
|
275
|
+
def detect(text)
|
|
276
|
+
response = post(detect_url, { text: text })
|
|
277
|
+
response.body["languageCode"]&.downcase
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# cache_key is optional: TranslationDiff::Providers.register stamps every
|
|
281
|
+
# instance built through the registry with its registered name, and
|
|
282
|
+
# Provider#cache_key falls back to that. Define it yourself only if this
|
|
283
|
+
# provider will also be instantiated and assigned directly, bypassing the
|
|
284
|
+
# registry -- see "Provider objects and cache_key" below.
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
TranslationDiff::Providers.register(:acme, AcmeProvider)
|
|
288
|
+
|
|
289
|
+
TranslationDiff.configure do |config|
|
|
290
|
+
config.provider = :acme
|
|
291
|
+
config.acme_api_key = ENV["ACME_API_KEY"]
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`translate_url`/`render_translate_payload`/`parse_translate_response` are
|
|
296
|
+
the three seams `HTTPProvider#translate` calls in order; `detect` is
|
|
297
|
+
entirely optional -- omit it (and leave `capabilities.detects_language:
|
|
298
|
+
false`) if the provider has no detection endpoint, or if callers of this
|
|
299
|
+
gem always pass `from:` explicitly.
|
|
300
|
+
|
|
301
|
+
**`self.sensitive_options` decides which of this provider's
|
|
302
|
+
`configuration_options` `Configuration#inspect` and `Provider#inspect`
|
|
303
|
+
filter.** By default it is every declared option whose name matches
|
|
304
|
+
`TranslationDiff::Redaction::SENSITIVE` (`key`, `secret`, `token`,
|
|
305
|
+
`password`, `auth`, `credential`) -- `acme_api_key` above is caught by that
|
|
306
|
+
pattern for free. Override it when a credential's name doesn't match: a
|
|
307
|
+
provider reading `config.acme_handshake` for its credential leaks it on
|
|
308
|
+
every `inspect` unless it says so itself:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
def self.sensitive_options = %i[acme_handshake]
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Provider names must be unique.** `TranslationDiff::Providers.register`
|
|
315
|
+
overwrites whatever was previously registered under that name, silently --
|
|
316
|
+
there is no error for registering `:deepl` twice. This is deliberate: a
|
|
317
|
+
raise would break Rails development-mode reloading and a defensive double
|
|
318
|
+
`require`. It also means a typo in a name collides with a real provider
|
|
319
|
+
without warning, so choose names as carefully as you would a constant.
|
|
320
|
+
|
|
321
|
+
What happens when they are not unique is worth being precise about. The last
|
|
322
|
+
class registered under the name wins, and it also inherits the cache entries
|
|
323
|
+
of the one it replaced: `cache_key` falls back to the registered name, so a
|
|
324
|
+
class registered over `:deepl` reads and writes exactly the entries the real
|
|
325
|
+
DeepL provider wrote. Callers are then served one service's translations from
|
|
326
|
+
another service's cache, for as long as those entries live, with nothing in
|
|
327
|
+
the log to say so. Registering over an existing name is not a way to
|
|
328
|
+
substitute a service -- give the replacement its own name, or clear the cache
|
|
329
|
+
(`cache_namespace` is the cheapest way to do that).
|
|
330
|
+
|
|
331
|
+
**An option can declare a default.** A bare symbol in
|
|
332
|
+
`configuration_options` declares an option with no default. Writing
|
|
333
|
+
`key => default` instead declares one, and a callable default is evaluated on
|
|
334
|
+
every read rather than at load time -- which is what lets an environment
|
|
335
|
+
variable work when the application exports it after requiring this gem:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
def self.configuration_options
|
|
339
|
+
[:acme_api_base, { acme_api_key: -> { ENV.fetch("ACME_API_KEY", nil) } }]
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
An explicitly configured value always wins over a default, and a default that
|
|
344
|
+
resolves to a blank string reads as unset -- the same rule assignment follows.
|
|
345
|
+
|
|
346
|
+
**Option names are unique too, and enforced.** Two providers declaring the
|
|
347
|
+
same `configuration_options` name would share one accessor on
|
|
348
|
+
`TranslationDiff::Configuration`, which would hand one service's credential
|
|
349
|
+
to the other, so registering the second one raises and names both providers
|
|
350
|
+
and the option. Prefix your options with your provider's name --
|
|
351
|
+
`deepl_api_key`, `google_api_key` -- the way the built-ins do. The same
|
|
352
|
+
provider redeclaring its own options is not a conflict: a double `require`
|
|
353
|
+
and a Rails reload both re-run registration.
|
|
354
|
+
|
|
355
|
+
`TranslationDiff::Providers.names` lists every registered provider;
|
|
356
|
+
`TranslationDiff::Providers.registered?(:acme)` checks one.
|
|
357
|
+
|
|
358
|
+
`test/support/provider_contract.rb` and `test/support/http_provider_contract.rb`
|
|
359
|
+
are the executable form of the provider contract: include `ProviderContract`
|
|
360
|
+
(and, for an `HTTPProvider` subclass, `HTTPProviderContract`) in a test class
|
|
361
|
+
that defines `#provider`, and they verify a provider inherits
|
|
362
|
+
`TranslationDiff::Provider`, that `#translate` preserves order and returns
|
|
363
|
+
one string per input, and that its declared capabilities are internally
|
|
364
|
+
consistent (a provider claiming `notranslate` must also claim an HTML mode).
|
|
365
|
+
|
|
366
|
+
### Provider objects and cache_key
|
|
367
|
+
|
|
368
|
+
A provider built through `TranslationDiff::Providers.build` (which is what
|
|
369
|
+
happens when `config.provider` is a symbol) is stamped with its registered
|
|
370
|
+
name automatically, and never needs to define `cache_key` itself --
|
|
371
|
+
`Provider#cache_key` falls back to that stamped name.
|
|
372
|
+
|
|
373
|
+
A provider object assigned straight to `config.provider` never passes
|
|
374
|
+
through the registry, so it gets no name and **must define `cache_key`
|
|
375
|
+
itself**, or every call through it raises. This is not pedantry: `cache_key`
|
|
376
|
+
is a segment of every cache entry this provider ever reads or writes, so two
|
|
377
|
+
providers sharing one -- or both silently falling back to an empty one --
|
|
378
|
+
would let a caller be served another provider's cached translation.
|
|
379
|
+
|