acis-browser 1.0.0__py3-none-any.whl
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.
- acis_browser/__init__.py +34 -0
- acis_browser/a_number.py +21 -0
- acis_browser/browser.py +357 -0
- acis_browser/exceptions.py +54 -0
- acis_browser/nationalities.py +60 -0
- acis_browser/py.typed +0 -0
- acis_browser/reference/nationality-codes.json +1257 -0
- acis_browser-1.0.0.dist-info/METADATA +20 -0
- acis_browser-1.0.0.dist-info/RECORD +11 -0
- acis_browser-1.0.0.dist-info/WHEEL +4 -0
- acis_browser-1.0.0.dist-info/licenses/LICENSE +661 -0
acis_browser/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Automated browser to perform ACIS lookups."""
|
|
2
|
+
|
|
3
|
+
from acis_browser.a_number import normalize_a_number, redact
|
|
4
|
+
from acis_browser.browser import AcisBrowser, Outcome, raise_for_outcome
|
|
5
|
+
from acis_browser.exceptions import (
|
|
6
|
+
AcisError,
|
|
7
|
+
CaptchaError,
|
|
8
|
+
CaseNotFoundError,
|
|
9
|
+
CaseUnavailableError,
|
|
10
|
+
InvalidANumberError,
|
|
11
|
+
InvalidNationalityError,
|
|
12
|
+
UnknownNationalityError,
|
|
13
|
+
UpstreamError,
|
|
14
|
+
)
|
|
15
|
+
from acis_browser.nationalities import Nationality, get_by_code, resolve
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AcisBrowser",
|
|
19
|
+
"AcisError",
|
|
20
|
+
"CaptchaError",
|
|
21
|
+
"CaseNotFoundError",
|
|
22
|
+
"CaseUnavailableError",
|
|
23
|
+
"InvalidANumberError",
|
|
24
|
+
"InvalidNationalityError",
|
|
25
|
+
"Nationality",
|
|
26
|
+
"Outcome",
|
|
27
|
+
"UnknownNationalityError",
|
|
28
|
+
"UpstreamError",
|
|
29
|
+
"get_by_code",
|
|
30
|
+
"normalize_a_number",
|
|
31
|
+
"raise_for_outcome",
|
|
32
|
+
"redact",
|
|
33
|
+
"resolve",
|
|
34
|
+
]
|
acis_browser/a_number.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Utilities for working with A-Numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from acis_browser.exceptions import InvalidANumberError
|
|
8
|
+
|
|
9
|
+
_NON_DIGITS = re.compile(r"[\s\-]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_a_number(value: str) -> str:
|
|
13
|
+
candidate = _NON_DIGITS.sub("", value or "")
|
|
14
|
+
candidate = candidate.removeprefix("A").removeprefix("a")
|
|
15
|
+
if not candidate.isdigit() or len(candidate) != 9:
|
|
16
|
+
raise InvalidANumberError(f"A-Number must be 9 digits, got {value!r}")
|
|
17
|
+
return candidate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def redact(a_number: str) -> str:
|
|
21
|
+
return f"***{a_number[-4:]}" if len(a_number) >= 4 else "***"
|
acis_browser/browser.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Wrapper around patchright to drive headed Chrome to do ACIS lookups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum, auto
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal, Self
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
from patchright.async_api import Error as PlaywrightError
|
|
16
|
+
from patchright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
17
|
+
from patchright.async_api import async_playwright
|
|
18
|
+
|
|
19
|
+
from acis_browser.a_number import redact
|
|
20
|
+
from acis_browser.exceptions import (
|
|
21
|
+
CaptchaError,
|
|
22
|
+
CaseNotFoundError,
|
|
23
|
+
CaseUnavailableError,
|
|
24
|
+
InvalidNationalityError,
|
|
25
|
+
UpstreamError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from types import TracebackType
|
|
31
|
+
|
|
32
|
+
from patchright.async_api import (
|
|
33
|
+
BrowserContext,
|
|
34
|
+
Page,
|
|
35
|
+
Playwright,
|
|
36
|
+
Request,
|
|
37
|
+
Response,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = structlog.get_logger()
|
|
41
|
+
|
|
42
|
+
ACIS_URL = "https://acis.eoir.justice.gov/en/"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def option_label(nat_name: str, nat_code: str) -> re.Pattern[str]:
|
|
46
|
+
return re.compile(rf"^{re.escape(f'{nat_name} ({nat_code})')}$", re.IGNORECASE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
##### Outcomes #####
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Outcome(StrEnum):
|
|
53
|
+
"""Classification of an ACIS payload by its pinned ``message`` fragments."""
|
|
54
|
+
|
|
55
|
+
OK = auto()
|
|
56
|
+
CAPTCHA_REJECTED = auto()
|
|
57
|
+
UNAVAILABLE = auto()
|
|
58
|
+
NOT_FOUND = auto()
|
|
59
|
+
INVALID_NATIONALITY = auto()
|
|
60
|
+
OTHER = auto()
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_payload(cls, payload: dict[str, Any]) -> Outcome:
|
|
64
|
+
if isinstance(payload.get("Data"), dict):
|
|
65
|
+
return cls.OK
|
|
66
|
+
message = payload.get("message") or ""
|
|
67
|
+
for fragment, outcome in _MESSAGES:
|
|
68
|
+
if fragment in message:
|
|
69
|
+
return outcome
|
|
70
|
+
return cls.OTHER
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_MESSAGES = (
|
|
74
|
+
("Invalid Captcha Provided", Outcome.CAPTCHA_REJECTED),
|
|
75
|
+
("Case information is unavailable", Outcome.UNAVAILABLE),
|
|
76
|
+
("No case info found", Outcome.NOT_FOUND),
|
|
77
|
+
("Invalid nationality code", Outcome.INVALID_NATIONALITY),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def raise_for_outcome(payload: dict[str, Any]) -> None:
|
|
82
|
+
"""Raise the exception matching the payload's outcome; return for a success."""
|
|
83
|
+
outcome = Outcome.from_payload(payload)
|
|
84
|
+
if outcome is Outcome.OK:
|
|
85
|
+
return
|
|
86
|
+
message = payload.get("message") or ""
|
|
87
|
+
if outcome is Outcome.CAPTCHA_REJECTED:
|
|
88
|
+
raise CaptchaError(
|
|
89
|
+
message, reason=CaptchaError.Reason.REJECTED, payload=payload
|
|
90
|
+
)
|
|
91
|
+
if outcome is Outcome.NOT_FOUND:
|
|
92
|
+
raise CaseNotFoundError(message, payload=payload)
|
|
93
|
+
if outcome is Outcome.INVALID_NATIONALITY:
|
|
94
|
+
raise InvalidNationalityError(message, payload=payload)
|
|
95
|
+
if outcome is Outcome.UNAVAILABLE:
|
|
96
|
+
raise CaseUnavailableError(message, payload=payload)
|
|
97
|
+
raise UpstreamError(
|
|
98
|
+
message or "ACIS returned an unexpected payload", payload=payload
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
##### Browser #####
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class _Capture:
|
|
107
|
+
requested: bool = False
|
|
108
|
+
status: int = 0
|
|
109
|
+
body: str | None = None
|
|
110
|
+
received: asyncio.Event = field(default_factory=asyncio.Event)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(kw_only=True)
|
|
114
|
+
class AcisBrowser:
|
|
115
|
+
"""A Playwright/Patchright wrapper to do ACIS lookups."""
|
|
116
|
+
|
|
117
|
+
profile_dir: Path
|
|
118
|
+
lookup_timeout: float = 20
|
|
119
|
+
lookup_attempts: int = 2
|
|
120
|
+
idle_timeout: float = 900
|
|
121
|
+
|
|
122
|
+
# Lock to ensure only one lookup at a time
|
|
123
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
|
|
124
|
+
_playwright: Playwright | None = field(default=None, init=False, repr=False)
|
|
125
|
+
_context: BrowserContext | None = field(default=None, init=False, repr=False)
|
|
126
|
+
_last_used: float = field(default=0.0, init=False, repr=False)
|
|
127
|
+
|
|
128
|
+
##### Lifecycle #####
|
|
129
|
+
|
|
130
|
+
async def start(self) -> None:
|
|
131
|
+
"""Launch Chrome. Safe to call repeatedly."""
|
|
132
|
+
if self._context is not None:
|
|
133
|
+
return
|
|
134
|
+
self.profile_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
logger.info("browser starting", profile=str(self.profile_dir))
|
|
136
|
+
self._playwright = await async_playwright().start()
|
|
137
|
+
self._context = await self._playwright.chromium.launch_persistent_context(
|
|
138
|
+
str(self.profile_dir),
|
|
139
|
+
channel="chrome",
|
|
140
|
+
headless=False,
|
|
141
|
+
no_viewport=True,
|
|
142
|
+
)
|
|
143
|
+
self._last_used = time.monotonic()
|
|
144
|
+
|
|
145
|
+
async def close(self) -> None:
|
|
146
|
+
if self._context is not None:
|
|
147
|
+
logger.info("browser stopping")
|
|
148
|
+
try:
|
|
149
|
+
await self._context.close()
|
|
150
|
+
finally:
|
|
151
|
+
self._context = None
|
|
152
|
+
if self._playwright is not None:
|
|
153
|
+
try:
|
|
154
|
+
await self._playwright.stop()
|
|
155
|
+
finally:
|
|
156
|
+
self._playwright = None
|
|
157
|
+
|
|
158
|
+
async def close_if_idle(self) -> bool:
|
|
159
|
+
if self._context is None:
|
|
160
|
+
return False
|
|
161
|
+
idle = time.monotonic() - self._last_used
|
|
162
|
+
if idle < self.idle_timeout:
|
|
163
|
+
return False
|
|
164
|
+
async with self._lock:
|
|
165
|
+
# Re-check under the lock: a lookup may have started meanwhile.
|
|
166
|
+
if self._context is None:
|
|
167
|
+
return False
|
|
168
|
+
if time.monotonic() - self._last_used < self.idle_timeout:
|
|
169
|
+
return False
|
|
170
|
+
logger.info("browser idle, closing", idle_seconds=round(idle))
|
|
171
|
+
await self.close()
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
async def __aenter__(self) -> Self:
|
|
175
|
+
await self.start()
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
async def __aexit__(
|
|
179
|
+
self,
|
|
180
|
+
exc_type: type[BaseException] | None,
|
|
181
|
+
exc: BaseException | None,
|
|
182
|
+
tb: TracebackType | None,
|
|
183
|
+
) -> None:
|
|
184
|
+
await self.close()
|
|
185
|
+
|
|
186
|
+
##### Lookup #####
|
|
187
|
+
|
|
188
|
+
async def lookup(
|
|
189
|
+
self, a_number: str, nat_code: str, nat_name: str
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""Return the raw ACIS JSON payload for a case, whatever the outcome.
|
|
192
|
+
|
|
193
|
+
Retries transient captcha failures. The form takes the nationality
|
|
194
|
+
*name*; the code only disambiguates the option label, since names are
|
|
195
|
+
substrings of one another.
|
|
196
|
+
"""
|
|
197
|
+
attempts = self.lookup_attempts
|
|
198
|
+
async with self._lock:
|
|
199
|
+
await self.start()
|
|
200
|
+
self._last_used = time.monotonic()
|
|
201
|
+
try:
|
|
202
|
+
attempt = 0
|
|
203
|
+
while True:
|
|
204
|
+
attempt += 1
|
|
205
|
+
try:
|
|
206
|
+
payload = await self._lookup_once(a_number, nat_code, nat_name)
|
|
207
|
+
except CaptchaError as exc:
|
|
208
|
+
logger.warning(
|
|
209
|
+
"lookup.captcha_failed",
|
|
210
|
+
attempt=attempt,
|
|
211
|
+
attempts=attempts,
|
|
212
|
+
a_number=redact(a_number),
|
|
213
|
+
nat_code=nat_code,
|
|
214
|
+
reason=exc.reason,
|
|
215
|
+
error=str(exc),
|
|
216
|
+
)
|
|
217
|
+
if attempt >= attempts:
|
|
218
|
+
raise
|
|
219
|
+
await asyncio.sleep(2**attempt)
|
|
220
|
+
except PlaywrightError as exc:
|
|
221
|
+
logger.warning("lookup.browser_lost", error=str(exc))
|
|
222
|
+
with contextlib.suppress(PlaywrightError):
|
|
223
|
+
await self.close()
|
|
224
|
+
raise UpstreamError("Chrome is no longer available") from exc
|
|
225
|
+
else:
|
|
226
|
+
return payload
|
|
227
|
+
finally:
|
|
228
|
+
self._last_used = time.monotonic()
|
|
229
|
+
|
|
230
|
+
async def _lookup_once(
|
|
231
|
+
self, a_number: str, nat_code: str, nat_name: str
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Fill the form and return the parsed backend response."""
|
|
234
|
+
if self._context is None: # pragma: no cover
|
|
235
|
+
raise UpstreamError("Browser is not running")
|
|
236
|
+
page = await self._context.new_page()
|
|
237
|
+
captured = _Capture()
|
|
238
|
+
case_info_path = "/api/Case/GetCaseInfo"
|
|
239
|
+
|
|
240
|
+
async def on_request(request: Request) -> None:
|
|
241
|
+
if case_info_path in request.url:
|
|
242
|
+
captured.requested = True
|
|
243
|
+
|
|
244
|
+
async def on_response(response: Response) -> None:
|
|
245
|
+
if case_info_path not in response.url:
|
|
246
|
+
return
|
|
247
|
+
captured.status = response.status
|
|
248
|
+
try:
|
|
249
|
+
captured.body = await response.text()
|
|
250
|
+
except Exception as exc: # noqa: BLE001
|
|
251
|
+
logger.warning("could not read ACIS response body", error=str(exc))
|
|
252
|
+
finally:
|
|
253
|
+
captured.received.set()
|
|
254
|
+
|
|
255
|
+
page.on("request", on_request)
|
|
256
|
+
page.on("response", on_response)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
await page.goto(ACIS_URL, wait_until="domcontentloaded", timeout=60_000)
|
|
260
|
+
await self._wait_for(
|
|
261
|
+
page,
|
|
262
|
+
'.ReactModal__Overlay, input[inputmode="numeric"]',
|
|
263
|
+
"app",
|
|
264
|
+
timeout_ms=30_000,
|
|
265
|
+
)
|
|
266
|
+
await self._dismiss_modal(page)
|
|
267
|
+
await self._fill_form(page, a_number, nat_code, nat_name)
|
|
268
|
+
await page.locator("#btn_submit").click()
|
|
269
|
+
with contextlib.suppress(TimeoutError):
|
|
270
|
+
async with asyncio.timeout(self.lookup_timeout):
|
|
271
|
+
await captured.received.wait()
|
|
272
|
+
except PlaywrightError as exc:
|
|
273
|
+
raise UpstreamError("Could not lookup case") from exc
|
|
274
|
+
finally:
|
|
275
|
+
try:
|
|
276
|
+
await page.close()
|
|
277
|
+
except PlaywrightError:
|
|
278
|
+
logger.warning("could not close page")
|
|
279
|
+
|
|
280
|
+
return self._parse(captured)
|
|
281
|
+
|
|
282
|
+
async def _wait_for(
|
|
283
|
+
self,
|
|
284
|
+
page: Page,
|
|
285
|
+
selector: str,
|
|
286
|
+
description: str,
|
|
287
|
+
*,
|
|
288
|
+
state: Literal["attached", "detached", "hidden", "visible"] = "visible",
|
|
289
|
+
timeout_ms: int = 5_000,
|
|
290
|
+
) -> bool:
|
|
291
|
+
try:
|
|
292
|
+
await page.wait_for_selector(selector, state=state, timeout=timeout_ms)
|
|
293
|
+
except PlaywrightTimeoutError:
|
|
294
|
+
logger.warning("lookup.wait_timeout", condition=description, state=state)
|
|
295
|
+
return False
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
async def _dismiss_modal(self, page: Page) -> None:
|
|
299
|
+
try:
|
|
300
|
+
await page.wait_for_selector(".ReactModal__Overlay", timeout=500)
|
|
301
|
+
except PlaywrightTimeoutError:
|
|
302
|
+
return
|
|
303
|
+
await page.locator(".ReactModalPortal button").last.click()
|
|
304
|
+
await self._wait_for(
|
|
305
|
+
page, ".ReactModal__Overlay", "consent modal to close", state="detached"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
async def _fill_form(
|
|
309
|
+
self, page: Page, a_number: str, nat_code: str, nat_name: str
|
|
310
|
+
) -> None:
|
|
311
|
+
type_delay = 70 # milliseconds
|
|
312
|
+
await page.locator('input[inputmode="numeric"]').first.click()
|
|
313
|
+
await page.keyboard.type(a_number, delay=type_delay)
|
|
314
|
+
|
|
315
|
+
await page.locator("#react-select-3-input").click()
|
|
316
|
+
await page.keyboard.type(nat_name, delay=type_delay)
|
|
317
|
+
option = page.get_by_role("option", name=option_label(nat_name, nat_code))
|
|
318
|
+
await option.first.click(timeout=5000)
|
|
319
|
+
await self._wait_for(
|
|
320
|
+
page, '[role="option"]', "nationality menu to close", state="detached"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _parse(self, captured: _Capture) -> dict[str, Any]:
|
|
324
|
+
if not captured.received.is_set():
|
|
325
|
+
if not captured.requested:
|
|
326
|
+
raise CaptchaError(
|
|
327
|
+
"No captcha token obtained: the form was submitted but no case "
|
|
328
|
+
"request followed",
|
|
329
|
+
reason=CaptchaError.Reason.NO_REQUEST,
|
|
330
|
+
)
|
|
331
|
+
raise CaptchaError(
|
|
332
|
+
f"No response to the case request within {self.lookup_timeout}s",
|
|
333
|
+
reason=CaptchaError.Reason.NO_RESPONSE,
|
|
334
|
+
)
|
|
335
|
+
if captured.body is None:
|
|
336
|
+
raise UpstreamError(
|
|
337
|
+
f"Could not read ACIS response body (HTTP {captured.status})"
|
|
338
|
+
)
|
|
339
|
+
status, body = captured.status, captured.body
|
|
340
|
+
try:
|
|
341
|
+
payload = json.loads(body)
|
|
342
|
+
except json.JSONDecodeError as exc:
|
|
343
|
+
raise UpstreamError(f"ACIS returned non-JSON (HTTP {status})") from exc
|
|
344
|
+
if not isinstance(payload, dict):
|
|
345
|
+
raise UpstreamError("ACIS returned an unexpected payload")
|
|
346
|
+
|
|
347
|
+
outcome = Outcome.from_payload(payload)
|
|
348
|
+
if outcome is Outcome.CAPTCHA_REJECTED:
|
|
349
|
+
raise CaptchaError(
|
|
350
|
+
payload["message"], reason=CaptchaError.Reason.REJECTED, payload=payload
|
|
351
|
+
)
|
|
352
|
+
if outcome not in (Outcome.OK, Outcome.OTHER):
|
|
353
|
+
return payload
|
|
354
|
+
|
|
355
|
+
if status != 200:
|
|
356
|
+
raise UpstreamError(f"ACIS returned HTTP {status}")
|
|
357
|
+
return payload
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum, auto
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AcisError(Exception):
|
|
8
|
+
"""Base class for failures talking to ACIS."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, *args: object, payload: dict[str, Any] | None = None) -> None:
|
|
11
|
+
super().__init__(*args)
|
|
12
|
+
self.payload = payload
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidANumberError(AcisError):
|
|
16
|
+
"""Raised when an A-Number isn't valid."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnknownNationalityError(AcisError):
|
|
20
|
+
"""Raised when a nationality code or name can't be resolved."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CaptchaError(AcisError):
|
|
24
|
+
"""Raised when an hCaptcha token can't be obtained or the token is refused."""
|
|
25
|
+
|
|
26
|
+
class Reason(StrEnum):
|
|
27
|
+
NO_REQUEST = auto()
|
|
28
|
+
"""Form was submitted but no case request followed."""
|
|
29
|
+
NO_RESPONSE = auto()
|
|
30
|
+
"""Case request went out but nothing came back."""
|
|
31
|
+
REJECTED = auto()
|
|
32
|
+
"""Token was submitted and rejected."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, message: str, *, reason: Reason, payload: dict[str, Any] | None = None
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message, payload=payload)
|
|
38
|
+
self.reason = reason
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CaseNotFoundError(AcisError):
|
|
42
|
+
"""Raised when ACIS has no case for a given A-Number."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InvalidNationalityError(AcisError):
|
|
46
|
+
"""Raised when ACIS rejects the nationality code."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CaseUnavailableError(AcisError):
|
|
50
|
+
"""Raised when the case exists but ACIS won't release information for it."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UpstreamError(AcisError):
|
|
54
|
+
"""Raised when ACIS returns an unexpected response."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Utilities for working with EOIR nationality codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from acis_browser.exceptions import UnknownNationalityError
|
|
11
|
+
|
|
12
|
+
HERE = Path(__file__).parent
|
|
13
|
+
REFERENCE_FILE = HERE / "reference" / "nationality-codes.json"
|
|
14
|
+
|
|
15
|
+
# Placeholder entry in the upstream table; never a valid lookup value.
|
|
16
|
+
UNKNOWN_CODE = "??"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, kw_only=True)
|
|
20
|
+
class Nationality:
|
|
21
|
+
code: str
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cache
|
|
26
|
+
def _tables() -> tuple[dict[str, Nationality], dict[str, Nationality]]:
|
|
27
|
+
"""Return (by_code, by_name) lookup tables of active nationalities."""
|
|
28
|
+
raw = json.loads(REFERENCE_FILE.read_text(encoding="utf-8"))
|
|
29
|
+
by_code: dict[str, Nationality] = {}
|
|
30
|
+
by_name: dict[str, Nationality] = {}
|
|
31
|
+
for entry in raw:
|
|
32
|
+
code = entry["Code"].strip().upper()
|
|
33
|
+
if not entry.get("IsActive") or code == UNKNOWN_CODE:
|
|
34
|
+
continue
|
|
35
|
+
nationality = Nationality(code=code, name=entry["Name"].strip().upper())
|
|
36
|
+
by_code[nationality.code] = nationality
|
|
37
|
+
by_name[nationality.name] = nationality
|
|
38
|
+
return by_code, by_name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_by_code(code: str) -> Nationality:
|
|
42
|
+
"""Look up strictly by code."""
|
|
43
|
+
by_code, _ = _tables()
|
|
44
|
+
try:
|
|
45
|
+
return by_code[code.strip().upper()]
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise UnknownNationalityError(f"Unknown nationality code: {code!r}") from None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve(value: str) -> Nationality:
|
|
51
|
+
"""Resolve a code (``MX``) or a name (``mexico``) to a Nationality."""
|
|
52
|
+
candidate = (value or "").strip().upper()
|
|
53
|
+
if not candidate:
|
|
54
|
+
raise UnknownNationalityError("Nationality is required")
|
|
55
|
+
by_code, by_name = _tables()
|
|
56
|
+
if candidate in by_code:
|
|
57
|
+
return by_code[candidate]
|
|
58
|
+
if candidate in by_name:
|
|
59
|
+
return by_name[candidate]
|
|
60
|
+
raise UnknownNationalityError(f"Unknown nationality: {value!r}")
|
acis_browser/py.typed
ADDED
|
File without changes
|