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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Talks to DeepL's REST API directly, not deepl-rb: it logged the auth key at DEBUG and defaulted notranslate off.
|
|
2
|
+
class TranslationDiff::Providers::DeepL < TranslationDiff::HTTPProvider
|
|
3
|
+
PAID_HOST = "https://api.deepl.com".freeze
|
|
4
|
+
FREE_HOST = "https://api-free.deepl.com".freeze
|
|
5
|
+
|
|
6
|
+
# A key ending in :fx is a free-plan key, and the free plan lives on its own host.
|
|
7
|
+
FREE_KEY_SUFFIX = ":fx".freeze
|
|
8
|
+
|
|
9
|
+
# DeepL honours class="notranslate" only under HTML tag handling -- otherwise content translates, tags survive.
|
|
10
|
+
DEFAULT_OPTIONS = { tag_handling: :html, tag_handling_version: "v2" }.freeze
|
|
11
|
+
|
|
12
|
+
# 50 texts / 128 KiB are DeepL's documented per-request limits; max_batch_size was wrong before (it said 300).
|
|
13
|
+
def self.capabilities
|
|
14
|
+
TranslationDiff::Capabilities.new(
|
|
15
|
+
max_request_size: 1_700, max_batch_size: 50, max_text_size: nil,
|
|
16
|
+
html: :tag_handling, notranslate: true, detects_language: true, reports_billing: true
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# DEEPL_AUTH_KEY is what deepl-rb read on our behalf; the callable keeps it read on use, not at load.
|
|
21
|
+
def self.configuration_options
|
|
22
|
+
[:deepl_api_base, { deepl_api_key: -> { ENV.fetch("DEEPL_AUTH_KEY", nil) } }]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.configuration_requirements = %i[deepl_api_key]
|
|
26
|
+
|
|
27
|
+
# DeepL is the one vendor documenting upper-case codes.
|
|
28
|
+
def self.language_case = :upcase
|
|
29
|
+
|
|
30
|
+
# DeepL requires a target language even when only detection is wanted, so the provider picks one.
|
|
31
|
+
DETECTION_TARGET = "EN".freeze
|
|
32
|
+
|
|
33
|
+
def api_base
|
|
34
|
+
config.deepl_api_base || (free_key? ? FREE_HOST : PAID_HOST)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def headers = { "Authorization" => "DeepL-Auth-Key #{config.deepl_api_key}" }
|
|
38
|
+
|
|
39
|
+
def translate_url = "v2/translate"
|
|
40
|
+
|
|
41
|
+
def render_translate_payload(request)
|
|
42
|
+
DEFAULT_OPTIONS
|
|
43
|
+
.merge(request.options)
|
|
44
|
+
.merge(text: request.texts, target_lang: language(request.to))
|
|
45
|
+
.tap { |payload| payload[:source_lang] = language(request.from) unless request.from.nil? }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_translate_response(body, _headers, request)
|
|
49
|
+
translations = Array(body["translations"])
|
|
50
|
+
|
|
51
|
+
TranslationDiff::Translation::Response.build(
|
|
52
|
+
request: request,
|
|
53
|
+
texts: translations.map { |t| t["text"] },
|
|
54
|
+
detected_source: translations.first&.dig("detected_source_language")&.downcase,
|
|
55
|
+
usage: usage_for(request, translations)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# DeepL has no detection endpoint; translating a sample and reading the source it reports is the only way.
|
|
60
|
+
def detect(text)
|
|
61
|
+
request = TranslationDiff::Translation::Request.new(texts: [text], from: nil,
|
|
62
|
+
to: DETECTION_TARGET)
|
|
63
|
+
translate(request).detected_source
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def languages
|
|
67
|
+
{ source: codes(get("v2/languages?type=source").body),
|
|
68
|
+
target: codes(get("v2/languages?type=target").body) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The source-list URL is enough to document what #languages fetches; the target one differs only by query.
|
|
72
|
+
def languages_endpoint = "#{api_base}/v2/languages"
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def codes(body) = Array(body).map { |entry| entry["language"] }
|
|
77
|
+
|
|
78
|
+
def free_key? = config.deepl_api_key.to_s.end_with?(FREE_KEY_SUFFIX)
|
|
79
|
+
|
|
80
|
+
def usage_for(request, translations)
|
|
81
|
+
TranslationDiff::Translation::Usage.new(
|
|
82
|
+
characters: request.texts.sum(&:size),
|
|
83
|
+
billed_characters: billed_characters(translations.map { |t| t["billed_characters"] })
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
TranslationDiff::Providers.register(:deepl, TranslationDiff::Providers::DeepL)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Talks to Cloud Translation v2 directly, not google-cloud-translate-v2, which pulled in grpc for one POST.
|
|
2
|
+
class TranslationDiff::Providers::Google < TranslationDiff::HTTPProvider
|
|
3
|
+
HOST = "https://translation.googleapis.com".freeze
|
|
4
|
+
|
|
5
|
+
# Verified against the live API: `text` format translates the protected span and drops its markup.
|
|
6
|
+
DEFAULT_FORMAT = :html
|
|
7
|
+
|
|
8
|
+
# Google's documented limits: 128 strings/request, 5,000 chars recommended (hard ceiling 100 KB).
|
|
9
|
+
def self.capabilities
|
|
10
|
+
TranslationDiff::Capabilities.new(
|
|
11
|
+
max_request_size: 5_000, max_batch_size: 128, max_text_size: nil,
|
|
12
|
+
html: :format, notranslate: true, detects_language: true, reports_billing: false
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# TRANSLATE_KEY then GOOGLE_CLOUD_KEY, the order google-cloud-translate-v2 read them in.
|
|
17
|
+
def self.configuration_options
|
|
18
|
+
[:google_api_base,
|
|
19
|
+
{ google_api_key: -> { ENV.fetch("TRANSLATE_KEY", nil) || ENV.fetch("GOOGLE_CLOUD_KEY", nil) },
|
|
20
|
+
google_project_id: -> { ENV.fetch("TRANSLATE_PROJECT", nil) } }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.configuration_requirements = %i[google_api_key]
|
|
24
|
+
|
|
25
|
+
def api_base = config.google_api_base || HOST
|
|
26
|
+
def translate_url = "language/translate/v2?key=#{CGI.escape(config.google_api_key.to_s)}"
|
|
27
|
+
def detect_url = "language/translate/v2/detect?key=#{CGI.escape(config.google_api_key.to_s)}"
|
|
28
|
+
|
|
29
|
+
def render_translate_payload(request)
|
|
30
|
+
{ format: DEFAULT_FORMAT }
|
|
31
|
+
.merge(request.options)
|
|
32
|
+
.merge(q: request.texts, target: language(request.to))
|
|
33
|
+
.tap { |payload| payload[:source] = language(request.from) unless request.from.nil? }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_translate_response(body, _headers, request)
|
|
37
|
+
translations = Array(body.dig("data", "translations"))
|
|
38
|
+
|
|
39
|
+
TranslationDiff::Translation::Response.build(
|
|
40
|
+
request: request,
|
|
41
|
+
texts: translations.map { |t| t["translatedText"] },
|
|
42
|
+
detected_source: translations.first&.dig("detectedSourceLanguage")&.downcase,
|
|
43
|
+
usage: TranslationDiff::Translation::Usage.new(characters: request.texts.sum(&:size))
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def detect(text)
|
|
48
|
+
response = post(detect_url, { q: [text] })
|
|
49
|
+
response.body.dig("data", "detections", 0, 0, "language")&.downcase
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One list, used in both directions.
|
|
53
|
+
def languages
|
|
54
|
+
codes = Array(get("language/translate/v2/languages?key=#{CGI.escape(config.google_api_key.to_s)}")
|
|
55
|
+
.body.dig("data", "languages")).map { |entry| entry["language"] }
|
|
56
|
+
|
|
57
|
+
{ source: codes, target: codes }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The key is left out: it is a credential, not part of what documents where this list comes from.
|
|
61
|
+
def languages_endpoint = "#{api_base}/language/translate/v2/languages"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
TranslationDiff::Providers.register(:google, TranslationDiff::Providers::Google)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# The only free, self-hosted provider here; base URL is required (everyone runs their own), API key is optional.
|
|
2
|
+
class TranslationDiff::Providers::LibreTranslate < TranslationDiff::HTTPProvider
|
|
3
|
+
DEFAULT_FORMAT = "html".freeze
|
|
4
|
+
|
|
5
|
+
# The API's own way of asking for detection: `source` is required, and "auto" means "work it out".
|
|
6
|
+
AUTO = "auto".freeze
|
|
7
|
+
|
|
8
|
+
# Observed 2026-09-09 via Docker: LibreTranslate's HTML format preserves markup but translates content anyway.
|
|
9
|
+
LIBRETRANSLATE_HONOURS_NOTRANSLATE = false
|
|
10
|
+
|
|
11
|
+
# LibreTranslate publishes no per-request limits; these are this library's own conservative numbers.
|
|
12
|
+
def self.capabilities
|
|
13
|
+
TranslationDiff::Capabilities.new(
|
|
14
|
+
max_request_size: 5_000, max_batch_size: 50, max_text_size: nil,
|
|
15
|
+
html: :format, notranslate: LIBRETRANSLATE_HONOURS_NOTRANSLATE,
|
|
16
|
+
detects_language: true, reports_billing: false
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.configuration_options = %i[libretranslate_api_key libretranslate_api_base]
|
|
21
|
+
def self.configuration_requirements = %i[libretranslate_api_base]
|
|
22
|
+
|
|
23
|
+
def api_base = config.libretranslate_api_base
|
|
24
|
+
def translate_url = "translate"
|
|
25
|
+
|
|
26
|
+
def render_translate_payload(request)
|
|
27
|
+
{ format: DEFAULT_FORMAT }
|
|
28
|
+
.merge(request.options)
|
|
29
|
+
.merge(q: request.texts, target: language(request.to),
|
|
30
|
+
source: request.from.nil? ? AUTO : language(request.from))
|
|
31
|
+
.tap { |payload| payload[:api_key] = config.libretranslate_api_key if config.libretranslate_api_key }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_translate_response(body, _headers, request)
|
|
35
|
+
translated = body["translatedText"]
|
|
36
|
+
detected = body["detectedLanguage"]
|
|
37
|
+
detected = detected.first if detected.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
TranslationDiff::Translation::Response.build(
|
|
40
|
+
request: request,
|
|
41
|
+
texts: translated.is_a?(Array) ? translated : [translated].compact,
|
|
42
|
+
detected_source: detected.is_a?(Hash) ? detected["language"]&.downcase : nil,
|
|
43
|
+
usage: TranslationDiff::Translation::Usage.new(characters: request.texts.sum(&:size))
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def detect(text)
|
|
48
|
+
payload = { q: text }
|
|
49
|
+
payload[:api_key] = config.libretranslate_api_key if config.libretranslate_api_key
|
|
50
|
+
|
|
51
|
+
post("detect", payload).body.dig(0, "language")&.downcase
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Each entry lists its own targets; a self-hosted instance answers for itself, which is the point.
|
|
55
|
+
def languages
|
|
56
|
+
entries = Array(get("languages").body)
|
|
57
|
+
|
|
58
|
+
{ source: entries.map { |entry| entry["code"] },
|
|
59
|
+
target: entries.flat_map { |entry| Array(entry["targets"]) }.uniq }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def languages_endpoint = "#{api_base}/languages"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
TranslationDiff::Providers.register(:libretranslate, TranslationDiff::Providers::LibreTranslate)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ModernMT: adaptive translation with translation memories.
|
|
2
|
+
class TranslationDiff::Providers::ModernMT < TranslationDiff::HTTPProvider
|
|
3
|
+
HOST = "https://api.modernmt.com".freeze
|
|
4
|
+
|
|
5
|
+
# ModernMT spells its formats as MIME types.
|
|
6
|
+
DEFAULT_FORMAT = "text/html".freeze
|
|
7
|
+
|
|
8
|
+
# Unverified, not observed: no key was available to probe it; false is the safe assumption either way.
|
|
9
|
+
MODERNMT_HONOURS_NOTRANSLATE = false
|
|
10
|
+
|
|
11
|
+
# 128 texts is documented; the character limit is not, so Google's 5,000 recommendation is borrowed.
|
|
12
|
+
def self.capabilities
|
|
13
|
+
TranslationDiff::Capabilities.new(
|
|
14
|
+
max_request_size: 5_000, max_batch_size: 128, max_text_size: nil,
|
|
15
|
+
html: :format, notranslate: MODERNMT_HONOURS_NOTRANSLATE,
|
|
16
|
+
detects_language: true, reports_billing: true
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.configuration_options = %i[modernmt_api_key modernmt_api_base]
|
|
21
|
+
def self.configuration_requirements = %i[modernmt_api_key]
|
|
22
|
+
|
|
23
|
+
def api_base = config.modernmt_api_base || HOST
|
|
24
|
+
def headers = { "MMT-ApiKey" => config.modernmt_api_key.to_s }
|
|
25
|
+
def translate_url = "translate"
|
|
26
|
+
|
|
27
|
+
def render_translate_payload(request)
|
|
28
|
+
{ format: DEFAULT_FORMAT }
|
|
29
|
+
.merge(request.options)
|
|
30
|
+
.merge(q: request.texts, target: language(request.to))
|
|
31
|
+
.tap { |payload| payload[:source] = language(request.from) unless request.from.nil? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One text comes back as an object rather than a one-element array, so the envelope is always coerced.
|
|
35
|
+
def parse_translate_response(body, _headers, request)
|
|
36
|
+
results = results_from(body)
|
|
37
|
+
|
|
38
|
+
TranslationDiff::Translation::Response.build(
|
|
39
|
+
request: request,
|
|
40
|
+
texts: results.map { |r| r["translation"] },
|
|
41
|
+
detected_source: results.first&.dig("detectedLanguage")&.downcase,
|
|
42
|
+
usage: usage_for(request, results)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def detect(text)
|
|
47
|
+
request = TranslationDiff::Translation::Request.new(texts: [text], from: nil, to: "en")
|
|
48
|
+
translate(request).detected_source
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def languages
|
|
52
|
+
codes = Array(get("translate/languages").body["data"])
|
|
53
|
+
|
|
54
|
+
{ source: codes, target: codes }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def languages_endpoint = "#{api_base}/translate/languages"
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def results_from(body)
|
|
62
|
+
data = body["data"]
|
|
63
|
+
data.is_a?(Array) ? data : [data].compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def usage_for(request, results)
|
|
67
|
+
TranslationDiff::Translation::Usage.new(
|
|
68
|
+
characters: request.texts.sum(&:size),
|
|
69
|
+
billed_characters: billed_characters(results.map { |r| r["billedCharacters"] })
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
TranslationDiff::Providers.register(:modernmt, TranslationDiff::Providers::ModernMT)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Hands back what it was given -- for tests, and for wiring a pipeline up before a real provider is available.
|
|
2
|
+
class TranslationDiff::Providers::Null < TranslationDiff::Provider
|
|
3
|
+
# Deliberately not detecting: this is the provider that proves the optional branch works.
|
|
4
|
+
def self.capabilities
|
|
5
|
+
TranslationDiff::Capabilities.new(
|
|
6
|
+
max_request_size: 1_000_000, max_batch_size: 1_000_000, max_text_size: nil,
|
|
7
|
+
html: :none, notranslate: false, detects_language: false, reports_billing: false
|
|
8
|
+
)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def translate(request)
|
|
12
|
+
TranslationDiff::Translation::Response.build(
|
|
13
|
+
request: request, texts: request.texts.map(&:to_s)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cache_key = "null"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
TranslationDiff::Providers.register(:null, TranslationDiff::Providers::Null)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Translation providers, by name; registering one also declares its configuration options.
|
|
2
|
+
module TranslationDiff::Providers
|
|
3
|
+
# Said once, so a provider refused by name, by class or as an object is refused in the same words.
|
|
4
|
+
CONTRACT = "The base class supplies the transport, the configuration check and the " \
|
|
5
|
+
"capability defaults, so a provider that skips it has none of them.".freeze
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
# Options are declared before the registry entry is written, so a name collision raises without replacing.
|
|
9
|
+
def register(name, klass)
|
|
10
|
+
ensure_provider_class!(klass)
|
|
11
|
+
TranslationDiff::Configuration.register_provider_options(klass.configuration_options, klass)
|
|
12
|
+
registry.register(name, klass)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Guards registration. `klass < Provider` alone raises NoMethodError for an instance or a non-Module.
|
|
16
|
+
def ensure_provider_class!(klass)
|
|
17
|
+
return klass if klass.is_a?(Class) && klass < TranslationDiff::Provider
|
|
18
|
+
|
|
19
|
+
raise TranslationDiff::InvalidProviderError,
|
|
20
|
+
"#{describe(klass)} cannot be registered as a provider: the registry takes a " \
|
|
21
|
+
"class inheriting TranslationDiff::Provider. #{CONTRACT}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Guards the other two ways a provider arrives: assigned to config.provider, or passed as `provider:`.
|
|
25
|
+
def ensure_provider!(instance)
|
|
26
|
+
return instance if instance.is_a?(TranslationDiff::Provider)
|
|
27
|
+
|
|
28
|
+
raise TranslationDiff::InvalidProviderError,
|
|
29
|
+
"#{describe(instance)} cannot be used as a provider: it does not inherit " \
|
|
30
|
+
"TranslationDiff::Provider. #{CONTRACT}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build(name, config)
|
|
34
|
+
registry.build(name, config).tap { |provider| provider.name = name.to_sym }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The one seam Translator and Previewer both resolve a provider through, cache_key guard included.
|
|
38
|
+
def resolve(requested, config)
|
|
39
|
+
provider = requested.nil? ? config.provider_instance : resolve_requested(requested, config)
|
|
40
|
+
ensure_cache_key!(provider)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def registered?(name) = registry.registered?(name)
|
|
44
|
+
def names = registry.names
|
|
45
|
+
def classes = registry.classes
|
|
46
|
+
def registry = @registry ||= TranslationDiff::Registry.new("provider")
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# A provider arrives as a name to build, or as an object to use as it is.
|
|
51
|
+
def resolve_requested(requested, config)
|
|
52
|
+
return build(requested, config) if requested.is_a?(Symbol) || requested.is_a?(String)
|
|
53
|
+
|
|
54
|
+
ensure_provider!(requested)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The cache key names the provider in every payload too: it is the one identifier every provider must have.
|
|
58
|
+
def ensure_cache_key!(provider)
|
|
59
|
+
return provider unless provider.cache_key.to_s.strip.empty?
|
|
60
|
+
|
|
61
|
+
raise TranslationDiff::InvalidProviderError,
|
|
62
|
+
"#{provider.class} must define #cache_key: a blank one would file its " \
|
|
63
|
+
"translations in every other provider's cache namespace."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Never #to_s on a non-Module: an arbitrary object renders its own content, or an address.
|
|
67
|
+
def describe(value) = value.is_a?(Module) ? value.to_s : "an instance of #{value.class}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Loaded only when Rails already is (see the guarded require in translation_diff.rb), never on its own.
|
|
2
|
+
require "active_support/core_ext/module/delegation"
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
# Adds translation_diff:prune to a host Rails application's own rake tasks; the gem's dev Rakefile loads the
|
|
6
|
+
# same task file directly, so a host application's `rake -T` and this gem's own suite see one definition.
|
|
7
|
+
class TranslationDiff::Railtie < Rails::Railtie
|
|
8
|
+
rake_tasks do
|
|
9
|
+
load File.expand_path("tasks/translation_diff.rake", __dir__)
|
|
10
|
+
Rake::Task["translation_diff:prune"].enhance(["environment"])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Throttles by counting characters into namespaced, time-bucketed rows in the application's own database.
|
|
2
|
+
class TranslationDiff::RateLimiters::ActiveRecord
|
|
3
|
+
include TranslationDiff::ActiveRecord::Support
|
|
4
|
+
|
|
5
|
+
DEFAULT_THRESHOLD = 8000
|
|
6
|
+
DEFAULT_INTERVAL = 60
|
|
7
|
+
|
|
8
|
+
# A bucket a twelfth of the interval wide caps a window's slop at under 10%, the same shape the `ratelimit` gem
|
|
9
|
+
# gets from its own fixed five-second buckets at the default 60-second interval.
|
|
10
|
+
BUCKET_FRACTION = 12
|
|
11
|
+
|
|
12
|
+
# An unset rate_limit must mean DEFAULT_THRESHOLD, not the nil that would override that keyword default.
|
|
13
|
+
def self.build(config)
|
|
14
|
+
options = { namespace: config.cache_namespace, table_name: config.rate_limit_table_name,
|
|
15
|
+
interval: config.rate_interval, base: config.active_record_base }
|
|
16
|
+
options[:threshold] = config.rate_limit unless config.rate_limit.nil?
|
|
17
|
+
new(**options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(namespace:, table_name:, threshold: DEFAULT_THRESHOLD, interval: DEFAULT_INTERVAL, base: nil,
|
|
21
|
+
clock: -> { Time.now })
|
|
22
|
+
@namespace = namespace
|
|
23
|
+
@table_name = table_name
|
|
24
|
+
@threshold = threshold
|
|
25
|
+
@interval = interval
|
|
26
|
+
@base = base
|
|
27
|
+
@clock = clock
|
|
28
|
+
@bucket_width = [@interval / BUCKET_FRACTION, 1].max
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A sliding window: every bucket covering the last `interval` seconds is summed, not just the current one.
|
|
32
|
+
def check(size)
|
|
33
|
+
raise TranslationDiff::RateLimitExceeded, exceeded_message if current_total >= @threshold
|
|
34
|
+
|
|
35
|
+
add(size)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
raise unless ar_error?(e)
|
|
38
|
+
|
|
39
|
+
raise redacted_error(e), cause: nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The limiter's own statements carry counts, not content -- but a ReadOnlyError quotes the statement, and
|
|
43
|
+
# a raw ActiveRecord error from inside a translate call tells a caller nothing about which gem it came from.
|
|
44
|
+
def redacted_error(error)
|
|
45
|
+
adapter_error = error.cause&.class || error.class
|
|
46
|
+
TranslationDiff::Error.new("the rate limit check failed (#{adapter_error}): a read or upsert on " \
|
|
47
|
+
"#{@table_name}(namespace, bucket, characters)")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Counts and settings, never a character of what was being translated.
|
|
51
|
+
def exceeded_message
|
|
52
|
+
"rate limit reached for #{@namespace}: #{@threshold} characters per #{@interval} seconds"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Buckets that have fully aged out of the window as of now; the oldest bucket itself is still counted by it.
|
|
56
|
+
def prune = model.where(namespace: @namespace).where(bucket: ...oldest_bucket).delete_all
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# The oldest bucket is only ever partially inside the window, so summing from it, not past it, errs strict.
|
|
61
|
+
def current_total
|
|
62
|
+
model.where(namespace: @namespace, bucket: oldest_bucket..current_bucket).sum(:characters)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def current_bucket = now / @bucket_width
|
|
66
|
+
|
|
67
|
+
def oldest_bucket = (now - @interval) / @bucket_width
|
|
68
|
+
|
|
69
|
+
def now = @clock.call.to_i
|
|
70
|
+
|
|
71
|
+
# One statement, so two processes incrementing the same bucket cannot lose an increment between them.
|
|
72
|
+
# A negative size would otherwise hand back headroom it never used, so it is clamped before it reaches SQL.
|
|
73
|
+
def add(size)
|
|
74
|
+
size = size.to_i.clamp(0..)
|
|
75
|
+
model.upsert_all([{ namespace: @namespace, bucket: current_bucket, characters: size }],
|
|
76
|
+
**upsert_options(model.connection, size))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# MySQL's adapter never answers true here and its ON DUPLICATE KEY UPDATE already targets every unique key.
|
|
80
|
+
def upsert_options(connection, size)
|
|
81
|
+
table = connection.quote_table_name(model.table_name)
|
|
82
|
+
options = { on_duplicate: Arel.sql("characters = #{table}.characters + #{size}") }
|
|
83
|
+
options[:unique_by] = %i[namespace bucket] if connection.supports_insert_conflict_target?
|
|
84
|
+
options
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def active_record_feature = "the rate limiter"
|
|
88
|
+
def active_record_component = "ActiveRecord rate limiter"
|
|
89
|
+
def active_record_upsert_detail = "upsert_all takes unique_by there."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
TranslationDiff::RateLimiters.register(:active_record, TranslationDiff::RateLimiters::ActiveRecord)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
class TranslationDiff::RateLimiters::Redis
|
|
2
|
+
DEFAULT_THRESHOLD = 8000
|
|
3
|
+
DEFAULT_INTERVAL = 60
|
|
4
|
+
DEFAULT_NAMESPACE = "translation-diff".freeze
|
|
5
|
+
|
|
6
|
+
# This library limits the provider as a whole rather than per caller, so there is exactly one subject.
|
|
7
|
+
SUBJECT = "call".freeze
|
|
8
|
+
|
|
9
|
+
# An unset rate_limit must mean DEFAULT_THRESHOLD, not the nil that would override that keyword default.
|
|
10
|
+
def self.build(config)
|
|
11
|
+
options = { interval: config.rate_interval, namespace: config.cache_namespace }
|
|
12
|
+
options[:threshold] = config.rate_limit unless config.rate_limit.nil?
|
|
13
|
+
new(config.redis_pool, **options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# `connection_pool` is duck-typed to #with; neither connection_pool nor ratelimit is a hard dependency.
|
|
17
|
+
def initialize(connection_pool,
|
|
18
|
+
threshold: DEFAULT_THRESHOLD,
|
|
19
|
+
interval: DEFAULT_INTERVAL,
|
|
20
|
+
namespace: DEFAULT_NAMESPACE)
|
|
21
|
+
@connection_pool = connection_pool
|
|
22
|
+
@threshold = threshold
|
|
23
|
+
@interval = interval
|
|
24
|
+
@namespace = namespace
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check(size)
|
|
28
|
+
limiter_class = ratelimit_class
|
|
29
|
+
|
|
30
|
+
connection_pool.with do |redis|
|
|
31
|
+
rate_limit = limiter_class.new(namespace, redis: redis)
|
|
32
|
+
exceeded = rate_limit.exceeded?(SUBJECT, threshold: threshold, interval: interval)
|
|
33
|
+
raise TranslationDiff::RateLimitExceeded, exceeded_message if exceeded
|
|
34
|
+
|
|
35
|
+
rate_limit.add(SUBJECT, size)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Counts and settings, never a character of what was being translated.
|
|
42
|
+
def exceeded_message
|
|
43
|
+
"rate limit reached for #{namespace}: #{threshold} characters per #{interval} seconds"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :connection_pool, :threshold, :interval, :namespace
|
|
47
|
+
|
|
48
|
+
# Required at first check, not load time; naming the bare constant instead would raise a raw NameError.
|
|
49
|
+
def ratelimit_class
|
|
50
|
+
require "ratelimit"
|
|
51
|
+
::Ratelimit
|
|
52
|
+
rescue LoadError
|
|
53
|
+
raise TranslationDiff::Error,
|
|
54
|
+
"a rate limit was configured but the `ratelimit` gem is not available. " \
|
|
55
|
+
'Add `gem "ratelimit"` to your Gemfile.'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
TranslationDiff::RateLimiters.register(:redis, TranslationDiff::RateLimiters::Redis)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Rate limiters, by name; assigning an object to `config.rate_limiter` bypasses this entirely.
|
|
2
|
+
module TranslationDiff::RateLimiters
|
|
3
|
+
def self.register(name, klass) = registry.register(name, klass)
|
|
4
|
+
def self.build(name, config) = registry.build(name, config)
|
|
5
|
+
def self.registered?(name) = registry.registered?(name)
|
|
6
|
+
def self.names = registry.names
|
|
7
|
+
def self.classes = registry.classes
|
|
8
|
+
def self.registry = @registry ||= TranslationDiff::Registry.new("rate limiter")
|
|
9
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
# Which configuration options must never be printed, decided by name rather than by a list someone maintains.
|
|
4
|
+
module TranslationDiff::Redaction
|
|
5
|
+
SENSITIVE = /key|secret|token|password|auth|credential/
|
|
6
|
+
FILTERED = "[FILTERED]".freeze
|
|
7
|
+
|
|
8
|
+
def self.sensitive?(name) = name.to_s.match?(SENSITIVE)
|
|
9
|
+
|
|
10
|
+
# Unioned fresh on every call, never memoised -- a provider can register after the first inspect.
|
|
11
|
+
def self.declared_sensitive
|
|
12
|
+
TranslationDiff::Providers.classes.flat_map(&:sensitive_options).map(&:to_sym)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Reads through the public accessor, or an option set only through its ENV-backed default goes unnoticed.
|
|
16
|
+
def self.render(config)
|
|
17
|
+
declared = declared_sensitive
|
|
18
|
+
|
|
19
|
+
TranslationDiff::Configuration.options.filter_map do |key|
|
|
20
|
+
value = config.public_send(key)
|
|
21
|
+
next if value.nil?
|
|
22
|
+
|
|
23
|
+
"#{key}=#{rendered_value(key, value, declared)}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.rendered_value(key, value, declared)
|
|
28
|
+
return FILTERED if sensitive?(key) || declared.include?(key)
|
|
29
|
+
|
|
30
|
+
(redact_userinfo(value) || value).inspect
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# redis_url and a provider's *_api_base can carry a credential inline; the host stays, only the userinfo hides.
|
|
34
|
+
def self.redact_userinfo(value)
|
|
35
|
+
return nil unless value.is_a?(String)
|
|
36
|
+
|
|
37
|
+
userinfo = URI.parse(value).userinfo
|
|
38
|
+
return nil unless userinfo
|
|
39
|
+
|
|
40
|
+
user, separator, = userinfo.partition(":")
|
|
41
|
+
value.sub(userinfo, separator.empty? ? FILTERED : "#{user}:#{FILTERED}")
|
|
42
|
+
rescue URI::Error
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Maps a symbol to a class that builds itself from a Configuration; the whole contract is answering `build(config)`.
|
|
2
|
+
class TranslationDiff::Registry
|
|
3
|
+
# `kind` appears in the unknown-name error message, so it should be a singular noun: "provider".
|
|
4
|
+
def initialize(kind)
|
|
5
|
+
@kind = kind
|
|
6
|
+
@entries = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(name, klass)
|
|
10
|
+
@entries[name.to_sym] = klass
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(name, config)
|
|
14
|
+
fetch(name).build(config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def registered?(name) = @entries.key?(name.to_sym)
|
|
18
|
+
|
|
19
|
+
def names = @entries.keys
|
|
20
|
+
|
|
21
|
+
def classes = @entries.values
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def fetch(name)
|
|
26
|
+
@entries.fetch(name.to_sym) do
|
|
27
|
+
raise TranslationDiff::Error,
|
|
28
|
+
"Unknown #{@kind} #{name.to_sym.inspect}. Registered: #{names.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# A sentence with the whitespace it was found in: its core is the text a provider sees, its render is markup again.
|
|
2
|
+
class TranslationDiff::Segment
|
|
3
|
+
attr_reader :source, :body, :core
|
|
4
|
+
attr_accessor :translation
|
|
5
|
+
|
|
6
|
+
# A core is compared decoded, so a sentence that was only ` ` counts as the padding it is and is never sent.
|
|
7
|
+
BLANK = /\A[[:space:]]*\z/
|
|
8
|
+
|
|
9
|
+
def initialize(source)
|
|
10
|
+
@source = source.dup
|
|
11
|
+
@leading, @body, @trailing = @source.partition(/[^[:space:]].*[^[:space:]]|[^[:space:]]/m)
|
|
12
|
+
@core = TranslationDiff::Markup.decode_entities(@body)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Reflects whether a translation is set right now, not history -- clearing it to nil flips this back to false.
|
|
16
|
+
def translated? = !translation.nil?
|
|
17
|
+
|
|
18
|
+
def empty? = core.match?(BLANK)
|
|
19
|
+
|
|
20
|
+
# Untranslated hands back the bytes it was cut from; a translation is text, so it is encoded as markup on the way out.
|
|
21
|
+
def render
|
|
22
|
+
"#{@leading}#{translated? ? escaped_translation : @body}#{@trailing}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# @body already carries this same escape from Passage; without it, Passage's one shared restore pass would
|
|
28
|
+
# read a translated `<` as a source document's own bare `<` and hand back markup nobody asked for.
|
|
29
|
+
def escaped_translation
|
|
30
|
+
TranslationDiff::Markup.escape_bare_angles(TranslationDiff::Markup.encode_translation(translation))
|
|
31
|
+
end
|
|
32
|
+
end
|