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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. 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