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,38 @@
|
|
|
1
|
+
# The full option table: every setting Configuration exposes, its default, and what it invalidates.
|
|
2
|
+
# Lives apart from Configuration itself so the class that implements the behaviour isn't measured by
|
|
3
|
+
# a list that only grows -- this module is documentation as much as code, read it as a reference.
|
|
4
|
+
module TranslationDiff::Configuration::OptionTable
|
|
5
|
+
# [key, default, the memoised reader(s) it invalidates -- nil means it invalidates nothing]
|
|
6
|
+
TABLE = [
|
|
7
|
+
[:provider, :deepl, :provider_instance], # rubocop:disable Style/SymbolArray -- stays [key, default, invalidates]
|
|
8
|
+
[:cache, nil, :cache_store],
|
|
9
|
+
[:cache_ttl, 604_800, :cache_store],
|
|
10
|
+
# Also the rate limiter's own namespace (RateLimiters::Redis, RateLimiters::ActiveRecord both read it).
|
|
11
|
+
[:cache_namespace, "translation-diff", %i[cache_store rate_limiter_instance]],
|
|
12
|
+
[:cache_max_size, 1_000, :cache_store],
|
|
13
|
+
[:cache_table_name, "translation_diff_translations", :cache_store],
|
|
14
|
+
[:rate_limit_table_name, "translation_diff_rate_limits", :rate_limiter_instance],
|
|
15
|
+
[:active_record_base, nil, %i[cache_store rate_limiter_instance]],
|
|
16
|
+
[:cache_prune_probability, 0.0, :cache_store],
|
|
17
|
+
[:redis_url, -> { ENV.fetch("REDIS_URL", nil) }, %i[redis_pool cache_store rate_limiter_instance]],
|
|
18
|
+
[:redis_pool_size, 5, %i[redis_pool cache_store rate_limiter_instance]],
|
|
19
|
+
[:redis_pool_timeout, 5, %i[redis_pool cache_store rate_limiter_instance]],
|
|
20
|
+
[:rate_limit, nil, :rate_limiter_instance],
|
|
21
|
+
[:rate_interval, 60, :rate_limiter_instance],
|
|
22
|
+
[:rate_limiter, nil, :rate_limiter_instance],
|
|
23
|
+
[:segmenter, :pragmatic, :segmenter_instance], # rubocop:disable Style/SymbolArray
|
|
24
|
+
[:opaque_elements, %i[script style pre code], nil],
|
|
25
|
+
[:instrumenter, nil, nil],
|
|
26
|
+
[:logger, nil, nil],
|
|
27
|
+
# HTTPProvider#connection memoises a Faraday connection built from these three, and the provider itself
|
|
28
|
+
# is memoised too, so a change here has to reach provider_instance or it never reaches the connection.
|
|
29
|
+
[:open_timeout, 5, :provider_instance],
|
|
30
|
+
[:timeout, 30, :provider_instance],
|
|
31
|
+
[:max_retries, 3, :provider_instance],
|
|
32
|
+
[:validate_languages, true, nil]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
def self.declare_on(configuration_class)
|
|
36
|
+
TABLE.each { |key, default, invalidates| configuration_class.option(key, default, invalidates: invalidates) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tracks which provider declared each option; two silently sharing one accessor would leak a credential.
|
|
2
|
+
class TranslationDiff::Configuration::ProviderOptionOwners
|
|
3
|
+
def initialize
|
|
4
|
+
@owners = {}
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
# All-or-nothing: recording key by key would attribute earlier keys to a provider that then failed.
|
|
8
|
+
def claim(keys, provider)
|
|
9
|
+
validate!(keys, provider)
|
|
10
|
+
keys.each { |key| @owners[key] = provider }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def validate!(keys, provider)
|
|
16
|
+
keys.each { |key| conflict!(key, provider) unless available?(key, provider) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def available?(key, provider)
|
|
20
|
+
owner = @owners[key]
|
|
21
|
+
owner.nil? || same_provider?(owner, provider)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# `<=>` is non-nil exactly when both sit on one inheritance chain -- also true if `provider` isn't a Module.
|
|
25
|
+
def same_provider?(owner, provider)
|
|
26
|
+
owner.equal?(provider) ||
|
|
27
|
+
(!owner.name.nil? && owner.name == provider.name) ||
|
|
28
|
+
!(provider <=> owner).nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def conflict!(key, provider)
|
|
32
|
+
owner = @owners[key]
|
|
33
|
+
raise TranslationDiff::Error,
|
|
34
|
+
"#{provider} declares the configuration option #{key.inspect}, which #{owner} " \
|
|
35
|
+
"already declared. Provider options share one namespace on " \
|
|
36
|
+
"TranslationDiff::Configuration: two providers declaring the same name would " \
|
|
37
|
+
"share one accessor, so a credential set for one would be handed to the other. " \
|
|
38
|
+
"Prefix the option with the provider's own name."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Every declared setting in one place; callable defaults are invoked on read, not at load time.
|
|
2
|
+
class TranslationDiff::Configuration
|
|
3
|
+
class << self
|
|
4
|
+
# `invalidates:` names the memoised reader(s) this option feeds; a writer clears exactly those ivars.
|
|
5
|
+
# An option that names none -- logger, instrumenter, the timeouts -- clears nothing, which is also
|
|
6
|
+
# what an option nobody classifies does: invalidation is opt-in, never a guess from the option's name.
|
|
7
|
+
def option(key, default = nil, invalidates: nil)
|
|
8
|
+
key = key.to_sym
|
|
9
|
+
return if options.include?(key)
|
|
10
|
+
|
|
11
|
+
define_option_accessors(key, Array(invalidates))
|
|
12
|
+
defaults[key] = default
|
|
13
|
+
options << key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# See ProviderOptionOwners for the conflict rules and the all-or-nothing guarantee. Every provider
|
|
17
|
+
# option invalidates provider_instance, whatever it is named -- the registry, not a remembered list,
|
|
18
|
+
# is what makes the set known.
|
|
19
|
+
def register_provider_options(declared, provider)
|
|
20
|
+
declared = normalise_declarations(declared)
|
|
21
|
+
provider_option_owners.claim(declared.keys, provider)
|
|
22
|
+
declared.each { |key, default| option(key, default, invalidates: :provider_instance) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def options = @options ||= []
|
|
26
|
+
def defaults = @defaults ||= {}
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# The writer clears exactly the memos this option was declared to invalidate; the reader defers to `read`.
|
|
31
|
+
# A write that leaves the raw value unchanged clears none of them -- a per-request write of the same
|
|
32
|
+
# tenant must not rebuild a cache store that was already warm.
|
|
33
|
+
def define_option_accessors(key, memos)
|
|
34
|
+
ivar = :"@#{key}"
|
|
35
|
+
define_method(:"#{key}=") do |value|
|
|
36
|
+
value = nil if value.is_a?(String) && value.strip.empty?
|
|
37
|
+
next if instance_variable_get(ivar) == value
|
|
38
|
+
|
|
39
|
+
instance_variable_set(ivar, value)
|
|
40
|
+
memos.each { |memo| instance_variable_set(:"@#{memo}", nil) }
|
|
41
|
+
end
|
|
42
|
+
define_method(key) { read(key) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `:key` declares an option with no default; `{ key => default }` declares one, and a callable is read lazily.
|
|
46
|
+
def normalise_declarations(declared)
|
|
47
|
+
entries = declared.is_a?(Hash) ? [declared] : Array(declared)
|
|
48
|
+
|
|
49
|
+
entries.each_with_object({}) do |entry, result|
|
|
50
|
+
entry.is_a?(Hash) ? result.merge!(entry.transform_keys(&:to_sym)) : result[entry.to_sym] = nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def provider_option_owners = @provider_option_owners ||= ProviderOptionOwners.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Required here, not centrally: the modules they define nest under this class, which must exist first.
|
|
58
|
+
require "translation_diff/configuration/option_table"
|
|
59
|
+
TranslationDiff::Configuration::OptionTable.declare_on(self)
|
|
60
|
+
|
|
61
|
+
require "translation_diff/configuration/cache_ttl_option"
|
|
62
|
+
require "translation_diff/configuration/cache_guard_options"
|
|
63
|
+
prepend TranslationDiff::Configuration::CacheTtlOption
|
|
64
|
+
prepend TranslationDiff::Configuration::CacheGuardOptions
|
|
65
|
+
|
|
66
|
+
# Credentials are filtered by name; everything else is shown, or an inspect is one nobody reads.
|
|
67
|
+
def inspect = "#<#{self.class.name} #{TranslationDiff::Redaction.render(self).join(' ')}>"
|
|
68
|
+
|
|
69
|
+
# Memoised collaborators aren't copied, or a tenant's own cache_namespace leaks its parent's rate limiter.
|
|
70
|
+
def copy
|
|
71
|
+
self.class.new.tap do |other|
|
|
72
|
+
self.class.options.each do |key|
|
|
73
|
+
other.instance_variable_set(:"@#{key}", instance_variable_get(:"@#{key}"))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Guarded, unlike cache/segmenter/rate_limiter: only the provider gained a base class to check against.
|
|
79
|
+
def provider_instance
|
|
80
|
+
@provider_instance ||=
|
|
81
|
+
TranslationDiff::Providers.ensure_provider!(resolve(provider, TranslationDiff::Providers))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Unset `cache` means Redis when a URL is configured, otherwise in-process -- works before anything runs.
|
|
85
|
+
def cache_store
|
|
86
|
+
@cache_store ||= resolve(cache || (redis_url ? :redis : :memory), TranslationDiff::Stores)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def segmenter_instance
|
|
90
|
+
@segmenter_instance ||= resolve(segmenter, TranslationDiff::Segmenters.registry)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# nil, not a null object: Dispatcher#throttle checks for nil and skips rate-limiting -- costs nothing normally.
|
|
94
|
+
def rate_limiter_instance
|
|
95
|
+
return nil if rate_limiter.nil? && rate_limit.nil?
|
|
96
|
+
|
|
97
|
+
@rate_limiter_instance ||= resolve(rate_limiter || :redis, TranslationDiff::RateLimiters)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One pool shared by the cache store and the rate limiter; callers used to build and pass it by hand.
|
|
101
|
+
def redis_pool
|
|
102
|
+
@redis_pool ||= build_redis_pool
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def build_redis_pool
|
|
108
|
+
require "connection_pool"
|
|
109
|
+
require "redis"
|
|
110
|
+
ConnectionPool.new(size: redis_pool_size, timeout: redis_pool_timeout) do
|
|
111
|
+
Redis.new(url: redis_url)
|
|
112
|
+
end
|
|
113
|
+
rescue LoadError
|
|
114
|
+
raise TranslationDiff::Error,
|
|
115
|
+
"a Redis-backed cache or rate limiter was requested but the gems are not " \
|
|
116
|
+
'available. Add `gem "redis"`, `gem "connection_pool"` and ' \
|
|
117
|
+
'`gem "redis-namespace"` to your Gemfile.'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A default is resolved on every read, and a blank one is unset -- the rule the writer already applies.
|
|
121
|
+
def read(key)
|
|
122
|
+
value = instance_variable_get(:"@#{key}")
|
|
123
|
+
return value unless value.nil?
|
|
124
|
+
|
|
125
|
+
default = self.class.defaults[key]
|
|
126
|
+
blank_to_nil(default.respond_to?(:call) ? default.call : default)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def blank_to_nil(value) = value.is_a?(String) && value.strip.empty? ? nil : value
|
|
130
|
+
|
|
131
|
+
# The symbol-or-object rule, implemented once for all three extension points.
|
|
132
|
+
def resolve(value, registry)
|
|
133
|
+
value.is_a?(Symbol) || value.is_a?(String) ? registry.build(value, self) : value
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# An isolated configuration scope with the same entry point as TranslationDiff itself.
|
|
2
|
+
class TranslationDiff::Context
|
|
3
|
+
attr_reader :config
|
|
4
|
+
|
|
5
|
+
def initialize(config)
|
|
6
|
+
@config = config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def translate(values, from: nil, to: nil, provider: nil, assume_supported: false, **)
|
|
10
|
+
TranslationDiff::Translator.new(
|
|
11
|
+
values, from: from, to: to, provider: provider, config: config,
|
|
12
|
+
assume_supported: assume_supported, **
|
|
13
|
+
).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Answers what #translate would do to `values` under this context's own configuration, without calling it.
|
|
17
|
+
def preview(values, from: nil, to: nil, provider: nil, assume_supported: false, **)
|
|
18
|
+
TranslationDiff::Previewer.new(
|
|
19
|
+
values, from: from, to: to, provider: provider, config: config,
|
|
20
|
+
assume_supported: assume_supported, **
|
|
21
|
+
).call
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Sends batches to the provider, throttles each one, and reports what it cost -- the seam between cache and wire.
|
|
2
|
+
class TranslationDiff::Dispatcher
|
|
3
|
+
include TranslationDiff::Instrumentation
|
|
4
|
+
|
|
5
|
+
attr_reader :config
|
|
6
|
+
|
|
7
|
+
def initialize(provider:, from:, to:, call_id:, options: {}, config: nil)
|
|
8
|
+
@provider = provider
|
|
9
|
+
@from = from
|
|
10
|
+
@to = to
|
|
11
|
+
@call_id = call_id
|
|
12
|
+
@options = options
|
|
13
|
+
@config = config || TranslationDiff.config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def dispatch(segments)
|
|
17
|
+
batches = TranslationDiff::Batch.pack(segments, capabilities: @provider.class.capabilities)
|
|
18
|
+
batches.each { |batch| send_batch(batch) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# The batch applies the reply to the segments that produced it, so no step ever correlates by position again.
|
|
24
|
+
def send_batch(batch)
|
|
25
|
+
texts = batch.texts
|
|
26
|
+
payload = { call_id: @call_id, provider: @provider.cache_key, batch: texts.size, characters: texts.sum(&:size) }
|
|
27
|
+
throttle(payload[:characters])
|
|
28
|
+
response = instrument("request", payload) { @provider.translate(request(texts)) }
|
|
29
|
+
report_usage(response, payload[:characters])
|
|
30
|
+
batch.apply(response.texts)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request(texts)
|
|
34
|
+
TranslationDiff::Translation::Request.new(texts: texts, from: @from, to: @to, options: @options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Consulted with what is about to be sent, before it is sent; nil means no rate limiting was configured at all.
|
|
38
|
+
def throttle(characters)
|
|
39
|
+
limiter = config.rate_limiter_instance
|
|
40
|
+
return if limiter.nil?
|
|
41
|
+
|
|
42
|
+
instrument("rate_limit", call_id: @call_id, provider: @provider.cache_key,
|
|
43
|
+
characters: characters) { limiter.check(characters) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A point event: what this request cost, as this library counted it -- a provider's own count never overrides it.
|
|
47
|
+
def report_usage(response, characters)
|
|
48
|
+
usage = response.usage
|
|
49
|
+
|
|
50
|
+
instrument("usage", call_id: @call_id,
|
|
51
|
+
provider: @provider.cache_key,
|
|
52
|
+
characters: characters,
|
|
53
|
+
billed_characters: usage&.billed_characters,
|
|
54
|
+
reported: @provider.class.capabilities.reports_billing?,
|
|
55
|
+
model: usage&.model)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# A caller's value together with its Hash/Array shape, walked without ever flattening it into an array.
|
|
2
|
+
class TranslationDiff::Document
|
|
3
|
+
def initialize(value) = @value = value
|
|
4
|
+
|
|
5
|
+
# Returns a new structure of the same shape with every leaf String replaced by the block's result.
|
|
6
|
+
def map(&) = walk(@value, &)
|
|
7
|
+
|
|
8
|
+
# Returns the leaf strings in document order, without touching the original value.
|
|
9
|
+
def strings
|
|
10
|
+
[].tap { |acc| walk(@value) { |string| acc << string } }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def walk(node, &block)
|
|
16
|
+
case node
|
|
17
|
+
when Hash then node.to_h { |key, value| [key, walk(value, &block)] }
|
|
18
|
+
when Array then node.map { |value| walk(value, &block) }
|
|
19
|
+
when String then block.call(node)
|
|
20
|
+
else node
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# No error carries the text being translated -- errors are logged, and this library handles other people's content.
|
|
2
|
+
module TranslationDiff
|
|
3
|
+
# Common ancestor for every error this gem raises, so `rescue TranslationDiff::Error` is enough.
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
# Raised by whichever limiter is configured, so a caller rescues one class rather than the one it happens to use.
|
|
9
|
+
class RateLimitExceeded < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised before any request: the pair is checked against data captured from the vendor, not by asking it.
|
|
12
|
+
class UnsupportedLanguageError < Error; end
|
|
13
|
+
|
|
14
|
+
class ProviderError < Error
|
|
15
|
+
attr_reader :provider, :status
|
|
16
|
+
|
|
17
|
+
def initialize(message, provider: nil, status: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@provider = provider
|
|
20
|
+
@status = status
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class AuthenticationError < ProviderError; end
|
|
25
|
+
class QuotaExceededError < ProviderError; end
|
|
26
|
+
class InvalidRequestError < ProviderError; end
|
|
27
|
+
class ServiceError < ProviderError; end
|
|
28
|
+
|
|
29
|
+
class RateLimitError < ProviderError
|
|
30
|
+
# Faraday's retry middleware already honours this header; it's here for a caller scheduling its own retry.
|
|
31
|
+
attr_reader :retry_after
|
|
32
|
+
|
|
33
|
+
def initialize(message, provider: nil, status: nil, retry_after: nil)
|
|
34
|
+
super(message, provider: provider, status: status)
|
|
35
|
+
@retry_after = retry_after
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class TransportError < Error; end
|
|
40
|
+
class ResponseError < Error; end
|
|
41
|
+
|
|
42
|
+
# Its own class, not the generic Error, so rescuing "wrong shape" can't also swallow an option-name collision.
|
|
43
|
+
class InvalidProviderError < Error; end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# A run of the source that is either markup, handed back as found, or prose, handed back through its segments.
|
|
2
|
+
class TranslationDiff::Fragment
|
|
3
|
+
EMPTY = [].freeze
|
|
4
|
+
|
|
5
|
+
attr_reader :source
|
|
6
|
+
|
|
7
|
+
# Markup has nothing a provider should see, so it carries no segments and renders the bytes it was cut from.
|
|
8
|
+
def self.markup(source) = new(source, nil)
|
|
9
|
+
|
|
10
|
+
# Prose is cut where the segmenter says sentences begin, so every segment keeps the whitespace it was found in.
|
|
11
|
+
def self.prose(source, segmenter:, language: nil)
|
|
12
|
+
new(source, cut(source, segmenter.split_offsets(source, language: language)))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The offsets start at 0 and strictly increase, so slicing between them and from the last to the end is exact.
|
|
16
|
+
def self.cut(source, offsets)
|
|
17
|
+
sentences = offsets.each_cons(2).map { |from, to| source[from...to] } << source[offsets.last..]
|
|
18
|
+
sentences.map { |sentence| TranslationDiff::Segment.new(sentence) }
|
|
19
|
+
end
|
|
20
|
+
private_class_method :cut
|
|
21
|
+
|
|
22
|
+
def initialize(source, segments)
|
|
23
|
+
@source = source
|
|
24
|
+
@segments = segments
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def markup? = @segments.nil?
|
|
28
|
+
|
|
29
|
+
def segments = @segments || EMPTY
|
|
30
|
+
|
|
31
|
+
# The slice is copied on the way out, as Segment copies its own: rendering must not hand a caller the passage.
|
|
32
|
+
def render = markup? ? source.dup : @segments.map(&:render).join
|
|
33
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "faraday/retry"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Every HTTP provider inherits this; no logging middleware, ever -- lines must carry no source text or credential.
|
|
6
|
+
class TranslationDiff::HTTPProvider < TranslationDiff::Provider
|
|
7
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
8
|
+
|
|
9
|
+
# Faraday raises these when nobody answered, as opposed to answering "no".
|
|
10
|
+
TRANSPORT_FAILURES = [Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError].freeze
|
|
11
|
+
|
|
12
|
+
def api_base = raise NotImplementedError, "#{self.class} must implement #api_base"
|
|
13
|
+
def headers = {}
|
|
14
|
+
|
|
15
|
+
def translate_url = raise NotImplementedError, "#{self.class} must implement #translate_url"
|
|
16
|
+
|
|
17
|
+
def render_translate_payload(_request)
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #render_translate_payload"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse_translate_response(_body, _headers, _request)
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #parse_translate_response"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def translate(request)
|
|
26
|
+
response = post(translate_url, render_translate_payload(request))
|
|
27
|
+
parse_translate_response(response.body, response.headers, request)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def connection = @connection ||= build_connection
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Mirrors the shape a Faraday::Response would give if its own JSON middleware were still in the stack.
|
|
35
|
+
Decoded = Data.define(:status, :headers, :body)
|
|
36
|
+
private_constant :Decoded
|
|
37
|
+
|
|
38
|
+
def post(url, payload)
|
|
39
|
+
raw = connection.post(url, payload)
|
|
40
|
+
response = Decoded.new(status: raw.status, headers: raw.headers, body: decode(raw))
|
|
41
|
+
raise_for_status!(response)
|
|
42
|
+
response
|
|
43
|
+
rescue *TRANSPORT_FAILURES => e
|
|
44
|
+
# The message is the transport's, never the payload's: the payload is the customer's text.
|
|
45
|
+
raise TranslationDiff::TransportError, "#{self.class}: #{e.class}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get(url)
|
|
49
|
+
raw = connection.get(url)
|
|
50
|
+
response = Decoded.new(status: raw.status, headers: raw.headers, body: decode(raw))
|
|
51
|
+
raise_for_status!(response)
|
|
52
|
+
response
|
|
53
|
+
rescue *TRANSPORT_FAILURES => e
|
|
54
|
+
raise TranslationDiff::TransportError, "#{self.class}: #{e.class}: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Faraday's JSON middleware passes parser options positionally, which json 3 (default on Ruby 4.x) removed.
|
|
58
|
+
def decode(response)
|
|
59
|
+
body = response.body
|
|
60
|
+
return body unless body.is_a?(String)
|
|
61
|
+
return body if body.strip.empty?
|
|
62
|
+
return body unless json?(response)
|
|
63
|
+
|
|
64
|
+
JSON.parse(body)
|
|
65
|
+
rescue JSON::ParserError => e
|
|
66
|
+
raise TranslationDiff::ResponseError,
|
|
67
|
+
"#{self.class} returned a body that is not JSON: #{e.message[0, 200]}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def json?(response) = response.headers["content-type"].to_s.match?(/\bjson\b/)
|
|
71
|
+
|
|
72
|
+
# The block is how a test swaps in Faraday's test adapter; Amazon overrides it too, to sign the body as sent.
|
|
73
|
+
def build_connection(&)
|
|
74
|
+
Faraday.new(url: api_base, headers: headers) do |faraday|
|
|
75
|
+
faraday.request :json
|
|
76
|
+
faraday.request :retry, retry_options
|
|
77
|
+
adapt(faraday, &)
|
|
78
|
+
apply_timeouts(faraday)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def adapt(faraday, &block)
|
|
83
|
+
block ? block.call(faraday) : faraday.adapter(Faraday.default_adapter)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def apply_timeouts(faraday)
|
|
87
|
+
faraday.options.open_timeout = config.open_timeout
|
|
88
|
+
faraday.options.timeout = config.timeout
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# faraday-retry reads Retry-After itself, which is why a 429 usually never reaches #raise_for_status!.
|
|
92
|
+
def retry_options
|
|
93
|
+
{ max: config.max_retries, interval: 0.5, backoff_factor: 2, interval_randomness: 0.5,
|
|
94
|
+
retry_statuses: RETRY_STATUSES, methods: %i[post get],
|
|
95
|
+
exceptions: TRANSPORT_FAILURES + [Faraday::RetriableResponse] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def raise_for_status!(response)
|
|
99
|
+
status = response.status
|
|
100
|
+
return if status < 400
|
|
101
|
+
|
|
102
|
+
raise error_class(status).new(error_message(response), **error_options(response))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def error_class(status)
|
|
106
|
+
case status
|
|
107
|
+
when 401, 403 then TranslationDiff::AuthenticationError
|
|
108
|
+
when 429 then TranslationDiff::RateLimitError
|
|
109
|
+
when 456 then TranslationDiff::QuotaExceededError
|
|
110
|
+
when 400..499 then TranslationDiff::InvalidRequestError
|
|
111
|
+
else TranslationDiff::ServiceError
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def error_options(response)
|
|
116
|
+
options = { provider: name, status: response.status }
|
|
117
|
+
return options unless response.status == 429
|
|
118
|
+
|
|
119
|
+
options.merge(retry_after: response.headers["Retry-After"]&.to_i)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Truncated: an untruncated provider error body can be a whole HTML error page.
|
|
123
|
+
def error_message(response)
|
|
124
|
+
body = response.body
|
|
125
|
+
text = body.is_a?(Hash) ? (body["message"] || body["error"] || body.to_s) : body.to_s
|
|
126
|
+
"#{self.class} responded #{response.status}: #{text.to_s[0, 300]}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Payloads carry counts, language codes and provider names -- never the text, its translation, or a credential.
|
|
2
|
+
module TranslationDiff::Instrumentation
|
|
3
|
+
SUFFIX = ".translation_diff".freeze
|
|
4
|
+
|
|
5
|
+
# `include` ignores the includer's own `private` keyword, so visibility has to be declared here.
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# A point event (no block) reports a fact that already happened, e.g. a cache hit/miss tally.
|
|
9
|
+
def instrument(name, payload = {})
|
|
10
|
+
instrumenter = config.instrumenter
|
|
11
|
+
return yield if instrumenter.nil? && block_given?
|
|
12
|
+
return if instrumenter.nil?
|
|
13
|
+
|
|
14
|
+
instrumenter.instrument("#{name}#{SUFFIX}", payload) { yield if block_given? }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log(message)
|
|
18
|
+
config.logger&.debug { "[translation_diff] #{message}" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# For the things an operator must see at a production log level; carries no more content than #log does.
|
|
22
|
+
def warn_log(message)
|
|
23
|
+
config.logger&.warn { "[translation_diff] #{message}" }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# A maintainer's tool: re-fetches every provider's lists and rewrites the shipped files.
|
|
2
|
+
class TranslationDiff::Languages::Refresh
|
|
3
|
+
def self.call(providers:, directory: TranslationDiff::Languages::DIRECTORY, on: Time.now.strftime("%Y-%m-%d"))
|
|
4
|
+
new(providers: providers, directory: directory, on: on).call
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def initialize(providers:, directory:, on:)
|
|
8
|
+
@providers = providers
|
|
9
|
+
@directory = directory
|
|
10
|
+
@on = on
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# A provider whose fetch fails keeps its previous file: an emptied list is worse than a stale one.
|
|
14
|
+
def call
|
|
15
|
+
report = { updated: [], failed: {}, skipped: [] }
|
|
16
|
+
|
|
17
|
+
@providers.each do |provider|
|
|
18
|
+
name = provider.cache_key
|
|
19
|
+
fetched = fetch(provider, name, report)
|
|
20
|
+
next if fetched.nil?
|
|
21
|
+
|
|
22
|
+
persist(name, provider, fetched, report)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
report
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def fetch(provider, name, report)
|
|
31
|
+
fetched = provider.languages
|
|
32
|
+
return report_empty(name, report) if empty?(fetched)
|
|
33
|
+
|
|
34
|
+
fetched
|
|
35
|
+
rescue NotImplementedError
|
|
36
|
+
report[:skipped] << name
|
|
37
|
+
nil
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
report[:failed][name] = "#{e.class}: #{e.message}"
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A 200 with an empty or malformed body degrades to [] through Array(...); that is not data, it's a failure.
|
|
44
|
+
def empty?(fetched) = Array(fetched[:source]).empty? || Array(fetched[:target]).empty?
|
|
45
|
+
|
|
46
|
+
def report_empty(name, report)
|
|
47
|
+
report[:failed][name] = "the vendor answered with an empty source or target list"
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A write that cannot land -- a read-only checkout, a full disk -- must not cost the rest of the run.
|
|
52
|
+
def persist(name, provider, fetched, report)
|
|
53
|
+
write(name, provider, fetched)
|
|
54
|
+
report[:updated] << name
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
report[:failed][name] = "#{e.class}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write(name, provider, fetched)
|
|
60
|
+
document = { "provider" => name, "captured_at" => @on,
|
|
61
|
+
"endpoint" => endpoint(provider),
|
|
62
|
+
"source" => normalise(fetched[:source]), "target" => normalise(fetched[:target]) }
|
|
63
|
+
|
|
64
|
+
File.write(File.join(@directory, "#{name}.json"), "#{JSON.pretty_generate(document)}\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def normalise(codes) = Array(codes).map { |code| code.to_s.downcase }.uniq.sort
|
|
68
|
+
|
|
69
|
+
def endpoint(provider) = provider.languages_endpoint.to_s
|
|
70
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# One provider's languages as captured on a date; source and target differ, so they are kept apart.
|
|
2
|
+
class TranslationDiff::Languages::Set
|
|
3
|
+
attr_reader :provider, :captured_at, :endpoint, :source, :target
|
|
4
|
+
|
|
5
|
+
# ISO 639-1 macrolanguage codes callers write, mapped to the ISO 639-3 individual some vendors ship instead.
|
|
6
|
+
# One-way only: a vendor listing the individual has, by definition, covered its macro; the reverse is not true.
|
|
7
|
+
MACRO_ALIASES = {
|
|
8
|
+
"fa" => "pes", "uz" => "uzn", "yi" => "ydd", "om" => "gaz", "qu" => "quy", "ay" => "ayr",
|
|
9
|
+
"mn" => "khk", "ms" => "zsm", "lv" => "lvs", "mg" => "plt", "az" => "azj", "ps" => "pbt",
|
|
10
|
+
"sw" => "swh", "ku" => "kmr", "zh" => "cmn", "ne" => "npi", "or" => "ory", "sq" => "als"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.load(path)
|
|
14
|
+
document = parse(path)
|
|
15
|
+
|
|
16
|
+
new(provider: document["provider"], captured_at: document["captured_at"],
|
|
17
|
+
endpoint: document["endpoint"], source: document["source"], target: document["target"])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A maintainer sees a path and the parser's own complaint instead of guessing which of the shipped files broke.
|
|
21
|
+
def self.parse(path)
|
|
22
|
+
JSON.parse(File.read(path))
|
|
23
|
+
rescue JSON::ParserError => e
|
|
24
|
+
raise TranslationDiff::Error, "#{path} is not valid JSON: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
private_class_method :parse
|
|
27
|
+
|
|
28
|
+
def initialize(provider:, captured_at:, endpoint:, source:, target:)
|
|
29
|
+
@provider = provider
|
|
30
|
+
@captured_at = captured_at
|
|
31
|
+
@endpoint = endpoint
|
|
32
|
+
@source = normalise(source)
|
|
33
|
+
@target = normalise(target)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def supports_source?(code) = matches?(@source, code)
|
|
38
|
+
def supports_target?(code) = matches?(@target, code)
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalise(codes) = Array(codes).map { |code| code.to_s.downcase }.freeze
|
|
43
|
+
|
|
44
|
+
# Primary subtag in both directions, plus a macro wanted against the individual code this provider lists for it.
|
|
45
|
+
def matches?(codes, code)
|
|
46
|
+
wanted = code.to_s.downcase
|
|
47
|
+
return true if wanted.empty?
|
|
48
|
+
|
|
49
|
+
primary = wanted.split("-").first
|
|
50
|
+
accepted = [primary, MACRO_ALIASES[primary]].compact
|
|
51
|
+
codes.any? { |known| known == wanted || accepted.include?(known.split("-").first) }
|
|
52
|
+
end
|
|
53
|
+
end
|