diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0fe0a30 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +equasis-cli +Copyright (c) 2025-2026 rhinonix + +This software includes third-party data: + +Maritime Identification Digits (src/equasis_cli/enrichment/data/mids.json) + Derived from the ITU Maritime Identification Digits table as compiled in + https://github.com/michaeljfazio/MIDs, licensed under the Apache License, + Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0). + +Country codes (src/equasis_cli/enrichment/data/countries.json) + ISO 3166-1 country names and codes. + +Equasis is a trademark of its respective owner. equasis-cli is not affiliated +with or endorsed by Equasis. diff --git a/pyproject.toml b/pyproject.toml index 68fa372..162e1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Command-line access to Equasis maritime data: vessel profiles, PS readme = "README.md" requires-python = ">=3.10" license = "PolyForm-Noncommercial-1.0.0" -license-files = ["LICENSE", "LICENSING.md"] +license-files = ["LICENSE", "LICENSING.md", "NOTICE"] authors = [{ name = "rhinonix", email = "rhinonix.github.exclaim769@slmail.me" }] keywords = [ "equasis", @@ -71,7 +71,7 @@ version = { attr = "equasis_cli._version.__version__" } where = ["src"] [tool.setuptools.package-data] -equasis_cli = ["py.typed"] +equasis_cli = ["py.typed", "enrichment/data/*.json"] [tool.ruff] line-length = 100 diff --git a/src/equasis_cli/cache.py b/src/equasis_cli/cache.py new file mode 100644 index 0000000..e9ecfa7 --- /dev/null +++ b/src/equasis_cli/cache.py @@ -0,0 +1,147 @@ +"""On-disk cache of Equasis pages. + +Caching avoids repeating identical requests within a short period, which keeps +load on Equasis low, makes repeated lookups instant, and lets an interrupted +batch be re-run without fetching completed items again. + +Pages are stored as HTML files named by a hash of the request. Only regular +content pages are cached; login, error, and not-found pages never are. Cached +pages include the name of the Equasis account that fetched them, so the cache +directory is created with owner-only permissions. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +import time +import uuid +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +APP_NAME = "equasis-cli" +DEFAULT_TTL = timedelta(hours=24) + + +def default_cache_dir() -> Path: + """Return the platform-appropriate cache directory.""" + override = os.environ.get("EQUASIS_CACHE_DIR") + if override: + return Path(override).expanduser() + xdg = os.environ.get("XDG_CACHE_HOME") + if xdg: + return Path(xdg) / APP_NAME + if sys.platform == "win32": + local = os.environ.get("LOCALAPPDATA") + if local: + return Path(local) / APP_NAME / "Cache" + return Path.home() / ".cache" / APP_NAME + + +@dataclass(frozen=True) +class CachedPage: + html: str + fetched_at: datetime + + +@dataclass(frozen=True) +class CacheStats: + directory: Path + entries: int + size_bytes: int + oldest: datetime | None + newest: datetime | None + + +class PageCache: + """Stores Equasis pages for ``ttl`` after they were fetched.""" + + def __init__( + self, + directory: Path | None = None, + *, + ttl: timedelta = DEFAULT_TTL, + clock: Callable[[], float] = time.time, + ) -> None: + self.directory = directory or default_cache_dir() + self.ttl = ttl + self._clock = clock + + @property + def pages_dir(self) -> Path: + return self.directory / "pages" + + @staticmethod + def key( + method: str, + path: str, + params: Mapping[str, str] | None = None, + data: Mapping[str, str] | None = None, + ) -> str: + """Stable identifier for a request.""" + payload = json.dumps( + [method.upper(), path, sorted((params or {}).items()), sorted((data or {}).items())], + ensure_ascii=False, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def _path(self, key: str) -> Path: + return self.pages_dir / f"{key}.html" + + def get(self, key: str) -> CachedPage | None: + """Return the cached page if it exists and is younger than the TTL.""" + path = self._path(key) + try: + modified = path.stat().st_mtime + except OSError: + return None + if self._clock() - modified > self.ttl.total_seconds(): + return None + try: + html = path.read_text(encoding="utf-8") + except OSError: + return None + return CachedPage(html=html, fetched_at=datetime.fromtimestamp(modified, timezone.utc)) + + def put(self, key: str, html: str) -> None: + """Store a page atomically.""" + self.pages_dir.mkdir(parents=True, exist_ok=True) + if os.name == "posix": + for directory in (self.directory, self.pages_dir): + directory.chmod(0o700) + target = self._path(key) + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_text(html, encoding="utf-8") + os.replace(temporary, target) + except OSError: + temporary.unlink(missing_ok=True) + + def clear(self) -> int: + """Delete every cached page. Returns the number of pages removed.""" + removed = 0 + if not self.pages_dir.is_dir(): + return removed + for page in self.pages_dir.glob("*.html"): + try: + page.unlink() + removed += 1 + except OSError: + continue + return removed + + def stats(self) -> CacheStats: + pages = list(self.pages_dir.glob("*.html")) if self.pages_dir.is_dir() else [] + stamps = [p.stat() for p in pages] + times = [datetime.fromtimestamp(s.st_mtime, timezone.utc) for s in stamps] + return CacheStats( + directory=self.directory, + entries=len(pages), + size_bytes=sum(s.st_size for s in stamps), + oldest=min(times) if times else None, + newest=max(times) if times else None, + ) diff --git a/src/equasis_cli/cli.py b/src/equasis_cli/cli.py index e24b16b..40749ca 100644 --- a/src/equasis_cli/cli.py +++ b/src/equasis_cli/cli.py @@ -18,6 +18,7 @@ from . import output from ._version import __version__ from .banner import DISCLAIMER +from .cache import DEFAULT_TTL, PageCache, default_cache_dir from .client import DEFAULT_SEARCH_PAGES, EquasisClient from .console import Console from .credentials import CredentialStore @@ -46,6 +47,9 @@ logger = logging.getLogger("equasis_cli") +ClientFactory = Callable[..., EquasisClient] +"""Called as ``factory(username, password, transport=..., cache=..., refresh=...)``.""" + class ExitCode(enum.IntEnum): OK = 0 @@ -151,6 +155,18 @@ def default(value: Any) -> Any: default=default(None), help="save every Equasis page to DIR for troubleshooting (pages include your account name)", ) + connection.add_argument( + "--no-cache", + action="store_true", + default=default(False), + help="do not read or write the page cache", + ) + connection.add_argument( + "--refresh", + action="store_true", + default=default(False), + help="fetch fresh pages even if cached copies exist", + ) connection.add_argument( "--no-banner", action="store_true", default=default(False), help=argparse.SUPPRESS ) @@ -281,6 +297,17 @@ def build_parser() -> argparse.ArgumentParser: action.add_argument("--clear", action="store_true", help="delete stored credentials") _add_common_options(configure, subcommand=True) + cache = commands.add_parser( + "cache", + help="show or clear cached Equasis pages", + description=( + f"Equasis pages are cached for {int(DEFAULT_TTL.total_seconds() // 3600)} hours in " + f"{default_cache_dir()} (set EQUASIS_CACHE_DIR to change it)." + ), + ) + cache.add_argument("action", nargs="?", choices=("info", "clear"), default="info") + _add_common_options(cache, subcommand=True) + commands.add_parser("interactive", help="start the interactive shell") return parser @@ -299,7 +326,7 @@ def __init__( stdout: IO[str], stdin: IO[str], store: CredentialStore, - client_factory: Callable[[str, str, Transport], EquasisClient], + client_factory: ClientFactory, ) -> None: self.args = args self.console = console @@ -321,7 +348,13 @@ def client(self) -> EquasisClient: if credentials is None: raise ConfigurationError("no Equasis credentials found") transport = Transport(min_interval=max(0.0, args.delay), save_html_dir=args.save_html) - return self.client_factory(credentials.username, credentials.password, transport) + return self.client_factory( + credentials.username, + credentials.password, + transport=transport, + cache=None if args.no_cache else PageCache(), + refresh=args.refresh, + ) @property def format(self) -> str: @@ -356,6 +389,7 @@ def run(self) -> int: "search": self.search, "fleet": self.fleet, "configure": self.configure, + "cache": self.cache, }[command] return handler() @@ -547,6 +581,26 @@ def _report_item(self, item: BatchItem[Any]) -> None: if item.status is not ItemStatus.OK: self.console.warn(f"{item.query}: {item.error}") + def cache(self) -> int: + cache = PageCache() + if self.args.action == "clear": + removed = cache.clear() + self.console.success(f"Removed {removed} cached page{'s' if removed != 1 else ''}") + return ExitCode.OK + stats = cache.stats() + lines = [ + f"Cache directory: {stats.directory}", + f"Cached pages: {stats.entries} ({stats.size_bytes / 1_048_576:.1f} MB)", + f"Expiry: {int(cache.ttl.total_seconds() // 3600)} hours after retrieval", + ] + if stats.oldest and stats.newest: + lines.append( + f"Stored between: {stats.oldest:%Y-%m-%d %H:%M} and " + f"{stats.newest:%Y-%m-%d %H:%M} UTC" + ) + self.stdout.write("\n".join(lines) + "\n") + return ExitCode.OK + def configure(self) -> int: args = self.args store = self.store @@ -602,7 +656,7 @@ def _setup(self) -> int: transport = Transport(min_interval=0) try: self.console.progress("Checking the credentials with Equasis...") - self.client_factory(username, password, transport).login() + self.client_factory(username, password, transport=transport).login() self.console.success("Login succeeded.") except AuthenticationError as exc: self.console.error(str(exc)) @@ -649,7 +703,7 @@ def main( stdin: IO[str] | None = None, stderr: IO[str] | None = None, store: CredentialStore | None = None, - client_factory: Callable[[str, str, Transport], EquasisClient] | None = None, + client_factory: ClientFactory | None = None, ) -> int: """Run the CLI and return the exit status.""" stdout = stdout or sys.stdout @@ -678,12 +732,7 @@ def main( stdout=stdout, stdin=stdin, store=store or CredentialStore(), - client_factory=client_factory - or ( - lambda username, password, transport: EquasisClient( - username, password, transport=transport - ) - ), + client_factory=client_factory or EquasisClient, ) try: return runner.run() diff --git a/src/equasis_cli/client.py b/src/equasis_cli/client.py index c1a76ea..1c0d3dd 100644 --- a/src/equasis_cli/client.py +++ b/src/equasis_cli/client.py @@ -4,12 +4,15 @@ import logging from collections.abc import Mapping +from datetime import datetime, timezone from types import TracebackType +from .cache import PageCache from .exceptions import ( AuthenticationError, EquasisError, LayoutChangedError, + NetworkError, NotFoundError, SessionExpiredError, ) @@ -51,13 +54,30 @@ class EquasisClient: vessel = client.get_vessel("9811000") """ - def __init__(self, username: str, password: str, *, transport: Transport | None = None): + def __init__( + self, + username: str, + password: str, + *, + transport: Transport | None = None, + cache: PageCache | None = None, + refresh: bool = False, + ): + """ + Args: + transport: HTTP transport (pacing, retries, response capture). + cache: Optional page cache. Cached pages are used until they expire. + refresh: Ignore cached pages but still store fresh ones. + """ if not username or not password: raise AuthenticationError("an Equasis username and password are required") self._username = username self._password = password self.transport = transport or Transport() + self.cache = cache + self.refresh = refresh self._logged_in = False + self._fetch_times: list[datetime] = [] # ------------------------------------------------------------------ lifecycle @@ -106,8 +126,21 @@ def _page( *, params: Mapping[str, str] | None = None, data: Mapping[str, str] | None = None, + use_cache: bool = True, ) -> str: - """Fetch an authenticated page, logging in again once if the session expired.""" + """Fetch an authenticated page, logging in again once if the session expired. + + Regular content pages are served from and stored in the cache when one is + configured. + """ + key = PageCache.key(method, path, params, data) if self.cache else None + if self.cache and key and use_cache and not self.refresh: + cached = self.cache.get(key) + if cached is not None: + logger.debug("%s %s served from cache", method, path) + self._fetch_times.append(cached.fetched_at) + return cached.html + if not self._logged_in: self.login() html = self.transport.request(method, path, params=params, data=data) @@ -118,8 +151,22 @@ def _page( if classify(html) is PageKind.LOGIN: self._logged_in = False raise SessionExpiredError("Equasis ended the session and it could not be renewed") + if self.cache and key and classify(html) is PageKind.CONTENT: + self.cache.put(key, html) + self._fetch_times.append(datetime.now(timezone.utc)) return html + def _is_cached(self, method: str, path: str, data: Mapping[str, str]) -> bool: + if not self.cache or self.refresh: + return False + return self.cache.get(PageCache.key(method, path, None, data)) is not None + + def _start(self) -> None: + self._fetch_times = [] + + def _retrieved_at(self) -> datetime: + return min(self._fetch_times) if self._fetch_times else datetime.now(timezone.utc) + # -------------------------------------------------------------------- vessels def get_vessel(self, imo: str) -> Vessel: @@ -134,6 +181,7 @@ def get_vessel(self, imo: str) -> Vessel: LayoutChangedError: if the Ship Info page cannot be parsed. """ imo = normalize_imo(imo) + self._start() params = {"fs": "ShipInfo", "P_IMO": imo} try: vessel = parse_ship_info(self._page("GET", "restricted/ShipInfo", params=params)) @@ -156,7 +204,9 @@ def get_vessel(self, imo: str) -> Vessel: vessel.warnings.append(f"ship history unavailable: {exc}") logger.warning("could not parse ship history for IMO %s: %s", imo, exc) - return merge_vessel(vessel, inspections, history) + vessel = merge_vessel(vessel, inspections, history) + vessel.retrieved_at = self._retrieved_at() + return vessel # --------------------------------------------------------------------- search @@ -180,6 +230,7 @@ def search( query = query.strip() if not query: raise EquasisError("search text must not be empty") + self._start() first = parse_search( self._page( "POST", @@ -229,6 +280,7 @@ def follow_up(tab: str, page_number: int) -> dict[str, str]: break results.companies.extend(new_companies) + results.retrieved_at = self._retrieved_at() return results def search_ships( @@ -241,6 +293,7 @@ def search_ships( """Look up ships by exact identifier using Equasis advanced search.""" if not any((imo, mmsi, call_sign)): raise EquasisError("provide an IMO number, MMSI, or call sign") + self._start() data = { "P_PAGE": "1", "P_PAGE_COMP": "1", @@ -259,7 +312,12 @@ def search_ships( for label, value in (("imo", imo), ("mmsi", mmsi), ("call_sign", call_sign)) if value ) - return SearchResults(query=query, ships=page.ships, total_ships=len(page.ships)) + return SearchResults( + query=query, + ships=page.ships, + total_ships=len(page.ships), + retrieved_at=self._retrieved_at(), + ) # ---------------------------------------------------------------------- fleet @@ -271,13 +329,13 @@ def get_fleet(self, company_id: str, *, max_pages: int | None = None) -> Fleet: NotFoundError: if Equasis has no company with this number. """ company_id = normalize_company_id(company_id) + self._start() - def fetch(page_number: int) -> str: - return self._page( - "POST", - _FLEET_PATH, - data={"P_PAGE": str(page_number), "P_COMP": company_id, "ongletActifSC": "comp"}, - ) + def form(page_number: int) -> dict[str, str]: + return {"P_PAGE": str(page_number), "P_COMP": company_id, "ongletActifSC": "comp"} + + def fetch(page_number: int, *, use_cache: bool = True) -> str: + return self._page("POST", _FLEET_PATH, data=form(page_number), use_cache=use_cache) html = fetch(1) if classify(html) in (PageKind.NOT_FOUND, PageKind.ERROR): @@ -288,13 +346,29 @@ def fetch(page_number: int) -> str: ) last = min(_page_limit(first.last_page, max_pages), MAX_FLEET_PAGES) + # Equasis only serves later fleet pages after page 1 in the same session. + session_ready = not self._is_cached("POST", _FLEET_PATH, form(1)) or all( + self._is_cached("POST", _FLEET_PATH, form(n)) for n in range(2, last + 1) + ) for number in range(2, last + 1): - page = parse_fleet(fetch(number)) + if not session_ready and not self._is_cached("POST", _FLEET_PATH, form(number)): + fetch(1, use_cache=False) + session_ready = True + try: + page_html = fetch(number) + except NetworkError as exc: + if exc.status_code != 500: + raise + logger.info("fleet page %s refused; reloading page 1 and retrying", number) + fetch(1, use_cache=False) + page_html = fetch(number, use_cache=False) + page = parse_fleet(page_html) known = {v.imo for v in fleet.vessels} new = [v for v in page.vessels if v.imo not in known] if not new: break fleet.vessels.extend(new) + fleet.retrieved_at = self._retrieved_at() return fleet diff --git a/src/equasis_cli/enrichment/__init__.py b/src/equasis_cli/enrichment/__init__.py new file mode 100644 index 0000000..eb298dd --- /dev/null +++ b/src/equasis_cli/enrichment/__init__.py @@ -0,0 +1,13 @@ +"""Derived intelligence: indicators and reference data.""" + +from .indicators import VesselIndicators, compute_indicators +from .reference import Country, country, country_by_name, mmsi_country + +__all__ = [ + "Country", + "VesselIndicators", + "compute_indicators", + "country", + "country_by_name", + "mmsi_country", +] diff --git a/src/equasis_cli/enrichment/data/countries.json b/src/equasis_cli/enrichment/data/countries.json new file mode 100644 index 0000000..458811b --- /dev/null +++ b/src/equasis_cli/enrichment/data/countries.json @@ -0,0 +1,1043 @@ +{ + "source": "ISO 3166-1", + "countries": { + "ABW": { + "alpha2": "AW", + "name": "Aruba" + }, + "AFG": { + "alpha2": "AF", + "name": "Afghanistan" + }, + "AGO": { + "alpha2": "AO", + "name": "Angola" + }, + "AIA": { + "alpha2": "AI", + "name": "Anguilla" + }, + "ALB": { + "alpha2": "AL", + "name": "Albania" + }, + "AND": { + "alpha2": "AD", + "name": "Andorra" + }, + "ARE": { + "alpha2": "AE", + "name": "United Arab Emirates" + }, + "ARG": { + "alpha2": "AR", + "name": "Argentina" + }, + "ARM": { + "alpha2": "AM", + "name": "Armenia" + }, + "ASM": { + "alpha2": "AS", + "name": "American Samoa" + }, + "ATA": { + "alpha2": "AQ", + "name": "Antarctica" + }, + "ATF": { + "alpha2": "TF", + "name": "French Southern Territories" + }, + "ATG": { + "alpha2": "AG", + "name": "Antigua and Barbuda" + }, + "AUS": { + "alpha2": "AU", + "name": "Australia" + }, + "AUT": { + "alpha2": "AT", + "name": "Austria" + }, + "AZE": { + "alpha2": "AZ", + "name": "Azerbaijan" + }, + "BDI": { + "alpha2": "BI", + "name": "Burundi" + }, + "BEL": { + "alpha2": "BE", + "name": "Belgium" + }, + "BEN": { + "alpha2": "BJ", + "name": "Benin" + }, + "BES": { + "alpha2": "BQ", + "name": "Bonaire" + }, + "BFA": { + "alpha2": "BF", + "name": "Burkina Faso" + }, + "BGD": { + "alpha2": "BD", + "name": "Bangladesh" + }, + "BGR": { + "alpha2": "BG", + "name": "Bulgaria" + }, + "BHR": { + "alpha2": "BH", + "name": "Bahrain" + }, + "BHS": { + "alpha2": "BS", + "name": "Bahamas" + }, + "BIH": { + "alpha2": "BA", + "name": "Bosnia and Herzegovina" + }, + "BLM": { + "alpha2": "BL", + "name": "Saint Barthelemy" + }, + "BLR": { + "alpha2": "BY", + "name": "Belarus" + }, + "BLZ": { + "alpha2": "BZ", + "name": "Belize" + }, + "BMU": { + "alpha2": "BM", + "name": "Bermuda" + }, + "BOL": { + "alpha2": "BO", + "name": "Bolivia" + }, + "BRA": { + "alpha2": "BR", + "name": "Brazil" + }, + "BRB": { + "alpha2": "BB", + "name": "Barbados" + }, + "BRN": { + "alpha2": "BN", + "name": "Brunei Darussalam" + }, + "BTN": { + "alpha2": "BT", + "name": "Bhutan" + }, + "BVT": { + "alpha2": "BV", + "name": "Bouvet Island" + }, + "BWA": { + "alpha2": "BW", + "name": "Botswana" + }, + "CAF": { + "alpha2": "CF", + "name": "Central African Republic" + }, + "CAN": { + "alpha2": "CA", + "name": "Canada" + }, + "CCK": { + "alpha2": "CC", + "name": "Cocos Islands" + }, + "CHE": { + "alpha2": "CH", + "name": "Switzerland" + }, + "CHL": { + "alpha2": "CL", + "name": "Chile" + }, + "CHN": { + "alpha2": "CN", + "name": "China" + }, + "CIV": { + "alpha2": "CI", + "name": "Cote d'Ivoire" + }, + "CMR": { + "alpha2": "CM", + "name": "Cameroon" + }, + "COD": { + "alpha2": "CD", + "name": "Democratic Republic of the Congo" + }, + "COG": { + "alpha2": "CG", + "name": "Congo" + }, + "COK": { + "alpha2": "CK", + "name": "Cook Islands" + }, + "COL": { + "alpha2": "CO", + "name": "Colombia" + }, + "COM": { + "alpha2": "KM", + "name": "Comoros" + }, + "CPV": { + "alpha2": "CV", + "name": "Cape Verde" + }, + "CRI": { + "alpha2": "CR", + "name": "Costa Rica" + }, + "CUB": { + "alpha2": "CU", + "name": "Cuba" + }, + "CUW": { + "alpha2": "CW", + "name": "Curacao" + }, + "CXR": { + "alpha2": "CX", + "name": "Christmas Island" + }, + "CYM": { + "alpha2": "KY", + "name": "Cayman Islands" + }, + "CYP": { + "alpha2": "CY", + "name": "Cyprus" + }, + "CZE": { + "alpha2": "CZ", + "name": "Czechia" + }, + "DEU": { + "alpha2": "DE", + "name": "Germany" + }, + "DJI": { + "alpha2": "DJ", + "name": "Djibouti" + }, + "DMA": { + "alpha2": "DM", + "name": "Dominica" + }, + "DNK": { + "alpha2": "DK", + "name": "Denmark" + }, + "DOM": { + "alpha2": "DO", + "name": "Dominican Republic" + }, + "DZA": { + "alpha2": "DZ", + "name": "Algeria" + }, + "ECU": { + "alpha2": "EC", + "name": "Ecuador" + }, + "EGY": { + "alpha2": "EG", + "name": "Egypt" + }, + "ERI": { + "alpha2": "ER", + "name": "Eritrea" + }, + "ESH": { + "alpha2": "EH", + "name": "Western Sahara" + }, + "ESP": { + "alpha2": "ES", + "name": "Spain" + }, + "EST": { + "alpha2": "EE", + "name": "Estonia" + }, + "ETH": { + "alpha2": "ET", + "name": "Ethiopia" + }, + "FIN": { + "alpha2": "FI", + "name": "Finland" + }, + "FJI": { + "alpha2": "FJ", + "name": "Fiji" + }, + "FLK": { + "alpha2": "FK", + "name": "Falkland Islands" + }, + "FRA": { + "alpha2": "FR", + "name": "France" + }, + "FRO": { + "alpha2": "FO", + "name": "Faroe Islands" + }, + "FSM": { + "alpha2": "FM", + "name": "Micronesia" + }, + "GAB": { + "alpha2": "GA", + "name": "Gabon" + }, + "GBR": { + "alpha2": "GB", + "name": "United Kingdom" + }, + "GEO": { + "alpha2": "GE", + "name": "Georgia" + }, + "GGY": { + "alpha2": "GG", + "name": "Guernsey" + }, + "GHA": { + "alpha2": "GH", + "name": "Ghana" + }, + "GIB": { + "alpha2": "GI", + "name": "Gibraltar" + }, + "GIN": { + "alpha2": "GN", + "name": "Guinea" + }, + "GLP": { + "alpha2": "GP", + "name": "Guadeloupe" + }, + "GMB": { + "alpha2": "GM", + "name": "Gambia" + }, + "GNB": { + "alpha2": "GW", + "name": "Guinea-Bissau" + }, + "GNQ": { + "alpha2": "GQ", + "name": "Equatorial Guinea" + }, + "GRC": { + "alpha2": "GR", + "name": "Greece" + }, + "GRD": { + "alpha2": "GD", + "name": "Grenada" + }, + "GRL": { + "alpha2": "GL", + "name": "Greenland" + }, + "GTM": { + "alpha2": "GT", + "name": "Guatemala" + }, + "GUF": { + "alpha2": "GF", + "name": "French Guiana" + }, + "GUM": { + "alpha2": "GU", + "name": "Guam" + }, + "GUY": { + "alpha2": "GY", + "name": "Guyana" + }, + "HKG": { + "alpha2": "HK", + "name": "Hong Kong" + }, + "HND": { + "alpha2": "HN", + "name": "Honduras" + }, + "HRV": { + "alpha2": "HR", + "name": "Croatia" + }, + "HTI": { + "alpha2": "HT", + "name": "Haiti" + }, + "HUN": { + "alpha2": "HU", + "name": "Hungary" + }, + "IDN": { + "alpha2": "ID", + "name": "Indonesia" + }, + "IMN": { + "alpha2": "IM", + "name": "Isle of Man" + }, + "IND": { + "alpha2": "IN", + "name": "India" + }, + "IOT": { + "alpha2": "IO", + "name": "British Indian Ocean Territory" + }, + "IRL": { + "alpha2": "IE", + "name": "Ireland" + }, + "IRN": { + "alpha2": "IR", + "name": "Iran" + }, + "IRQ": { + "alpha2": "IQ", + "name": "Iraq" + }, + "ISL": { + "alpha2": "IS", + "name": "Iceland" + }, + "ISR": { + "alpha2": "IL", + "name": "Israel" + }, + "ITA": { + "alpha2": "IT", + "name": "Italy" + }, + "JAM": { + "alpha2": "JM", + "name": "Jamaica" + }, + "JEY": { + "alpha2": "JE", + "name": "Jersey" + }, + "JOR": { + "alpha2": "JO", + "name": "Jordan" + }, + "JPN": { + "alpha2": "JP", + "name": "Japan" + }, + "KAZ": { + "alpha2": "KZ", + "name": "Kazakhstan" + }, + "KEN": { + "alpha2": "KE", + "name": "Kenya" + }, + "KGZ": { + "alpha2": "KG", + "name": "Kyrgyzstan" + }, + "KHM": { + "alpha2": "KH", + "name": "Cambodia" + }, + "KIR": { + "alpha2": "KI", + "name": "Kiribati" + }, + "KNA": { + "alpha2": "KN", + "name": "Saint Kitts and Nevis" + }, + "KOR": { + "alpha2": "KR", + "name": "South Korea" + }, + "KWT": { + "alpha2": "KW", + "name": "Kuwait" + }, + "LAO": { + "alpha2": "LA", + "name": "Laos" + }, + "LBN": { + "alpha2": "LB", + "name": "Lebanon" + }, + "LBR": { + "alpha2": "LR", + "name": "Liberia" + }, + "LBY": { + "alpha2": "LY", + "name": "Libya" + }, + "LCA": { + "alpha2": "LC", + "name": "Saint Lucia" + }, + "LIE": { + "alpha2": "LI", + "name": "Liechtenstein" + }, + "LKA": { + "alpha2": "LK", + "name": "Sri Lanka" + }, + "LSO": { + "alpha2": "LS", + "name": "Lesotho" + }, + "LTU": { + "alpha2": "LT", + "name": "Lithuania" + }, + "LUX": { + "alpha2": "LU", + "name": "Luxembourg" + }, + "LVA": { + "alpha2": "LV", + "name": "Latvia" + }, + "MAC": { + "alpha2": "MO", + "name": "Macao" + }, + "MAF": { + "alpha2": "MF", + "name": "Saint Martin" + }, + "MAR": { + "alpha2": "MA", + "name": "Morocco" + }, + "MCO": { + "alpha2": "MC", + "name": "Monaco" + }, + "MDA": { + "alpha2": "MD", + "name": "Moldova" + }, + "MDG": { + "alpha2": "MG", + "name": "Madagascar" + }, + "MDV": { + "alpha2": "MV", + "name": "Maldives" + }, + "MEX": { + "alpha2": "MX", + "name": "Mexico" + }, + "MHL": { + "alpha2": "MH", + "name": "Marshall Islands" + }, + "MKD": { + "alpha2": "MK", + "name": "North Macedonia" + }, + "MLI": { + "alpha2": "ML", + "name": "Mali" + }, + "MLT": { + "alpha2": "MT", + "name": "Malta" + }, + "MMR": { + "alpha2": "MM", + "name": "Myanmar" + }, + "MNE": { + "alpha2": "ME", + "name": "Montenegro" + }, + "MNG": { + "alpha2": "MN", + "name": "Mongolia" + }, + "MNP": { + "alpha2": "MP", + "name": "Northern Mariana Islands" + }, + "MOZ": { + "alpha2": "MZ", + "name": "Mozambique" + }, + "MRT": { + "alpha2": "MR", + "name": "Mauritania" + }, + "MSR": { + "alpha2": "MS", + "name": "Montserrat" + }, + "MTQ": { + "alpha2": "MQ", + "name": "Martinique" + }, + "MUS": { + "alpha2": "MU", + "name": "Mauritius" + }, + "MWI": { + "alpha2": "MW", + "name": "Malawi" + }, + "MYS": { + "alpha2": "MY", + "name": "Malaysia" + }, + "MYT": { + "alpha2": "YT", + "name": "Mayotte" + }, + "NAM": { + "alpha2": "NA", + "name": "Namibia" + }, + "NCL": { + "alpha2": "NC", + "name": "New Caledonia" + }, + "NER": { + "alpha2": "NE", + "name": "Niger" + }, + "NFK": { + "alpha2": "NF", + "name": "Norfolk Island" + }, + "NGA": { + "alpha2": "NG", + "name": "Nigeria" + }, + "NIC": { + "alpha2": "NI", + "name": "Nicaragua" + }, + "NIU": { + "alpha2": "NU", + "name": "Niue" + }, + "NLD": { + "alpha2": "NL", + "name": "Netherlands" + }, + "NOR": { + "alpha2": "NO", + "name": "Norway" + }, + "NPL": { + "alpha2": "NP", + "name": "Nepal" + }, + "NRU": { + "alpha2": "NR", + "name": "Nauru" + }, + "NZL": { + "alpha2": "NZ", + "name": "New Zealand" + }, + "OMN": { + "alpha2": "OM", + "name": "Oman" + }, + "PAK": { + "alpha2": "PK", + "name": "Pakistan" + }, + "PAN": { + "alpha2": "PA", + "name": "Panama" + }, + "PCN": { + "alpha2": "PN", + "name": "Pitcairn" + }, + "PER": { + "alpha2": "PE", + "name": "Peru" + }, + "PHL": { + "alpha2": "PH", + "name": "Philippines" + }, + "PLW": { + "alpha2": "PW", + "name": "Palau" + }, + "PNG": { + "alpha2": "PG", + "name": "Papua New Guinea" + }, + "POL": { + "alpha2": "PL", + "name": "Poland" + }, + "PRI": { + "alpha2": "PR", + "name": "Puerto Rico" + }, + "PRK": { + "alpha2": "KP", + "name": "North Korea" + }, + "PRT": { + "alpha2": "PT", + "name": "Portugal" + }, + "PRY": { + "alpha2": "PY", + "name": "Paraguay" + }, + "PSE": { + "alpha2": "PS", + "name": "Palestine" + }, + "PYF": { + "alpha2": "PF", + "name": "French Polynesia" + }, + "QAT": { + "alpha2": "QA", + "name": "Qatar" + }, + "REU": { + "alpha2": "RE", + "name": "Reunion" + }, + "ROU": { + "alpha2": "RO", + "name": "Romania" + }, + "RUS": { + "alpha2": "RU", + "name": "Russia" + }, + "RWA": { + "alpha2": "RW", + "name": "Rwanda" + }, + "SAU": { + "alpha2": "SA", + "name": "Saudi Arabia" + }, + "SDN": { + "alpha2": "SD", + "name": "Sudan" + }, + "SEN": { + "alpha2": "SN", + "name": "Senegal" + }, + "SGP": { + "alpha2": "SG", + "name": "Singapore" + }, + "SGS": { + "alpha2": "GS", + "name": "South Georgia" + }, + "SHN": { + "alpha2": "SH", + "name": "Saint Helena" + }, + "SJM": { + "alpha2": "SJ", + "name": "Svalbard" + }, + "SLB": { + "alpha2": "SB", + "name": "Solomon Islands" + }, + "SLE": { + "alpha2": "SL", + "name": "Sierra Leone" + }, + "SLV": { + "alpha2": "SV", + "name": "El Salvador" + }, + "SMR": { + "alpha2": "SM", + "name": "San Marino" + }, + "SOM": { + "alpha2": "SO", + "name": "Somalia" + }, + "SPM": { + "alpha2": "PM", + "name": "Saint Pierre and Miquelon" + }, + "SRB": { + "alpha2": "RS", + "name": "Serbia" + }, + "SSD": { + "alpha2": "SS", + "name": "South Sudan" + }, + "STP": { + "alpha2": "ST", + "name": "Sao Tome and Principe" + }, + "SUR": { + "alpha2": "SR", + "name": "Suriname" + }, + "SVK": { + "alpha2": "SK", + "name": "Slovakia" + }, + "SVN": { + "alpha2": "SI", + "name": "Slovenia" + }, + "SWE": { + "alpha2": "SE", + "name": "Sweden" + }, + "SWZ": { + "alpha2": "SZ", + "name": "Eswatini" + }, + "SXM": { + "alpha2": "SX", + "name": "Sint Maarten" + }, + "SYC": { + "alpha2": "SC", + "name": "Seychelles" + }, + "SYR": { + "alpha2": "SY", + "name": "Syria" + }, + "TCA": { + "alpha2": "TC", + "name": "Turks and Caicos Islands" + }, + "TCD": { + "alpha2": "TD", + "name": "Chad" + }, + "TGO": { + "alpha2": "TG", + "name": "Togo" + }, + "THA": { + "alpha2": "TH", + "name": "Thailand" + }, + "TJK": { + "alpha2": "TJ", + "name": "Tajikistan" + }, + "TKL": { + "alpha2": "TK", + "name": "Tokelau" + }, + "TKM": { + "alpha2": "TM", + "name": "Turkmenistan" + }, + "TLS": { + "alpha2": "TL", + "name": "Timor-Leste" + }, + "TON": { + "alpha2": "TO", + "name": "Tonga" + }, + "TTO": { + "alpha2": "TT", + "name": "Trinidad and Tobago" + }, + "TUN": { + "alpha2": "TN", + "name": "Tunisia" + }, + "TUR": { + "alpha2": "TR", + "name": "Turkey" + }, + "TUV": { + "alpha2": "TV", + "name": "Tuvalu" + }, + "TWN": { + "alpha2": "TW", + "name": "Taiwan" + }, + "TZA": { + "alpha2": "TZ", + "name": "Tanzania" + }, + "UGA": { + "alpha2": "UG", + "name": "Uganda" + }, + "UKR": { + "alpha2": "UA", + "name": "Ukraine" + }, + "URY": { + "alpha2": "UY", + "name": "Uruguay" + }, + "USA": { + "alpha2": "US", + "name": "United States" + }, + "UZB": { + "alpha2": "UZ", + "name": "Uzbekistan" + }, + "VAT": { + "alpha2": "VA", + "name": "Vatican City" + }, + "VCT": { + "alpha2": "VC", + "name": "Saint Vincent and the Grenadines" + }, + "VEN": { + "alpha2": "VE", + "name": "Venezuela" + }, + "VGB": { + "alpha2": "VG", + "name": "Virgin Islands (British)" + }, + "VIR": { + "alpha2": "VI", + "name": "Virgin Islands (US)" + }, + "VNM": { + "alpha2": "VN", + "name": "Vietnam" + }, + "VUT": { + "alpha2": "VU", + "name": "Vanuatu" + }, + "WLF": { + "alpha2": "WF", + "name": "Wallis and Futuna" + }, + "WSM": { + "alpha2": "WS", + "name": "Samoa" + }, + "YEM": { + "alpha2": "YE", + "name": "Yemen" + }, + "ZAF": { + "alpha2": "ZA", + "name": "South Africa" + }, + "ZMB": { + "alpha2": "ZM", + "name": "Zambia" + }, + "ZWE": { + "alpha2": "ZW", + "name": "Zimbabwe" + } + }, + "aliases": { + "Antigua & Barbuda": "ATG", + "BGD": "BGD", + "Britain": "GBR", + "Burma": "MMR", + "CIV": "CIV", + "Cabo Verde": "CPV", + "Cape Verde": "CPV", + "China Peoples's Republic": "CHN", + "Cote d Ivoire": "CIV", + "Curaçao": "CUW", + "Côte d'Ivoire": "CIV", + "DPRK": "PRK", + "Democratic People's Republic of Korea": "PRK", + "East Timor": "TLS", + "GHA": "GHA", + "Great Britain": "GBR", + "Hong Kong, China": "HKG", + "Iran, Islamic Republic Of": "IRN", + "Korea, Democratic People's Republic Of": "PRK", + "Korea, Republic Of": "KOR", + "MHL": "MHL", + "Macao SAR": "MAC", + "Macedonia": "MKD", + "Myanmar union of": "MMR", + "PLW": "PLW", + "PRC": "CHN", + "Palau (Republic of)": "PLW", + "People's Republic of China": "CHN", + "Republic of Korea": "KOR", + "Republic of Palau": "PLW", + "Réunion": "REU", + "Saint Kitts & Nevis": "KNA", + "Saint Kitts And Nevis": "KNA", + "Sao Tome & Principe": "STP", + "St Kitts & Nevis": "KNA", + "St Vincent & Grenadines": "VCT", + "St. Kitts & Nevis": "KNA", + "St. Kitts and Nevis": "KNA", + "St. Vincent and the Grenadines": "VCT", + "Swaziland": "SWZ", + "The Bahamas": "BHS", + "The Gambia": "GMB", + "U.K.": "GBR", + "U.S.": "USA", + "U.S.A.": "USA", + "UAE": "ARE", + "UK": "GBR", + "US": "USA", + "USA": "USA", + "United States of America": "USA", + "Venezuela, Bolivarian Republic Of": "VEN", + "Viet Nam": "VNM" + } +} diff --git a/src/equasis_cli/enrichment/data/mids.json b/src/equasis_cli/enrichment/data/mids.json new file mode 100644 index 0000000..1669961 --- /dev/null +++ b/src/equasis_cli/enrichment/data/mids.json @@ -0,0 +1,1173 @@ +{ + "source": "ITU Maritime Identification Digits, via github.com/michaeljfazio/MIDs (Apache License 2.0)", + "mids": { + "201": { + "alpha3": "ALB", + "name": "Albania" + }, + "202": { + "alpha3": "AND", + "name": "Andorra" + }, + "203": { + "alpha3": "AUT", + "name": "Austria" + }, + "204": { + "alpha3": "PRT", + "name": "Azores" + }, + "205": { + "alpha3": "BEL", + "name": "Belgium" + }, + "206": { + "alpha3": "BLR", + "name": "Belarus" + }, + "207": { + "alpha3": "BGR", + "name": "Bulgaria" + }, + "208": { + "alpha3": "VAT", + "name": "Vatican City State" + }, + "209": { + "alpha3": "CYP", + "name": "Cyprus" + }, + "210": { + "alpha3": "CYP", + "name": "Cyprus" + }, + "211": { + "alpha3": "DEU", + "name": "Germany" + }, + "212": { + "alpha3": "CYP", + "name": "Cyprus" + }, + "213": { + "alpha3": "GEO", + "name": "Georgia" + }, + "214": { + "alpha3": "MDA", + "name": "Moldova" + }, + "215": { + "alpha3": "MLT", + "name": "Malta" + }, + "216": { + "alpha3": "ARM", + "name": "Armenia" + }, + "218": { + "alpha3": "DEU", + "name": "Germany" + }, + "219": { + "alpha3": "DNK", + "name": "Denmark" + }, + "220": { + "alpha3": "DNK", + "name": "Denmark" + }, + "224": { + "alpha3": "ESP", + "name": "Spain" + }, + "225": { + "alpha3": "ESP", + "name": "Spain" + }, + "226": { + "alpha3": "FRA", + "name": "France" + }, + "227": { + "alpha3": "FRA", + "name": "France" + }, + "228": { + "alpha3": "FRA", + "name": "France" + }, + "229": { + "alpha3": "MLT", + "name": "Malta" + }, + "230": { + "alpha3": "FIN", + "name": "Finland" + }, + "231": { + "alpha3": "FRO", + "name": "Faroe Islands" + }, + "232": { + "alpha3": "GBR", + "name": "United Kingdom" + }, + "233": { + "alpha3": "GBR", + "name": "United Kingdom" + }, + "234": { + "alpha3": "GBR", + "name": "United Kingdom" + }, + "235": { + "alpha3": "GBR", + "name": "United Kingdom" + }, + "236": { + "alpha3": "GIB", + "name": "Gibraltar" + }, + "237": { + "alpha3": "GRC", + "name": "Greece" + }, + "238": { + "alpha3": "HRV", + "name": "Croatia" + }, + "239": { + "alpha3": "GRC", + "name": "Greece" + }, + "240": { + "alpha3": "GRC", + "name": "Greece" + }, + "241": { + "alpha3": "GRC", + "name": "Greece" + }, + "242": { + "alpha3": "MAR", + "name": "Morocco" + }, + "243": { + "alpha3": "HUN", + "name": "Hungary" + }, + "244": { + "alpha3": "NLD", + "name": "Netherlands" + }, + "245": { + "alpha3": "NLD", + "name": "Netherlands" + }, + "246": { + "alpha3": "NLD", + "name": "Netherlands" + }, + "247": { + "alpha3": "ITA", + "name": "Italy" + }, + "248": { + "alpha3": "MLT", + "name": "Malta" + }, + "249": { + "alpha3": "MLT", + "name": "Malta" + }, + "250": { + "alpha3": "IRL", + "name": "Ireland" + }, + "251": { + "alpha3": "ISL", + "name": "Iceland" + }, + "252": { + "alpha3": "LIE", + "name": "Liechtenstein" + }, + "253": { + "alpha3": "LUX", + "name": "Luxembourg" + }, + "254": { + "alpha3": "MCO", + "name": "Monaco" + }, + "255": { + "alpha3": "PRT", + "name": "Madeira" + }, + "256": { + "alpha3": "MLT", + "name": "Malta" + }, + "257": { + "alpha3": "NOR", + "name": "Norway" + }, + "258": { + "alpha3": "NOR", + "name": "Norway" + }, + "259": { + "alpha3": "NOR", + "name": "Norway" + }, + "261": { + "alpha3": "POL", + "name": "Poland" + }, + "262": { + "alpha3": "MNE", + "name": "Montenegro" + }, + "263": { + "alpha3": "PRT", + "name": "Portugal" + }, + "264": { + "alpha3": "ROU", + "name": "Romania" + }, + "265": { + "alpha3": "SWE", + "name": "Sweden" + }, + "266": { + "alpha3": "SWE", + "name": "Sweden" + }, + "267": { + "alpha3": "SVK", + "name": "Slovak Republic" + }, + "268": { + "alpha3": "SMR", + "name": "San Marino" + }, + "269": { + "alpha3": "CHE", + "name": "Switzerland" + }, + "270": { + "alpha3": "CZE", + "name": "Czech Republic" + }, + "271": { + "alpha3": "TUR", + "name": "Turkey" + }, + "272": { + "alpha3": "UKR", + "name": "Ukraine" + }, + "273": { + "alpha3": "RUS", + "name": "Russia" + }, + "274": { + "alpha3": "MKD", + "name": "Macedonia" + }, + "275": { + "alpha3": "LVA", + "name": "Latvia" + }, + "276": { + "alpha3": "EST", + "name": "Estonia" + }, + "277": { + "alpha3": "LTU", + "name": "Lithuania" + }, + "278": { + "alpha3": "SVN", + "name": "Slovenia" + }, + "279": { + "alpha3": "SRB", + "name": "Serbia" + }, + "301": { + "alpha3": "AIA", + "name": "Anguilla" + }, + "303": { + "alpha3": "USA", + "name": "Alaska" + }, + "304": { + "alpha3": "ATG", + "name": "Antigua and Barbuda" + }, + "305": { + "alpha3": "ATG", + "name": "Antigua and Barbuda" + }, + "306": { + "alpha3": "CUW", + "name": "Antilles" + }, + "307": { + "alpha3": "ABW", + "name": "Aruba" + }, + "308": { + "alpha3": "BHS", + "name": "Bahamas" + }, + "309": { + "alpha3": "BHS", + "name": "Bahamas" + }, + "310": { + "alpha3": "BMU", + "name": "Bermuda" + }, + "311": { + "alpha3": "BMU", + "name": "Bahamas" + }, + "312": { + "alpha3": "BLZ", + "name": "Belize" + }, + "314": { + "alpha3": "BRB", + "name": "Barbados" + }, + "316": { + "alpha3": "CAN", + "name": "Canada" + }, + "319": { + "alpha3": "CYM", + "name": "Cayman Islands" + }, + "321": { + "alpha3": "CRI", + "name": "Costa Rica" + }, + "323": { + "alpha3": "CUB", + "name": "Cuba" + }, + "325": { + "alpha3": "DMA", + "name": "Dominica" + }, + "327": { + "alpha3": "DOM", + "name": "Dominican Republic" + }, + "329": { + "alpha3": "GLP", + "name": "Guadeloupe" + }, + "330": { + "alpha3": "GRD", + "name": "Grenada" + }, + "331": { + "alpha3": "GRL", + "name": "Greenland" + }, + "332": { + "alpha3": "GTM", + "name": "Guatemala" + }, + "334": { + "alpha3": "HND", + "name": "Honduras" + }, + "336": { + "alpha3": "HTI", + "name": "Haiti" + }, + "338": { + "alpha3": "USA", + "name": "United States of America" + }, + "339": { + "alpha3": "JAM", + "name": "Jamaica" + }, + "341": { + "alpha3": "KNA", + "name": "Saint Kitts and Nevis" + }, + "343": { + "alpha3": "LCA", + "name": "Saint Lucia" + }, + "345": { + "alpha3": "MEX", + "name": "Mexico" + }, + "347": { + "alpha3": "MTQ", + "name": "Martinique" + }, + "348": { + "alpha3": "MSR", + "name": "Montserrat" + }, + "350": { + "alpha3": "NIC", + "name": "Nicaragua" + }, + "351": { + "alpha3": "PAN", + "name": "Panama" + }, + "352": { + "alpha3": "PAN", + "name": "Panama" + }, + "353": { + "alpha3": "PAN", + "name": "Panama" + }, + "354": { + "alpha3": "PAN", + "name": "Panama" + }, + "355": { + "alpha3": "PAN", + "name": "Panama" + }, + "356": { + "alpha3": "PAN", + "name": "Panama" + }, + "357": { + "alpha3": "PAN", + "name": "Panama" + }, + "358": { + "alpha3": "PRI", + "name": "Puerto Rico" + }, + "359": { + "alpha3": "SLV", + "name": "El Salvador" + }, + "361": { + "alpha3": "SPM", + "name": "Saint Pierre and Miquelon" + }, + "362": { + "alpha3": "TTO", + "name": "Trinidad and Tobago" + }, + "364": { + "alpha3": "TCA", + "name": "Turks and Caicos Islands" + }, + "366": { + "alpha3": "USA", + "name": "United States of America" + }, + "367": { + "alpha3": "USA", + "name": "United States of America" + }, + "368": { + "alpha3": "USA", + "name": "United States of America" + }, + "369": { + "alpha3": "USA", + "name": "United States of America" + }, + "370": { + "alpha3": "PAN", + "name": "Panama" + }, + "371": { + "alpha3": "PAN", + "name": "Panama" + }, + "372": { + "alpha3": "PAN", + "name": "Panama" + }, + "373": { + "alpha3": "PAN", + "name": "Panama" + }, + "374": { + "alpha3": "PAN", + "name": "Panama" + }, + "375": { + "alpha3": "VCT", + "name": "Saint Vincent and the Grenadines" + }, + "376": { + "alpha3": "VCT", + "name": "Saint Vincent and the Grenadines" + }, + "377": { + "alpha3": "VCT", + "name": "Saint Vincent and the Grenadines" + }, + "378": { + "alpha3": "VGB", + "name": "British Virgin Islands" + }, + "379": { + "alpha3": "VIR", + "name": "United States Virgin Islands" + }, + "401": { + "alpha3": "AFG", + "name": "Afghanistan" + }, + "403": { + "alpha3": "SAU", + "name": "Saudi Arabia" + }, + "405": { + "alpha3": "BGD", + "name": "Bangladesh" + }, + "408": { + "alpha3": "BHR", + "name": "Bahrain" + }, + "410": { + "alpha3": "BTN", + "name": "Bhutan" + }, + "412": { + "alpha3": "CHN", + "name": "China" + }, + "413": { + "alpha3": "CHN", + "name": "China" + }, + "414": { + "alpha3": "CHN", + "name": "China" + }, + "416": { + "alpha3": "TWN", + "name": "Taiwan" + }, + "417": { + "alpha3": "LKA", + "name": "Sri Lanka" + }, + "419": { + "alpha3": "IND", + "name": "India" + }, + "422": { + "alpha3": "IRN", + "name": "Iran" + }, + "423": { + "alpha3": "AZE", + "name": "Azerbaijan" + }, + "425": { + "alpha3": "IRQ", + "name": "Iraq" + }, + "428": { + "alpha3": "ISR", + "name": "Israel" + }, + "431": { + "alpha3": "JPN", + "name": "Japan" + }, + "432": { + "alpha3": "JPN", + "name": "Japan" + }, + "434": { + "alpha3": "TKM", + "name": "Turkmenistan" + }, + "436": { + "alpha3": "KAZ", + "name": "Kazakhstan" + }, + "437": { + "alpha3": "UZB", + "name": "Uzbekistan" + }, + "438": { + "alpha3": "JOR", + "name": "Jordan" + }, + "440": { + "alpha3": "KOR", + "name": "Korea" + }, + "441": { + "alpha3": "KOR", + "name": "Korea" + }, + "443": { + "alpha3": "PSE", + "name": "State of Palestine" + }, + "445": { + "alpha3": "PRK", + "name": "Democratic People's Republic of Korea" + }, + "447": { + "alpha3": "KWT", + "name": "Kuwait" + }, + "450": { + "alpha3": "LBN", + "name": "Lebanon" + }, + "451": { + "alpha3": "KGZ", + "name": "Kyrgyz Republic" + }, + "453": { + "alpha3": "MAC", + "name": "Macao" + }, + "455": { + "alpha3": "MDV", + "name": "Maldives" + }, + "457": { + "alpha3": "MNG", + "name": "Mongolia" + }, + "459": { + "alpha3": "NPL", + "name": "Nepal" + }, + "461": { + "alpha3": "OMN", + "name": "Oman" + }, + "463": { + "alpha3": "PAK", + "name": "Pakistan" + }, + "466": { + "alpha3": "QAT", + "name": "Qatar (State of)" + }, + "468": { + "alpha3": "SYR", + "name": "Syrian Arab Republic" + }, + "470": { + "alpha3": "ARE", + "name": "United Arab Emirates" + }, + "471": { + "alpha3": "ARE", + "name": "United Arab Emirates" + }, + "472": { + "alpha3": "TJK", + "name": "Tajikistan" + }, + "473": { + "alpha3": "YEM", + "name": "Yemen" + }, + "475": { + "alpha3": "YEM", + "name": "Yemen" + }, + "477": { + "alpha3": "HKG", + "name": "Hong Kong" + }, + "478": { + "alpha3": "BIH", + "name": "Bosnia and Herzegovina" + }, + "501": { + "alpha3": "FRA", + "name": "Adelie Land" + }, + "503": { + "alpha3": "AUS", + "name": "Australia" + }, + "506": { + "alpha3": "MMR", + "name": "Myanmar" + }, + "508": { + "alpha3": "BRN", + "name": "Brunei Darussalam" + }, + "510": { + "alpha3": "FSM", + "name": "Micronesia" + }, + "511": { + "alpha3": "PLW", + "name": "Palau" + }, + "512": { + "alpha3": "NZL", + "name": "New Zealand" + }, + "514": { + "alpha3": "KHM", + "name": "Cambodia" + }, + "515": { + "alpha3": "KHM", + "name": "Cambodia" + }, + "516": { + "alpha3": "CXR", + "name": "Christmas Island" + }, + "518": { + "alpha3": "COK", + "name": "Cook Islands" + }, + "520": { + "alpha3": "FJI", + "name": "Fiji" + }, + "523": { + "alpha3": "CCK", + "name": "Cocos (Keeling) Islands" + }, + "525": { + "alpha3": "IDN", + "name": "Indonesia" + }, + "529": { + "alpha3": "KIR", + "name": "Kiribati" + }, + "531": { + "alpha3": "LAO", + "name": "Lao People's Democratic Republic" + }, + "533": { + "alpha3": "MYS", + "name": "Malaysia" + }, + "536": { + "alpha3": "MNP", + "name": "Northern Mariana Islands" + }, + "538": { + "alpha3": "MHL", + "name": "Marshall Islands" + }, + "540": { + "alpha3": "NCL", + "name": "New Caledonia" + }, + "542": { + "alpha3": "NIU", + "name": "Niue" + }, + "544": { + "alpha3": "NRU", + "name": "Nauru" + }, + "546": { + "alpha3": "PYF", + "name": "French Polynesia" + }, + "548": { + "alpha3": "PHL", + "name": "Philippines" + }, + "550": { + "alpha3": "TLS", + "name": "Timor-Leste (Democratic Republic of)" + }, + "553": { + "alpha3": "PNG", + "name": "Papua New Guinea" + }, + "555": { + "alpha3": "PCN", + "name": "Pitcairn Island" + }, + "557": { + "alpha3": "SLB", + "name": "Solomon Islands" + }, + "559": { + "alpha3": "ASM", + "name": "American Samoa" + }, + "561": { + "alpha3": "WSM", + "name": "Samoa" + }, + "563": { + "alpha3": "SGP", + "name": "Singapore" + }, + "564": { + "alpha3": "SGP", + "name": "Singapore" + }, + "565": { + "alpha3": "SGP", + "name": "Singapore" + }, + "566": { + "alpha3": "SGP", + "name": "Singapore" + }, + "567": { + "alpha3": "THA", + "name": "Thailand" + }, + "570": { + "alpha3": "TON", + "name": "Tonga" + }, + "572": { + "alpha3": "TUV", + "name": "Tuvalu" + }, + "574": { + "alpha3": "VNM", + "name": "Viet Nam" + }, + "576": { + "alpha3": "VUT", + "name": "Vanuatu" + }, + "577": { + "alpha3": "VUT", + "name": "Vanuatu" + }, + "578": { + "alpha3": "WLF", + "name": "Wallis and Futuna Islands" + }, + "601": { + "alpha3": "ZAF", + "name": "South Africa" + }, + "603": { + "alpha3": "AGO", + "name": "Angola" + }, + "605": { + "alpha3": "DZA", + "name": "Algeria" + }, + "607": { + "alpha3": "FRA", + "name": "Saint Paul and Amsterdam Islands" + }, + "608": { + "alpha3": "GBR", + "name": "Ascension Island" + }, + "609": { + "alpha3": "BDI", + "name": "Burundi" + }, + "610": { + "alpha3": "BEN", + "name": "Benin" + }, + "611": { + "alpha3": "BWA", + "name": "Botswana" + }, + "612": { + "alpha3": "CAF", + "name": "Central African Republic" + }, + "613": { + "alpha3": "CMR", + "name": "Cameroon" + }, + "615": { + "alpha3": "COG", + "name": "Congo" + }, + "616": { + "alpha3": "COM", + "name": "Comoros" + }, + "617": { + "alpha3": "CPV", + "name": "Cabo Verde" + }, + "618": { + "alpha3": "FRA", + "name": "Crozet Archipelago" + }, + "619": { + "alpha3": "CIV", + "name": "Ivory Coast" + }, + "620": { + "alpha3": "COM", + "name": "Comoros" + }, + "621": { + "alpha3": "DJI", + "name": "Djibouti" + }, + "622": { + "alpha3": "EGY", + "name": "Egypt" + }, + "624": { + "alpha3": "ETH", + "name": "Ethiopia" + }, + "625": { + "alpha3": "ERI", + "name": "Eritrea" + }, + "626": { + "alpha3": "GAB", + "name": "Gabonese Republic" + }, + "627": { + "alpha3": "GHA", + "name": "Ghana" + }, + "629": { + "alpha3": "GMB", + "name": "Gambia" + }, + "630": { + "alpha3": "GNB", + "name": "Guinea-Bissau" + }, + "631": { + "alpha3": "GNQ", + "name": "Equatorial Guinea" + }, + "632": { + "alpha3": "GIN", + "name": "Guinea" + }, + "633": { + "alpha3": "BFA", + "name": "Burkina Faso" + }, + "634": { + "alpha3": "KEN", + "name": "Kenya" + }, + "635": { + "alpha3": "FRA", + "name": "Kerguelen Islands" + }, + "636": { + "alpha3": "LBR", + "name": "Liberia" + }, + "637": { + "alpha3": "LBR", + "name": "Liberia" + }, + "638": { + "alpha3": "SSD", + "name": "South Sudan" + }, + "642": { + "alpha3": "LBY", + "name": "Libya" + }, + "644": { + "alpha3": "LSO", + "name": "Lesotho" + }, + "645": { + "alpha3": "MUS", + "name": "Mauritius" + }, + "647": { + "alpha3": "MDG", + "name": "Madagascar" + }, + "649": { + "alpha3": "MLI", + "name": "Mali" + }, + "650": { + "alpha3": "MOZ", + "name": "Mozambique" + }, + "654": { + "alpha3": "MRT", + "name": "Mauritania" + }, + "655": { + "alpha3": "MWI", + "name": "Malawi" + }, + "656": { + "alpha3": "NER", + "name": "Niger" + }, + "657": { + "alpha3": "NGA", + "name": "Nigeria" + }, + "659": { + "alpha3": "NAM", + "name": "Namibia" + }, + "660": { + "alpha3": "REU", + "name": "Reunion" + }, + "661": { + "alpha3": "RWA", + "name": "Rwanda" + }, + "662": { + "alpha3": "SDN", + "name": "Sudan" + }, + "663": { + "alpha3": "SEN", + "name": "Senegal" + }, + "664": { + "alpha3": "SYC", + "name": "Seychelles" + }, + "665": { + "alpha3": "SHN", + "name": "Saint Helena" + }, + "666": { + "alpha3": "SOM", + "name": "Somali Democratic Republic" + }, + "667": { + "alpha3": "SLE", + "name": "Sierra Leone" + }, + "668": { + "alpha3": "STP", + "name": "Sao Tome and Principe" + }, + "669": { + "alpha3": "SWZ", + "name": "Swaziland" + }, + "670": { + "alpha3": "TCD", + "name": "Chad" + }, + "671": { + "alpha3": "TGO", + "name": "Togolese Republic" + }, + "672": { + "alpha3": "TUN", + "name": "Tunisian Republic" + }, + "674": { + "alpha3": "TZA", + "name": "Tanzania" + }, + "675": { + "alpha3": "UGA", + "name": "Uganda" + }, + "676": { + "alpha3": "COD", + "name": "Democratic Republic of the Congo" + }, + "677": { + "alpha3": "TZA", + "name": "Tanzania" + }, + "678": { + "alpha3": "ZMB", + "name": "Zambia" + }, + "679": { + "alpha3": "ZWE", + "name": "Zimbabwe" + }, + "701": { + "alpha3": "ARG", + "name": "Argentine Republic" + }, + "710": { + "alpha3": "BRA", + "name": "Brazil" + }, + "720": { + "alpha3": "BOL", + "name": "Bolivia" + }, + "725": { + "alpha3": "CHL", + "name": "Chile" + }, + "730": { + "alpha3": "COL", + "name": "Colombia" + }, + "735": { + "alpha3": "ECU", + "name": "Ecuador" + }, + "740": { + "alpha3": "FLK", + "name": "Falkland Islands" + }, + "745": { + "alpha3": "GUF", + "name": "Guiana" + }, + "750": { + "alpha3": "GUY", + "name": "Guyana" + }, + "755": { + "alpha3": "PRY", + "name": "Paraguay" + }, + "760": { + "alpha3": "PER", + "name": "Peru" + }, + "765": { + "alpha3": "SUR", + "name": "Suriname" + }, + "770": { + "alpha3": "URY", + "name": "Uruguay" + }, + "775": { + "alpha3": "VEN", + "name": "Venezuela" + } + } +} diff --git a/src/equasis_cli/enrichment/indicators.py b/src/equasis_cli/enrichment/indicators.py new file mode 100644 index 0000000..86ba2ca --- /dev/null +++ b/src/equasis_cli/enrichment/indicators.py @@ -0,0 +1,196 @@ +"""Factual indicators derived from a vessel profile. + +Indicators summarise signals that researchers commonly look at when assessing a +vessel: frequent changes of name, flag or management, recent detentions, flag +performance, classification problems, and whether the MMSI is consistent with +the flag. Each indicator is a plain fact taken from Equasis data. No score is +computed, because any weighting of these signals would be arbitrary. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import date + +from ..models import ClassStatus, CompanyChange, FlagChange, Inspection, NameChange, Vessel +from .reference import mmsi_country + +RECENT_MONTHS = 12 +WINDOW_MONTHS = 36 +MANAGEMENT_ROLES = ("registered owner", "ship manager", "ism manager") +CONCERNING_LISTS = ("grey", "gray", "black") + + +@dataclass(kw_only=True) +class VesselIndicators: + """Derived indicators for one vessel, relative to ``reference_date``.""" + + reference_date: date + age_years: int | None = None + + name_changes_12_months: int = 0 + name_changes_36_months: int = 0 + flag_changes_12_months: int = 0 + flag_changes_36_months: int = 0 + management_changes_36_months: int = 0 + """Changes of registered owner, ship manager, or ISM manager.""" + + inspections_36_months: int = 0 + detentions_36_months: int = 0 + deficiencies_36_months: int = 0 + last_inspection: date | None = None + last_detention: date | None = None + + flag_on_paris_mou_grey_or_black_list: bool | None = None + flag_on_tokyo_mou_grey_or_black_list: bool | None = None + uscg_targeted_flag: bool | None = None + iacs_classed: bool | None = None + class_withdrawn_or_suspended_36_months: list[str] = field(default_factory=list) + has_pandi_insurance: bool = False + + mmsi_country: str | None = None + """Country allocated the MMSI's Maritime Identification Digits.""" + mmsi_matches_flag: bool | None = None + + observations: list[str] = field(default_factory=list) + """Plain-language summary of the notable indicators.""" + + +def months_before(reference: date, months: int) -> date: + """The same day ``months`` months earlier (clamped to the end of short months).""" + total = reference.year * 12 + reference.month - 1 - months + year, month = divmod(total, 12) + month += 1 + for day in (reference.day, 30, 29, 28): + try: + return date(year, month, day) + except ValueError: + continue + raise AssertionError("unreachable") # pragma: no cover + + +def _changes_since(records: Sequence[NameChange | FlagChange], cutoff: date) -> int: + """Records that started on or after ``cutoff``, excluding the first registration.""" + changes = records[:-1] # the oldest record is the original name or flag, not a change + return sum(1 for r in changes if r.since is not None and r.since >= cutoff) + + +def _management_changes(history: Sequence[CompanyChange], cutoff: date) -> int: + count = 0 + for role in MANAGEMENT_ROLES: + records = [c for c in history if c.role.lower().startswith(role)] + count += sum(1 for c in records[:-1] if c.since is not None and c.since >= cutoff) + return count + + +def _on_concerning_list(value: str | None) -> bool | None: + if not value: + return None + return value.strip().lower() in CONCERNING_LISTS + + +def _recent_inspections(inspections: Sequence[Inspection], cutoff: date) -> list[Inspection]: + return [i for i in inspections if i.date is not None and i.date >= cutoff] + + +def _class_problems(statuses: Sequence[ClassStatus], cutoff: date) -> list[str]: + return [ + f"{s.society}: {s.status}" + for s in statuses + if (s.status or "").lower() in ("withdrawn", "suspended") + and s.since is not None + and s.since >= cutoff + ] + + +def compute_indicators(vessel: Vessel, reference_date: date | None = None) -> VesselIndicators: + """Derive indicators for ``vessel`` as of ``reference_date`` (default: today).""" + today = reference_date or date.today() + recent = months_before(today, RECENT_MONTHS) + window = months_before(today, WINDOW_MONTHS) + performance = vessel.flag_performance + + indicators = VesselIndicators( + reference_date=today, + age_years=today.year - vessel.year_built if vessel.year_built else None, + name_changes_12_months=_changes_since(vessel.name_history, recent), + name_changes_36_months=_changes_since(vessel.name_history, window), + flag_changes_12_months=_changes_since(vessel.flag_history, recent), + flag_changes_36_months=_changes_since(vessel.flag_history, window), + management_changes_36_months=_management_changes(vessel.company_history, window), + flag_on_paris_mou_grey_or_black_list=_on_concerning_list(performance.paris_mou), + flag_on_tokyo_mou_grey_or_black_list=_on_concerning_list(performance.tokyo_mou), + uscg_targeted_flag=performance.uscg_targeted, + iacs_classed=performance.iacs_classed, + class_withdrawn_or_suspended_36_months=_class_problems(vessel.class_status, window), + has_pandi_insurance=bool(vessel.pandi), + ) + + recent_inspections = _recent_inspections(vessel.inspections, window) + indicators.inspections_36_months = len(recent_inspections) + indicators.detentions_36_months = sum(1 for i in recent_inspections if i.detained) + indicators.deficiencies_36_months = sum(i.deficiencies or 0 for i in recent_inspections) + dated = [i for i in vessel.inspections if i.date is not None] + if dated: + indicators.last_inspection = max(i.date for i in dated if i.date) + detained = [i.date for i in dated if i.detained and i.date] + indicators.last_detention = max(detained) if detained else None + + allocated = mmsi_country(vessel.mmsi) + if allocated: + indicators.mmsi_country = allocated.name + if vessel.flag_code: + indicators.mmsi_matches_flag = allocated.alpha3 == vessel.flag_code.upper() + + indicators.observations = _observations(vessel, indicators) + return indicators + + +def _plural(count: int, noun: str) -> str: + return f"{count} {noun}{'' if count == 1 else 's'}" + + +def _observations(vessel: Vessel, ind: VesselIndicators) -> list[str]: + notes: list[str] = [] + if ind.name_changes_36_months: + notes.append(f"Renamed {_plural(ind.name_changes_36_months, 'time')} in the last 36 months") + if ind.flag_changes_36_months: + notes.append( + f"Changed flag {_plural(ind.flag_changes_36_months, 'time')} in the last 36 months" + ) + if ind.management_changes_36_months >= 2: + notes.append( + f"{ind.management_changes_36_months} changes of owner or manager in the last 36 months" + ) + if ind.detentions_36_months: + notes.append(f"Detained {_plural(ind.detentions_36_months, 'time')} in the last 36 months") + lists = [ + name + for name, flagged in ( + ("Paris MoU", ind.flag_on_paris_mou_grey_or_black_list), + ("Tokyo MoU", ind.flag_on_tokyo_mou_grey_or_black_list), + ) + if flagged + ] + if lists: + notes.append(f"Flag is on the grey or black list of the {' and '.join(lists)}") + if ind.uscg_targeted_flag: + notes.append("Flag is targeted by the US Coast Guard") + if ind.iacs_classed is False: + notes.append("Not classed by an IACS member society") + for problem in ind.class_withdrawn_or_suspended_36_months: + notes.append( + f"Class {problem.split(': ', 1)[1].lower()} in the last 36 months " + f"({problem.split(': ', 1)[0]})" + ) + if ind.mmsi_matches_flag is False: + notes.append( + f"MMSI {vessel.mmsi} is allocated to {ind.mmsi_country}, not the flag state " + f"({vessel.flag})" + ) + if not ind.has_pandi_insurance and vessel.status and "service" in vessel.status.lower(): + notes.append("No P&I insurer recorded in Equasis") + if ind.inspections_36_months == 0 and vessel.status and "service" in vessel.status.lower(): + notes.append("No port state control inspections recorded in the last 36 months") + return notes diff --git a/src/equasis_cli/enrichment/reference.py b/src/equasis_cli/enrichment/reference.py new file mode 100644 index 0000000..143d892 --- /dev/null +++ b/src/equasis_cli/enrichment/reference.py @@ -0,0 +1,70 @@ +"""Country and Maritime Identification Digit (MID) reference data.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from functools import lru_cache +from importlib import resources +from typing import Any + + +@dataclass(frozen=True) +class Country: + alpha3: str + alpha2: str | None + name: str + + +def _load(name: str) -> Any: + return json.loads( + resources.files(__package__).joinpath("data").joinpath(name).read_text("utf-8") + ) + + +@lru_cache(maxsize=1) +def countries() -> dict[str, Country]: + """ISO 3166-1 countries keyed by alpha-3 code.""" + data = _load("countries.json") + return { + code: Country(alpha3=code, alpha2=entry["alpha2"], name=entry["name"]) + for code, entry in data["countries"].items() + } + + +@lru_cache(maxsize=1) +def _names() -> dict[str, str]: + data = _load("countries.json") + index = {entry["name"].casefold(): code for code, entry in data["countries"].items()} + index.update({alias.casefold(): code for alias, code in data["aliases"].items()}) + return index + + +@lru_cache(maxsize=1) +def _mids() -> dict[str, str]: + return {mid: entry["alpha3"] for mid, entry in _load("mids.json")["mids"].items()} + + +def country(alpha3: str | None) -> Country | None: + """Look up a country by ISO alpha-3 code.""" + return countries().get(alpha3.upper()) if alpha3 else None + + +def country_by_name(name: str | None) -> Country | None: + """Look up a country by name or common alias (case-insensitive).""" + if not name: + return None + code = _names().get(name.strip().casefold()) + return countries().get(code) if code else None + + +def mmsi_country(mmsi: str | None) -> Country | None: + """Country allocated the MID of a ship station MMSI. + + Ship station MMSIs start with the three-digit MID (first digit 2-7). Other + station types (coast stations, aircraft, aids to navigation) return ``None``. + """ + if not mmsi or len(mmsi) != 9 or not mmsi.isdigit() or mmsi[0] not in "234567": + return None + code = _mids().get(mmsi[:3]) + return countries().get(code) if code else None diff --git a/src/equasis_cli/models.py b/src/equasis_cli/models.py index 37809d3..f046e24 100644 --- a/src/equasis_cli/models.py +++ b/src/equasis_cli/models.py @@ -217,6 +217,9 @@ class Vessel: """Problems that left parts of the profile incomplete (for example an Inspections page that could not be parsed).""" + retrieved_at: dt.datetime | None = None + """When the underlying Equasis pages were fetched (earlier if served from cache).""" + # --------------------------------------------------------------------- search and fleet @@ -251,6 +254,7 @@ class SearchResults: companies: list[CompanySummary] = field(default_factory=list) total_ships: int | None = None """Total ship matches reported by Equasis (may exceed ``len(ships)`` if truncated).""" + retrieved_at: dt.datetime | None = None @dataclass(kw_only=True) @@ -296,6 +300,7 @@ class Fleet: vessels: list[FleetVessel] = field(default_factory=list) total_vessels: int | None = None """Total reported by Equasis (may exceed ``len(vessels)`` if truncated).""" + retrieved_at: dt.datetime | None = None # ------------------------------------------------------------------------------- batch diff --git a/src/equasis_cli/output.py b/src/equasis_cli/output.py index 489957c..728c3f1 100644 --- a/src/equasis_cli/output.py +++ b/src/equasis_cli/output.py @@ -17,6 +17,7 @@ from pathlib import Path from typing import Any, Literal +from .enrichment import VesselIndicators, compute_indicators from .models import ( SCHEMA_VERSION, BatchItem, @@ -83,6 +84,8 @@ def render(result: Renderable, fmt: str, *, retrieved_at: datetime | None = None """Render ``result`` in format ``fmt`` (one of :data:`FORMATS`).""" if fmt not in FORMATS: raise ValueError(f"unknown output format {fmt!r}; choose from {', '.join(FORMATS)}") + if retrieved_at is None and not isinstance(result, BatchReport): + retrieved_at = result.retrieved_at retrieved_at = retrieved_at or datetime.now(timezone.utc) if isinstance(result, Vessel): return _render_vessel(result, fmt, retrieved_at) @@ -93,9 +96,9 @@ def render(result: Renderable, fmt: str, *, retrieved_at: datetime | None = None return _render_batch(result, fmt, retrieved_at) -def render_batch_item_jsonl(kind: str, item: BatchItem[Any]) -> str: +def render_batch_item_jsonl(kind: str, item: BatchItem[Any], today: date | None = None) -> str: """Render one batch item as a JSON Lines record (for streaming output).""" - return _dumps_line(_batch_item_record(kind, item)) + return _dumps_line(_batch_item_record(kind, item, today or date.today())) def write_output(text: str, path: str | os.PathLike[str]) -> Path: @@ -138,6 +141,10 @@ def _timestamp(moment: datetime) -> str: return moment.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") +def _display_time(moment: datetime) -> str: + return moment.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + def _dumps(data: Any) -> str: return json.dumps(data, indent=2, ensure_ascii=False) + "\n" @@ -241,6 +248,14 @@ def _key_values(pairs: Sequence[tuple[str, Any]]) -> list[str]: "last_inspection", "name_changes", "flag_changes", + "age_years", + "name_changes_36_months", + "flag_changes_36_months", + "management_changes_36_months", + "inspections_36_months", + "detentions_36_months", + "mmsi_matches_flag", + "observations", "warnings", ) @@ -250,7 +265,8 @@ def _company_for_role(vessel: Vessel, role_prefix: str) -> str | None: return "; ".join(dict.fromkeys(names)) or None -def _vessel_csv_row(vessel: Vessel) -> list[Any]: +def _vessel_csv_row(vessel: Vessel, today: date) -> list[Any]: + indicators = compute_indicators(vessel, today) dated = [i.date for i in vessel.inspections if i.date] return [ vessel.imo, @@ -279,21 +295,34 @@ def _vessel_csv_row(vessel: Vessel) -> list[Any]: max(dated) if dated else None, max(0, len(vessel.name_history) - 1), max(0, len(vessel.flag_history) - 1), + indicators.age_years, + indicators.name_changes_36_months, + indicators.flag_changes_36_months, + indicators.management_changes_36_months, + indicators.inspections_36_months, + indicators.detentions_36_months, + indicators.mmsi_matches_flag, + "; ".join(indicators.observations) or None, "; ".join(vessel.warnings) or None, ] def _render_vessel(vessel: Vessel, fmt: str, retrieved_at: datetime) -> str: - if fmt == "json": - return _dumps(_envelope("vessel", retrieved_at, vessel=vessel)) - if fmt == "jsonl": - return _dumps_line(_envelope("vessel", retrieved_at, vessel=vessel)) + today = retrieved_at.date() + if fmt in ("json", "jsonl"): + document = _envelope( + "vessel", + retrieved_at, + vessel=vessel, + indicators=compute_indicators(vessel, today), + ) + return _dumps(document) if fmt == "json" else _dumps_line(document) if fmt == "csv": - return _csv(_VESSEL_CSV_HEADERS, [_vessel_csv_row(vessel)]) - return "\n".join(_vessel_table(vessel)) + "\n" + return _csv(_VESSEL_CSV_HEADERS, [_vessel_csv_row(vessel, today)]) + return "\n".join(_vessel_table(vessel, compute_indicators(vessel, today))) + "\n" -def _vessel_table(vessel: Vessel) -> list[str]: +def _vessel_table(vessel: Vessel, indicators: VesselIndicators) -> list[str]: status = vessel.status if status and vessel.status_since: status = f"{status} (since {vessel.status_since.isoformat()})" @@ -314,9 +343,16 @@ def _vessel_table(vessel: Vessel) -> list[str]: ("Call sign", vessel.call_sign), ("MMSI", vessel.mmsi), ("Particulars updated", vessel.particulars_updated), + ("Data retrieved", vessel.retrieved_at and _display_time(vessel.retrieved_at)), ] ) + _section(lines, "Indicators") + if indicators.observations: + lines += [f" * {note}" for note in indicators.observations] + else: + lines.append(" No notable indicators") + performance = vessel.flag_performance _section(lines, "Flag performance") lines += _key_values( @@ -563,6 +599,7 @@ def _render_fleet(fleet: Fleet, fmt: str, retrieved_at: datetime) -> str: ("Status", company.status), ("Last update", company.last_update), ("Vessels", fleet.total_vessels or len(fleet.vessels)), + ("Data retrieved", fleet.retrieved_at and _display_time(fleet.retrieved_at)), ] ) if fleet.total_vessels and fleet.total_vessels > len(fleet.vessels): @@ -592,7 +629,7 @@ def _render_fleet(fleet: Fleet, fmt: str, retrieved_at: datetime) -> str: # ---------------------------------------------------------------------------- batch -def _batch_item_record(kind: str, item: BatchItem[Any]) -> dict[str, Any]: +def _batch_item_record(kind: str, item: BatchItem[Any], today: date) -> dict[str, Any]: key = "vessel" if kind == "vessels" else "fleet" record: dict[str, Any] = { "query": item.query, @@ -602,6 +639,12 @@ def _batch_item_record(kind: str, item: BatchItem[Any]) -> dict[str, Any]: "elapsed_seconds": round(item.elapsed_seconds, 2), key: to_jsonable(item.result), } + if kind == "vessels": + record["indicators"] = ( + to_jsonable(compute_indicators(item.result, today)) + if isinstance(item.result, Vessel) + else None + ) return record @@ -609,7 +652,9 @@ def _render_batch(report: BatchReport, fmt: str, retrieved_at: datetime) -> str: kind = report.kind if fmt in ("json", "jsonl"): if fmt == "jsonl": - return "".join(render_batch_item_jsonl(kind, item) for item in report.items) + return "".join( + render_batch_item_jsonl(kind, item, retrieved_at.date()) for item in report.items + ) summary = { "total": len(report.items), "succeeded": report.succeeded, @@ -620,7 +665,9 @@ def _render_batch(report: BatchReport, fmt: str, retrieved_at: datetime) -> str: { **_envelope(f"batch_{kind}", retrieved_at), "summary": summary, - "results": [_batch_item_record(kind, item) for item in report.items], + "results": [ + _batch_item_record(kind, item, retrieved_at.date()) for item in report.items + ], } ) @@ -629,7 +676,7 @@ def _render_batch(report: BatchReport, fmt: str, retrieved_at: datetime) -> str: rows = [] for item in report.items: vessel_row = ( - _vessel_csv_row(item.result) + _vessel_csv_row(item.result, retrieved_at.date()) if isinstance(item.result, Vessel) else [None] * len(_VESSEL_CSV_HEADERS) ) diff --git a/src/equasis_cli/tui/commands.py b/src/equasis_cli/tui/commands.py index c6fabf6..946f61e 100644 --- a/src/equasis_cli/tui/commands.py +++ b/src/equasis_cli/tui/commands.py @@ -14,6 +14,7 @@ from typing import Any from .. import output +from ..cache import PageCache from ..client import DEFAULT_SEARCH_PAGES, EquasisClient from ..credentials import CredentialStore from ..exceptions import ( @@ -71,6 +72,7 @@ def param(self, name: str) -> Param | None: OUTPUT = Param("output", "save to a file (format from the extension)") FIRST = Param("first", "use the best match when a name matches several companies", False) PAGES = Param("pages", "result pages to fetch (100 results each)") +REFRESH = Param("refresh", "ignore cached pages and fetch fresh data", False) COMMANDS: dict[str, CommandSpec] = { spec.name: spec @@ -79,7 +81,7 @@ def param(self, name: str) -> Param | None: "vessel", "Full vessel profile by IMO number", "vessel /imo IMO [/format FORMAT] [/output FILE]", - (Param("imo", "IMO number (required)"), FORMAT, OUTPUT), + (Param("imo", "IMO number (required)"), REFRESH, FORMAT, OUTPUT), ("Includes management, classification, PSC inspections and history.",), ), CommandSpec( @@ -94,6 +96,7 @@ def param(self, name: str) -> Param | None: Param("callsign", "exact call sign"), Param("type", "all, ships, or companies (name searches)"), PAGES, + REFRESH, FORMAT, OUTPUT, ), @@ -107,6 +110,7 @@ def param(self, name: str) -> Param | None: Param("id", "7-digit Equasis company number"), FIRST, PAGES, + REFRESH, FORMAT, OUTPUT, ), @@ -127,12 +131,19 @@ def param(self, name: str) -> Param | None: Param("company-file", "file with one company per line"), Param("fail-fast", "stop at the first failed lookup", False), FIRST, + REFRESH, FORMAT, OUTPUT, ), ("Files may contain blank lines and # comments.",), ), CommandSpec("format", "Set the default output format", "format table|json|jsonl|csv"), + CommandSpec( + "cache", + "Show or clear cached Equasis pages", + "cache [info|clear]", + details=("Pages are reused for 24 hours; add /refresh to a command to bypass them.",), + ), CommandSpec("status", "Show session status", "status"), CommandSpec("clear", "Clear the output", "clear"), CommandSpec("help", "Show help for all or one command", "help [COMMAND]"), @@ -230,13 +241,16 @@ def __init__( state: ShellState | None = None, store: CredentialStore | None = None, client_factory: Callable[[str, str], EquasisClient] | None = None, + cache: PageCache | None = None, ) -> None: self.write = write self.status = status + self.refresh = False self.state = state or ShellState() self.store = store or CredentialStore() + self.cache = cache if cache is not None else PageCache() self.client_factory = client_factory or ( - lambda username, password: EquasisClient(username, password) + lambda username, password: EquasisClient(username, password, cache=self.cache) ) # ------------------------------------------------------------------- helpers @@ -255,9 +269,7 @@ def client(self) -> EquasisClient: ) self.state.client = self.client_factory(credentials.username, credentials.password) self.state.username = credentials.username - if not self.state.client.logged_in: - self.status("Logging in to Equasis...") - self.state.client.login() + self.state.client.refresh = self.refresh return self.state.client def close(self) -> None: @@ -315,7 +327,11 @@ def run(self, line: str) -> bool: if command.name == "exit": return False handler: Callable[[ParsedCommand], None] = getattr(self, f"_cmd_{command.name}") - handler(command) + self.refresh = command.flag("refresh") + try: + handler(command) + finally: + self.refresh = False except CommandError as exc: self.write(f"Error: {exc}") except AuthenticationError as exc: @@ -517,6 +533,20 @@ def _cmd_status(self, command: ParsedCommand) -> None: ] self.write("\n".join(lines)) + def _cmd_cache(self, command: ParsedCommand) -> None: + action = command.arguments[0].lower() if command.arguments else "info" + if action == "clear": + removed = self.cache.clear() + self.write(f"Removed {removed} cached page{'s' if removed != 1 else ''}.") + return + if action != "info": + raise CommandError("use 'cache info' or 'cache clear'") + stats = self.cache.stats() + self.write( + f"Cache: {stats.entries} pages ({stats.size_bytes / 1_048_576:.1f} MB) in " + f"{stats.directory}" + ) + def _cmd_clear(self, command: ParsedCommand) -> None: # handled by the application pass diff --git a/tests/fakes.py b/tests/fakes.py index ab19ff8..27caf1d 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -63,6 +63,7 @@ def __init__( self.reject_login = reject_login self.calls: list[tuple[str, object]] = [] self.logged_in = False + self.refresh = False self.closed = False def __enter__(self) -> FakeClient: @@ -85,10 +86,13 @@ def login(self) -> None: raise AuthenticationError("Equasis rejected the login") self.logged_in = True + def _ensure_login(self) -> None: + if not self.logged_in: + self.login() + def get_vessel(self, imo: str) -> Vessel: self.calls.append(("get_vessel", imo)) - if self.reject_login: - raise AuthenticationError("Equasis rejected the login") + self._ensure_login() imo = normalize_imo(imo) if imo not in self.vessels: raise NotFoundError(f"no vessel with IMO {imo} in Equasis") @@ -98,6 +102,7 @@ def search( self, query: str, *, max_pages: int | None = 3, ships: bool = True, companies: bool = True ) -> SearchResults: self.calls.append(("search", query)) + self._ensure_login() matching_ships = [ ShipSummary(imo=v.imo, name=v.name, flag=v.flag) for v in self.vessels.values() @@ -115,6 +120,7 @@ def search_ships( self, *, imo: str | None = None, mmsi: str | None = None, call_sign: str | None = None ) -> SearchResults: self.calls.append(("search_ships", imo or mmsi or call_sign)) + self._ensure_login() ships = [ ShipSummary(imo=v.imo, name=v.name) for v in self.vessels.values() @@ -124,6 +130,7 @@ def search_ships( def get_fleet(self, company_id: str, *, max_pages: int | None = None) -> Fleet: self.calls.append(("get_fleet", company_id)) + self._ensure_login() if company_id not in self.fleets: raise NotFoundError(f"no company with number {company_id} in Equasis") return self.fleets[company_id] diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..f3347e4 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,172 @@ +"""Tests for the page cache and its use by the client.""" + +from __future__ import annotations + +import os +import stat +from collections.abc import Callable +from datetime import timedelta +from pathlib import Path +from urllib.parse import parse_qs + +import pytest +import responses + +from equasis_cli.cache import PageCache, default_cache_dir +from equasis_cli.client import EquasisClient +from equasis_cli.transport import BASE_URL, RetryPolicy, Transport + +Loader = Callable[[str], str] +LOGIN_URL = BASE_URL + "authen/HomePage?fs=HomePage" +FLEET_URL = BASE_URL + "restricted/FleetInfo?fs=CompanyInfo" + + +class Clock: + def __init__(self) -> None: + self.now = 1_900_000_000.0 + + def __call__(self) -> float: + return self.now + + +def test_put_get_and_expiry(tmp_path: Path) -> None: + clock = Clock() + cache = PageCache(tmp_path, ttl=timedelta(hours=1), clock=clock) + key = PageCache.key("GET", "restricted/ShipInfo", {"P_IMO": "9811000"}) + + assert cache.get(key) is None + cache.put(key, "ship") + clock.now = os.stat(cache.pages_dir / f"{key}.html").st_mtime + 10 + page = cache.get(key) + assert page is not None + assert page.html == "ship" + + clock.now = os.stat(cache.pages_dir / f"{key}.html").st_mtime + 3601 + assert cache.get(key) is None + + +def test_keys_depend_on_every_request_detail() -> None: + base = PageCache.key("POST", "restricted/Search", data={"P_ENTREE": "TORM", "P_PAGE": "1"}) + assert base == PageCache.key( + "POST", "restricted/Search", data={"P_PAGE": "1", "P_ENTREE": "TORM"} + ) + assert base != PageCache.key( + "POST", "restricted/Search", data={"P_ENTREE": "TORM", "P_PAGE": "2"} + ) + assert base != PageCache.key( + "GET", "restricted/Search", data={"P_ENTREE": "TORM", "P_PAGE": "1"} + ) + + +def test_stats_and_clear(tmp_path: Path) -> None: + cache = PageCache(tmp_path) + assert cache.stats().entries == 0 + assert cache.clear() == 0 + cache.put("a", "one") + cache.put("b", "two") + stats = cache.stats() + assert stats.entries == 2 + assert stats.size_bytes == 6 + assert cache.clear() == 2 + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions") +def test_cache_directory_is_private(tmp_path: Path) -> None: + cache = PageCache(tmp_path / "cache") + cache.put("a", "page") + assert stat.S_IMODE(cache.directory.stat().st_mode) == 0o700 + + +def test_default_cache_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("EQUASIS_CACHE_DIR", str(tmp_path / "custom")) + assert default_cache_dir() == tmp_path / "custom" + monkeypatch.delenv("EQUASIS_CACHE_DIR") + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + assert default_cache_dir() == tmp_path / "equasis-cli" + + +def make_client(cache: PageCache, *, refresh: bool = False) -> EquasisClient: + transport = Transport(min_interval=0, retry=RetryPolicy(max_retries=0), sleep=lambda _: None) + return EquasisClient( + "user@example.com", "secret", transport=transport, cache=cache, refresh=refresh + ) + + +def ship_url(page: str) -> str: + return f"{BASE_URL}restricted/{page}?fs=ShipInfo&P_IMO=9811000" + + +def mock_vessel(fixture_html: Loader) -> None: + responses.get(LOGIN_URL, body=fixture_html("login_page")) + responses.post(LOGIN_URL, body=fixture_html("home_logged_in")) + responses.get(ship_url("ShipInfo"), body=fixture_html("ship_info_9811000")) + responses.get(ship_url("ShipInspection"), body=fixture_html("inspections_9811000")) + responses.get(ship_url("ShipHistory"), body=fixture_html("history_9811000")) + + +@responses.activate +def test_cached_vessel_needs_no_requests(tmp_path: Path, fixture_html: Loader) -> None: + cache = PageCache(tmp_path) + mock_vessel(fixture_html) + first = make_client(cache).get_vessel("9811000") + requests_made = len(responses.calls) + + second_client = make_client(cache) + second = second_client.get_vessel("9811000") + + assert second.name == first.name == "EVER GIVEN" + assert len(responses.calls) == requests_made + assert not second_client.logged_in + assert second.retrieved_at is not None + assert first.retrieved_at is not None + # The cached copy is dated by its file modification time, which some file systems + # record slightly after the in-memory timestamp. + assert abs((second.retrieved_at - first.retrieved_at).total_seconds()) < 2 + + +@responses.activate +def test_refresh_bypasses_the_cache(tmp_path: Path, fixture_html: Loader) -> None: + cache = PageCache(tmp_path) + mock_vessel(fixture_html) + make_client(cache).get_vessel("9811000") + requests_made = len(responses.calls) + + make_client(cache, refresh=True).get_vessel("9811000") + + assert len(responses.calls) > requests_made + + +@responses.activate +def test_login_and_error_pages_are_not_cached(tmp_path: Path, fixture_html: Loader) -> None: + cache = PageCache(tmp_path) + responses.get(LOGIN_URL, body=fixture_html("login_page")) + responses.post(LOGIN_URL, body=fixture_html("home_logged_in")) + responses.post(FLEET_URL, body=fixture_html("fleet_unknown_company")) + + with pytest.raises(Exception, match="no company"): + make_client(cache).get_fleet("9999999") + + assert cache.stats().entries == 0 + + +@responses.activate +def test_uncached_fleet_page_reloads_page_one_first(tmp_path: Path, fixture_html: Loader) -> None: + """Equasis serves later fleet pages only after page 1 in the same session.""" + cache = PageCache(tmp_path) + page_one = PageCache.key( + "POST", + "restricted/FleetInfo?fs=CompanyInfo", + data={"P_PAGE": "1", "P_COMP": "0152944", "ongletActifSC": "comp"}, + ) + cache.put(page_one, fixture_html("fleet_msc_page1")) + responses.get(LOGIN_URL, body=fixture_html("login_page")) + responses.post(LOGIN_URL, body=fixture_html("home_logged_in")) + responses.post(FLEET_URL, body=fixture_html("fleet_msc_page1")) + responses.post(FLEET_URL, body=fixture_html("fleet_msc_page2")) + + fleet = make_client(cache).get_fleet("0152944", max_pages=2) + + fleet_posts = [c for c in responses.calls if c.request.url == FLEET_URL] + pages = [parse_qs(str(c.request.body))["P_PAGE"][0] for c in fleet_posts] + assert pages == ["1", "2"] + assert len(fleet.vessels) == 10 diff --git a/tests/test_cli.py b/tests/test_cli.py index c43ae12..8455f02 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,9 +55,9 @@ def invoke(*argv: str, stdin: str = "", tty: bool = False) -> Result: stdin_stream = TTYStringIO(stdin) if tty else io.StringIO(stdin) seen: list[Transport] = [] - def factory(username: str, password: str, transport: Transport) -> FakeClient: + def factory(username: str, password: str, **options: object) -> FakeClient: assert (username, password) == ("user@example.com", "secret") - seen.append(transport) + seen.append(options["transport"]) # type: ignore[arg-type] return fake code = main( @@ -263,13 +263,15 @@ def test_delay_and_save_html_reach_the_transport( store: CredentialStore, fake: FakeClient, tmp_path: Path ) -> None: seen: list[Transport] = [] + cache_options: list[tuple[object, object]] = [] - def factory(username: str, password: str, transport: Transport) -> FakeClient: - seen.append(transport) + def factory(username: str, password: str, **options: object) -> FakeClient: + seen.append(options["transport"]) # type: ignore[arg-type] + cache_options.append((options["cache"], options["refresh"])) return fake main( - ["vessel", "9074729", "--delay", "2.5", "--save-html", str(tmp_path)], + ["vessel", "9074729", "--delay", "2.5", "--save-html", str(tmp_path), "--no-cache"], stdout=io.StringIO(), stderr=io.StringIO(), stdin=io.StringIO(), @@ -278,3 +280,16 @@ def factory(username: str, password: str, transport: Transport) -> FakeClient: ) assert seen[0].min_interval == 2.5 assert seen[0].save_html_dir == tmp_path + assert cache_options == [(None, False)] + + +def test_cache_command(run, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("EQUASIS_CACHE_DIR", str(tmp_path / "cache")) + from equasis_cli.cache import PageCache + + PageCache().put("abc", "") + info = run("cache") + assert "Cached pages: 1" in info.stdout + cleared = run("cache", "clear") + assert "Removed 1 cached page" in cleared.stderr + assert PageCache().stats().entries == 0 diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py new file mode 100644 index 0000000..8179b3a --- /dev/null +++ b/tests/test_enrichment.py @@ -0,0 +1,162 @@ +"""Tests for reference data and derived vessel indicators.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from equasis_cli.enrichment import compute_indicators, country, country_by_name, mmsi_country +from equasis_cli.enrichment.indicators import months_before +from equasis_cli.models import ( + ClassStatus, + CompanyChange, + FlagChange, + FlagPerformance, + Inspection, + InspectionReport, + NameChange, + PandIInsurer, + Vessel, +) +from tests.fakes import fixture_vessel + +TODAY = date(2026, 9, 16) + + +def test_country_lookups() -> None: + panama = country("pan") + assert panama is not None + assert (panama.alpha2, panama.name) == ("PA", "Panama") + assert country_by_name("PANAMA") == panama + assert country("XXX") is None + assert country_by_name(None) is None + + +@pytest.mark.parametrize( + ("mmsi", "expected"), + [ + ("353136000", "PAN"), + ("538001234", "MHL"), + ("477123456", "HKG"), + ("003531360", None), # coast station + ("111353100", None), # search and rescue aircraft + ("35313600", None), + (None, None), + ], +) +def test_mmsi_country(mmsi: str | None, expected: str | None) -> None: + result = mmsi_country(mmsi) + assert (result.alpha3 if result else None) == expected + + +@pytest.mark.parametrize( + ("reference", "months", "expected"), + [ + (date(2026, 9, 16), 36, date(2023, 9, 16)), + (date(2026, 3, 31), 1, date(2026, 2, 28)), + (date(2024, 3, 31), 1, date(2024, 2, 29)), + (date(2026, 1, 15), 12, date(2025, 1, 15)), + ], +) +def test_months_before(reference: date, months: int, expected: date) -> None: + assert months_before(reference, months) == expected + + +def test_indicators_for_a_vessel_with_red_flags() -> None: + vessel = Vessel( + imo="9999999", + name="NEW NAME", + flag="Cameroon", + flag_code="CMR", + mmsi="538001234", + year_built=1996, + status="In Service/Commission", + flag_performance=FlagPerformance( + paris_mou="Black", tokyo_mou="Grey", uscg_targeted=True, iacs_classed=False + ), + class_status=[ + ClassStatus(society="Some Register", status="Withdrawn", since=date(2025, 5, 1)) + ], + name_history=[ + NameChange(name="NEW NAME", since=date(2026, 5, 1)), + NameChange(name="MIDDLE", since=date(2024, 1, 1)), + NameChange(name="ORIGINAL", since=date(2001, 1, 1)), + ], + flag_history=[ + FlagChange(flag="Cameroon", since=date(2026, 5, 1)), + FlagChange(flag="Liberia", since=date(2001, 1, 1)), + ], + company_history=[ + CompanyChange(company="C", role="Registered owner", since=date(2026, 5, 1)), + CompanyChange(company="B", role="Registered owner", since=date(2024, 1, 1)), + CompanyChange(company="A", role="Registered owner", since=date(2001, 1, 1)), + ], + inspections=[ + Inspection( + authority="Spain", + port="Algeciras", + date=date(2026, 6, 1), + detained=True, + reports=[InspectionReport(psc_organisation="Paris MoU", deficiencies=12)], + ), + Inspection(authority="Oman", port="Sohar", date=date(2019, 1, 1), detained=True), + ], + ) + + indicators = compute_indicators(vessel, TODAY) + + assert indicators.age_years == 30 + assert indicators.name_changes_12_months == 1 + assert indicators.name_changes_36_months == 2 + assert indicators.flag_changes_12_months == 1 + assert indicators.management_changes_36_months == 2 + assert indicators.inspections_36_months == 1 + assert indicators.detentions_36_months == 1 + assert indicators.deficiencies_36_months == 12 + assert indicators.last_detention == date(2026, 6, 1) + assert indicators.flag_on_paris_mou_grey_or_black_list is True + assert indicators.flag_on_tokyo_mou_grey_or_black_list is True + assert indicators.class_withdrawn_or_suspended_36_months == ["Some Register: Withdrawn"] + assert indicators.mmsi_country == "Marshall Islands" + assert indicators.mmsi_matches_flag is False + assert indicators.observations == [ + "Renamed 2 times in the last 36 months", + "Changed flag 1 time in the last 36 months", + "2 changes of owner or manager in the last 36 months", + "Detained 1 time in the last 36 months", + "Flag is on the grey or black list of the Paris MoU and Tokyo MoU", + "Flag is targeted by the US Coast Guard", + "Not classed by an IACS member society", + "Class withdrawn in the last 36 months (Some Register)", + "MMSI 538001234 is allocated to Marshall Islands, not the flag state (Cameroon)", + "No P&I insurer recorded in Equasis", + ] + + +def test_indicators_for_a_quiet_vessel() -> None: + vessel = Vessel( + imo="9811000", + name="QUIET", + flag_code="PAN", + mmsi="353136000", + status="In Service/Commission", + pandi=[PandIInsurer(name="UK P&I Club")], + flag_performance=FlagPerformance(paris_mou="White", tokyo_mou="White", iacs_classed=True), + inspections=[Inspection(authority="x", port="y", date=date(2026, 1, 1), detained=False)], + ) + indicators = compute_indicators(vessel, TODAY) + assert indicators.mmsi_matches_flag is True + assert indicators.observations == [] + + +def test_indicators_from_recorded_vessels() -> None: + kavita = compute_indicators(fixture_vessel("9074729"), TODAY) + assert kavita.age_years == 31 + assert kavita.management_changes_36_months == 1 + assert kavita.last_inspection == date(2025, 1, 8) + assert "Flag is on the grey or black list of the Paris MoU and Tokyo MoU" in kavita.observations + + ever_given = compute_indicators(fixture_vessel("9811000"), TODAY) + assert ever_given.mmsi_matches_flag is True + assert ever_given.observations == ["Flag is targeted by the US Coast Guard"] diff --git a/tests/test_output.py b/tests/test_output.py index d7c848b..7a02429 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -147,7 +147,9 @@ def test_batch_formats() -> None: "warnings": [], "elapsed_seconds": 0.0, "vessel": None, + "indicators": None, } + assert document["results"][0]["indicators"]["age_years"] == 31 rows = list(csv.DictReader(io.StringIO(output.render(report, "csv")))) assert [(r["query"], r["lookup_status"], r["name"]) for r in rows] == [ diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 3a11b48..03d3045 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -13,6 +13,7 @@ from prompt_toolkit.input.base import PipeInput from prompt_toolkit.output import DummyOutput +from equasis_cli.cache import PageCache from equasis_cli.credentials import CredentialStore from equasis_cli.tui.app import MAX_OUTPUT_LINES, PROMPT, InteractiveShell, OutputLexer from equasis_cli.tui.commands import CommandRunner @@ -40,6 +41,7 @@ def shell(pipe: PipeInput, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> I print, store=store, client_factory=lambda u, p: fake, # type: ignore[arg-type,return-value] + cache=PageCache(tmp_path / "cache"), ) return InteractiveShell(runner=runner, input=pipe, output=DummyOutput()) @@ -55,10 +57,24 @@ def test_banner_is_shown(shell: InteractiveShell) -> None: @pytest.mark.parametrize( ("text", "expected"), [ - ("/", ["vessel", "search", "fleet", "batch", "format", "status", "clear", "help", "exit"]), + ( + "/", + [ + "vessel", + "search", + "fleet", + "batch", + "format", + "cache", + "status", + "clear", + "help", + "exit", + ], + ), ("/ba", ["batch"]), ("batch /co", ["/companies", "/company-file"]), - ("vessel /imo 1 /", ["/format", "/output"]), + ("vessel /imo 1 /", ["/refresh", "/format", "/output"]), ("vessel ", []), ("ves", []), ], diff --git a/tests/test_tui_commands.py b/tests/test_tui_commands.py index 255a22b..1c8aae3 100644 --- a/tests/test_tui_commands.py +++ b/tests/test_tui_commands.py @@ -9,6 +9,7 @@ import pytest +from equasis_cli.cache import PageCache from equasis_cli.credentials import CredentialStore from equasis_cli.models import CompanySummary from equasis_cli.tui.commands import CommandError, CommandRunner, help_text, parse_command @@ -115,6 +116,7 @@ def runner(store: CredentialStore, fake: FakeClient, recorder: Recorder) -> Comm recorder.statuses.append, store=store, client_factory=lambda username, password: fake, # type: ignore[arg-type,return-value] + cache=PageCache(store.config_dir.parent / "cache"), ) @@ -249,3 +251,20 @@ def test_invalid_imo_and_status(runner: CommandRunner, recorder: Recorder) -> No assert "not a valid IMO number" in recorder.lines[-1] runner.run("status") assert "Account: user@example.com" in recorder.lines[-1] + + +def test_refresh_flag_and_cache_command( + runner: CommandRunner, recorder: Recorder, fake: FakeClient +) -> None: + runner.run("vessel /imo 9074729 /refresh") + assert fake.refresh is True + runner.run("vessel /imo 9074729") + assert fake.refresh is False + + runner.cache.put("x", "") + runner.run("cache") + assert recorder.lines[-1].startswith("Cache: 1 pages") + runner.run("cache clear") + assert recorder.lines[-1] == "Removed 1 cached page." + runner.run("cache purge") + assert "use 'cache info' or 'cache clear'" in recorder.lines[-1]