wp2txt 2.2.0 → 2.3.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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "set"
5
+ require "time"
6
+ require "zlib"
7
+ require_relative "metadata_index"
8
+ require_relative "version"
9
+
10
+ module Wp2txt
11
+ # Imports the official langlinks dump ({lang}wiki-{date}-langlinks.sql.gz,
12
+ # MySQL dump format) into the Tier 1 metadata DB as a `langlinks` table
13
+ # (ll_from = source page_id, ll_lang = target language, ll_title = title in
14
+ # the target edition, normalized like pages.title).
15
+ #
16
+ # Version pinning is the reason this feature exists: the langlinks file's
17
+ # dump name (e.g. jawiki-20260701) MUST equal the metadata DB's dump_name;
18
+ # a mismatch is rejected with no override.
19
+ class LanglinksImporter
20
+ # Rows inserted per transaction (index creation is deferred until after
21
+ # the load, so inserts stay fast)
22
+ BATCH_SIZE = 10_000
23
+
24
+ # Post-import sanity check (design doc §1.6): per target language, join a
25
+ # random sample of ll_title values against the target language's local
26
+ # meta DB (when installed) and report the match rate; a low rate signals
27
+ # a title-normalization mismatch
28
+ SANITY_SAMPLE_SIZE = 1000
29
+ SANITY_WARN_THRESHOLD = 0.9
30
+
31
+ INSERT_PREFIX = /\A\s*INSERT\s+INTO\s+`langlinks`\s+VALUES\s+/i
32
+
33
+ # MySQL backslash escapes inside mysqldump string literals
34
+ UNESCAPES = {
35
+ "0" => "\0", "'" => "'", '"' => '"', "b" => "\b", "n" => "\n",
36
+ "r" => "\r", "t" => "\t", "Z" => "\x1A", "\\" => "\\"
37
+ }.freeze
38
+
39
+ def initialize(db_path, cache_dir: nil)
40
+ @db_path = db_path
41
+ @cache_dir = cache_dir
42
+ end
43
+
44
+ # "jawiki-20260701-langlinks.sql.gz" => "jawiki-20260701" (same extraction
45
+ # rule as the dump_name recorded in the metadata DB)
46
+ def self.dump_name_of(path)
47
+ File.basename(path)[/\A[a-z0-9_\-]+?-\d{8}/]
48
+ end
49
+
50
+ # @param source_path [String] langlinks .sql or .sql.gz file
51
+ # @param langs [Array<String>, nil] target languages to import (nil = all)
52
+ # @param force [Boolean] drop and re-import an existing langlinks table
53
+ # @param progress [Proc, nil] called with the running row count per batch
54
+ # @return [Hash] { status: :imported | :already_imported, ... }
55
+ def import!(source_path, langs: nil, force: false, progress: nil)
56
+ raise ArgumentError, "langlinks file not found: #{source_path}" unless File.exist?(source_path)
57
+
58
+ db = open_db
59
+ dump_name = metadata_value(db, "dump_name")
60
+ raise ArgumentError, "metadata index is not built: #{@db_path}" unless dump_name
61
+
62
+ source_dump = self.class.dump_name_of(source_path)
63
+ unless source_dump && source_dump == dump_name
64
+ raise ArgumentError,
65
+ "dump version mismatch: the metadata index is #{dump_name} but the langlinks file is " \
66
+ "#{source_dump || File.basename(source_path)} (versions must match; there is no override)"
67
+ end
68
+
69
+ if !force && (existing = imported_at(db))
70
+ return { status: :already_imported, imported_at: existing,
71
+ row_count: db.get_first_value("SELECT COUNT(*) FROM langlinks").to_i }
72
+ end
73
+
74
+ lang_filter = langs && Set.new(langs)
75
+
76
+ db.execute("DROP TABLE IF EXISTS langlinks")
77
+ # Clear stale provenance immediately: if the load below fails midway,
78
+ # the DB must be left as "not imported" (partial table only), so the
79
+ # next non-force run re-imports instead of reporting a stale success
80
+ db.execute("DELETE FROM metadata WHERE key LIKE 'langlinks\\_%' ESCAPE '\\'")
81
+ db.execute(<<~SQL)
82
+ CREATE TABLE langlinks (
83
+ ll_from INTEGER NOT NULL,
84
+ ll_lang TEXT NOT NULL,
85
+ ll_title TEXT NOT NULL
86
+ )
87
+ SQL
88
+
89
+ row_count = 0
90
+ batch = []
91
+ flush = lambda do
92
+ db.transaction do
93
+ stmt = db.prepare("INSERT INTO langlinks (ll_from, ll_lang, ll_title) VALUES (?, ?, ?)")
94
+ batch.each { |row| stmt.execute(row) }
95
+ stmt.close
96
+ end
97
+ row_count += batch.size
98
+ progress&.call(row_count)
99
+ batch.clear
100
+ end
101
+
102
+ skipped_invalid = each_source_row(source_path) do |ll_from, ll_lang, ll_title|
103
+ next if lang_filter && !lang_filter.include?(ll_lang)
104
+
105
+ batch << [ll_from, ll_lang, MetadataIndex.normalize_title(ll_title)]
106
+ flush.call if batch.size >= BATCH_SIZE
107
+ end
108
+ flush.call unless batch.empty?
109
+
110
+ # Indexes are created after the load, not before (insert speed)
111
+ db.execute("CREATE INDEX idx_langlinks_from ON langlinks(ll_from, ll_lang)")
112
+ db.execute("CREATE INDEX idx_langlinks_lang_title ON langlinks(ll_lang, ll_title)")
113
+
114
+ stamp_provenance(db, source_path, langs, row_count, skipped_invalid)
115
+
116
+ { status: :imported, row_count: row_count, skipped_invalid: skipped_invalid,
117
+ provenance: read_provenance(db),
118
+ sanity: sanity_check(db, dump_name) }
119
+ ensure
120
+ db&.close
121
+ end
122
+
123
+ private
124
+
125
+ def open_db
126
+ db = SQLite3::Database.new(@db_path)
127
+ db.busy_timeout = 5000
128
+ db
129
+ end
130
+
131
+ def metadata_value(db, key)
132
+ db.get_first_value("SELECT value FROM metadata WHERE key = ?", [key])
133
+ rescue SQLite3::Exception
134
+ nil
135
+ end
136
+
137
+ # Non-nil only when a previous completed import is still in place
138
+ def imported_at(db)
139
+ table = db.get_first_value(
140
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'langlinks'"
141
+ )
142
+ table && metadata_value(db, "langlinks_imported_at")
143
+ end
144
+
145
+ def stamp_provenance(db, source_path, langs, row_count, skipped_invalid)
146
+ values = {
147
+ langlinks_source: File.basename(source_path),
148
+ langlinks_source_size: File.size(source_path),
149
+ langlinks_imported_at: Time.now.utc.iso8601,
150
+ langlinks_wp2txt_version: Wp2txt::VERSION,
151
+ langlinks_lang_filter: langs.nil? ? "all" : langs.join(","),
152
+ langlinks_row_count: row_count,
153
+ langlinks_skipped_invalid: skipped_invalid
154
+ }
155
+ stmt = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)")
156
+ values.each { |k, v| stmt.execute([k.to_s, v.to_s]) }
157
+ stmt.close
158
+ end
159
+
160
+ def read_provenance(db)
161
+ {
162
+ source: metadata_value(db, "langlinks_source"),
163
+ source_size: metadata_value(db, "langlinks_source_size").to_i,
164
+ imported_at: metadata_value(db, "langlinks_imported_at"),
165
+ imported_with: metadata_value(db, "langlinks_wp2txt_version"),
166
+ lang_filter: metadata_value(db, "langlinks_lang_filter"),
167
+ row_count: metadata_value(db, "langlinks_row_count").to_i,
168
+ skipped_invalid: metadata_value(db, "langlinks_skipped_invalid").to_i
169
+ }
170
+ end
171
+
172
+ # Post-import sanity check (§1.6): for each target language whose meta DB
173
+ # is installed locally, draw a random sample and report how many ll_title
174
+ # values exist in that DB's pages.title
175
+ def sanity_check(db, dump_name)
176
+ reports = []
177
+ main_date = dump_name[/\d{8}\z/]
178
+ db.execute("SELECT DISTINCT ll_lang FROM langlinks ORDER BY ll_lang").flatten.each do |lang|
179
+ candidates = MetadataIndex.cached_candidates(lang, cache_dir: @cache_dir)
180
+ next if candidates.empty? # target edition not installed locally: skip
181
+
182
+ pick = candidates.find { |c| c[:dump_name].to_s.end_with?(main_date.to_s) } || candidates.first
183
+ sample = db.execute(
184
+ "SELECT ll_title FROM langlinks WHERE ll_lang = ? ORDER BY RANDOM() LIMIT ?",
185
+ [lang, SANITY_SAMPLE_SIZE]
186
+ ).flatten
187
+ next if sample.empty?
188
+
189
+ other = SQLite3::Database.new(pick[:db_path], readonly: true)
190
+ begin
191
+ stmt = other.prepare("SELECT 1 FROM pages WHERE title = ? LIMIT 1")
192
+ matched = sample.count { |title| stmt.execute(title).any? }
193
+ stmt.close
194
+ ensure
195
+ other.close
196
+ end
197
+
198
+ rate = matched.to_f / sample.size
199
+ reports << { lang: lang, sampled: sample.size, matched: matched,
200
+ match_rate: rate.round(3), against: pick[:dump_name],
201
+ warning: rate < SANITY_WARN_THRESHOLD }
202
+ end
203
+ reports
204
+ end
205
+
206
+ # ------------------------------------------------------------------
207
+ # Streaming MySQL dump parser
208
+ # ------------------------------------------------------------------
209
+
210
+ # Yield [ll_from, ll_lang, ll_title] for every VALID tuple of every
211
+ # INSERT INTO `langlinks` statement, streaming (the dump is never
212
+ # loaded into memory whole). Handles .sql.gz and plain .sql.
213
+ # @return [Integer] number of tuples skipped for invalid UTF-8
214
+ #
215
+ # Tuple extraction is regex-based: a hand-rolled line[i] character-index
216
+ # loop is O(n²) on multibyte (UTF-8 code-range) lines, and real extended
217
+ # INSERT lines are MB-scale with multilingual titles — the regex engine
218
+ # scans at C speed and is O(n) regardless of encoding.
219
+ #
220
+ # Lines are read as BINARY: ll_title is VARBINARY in MySQL and real dumps
221
+ # contain historically corrupted bytes, so regex matching on UTF-8-tagged
222
+ # strings can raise "invalid byte sequence". The patterns are ASCII-only,
223
+ # so they run on byte strings without encoding checks; captures are then
224
+ # tagged UTF-8 and validated — a garbled title could never join
225
+ # pages.title anyway, so such rows are skipped (and counted), not scrubbed.
226
+ def each_source_row(source_path)
227
+ io = if source_path.end_with?(".gz")
228
+ # GzipReader ignores set_encoding; the encoding must be given
229
+ # at open time (lines must come out as BINARY — see below)
230
+ Zlib::GzipReader.open(source_path, encoding: Encoding::BINARY.to_s)
231
+ else
232
+ File.open(source_path, "rb")
233
+ end
234
+
235
+ skipped = 0
236
+ begin
237
+ io.each_line do |line|
238
+ next unless INSERT_PREFIX.match?(line)
239
+
240
+ line.scan(TUPLE_REGEX) do |ll_from, ll_lang, ll_title|
241
+ lang = unescape_mysql(ll_lang).force_encoding(Encoding::UTF_8)
242
+ title = unescape_mysql(ll_title).force_encoding(Encoding::UTF_8)
243
+ unless lang.valid_encoding? && title.valid_encoding?
244
+ skipped += 1
245
+ next
246
+ end
247
+
248
+ yield ll_from.to_i, lang, title
249
+ end
250
+ end
251
+ ensure
252
+ io.close
253
+ end
254
+ skipped
255
+ end
256
+
257
+ # One extended-INSERT tuple: (123,'lang','Title'). The string classes
258
+ # [^'\\]|\\. match any run of non-quote/non-backslash characters and
259
+ # backslash escape pairs, so escaped quotes (\') and backslashes (\\) —
260
+ # and commas/parens inside titles — do not terminate the capture.
261
+ # Tuples that do not match this shape are simply not extracted
262
+ # (equivalent to the old parser skipping malformed tuples)
263
+ TUPLE_REGEX = /\((\d+),'((?:[^'\\]|\\.)*)','((?:[^'\\]|\\.)*)'\)/
264
+
265
+ UNESCAPE_REGEX = /\\(.)/m
266
+
267
+ # Resolve MySQL backslash escapes in a captured string literal
268
+ # (same mapping as the old hand-rolled parser)
269
+ def unescape_mysql(str)
270
+ str.gsub(UNESCAPE_REGEX) { UNESCAPES[::Regexp.last_match(1)] || ::Regexp.last_match(1) }
271
+ end
272
+ end
273
+ end
@@ -37,15 +37,47 @@ module Wp2txt
37
37
  File.join(dir, "#{basename}_#{path_hash}#{CACHE_SUFFIX}")
38
38
  end
39
39
 
40
- # Normalize a category name the way MediaWiki treats titles:
40
+ # Normalize a page title the way MediaWiki treats titles:
41
41
  # underscores to spaces, trimmed, first letter capitalized
42
- def self.normalize_category(name)
42
+ def self.normalize_title(name)
43
43
  n = name.to_s.tr("_", " ").strip.squeeze(" ")
44
44
  return n if n.empty?
45
45
 
46
46
  n[0].upcase + n[1..].to_s
47
47
  end
48
48
 
49
+ # Normalize a category name (same MediaWiki title rules as normalize_title)
50
+ def self.normalize_category(name)
51
+ normalize_title(name)
52
+ end
53
+
54
+ # Built metadata DBs for one language found in a cache directory, most
55
+ # recently built first. Used for cross-dump ATTACH resolution (Corpus)
56
+ # and langlinks sanity checks (LanglinksImporter).
57
+ # @return [Array<Hash>] [{db_path:, dump_name:, built_at:, built_with:}]
58
+ def self.cached_candidates(lang, cache_dir: nil)
59
+ dir = cache_dir || File.expand_path("~/.wp2txt/cache")
60
+ Dir.glob(File.join(dir, "#{lang}wiki-*#{CACHE_SUFFIX}")).filter_map do |path|
61
+ meta = read_metadata_file(path)
62
+ next unless meta && meta[:schema_version].to_i == SCHEMA_VERSION && meta[:built_at]
63
+
64
+ { db_path: path, dump_name: meta[:dump_name], built_at: meta[:built_at],
65
+ built_with: meta[:wp2txt_version] }
66
+ end.sort_by { |c| c[:built_at].to_s }.reverse
67
+ end
68
+
69
+ # Light, read-only metadata table read for a DB file we do not manage
70
+ def self.read_metadata_file(path)
71
+ db = SQLite3::Database.new(path, readonly: true)
72
+ result = {}
73
+ db.execute("SELECT key, value FROM metadata") { |key, value| result[key.to_sym] = value }
74
+ result
75
+ rescue SQLite3::Exception
76
+ nil
77
+ ensure
78
+ db&.close
79
+ end
80
+
49
81
  # Remove wiki markup from a heading ('''bold''', [[link|label]], HTML tags)
50
82
  def self.clean_heading(text)
51
83
  t = text.gsub(/'{2,}/, "")
@@ -111,6 +143,24 @@ module Wp2txt
111
143
  }
112
144
  end
113
145
 
146
+ # Provenance of an imported langlinks table (nil when not imported).
147
+ # The langlinks table is an optional post-build addition (LanglinksImporter),
148
+ # so its absence does not affect built? or schema_version.
149
+ def langlinks_provenance
150
+ return nil unless File.exist?(@db_path)
151
+
152
+ meta = read_metadata
153
+ return nil unless meta && meta[:langlinks_imported_at]
154
+
155
+ { source: meta[:langlinks_source],
156
+ source_size: meta[:langlinks_source_size].to_i,
157
+ imported_at: meta[:langlinks_imported_at],
158
+ imported_with: meta[:langlinks_wp2txt_version],
159
+ lang_filter: meta[:langlinks_lang_filter],
160
+ row_count: meta[:langlinks_row_count].to_i,
161
+ skipped_invalid: meta[:langlinks_skipped_invalid].to_i }
162
+ end
163
+
114
164
  def close
115
165
  @db&.close
116
166
  @db = nil
@@ -269,6 +319,22 @@ module Wp2txt
269
319
  ).map(&:first)
270
320
  end
271
321
 
322
+ # Look up titles in pages (existence + redirect target), batched to keep
323
+ # the IN clause small. Used by Corpus#extract_corpus titles: resolution.
324
+ # @return [Hash] { title => redirect_to_or_nil } for the titles that exist
325
+ def redirect_map(titles)
326
+ result = {}
327
+ titles.each_slice(500) do |slice|
328
+ placeholders = slice.map { "?" }.join(",")
329
+ open_db.execute(
330
+ "SELECT title, redirect_to FROM pages WHERE title IN (#{placeholders})", slice
331
+ ).each do |title, redirect_to|
332
+ result[title] = redirect_to
333
+ end
334
+ end
335
+ result
336
+ end
337
+
272
338
  # Subcategory tree starting at category, as [{name:, depth:}, ...] (BFS order)
273
339
  def category_tree(category, depth: 2)
274
340
  cat = self.class.normalize_category(category)
@@ -826,6 +826,35 @@ module Wp2txt
826
826
  File.join(@cache_dir, "#{@lang}wiki-#{latest_dump_date}-multistream.xml.bz2")
827
827
  end
828
828
 
829
+ # URL of the langlinks dump for an explicit dump date
830
+ def langlinks_url(date)
831
+ wiki = "#{@lang}wiki"
832
+ "#{DUMP_BASE_URL}/#{wiki}/#{date}/#{wiki}-#{date}-langlinks.sql.gz"
833
+ end
834
+
835
+ # Cache path of the langlinks dump for an explicit dump date
836
+ def cached_langlinks_path(date)
837
+ File.join(@cache_dir, "#{@lang}wiki-#{date}-langlinks.sql.gz")
838
+ end
839
+
840
+ # Download the langlinks dump for an explicit dump date. The date MUST
841
+ # come from the built metadata index (not latest_dump_date) so the
842
+ # imported links stay pinned to the indexed dump version.
843
+ def download_langlinks(date:, force: false)
844
+ path = cached_langlinks_path(date)
845
+ if File.exist?(path) && !force
846
+ puts "Langlinks already cached: #{File.basename(path)}"
847
+ $stdout.flush
848
+ return path
849
+ end
850
+
851
+ url = langlinks_url(date)
852
+ puts "Downloading langlinks: #{url}"
853
+ $stdout.flush
854
+ download_file(url, path)
855
+ path
856
+ end
857
+
829
858
  # Check if cache is fresh (within configured days)
830
859
  def cache_fresh?(days = nil)
831
860
  days ||= @dump_expiry_days
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wp2txt
4
+ # Server-side output path confinement shared by the file-writing tools
5
+ # (extract_corpus / start_extract_job / query_sql with output_path).
6
+ # Paths must resolve under the server's output directory — an agent mixing
7
+ # up paths must not be able to clobber arbitrary user files — and existing
8
+ # files are not replaced unless overwrite is set.
9
+ module OutputPath
10
+ module_function
11
+
12
+ # @return [String] the confined, absolute output path
13
+ # @raise [ArgumentError] when the path escapes base_dir or the file exists
14
+ def confine(output_path, base_dir, overwrite: false)
15
+ path = File.expand_path(output_path, base_dir)
16
+ base = File.expand_path(base_dir)
17
+ unless path == base || path.start_with?(base + File::SEPARATOR)
18
+ raise ArgumentError, "output_path must stay within the server output directory (#{base})"
19
+ end
20
+ if File.exist?(path) && !overwrite
21
+ raise ArgumentError, "output file already exists: #{path} (pass overwrite: true to replace it)"
22
+ end
23
+
24
+ path
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wp2txt
4
- VERSION = "2.2.0"
4
+ VERSION = "2.3.0"
5
5
  end
@@ -33,6 +33,26 @@ RSpec.describe "Wp2txt Auto Download" do
33
33
  end
34
34
  end
35
35
 
36
+ describe "langlinks paths" do
37
+ it "builds URL and cache path for an explicit dump date (no network)" do
38
+ manager = Wp2txt::DumpManager.new(:ja, cache_dir: cache_dir)
39
+ expect(manager.langlinks_url("20260101")).to eq(
40
+ "https://dumps.wikimedia.org/jawiki/20260101/jawiki-20260101-langlinks.sql.gz"
41
+ )
42
+ expect(manager.cached_langlinks_path("20260101")).to eq(
43
+ File.join(cache_dir, "jawiki-20260101-langlinks.sql.gz")
44
+ )
45
+ end
46
+
47
+ it "reuses a cached langlinks file without downloading" do
48
+ manager = Wp2txt::DumpManager.new(:ja, cache_dir: cache_dir)
49
+ path = manager.cached_langlinks_path("20260101")
50
+ File.write(path, "cached")
51
+ expect(manager.download_langlinks(date: "20260101")).to eq(path)
52
+ expect(File.read(path)).to eq("cached")
53
+ end
54
+ end
55
+
36
56
  describe "#cache_status" do
37
57
  it "returns status hash with expected keys" do
38
58
  manager = Wp2txt::DumpManager.new(:ja, cache_dir: cache_dir)
@@ -285,6 +305,63 @@ RSpec.describe "Wp2txt Auto Download" do
285
305
  end
286
306
  end
287
307
  end
308
+
309
+ context "--import-langlinks option" do
310
+ it "accepts --import-langlinks with --lang" do
311
+ opts = Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--cache-dir=#{cache_dir}"])
312
+ expect(opts[:import_langlinks]).to be true
313
+ end
314
+
315
+ it "requires --lang" do
316
+ suppress_stderr do
317
+ expect { Wp2txt::CLI.parse_options(["--import-langlinks", "--cache-dir=#{cache_dir}"]) }.to raise_error(SystemExit)
318
+ end
319
+ end
320
+
321
+ it "cannot be combined with --build-index" do
322
+ suppress_stderr do
323
+ expect do
324
+ Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--build-index", "--cache-dir=#{cache_dir}"])
325
+ end.to raise_error(SystemExit)
326
+ end
327
+ end
328
+
329
+ it "accepts --langlinks-file with an existing file" do
330
+ file = File.join(cache_dir, "jawiki-20260101-langlinks.sql")
331
+ File.write(file, "")
332
+ opts = Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-file=#{file}", "--cache-dir=#{cache_dir}"])
333
+ expect(opts[:langlinks_file]).to eq(file)
334
+ end
335
+
336
+ it "rejects --langlinks-file without --import-langlinks" do
337
+ suppress_stderr do
338
+ expect do
339
+ Wp2txt::CLI.parse_options(["--lang=ja", "--langlinks-file=x.sql", "--cache-dir=#{cache_dir}"])
340
+ end.to raise_error(SystemExit)
341
+ end
342
+ end
343
+
344
+ it "rejects a missing --langlinks-file" do
345
+ suppress_stderr do
346
+ expect do
347
+ Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-file=#{cache_dir}/nope.sql", "--cache-dir=#{cache_dir}"])
348
+ end.to raise_error(SystemExit)
349
+ end
350
+ end
351
+
352
+ it "accepts --langlinks-langs as a comma-separated list" do
353
+ opts = Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-langs=en,de,fr", "--cache-dir=#{cache_dir}"])
354
+ expect(opts[:langlinks_langs]).to eq("en,de,fr")
355
+ end
356
+
357
+ it "rejects invalid language codes in --langlinks-langs" do
358
+ suppress_stderr do
359
+ expect do
360
+ Wp2txt::CLI.parse_options(["--lang=ja", "--import-langlinks", "--langlinks-langs=en,../x", "--cache-dir=#{cache_dir}"])
361
+ end.to raise_error(SystemExit)
362
+ end
363
+ end
364
+ end
288
365
  end
289
366
  end
290
367