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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +237 -0
- data/exe/swiss-netex +6 -0
- data/lib/swiss_netex/catalog.rb +41 -0
- data/lib/swiss_netex/cli.rb +546 -0
- data/lib/swiss_netex/dataset_page.rb +90 -0
- data/lib/swiss_netex/download.rb +239 -0
- data/lib/swiss_netex/error.rb +5 -0
- data/lib/swiss_netex/extract.rb +56 -0
- data/lib/swiss_netex/filter/common_frame.rb +194 -0
- data/lib/swiss_netex/filter/frame_worker.rb +188 -0
- data/lib/swiss_netex/filter/frames.rb +126 -0
- data/lib/swiss_netex/filter/refs.rb +106 -0
- data/lib/swiss_netex/filter/service_lines.rb +139 -0
- data/lib/swiss_netex/filter/timetable.rb +387 -0
- data/lib/swiss_netex/filter/timetable_batch.rb +330 -0
- data/lib/swiss_netex/filter/xml_sieve.rb +189 -0
- data/lib/swiss_netex/filter.rb +346 -0
- data/lib/swiss_netex/http.rb +130 -0
- data/lib/swiss_netex/lines.rb +161 -0
- data/lib/swiss_netex/operators.rb +254 -0
- data/lib/swiss_netex/package.rb +145 -0
- data/lib/swiss_netex/package_source.rb +30 -0
- data/lib/swiss_netex/resource_frame.rb +73 -0
- data/lib/swiss_netex/tsv.rb +53 -0
- data/lib/swiss_netex/version.rb +5 -0
- data/lib/swiss_netex.rb +20 -0
- data/swiss-netex.gemspec +40 -0
- metadata +104 -0
|
@@ -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,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
|