Skip to content

feat(crossref): offline OpenSanctions screening and API key support (#117) - #123

Merged
JordanCoin merged 3 commits into
mainfrom
feat/opensanctions-offline
Oct 23, 2026
Merged

JordanCoin merged 3 commits into
mainfrom
feat/opensanctions-offline

Conversation

@JordanCoin

@JordanCoin JordanCoin commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Closes #117.

What changed

API key. OPENFOIA_OPENSANCTIONS_API_KEY, read from the environment only (never config, never logged), is sent as Authorization: ApiKey <key>, the header documented at https://www.opensanctions.org/docs/api/authentication/. Without it the request goes out keyless as before. One live keyless request (/search/default?q=test, 2026-10-21) returned HTTP 401 {"detail":"No API key provided."}, so the API source now needs a key for everyone. 401/403 are reported as API key required: set OPENFOIA_OPENSANCTIONS_API_KEY instead of a bare HTTP status.

Offline screening. New source opensanctions-local, added to OFFLINE_SOURCES, so it runs with no network opt-in, no warning and no prompt:

openfoia crossref --sources opensanctions-local --opensanctions-data ./os/

The user downloads targets.simple.csv (449.34 MB) from https://www.opensanctions.org/datasets/default/ into ./os/. It is streamed row by row, never loaded whole. The name column (score 1.0, exact) and the ;-nested aliases column (score 0.8, alias) are matched after lowercasing, turning punctuation into spaces and collapsing whitespace. Hits carry the same extra keys as the API checker (score, is_pep, is_sanctioned, datasets). A directory with no targets.simple.csv is reported as NoOpenSanctionsData, not as a clean result. Read errors surface with partial hits, the same way the ICIJ checker does.

I did not use names.txt (105.1 MB): it has no ids or dataset columns, so it cannot fill is_sanctioned or datasets. The issue's optional rigour normalization is not in the repo yet, so matching uses the plain normalization described above.

CLI. The network warning now skips every OFFLINE_SOURCES entry, where before it skipped only icij. --sources icij or --sources opensanctions-local without its data flag now exits with an error. Before, it ran zero sources and looked like a clean screening.

Docs. The README row and --help give the data license, CC BY-NC 4.0 (https://www.opensanctions.org/licensing/): free for journalists and non-commercial use. The CHANGELOG entry is under Unreleased.

Known limit

The offline check makes one full pass over the file for each entity. If screening hundreds of entities against the 449 MB file gets slow, the next step is a name index (marked ponytail: in the code).

Tests

tests/test_crossref_opensanctions.py uses a 20-row fixture with the documented columns and fictional names. It covers exact, alias and non-Latin matches, a missing file, a read error that never logs the name, the offline run under the no-network fixture through both crossref_entities and the CLI, the key header being sent only when the variable is set (the key never appears in logs or output), and 401/403 producing the key-required message. Full suite: 541 passed, 20 skipped, exit 0 (HF_HUB_OFFLINE=1). ruff check and ruff format --check are clean.

Review fixes

  • SANCTIONED is no longer bool(row["sanctions"]). The real file fills that column for SAM procurement exclusions, HHS/Medicaid excluded providers, FINRA bars, INTERPOL notices and ship detentions. is_sanctioned now comes only from list titles that contain "sanction", plus OFAC lists: the reviewer's own example, "US OFAC Specially Designated Nationals (SDN) List", does not contain the word, so a "sanction"-only rule would have stopped flagging the SDN list. Details name the first two list titles (Lists: ...), so an HHS exclusion shows its list and never prints SANCTIONED.
  • PEP matches "pep"/"peps" as a word or "politically exposed", not substrings like "pepper". The API and local checkers share one helper, _classify_datasets.
  • Keyless runs: the first 401/403 takes opensanctions out of the rest of the run (status skipped(api-key-required)), the same way a rate limit does, so no further entity names leave the machine. The report line is still API key required: set OPENFOIA_OPENSANCTIONS_API_KEY.
  • --opensanctions-data pointing at the CSV file exits with "pass the directory containing targets.simple.csv". The data-flag checks now run before the database check. Before, they could only be reached with an initialized database. The CLI tests use an empty data dir, so they cannot pass just because a local database exists.
  • Fixture: dataset cells are now realistic titles. It adds an HHS OIG exclusion row with a non-empty sanctions cell, asserted not sanctioned, and a "Peppercorn" title, asserted not PEP. A new test sends two entities to a mocked 401 and asserts exactly one request went out.
  • Full suite: 545 passed, 20 skipped, exit 0 (HF_HUB_OFFLINE=1). Ruff check and format are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wdqj51cPquyTRKmoJLQV6y

JordanCoin and others added 3 commits October 21, 2026 17:42
…117)

The hosted OpenSanctions API now answers keyless requests with
401 {"detail":"No API key provided."} (checked 2026-10-21), so the
existing opensanctions source errored for everyone.

- OPENFOIA_OPENSANCTIONS_API_KEY (environment only) is sent as the
  documented "Authorization: ApiKey <key>" header. 401/403 become a
  source error that says a key is required and names the variable.
- New offline source opensanctions-local (in OFFLINE_SOURCES):
  --opensanctions-data <dir> streams targets.simple.csv (449.34 MB per
  the default dataset page) row by row and matches the normalized name
  column (score 1.0) and the ;-nested aliases column (0.8). Same extra
  keys as the API checker. Missing file -> NoOpenSanctionsData; read
  errors surface with partial hits, like ICIJ.
- The CLI treats every OFFLINE_SOURCES entry as offline when deciding
  whether to warn, and refuses --sources icij/opensanctions-local with
  no data flag instead of running zero sources.
- README and --help carry the CC BY-NC 4.0 data license.

names.txt (105.1 MB) was not used: it has no ids or dataset columns, so
it cannot fill is_sanctioned/datasets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdqj51cPquyTRKmoJLQV6y
- SANCTIONED comes only from list titles (contains "sanction", or an OFAC
  list, since "US OFAC Specially Designated Nationals (SDN) List" lacks the
  word). The `sanctions` column is filled for SAM debarments, HHS/Medicaid
  exclusions, FINRA bars and INTERPOL notices, so it no longer flags.
- PEP matches "pep"/"peps" as a word or "politically exposed", not
  substrings like "pepper". Both API and local checkers share the rule.
- Details now name the first two list titles.
- A 401/403 from the OpenSanctions API takes the source out of the rest of
  the run (status skipped(api-key-required)), so no further entity names
  leave the machine. The report still says "API key required: set
  OPENFOIA_OPENSANCTIONS_API_KEY".
- --opensanctions-data pointing at the CSV file asks for its directory.
- Fixture `dataset` cells are realistic titles; adds an HHS exclusion row
  with a non-empty sanctions cell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdqj51cPquyTRKmoJLQV6y
The flag errors (offline source without data, --opensanctions-data given
the CSV file) were only reachable with an initialized database, so CI
printed "Database not initialized" instead. The CLI tests now run with an
empty data dir so they cannot pass on a local database.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdqj51cPquyTRKmoJLQV6y
@JordanCoin
JordanCoin merged commit df42070 into main Oct 23, 2026
7 checks passed
@JordanCoin
JordanCoin deleted the feat/opensanctions-offline branch October 23, 2026 17:54
@JordanCoin JordanCoin mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

crossref: offline OpenSanctions screening from a downloaded dataset, plus API key support

1 participant