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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "uri"
5
+ require "zip"
6
+
7
+ module SwissNetex
8
+ # Resolve the latest national NeTEx zip from a dataset page and stream it to
9
+ # disk. Shared by the `download` CLI command and later `extract`.
10
+ class Download
11
+ DEFAULT_CACHE_DIR = File.join(Dir.home, ".cache", "swiss-netex").freeze
12
+ DATA_HOST = "https://data.opentransportdata.swiss"
13
+ CATALOG_SEARCH_URL = "#{DATA_HOST}/en/dataset/?q=timetablenetex".freeze
14
+ PROGRESS_INTERVAL_BYTES = 1024 * 1024 # 1 MiB
15
+
16
+ Result = Data.define(:path, :url, :filename, :bytes, :cached, :dataset)
17
+
18
+ # dataset: nil → discover highest published timetablenetex_YYYY from catalog.
19
+ def initialize(
20
+ dataset: nil,
21
+ output: nil,
22
+ cache_dir: DEFAULT_CACHE_DIR,
23
+ force: false,
24
+ http: Http.new,
25
+ progress_io: nil
26
+ )
27
+ @dataset = dataset
28
+ @output = output
29
+ @cache_dir = cache_dir
30
+ @force = force
31
+ @http = http
32
+ @progress_io = progress_io
33
+ end
34
+
35
+ def call
36
+ page_url, dataset_slug = resolve_dataset_page
37
+ log "Resolving latest package from #{page_url}…"
38
+
39
+ html = @http.get_body(page_url)
40
+ resource = DatasetPage.latest(html, base_url: page_origin(page_url))
41
+ destination = resolve_destination(resource.filename)
42
+
43
+ # Package filenames embed the export timestamp, so a non-empty file with
44
+ # the same name is a cache hit. OTD's R2 signed URLs reject HEAD, so we
45
+ # cannot cheaply verify remote size without starting a full GET.
46
+ if !@force && cache_hit?(destination)
47
+ bytes = File.size(destination)
48
+ log "Using cached package (#{human_bytes(bytes)}): #{destination}"
49
+ return Result.new(
50
+ path: destination,
51
+ url: resource.url,
52
+ filename: resource.filename,
53
+ bytes: bytes,
54
+ cached: true,
55
+ dataset: dataset_slug
56
+ )
57
+ end
58
+
59
+ bytes = stream_to(destination, resource)
60
+ log "Downloaded #{human_bytes(bytes)} → #{destination}"
61
+
62
+ Result.new(
63
+ path: destination,
64
+ url: resource.url,
65
+ filename: resource.filename,
66
+ bytes: bytes,
67
+ cached: false,
68
+ dataset: dataset_slug
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def resolve_dataset_page
75
+ value = @dataset
76
+ value = value.to_s.strip unless value.nil?
77
+
78
+ if value.nil? || value.empty?
79
+ slug = discover_latest_dataset_slug
80
+ return ["#{DATA_HOST}/en/dataset/#{slug}", slug]
81
+ end
82
+
83
+ return [value, slug_from_url(value)] if value.match?(%r{\Ahttps?://}i)
84
+
85
+ slug = value.delete_prefix("/").delete_prefix("dataset/")
86
+ ["#{DATA_HOST}/en/dataset/#{slug}", slug]
87
+ end
88
+
89
+ def discover_latest_dataset_slug
90
+ log "Discovering latest national dataset from catalog…"
91
+ html = @http.get_body(CATALOG_SEARCH_URL)
92
+ slug = Catalog.latest_slug(html)
93
+ log "Using dataset #{slug}"
94
+ slug
95
+ end
96
+
97
+ def slug_from_url(url)
98
+ path = URI.parse(url).path.to_s
99
+ match = path.match(%r{/dataset/([^/]+)}i)
100
+ match ? match[1] : path
101
+ rescue URI::InvalidURIError
102
+ url
103
+ end
104
+
105
+ def page_origin(page_url)
106
+ uri = URI.parse(page_url)
107
+ return DATA_HOST unless uri.is_a?(URI::HTTP) && uri.host
108
+
109
+ origin = "#{uri.scheme}://#{uri.host}"
110
+ origin += ":#{uri.port}" if uri.port && uri.port != uri.default_port
111
+ origin
112
+ rescue URI::InvalidURIError
113
+ DATA_HOST
114
+ end
115
+
116
+ def resolve_destination(filename)
117
+ if @output && !@output.empty?
118
+ path = File.expand_path(@output)
119
+ return File.join(path, filename) if directory_output?(path)
120
+
121
+ return path
122
+ end
123
+
124
+ File.join(File.expand_path(@cache_dir), filename)
125
+ end
126
+
127
+ def directory_output?(path)
128
+ return true if @output.end_with?("/") || @output.end_with?(File::SEPARATOR)
129
+ return true if File.directory?(path)
130
+
131
+ # Treat bare/non-zip paths that don't exist yet as directories when they
132
+ # look like dirs (no extension). Explicit .zip paths stay files.
133
+ !File.exist?(path) && File.extname(path).empty?
134
+ end
135
+
136
+ # Filename match is the cache key (export timestamp embedded). Still open the
137
+ # zip central directory so a truncated / non-zip file is not treated as a hit.
138
+ def cache_hit?(path)
139
+ return false unless File.file?(path) && File.size(path).positive?
140
+
141
+ Zip::File.open(path) { true }
142
+ rescue Zip::Error, Errno::ENOENT, Errno::EACCES
143
+ false
144
+ end
145
+
146
+ def stream_to(destination, resource)
147
+ FileUtils.mkdir_p(File.dirname(destination))
148
+ temp_path = "#{destination}.partial.#{Process.pid}"
149
+
150
+ log "Downloading #{resource.filename}…"
151
+
152
+ bytes = 0
153
+ last_report_at = 0
154
+ expected_bytes = nil
155
+
156
+ begin
157
+ File.open(temp_path, "wb") do |file|
158
+ @http.stream(resource.url) do |chunk, content_length|
159
+ expected_bytes ||= content_length
160
+ file.write(chunk)
161
+ bytes += chunk.bytesize
162
+
163
+ if should_report_progress?(bytes, last_report_at, expected_bytes)
164
+ report_progress(bytes, expected_bytes)
165
+ last_report_at = bytes
166
+ end
167
+ end
168
+ end
169
+ finish_progress_line
170
+
171
+ if expected_bytes && bytes != expected_bytes
172
+ raise Error,
173
+ "Download size mismatch for #{resource.filename}: " \
174
+ "expected #{expected_bytes} bytes, got #{bytes}"
175
+ end
176
+
177
+ raise Error, "Downloaded empty file: #{resource.filename}" if bytes.zero?
178
+
179
+ File.rename(temp_path, destination)
180
+ bytes
181
+ ensure
182
+ # Interrupt / signal and size errors leave junk otherwise.
183
+ FileUtils.rm_f(temp_path)
184
+ end
185
+ end
186
+
187
+ def should_report_progress?(bytes, last_report_at, expected_bytes)
188
+ return false unless @progress_io
189
+ return true if expected_bytes && bytes >= expected_bytes
190
+
191
+ (bytes - last_report_at) >= PROGRESS_INTERVAL_BYTES
192
+ end
193
+
194
+ def report_progress(bytes, expected_bytes)
195
+ return unless @progress_io
196
+
197
+ message =
198
+ if expected_bytes&.positive?
199
+ percent = [(bytes * 100.0 / expected_bytes).floor, 100].min
200
+ format(
201
+ "\r %<done>s / %<total>s (%<percent>d%%)",
202
+ done: human_bytes(bytes),
203
+ total: human_bytes(expected_bytes),
204
+ percent: percent
205
+ )
206
+ else
207
+ format("\r %<done>s", done: human_bytes(bytes))
208
+ end
209
+
210
+ @progress_io.print message
211
+ @progress_io.flush
212
+ end
213
+
214
+ def finish_progress_line
215
+ return unless @progress_io
216
+
217
+ @progress_io.print "\n"
218
+ @progress_io.flush
219
+ end
220
+
221
+ def log(message)
222
+ return unless @progress_io
223
+
224
+ @progress_io.puts message
225
+ @progress_io.flush
226
+ end
227
+
228
+ def human_bytes(bytes)
229
+ units = %w[B KB MB GB TB]
230
+ value = bytes.to_f
231
+ units.each_with_index do |unit, index|
232
+ last_unit = index == units.length - 1
233
+ return format("%<n>.1f %<u>s", n: value, u: unit) if value < 1024.0 || last_unit
234
+
235
+ value /= 1024.0
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissNetex
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissNetex
4
+ # Convenience pipeline: resolve a national package (download if needed) then
5
+ # filter by operator/line. Shared CLI surface for `extract`.
6
+ class Extract
7
+ def initialize(
8
+ operators:,
9
+ output:,
10
+ lines: [],
11
+ from: nil,
12
+ dataset: nil,
13
+ cache_dir: Download::DEFAULT_CACHE_DIR,
14
+ force: false,
15
+ allow_empty: false,
16
+ verbose: false,
17
+ http: nil,
18
+ progress_io: nil
19
+ )
20
+ @operators = Array(operators)
21
+ @lines = Array(lines)
22
+ @output = output
23
+ @from = from
24
+ @dataset = dataset
25
+ @cache_dir = cache_dir
26
+ @force = force
27
+ @allow_empty = allow_empty
28
+ @verbose = verbose
29
+ @http = http
30
+ @progress_io = progress_io
31
+ end
32
+
33
+ def call
34
+ raise Error, "No --operator given" if @operators.empty?
35
+ raise Error, "Missing --output path" if @output.to_s.empty?
36
+
37
+ source = PackageSource.resolve(
38
+ from: @from,
39
+ dataset: @dataset,
40
+ cache_dir: @cache_dir,
41
+ force: @force,
42
+ http: @http,
43
+ progress_io: @progress_io
44
+ )
45
+ Filter.new(
46
+ from: source,
47
+ output: @output,
48
+ operators: @operators,
49
+ lines: @lines,
50
+ allow_empty: @allow_empty,
51
+ verbose: @verbose,
52
+ progress_io: @progress_io
53
+ ).call
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "tempfile"
5
+
6
+ module SwissNetex
7
+ class Filter
8
+ # COMMON holds JourneyMeeting, InterchangeRule, TypeOfService, ServiceFacilitySet.
9
+ # Chunked byte scanner (no Nokogiri Reader) keeps peak RSS near frame size.
10
+ # Regexes use /n (NOENCODING) so zip entry streams (ASCII-8BIT) match cleanly.
11
+ # Assumes Swiss default-namespace elements (unprefixed local names). Prefixed
12
+ # entity tags would pass through as structure.
13
+ #
14
+ # Pure-whitespace structure before a dropped entity is discarded so filtered
15
+ # output does not keep blank holes between kept siblings (XmlSieve parity).
16
+ class CommonFrame
17
+ CHUNK = 1 << 20 # 1 MiB
18
+ TAIL = 64
19
+ OPEN_RE = %r{<(JourneyMeeting|InterchangeRule|ServiceFacilitySet|TypeOfService)([\s>/])}n
20
+ JOURNEY_REF_RE = /<(?:FromJourneyRef|ToJourneyRef|ServiceJourneyRef)\b[^>]*\bref="([^"]+)"/n
21
+ STOP_REF_RE = /<(?:StopPlaceRef|AdjacentStopPlaceRef)\b[^>]*\bref="([^"]+)"/n
22
+ LINE_REF_RE = /<LineRef\b[^>]*\bref="([^"]+)"/n
23
+ ID_RE = /\bid="([^"]+)"/n
24
+ XML_DECL_RE = /\A(?:\xEF\xBB\xBF)?\s*<\?xml\b.*?\?>\s*/nm
25
+ NON_WS_RE = /\S/n
26
+
27
+ def initialize(refs)
28
+ @refs = refs
29
+ end
30
+
31
+ def call(source)
32
+ out = new_tempfile
33
+ out << "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
34
+ kept = 0
35
+ dropped = 0
36
+ buffer = +"".b
37
+ io = to_io(source)
38
+ pending_decl = true
39
+
40
+ while (chunk = io.read(CHUNK))
41
+ buffer << chunk.b
42
+ if pending_decl
43
+ stripped = strip_xml_decl(buffer)
44
+ pending_decl = false if stripped.bytesize != buffer.bytesize || buffer.match?(NON_WS_RE)
45
+ buffer = stripped
46
+ end
47
+
48
+ k, d, buffer = drain(buffer, out, final: false)
49
+ kept += k
50
+ dropped += d
51
+ end
52
+
53
+ k, d, remainder = drain(buffer, out, final: true)
54
+ kept += k
55
+ dropped += d
56
+ out << remainder
57
+
58
+ out.flush
59
+ out.rewind
60
+ XmlSieve::Result.new(io: out, kept: kept, dropped: dropped)
61
+ end
62
+
63
+ private
64
+
65
+ def drain(buffer, out, final:)
66
+ kept = 0
67
+ dropped = 0
68
+
69
+ loop do
70
+ match = OPEN_RE.match(buffer)
71
+ return [kept, dropped, flush_structure(buffer, out, final: final)] unless match
72
+
73
+ prefix = +"".b
74
+ if match.begin(0).positive?
75
+ prefix = buffer.byteslice(0, match.begin(0))
76
+ buffer = buffer.byteslice(match.begin(0), buffer.bytesize - match.begin(0))
77
+ end
78
+
79
+ name = match[1]
80
+ block, rest = extract_element(buffer, name)
81
+ unless block
82
+ # Entity not complete yet — put prefix back so the next chunk can retry.
83
+ return [kept, dropped, prefix + buffer]
84
+ end
85
+
86
+ buffer = rest
87
+ if keep_block?(name, block)
88
+ out << prefix unless prefix.empty?
89
+ out << block
90
+ kept += 1
91
+ else
92
+ # Drop: only keep structural prefix that is not pure whitespace.
93
+ out << prefix if prefix.match?(NON_WS_RE)
94
+ dropped += 1
95
+ end
96
+ end
97
+ end
98
+
99
+ def flush_structure(buffer, out, final:)
100
+ return buffer if final || buffer.bytesize <= TAIL
101
+
102
+ flush_len = buffer.bytesize - TAIL
103
+ out << buffer.byteslice(0, flush_len)
104
+ buffer.byteslice(flush_len, TAIL)
105
+ end
106
+
107
+ def extract_element(buffer, name)
108
+ gt = buffer.index(">".b)
109
+ return [nil, buffer] unless gt
110
+
111
+ start = buffer.byteslice(0, gt + 1)
112
+ if start.end_with?("/>".b)
113
+ return [start, buffer.byteslice(gt + 1, buffer.bytesize - gt - 1)]
114
+ end
115
+
116
+ close = "</#{name}>".b
117
+ idx = buffer.index(close)
118
+ return [nil, buffer] unless idx
119
+
120
+ stop = idx + close.bytesize
121
+ [buffer.byteslice(0, stop), buffer.byteslice(stop, buffer.bytesize - stop)]
122
+ end
123
+
124
+ def keep_block?(name, block)
125
+ case name
126
+ when "JourneyMeeting".b
127
+ journey_ref_kept?(block)
128
+ when "InterchangeRule".b
129
+ interchange_kept?(block)
130
+ when "ServiceFacilitySet".b
131
+ id_kept?(block, :service_facility_sets)
132
+ when "TypeOfService".b
133
+ id_kept?(block, :type_of_services)
134
+ else
135
+ false
136
+ end
137
+ end
138
+
139
+ # Require every tracked journey side to resolve — one-sided meetings leave
140
+ # dangling refs in the filtered package. No journey refs → drop.
141
+ def journey_ref_kept?(block)
142
+ refs = scan_refs(block, JOURNEY_REF_RE)
143
+ return false if refs.empty?
144
+
145
+ all_refs_kept?(refs, :journeys)
146
+ end
147
+
148
+ # Same closure rule as SERVICE SiteConnection: keep only when every tracked
149
+ # journey / stop / line ref is present. Entities with no tracked refs drop.
150
+ def interchange_kept?(block)
151
+ journey_refs = scan_refs(block, JOURNEY_REF_RE)
152
+ stop_refs = scan_refs(block, STOP_REF_RE)
153
+ line_refs = scan_refs(block, LINE_REF_RE)
154
+ return false if journey_refs.empty? && stop_refs.empty? && line_refs.empty?
155
+
156
+ all_refs_kept?(journey_refs, :journeys) &&
157
+ all_refs_kept?(stop_refs, :stop_places) &&
158
+ all_refs_kept?(line_refs, :lines)
159
+ end
160
+
161
+ def all_refs_kept?(refs, bucket)
162
+ return true if refs.empty?
163
+
164
+ refs.all? { |ref| @refs.include?(bucket, ref) }
165
+ end
166
+
167
+ def id_kept?(block, bucket)
168
+ id = block[ID_RE, 1]&.force_encoding(Encoding::UTF_8)
169
+ id && @refs.include?(bucket, id)
170
+ end
171
+
172
+ def scan_refs(block, regex)
173
+ block.scan(regex).flatten.map { |ref| ref.force_encoding(Encoding::UTF_8) }
174
+ end
175
+
176
+ def strip_xml_decl(text)
177
+ text.sub(XML_DECL_RE, "".b)
178
+ end
179
+
180
+ def new_tempfile
181
+ file = Tempfile.new(["swiss-netex-common", ".xml"])
182
+ file.binmode
183
+ file
184
+ end
185
+
186
+ def to_io(source)
187
+ return StringIO.new(source.b) if source.is_a?(String)
188
+ return source if source.respond_to?(:read)
189
+
190
+ raise Error, "CommonFrame expects a String or IO-like object"
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "json"
5
+
6
+ module SwissNetex
7
+ class Filter
8
+ # Rewrite support frames in short-lived children so Nokogiri Reader heap is
9
+ # freed at process exit. Falls back to in-process when fork is unavailable.
10
+ # Multiple kinds run concurrently when fork is available (SWISS_NETEX_TT_WORKERS).
11
+ class FrameWorker
12
+ def initialize(package_path:, tmp:, refs:)
13
+ @package_path = package_path.to_s
14
+ @tmp = tmp.to_s
15
+ @refs = refs
16
+ @workers = resolve_workers
17
+ end
18
+
19
+ def call(kind, name)
20
+ call_many([[kind, name]]).fetch(kind)
21
+ end
22
+
23
+ # Parallel rewrite of independent support frames. +pairs+ is [[kind, name], …].
24
+ # Returns { kind => { "kept" => …, "dropped" => … } }.
25
+ def call_many(pairs)
26
+ pairs = pairs.select { |_kind, name| name }
27
+ return {} if pairs.empty?
28
+ return run_pool(pairs) if fork_supported? && pairs.size > 1 && @workers > 1
29
+
30
+ pairs.each_with_object({}) do |(kind, name), out|
31
+ out[kind] = run_one(kind, name)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def run_one(kind, name)
38
+ return process_frame(kind, name, @refs) unless fork_supported?
39
+
40
+ read_io, write_io = IO.pipe
41
+ refs_export = @refs.export
42
+ pid = fork do
43
+ read_io.close
44
+ local_refs = Refs.new.merge_export(refs_export)
45
+ result = process_frame(kind, name, local_refs)
46
+ write_io.write(JSON.generate(result))
47
+ write_io.close
48
+ exit! 0
49
+ end
50
+ write_io.close
51
+
52
+ raw = nil
53
+ status = nil
54
+ begin
55
+ raw = read_io.read
56
+ ensure
57
+ read_io.close
58
+ _pid, status = Process.wait2(pid)
59
+ end
60
+ raise Error, "#{kind} worker failed (exit #{status.exitstatus})" unless status.success?
61
+
62
+ JSON.parse(raw)
63
+ rescue JSON::ParserError => e
64
+ raise Error, "#{kind} worker returned invalid payload: #{e.message}"
65
+ end
66
+
67
+ def run_pool(pairs)
68
+ queue = pairs.dup
69
+ inflight = {}
70
+ results = {}
71
+ refs_export = @refs.export
72
+
73
+ while queue.any? || inflight.any?
74
+ while inflight.size < @workers && queue.any?
75
+ kind, name = queue.shift
76
+ spawn_job(kind, name, refs_export, inflight)
77
+ end
78
+
79
+ pid, status = Process.wait2(-1)
80
+ meta = inflight.delete(pid)
81
+ next unless meta
82
+
83
+ raw = meta[:reader].value
84
+ meta[:read_io].close
85
+ unless status.success?
86
+ reap_inflight(inflight)
87
+ raise Error, "#{meta[:kind]} worker failed (exit #{status.exitstatus})"
88
+ end
89
+
90
+ results[meta[:kind]] = JSON.parse(raw)
91
+ end
92
+
93
+ results
94
+ rescue JSON::ParserError => e
95
+ reap_inflight(inflight || {})
96
+ raise Error, "support-frame worker returned invalid payload: #{e.message}"
97
+ rescue StandardError
98
+ reap_inflight(inflight || {})
99
+ raise
100
+ end
101
+
102
+ def spawn_job(kind, name, refs_export, inflight)
103
+ read_io, write_io = IO.pipe
104
+ pid = fork do
105
+ read_io.close
106
+ local_refs = Refs.new.merge_export(refs_export)
107
+ result = process_frame(kind, name, local_refs)
108
+ write_io.write(JSON.generate(result))
109
+ write_io.close
110
+ exit! 0
111
+ end
112
+ write_io.close
113
+ reader = Thread.new { read_io.read }
114
+ inflight[pid] = { kind: kind, read_io: read_io, reader: reader }
115
+ end
116
+
117
+ def reap_inflight(inflight)
118
+ inflight.each_key do |pid|
119
+ Process.kill("TERM", pid)
120
+ rescue Errno::ESRCH
121
+ # already gone
122
+ end
123
+ inflight.each do |pid, meta|
124
+ Process.wait2(pid)
125
+ rescue Errno::ECHILD
126
+ # already reaped
127
+ ensure
128
+ meta[:reader]&.kill
129
+ meta[:read_io]&.close unless meta[:read_io]&.closed?
130
+ end
131
+ inflight.clear
132
+ end
133
+
134
+ def process_frame(kind, name, refs)
135
+ destination = File.join(@tmp, File.basename(name))
136
+ kept = 0
137
+ dropped = 0
138
+
139
+ Package.open(@package_path) do |pkg|
140
+ pkg.open_member(name) do |io|
141
+ result = Frames.public_send(kind, io, refs)
142
+ begin
143
+ kept = result.kept
144
+ dropped = result.dropped
145
+ copy_io_to_file(result.io, destination)
146
+ ensure
147
+ close_temp(result.io)
148
+ end
149
+ end
150
+ end
151
+
152
+ { "kept" => kept, "dropped" => dropped }
153
+ end
154
+
155
+ def fork_supported?
156
+ return false if ENV["SWISS_NETEX_NO_FORK"]
157
+
158
+ Process.respond_to?(:fork)
159
+ end
160
+
161
+ def resolve_workers
162
+ raw = ENV["SWISS_NETEX_TT_WORKERS"].to_s.strip
163
+ if raw.empty?
164
+ n = Etc.nprocessors
165
+ return n.positive? ? n : 1
166
+ end
167
+
168
+ size = Integer(raw, 10)
169
+ size.positive? ? size : 1
170
+ rescue ArgumentError
171
+ 1
172
+ end
173
+
174
+ def copy_io_to_file(io, destination)
175
+ io.rewind if io.respond_to?(:rewind)
176
+ File.open(destination, "wb") { |out| IO.copy_stream(io, out) }
177
+ end
178
+
179
+ def close_temp(io)
180
+ return unless io
181
+
182
+ io.close!
183
+ rescue StandardError
184
+ io.close if io.respond_to?(:close) && !io.closed?
185
+ end
186
+ end
187
+ end
188
+ end