swiss-netex 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.
@@ -0,0 +1,546 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module SwissNetex
6
+ class CLI
7
+ EXIT_OK = 0
8
+ EXIT_USAGE = 64
9
+ EXIT_FAILURE = 1
10
+
11
+ COMMANDS = {
12
+ "download" => "Download the latest national NeTEx package",
13
+ "extract" => "Download (if needed) and filter by operator/line",
14
+ "filter" => "Filter a local package by operator/line",
15
+ "help" => "Show help for the CLI or a command",
16
+ "lines" => "List lines in a package (table/TSV)",
17
+ "operators" => "List operators in a package (table/TSV)",
18
+ "version" => "Print the swiss-netex version"
19
+ }.freeze
20
+
21
+ def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr, http: nil)
22
+ new(stdout: stdout, stderr: stderr, http: http).run(argv)
23
+ end
24
+
25
+ def initialize(stdout: $stdout, stderr: $stderr, http: nil)
26
+ @stdout = stdout
27
+ @stderr = stderr
28
+ @http = http
29
+ @active_help = nil
30
+ end
31
+
32
+ def run(argv)
33
+ command, *args = argv
34
+ command = "help" if command.nil? || %w[-h --help].include?(command)
35
+ command = "version" if %w[-v --version].include?(command)
36
+
37
+ dispatch(command, args)
38
+ rescue Error => e
39
+ fail_error(e.message)
40
+ rescue OptionParser::ParseError => e
41
+ fail_usage(e.message)
42
+ rescue Zip::Error => e
43
+ fail_error("Corrupt or unreadable zip: #{e.message}")
44
+ end
45
+
46
+ private
47
+
48
+ def dispatch(command, args)
49
+ case command
50
+ when "download" then download(args)
51
+ when "extract" then extract(args)
52
+ when "filter" then filter(args)
53
+ when "operators" then operators(args)
54
+ when "lines" then lines(args)
55
+ when "help" then help(args.first)
56
+ when "version" then version
57
+ else unknown_command(command)
58
+ end
59
+ end
60
+
61
+ def version
62
+ @stdout.puts "swiss-netex #{VERSION}"
63
+ EXIT_OK
64
+ end
65
+
66
+ def help(command = nil)
67
+ if command.nil? || command.empty?
68
+ @stdout.puts top_level_help
69
+ return EXIT_OK
70
+ end
71
+
72
+ return unknown_command(command) unless COMMANDS.key?(command)
73
+
74
+ @stdout.puts command_help(command)
75
+ EXIT_OK
76
+ end
77
+
78
+ def download(argv)
79
+ @active_help = download_help
80
+ options = blank_download_options
81
+ leftover = download_parser(options).parse(argv)
82
+ return show_command_help(download_help) if options[:show_help]
83
+ return unexpected_args(leftover, download_help) if leftover.any?
84
+
85
+ dataset = resolve_download_dataset(options[:dataset], options[:year])
86
+ result = Download.new(
87
+ dataset: dataset,
88
+ output: options[:output],
89
+ cache_dir: options[:cache_dir],
90
+ force: options[:force],
91
+ http: @http || Http.new,
92
+ progress_io: @stderr
93
+ ).call
94
+
95
+ @stdout.puts result.path
96
+ EXIT_OK
97
+ end
98
+
99
+ def filter(argv)
100
+ @active_help = filter_help
101
+ options = blank_filter_options
102
+ leftover = filter_parser(options).parse(argv)
103
+ return show_command_help(filter_help) if options[:show_help]
104
+ return unexpected_args(leftover, filter_help) if leftover.any?
105
+
106
+ result = Filter.new(
107
+ from: options[:from],
108
+ output: options[:output],
109
+ operators: options[:operators],
110
+ lines: options[:lines],
111
+ allow_empty: options[:allow_empty],
112
+ verbose: options[:verbose],
113
+ progress_io: @stderr
114
+ ).call
115
+
116
+ @stdout.puts result.path
117
+ EXIT_OK
118
+ end
119
+
120
+ def extract(argv)
121
+ @active_help = extract_help
122
+ options = blank_extract_options
123
+ leftover = extract_parser(options).parse(argv)
124
+ return show_command_help(extract_help) if options[:show_help]
125
+ return unexpected_args(leftover, extract_help) if leftover.any?
126
+
127
+ reject_unused_download_opts_with_from!(options)
128
+ dataset = resolve_download_dataset(options[:dataset], options[:year])
129
+ result = Extract.new(
130
+ operators: options[:operators],
131
+ lines: options[:lines],
132
+ from: options[:from],
133
+ output: options[:output],
134
+ dataset: dataset,
135
+ cache_dir: options[:cache_dir],
136
+ force: options[:force],
137
+ allow_empty: options[:allow_empty],
138
+ verbose: options[:verbose],
139
+ http: @http,
140
+ progress_io: @stderr
141
+ ).call
142
+
143
+ @stdout.puts result.path
144
+ EXIT_OK
145
+ end
146
+
147
+ def operators(argv)
148
+ @active_help = operators_help
149
+ options = blank_discovery_options
150
+ leftover = operators_parser(options).parse(argv)
151
+ return show_command_help(operators_help) if options[:show_help]
152
+ return unexpected_args(leftover, operators_help) if leftover.any?
153
+
154
+ path = resolve_package_path(options)
155
+ index = Operators.load(path)
156
+ raise Error, "No operators found in #{path}" if index.empty?
157
+
158
+ Tsv.write(@stdout, Operators::HEADERS, index.rows)
159
+ EXIT_OK
160
+ end
161
+
162
+ def lines(argv)
163
+ @active_help = lines_help
164
+ options = blank_discovery_options.merge(operators: [], lines: [])
165
+ leftover = lines_parser(options).parse(argv)
166
+ return show_command_help(lines_help) if options[:show_help]
167
+ return unexpected_args(leftover, lines_help) if leftover.any?
168
+
169
+ path = resolve_package_path(options)
170
+ entries = Lines.load(
171
+ path,
172
+ operators: options[:operators],
173
+ lines: options[:lines]
174
+ )
175
+ raise Error, "No lines matched" if entries.empty?
176
+
177
+ Tsv.write(@stdout, Lines::HEADERS, entries.map(&:to_row))
178
+ EXIT_OK
179
+ end
180
+
181
+ def blank_download_options
182
+ {
183
+ dataset: nil,
184
+ year: nil,
185
+ output: nil,
186
+ cache_dir: Download::DEFAULT_CACHE_DIR,
187
+ cache_dir_set: false,
188
+ force: false,
189
+ show_help: false
190
+ }
191
+ end
192
+
193
+ def blank_filter_options
194
+ {
195
+ from: nil,
196
+ output: nil,
197
+ operators: [],
198
+ lines: [],
199
+ allow_empty: false,
200
+ verbose: false,
201
+ show_help: false
202
+ }
203
+ end
204
+
205
+ def blank_extract_options
206
+ blank_filter_options.merge(blank_package_source_options)
207
+ end
208
+
209
+ def blank_discovery_options
210
+ blank_package_source_options.merge(from: nil, show_help: false)
211
+ end
212
+
213
+ def blank_package_source_options
214
+ {
215
+ dataset: nil,
216
+ year: nil,
217
+ cache_dir: Download::DEFAULT_CACHE_DIR,
218
+ cache_dir_set: false,
219
+ force: false
220
+ }
221
+ end
222
+
223
+ def download_parser(options)
224
+ OptionParser.new do |opts|
225
+ opts.banner = <<~BANNER.chomp
226
+ Usage: swiss-netex download [options]
227
+
228
+ Download the latest Swiss national NeTEx zip from opentransportdata.swiss.
229
+ Resource downloads are public; no API token is required on the happy path.
230
+ BANNER
231
+ opts.separator ""
232
+ opts.separator "Options:"
233
+ add_dataset_options(opts, options)
234
+ opts.on("-o", "--output PATH", "--out PATH",
235
+ "Output file or directory (default: under --cache-dir)") do |value|
236
+ options[:output] = value
237
+ end
238
+ add_cache_dir_option(opts, options)
239
+ add_force_option(opts, options)
240
+ add_help_option(opts, options)
241
+ opts.separator ""
242
+ opts.separator "Prints the downloaded (or cached) file path on stdout."
243
+ opts.separator "Progress and status messages go to stderr."
244
+ end
245
+ end
246
+
247
+ def filter_parser(options)
248
+ OptionParser.new do |opts|
249
+ opts.banner = <<~BANNER.chomp
250
+ Usage: swiss-netex filter [options]
251
+
252
+ Filter a local Swiss national NeTEx package (zip or directory) by operator
253
+ and optional line. Writes a smaller zip with transitive frame contents.
254
+ BANNER
255
+ opts.separator ""
256
+ opts.separator "Options:"
257
+ add_from_option(opts, options, required_note: true)
258
+ add_output_option(opts, options)
259
+ add_operator_option(opts, options)
260
+ add_line_option(opts, options)
261
+ add_allow_empty_option(opts, options)
262
+ add_verbose_option(opts, options)
263
+ add_help_option(opts, options)
264
+ opts.separator ""
265
+ opts.separator "Prints the output zip path on stdout."
266
+ opts.separator "Progress and status messages go to stderr."
267
+ opts.separator "--operator may be repeated (union of operators)."
268
+ opts.separator "National filter needs several GB of free RAM (TIMETABLE streaming)."
269
+ end
270
+ end
271
+
272
+ def extract_parser(options)
273
+ OptionParser.new do |opts|
274
+ opts.banner = <<~BANNER.chomp
275
+ Usage: swiss-netex extract [options]
276
+
277
+ Download the latest national package when --from is omitted, then filter by
278
+ operator/line. Equivalent to download + filter for the common workflow.
279
+ BANNER
280
+ opts.separator ""
281
+ opts.separator "Options:"
282
+ add_operator_option(opts, options)
283
+ add_line_option(opts, options)
284
+ add_from_option(opts, options, required_note: false)
285
+ add_dataset_options(opts, options)
286
+ add_output_option(opts, options)
287
+ add_cache_dir_option(opts, options)
288
+ add_force_option(opts, options)
289
+ add_allow_empty_option(opts, options)
290
+ add_verbose_option(opts, options)
291
+ add_help_option(opts, options)
292
+ opts.separator ""
293
+ opts.separator "Prints the output zip path on stdout."
294
+ opts.separator "Progress and status messages go to stderr."
295
+ end
296
+ end
297
+
298
+ def operators_parser(options)
299
+ OptionParser.new do |opts|
300
+ opts.banner = <<~BANNER.chomp
301
+ Usage: swiss-netex operators [options]
302
+
303
+ List operators from the RESOURCE frame on stdout (TSV when piped;
304
+ aligned columns on a TTY). Scans RESOURCE only (no TIMETABLE).
305
+ Omit --from to download (or reuse cache) first.
306
+ BANNER
307
+ opts.separator ""
308
+ opts.separator "Options:"
309
+ add_from_option(opts, options, required_note: false)
310
+ add_dataset_options(opts, options)
311
+ add_cache_dir_option(opts, options)
312
+ add_force_option(opts, options)
313
+ add_help_option(opts, options)
314
+ opts.separator ""
315
+ opts.separator "Columns: #{Operators::HEADERS.join(", ")}"
316
+ end
317
+ end
318
+
319
+ def lines_parser(options)
320
+ OptionParser.new do |opts|
321
+ opts.banner = <<~BANNER.chomp
322
+ Usage: swiss-netex lines [options]
323
+
324
+ List lines from the SERVICE frame on stdout (TSV when piped; aligned
325
+ columns on a TTY). Scans SERVICE only. Optional --operator / --line
326
+ narrow the list (same alias rules as filter). Omit --from to download
327
+ (or reuse cache) first.
328
+ BANNER
329
+ opts.separator ""
330
+ opts.separator "Options:"
331
+ add_from_option(opts, options, required_note: false)
332
+ add_dataset_options(opts, options)
333
+ add_cache_dir_option(opts, options)
334
+ add_force_option(opts, options)
335
+ add_operator_option(opts, options)
336
+ add_line_option(opts, options)
337
+ add_help_option(opts, options)
338
+ opts.separator ""
339
+ opts.separator "Columns: #{Lines::HEADERS.join(", ")}"
340
+ end
341
+ end
342
+
343
+ def add_from_option(opts, options, required_note:)
344
+ label =
345
+ if required_note
346
+ "Source package zip or extracted directory (required)"
347
+ else
348
+ "Local package zip/dir (skips download when set)"
349
+ end
350
+ opts.on("--from PATH", label) { |value| options[:from] = value }
351
+ end
352
+
353
+ def add_output_option(opts, options)
354
+ opts.on("-o", "--output PATH", "--out PATH",
355
+ "Output zip path or directory (bare names get .zip)") do |value|
356
+ options[:output] = value
357
+ end
358
+ end
359
+
360
+ def add_operator_option(opts, options)
361
+ opts.on("-O", "--operator ALIAS",
362
+ "Operator alias (GO, NeTEx id, SBOID, ShortName); repeatable") do |value|
363
+ options[:operators] << value
364
+ end
365
+ end
366
+
367
+ def add_line_option(opts, options)
368
+ opts.on("-l", "--line ALIAS", "--lines ALIAS",
369
+ "Optional line filter (PublicCode, id, SLNID, ShortName, Name);",
370
+ "repeatable") do |value|
371
+ options[:lines] << value
372
+ end
373
+ end
374
+
375
+ def add_allow_empty_option(opts, options)
376
+ opts.on("--allow-empty",
377
+ "Write output even when no lines or ServiceJourneys match") do
378
+ options[:allow_empty] = true
379
+ end
380
+ end
381
+
382
+ def add_verbose_option(opts, options)
383
+ opts.on("-v", "--verbose",
384
+ "Per-shard and per-frame keep/drop counters on stderr") do
385
+ options[:verbose] = true
386
+ end
387
+ end
388
+
389
+ def add_help_option(opts, options)
390
+ opts.on("-h", "--help", "Show this help") { options[:show_help] = true }
391
+ end
392
+
393
+ def add_dataset_options(opts, options)
394
+ opts.on("-d", "--dataset URL_OR_SLUG",
395
+ "Dataset page URL or slug",
396
+ "(default: latest timetablenetex_YYYY from catalog)") do |value|
397
+ options[:dataset] = value
398
+ end
399
+ opts.on("-y", "--year YYYY",
400
+ "Shortcut for --dataset timetablenetex_YYYY",
401
+ "(mutually exclusive with --dataset)") do |value|
402
+ options[:year] = value
403
+ end
404
+ end
405
+
406
+ def add_cache_dir_option(opts, options)
407
+ opts.on("-c", "--cache-dir PATH",
408
+ "Cache directory when downloading",
409
+ "(default: #{Download::DEFAULT_CACHE_DIR})") do |value|
410
+ options[:cache_dir] = value
411
+ options[:cache_dir_set] = true
412
+ end
413
+ end
414
+
415
+ def add_force_option(opts, options)
416
+ opts.on("-f", "--force", "Re-download even if a cache file already exists") do
417
+ options[:force] = true
418
+ end
419
+ end
420
+
421
+ def download_help = download_parser({}).help.rstrip
422
+ def filter_help = filter_parser(blank_filter_options).help.rstrip
423
+ def extract_help = extract_parser(blank_extract_options).help.rstrip
424
+ def operators_help = operators_parser(blank_discovery_options).help.rstrip
425
+
426
+ def lines_help
427
+ lines_parser(blank_discovery_options.merge(operators: [], lines: [])).help.rstrip
428
+ end
429
+
430
+ def resolve_package_path(options)
431
+ reject_unused_download_opts_with_from!(options)
432
+ dataset = resolve_download_dataset(options[:dataset], options[:year])
433
+ PackageSource.resolve(
434
+ from: options[:from],
435
+ dataset: dataset,
436
+ cache_dir: options[:cache_dir],
437
+ force: options[:force],
438
+ http: @http,
439
+ progress_io: @stderr
440
+ )
441
+ end
442
+
443
+ def resolve_download_dataset(dataset, year)
444
+ if dataset && year
445
+ raise OptionParser::ParseError, "--dataset and --year are mutually exclusive"
446
+ end
447
+
448
+ return dataset if dataset
449
+ return nil if year.nil?
450
+
451
+ unless year.match?(/\A\d{4}\z/)
452
+ raise OptionParser::ParseError, "--year must be a 4-digit Fahrplanjahr (e.g. 2026)"
453
+ end
454
+
455
+ "timetablenetex_#{year}"
456
+ end
457
+
458
+ # --from skips download; reject flags that would otherwise be silently ignored.
459
+ def reject_unused_download_opts_with_from!(options)
460
+ return if options[:from].to_s.strip.empty?
461
+
462
+ used = []
463
+ used << "--dataset" if options[:dataset]
464
+ used << "--year" if options[:year]
465
+ used << "--force" if options[:force]
466
+ used << "--cache-dir" if options[:cache_dir_set]
467
+ return if used.empty?
468
+
469
+ raise OptionParser::ParseError,
470
+ "--from cannot be combined with #{used.join(", ")}"
471
+ end
472
+
473
+ def show_command_help(text)
474
+ @stdout.puts text
475
+ EXIT_OK
476
+ end
477
+
478
+ def unexpected_args(leftover, help_text)
479
+ @stderr.puts "Error: unexpected argument(s): #{leftover.join(" ")}"
480
+ @stderr.puts
481
+ @stderr.puts help_text
482
+ EXIT_USAGE
483
+ end
484
+
485
+ def unknown_command(command)
486
+ @stderr.puts "Unknown command: #{command}"
487
+ @stderr.puts
488
+ @stderr.puts top_level_help
489
+ EXIT_USAGE
490
+ end
491
+
492
+ def fail_error(message)
493
+ @stderr.puts "Error: #{message}"
494
+ EXIT_FAILURE
495
+ end
496
+
497
+ def fail_usage(message)
498
+ @stderr.puts "Error: #{message}"
499
+ @stderr.puts
500
+ @stderr.puts(@active_help || top_level_help)
501
+ EXIT_USAGE
502
+ end
503
+
504
+ def top_level_help
505
+ lines = [
506
+ "Usage: swiss-netex <command> [options]",
507
+ "",
508
+ "CLI for Swiss national NeTEx timetable packages",
509
+ "(opentransportdata.swiss): download, inspect, and filter by operator/line.",
510
+ "",
511
+ "Commands:"
512
+ ]
513
+
514
+ COMMANDS.each do |name, summary|
515
+ lines << format(" %<name>-12s %<summary>s", name: name, summary: summary)
516
+ end
517
+
518
+ (lines + [
519
+ "",
520
+ "Run `swiss-netex help <command>` for command-specific usage."
521
+ ]).join("\n")
522
+ end
523
+
524
+ def command_help(command)
525
+ case command
526
+ when "download" then download_help
527
+ when "filter" then filter_help
528
+ when "extract" then extract_help
529
+ when "operators" then operators_help
530
+ when "lines" then lines_help
531
+ when "help"
532
+ <<~HELP.chomp
533
+ Usage: swiss-netex help [command]
534
+
535
+ Show top-level help, or detailed usage for a command.
536
+ HELP
537
+ when "version"
538
+ <<~HELP.chomp
539
+ Usage: swiss-netex version
540
+
541
+ Print the installed swiss-netex version.
542
+ HELP
543
+ end
544
+ end
545
+ end
546
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module SwissNetex
6
+ # Parses an opentransportdata.swiss CKAN dataset HTML page and picks the
7
+ # newest national NeTEx package by the export timestamp embedded in the
8
+ # filename (`…_YYYYMMDDHHMM.zip`).
9
+ class DatasetPage
10
+ HREF_RE = /href\s*=\s*["']([^"']+)["']/i
11
+ PACKAGE_NAME_RE = /\A(PROD_NETEX_TT_.+\.zip)\z/i
12
+ TIMESTAMP_RE = /_(\d{12})\.zip\z/i
13
+
14
+ Resource = Data.define(:url, :filename, :timestamp)
15
+
16
+ def self.latest(html, base_url:)
17
+ new(html, base_url: base_url).latest
18
+ end
19
+
20
+ def initialize(html, base_url:)
21
+ @html = html.to_s
22
+ @base_url = base_url.to_s.chomp("/")
23
+ end
24
+
25
+ def resources
26
+ seen = {}
27
+ @html.scan(HREF_RE).filter_map do |(href)|
28
+ resource = resource_from_href(unescape_attr(href))
29
+ next unless resource
30
+ next if seen[resource.url]
31
+
32
+ seen[resource.url] = true
33
+ resource
34
+ end
35
+ end
36
+
37
+ def latest
38
+ list = resources
39
+ raise Error, "No PROD_NETEX_TT_*.zip resource found on dataset page" if list.empty?
40
+
41
+ list.max_by(&:timestamp)
42
+ end
43
+
44
+ private
45
+
46
+ def resource_from_href(href)
47
+ uri = absolute_uri(href)
48
+ return unless uri
49
+
50
+ filename = File.basename(uri.path.to_s)
51
+ match = PACKAGE_NAME_RE.match(filename)
52
+ return unless match
53
+
54
+ timestamp = TIMESTAMP_RE.match(filename)&.captures&.first
55
+ return unless timestamp
56
+
57
+ Resource.new(
58
+ url: uri.to_s,
59
+ filename: canonicalize_filename(match[1]),
60
+ timestamp: timestamp
61
+ )
62
+ rescue URI::InvalidURIError
63
+ nil
64
+ end
65
+
66
+ def absolute_uri(href)
67
+ return if href.nil? || href.empty? || href.start_with?("#", "javascript:")
68
+
69
+ uri = URI.parse(href)
70
+ return uri if uri.absolute?
71
+
72
+ URI.join("#{@base_url}/", href)
73
+ end
74
+
75
+ # CKAN download paths are lowercased; keep the conventional display casing.
76
+ def canonicalize_filename(name)
77
+ stem = name.sub(/\.zip\z/i, "")
78
+ "#{stem.upcase}.zip"
79
+ end
80
+
81
+ def unescape_attr(value)
82
+ value
83
+ .gsub("&amp;", "&")
84
+ .gsub("&quot;", "\"")
85
+ .gsub("&#39;", "'")
86
+ .gsub("&lt;", "<")
87
+ .gsub("&gt;", ">")
88
+ end
89
+ end
90
+ end