@yukioa2z/visa-apply 3.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.
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env python3
2
+ """Validate and query the visa destination and official-source registries."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from datetime import date
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ REGISTRY_PATH = ROOT / "references" / "official-sources.json"
15
+ DIRECTORY_PATH = ROOT / "references" / "jurisdictions.json"
16
+ IATA_TRAVEL_CENTRE = (
17
+ "https://www.iata.org/en/services/compliance/timatic/travel-documentation/"
18
+ )
19
+ ALLOWED_LEVELS = {"full_adapter", "source_seeded", "discovered"}
20
+ ALLOWED_ROLES = {
21
+ "authoritative_rules",
22
+ "official_application",
23
+ "delegated_application",
24
+ "official_status",
25
+ }
26
+
27
+
28
+ def load_registry() -> dict:
29
+ return json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
30
+
31
+
32
+ def load_directory() -> dict:
33
+ return json.loads(DIRECTORY_PATH.read_text(encoding="utf-8"))
34
+
35
+
36
+ def _candidates(jurisdiction: dict) -> set[str]:
37
+ values = {
38
+ jurisdiction.get("id", ""),
39
+ jurisdiction.get("iso2", ""),
40
+ jurisdiction.get("name", ""),
41
+ *jurisdiction.get("aliases", []),
42
+ }
43
+ return {value.casefold() for value in values if value}
44
+
45
+
46
+ def find_jurisdiction(registry: dict, query: str) -> dict | None:
47
+ normalized = query.casefold()
48
+ return next(
49
+ (
50
+ jurisdiction
51
+ for jurisdiction in registry["jurisdictions"]
52
+ if normalized in _candidates(jurisdiction)
53
+ ),
54
+ None,
55
+ )
56
+
57
+
58
+ def find_directory_jurisdiction(directory: dict, query: str) -> dict | None:
59
+ normalized = query.casefold()
60
+ return next(
61
+ (
62
+ jurisdiction
63
+ for jurisdiction in directory["jurisdictions"]
64
+ if normalized in _candidates(jurisdiction)
65
+ ),
66
+ None,
67
+ )
68
+
69
+
70
+ def find_destination(registry: dict, directory: dict, query: str) -> tuple[dict, dict | None] | None:
71
+ seed = find_jurisdiction(registry, query)
72
+ if seed:
73
+ directory_match = (
74
+ find_directory_jurisdiction(directory, seed["iso2"])
75
+ if seed.get("iso2")
76
+ else None
77
+ )
78
+ return directory_match or seed, seed
79
+
80
+ directory_match = find_directory_jurisdiction(directory, query)
81
+ if directory_match:
82
+ seed = find_jurisdiction(registry, directory_match["iso2"])
83
+ return directory_match, seed
84
+ return None
85
+
86
+
87
+ def _valid_https_url(value: str) -> bool:
88
+ parsed = urlparse(value)
89
+ return parsed.scheme == "https" and bool(parsed.netloc)
90
+
91
+
92
+ def _valid_iso_date(value: str) -> bool:
93
+ if not isinstance(value, str):
94
+ return False
95
+ try:
96
+ date.fromisoformat(value)
97
+ return True
98
+ except ValueError:
99
+ return False
100
+
101
+
102
+ def validate(registry: dict, directory: dict | None = None) -> list[str]:
103
+ errors: list[str] = []
104
+ if registry.get("schema_version") != 1:
105
+ errors.append("official source schema_version must be 1")
106
+ if not _valid_iso_date(registry.get("last_verified", "")):
107
+ errors.append("official source last_verified must be an ISO date (YYYY-MM-DD)")
108
+
109
+ jurisdictions = registry.get("jurisdictions")
110
+ if not isinstance(jurisdictions, list) or not jurisdictions:
111
+ return errors + ["official source jurisdictions must be a non-empty list"]
112
+
113
+ seen_ids: set[str] = set()
114
+ for index, jurisdiction in enumerate(jurisdictions):
115
+ prefix = f"official_sources.jurisdictions[{index}]"
116
+ jurisdiction_id = jurisdiction.get("id")
117
+ if not jurisdiction_id:
118
+ errors.append(f"{prefix}.id is required")
119
+ elif jurisdiction_id in seen_ids:
120
+ errors.append(f"duplicate official source jurisdiction id: {jurisdiction_id}")
121
+ else:
122
+ seen_ids.add(jurisdiction_id)
123
+
124
+ if jurisdiction.get("support_level") not in ALLOWED_LEVELS:
125
+ errors.append(f"{prefix}.support_level is invalid")
126
+
127
+ if not _valid_iso_date(jurisdiction.get("last_verified", "")):
128
+ errors.append(f"{prefix}.last_verified must be an ISO date (YYYY-MM-DD)")
129
+
130
+ sources = jurisdiction.get("sources")
131
+ if not isinstance(sources, list) or not sources:
132
+ errors.append(f"{prefix}.sources must be a non-empty list")
133
+ continue
134
+
135
+ has_rule_authority = False
136
+ for source_index, source in enumerate(sources):
137
+ source_prefix = f"{prefix}.sources[{source_index}]"
138
+ roles = set(source.get("roles", []))
139
+ unknown_roles = roles - ALLOWED_ROLES
140
+ if unknown_roles:
141
+ errors.append(f"{source_prefix}.roles contains {sorted(unknown_roles)}")
142
+ if "authoritative_rules" in roles:
143
+ has_rule_authority = True
144
+ if not _valid_https_url(source.get("url", "")):
145
+ errors.append(f"{source_prefix}.url must be an https URL")
146
+ for key in ("label", "authority", "covers"):
147
+ if not source.get(key):
148
+ errors.append(f"{source_prefix}.{key} is required")
149
+
150
+ if not has_rule_authority:
151
+ errors.append(f"{prefix} needs an authoritative_rules source")
152
+
153
+ if directory is None:
154
+ return errors
155
+ if directory.get("schema_version") != 1:
156
+ errors.append("jurisdiction directory schema_version must be 1")
157
+
158
+ directory_jurisdictions = directory.get("jurisdictions")
159
+ if not isinstance(directory_jurisdictions, list) or not directory_jurisdictions:
160
+ return errors + ["directory jurisdictions must be a non-empty list"]
161
+
162
+ directory_ids: set[str] = set()
163
+ directory_iso2: set[str] = set()
164
+ for index, jurisdiction in enumerate(directory_jurisdictions):
165
+ prefix = f"directory.jurisdictions[{index}]"
166
+ jurisdiction_id = jurisdiction.get("id")
167
+ iso2 = jurisdiction.get("iso2")
168
+ if not jurisdiction_id or not iso2 or not jurisdiction.get("name"):
169
+ errors.append(f"{prefix} requires id, iso2, and name")
170
+ continue
171
+ if jurisdiction_id in directory_ids:
172
+ errors.append(f"duplicate directory jurisdiction id: {jurisdiction_id}")
173
+ if iso2 in directory_iso2:
174
+ errors.append(f"duplicate directory ISO2: {iso2}")
175
+ directory_ids.add(jurisdiction_id)
176
+ directory_iso2.add(iso2)
177
+
178
+ for source_index, source in enumerate(directory.get("directory_sources", [])):
179
+ if not source.get("label") or not _valid_https_url(source.get("url", "")):
180
+ errors.append(f"directory.directory_sources[{source_index}] is invalid")
181
+
182
+ for jurisdiction in jurisdictions:
183
+ iso2 = jurisdiction.get("iso2")
184
+ if iso2 and iso2 not in directory_iso2:
185
+ errors.append(f"official source ISO2 not in directory: {iso2}")
186
+
187
+ return errors
188
+
189
+
190
+ def print_jurisdiction(destination: dict, seed: dict | None) -> None:
191
+ support_level = seed["support_level"] if seed else "live_discovery"
192
+ print(f"{destination['name']} [{destination['id']}] - {support_level}")
193
+ if not seed:
194
+ print("No cached official source seed. Live official-source discovery is required.")
195
+ return
196
+ if seed.get("notes"):
197
+ print(f"Note: {seed['notes']}")
198
+ for source in seed["sources"]:
199
+ roles = ", ".join(source["roles"])
200
+ print(f"- {source['label']} ({roles})")
201
+ print(f" {source['url']}")
202
+
203
+
204
+ def add_route_arguments(parser: argparse.ArgumentParser) -> None:
205
+ parser.add_argument("jurisdiction")
206
+ parser.add_argument("--nationality", default="Pending")
207
+ parser.add_argument("--passport-type", default="Ordinary passport")
208
+ parser.add_argument("--travel-document-issuer", default="Pending")
209
+ parser.add_argument("--residence", default="Pending")
210
+ parser.add_argument("--residence-status", default="Pending")
211
+ parser.add_argument("--application-location", default="Pending")
212
+ parser.add_argument("--purpose", default="Pending")
213
+ parser.add_argument("--arrival-date", default="Pending")
214
+ parser.add_argument("--duration", default="Pending")
215
+ parser.add_argument("--entries", default="Pending")
216
+ parser.add_argument("--arrival-mode", default="Pending")
217
+ parser.add_argument("--transit", default="None declared")
218
+ parser.add_argument("--existing-entry-rights", default="None declared")
219
+
220
+
221
+ def print_live_check_plan(destination: dict, seed: dict | None, args: argparse.Namespace) -> None:
222
+ print(f"Live visa-need check: {destination['name']}")
223
+ print(f"Nationality/status: {args.nationality}")
224
+ print(f"Travel document: {args.passport_type}; issuer: {args.travel_document_issuer}")
225
+ print(f"Legal residence/status: {args.residence}; {args.residence_status}")
226
+ print(f"Application location: {args.application_location}")
227
+ print(f"Purpose: {args.purpose}")
228
+ print(f"Arrival/duration/entries: {args.arrival_date}; {args.duration}; {args.entries}")
229
+ print(f"Arrival mode: {args.arrival_mode}")
230
+ print(f"Transit/self-transfer: {args.transit}")
231
+ print(f"Existing visas/residence permits: {args.existing_entry_rights}")
232
+ print("\nMandatory live research order:")
233
+ print("1. Open the destination immigration authority or foreign ministry.")
234
+ print("2. Find the current visa-exemption, ETA, eVisa, visa-on-arrival, and transit rules for this exact travel document.")
235
+ print("3. Check whether residence permits or third-country visas change eligibility.")
236
+ print("4. Resolve the responsible embassy/consulate and location-specific filing rules.")
237
+ print("5. Check Timatic/IATA for carrier boarding requirements; treat it as an operational cross-check, not legal authority.")
238
+ print("6. Record checked time, effective dates, conditions, conflicts, and a refresh deadline in the HTML.")
239
+ print("\nRequired result fields:")
240
+ print("- verdict: visa_free | eta | evisa | visa_on_arrival | consular_visa | transit_authorization | residence_route | mixed | unresolved")
241
+ print("- allowed purpose, maximum stay, entries, validity window, and extension rules")
242
+ print("- passport validity/blank pages, arrival ports/modes, onward travel, funds, insurance, accommodation, and registration conditions")
243
+ print("- transit and self-transfer result for every intermediate country")
244
+ print("- government source URLs, page dates/effective dates, checked timestamp, and conflict notes")
245
+ print("- Timatic/IATA or carrier cross-check result and checked timestamp")
246
+ print("\nOfficial starting sources:")
247
+ if seed:
248
+ for source in seed["sources"]:
249
+ print(f"- {source['label']}: {source['url']}")
250
+ else:
251
+ print(f"- Search for the official immigration authority or foreign ministry of {destination['name']}.")
252
+ print(f"- Search query: {destination['name']} official visa requirements {args.nationality} passport")
253
+ print(f"- Search query: {destination['name']} immigration eVisa ETA visa on arrival official")
254
+ print(f"- Search query: {destination['name']} embassy {args.application_location} visa official")
255
+ print(f"- IATA Travel Centre: {IATA_TRAVEL_CENTRE}")
256
+ print("\nGate: do not collect the full application form until the verdict is resolved or explicitly marked unresolved for applicant review.")
257
+
258
+
259
+ BROWSER_USER_AGENT = (
260
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
261
+ "(KHTML, like Gecko) Chrome/125.0 Safari/537.36"
262
+ )
263
+ # Government hosts routinely bot-block automated clients with these codes or by
264
+ # dropping the connection. That means "guarded", not "dead" — we warn, not fail.
265
+ GUARDED_STATUSES = {401, 403, 405, 406, 429, 503}
266
+
267
+
268
+ def check_urls(registry: dict, timeout: float) -> tuple[list[str], list[str]]:
269
+ """Probe every seeded source URL. Returns (dead, guarded).
270
+
271
+ `dead` = genuinely broken (404/410, DNS failure, refused connection) and
272
+ should fail CI. `guarded` = reachable but bot-blocked; reported as a warning
273
+ only, because government hosts commonly reject non-browser clients.
274
+ """
275
+ import urllib.error
276
+ import urllib.request
277
+
278
+ dead: list[str] = []
279
+ guarded: list[str] = []
280
+ checked = 0
281
+ for jurisdiction in registry["jurisdictions"]:
282
+ for source in jurisdiction.get("sources", []):
283
+ url = source.get("url", "")
284
+ checked += 1
285
+ request = urllib.request.Request(
286
+ url,
287
+ headers={"User-Agent": BROWSER_USER_AGENT},
288
+ )
289
+ try:
290
+ with urllib.request.urlopen(request, timeout=timeout) as response:
291
+ status = response.status
292
+ except urllib.error.HTTPError as error:
293
+ if error.code in GUARDED_STATUSES:
294
+ guarded.append(f"{jurisdiction['id']} {url} -> HTTP {error.code}")
295
+ else:
296
+ dead.append(f"{jurisdiction['id']} {url} -> HTTP {error.code}")
297
+ continue
298
+ except urllib.error.URLError as error:
299
+ # A dropped connection from a live host is typically anti-bot;
300
+ # a DNS/name-resolution failure is a genuinely dead host.
301
+ reason = str(error.reason)
302
+ if "Name or service not known" in reason or "nodename" in reason:
303
+ dead.append(f"{jurisdiction['id']} {url} -> {error.reason}")
304
+ else:
305
+ guarded.append(f"{jurisdiction['id']} {url} -> {error.reason}")
306
+ continue
307
+ except Exception as error: # noqa: BLE001
308
+ guarded.append(f"{jurisdiction['id']} {url} -> {error}")
309
+ continue
310
+ if status in {404, 410}:
311
+ dead.append(f"{jurisdiction['id']} {url} -> HTTP {status}")
312
+ elif status >= 400:
313
+ guarded.append(f"{jurisdiction['id']} {url} -> HTTP {status}")
314
+ print(
315
+ f"Probed {checked} source URLs; {len(dead)} dead, {len(guarded)} guarded/unverified."
316
+ )
317
+ return dead, guarded
318
+
319
+
320
+ def main() -> None:
321
+ parser = argparse.ArgumentParser(description=__doc__)
322
+ subparsers = parser.add_subparsers(dest="command", required=True)
323
+ subparsers.add_parser("validate")
324
+ check_urls_parser = subparsers.add_parser("check-urls")
325
+ check_urls_parser.add_argument("--timeout", type=float, default=20.0)
326
+ subparsers.add_parser("coverage")
327
+ list_parser = subparsers.add_parser("list")
328
+ list_parser.add_argument("--seeded-only", action="store_true")
329
+ show_parser = subparsers.add_parser("show")
330
+ show_parser.add_argument("jurisdiction")
331
+ add_route_arguments(subparsers.add_parser("live-check-plan"))
332
+ add_route_arguments(subparsers.add_parser("research-plan"))
333
+ args = parser.parse_args()
334
+
335
+ registry = load_registry()
336
+ directory = load_directory()
337
+ errors = validate(registry, directory)
338
+ if errors:
339
+ for error in errors:
340
+ print(f"ERROR: {error}")
341
+ raise SystemExit(1)
342
+
343
+ seeded_iso2 = {
344
+ jurisdiction["iso2"]
345
+ for jurisdiction in registry["jurisdictions"]
346
+ if jurisdiction.get("iso2")
347
+ }
348
+ if args.command == "validate":
349
+ print(
350
+ f"Valid registries: {len(directory['jurisdictions'])} destinations; "
351
+ f"{len(registry['jurisdictions'])} official source seeds"
352
+ )
353
+ elif args.command == "check-urls":
354
+ dead, guarded = check_urls(registry, args.timeout)
355
+ for warning in guarded:
356
+ print(f"GUARDED: {warning}")
357
+ for failure in dead:
358
+ print(f"DEAD: {failure}")
359
+ if dead:
360
+ raise SystemExit(1)
361
+ elif args.command == "coverage":
362
+ print(f"Searchable destinations: {len(directory['jurisdictions'])}")
363
+ print(f"Destinations with cached official seeds: {len(seeded_iso2)}")
364
+ print(f"Destinations requiring live source discovery: {len(directory['jurisdictions']) - len(seeded_iso2)}")
365
+ print(f"Additional route areas with seeds: {len(registry['jurisdictions']) - len(seeded_iso2)}")
366
+ elif args.command == "list":
367
+ for destination in directory["jurisdictions"]:
368
+ seed = find_jurisdiction(registry, destination["iso2"])
369
+ if args.seeded_only and not seed:
370
+ continue
371
+ support_level = seed["support_level"] if seed else "live_discovery"
372
+ print(f"{destination['id']}\t{destination['name']}\t{support_level}")
373
+ elif args.command == "show":
374
+ result = find_destination(registry, directory, args.jurisdiction)
375
+ if not result:
376
+ raise SystemExit(f"Unknown destination: {args.jurisdiction}")
377
+ print_jurisdiction(*result)
378
+ elif args.command in {"live-check-plan", "research-plan"}:
379
+ result = find_destination(registry, directory, args.jurisdiction)
380
+ if not result:
381
+ raise SystemExit(f"Unknown destination: {args.jurisdiction}")
382
+ print_live_check_plan(*result, args)
383
+
384
+
385
+ if __name__ == "__main__":
386
+ main()