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,291 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/parsers"
|
|
7
|
+
require "active_sanction/sources"
|
|
8
|
+
require "active_sanction/sources/base"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
module Sources
|
|
12
|
+
# Australia's Consolidated List: everybody the Foreign Minister has
|
|
13
|
+
# designated or declared under the Autonomous Sanctions Regulations 2011,
|
|
14
|
+
# together with every UN Security Council listing Australia gives effect to.
|
|
15
|
+
#
|
|
16
|
+
# snapshot = ActiveSanction::Sources[:australia_dfat].new.sync
|
|
17
|
+
#
|
|
18
|
+
# ### It is published as a spreadsheet, and as nothing else
|
|
19
|
+
#
|
|
20
|
+
# https://www.dfat.gov.au/sites/default/files/Australian_Sanctions_Consolidated_List.xlsx
|
|
21
|
+
#
|
|
22
|
+
# "Consolidated List" 11,163 rows, 19 columns, 1.3 MB
|
|
23
|
+
#
|
|
24
|
+
# One `.xlsx` file, one sheet. There is no CSV, no XML and no JSON: DFAT's
|
|
25
|
+
# Consolidated List page offers exactly one download, and this is it. So
|
|
26
|
+
# reading a spreadsheet is not a convenience here, it is the price of
|
|
27
|
+
# screening against Australian sanctions at all.
|
|
28
|
+
#
|
|
29
|
+
# That was the open question on this list, and the answer was not to take a
|
|
30
|
+
# dependency. Parsers::Spreadsheet reads the workbook with `zlib` and the
|
|
31
|
+
# XML toolkit the other five adapters already use -- an `.xlsx` being a ZIP
|
|
32
|
+
# of XML parts -- so this adapter costs a host nothing to install. See that
|
|
33
|
+
# class for what it does and does not read.
|
|
34
|
+
#
|
|
35
|
+
# ### The URL moved, and the old one still answers
|
|
36
|
+
#
|
|
37
|
+
# The path this gem was scoped against, `regulation8_consolidated.xlsx`,
|
|
38
|
+
# is gone. What is left in its place is a redirect to
|
|
39
|
+
# `regulation8_consolidated_2.xls` -- a real file, served 200, in the old
|
|
40
|
+
# binary Excel format, last modified in March 2022. An adapter pointed at it
|
|
41
|
+
# would download a list four years stale on every sync and never once look
|
|
42
|
+
# unhealthy. The URL above is the one DFAT's own page links today.
|
|
43
|
+
#
|
|
44
|
+
# ### DFAT's edge rejects this gem's User-Agent
|
|
45
|
+
#
|
|
46
|
+
# Verified against the live endpoint: `active_sanction/x.y.z (+https://...)`
|
|
47
|
+
# gets no response at all -- not a 403, a dropped connection -- while
|
|
48
|
+
# `curl/8.7.1`, `Wget/1.21` and `python-requests/2.31.0` are all served. The
|
|
49
|
+
# filter is on the leading product token, and an unrecognised one is
|
|
50
|
+
# dropped, so identifying ourselves honestly is what gets us blocked.
|
|
51
|
+
#
|
|
52
|
+
# What is sent instead is the configured agent inside the form written for
|
|
53
|
+
# exactly this: `Mozilla/5.0 (compatible; <agent>)`, which is how a
|
|
54
|
+
# well-behaved crawler has identified itself since Googlebot. It is not a
|
|
55
|
+
# disguise -- the agent, its version and the contact URL an operator
|
|
56
|
+
# configured are all still in the string, and DFAT can still see who we are
|
|
57
|
+
# and block us on purpose. It is the same string in a shape the edge parses.
|
|
58
|
+
# See #fetch_file, which is the only place this source departs from Base.
|
|
59
|
+
#
|
|
60
|
+
# ### A record is several rows
|
|
61
|
+
#
|
|
62
|
+
# DFAT publishes one row per name. Reference `1000` is a primary name and
|
|
63
|
+
# `1000a`, `1000b` are its aliases, with every other column repeated on each
|
|
64
|
+
# -- so 11,163 rows are 3,906 records: 2,543 people, 1,041 organizations and
|
|
65
|
+
# 322 vessels. Record says how they are joined, and what the Control Date
|
|
66
|
+
# is not.
|
|
67
|
+
#
|
|
68
|
+
# ### What a clean Australian result is worth
|
|
69
|
+
#
|
|
70
|
+
# About what a UN one is worth, which is unsurprising: 1,172 of the 3,906
|
|
71
|
+
# records are listings by a UN committee that Australia has given effect to,
|
|
72
|
+
# and they arrive with the UN's own text intact. Alias grading is published
|
|
73
|
+
# as a field on all 6,802 aliases. Birth dates are published on 6,823 rows
|
|
74
|
+
# in nine spellings, which PublishedDate reads to within four fragments of
|
|
75
|
+
# the whole list.
|
|
76
|
+
#
|
|
77
|
+
# Against that, DFAT publishes no document numbers of any kind -- no
|
|
78
|
+
# passport, no national identity number, no company registration -- for any
|
|
79
|
+
# of the 3,906 records. The only identifier on the list is an IMO number, on
|
|
80
|
+
# 344 vessel rows. So an Australian name match has nothing behind it to make
|
|
81
|
+
# it decisive, in the way an OFAC passport number usually settles one, and a
|
|
82
|
+
# screening policy should know that before it sets a threshold.
|
|
83
|
+
#
|
|
84
|
+
# ### What this adapter does not do
|
|
85
|
+
#
|
|
86
|
+
# It does not read the Listing Information prose for anything but the
|
|
87
|
+
# listing date. The column holds relisting histories, UN committee
|
|
88
|
+
# references and the reasons for a designation, in a dozen shapes, and
|
|
89
|
+
# reading it properly is the job OFAC's RemarksParser does and wants the
|
|
90
|
+
# same treatment -- a measured coverage figure -- rather than a regex added
|
|
91
|
+
# here in passing. It is kept verbatim in remarks.
|
|
92
|
+
#
|
|
93
|
+
# It does not decompose an address. DFAT publishes one free-text column and
|
|
94
|
+
# no parts, so the whole published string is the street; see Record.
|
|
95
|
+
class AustraliaDfat < Base
|
|
96
|
+
extend T::Sig
|
|
97
|
+
|
|
98
|
+
key :australia_dfat
|
|
99
|
+
jurisdiction :au
|
|
100
|
+
authority "Australian Sanctions Office, Department of Foreign Affairs and Trade"
|
|
101
|
+
format :xlsx
|
|
102
|
+
|
|
103
|
+
# Creative Commons Attribution 4.0, with the Commonwealth Coat of Arms
|
|
104
|
+
# carved out -- the same shape as the UK's: the data is reusable, the
|
|
105
|
+
# crest is not.
|
|
106
|
+
licence_notice "Creative Commons Attribution 4.0 International, " \
|
|
107
|
+
"attributed to the Department of Foreign Affairs and " \
|
|
108
|
+
"Trade. The Commonwealth Coat of Arms is excluded. " \
|
|
109
|
+
"Verified 2026-09-11."
|
|
110
|
+
licence_url "https://www.dfat.gov.au/about-us/about-this-website/copyright"
|
|
111
|
+
|
|
112
|
+
url :main, "https://www.dfat.gov.au/sites/default/files/Australian_Sanctions_Consolidated_List.xlsx"
|
|
113
|
+
|
|
114
|
+
# @api private
|
|
115
|
+
SHEET = T.let("Consolidated List", String)
|
|
116
|
+
|
|
117
|
+
# @api private
|
|
118
|
+
LIST = T.let(Parsers::Spreadsheet.new(sheet: SHEET), Parsers::Spreadsheet)
|
|
119
|
+
|
|
120
|
+
# The columns this adapter reads by name. The sheet names its own, so
|
|
121
|
+
# these are not a declaration of its shape -- they are what is checked
|
|
122
|
+
# before a single row is mapped, so that a column DFAT renames says so
|
|
123
|
+
# once and loudly rather than reading nil on all 11,163 rows.
|
|
124
|
+
#
|
|
125
|
+
# @api private
|
|
126
|
+
REQUIRED_COLUMNS = T.let(
|
|
127
|
+
%i[reference name_of_individual_or_entity type name_type alias_strength date_of_birth citizenship
|
|
128
|
+
address additional_information listing_information imo_number committees control_date
|
|
129
|
+
instrument_of_designation].freeze,
|
|
130
|
+
T::Array[Symbol]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# The letters DFAT suffixes an alias reference with: `1000a`, and six
|
|
134
|
+
# times `1000aa`. Stripping them is what joins a group.
|
|
135
|
+
#
|
|
136
|
+
# @api private
|
|
137
|
+
ALIAS_SUFFIX = T.let(/[a-z]+\z/, Regexp)
|
|
138
|
+
|
|
139
|
+
# `a) ... b) ...`: a UN enumeration inside one cell, which DFAT carries
|
|
140
|
+
# through into the addresses and the birth dates.
|
|
141
|
+
#
|
|
142
|
+
# @api private
|
|
143
|
+
ENUMERATOR = T.let(/(?:\A|[[:space:]])[a-z]\)[[:space:]]*/, Regexp)
|
|
144
|
+
|
|
145
|
+
# Every kind of space: these cells carry non-breaking ones, which
|
|
146
|
+
# `String#strip` leaves in place.
|
|
147
|
+
#
|
|
148
|
+
# @api private
|
|
149
|
+
SPACE = T.let(/[[:space:]]+/, Regexp)
|
|
150
|
+
|
|
151
|
+
# The form a filtered edge recognises -- see the class comment.
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
COMPATIBLE_AGENT = T.let("Mozilla/5.0 (compatible; %s)", String)
|
|
155
|
+
|
|
156
|
+
# Records that could not be used, and fields that could not be read.
|
|
157
|
+
# Read after #parse; sync orchestration (#34) reports them.
|
|
158
|
+
sig { returns(T::Array[Parsers::Warning]) }
|
|
159
|
+
attr_reader :warnings
|
|
160
|
+
|
|
161
|
+
sig { params(args: T.untyped, options: T.untyped).void }
|
|
162
|
+
def initialize(*args, **options)
|
|
163
|
+
super
|
|
164
|
+
@warnings = T.let([], T::Array[Parsers::Warning])
|
|
165
|
+
@unmapped = T.let([], T::Array[Parsers::Warning])
|
|
166
|
+
@modified = T.let(nil, T.nilable(String))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
|
|
170
|
+
def parse(raw)
|
|
171
|
+
reader = LIST.read(raw)
|
|
172
|
+
entities = build(reader)
|
|
173
|
+
@modified = reader.modified
|
|
174
|
+
@warnings = reader.warnings + @unmapped
|
|
175
|
+
entities
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# `2026-09-04T05:37:12Z` -- the moment DFAT saved the workbook, which it
|
|
179
|
+
# stamps inside the file and reports on its own download page as the date
|
|
180
|
+
# the list was last updated. Falls back to Last-Modified for a payload
|
|
181
|
+
# handed straight to #snapshot.
|
|
182
|
+
sig { override.returns(T.nilable(String)) }
|
|
183
|
+
def source_version = @modified || super
|
|
184
|
+
|
|
185
|
+
# A reference with its alias suffix removed: `1000a` and `1000b` are both
|
|
186
|
+
# part of `1000`. Public because it is what Record identifies a group by
|
|
187
|
+
# and what a caller reconciling a hit against DFAT's own site needs.
|
|
188
|
+
sig { params(reference: T.untyped).returns(T.nilable(String)) }
|
|
189
|
+
def self.group(reference)
|
|
190
|
+
base = reference.to_s.strip.sub(ALIAS_SUFFIX, "")
|
|
191
|
+
base.empty? ? nil : base
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# One cell split on the `a) ... b) ...` enumeration DFAT writes several
|
|
195
|
+
# values with, or the whole cell where it wrote only one.
|
|
196
|
+
sig { params(cell: T.untyped).returns(T::Array[String]) }
|
|
197
|
+
def self.enumerated(cell)
|
|
198
|
+
cell.to_s.split(ENUMERATOR).map { |part| part.gsub(SPACE, " ").strip }.reject(&:empty?)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# The one place this source departs from Base: the request carries the
|
|
204
|
+
# configured User-Agent wrapped in the `Mozilla/5.0 (compatible; ...)`
|
|
205
|
+
# form, because DFAT's edge drops a request whose leading product token
|
|
206
|
+
# it does not recognise. The class comment says why that is identification
|
|
207
|
+
# rather than concealment.
|
|
208
|
+
sig { override.params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
|
|
209
|
+
def fetch_file(name, address, force)
|
|
210
|
+
fetcher.fetch(address, key: file_key(name), force: force, headers: { "User-Agent" => compatible_agent })
|
|
211
|
+
.success!
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# `Kernel.format` explicitly: `format` on a source is the Definition
|
|
215
|
+
# reader that answers `:xlsx`, and calling it here would be asking the
|
|
216
|
+
# declaration for a User-Agent.
|
|
217
|
+
sig { returns(String) }
|
|
218
|
+
def compatible_agent = Kernel.format(COMPATIBLE_AGENT, ActiveSanction.config.user_agent)
|
|
219
|
+
|
|
220
|
+
sig { params(reader: Parsers::Spreadsheet::Reader).returns(T::Array[Entity]) }
|
|
221
|
+
def build(reader)
|
|
222
|
+
@unmapped = []
|
|
223
|
+
rows = reader.to_a
|
|
224
|
+
verify_columns!(rows.first)
|
|
225
|
+
group(rows).filter_map do |reference, group|
|
|
226
|
+
record = Record.new(group)
|
|
227
|
+
record.entity || note_nameless(reference, group)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Rows in published order, gathered under the reference they share. A row
|
|
232
|
+
# whose reference is blank cannot be joined to anything and is dropped
|
|
233
|
+
# with a warning; none of the 11,163 published today is.
|
|
234
|
+
sig do
|
|
235
|
+
params(rows: T::Array[Parsers::Spreadsheet::Row])
|
|
236
|
+
.returns(T::Hash[String, T::Array[Parsers::Spreadsheet::Row]])
|
|
237
|
+
end
|
|
238
|
+
def group(rows)
|
|
239
|
+
rows.each_with_object({}) do |row, groups|
|
|
240
|
+
reference = self.class.group(row[:reference]) || note_unreferenced(row)
|
|
241
|
+
(groups[reference] ||= []) << row unless reference.nil?
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A sheet whose header no longer names a column this reads is not a list
|
|
246
|
+
# with an empty column; it is a publisher who has changed the file, and
|
|
247
|
+
# every record built from it afterwards would be missing whatever that
|
|
248
|
+
# column carried. There is nothing to salvage, so nothing is.
|
|
249
|
+
sig { params(row: T.nilable(Parsers::Spreadsheet::Row)).void }
|
|
250
|
+
def verify_columns!(row)
|
|
251
|
+
raise Parsers::ParseError, "the #{SHEET.inspect} sheet has a header and no rows" if row.nil?
|
|
252
|
+
|
|
253
|
+
missing = REQUIRED_COLUMNS - row.columns
|
|
254
|
+
return if missing.empty?
|
|
255
|
+
|
|
256
|
+
raise Parsers::ParseError,
|
|
257
|
+
"the #{SHEET.inspect} sheet does not name the column(s) #{missing.join(", ")}. It names: " \
|
|
258
|
+
"#{row.columns.join(", ")}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
sig { params(row: Parsers::Spreadsheet::Row).returns(NilClass) }
|
|
262
|
+
def note_unreferenced(row)
|
|
263
|
+
@unmapped << Parsers::Warning.new(
|
|
264
|
+
line: row.number,
|
|
265
|
+
message: "row #{row.number} carries no reference, so it cannot be joined to a record",
|
|
266
|
+
snippet: row[:name_of_individual_or_entity]
|
|
267
|
+
)
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A group whose rows are all nameless cannot be screened against. None of
|
|
272
|
+
# the 3,906 records published today is nameless; the warning exists so
|
|
273
|
+
# that the day one is, it is visible rather than absent.
|
|
274
|
+
sig do
|
|
275
|
+
params(reference: String, group: T::Array[Parsers::Spreadsheet::Row]).returns(NilClass)
|
|
276
|
+
end
|
|
277
|
+
def note_nameless(reference, group)
|
|
278
|
+
@unmapped << Parsers::Warning.new(
|
|
279
|
+
line: group.first&.number,
|
|
280
|
+
message: "reference #{reference.inspect} has no name on any of its #{group.size} row(s) and was skipped"
|
|
281
|
+
)
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
require "active_sanction/sources/australia_dfat/published_date"
|
|
289
|
+
require "active_sanction/sources/australia_dfat/record"
|
|
290
|
+
|
|
291
|
+
ActiveSanction::Sources.register(ActiveSanction::Sources::AustraliaDfat)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/entity"
|
|
9
|
+
require "active_sanction/snapshot"
|
|
10
|
+
require "active_sanction/fetcher"
|
|
11
|
+
require "active_sanction/parsers"
|
|
12
|
+
require "active_sanction/payload_cache"
|
|
13
|
+
require "active_sanction/sources"
|
|
14
|
+
require "active_sanction/sources/definition"
|
|
15
|
+
require "active_sanction/sources/remarks"
|
|
16
|
+
|
|
17
|
+
module ActiveSanction
|
|
18
|
+
module Sources
|
|
19
|
+
# The contract every sanctions list adapter implements: declare what the
|
|
20
|
+
# list is and where it lives, then turn its bytes into Entities.
|
|
21
|
+
#
|
|
22
|
+
# class UnConsolidated < ActiveSanction::Sources::Base
|
|
23
|
+
# key :un_consolidated
|
|
24
|
+
# jurisdiction :un
|
|
25
|
+
# authority "United Nations Security Council"
|
|
26
|
+
# format :xml
|
|
27
|
+
# url :main, "https://scsanctions.un.org/resources/xml/en/consolidated.xml"
|
|
28
|
+
#
|
|
29
|
+
# def parse(raw)
|
|
30
|
+
# ... # => [Entity, ...]
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# ActiveSanction::Sources.register(UnConsolidated)
|
|
35
|
+
#
|
|
36
|
+
# #parse is the whole of what an adapter must write. Everything above it is
|
|
37
|
+
# declaration (Definition), and everything below it -- conditional GET,
|
|
38
|
+
# payload caching, checksumming the result into a Snapshot -- is here, the
|
|
39
|
+
# same for every list, so that adding a jurisdiction is a parsing problem
|
|
40
|
+
# and not a plumbing one.
|
|
41
|
+
#
|
|
42
|
+
# snapshot = ActiveSanction::Sources[:un_consolidated].new.sync
|
|
43
|
+
# snapshot # => Snapshot, or nil if nothing changed
|
|
44
|
+
#
|
|
45
|
+
# ### What #parse is handed
|
|
46
|
+
#
|
|
47
|
+
# A source declaring one URL gets the bytes. One declaring several gets a
|
|
48
|
+
# Hash keyed by the names it declared, because OFAC's three files only mean
|
|
49
|
+
# anything joined:
|
|
50
|
+
#
|
|
51
|
+
# def parse(raw)
|
|
52
|
+
# join(raw[:sdn], raw[:alt], raw[:add])
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# Which of the two it is follows from the declaration, not from what a
|
|
56
|
+
# caller happened to pass, so an adapter's signature does not change under
|
|
57
|
+
# it when a fixture is handed to #snapshot directly.
|
|
58
|
+
#
|
|
59
|
+
# The bytes arrive as a String. A list too large to hold in memory wants
|
|
60
|
+
# #15's streaming parse rather than this path; the cached Entry, which
|
|
61
|
+
# knows how to hand out a verified file handle, is where that will start.
|
|
62
|
+
#
|
|
63
|
+
# ### What sync does not do
|
|
64
|
+
#
|
|
65
|
+
# It does not store the snapshot, and it does not rescue anything. One
|
|
66
|
+
# source's failure being isolated from the others, and the previous good
|
|
67
|
+
# snapshot being kept when a list fails, are decisions about a *run* rather
|
|
68
|
+
# than about a list -- they belong to sync orchestration (#34), which needs
|
|
69
|
+
# an exception here to notice.
|
|
70
|
+
class Base
|
|
71
|
+
extend T::Sig
|
|
72
|
+
extend Definition
|
|
73
|
+
|
|
74
|
+
sig { returns(Fetcher) }
|
|
75
|
+
attr_reader :fetcher
|
|
76
|
+
|
|
77
|
+
# nil turns payload caching off -- see #initialize.
|
|
78
|
+
sig { returns(T.nilable(PayloadCache)) }
|
|
79
|
+
attr_reader :cache
|
|
80
|
+
|
|
81
|
+
# Anything Logger-shaped, or nil, as Configuration#logger has it.
|
|
82
|
+
sig { returns(T.untyped) }
|
|
83
|
+
attr_reader :logger
|
|
84
|
+
|
|
85
|
+
# `cache: nil` turns off payload caching, which costs one thing worth
|
|
86
|
+
# knowing: a multi-file source can no longer answer a sync where some of
|
|
87
|
+
# its files changed and others came back 304, so the unchanged ones are
|
|
88
|
+
# downloaded again in full.
|
|
89
|
+
sig do
|
|
90
|
+
params(fetcher: Fetcher, cache: T.nilable(PayloadCache), logger: T.untyped).void
|
|
91
|
+
end
|
|
92
|
+
def initialize(fetcher: Fetcher.new, cache: PayloadCache.new, logger: ActiveSanction.config.logger)
|
|
93
|
+
@fetcher = T.let(fetcher, Fetcher)
|
|
94
|
+
@cache = T.let(cache, T.nilable(PayloadCache))
|
|
95
|
+
@logger = T.let(logger, T.untyped)
|
|
96
|
+
@results = T.let({}, T::Hash[Symbol, Fetcher::Result])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { returns(Symbol) }
|
|
100
|
+
def key = self.class.key
|
|
101
|
+
|
|
102
|
+
sig { returns(Symbol) }
|
|
103
|
+
def jurisdiction = self.class.jurisdiction
|
|
104
|
+
|
|
105
|
+
sig { returns(String) }
|
|
106
|
+
def authority = self.class.authority
|
|
107
|
+
|
|
108
|
+
sig { returns(T.nilable(Symbol)) }
|
|
109
|
+
def format = self.class.format
|
|
110
|
+
|
|
111
|
+
sig { returns(T::Hash[Symbol, String]) }
|
|
112
|
+
def urls = self.class.urls
|
|
113
|
+
|
|
114
|
+
sig { params(name: T.untyped).returns(String) }
|
|
115
|
+
def url(name = nil) = name.nil? ? self.class.url : self.class.url(name)
|
|
116
|
+
|
|
117
|
+
sig { params(name: T.untyped).returns(Symbol) }
|
|
118
|
+
def file_key(name) = self.class.file_key(name)
|
|
119
|
+
|
|
120
|
+
# The lower bounds this list is held to when there is nothing to compare
|
|
121
|
+
# it against. See Definition#floor, and Doctor, which is the only thing
|
|
122
|
+
# that reads them.
|
|
123
|
+
sig { returns(T::Hash[Symbol, Numeric]) }
|
|
124
|
+
def floors = self.class.floors
|
|
125
|
+
|
|
126
|
+
# A remark with everything this adapter appended stripped back off --
|
|
127
|
+
# the publisher's own words and nothing else. Inherited, so it reads the
|
|
128
|
+
# same for every source and a caller does not have to know which list a
|
|
129
|
+
# remark came from before it can strip one. See Sources::Remarks.
|
|
130
|
+
sig { params(remarks: T.untyped).returns(T.nilable(String)) }
|
|
131
|
+
def self.published_remarks(remarks) = Remarks.published(remarks)
|
|
132
|
+
|
|
133
|
+
# The one method an adapter must write: bytes in, canonical records out.
|
|
134
|
+
sig { params(_raw: T.untyped).returns(T::Array[Entity]) }
|
|
135
|
+
def parse(_raw)
|
|
136
|
+
raise UnsupportedError,
|
|
137
|
+
"#{self.class} must implement #parse(raw) and return an Array of ActiveSanction::Entity"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Fetches, parses, and checksums -- or returns nil when the publisher
|
|
141
|
+
# says nothing has changed, which is the outcome to expect on most runs
|
|
142
|
+
# and the reason conditional GET exists.
|
|
143
|
+
sig { params(force: T::Boolean).returns(T.nilable(Snapshot)) }
|
|
144
|
+
def sync(force: false)
|
|
145
|
+
payloads = retrieve(force: force)
|
|
146
|
+
return nil if payloads.nil?
|
|
147
|
+
|
|
148
|
+
snapshot(payloads)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Parses payloads already in hand into a Snapshot. What #sync calls, and
|
|
152
|
+
# what an adapter's own spec calls with a fixture and no network:
|
|
153
|
+
#
|
|
154
|
+
# source.snapshot(main: File.read("spec/fixtures/un_consolidated.xml"))
|
|
155
|
+
#
|
|
156
|
+
# The files may be named as keywords, as above, or passed as one Hash --
|
|
157
|
+
# or, for a source that declares a single file, as the bytes themselves.
|
|
158
|
+
sig { params(payloads: T.untyped, files: T.untyped).returns(Snapshot) }
|
|
159
|
+
def snapshot(payloads = nil, **files)
|
|
160
|
+
Snapshot.new(source: key, entities: parse(parse_argument(payloads || files)),
|
|
161
|
+
fetched_at: Time.now.utc, source_version: source_version)
|
|
162
|
+
rescue ActiveSanction::Error => e
|
|
163
|
+
raise e.in_source(declared_key)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Every declared file, conditionally: a Hash of name => bytes, or nil
|
|
167
|
+
# when the publisher answered 304 for all of them.
|
|
168
|
+
#
|
|
169
|
+
# A file that came back unchanged is served from the payload cache, so a
|
|
170
|
+
# sync in which one of OFAC's three files moved downloads one file and
|
|
171
|
+
# not three. If the cache has nothing to serve -- a first run against a
|
|
172
|
+
# store that already has validators, a cache directory a user deleted --
|
|
173
|
+
# that file alone is re-fetched in full.
|
|
174
|
+
sig { params(force: T::Boolean).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
|
|
175
|
+
def retrieve(force: false)
|
|
176
|
+
raise DeclarationError, "#{self.class} declares no URL to retrieve" if urls.empty?
|
|
177
|
+
|
|
178
|
+
@results = urls.to_h { |name, address| [name, fetch_file(name, address, force)] }
|
|
179
|
+
return nil if @results.each_value.all?(&:unchanged?)
|
|
180
|
+
|
|
181
|
+
@results.keys.to_h { |name| [name, payload(name)] }
|
|
182
|
+
rescue ActiveSanction::Error => e
|
|
183
|
+
raise e.in_source(declared_key)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The positional-column assertions this source's raw files satisfy, or
|
|
187
|
+
# do not. Takes what #retrieve returned, or what #snapshot would be given,
|
|
188
|
+
# and answers with one Parsers::ColumnShape::Tally per declared column.
|
|
189
|
+
#
|
|
190
|
+
# Empty here, because most publishers ship a file that names its own
|
|
191
|
+
# fields and a named field cannot be quietly swapped with the one beside
|
|
192
|
+
# it. An adapter over a headerless file overrides #column_shapes -- see
|
|
193
|
+
# Sources::Ofac, and Parsers::ColumnShape for why a declared width is not
|
|
194
|
+
# enough on its own.
|
|
195
|
+
sig { params(payloads: T.untyped, files: T.untyped).returns(T::Array[Parsers::ColumnShape::Tally]) }
|
|
196
|
+
def column_tallies(payloads = nil, **files)
|
|
197
|
+
column_shapes(parse_argument(payloads || files))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The hook #column_tallies dispatches to, handed exactly what #parse is
|
|
201
|
+
# handed. Overridden by an adapter over a positional file.
|
|
202
|
+
sig { params(_raw: T.untyped).returns(T::Array[Parsers::ColumnShape::Tally]) }
|
|
203
|
+
def column_shapes(_raw) = []
|
|
204
|
+
|
|
205
|
+
# Whether any of this source's files is due a fetch, answered locally and
|
|
206
|
+
# without a request. See Fetcher#stale? for what that does and does not
|
|
207
|
+
# claim.
|
|
208
|
+
sig { returns(T::Boolean) }
|
|
209
|
+
def stale? = urls.any? { |name, address| fetcher.stale?(file_key(name), url: address) }
|
|
210
|
+
|
|
211
|
+
sig { returns(T::Boolean) }
|
|
212
|
+
def fresh? = !stale?
|
|
213
|
+
|
|
214
|
+
# The publisher's own marker for the version just fetched. Last-Modified
|
|
215
|
+
# is the only one every launch source serves; an adapter whose document
|
|
216
|
+
# carries a generation date inside it should override this and say so,
|
|
217
|
+
# because that is the string an examiner will recognise.
|
|
218
|
+
sig { returns(T.nilable(String)) }
|
|
219
|
+
def source_version = @results.values.first&.last_modified
|
|
220
|
+
|
|
221
|
+
sig { returns(String) }
|
|
222
|
+
def inspect
|
|
223
|
+
name = self.class.declared?(:key) ? key : "(no key)"
|
|
224
|
+
"#<#{self.class} #{name} #{urls.size} url(s)>"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# What #parse is handed, worked out from what #retrieve returned: the
|
|
228
|
+
# bytes for a source declaring one file, the Hash keyed by declaration
|
|
229
|
+
# name for one declaring several. Public because a caller that has
|
|
230
|
+
# already fetched -- Doctor, which parses and then reads the same
|
|
231
|
+
# payloads a second time -- has to be able to produce the same argument
|
|
232
|
+
# without knowing how many files this source declares.
|
|
233
|
+
sig { params(payloads: T.untyped).returns(T.untyped) }
|
|
234
|
+
def parse_argument(payloads)
|
|
235
|
+
return payloads if payloads.is_a?(String)
|
|
236
|
+
|
|
237
|
+
self.class.multi_url? ? payloads.to_h : payloads.to_h.values.first
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
# This adapter's key, or nil for one that never declared it. What
|
|
243
|
+
# #source_id is stamped from -- see Error#in_source. The layers under
|
|
244
|
+
# here are deliberately ignorant of which list they are working on: the
|
|
245
|
+
# HTTP client sees a URL, the XML reader sees a payload, and neither can
|
|
246
|
+
# name the list in the error it raises. This is the one place that can,
|
|
247
|
+
# and it is the boundary a caller rescues at.
|
|
248
|
+
sig { returns(T.nilable(Symbol)) }
|
|
249
|
+
def declared_key = self.class.declared?(:key) ? key : nil
|
|
250
|
+
|
|
251
|
+
sig { params(name: Symbol, address: String, force: T::Boolean).returns(Fetcher::Result) }
|
|
252
|
+
def fetch_file(name, address, force)
|
|
253
|
+
fetcher.fetch(address, key: file_key(name), force: force).success!
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
sig { params(name: Symbol).returns(T.untyped) }
|
|
257
|
+
def payload(name)
|
|
258
|
+
result = @results.fetch(name)
|
|
259
|
+
return store(name, result) if result.changed?
|
|
260
|
+
|
|
261
|
+
cached(name) || store(name, refetch(name))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
sig { params(name: Symbol, result: Fetcher::Result).returns(T.nilable(String)) }
|
|
265
|
+
def store(name, result)
|
|
266
|
+
cache&.write(file_key(name), result.body, url: url(name), final_url: result.uri.to_s,
|
|
267
|
+
etag: result.etag, last_modified: result.last_modified)
|
|
268
|
+
result.body
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A cached payload that no longer hashes to its sidecar is not used and
|
|
272
|
+
# not repaired -- but it is also not fatal here, because the bytes it
|
|
273
|
+
# failed to prove are a download away. The corrupt entry stays on disk
|
|
274
|
+
# for whoever investigates it.
|
|
275
|
+
sig { params(name: Symbol).returns(T.nilable(String)) }
|
|
276
|
+
def cached(name)
|
|
277
|
+
cache&.latest(file_key(name))&.read
|
|
278
|
+
rescue PayloadCache::CorruptEntry => e
|
|
279
|
+
logger&.info("[active_sanction] #{key} #{name} cached payload unusable (#{e.class}); fetching in full")
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
sig { params(name: Symbol).returns(Fetcher::Result) }
|
|
284
|
+
def refetch(name)
|
|
285
|
+
logger&.info("[active_sanction] #{key} #{name} unchanged but not cached; fetching in full")
|
|
286
|
+
result = fetcher.fetch(url(name), key: file_key(name), force: true).success!
|
|
287
|
+
return result if result.changed?
|
|
288
|
+
|
|
289
|
+
raise MissingPayload,
|
|
290
|
+
"#{key} #{name} answered #{result.status} to an unconditional request, so the bytes " \
|
|
291
|
+
"for #{url(name)} could not be obtained"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|