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,194 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "csv"
|
|
7
|
+
require "stringio"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
module Parsers
|
|
11
|
+
class DelimitedTable
|
|
12
|
+
# One pass over one payload. Enumerable, and lazy: rows are yielded as
|
|
13
|
+
# they are read rather than collected, so a 5.6 MB list costs one row of
|
|
14
|
+
# memory plus whatever the caller keeps.
|
|
15
|
+
#
|
|
16
|
+
# reader = table.read(bytes)
|
|
17
|
+
# reader.each { |row| ... }
|
|
18
|
+
# reader.warnings # => rows that could not be read
|
|
19
|
+
#
|
|
20
|
+
# Re-enumerating rewinds and starts over, which also resets #warnings --
|
|
21
|
+
# so `reader.count` followed by `reader.warnings` reports the warnings
|
|
22
|
+
# from the counting pass, not from two passes appended together.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
class Reader
|
|
26
|
+
extend T::Sig
|
|
27
|
+
extend T::Generic
|
|
28
|
+
include Enumerable
|
|
29
|
+
|
|
30
|
+
Elem = type_member { { fixed: Row } }
|
|
31
|
+
|
|
32
|
+
# A parser that raises on every row is not isolating failures, it is
|
|
33
|
+
# failing -- most often because the payload is not the format the table
|
|
34
|
+
# was told to expect (an HTML error page saved as .csv is the classic).
|
|
35
|
+
# Collecting 19,321 warnings to say so helps nobody.
|
|
36
|
+
MAX_CONSECUTIVE_FAILURES = T.let(100, Integer)
|
|
37
|
+
|
|
38
|
+
EOF = T.let(Object.new.freeze, Object)
|
|
39
|
+
private_constant :EOF
|
|
40
|
+
|
|
41
|
+
sig { returns(DelimitedTable) }
|
|
42
|
+
attr_reader :table
|
|
43
|
+
|
|
44
|
+
# The rows this pass could not read. Reset by each pass -- see the
|
|
45
|
+
# class comment.
|
|
46
|
+
sig { returns(T::Array[Warning]) }
|
|
47
|
+
attr_reader :warnings
|
|
48
|
+
|
|
49
|
+
sig { params(table: DelimitedTable, payload: T.untyped).void }
|
|
50
|
+
def initialize(table:, payload:)
|
|
51
|
+
@table = T.let(table, DelimitedTable)
|
|
52
|
+
@payload = T.let(payload, T.untyped)
|
|
53
|
+
@warnings = T.let([], T::Array[Warning])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { override.params(block: T.nilable(T.proc.params(row: Row).void)).returns(T.untyped) }
|
|
57
|
+
def each(&block)
|
|
58
|
+
return enum_for(:each) unless block
|
|
59
|
+
|
|
60
|
+
csv = start
|
|
61
|
+
columns = table.columns || header!(csv)
|
|
62
|
+
consecutive = 0
|
|
63
|
+
loop do
|
|
64
|
+
values = shift(csv)
|
|
65
|
+
break if values.equal?(EOF)
|
|
66
|
+
|
|
67
|
+
consecutive = advance(consecutive, values)
|
|
68
|
+
block.call(build(columns, values, csv.lineno)) unless values.nil?
|
|
69
|
+
end
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Every row, in memory. The convenience the small files get to use;
|
|
74
|
+
# anything list-sized should stay with #each.
|
|
75
|
+
sig { returns(T::Array[Row]) }
|
|
76
|
+
def to_a = each.to_a
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Counts consecutive unreadable rows, and stops the pass once there
|
|
81
|
+
# have been too many to be explained by anything but the wrong format.
|
|
82
|
+
sig { params(consecutive: Integer, values: T.untyped).returns(Integer) }
|
|
83
|
+
def advance(consecutive, values)
|
|
84
|
+
return 0 unless values.nil?
|
|
85
|
+
|
|
86
|
+
count = consecutive + 1
|
|
87
|
+
give_up!(count) if count >= MAX_CONSECUTIVE_FAILURES
|
|
88
|
+
count
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { returns(CSV) }
|
|
92
|
+
def start
|
|
93
|
+
@warnings = []
|
|
94
|
+
CSV.new(StringIO.new(payload!), **table.csv_options)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# No sanctions list has ever been published empty, so a payload with
|
|
98
|
+
# nothing in it is a failed download, a moved URL or an outage -- never
|
|
99
|
+
# a day on which nobody is sanctioned. Yielding no rows would let a
|
|
100
|
+
# sync succeed at screening against nothing, which is the most
|
|
101
|
+
# expensive way this library can fail, so it raises instead. The XML
|
|
102
|
+
# reader refuses the same payload for the same reason.
|
|
103
|
+
sig { returns(String) }
|
|
104
|
+
def payload!
|
|
105
|
+
string = decoded
|
|
106
|
+
raise ParseError, "expected #{table.col_sep_name} rows, got an empty payload" if string.strip.empty?
|
|
107
|
+
|
|
108
|
+
string
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Decoding happens once per pass rather than per row, and never raises.
|
|
112
|
+
# Format#decode says why, and strips the BOM; what is left here is the
|
|
113
|
+
# marker only a delimited file carries.
|
|
114
|
+
sig { returns(String) }
|
|
115
|
+
def decoded
|
|
116
|
+
string, replaced = table.decode(@payload)
|
|
117
|
+
record(0, table.invalid_bytes_message) if replaced
|
|
118
|
+
trim(string)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# SUB (0x1A) is CP/M's end-of-file character, and DOS-lineage export
|
|
122
|
+
# tooling still writes it: all three OFAC files end with `\r\n\x1A`.
|
|
123
|
+
# Left alone it parses as a final one-column row, so every sync reports
|
|
124
|
+
# a malformed row it can do nothing about -- and a warning that fires
|
|
125
|
+
# every single time is a warning nobody reads.
|
|
126
|
+
sig { params(string: String).returns(String) }
|
|
127
|
+
def trim(string) = string.sub(/\r?\n?\x1A\s*\z/, "")
|
|
128
|
+
|
|
129
|
+
# Column names taken from the file's own first row, lowercased and
|
|
130
|
+
# snake_cased so that `City/State/Province/ZIP/Postal Code` and
|
|
131
|
+
# `city_state_province_zip_postal_code` are the same column to an
|
|
132
|
+
# adapter regardless of how the publisher capitalized it this quarter.
|
|
133
|
+
# An empty payload has already been refused, so what is left to fail on
|
|
134
|
+
# here is a first row that could not be read at all -- and a file whose
|
|
135
|
+
# header is unreadable has no columns to name anything by.
|
|
136
|
+
sig { params(csv: CSV).returns(T::Array[Symbol]) }
|
|
137
|
+
def header!(csv)
|
|
138
|
+
values = shift(csv)
|
|
139
|
+
if values.nil? || values.equal?(EOF)
|
|
140
|
+
raise ParseError.new("expected a header row, read nothing usable as one", line: 1)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
values.map { |value| normalize_header(value) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
147
|
+
def normalize_header(value)
|
|
148
|
+
value.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_").to_sym
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns the row's values, EOF at the end of the payload, or nil for a
|
|
152
|
+
# row that could not be parsed -- already recorded as a warning.
|
|
153
|
+
sig { params(csv: CSV).returns(T.untyped) }
|
|
154
|
+
def shift(csv)
|
|
155
|
+
row = csv.shift
|
|
156
|
+
row.nil? ? EOF : row
|
|
157
|
+
rescue CSV::MalformedCSVError => e
|
|
158
|
+
record(csv.lineno, "malformed #{table.col_sep_name}: #{e.message}")
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sig { params(columns: T::Array[Symbol], values: T::Array[T.untyped], line: Integer).returns(Row) }
|
|
163
|
+
def build(columns, values, line)
|
|
164
|
+
record_arity(columns, values, line) unless values.size == columns.size
|
|
165
|
+
Row.new(values: table.coerce(columns, values), line: line)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A row of the wrong width is kept, not dropped. Short rows are padded
|
|
169
|
+
# with nil and long ones keep their extra values under no name, because
|
|
170
|
+
# a publisher appending a column mid-year should degrade the fields
|
|
171
|
+
# nobody has mapped yet rather than the whole list.
|
|
172
|
+
sig { params(columns: T::Array[Symbol], values: T::Array[T.untyped], line: Integer).void }
|
|
173
|
+
def record_arity(columns, values, line)
|
|
174
|
+
shape = values.size < columns.size ? "only #{values.size}" : values.size.to_s
|
|
175
|
+
record(line, "expected #{columns.size} columns, got #{shape}", values.join(table.col_sep))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
sig { params(line: T.nilable(Integer), message: String, snippet: T.untyped).void }
|
|
179
|
+
def record(line, message, snippet = nil)
|
|
180
|
+
@warnings << Warning.new(line: line, message: message, snippet: snippet)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
sig { params(consecutive: Integer).void }
|
|
184
|
+
def give_up!(consecutive)
|
|
185
|
+
raise ParseError.new(
|
|
186
|
+
"#{consecutive} consecutive rows could not be parsed. This payload is almost certainly not the " \
|
|
187
|
+
"#{table.col_sep_name} it was read as -- check the URL, and whether the publisher served an " \
|
|
188
|
+
"error page. First complaint: #{warnings.first}", line: warnings.first&.line
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Parsers
|
|
8
|
+
class DelimitedTable
|
|
9
|
+
# One parsed record: column name to value, with the publisher's null
|
|
10
|
+
# sentinel already resolved to nil.
|
|
11
|
+
#
|
|
12
|
+
# row[:sdn_name] # => "AEROCARIBBEAN AIRLINES"
|
|
13
|
+
# row[:title] # => nil, because OFAC wrote "-0- " there
|
|
14
|
+
# row.line # => 2
|
|
15
|
+
#
|
|
16
|
+
# Deliberately not a Hash. A Hash would answer `row[:sdn_nme]` with nil
|
|
17
|
+
# and let a typo in an adapter look like an empty column all the way into
|
|
18
|
+
# a snapshot; #[] here raises on a name the table never declared, and
|
|
19
|
+
# #fetch is available for the genuinely optional case.
|
|
20
|
+
class Row
|
|
21
|
+
extend T::Sig
|
|
22
|
+
|
|
23
|
+
# Column name to value, with the publisher's null sentinel already
|
|
24
|
+
# resolved to nil.
|
|
25
|
+
sig { returns(T::Hash[Symbol, T.nilable(String)]) }
|
|
26
|
+
attr_reader :values
|
|
27
|
+
|
|
28
|
+
sig { returns(Integer) }
|
|
29
|
+
attr_reader :line
|
|
30
|
+
|
|
31
|
+
sig { params(values: T::Hash[Symbol, T.nilable(String)], line: Integer).void }
|
|
32
|
+
def initialize(values:, line:)
|
|
33
|
+
@values = T.let(values.freeze, T::Hash[Symbol, T.nilable(String)])
|
|
34
|
+
@line = T.let(line, Integer)
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raises on an undeclared column, which is almost always a typo in an
|
|
39
|
+
# adapter rather than a question about the data.
|
|
40
|
+
sig { params(column: Symbol).returns(T.nilable(String)) }
|
|
41
|
+
def [](column)
|
|
42
|
+
values.fetch(column) do
|
|
43
|
+
raise MissingKey, "no column #{column.inspect} in this table. Declared: #{columns.join(", ")}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { params(column: Symbol, default: T.untyped).returns(T.untyped) }
|
|
48
|
+
def fetch(column, default = nil) = values.fetch(column, default)
|
|
49
|
+
|
|
50
|
+
# True when the publisher left the column blank or wrote its null
|
|
51
|
+
# sentinel there. Both arrive as nil, because "-0- " and "" mean the
|
|
52
|
+
# same thing in a file that uses both.
|
|
53
|
+
sig { params(column: Symbol).returns(T::Boolean) }
|
|
54
|
+
def null?(column) = self[column].nil?
|
|
55
|
+
|
|
56
|
+
sig { returns(T::Array[Symbol]) }
|
|
57
|
+
def columns = values.keys
|
|
58
|
+
|
|
59
|
+
sig { returns(T::Hash[Symbol, T.nilable(String)]) }
|
|
60
|
+
def to_h = values
|
|
61
|
+
|
|
62
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
63
|
+
def ==(other)
|
|
64
|
+
return false unless other.instance_of?(self.class)
|
|
65
|
+
|
|
66
|
+
values == other.values && line == other.line
|
|
67
|
+
end
|
|
68
|
+
alias eql? ==
|
|
69
|
+
|
|
70
|
+
sig { returns(Integer) }
|
|
71
|
+
def hash = [self.class, values, line].hash
|
|
72
|
+
|
|
73
|
+
sig { returns(String) }
|
|
74
|
+
def inspect
|
|
75
|
+
filled = values.compact
|
|
76
|
+
"#<#{self.class} line=#{line} #{filled.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}>"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "csv"
|
|
7
|
+
require "active_sanction/parsers/format"
|
|
8
|
+
require "active_sanction/parsers/delimited_table/row"
|
|
9
|
+
require "active_sanction/parsers/delimited_table/reader"
|
|
10
|
+
|
|
11
|
+
module ActiveSanction
|
|
12
|
+
module Parsers
|
|
13
|
+
# Reads a record-oriented delimited list -- CSV, TSV, anything stdlib CSV
|
|
14
|
+
# can be told about -- into rows an adapter can map onto Entities.
|
|
15
|
+
#
|
|
16
|
+
# A table is a description of the file, built once and reused for every
|
|
17
|
+
# sync; a Reader is one pass over one payload.
|
|
18
|
+
#
|
|
19
|
+
# SDN = ActiveSanction::Parsers::DelimitedTable.new(
|
|
20
|
+
# columns: %i[ent_num sdn_name sdn_type program title remarks],
|
|
21
|
+
# null: "-0-",
|
|
22
|
+
# encoding: Encoding::WINDOWS_1252
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# SDN.read(bytes).each { |row| row[:sdn_name] }
|
|
26
|
+
#
|
|
27
|
+
# ### Headerless files, and why columns are declared
|
|
28
|
+
#
|
|
29
|
+
# OFAC ships all three of its files with no header row, so the names have
|
|
30
|
+
# to come from somewhere. Declaring them in the adapter also pins the
|
|
31
|
+
# file's shape: if OFAC inserts a column, rows arrive the wrong width and
|
|
32
|
+
# every one of them says so in #warnings, which is a far better failure
|
|
33
|
+
# than 19,321 entities quietly built from shifted fields.
|
|
34
|
+
#
|
|
35
|
+
# A file that does carry a header is read with `columns: nil`, and the
|
|
36
|
+
# names come from its first row.
|
|
37
|
+
#
|
|
38
|
+
# ### The null sentinel
|
|
39
|
+
#
|
|
40
|
+
# OFAC does not leave a field empty; it writes `-0- `, with a trailing
|
|
41
|
+
# space, and it does this roughly a quarter of a million times:
|
|
42
|
+
#
|
|
43
|
+
# 36,"AEROCARIBBEAN AIRLINES",-0- ,"CUBA",-0- ,-0- ,...
|
|
44
|
+
#
|
|
45
|
+
# Any declared sentinel is matched after stripping surrounding whitespace,
|
|
46
|
+
# and a field that is empty or all whitespace is nil as well -- a list that
|
|
47
|
+
# uses both conventions in one file (they all do) should not make an
|
|
48
|
+
# adapter check for both.
|
|
49
|
+
class DelimitedTable
|
|
50
|
+
extend T::Sig
|
|
51
|
+
include Format
|
|
52
|
+
|
|
53
|
+
# @api private
|
|
54
|
+
SEPARATOR_NAMES = T.let(
|
|
55
|
+
{ "," => "CSV", "\t" => "TSV", "|" => "pipe-delimited text",
|
|
56
|
+
";" => "semicolon-delimited text" }.freeze,
|
|
57
|
+
T::Hash[String, String]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# nil where the file names its own columns -- see #headers?.
|
|
61
|
+
sig { returns(T.nilable(T::Array[Symbol])) }
|
|
62
|
+
attr_reader :columns
|
|
63
|
+
|
|
64
|
+
sig { returns(String) }
|
|
65
|
+
attr_reader :col_sep
|
|
66
|
+
|
|
67
|
+
sig { returns(String) }
|
|
68
|
+
attr_reader :quote_char
|
|
69
|
+
|
|
70
|
+
sig { override.returns(T::Array[String]) }
|
|
71
|
+
attr_reader :nulls
|
|
72
|
+
|
|
73
|
+
sig { override.returns(Encoding) }
|
|
74
|
+
attr_reader :encoding
|
|
75
|
+
|
|
76
|
+
# `liberal_parsing` is on by default because these files are published,
|
|
77
|
+
# not validated: an unescaped quote inside a company name is common
|
|
78
|
+
# enough in OFAC and UK OFSI data that failing the row is the wrong
|
|
79
|
+
# default. Turn it off for a source where a stray quote should be loud.
|
|
80
|
+
sig do
|
|
81
|
+
params(columns: T.untyped, null: T.untyped, col_sep: T.untyped, quote_char: T.untyped,
|
|
82
|
+
encoding: T.untyped, liberal_parsing: T::Boolean).void
|
|
83
|
+
end
|
|
84
|
+
def initialize(columns: nil, null: nil, col_sep: ",", quote_char: '"',
|
|
85
|
+
encoding: DEFAULT_ENCODING, liberal_parsing: true)
|
|
86
|
+
@columns = T.let(columns!(columns), T.nilable(T::Array[Symbol]))
|
|
87
|
+
@nulls = T.let(nulls!(null), T::Array[String])
|
|
88
|
+
@col_sep = T.let(col_sep.to_s, String)
|
|
89
|
+
@quote_char = T.let(quote_char.to_s, String)
|
|
90
|
+
@encoding = T.let(encoding!(encoding), Encoding)
|
|
91
|
+
@liberal_parsing = T.let(liberal_parsing, T::Boolean)
|
|
92
|
+
freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A pass over one payload. Takes the bytes as a String, which is what
|
|
96
|
+
# Sources::Base hands #parse.
|
|
97
|
+
sig { params(payload: T.untyped).returns(Reader) }
|
|
98
|
+
def read(payload) = Reader.new(table: self, payload: payload)
|
|
99
|
+
|
|
100
|
+
# Whether the file names its own columns.
|
|
101
|
+
sig { returns(T::Boolean) }
|
|
102
|
+
def headers? = columns.nil?
|
|
103
|
+
|
|
104
|
+
# Zips a row's values against the column names. Extra values are dropped
|
|
105
|
+
# and missing ones are nil; the Reader has already warned about both.
|
|
106
|
+
sig { params(names: T::Array[Symbol], values: T::Array[T.untyped]).returns(T::Hash[Symbol, T.nilable(String)]) }
|
|
107
|
+
def coerce(names, values)
|
|
108
|
+
names.each_with_index.to_h { |name, index| [name, value(values[index])] }.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
112
|
+
def csv_options
|
|
113
|
+
{ col_sep: col_sep, quote_char: quote_char, headers: false,
|
|
114
|
+
liberal_parsing: @liberal_parsing, skip_blanks: true }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# What to call this format in an error message, so a complaint about
|
|
118
|
+
# OFAC's file says "CSV" rather than "delimited text".
|
|
119
|
+
sig { returns(String) }
|
|
120
|
+
def col_sep_name = SEPARATOR_NAMES.fetch(col_sep, "delimited text")
|
|
121
|
+
|
|
122
|
+
sig { returns(String) }
|
|
123
|
+
def inspect
|
|
124
|
+
declared = columns
|
|
125
|
+
shape = declared.nil? ? "headers from file" : "#{declared.size} columns"
|
|
126
|
+
"#<#{self.class} #{col_sep_name} #{shape}#{" null=#{nulls.first.inspect}" if nulls.any?}>"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
sig { params(value: T.untyped).returns(T.nilable(T::Array[Symbol])) }
|
|
132
|
+
def columns!(value)
|
|
133
|
+
return nil if value.nil?
|
|
134
|
+
|
|
135
|
+
names = column_names!(value)
|
|
136
|
+
duplicated = names.tally.select { |_, count| count > 1 }.keys
|
|
137
|
+
raise InvalidArgument, "duplicate column name(s): #{duplicated.join(", ")}" if duplicated.any?
|
|
138
|
+
|
|
139
|
+
names.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sig { params(value: T.untyped).returns(T::Array[Symbol]) }
|
|
143
|
+
def column_names!(value)
|
|
144
|
+
raise InvalidArgument, "columns must be an Array of names, got #{value.inspect}" unless value.is_a?(Array)
|
|
145
|
+
raise InvalidArgument, "columns cannot be empty; pass nil to read them from the file's header" if value.empty?
|
|
146
|
+
|
|
147
|
+
value.map { |name| name.to_s.strip.to_sym }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Parsers
|
|
8
|
+
# The two questions every format toolkit has to answer about a publisher's
|
|
9
|
+
# bytes, regardless of whether they arrive as rows or as elements: what
|
|
10
|
+
# encoding they are in, and what the publisher writes where it means
|
|
11
|
+
# nothing.
|
|
12
|
+
#
|
|
13
|
+
# Included into the description objects -- DelimitedTable, XmlRecords --
|
|
14
|
+
# rather than into the readers, because both are properties of the *file*
|
|
15
|
+
# that an adapter declares once and reuses for every sync.
|
|
16
|
+
#
|
|
17
|
+
# (Not to be confused with `Sources::Definition#format`, which is the
|
|
18
|
+
# publisher-facing label -- :csv, :xml -- that a CLI prints. This is the
|
|
19
|
+
# machinery behind reading either one.)
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
module Format
|
|
23
|
+
extend T::Sig
|
|
24
|
+
extend T::Helpers
|
|
25
|
+
|
|
26
|
+
abstract!
|
|
27
|
+
|
|
28
|
+
# Included into DelimitedTable and XmlRecords, both of which are ordinary
|
|
29
|
+
# objects; this is what lets `raise` and `Array()` resolve here.
|
|
30
|
+
requires_ancestor { Kernel }
|
|
31
|
+
|
|
32
|
+
DEFAULT_ENCODING = T.let(Encoding::UTF_8, Encoding)
|
|
33
|
+
|
|
34
|
+
# A UTF-8 BOM left in place becomes part of the first thing parsed: the
|
|
35
|
+
# first column name of a headered CSV, or the `<?xml` of a document that
|
|
36
|
+
# then does not start with `<?xml`. Neither publisher meant to send it.
|
|
37
|
+
BOM = T.let("", String)
|
|
38
|
+
|
|
39
|
+
# Both are the including class's to hold, because both are assigned by
|
|
40
|
+
# its constructor from what an adapter declared about the file. Declaring
|
|
41
|
+
# them abstract here rather than reading the instance variables directly
|
|
42
|
+
# is what lets this module say what it needs of an includer instead of
|
|
43
|
+
# reaching into one.
|
|
44
|
+
sig { abstract.returns(T::Array[String]) }
|
|
45
|
+
def nulls; end
|
|
46
|
+
|
|
47
|
+
sig { abstract.returns(Encoding) }
|
|
48
|
+
def encoding; end
|
|
49
|
+
|
|
50
|
+
# What a caller declared as null, resolved. Public because an adapter
|
|
51
|
+
# joining files by hand needs the same rule the reader applies.
|
|
52
|
+
#
|
|
53
|
+
# table.value("-0- ") # => nil
|
|
54
|
+
# table.value(" ") # => nil
|
|
55
|
+
# table.value(" CUBA") # => "CUBA"
|
|
56
|
+
sig { params(raw: T.untyped).returns(T.nilable(String)) }
|
|
57
|
+
def value(raw)
|
|
58
|
+
string = raw.to_s.strip
|
|
59
|
+
return nil if string.empty? || nulls.include?(string)
|
|
60
|
+
|
|
61
|
+
-string
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The payload as a String in UTF-8, and whether anything had to be
|
|
65
|
+
# replaced to get there.
|
|
66
|
+
#
|
|
67
|
+
# Decoding never raises: a byte that is not valid in the declared
|
|
68
|
+
# encoding becomes U+FFFD and the caller reports it, because losing one
|
|
69
|
+
# character of one address is a far better outcome than refusing to load
|
|
70
|
+
# the list. OFAC serves Windows-1252 and the UN serves UTF-8, and neither
|
|
71
|
+
# declares it in a header we can trust, which is why the encoding is
|
|
72
|
+
# something the adapter states.
|
|
73
|
+
sig { params(payload: T.untyped).returns([String, T::Boolean]) }
|
|
74
|
+
def decode(payload)
|
|
75
|
+
string = payload.to_s.dup.force_encoding(encoding)
|
|
76
|
+
return [string.delete_prefix(BOM), false] if string.valid_encoding? && encoding == DEFAULT_ENCODING
|
|
77
|
+
|
|
78
|
+
replaced = !string.valid_encoding?
|
|
79
|
+
utf8 = string.encode(DEFAULT_ENCODING, invalid: :replace, undef: :replace, replace: "�")
|
|
80
|
+
[utf8.delete_prefix(BOM), replaced]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { returns(String) }
|
|
84
|
+
def invalid_bytes_message
|
|
85
|
+
"payload contains bytes that are not valid #{encoding}; they were replaced with U+FFFD"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Accepts one sentinel or several: a publisher that writes both "-0-" and
|
|
91
|
+
# "N/A" is not unusual, and an adapter should be able to say so once.
|
|
92
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
93
|
+
def nulls!(value)
|
|
94
|
+
Array(value).map { |sentinel| -sentinel.to_s.strip }.reject(&:empty?).uniq.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
sig { params(value: T.untyped).returns(Encoding) }
|
|
98
|
+
def encoding!(value)
|
|
99
|
+
return value if value.is_a?(Encoding)
|
|
100
|
+
|
|
101
|
+
Encoding.find(value.to_s)
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
raise InvalidArgument, "unknown encoding #{value.inspect}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "set"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Parsers
|
|
10
|
+
# Joins one primary file to any number of child files on a shared column.
|
|
11
|
+
#
|
|
12
|
+
# Sanctions publishers routinely split one logical record across several
|
|
13
|
+
# files. OFAC is the extreme case: an entity's name is in SDN.CSV, its
|
|
14
|
+
# aliases are in ALT.CSV and its addresses are in ADD.CSV, and all three
|
|
15
|
+
# are keyed on `ent_num`. None of the three means anything alone.
|
|
16
|
+
#
|
|
17
|
+
# join = ActiveSanction::Parsers::Join.new(
|
|
18
|
+
# on: :ent_num, aliases: ALT.read(raw[:alt]), addresses: ADD.read(raw[:add])
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# join.each(SDN.read(raw[:sdn])) do |row, related|
|
|
22
|
+
# related[:aliases] # => [Row, ...] -- always an Array, never nil
|
|
23
|
+
# related[:addresses] # => [Row, ...]
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# ### What streams and what does not
|
|
27
|
+
#
|
|
28
|
+
# The primary file streams: one row at a time, and the caller decides what
|
|
29
|
+
# to keep. The child files are indexed, which means they are held in memory
|
|
30
|
+
# for the length of the join.
|
|
31
|
+
#
|
|
32
|
+
# That asymmetry is not a shortcut, it is the only honest option. A join
|
|
33
|
+
# can stream both sides only if both are sorted on the key, and a
|
|
34
|
+
# publisher's sort order is not something to bet a parse on -- OFAC's files
|
|
35
|
+
# happen to arrive sorted today, and nothing says they will next quarter. So
|
|
36
|
+
# the smaller side is indexed and the larger side streams: for OFAC that is
|
|
37
|
+
# ~45k child rows resident while 19,321 primary rows pass through, which is
|
|
38
|
+
# a few tens of megabytes and entirely affordable. A source whose child
|
|
39
|
+
# files are genuinely too large for that wants a different strategy, and
|
|
40
|
+
# should say so rather than discovering it here.
|
|
41
|
+
#
|
|
42
|
+
# ### Orphans
|
|
43
|
+
#
|
|
44
|
+
# A child row whose key matches no primary row is dropped and counted. It
|
|
45
|
+
# is worth counting: a nonzero orphan count after a sync usually means the
|
|
46
|
+
# three files were downloaded at different moments and do not describe the
|
|
47
|
+
# same version of the list, which is a data problem no amount of careful
|
|
48
|
+
# parsing fixes.
|
|
49
|
+
class Join
|
|
50
|
+
extend T::Sig
|
|
51
|
+
|
|
52
|
+
# `warnings` gathers every complaint from every file in the join, primary
|
|
53
|
+
# first, and `orphans` counts the child rows that matched nothing. Both
|
|
54
|
+
# are populated by #each, since that is when the files are actually read.
|
|
55
|
+
sig { returns(Symbol) }
|
|
56
|
+
attr_reader :on
|
|
57
|
+
|
|
58
|
+
# The child readers, by the name the caller gave each one; that name is
|
|
59
|
+
# what #each yields them back under.
|
|
60
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
61
|
+
attr_reader :children
|
|
62
|
+
|
|
63
|
+
sig { returns(T::Hash[Symbol, Integer]) }
|
|
64
|
+
attr_reader :orphans
|
|
65
|
+
|
|
66
|
+
sig { returns(T::Array[Warning]) }
|
|
67
|
+
attr_reader :warnings
|
|
68
|
+
|
|
69
|
+
sig { params(on: T.untyped, children: T.untyped).void }
|
|
70
|
+
def initialize(on:, **children)
|
|
71
|
+
raise InvalidArgument, "a join needs at least one child reader" if children.empty?
|
|
72
|
+
|
|
73
|
+
@on = T.let(on.to_sym, Symbol)
|
|
74
|
+
@children = T.let(children, T::Hash[Symbol, T.untyped])
|
|
75
|
+
@orphans = T.let({}, T::Hash[Symbol, Integer])
|
|
76
|
+
@warnings = T.let([], T::Array[Warning])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Yields each primary row with its related child rows. Returns an
|
|
80
|
+
# Enumerator without a block, so `join.each(rows).lazy` works.
|
|
81
|
+
#
|
|
82
|
+
# Re-running rebuilds the indexes rather than reusing them, because the
|
|
83
|
+
# readers reset their own warnings on re-enumeration and a join that kept
|
|
84
|
+
# a stale index would report a first pass's problems against a second
|
|
85
|
+
# pass's rows.
|
|
86
|
+
sig { params(primary: T.untyped, block: T.untyped).returns(T.untyped) }
|
|
87
|
+
def each(primary, &block)
|
|
88
|
+
return enum_for(:each, primary) unless block
|
|
89
|
+
|
|
90
|
+
indexes = build_indexes
|
|
91
|
+
matched = Hash.new { |hash, name| hash[name] = Set.new }
|
|
92
|
+
primary.each { |row| block.call(row, related(indexes, matched, row.fetch(on))) }
|
|
93
|
+
@warnings = collect_warnings(primary)
|
|
94
|
+
count_orphans(indexes, matched)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { returns(String) }
|
|
99
|
+
def inspect = "#<#{self.class} on=#{on.inspect} children=#{children.keys.join(", ")}>"
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
104
|
+
def build_indexes
|
|
105
|
+
children.transform_values { |reader| index(reader) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Rows filed under their key, in the order the publisher wrote them --
|
|
109
|
+
# OFAC's `alt_num` ordering is the closest thing its aliases have to a
|
|
110
|
+
# priority, so it must survive the join.
|
|
111
|
+
sig { params(reader: T.untyped).returns(T::Hash[T.untyped, T::Array[T.untyped]]) }
|
|
112
|
+
def index(reader)
|
|
113
|
+
table = Hash.new { |hash, key| hash[key] = [] }
|
|
114
|
+
reader.each do |row|
|
|
115
|
+
key = row.fetch(on)
|
|
116
|
+
next if key.nil?
|
|
117
|
+
|
|
118
|
+
table[key] << row
|
|
119
|
+
end
|
|
120
|
+
table
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Also records that the key was seen, which is what makes a child row
|
|
124
|
+
# left over at the end of the pass an orphan rather than just unvisited.
|
|
125
|
+
sig do
|
|
126
|
+
params(indexes: T::Hash[Symbol, T.untyped], matched: T.untyped, key: T.untyped)
|
|
127
|
+
.returns(T::Hash[Symbol, T::Array[T.untyped]])
|
|
128
|
+
end
|
|
129
|
+
def related(indexes, matched, key)
|
|
130
|
+
indexes.to_h do |name, table|
|
|
131
|
+
matched[name] << key unless key.nil?
|
|
132
|
+
[name, key.nil? ? [] : table.fetch(key, [])]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { params(indexes: T::Hash[Symbol, T.untyped], matched: T.untyped).void }
|
|
137
|
+
def count_orphans(indexes, matched)
|
|
138
|
+
@orphans = indexes.to_h do |name, table|
|
|
139
|
+
[name, table.except(*matched[name]).values.sum(&:size)]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { params(primary: T.untyped).returns(T::Array[Warning]) }
|
|
144
|
+
def collect_warnings(primary)
|
|
145
|
+
(primary.warnings + children.values.flat_map(&:warnings)).freeze
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|