active_sanction 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 +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "zlib"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Parsers
|
|
10
|
+
class Spreadsheet
|
|
11
|
+
# The ZIP container an .xlsx workbook arrives in, read with nothing but
|
|
12
|
+
# `zlib`.
|
|
13
|
+
#
|
|
14
|
+
# archive = Archive.new(bytes)
|
|
15
|
+
# archive.names # => ["[Content_Types].xml", "xl/workbook.xml", ...]
|
|
16
|
+
# archive.fetch("xl/workbook.xml") # => "<?xml version=..."
|
|
17
|
+
#
|
|
18
|
+
# ### Why this is here rather than in a gem
|
|
19
|
+
#
|
|
20
|
+
# An .xlsx file is a ZIP of XML parts, and the gem already reads XML. The
|
|
21
|
+
# only thing standing between it and a spreadsheet is the container, and
|
|
22
|
+
# the container is a 1990 format with a fixed-width header -- a hundred
|
|
23
|
+
# lines of unpacking, against a spreadsheet gem that would be the first
|
|
24
|
+
# third-party dependency this library has taken for one publisher's
|
|
25
|
+
# choice of file format. The gemspec's rule is that a compliance library
|
|
26
|
+
# should not be the reason a deployment installs something; that rule is
|
|
27
|
+
# worth more than the hundred lines.
|
|
28
|
+
#
|
|
29
|
+
# What is deliberately not implemented is everything a *general* ZIP
|
|
30
|
+
# reader needs and a published spreadsheet never uses: encryption, spanned
|
|
31
|
+
# archives, and ZIP64. Each of them raises by name rather than being
|
|
32
|
+
# half-read, because a container this cannot read is a payload that must
|
|
33
|
+
# not be parsed as though it were empty.
|
|
34
|
+
#
|
|
35
|
+
# ### Read from the central directory, not from the local headers
|
|
36
|
+
#
|
|
37
|
+
# A ZIP entry's sizes appear twice -- once in the central directory at the
|
|
38
|
+
# end of the file and once in the local header in front of the bytes --
|
|
39
|
+
# and the local copy is allowed to be zeroes, with the real sizes written
|
|
40
|
+
# in a data descriptor *after* the compressed data. Excel does exactly
|
|
41
|
+
# that on some writes. So sizes and offsets are taken from the central
|
|
42
|
+
# directory, which is authoritative, and the local header is read only for
|
|
43
|
+
# the two lengths that say where the entry's bytes actually begin.
|
|
44
|
+
#
|
|
45
|
+
# @api private
|
|
46
|
+
class Archive
|
|
47
|
+
extend T::Sig
|
|
48
|
+
|
|
49
|
+
EOCD_SIGNATURE = T.let("PK\x05\x06".b, String)
|
|
50
|
+
CENTRAL_SIGNATURE = T.let("PK\x01\x02".b, String)
|
|
51
|
+
LOCAL_SIGNATURE = T.let("PK\x03\x04".b, String)
|
|
52
|
+
|
|
53
|
+
# 22 bytes of fixed header plus a comment that the format caps at 64 KB.
|
|
54
|
+
EOCD_FIXED = T.let(22, Integer)
|
|
55
|
+
MAX_COMMENT = T.let(0xFFFF, Integer)
|
|
56
|
+
|
|
57
|
+
CENTRAL_FIXED = T.let(46, Integer)
|
|
58
|
+
LOCAL_FIXED = T.let(30, Integer)
|
|
59
|
+
|
|
60
|
+
STORED = T.let(0, Integer)
|
|
61
|
+
DEFLATED = T.let(8, Integer)
|
|
62
|
+
|
|
63
|
+
# The value every ZIP64 field is replaced by in the 32-bit record that
|
|
64
|
+
# cannot hold it. Seeing one means the real number is in an extra field
|
|
65
|
+
# this does not read -- see the class comment.
|
|
66
|
+
OVERFLOW_32 = T.let(0xFFFFFFFF, Integer)
|
|
67
|
+
OVERFLOW_16 = T.let(0xFFFF, Integer)
|
|
68
|
+
|
|
69
|
+
# General purpose bit 0. Set on an entry whose bytes are encrypted,
|
|
70
|
+
# which inflates to noise rather than failing, so it is refused here.
|
|
71
|
+
ENCRYPTED = T.let(0x0001, Integer)
|
|
72
|
+
|
|
73
|
+
# A raw deflate stream -- no zlib header, no adler checksum -- which is
|
|
74
|
+
# what a ZIP entry holds and what a negative window size selects.
|
|
75
|
+
RAW_DEFLATE = T.let(-Zlib::MAX_WBITS, Integer)
|
|
76
|
+
|
|
77
|
+
sig { params(payload: T.untyped).void }
|
|
78
|
+
def initialize(payload)
|
|
79
|
+
@bytes = T.let(binary(payload), String)
|
|
80
|
+
@entries = T.let(read_central_directory, T::Hash[String, T::Hash[Symbol, Integer]])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The part names in the archive, in central-directory order.
|
|
84
|
+
sig { returns(T::Array[String]) }
|
|
85
|
+
def names = @entries.keys
|
|
86
|
+
|
|
87
|
+
sig { params(name: T.untyped).returns(T::Boolean) }
|
|
88
|
+
def include?(name) = @entries.key?(name.to_s)
|
|
89
|
+
|
|
90
|
+
# One part's bytes, decompressed, or nil when the archive has no such
|
|
91
|
+
# part. The parts of a workbook are all XML, and the XML reader decodes
|
|
92
|
+
# them, so what comes back here is binary.
|
|
93
|
+
sig { params(name: T.untyped).returns(T.nilable(String)) }
|
|
94
|
+
def [](name)
|
|
95
|
+
entry = @entries[name.to_s]
|
|
96
|
+
entry.nil? ? nil : extract(name.to_s, entry)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# For a part the caller cannot proceed without. Names what the archive
|
|
100
|
+
# does hold, because the usual cause of a missing part is that the
|
|
101
|
+
# payload is not the workbook it was taken for.
|
|
102
|
+
sig { params(name: T.untyped).returns(String) }
|
|
103
|
+
def fetch(name)
|
|
104
|
+
self[name] || raise(ParseError, "#{name.to_s.inspect} is not in this archive. It holds: #{names.join(", ")}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
sig { returns(String) }
|
|
108
|
+
def inspect = "#<#{self.class} #{@entries.size} part(s)>"
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
sig { params(payload: T.untyped).returns(String) }
|
|
113
|
+
def binary(payload)
|
|
114
|
+
string = payload.to_s
|
|
115
|
+
string.encoding == Encoding::BINARY ? string : string.dup.force_encoding(Encoding::BINARY)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The end-of-central-directory record is the only fixed landmark in a
|
|
119
|
+
# ZIP, and it is at the end -- behind a comment of unknown length, so it
|
|
120
|
+
# is searched for backwards. A payload with no such record is not a
|
|
121
|
+
# truncated spreadsheet that could be salvaged; it is a file whose index
|
|
122
|
+
# was never received, and none of the entries can be located without it.
|
|
123
|
+
sig { returns(Integer) }
|
|
124
|
+
def eocd_offset
|
|
125
|
+
last = @bytes.bytesize - EOCD_FIXED
|
|
126
|
+
offset = last.negative? ? nil : @bytes.rindex(EOCD_SIGNATURE, last)
|
|
127
|
+
return offset if offset && offset >= last - MAX_COMMENT
|
|
128
|
+
|
|
129
|
+
raise ParseError,
|
|
130
|
+
"expected a ZIP archive (an .xlsx workbook is one), and found no end-of-central-directory " \
|
|
131
|
+
"record in #{@bytes.bytesize} byte(s) -- the payload is truncated, or is not a workbook"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sig { returns(T::Hash[String, T::Hash[Symbol, Integer]]) }
|
|
135
|
+
def read_central_directory
|
|
136
|
+
eocd = eocd_offset
|
|
137
|
+
count, size, start = @bytes[eocd + 10, 12].to_s.unpack("vVV")
|
|
138
|
+
refuse_zip64!(count, start)
|
|
139
|
+
walk_central_directory(Integer(start), Integer(size), Integer(count))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sig { params(count: T.untyped, start: T.untyped).void }
|
|
143
|
+
def refuse_zip64!(count, start)
|
|
144
|
+
return unless count == OVERFLOW_16 || start == OVERFLOW_32
|
|
145
|
+
|
|
146
|
+
raise ParseError,
|
|
147
|
+
"this is a ZIP64 archive, which this reader does not implement. No published sanctions list is " \
|
|
148
|
+
"anywhere near the 4 GB that requires one, so the payload is almost certainly not a workbook"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
sig { params(start: Integer, size: Integer, count: Integer).returns(T::Hash[String, T::Hash[Symbol, Integer]]) }
|
|
152
|
+
def walk_central_directory(start, size, count)
|
|
153
|
+
entries = {}
|
|
154
|
+
offset = start
|
|
155
|
+
finish = start + size
|
|
156
|
+
count.times do
|
|
157
|
+
break if offset + CENTRAL_FIXED > finish
|
|
158
|
+
|
|
159
|
+
name, entry, offset = read_central_entry(offset)
|
|
160
|
+
entries[name] = entry
|
|
161
|
+
end
|
|
162
|
+
entries
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
sig { params(offset: Integer).returns([String, T::Hash[Symbol, Integer], Integer]) }
|
|
166
|
+
def read_central_entry(offset)
|
|
167
|
+
header = @bytes[offset, CENTRAL_FIXED].to_s
|
|
168
|
+
unless header.start_with?(CENTRAL_SIGNATURE)
|
|
169
|
+
raise ParseError.new("the central directory ends inside an entry header", offset: offset)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
entry = central_entry(header)
|
|
173
|
+
lengths = header[28, 6].to_s.unpack("vvv").map { |length| Integer(length) }
|
|
174
|
+
name = @bytes[offset + CENTRAL_FIXED, T.must(lengths.first)].to_s.force_encoding(Encoding::UTF_8)
|
|
175
|
+
[name, entry, offset + CENTRAL_FIXED + lengths.sum]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Flags, compression method, both sizes and where the entry's local
|
|
179
|
+
# header sits. The four bytes skipped are the modification timestamp,
|
|
180
|
+
# and the four after them the CRC, which the size check below stands in
|
|
181
|
+
# for -- a part that inflates to the stated length has not been
|
|
182
|
+
# truncated, and a part whose deflate stream is damaged raises out of
|
|
183
|
+
# zlib before it can be measured.
|
|
184
|
+
sig { params(header: String).returns(T::Hash[Symbol, Integer]) }
|
|
185
|
+
def central_entry(header)
|
|
186
|
+
flags, method, compressed, uncompressed = header[8, 20].to_s.unpack("vvx8VV")
|
|
187
|
+
{ flags: Integer(flags), method: Integer(method), compressed: Integer(compressed),
|
|
188
|
+
uncompressed: Integer(uncompressed), local: Integer(header[42, 4].to_s.unpack1("V")) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
sig { params(name: String, entry: T::Hash[Symbol, Integer]).returns(String) }
|
|
192
|
+
def extract(name, entry)
|
|
193
|
+
refuse_unreadable!(name, entry)
|
|
194
|
+
data = @bytes[data_offset(name, entry), Integer(entry.fetch(:compressed))].to_s
|
|
195
|
+
bytes = entry.fetch(:method) == STORED ? data : inflate(name, data)
|
|
196
|
+
verify_size!(name, bytes, Integer(entry.fetch(:uncompressed)))
|
|
197
|
+
bytes
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
sig { params(name: String, entry: T::Hash[Symbol, Integer]).void }
|
|
201
|
+
def refuse_unreadable!(name, entry)
|
|
202
|
+
if entry.fetch(:flags).anybits?(ENCRYPTED)
|
|
203
|
+
raise ParseError, "#{name.inspect} is encrypted, and this reader holds no password"
|
|
204
|
+
end
|
|
205
|
+
if entry.values_at(:compressed, :uncompressed, :local).include?(OVERFLOW_32)
|
|
206
|
+
raise ParseError, "#{name.inspect} states its size in a ZIP64 extra field, which this reader does not read"
|
|
207
|
+
end
|
|
208
|
+
return if [STORED, DEFLATED].include?(entry.fetch(:method))
|
|
209
|
+
|
|
210
|
+
raise ParseError,
|
|
211
|
+
"#{name.inspect} is stored with compression method #{entry.fetch(:method)}, and this reader " \
|
|
212
|
+
"implements only stored (0) and deflate (8), which is everything a spreadsheet writer emits"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The local header repeats the name and carries its own extra field,
|
|
216
|
+
# and the two lengths are the only thing read from it -- see the class
|
|
217
|
+
# comment on why the sizes are not.
|
|
218
|
+
sig { params(name: String, entry: T::Hash[Symbol, Integer]).returns(Integer) }
|
|
219
|
+
def data_offset(name, entry)
|
|
220
|
+
offset = Integer(entry.fetch(:local))
|
|
221
|
+
header = @bytes[offset, LOCAL_FIXED].to_s
|
|
222
|
+
unless header.start_with?(LOCAL_SIGNATURE)
|
|
223
|
+
raise ParseError.new("the central directory points at #{name.inspect}, which is not a local header",
|
|
224
|
+
offset: offset)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
name_length, extra_length = header[26, 4].to_s.unpack("vv")
|
|
228
|
+
offset + LOCAL_FIXED + Integer(name_length) + Integer(extra_length)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sig { params(name: String, data: String).returns(String) }
|
|
232
|
+
def inflate(name, data)
|
|
233
|
+
stream = Zlib::Inflate.new(RAW_DEFLATE)
|
|
234
|
+
begin
|
|
235
|
+
stream.inflate(data) << stream.finish
|
|
236
|
+
ensure
|
|
237
|
+
stream.close
|
|
238
|
+
end
|
|
239
|
+
rescue Zlib::Error => e
|
|
240
|
+
raise ParseError, "#{name.inspect} could not be decompressed: #{e.class} #{e.message}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The central directory states what the part should weigh, and a part
|
|
244
|
+
# that arrives short is a truncated download rather than a shorter
|
|
245
|
+
# spreadsheet. Saying so here is what stops half a sanctions list being
|
|
246
|
+
# parsed as though it were the whole one.
|
|
247
|
+
sig { params(name: String, bytes: String, expected: Integer).void }
|
|
248
|
+
def verify_size!(name, bytes, expected)
|
|
249
|
+
return if expected.zero? || bytes.bytesize == expected
|
|
250
|
+
|
|
251
|
+
raise ParseError,
|
|
252
|
+
"#{name.inspect} unpacked to #{bytes.bytesize} byte(s) where the archive's own index says " \
|
|
253
|
+
"#{expected} -- the payload is truncated or corrupt"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/parsers/xml_records"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Parsers
|
|
10
|
+
class Spreadsheet
|
|
11
|
+
# One pass over one workbook. Enumerable, and lazy in the same sense the
|
|
12
|
+
# XML reader is: the sheet is streamed row by row, so what is held is one
|
|
13
|
+
# row plus the shared string table -- which is not optional, a cell being
|
|
14
|
+
# an offset into it.
|
|
15
|
+
#
|
|
16
|
+
# reader = table.read(bytes)
|
|
17
|
+
# reader.each { |row| row[:reference] }
|
|
18
|
+
# reader.warnings # => rows that could not be read
|
|
19
|
+
# reader.sheet_names # => every sheet, whether or not it was read
|
|
20
|
+
#
|
|
21
|
+
# Re-enumerating re-reads from the start, which also resets #warnings --
|
|
22
|
+
# so `reader.count` followed by `reader.warnings` reports the warnings
|
|
23
|
+
# from the counting pass, not from two passes appended together. The ZIP
|
|
24
|
+
# is unpacked once and reused across passes.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
class Reader
|
|
28
|
+
extend T::Sig
|
|
29
|
+
extend T::Generic
|
|
30
|
+
include Enumerable
|
|
31
|
+
|
|
32
|
+
Elem = type_member { { fixed: Row } }
|
|
33
|
+
|
|
34
|
+
ROWS = T.let(Parsers::XmlRecords.new(records: %w[row]), Parsers::XmlRecords)
|
|
35
|
+
|
|
36
|
+
# `A`, `AB`, `XFD`: the letters in a cell's `r="C7"` address, which is
|
|
37
|
+
# the only thing that says which column a cell is in. A row states only
|
|
38
|
+
# the cells that hold something, so position in the row means nothing.
|
|
39
|
+
ADDRESS = T.let(/\A([A-Z]+)/, Regexp)
|
|
40
|
+
|
|
41
|
+
LETTERS = T.let(26, Integer)
|
|
42
|
+
|
|
43
|
+
DATE_FORMATS = T.let({ day: "%Y-%m-%d", month: "%Y-%m", year: "%Y" }.freeze, T::Hash[Symbol, String])
|
|
44
|
+
|
|
45
|
+
sig { returns(Spreadsheet) }
|
|
46
|
+
attr_reader :table
|
|
47
|
+
|
|
48
|
+
sig { returns(T::Array[Warning]) }
|
|
49
|
+
attr_reader :warnings
|
|
50
|
+
|
|
51
|
+
sig { params(table: Spreadsheet, payload: T.untyped).void }
|
|
52
|
+
def initialize(table:, payload:)
|
|
53
|
+
@table = T.let(table, Spreadsheet)
|
|
54
|
+
@payload = T.let(payload, T.untyped)
|
|
55
|
+
@warnings = T.let([], T::Array[Warning])
|
|
56
|
+
@workbook = T.let(nil, T.nilable(Workbook))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The workbook's sheets, in the order it lists them. Worth printing when
|
|
60
|
+
# a publisher adds a second tab: only one of them is being read.
|
|
61
|
+
sig { returns(T::Array[String]) }
|
|
62
|
+
def sheet_names = workbook.sheet_names
|
|
63
|
+
|
|
64
|
+
# When the workbook says it was last saved -- see Workbook#modified.
|
|
65
|
+
# Costs one part of the archive and no pass over the sheet.
|
|
66
|
+
sig { returns(T.nilable(String)) }
|
|
67
|
+
def modified = workbook.modified
|
|
68
|
+
|
|
69
|
+
sig { override.params(block: T.nilable(T.proc.params(row: Row).void)).returns(T.untyped) }
|
|
70
|
+
def each(&block)
|
|
71
|
+
return enum_for(:each) unless block
|
|
72
|
+
|
|
73
|
+
@warnings = []
|
|
74
|
+
rows = ROWS.read(workbook.sheet(table.sheet))
|
|
75
|
+
columns = T.let(nil, T.nilable(T::Array[Symbol]))
|
|
76
|
+
rows.each do |node|
|
|
77
|
+
cells = read_row(node)
|
|
78
|
+
columns = table.columns || header!(cells, node) and next if columns.nil?
|
|
79
|
+
|
|
80
|
+
# A `<row>` with no cell holding anything is spacing, not a record.
|
|
81
|
+
block.call(build(columns, cells, number(node))) if cells.any?
|
|
82
|
+
end
|
|
83
|
+
@warnings.concat(rows.warnings)
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Every row, in memory. The convenience the small sheets get to use.
|
|
88
|
+
sig { returns(T::Array[Row]) }
|
|
89
|
+
def to_a = each.to_a
|
|
90
|
+
|
|
91
|
+
sig { returns(String) }
|
|
92
|
+
def inspect = "#<#{self.class} #{table.sheet_name} of #{sheet_names.size} sheet(s)>"
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
sig { returns(Workbook) }
|
|
97
|
+
def workbook
|
|
98
|
+
@workbook ||= Workbook.new(table: table, archive: Archive.new(@payload))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Column index to value, indexed from the cell's own address so that a
|
|
102
|
+
# row which states only the cells it filled still lines up with the
|
|
103
|
+
# header. Nothing here assumes the row is contiguous, because it is not:
|
|
104
|
+
# a row whose first two cells are empty starts at `C`.
|
|
105
|
+
sig { params(node: Parsers::XmlRecords::Record).returns(T::Hash[Integer, String]) }
|
|
106
|
+
def read_row(node)
|
|
107
|
+
node.nodes("c").each_with_object({}) do |cell, values|
|
|
108
|
+
index = column_index(cell.attribute("r"))
|
|
109
|
+
value = table.value(cell_value(cell))
|
|
110
|
+
values[index] = value if index && value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# `C` is 2, `AA` is 26, in the bijective base-26 a spreadsheet numbers
|
|
115
|
+
# its columns with.
|
|
116
|
+
sig { params(address: T.nilable(String)).returns(T.nilable(Integer)) }
|
|
117
|
+
def column_index(address)
|
|
118
|
+
letters = ADDRESS.match(address.to_s)&.captures&.first
|
|
119
|
+
return nil if letters.nil?
|
|
120
|
+
|
|
121
|
+
letters.each_char.inject(0) { |index, letter| (index * LETTERS) + (letter.ord - 64) } - 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A cell states its type in `t` and its value in `<v>` -- except an
|
|
125
|
+
# inline string, which is the one shape that carries its text where a
|
|
126
|
+
# shared string's offset would be.
|
|
127
|
+
sig { params(cell: Parsers::XmlRecords::Record).returns(T.nilable(String)) }
|
|
128
|
+
def cell_value(cell)
|
|
129
|
+
case cell.attribute("t")
|
|
130
|
+
when "s" then shared(cell["v"])
|
|
131
|
+
when "inlineStr" then table.unescape(cell.values("is/t").join)
|
|
132
|
+
when "b" then cell["v"] == "1" ? "TRUE" : "FALSE"
|
|
133
|
+
when "str", "e", "d" then cell["v"]
|
|
134
|
+
else numeric(cell)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sig { params(offset: T.nilable(String)).returns(T.nilable(String)) }
|
|
139
|
+
def shared(offset) = offset.nil? ? nil : workbook.strings[offset.to_i]
|
|
140
|
+
|
|
141
|
+
# A number is a date when the cell's style says it is displayed as one,
|
|
142
|
+
# and is otherwise passed through as the publisher wrote it -- `1963`
|
|
143
|
+
# stays `1963`, because a year is not a date and rendering it as one
|
|
144
|
+
# would invent a January the 1st nobody published.
|
|
145
|
+
sig { params(cell: Parsers::XmlRecords::Record).returns(T.nilable(String)) }
|
|
146
|
+
def numeric(cell)
|
|
147
|
+
raw = cell["v"]
|
|
148
|
+
precision = workbook.precision(cell.attribute("s")&.to_i)
|
|
149
|
+
return raw if raw.nil? || precision.nil?
|
|
150
|
+
|
|
151
|
+
formatted(raw, precision) || raw
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ISO 8601, truncated to what the cell's format actually displays: a
|
|
155
|
+
# `mmm-yy` cell says a month and its serial's day is whatever Excel
|
|
156
|
+
# needed to store one. PartialDate::Parser reads all three shapes, so an
|
|
157
|
+
# adapter gets the publisher's own precision without a format of its own.
|
|
158
|
+
sig { params(raw: String, precision: Symbol).returns(T.nilable(String)) }
|
|
159
|
+
def formatted(raw, precision)
|
|
160
|
+
workbook.date(raw)&.strftime(DATE_FORMATS.fetch(precision))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Column names from the sheet's own first row, normalized the way the
|
|
164
|
+
# delimited reader normalizes a CSV header, so that `Name of Individual
|
|
165
|
+
# or Entity` and `name_of_individual_or_entity` are the same column
|
|
166
|
+
# however the publisher capitalized it this quarter.
|
|
167
|
+
sig { params(cells: T::Hash[Integer, String], node: Parsers::XmlRecords::Record).returns(T::Array[Symbol]) }
|
|
168
|
+
def header!(cells, node)
|
|
169
|
+
raise ParseError.new("expected a header row, and it is empty", line: number(node)) if cells.empty?
|
|
170
|
+
|
|
171
|
+
(0..T.must(cells.keys.max)).map { |index| normalize_header(cells[index], index) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A header cell the publisher left blank still names a column, because
|
|
175
|
+
# the columns after it have to keep their positions.
|
|
176
|
+
sig { params(value: T.nilable(String), index: Integer).returns(Symbol) }
|
|
177
|
+
def normalize_header(value, index)
|
|
178
|
+
name = value.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
179
|
+
name.empty? ? :"column_#{index + 1}" : name.to_sym
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
sig { params(node: Parsers::XmlRecords::Record).returns(Integer) }
|
|
183
|
+
def number(node) = node.attribute("r")&.to_i || node.line || 0
|
|
184
|
+
|
|
185
|
+
sig do
|
|
186
|
+
params(columns: T::Array[Symbol], cells: T::Hash[Integer, String], number: Integer).returns(Row)
|
|
187
|
+
end
|
|
188
|
+
def build(columns, cells, number)
|
|
189
|
+
record_width(columns, cells, number)
|
|
190
|
+
Row.new(values: table.coerce(columns, cells), number: number)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A row with a value in a column the header never named is kept, and the
|
|
194
|
+
# extra value is dropped rather than the row. A publisher appending a
|
|
195
|
+
# column mid-year should degrade the fields nobody has mapped yet, not
|
|
196
|
+
# the whole list -- but it must say so, because a column silently
|
|
197
|
+
# ignored is how a new sanctions measure stops being read.
|
|
198
|
+
sig { params(columns: T::Array[Symbol], cells: T::Hash[Integer, String], number: Integer).void }
|
|
199
|
+
def record_width(columns, cells, number)
|
|
200
|
+
beyond = cells.keys.select { |index| index >= columns.size }
|
|
201
|
+
return if beyond.empty?
|
|
202
|
+
|
|
203
|
+
@warnings << Warning.new(
|
|
204
|
+
line: number,
|
|
205
|
+
message: "row #{number} holds #{beyond.size} value(s) past the #{columns.size} column(s) " \
|
|
206
|
+
"this sheet named",
|
|
207
|
+
snippet: beyond.map { |index| cells[index] }.join(" | ")
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Parsers
|
|
8
|
+
class Spreadsheet
|
|
9
|
+
# One row of a sheet: column name to value, with every cell already
|
|
10
|
+
# resolved to the string it displays as -- a shared string looked up, a
|
|
11
|
+
# date serial rendered, a blank cell nil.
|
|
12
|
+
#
|
|
13
|
+
# row[:name_of_individual_or_entity] # => "MOHAMMAD HASSAN AKHUND"
|
|
14
|
+
# row[:date_of_birth] # => "1963-01-30"
|
|
15
|
+
# row[:alias_strength] # => nil, the cell being empty
|
|
16
|
+
# row.number # => 2, the sheet's own row number
|
|
17
|
+
#
|
|
18
|
+
# Deliberately not a Hash, for the reason DelimitedTable::Row is not one:
|
|
19
|
+
# a Hash would answer `row[:date_of_brith]` with nil and let a typo in an
|
|
20
|
+
# adapter look like an empty column all the way into a snapshot. #[]
|
|
21
|
+
# raises on a name the sheet never had, and #fetch is there for the
|
|
22
|
+
# genuinely optional case.
|
|
23
|
+
#
|
|
24
|
+
# It is a separate class from the delimited table's row rather than a
|
|
25
|
+
# shared one because the two disagree about what locates a record. A CSV
|
|
26
|
+
# row is at a line of a file; a sheet row is at a row number the
|
|
27
|
+
# spreadsheet itself assigns, which is what is showing in the corner of
|
|
28
|
+
# the window when somebody opens the published file to check a record --
|
|
29
|
+
# and which survives the workbook being re-saved with different XML.
|
|
30
|
+
class Row
|
|
31
|
+
extend T::Sig
|
|
32
|
+
|
|
33
|
+
sig { returns(T::Hash[Symbol, T.nilable(String)]) }
|
|
34
|
+
attr_reader :values
|
|
35
|
+
|
|
36
|
+
# The sheet's own row number, 1-based and counting the header row --
|
|
37
|
+
# the number a reader of the published file will see beside the record.
|
|
38
|
+
sig { returns(Integer) }
|
|
39
|
+
attr_reader :number
|
|
40
|
+
|
|
41
|
+
sig { params(values: T::Hash[Symbol, T.nilable(String)], number: Integer).void }
|
|
42
|
+
def initialize(values:, number:)
|
|
43
|
+
@values = T.let(values.freeze, T::Hash[Symbol, T.nilable(String)])
|
|
44
|
+
@number = T.let(number, Integer)
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raises on a column the sheet never had, which is almost always a typo
|
|
49
|
+
# in an adapter rather than a question about the data.
|
|
50
|
+
sig { params(column: Symbol).returns(T.nilable(String)) }
|
|
51
|
+
def [](column)
|
|
52
|
+
values.fetch(column) do
|
|
53
|
+
raise MissingKey, "no column #{column.inspect} in this sheet. Read: #{columns.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(column: Symbol, default: T.untyped).returns(T.untyped) }
|
|
58
|
+
def fetch(column, default = nil) = values.fetch(column, default)
|
|
59
|
+
|
|
60
|
+
sig { params(column: Symbol).returns(T::Boolean) }
|
|
61
|
+
def null?(column) = self[column].nil?
|
|
62
|
+
|
|
63
|
+
sig { returns(T::Array[Symbol]) }
|
|
64
|
+
def columns = values.keys
|
|
65
|
+
|
|
66
|
+
sig { returns(T::Hash[Symbol, T.nilable(String)]) }
|
|
67
|
+
def to_h = values
|
|
68
|
+
|
|
69
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
70
|
+
def ==(other)
|
|
71
|
+
return false unless other.instance_of?(self.class)
|
|
72
|
+
|
|
73
|
+
values == other.values && number == other.number
|
|
74
|
+
end
|
|
75
|
+
alias eql? ==
|
|
76
|
+
|
|
77
|
+
sig { returns(Integer) }
|
|
78
|
+
def hash = [self.class, values, number].hash
|
|
79
|
+
|
|
80
|
+
sig { returns(String) }
|
|
81
|
+
def inspect
|
|
82
|
+
filled = values.compact
|
|
83
|
+
"#<#{self.class} row=#{number} #{filled.map { |name, value| "#{name}=#{value.inspect}" }.join(" ")}>"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|