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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +84 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +11 -0
  5. data/.rubocop.yml +39 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +723 -0
  8. data/Gemfile +61 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +158 -0
  11. data/Rakefile +41 -0
  12. data/data/languages/azure.json +285 -0
  13. data/data/languages/deepl.json +220 -0
  14. data/data/languages/google.json +399 -0
  15. data/data/languages/modernmt.json +413 -0
  16. data/docs/caching.md +185 -0
  17. data/docs/configuration.md +205 -0
  18. data/docs/contracts.md +187 -0
  19. data/docs/development.md +34 -0
  20. data/docs/errors.md +92 -0
  21. data/docs/how-it-works.md +145 -0
  22. data/docs/instrumentation.md +96 -0
  23. data/docs/languages.md +94 -0
  24. data/docs/providers.md +379 -0
  25. data/docs/sql-cache.md +366 -0
  26. data/lib/generators/translation_diff/install_generator.rb +15 -0
  27. data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
  28. data/lib/translation_diff/active_record/support.rb +34 -0
  29. data/lib/translation_diff/active_record.rb +3 -0
  30. data/lib/translation_diff/batch.rb +98 -0
  31. data/lib/translation_diff/call_preparation.rb +47 -0
  32. data/lib/translation_diff/capabilities.rb +9 -0
  33. data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
  34. data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
  35. data/lib/translation_diff/configuration/option_table.rb +38 -0
  36. data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
  37. data/lib/translation_diff/configuration.rb +135 -0
  38. data/lib/translation_diff/context.rb +23 -0
  39. data/lib/translation_diff/dispatcher.rb +57 -0
  40. data/lib/translation_diff/document.rb +23 -0
  41. data/lib/translation_diff/errors.rb +44 -0
  42. data/lib/translation_diff/fragment.rb +33 -0
  43. data/lib/translation_diff/http_provider.rb +128 -0
  44. data/lib/translation_diff/instrumentation.rb +25 -0
  45. data/lib/translation_diff/languages/refresh.rb +70 -0
  46. data/lib/translation_diff/languages/set.rb +53 -0
  47. data/lib/translation_diff/languages.rb +30 -0
  48. data/lib/translation_diff/leaves.rb +22 -0
  49. data/lib/translation_diff/markup.rb +85 -0
  50. data/lib/translation_diff/passage.rb +149 -0
  51. data/lib/translation_diff/preview.rb +3 -0
  52. data/lib/translation_diff/previewer.rb +78 -0
  53. data/lib/translation_diff/provider.rb +91 -0
  54. data/lib/translation_diff/providers/amazon.rb +126 -0
  55. data/lib/translation_diff/providers/azure.rb +78 -0
  56. data/lib/translation_diff/providers/deepl.rb +88 -0
  57. data/lib/translation_diff/providers/google.rb +64 -0
  58. data/lib/translation_diff/providers/libretranslate.rb +65 -0
  59. data/lib/translation_diff/providers/modernmt.rb +74 -0
  60. data/lib/translation_diff/providers/null.rb +20 -0
  61. data/lib/translation_diff/providers.rb +69 -0
  62. data/lib/translation_diff/railtie.rb +12 -0
  63. data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
  64. data/lib/translation_diff/rate_limiters/redis.rb +59 -0
  65. data/lib/translation_diff/rate_limiters.rb +9 -0
  66. data/lib/translation_diff/redaction.rb +45 -0
  67. data/lib/translation_diff/registry.rb +31 -0
  68. data/lib/translation_diff/segment.rb +32 -0
  69. data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
  70. data/lib/translation_diff/segmenters/simple.rb +122 -0
  71. data/lib/translation_diff/segmenters.rb +4 -0
  72. data/lib/translation_diff/sentence_cache.rb +76 -0
  73. data/lib/translation_diff/stores/active_record.rb +106 -0
  74. data/lib/translation_diff/stores/memory.rb +34 -0
  75. data/lib/translation_diff/stores/redis.rb +49 -0
  76. data/lib/translation_diff/stores.rb +9 -0
  77. data/lib/translation_diff/tasks/translation_diff.rake +21 -0
  78. data/lib/translation_diff/translation/request.rb +8 -0
  79. data/lib/translation_diff/translation/response.rb +35 -0
  80. data/lib/translation_diff/translation/usage.rb +8 -0
  81. data/lib/translation_diff/translator.rb +103 -0
  82. data/lib/translation_diff/version.rb +3 -0
  83. data/lib/translation_diff.rb +93 -0
  84. data/translation_diff.gemspec +56 -0
  85. 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