Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions src/equasis_cli/cache.py
Original file line number Diff line number Diff line change
@@ -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,
)
69 changes: 59 additions & 10 deletions src/equasis_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -356,6 +389,7 @@ def run(self) -> int:
"search": self.search,
"fleet": self.fleet,
"configure": self.configure,
"cache": self.cache,
}[command]
return handler()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading