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,157 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "rexml/parsers/pullparser"
|
|
7
|
+
require "rexml/text"
|
|
8
|
+
require "active_sanction/parsers/xml_records/backends"
|
|
9
|
+
require "active_sanction/parsers/xml_records/builder"
|
|
10
|
+
|
|
11
|
+
module ActiveSanction
|
|
12
|
+
module Parsers
|
|
13
|
+
class XmlRecords
|
|
14
|
+
module Backends
|
|
15
|
+
# The default backend: stdlib REXML, pulled one event at a time.
|
|
16
|
+
#
|
|
17
|
+
# Chosen as the default because it is everywhere Ruby is, needs no
|
|
18
|
+
# build step, and gives every installation the same answer -- see
|
|
19
|
+
# Backends for why that last property is the one that matters for a
|
|
20
|
+
# list whose parsed content gets checksummed into an audit trail.
|
|
21
|
+
#
|
|
22
|
+
# It is the slower of the two. On the UN's 2.2 MB consolidated list
|
|
23
|
+
# that is a fraction of a second on a job that already spent longer
|
|
24
|
+
# downloading the file; a host parsing OFAC's 126 MB advanced XML on a
|
|
25
|
+
# schedule is the case that should switch to libxml2, and can.
|
|
26
|
+
class Rexml
|
|
27
|
+
extend T::Sig
|
|
28
|
+
|
|
29
|
+
# Nothing to check: `rexml` is a declared dependency of this gem.
|
|
30
|
+
sig { returns(T::Boolean) }
|
|
31
|
+
def self.available? = true
|
|
32
|
+
|
|
33
|
+
sig { returns(T.nilable(String)) }
|
|
34
|
+
def self.unavailable_reason = nil
|
|
35
|
+
|
|
36
|
+
# The document element's attributes, which is where these publishers
|
|
37
|
+
# put the version of the list.
|
|
38
|
+
sig { returns(T.nilable(T::Hash[String, String])) }
|
|
39
|
+
attr_reader :root
|
|
40
|
+
|
|
41
|
+
sig { params(table: XmlRecords, xml: String).void }
|
|
42
|
+
def initialize(table:, xml:)
|
|
43
|
+
@table = T.let(table, XmlRecords)
|
|
44
|
+
@xml = T.let(xml, String)
|
|
45
|
+
@root = T.let(nil, T.nilable(T::Hash[String, String]))
|
|
46
|
+
@depth = T.let(0, Integer)
|
|
47
|
+
@builder = T.let(Builder.new(table: table), Builder)
|
|
48
|
+
@newlines = T.let(nil, T.nilable(T::Array[Integer]))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(block: T.proc.params(record: Record).void).void }
|
|
52
|
+
def each_record(&block)
|
|
53
|
+
parser = ::REXML::Parsers::PullParser.new(@xml)
|
|
54
|
+
pull(parser, &block)
|
|
55
|
+
truncated! if @builder.open? || @depth.positive?
|
|
56
|
+
rescue ::REXML::ParseException => e
|
|
57
|
+
raise malformed(e, parser)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
sig { params(parser: T.untyped, block: T.proc.params(record: Record).void).void }
|
|
63
|
+
def pull(parser, &block)
|
|
64
|
+
handle(parser.pull, parser, &block) while parser.has_next?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { params(event: T.untyped, parser: T.untyped, block: T.proc.params(record: Record).void).void }
|
|
68
|
+
def handle(event, parser, &block)
|
|
69
|
+
case event.event_type
|
|
70
|
+
when :start_element then start(event[0], event[1], parser)
|
|
71
|
+
when :end_element then close(&block)
|
|
72
|
+
when :text then text(::REXML::Text.unnormalize(event[0]))
|
|
73
|
+
when :cdata then text(event[0])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(string: String).void }
|
|
78
|
+
def text(string)
|
|
79
|
+
@builder.text(string) if @builder.open?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The first element in the document is its root, whose attributes are
|
|
83
|
+
# where these publishers put the version of the list -- the UN's
|
|
84
|
+
# `dateGenerated`. Captured whether or not anything else parses.
|
|
85
|
+
sig { params(name: T.untyped, attributes: T.untyped, parser: T.untyped).void }
|
|
86
|
+
def start(name, attributes, parser)
|
|
87
|
+
local = Backends.local_name(name)
|
|
88
|
+
attrs = Backends.local_attributes(attributes)
|
|
89
|
+
@root ||= attrs
|
|
90
|
+
@depth += 1
|
|
91
|
+
return unless @builder.open? || @table.record?(local)
|
|
92
|
+
|
|
93
|
+
@builder.enter(local, attrs, line_at(parser))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { params(block: T.proc.params(record: Record).void).void }
|
|
97
|
+
def close(&block)
|
|
98
|
+
@depth -= 1
|
|
99
|
+
return unless @builder.open?
|
|
100
|
+
|
|
101
|
+
record = @builder.leave
|
|
102
|
+
block.call(record) if record
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# REXML stops at the end of the payload without complaint when a tag
|
|
106
|
+
# was never closed, so a download cut in half looks like a short list
|
|
107
|
+
# rather than like a problem. Elements still open at the end are the
|
|
108
|
+
# evidence, and it is worth raising on: a truncated list that loads
|
|
109
|
+
# quietly is how a screening run silently stops covering people.
|
|
110
|
+
#
|
|
111
|
+
# The depth counter is what catches the commoner half of it. A
|
|
112
|
+
# download truncated between two records leaves the builder closed
|
|
113
|
+
# and only the document element unfinished, so counting starts and
|
|
114
|
+
# ends is the only thing that can tell that list from a complete one.
|
|
115
|
+
sig { void }
|
|
116
|
+
def truncated!
|
|
117
|
+
raise MalformedDocument.new("the document ended inside an unclosed element", line: nil)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
sig { params(error: T.untyped, parser: T.untyped).returns(MalformedDocument) }
|
|
121
|
+
def malformed(error, parser)
|
|
122
|
+
MalformedDocument.new(error.message.lines.first.to_s.strip, line: line_at(parser))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# REXML reports a byte offset rather than a line, so the offsets of
|
|
126
|
+
# every newline are indexed once per pass and binary-searched. The
|
|
127
|
+
# index is built over the bytes, not the characters, because that is
|
|
128
|
+
# what the offset counts and the two part company on the first
|
|
129
|
+
# accented name -- of which these lists have thousands.
|
|
130
|
+
sig { params(parser: T.untyped).returns(T.nilable(Integer)) }
|
|
131
|
+
def line_at(parser)
|
|
132
|
+
offset = parser&.source&.position
|
|
133
|
+
return nil unless offset.is_a?(Integer)
|
|
134
|
+
|
|
135
|
+
(newlines.bsearch_index { |at| at >= offset } || newlines.size) + 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sig { returns(T::Array[Integer]) }
|
|
139
|
+
def newlines
|
|
140
|
+
@newlines ||= begin
|
|
141
|
+
bytes = @xml.b
|
|
142
|
+
offsets = T.let([], T::Array[Integer])
|
|
143
|
+
at = T.let(bytes.index("\n"), T.nilable(Integer))
|
|
144
|
+
while at
|
|
145
|
+
offsets << at
|
|
146
|
+
at = bytes.index("\n", at + 1)
|
|
147
|
+
end
|
|
148
|
+
offsets
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
register(:rexml, Rexml)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Parsers
|
|
8
|
+
class XmlRecords
|
|
9
|
+
# The XML libraries this toolkit knows how to drive, and the seam a host
|
|
10
|
+
# application swaps one for another through.
|
|
11
|
+
#
|
|
12
|
+
# ActiveSanction.configure { |c| c.xml_backend = :nokogiri }
|
|
13
|
+
#
|
|
14
|
+
# ### The contract
|
|
15
|
+
#
|
|
16
|
+
# A backend is a class that answers `.available?` and, per pass, is built
|
|
17
|
+
# with `table:` and the decoded `xml:` and answers two things:
|
|
18
|
+
#
|
|
19
|
+
# backend.each_record { |record| ... } # yields XmlRecords::Record
|
|
20
|
+
# backend.root # the document element's attributes
|
|
21
|
+
#
|
|
22
|
+
# It raises XmlRecords::MalformedDocument, with a line where its library
|
|
23
|
+
# supplies one, when the payload stops being XML. Everything else --
|
|
24
|
+
# paths, null resolution, warnings, Enumerable -- lives above it and is
|
|
25
|
+
# written once, so a backend is only ever event translation. Registering
|
|
26
|
+
# another is a public act:
|
|
27
|
+
#
|
|
28
|
+
# ActiveSanction::Parsers::XmlRecords::Backends.register(:ox, MyOxBackend)
|
|
29
|
+
#
|
|
30
|
+
# ### Why the default is not chosen by what happens to be loaded
|
|
31
|
+
#
|
|
32
|
+
# It would be easy to prefer Nokogiri whenever a host has it, and #15
|
|
33
|
+
# first asked for exactly that. It is the wrong default here. Snapshot
|
|
34
|
+
# checksums a list's parsed content, and a screening decision is supposed
|
|
35
|
+
# to be re-derivable months later in front of an examiner. If the parser
|
|
36
|
+
# is picked by whether Rails happened to load Nokogiri, then two installs
|
|
37
|
+
# of the same gem screening the same file can checksum apart, and the
|
|
38
|
+
# thing that changed is invisible in every artifact either one keeps.
|
|
39
|
+
#
|
|
40
|
+
# So the default is REXML on every installation -- stdlib, no build step,
|
|
41
|
+
# and the same answer everywhere -- and a host that wants libxml2's speed
|
|
42
|
+
# says so out loud, in one line, where a reviewer can see it.
|
|
43
|
+
#
|
|
44
|
+
# @api private
|
|
45
|
+
module Backends
|
|
46
|
+
DEFAULT = T.let(:rexml, Symbol)
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
extend T::Sig
|
|
50
|
+
|
|
51
|
+
# A backend is a class answering the four methods the comment above
|
|
52
|
+
# names, which is why these signatures say `T.untyped` where one
|
|
53
|
+
# goes: registering another is a public act, and an out-of-repo
|
|
54
|
+
# backend is not a subclass of anything here.
|
|
55
|
+
|
|
56
|
+
sig { params(name: T.untyped, backend: T.untyped).returns(T.untyped) }
|
|
57
|
+
def register(name, backend)
|
|
58
|
+
registry[name.to_sym] = backend
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { params(name: T.untyped).returns(T.untyped) }
|
|
62
|
+
def resolve(name)
|
|
63
|
+
backend = registry.fetch(name.to_sym) do
|
|
64
|
+
raise InvalidArgument,
|
|
65
|
+
"unknown XML backend #{name.inspect}. Registered: #{registry.keys.join(", ")}"
|
|
66
|
+
end
|
|
67
|
+
return backend if backend.available?
|
|
68
|
+
|
|
69
|
+
raise InvalidArgument, "the #{name.inspect} XML backend cannot run here: #{backend.unavailable_reason}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Every registered backend that could actually run in this process.
|
|
73
|
+
sig { returns(T::Array[Symbol]) }
|
|
74
|
+
def available = registry.select { |_, backend| backend.available? }.keys
|
|
75
|
+
|
|
76
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
77
|
+
def registry
|
|
78
|
+
@registry ||= T.let({}, T.nilable(T::Hash[Symbol, T.untyped]))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# An element name with any namespace prefix removed, so a list that
|
|
82
|
+
# grows an `xmlns` next quarter does not stop parsing. The prefix is
|
|
83
|
+
# discarded rather than resolved: none of these publishers uses two
|
|
84
|
+
# namespaces in one document, and an adapter written against
|
|
85
|
+
# `INDIVIDUAL` should not have to be rewritten as `un:INDIVIDUAL`.
|
|
86
|
+
sig { params(name: T.untyped).returns(String) }
|
|
87
|
+
def local_name(name)
|
|
88
|
+
string = name.to_s
|
|
89
|
+
index = string.rindex(":")
|
|
90
|
+
index.nil? ? string : string[(index + 1)..]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Attribute keys are stripped the same way, so `xsi:type` is read as
|
|
94
|
+
# `type` and a default-namespaced document reads like a plain one.
|
|
95
|
+
sig { params(attributes: T.untyped).returns(T::Hash[String, String]) }
|
|
96
|
+
def local_attributes(attributes)
|
|
97
|
+
(attributes || {}).to_h { |key, value| [local_name(key), value.to_s] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/parsers/xml_records/record"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Parsers
|
|
10
|
+
class XmlRecords
|
|
11
|
+
# Turns a stream of parser events into one Record at a time.
|
|
12
|
+
#
|
|
13
|
+
# This is the part of XML parsing that is the same whichever library is
|
|
14
|
+
# doing it: enter an element, collect its text, leave it into its parent,
|
|
15
|
+
# and hand back a finished Record when the record element itself closes.
|
|
16
|
+
# A backend translates its library's events into these four calls and
|
|
17
|
+
# writes no tree-building code of its own, which is what keeps a second
|
|
18
|
+
# backend small enough to be worth having.
|
|
19
|
+
#
|
|
20
|
+
# It is also what makes the toolkit streaming rather than DOM-based: the
|
|
21
|
+
# stack only ever holds one record's depth, so a 126 MB file costs the
|
|
22
|
+
# memory of its largest single record and not of itself.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
class Builder
|
|
26
|
+
extend T::Sig
|
|
27
|
+
|
|
28
|
+
Frame = Struct.new(:name, :attributes, :text, :children, :line)
|
|
29
|
+
private_constant :Frame
|
|
30
|
+
|
|
31
|
+
sig { params(table: XmlRecords).void }
|
|
32
|
+
def initialize(table:)
|
|
33
|
+
@table = T.let(table, XmlRecords)
|
|
34
|
+
@stack = T.let([], T::Array[T.untyped])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether we are inside a record. A backend asks this to decide whether
|
|
38
|
+
# an element is part of a record or is still the file's scaffolding.
|
|
39
|
+
sig { returns(T::Boolean) }
|
|
40
|
+
def open? = !@stack.empty?
|
|
41
|
+
|
|
42
|
+
sig { params(name: T.untyped, attributes: T.untyped, line: T.nilable(Integer)).void }
|
|
43
|
+
def enter(name, attributes = {}, line = nil)
|
|
44
|
+
@stack << Frame.new(name, attributes, nil, [], line)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Appended rather than replaced: a parser is free to split one run of
|
|
48
|
+
# text across several events, and libxml2 does exactly that around
|
|
49
|
+
# entity references.
|
|
50
|
+
sig { params(string: String).void }
|
|
51
|
+
def text(string)
|
|
52
|
+
frame = @stack.last
|
|
53
|
+
return if frame.nil?
|
|
54
|
+
|
|
55
|
+
frame.text = frame.text ? frame.text + string : +string
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Closes the innermost element. Returns the finished Record when that
|
|
59
|
+
# was the record element itself, and nil while still inside one.
|
|
60
|
+
sig { returns(T.nilable(Record)) }
|
|
61
|
+
def leave
|
|
62
|
+
frame = @stack.pop
|
|
63
|
+
return nil if frame.nil?
|
|
64
|
+
|
|
65
|
+
record = build(frame)
|
|
66
|
+
return record if @stack.empty?
|
|
67
|
+
|
|
68
|
+
@stack.last.children << record
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A void element -- `<QUALITY/>` -- which libxml2 reports as a start
|
|
73
|
+
# with no matching end.
|
|
74
|
+
sig { params(name: T.untyped, attributes: T.untyped, line: T.nilable(Integer)).returns(T.nilable(Record)) }
|
|
75
|
+
def void(name, attributes = {}, line = nil)
|
|
76
|
+
enter(name, attributes, line)
|
|
77
|
+
leave
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
sig { params(frame: T.untyped).returns(Record) }
|
|
83
|
+
def build(frame)
|
|
84
|
+
Record.new(table: @table, name: frame.name, attributes: frame.attributes,
|
|
85
|
+
text: frame.text, children: frame.children, line: frame.line)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Parsers
|
|
8
|
+
class XmlRecords
|
|
9
|
+
# One pass over one payload. Enumerable, and lazy: records are yielded as
|
|
10
|
+
# they are parsed rather than collected, so the memory cost of a pass is
|
|
11
|
+
# one record plus whatever the caller keeps -- which is the whole point
|
|
12
|
+
# of the toolkit, and the reason OFAC's 126 MB advanced XML can drop in
|
|
13
|
+
# later without a redesign.
|
|
14
|
+
#
|
|
15
|
+
# reader = table.read(bytes)
|
|
16
|
+
# reader.each { |record| record["DATAID"] }
|
|
17
|
+
# reader.root # => {"dateGenerated" => "2026-08-28T00:00:00"}
|
|
18
|
+
# reader.warnings # => what could not be read
|
|
19
|
+
#
|
|
20
|
+
# Re-enumerating re-parses from the start, 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: Record } }
|
|
31
|
+
|
|
32
|
+
sig { returns(XmlRecords) }
|
|
33
|
+
attr_reader :table
|
|
34
|
+
|
|
35
|
+
# The records this pass could not read. Reset by each pass -- see the
|
|
36
|
+
# class comment.
|
|
37
|
+
sig { returns(T::Array[Warning]) }
|
|
38
|
+
attr_reader :warnings
|
|
39
|
+
|
|
40
|
+
sig { params(table: XmlRecords, payload: T.untyped).void }
|
|
41
|
+
def initialize(table:, payload:)
|
|
42
|
+
@table = T.let(table, XmlRecords)
|
|
43
|
+
@payload = T.let(payload, T.untyped)
|
|
44
|
+
@warnings = T.let([], T::Array[Warning])
|
|
45
|
+
@backend = T.let(nil, T.untyped)
|
|
46
|
+
@count = T.let(0, Integer)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { override.params(block: T.nilable(T.proc.params(record: Record).void)).returns(T.untyped) }
|
|
50
|
+
def each(&block)
|
|
51
|
+
return enum_for(:each) unless block
|
|
52
|
+
|
|
53
|
+
@warnings = []
|
|
54
|
+
@backend = table.backend.new(table: table, xml: decoded)
|
|
55
|
+
read(&block)
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The document element's attributes. The UN puts the list's generation
|
|
60
|
+
# date there and nowhere else, and it is the version string an examiner
|
|
61
|
+
# recognises, so it has to be reachable without the adapter reaching
|
|
62
|
+
# around the toolkit for it.
|
|
63
|
+
#
|
|
64
|
+
# Parsing far enough to answer costs only the bytes up to the first
|
|
65
|
+
# record, so asking before a pass is cheap; asking after one is free.
|
|
66
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
67
|
+
def root
|
|
68
|
+
each.first if @backend.nil?
|
|
69
|
+
@backend&.root || {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Every record, in memory. The convenience the small lists get to use;
|
|
73
|
+
# anything list-sized should stay with #each.
|
|
74
|
+
sig { returns(T::Array[Record]) }
|
|
75
|
+
def to_a = each.to_a
|
|
76
|
+
|
|
77
|
+
sig { returns(String) }
|
|
78
|
+
def inspect = "#<#{self.class} #{table.record_names.join(", ")} via #{table.backend}>"
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# A document that stops being XML part-way is not treated the way a
|
|
83
|
+
# malformed CSV row is, because it cannot be: XML has no row boundary
|
|
84
|
+
# to resynchronise on, so everything after the break is unreadable no
|
|
85
|
+
# matter how it is handled. What can be saved is everything before it,
|
|
86
|
+
# and that is what happens -- the records already yielded stand, and
|
|
87
|
+
# the break is recorded as a warning naming the line.
|
|
88
|
+
#
|
|
89
|
+
# Failing before the first record is the different case, and raises:
|
|
90
|
+
# nothing was salvaged, and the overwhelmingly likely cause is that the
|
|
91
|
+
# payload was never XML. An HTML error page saved under a .xml URL is
|
|
92
|
+
# the classic, and reporting that as "0 records, one warning" would let
|
|
93
|
+
# a sync succeed at screening against nothing.
|
|
94
|
+
sig { params(block: T.proc.params(record: Record).void).void }
|
|
95
|
+
def read(&block)
|
|
96
|
+
@count = 0
|
|
97
|
+
@backend.each_record do |record|
|
|
98
|
+
@count += 1
|
|
99
|
+
block.call(record)
|
|
100
|
+
end
|
|
101
|
+
rescue MalformedDocument => e
|
|
102
|
+
give_up!(e) if @count.zero?
|
|
103
|
+
record(e.line, "the document ended after #{@count} record(s): #{e.message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sig { returns(String) }
|
|
107
|
+
def decoded
|
|
108
|
+
string, replaced = table.decode(@payload)
|
|
109
|
+
record(nil, table.invalid_bytes_message) if replaced
|
|
110
|
+
raise ParseError, "expected an XML document, got an empty payload" if string.strip.empty?
|
|
111
|
+
|
|
112
|
+
string
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { params(line: T.nilable(Integer), message: String).void }
|
|
116
|
+
def record(line, message)
|
|
117
|
+
@warnings << Warning.new(line: line, message: message)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The line the backend stopped on is carried through, because that is
|
|
121
|
+
# the whole difference between "this 25 MB file is not XML" and a
|
|
122
|
+
# complaint somebody can open an editor to.
|
|
123
|
+
sig { params(error: MalformedDocument).void }
|
|
124
|
+
def give_up!(error)
|
|
125
|
+
raise ParseError.new(
|
|
126
|
+
"no <#{table.record_names.join("> or <")}> element could be read before the document stopped " \
|
|
127
|
+
"being XML. This payload is almost certainly not the XML it was read as -- check the URL, " \
|
|
128
|
+
"and whether the publisher served an error page. #{error.message}", line: error.line
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Parsers
|
|
8
|
+
class XmlRecords
|
|
9
|
+
# One record element and everything under it: the UN's `<INDIVIDUAL>`,
|
|
10
|
+
# Canada's `<record>`. Fields are read by path relative to the record,
|
|
11
|
+
# and a nested element is itself a Record, which is what lets an adapter
|
|
12
|
+
# walk repeated children without knowing how the file was parsed.
|
|
13
|
+
#
|
|
14
|
+
# record.name # => "INDIVIDUAL"
|
|
15
|
+
# record["DATAID"] # => "6907993"
|
|
16
|
+
# record["INDIVIDUAL_DATE_OF_BIRTH/YEAR"]
|
|
17
|
+
# record.values("NATIONALITY/VALUE") # => ["Chad", "Sudan"]
|
|
18
|
+
# record.nodes("INDIVIDUAL_ALIAS") # => [Record, Record]
|
|
19
|
+
# record["@dateGenerated"] # an attribute, XPath-style
|
|
20
|
+
#
|
|
21
|
+
# ### Absent, empty, and blank are one answer
|
|
22
|
+
#
|
|
23
|
+
# An element that is missing, self-closing, or holds only whitespace all
|
|
24
|
+
# read as nil. That is not laziness about the difference; it is the only
|
|
25
|
+
# reading that survives the UN, which files placeholder aliases as
|
|
26
|
+
# `<INDIVIDUAL_ALIAS><QUALITY/><ALIAS_NAME/></INDIVIDUAL_ALIAS>` and means
|
|
27
|
+
# nothing at all by them. An adapter that had to distinguish the three
|
|
28
|
+
# would produce blank-valued Names for every one of those placeholders.
|
|
29
|
+
#
|
|
30
|
+
# ### Why #[] does not raise the way a CSV Row does
|
|
31
|
+
#
|
|
32
|
+
# DelimitedTable::Row raises on a column its table never declared, since
|
|
33
|
+
# a table's shape is fixed and an unknown name there is a typo. XML has
|
|
34
|
+
# no such shape: one `<INDIVIDUAL>` carries elements the next one omits,
|
|
35
|
+
# so an absent path is ordinary and #[] answers nil. #fetch is there for
|
|
36
|
+
# the field an adapter considers mandatory, and it names the record and
|
|
37
|
+
# what the record does carry when the field is missing.
|
|
38
|
+
class Record
|
|
39
|
+
extend T::Sig
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
SEPARATOR = T.let("/", String)
|
|
43
|
+
# @api private
|
|
44
|
+
ATTRIBUTE = T.let("@", String)
|
|
45
|
+
|
|
46
|
+
# @api private
|
|
47
|
+
UNSET = T.let(Object.new.freeze, Object)
|
|
48
|
+
private_constant :UNSET
|
|
49
|
+
|
|
50
|
+
# The element's own name, with any namespace prefix already removed by
|
|
51
|
+
# the backend -- see Backends.local_name.
|
|
52
|
+
sig { returns(String) }
|
|
53
|
+
attr_reader :name
|
|
54
|
+
|
|
55
|
+
sig { returns(T::Hash[String, T.untyped]) }
|
|
56
|
+
attr_reader :attributes
|
|
57
|
+
|
|
58
|
+
sig { returns(T::Array[Record]) }
|
|
59
|
+
attr_reader :children
|
|
60
|
+
|
|
61
|
+
# nil where the backend reports no position.
|
|
62
|
+
sig { returns(T.nilable(Integer)) }
|
|
63
|
+
attr_reader :line
|
|
64
|
+
|
|
65
|
+
sig do
|
|
66
|
+
params(table: XmlRecords, name: T.untyped, attributes: T::Hash[String, T.untyped], text: T.untyped,
|
|
67
|
+
children: T::Array[Record], line: T.nilable(Integer)).void
|
|
68
|
+
end
|
|
69
|
+
def initialize(table:, name:, attributes: {}, text: nil, children: [], line: nil)
|
|
70
|
+
@table = T.let(table, XmlRecords)
|
|
71
|
+
@name = T.let(-name.to_s, String)
|
|
72
|
+
@attributes = T.let(attributes.freeze, T::Hash[String, T.untyped])
|
|
73
|
+
@raw_text = T.let(text, T.untyped)
|
|
74
|
+
@children = T.let(children.freeze, T::Array[Record])
|
|
75
|
+
@line = T.let(line, T.nilable(Integer))
|
|
76
|
+
freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# This element's own text, with blanks and any declared null sentinel
|
|
80
|
+
# resolved to nil. Text belonging to child elements is not included.
|
|
81
|
+
sig { returns(T.nilable(String)) }
|
|
82
|
+
def text = table.value(@raw_text)
|
|
83
|
+
|
|
84
|
+
# The first value at `path`, or nil if nothing is there.
|
|
85
|
+
sig { params(path: T.untyped).returns(T.nilable(String)) }
|
|
86
|
+
def [](path) = values(path).first
|
|
87
|
+
|
|
88
|
+
# Every value at `path`, in document order, with blanks dropped. The
|
|
89
|
+
# answer to a repeated element: the UN files each nationality as its
|
|
90
|
+
# own `<NATIONALITY><VALUE>`.
|
|
91
|
+
sig { params(path: T.untyped).returns(T::Array[String]) }
|
|
92
|
+
def values(path)
|
|
93
|
+
steps, attribute = split(path)
|
|
94
|
+
nodes = descend(steps)
|
|
95
|
+
return nodes.filter_map { |node| node.attribute(attribute) } if attribute
|
|
96
|
+
|
|
97
|
+
nodes.filter_map(&:text)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The elements at `path`, as Records, whether or not they hold text --
|
|
101
|
+
# an adapter reading `<INDIVIDUAL_ADDRESS>` wants the node, not a value.
|
|
102
|
+
sig { params(path: T.untyped).returns(T::Array[Record]) }
|
|
103
|
+
def nodes(path)
|
|
104
|
+
steps, attribute = split(path)
|
|
105
|
+
raise InvalidArgument, "#nodes reads elements, not the attribute #{path.inspect}" if attribute
|
|
106
|
+
|
|
107
|
+
descend(steps)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sig { params(key: T.untyped).returns(T.nilable(String)) }
|
|
111
|
+
def attribute(key) = table.value(attributes[key.to_s])
|
|
112
|
+
|
|
113
|
+
# For a field the adapter treats as mandatory. Raises rather than
|
|
114
|
+
# letting a renamed element arrive downstream as a nil nobody notices.
|
|
115
|
+
sig { params(path: T.untyped, default: T.untyped).returns(T.untyped) }
|
|
116
|
+
def fetch(path, default = UNSET)
|
|
117
|
+
value = self[path]
|
|
118
|
+
return value unless value.nil?
|
|
119
|
+
return default unless default.equal?(UNSET)
|
|
120
|
+
|
|
121
|
+
raise MissingKey, "no value at #{path.inspect} in <#{name}>#{" on line #{line}" if line}. " \
|
|
122
|
+
"It carries: #{present.join(", ")}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sig { params(path: T.untyped).returns(T::Boolean) }
|
|
126
|
+
def null?(path) = self[path].nil?
|
|
127
|
+
|
|
128
|
+
# The child element names that actually carry something, which is what
|
|
129
|
+
# a #fetch failure has to print and what makes an unfamiliar list
|
|
130
|
+
# explorable from a console.
|
|
131
|
+
sig { returns(T::Array[String]) }
|
|
132
|
+
def present
|
|
133
|
+
names = children.select { |child| !child.text.nil? || child.children.any? }.map(&:name)
|
|
134
|
+
names.uniq
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
sig { returns(String) }
|
|
138
|
+
def to_s = text.to_s
|
|
139
|
+
|
|
140
|
+
sig { returns(String) }
|
|
141
|
+
def inspect = "#<#{self.class} <#{name}>#{" line=#{line}" if line} #{present.join(" ")}>"
|
|
142
|
+
|
|
143
|
+
protected
|
|
144
|
+
|
|
145
|
+
sig { returns(XmlRecords) }
|
|
146
|
+
attr_reader :table
|
|
147
|
+
|
|
148
|
+
sig { params(wanted: String).returns(T::Array[Record]) }
|
|
149
|
+
def children_named(wanted) = children.select { |child| child.name == wanted }
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Splits "INDIVIDUAL_ALIAS/ALIAS_NAME" into its steps, and peels off a
|
|
154
|
+
# trailing "@attr" as the attribute to read instead of the text.
|
|
155
|
+
sig { params(path: T.untyped).returns([T::Array[String], T.nilable(String)]) }
|
|
156
|
+
def split(path)
|
|
157
|
+
steps = path.to_s.split(SEPARATOR).map(&:strip).reject(&:empty?)
|
|
158
|
+
return [steps, nil] unless steps.last&.start_with?(ATTRIBUTE)
|
|
159
|
+
|
|
160
|
+
[steps[0..-2], steps.last.delete_prefix(ATTRIBUTE)]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# An empty path is the record itself, which is what makes `record["@id"]`
|
|
164
|
+
# and `record[""]` mean the obvious things.
|
|
165
|
+
sig { params(steps: T::Array[String]).returns(T::Array[Record]) }
|
|
166
|
+
def descend(steps)
|
|
167
|
+
steps.inject([self]) do |nodes, wanted|
|
|
168
|
+
nodes.flat_map { |node| node.children_named(wanted) }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|