diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c5fc08f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Something does not work as documented +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. Please never include your Equasis password, your + credentials file, or pages saved with `--save-html` that still contain your name. + - type: input + id: version + attributes: + label: equasis-cli version + description: Output of `equasis --version` + placeholder: equasis 3.0.0 + validations: + required: true + - type: input + id: environment + attributes: + label: Operating system and Python version + placeholder: macOS 15, Python 3.13 + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Mode + options: + - Command line + - Interactive shell + - Python API + validations: + required: true + - type: textarea + id: command + attributes: + label: Command + description: The exact command you ran + render: shell + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + validations: + required: true + - type: textarea + id: actual + attributes: + label: What happened + description: Include the output and exit code. Re-running with `--debug` often helps. + render: text + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..54fdf83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/rhinonix/equasis-cli/security/advisories/new + about: Report security issues privately. + - name: Commercial licensing + url: https://github.com/rhinonix/equasis-cli/blob/main/LICENSING.md + about: Using equasis-cli commercially. diff --git a/.github/ISSUE_TEMPLATE/equasis_changed.yml b/.github/ISSUE_TEMPLATE/equasis_changed.yml new file mode 100644 index 0000000..cc39d40 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/equasis_changed.yml @@ -0,0 +1,42 @@ +name: Equasis page changed +description: Exit code 6, "could not read the Equasis page", or data that is missing or in the wrong fields +labels: [bug] +body: + - type: markdown + attributes: + value: | + Equasis sometimes changes its website, which can break parsing. A copy of the page + makes this quick to fix. + + 1. Re-run the failing command with `--save-html pages --refresh`. + 2. Open the saved page for the failing request in a text editor and replace your + name in the "Welcome" section with `Test User`. + 3. Attach the edited page below. + - type: input + id: version + attributes: + label: equasis-cli version + description: Output of `equasis --version` + validations: + required: true + - type: textarea + id: command + attributes: + label: Command and output + render: text + validations: + required: true + - type: textarea + id: page + attributes: + label: Saved page (with your name removed) + description: Drag the HTML file here, or describe what looks different on equasis.org. + validations: + required: false + - type: checkboxes + id: privacy + attributes: + label: Privacy + options: + - label: I removed my name from any attached page + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..ca8b836 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,19 @@ +name: Feature request +description: Suggest an improvement +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: Describe the research or workflow problem, not only the solution. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + - type: textarea + id: alternatives + attributes: + label: Alternatives you have considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4a0159d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Checklist + +- [ ] Tests added or updated (parser changes include a fixture made with `tests/tools/make_fixture.py`) +- [ ] `ruff check .`, `ruff format --check .`, `mypy`, and `pytest` pass +- [ ] Documentation updated for user-visible changes +- [ ] Entry added under "Unreleased" in `CHANGELOG.md` +- [ ] No credentials or personal information in code, fixtures, or logs diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 0000000..71eeb65 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,44 @@ +name: Live canary + +# Runs the live test suite against equasis.org once a week to detect website +# changes before users do. Requires the repository secrets EQUASIS_USERNAME and +# EQUASIS_PASSWORD; without them the job is skipped. + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + live: + name: Live Equasis checks + runs-on: ubuntu-latest + env: + EQUASIS_USERNAME: ${{ secrets.EQUASIS_USERNAME }} + EQUASIS_PASSWORD: ${{ secrets.EQUASIS_PASSWORD }} + EQUASIS_LIVE_TESTS: "1" + steps: + - name: Check for credentials + id: credentials + run: | + if [ -n "$EQUASIS_USERNAME" ] && [ -n "$EQUASIS_PASSWORD" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::EQUASIS_USERNAME and EQUASIS_PASSWORD secrets are not set; skipping." + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.credentials.outputs.available == 'true' + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + if: steps.credentials.outputs.available == 'true' + with: + python-version: "3.14" + cache: pip + - name: Install + if: steps.credentials.outputs.available == 'true' + run: python -m pip install -e ".[dev]" + - name: Run live tests + if: steps.credentials.outputs.available == 'true' + run: pytest tests/test_live.py -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2425cab..c9b281b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: lint: - name: Lint + name: Lint and type check runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -29,6 +29,8 @@ jobs: run: ruff check . - name: Ruff format run: ruff format --check . + - name: Mypy + run: mypy test: name: Test (Python ${{ matrix.python-version }}, ${{ matrix.os }}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..603b95b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# Publishing uses PyPI trusted publishing (OpenID Connect), so no API tokens are stored. +# - Run manually ("Run workflow") to publish to TestPyPI for a dry run. +# - Publishing a GitHub release publishes to PyPI after approval of the "pypi" environment. +# See RELEASING.md for the one-time setup. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Check that the tag matches the package version + if: github.event_name == 'release' + env: + TAG: ${{ github.event.release.tag_name }} + run: | + version=$(python -c "import re, pathlib; print(re.search(r'__version__ = \"(.+)\"', pathlib.Path('src/equasis_cli/_version.py').read_text()).group(1))") + if [ "v$version" != "$TAG" ]; then + echo "::error::Tag $TAG does not match package version $version" + exit 1 + fi + - name: Build and check + run: | + python -m pip install build twine + python -m build + python -m twine check --strict dist/* + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + + testpypi: + name: Publish to TestPyPI + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/equasis-cli + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi: + name: Publish to PyPI + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/equasis-cli + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b54f81c..469074a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +# Recorded Equasis pages are test data and must stay byte-for-byte as generated. +exclude: ^tests/fixtures/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..df059a4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +All notable changes to equasis-cli are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [3.0.0] - 2026-09-16 + +A rebuild focused on reliability, correct data, and scripting. Existing users should read +**Upgrading from 2.x** below. + +### Fixed + +- Interactive `batch` did nothing ([#16](https://github.com/rhinonix/equasis-cli/issues/16)). +- Interactive `/output file.json` and `/output file.csv` wrote table text instead of JSON + or CSV ([#17](https://github.com/rhinonix/equasis-cli/issues/17)). +- Interactive `fleet` did nothing ([#18](https://github.com/rhinonix/equasis-cli/issues/18)). +- An expired Equasis session was reported as "No vessel found". The session is now renewed + automatically. +- An inspection reported to two PSC regimes produced an extra, garbled inspection record. +- Every inspection was flagged as a detention in table output. +- Tables were read by column position, so values could land in the wrong fields. + Columns are now matched by their headers. +- Permanent HTTP errors such as 404 were retried, and `Retry-After` was ignored. +- Status messages were printed into JSON and CSV output, and every failure exited with + status 0. +- CSV output did not quote values containing commas and had no header for single vessels. +- `--output` and `--output-file` only worked before the command name. +- `--debug` had no effect, and `--continue-on-error` did nothing. +- Batch files with inline `#` comments sent the comment to Equasis as part of the IMO number. +- Interactive log messages appeared twice, and typographic quotes and parameters such as + `/company-file` were not recognized. +- Search and fleet results beyond the first page were missing. +- The Comoros flag was reported with the code `XCM` instead of `COM`. + +### Added + +- Indicators: name, flag, and management changes; recent inspections, detentions, and + deficiencies; grey or black listed flags; USCG targeting; class withdrawals and + suspensions; P&I insurance; and whether the MMSI matches the flag state. +- New vessel data: safety management certificates, P&I insurance, class renewal surveys, + classification history, human element deficiencies, and company roles with dates. +- Fleet data: class, detention counts, and the roles the company holds for each vessel. +- `search --imo`, `--mmsi`, and `--call-sign` for exact lookups, plus `--type`, + `--max-pages`, and `--all-pages`. +- `fleet` accepts company numbers (`--company-id`), prompts when a name is ambiguous, and + supports `--first-match`. +- JSON Lines output (`-f jsonl`), which streams batch results as they complete. +- A 24-hour page cache with `--no-cache`, `--refresh`, and `equasis cache [info|clear]` + ([#2](https://github.com/rhinonix/equasis-cli/issues/2)). +- `--delay`, `--save-html`, `--quiet`, `--no-color`, `configure --test`, and `--version`. +- Documented exit codes for scripting. +- Batch input from standard input (`--imo-file -`) and IMO check-digit warnings. +- Interactive `help COMMAND`, `cache`, `/refresh`, `/pages`, and `/first`. +- A typed Python API (`EquasisClient`, models, and `compute_indicators`). +- Tests with recorded Equasis pages, continuous integration on Linux, macOS, and Windows, + and a weekly live check against equasis.org. + +### Changed + +- Relicensed from CC BY-NC-SA 4.0 to the PolyForm Noncommercial License 1.0.0, with an + additional permission for journalism and nonprofit research (see LICENSING.md). +- Python 3.10 or later is required. +- JSON output uses a versioned envelope (`schema_version`, `type`, `retrieved_at`), ISO 8601 + dates, and integer tonnages. The layout is documented in docs/output-schema.md. +- CSV output was redesigned: one row per vessel with key facts and indicators, and batch + files use a `lookup_status` column. +- Batches continue after failures by default; use `--fail-fast` to stop. +- Credentials are written atomically with owner-only permissions. A warning is shown if + the credentials file is readable by others. +- `-f/--format` is the name of the format option (`--output` still works) and + `-o/--output-file` sets the output file. +- Progress messages, warnings, and errors are written to stderr. + +### Removed + +- The `.env` file references (credentials come from `configure --setup` or environment + variables) and the unused `python-dotenv` and `lxml` dependencies. +- The unused `--batch` option. +- The startup banner before command output. `--no-banner` is still accepted and has no + effect. + +### Upgrading from 2.x + +- Update scripts that read JSON: vessel fields are under `.vessel` (for example + `.vessel.name` instead of `.basic_info.name`), and dates are ISO 8601. +- Update scripts that read CSV to use the new column names. +- Scripts that relied on exit status 0 for failures should check for the new codes. +- The Python API changed: use `EquasisClient.get_vessel`, `search`, `search_ships`, and + `get_fleet`. + +## [2.0.0] - 2025-10-01 + +### Added + +- Full-screen interactive shell with command menus, loading indicators, and scroll mode. +- Company search and fleet retrieval by company number. + +## [1.0.0] - 2025-09-27 + +### Added + +- First stable release: vessel lookup across the Ship Info, Inspections, and Ship History + pages, vessel search, fleets, batch processing, retries, and credential management. + +[Unreleased]: https://github.com/rhinonix/equasis-cli/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/rhinonix/equasis-cli/compare/v2.0.0...v3.0.0 +[2.0.0]: https://github.com/rhinonix/equasis-cli/compare/v1.0.0...v2.0.0 +[1.0.0]: https://github.com/rhinonix/equasis-cli/releases/tag/v1.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..078b6ee --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [rhinonix.github.exclaim769@slmail.me](mailto:rhinonix.github.exclaim769@slmail.me). All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6a1e71c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +Thank you for helping improve equasis-cli. Bug reports, parser fixes for Equasis website +changes, and documentation improvements are especially welcome. + +## Reporting problems + +- **Equasis changed its website** (exit code 6, or missing or wrong data): use the + "Equasis page changed" issue template. Re-run the command with `--save-html DIR` and + attach the relevant page after removing your name from it. Your name appears in the + "Welcome" section near the top of the page. +- **Other bugs**: use the bug report template and include `equasis --version`, your + operating system, the exact command, and the output with `--debug`. +- **Security issues**: do not open a public issue; see [SECURITY.md](SECURITY.md). + +Never post your Equasis password, the contents of your credentials file, or unredacted +pages saved with `--save-html`. + +## Development setup + +```bash +git clone https://github.com/rhinonix/equasis-cli.git +cd equasis-cli +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +pre-commit install +``` + +See [docs/development.md](docs/development.md) for the project layout, tests, and test +fixtures. + +## Pull requests + +1. Open an issue first for larger changes so the approach can be agreed. +2. Keep each pull request focused, and write commit messages that explain what changed + and why. +3. Add or update tests. Parser changes need a fixture recorded from the real page (see + [docs/development.md](docs/development.md#test-fixtures)). +4. Update the documentation and add an entry under "Unreleased" in + [CHANGELOG.md](CHANGELOG.md) for user-visible changes. +5. Make sure the checks pass: + + ```bash + ruff check . + ruff format --check . + mypy + pytest + ``` + +## Licensing of contributions + +equasis-cli is licensed under the PolyForm Noncommercial License 1.0.0, and commercial +licenses are available separately (see [LICENSING.md](LICENSING.md)). So that the project +can continue to offer both, you will be asked to agree to a contributor license agreement +(CLA) before your first pull request is merged. You keep the copyright to your +contribution; the agreement allows it to be distributed under both licenses. + +## Code of conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating you +agree to uphold it. 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/README.md b/README.md index 192b081..196a23b 100644 --- a/README.md +++ b/README.md @@ -1,1347 +1,191 @@ -# Equasis CLI Tool +# equasis-cli -![Alt text](https://github.com/user-attachments/assets/d8efd954-438b-4e91-b000-8391b2f0321f "Screenshot of equasis-cli") +[![CI](https://github.com/rhinonix/equasis-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/rhinonix/equasis-cli/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/equasis-cli.svg)](https://pypi.org/project/equasis-cli/) +[![Python](https://img.shields.io/pypi/pyversions/equasis-cli.svg)](https://pypi.org/project/equasis-cli/) +[![License: PolyForm Noncommercial](https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-lightgrey.svg)](LICENSING.md) -[![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) -[![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-lightgrey.svg)](LICENSING.md) -[![GitHub Stars](https://img.shields.io/github/stars/rhinonix/equasis-cli?style=social)](https://github.com/rhinonix/equasis-cli/stargazers) -[![GitHub Issues](https://img.shields.io/github/issues/rhinonix/equasis-cli)](https://github.com/rhinonix/equasis-cli/issues) +Look up vessels, port state control inspections, ownership history, and company fleets +in [Equasis](https://www.equasis.org) from the command line, an interactive shell, or +Python. -A command-line tool for accessing Equasis maritime intelligence. Created to solve a real problem: investigating large numbers of vessels and management companies is cumbersome through the web interface. This tool provides a scriptable way to query vessel data, export in multiple formats, and analyze fleets efficiently. +Investigating many vessels or companies through the Equasis website is slow and manual. +equasis-cli turns it into scriptable commands that produce tables, JSON, JSON Lines, or +CSV, handle batches of hundreds of vessels, and summarize the signals researchers look for. -> **Major Update**: Now provides comprehensive vessel intelligence with 50+ data points including management companies, PSC inspections, historical names/flags, and classification details - all collected automatically from multiple Equasis tabs. - -> [!IMPORTANT] -> The HTML parsing may need updates if Equasis changes their website structure. If you encounter parsing errors or missing data, please [submit an issue](https://github.com/rhinonix/equasis-cli/issues) with details about the error. Developers are welcome to submit pull requests with fixes. - -## Table of Contents - -- [Overview](#overview) -- [Features](#features) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Configuration](#configuration) -- [Usage](#usage) -- [Output Formats](#output-formats) -- [Examples](#examples) -- [Development](#development) -- [Troubleshooting](#troubleshooting) -- [Legal and Compliance](#legal-and-compliance) -- [Contributing](#contributing) -- [License](#license) - -## Overview - -Equasis CLI Tool is a Python-based command-line application that interfaces with the Equasis maritime database to retrieve vessel and fleet information. Unlike web-based tools, this CLI provides: - -- Fast, scriptable access to maritime data -- Multiple output formats (table, JSON, CSV) -- Batch processing capabilities -- Integration with automation workflows -- Clean separation from GUI dependencies +![Screenshot of the equasis-cli interactive shell](https://github.com/user-attachments/assets/d8efd954-438b-4e91-b000-8391b2f0321f) ## Features -### Current Features - -#### Core Vessel Intelligence - -- **Comprehensive Vessel Lookup**: Get complete vessel profiles with 50+ data points from multiple Equasis tabs -- **3-Tab Data Collection**: Automatically fetches Ship Info, Inspections, and Ship History data -- **Management Company Details**: Current and historical management with roles and dates -- **PSC Inspection History**: Port State Control inspections with detention records -- **Historical Vessel Data**: Name changes, flag changes, and ownership history over time -- **Classification Society Info**: Current and historical classification with status -- **Geographical Tracking**: Vessel location history and movement patterns -- **Flag Performance**: Paris MOU and Tokyo MOU ratings, USCG targeting status - -#### Search & Fleet Operations - -- **Vessel Search by IMO**: Look up comprehensive vessel information using IMO numbers -- **Vessel Search by Name**: Search for vessels using partial or complete names -- **Fleet Information**: Retrieve fleet data for shipping companies -- **Batch Processing**: Process multiple vessels or companies in a single operation with progress tracking - -#### Technical Features - -- **Professional Package Structure**: Modular architecture ready for distribution (Homebrew, PyPI) -- **Multiple Output Formats**: Export data as formatted tables, structured JSON, or CSV -- **File Output**: Save results directly to files -- **Session Management**: Automatic login and session handling with error recovery -- **Rate Limiting**: Built-in delays to respect server resources (1-second intervals) -- **Environment Variables**: Secure credential management via .env files -- **Comprehensive Logging**: Debug and monitor operations with detailed logging -- **Enterprise-Grade Retry Logic**: Exponential backoff with jitter for network resilience - -### [Planned Features](https://github.com/users/rhinonix/projects/1/views/1) - -#### Near-Term Enhancements - -- **Data Caching**: Reduce redundant API calls with intelligent caching -- **Enhanced Search/Fleet Parsing**: Apply comprehensive parsing to search results -- **Progress Indicators**: Visual feedback for long operations -- **Configuration Files**: User preferences and custom settings - -#### Medium-Term Goals - -- **Homebrew Distribution**: Native macOS installation via `brew install equasis-cli` -- **Additional Maritime Data**: Integration with other vessel databases -- **Export Formats**: Excel, PDF reports, specialized maritime formats -- **Vessel Analytics**: Risk assessment and compliance scoring -- **Fleet Intelligence**: Company-wide vessel analysis and reporting - -#### Advanced Features - -- **Maritime Intelligence Platform**: Beyond basic vessel lookup -- **API Service**: RESTful API for integration with other maritime tools -- **Visualization**: Charts and graphs for historical vessel data -- **Automated Monitoring**: Alerts for vessel status changes - -## Prerequisites - -- **Python 3.10+**: Required for modern features and type hints -- **Equasis Account**: Valid username and password from equasis.org -- **Internet Connection**: Required for accessing Equasis servers - -### System Requirements - -- macOS, Linux, or Windows -- 50MB disk space for virtual environment -- Stable internet connection +- **Complete vessel profiles**: particulars, flag performance, management companies, + classification and surveys, safety management certificates, P&I insurance, recent + sightings, PSC inspections with detentions and deficiencies, and name, flag, class, and + company history +- **Indicators**: renames, reflagging, and management changes; recent detentions; grey or + black listed flags; class withdrawals; missing P&I cover; MMSI and flag mismatches. + All are facts from Equasis data, with no opaque scores. +- **Search**: ships and companies by name, or ships by exact IMO number, MMSI, or call sign +- **Fleets**: every vessel linked to a company, with roles, class, and detention counts +- **Batches**: hundreds of vessels or companies from a file, continuing past failures and + streaming results as they complete +- **Output for people and programs**: readable tables, or versioned JSON, JSON Lines, and + CSV. Results go to stdout, messages to stderr, and exit codes are documented. +- **Interactive shell** with command menus, history, and scrolling +- **Considerate by default**: request pacing, retries with backoff, and a 24-hour page cache ## Installation -### Prerequisites - -- **pipx**: Recommended for isolated installation of Python CLI tools -- **Python 3.10+**: Required for the application -- **Git** (optional): For cloning the repository - -### Install pipx (if not already installed) - -```bash -# On macOS with Homebrew -brew install pipx - -# On other systems, use pip -python3 -m pip install --user pipx - -# Add pipx to PATH -pipx ensurepath - -# Restart your terminal or run: -source ~/.zshrc # or ~/.bashrc -``` - -### Install Equasis CLI - -#### Option 1: Install from Source (Recommended for Development) - -```bash -# Clone or download the project -cd ~/Projects/ # or your preferred location -git clone equasis-cli # or download and extract -cd equasis-cli - -# Install with pipx in editable mode -pipx install -e . - -# Verify installation -equasis --help -``` - -#### Option 2: Install from Git (if available) - -```bash -# Install directly from repository -pipx install git+ -``` - -#### Option 3: Traditional Virtual Environment (Alternative) - -If you prefer not to use pipx: - -```bash -cd ~/Projects/equasis-cli - -# Create and activate virtual environment -python3 -m venv venv -source venv/bin/activate - -# Install dependencies -pip install -r requirements.txt - -# Install in development mode -pip install -e . - -# Note: You'll need to activate the venv each time you use the tool -``` - -## Configuration - -### Recommended: Secure Credential Storage +equasis-cli requires Python 3.10 or later. Installing with [pipx](https://pipx.pypa.io) +keeps it isolated from other Python packages: -Set up your Equasis credentials using the built-in credential manager: - -```bash -# Interactive credential setup (recommended) -equasis configure --setup -``` - -This securely stores your credentials in `~/.config/equasis-cli/credentials.json` with proper file permissions (owner-only access). Works from any directory and follows industry standards. - -### Alternative Methods - -**Environment Variables:** -```bash -# Set environment variables (works globally) -export EQUASIS_USERNAME=your_username_here -export EQUASIS_PASSWORD=your_password_here -``` - -**Command Line Arguments:** ```bash -# Pass credentials directly (least secure, not recommended) -equasis --username your_user --password your_pass vessel --imo 9074729 +pipx install equasis-cli ``` -### Credential Management Commands - -```bash -# Set up credentials interactively -equasis configure --setup - -# Check credential status (helpful for debugging) -equasis configure --show - -# Remove stored credentials -equasis configure --clear -``` - -### Security Features - -- **XDG-compliant storage**: Follows industry standards (`~/.config/equasis-cli/`) -- **Secure file permissions**: Config file readable only by owner (600 permissions) -- **Credential hierarchy**: Command-line args override environment vars override config file -- **Cross-platform**: Works on macOS, Linux, Windows with appropriate config directories -- **Global operation**: No dependency on project directories or local files - -## Usage - -The tool is available globally after installation via pipx. No need to activate virtual environments or navigate to specific directories. - -### Interactive Mode (Recommended) - -Simply type `equasis` with no arguments to enter interactive mode: - -```bash -equasis -``` - -This launches a **modern professional TUI** with advanced features: - -**TUI Features:** -- **Fixed input area** at bottom (doesn't scroll away) -- **Persistent status bar** showing connection, format, and operation status -- **Gradient color scheme** (professional magenta-to-purple gradient banner) -- **Context-aware ephemeral menus** - Press `?` for help, `Tab` for slash parameters -- **Interactive company selection** - Automatic disambiguation for fleet queries -- **Ephemeral help menu** - Press `?` to show available commands -- **Vim/Less-style scrolling** - Navigate output with `j`/`k` keys -- **Animated loading indicators** - Spinner with shine effect during operations -- **Custom styled output** - Gradient ASCII art and syntax highlighting -- **Auto-completion** - Tab completion for slash parameters - -**Example session:** -``` -──────────────────────────────────────── -> vessel /imo 9074729 -──────────────────────────────────────── -[vessel data table appears here] -──────────────────────────────────────── -● Connected • Format: table • ✓ Vessel 9074729 found - -──────────────────────────────────────── -> search /name "MAERSK" -──────────────────────────────────────── -[search results appear here] -──────────────────────────────────────── -● Connected • Format: table • ✓ Found 45 vessel(s) -> exit -``` - -**Interactive Commands:** -```bash -# Vessel lookup -vessel /imo 9074729 -vessel /imo 9074729 /format json -vessel /imo 9074729 /output vessel_data.json - -# Vessel search -search /name "MAERSK" -search /imo 9074729 # Search by IMO (useful for companies) -search /name "EVER GIVEN" /format json - -# Fleet information -fleet /company "MSC" -fleet /company "MAERSK LINE" /format csv - -# Batch processing -batch /imos "9074729,8515128,9632179" -batch /file fleet_imos.txt /format json /output results.json -batch /companies "MSC,MAERSK,COSCO" /progress - -# Utility commands -format json # Set default output format -status # Show connection status -clear # Clear screen -? # Quick help (clears screen first) -help # Full command list -help vessel # Command-specific help -exit # Exit interactive mode -``` - -**Keyboard Shortcuts:** -- **`?`** - Toggle help menu (keyboard shortcuts overlay) -- **`/`** - Show context-aware command/parameter menu with filtering -- **`Tab`** - Select from menu (replaces from last `/` for parameters) -- **`Enter`** - Execute command -- **`Up/Down`** - Navigate menu / scroll output / command history (context-aware) -- **`Esc`** - Enter scroll mode (navigate output buffer) -- **`i`** - Return to input mode from scroll mode -- **`Ctrl+K/J`** - Quick scroll up/down (5 lines) -- **`Page Up/Down`**, **`Shift+Up/Down`** - Scroll output buffer -- **`Ctrl+C`** - Cancel input and hide menus -- **`Ctrl+D`** - Exit application - -**Interactive Mode Benefits:** -- **Persistent Session**: Authenticate once, run multiple queries -- **Modern Syntax**: Use `/param value` slash command syntax -- **Fast Queries**: No re-authentication between commands -- **Context-Aware Menus**: Commands without `/`, parameters with `/` -- **Format Control**: Set default output format or override per-command -- **File Output**: Save results with `/output filename.ext` -- **Batch Processing**: Process multiple vessels or companies efficiently -- **Scroll Mode**: Vim/less-style navigation for reviewing output - -### Traditional CLI Mode - -You can also use traditional command-line syntax: - -```bash -equasis [global_options] command [command_options] -``` - -### Global Options - -- `--username USERNAME`: Equasis username (optional if set in .env) -- `--password PASSWORD`: Equasis password (optional if set in .env) -- `--output FORMAT`: Output format (table, json, csv) - default: table -- `--output-file FILE`: Save output to file instead of printing - -### Tab Completion - -The tool includes comprehensive tab completion for zsh with intelligent suggestions and examples. - -**Completion Features:** - -- **Commands**: `equasis ` shows available commands with descriptions -- **Command-specific options**: `equasis vessel ` shows only vessel-related options -- **Output formats**: `equasis --output ` shows format options with descriptions -- **Real examples**: Completion provides actual IMO numbers, vessel names, and company names -- **Context-aware**: Different completions for different commands - -**What you can complete:** - -- Commands: `vessel`, `search`, `fleet` -- Global options: `--username`, `--password`, `--output`, `--output-file`, `--help` -- Output formats: `table`, `json`, `csv` (with descriptions) -- Example IMO numbers: Real vessels like EVER GIVEN (9074729) -- Example vessel names: "EVER GIVEN", "MAERSK", "COSCO", etc. -- Major shipping companies: "MAERSK LINE", "MSC", "COSCO SHIPPING", etc. - -**Setting up tab completion:** +Alternatives: `uv tool install equasis-cli`, or `pip install equasis-cli` inside a +virtual environment. -1. **Ensure completion directory exists:** +## Getting started - ```bash - mkdir -p ~/.zsh/completions - ``` - -2. **Copy completion script** (if installed from source): +1. Register for a free account at [equasis.org](https://www.equasis.org). +2. Store your credentials. This checks that the login works and saves the credentials to + a file only you can read: ```bash - cp ~/Projects/equasis-cli/completions/equasis_completion.zsh ~/.zsh/completions/_equasis + equasis configure --setup ``` -3. **Add to .zshrc** (if not already present): + You can also set `EQUASIS_USERNAME` and `EQUASIS_PASSWORD` in the environment. - ```bash - echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc - echo 'autoload -Uz compinit && compinit' >> ~/.zshrc - ``` +3. Look up a vessel: -4. **Reload shell:** ```bash - source ~/.zshrc + equasis vessel 9811000 ``` -**Usage tips:** - -- Always add a space before pressing TAB: `equasis vessel --imo ` (not `equasis vessel --imo`) -- Use TAB at any point to see available options -- Select from examples or type your own values -- Descriptions help you understand what each option does - -### Commands - -#### Interactive Mode Commands - -When in interactive mode (started with `equasis`), use these commands: - -##### vessel - -Get comprehensive vessel information by IMO number. - -```bash -> vessel /imo 9074729 -> vessel /imo 9074729 /format json -> vessel /imo 9074729 /output vessel_data.json -``` - -**Parameters:** -- `/imo IMO_NUMBER`: Required. The IMO number of the vessel -- `/format FORMAT`: Optional. Output format (table, json, csv) -- `/output FILE_OR_FORMAT`: Optional. Save to file or set format - -##### search - -Search for vessels by name (partial matches supported). - -```bash -> search /name "MAERSK" -> search /name "EVER GIVEN" /format json -> search /name "CONTAINER" /output results.csv -``` - -**Parameters:** -- `/name VESSEL_NAME`: Required. Full or partial vessel name -- `/format FORMAT`: Optional. Output format (table, json, csv) -- `/output FILE_OR_FORMAT`: Optional. Save to file or set format - -##### fleet - -Get fleet information for a shipping company. - -```bash -> fleet /company "MSC" -> fleet /company "MAERSK LINE" /format json -> fleet /company "COSCO" /output fleet_data.json -``` - -**Parameters:** -- `/company COMPANY_NAME`: Required. Company name or identifier -- `/format FORMAT`: Optional. Output format (table, json, csv) -- `/output FILE_OR_FORMAT`: Optional. Save to file or set format - -##### format - -Set default output format for all commands. - -```bash -> format json -> format table -> format csv -``` - -##### status - -Show current session status. - -```bash -> status -``` - -Displays: -- Connection status -- Current output format -- Debug mode status - -##### clear - -Clear the terminal screen. - -```bash -> clear -``` - -##### help - -Show help information. - -```bash -> help # List all commands -> help vessel # Show help for vessel command -> help output # Show help for file output -``` - -##### exit / quit - -Exit interactive mode. - -```bash -> exit -> quit -``` - -You can also use `Ctrl+D` to exit. - -#### Traditional CLI Mode Commands - -When using traditional syntax: - -##### vessel - -```bash -equasis vessel --imo IMO_NUMBER -``` - -**Options:** -- `--imo IMO_NUMBER`: Required. The IMO number of the vessel - -##### search - -```bash -equasis search --name VESSEL_NAME -``` - -**Options:** -- `--name VESSEL_NAME`: Required. Full or partial vessel name - -##### fleet - -```bash -equasis fleet --company COMPANY_NAME -``` - -**Options:** -- `--company COMPANY_NAME`: Required. Company name or identifier - -## Output Formats - -### Table Format (Default) - Comprehensive Intelligence - -Human-readable formatted output with comprehensive vessel intelligence. - -``` -Vessel Information (Comprehensive): -=================================== -IMO Number: 8515128 -Name: ALMAHER -Flag: St.Kitts and Nevis (KNA) -Call Sign: V4FN6 -MMSI: 341787001 -Type: Passenger/Ro-Ro Ship (vehicles) -Gross Tonnage: 1792 -DWT: 964 -Year Built: 1986 -Status: In Service/Commission -Last Update: 02/07/2025 - -Management Companies (3): ------------------------------- -• AL JADARA MEMIZA (Ship manager/Commercial manager) -• AL JADARA MEMIZA (Registered owner) -• UNKNOWN (ISM Manager) - -Classification (1): ----------------- -• International Register of Shipping (IS) - Withdrawn - -Recent PSC Inspections (1 of 1): ------------------------------------ -• 03/04/2009: Tokyo MoU - ⚠️ Detention: N - -Historical Names (3 total): ----------------------- -• ALMAHER (since 01/07/2024) -• St. Anthony de Padua (since 01/08/2012) -• Cebu Ferry 2 (since 01/04/2009) -``` - -### JSON Format - Rich Nested Structure - -Structured data with comprehensive vessel intelligence for programmatic processing. - -```json -{ - "basic_info": { - "imo": "8515128", - "name": "ALMAHER", - "flag": "St.Kitts and Nevis", - "flag_code": "KNA", - "call_sign": "V4FN6", - "mmsi": "341787001", - "vessel_type": "Passenger/Ro-Ro Ship (vehicles)", - "gross_tonnage": "1792", - "dwt": "964", - "year_built": "1986", - "status": "In Service/Commission", - "last_update": "02/07/2025" - }, - "management": [ - { - "name": "AL JADARA MEMIZA", - "role": "Ship manager/Commercial manager", - "imo": "0050002", - "date_effect": "since 01/07/2024" - } - ], - "inspections": [ - { - "date": "03/04/2009", - "psc_organization": "Tokyo MoU", - "detention": "N", - "authority": "Japan", - "port": "Osaka" - } - ], - "historical_names": [ - { - "name": "ALMAHER", - "date_effect": "since 01/07/2024", - "source": "Equasis" - } - ] -} -``` - -### CSV Format - -Basic CSV format for spreadsheet compatibility (basic fields only). - -```csv -8515128,ALMAHER,St.Kitts and Nevis,Passenger/Ro-Ro Ship,964,1792,1986 -``` - -## Examples - -### Interactive Mode Examples - -#### Starting Interactive Mode - -```bash -# Simply run equasis with no arguments -equasis -``` - -You'll see the banner and enter interactive mode: - -``` - _ ___ - ___ ____ ___ ______ ______(_)____ _____/ (_) - / _ \/ __ `/ / / / __ `/ ___/ / ___/_____/ ___/ / / -/ __/ /_/ / /_/ / /_/ (__ ) (__ )_____/ /__/ / / -\___/\__, /\__,_/\__,_/____/_/____/ \___/_/_/ - /_/ - -Maritime Intelligence Tool v2.0.0 - -┌──────────────────────────────────────────────────────────────────────────┐ -│ Important Disclaimers │ -└──────────────────────────────────────────────────────────────────────────┘ -• This tool is NOT associated with, endorsed by, or affiliated with Equasis -• May break if Equasis changes their website structure -• Users must monitor for changes and update parsing logic accordingly -• Respect Equasis Terms of Service and rate limiting -• Use for legitimate maritime research and safety purposes only - -Type 'help' for available commands or 'exit' to quit. -Press ? for quick help. - -> -``` - -#### Comprehensive Vessel Lookup - -Get complete vessel intelligence with management, inspections, and history: - -```bash -> vessel /imo 9074729 - -# Get data in JSON format -> vessel /imo 9074729 /format json - -# Save to file (format auto-detected from extension) -> vessel /imo 9074729 /output vessel_data.json - -# Set JSON as default format for all subsequent commands -> format json -> vessel /imo 9074729 -``` - -**What you get**: Complete vessel profile including management companies, PSC inspection history, historical names/flags, classification details, and geographical tracking - all from a single command! - -#### Batch Processing Multiple Vessels and Companies +## Usage -Process multiple vessels at once: +### Vessels ```bash -# Process multiple IMO numbers directly -> batch /imos "9074729,9632179,9839333" - -# Process IMOs from a file -> batch /file fleet_imos.txt - -# Save batch results to JSON -> batch /imos "9074729,9632179" /format json /output batch_results.json - -# Process with CSV output -> batch /file my_vessels.txt /format csv /output fleet_analysis.csv +equasis vessel 9811000 # full profile as a table +equasis vessel 9811000 -f json # JSON document +equasis vessel 9811000 -o ever-given.json # format follows the file extension +equasis vessel 9811000 9074729 -o vessels.csv # several vessels: one CSV row each +equasis vessel --imo-file fleet.txt -o results.jsonl ``` -Process multiple companies for fleet analysis: - -```bash -# Process multiple companies directly -> batch /companies "MSC,MAERSK,COSCO" - -# Process companies from a file -> batch /company-file shipping_companies.txt +List files contain one IMO number per line. Blank lines and `#` comments are ignored, and +`-` reads from standard input: -# Save company batch results to JSON -> batch /companies "MSC,EVERGREEN" /format json /output fleet_comparison.json - -# Process with progress indicators -> batch /companies "MSC,MAERSK,COSCO,EVERGREEN" /format csv /output major_fleets.csv -``` - -**File Format for Batch Processing:** ```text -# fleet_imos.txt - IMO numbers with comments -9074729 # EMMA MAERSK -9632179 # MSC OSCAR -9839333 # EVER GIVEN -# Empty lines are ignored - -# shipping_companies.txt - Company names with comments -MSC # Mediterranean Shipping Company -MAERSK # A.P. Moller-Maersk -COSCO SHIPPING # China COSCO Shipping -# Comments and empty lines are supported -``` - -#### Multiple Queries in One Session - -```bash -> vessel /imo 9074729 -> search /name "MAERSK" -> fleet /company "MSC" -> status -> exit -``` - -No re-authentication needed between commands! - -### Traditional CLI Examples - -#### Comprehensive Vessel Lookup - -```bash -# Using environment variables for credentials (recommended) -equasis vessel --imo 8515128 - -# Using command-line credentials -equasis --username myuser --password mypass vessel --imo 8515128 - -# Use tab completion for IMO examples -equasis vessel --imo # Shows real vessel examples like EVER GIVEN - -# Get comprehensive data in JSON format -equasis --output json vessel --imo 8515128 - -# Save comprehensive report to file -equasis --output json --output-file vessel_intelligence.json vessel --imo 8515128 - -# BATCH PROCESSING: Multiple vessels at once -equasis vessel --imo 9074729 9632179 9839333 --progress - -# Process IMOs from a file -equasis vessel --imo-file fleet_imos.txt --output csv --output-file results.csv - -# Continue on errors with progress display -equasis vessel --imo-file large_fleet.txt --continue-on-error --progress -``` - -#### Search by Name - -Interactive mode: -```bash -> search /name "MAERSK" -> search /name "EVER GIVEN" /output results.json -``` - -Traditional CLI: -```bash -# Search for vessels with "MAERSK" in the name -equasis search --name "MAERSK" - -# Use tab completion for vessel name examples -equasis search --name # Shows examples like "EVER GIVEN", "MAERSK" - -# Partial name search -equasis search --name "EVER" -``` - -#### Fleet Information - -Interactive mode: -```bash -> fleet /company "MSC" -> fleet /company "MAERSK LINE" /output fleet.csv -``` - -Traditional CLI: -```bash -# Get fleet information for a company -equasis fleet --company "MAERSK LINE" - -# Use tab completion for company examples -equasis fleet --company # Shows major shipping companies -``` - -### Tab Completion Examples - -```bash -# Complete commands -equasis # Shows: vessel, search, fleet - -# Complete command-specific options -equasis vessel # Shows: --imo, --help, --username, etc. -equasis search # Shows: --name, --help, --username, etc. - -# Complete with real examples -equasis vessel --imo # Shows real IMO numbers with vessel names -equasis fleet --company # Shows major shipping companies - -# Complete output formats with descriptions -equasis --output # Shows: table, json, csv with descriptions +# Vessels of interest +9811000 # EVER GIVEN +9074729 ``` -#### Different Output Formats - -Interactive mode: -```bash -# Set default format -> format json -> vessel /imo 9074729 - -# Override format for one command -> vessel /imo 9074729 /format csv +Batches keep going when a lookup fails and report every item. Add `--fail-fast` to stop at +the first failure. -# Save to file (format auto-detected) -> vessel /imo 9074729 /output vessel_data.json -> vessel /imo 9074729 /output vessel_data.csv -``` +### Search -Traditional CLI: ```bash -# JSON output -equasis --output json vessel --imo 9074729 - -# CSV output -equasis --output csv vessel --imo 9074729 - -# Save to file -equasis --output json --output-file vessel_data.json vessel --imo 9074729 -``` - -### Batch Processing - -#### Built-in Batch Processing (Recommended) - -**Vessel Batch Processing:** -```bash -# Process multiple IMO numbers directly -equasis vessel --imo 9074729 9632179 9839333 --output json - -# Process from file with progress indicators -equasis vessel --imo-file fleet_imos.txt --progress --output csv --output-file batch_results.csv - -# Continue on errors instead of stopping -equasis vessel --imo-file fleet_imos.txt --continue-on-error --progress -``` - -**Company Fleet Batch Processing:** -```bash -# Process multiple companies directly -equasis fleet --company "MSC" "MAERSK" "COSCO" --output json - -# Process from file with progress indicators -equasis fleet --company-file shipping_companies.txt --progress --output csv --output-file fleet_analysis.csv - -# Continue on errors instead of stopping -equasis fleet --company-file shipping_companies.txt --continue-on-error --progress -``` - -#### Manual Batch Processing (Legacy) - -```bash -# Process multiple IMO numbers from a file (manual approach) -while read imo; do - echo "Processing IMO: $imo" - equasis vessel --imo "$imo" --output csv >> vessels.csv - sleep 2 # Be respectful to the server -done < imo_list.txt -``` - -#### Integration with Other Tools - -Traditional CLI works great with pipes: - -```bash -# Use with jq for JSON processing -equasis --output json vessel --imo 9074729 | jq '.name' - -# Pipe to grep for filtering -equasis search --name "CONTAINER" | grep "Singapore" - -# Count results -equasis search --name "MAERSK" --output csv | wc -l - -# Use tab completion with pipes -equasis vessel --imo # Select example, then continue with pipe -equasis vessel --imo 9074729 | head -5 -``` - -Interactive mode is best for exploration, traditional CLI for scripting. - -## Architecture & Data Collection - -### Comprehensive Intelligence Strategy - -The Equasis CLI uses a **3-request multi-tab strategy** to collect comprehensive vessel data: - -1. **Ship Info Tab**: Management, classification, geographical data -2. **Inspections Tab**: PSC inspection history and detention records -3. **Ship History Tab**: Historical names, flags, and ownership changes - -Each vessel lookup automatically: - -- Authenticates with Equasis using your credentials -- Makes 3 targeted requests to different Equasis tabs -- Parses rich HTML data structures from each tab -- Merges all data into a comprehensive vessel profile -- Formats output with smart summarization - -### Data Architecture - -``` -Single IMO Request → 3 Parallel Tab Requests → Comprehensive Profile - -┌─────────────────┐ ┌──────────────┐ ┌─────────────────────┐ -│ Ship Info Tab │ │Inspections │ │ Ship History Tab │ -│ │ │Tab │ │ │ -│ • Management │ │ │ │ • Historical Names │ -│ • Classification│ → │ • PSC Records│ → │ • Historical Flags │ -│ • Geographical │ │ • Detentions │ │ • Historical Owners │ -│ • Flag Performance│ │ • Authorities│ │ • Name Changes │ -└─────────────────┘ └──────────────┘ └─────────────────────┘ - ↓ ↓ ↓ - └─────────────── Merge Data ──────────────┘ - ↓ - ┌─────────────────────┐ - │ Complete Vessel │ - │ Intelligence Profile│ - │ │ - │ 50+ Data Points │ - │ Across 9 Categories │ - └─────────────────────┘ -``` - -### Professional Package Structure - -``` -equasis-cli/ -├── equasis_cli/ # Main package -│ ├── __init__.py # Package initialization -│ ├── main.py # CLI interface & argument parsing -│ ├── client.py # EquasisClient & authentication -│ ├── parser.py # HTML parser & data structures -│ └── formatter.py # Output formatting -├── setup.py # Package distribution config -├── requirements.txt # Dependencies -├── completions/ # Tab completion -│ └── equasis_completion.zsh -├── old_files/ # Backup of original code -└── .env # Credentials (not in git) -``` - -## Development - -### Updated Project Structure - -The tool now uses a professional package structure: - -``` -equasis-cli/ -├── equasis_cli/ # Main package directory -│ ├── __init__.py # Package initialization & public API -│ ├── main.py # CLI interface & argument parsing -│ ├── client.py # EquasisClient & web scraping -│ ├── parser.py # Comprehensive HTML parser -│ └── formatter.py # Output formatting (table/JSON/CSV) -├── setup.py # Package distribution configuration -├── requirements.txt # Python dependencies -├── completions/ # Tab completion scripts -│ └── equasis_completion.zsh # Zsh completion (unchanged) -├── local/ # Development and archive files -│ ├── archive/ # Original monolithic code backup -│ ├── development/ # Intermediate development versions -│ └── debug/ # HTML debug files and analysis tools -├── .env # Environment variables (not in git) -├── .env.example # Environment template -├── SESSION_SUMMARY.md # Development session documentation -├── SOLUTION_SUMMARY.md # Technical implementation notes -├── README.md # This file -└── MANIFEST.in # Package manifest +equasis search "EVER GIVEN" # ships and companies by name +equasis search TORM --type companies # companies only +equasis search --mmsi 353136000 # exact identifier (also --imo, --call-sign) +equasis search MAERSK --all-pages # every page of results (default: 3 pages) ``` -### Setting Up Development Environment +### Fleets ```bash -# Clone or download the project -cd ~/Projects/ -git clone equasis-cli # or create directory manually -cd equasis-cli - -# Install in editable mode with pipx (recommended) -pipx install -e . - -# Or use traditional virtual environment -python3 -m venv venv -source venv/bin/activate -pip install -e . +equasis fleet "TORM A/S" # company name +equasis fleet --company-id 0310062 # 7-digit Equasis company number +equasis fleet --company-file companies.txt -o fleets.csv ``` -### Making Changes +When a name matches several companies, equasis-cli asks you to choose (in a terminal) or +lists the matches and exits. Use `--company-id` or `--first-match` in scripts. -With the new package structure, changes to individual modules are automatically available with pipx editable installation: +### Interactive shell -```bash -# Code changes in equasis_cli/*.py files (automatic) -# No action needed - changes are immediately available - -# After modifying setup.py, requirements.txt, or package structure -cd ~/Projects/equasis-cli -pipx install -e . --force +Run `equasis` with no arguments: -# Alternative: upgrade command -pipx upgrade equasis-cli +```text +> vessel /imo 9811000 +> search /name "EVER GIVEN" +> fleet /company "TORM A/S" /output torm.csv +> batch /file fleet.txt /format json /output results.json +> help batch ``` -### Module-Specific Development +Type `/` for a menu of commands or parameters and press `?` for keyboard shortcuts. -```bash -# Test individual components -cd ~/Projects/equasis-cli +### Output and scripting -# Test parser independently -python3 -c "from equasis_cli.parser import EquasisParser; print('Parser loaded successfully')" - -# Test client authentication -python3 -c "from equasis_cli.client import EquasisClient; print('Client loaded successfully')" - -# Test output formatting -python3 -c "from equasis_cli.formatter import OutputFormatter; print('Formatter loaded successfully')" -``` - -### Package Management +| Format | Use | +| --- | --- | +| `table` | Reading in a terminal (default) | +| `json` | A single document with `schema_version`, `retrieved_at`, results, and indicators | +| `jsonl` | One JSON object per line; batches stream results as they complete | +| `csv` | Spreadsheets; one row per vessel with key facts and indicator columns | ```bash -# List installed pipx packages -pipx list - -# Show package details -pipx list --include-deps - -# Uninstall if needed -pipx uninstall equasis-cli - -# Reinstall from scratch -pipx install -e . --force +equasis vessel 9811000 -f json | jq '.indicators.observations' ``` -### Debugging +Exit codes: `0` success, `1` error, `2` invalid usage, `3` authentication failed, +`4` not found, `5` some batch items failed, `6` Equasis page layout changed, +`130` interrupted. -Enable debug logging by modifying the logging level: - -```python -logging.basicConfig(level=logging.DEBUG) -``` +See [docs/usage.md](docs/usage.md) for every command and option and +[docs/output-schema.md](docs/output-schema.md) for the output fields. -Or set environment variable: - -```bash -export PYTHONPATH=$(pwd) -python3 -c "import logging; logging.basicConfig(level=logging.DEBUG)" equasis_cli.py vessel --imo 9074729 -``` - -### Testing HTML Parsing - -Since Equasis HTML structure may change, you can test parsing with saved HTML: +### Python ```python -def test_parsing(): - with open("sample_vessel_page.html", "r") as f: - html = f.read() - - # Import the client class - from equasis_cli import EquasisClient +from equasis_cli import EquasisClient +from equasis_cli.enrichment import compute_indicators - client = EquasisClient("", "") - result = client._parse_vessel_info(html, "1234567") - print(result) - - -if __name__ == "__main__": - test_parsing() +with EquasisClient("you@example.com", "password") as client: + vessel = client.get_vessel("9811000") + print(vessel.name, vessel.flag, len(vessel.inspections)) + print(compute_indicators(vessel).observations) ``` -### Extending Functionality - -The modular design makes it easy to add features: - -1. **Add new search types**: Extend the argument parser and add methods to `EquasisClient` -2. **Create new formatters**: Add methods to `OutputFormatter` class -3. **Add data validation**: Create validation functions for input data -4. **Implement caching**: Add caching layer to reduce redundant requests - -## Network Resilience & Retry Logic - -equasis-cli includes enterprise-grade retry functionality to handle network issues and server-side problems gracefully. - -### Automatic Retry Features - -- **Exponential Backoff**: Delays increase exponentially between retry attempts (1s, 2s, 4s, etc.) -- **Jitter**: Random variance added to prevent "thundering herd" problems -- **Smart Error Classification**: Distinguishes between retryable and non-retryable errors -- **Configurable Parameters**: Customizable retry limits and timing - -### Retried Error Types - -**Network Errors (Always Retried):** -- Connection timeouts and failures -- DNS resolution failures -- Network interruptions -- Remote connection drops - -**HTTP Errors (Conditionally Retried):** -- `429` Too Many Requests -- `500` Internal Server Error -- `502` Bad Gateway -- `503` Service Unavailable -- `504` Gateway Timeout -- CloudFlare error codes (520-524) - -**Non-Retried Errors:** -- `401` Unauthorized (credential issues) -- `404` Not Found (vessel doesn't exist) -- `400` Bad Request (malformed request) - -### Default Retry Configuration +## Responsible use -- **Maximum Retries**: 3 attempts per request -- **Base Delay**: 1 second -- **Maximum Delay**: 60 seconds -- **Backoff Multiplier**: 2.0 (exponential) -- **Jitter**: ±25% randomization +equasis-cli reads the same pages you would open in a browser, using your own Equasis +account. Equasis is a free public service, so please: -Example retry sequence for a connection error: -``` -Attempt 1: Immediate -Attempt 2: ~1s delay -Attempt 3: ~2s delay -Attempt 4: ~4s delay -``` - -### Viewing Retry Activity - -Enable debug logging to see retry attempts: -```bash -equasis --debug vessel --imo 9074729 -``` +- Follow the [Equasis terms and conditions](https://www.equasis.org). +- Keep request volumes modest. Requests are paced at one per second by default (`--delay`), + each vessel takes three requests, and pages are cached for 24 hours (`--refresh` bypasses + the cache). Split very large batches across days. +- Verify important findings on equasis.org before relying on them. ## Troubleshooting -### Common Issues - -#### Command Not Found - -``` -zsh: command not found: equasis -``` - -**Solutions:** - -1. Ensure pipx installation was successful: `pipx list` -2. Check that pipx is in your PATH: `pipx ensurepath` then restart terminal -3. Verify the package is installed: `pipx list | grep equasis` -4. If using virtual environment instead of pipx, ensure it's activated - -#### Tab Completion Not Working - -**Solutions:** - -1. Ensure completion script exists: `ls ~/.zsh/completions/_equasis` -2. Check completion script is properly formatted (no syntax errors) -3. Verify your `.zshrc` has completion initialization: - ```bash - grep -E "(fpath|compinit)" ~/.zshrc - ``` - Should show: - ```bash - fpath=(~/.zsh/completions $fpath) - autoload -Uz compinit && compinit - ``` -4. Clear completion cache and reload: - ```bash - rm -f ~/.zcompdump* - source ~/.zshrc - ``` -5. Test with a simple completion first: `equasis ` - -#### Completion Shows Wrong Options - -**Solutions:** - -1. Ensure you're using spaces correctly: `equasis vessel --imo ` (space before TAB) -2. Clear old cached completions: `unfunction _equasis 2>/dev/null && autoload -Uz compinit && compinit` -3. Reinstall completion script: `cp ~/Projects/equasis-cli/completions/equasis_completion.zsh ~/.zsh/completions/_equasis` - -#### Connection Issues - -``` -Error: Failed to access login page: 500 -``` - -**Solutions:** - -1. Check your internet connection -2. Verify Equasis website is accessible (equasis.org) -3. Try again later (server may be temporarily unavailable) -4. Check if you're behind a firewall or proxy - -#### Parsing Errors - -``` -Error: No vessel found with IMO: 1234567 -``` - -**Solutions:** - -1. Verify the IMO number is correct and exists in Equasis -2. The HTML parsing may need adjustment for current Equasis structure -3. Enable debug logging to see raw HTML response -4. Check if Equasis has changed their page structure - -#### Python Environment Issues - -``` -ModuleNotFoundError: No module named 'requests' -``` - -**Solutions:** - -1. Ensure virtual environment is activated -2. Reinstall requirements: `pip install -r requirements.txt` -3. Check Python version compatibility - -### Rate Limiting - -If you encounter rate limiting: - -1. Increase delay between requests in the code -2. Reduce concurrent operations -3. Implement exponential backoff -4. Consider contacting Equasis for API access - -### Debug Mode - -Enable comprehensive logging: - -```bash -PYTHONPATH=$(pwd) python3 -c " -import logging -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -exec(open('equasis_cli.py').read()) -" vessel --imo 9074729 -``` - -## Legal and Compliance - -### Terms of Service - -- This tool accesses publicly available data from Equasis -- Users must comply with Equasis Terms of Service -- Respect rate limits and server resources -- Use for legitimate maritime research and safety purposes only - -### Best Practices - -- **Rate Limiting**: Don't overload Equasis servers -- **Data Usage**: Use retrieved data responsibly -- **Authentication**: Keep credentials secure -- **Compliance**: Follow maritime industry regulations - -### Disclaimer - -- This tool is for educational and research purposes -- Users are responsible for compliance with applicable laws -- No warranty is provided for data accuracy or availability -- The tool may break if Equasis changes their website structure +| Problem | What to do | +| --- | --- | +| `authentication failed` (exit 3) | Run `equasis configure --test`, and check that you can log in on equasis.org | +| `could not read the Equasis page` (exit 6) | Equasis may have changed its website. Re-run with `--save-html DIR` and [open an issue](https://github.com/rhinonix/equasis-cli/issues/new/choose). Remove your name from saved pages before attaching them. | +| `rate limiting requests (HTTP 429)` | Wait a while, then retry with a larger `--delay` | +| Unexpected or stale results | Add `--refresh`, or run `equasis cache clear` | +| Anything else | Re-run with `--debug` to log HTTP activity | ## Contributing -Contributions are welcome! Please follow these guidelines: - -### Reporting Issues - -1. Check existing issues before creating new ones -2. Provide detailed error messages and steps to reproduce -3. Include your Python version and operating system -4. Don't include credentials in issue reports - -### Submitting Changes - -1. Fork the repository -2. Create a feature branch -3. Make your changes with appropriate tests -4. Update documentation as needed -5. Submit a pull request with clear description - -### Development Guidelines - -- Follow PEP 8 style guidelines -- Add type hints for new functions -- Include docstrings for public methods -- Test changes with different Equasis data -- Update README for new features +Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) and +[docs/development.md](docs/development.md). Report security issues privately as described +in [SECURITY.md](SECURITY.md). ## License equasis-cli is source-available software licensed under the -[PolyForm Noncommercial License 1.0.0](LICENSE). - -- Free for personal, research, educational, nonprofit, and government use -- Journalists and nonprofit researchers have an additional permission, described in [LICENSING.md](LICENSING.md) -- Commercial use requires a separate license: contact [rhinonix.github.exclaim769@slmail.me](mailto:rhinonix.github.exclaim769@slmail.me) - -Releases up to and including v2.0.0 remain available under the -[Creative Commons Attribution-NonCommercial-ShareAlike 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) -license. - ---- +[PolyForm Noncommercial License 1.0.0](LICENSE). It is free for personal, research, +educational, nonprofit, and government use, and journalists and nonprofit researchers have +an additional permission. Commercial use requires a separate license. See +[LICENSING.md](LICENSING.md) for details and contact information. -**Note**: This tool interfaces with Equasis through web scraping. The HTML parsing may need updates if Equasis changes their website structure. Users should monitor for any changes and update the parsing logic accordingly. +equasis-cli is not affiliated with or endorsed by Equasis. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..cbb96fe --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,89 @@ +# Releasing + +Releases are published to [PyPI](https://pypi.org/p/equasis-cli) by the +[Release workflow](.github/workflows/release.yml) using +[trusted publishing](https://docs.pypi.org/trusted-publishers/): PyPI trusts this +repository's workflow directly, so no API tokens are created or stored. + +## One-time setup + +Do these steps once, before the first release. + +### 1. Create the accounts + +1. Register at [pypi.org](https://pypi.org/account/register/) and at + [test.pypi.org](https://test.pypi.org/account/register/). TestPyPI is a separate + practice site with its own accounts. +2. Verify your e-mail address on both sites. +3. Enable two-factor authentication on both (Account settings, then "Add 2FA with + authentication application"). PyPI requires it. Store the recovery codes somewhere safe. + +### 2. Register the trusted publishers + +The project does not exist on PyPI yet, so add a *pending* publisher; the first upload +creates the project. + +On **test.pypi.org**, open [Account settings, Publishing](https://test.pypi.org/manage/account/publishing/) +and add a GitHub pending publisher with: + +| Field | Value | +| --- | --- | +| PyPI project name | `equasis-cli` | +| Owner | `rhinonix` | +| Repository name | `equasis-cli` | +| Workflow name | `release.yml` | +| Environment name | `testpypi` | + +Repeat on **pypi.org** at [Account settings, Publishing](https://pypi.org/manage/account/publishing/) +with the same values, except **Environment name** `pypi`. + +### 3. Create the GitHub environments + +In the repository, open **Settings, Environments**: + +1. Create an environment named `testpypi`. +2. Create an environment named `pypi`. Under **Deployment protection rules**, enable + **Required reviewers** and add yourself. Nothing is then published to PyPI until you + approve it. + +## Every release + +1. **Prepare the release pull request.** + - Set the version in `src/equasis_cli/_version.py` (for example `3.1.0`). + - In `CHANGELOG.md`, rename "Unreleased" to the version and date, and add a new empty + "Unreleased" section and comparison link. + - Merge the pull request after CI passes. + +2. **Dry run on TestPyPI.** In the **Actions** tab, open the Release workflow, choose + **Run workflow** on `main`, and wait for it to finish. Then check the package in a clean + environment: + + ```bash + pipx install --index-url https://test.pypi.org/simple/ \ + --pip-args="--extra-index-url https://pypi.org/simple/" equasis-cli + equasis --version + equasis vessel 9811000 + pipx uninstall equasis-cli + ``` + + A version number can be uploaded to TestPyPI only once. To repeat a dry run for the + same version, add a suffix such as `3.1.0rc1` to the version first. + +3. **Publish.** Create a GitHub release: + - Go to **Releases**, then **Draft a new release**. + - Create a new tag named `v` plus the version (for example `v3.1.0`) on `main`. The + workflow checks that the tag matches the package version. + - Use the version as the title, and paste the changelog section as the description. + - Choose **Publish release**. + +4. **Approve.** Open the running Release workflow and approve the `pypi` deployment. + +5. **Verify.** + + ```bash + pipx install equasis-cli # or: pipx upgrade equasis-cli + equasis --version + ``` + +Published versions cannot be replaced on PyPI. If something is wrong, fix it and release +a new version; a broken release can be yanked from the project's PyPI page. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1e61841 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security policy + +## Supported versions + +Security fixes are released for the latest minor version of equasis-cli. + +| Version | Supported | +| --- | --- | +| 3.x | Yes | +| 2.x and earlier | No | + +## Reporting a vulnerability + +Please report vulnerabilities privately, either through +[GitHub private vulnerability reporting](https://github.com/rhinonix/equasis-cli/security/advisories/new) +or by e-mail to +[rhinonix.github.exclaim769@slmail.me](mailto:rhinonix.github.exclaim769@slmail.me). + +Include a description of the issue, the steps to reproduce it, the affected version, and +its potential impact. You can expect an acknowledgement within five working days. Please +allow time for a fix to be released before disclosing the issue publicly. + +## Scope + +Examples of issues to report: + +- Exposure of Equasis credentials, for example through logs, error messages, file + permissions, or saved output +- Writing files outside the location the user asked for +- Vulnerabilities in how equasis-cli handles content received from Equasis + +Problems with the Equasis website itself should be reported to Equasis. + +## Handling credentials safely + +- Prefer `equasis configure --setup` or environment variables over `--password`, which is + visible in shell history and process listings. +- The credentials file and page cache are created readable only by you. Keep it that way. +- Pages saved with `--save-html` and the page cache contain your Equasis account name. + Remove it before sharing a page. diff --git a/completions/_equasis b/completions/_equasis new file mode 100644 index 0000000..e8eb741 --- /dev/null +++ b/completions/_equasis @@ -0,0 +1,98 @@ +#compdef equasis +# zsh completion for equasis-cli. Copy this file to a directory on $fpath. + +_equasis_formats() { + local -a formats=( + 'table:readable table (default)' + 'json:JSON document' + 'jsonl:JSON Lines, one record per line' + 'csv:comma-separated values' + ) + _describe 'output format' formats +} + +_equasis_common=( + '(-f --format --output)'{-f,--format}'[output format]:format:_equasis_formats' + '(-o --output-file)'{-o,--output-file}'[write results to a file]:file:_files' + '(-q --quiet)'{-q,--quiet}'[only print results and errors]' + '--no-color[disable colored messages]' + '--username[Equasis username (e-mail address)]:username:' + '--password[Equasis password]:password:' + '--delay[minimum pause between requests]:seconds:' + '--debug[log HTTP activity to stderr]' + '--save-html[save Equasis pages for troubleshooting]:directory:_files -/' + '--no-cache[do not read or write the page cache]' + '--refresh[fetch fresh pages even if cached]' +) + +_equasis() { + local curcontext="$curcontext" state line + typeset -A opt_args + + _arguments -C \ + '(- *)'{-h,--help}'[show help]' \ + '(- *)--version[show version]' \ + '--interactive[start the interactive shell]' \ + $_equasis_common \ + '1: :->command' \ + '*:: :->arguments' && return + + case $state in + command) + local -a commands=( + 'vessel:full vessel profile by IMO number' + 'search:search ships and companies' + 'fleet:vessels associated with a company' + 'configure:manage stored credentials' + 'cache:show or clear cached Equasis pages' + 'interactive:start the interactive shell' + ) + _describe 'command' commands + ;; + arguments) + case $line[1] in + vessel) + _arguments \ + '--imo-file[read IMO numbers from a file]:file:_files' \ + '--fail-fast[stop at the first failed lookup]' \ + $_equasis_common \ + '*:IMO number:' + ;; + search) + _arguments \ + '--imo[find a ship by IMO number]:IMO number:' \ + '--mmsi[find a ship by MMSI]:MMSI:' \ + '--call-sign[find a ship by call sign]:call sign:' \ + '--type[result types]:type:(all ships companies)' \ + '(--all-pages)--max-pages[result pages to fetch]:pages:' \ + '(--max-pages)--all-pages[fetch every page]' \ + $_equasis_common \ + '1:name:' + ;; + fleet) + _arguments \ + '*--company-id[Equasis company number]:company number:' \ + '--company-file[read companies from a file]:file:_files' \ + '--first-match[use the best match for ambiguous names]' \ + '--max-pages[limit fleet pages]:pages:' \ + '--fail-fast[stop at the first failed lookup]' \ + $_equasis_common \ + '*:company name or number:' + ;; + configure) + _arguments \ + '(--show --test --clear)--setup[store credentials interactively]' \ + '(--setup --test --clear)--show[show where credentials come from]' \ + '(--setup --show --clear)--test[check that the credentials work]' \ + '(--setup --show --test)--clear[delete stored credentials]' \ + $_equasis_common + ;; + cache) + _arguments $_equasis_common '1:action:(info clear)' + ;; + esac + ;; + esac +} + +_equasis "$@" diff --git a/completions/equasis.bash b/completions/equasis.bash new file mode 100644 index 0000000..654060b --- /dev/null +++ b/completions/equasis.bash @@ -0,0 +1,54 @@ +# bash completion for equasis-cli. Source this file from ~/.bashrc. + +_equasis() { + local cur prev words cword + _init_completion 2>/dev/null || { + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + } + + local commands="vessel search fleet configure cache interactive" + local common="--format --output-file --quiet --no-color --username --password --delay --debug --save-html --no-cache --refresh --help" + + case "$prev" in + -f|--format|--output) + COMPREPLY=($(compgen -W "table json jsonl csv" -- "$cur")) + return + ;; + -o|--output-file|--imo-file|--company-file) + COMPREPLY=($(compgen -f -- "$cur")) + return + ;; + --save-html) + COMPREPLY=($(compgen -d -- "$cur")) + return + ;; + --type) + COMPREPLY=($(compgen -W "all ships companies" -- "$cur")) + return + ;; + esac + + local command="" word + for word in "${words[@]:1:cword-1}"; do + case "$word" in + vessel|search|fleet|configure|cache|interactive) command="$word"; break ;; + esac + done + + local options + case "$command" in + "") options="$commands --version --interactive $common" ;; + vessel) options="--imo-file --fail-fast $common" ;; + search) options="--imo --mmsi --call-sign --type --max-pages --all-pages $common" ;; + fleet) options="--company-id --company-file --first-match --max-pages --fail-fast $common" ;; + configure) options="--setup --show --test --clear $common" ;; + cache) options="info clear $common" ;; + interactive) options="" ;; + esac + COMPREPLY=($(compgen -W "$options" -- "$cur")) +} + +complete -F _equasis equasis diff --git a/completions/equasis_completion.zsh b/completions/equasis_completion.zsh deleted file mode 100644 index 20c40d5..0000000 --- a/completions/equasis_completion.zsh +++ /dev/null @@ -1,127 +0,0 @@ -#compdef equasis - -_equasis() { - local context state state_descr line - typeset -A opt_args - - _arguments -C \ - '(--help -h)'{--help,-h}'[Show help message]' \ - '--username[Equasis username]:username:' \ - '--password[Equasis password]:password:' \ - '--output[Output format]:format:_equasis_output_formats' \ - '--output-file[Output file path]:file:_files' \ - '1: :_equasis_commands' \ - '*:: :->command_args' \ - && return 0 - - case $state in - command_args) - case $words[1] in - vessel) - _equasis_vessel_args - ;; - search) - _equasis_search_args - ;; - fleet) - _equasis_fleet_args - ;; - esac - ;; - esac -} - -_equasis_commands() { - local -a commands=( - 'vessel:Get detailed vessel information by IMO number' - 'search:Search for vessels by name (supports partial matches)' - 'fleet:Get fleet information for a shipping company' - ) - _describe 'equasis commands' commands -} - -_equasis_vessel_args() { - _arguments \ - '(--help -h)'{--help,-h}'[Show help for vessel command]' \ - '--imo[IMO number (7 digits)]:imo_number:_equasis_imo_examples' \ - '--username[Equasis username]:username:' \ - '--password[Equasis password]:password:' \ - '--output[Output format]:format:_equasis_output_formats' \ - '--output-file[Output file path]:file:_files' -} - -_equasis_search_args() { - _arguments \ - '(--help -h)'{--help,-h}'[Show help for search command]' \ - '--name[Vessel name (partial match supported)]:vessel_name:_equasis_vessel_examples' \ - '--username[Equasis username]:username:' \ - '--password[Equasis password]:password:' \ - '--output[Output format]:format:_equasis_output_formats' \ - '--output-file[Output file path]:file:_files' -} - -_equasis_fleet_args() { - _arguments \ - '(--help -h)'{--help,-h}'[Show help for fleet command]' \ - '--company[Company name or identifier]:company_name:_equasis_company_examples' \ - '--username[Equasis username]:username:' \ - '--password[Equasis password]:password:' \ - '--output[Output format]:format:_equasis_output_formats' \ - '--output-file[Output file path]:file:_files' -} - -_equasis_output_formats() { - local -a formats=( - 'table:Human-readable table format (default)' - 'json:JSON format for programmatic use' - 'csv:Comma-separated values for spreadsheets' - ) - _describe 'output formats' formats -} - -_equasis_imo_examples() { - # Provide actual example IMO numbers that users can select - local -a examples=( - '9074729:EVER GIVEN (container ship)' - '9321483:MSC GULSUN (container ship)' - '9516579:HMM ALGECIRAS (container ship)' - '9811000:SYMPHONY OF THE SEAS (cruise ship)' - '9398538:ALLURE OF THE SEAS (cruise ship)' - ) - _describe 'example IMO numbers' examples - # Also allow typing custom numbers - _message 'or enter 7-digit IMO number' -} - -_equasis_vessel_examples() { - # Provide example vessel names - local -a examples=( - '"EVER GIVEN":Famous container ship' - '"MSC GULSUN":Large container vessel' - '"MAERSK":Search all Maersk vessels' - '"COSCO":Search all COSCO vessels' - '"SYMPHONY":Search Symphony vessels' - ) - _describe 'example vessel names' examples - _message 'or enter vessel name (partial matches supported)' -} - -_equasis_company_examples() { - # Provide common shipping companies as actual completions - local -a companies=( - '"MAERSK LINE":A.P. Moller - Maersk' - '"MSC":Mediterranean Shipping Company' - '"COSCO SHIPPING":China COSCO Shipping' - '"CMA CGM":CMA CGM Group' - '"HAPAG-LLOYD":Hapag-Lloyd' - '"EVERGREEN MARINE":Evergreen Marine' - '"YANG MING":Yang Ming Marine Transport' - '"HMM":Hyundai Merchant Marine' - '"ONE":Ocean Network Express' - '"ZIM":ZIM Integrated Shipping Services' - ) - _describe 'major shipping companies' companies - _message 'or enter company name' -} - -_equasis "$@" diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f1a6b45 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,104 @@ +# Development guide + +## Setup + +```bash +git clone https://github.com/rhinonix/equasis-cli.git +cd equasis-cli +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +pre-commit install +``` + +## Checks + +```bash +ruff check . # lint +ruff format . # format +mypy # strict type checking of src/ +pytest # offline test suite +pytest --cov # with coverage +``` + +CI runs the same checks on Python 3.10 to 3.14 on Linux, and on 3.10 and 3.14 on macOS and +Windows. It also builds the package and smoke-tests the wheel. + +### Live tests + +`tests/test_live.py` makes a handful of real requests to equasis.org to detect website +changes. The tests are skipped unless enabled: + +```bash +EQUASIS_LIVE_TESTS=1 pytest tests/test_live.py -v +``` + +They use your configured credentials. The "Live canary" workflow runs them weekly when the +repository secrets `EQUASIS_USERNAME` and `EQUASIS_PASSWORD` are set. + +## Project layout + +```text +src/equasis_cli/ + cli.py argument parsing, exit codes, command execution + tui/app.py interactive shell (prompt_toolkit layout and key bindings) + tui/commands.py interactive command parsing and execution, no terminal code + service.py batch processing and company resolution shared by both interfaces + client.py EquasisClient: login, session renewal, vessels, search, fleets + transport.py HTTP session, request pacing, retries, response capture + cache.py on-disk page cache + parsing/ one parser per Equasis page type + html.py table and panel helpers (header lookup, rowspan handling) + page.py classifies login, not-found, and error pages + ship.py Ship Info, Inspections, Ship History + search.py search results + fleet.py Fleet info + enrichment/ indicators and country and MID reference data + models.py dataclasses for all results + output.py table, JSON, JSON Lines, and CSV rendering; file writing + credentials.py credential resolution and storage + validation.py IMO, MMSI, and company number validation; list parsing + exceptions.py exception hierarchy + console.py stderr messages +tests/ + fixtures/ anonymized pages recorded from equasis.org + tools/make_fixture.py +``` + +Requests flow through the layers in one direction: `cli.py` or `tui/commands.py` calls +`service.py` or `client.py`, which uses `transport.py` and `cache.py` to fetch pages and +`parsing/` to turn them into `models.py` objects. `output.py` renders the objects. + +### Parsing principles + +- Find panels by their visible title and table columns by their header text, never by + position. Equasis reorders panels between pages. +- Ignore rows marked `hidden-lg hidden-md`, which duplicate content for small screens. +- Expand `rowspan` cells. Equasis nests the spanned row inside the previous ``, so + rows are collected recursively. +- Raise `LayoutChangedError` when an expected heading or column is missing, so users get a + clear message instead of silently wrong data. + +## Test fixtures + +Parser tests run against real pages stored in `tests/fixtures/`. To add or refresh one: + +1. Capture the page: `equasis vessel 9811000 --save-html /tmp/pages --refresh`. Files are + numbered in request order, for example `003-get-restricted-ShipInfo.html`. +2. Anonymize and minimize it: + + ```bash + python tests/tools/make_fixture.py /tmp/pages/003-get-restricted-ShipInfo.html \ + tests/fixtures/ship_info_9811000.html + ``` + +3. Check that parsing the fixture gives the same result as parsing the original page. +4. Run `pytest tests/test_parsing_pages.py`. It fails if any fixture still contains an + account name. +5. Update `tests/fixtures/README.md`. + +Never commit pages that have not been through `make_fixture.py`. + +## Releasing + +See [RELEASING.md](../RELEASING.md). diff --git a/docs/output-schema.md b/docs/output-schema.md new file mode 100644 index 0000000..8635edd --- /dev/null +++ b/docs/output-schema.md @@ -0,0 +1,318 @@ +# Output formats and schema + +equasis-cli writes four formats. `table` is for reading in a terminal and may change +between releases. `json`, `jsonl`, and `csv` are for programs, and changes to them follow +the rules below. + +- [Conventions](#conventions) +- [JSON](#json) +- [JSON Lines](#json-lines) +- [CSV](#csv) +- [Vessel fields](#vessel-fields) +- [Indicators](#indicators) +- [Search fields](#search-fields) +- [Fleet fields](#fleet-fields) +- [Stability](#stability) + +## Conventions + +- Dates are ISO 8601 (`2026-09-15`). Timestamps are UTC (`2026-09-16T15:08:00Z`). +- Tonnages, years, and counts are integers. +- Values Equasis does not show are `null` in JSON and empty in CSV. +- Text is exactly as Equasis shows it, with whitespace normalized. +- Lists of history records are ordered newest first, as on Equasis. +- JSON and CSV files are UTF-8. + +## JSON + +Every JSON document has the same envelope: + +| Field | Description | +| --- | --- | +| `schema_version` | Version of this layout (currently `1`) | +| `type` | `vessel`, `search`, `fleet`, `batch_vessels`, or `batch_fleets` | +| `retrieved_at` | When the Equasis pages were fetched. This is earlier than now if they came from the cache. | +| `source` | `https://www.equasis.org` | + +The payload key depends on the type: + +| `type` | Payload | +| --- | --- | +| `vessel` | `vessel` ([vessel fields](#vessel-fields)) and `indicators` ([indicators](#indicators)) | +| `search` | `search` ([search fields](#search-fields)) | +| `fleet` | `fleet` ([fleet fields](#fleet-fields)) | +| `batch_vessels`, `batch_fleets` | `summary` and `results` (see below) | + +Example, with lists shortened to one entry: + +```json +{ + "schema_version": 1, + "type": "vessel", + "retrieved_at": "2026-09-16T15:08:00Z", + "source": "https://www.equasis.org", + "vessel": { + "imo": "9811000", + "name": "EVER GIVEN", + "flag": "Panama", + "flag_code": "PAN", + "call_sign": "H3RC", + "mmsi": "353136000", + "gross_tonnage": 219079, + "deadweight": 199489, + "ship_type": "Container Ship", + "year_built": 2018, + "status": "In Service/Commission", + "status_since": "2021-11-14", + "particulars_updated": "2026-09-15", + "flag_performance": { + "paris_mou": "White", + "tokyo_mou": "White", + "uscg_targeted": true, + "iacs_classed": true, + "detention_rate_36_months": 0.0 + }, + "management": [ + { + "company_id": "1135348", + "name": "BERNHARD SCHULTE-HKG LP", + "role": "Ship manager/Commercial manager", + "address": "Room 2602, K Wah Centre, 191, Java Road, North Point, Hong Kong, China.", + "since": "2021-03-01" + } + ], + "class_status": [ + {"society": "American Bureau of Shipping (IACS)", "status": "Delivered", "since": "2018-10-25", "reason": null} + ], + "class_surveys": [ + {"society": "American Bureau of Shipping (IACS)", "last_renewal": "2023-07-12", "next_renewal": "2028-09-24"} + ], + "safety_certificates": [ + { + "society": "American Bureau of Shipping (IACS)", + "survey_date": "2023-12-04", + "expiry_date": "2029-01-31", + "status_change_date": "2018-09-25", + "status": "Delivered", + "reason": null, + "convention": "Convention" + } + ], + "pandi": [{"name": "UK P&I Club", "inception": "2018-09-25"}], + "sightings": [{"period": "September 2026", "areas": ["West Europe"], "source": "MarineTraffic"}], + "inspections": [ + { + "authority": "Greece", + "port": "Piraeus", + "date": "2026-04-29", + "detained": false, + "reports": [ + { + "psc_organisation": "Paris MoU", + "inspection_type": "Initial inspection", + "duration_days": null, + "deficiencies": null, + "inspection_id": "6406427" + } + ], + "deficiencies": null + } + ], + "human_element_deficiencies": [], + "name_history": [{"name": "EVER GIVEN", "since": "2018-09-01", "until": null, "source": "IHS Maritime"}], + "flag_history": [{"flag": "Panama", "since": "2018-09-01", "until": null, "source": "IHS Maritime"}], + "class_history": [ + {"society": "American Bureau of Shipping (IACS)", "survey_date": "2023-07-12", "source": "American Bureau of Shipping"} + ], + "company_history": [ + { + "company": "BERNHARD SCHULTE-HKG LP", + "role": "Ship manager/Commercial manager", + "since": "2021-03-01", + "until": null, + "source": "IHS Maritime" + } + ], + "warnings": [], + "retrieved_at": "2026-09-16T15:08:00Z" + }, + "indicators": { + "reference_date": "2026-09-16", + "age_years": 8, + "name_changes_12_months": 0, + "name_changes_36_months": 0, + "flag_changes_12_months": 0, + "flag_changes_36_months": 0, + "management_changes_36_months": 0, + "inspections_36_months": 6, + "detentions_36_months": 0, + "deficiencies_36_months": 19, + "last_inspection": "2026-04-29", + "last_detention": null, + "flag_on_paris_mou_grey_or_black_list": false, + "flag_on_tokyo_mou_grey_or_black_list": false, + "uscg_targeted_flag": true, + "iacs_classed": true, + "class_withdrawn_or_suspended_36_months": [], + "has_pandi_insurance": true, + "mmsi_country": "Panama", + "mmsi_matches_flag": true, + "observations": ["Flag is targeted by the US Coast Guard"] + } +} +``` + +### Batch documents + +| Field | Description | +| --- | --- | +| `summary.total` | Number of inputs | +| `summary.succeeded`, `summary.failed` | Counts by outcome | +| `summary.started_at` | When the batch started | +| `results[]` | One record per input, in input order | + +Each record in `results` has: + +| Field | Description | +| --- | --- | +| `query` | The input as given (IMO number, company name, or company number) | +| `status` | `ok`, `not_found`, `ambiguous` (fleets only), or `error` | +| `error` | Explanation when `status` is not `ok` | +| `warnings` | Non-fatal problems, such as a failed IMO check digit | +| `elapsed_seconds` | Time spent on this item | +| `vessel` or `fleet` | The result, or `null` | +| `indicators` | Vessel batches only: [indicators](#indicators) for the vessel, or `null` | + +## JSON Lines + +JSON Lines output has one JSON object per line: + +| Result | Lines | +| --- | --- | +| Vessel | One line containing the full JSON document | +| Search | One line per result, with `"kind": "ship"` or `"kind": "company"` added | +| Fleet | One line per vessel, with `company_id` and `company_name` added | +| Batch | One line per input with the fields of a batch record, written as soon as that item completes | + +Because batch lines are streamed, a partially completed batch still leaves valid output. + +## CSV + +CSV files have a header row, quote values where needed, and use `true` and `false` for +booleans. Multiple values in one cell are separated by `; `. + +**Vessels**: one row per vessel: + +| Column | Description | +| --- | --- | +| `imo`, `name`, `flag`, `flag_code`, `call_sign`, `mmsi` | Identity | +| `gross_tonnage`, `deadweight`, `ship_type`, `year_built` | Particulars | +| `status`, `status_since`, `particulars_updated` | Status | +| `paris_mou`, `tokyo_mou`, `uscg_targeted` | Flag performance | +| `registered_owner`, `ship_manager`, `ism_manager` | Current companies in these roles | +| `class_societies` | Classification societies, excluding those with status `Withdrawn` | +| `pandi` | P&I insurers | +| `inspections`, `detentions`, `last_inspection` | All recorded PSC inspections | +| `name_changes`, `flag_changes` | All recorded 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` | [Indicators](#indicators) | +| `warnings` | Parts of the profile that could not be read | + +**Vessel batches** have the same columns, preceded by `query`, `lookup_status`, and +`error`. + +**Search results**: `kind` (`ship` or `company`), `imo`, `company_id`, `name`, +`gross_tonnage`, `ship_type`, `year_built`, `flag`, `address`. + +**Fleets**: one row per vessel: `company_id`, `company_name`, `imo`, `name`, +`gross_tonnage`, `ship_type`, `year_built`, `flag`, `class_societies`, +`detentions_as_ism_manager_3y`, `detentions_all_companies_3y`, `roles`. + +**Fleet batches** have the fleet columns preceded by `query`, `lookup_status`, and +`error`, with one row per vessel. A failed company has a single row. + +## Vessel fields + +| Field | Description | +| --- | --- | +| `imo`, `name` | IMO number and current name | +| `flag`, `flag_code` | Flag state name and ISO 3166-1 alpha-3 code | +| `call_sign`, `mmsi` | Radio identifiers | +| `gross_tonnage`, `deadweight` | Tonnages | +| `ship_type`, `year_built` | Type and year of build | +| `status`, `status_since` | For example `In Service/Commission`, `Broken Up` | +| `particulars_updated` | When Equasis last updated the particulars | +| `flag_performance` | `paris_mou` and `tokyo_mou` list colour (`White`, `Grey`, `Black`), `uscg_targeted`, `iacs_classed`, `detention_rate_36_months` (percent) | +| `management[]` | Current companies: `company_id`, `name`, `role`, `address`, `since` | +| `class_status[]` | Classification status: `society`, `status`, `since`, `reason` | +| `class_surveys[]` | Renewal surveys: `society`, `last_renewal`, `next_renewal` | +| `safety_certificates[]` | `society`, `survey_date`, `expiry_date`, `status_change_date`, `status`, `reason`, `convention` | +| `pandi[]` | P&I insurers: `name`, `inception` | +| `sightings[]` | `period` (month), `areas`, `source` | +| `inspections[]` | PSC inspections: `authority` (port state), `port`, `date`, `detained`, `deficiencies`, `reports[]` | +| `inspections[].reports[]` | One entry per PSC regime the inspection was reported to: `psc_organisation`, `inspection_type`, `duration_days`, `deficiencies`, `inspection_id` | +| `human_element_deficiencies[]` | `psc_organisation`, `authority`, `port`, `inspection_type`, `date`, `deficiencies` | +| `name_history[]`, `flag_history[]` | `name` or `flag`, `since`, `until`, `source` | +| `class_history[]` | Class renewal surveys: `society`, `survey_date`, `source` | +| `company_history[]` | `company`, `role`, `since`, `until`, `source` | +| `warnings[]` | Parts of the profile that could not be read | +| `retrieved_at` | When the pages were fetched | + +Notes: + +- An inspection reported to more than one PSC regime appears once, with one entry per + regime in `reports`. `inspections[].deficiencies` is the highest count any regime + reported. +- A deficiency count of `null` means Equasis showed no number, which usually means none + were recorded. +- `until` is not published by Equasis. It is derived from the `since` date of the next + newer record (for companies, the next newer record with the same role). +- Dates Equasis gives only to the month (`during 09/2024`) use the first day of that month. + +## Indicators + +Indicators are computed from the vessel profile relative to `reference_date` (the +retrieval date). They are facts, not a risk score. + +| Field | Description | +| --- | --- | +| `age_years` | Years since the year of build | +| `name_changes_12_months`, `name_changes_36_months` | Renames that took effect in the window | +| `flag_changes_12_months`, `flag_changes_36_months` | Flag changes that took effect in the window | +| `management_changes_36_months` | Changes of registered owner, ship manager, or ISM manager | +| `inspections_36_months`, `detentions_36_months`, `deficiencies_36_months` | PSC activity in the window | +| `last_inspection`, `last_detention` | Most recent dates on record | +| `flag_on_paris_mou_grey_or_black_list`, `flag_on_tokyo_mou_grey_or_black_list` | Flag performance list membership | +| `uscg_targeted_flag`, `iacs_classed` | As reported by Equasis | +| `class_withdrawn_or_suspended_36_months` | `"Society: Status"` entries whose status changed in the window | +| `has_pandi_insurance` | Whether any P&I insurer is recorded | +| `mmsi_country`, `mmsi_matches_flag` | Country allocated the MMSI's Maritime Identification Digits, and whether it is the flag state. `null` when either value is unavailable. | +| `observations` | Plain-language sentences describing the notable indicators | + +## Search fields + +| Field | Description | +| --- | --- | +| `query` | The search text, or the identifiers searched for | +| `ships[]` | `imo`, `name`, `gross_tonnage`, `ship_type`, `year_built`, `flag` | +| `companies[]` | `company_id`, `name`, `address` | +| `total_ships` | Total ship matches reported by Equasis; can exceed `len(ships)` when results were limited by `--max-pages` | +| `retrieved_at` | When the pages were fetched | + +## Fleet fields + +| Field | Description | +| --- | --- | +| `company` | `company_id`, `name`, `address`, `status`, `last_update` | +| `vessels[]` | `imo`, `name`, `gross_tonnage`, `ship_type`, `year_built`, `flag`, `class_societies`, `detentions_as_ism_manager_3y`, `detentions_all_companies_3y`, `roles[]` (`role`, `since`) | +| `total_vessels` | Total reported by Equasis; can exceed `len(vessels)` when limited by `--max-pages` | +| `retrieved_at` | When the pages were fetched | + +## Stability + +Within a `schema_version`: + +- Fields and CSV columns are never removed or renamed, and their types do not change. +- New fields and CSV columns may be added. Read CSV files by column name, not position. + +Incompatible changes increase `schema_version` and are listed in the +[changelog](../CHANGELOG.md). diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..58048d2 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,244 @@ +# Usage reference + +This page covers every command and option. For a quick introduction see the +[README](../README.md); for output fields see [output-schema.md](output-schema.md). + +- [Credentials](#credentials) +- [Global options](#global-options) +- [vessel](#vessel) +- [search](#search) +- [fleet](#fleet) +- [configure](#configure) +- [cache](#cache) +- [Interactive shell](#interactive-shell) +- [Batch input files](#batch-input-files) +- [Exit codes](#exit-codes) +- [Environment variables](#environment-variables) +- [Shell completion](#shell-completion) + +## Credentials + +equasis-cli logs in with your own Equasis account and looks for credentials in this order: + +1. `--username` and `--password` options +2. `EQUASIS_USERNAME` and `EQUASIS_PASSWORD` environment variables +3. The credentials file written by `equasis configure --setup` + +The credentials file is `$XDG_CONFIG_HOME/equasis-cli/credentials.json` +(`~/.config/equasis-cli/credentials.json` by default) on Linux and macOS, and +`%APPDATA%\equasis-cli\credentials.json` on Windows. It is created readable only by you, +and equasis-cli warns if its permissions allow other users to read it. + +Avoid `--password` where possible: command-line arguments are visible in shell history and +process listings. + +## Global options + +These options work before or after the command name, so `equasis -f json vessel 9811000` +and `equasis vessel 9811000 -f json` are equivalent. + +| Option | Description | +| --- | --- | +| `-f`, `--format {table,json,jsonl,csv}` | Output format. Defaults to the format implied by `--output-file`, otherwise `table`. | +| `-o`, `--output-file PATH` | Write results to `PATH` instead of standard output. Parent directories are created; the file is replaced atomically. | +| `-q`, `--quiet` | Print only results, warnings, and errors | +| `--no-color` | Disable colored messages (also honoured: `NO_COLOR`) | +| `--username USERNAME` | Equasis username (the e-mail address you registered with) | +| `--password PASSWORD` | Equasis password | +| `--delay SECONDS` | Minimum pause between requests to Equasis (default `1.0`) | +| `--no-cache` | Neither read nor write the page cache | +| `--refresh` | Fetch fresh pages even when cached copies exist (fresh pages are still cached) | +| `--debug` | Log HTTP requests, retries, and cache use to stderr | +| `--save-html DIR` | Save every page received from Equasis to `DIR`. Saved pages include your account name. | +| `--version` | Print the version and exit | + +File extensions map to formats as follows: `.json` is JSON, `.jsonl` and `.ndjson` are +JSON Lines, `.csv` is CSV, and `.txt` is a table. An explicit `--format` always wins. + +Results are written to standard output. Progress messages, warnings, and errors go to +standard error, so output can be piped safely. + +## vessel + +```text +equasis vessel [IMO ...] [--imo-file FILE] [--fail-fast] +``` + +Retrieves the full profile of a vessel from the Ship Info, Inspections, and Ship History +pages: particulars, flag performance, management, classification, safety management +certificates, P&I insurance, sightings, PSC inspections, and name, flag, class, and company +history, plus [indicators](output-schema.md#indicators). + +IMO numbers may be written as `9811000` or `IMO 9811000`. A number that fails the IMO +check digit is still looked up, with a warning that it may contain a typo. + +One IMO number produces a single vessel document. Several numbers, or any `--imo-file`, +produce a batch report: + +- Every input appears in the report with a status of `ok`, `not_found`, or `error`. +- Processing continues after failures unless `--fail-fast` is given. +- With JSON Lines output, each result is written as soon as it is available. +- The exit code is `5` if any item failed. + +If a secondary page (Inspections or Ship History) cannot be read, the profile is still +returned and the problem is listed under `warnings`. + +## search + +```text +equasis search QUERY [--type {all,ships,companies}] [--max-pages N | --all-pages] +equasis search --imo IMO | --mmsi MMSI | --call-sign CALL_SIGN +``` + +A name search matches partial names and returns both ships and companies. Equasis returns +100 results per page. equasis-cli fetches up to 3 pages by default; use `--max-pages` or +`--all-pages` for more. A warning is shown when results were truncated. + +Identifier searches use Equasis advanced search and return ships whose IMO number, MMSI, +or call sign match exactly. + +The exit code is `4` if nothing matched. + +## fleet + +```text +equasis fleet [COMPANY ...] [--company-id NUMBER ...] [--company-file FILE] + [--first-match] [--max-pages N] [--fail-fast] +``` + +Retrieves every vessel associated with a company: particulars, current class, detention +counts for the last three years, and the roles the company holds (registered owner, ship +manager, ISM manager, and so on). + +A company can be given by name or by its 7-digit Equasis company number (shown by +`equasis search NAME --type companies`). Names are resolved as follows: + +1. A single case-insensitive exact name match is used. +2. A search that returns exactly one company is used. +3. Otherwise the name is ambiguous. In a terminal you are asked to choose; elsewhere the + matches are listed and the command exits with code `4`. `--first-match` uses the best + match instead. + +Several companies, or any `--company-file`, produce a batch report as for `vessel`. +Ambiguous names in a batch are reported with status `ambiguous` unless `--first-match` is +set. + +## configure + +| Command | Description | +| --- | --- | +| `equasis configure --setup` | Prompt for credentials, check them against Equasis, and save them | +| `equasis configure --test` | Log in with the configured credentials | +| `equasis configure --show` | Show which credential sources are set (never the password) | +| `equasis configure --clear` | Delete the credentials file | + +## cache + +```text +equasis cache [info|clear] +``` + +Equasis pages are cached for 24 hours so repeated lookups do not send the same requests +again. This also means an interrupted batch can be re-run without fetching completed items +a second time. Login, error, and not-found pages are never cached. + +The cache is stored in `$XDG_CACHE_HOME/equasis-cli` (`~/.cache/equasis-cli` by default), +or `%LOCALAPPDATA%\equasis-cli\Cache` on Windows. `EQUASIS_CACHE_DIR` overrides the +location. Cached pages contain your Equasis account name, so the directory is readable +only by you. + +`equasis cache` shows the location, number of pages, and size; `equasis cache clear` +deletes the cached pages. + +## Interactive shell + +Start the shell by running `equasis` with no arguments (or `equasis interactive`). The +session stays logged in between commands. + +| Command | Parameters | +| --- | --- | +| `vessel` | `/imo IMO`, `/refresh`, `/format FORMAT`, `/output FILE` | +| `search` | `/name NAME` or `/imo IMO` or `/mmsi MMSI` or `/callsign CALLSIGN`; `/type all\|ships\|companies`, `/pages N\|all`, `/refresh`, `/format`, `/output` | +| `fleet` | `/company NAME` or `/id NUMBER`; `/first`, `/pages N\|all`, `/refresh`, `/format`, `/output` | +| `batch` | `/imos "IMO,IMO"` or `/file FILE` or `/companies "NAME,NAME"` or `/company-file FILE`; `/fail-fast`, `/first`, `/refresh`, `/format`, `/output` | +| `format` | `table`, `json`, `jsonl`, or `csv`: sets the default format for the session | +| `cache` | `info` or `clear` | +| `status` | Connection, account, credential source, and default format | +| `help` | `help` lists commands; `help COMMAND` shows its parameters | +| `clear` | Clears the output | +| `exit`, `quit` | Leaves the shell | + +Quote values that contain spaces: `search /name "EVER GIVEN"`. Straight and typographic +quotes both work. `/output FILE` picks the format from the file extension, and +`/output json` (a format name without an extension) sets the format for that command. + +When a company name is ambiguous, `fleet` lists the matches with their company numbers. +Repeat the command with `/id NUMBER`, or add `/first`. + +Keyboard shortcuts: + +| Key | Action | +| --- | --- | +| `/` | Show commands, or the parameters of the current command | +| `Tab` | Insert the highlighted menu item, or complete a command name | +| `Up`, `Down` | Move through the menu, or recall previous commands | +| `Esc` | Close the menu; with no menu open, scroll the output (press `i`, `q`, or start typing to return) | +| `j`, `k`, arrows | Scroll one line while scrolling the output | +| `PgUp`, `PgDn`, `Shift+Up`, `Shift+Down`, `Ctrl+K`, `Ctrl+J` | Scroll the output | +| `?` | Show or hide the shortcut list (on an empty input line) | +| `Ctrl+C` | Clear the input line | +| `Ctrl+D` | Exit | + +## Batch input files + +`--imo-file`, `--company-file`, and the shell's `/file` and `/company-file` read one item +per line: + +```text +# Comments start with '#', either on their own line +9811000 # or after a value +9074729 + +9321483 +``` + +Blank lines are ignored and duplicates are removed. On the command line, `-` reads the +list from standard input: + +```bash +cut -d, -f1 vessels.csv | tail -n +2 | equasis vessel --imo-file - -o results.jsonl +``` + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Error (network failure, unexpected Equasis response) | +| `2` | Invalid usage or input (for example a malformed IMO number) | +| `3` | Missing credentials or authentication failed | +| `4` | Vessel, company, or search results not found; ambiguous company name | +| `5` | Batch completed, but at least one item failed | +| `6` | An Equasis page did not have the expected layout; please report it | +| `130` | Interrupted | + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `EQUASIS_USERNAME`, `EQUASIS_PASSWORD` | Credentials | +| `EQUASIS_CACHE_DIR` | Cache location | +| `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` | Base directories for credentials and cache | +| `NO_COLOR` | Disable colored messages | + +## Shell completion + +Completion scripts for zsh and bash are in [`completions/`](../completions). + +```bash +# zsh: copy to a directory on $fpath, then restart the shell +cp completions/_equasis ~/.zsh/completions/ + +# bash: source from ~/.bashrc +source /path/to/equasis-cli/completions/equasis.bash +``` diff --git a/pyproject.toml b/pyproject.toml index 419fdee..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", @@ -62,7 +62,7 @@ Issues = "https://github.com/rhinonix/equasis-cli/issues" Changelog = "https://github.com/rhinonix/equasis-cli/blob/main/CHANGELOG.md" [project.scripts] -equasis = "equasis_cli.main:main" +equasis = "equasis_cli.cli:run" [tool.setuptools.dynamic] version = { attr = "equasis_cli._version.__version__" } @@ -71,26 +71,16 @@ 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 target-version = "py310" src = ["src", "tests"] -# Legacy modules are excluded until they are rewritten in the core reliability -# refactor; new and rewritten code must pass the full rule set. -extend-exclude = [ - "src/equasis_cli/banner.py", - "src/equasis_cli/client.py", - "src/equasis_cli/credentials.py", - "src/equasis_cli/formatter.py", - "src/equasis_cli/interactive.py", - "src/equasis_cli/main.py", - "src/equasis_cli/parser.py", - "src/equasis_cli/__init__.py", -] [tool.ruff.lint] +# Typographic quotes are normalized in shell input; the prompt uses a chevron. +allowed-confusables = ["❯", "‘", "’", "“", "”"] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -115,10 +105,6 @@ warn_unreachable = true mypy_path = "src" files = ["src/equasis_cli"] -[[tool.mypy.overrides]] -module = ["bs4.*"] -ignore_missing_imports = true - [tool.pytest.ini_options] testpaths = ["tests"] addopts = ["-ra", "--strict-markers", "--strict-config"] diff --git a/src/equasis_cli/__init__.py b/src/equasis_cli/__init__.py index b77b458..ced4ae8 100644 --- a/src/equasis_cli/__init__.py +++ b/src/equasis_cli/__init__.py @@ -1,35 +1,65 @@ -#!/usr/bin/env python3 -""" -Equasis CLI Tool - A command-line interface for accessing Equasis maritime data +"""equasis-cli: command-line and Python access to Equasis maritime data. + +Example:: -This package provides programmatic access to vessel and fleet information -from the Equasis database through a clean, scriptable CLI interface. + from equasis_cli import EquasisClient + + with EquasisClient("user@example.com", "password") as client: + vessel = client.get_vessel("9811000") + print(vessel.name, vessel.flag, len(vessel.inspections)) """ -from .main import main -from .client import EquasisClient, SimpleVesselInfo, FleetInfo -from .parser import EquasisParser, EquasisVesselData, VesselBasicInfo -from .formatter import OutputFormatter -from .banner import get_version, display_banner, check_credentials, display_credentials_note -from .interactive import InteractiveShell +import logging from ._version import __version__ -__author__ = "rhinonix" -__email__ = "rhinonix.github.exclaim769@slmail.me" +from .client import EquasisClient +from .exceptions import ( + AuthenticationError, + ConfigurationError, + EquasisError, + InvalidInputError, + LayoutChangedError, + NetworkError, + NotFoundError, + RateLimitedError, + SessionExpiredError, +) +from .models import ( + Company, + CompanySummary, + Fleet, + FleetVessel, + Inspection, + SearchResults, + ShipSummary, + Vessel, + to_jsonable, +) +from .transport import RetryPolicy, Transport + +logging.getLogger(__name__).addHandler(logging.NullHandler()) -# Public API __all__ = [ - 'main', - 'EquasisClient', - 'EquasisParser', - 'EquasisVesselData', - 'VesselBasicInfo', - 'SimpleVesselInfo', - 'FleetInfo', - 'OutputFormatter', - 'get_version', - 'display_banner', - 'check_credentials', - 'display_credentials_note', - 'InteractiveShell' + "AuthenticationError", + "Company", + "CompanySummary", + "ConfigurationError", + "EquasisClient", + "EquasisError", + "Fleet", + "FleetVessel", + "Inspection", + "InvalidInputError", + "LayoutChangedError", + "NetworkError", + "NotFoundError", + "RateLimitedError", + "RetryPolicy", + "SearchResults", + "SessionExpiredError", + "ShipSummary", + "Transport", + "Vessel", + "__version__", + "to_jsonable", ] diff --git a/src/equasis_cli/__main__.py b/src/equasis_cli/__main__.py index 1b9e8ea..4f67c42 100644 --- a/src/equasis_cli/__main__.py +++ b/src/equasis_cli/__main__.py @@ -1,6 +1,6 @@ """Allow ``python -m equasis_cli``.""" -from equasis_cli.main import main +from equasis_cli.cli import run if __name__ == "__main__": - main() + run() diff --git a/src/equasis_cli/_version.py b/src/equasis_cli/_version.py index 5b151d5..4afa5ff 100644 --- a/src/equasis_cli/_version.py +++ b/src/equasis_cli/_version.py @@ -1,3 +1,3 @@ """Package version (single source of truth).""" -__version__ = "3.0.0.dev0" +__version__ = "3.0.0" diff --git a/src/equasis_cli/banner.py b/src/equasis_cli/banner.py index 62f36dc..7823cbf 100644 --- a/src/equasis_cli/banner.py +++ b/src/equasis_cli/banner.py @@ -1,440 +1,38 @@ -""" -Banner and startup information display for Equasis CLI -""" +"""Banner and disclaimer text.""" -import sys -import os -from typing import Optional +from __future__ import annotations -# ANSI color codes for terminal formatting -class Colors: - # Bright text colors (for emphasis) - RED = '\033[91m' - GREEN = '\033[92m' - YELLOW = '\033[93m' - BLUE = '\033[94m' - MAGENTA = '\033[95m' - CYAN = '\033[96m' - WHITE = '\033[97m' - - # Dim/subdued colors for banner text - DIM_RED = '\033[31m' # Darker red - DIM_GREEN = '\033[32m' # Darker green - DIM_YELLOW = '\033[33m' # Darker yellow - DIM_BLUE = '\033[34m' # Darker blue - DIM_MAGENTA = '\033[35m' # Darker magenta - DIM_CYAN = '\033[36m' # Darker cyan - DIM_WHITE = '\033[37m' # Darker white/gray - DIM = '\033[2m' # Dim/faint text - - # Text styles - BOLD = '\033[1m' - UNDERLINE = '\033[4m' - - # Reset - RESET = '\033[0m' - - @staticmethod - def supports_color(): - """Check if terminal supports color""" - return hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() - -# Version information from ._version import __version__ -BANNER_ART = r""" - _ ___ +ASCII_ART = r""" _ ___ ___ ____ ___ ______ ______(_)____ _____/ (_) / _ \/ __ `/ / / / __ `/ ___/ / ___/_____/ ___/ / / / __/ /_/ / /_/ / /_/ (__ ) (__ )_____/ /__/ / / \___/\__, /\__,_/\__,_/____/_/____/ \___/_/_/ - /_/ - - Maritime Intelligence Tool -""" - -def get_version() -> str: - """Get the current version string""" - return __version__ - -def check_credentials() -> bool: - """Check if Equasis credentials are configured""" - try: - from .credentials import get_credential_manager - credential_manager = get_credential_manager() - username, password = credential_manager.get_credentials() - return bool(username and password) - except ImportError: - # Fallback to environment variables if credential module not available - username = os.getenv('EQUASIS_USERNAME') - password = os.getenv('EQUASIS_PASSWORD') - return bool(username and password) - -def display_credentials_note() -> None: - """Display a colorized credentials setup note""" - color_support = Colors.supports_color() - - if color_support: - # Fully colorized note - print(f"{Colors.YELLOW}{Colors.BOLD}┌{'─' * 76}┐{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} CREDENTIALS REQUIRED │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} To use this tool, set your Equasis credentials: │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} Option 1 (Recommended): Use credential manager: │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} equasis configure --setup │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} Option 2: Set environment variables: │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} export EQUASIS_USERNAME=your_username │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} export EQUASIS_PASSWORD=your_password │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} Option 3: Use command line flags: │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} equasis --username your_user --password your_pass vessel --imo 123 │{Colors.RESET}") - print(f"{Colors.YELLOW}│{Colors.RESET}{Colors.YELLOW} │{Colors.RESET}") - print(f"{Colors.YELLOW}└{'─' * 76}┘{Colors.RESET}") - else: - print("┌" + "─" * 76 + "┐") - print("│ CREDENTIALS REQUIRED │") - print("│ │") - print("│ To use this tool, set your Equasis credentials: │") - print("│ │") - print("│ Option 1 (Recommended): Use credential manager: │") - print("│ equasis configure --setup │") - print("│ │") - print("│ Option 2: Set environment variables: │") - print("│ export EQUASIS_USERNAME=your_username │") - print("│ export EQUASIS_PASSWORD=your_password │") - print("│ │") - print("│ Option 3: Use command line flags: │") - print("│ equasis --username your_user --password your_pass vessel --imo 123│") - print("│ │") - print("└" + "─" * 76 + "┘") - print() - -def display_banner(show_banner: bool = True, show_info: bool = True) -> None: - """ - Display startup banner and information - - Args: - show_banner: Whether to show ASCII art banner - show_info: Whether to show startup information and disclaimers - """ - if not show_banner and not show_info: - return - - # Only display banner if output is going to a terminal (not piped) - if not sys.stdout.isatty(): - return - - if show_banner: - print(BANNER_ART) - color_support = Colors.supports_color() - if color_support: - print(f" {Colors.DIM}Version {__version__}{Colors.RESET}") - else: - print(f" Version {__version__}") - print() - - if show_info: - color_support = Colors.supports_color() - - # Feature highlights with subdued bullets - if color_support: - print(f"{Colors.DIM_BLUE}•{Colors.RESET} {Colors.DIM}Professional maritime intelligence tool for Equasis data{Colors.RESET}") - print(f"{Colors.DIM_BLUE}•{Colors.RESET} {Colors.DIM}Comprehensive vessel profiles with {Colors.DIM_WHITE}50+ data points{Colors.RESET}") - print(f"{Colors.DIM_BLUE}•{Colors.RESET} {Colors.DIM}Management companies, PSC inspections, historical data{Colors.RESET}") - else: - print("• Professional maritime intelligence tool for Equasis data") - print("• Comprehensive vessel profiles with 50+ data points") - print("• Management companies, PSC inspections, historical data") - print() - - # Important disclaimers with properly aligned border - border_width = 78 - if color_support: - print(f"{Colors.DIM_RED}┌" + "─" * border_width + f"┐{Colors.RESET}") - # Header - centered - header = "IMPORTANT DISCLAIMERS" - padding = (border_width - len(header)) // 2 - print(f"{Colors.DIM_RED}│{Colors.RESET}" + " " * padding + f"{Colors.DIM}{header}{Colors.RESET}" + " " * (border_width - len(header) - padding) + f"{Colors.DIM_RED}│{Colors.RESET}") - print(f"{Colors.DIM_RED}├" + "─" * border_width + f"┤{Colors.RESET}") - - # Content lines - each padded to exact width - lines = [ - " • This tool is NOT associated with, endorsed by, or affiliated with", - " Equasis", - " • May break if Equasis changes their website structure", - " • Users must monitor for changes and update parsing logic", - " accordingly", - " • Respect Equasis Terms of Service and rate limiting", - " • Use for legitimate maritime research and safety purposes only" - ] - - for line in lines: - padding_needed = border_width - len(line) - if line.startswith(" •"): - # Bullet point line - fully subdued - plain_text = line.replace(" •", " •") # Keep bullet as plain text - if "NOT" in plain_text: - # Even "NOT" should be subdued - plain_text = plain_text.replace("NOT", "NOT") - formatted_line = f"{Colors.DIM_RED}│{Colors.RESET} {Colors.DIM}•{Colors.RESET} {Colors.DIM}{plain_text[3:]}{Colors.RESET}" - print(formatted_line + " " * padding_needed + f"{Colors.DIM_RED}│{Colors.RESET}") - else: - # Continuation line - fully subdued - formatted_line = f"{Colors.DIM_RED}│{Colors.RESET}{Colors.DIM}{line}{Colors.RESET}" - print(formatted_line + " " * padding_needed + f"{Colors.DIM_RED}│{Colors.RESET}") - - print(f"{Colors.DIM_RED}└" + "─" * border_width + f"┘{Colors.RESET}") - else: - print("┌" + "─" * border_width + "┐") - header = "IMPORTANT DISCLAIMERS" - padding = (border_width - len(header)) // 2 - print("│" + " " * padding + header + " " * (border_width - len(header) - padding) + "│") - print("├" + "─" * border_width + "┤") - - lines = [ - " • This tool is NOT associated with, endorsed by, or affiliated with", - " Equasis", - " • May break if Equasis changes their website structure", - " • Users must monitor for changes and update parsing logic", - " accordingly", - " • Respect Equasis Terms of Service and rate limiting", - " • Use for legitimate maritime research and safety purposes only" - ] - - for line in lines: - padding_needed = border_width - len(line) - print("│" + line + " " * padding_needed + "│") - - print("└" + "─" * border_width + "┘") - print() - - # Quick help with subdued highlighting - if color_support: - print(f"{Colors.DIM_GREEN}Quick Start:{Colors.RESET}") - print(f" {Colors.DIM_CYAN}equasis vessel --imo 9074729{Colors.RESET} {Colors.DIM}# Get comprehensive vessel data{Colors.RESET}") - print(f" {Colors.DIM_CYAN}equasis search --name \"MAERSK\"{Colors.RESET} {Colors.DIM}# Search vessels by name{Colors.RESET}") - print(f" {Colors.DIM_CYAN}equasis --help{Colors.RESET} {Colors.DIM}# Show full help{Colors.RESET}") - else: - print("Quick Start:") - print(" equasis vessel --imo 9074729 # Get comprehensive vessel data") - print(" equasis search --name \"MAERSK\" # Search vessels by name") - print(" equasis --help # Show full help") - print() - - # Check credentials and show note if needed - if not check_credentials(): - display_credentials_note() - - if color_support: - print(f"{Colors.DIM}{'─' * 80}{Colors.RESET}") - else: - print("─" * 80) - print() - -def get_interactive_banner() -> str: - """ - Get banner text for interactive TUI mode - Returns plain text - styling will be applied by lexer with gradient - """ - banner = f""" _ ___ - ___ ____ ___ ______ ______(_)____ _____/ (_) - / _ \\/ __ `/ / / / __ `/ ___/ / ___/_____/ ___/ / / -/ __/ /_/ / /_/ / /_/ (__ ) (__ )_____/ /__/ / / -\\___/\\__, /\\__,_/\\__,_/____/_/____/ \\___/_/_/ - /_/ - -Maritime Intelligence Tool v{__version__} - -┌──────────────────────────────────────────────────────────────────────────┐ -│ Important Disclaimers │ -└──────────────────────────────────────────────────────────────────────────┘ -• This tool is NOT associated with, endorsed by, or affiliated with Equasis -• May break if Equasis changes their website structure -• Users must monitor for changes and update parsing logic accordingly -• Respect Equasis Terms of Service and rate limiting -• Use for legitimate maritime research and safety purposes only - -Type 'help' for available commands or 'exit' to quit. -Press ? for quick help.""" - - return banner - -def display_compact_info() -> None: - """Display minimal startup info for non-interactive use""" - if sys.stdout.isatty(): - color_support = Colors.supports_color() - if color_support: - print(f"{Colors.DIM}Equasis CLI v{__version__} | Maritime Intelligence Tool{Colors.RESET}") - print(f"{Colors.DIM_YELLOW}WARNING:{Colors.RESET} {Colors.DIM}Not affiliated with Equasis | Web scraping tool - monitor for changes{Colors.RESET}") - else: - print(f"Equasis CLI v{__version__} | Maritime Intelligence Tool") - print("WARNING: Not affiliated with Equasis | Web scraping tool - monitor for changes") - print() - -def display_error_banner(error_type: str, message: str) -> None: - """ - Display error information with consistent formatting - - Args: - error_type: Type of error (e.g., "Authentication", "Connection", "Parsing") - message: Error message to display - """ - color_support = Colors.supports_color() - - print() - if color_support: - print(f"{Colors.RED}{Colors.BOLD}ERROR:{Colors.RESET} " + "=" * 56) - print(f" {Colors.BOLD}{Colors.RED}{error_type.upper()} ERROR{Colors.RESET}") - print("=" * 62) - print() - print(f" {message}") - print() - else: - print("ERROR: " + "=" * 56) - print(f" {error_type.upper()} ERROR") - print("=" * 62) - print() - print(f" {message}") - print() - - if error_type.lower() == "authentication": - if color_support: - print(f"{Colors.BOLD}{Colors.GREEN}Quick Fix:{Colors.RESET}") - print(f" {Colors.YELLOW}•{Colors.RESET} Check your .env file has correct EQUASIS_USERNAME and EQUASIS_PASSWORD") - print(f" {Colors.YELLOW}•{Colors.RESET} Verify credentials work by logging into equasis.org manually") - print(f" {Colors.YELLOW}•{Colors.RESET} Use --username and --password flags as alternative") - else: - print("Quick Fix:") - print(" • Check your .env file has correct EQUASIS_USERNAME and EQUASIS_PASSWORD") - print(" • Verify credentials work by logging into equasis.org manually") - print(" • Use --username and --password flags as alternative") - print() - elif error_type.lower() == "parsing": - if color_support: - print(f"{Colors.BOLD}{Colors.BLUE}Possible Causes:{Colors.RESET}") - print(f" {Colors.YELLOW}•{Colors.RESET} Equasis may have changed their website structure") - print(f" {Colors.YELLOW}•{Colors.RESET} IMO number may not exist in Equasis database") - print(f" {Colors.YELLOW}•{Colors.RESET} Network issues or temporary Equasis downtime") - print(f" {Colors.YELLOW}•{Colors.RESET} Enable debug mode to inspect HTML response") - else: - print("Possible Causes:") - print(" • Equasis may have changed their website structure") - print(" • IMO number may not exist in Equasis database") - print(" • Network issues or temporary Equasis downtime") - print(" • Enable debug mode to inspect HTML response") - print() - elif error_type.lower() == "connection": - if color_support: - print(f"{Colors.BOLD}{Colors.CYAN}Troubleshooting:{Colors.RESET}") - print(f" {Colors.YELLOW}•{Colors.RESET} Check internet connection") - print(f" {Colors.YELLOW}•{Colors.RESET} Verify equasis.org is accessible") - print(f" {Colors.YELLOW}•{Colors.RESET} Check firewall/proxy settings") - print(f" {Colors.YELLOW}•{Colors.RESET} Try again later if Equasis servers are down") - else: - print("Troubleshooting:") - print(" • Check internet connection") - print(" • Verify equasis.org is accessible") - print(" • Check firewall/proxy settings") - print(" • Try again later if Equasis servers are down") - print() - - print("=" * 62) - print() - -def display_success_summary(operation: str, details: Optional[str] = None) -> None: - """ - Display success information with consistent formatting - - Args: - operation: Operation that succeeded (e.g., "Vessel Lookup", "Search") - details: Optional additional details - """ - if sys.stdout.isatty(): - color_support = Colors.supports_color() - print() - if color_support: - print(f"{Colors.BOLD}{Colors.GREEN}SUCCESS:{Colors.RESET} {operation} completed successfully") - if details: - print(f" {Colors.CYAN}{details}{Colors.RESET}") - else: - print(f"SUCCESS: {operation} completed successfully") - if details: - print(f" {details}") - print() - -class StatusBar: - """Persistent status bar for interactive mode""" - - def __init__(self): - self.connected = False - self.last_result_success: Optional[bool] = None - self.last_result_message: str = "" - self.output_format: str = "table" - self.color_support = Colors.supports_color() - - def set_connection_status(self, connected: bool) -> None: - """Update connection status""" - self.connected = connected - - def set_last_result(self, success: bool, message: str = "") -> None: - """Update last command result""" - self.last_result_success = success - self.last_result_message = message - - def set_format(self, format_type: str) -> None: - """Update output format""" - self.output_format = format_type - - def clear_last_result(self) -> None: - """Clear last result (for commands that don't need result tracking)""" - self.last_result_success = None - self.last_result_message = "" - - def render(self) -> str: - """Render status bar as a single line string""" - parts = [] - - # Connection status - if self.color_support: - if self.connected: - parts.append(f"{Colors.GREEN}●{Colors.RESET} {Colors.DIM}Connected{Colors.RESET}") - else: - parts.append(f"{Colors.DIM_RED}○{Colors.RESET} {Colors.DIM}Not connected{Colors.RESET}") - else: - if self.connected: - parts.append("● Connected") - else: - parts.append("○ Not connected") - - # Output format - if self.color_support: - parts.append(f"{Colors.DIM}Format: {Colors.DIM_CYAN}{self.output_format}{Colors.RESET}") - else: - parts.append(f"Format: {self.output_format}") - - # Last result (if available) - if self.last_result_success is not None: - if self.color_support: - if self.last_result_success: - status_icon = f"{Colors.GREEN}✓{Colors.RESET}" - result_text = self.last_result_message or "Success" - parts.append(f"{status_icon} {Colors.DIM}{result_text}{Colors.RESET}") - else: - status_icon = f"{Colors.RED}✗{Colors.RESET}" - result_text = self.last_result_message or "Failed" - parts.append(f"{status_icon} {Colors.DIM}{result_text}{Colors.RESET}") - else: - if self.last_result_success: - parts.append(f"✓ {self.last_result_message or 'Success'}") - else: - parts.append(f"✗ {self.last_result_message or 'Failed'}") - - # Join with separators - if self.color_support: - separator = f" {Colors.DIM}|{Colors.RESET} " - else: - separator = " | " - - return separator.join(parts) + /_/""" + +DISCLAIMER = ( + "equasis-cli is not affiliated with or endorsed by Equasis. It reads the Equasis " + "website, which may change without notice. Use it in line with the Equasis terms " + "and conditions, keep request volumes modest, and verify important findings on " + "equasis.org." +) + +TAGLINE = "Maritime Intelligence Tool" + + +def interactive_banner() -> str: + """Banner shown at the top of the interactive shell.""" + return "\n".join( + [ + ASCII_ART, + "", + f"{TAGLINE:<67}v{__version__}", + "", + "Not affiliated with Equasis. The Equasis website may change without notice;", + "use in line with the Equasis terms and keep request volumes modest.", + "", + "Type 'help' for commands or 'exit' to quit.", + "Press ? for quick help.", + ] + ) 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 new file mode 100644 index 0000000..40749ca --- /dev/null +++ b/src/equasis_cli/cli.py @@ -0,0 +1,777 @@ +"""Command-line interface. + +Data goes to stdout (or ``--output-file``); progress, warnings and errors go to +stderr. The exit status tells scripts what happened (see :class:`ExitCode`). +""" + +from __future__ import annotations + +import argparse +import enum +import getpass +import logging +import sys +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import IO, Any, NoReturn + +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 +from .exceptions import ( + AuthenticationError, + ConfigurationError, + EquasisError, + InvalidInputError, + LayoutChangedError, + NetworkError, + NotFoundError, +) +from .models import BatchItem, CompanySummary, ItemStatus +from .service import ( + LARGE_BATCH_WARNING_THRESHOLD, + Resolution, + describe_candidates, + imo_warnings, + is_company_id, + iter_fleets, + iter_vessels, + resolve_company, +) +from .transport import Transport +from .validation import normalize_company_id, normalize_imo, parse_list, split_values + +logger = logging.getLogger("equasis_cli") + +ClientFactory = Callable[..., EquasisClient] +"""Called as ``factory(username, password, transport=..., cache=..., refresh=...)``.""" + + +class ExitCode(enum.IntEnum): + OK = 0 + ERROR = 1 + USAGE = 2 + AUTHENTICATION = 3 + NOT_FOUND = 4 + PARTIAL_FAILURE = 5 + LAYOUT_CHANGED = 6 + INTERRUPTED = 130 + + +EXAMPLES = """\ +examples: + equasis vessel 9811000 + equasis vessel --imo-file imos.txt -o vessels.csv + equasis search "EVER GIVEN" + equasis search --mmsi 353136000 --format json + equasis fleet "TORM A/S" --format csv -o torm.csv + equasis configure --setup + +exit status: + 0 success, 1 error, 2 invalid usage, 3 authentication failed, 4 not found, + 5 some batch items failed, 6 Equasis page layout changed, 130 interrupted +""" + + +# ------------------------------------------------------------------------- arguments + + +class _Parser(argparse.ArgumentParser): + def error(self, message: str) -> NoReturn: + self.print_usage(sys.stderr) + self.exit(ExitCode.USAGE, f"{self.prog}: error: {message}\n") + + +def _add_common_options(parser: argparse.ArgumentParser, *, subcommand: bool) -> None: + """Options accepted both before and after the command name. + + On subcommands the defaults are suppressed, so a value given after the command + overrides one given before it without resetting it when omitted. + """ + + def default(value: Any) -> Any: + return argparse.SUPPRESS if subcommand else value + + group = parser.add_argument_group("output options") + group.add_argument( + "-f", + "--format", + choices=output.FORMATS, + default=default(None), + help="output format (default: inferred from --output-file, otherwise table)", + ) + group.add_argument( + "--output", + dest="format", + choices=output.FORMATS, + default=default(None), + help=argparse.SUPPRESS, # pre-3.0 name for --format + ) + group.add_argument( + "-o", + "--output-file", + metavar="PATH", + default=default(None), + help="write results to PATH instead of stdout", + ) + group.add_argument( + "-q", + "--quiet", + action="store_true", + default=default(False), + help="only print results and errors", + ) + group.add_argument( + "--no-color", action="store_true", default=default(False), help="disable colored messages" + ) + + connection = parser.add_argument_group("connection options") + connection.add_argument( + "--username", default=default(None), help="Equasis username (e-mail address)" + ) + connection.add_argument( + "--password", + default=default(None), + help="Equasis password; prefer 'equasis configure --setup' or EQUASIS_PASSWORD", + ) + connection.add_argument( + "--delay", + type=float, + metavar="SECONDS", + default=default(1.0), + help="minimum pause between requests to Equasis (default: 1.0)", + ) + connection.add_argument( + "--debug", action="store_true", default=default(False), help="log HTTP activity to stderr" + ) + connection.add_argument( + "--save-html", + metavar="DIR", + type=Path, + 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 + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = _Parser( + prog="equasis", + description="Look up vessels, PSC inspections, ownership history and fleets in Equasis.", + epilog=f"{EXAMPLES}\n{DISCLAIMER}", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument( + "--interactive", + action="store_true", + help="start the interactive shell (default with no command)", + ) + _add_common_options(parser, subcommand=False) + + commands = parser.add_subparsers(dest="command", metavar="COMMAND", parser_class=_Parser) + + vessel = commands.add_parser( + "vessel", + help="full vessel profile by IMO number", + description=( + "Retrieve vessel particulars, management, classification, PSC inspections and " + "name, flag and company history. Several IMO numbers produce a batch report." + ), + ) + vessel.add_argument("imos", nargs="*", metavar="IMO", help="IMO number(s)") + vessel.add_argument( + "--imo", nargs="+", action="extend", default=[], metavar="IMO", help=argparse.SUPPRESS + ) + vessel.add_argument( + "--imo-file", + metavar="FILE", + help="read IMO numbers from FILE ('-' for stdin); one per line, '#' starts a comment", + ) + vessel.add_argument( + "--fail-fast", action="store_true", help="stop a batch at the first failed lookup" + ) + vessel.add_argument("--continue-on-error", action="store_true", help=argparse.SUPPRESS) + vessel.add_argument("--progress", action="store_true", help=argparse.SUPPRESS) + _add_common_options(vessel, subcommand=True) + + search = commands.add_parser( + "search", + help="search ships and companies", + description=( + "Search by name (partial matches, ships and companies) or look up ships by an " + "exact IMO number, MMSI or call sign." + ), + ) + search.add_argument("query", nargs="?", help="name to search for") + search.add_argument("--name", help=argparse.SUPPRESS) + search.add_argument("--imo", help="find a ship by IMO number") + search.add_argument("--mmsi", help="find a ship by MMSI") + search.add_argument("--call-sign", help="find a ship by call sign") + search.add_argument( + "--type", + choices=("all", "ships", "companies"), + default="all", + help="which results to include for name searches (default: all)", + ) + pages = search.add_mutually_exclusive_group() + pages.add_argument( + "--max-pages", + type=int, + default=DEFAULT_SEARCH_PAGES, + metavar="N", + help=f"result pages to fetch, 100 results each (default: {DEFAULT_SEARCH_PAGES})", + ) + pages.add_argument( + "--all-pages", action="store_const", dest="max_pages", const=None, help="fetch every page" + ) + _add_common_options(search, subcommand=True) + + fleet = commands.add_parser( + "fleet", + help="vessels associated with a company", + description=( + "Retrieve the fleet of a company, given its name or 7-digit Equasis company " + "number. Several companies produce a batch report." + ), + ) + fleet.add_argument("companies", nargs="*", metavar="COMPANY", help="company name or number") + fleet.add_argument( + "--company", nargs="+", action="extend", default=[], metavar="NAME", help=argparse.SUPPRESS + ) + fleet.add_argument( + "--company-id", + nargs="+", + action="extend", + default=[], + metavar="NUMBER", + help="Equasis company number(s)", + ) + fleet.add_argument( + "--company-file", + metavar="FILE", + help="read company names or numbers from FILE ('-' for stdin), one per line", + ) + fleet.add_argument( + "--first-match", + action="store_true", + help="when a name matches several companies, use the best match instead of stopping", + ) + fleet.add_argument( + "--max-pages", type=int, metavar="N", help="limit fleet pages (100 vessels each)" + ) + fleet.add_argument( + "--fail-fast", action="store_true", help="stop a batch at the first failed lookup" + ) + fleet.add_argument("--continue-on-error", action="store_true", help=argparse.SUPPRESS) + fleet.add_argument("--progress", action="store_true", help=argparse.SUPPRESS) + _add_common_options(fleet, subcommand=True) + + configure = commands.add_parser( + "configure", + help="manage stored credentials", + description=f"Credentials are stored in {CredentialStore().path} with owner-only access.", + ) + action = configure.add_mutually_exclusive_group() + action.add_argument("--setup", action="store_true", help="store credentials interactively") + action.add_argument("--show", action="store_true", help="show where credentials come from") + action.add_argument("--test", action="store_true", help="check that the credentials work") + 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 + + +# ----------------------------------------------------------------------------- runner + + +class Runner: + """Executes a parsed command and maps failures to exit codes.""" + + def __init__( + self, + args: argparse.Namespace, + *, + console: Console, + stdout: IO[str], + stdin: IO[str], + store: CredentialStore, + client_factory: ClientFactory, + ) -> None: + self.args = args + self.console = console + self.stdout = stdout + self.stdin = stdin + self.store = store + self.client_factory = client_factory + + # ---------------------------------------------------------------- helpers + + def client(self) -> EquasisClient: + args = self.args + if args.password and args.username: + self.console.warn( + "--password is visible in shell history and process listings; " + "prefer 'equasis configure --setup' or EQUASIS_PASSWORD" + ) + credentials = self.store.resolve(args.username, args.password) + 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=transport, + cache=None if args.no_cache else PageCache(), + refresh=args.refresh, + ) + + @property + def format(self) -> str: + return output.resolve_format(self.args.format, self.args.output_file) + + def emit(self, text: str) -> None: + if self.args.output_file: + path = output.write_output(text, self.args.output_file) + self.console.success(f"Saved to {path}") + else: + self.stdout.write(text) + self.stdout.flush() + + def read_list(self, path: str) -> list[str]: + if path == "-": + return parse_list(self.stdin) + try: + with open(path, encoding="utf-8") as handle: + return parse_list(handle) + except OSError as exc: + raise InvalidInputError(f"cannot read {path}: {exc.strerror or exc}") from exc + + def interactive_terminal(self) -> bool: + return self.console.is_terminal and bool(getattr(self.stdin, "isatty", lambda: False)()) + + # --------------------------------------------------------------- commands + + def run(self) -> int: + command = self.args.command + handler = { + "vessel": self.vessel, + "search": self.search, + "fleet": self.fleet, + "configure": self.configure, + "cache": self.cache, + }[command] + return handler() + + def vessel(self) -> int: + args = self.args + queries = split_values([*args.imos, *args.imo]) + from_file = bool(args.imo_file) + if from_file: + queries += [q for q in self.read_list(args.imo_file) if q not in queries] + if not queries: + raise InvalidInputError("provide at least one IMO number or --imo-file") + + if len(queries) == 1 and not from_file: + imo = normalize_imo(queries[0]) + for warning in imo_warnings(imo): + self.console.warn(warning) + with self.client() as client: + self.console.progress(f"Retrieving IMO {imo}...") + vessel = client.get_vessel(imo) + for warning in vessel.warnings: + self.console.warn(warning) + self.emit(output.render(vessel, self.format)) + return ExitCode.OK + + return self._batch("vessels", queries) + + def search(self) -> int: + args = self.args + query = args.query or args.name + identifiers = (args.imo, args.mmsi, args.call_sign) + if query and any(identifiers): + raise InvalidInputError("search by name or by identifier, not both") + with self.client() as client: + if any(identifiers): + self.console.progress("Searching Equasis...") + results = client.search_ships( + imo=args.imo, mmsi=args.mmsi, call_sign=args.call_sign + ) + elif query: + if args.max_pages is not None and args.max_pages < 1: + raise InvalidInputError("--max-pages must be at least 1") + self.console.progress(f"Searching Equasis for '{query}'...") + results = client.search( + query, + max_pages=args.max_pages, + ships=args.type in ("all", "ships"), + companies=args.type in ("all", "companies"), + ) + else: + raise InvalidInputError( + "provide a name to search for, or --imo, --mmsi or --call-sign" + ) + + if results.total_ships and results.total_ships > len(results.ships): + self.console.warn( + f"showing {len(results.ships)} of {results.total_ships} ships; " + "use --max-pages or --all-pages for more" + ) + self.emit(output.render(results, self.format)) + if not results.ships and not results.companies: + self.console.info("No matches found.") + return ExitCode.NOT_FOUND + return ExitCode.OK + + def fleet(self) -> int: + args = self.args + queries = [*args.companies, *args.company] + queries += [normalize_company_id(c) for c in args.company_id] + from_file = bool(args.company_file) + if from_file: + queries += self.read_list(args.company_file) + queries = parse_list(queries) + if not queries: + raise InvalidInputError("provide a company name, --company-id, or --company-file") + if args.max_pages is not None and args.max_pages < 1: + raise InvalidInputError("--max-pages must be at least 1") + + if len(queries) > 1 or from_file: + return self._batch("fleets", queries) + + query = queries[0] + with self.client() as client: + if is_company_id(query): + company_id = query + else: + self.console.progress(f"Looking up companies matching '{query}'...") + company_id = self._choose_company(client, query).company_id + self.console.progress(f"Retrieving fleet of company {company_id}...") + fleet = client.get_fleet(company_id, max_pages=args.max_pages) + self.emit(output.render(fleet, self.format)) + return ExitCode.OK + + def _choose_company(self, client: EquasisClient, query: str) -> CompanySummary: + match = resolve_company(client, query) + if match.resolution is Resolution.NOT_FOUND: + raise NotFoundError(f"no company matches '{query}'") + if match.resolution is Resolution.RESOLVED and match.company is not None: + return match.company + candidates = match.candidates + if self.args.first_match: + chosen = candidates[0] + self.console.warn( + f"'{query}' matched {len(candidates)} companies; using {chosen.name} " + f"({chosen.company_id})" + ) + return chosen + if not self.interactive_terminal(): + raise NotFoundError( + f"'{query}' matches {len(candidates)} companies: " + f"{describe_candidates(candidates)}. " + "Pass a company number (--company-id) or --first-match" + ) + self.console.info(f"'{query}' matches {len(candidates)} companies:") + self.console.info(output.render_company_choices(candidates)) + while True: + self.console.stream.write(f"Choose 1-{len(candidates)} (or q to cancel): ") + self.console.stream.flush() + answer = self.stdin.readline().strip().lower() + if answer in ("", "q", "quit"): + raise KeyboardInterrupt + if answer.isdigit() and 1 <= int(answer) <= len(candidates): + return candidates[int(answer) - 1] + + def _batch(self, kind: str, queries: list[str]) -> int: + args = self.args + fmt = self.format + noun = "vessels" if kind == "vessels" else "companies" + if len(queries) > LARGE_BATCH_WARNING_THRESHOLD: + self.console.warn( + f"processing {len(queries)} {noun}; very large batches can lead Equasis to " + "restrict your account. Consider splitting the work across days." + ) + self.console.info(f"Processing {len(queries)} {noun}...") + + def on_start(position: int, total: int, query: str) -> None: + self.console.progress(f"[{position}/{total}] {query}") + + report = output.BatchReport(kind="vessels" if kind == "vessels" else "fleets") + stream: IO[str] | None = None + if fmt == "jsonl": + if args.output_file: + target = Path(args.output_file).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + stream = open(target, "w", encoding="utf-8", newline="") # noqa: SIM115 + else: + stream = self.stdout + + try: + with self.client() as client: + items: Any + if kind == "vessels": + items = iter_vessels( + client, queries, fail_fast=args.fail_fast, on_start=on_start + ) + else: + items = iter_fleets( + client, + queries, + first_match=args.first_match, + fail_fast=args.fail_fast, + max_pages=args.max_pages, + on_start=on_start, + ) + for item in items: + self._report_item(item) + report.items.append(item) + if stream is not None: + stream.write(output.render_batch_item_jsonl(report.kind, item)) + stream.flush() + finally: + if stream is not None and stream is not self.stdout: + stream.close() + + if stream is None: + self.emit(output.render(report, fmt)) + elif args.output_file: + self.console.success(f"Saved to {Path(args.output_file).expanduser()}") + + summary = f"{report.succeeded} of {len(report.items)} {noun} retrieved" + if report.failed: + self.console.warn(summary) + return ExitCode.PARTIAL_FAILURE + self.console.success(summary) + return ExitCode.OK + + def _report_item(self, item: BatchItem[Any]) -> None: + for warning in item.warnings: + self.console.warn(f"{item.query}: {warning}") + 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 + if args.clear: + if store.clear(): + self.console.success(f"Removed {store.path}") + else: + self.console.info(f"No stored credentials at {store.path}") + return ExitCode.OK + if args.show: + info = store.describe() + lines = [ + "Credential sources (highest priority first):", + " 1. --username / --password options", + " 2. environment variables: " + + ", ".join( + f"{k} {'set' if v else 'not set'}" for k, v in info["environment"].items() + ), + f" 3. credentials file: {info['file']['path']}", + f" {'present' if info['file']['complete'] else 'not configured'}" + + (f" (username {info['file']['username']})" if info["file"]["username"] else ""), + ] + self.stdout.write("\n".join(lines) + "\n") + return ExitCode.OK + if args.test: + credentials = store.resolve(args.username, args.password) + if credentials is None: + raise ConfigurationError("no Equasis credentials found") + with self.client() as client: + self.console.progress("Logging in to Equasis...") + client.login() + self.console.success( + f"Logged in as {credentials.username} (from {credentials.source.value})" + ) + return ExitCode.OK + if args.setup: + return self._setup() + build_parser().parse_args(["configure", "--help"]) + return ExitCode.OK # pragma: no cover - --help exits + + def _setup(self) -> int: + store = self.store + if not self.interactive_terminal(): + raise InvalidInputError("configure --setup needs an interactive terminal") + self.console.info(f"Credentials will be stored in {store.path} (readable only by you).") + self.console.stream.write("Equasis username (e-mail): ") + self.console.stream.flush() + username = self.stdin.readline().strip() + password = getpass.getpass("Equasis password: ") + if not username or not password: + raise InvalidInputError("username and password must not be empty") + + transport = Transport(min_interval=0) + try: + self.console.progress("Checking the credentials with Equasis...") + self.client_factory(username, password, transport=transport).login() + self.console.success("Login succeeded.") + except AuthenticationError as exc: + self.console.error(str(exc)) + self.console.stream.write("Save these credentials anyway? [y/N]: ") + self.console.stream.flush() + if self.stdin.readline().strip().lower() not in ("y", "yes"): + self.console.info("Nothing saved.") + return ExitCode.AUTHENTICATION + except NetworkError as exc: + self.console.warn(f"could not verify the credentials: {exc}") + finally: + transport.close() + + path = store.save(username, password) + self.console.success(f"Saved credentials to {path}") + return ExitCode.OK + + +# ------------------------------------------------------------------------------ entry + + +def _configure_logging(debug: bool) -> None: + logging.basicConfig( + level=logging.DEBUG if debug else logging.WARNING, + format="%(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + force=True, + ) + if not debug: + logging.getLogger("urllib3").setLevel(logging.WARNING) + + +def _start_interactive() -> int: + from .tui import InteractiveShell + + InteractiveShell().start() + return ExitCode.OK + + +def main( + argv: Sequence[str] | None = None, + *, + stdout: IO[str] | None = None, + stdin: IO[str] | None = None, + stderr: IO[str] | None = None, + store: CredentialStore | None = None, + client_factory: ClientFactory | None = None, +) -> int: + """Run the CLI and return the exit status.""" + stdout = stdout or sys.stdout + stdin = stdin or sys.stdin + argv = list(sys.argv[1:] if argv is None else argv) + parser = build_parser() + + if not argv: + if stdin.isatty() and stdout.isatty(): + return _start_interactive() + parser.print_help(stderr or sys.stderr) + return ExitCode.USAGE + + args = parser.parse_args(argv) + console = Console(stderr, quiet=args.quiet, color=False if args.no_color else None) + if args.interactive or args.command == "interactive": + return _start_interactive() + if args.command is None: + parser.print_help(stdout) + return ExitCode.USAGE + + _configure_logging(args.debug) + runner = Runner( + args, + console=console, + stdout=stdout, + stdin=stdin, + store=store or CredentialStore(), + client_factory=client_factory or EquasisClient, + ) + try: + return runner.run() + except KeyboardInterrupt: + console.error("interrupted") + return ExitCode.INTERRUPTED + except ConfigurationError as exc: + console.error( + str(exc), + hint="run 'equasis configure --setup' or set EQUASIS_USERNAME and EQUASIS_PASSWORD", + ) + return ExitCode.AUTHENTICATION + except AuthenticationError as exc: + console.error(str(exc), hint="check your details with 'equasis configure --test'") + return ExitCode.AUTHENTICATION + except InvalidInputError as exc: + console.error(str(exc)) + return ExitCode.USAGE + except NotFoundError as exc: + console.error(str(exc)) + return ExitCode.NOT_FOUND + except LayoutChangedError as exc: + console.error( + f"could not read the Equasis page: {exc}", + hint=( + "Equasis may have changed its website. Re-run with --save-html DIR and " + "report it at https://github.com/rhinonix/equasis-cli/issues " + "(remove your name from saved pages)" + ), + ) + return ExitCode.LAYOUT_CHANGED + except EquasisError as exc: + console.error(str(exc)) + return ExitCode.ERROR + except OSError as exc: + console.error(f"{exc.strerror or exc}: {exc.filename}" if exc.filename else str(exc)) + return ExitCode.ERROR + + +def run() -> NoReturn: + """Console script entry point.""" + sys.exit(main()) diff --git a/src/equasis_cli/client.py b/src/equasis_cli/client.py index f19cfc4..1c0d3dd 100644 --- a/src/equasis_cli/client.py +++ b/src/equasis_cli/client.py @@ -1,838 +1,376 @@ -#!/usr/bin/env python3 -""" -Equasis Client - Handles authentication and web scraping of Equasis website -""" +"""High-level Equasis client: authentication, vessel lookups, search, and fleets.""" + +from __future__ import annotations -import requests -import time import logging -import random -from typing import Optional, List, Callable -from bs4 import BeautifulSoup -from dataclasses import dataclass -from requests.exceptions import ( - ConnectionError, Timeout, HTTPError, RequestException, - ChunkedEncodingError, ContentDecodingError +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, ) - -from .parser import EquasisParser, EquasisVesselData +from .models import Fleet, SearchResults, Vessel +from .parsing import ( + PageKind, + classify, + is_logged_in, + merge_vessel, + parse_fleet, + parse_history, + parse_inspections, + parse_search, + parse_ship_info, +) +from .transport import Transport +from .validation import normalize_company_id, normalize_imo, normalize_mmsi logger = logging.getLogger(__name__) -@dataclass -class RetryConfig: - """Configuration for retry behavior following industry best practices""" - max_retries: int = 3 - base_delay: float = 1.0 # Base delay in seconds - max_delay: float = 60.0 # Maximum delay in seconds - exponential_base: float = 2.0 # Exponential backoff multiplier - jitter: bool = True # Add randomness to prevent thundering herd - - def __post_init__(self): - """Initialize mutable defaults after object creation""" - if not hasattr(self, '_retryable_exceptions'): - self._retryable_exceptions = ( - ConnectionError, - Timeout, - ChunkedEncodingError, - ContentDecodingError, - HTTPError, # Will check status code separately - ) - if not hasattr(self, '_retryable_status_codes'): - self._retryable_status_codes = { - 429, # Too Many Requests - 500, # Internal Server Error - 502, # Bad Gateway - 503, # Service Unavailable - 504, # Gateway Timeout - 520, # CloudFlare: Unknown Error - 521, # CloudFlare: Web Server Is Down - 522, # CloudFlare: Connection Timed Out - 523, # CloudFlare: Origin Is Unreachable - 524, # CloudFlare: A Timeout Occurred - } - - @property - def retryable_exceptions(self): - return self._retryable_exceptions - - @property - def retryable_status_codes(self): - return self._retryable_status_codes +_LOGIN_PATH = "authen/HomePage?fs=HomePage" +_SEARCH_PATH = "restricted/Search?fs=Search" +_FLEET_PATH = "restricted/FleetInfo?fs=CompanyInfo" -def with_retry(retry_config: RetryConfig = None): - """ - Decorator that implements exponential backoff with jitter for network operations +DEFAULT_SEARCH_PAGES = 3 +"""Search result pages fetched by default (Equasis shows 100 results per page).""" - Based on AWS best practices: - https://docs.aws.amazon.com/general/latest/gr/api-retries.html - """ - if retry_config is None: - retry_config = RetryConfig() - - def decorator(func): - def wrapper(*args, **kwargs): - last_exception = None - - for attempt in range(retry_config.max_retries + 1): - try: - result = func(*args, **kwargs) - - # Check for HTTP errors that should be retried - if hasattr(result, 'status_code') and result.status_code in retry_config.retryable_status_codes: - raise HTTPError(f"HTTP {result.status_code}", response=result) - - # Success - reset any session issues and return - if attempt > 0: - logger.info(f"Operation succeeded on attempt {attempt + 1}") - return result - - except retry_config.retryable_exceptions as e: - last_exception = e - - if attempt == retry_config.max_retries: - logger.error(f"Operation failed after {retry_config.max_retries + 1} attempts: {e}") - break - - # Calculate delay with exponential backoff and jitter - delay = min( - retry_config.base_delay * (retry_config.exponential_base ** attempt), - retry_config.max_delay - ) - - if retry_config.jitter: - # Add jitter: delay ± 25% - jitter_range = delay * 0.25 - delay = delay + random.uniform(-jitter_range, jitter_range) - delay = max(0.1, delay) # Ensure minimum delay - - logger.warning(f"Attempt {attempt + 1} failed ({e}), retrying in {delay:.1f}s...") - time.sleep(delay) - - except Exception as e: - # Non-retryable exception - logger.error(f"Non-retryable error: {e}") - raise - - # All retries exhausted - raise last_exception - - return wrapper - return decorator - -@dataclass -class BatchResult: - """Result from batch vessel processing""" - imo: str - success: bool - vessel_data: Optional[EquasisVesselData] = None - error_message: Optional[str] = None - processing_time: float = 0.0 - -@dataclass -class BatchSummary: - """Summary of batch processing operation""" - total_vessels: int - successful: int - failed: int - processing_time: float - failed_imos: List[str] - timestamp: str = "" - -@dataclass -class CompanyBatchResult: - """Result from batch company processing""" - company_name: str - success: bool - fleet_data: Optional['FleetInfo'] = None - error_message: Optional[str] = None - processing_time: float = 0.0 - -@dataclass -class CompanyBatchSummary: - """Summary of company batch processing operation""" - total_companies: int - successful: int - failed: int - total_vessels: int # Sum across all fleets - processing_time: float - failed_companies: List[str] - timestamp: str = "" - -@dataclass -class SimpleVesselInfo: - """Simple vessel info for fleet/search operations""" - imo: str - name: str - flag: str - vessel_type: str - dwt: Optional[str] = None - grt: Optional[str] = None - year_built: Optional[str] = None - status: Optional[str] = None - -@dataclass -class FleetInfo: - """Data class for fleet information""" - company_name: str - vessels: List[SimpleVesselInfo] - total_vessels: int +MAX_FLEET_PAGES = 50 +"""Safety limit for fleet pagination (5,000 vessels).""" class EquasisClient: - """Client for interacting with Equasis API""" - - def __init__(self, username: str, password: str, retry_config: RetryConfig = None): - self.username = username - self.password = password - self.session = requests.Session() - self.base_url = "https://www.equasis.org" - self.logged_in = False - self.comprehensive_parser = EquasisParser() - self.retry_config = retry_config or RetryConfig() - - # Configure session timeouts (industry standard) - self.session.timeout = (10, 30) # (connect_timeout, read_timeout) - - # Set headers to mimic a real browser - self.session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate', - 'Connection': 'keep-alive', - }) - - @with_retry() - def _safe_get(self, url: str, **kwargs): - """HTTP GET with retry logic""" - logger.debug(f"GET request to: {url}") - response = self.session.get(url, timeout=self.session.timeout, **kwargs) - response.raise_for_status() - return response - - @with_retry() - def _safe_post(self, url: str, **kwargs): - """HTTP POST with retry logic""" - logger.debug(f"POST request to: {url}") - response = self.session.post(url, timeout=self.session.timeout, **kwargs) - response.raise_for_status() - return response - - def login(self) -> bool: - """Login to Equasis""" - try: - # Get login page first - login_url = f"{self.base_url}/EquasisWeb/authen/HomePage?fs=HomePage" - response = self._safe_get(login_url) - - # Parse login form to get any hidden fields - soup = BeautifulSoup(response.text, 'html.parser') - - # Prepare login data - login_data = { - 'j_email': self.username, - 'j_password': self.password, - 'submit': 'Ok' - } - - # Submit login - response = self._safe_post(login_url, data=login_data) - - # Check if login was successful (you may need to adjust this check) - if "restricted" in response.url or "Welcome" in response.text: - self.logged_in = True - logger.info("Successfully logged in to Equasis") - return True - else: - logger.error("Login failed - check credentials") - return False - - except Exception as e: - logger.error(f"Login error: {e}") - return False - - def get_comprehensive_vessel_info(self, imo: str) -> Optional[EquasisVesselData]: - """Get comprehensive vessel information from all tabs""" - if not self.logged_in and not self.login(): - return None - - try: - # URLs for the three tabs - tab_urls = { - 'ship_info': f"{self.base_url}/EquasisWeb/restricted/ShipInfo?fs=ShipInfo&P_IMO={imo}", - 'inspections': f"{self.base_url}/EquasisWeb/restricted/ShipInspection?fs=ShipInfo&P_IMO={imo}", - 'ship_history': f"{self.base_url}/EquasisWeb/restricted/ShipHistory?fs=ShipInfo&P_IMO={imo}" - } - - vessel_data = None - - # Fetch each tab - for tab_name, url in tab_urls.items(): - logger.info(f"Fetching {tab_name} tab for IMO {imo}") - - try: - response = self._safe_get(url) - time.sleep(1) # Rate limiting - # Parse this tab's content - tab_data = self.comprehensive_parser.parse_html(response.text, tab_name) - - if tab_data: - if vessel_data is None: - # First successful tab - use as base - vessel_data = tab_data - else: - # Merge data from additional tabs - self._merge_vessel_data(vessel_data, tab_data, tab_name) - else: - logger.warning(f"Failed to parse {tab_name} tab") - - except Exception as e: - logger.error(f"Failed to fetch {tab_name} tab for IMO {imo}: {e}") - # Continue with other tabs even if one fails - - return vessel_data - - except Exception as e: - logger.error(f"Error getting comprehensive vessel info for {imo}: {e}") - return None - - def _merge_vessel_data(self, base_data: EquasisVesselData, new_data: EquasisVesselData, tab_name: str): - """Merge data from additional tabs into base vessel data""" - if tab_name == 'inspections': - base_data.inspections = new_data.inspections - elif tab_name == 'ship_history': - base_data.historical_names = new_data.historical_names - base_data.historical_flags = new_data.historical_flags - base_data.historical_companies = new_data.historical_companies - elif tab_name == 'ship_info': - # Update ship info specific data - if new_data.overview: - base_data.overview = new_data.overview - if new_data.management: - base_data.management = new_data.management - if new_data.classification: - base_data.classification = new_data.classification - if new_data.geographical: - base_data.geographical = new_data.geographical - - def search_vessel_by_imo(self, imo: str) -> Optional[EquasisVesselData]: - """Search for vessel by IMO number - returns comprehensive vessel data""" - return self.get_comprehensive_vessel_info(imo) - - def search_vessel_by_name(self, name: str) -> List[SimpleVesselInfo]: - """ - Search for vessels by name using general search endpoint - - This uses the same search mechanism as company search, - which returns both ship and company results. - """ - if not self.logged_in and not self.login(): - return [] - - try: - logger.info(f"Searching for vessels matching: {name}") - # Use general search endpoint (same as company search) - search_url = f"{self.base_url}/EquasisWeb/restricted/Search?fs=Search" - search_data = { - 'P_PAGE': '1', - 'P_PAGE_COMP': '1', - 'P_PAGE_SHIP': '1', - 'P_ENTREE_ENTETE': name, - 'P_ENTREE_ENTETE_HIDDEN': name - } - - response = self._safe_post(search_url, data=search_data) - time.sleep(1) - - vessels = self._parse_vessel_list(response.text) - - if not vessels: - logger.warning(f"No vessels found matching: {name}") - else: - logger.info(f"Found {len(vessels)} vessels matching: {name}") - - return vessels - - except Exception as e: - logger.error(f"Error searching vessel by name {name}: {e}") - return [] - - def search_companies(self, company_identifier: str) -> List[dict]: - """ - Search for companies by name, returns list of matching companies - - Returns: - List of dicts with keys: p_comp, name, address - """ - if not self.logged_in and not self.login(): - return [] - - try: - logger.info(f"Searching for company: {company_identifier}") - search_url = f"{self.base_url}/EquasisWeb/restricted/Search?fs=Search" - search_data = { - 'P_PAGE': '1', - 'P_PAGE_COMP': '1', - 'P_PAGE_SHIP': '1', - 'P_ENTREE_ENTETE': company_identifier, - 'P_ENTREE_ENTETE_HIDDEN': company_identifier - } - - response = self._safe_post(search_url, data=search_data) - time.sleep(1) - - # Parse search results to extract P_COMP IDs and company names - company_results = self._parse_company_search_results(response.text) - - if not company_results: - logger.warning(f"No companies found matching: {company_identifier}") - else: - logger.info(f"Found {len(company_results)} companies matching: {company_identifier}") + """Authenticated access to Equasis. - return company_results + The client logs in lazily on first use and transparently logs in again if + the session expires. Use it as a context manager to release the HTTP session:: - except Exception as e: - logger.error(f"Error searching for company {company_identifier}: {e}") - return [] + with EquasisClient(username, password) as client: + vessel = client.get_vessel("9811000") + """ - def get_fleet_by_company_id(self, p_comp: str, company_name: str) -> Optional[FleetInfo]: + def __init__( + self, + username: str, + password: str, + *, + transport: Transport | None = None, + cache: PageCache | None = None, + refresh: bool = False, + ): """ - Get fleet information using P_COMP ID - Args: - p_comp: Company ID from Equasis - company_name: Company name for display - - Returns: - FleetInfo object or None + 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 self.logged_in and not self.login(): - return None - - try: - logger.info(f"Fetching fleet info for {company_name} (P_COMP={p_comp})") - fleet_url = f"{self.base_url}/EquasisWeb/restricted/FleetInfo?fs=CompanyInfo" - fleet_data = { - 'P_PAGE': '1', - 'P_COMP': p_comp, - 'ongletActifSC': 'comp' - } + 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 + + def __enter__(self) -> EquasisClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + self.transport.close() - response = self._safe_post(fleet_url, data=fleet_data) - time.sleep(1) - - # Parse fleet info - return self._parse_fleet_info(response.text, company_name) + @property + def logged_in(self) -> bool: + return self._logged_in - except Exception as e: - logger.error(f"Error getting fleet info for {company_name}: {e}") - return None + def login(self) -> None: + """Log in to Equasis. - def get_fleet_info(self, company_identifier: str) -> Optional[FleetInfo]: - """ - Get fleet information for a company (convenience method - uses first match) - For interactive selection, use search_companies() then get_fleet_by_company_id() + Raises: + AuthenticationError: if Equasis rejects the credentials. + NetworkError: if Equasis cannot be reached. """ - company_results = self.search_companies(company_identifier) - - if not company_results: - return None - - # Use first match for backward compatibility - if len(company_results) > 1: - logger.info(f"Multiple companies found, using first match: {company_results[0]['name']}") - - return self.get_fleet_by_company_id( - company_results[0]['p_comp'], - company_results[0]['name'] + self._logged_in = False + self.transport.get(_LOGIN_PATH) + html = self.transport.post( + _LOGIN_PATH, + {"j_email": self._username, "j_password": self._password, "submit": "Login"}, ) - - def _parse_company_search_results(self, html: str) -> List[dict]: + if not is_logged_in(html): + raise AuthenticationError( + "Equasis rejected the login; check your username (e-mail) and password" + ) + self._logged_in = True + logger.info("logged in to Equasis") + + def _page( + self, + method: str, + path: str, + *, + 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. + + Regular content pages are served from and stored in the cache when one is + configured. """ - Parse company search results to extract P_COMP IDs and company names + 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) + if classify(html) is PageKind.LOGIN: + logger.info("Equasis session expired; logging in again") + self.login() + html = self.transport.request(method, path, params=params, data=data) + 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 - Returns list of dicts with keys: p_comp, name, address - """ - from bs4 import BeautifulSoup - import re + def _start(self) -> None: + self._fetch_times = [] - soup = BeautifulSoup(html, 'html.parser') - companies = [] + def _retrieved_at(self) -> datetime: + return min(self._fetch_times) if self._fetch_times else datetime.now(timezone.utc) - # Find the company results div - company_div = soup.find('div', id='CompanyResultId') - if not company_div: - return companies + # -------------------------------------------------------------------- vessels - # Find the company table - table = company_div.find('table') - if not table: - return companies + def get_vessel(self, imo: str) -> Vessel: + """Retrieve a full vessel profile (Ship Info, Inspections and Ship History). - tbody = table.find('tbody') - if not tbody: - return companies + If the Inspections or Ship History page cannot be parsed, the profile is + still returned and the problem is recorded in ``Vessel.warnings``. - # Parse each company row - # Structure: 0310062TORM A/SAddress... - for row in tbody.find_all('tr'): - try: - cells = row.find_all(['th', 'td']) - if len(cells) >= 2: - # First cell is with tag containing onclick handler - first_cell = cells[0] - - # Look for tag with onClick attribute - link = first_cell.find('a', attrs={'onClick': True}) - if not link: - # Try lowercase onclick - link = first_cell.find('a', attrs={'onclick': True}) - - if link: - onclick = link.get('onClick') or link.get('onclick', '') - - # Extract P_COMP value from onclick handler - match = re.search(r"P_COMP\.value\s*=\s*['\"](\d+)['\"]", onclick) - if match: - p_comp = match.group(1) - name = cells[1].get_text(strip=True) if len(cells) > 1 else '' - address = cells[2].get_text(strip=True) if len(cells) > 2 else '' - - # Skip companies with empty names or address-only entries - # (Equasis has responsive duplicate rows for mobile view) - if name and not name.startswith('C/O:') and (',' in name and len(name) > 50 or address): - companies.append({ - 'p_comp': p_comp, - 'name': name, - 'address': address - }) - except Exception as e: - logger.warning(f"Error parsing company row: {e}") - continue - - # Deduplicate by P_COMP (Equasis has duplicate rows for responsive design) - seen_comps = set() - unique_companies = [] - for company in companies: - if company['p_comp'] not in seen_comps: - seen_comps.add(company['p_comp']) - unique_companies.append(company) - - return unique_companies - - def _parse_vessel_list(self, html: str) -> List[SimpleVesselInfo]: + Raises: + InvalidInputError: if ``imo`` is not a 7-digit number. + NotFoundError: if Equasis has no vessel with this IMO number. + LayoutChangedError: if the Ship Info page cannot be parsed. """ - Parse vessel list from search results using robust element-based parsing + imo = normalize_imo(imo) + self._start() + params = {"fs": "ShipInfo", "P_IMO": imo} + try: + vessel = parse_ship_info(self._page("GET", "restricted/ShipInfo", params=params)) + except NotFoundError: + raise NotFoundError(f"no vessel with IMO {imo} in Equasis") from None - Applies same principles as fleet parsing: - - Find IMO link first (most reliable) - - Normalize whitespace - - Position-based text extraction - """ - from bs4 import BeautifulSoup - import re - - soup = BeautifulSoup(html, 'html.parser') - vessels = [] - - # Find tables - ship results are in the first table - tables = soup.find_all('table', class_='table') - if not tables: - return vessels - - # First table should be ship results - ship_table = tables[0] - tbody = ship_table.find('tbody') - if not tbody: - return vessels - - # Parse each vessel row - # Structure may vary: check if first cell contains IMO in link or just text - for row in tbody.find_all('tr'): - try: - cells = row.find_all(['th', 'td']) - if len(cells) >= 6: - # Try robust parsing (with IMO link) first - first_cell = cells[0] - imo_link = first_cell.find('a') - - if imo_link: - # Robust parsing: IMO in link, vessel name after - imo = imo_link.get_text(strip=True) - - # Validate IMO format - if not imo.isdigit() or len(imo) != 7: - # Fallback to simple text extraction - imo = first_cell.get_text(strip=True) - - # Normalize whitespace and extract vessel name - full_text = ' '.join(first_cell.get_text().split()) - if imo in full_text: - imo_pos = full_text.find(imo) - after_imo = full_text[imo_pos + len(imo):].strip() - name = after_imo.lstrip(')').lstrip('(').strip() - else: - name = cells[1].get_text(strip=True) if len(cells) > 1 else '' - - # If name is empty, it's in next cell - if not name and len(cells) > 1: - name = cells[1].get_text(strip=True) - else: - # Simple parsing: no link structure - imo = cells[0].get_text(strip=True) - name = cells[1].get_text(strip=True) - - # Extract remaining fields with whitespace normalization - vessel = SimpleVesselInfo( - imo=imo, - name=name, - grt=' '.join(cells[2].get_text().split()), - vessel_type=' '.join(cells[3].get_text().split()), - year_built=' '.join(cells[4].get_text().split()), - flag=' '.join(cells[5].get_text().split()), - dwt=None, - status=None - ) - vessels.append(vessel) - except Exception as e: - logger.warning(f"Error parsing vessel row: {e}") - continue - - # Deduplicate by IMO (handle responsive design duplicates) - seen_imos = set() - unique_vessels = [] - for vessel in vessels: - if vessel.imo and vessel.imo not in seen_imos: - seen_imos.add(vessel.imo) - unique_vessels.append(vessel) - - return unique_vessels - - def _parse_fleet_info(self, html: str, company: str) -> Optional[FleetInfo]: - """Parse fleet information from Fleet Info page""" - from bs4 import BeautifulSoup - import re - - soup = BeautifulSoup(html, 'html.parser') - - # Find tables - tables = soup.find_all('table', class_='table') - if not tables: - logger.warning(f"No tables found in fleet info for {company}") - return None - - # Parse the fleet table (should be first table) - fleet_table = tables[0] - tbody = fleet_table.find('tbody') - if not tbody: - logger.warning(f"No tbody found in fleet table for {company}") - return None - - vessels = [] - - # Parse each vessel row - # Fleet table structure: "(IMO)\nVessel Name", GRT, Type, Year, Flag, Class, Detentions..., Acting as, Details - for row in tbody.find_all('tr'): - try: - cells = row.find_all(['th', 'td']) - if len(cells) >= 6: - # Strategy: Find the IMO link in the first cell, then extract vessel name from remaining text - first_cell = cells[0] - - # Find the tag with IMO - it contains the IMO number - imo_link = first_cell.find('a') - if not imo_link: - continue # Skip rows without IMO link - - # Extract IMO from the link text (most reliable) - imo = imo_link.get_text(strip=True) - - # Validate it's a 7-digit IMO - if not imo.isdigit() or len(imo) != 7: - continue - - # Get all text from the cell and normalize whitespace - full_text = ' '.join(first_cell.get_text().split()) - - # Remove the IMO and parentheses to get vessel name - # The pattern is usually: "( IMO ) Vessel Name" - # After normalization: "(IMO) Vessel Name" or "( IMO ) Vessel Name" - # Strategy: Split by IMO, take everything after it, remove closing paren - if imo in full_text: - # Find position of IMO and take everything after - imo_pos = full_text.find(imo) - after_imo = full_text[imo_pos + len(imo):].strip() - # Remove leading/trailing parentheses and whitespace - name_text = after_imo.lstrip(')').strip() - else: - name_text = '' - - vessel = SimpleVesselInfo( - imo=imo, - name=name_text, - grt=cells[1].get_text(strip=True), - vessel_type=cells[2].get_text(strip=True), - year_built=cells[3].get_text(strip=True), - flag=cells[4].get_text(strip=True), - dwt=None, - status=None - ) - vessels.append(vessel) - except Exception as e: - logger.warning(f"Error parsing fleet vessel row: {e}") - continue - - if not vessels: - logger.warning(f"No vessels found in fleet for {company}") - return None - - return FleetInfo( - company_name=company, - vessels=vessels, - total_vessels=len(vessels) - ) + inspections = None + try: + inspections = parse_inspections( + self._page("GET", "restricted/ShipInspection", params=params) + ) + except LayoutChangedError as exc: + vessel.warnings.append(f"inspections unavailable: {exc}") + logger.warning("could not parse inspections for IMO %s: %s", imo, exc) - def search_vessels_by_imo_batch(self, imos: List[str], - progress_callback=None, - stop_on_error: bool = False) -> List[BatchResult]: - """ - Process multiple IMO numbers with progress tracking + history = None + try: + history = parse_history(self._page("GET", "restricted/ShipHistory", params=params)) + except LayoutChangedError as exc: + vessel.warnings.append(f"ship history unavailable: {exc}") + logger.warning("could not parse ship history for IMO %s: %s", imo, exc) + + vessel = merge_vessel(vessel, inspections, history) + vessel.retrieved_at = self._retrieved_at() + return vessel + + # --------------------------------------------------------------------- search + + def search( + self, + query: str, + *, + max_pages: int | None = DEFAULT_SEARCH_PAGES, + ships: bool = True, + companies: bool = True, + ) -> SearchResults: + """Search ships and companies by name (partial matches). Args: - imos: List of IMO numbers to process - progress_callback: Optional callback for progress updates (current, total, imo, status) - stop_on_error: Whether to stop processing on first error - - Returns: - List of BatchResult objects + query: Text to search for. + max_pages: Maximum result pages to fetch per result type (100 results + per page); ``None`` fetches every page. + ships: Include ship results (and fetch their additional pages). + companies: Include company results (and fetch their additional pages). """ - import time - from datetime import datetime - - results = [] - start_time = time.time() - - for idx, imo in enumerate(imos, 1): - vessel_start = time.time() - - # Call progress callback if provided - if progress_callback: - progress_callback(idx, len(imos), imo, "processing") + query = query.strip() + if not query: + raise EquasisError("search text must not be empty") + self._start() + first = parse_search( + self._page( + "POST", + _SEARCH_PATH, + data={ + "P_PAGE": "1", + "P_PAGE_COMP": "1", + "P_PAGE_SHIP": "1", + "P_ENTREE_ENTETE": query, + "P_ENTREE_ENTETE_HIDDEN": query, + }, + ) + ) + results = SearchResults( + query=query, + ships=list(first.ships) if ships else [], + companies=list(first.companies) if companies else [], + total_ships=first.total_ships if ships else None, + ) - try: - # Use existing method to get vessel data - vessel_data = self.search_vessel_by_imo(imo) - - if vessel_data: - result = BatchResult( - imo=imo, - success=True, - vessel_data=vessel_data, - processing_time=time.time() - vessel_start - ) - if progress_callback: - progress_callback(idx, len(imos), imo, "success") - else: - result = BatchResult( - imo=imo, - success=False, - error_message="Vessel not found", - processing_time=time.time() - vessel_start - ) - if progress_callback: - progress_callback(idx, len(imos), imo, "not_found") - - except Exception as e: - result = BatchResult( - imo=imo, - success=False, - error_message=str(e), - processing_time=time.time() - vessel_start - ) - if progress_callback: - progress_callback(idx, len(imos), imo, "error") - - if stop_on_error: - logger.error(f"Stopping batch processing due to error: {e}") - break - - results.append(result) - - # Rate limiting - already handled in search_vessel_by_imo, but add extra safety - if idx < len(imos): # Don't delay after last vessel - time.sleep(0.1) # Small additional delay between vessels + def follow_up(tab: str, page_number: int) -> dict[str, str]: + return { + "P_PAGE": "1", + "P_PAGE_COMP": str(page_number) if tab == "comp" else "1", + "P_PAGE_SHIP": str(page_number) if tab == "ship" else "1", + "ongletActifSC": tab, + "P_ENTREE_HOME_HIDDEN": query, + "P_ENTREE": query, + "checkbox-shipSearch": "Ship", + "checkbox-companySearch": "Company", + } + ship_pages = _page_limit(first.last_ship_page, max_pages) if ships else 1 + for number in range(2, ship_pages + 1): + page = parse_search(self._page("POST", _SEARCH_PATH, data=follow_up("ship", number))) + new = [s for s in page.ships if s.imo not in {x.imo for x in results.ships}] + if not new: + break + results.ships.extend(new) + + company_pages = _page_limit(first.last_company_page, max_pages) if companies else 1 + for number in range(2, company_pages + 1): + page = parse_search(self._page("POST", _SEARCH_PATH, data=follow_up("comp", number))) + known = {c.company_id for c in results.companies} + new_companies = [c for c in page.companies if c.company_id not in known] + if not new_companies: + break + results.companies.extend(new_companies) + + results.retrieved_at = self._retrieved_at() return results - def search_companies_batch(self, companies: List[str], - progress_callback=None, - stop_on_error: bool = False) -> List[CompanyBatchResult]: - """ - Process multiple company names with progress tracking + def search_ships( + self, + *, + imo: str | None = None, + mmsi: str | None = None, + call_sign: str | None = None, + ) -> SearchResults: + """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", + "P_PAGE_SHIP": "1", + "ongletActifSC": "ship", + "P_ENTREE_HOME_HIDDEN": "", + "P_IMO": normalize_imo(imo) if imo else "", + "P_CALLSIGN": call_sign.strip().upper() if call_sign else "", + "P_NAME": "", + "P_MMSI": normalize_mmsi(mmsi) if mmsi else "", + "buttonAdvancedSearch": "advancedOk", + } + page = parse_search(self._page("POST", _SEARCH_PATH, data=data)) + query = " ".join( + f"{label}={value}" + 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), + retrieved_at=self._retrieved_at(), + ) - Args: - companies: List of company names to process - progress_callback: Optional callback for progress updates (current, total, company, status) - stop_on_error: Whether to stop processing on first error + # ---------------------------------------------------------------------- fleet + + def get_fleet(self, company_id: str, *, max_pages: int | None = None) -> Fleet: + """Retrieve every vessel associated with an Equasis company number. - Returns: - List of CompanyBatchResult objects + Raises: + InvalidInputError: if ``company_id`` is not a 7-digit number. + NotFoundError: if Equasis has no company with this number. """ - import time - from datetime import datetime + company_id = normalize_company_id(company_id) + self._start() - results = [] - start_time = time.time() + def form(page_number: int) -> dict[str, str]: + return {"P_PAGE": str(page_number), "P_COMP": company_id, "ongletActifSC": "comp"} - for idx, company in enumerate(companies, 1): - company_start = time.time() + def fetch(page_number: int, *, use_cache: bool = True) -> str: + return self._page("POST", _FLEET_PATH, data=form(page_number), use_cache=use_cache) - # Call progress callback if provided - if progress_callback: - progress_callback(idx, len(companies), company, "processing") + html = fetch(1) + if classify(html) in (PageKind.NOT_FOUND, PageKind.ERROR): + raise NotFoundError(f"no company with number {company_id} in Equasis") + first = parse_fleet(html) + fleet = Fleet( + company=first.company, vessels=list(first.vessels), total_vessels=first.total_vessels + ) + 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): + if not session_ready and not self._is_cached("POST", _FLEET_PATH, form(number)): + fetch(1, use_cache=False) + session_ready = True try: - # Use existing method to get fleet data - fleet_data = self.get_fleet_info(company) - - if fleet_data: - result = CompanyBatchResult( - company_name=company, - success=True, - fleet_data=fleet_data, - processing_time=time.time() - company_start - ) - if progress_callback: - progress_callback(idx, len(companies), company, "success") - else: - result = CompanyBatchResult( - company_name=company, - success=False, - error_message="Company not found or no fleet data available", - processing_time=time.time() - company_start - ) - if progress_callback: - progress_callback(idx, len(companies), company, "not_found") - - except Exception as e: - result = CompanyBatchResult( - company_name=company, - success=False, - error_message=str(e), - processing_time=time.time() - company_start - ) - if progress_callback: - progress_callback(idx, len(companies), company, "error") - - if stop_on_error: - logger.error(f"Stopping company batch processing due to error: {e}") - break - - results.append(result) - - # Rate limiting - already handled in get_fleet_info, but add extra safety - if idx < len(companies): # Don't delay after last company - time.sleep(0.1) # Small additional delay between companies - - return results + 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 + + +def _page_limit(last_page: int, max_pages: int | None) -> int: + return last_page if max_pages is None else max(1, min(last_page, max_pages)) diff --git a/src/equasis_cli/console.py b/src/equasis_cli/console.py new file mode 100644 index 0000000..142e45e --- /dev/null +++ b/src/equasis_cli/console.py @@ -0,0 +1,67 @@ +"""Status messages for people, written to stderr so stdout stays machine-readable.""" + +from __future__ import annotations + +import os +import sys +from typing import IO, ClassVar + + +class Console: + """Writes progress, warnings, and errors to a text stream (stderr by default). + + Color is used only when the stream is a terminal and neither ``NO_COLOR`` + (https://no-color.org) nor ``--no-color`` is set. + """ + + _CODES: ClassVar[dict[str, str]] = { + "red": "31", + "green": "32", + "yellow": "33", + "dim": "2", + "bold": "1", + } + + def __init__( + self, stream: IO[str] | None = None, *, quiet: bool = False, color: bool | None = None + ) -> None: + self.stream = stream or sys.stderr + self.quiet = quiet + if color is None: + color = self.is_terminal and not os.environ.get("NO_COLOR") + self.color = color + + @property + def is_terminal(self) -> bool: + isatty = getattr(self.stream, "isatty", None) + return bool(isatty and isatty()) + + def style(self, text: str, *styles: str) -> str: + if not self.color or not styles: + return text + codes = ";".join(self._CODES[s] for s in styles) + return f"\033[{codes}m{text}\033[0m" + + def _write(self, text: str) -> None: + self.stream.write(text + "\n") + self.stream.flush() + + def info(self, message: str) -> None: + if not self.quiet: + self._write(message) + + def progress(self, message: str) -> None: + if not self.quiet: + self._write(self.style(message, "dim")) + + def success(self, message: str) -> None: + if not self.quiet: + self._write(self.style(message, "green")) + + def warn(self, message: str) -> None: + self._write(self.style(f"warning: {message}", "yellow")) + + def error(self, message: str, hint: str | None = None) -> None: + self._write(self.style(f"error: {message}", "red", "bold")) + if hint: + self._write(f"hint: {hint}") diff --git a/src/equasis_cli/credentials.py b/src/equasis_cli/credentials.py index 7576d45..efbe057 100644 --- a/src/equasis_cli/credentials.py +++ b/src/equasis_cli/credentials.py @@ -1,236 +1,156 @@ -#!/usr/bin/env python3 -""" -Credential management following industry best practices -Supports XDG Base Directory Specification and standard credential hierarchy +"""Locate, store, and remove Equasis credentials. + +Credentials are resolved in this order: + +1. ``--username`` and ``--password`` command-line options +2. ``EQUASIS_USERNAME`` and ``EQUASIS_PASSWORD`` environment variables +3. The credentials file written by ``equasis configure --setup`` + +The credentials file lives in the user's configuration directory +(``$XDG_CONFIG_HOME/equasis-cli`` or ``~/.config/equasis-cli`` on Linux and macOS, +``%APPDATA%\\equasis-cli`` on Windows) and is created with owner-only permissions. """ -import os +from __future__ import annotations + +import enum import json -import getpass -from pathlib import Path -from typing import Optional, Tuple, Dict, Any import logging +import os +import stat +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any logger = logging.getLogger(__name__) +APP_NAME = "equasis-cli" +FILE_NAME = "credentials.json" +ENV_USERNAME = "EQUASIS_USERNAME" +ENV_PASSWORD = "EQUASIS_PASSWORD" # noqa: S105 - environment variable name, not a secret -class CredentialManager: - """Manages Equasis credentials following industry best practices""" - - def __init__(self): - self.app_name = "equasis-cli" - self.config_dir = self._get_config_directory() - self.credentials_file = self.config_dir / "credentials.json" - - def _get_config_directory(self) -> Path: - """Get configuration directory following XDG Base Directory Specification""" - # Follow XDG Base Directory Specification - xdg_config_home = os.environ.get('XDG_CONFIG_HOME') - - if xdg_config_home: - config_dir = Path(xdg_config_home) / self.app_name - else: - # Default to ~/.config/equasis-cli on Unix systems - home = Path.home() - if os.name == 'posix': # Unix-like systems - config_dir = home / '.config' / self.app_name - else: # Windows - config_dir = home / '.equasis-cli' - - return config_dir - - def get_credentials(self, username_arg: Optional[str] = None, - password_arg: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: - """ - Get credentials following industry-standard hierarchy: - 1. Command line arguments (highest priority) - 2. Environment variables - 3. Configuration file (lowest priority) - - Returns: - Tuple of (username, password) or (None, None) if not found - """ - username = None - password = None - - # 1. Command line arguments (highest priority) - if username_arg and password_arg: - logger.debug("Using credentials from command line arguments") - return username_arg, password_arg - - # 2. Environment variables - env_username = os.environ.get('EQUASIS_USERNAME') - env_password = os.environ.get('EQUASIS_PASSWORD') - if env_username and env_password: - logger.debug("Using credentials from environment variables") - return env_username, env_password +class CredentialSource(str, enum.Enum): + ARGUMENTS = "command-line options" + ENVIRONMENT = "environment variables" + CONFIG_FILE = "credentials file" - # 3. Configuration file - config_username, config_password = self._load_from_config() - logger.debug(f"Config file check: username={'set' if config_username else 'not set'}, password={'set' if config_password else 'not set'}") - if config_username and config_password: - logger.debug(f"Using credentials from config file: {self.credentials_file}") - return config_username, config_password - # Nothing found - logger.debug("No credentials found in any source") - return None, None +@dataclass(frozen=True) +class Credentials: + username: str + password: str + source: CredentialSource - def _load_from_config(self) -> Tuple[Optional[str], Optional[str]]: - """Load credentials from configuration file""" - if not self.credentials_file.exists(): - return None, None + def __repr__(self) -> str: # never expose the password in logs or tracebacks + return f"Credentials(username={self.username!r}, source={self.source.value!r})" - try: - with open(self.credentials_file, 'r') as f: - config = json.load(f) - username = config.get('username') - password = config.get('password') +def default_config_dir() -> Path: + """Return the platform-appropriate configuration directory.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + return Path(xdg) / APP_NAME + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if appdata: + return Path(appdata) / APP_NAME + return Path.home() / ".config" / APP_NAME - return username, password - except (json.JSONDecodeError, IOError) as e: - logger.warning(f"Failed to load credentials from {self.credentials_file}: {e}") - return None, None +class CredentialStore: + """Reads and writes the credentials file and resolves credential sources.""" - def save_credentials(self, username: str, password: str) -> bool: - """ - Save credentials to configuration file + def __init__(self, config_dir: Path | None = None) -> None: + self.config_dir = config_dir or default_config_dir() - Args: - username: Equasis username - password: Equasis password + @property + def path(self) -> Path: + return self.config_dir / FILE_NAME - Returns: - True if successful, False otherwise - """ + def resolve( + self, username: str | None = None, password: str | None = None + ) -> Credentials | None: + """Return the highest-priority complete set of credentials, if any.""" + if username and password: + return Credentials(username, password, CredentialSource.ARGUMENTS) + env_username = os.environ.get(ENV_USERNAME) + env_password = os.environ.get(ENV_PASSWORD) + if env_username and env_password: + return Credentials(env_username, env_password, CredentialSource.ENVIRONMENT) + stored = self.load() + if stored: + return Credentials(stored[0], stored[1], CredentialSource.CONFIG_FILE) + return None + + def load(self) -> tuple[str, str] | None: + """Read the credentials file; ``None`` if it is missing or unreadable.""" + path = self.path + if not path.is_file(): + return None + self._warn_if_exposed(path) try: - # Create config directory if it doesn't exist - self.config_dir.mkdir(parents=True, exist_ok=True) - - # Prepare credentials data - credentials = { - 'username': username, - 'password': password, - 'created_by': 'equasis-cli', - 'note': 'Equasis credentials for maritime intelligence tool' - } - - # Write credentials file - with open(self.credentials_file, 'w') as f: - json.dump(credentials, f, indent=2) - - # Set secure permissions (owner read/write only) - if os.name == 'posix': - self.credentials_file.chmod(0o600) - - logger.info(f"Credentials saved to {self.credentials_file}") - return True - - except (IOError, OSError) as e: - logger.error(f"Failed to save credentials: {e}") - return False + data: Any = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("could not read %s: %s", path, exc) + return None + if not isinstance(data, dict): + logger.warning("ignoring %s: unexpected format", path) + return None + username, password = data.get("username"), data.get("password") + if isinstance(username, str) and isinstance(password, str) and username and password: + return username, password + return None - def clear_credentials(self) -> bool: - """Remove stored credentials""" + def save(self, username: str, password: str) -> Path: + """Write credentials atomically with owner-only permissions.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + payload = json.dumps({"username": username, "password": password}, indent=2) + "\n" + fd, temp_name = tempfile.mkstemp(prefix=".credentials.", dir=self.config_dir) try: - if self.credentials_file.exists(): - self.credentials_file.unlink() - logger.info("Stored credentials cleared") - return True - else: - logger.info("No stored credentials found") - return True - - except OSError as e: - logger.error(f"Failed to clear credentials: {e}") + if os.name == "posix": + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + os.replace(temp_name, self.path) + except BaseException: + Path(temp_name).unlink(missing_ok=True) + raise + return self.path + + def clear(self) -> bool: + """Delete the credentials file. Returns ``True`` if a file was removed.""" + try: + self.path.unlink() + except FileNotFoundError: return False - - def has_stored_credentials(self) -> bool: - """Check if credentials are stored in config file""" - username, password = self._load_from_config() - return bool(username and password) - - def get_credential_sources(self) -> Dict[str, Any]: - """Get information about available credential sources for debugging""" - sources = { - 'environment_variables': { - 'username': bool(os.environ.get('EQUASIS_USERNAME')), - 'password': bool(os.environ.get('EQUASIS_PASSWORD')) + return True + + def describe(self) -> dict[str, Any]: + """Report which credential sources are configured (never the values).""" + stored = self.load() + return { + "environment": { + ENV_USERNAME: bool(os.environ.get(ENV_USERNAME)), + ENV_PASSWORD: bool(os.environ.get(ENV_PASSWORD)), }, - 'config_file': { - 'path': str(self.credentials_file), - 'exists': self.credentials_file.exists(), - 'has_credentials': self.has_stored_credentials() + "file": { + "path": str(self.path), + "exists": self.path.is_file(), + "complete": stored is not None, + "username": stored[0] if stored else None, }, - 'config_directory': { - 'path': str(self.config_dir), - 'exists': self.config_dir.exists() - } } - return sources - - def interactive_setup(self) -> bool: - """ - Interactively collect and save credentials - - Returns: - True if credentials were saved successfully - """ - print() - print("=== Equasis CLI Credential Setup ===") - print() - print("This will securely store your Equasis credentials for future use.") - print(f"Credentials will be saved to: {self.credentials_file}") - print() - + @staticmethod + def _warn_if_exposed(path: Path) -> None: + if os.name != "posix": + return try: - username = input("Equasis Username: ").strip() - if not username: - print("Username cannot be empty") - return False - - password = getpass.getpass("Equasis Password: ").strip() - if not password: - print("Password cannot be empty") - return False - - # Confirm before saving - print() - confirm = input(f"Save credentials to {self.credentials_file}? [y/N]: ").strip().lower() - if confirm not in ['y', 'yes']: - print("Setup cancelled") - return False - - # Save credentials - success = self.save_credentials(username, password) - - if success: - print() - print("✓ Credentials saved successfully!") - print(" You can now use equasis commands without --username/--password flags") - print() - print("Security notes:") - print(f" • File permissions set to owner-only (600)") - print(f" • To remove credentials later: equasis configure --clear") - print() - return True - else: - print("✗ Failed to save credentials") - return False - - except KeyboardInterrupt: - print() - print("Setup cancelled by user") - return False - except Exception as e: - print(f"Error during setup: {e}") - return False - - -def get_credential_manager() -> CredentialManager: - """Get a credential manager instance""" - return CredentialManager() + mode = path.stat().st_mode + except OSError: + return + if mode & (stat.S_IRWXG | stat.S_IRWXO): + logger.warning("%s is readable by other users; run: chmod 600 %s", path, path) 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/exceptions.py b/src/equasis_cli/exceptions.py new file mode 100644 index 0000000..3bb4e28 --- /dev/null +++ b/src/equasis_cli/exceptions.py @@ -0,0 +1,51 @@ +"""Exception hierarchy for equasis-cli. + +Every error raised deliberately by the package derives from :class:`EquasisError` +so callers can handle failures without catching unrelated exceptions. +""" + +from __future__ import annotations + + +class EquasisError(Exception): + """Base class for all equasis-cli errors.""" + + +class ConfigurationError(EquasisError): + """Credentials or settings are missing or invalid.""" + + +class InvalidInputError(EquasisError, ValueError): + """A user-supplied value (for example an IMO number) is malformed.""" + + +class AuthenticationError(EquasisError): + """Equasis rejected the supplied credentials.""" + + +class SessionExpiredError(EquasisError): + """The Equasis session expired and could not be re-established.""" + + +class NotFoundError(EquasisError): + """The requested vessel or company does not exist in Equasis.""" + + +class LayoutChangedError(EquasisError): + """An Equasis page no longer matches the structure the parser expects. + + This usually means Equasis changed its website and equasis-cli needs an + update. Re-running with ``--save-html DIR`` captures the page for a bug report. + """ + + +class NetworkError(EquasisError): + """Equasis could not be reached or returned an unexpected HTTP error.""" + + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +class RateLimitedError(NetworkError): + """Equasis kept responding with HTTP 429 after all retries.""" diff --git a/src/equasis_cli/formatter.py b/src/equasis_cli/formatter.py deleted file mode 100644 index 4427149..0000000 --- a/src/equasis_cli/formatter.py +++ /dev/null @@ -1,434 +0,0 @@ -#!/usr/bin/env python3 -""" -Output Formatter - Handles different output formats for vessel, fleet, and search data -""" - -import json -from typing import List -from dataclasses import asdict -from datetime import datetime - -from .parser import EquasisVesselData -from .client import SimpleVesselInfo, FleetInfo, BatchResult, BatchSummary, CompanyBatchResult, CompanyBatchSummary - - -class OutputFormatter: - """Handle different output formats""" - - @staticmethod - def format_vessel_info(vessel_data: EquasisVesselData, output_format: str = 'table', detail_level: str = 'summary'): - """Format comprehensive vessel information""" - if output_format == 'json': - return json.dumps({ - 'basic_info': asdict(vessel_data.basic_info), - 'overview': vessel_data.overview, - 'management': [asdict(c) for c in vessel_data.management], - 'classification': [asdict(c) for c in vessel_data.classification], - 'geographical': [asdict(g) for g in vessel_data.geographical], - 'inspections': [asdict(i) for i in vessel_data.inspections], - 'historical_names': [asdict(h) for h in vessel_data.historical_names], - 'historical_flags': [asdict(h) for h in vessel_data.historical_flags], - 'historical_companies': [asdict(h) for h in vessel_data.historical_companies] - }, indent=2) - elif output_format == 'csv': - # Basic CSV format for compatibility - basic = vessel_data.basic_info - return f"{basic.imo},{basic.name},{basic.flag},{basic.vessel_type or ''},{basic.dwt or ''},{basic.gross_tonnage or ''},{basic.year_built or ''}" - else: # table format - return OutputFormatter._format_comprehensive_table(vessel_data, detail_level) - - @staticmethod - def _format_comprehensive_table(vessel_data: EquasisVesselData, detail_level: str = 'summary'): - """Format comprehensive vessel data as a table""" - basic = vessel_data.basic_info - output = f""" -Vessel Information (Comprehensive): -=================================== -IMO Number: {basic.imo} -Name: {basic.name} -Flag: {basic.flag} ({basic.flag_code or 'N/A'}) -Call Sign: {basic.call_sign or 'N/A'} -MMSI: {basic.mmsi or 'N/A'} -Type: {basic.vessel_type or 'N/A'} -Gross Tonnage: {basic.gross_tonnage or 'N/A'} -DWT: {basic.dwt or 'N/A'} -Year Built: {basic.year_built or 'N/A'} -Status: {basic.status or 'N/A'} -Last Update: {basic.last_update or 'N/A'} -""" - - # Add overview section - if vessel_data.overview: - output += "\nOverview:\n" + "-" * 9 + "\n" - for key, value in vessel_data.overview.items(): - formatted_key = key.replace('_', ' ').title() - output += f"{formatted_key}: {value}\n" - - # Add management companies - if vessel_data.management: - output += f"\nManagement Companies ({len(vessel_data.management)}):\n" + "-" * 30 + "\n" - for company in vessel_data.management: - output += f"• {company.name} ({company.role})\n" - if company.date_effect and detail_level == 'detailed': - output += f" Since: {company.date_effect}\n" - - # Add classification - if vessel_data.classification: - output += f"\nClassification ({len(vessel_data.classification)}):\n" + "-" * 16 + "\n" - for cls in vessel_data.classification: - output += f"• {cls.society} - {cls.status}\n" - if cls.date_effect and detail_level == 'detailed': - output += f" {cls.date_effect}\n" - - # Add recent inspections - if vessel_data.inspections: - recent_count = min(3, len(vessel_data.inspections)) if detail_level == 'summary' else len(vessel_data.inspections) - output += f"\nRecent PSC Inspections ({recent_count} of {len(vessel_data.inspections)}):\n" + "-" * 35 + "\n" - for inspection in vessel_data.inspections[:recent_count]: - output += f"• {inspection.date}: {inspection.psc_organization}\n" - if inspection.detention != 'No detention': - output += f" ⚠️ Detention: {inspection.detention}\n" - - # Add historical data summary - if vessel_data.historical_names and detail_level == 'summary': - output += f"\nHistorical Names ({len(vessel_data.historical_names)} total):\n" + "-" * 22 + "\n" - for name in vessel_data.historical_names[:3]: - output += f"• {name.name} ({name.date_effect})\n" - if len(vessel_data.historical_names) > 3: - output += f" ... and {len(vessel_data.historical_names) - 3} more\n" - - # Detailed historical data - elif vessel_data.historical_names and detail_level == 'detailed': - output += f"\nHistorical Names ({len(vessel_data.historical_names)}):\n" + "-" * 18 + "\n" - for name in vessel_data.historical_names: - output += f"• {name.name} (since {name.date_effect})\n" - - if vessel_data.historical_flags and detail_level == 'detailed': - output += f"\nHistorical Flags ({len(vessel_data.historical_flags)}):\n" + "-" * 18 + "\n" - for flag in vessel_data.historical_flags: - output += f"• {flag.flag} (since {flag.date_effect})\n" - - return output - - @staticmethod - def format_simple_vessel_list(vessels: List[SimpleVesselInfo], output_format: str = 'table'): - """Format simple vessel list for search results""" - if output_format == 'json': - return json.dumps([vessel.__dict__ for vessel in vessels], indent=2) - elif output_format == 'csv': - lines = ['IMO,Name,Flag,Type,DWT,GRT,YearBuilt,Status'] - for vessel in vessels: - lines.append(f"{vessel.imo},{vessel.name},{vessel.flag},{vessel.vessel_type},{vessel.dwt or ''},{vessel.grt or ''},{vessel.year_built or ''},{vessel.status or ''}") - return '\n'.join(lines) - else: # table format - output = f"\nSearch Results ({len(vessels)} vessels found):\n" + "=" * 40 + "\n" - for vessel in vessels: - output += f"IMO: {vessel.imo} | Name: {vessel.name} | Flag: {vessel.flag} | Type: {vessel.vessel_type}\n" - return output - - @staticmethod - def format_fleet_info(fleet: FleetInfo, output_format: str = 'table'): - """Format fleet information for output""" - if output_format == 'json': - return json.dumps({ - 'company': fleet.company_name, - 'total_vessels': fleet.total_vessels, - 'vessels': [vessel.__dict__ for vessel in fleet.vessels] - }, indent=2) - elif output_format == 'csv': - lines = ['IMO,Name,Flag,Type,DWT,GRT,YearBuilt,Status'] - for vessel in fleet.vessels: - lines.append(f"{vessel.imo},{vessel.name},{vessel.flag},{vessel.vessel_type},{vessel.dwt or ''},{vessel.grt or ''},{vessel.year_built or ''},{vessel.status or ''}") - return '\n'.join(lines) - else: # table format - output = f"\nFleet Information: {fleet.company_name}\n" - output += f"Total Vessels: {fleet.total_vessels}\n" - output += "=" * 50 + "\n" - for vessel in fleet.vessels: - output += f"IMO: {vessel.imo} | Name: {vessel.name} | Flag: {vessel.flag} | Type: {vessel.vessel_type}\n" - return output - - @staticmethod - def format_batch_vessel_info(results: List[BatchResult], output_format: str = 'table') -> str: - """Format batch vessel processing results""" - # Calculate summary statistics - total = len(results) - successful = sum(1 for r in results if r.success) - failed = total - successful - total_time = sum(r.processing_time for r in results) - failed_imos = [r.imo for r in results if not r.success] - - summary = BatchSummary( - total_vessels=total, - successful=successful, - failed=failed, - processing_time=total_time, - failed_imos=failed_imos, - timestamp=datetime.now().isoformat() - ) - - if output_format == 'json': - return OutputFormatter._format_batch_json(results, summary) - elif output_format == 'csv': - return OutputFormatter._format_batch_csv(results) - else: # table format - return OutputFormatter._format_batch_table(results, summary) - - @staticmethod - def _format_batch_json(results: List[BatchResult], summary: BatchSummary) -> str: - """Format batch results as JSON""" - output_data = { - "batch_summary": { - "total_vessels": summary.total_vessels, - "successful": summary.successful, - "failed": summary.failed, - "processing_time": round(summary.processing_time, 2), - "timestamp": summary.timestamp, - "failed_imos": summary.failed_imos - }, - "results": [] - } - - for result in results: - if result.success and result.vessel_data: - result_entry = { - "imo": result.imo, - "success": True, - "processing_time": round(result.processing_time, 2), - "vessel_data": { - "basic_info": asdict(result.vessel_data.basic_info), - "overview": result.vessel_data.overview, - "management": [asdict(c) for c in result.vessel_data.management], - "classification": [asdict(c) for c in result.vessel_data.classification], - "geographical": [asdict(g) for g in result.vessel_data.geographical], - "inspections": [asdict(i) for i in result.vessel_data.inspections], - "historical_names": [asdict(h) for h in result.vessel_data.historical_names], - "historical_flags": [asdict(h) for h in result.vessel_data.historical_flags], - "historical_companies": [asdict(h) for h in result.vessel_data.historical_companies] - } - } - else: - result_entry = { - "imo": result.imo, - "success": False, - "processing_time": round(result.processing_time, 2), - "error": result.error_message or "Unknown error" - } - - output_data["results"].append(result_entry) - - return json.dumps(output_data, indent=2) - - @staticmethod - def _format_batch_csv(results: List[BatchResult]) -> str: - """Format batch results as CSV""" - lines = ["IMO,Name,Flag,Type,GT,DWT,Year,Status,Error"] - - for result in results: - if result.success and result.vessel_data: - basic = result.vessel_data.basic_info - lines.append( - f"{basic.imo}," - f"{basic.name}," - f"{basic.flag}," - f"{basic.vessel_type or ''}," - f"{basic.gross_tonnage or ''}," - f"{basic.dwt or ''}," - f"{basic.year_built or ''}," - f"{basic.status or ''}," - ) - else: - lines.append(f"{result.imo},,,,,,,,\"{result.error_message or 'Unknown error'}\"") - - return '\n'.join(lines) - - @staticmethod - def _format_batch_table(results: List[BatchResult], summary: BatchSummary) -> str: - """Format batch results as a table""" - output = [] - output.append("\n" + "=" * 70) - output.append("BATCH PROCESSING RESULTS") - output.append("=" * 70) - output.append(f"Total Vessels: {summary.total_vessels}") - output.append(f"Successful: {summary.successful}") - output.append(f"Failed: {summary.failed}") - output.append(f"Processing Time: {summary.processing_time:.1f}s") - output.append(f"Timestamp: {summary.timestamp}") - output.append("") - - # Successful vessels - successful_results = [r for r in results if r.success and r.vessel_data] - if successful_results: - output.append("Successfully Retrieved:") - output.append("-" * 70) - output.append(f"{'IMO':<10} | {'Name':<25} | {'Flag':<15} | {'Type':<15}") - output.append("-" * 70) - - for result in successful_results: - basic = result.vessel_data.basic_info - output.append( - f"{basic.imo:<10} | " - f"{basic.name[:25]:<25} | " - f"{(basic.flag or 'N/A')[:15]:<15} | " - f"{(basic.vessel_type or 'N/A')[:15]:<15}" - ) - - # Failed lookups - failed_results = [r for r in results if not r.success] - if failed_results: - output.append("") - output.append("Failed Lookups:") - output.append("-" * 70) - output.append(f"{'IMO':<10} | {'Error':<58}") - output.append("-" * 70) - - for result in failed_results: - error_msg = (result.error_message or "Unknown error")[:58] - output.append(f"{result.imo:<10} | {error_msg}") - - output.append("=" * 70) - return '\n'.join(output) - - @staticmethod - def format_batch_fleet_info(results: List[CompanyBatchResult], output_format: str = 'table') -> str: - """Format batch company processing results""" - # Calculate summary statistics - total = len(results) - successful = sum(1 for r in results if r.success) - failed = total - successful - total_time = sum(r.processing_time for r in results) - total_vessels = sum(r.fleet_data.total_vessels for r in results if r.success and r.fleet_data) - failed_companies = [r.company_name for r in results if not r.success] - - summary = CompanyBatchSummary( - total_companies=total, - successful=successful, - failed=failed, - total_vessels=total_vessels, - processing_time=total_time, - failed_companies=failed_companies, - timestamp=datetime.now().isoformat() - ) - - if output_format == 'json': - return OutputFormatter._format_batch_fleet_json(results, summary) - elif output_format == 'csv': - return OutputFormatter._format_batch_fleet_csv(results) - else: # table format - return OutputFormatter._format_batch_fleet_table(results, summary) - - @staticmethod - def _format_batch_fleet_json(results: List[CompanyBatchResult], summary: CompanyBatchSummary) -> str: - """Format batch fleet results as JSON""" - output_data = { - "batch_summary": { - "total_companies": summary.total_companies, - "successful": summary.successful, - "failed": summary.failed, - "total_vessels": summary.total_vessels, - "processing_time": round(summary.processing_time, 2), - "timestamp": summary.timestamp, - "failed_companies": summary.failed_companies - }, - "results": [] - } - - for result in results: - if result.success and result.fleet_data: - result_entry = { - "company_name": result.company_name, - "success": True, - "processing_time": round(result.processing_time, 2), - "fleet_data": { - "company_name": result.fleet_data.company_name, - "total_vessels": result.fleet_data.total_vessels, - "vessels": [asdict(vessel) for vessel in result.fleet_data.vessels] - } - } - else: - result_entry = { - "company_name": result.company_name, - "success": False, - "processing_time": round(result.processing_time, 2), - "error": result.error_message or "Unknown error" - } - - output_data["results"].append(result_entry) - - return json.dumps(output_data, indent=2) - - @staticmethod - def _format_batch_fleet_csv(results: List[CompanyBatchResult]) -> str: - """Format batch fleet results as CSV""" - lines = ["Company,IMO,Name,Flag,Type,DWT,GRT,YearBuilt,Status,Error"] - - for result in results: - if result.success and result.fleet_data: - # Add each vessel with company name - for vessel in result.fleet_data.vessels: - lines.append( - f"{result.company_name}," - f"{vessel.imo}," - f"{vessel.name}," - f"{vessel.flag}," - f"{vessel.vessel_type or ''}," - f"{vessel.dwt or ''}," - f"{vessel.grt or ''}," - f"{vessel.year_built or ''}," - f"{vessel.status or ''}," - ) - else: - # Add error row for failed company - lines.append(f"{result.company_name},,,,,,,,\"{result.error_message or 'Unknown error'}\"") - - return '\n'.join(lines) - - @staticmethod - def _format_batch_fleet_table(results: List[CompanyBatchResult], summary: CompanyBatchSummary) -> str: - """Format batch fleet results as a table""" - output = [] - output.append("\n" + "=" * 80) - output.append("COMPANY BATCH PROCESSING RESULTS") - output.append("=" * 80) - output.append(f"Total Companies: {summary.total_companies}") - output.append(f"Successful: {summary.successful}") - output.append(f"Failed: {summary.failed}") - output.append(f"Total Vessels: {summary.total_vessels:,}") - output.append(f"Processing Time: {summary.processing_time:.1f}s") - output.append(f"Timestamp: {summary.timestamp}") - output.append("") - - # Successful company fleets - successful_results = [r for r in results if r.success and r.fleet_data] - if successful_results: - output.append("Successfully Retrieved Fleet Data:") - output.append("-" * 80) - output.append(f"{'Company':<30} | {'Vessels':<8} | {'Processing Time':<15}") - output.append("-" * 80) - - for result in successful_results: - vessel_count = result.fleet_data.total_vessels - processing_time = f"{result.processing_time:.1f}s" - company_name = result.company_name[:29] if len(result.company_name) > 29 else result.company_name - output.append( - f"{company_name:<30} | " - f"{vessel_count:<8} | " - f"{processing_time:<15}" - ) - - # Failed lookups - failed_results = [r for r in results if not r.success] - if failed_results: - output.append("") - output.append("Failed Company Lookups:") - output.append("-" * 80) - output.append(f"{'Company':<30} | {'Error':<48}") - output.append("-" * 80) - - for result in failed_results: - error_msg = (result.error_message or "Unknown error")[:47] - company_name = result.company_name[:29] if len(result.company_name) > 29 else result.company_name - output.append(f"{company_name:<30} | {error_msg}") - - output.append("=" * 80) - return '\n'.join(output) diff --git a/src/equasis_cli/interactive.py b/src/equasis_cli/interactive.py deleted file mode 100644 index 7597493..0000000 --- a/src/equasis_cli/interactive.py +++ /dev/null @@ -1,1129 +0,0 @@ -#!/usr/bin/env python3 -""" -Interactive Shell for Equasis CLI with proper Application layout -Output stays above, prompt stays fixed at bottom -""" - -import re -import os -import sys -import logging -from typing import Dict, Optional, Tuple - -from prompt_toolkit import Application -from prompt_toolkit.layout import ( - HSplit, Layout, Window, FloatContainer, Float, - ScrollbarMargin, ConditionalContainer, WindowAlign -) -from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl -from prompt_toolkit.buffer import Buffer -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.formatted_text import FormattedText, HTML, StyleAndTextTuples -from prompt_toolkit.styles import Style -from prompt_toolkit.lexers import Lexer -from prompt_toolkit.completion import Completer, Completion, CompleteEvent -from prompt_toolkit.document import Document -from prompt_toolkit.filters import Condition - -from .client import EquasisClient -from .formatter import OutputFormatter -from .banner import display_banner, display_credentials_note, check_credentials, Colors, get_interactive_banner -from .credentials import get_credential_manager - -logger = logging.getLogger(__name__) - - -class OutputLexer(Lexer): - """Custom lexer to style output buffer text with gradient for ASCII art""" - - def __init__(self): - self.banner_end_line = None # Track where banner ends - self.ascii_art_end_line = 5 # ASCII art is lines 0-5 - - def lex_document(self, document: Document) -> callable: - # Find where banner ends (after "Press ? for quick help.") - if self.banner_end_line is None: - for i, line in enumerate(document.lines): - if 'Press ? for quick help.' in line: - self.banner_end_line = i - break - - def get_line_styles(line_number: int) -> StyleAndTextTuples: - try: - line = document.lines[line_number] - - # User prompt lines (commands executed by user) - if line.startswith('❯ '): - return [('class:user-prompt', line)] - - # ASCII art gradient (lines 0-5): pink to purple - if line_number <= self.ascii_art_end_line: - # Gradient: magenta #c678dd -> purple #a464c2 -> violet #8b4fa8 - gradient_colors = [ - 'class:ascii-line-0', # Lightest magenta - 'class:ascii-line-1', - 'class:ascii-line-2', - 'class:ascii-line-3', - 'class:ascii-line-4', - 'class:ascii-line-5', # Darkest purple - ] - if line_number < len(gradient_colors): - return [(gradient_colors[line_number], line)] - - # "Maritime Intelligence Tool" line - title white, version grey - if 'Maritime Intelligence Tool' in line: - # Split the line to style title and version separately - if 'v' in line: - parts = line.split('v') - return [ - ('', parts[0]), # "Maritime Intelligence Tool" in white - ('class:banner-text', 'v' + parts[1]) # version in grey - ] - return [('', line)] - - # Rest of banner - subdued grey - if self.banner_end_line is not None and line_number <= self.banner_end_line: - return [('class:banner-text', line)] - - # Normal output (results) - return [('', line)] - except IndexError: - return [] - return get_line_styles - - -class SlashCommandCompleter(Completer): - """Auto-completion for slash parameters""" - - def __init__(self): - self.command_params = { - 'vessel': ['/imo', '/format', '/output'], - 'search': ['/name', '/format', '/output'], - 'fleet': ['/company', '/format', '/output'], - 'batch': ['/imos', '/file', '/companies', '/company-file', '/format', '/output'], - 'format': [], - 'status': [], - 'clear': [], - 'help': [], - 'exit': [], - 'quit': [], - } - - def get_completions(self, document: Document, complete_event: CompleteEvent): - """Generate completions based on current input""" - text = document.text_before_cursor - words = text.split() - - if not words: - return - - # First word - complete command names - if len(words) == 1 and not text.endswith(' '): - for cmd in self.command_params.keys(): - if cmd.startswith(words[0]): - yield Completion(cmd, start_position=-len(words[0])) - - # Slash parameter completion - elif '/' in text: - command = words[0] if words else '' - if command in self.command_params: - params = self.command_params[command] - last_word = words[-1] if words else '' - if last_word.startswith('/'): - for param in params: - if param.startswith(last_word): - yield Completion(param, start_position=-len(last_word)) - - -class InteractiveShell: - """Interactive shell with proper layout - output above, prompt below""" - - def __init__(self): - self.client: Optional[EquasisClient] = None - self.formatter = OutputFormatter() - self.output_format = 'table' - self.debug_mode = False - self.logged_in = False - self.running = True - - # Connection and status tracking - self.connection_status = "Not connected" - self.last_operation = "" - - # Color support - self.color_support = Colors.supports_color() - - # Menu visibility flags - self.show_help_menu = False - self.show_slash_menu = False - self.slash_menu_index = 0 # Selected index in slash menu - - # Loading indicator state - self.is_loading = False - self.loading_message = "" - self.loading_detail = "" - self.spinner_frame = 0 - self.shine_offset = 0 - - # Create buffers - # Output buffer with unlimited history (no max_line_count) - self.output_buffer = Buffer(read_only=True, multiline=True) - - # Input buffer with command history enabled - from prompt_toolkit.history import InMemoryHistory - self.history = InMemoryHistory() - self.input_buffer = Buffer( - multiline=False, - completer=SlashCommandCompleter(), - history=self.history, # Attach history object - enable_history_search=True, - ) - - # Setup key bindings - self.kb = KeyBindings() - self._setup_key_bindings() - - # Create layout - self.layout = self._create_layout() - - # Create application - self.app = Application( - layout=self.layout, - key_bindings=self.kb, - style=self._get_style(), - full_screen=True, - mouse_support=False, - ) - - def _setup_key_bindings(self): - """Setup custom key bindings""" - @self.kb.add('c-c') - def _(event): - """Ctrl+C: Cancel current input""" - self.input_buffer.text = '' - self.show_help_menu = False - self.show_slash_menu = False - - @self.kb.add('c-d') - def _(event): - """Ctrl+D: Exit""" - event.app.exit() - - @self.kb.add('enter', eager=True) - def _(event): - """Enter: Process command (menu just provides hints)""" - # Close menus - self.show_slash_menu = False - self.show_help_menu = False - self.slash_menu_index = 0 - # Process the command as typed - self._process_input() - - @self.kb.add('tab') - def _(event): - """Tab: Select from slash menu""" - if self.show_slash_menu: - # Insert selected command from filtered list - filtered_commands = self._get_filtered_commands() - if filtered_commands and self.slash_menu_index < len(filtered_commands): - selected = filtered_commands[self.slash_menu_index][0] - - buffer_text = self.input_buffer.text - slash_count = buffer_text.count('/') - is_primary = slash_count == 0 or (slash_count == 1 and buffer_text.startswith('/')) - - if is_primary: - # Primary command - replace everything (including leading / if present) - self.input_buffer.text = selected + ' ' - self.input_buffer.cursor_position = len(self.input_buffer.text) - else: - # Parameter - replace from last / onwards - last_slash_pos = buffer_text.rfind('/') - if last_slash_pos >= 0: - new_text = buffer_text[:last_slash_pos] + '/' + selected + ' ' - self.input_buffer.text = new_text - self.input_buffer.cursor_position = len(new_text) - - self.show_slash_menu = False - self.slash_menu_index = 0 - - # Command history navigation with Up/Down arrows - # Use eager=True to make these bindings take priority over buffer defaults - @self.kb.add('up', eager=True) - def _(event): - """Up arrow: Navigate menu, history, or scroll output""" - from prompt_toolkit.application import get_app - # Check if we're in scroll mode (output buffer focused) - if get_app().layout.has_focus(self.output_buffer): - # Scroll output up one line - self.output_buffer.cursor_up() - elif self.show_slash_menu: - # Navigate slash menu using filtered list count - DON'T modify buffer - filtered_commands = self._get_filtered_commands() - if filtered_commands: - self.slash_menu_index = max(0, self.slash_menu_index - 1) - # Don't do anything else - prevents buffer modification - else: - buff = event.app.current_buffer - # Use the history object directly to get previous item - history_strings = list(buff.history.get_strings()) - if history_strings: - # Get current working index - if not hasattr(self, '_history_index'): - self._history_index = len(history_strings) - - self._history_index = max(0, self._history_index - 1) - if self._history_index < len(history_strings): - buff.text = history_strings[self._history_index] - buff.cursor_position = len(buff.text) - - @self.kb.add('down', eager=True) - def _(event): - """Down arrow: Navigate menu, history, or scroll output""" - from prompt_toolkit.application import get_app - # Check if we're in scroll mode (output buffer focused) - if get_app().layout.has_focus(self.output_buffer): - # Scroll output down one line - self.output_buffer.cursor_down() - elif self.show_slash_menu: - # Navigate slash menu using filtered list count - DON'T modify buffer - filtered_commands = self._get_filtered_commands() - if filtered_commands: - self.slash_menu_index = min(len(filtered_commands) - 1, self.slash_menu_index + 1) - # Don't do anything else - prevents buffer modification - else: - buff = event.app.current_buffer - # Use the history object directly to get next item - history_strings = list(buff.history.get_strings()) - if history_strings and hasattr(self, '_history_index'): - self._history_index = min(len(history_strings), self._history_index + 1) - - if self._history_index < len(history_strings): - buff.text = history_strings[self._history_index] - buff.cursor_position = len(buff.text) - else: - # At the end of history, show empty prompt - buff.text = '' - buff.cursor_position = 0 - - # Scrolling keys for output buffer - # Multiple options for users without Fn key - @self.kb.add('pageup') - def _(event): - """Page Up: Scroll output up (requires Fn on Mac)""" - for _ in range(10): - self.output_buffer.cursor_up() - - @self.kb.add('pagedown') - def _(event): - """Page Down: Scroll output down (requires Fn on Mac)""" - for _ in range(10): - self.output_buffer.cursor_down() - - @self.kb.add('s-up') # Shift+Up - def _(event): - """Shift+Up: Scroll output up one line""" - self.output_buffer.cursor_up() - - @self.kb.add('s-down') # Shift+Down - def _(event): - """Shift+Down: Scroll output down one line""" - self.output_buffer.cursor_down() - - # Scroll mode: Press Esc to enter scroll mode (vim/less-style) - @self.kb.add('escape') - def _(event): - """Esc: Enter scroll mode (focus output buffer)""" - from prompt_toolkit.application import get_app - # Only enter scroll mode if not showing menus - if not self.show_help_menu and not self.show_slash_menu: - get_app().layout.focus(self.output_buffer) - self.last_operation = "Scroll mode • Press i or type to return to input" - - # Return to input mode: Press 'i' or any letter starts typing - @self.kb.add('i') - def _(event): - """i: Return to input mode from scroll mode""" - from prompt_toolkit.application import get_app - # Check if we're focused on output buffer - if get_app().layout.has_focus(self.output_buffer): - # Return to input mode - get_app().layout.focus(self.input_buffer) - self.last_operation = "" - else: - # Already in input, insert 'i' - self.input_buffer.insert_text('i') - - # Vim-style scrolling (work in both modes but better in scroll mode) - @self.kb.add('c-k') # Ctrl+K - def _(event): - """Ctrl+K: Scroll output up""" - for _ in range(5): - self.output_buffer.cursor_up() - event.app.invalidate() - - @self.kb.add('c-j') # Ctrl+J - def _(event): - """Ctrl+J: Scroll output down""" - for _ in range(5): - self.output_buffer.cursor_down() - event.app.invalidate() - - @self.kb.add('?') - def _(event): - """? key: Toggle help menu (simple on/off)""" - self.show_help_menu = not self.show_help_menu - self.show_slash_menu = False - - @self.kb.add('/') - def _(event): - """/ key: Insert / and show slash menu""" - self.input_buffer.insert_text('/') - self.show_slash_menu = True - self.show_help_menu = False - self.slash_menu_index = 0 - - @self.kb.add('backspace') - def _(event): - """Backspace: Hide slash menu if / is deleted, reset index on filter change""" - buff = event.app.current_buffer - # Delete character first - if buff.text: - buff.delete_before_cursor(count=1) - # Check if buffer no longer starts with / - if not buff.text.startswith('/'): - self.show_slash_menu = False - self.slash_menu_index = 0 - else: - # Reset index when filter changes - self.slash_menu_index = 0 - - def _create_help_menu_content(self): - """Generate help menu text""" - return FormattedText([ - ('class:menu-text', '/ For commands\n'), - ('class:menu-text', 'Up/Down Navigate command history\n'), - ('class:menu-text', 'Esc Enter scroll mode (navigate output)\n'), - ('class:menu-text', 'i Return to input mode\n'), - ('class:menu-text', 'Ctrl+K/J Quick scroll (5 lines)\n'), - ('class:menu-text', 'Page Up/Down Scroll output\n'), - ('class:menu-text', 'Shift+Up/Down Scroll output (one line)\n'), - ('class:menu-text', '\n'), - ('class:menu-text', 'Ctrl+C Cancel current input\n'), - ('class:menu-text', 'Ctrl+D Exit'), - ]) - - def _get_filtered_commands(self): - """Get list of commands/parameters filtered by current input""" - buffer_text = self.input_buffer.text - - # Check if this is at the start (main commands) or after a command (parameters) - parts = buffer_text.split() - - # If we're at the beginning or typing the first /command - if len(parts) <= 1 or not buffer_text.count('/'): - all_commands = [ - ('vessel', 'Get vessel information by IMO'), - ('search', 'Search vessels by name or IMO'), - ('fleet', 'Get company fleet information'), - ('batch', 'Process multiple vessels/companies'), - ('format', 'Set default output format'), - ('status', 'Show connection status'), - ('clear', 'Clear output screen'), - ('help', 'Show detailed help for command'), - ('exit', 'Exit interactive mode'), - ] - - if buffer_text.startswith('/'): - filter_text = buffer_text[1:].lower() - if filter_text: - return [(cmd, desc) for cmd, desc in all_commands if filter_text in cmd.lower()] - else: - return all_commands - return all_commands - - # If typing a parameter (second / or later) - else: - # Determine which command was typed to show relevant parameters - first_part = parts[0].lower() - - # Define parameters for each command - if first_part == 'vessel': - params = [ - ('imo', 'IMO number (required)'), - ('format', 'Output format: table, json, csv'), - ('output', 'Save to file'), - ] - elif first_part == 'search': - params = [ - ('name', 'Vessel name to search'), - ('imo', 'IMO number to search'), - ('format', 'Output format: table, json, csv'), - ('output', 'Save to file'), - ] - elif first_part == 'fleet': - params = [ - ('company', 'Company name (required)'), - ('format', 'Output format: table, json, csv'), - ('output', 'Save to file'), - ] - elif first_part == 'batch': - params = [ - ('imos', 'Comma-separated IMO numbers'), - ('file', 'File with IMO numbers'), - ('companies', 'Comma-separated company names'), - ('company-file', 'File with company names'), - ('format', 'Output format: table, json, csv'), - ('output', 'Save to file'), - ] - else: - # Default common parameters - params = [ - ('format', 'Output format: table, json, csv'), - ('output', 'Save to file'), - ] - - # Filter parameters based on what's being typed after the last / - last_slash_pos = buffer_text.rfind('/') - if last_slash_pos >= 0: - filter_text = buffer_text[last_slash_pos + 1:].lower() - if filter_text: - return [(cmd, desc) for cmd, desc in params if filter_text in cmd.lower()] - else: - return params - - return params - - def _create_slash_menu_content(self): - """Generate slash command menu text with selection highlighting and filtering""" - commands = self._get_filtered_commands() - - # Ensure index is within bounds - if self.slash_menu_index >= len(commands): - self.slash_menu_index = len(commands) - 1 if commands else 0 - - # Determine if we're showing primary commands or parameters - buffer_text = self.input_buffer.text - slash_count = buffer_text.count('/') - is_primary = slash_count == 0 or (slash_count == 1 and buffer_text.startswith('/')) - - # Find max command length for alignment - max_cmd_len = max(len(cmd) for cmd, _ in commands) if commands else 0 - # Add 1 for the / prefix on parameters - if not is_primary: - max_cmd_len += 1 - - lines = [] - for i, (cmd, desc) in enumerate(commands): - # Format: primary commands have no /, parameters have / - prefix = '' if is_primary else '/' - full_cmd = f'{prefix}{cmd}' - - # Calculate padding to align descriptions - padding = ' ' * (max_cmd_len - len(full_cmd) + 4) - - if i == self.slash_menu_index: - # Highlighted item - colored - lines.append(('class:menu-item-selected', full_cmd)) - lines.append(('class:menu-desc-selected', f'{padding}{desc}')) - else: - # Normal item - grey - lines.append(('class:menu-item-unselected', full_cmd)) - lines.append(('class:menu-desc-unselected', f'{padding}{desc}')) - - if i < len(commands) - 1: - lines.append(('', '\n')) - - return FormattedText(lines) - - def _create_layout(self): - """Create the application layout""" - # Output window (scrollable, takes most space) - output_window = Window( - content=BufferControl( - buffer=self.output_buffer, - lexer=OutputLexer(), # Use custom lexer to style user prompts - ), - wrap_lines=True, - # Scrollbar is added via ScrollbarMargin in prompt_toolkit - right_margins=[ScrollbarMargin(display_arrows=True)], - ) - - # Separator line - separator = Window( - height=1, - char='─', - style='class:separator', - ) - - # Input prompt window (fixed at bottom) - input_window = Window( - content=BufferControl( - buffer=self.input_buffer, - focusable=True, - ), - height=1, - dont_extend_height=True, - ) - - # Prompt character ("❯ ") - prompt_window = Window( - content=FormattedTextControl( - lambda: FormattedText([('class:prompt', '❯ ')]) - ), - width=2, - dont_extend_width=True, - ) - - # Bottom toolbar (status bar) - def get_toolbar_text(): - parts = [] - if self.logged_in: - parts.append(('class:status-connected', '● Connected')) - else: - parts.append(('class:status-disconnected', '○ Not connected')) - - parts.append(('class:status-dim', f' • Format: ')) - parts.append(('class:status-format', self.output_format)) - - if self.last_operation: - parts.append(('class:status-dim', f' • {self.last_operation}')) - else: - parts.append(('class:status-dim', ' • Press ? for help')) - - return FormattedText(parts) - - toolbar = Window( - content=FormattedTextControl(get_toolbar_text), - height=1, - style='class:bottom-toolbar', - ) - - # Help menu (ephemeral, appears below prompt) - help_menu = ConditionalContainer( - Window( - content=FormattedTextControl(self._create_help_menu_content), - height=9, - style='class:menu', - ), - filter=Condition(lambda: self.show_help_menu), - ) - - # Slash command menu (ephemeral, appears below prompt) - slash_menu = ConditionalContainer( - Window( - content=FormattedTextControl(self._create_slash_menu_content), - height=9, - style='class:menu', - ), - filter=Condition(lambda: self.show_slash_menu), - ) - - # Loading indicator (appears above input when processing) - def get_loading_text(): - parts = [] - if self.is_loading: - # Animated spinner - spinner_chars = ['◐', '◓', '◑', '◒'] - spinner = spinner_chars[self.spinner_frame % len(spinner_chars)] - parts.append(('class:loading-spinner', f'{spinner} ')) - - # Shine effect on message text - sweeping highlight - message = self.loading_message - if message: - # Create shine effect with a bright highlight sweeping left to right - shined_text = [] - shine_width = 4 # Width of the shine effect - # Position wraps around with padding so shine fully exits before restarting - shine_pos = (self.shine_offset % (len(message) + shine_width * 2)) - shine_width - - for i, char in enumerate(message): - # Calculate distance from shine center - distance = i - shine_pos - - # Shine gradient: bright in center, fades out - if distance == 0: - # Center of shine - brightest (white/very bright) - shined_text.append(('class:loading-message-shine-center', char)) - elif distance in [1, -1]: - # Near shine - bright blue - shined_text.append(('class:loading-message-shine-near', char)) - elif distance in [2, -2]: - # Edge of shine - normal blue - shined_text.append(('class:loading-message-shine-edge', char)) - else: - # Outside shine - dimmed - shined_text.append(('class:loading-message', char)) - parts.extend(shined_text) - - if self.loading_detail: - parts.append(('class:loading-detail', f' {self.loading_detail}')) - return FormattedText(parts) - - loading_indicator = ConditionalContainer( - Window( - content=FormattedTextControl(get_loading_text), - height=1, - style='class:loading', - ), - filter=Condition(lambda: self.is_loading), - ) - - # Combine prompt + input on same line - from prompt_toolkit.layout.containers import VSplit - input_line = VSplit([prompt_window, input_window]) - - # Stack everything vertically - root_container = HSplit([ - output_window, # Top: scrollable output - separator, # Separator line - loading_indicator, # Loading status (conditional) - input_line, # Middle: prompt + input - help_menu, # Ephemeral help menu (conditional) - slash_menu, # Ephemeral slash menu (conditional) - separator, # Separator line - toolbar, # Bottom: status bar - ]) - - return Layout(root_container, focused_element=self.input_buffer) - - def _get_style(self): - """Define color style for prompt_toolkit""" - return Style.from_dict({ - # Separator - 'separator': 'fg:#5c6370 bg:default', - - # Bottom toolbar - 'bottom-toolbar': 'fg:#abb2bf bg:default noreverse', - - # Status bar colors - 'status-connected': 'fg:#98c379 bg:default noreverse', - 'status-disconnected': 'fg:#e06c75 bg:default noreverse', - 'status-dim': 'fg:#5c6370 bg:default noreverse', - 'status-format': 'fg:#61afef bg:default noreverse', - 'status-warning': 'fg:#e5c07b bg:default noreverse', - 'prompt': 'fg:#abb2bf bg:default noreverse', # White/default color - - # Output buffer - 'user-prompt': 'fg:#5c6370 bg:default noreverse', # Subdued grey for user prompts - 'banner-text': 'fg:#5c6370 bg:default noreverse', # Subdued grey for banner - - # ASCII art gradient (cyan to blue - heavier on blue) - 'ascii-line-0': 'fg:#56b6c2 bg:default noreverse', # Cyan (top) - 'ascii-line-1': 'fg:#5caec8 bg:default noreverse', # Cyan-blue - 'ascii-line-2': 'fg:#5fa9d5 bg:default noreverse', # More blue - 'ascii-line-3': 'fg:#60abe0 bg:default noreverse', # Much more blue - 'ascii-line-4': 'fg:#61aeea bg:default noreverse', # Almost full blue - 'ascii-line-5': 'fg:#61afef bg:default noreverse', # Blue (matches prompt) - - # Ephemeral menus - 'menu': 'bg:default noreverse', - 'menu-item-unselected': 'fg:#5c6370 bg:default noreverse', # Grey when not selected - 'menu-desc-unselected': 'fg:#5c6370 bg:default noreverse', # Grey when not selected - 'menu-item-selected': 'fg:#61afef bold bg:default noreverse', # Blue when selected - 'menu-desc-selected': 'fg:#61afef bg:default noreverse', # Blue when selected (same as command) - 'menu-text': 'fg:#5c6370 bg:default noreverse', # For help menu - - # Loading indicator - 'loading': 'bg:default noreverse', - 'loading-spinner': 'fg:#61afef bg:default noreverse', # Blue spinner (same as prompt) - 'loading-message': 'fg:#61afef bg:default noreverse', # Same blue as prompt (outside shine) - 'loading-message-shine-edge': 'fg:#61afef bg:default noreverse', # Same blue (edge of shine) - 'loading-message-shine-near': 'fg:#7dc4f4 bold bg:default noreverse', # Bright blue (near shine) - 'loading-message-shine-center': 'fg:#ffffff bold bg:default noreverse', # White/very bright (center of shine) - 'loading-detail': 'fg:#5c6370 bg:default noreverse', # Grey for detail messages - - # Completion menu - 'completion-menu': 'bg:default noreverse', - 'completion-menu.completion': 'fg:#abb2bf bg:default noreverse', - 'completion-menu.completion.current': 'fg:#61afef bold bg:default noreverse', - 'completion-menu.meta': 'fg:#5c6370 bg:default noreverse', - 'scrollbar.background': 'bg:default noreverse', - 'scrollbar.button': 'bg:default noreverse', - }) - - def _append_output(self, text: str): - """Append text to output buffer""" - # Temporarily make buffer writable to append text - self.output_buffer.read_only = lambda: False - current = self.output_buffer.text - new_text = current + text + '\n' if current else text + '\n' - self.output_buffer.set_document(Document(text=new_text, cursor_position=len(new_text))) - self.output_buffer.read_only = lambda: True # Make read-only again - - def _process_input(self): - """Process the current input command""" - command_text = self.input_buffer.text.strip() - - if not command_text: - return - - # Add to history before clearing - self.history.append_string(command_text) - - # Reset history index for next navigation - self._history_index = len(list(self.history.get_strings())) - - # Clear input immediately for better UX - self.input_buffer.text = '' - - # Show the command in output with special prefix for styling - # Use a unique prefix that can be styled by a lexer - self._append_output(f"❯ {command_text}") - - # Handle special commands - if command_text == 'exit' or command_text == 'quit': - self.app.exit() - return - - if command_text == 'clear': - # Clear the output buffer (make it writable first) - self.output_buffer.read_only = lambda: False - self.output_buffer.set_document(Document(text='', cursor_position=0)) - self.output_buffer.read_only = lambda: True - return - - # Process command in background task to allow UI updates - async def process_async(): - import asyncio - - # Show loading indicator - self.is_loading = True - self.loading_message = "Processing..." - self.loading_detail = "" - self.spinner_frame = 0 - self.shine_offset = 0 - self.app.invalidate() - - # Animation task to update spinner and shine - animation_counter = 0 - async def animate(): - nonlocal animation_counter - while self.is_loading: - await asyncio.sleep(0.1) # 10 FPS animation - animation_counter += 1 - - # Update spinner every 2 frames (slower rotation) - if animation_counter % 2 == 0: - self.spinner_frame += 1 - - # Update shine every frame (smooth sweep) - self.shine_offset += 1 - self.app.invalidate() - - # Start animation task - animation_task = asyncio.create_task(animate()) - - # Run the synchronous command in executor - loop = asyncio.get_event_loop() - - try: - await loop.run_in_executor(None, self._process_command, command_text) - finally: - # Hide loading indicator - self.is_loading = False - self.loading_message = "" - self.loading_detail = "" - self.app.invalidate() - - # Wait for animation to stop - await animation_task - - # Create background task - self.app.create_background_task(process_async()) - - def _process_command(self, line: str): - """Process a command line""" - # Parse command - parts = line.split(maxsplit=1) - if not parts: - return - - command = parts[0].lower() - args = parts[1] if len(parts) > 1 else '' - - # Route to command handlers - command_map = { - 'vessel': self._cmd_vessel, - 'search': self._cmd_search, - 'fleet': self._cmd_fleet, - 'batch': self._cmd_batch, - 'format': self._cmd_format, - 'status': self._cmd_status, - 'help': self._cmd_help, - } - - handler = command_map.get(command) - if handler: - # Redirect stdout/stderr to capture any print statements - import io - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = io.StringIO() - sys.stderr = io.StringIO() - - try: - handler(args) - - # Get any captured output - stdout_output = sys.stdout.getvalue() - stderr_output = sys.stderr.getvalue() - - if stdout_output: - self._append_output(stdout_output.rstrip()) - if stderr_output: - self._append_output(stderr_output.rstrip()) - finally: - # Restore stdout/stderr - sys.stdout = old_stdout - sys.stderr = old_stderr - else: - self._append_output(f"Unknown command: {command}") - self._append_output("Type 'help' for available commands") - - def parse_slash_command(self, line: str, expected_params: Dict[str, bool]) -> Tuple[Dict[str, str], bool]: - """Parse slash parameters""" - params = {} - - patterns = [ - r'/(\w+)\s+"([^"]+)"', - r'/(\w+)\s+"([^"]+)"', - r'/(\w+)\s+"([^"]+)"', - r"/(\w+)\s+'([^']+)'", - r'/(\w+)\s+(\S+)', - ] - - for pattern in patterns: - matches = re.findall(pattern, line) - for match in matches: - param = match[0] - value = match[1] - if param not in params: - params[param] = value - - all_required_present = all( - param in params - for param, required in expected_params.items() - if required - ) - - return params, all_required_present - - def ensure_authenticated(self) -> bool: - """Ensure client is authenticated""" - if not self.client: - credential_manager = get_credential_manager() - username, password = credential_manager.get_credentials() - - if not username or not password: - self._append_output("\nNo credentials found. Please configure credentials first.") - self._append_output("Run: equasis configure --setup") - return False - - try: - self.loading_message = "Connecting..." - self.loading_detail = "Authenticating with Equasis" - self.last_operation = "Connecting..." - - self.client = EquasisClient(username, password) - self.logged_in = self.client.login() - - if self.logged_in: - self.connection_status = "Connected" - self.last_operation = "" - else: - self._append_output("✗ Authentication failed") - self.last_operation = "Auth failed" - return False - - except Exception as e: - self._append_output(f"✗ Connection error: {e}") - self.last_operation = "Connection error" - return False - - if not self.logged_in: - self.last_operation = "Reconnecting..." - self.logged_in = self.client.login() - self.last_operation = "" if self.logged_in else "Reconnect failed" - - return self.logged_in - - def _cmd_vessel(self, args: str): - """Handle vessel command""" - expected_params = {'imo': True, 'format': False, 'output': False} - params, all_required = self.parse_slash_command(args, expected_params) - - if not all_required: - self._append_output("Error: Missing required parameter /imo") - self._append_output("Usage: vessel /imo [/format table|json|csv]") - return - - if not self.ensure_authenticated(): - return - - output_format = params.get('format', self.output_format) - output_file = params.get('output') - - try: - self.loading_message = "Osinting..." - self.loading_detail = f"Searching IMO {params['imo']}" - self.last_operation = f"Fetching IMO {params['imo']}..." - - vessel = self.client.search_vessel_by_imo(params['imo']) - - if vessel: - output = self.formatter.format_vessel_info(vessel, output_format) - if output_file: - with open(output_file, 'w') as f: - f.write(output) - self._append_output(f"✓ Vessel data saved to {output_file}") - else: - self._append_output(output) - self.last_operation = f"✓ Vessel {params['imo']} found" - else: - self._append_output(f"No vessel found with IMO: {params['imo']}") - self.last_operation = f"✗ Vessel {params['imo']} not found" - - except Exception as e: - self._append_output(f"Error: {e}") - self.last_operation = f"✗ Error" - - def _cmd_search(self, args: str): - """Handle search command""" - expected_params = {'name': False, 'imo': False, 'format': False, 'output': False} - params, _ = self.parse_slash_command(args, expected_params) - - # Require either /name or /imo - if not params.get('name') and not params.get('imo'): - self._append_output("Error: Missing required parameter /name or /imo") - return - - if not self.ensure_authenticated(): - return - - output_format = params.get('format', self.output_format) - - try: - if params.get('imo'): - # Search by IMO - self.last_operation = f"Searching IMO '{params['imo']}'..." - vessels = self.client.search_vessel_by_name(params['imo']) - else: - # Search by name - self.last_operation = f"Searching '{params['name']}'..." - vessels = self.client.search_vessel_by_name(params['name']) - - if vessels: - output = self.formatter.format_simple_vessel_list(vessels, output_format) - self._append_output(output) - self.last_operation = f"✓ Found {len(vessels)} vessel(s)" - else: - search_term = params.get('imo') or params.get('name') - self._append_output(f"No vessels found matching: {search_term}") - self.last_operation = "✗ No vessels found" - - except Exception as e: - self._append_output(f"Error: {e}") - self.last_operation = "✗ Error" - - def _cmd_fleet(self, args: str): - """Handle fleet command""" - self._append_output("Fleet command - to be implemented") - - def _cmd_batch(self, args: str): - """Handle batch command""" - self._append_output("Batch command - to be implemented") - - def _cmd_format(self, args: str): - """Handle format command""" - args = args.strip().lower() - if args in ['table', 'json', 'csv']: - self.output_format = args - self.last_operation = f"Format set to {args}" - self._append_output(f"Default output format set to: {args}") - else: - self._append_output("Invalid format. Use: table, json, or csv") - - def _cmd_status(self, args: str): - """Handle status command""" - self.last_operation = "" - self._append_output("\nSession Status:") - status_text = 'Connected' if self.logged_in else 'Not connected' - self._append_output(f" Authentication: {status_text}") - self._append_output(f" Default Format: {self.output_format}") - self._append_output(f" Debug Mode: {self.debug_mode}\n") - - def _cmd_help(self, args: str): - """Handle help command""" - self._append_output("\n" + "=" * 60) - self._append_output("EQUASIS CLI - INTERACTIVE MODE HELP") - self._append_output("=" * 60) - self._append_output("\nCommands:") - self._append_output(" vessel /imo Get vessel information") - self._append_output(" search /name \"\" Search vessels by name") - self._append_output(" search /imo Search by IMO number") - self._append_output(" fleet /company \"\" Get fleet information") - self._append_output(" format Set output format") - self._append_output(" status Show session status") - self._append_output(" clear Clear output") - self._append_output(" help Show this help") - self._append_output(" exit Exit interactive mode") - self._append_output("\nKeyboard shortcuts:") - self._append_output(" ? Show quick help") - self._append_output(" Up/Down Navigate command history") - self._append_output(" Page Up/Down Scroll output") - self._append_output(" Shift+Up/Down Scroll output (one line)") - self._append_output(" Ctrl+C Cancel current input") - self._append_output(" Ctrl+D Exit") - self._append_output("=" * 60 + "\n") - - def start(self): - """Start the interactive shell""" - # Redirect ALL logging to our output buffer instead of console - class OutputBufferHandler(logging.Handler): - def __init__(self, shell): - super().__init__() - self.shell = shell - - def emit(self, record): - try: - msg = self.format(record) - self.shell._append_output(msg) - except: - pass # Silently fail if we can't append - - # Remove ALL existing handlers from root logger and add our custom one - root_logger = logging.getLogger() - root_logger.handlers = [] - root_logger.addHandler(OutputBufferHandler(self)) - root_logger.setLevel(logging.WARNING) - - # Also set client logger specifically - client_logger = logging.getLogger('equasis_cli.client') - client_logger.handlers = [] - client_logger.addHandler(OutputBufferHandler(self)) - client_logger.setLevel(logging.WARNING) - - # Show banner in output buffer - banner_text = get_interactive_banner() - self._append_output(banner_text) - - # Run the application - try: - self.app.run() - except KeyboardInterrupt: - pass - except EOFError: - pass - - self._append_output("\nGoodbye!") - - -def main(): - """Entry point for interactive shell""" - shell = InteractiveShell() - shell.start() - - -if __name__ == '__main__': - main() diff --git a/src/equasis_cli/main.py b/src/equasis_cli/main.py deleted file mode 100644 index 7da84aa..0000000 --- a/src/equasis_cli/main.py +++ /dev/null @@ -1,380 +0,0 @@ -#!/usr/bin/env python3 -""" -Equasis CLI Tool - Main CLI application -Command-line interface for accessing Equasis maritime data -""" - -import argparse -import os -import logging - -from .client import EquasisClient -from .formatter import OutputFormatter -from .banner import display_banner, display_compact_info, display_error_banner, display_success_summary, check_credentials -from .credentials import get_credential_manager -from ._version import __version__ - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - - -def main(): - """Main CLI entry point - supports both interactive and traditional modes""" - import sys - - # Check if no arguments provided - start interactive mode - if len(sys.argv) == 1: - # No arguments - start interactive mode - try: - from .interactive import InteractiveShell - shell = InteractiveShell() - shell.start() - return - except (KeyboardInterrupt, EOFError): - print("\nGoodbye!") - return - except ImportError as e: - print(f"Error starting interactive mode: {e}") - print("Falling back to traditional CLI mode...") - except Exception as e: - print(f"Unexpected error in interactive mode: {e}") - print("Falling back to traditional CLI mode...") - - # Traditional CLI mode - traditional_main() - - -def traditional_main(): - """Traditional CLI mode with argparse""" - parser = argparse.ArgumentParser(description='Equasis CLI Tool for Maritime Data') - parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}') - parser.add_argument('--username', help='Equasis username (or set EQUASIS_USERNAME env var)') - parser.add_argument('--password', help='Equasis password (or set EQUASIS_PASSWORD env var)') - parser.add_argument('--output', choices=['table', 'json', 'csv'], default='table', help='Output format') - parser.add_argument('--output-file', help='Output file path (optional)') - parser.add_argument('--debug', action='store_true', help='Enable debug logging and save HTML responses') - parser.add_argument('--no-banner', action='store_true', help='Skip startup banner display') - parser.add_argument('--quiet', action='store_true', help='Minimal output mode') - parser.add_argument('--interactive', action='store_true', help='Start interactive mode') - parser.add_argument('--batch', action='store_true', help='Enable batch processing mode') - - subparsers = parser.add_subparsers(dest='command', help='Available commands') - - # Configure command - configure_parser = subparsers.add_parser('configure', help='Set up credentials and configuration') - configure_group = configure_parser.add_mutually_exclusive_group() - configure_group.add_argument('--setup', action='store_true', help='Interactively set up credentials') - configure_group.add_argument('--clear', action='store_true', help='Clear stored credentials') - configure_group.add_argument('--show', action='store_true', help='Show credential sources (for debugging)') - - # Vessel command - vessel_parser = subparsers.add_parser('vessel', help='Get vessel information') - vessel_parser.add_argument('--imo', nargs='+', help='IMO number(s) - can specify multiple') - vessel_parser.add_argument('--imo-file', help='File containing IMO numbers (one per line)') - vessel_parser.add_argument('--continue-on-error', action='store_true', - help='Continue processing on errors (batch mode)') - vessel_parser.add_argument('--progress', action='store_true', - help='Show progress indicators (batch mode)') - - # Search command - search_parser = subparsers.add_parser('search', help='Search vessels by name') - search_parser.add_argument('--name', required=True, help='Vessel name') - - # Fleet command - fleet_parser = subparsers.add_parser('fleet', help='Get fleet information') - fleet_parser.add_argument('--company', nargs='+', help='Company name(s) or identifier(s) - can specify multiple') - fleet_parser.add_argument('--company-file', help='File containing company names (one per line)') - fleet_parser.add_argument('--continue-on-error', action='store_true', - help='Continue processing on errors (batch mode)') - fleet_parser.add_argument('--progress', action='store_true', - help='Show progress indicators (batch mode)') - - args = parser.parse_args() - - # Check for interactive flag - if args.interactive: - try: - from .interactive import InteractiveShell - shell = InteractiveShell() - shell.start() - return - except (KeyboardInterrupt, EOFError): - print("\nGoodbye!") - return - except Exception as e: - print(f"Error starting interactive mode: {e}") - return - - # Set logging level based on debug flag - if args.debug: - logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - logger.info("Debug mode enabled") - else: - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - # Handle help display first - if not args.command: - # Show banner for help display - if not args.no_banner and not args.quiet: - display_banner() - parser.print_help() - return - - # Handle configure command - if args.command == 'configure': - credential_manager = get_credential_manager() - - if args.setup: - success = credential_manager.interactive_setup() - return - elif args.clear: - success = credential_manager.clear_credentials() - if success: - print("✓ Stored credentials cleared successfully") - else: - print("✗ Failed to clear credentials") - return - elif args.show: - sources = credential_manager.get_credential_sources() - print("\n=== Credential Sources ===") - print(f"Environment Variables:") - print(f" EQUASIS_USERNAME: {'✓ set' if sources['environment_variables']['username'] else '✗ not set'}") - print(f" EQUASIS_PASSWORD: {'✓ set' if sources['environment_variables']['password'] else '✗ not set'}") - print(f"\nConfig File: {sources['config_file']['path']}") - print(f" File exists: {'✓' if sources['config_file']['exists'] else '✗'}") - print(f" Has credentials: {'✓' if sources['config_file']['has_credentials'] else '✗'}") - print(f"\nConfig Directory: {sources['config_directory']['path']}") - print(f" Directory exists: {'✓' if sources['config_directory']['exists'] else '✗'}") - return - else: - # No specific action, show help - configure_parser.print_help() - return - - # Get credentials using new credential manager - credential_manager = get_credential_manager() - username, password = credential_manager.get_credentials(args.username, args.password) - - if not username or not password: - print() - display_error_banner("Authentication", - "No valid credentials found") - print("Available credential methods:") - print(" 1. Run 'equasis configure --setup' to store credentials securely") - print(" 2. Set environment variables: EQUASIS_USERNAME and EQUASIS_PASSWORD") - print(" 3. Use command line flags: --username and --password") - print() - print("For more information: equasis configure --show") - return - - # Display banner after credential validation (for actual commands) - if not args.no_banner and not args.quiet: - display_banner() - elif not args.quiet: - display_compact_info() - - # Initialize client - client = EquasisClient(username, password) - formatter = OutputFormatter() - - try: - if args.command == 'vessel': - # Check if batch mode (multiple IMOs or file input) - imo_list = [] - - # Handle IMO file input - if hasattr(args, 'imo_file') and args.imo_file: - try: - with open(args.imo_file, 'r') as f: - for line in f: - line = line.strip() - # Skip empty lines and comments - if line and not line.startswith('#'): - imo_list.append(line) - except FileNotFoundError: - display_error_banner("File Error", f"File not found: {args.imo_file}") - return - except Exception as e: - display_error_banner("File Error", f"Error reading file: {e}") - return - # Handle direct IMO input - elif hasattr(args, 'imo') and args.imo: - imo_list = args.imo if isinstance(args.imo, list) else [args.imo] - else: - display_error_banner("Input Error", "Either --imo or --imo-file is required") - return - - # Check if batch mode (multiple IMOs) - if len(imo_list) > 1: - # Batch processing - if not args.quiet: - print(f"Processing {len(imo_list)} vessels...\n") - - # Progress callback for batch mode - def progress_callback(current, total, imo, status): - if args.progress and not args.quiet: - if status == "processing": - print(f"[{current}/{total}] Processing IMO {imo}...") - elif status == "success": - print(f"[{current}/{total}] ✓ IMO {imo} retrieved") - elif status == "not_found": - print(f"[{current}/{total}] ⚠ IMO {imo} not found") - elif status == "error": - print(f"[{current}/{total}] ✗ IMO {imo} error") - - # Process batch - results = client.search_vessels_by_imo_batch( - imo_list, - progress_callback=progress_callback if args.progress else None, - stop_on_error=not getattr(args, 'continue_on_error', True) - ) - - # Format and output results - output = formatter.format_batch_vessel_info(results, args.output) - if args.output_file: - with open(args.output_file, 'w') as f: - f.write(output) - if not args.quiet: - successful = sum(1 for r in results if r.success) - display_success_summary("Batch Processing", - f"Processed {len(results)} vessels ({successful} successful), saved to {args.output_file}") - else: - print(output) - if not args.quiet: - successful = sum(1 for r in results if r.success) - display_success_summary("Batch Processing", - f"Processed {len(results)} vessels ({successful} successful)") - else: - # Single vessel processing (existing logic) - vessel = client.search_vessel_by_imo(imo_list[0]) - if vessel: - output = formatter.format_vessel_info(vessel, args.output) - if args.output_file: - with open(args.output_file, 'w') as f: - f.write(output) - if not args.quiet: - display_success_summary("Vessel Lookup", f"Data saved to {args.output_file}") - else: - print(output) - if not args.quiet: - display_success_summary("Vessel Lookup", f"Comprehensive data retrieved for IMO {imo_list[0]}") - else: - display_error_banner("Vessel Not Found", f"No vessel found with IMO: {imo_list[0]}") - - elif args.command == 'search': - vessels = client.search_vessel_by_name(args.name) - if vessels: - output = formatter.format_simple_vessel_list(vessels, args.output) - if args.output_file: - with open(args.output_file, 'w') as f: - f.write(output) - if not args.quiet: - display_success_summary("Vessel Search", f"Results saved to {args.output_file}") - else: - print(output) - if not args.quiet: - display_success_summary("Vessel Search", f"Found {len(vessels)} vessel(s) matching '{args.name}'") - else: - display_error_banner("Search Results", f"No vessels found with name: {args.name}") - - elif args.command == 'fleet': - # Check if batch mode (multiple companies or file input) - company_list = [] - - # Handle company file input - if hasattr(args, 'company_file') and args.company_file: - try: - with open(args.company_file, 'r') as f: - for line in f: - line = line.strip() - # Skip empty lines and comments - if line and not line.startswith('#'): - company_list.append(line) - except FileNotFoundError: - display_error_banner("File Error", f"File not found: {args.company_file}") - return - except Exception as e: - display_error_banner("File Error", f"Error reading file: {e}") - return - # Handle direct company input - elif hasattr(args, 'company') and args.company: - company_list = args.company if isinstance(args.company, list) else [args.company] - else: - display_error_banner("Input Error", "Either --company or --company-file is required") - return - - # Check if batch mode (multiple companies) - if len(company_list) > 1: - # Batch processing - if not args.quiet: - print(f"Processing {len(company_list)} companies...\n") - - # Progress callback for batch mode - def progress_callback(current, total, company, status): - if args.progress and not args.quiet: - if status == "processing": - print(f"[{current}/{total}] Processing company {company}...") - elif status == "success": - print(f"[{current}/{total}] ✓ Company {company} retrieved") - elif status == "not_found": - print(f"[{current}/{total}] ⚠ Company {company} not found") - elif status == "error": - print(f"[{current}/{total}] ✗ Company {company} error") - - # Process batch - results = client.search_companies_batch( - company_list, - progress_callback=progress_callback if args.progress else None, - stop_on_error=not getattr(args, 'continue_on_error', True) - ) - - # Format and output results - output = formatter.format_batch_fleet_info(results, args.output) - if args.output_file: - with open(args.output_file, 'w') as f: - f.write(output) - if not args.quiet: - successful = sum(1 for r in results if r.success) - total_vessels = sum(r.fleet_data.total_vessels for r in results if r.success and r.fleet_data) - display_success_summary("Company Batch Processing", - f"Processed {len(results)} companies ({successful} successful, {total_vessels} vessels), saved to {args.output_file}") - else: - print(output) - if not args.quiet: - successful = sum(1 for r in results if r.success) - total_vessels = sum(r.fleet_data.total_vessels for r in results if r.success and r.fleet_data) - display_success_summary("Company Batch Processing", - f"Processed {len(results)} companies ({successful} successful, {total_vessels} vessels)") - else: - # Single company processing (existing logic) - fleet = client.get_fleet_info(company_list[0]) - if fleet: - output = formatter.format_fleet_info(fleet, args.output) - if args.output_file: - with open(args.output_file, 'w') as f: - f.write(output) - if not args.quiet: - display_success_summary("Fleet Lookup", f"Fleet data saved to {args.output_file}") - else: - print(output) - if not args.quiet: - display_success_summary("Fleet Lookup", f"Fleet data retrieved for {company_list[0]}") - else: - display_error_banner("Fleet Not Found", f"No fleet found for company: {company_list[0]}") - - except KeyboardInterrupt: - if not args.quiet: - print("\nOperation cancelled by user") - else: - print("\nCancelled") - except Exception as e: - display_error_banner("Unexpected Error", f"An unexpected error occurred: {str(e)}") - if args.debug: - import traceback - print("Debug traceback:") - traceback.print_exc() - - -if __name__ == '__main__': - main() diff --git a/src/equasis_cli/models.py b/src/equasis_cli/models.py new file mode 100644 index 0000000..b8fa04b --- /dev/null +++ b/src/equasis_cli/models.py @@ -0,0 +1,353 @@ +"""Typed data structures for everything equasis-cli retrieves from Equasis. + +Dates are :class:`datetime.date` objects and numeric fields are integers, so the +JSON output is unambiguous (ISO 8601 dates, numbers as numbers). Use +:func:`to_jsonable` to convert any model to plain JSON-compatible values. +""" + +from __future__ import annotations + +import dataclasses +import datetime as dt +import enum +from dataclasses import dataclass, field +from typing import Any, Generic, TypeVar + +T = TypeVar("T") + +SCHEMA_VERSION = 1 +"""Version of the JSON document layout; bumped on incompatible changes.""" + + +# --------------------------------------------------------------------------- vessels + + +@dataclass(kw_only=True) +class FlagPerformance: + """Flag-state ratings shown in the Ship Info overview panel.""" + + paris_mou: str | None = None + tokyo_mou: str | None = None + uscg_targeted: bool | None = None + iacs_classed: bool | None = None + detention_rate_36_months: float | None = None + """Percentage of inspections that led to a detention in the last 36 months.""" + + +@dataclass(kw_only=True) +class CompanyRole: + """A company currently associated with a vessel (Management detail panel).""" + + company_id: str | None + name: str + role: str + address: str | None = None + since: dt.date | None = None + + +@dataclass(kw_only=True) +class ClassStatus: + """Classification society status (Classification panel).""" + + society: str + status: str | None = None + since: dt.date | None = None + reason: str | None = None + + +@dataclass(kw_only=True) +class ClassSurvey: + """Renewal surveys recorded by a classification society.""" + + society: str + last_renewal: dt.date | None = None + next_renewal: dt.date | None = None + + +@dataclass(kw_only=True) +class SafetyCertificate: + """Safety management certificate issued by a recognised organisation.""" + + society: str + survey_date: dt.date | None = None + expiry_date: dt.date | None = None + status_change_date: dt.date | None = None + status: str | None = None + reason: str | None = None + convention: str | None = None + + +@dataclass(kw_only=True) +class PandIInsurer: + """Protection and indemnity insurance.""" + + name: str + inception: dt.date | None = None + + +@dataclass(kw_only=True) +class Sighting: + """Geographical area where the vessel was reported.""" + + period: str + areas: list[str] = field(default_factory=list) + source: str | None = None + + +@dataclass(kw_only=True) +class InspectionReport: + """One PSC regime's report of an inspection. + + The same inspection can be reported to several PSC regimes (MoUs), which may + record different inspection types or deficiency counts. + """ + + psc_organisation: str | None + inspection_type: str | None = None + duration_days: int | None = None + deficiencies: int | None = None + inspection_id: str | None = None + + +@dataclass(kw_only=True) +class Inspection: + """A port state control inspection.""" + + authority: str | None + port: str | None + date: dt.date | None + detained: bool | None + reports: list[InspectionReport] = field(default_factory=list) + + @property + def deficiencies(self) -> int | None: + """Highest deficiency count reported for this inspection by any regime.""" + counts = [r.deficiencies for r in self.reports if r.deficiencies is not None] + return max(counts) if counts else None + + +@dataclass(kw_only=True) +class HumanElementDeficiency: + """Human element (ILO) deficiencies recorded during an inspection.""" + + psc_organisation: str | None + authority: str | None + port: str | None + inspection_type: str | None + date: dt.date | None + deficiencies: int | None + + +@dataclass(kw_only=True) +class NameChange: + """A historical vessel name. ``until`` is derived from the next newer record.""" + + name: str + since: dt.date | None + until: dt.date | None = None + source: str | None = None + + +@dataclass(kw_only=True) +class FlagChange: + """A historical flag registration.""" + + flag: str + since: dt.date | None + until: dt.date | None = None + source: str | None = None + + +@dataclass(kw_only=True) +class ClassChange: + """A historical classification society survey.""" + + society: str + survey_date: dt.date | None + source: str | None = None + + +@dataclass(kw_only=True) +class CompanyChange: + """A historical company role (owner, ship manager, ISM manager, ...).""" + + company: str + role: str + since: dt.date | None + until: dt.date | None = None + source: str | None = None + + +@dataclass(kw_only=True) +class Vessel: + """A complete vessel profile assembled from the Ship Info, Inspections and + Ship History pages.""" + + imo: str + name: str + flag: str | None = None + flag_code: str | None = None + call_sign: str | None = None + mmsi: str | None = None + gross_tonnage: int | None = None + deadweight: int | None = None + ship_type: str | None = None + year_built: int | None = None + status: str | None = None + status_since: dt.date | None = None + particulars_updated: dt.date | None = None + + flag_performance: FlagPerformance = field(default_factory=FlagPerformance) + management: list[CompanyRole] = field(default_factory=list) + class_status: list[ClassStatus] = field(default_factory=list) + class_surveys: list[ClassSurvey] = field(default_factory=list) + safety_certificates: list[SafetyCertificate] = field(default_factory=list) + pandi: list[PandIInsurer] = field(default_factory=list) + sightings: list[Sighting] = field(default_factory=list) + + inspections: list[Inspection] = field(default_factory=list) + human_element_deficiencies: list[HumanElementDeficiency] = field(default_factory=list) + + name_history: list[NameChange] = field(default_factory=list) + flag_history: list[FlagChange] = field(default_factory=list) + class_history: list[ClassChange] = field(default_factory=list) + company_history: list[CompanyChange] = field(default_factory=list) + + warnings: list[str] = field(default_factory=list) + """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 + + +@dataclass(kw_only=True) +class ShipSummary: + """A vessel as listed in search results.""" + + imo: str + name: str + gross_tonnage: int | None = None + ship_type: str | None = None + year_built: int | None = None + flag: str | None = None + + +@dataclass(kw_only=True) +class CompanySummary: + """A company as listed in search results.""" + + company_id: str + name: str + address: str | None = None + + +@dataclass(kw_only=True) +class SearchResults: + """Ships and companies matching a search.""" + + query: str + ships: list[ShipSummary] = field(default_factory=list) + 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) +class RoleSince: + """A role a company holds for a fleet vessel.""" + + role: str + since: dt.date | None = None + + +@dataclass(kw_only=True) +class FleetVessel: + """A vessel in a company's fleet.""" + + imo: str + name: str + gross_tonnage: int | None = None + ship_type: str | None = None + year_built: int | None = None + flag: str | None = None + class_societies: list[str] = field(default_factory=list) + detentions_as_ism_manager_3y: int | None = None + detentions_all_companies_3y: int | None = None + roles: list[RoleSince] = field(default_factory=list) + + +@dataclass(kw_only=True) +class Company: + """Company details shown at the top of the Fleet info page.""" + + company_id: str + name: str + address: str | None = None + status: str | None = None + last_update: dt.date | None = None + + +@dataclass(kw_only=True) +class Fleet: + """A company and the vessels it is associated with.""" + + company: Company + 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 + + +class ItemStatus(str, enum.Enum): + """Outcome of one item in a batch operation.""" + + OK = "ok" + NOT_FOUND = "not_found" + AMBIGUOUS = "ambiguous" + ERROR = "error" + + +@dataclass(kw_only=True) +class BatchItem(Generic[T]): + """Result for one input of a batch operation.""" + + query: str + status: ItemStatus + result: T | None = None + error: str | None = None + warnings: list[str] = field(default_factory=list) + elapsed_seconds: float = 0.0 + + +# ------------------------------------------------------------------------ serialization + + +def to_jsonable(value: Any) -> Any: + """Convert models (and containers of them) into JSON-compatible values.""" + if dataclasses.is_dataclass(value) and not isinstance(value, type): + data = {f.name: to_jsonable(getattr(value, f.name)) for f in dataclasses.fields(value)} + if isinstance(value, Inspection): + data["deficiencies"] = value.deficiencies + return data + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, dt.datetime): + if value.tzinfo is None: + return value.isoformat(timespec="seconds") + utc = value.astimezone(dt.timezone.utc).isoformat(timespec="seconds") + return utc.replace("+00:00", "Z") + if isinstance(value, dt.date): + return value.isoformat() + if isinstance(value, dict): + return {str(k): to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [to_jsonable(v) for v in value] + return value diff --git a/src/equasis_cli/output.py b/src/equasis_cli/output.py new file mode 100644 index 0000000..728c3f1 --- /dev/null +++ b/src/equasis_cli/output.py @@ -0,0 +1,731 @@ +"""Render results as table, JSON, JSON Lines, or CSV, and write them to files. + +Table output is meant for people; JSON, JSON Lines and CSV are stable, +machine-readable formats (see ``docs/output-schema.md``). +""" + +from __future__ import annotations + +import csv +import io +import json +import os +import uuid +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Any, Literal + +from .enrichment import VesselIndicators, compute_indicators +from .models import ( + SCHEMA_VERSION, + BatchItem, + CompanySummary, + Fleet, + ItemStatus, + SearchResults, + Vessel, + to_jsonable, +) + +FORMATS = ("table", "json", "jsonl", "csv") +SOURCE = "https://www.equasis.org" +TABLE_INSPECTION_LIMIT = 10 +TABLE_SIGHTING_LIMIT = 5 + +_EXTENSIONS = { + ".json": "json", + ".jsonl": "jsonl", + ".ndjson": "jsonl", + ".csv": "csv", + ".txt": "table", +} + + +@dataclass(kw_only=True) +class BatchReport: + """Results of a batch of vessel or fleet lookups.""" + + kind: Literal["vessels", "fleets"] + items: list[BatchItem[Any]] = field(default_factory=list) + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @property + def succeeded(self) -> int: + return sum(1 for item in self.items if item.status is ItemStatus.OK) + + @property + def failed(self) -> int: + return len(self.items) - self.succeeded + + +Renderable = Vessel | SearchResults | Fleet | BatchReport + + +# ------------------------------------------------------------------------ public API + + +def infer_format(path: str | os.PathLike[str] | None) -> str | None: + """Guess the output format from a file extension (``.json``, ``.csv``, ...).""" + if not path: + return None + return _EXTENSIONS.get(Path(path).suffix.lower()) + + +def resolve_format( + explicit: str | None, output_file: str | os.PathLike[str] | None, default: str = "table" +) -> str: + """Pick the output format: explicit choice, then file extension, then ``default``.""" + return explicit or infer_format(output_file) or default + + +def render(result: Renderable, fmt: str, *, retrieved_at: datetime | None = None) -> str: + """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) + if isinstance(result, SearchResults): + return _render_search(result, fmt, retrieved_at) + if isinstance(result, Fleet): + return _render_fleet(result, fmt, retrieved_at) + return _render_batch(result, fmt, retrieved_at) + + +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, today or date.today())) + + +def write_output(text: str, path: str | os.PathLike[str]) -> Path: + """Atomically write ``text`` to ``path`` as UTF-8, creating parent directories. + + The content is written to a temporary file that replaces the target only once + complete, so an interrupted run never leaves a truncated file behind. + """ + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + if text and not text.endswith("\n"): + text += "\n" + # A uniquely named sibling opened with "x" honours the user's umask, unlike + # tempfile.mkstemp, which always creates owner-only files. + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + try: + with open(temporary, "x", encoding="utf-8", newline="") as handle: + handle.write(text) + os.replace(temporary, target) + except BaseException: + temporary.unlink(missing_ok=True) + raise + return target + + +# --------------------------------------------------------------------------- helpers + + +def _envelope(kind: str, retrieved_at: datetime, **payload: Any) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "type": kind, + "retrieved_at": _timestamp(retrieved_at), + "source": SOURCE, + **{key: to_jsonable(value) for key, value in payload.items()}, + } + + +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" + + +def _dumps_line(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n" + + +def _csv(headers: Sequence[str], rows: Iterable[Sequence[Any]]) -> str: + buffer = io.StringIO() + writer = csv.writer(buffer, lineterminator="\n") + writer.writerow(headers) + for row in rows: + writer.writerow(["" if value is None else _csv_value(value) for value in row]) + return buffer.getvalue() + + +def _csv_value(value: Any) -> Any: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, date): + return value.isoformat() + return value + + +def _fmt(value: Any) -> str: + """Human-friendly value for table output.""" + if value is None or value == "": + return "-" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, int): + return f"{value:,}" + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def _table( + headers: Sequence[str], + rows: Sequence[Sequence[Any]], + *, + max_widths: dict[int, int] | None = None, + indent: str = " ", +) -> list[str]: + max_widths = max_widths or {} + text_rows = [[_fmt(cell) for cell in row] for row in rows] + for row in text_rows: + for index, limit in max_widths.items(): + if len(row[index]) > limit: + row[index] = row[index][: limit - 3] + "..." + widths = [len(h) for h in headers] + for row in text_rows: + widths = [max(w, len(cell)) for w, cell in zip(widths, row, strict=True)] + lines = [ + indent + + " ".join(h.upper().ljust(w) for h, w in zip(headers, widths, strict=True)).rstrip() + ] + for row in text_rows: + lines.append( + indent + " ".join(c.ljust(w) for c, w in zip(row, widths, strict=True)).rstrip() + ) + return lines + + +def _section(lines: list[str], title: str) -> None: + lines.extend(["", title]) + + +def _key_values(pairs: Sequence[tuple[str, Any]]) -> list[str]: + width = max(len(label) for label, _ in pairs) + return [f" {label.ljust(width)} {_fmt(value)}" for label, value in pairs] + + +# -------------------------------------------------------------------------- vessels + +_VESSEL_CSV_HEADERS = ( + "imo", + "name", + "flag", + "flag_code", + "call_sign", + "mmsi", + "gross_tonnage", + "deadweight", + "ship_type", + "year_built", + "status", + "status_since", + "particulars_updated", + "paris_mou", + "tokyo_mou", + "uscg_targeted", + "registered_owner", + "ship_manager", + "ism_manager", + "class_societies", + "pandi", + "inspections", + "detentions", + "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", +) + + +def _company_for_role(vessel: Vessel, role_prefix: str) -> str | None: + names = [c.name for c in vessel.management if c.role.lower().startswith(role_prefix)] + return "; ".join(dict.fromkeys(names)) or None + + +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, + vessel.name, + vessel.flag, + vessel.flag_code, + vessel.call_sign, + vessel.mmsi, + vessel.gross_tonnage, + vessel.deadweight, + vessel.ship_type, + vessel.year_built, + vessel.status, + vessel.status_since, + vessel.particulars_updated, + vessel.flag_performance.paris_mou, + vessel.flag_performance.tokyo_mou, + vessel.flag_performance.uscg_targeted, + _company_for_role(vessel, "registered owner"), + _company_for_role(vessel, "ship manager"), + _company_for_role(vessel, "ism manager"), + "; ".join(s.society for s in vessel.class_status if s.status != "Withdrawn") or None, + "; ".join(p.name for p in vessel.pandi) or None, + len(vessel.inspections), + sum(1 for i in vessel.inspections if i.detained), + 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: + 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, today)]) + return "\n".join(_vessel_table(vessel, compute_indicators(vessel, today))) + "\n" + + +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()})" + flag = vessel.flag + if flag and vessel.flag_code: + flag = f"{flag} ({vessel.flag_code})" + + title = f"{vessel.name} (IMO {vessel.imo})" + lines = [title, "=" * len(title)] + lines += _key_values( + [ + ("Flag", flag), + ("Type", vessel.ship_type), + ("Status", status), + ("Year built", vessel.year_built and str(vessel.year_built)), + ("Gross tonnage", vessel.gross_tonnage), + ("Deadweight", vessel.deadweight), + ("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( + [ + ("Paris MoU list", performance.paris_mou), + ("Tokyo MoU list", performance.tokyo_mou), + ("USCG targeted flag", performance.uscg_targeted), + ("IACS classed", performance.iacs_classed), + ( + "Detention rate (36 months)", + None + if performance.detention_rate_36_months is None + else f"{performance.detention_rate_36_months:.1f}%", + ), + ] + ) + + if vessel.management: + _section(lines, f"Management ({len(vessel.management)})") + lines += _table( + ("Role", "Company", "Company no.", "Since"), + [(c.role, c.name, c.company_id, c.since) for c in vessel.management], + max_widths={1: 45}, + ) + + if vessel.class_status: + _section(lines, "Classification") + lines += _table( + ("Society", "Status", "Since", "Reason"), + [(c.society, c.status, c.since, c.reason) for c in vessel.class_status], + max_widths={0: 45, 3: 45}, + ) + if vessel.class_surveys: + _section(lines, "Class renewal surveys") + lines += _table( + ("Society", "Last renewal", "Next renewal"), + [(s.society, s.last_renewal, s.next_renewal) for s in vessel.class_surveys], + ) + if vessel.safety_certificates: + _section(lines, "Safety management certificates") + lines += _table( + ("Society", "Status", "Surveyed", "Expires", "Type"), + [ + (c.society, c.status, c.survey_date, c.expiry_date, c.convention) + for c in vessel.safety_certificates + ], + max_widths={0: 45}, + ) + if vessel.pandi: + _section(lines, "P&I insurance") + lines += _table(("Insurer", "Inception"), [(p.name, p.inception) for p in vessel.pandi]) + + if vessel.inspections: + detentions = sum(1 for i in vessel.inspections if i.detained) + shown = vessel.inspections[:TABLE_INSPECTION_LIMIT] + _section( + lines, + f"PSC inspections ({len(vessel.inspections)} total, {detentions} with detention)", + ) + lines += _table( + ("Date", "Authority", "Port", "Detained", "Deficiencies", "Regime"), + [ + ( + i.date, + i.authority, + i.port, + i.detained, + i.deficiencies, + ", ".join(r.psc_organisation or "-" for r in i.reports), + ) + for i in shown + ], + max_widths={2: 30}, + ) + if len(vessel.inspections) > len(shown): + remaining = len(vessel.inspections) - len(shown) + lines.append(f" ... {remaining} older inspections (use --format json to see all)") + + if vessel.name_history: + _section(lines, "Name history") + lines += _table( + ("Name", "Since", "Until"), [(n.name, n.since, n.until) for n in vessel.name_history] + ) + if vessel.flag_history: + _section(lines, "Flag history") + lines += _table( + ("Flag", "Since", "Until"), [(f.flag, f.since, f.until) for f in vessel.flag_history] + ) + if vessel.company_history: + _section(lines, "Company history") + lines += _table( + ("Role", "Company", "Since", "Until"), + [(c.role, c.company, c.since, c.until) for c in vessel.company_history], + max_widths={1: 45}, + ) + if vessel.sightings: + _section(lines, "Recent sightings") + lines += _table( + ("Period", "Areas", "Source"), + [ + (s.period, ", ".join(s.areas), s.source) + for s in vessel.sightings[:TABLE_SIGHTING_LIMIT] + ], + max_widths={1: 60}, + ) + if vessel.warnings: + _section(lines, "Warnings") + lines += [f" ! {warning}" for warning in vessel.warnings] + return lines + + +# --------------------------------------------------------------------------- search + + +def _render_search(results: SearchResults, fmt: str, retrieved_at: datetime) -> str: + if fmt == "json": + return _dumps(_envelope("search", retrieved_at, search=results)) + if fmt == "jsonl": + records = [{"kind": "ship", **to_jsonable(s)} for s in results.ships] + records += [{"kind": "company", **to_jsonable(c)} for c in results.companies] + return "".join(_dumps_line(r) for r in records) + if fmt == "csv": + headers = ( + "kind", + "imo", + "company_id", + "name", + "gross_tonnage", + "ship_type", + "year_built", + "flag", + "address", + ) + rows: list[list[Any]] = [ + ["ship", s.imo, None, s.name, s.gross_tonnage, s.ship_type, s.year_built, s.flag, None] + for s in results.ships + ] + rows += [ + ["company", None, c.company_id, c.name, None, None, None, None, c.address] + for c in results.companies + ] + return _csv(headers, rows) + + lines: list[str] = [] + if results.ships: + total = results.total_ships or len(results.ships) + suffix = f", showing {len(results.ships)}" if total > len(results.ships) else "" + lines.append(f"Ships matching '{results.query}' ({total}{suffix})") + lines += _table( + ("IMO", "Name", "Type", "Built", "Gross tonnage", "Flag"), + [ + ( + s.imo, + s.name, + s.ship_type, + s.year_built and str(s.year_built), + s.gross_tonnage, + s.flag, + ) + for s in results.ships + ], + max_widths={2: 35}, + ) + if results.companies: + if lines: + lines.append("") + lines.append(f"Companies matching '{results.query}' ({len(results.companies)})") + lines += _companies_table(results.companies) + if not lines: + lines.append(f"No ships or companies match '{results.query}'") + return "\n".join(lines) + "\n" + + +def _companies_table(companies: Sequence[CompanySummary]) -> list[str]: + return _table( + ("Company no.", "Name", "Address"), + [(c.company_id, c.name, c.address) for c in companies], + max_widths={1: 45, 2: 60}, + ) + + +def render_company_choices(companies: Sequence[CompanySummary]) -> str: + """Numbered list of companies for disambiguation prompts.""" + rows = [(str(n), c.company_id, c.name, c.address) for n, c in enumerate(companies, start=1)] + return "\n".join(_table(("#", "Company no.", "Name", "Address"), rows, max_widths={3: 60})) + + +# ---------------------------------------------------------------------------- fleet + +_FLEET_CSV_HEADERS = ( + "company_id", + "company_name", + "imo", + "name", + "gross_tonnage", + "ship_type", + "year_built", + "flag", + "class_societies", + "detentions_as_ism_manager_3y", + "detentions_all_companies_3y", + "roles", +) + + +def _fleet_csv_rows(fleet: Fleet) -> list[list[Any]]: + return [ + [ + fleet.company.company_id, + fleet.company.name, + v.imo, + v.name, + v.gross_tonnage, + v.ship_type, + v.year_built, + v.flag, + "; ".join(v.class_societies) or None, + v.detentions_as_ism_manager_3y, + v.detentions_all_companies_3y, + "; ".join( + f"{r.role} (since {r.since.isoformat()})" if r.since else r.role for r in v.roles + ) + or None, + ] + for v in fleet.vessels + ] + + +def _render_fleet(fleet: Fleet, fmt: str, retrieved_at: datetime) -> str: + if fmt == "json": + return _dumps(_envelope("fleet", retrieved_at, fleet=fleet)) + if fmt == "jsonl": + owner = {"company_id": fleet.company.company_id, "company_name": fleet.company.name} + return "".join(_dumps_line({**owner, **to_jsonable(v)}) for v in fleet.vessels) + if fmt == "csv": + return _csv(_FLEET_CSV_HEADERS, _fleet_csv_rows(fleet)) + + company = fleet.company + title = f"{company.name} (company no. {company.company_id})" + lines = [title, "=" * len(title)] + lines += _key_values( + [ + ("Address", company.address), + ("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): + lines.append(f" (showing {len(fleet.vessels)} of {fleet.total_vessels})") + if fleet.vessels: + lines.append("") + lines += _table( + ("IMO", "Name", "Type", "Built", "Gross tonnage", "Flag", "Detentions (3y)", "Roles"), + [ + ( + v.imo, + v.name, + v.ship_type, + v.year_built and str(v.year_built), + v.gross_tonnage, + v.flag, + v.detentions_all_companies_3y, + ", ".join(r.role for r in v.roles), + ) + for v in fleet.vessels + ], + max_widths={2: 30, 7: 50}, + ) + return "\n".join(lines) + "\n" + + +# ---------------------------------------------------------------------------- batch + + +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, + "status": item.status.value, + "error": item.error, + "warnings": item.warnings, + "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 + + +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, retrieved_at.date()) for item in report.items + ) + summary = { + "total": len(report.items), + "succeeded": report.succeeded, + "failed": report.failed, + "started_at": _timestamp(report.started_at), + } + return _dumps( + { + **_envelope(f"batch_{kind}", retrieved_at), + "summary": summary, + "results": [ + _batch_item_record(kind, item, retrieved_at.date()) for item in report.items + ], + } + ) + + if fmt == "csv": + if kind == "vessels": + rows = [] + for item in report.items: + vessel_row = ( + _vessel_csv_row(item.result, retrieved_at.date()) + if isinstance(item.result, Vessel) + else [None] * len(_VESSEL_CSV_HEADERS) + ) + rows.append([item.query, item.status.value, item.error, *vessel_row]) + return _csv(("query", "lookup_status", "error", *_VESSEL_CSV_HEADERS), rows) + fleet_rows: list[list[Any]] = [] + for item in report.items: + if isinstance(item.result, Fleet) and item.result.vessels: + fleet_rows += [ + [item.query, item.status.value, item.error, *row] + for row in _fleet_csv_rows(item.result) + ] + else: + fleet_rows.append( + [item.query, item.status.value, item.error] + [None] * len(_FLEET_CSV_HEADERS) + ) + return _csv(("query", "lookup_status", "error", *_FLEET_CSV_HEADERS), fleet_rows) + + noun = "vessel" if kind == "vessels" else "company" + lines = [f"Batch results: {report.succeeded} of {len(report.items)} {noun} lookups succeeded"] + rows_table: list[tuple[Any, ...]] + if kind == "vessels": + rows_table = [ + ( + item.query, + item.status.value, + item.result.name if isinstance(item.result, Vessel) else None, + item.result.flag if isinstance(item.result, Vessel) else None, + item.result.ship_type if isinstance(item.result, Vessel) else item.error, + ) + for item in report.items + ] + lines += _table( + ("IMO", "Status", "Name", "Flag", "Type / error"), rows_table, max_widths={4: 60} + ) + else: + rows_table = [ + ( + item.query, + item.status.value, + item.result.company.name if isinstance(item.result, Fleet) else None, + item.result.company.company_id if isinstance(item.result, Fleet) else None, + len(item.result.vessels) if isinstance(item.result, Fleet) else item.error, + ) + for item in report.items + ] + lines += _table( + ("Query", "Status", "Company", "Company no.", "Vessels / error"), + rows_table, + max_widths={0: 30, 2: 40, 4: 60}, + ) + return "\n".join(lines) + "\n" diff --git a/src/equasis_cli/parser.py b/src/equasis_cli/parser.py deleted file mode 100644 index eff0bc4..0000000 --- a/src/equasis_cli/parser.py +++ /dev/null @@ -1,480 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive Equasis Parser for extracting vessel data from all tabs -Handles Ship Info, Inspections, Ship History tabs plus common vessel information -""" - -import re -import json -from bs4 import BeautifulSoup -from typing import Dict, List, Optional, Any -from dataclasses import dataclass, asdict, field -from datetime import datetime - -# Data classes for structured vessel information -@dataclass -class VesselBasicInfo: - """Basic vessel information from infoship section""" - imo: str - name: str - flag: str - flag_code: Optional[str] = None - call_sign: Optional[str] = None - mmsi: Optional[str] = None - gross_tonnage: Optional[str] = None - dwt: Optional[str] = None - vessel_type: Optional[str] = None - year_built: Optional[str] = None - status: Optional[str] = None - status_date: Optional[str] = None - last_update: Optional[str] = None - -@dataclass -class CompanyInfo: - """Company management information""" - imo: Optional[str] - name: str - role: str - address: Optional[str] = None - date_effect: Optional[str] = None - -@dataclass -class ClassificationInfo: - """Classification society information""" - society: str - status: str - date_effect: Optional[str] = None - -@dataclass -class GeographicalInfo: - """Geographical location information""" - date: str - area: str - source: str - -@dataclass -class InspectionInfo: - """Port State Control inspection information""" - authority: Optional[str] - port: Optional[str] - date: str - detention: str - psc_organization: str - inspection_type: Optional[str] = None - duration: Optional[str] = None - deficiencies: Optional[str] = None - inspection_id: Optional[str] = None - -@dataclass -class HistoricalName: - """Historical vessel name""" - name: str - date_effect: str - source: str - -@dataclass -class HistoricalFlag: - """Historical flag registration""" - flag: str - date_effect: str - source: str - -@dataclass -class HistoricalCompany: - """Historical company management""" - company: str - role: str - date_effect: str - source: str - -@dataclass -class EquasisVesselData: - """Complete vessel data from Equasis""" - # Basic info (always present) - basic_info: VesselBasicInfo - - # Ship Info tab data - overview: Optional[Dict[str, Any]] = None - management: List[CompanyInfo] = field(default_factory=list) - classification: List[ClassificationInfo] = field(default_factory=list) - geographical: List[GeographicalInfo] = field(default_factory=list) - - # Inspections tab data - inspections: List[InspectionInfo] = field(default_factory=list) - - # Ship History tab data - historical_names: List[HistoricalName] = field(default_factory=list) - historical_flags: List[HistoricalFlag] = field(default_factory=list) - historical_companies: List[HistoricalCompany] = field(default_factory=list) - - -class EquasisParser: - """Comprehensive parser for Equasis vessel information""" - - def __init__(self): - self.debug = False - - def parse_html(self, html: str, tab: str = "ship_info") -> Optional[EquasisVesselData]: - """ - Parse Equasis HTML content based on the active tab - - Args: - html: HTML content from Equasis - tab: Which tab is active ("ship_info", "inspections", "ship_history") - - Returns: - EquasisVesselData object or None if parsing fails - """ - soup = BeautifulSoup(html, 'html.parser') - - # First, extract basic info that's present on all tabs - basic_info = self._parse_basic_info(soup) - if not basic_info: - print("Failed to extract basic vessel information") - return None - - # Create the vessel data object - vessel_data = EquasisVesselData(basic_info=basic_info) - - # Parse tab-specific content based on which tab is active - if tab == "ship_info": - self._parse_ship_info_tab(soup, vessel_data) - elif tab == "inspections": - self._parse_inspections_tab(soup, vessel_data) - elif tab == "ship_history": - self._parse_ship_history_tab(soup, vessel_data) - - return vessel_data - - def _parse_basic_info(self, soup: BeautifulSoup) -> Optional[VesselBasicInfo]: - """Parse basic vessel info from the infoship section (present on all tabs)""" - - # Initialize data dictionary - data = {} - - # Method 1: Extract vessel name and IMO from h4 tag - h4 = soup.find('h4', class_='color-gris-bleu-copyright') - if h4: - # Extract name from first tag - name_tag = h4.find('b') - if name_tag: - data['name'] = name_tag.get_text(strip=True) - - # Extract IMO from second tag or from text - imo_match = re.search(r'IMO[^0-9]*(\d{7})', h4.get_text()) - if imo_match: - data['imo'] = imo_match.group(1) - - # Method 2: Parse vessel details from row structure - rows = soup.find_all('div', class_='row') - for row in rows: - cols = row.find_all('div', class_=re.compile(r'col-')) - if len(cols) >= 2: - # Check if first column has a bold label - label_tag = cols[0].find('b') - if label_tag: - label = label_tag.get_text(strip=True).lower() - value = cols[1].get_text(strip=True) if len(cols) > 1 else '' - - # Map labels to data fields - if 'flag' in label: - # Check for flag image - img = cols[1].find('img') - if img and 'src' in img.attrs: - # Extract flag code from image src (e.g., /flags/KNA.png) - flag_match = re.search(r'/flags/([A-Z]+)\.', img['src']) - if flag_match: - data['flag_code'] = flag_match.group(1) - - # Flag country name might be in third column - if len(cols) > 2: - third = cols[2].get_text(strip=True) - if third.startswith('(') and third.endswith(')'): - data['flag'] = third.strip('()') - - elif 'call sign' in label: - data['call_sign'] = value - - elif 'mmsi' in label: - data['mmsi'] = value - - elif 'gross tonnage' in label: - data['gross_tonnage'] = value - # Check for date in third column - if len(cols) > 2: - third = cols[2].get_text(strip=True) - if 'during' in third: - data['gt_date'] = third - - elif 'dwt' in label: - data['dwt'] = value - - elif 'type of ship' in label: - data['vessel_type'] = value - - elif 'year of build' in label or 'year built' in label: - data['year_built'] = value - - elif label == 'status': - data['status'] = value - # Check for date in third column - if len(cols) > 2: - third = cols[2].get_text(strip=True) - if 'since' in third or 'during' in third: - data['status_date'] = third - - # Look for last update date - update_badge = soup.find('p', class_='badge gris-bleu-copyright badge-notification') - if update_badge: - update_text = update_badge.get_text(strip=True) - date_match = re.search(r'(\d{2}/\d{2}/\d{4})', update_text) - if date_match: - data['last_update'] = date_match.group(1) - - # Create VesselBasicInfo object if we have minimum required data - if data.get('name') and data.get('imo'): - return VesselBasicInfo( - imo=data.get('imo', ''), - name=data.get('name', ''), - flag=data.get('flag', ''), - flag_code=data.get('flag_code'), - call_sign=data.get('call_sign'), - mmsi=data.get('mmsi'), - gross_tonnage=data.get('gross_tonnage'), - dwt=data.get('dwt'), - vessel_type=data.get('vessel_type'), - year_built=data.get('year_built'), - status=data.get('status'), - status_date=data.get('status_date'), - last_update=data.get('last_update') - ) - - return None - - def _parse_ship_info_tab(self, soup: BeautifulSoup, vessel_data: EquasisVesselData): - """Parse Ship Info tab content""" - - # Parse Overview section - overview_section = soup.find('div', id='collapse1') - if overview_section: - vessel_data.overview = self._parse_overview(overview_section) - - # Parse Management Details - mgmt_section = soup.find('div', id='collapse3') - if mgmt_section: - vessel_data.management = self._parse_management(mgmt_section) - - # Parse Classification - class_section = soup.find('div', id='collapse4') - if class_section: - vessel_data.classification = self._parse_classification(class_section) - - # Parse Geographical Information - geo_section = soup.find('div', id='collapse7') - if geo_section: - vessel_data.geographical = self._parse_geographical(geo_section) - - def _parse_overview(self, section) -> Dict[str, Any]: - """Parse overview section with flag performance and targeting""" - overview = {} - - # Look for flag performance badges - badges = section.find_all('div', class_='badge bleu-equasis') - for badge in badges: - text = badge.get_text(strip=True) - - # Parse Paris MOU - if 'Paris MOU' in text: - # Look for color indicator - color_div = badge.find('div', class_=re.compile(r'round-\d+')) - if color_div: - classes = ' '.join(color_div.get('class', [])) - if 'black' in classes: - overview['paris_mou'] = 'Black' - elif 'grey' in classes or 'gray' in classes: - overview['paris_mou'] = 'Grey' - elif 'white' in classes: - overview['paris_mou'] = 'White' - - # Parse Tokyo MOU - elif 'Tokyo MOU' in text or 'Tokyo MoU' in text: - color_div = badge.find('div', class_=re.compile(r'round-\d+')) - if color_div: - classes = ' '.join(color_div.get('class', [])) - if 'black' in classes: - overview['tokyo_mou'] = 'Black' - elif 'grey' in classes or 'gray' in classes: - overview['tokyo_mou'] = 'Grey' - elif 'white' in classes: - overview['tokyo_mou'] = 'White' - - # Parse USCG targeting - elif 'USCG' in text: - if 'not targeted' in text: - overview['uscg_targeting'] = 'Not targeted' - else: - overview['uscg_targeting'] = 'Targeted' - - return overview - - def _parse_management(self, section) -> List[CompanyInfo]: - """Parse management companies from table""" - companies = [] - - # Look for table - table = section.find('table', class_='tableLS') - if table: - tbody = table.find('tbody') - if tbody: - for row in tbody.find_all('tr'): - cells = row.find_all('td') - if len(cells) >= 5: - company = CompanyInfo( - imo=cells[0].get_text(strip=True) if cells[0] else None, - name=cells[2].get_text(strip=True) if cells[2] else '', - role=cells[1].get_text(strip=True) if cells[1] else '', - address=cells[3].get_text(strip=True) if cells[3] else None, - date_effect=cells[4].get_text(strip=True) if cells[4] else None - ) - companies.append(company) - - return companies - - def _parse_classification(self, section) -> List[ClassificationInfo]: - """Parse classification society information""" - classifications = [] - - # Look for classification entries - items = section.find_all('div', class_='access-body') - for item in items: - # Look for society name - society_p = item.find('p') - if society_p: - society_text = society_p.get_text(strip=True) - - # Look for status badge - status_badge = item.find('span', class_='badge') - status = status_badge.get_text(strip=True) if status_badge else '' - - # Look for date - date_p = item.find_all('p') - date_text = '' - for p in date_p: - text = p.get_text(strip=True) - if 'during' in text or 'since' in text: - date_text = text - break - - if society_text: - classifications.append(ClassificationInfo( - society=society_text, - status=status, - date_effect=date_text - )) - - return classifications - - def _parse_geographical(self, section) -> List[GeographicalInfo]: - """Parse geographical location history""" - locations = [] - - # Look for table - table = section.find('table', class_='tableLS') - if table: - tbody = table.find('tbody') - if tbody: - for row in tbody.find_all('tr'): - cells = row.find_all('td') - if len(cells) >= 3: - locations.append(GeographicalInfo( - date=cells[0].get_text(strip=True), - area=cells[1].get_text(strip=True), - source=cells[2].get_text(strip=True) - )) - - return locations - - def _parse_inspections_tab(self, soup: BeautifulSoup, vessel_data: EquasisVesselData): - """Parse Inspections tab content""" - - # Find PSC inspections section - psc_section = soup.find('div', id='collapse1DD') - if not psc_section: - # Try alternative ID - psc_section = soup.find('div', class_='tableLSDD') - - if psc_section: - # Look for inspection table - table = psc_section.find('table', class_='tableLSDD') or psc_section.find('table', class_='table') - if table: - tbody = table.find('tbody') - if tbody: - for row in tbody.find_all('tr'): - cells = row.find_all('td') - if len(cells) >= 5: - # Extract inspection ID from onclick if available - insp_id = None - onclick = row.get('onclick') or (cells[-1].find('a', onclick=True) if cells else None) - if onclick: - id_match = re.search(r"P_INSP\.value='(\d+)'", str(onclick)) - if id_match: - insp_id = id_match.group(1) - - inspection = InspectionInfo( - authority=cells[0].get_text(strip=True) if cells[0] else None, - port=cells[1].get_text(strip=True) if cells[1] else None, - date=cells[2].get_text(strip=True) if len(cells) > 2 else '', - detention=cells[3].get_text(strip=True) if len(cells) > 3 else '', - psc_organization=cells[4].get_text(strip=True) if len(cells) > 4 else '', - inspection_type=cells[5].get_text(strip=True) if len(cells) > 5 else None, - duration=cells[6].get_text(strip=True) if len(cells) > 6 else None, - deficiencies=cells[7].get_text(strip=True) if len(cells) > 7 else None, - inspection_id=insp_id - ) - vessel_data.inspections.append(inspection) - - def _parse_ship_history_tab(self, soup: BeautifulSoup, vessel_data: EquasisVesselData): - """Parse Ship History tab content""" - - # Parse historical names - names_section = soup.find('div', id='collapse1') - if names_section: - table = names_section.find('table', class_='tableLS') - if table and table.find('tbody'): - for row in table.find('tbody').find_all('tr'): - cells = row.find_all('td') - if len(cells) >= 3: - vessel_data.historical_names.append(HistoricalName( - name=cells[0].get_text(strip=True), - date_effect=cells[1].get_text(strip=True), - source=cells[2].get_text(strip=True) - )) - - # Parse historical flags - flags_section = soup.find('div', id='collapse2') - if flags_section: - table = flags_section.find('table', class_='tableLS') - if table and table.find('tbody'): - for row in table.find('tbody').find_all('tr'): - cells = row.find_all('td') - if len(cells) >= 3: - vessel_data.historical_flags.append(HistoricalFlag( - flag=cells[0].get_text(strip=True), - date_effect=cells[1].get_text(strip=True), - source=cells[2].get_text(strip=True) - )) - - # Parse historical companies - companies_section = soup.find('div', id='collapse4') - if companies_section: - table = companies_section.find('table', class_='tableLS') - if table and table.find('tbody'): - for row in table.find('tbody').find_all('tr'): - cells = row.find_all('td') - if len(cells) >= 4: - vessel_data.historical_companies.append(HistoricalCompany( - company=cells[0].get_text(strip=True), - role=cells[1].get_text(strip=True), - date_effect=cells[2].get_text(strip=True), - source=cells[3].get_text(strip=True) - )) diff --git a/src/equasis_cli/parsing/__init__.py b/src/equasis_cli/parsing/__init__.py new file mode 100644 index 0000000..ef8820c --- /dev/null +++ b/src/equasis_cli/parsing/__init__.py @@ -0,0 +1,20 @@ +"""HTML parsers for Equasis pages.""" + +from .fleet import FleetPage, parse_fleet +from .page import PageKind, classify, is_logged_in +from .search import SearchPage, parse_search +from .ship import merge_vessel, parse_history, parse_inspections, parse_ship_info + +__all__ = [ + "FleetPage", + "PageKind", + "SearchPage", + "classify", + "is_logged_in", + "merge_vessel", + "parse_fleet", + "parse_history", + "parse_inspections", + "parse_search", + "parse_ship_info", +] diff --git a/src/equasis_cli/parsing/fleet.py b/src/equasis_cli/parsing/fleet.py new file mode 100644 index 0000000..9288459 --- /dev/null +++ b/src/equasis_cli/parsing/fleet.py @@ -0,0 +1,149 @@ +"""Parser for the company Fleet info page.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from bs4 import Tag +from bs4.element import Comment, NavigableString + +from ..exceptions import LayoutChangedError +from ..models import Company, FleetVessel, RoleSince +from .html import ( + TableRow, + clean, + desktop_table, + find_panel, + iter_rows, + make_soup, + normalize_header, + parse_date, + parse_int, + text_of, +) +from .page import require_content + +_COMPANY_HEADING = re.compile(r"IMO\D*(\d{7})") +_IMO = re.compile(r"\b(\d{7})\b") +_RESULTS = re.compile(r"(\d[\d,]*)\s+results? found", re.IGNORECASE) +_PAGE_LINK = re.compile(r"formFleet\.P_PAGE\.value\s*=\s*(\d+)\s*;") +_ROLE = re.compile(r"^(?P.+?)\s*\((?:since|during)\s+(?P[^)]*)\)\s*$") + +_IMO_HEADER = "(imo) ship's name" +_ISM_DETENTIONS = "detentions in last 3 years for this company (acting as ism manager)" +_ALL_DETENTIONS = "detentions in last 3 years for all companies (whatever their role)" + + +@dataclass +class FleetPage: + """One page of a company's fleet.""" + + company: Company + vessels: list[FleetVessel] = field(default_factory=list) + total_vessels: int | None = None + last_page: int = 1 + + +def parse_fleet(html: str) -> FleetPage: + """Parse a Fleet info page.""" + soup = make_soup(html) + require_content(soup, html, page="Fleet info") + + heading = soup.find("h4", class_="color-gris-bleu-copyright") + if not isinstance(heading, Tag): + raise LayoutChangedError("Fleet info page has no company heading") + name_tag = heading.find("b") + id_match = _COMPANY_HEADING.search(heading.get_text(" ")) + name = text_of(name_tag if isinstance(name_tag, Tag) else None) + if not name or not id_match: + raise LayoutChangedError("Fleet info heading does not contain a company name and number") + + company = Company(company_id=id_match.group(1), name=name) + container = heading.find_parent("div", class_="col-lg-8") + if isinstance(container, Tag): + for info_row in container.find_all("div", class_="row"): + columns = list(info_row.find_all("div", recursive=False)) + if len(columns) < 2 or not columns[0].find("b"): + continue + label = normalize_header(columns[0].get_text(" ")) + if label == "address": + company.address = text_of(columns[1]) + elif label == "status": + company.status = text_of(columns[1]) + badge = container.find("p", class_="badge-notification") + if isinstance(badge, Tag): + company.last_update = parse_date(badge.get_text(" ")) + + page = FleetPage(company=company) + table = desktop_table(find_panel(soup, "fleet")) + if table is None: + raise LayoutChangedError("Fleet info page has no fleet table") + + for row in iter_rows(table, required=(_IMO_HEADER, "gross tonnage")): + first = row.cells.get(_IMO_HEADER) + if first is None: + continue + link = first.find("a") + imo_match = _IMO.search(link.get_text() if isinstance(link, Tag) else first.get_text()) + if not imo_match: + continue + imo = imo_match.group(1) + cell_text = clean(first.get_text(" ")) or "" + name = clean(cell_text.split(imo, 1)[-1].lstrip(" )")) or "" + + page.vessels.append( + FleetVessel( + imo=imo, + name=name, + gross_tonnage=parse_int(row.text("gross tonnage")), + ship_type=row.text("ship's type"), + year_built=parse_int(row.text("year of build")), + flag=row.text("current flag"), + class_societies=_lines(row.cells.get("current class(es)")), + detentions_as_ism_manager_3y=_count(row, _ISM_DETENTIONS), + detentions_all_companies_3y=_count(row, _ALL_DETENTIONS), + roles=[_role(line) for line in _lines(row.cells.get("acting as (since)"))], + ) + ) + + results = soup.find("div", class_="results") + if isinstance(results, Tag) and (match := _RESULTS.search(text_of(results) or "")): + page.total_vessels = parse_int(match.group(1)) + page.last_page = max([1, *(int(n) for n in _PAGE_LINK.findall(html))]) + return page + + +def _count(row: TableRow, header: str) -> int | None: + """Detention counts are left blank when there were none.""" + if header not in row.cells: + return None + return parse_int(row.text(header)) or 0 + + +def _lines(cell: Tag | None) -> list[str]: + """Split a cell into the lines separated by ``
`` tags. + + Text between two ``
`` tags forms one line even if the markup itself wraps + it across several source lines. + """ + if cell is None: + return [] + lines: list[str] = [] + current: list[str] = [] + for node in cell.descendants: + if isinstance(node, Tag): + if node.name == "br": + lines.append(" ".join(current)) + current = [] + elif isinstance(node, NavigableString) and not isinstance(node, Comment): + current.append(str(node)) + lines.append(" ".join(current)) + return [text for line in lines if (text := clean(line))] + + +def _role(line: str) -> RoleSince: + match = _ROLE.match(line) + if not match: + return RoleSince(role=line) + return RoleSince(role=match.group("role"), since=parse_date(match.group("since"))) diff --git a/src/equasis_cli/parsing/html.py b/src/equasis_cli/parsing/html.py new file mode 100644 index 0000000..7ef83f9 --- /dev/null +++ b/src/equasis_cli/parsing/html.py @@ -0,0 +1,204 @@ +"""Low-level HTML helpers shared by the page parsers.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from datetime import date + +from bs4 import BeautifulSoup, Tag + +from ..exceptions import LayoutChangedError + +_WHITESPACE = re.compile(r"\s+") +_DMY = re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b") +_ISO = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b") +_MY = re.compile(r"\b(\d{1,2})/(\d{4})\b") +_MOBILE_ONLY = {"hidden-lg", "hidden-md"} + + +def make_soup(html: str) -> BeautifulSoup: + """Parse HTML with the standard library parser (no optional dependencies).""" + return BeautifulSoup(html, "html.parser") + + +def clean(text: str | None) -> str | None: + """Collapse whitespace; return ``None`` for empty strings.""" + if text is None: + return None + collapsed = _WHITESPACE.sub(" ", text).strip() + return collapsed or None + + +def text_of(tag: Tag | None, separator: str = " ") -> str | None: + """Return the normalized text content of ``tag``.""" + if tag is None: + return None + return clean(tag.get_text(separator)) + + +def parse_int(text: str | None) -> int | None: + """Parse an integer such as ``"219079"`` or ``"1,234"``; ``None`` if absent.""" + if not text: + return None + digits = re.sub(r"[,\s]", "", text) + return int(digits) if digits.isdigit() else None + + +def parse_float(text: str | None) -> float | None: + """Parse the first decimal number in ``text`` (for example ``"0.0%"``).""" + if not text: + return None + match = re.search(r"\d+(?:\.\d+)?", text) + return float(match.group()) if match else None + + +def parse_date(text: str | None) -> date | None: + """Parse the date formats Equasis uses. + + Supports ``DD/MM/YYYY`` (optionally prefixed with "since"), ``YYYY-MM-DD``, and + month precision ``MM/YYYY`` ("during 09/2024"), which maps to the first day of + that month. + """ + if not text: + return None + try: + if match := _DMY.search(text): + day, month, year = (int(g) for g in match.groups()) + return date(year, month, day) + if match := _ISO.search(text): + year, month, day = (int(g) for g in match.groups()) + return date(year, month, day) + if match := _MY.search(text): + month, year = (int(g) for g in match.groups()) + return date(year, month, 1) + except ValueError: + return None + return None + + +def is_mobile_only(tag: Tag) -> bool: + """True for elements Equasis renders only on small screens (duplicates).""" + return _MOBILE_ONLY.issubset(set(class_list(tag))) + + +def class_list(tag: Tag) -> list[str]: + """The CSS classes of ``tag`` as a list.""" + value = tag.get("class") + if value is None: + return [] + return [value] if isinstance(value, str) else list(value) + + +def paragraph_texts(tag: Tag) -> list[str]: + """Non-empty, normalized text of every ``

`` inside ``tag``.""" + return [text for p in tag.find_all("p") if (text := clean(p.get_text(" ")))] + + +def normalize_header(text: str | None) -> str: + return (clean(text) or "").lower() + + +@dataclass +class TableRow: + """A table row with cells keyed by normalized header text. + + ``continuation`` is true when the row inherited cells from a ``rowspan`` in a + previous row (for example an inspection reported to a second PSC regime). + """ + + cells: dict[str, Tag] + own_cells: dict[str, Tag] + continuation: bool + + def text(self, header: str) -> str | None: + return text_of(self.cells.get(header)) + + def own_text(self, header: str) -> str | None: + return text_of(self.own_cells.get(header)) + + +def table_headers(table: Tag) -> list[str]: + head = table.find("thead") + header_row = head if isinstance(head, Tag) else table + return [normalize_header(th.get_text(" ")) for th in header_row.find_all("th")] + + +def iter_rows(table: Tag, *, required: Iterable[str] = ()) -> Iterator[TableRow]: + """Yield desktop rows of ``table`` keyed by header, expanding ``rowspan`` cells. + + Equasis emits invalid markup where a row spanned by ``rowspan`` is nested + inside the previous ````, so rows are collected recursively and only their + direct ```` children are used. + + Raises: + LayoutChangedError: if any ``required`` header is missing. + """ + headers = table_headers(table) + missing = [h for h in required if h not in headers] + if missing: + raise LayoutChangedError( + f"table is missing expected columns {missing}; found {headers or 'no headers'}" + ) + + body = table.find("tbody") + container = body if isinstance(body, Tag) else table + pending: dict[int, tuple[Tag, int]] = {} + + for tr in container.find_all("tr"): + if not isinstance(tr, Tag) or is_mobile_only(tr): + continue + own = [c for c in tr.find_all(["td", "th"], recursive=False) if isinstance(c, Tag)] + if all(cell.name == "th" for cell in own) and not pending: + continue # header row, or a row with no cells + + cells: dict[str, Tag] = {} + own_cells: dict[str, Tag] = {} + continuation = False + own_iter = iter(own) + for index, header in enumerate(headers): + if index in pending: + cell, remaining = pending[index] + cells[header] = cell + continuation = True + if remaining <= 1: + del pending[index] + else: + pending[index] = (cell, remaining - 1) + continue + td = next(own_iter, None) + if td is None: + break + cells[header] = td + own_cells[header] = td + span = parse_int(str(td.get("rowspan", "1"))) or 1 + if span > 1: + pending[index] = (td, span - 1) + + if own_cells: + yield TableRow(cells=cells, own_cells=own_cells, continuation=continuation) + + +def find_panel(soup: BeautifulSoup, title_prefix: str) -> Tag | None: + """Find a collapsible panel by the text of the link that toggles it. + + Panel ids (``collapse3``...) are positional and differ between pages, so the + visible title is the stable key. + """ + wanted = title_prefix.lower() + for link in soup.find_all("a", href=re.compile(r"^#collapse")): + title = normalize_header(link.get_text(" ")) + if title.startswith(wanted): + panel = soup.find(id=str(link["href"])[1:]) + if isinstance(panel, Tag): + return panel + return None + + +def desktop_table(panel: Tag | None) -> Tag | None: + """Return the first table inside ``panel``, if any.""" + if panel is None: + return None + table = panel.find("table") + return table if isinstance(table, Tag) else None diff --git a/src/equasis_cli/parsing/page.py b/src/equasis_cli/parsing/page.py new file mode 100644 index 0000000..1f5ec66 --- /dev/null +++ b/src/equasis_cli/parsing/page.py @@ -0,0 +1,62 @@ +"""Classify Equasis responses before parsing them. + +Equasis answers almost everything with HTTP 200, so the page body is the only +reliable signal for an expired session, a missing record, or an error page. +""" + +from __future__ import annotations + +import enum + +from bs4 import BeautifulSoup, Tag + +from ..exceptions import EquasisError, NotFoundError, SessionExpiredError +from .html import clean, make_soup + + +class PageKind(enum.Enum): + """What kind of page Equasis returned.""" + + LOGIN = "login" + """The login form: the session is missing, expired, or credentials were rejected.""" + NOT_FOUND = "not_found" + """The general error page reporting that nothing matched.""" + ERROR = "error" + """Any other general error page.""" + CONTENT = "content" + """A regular page.""" + + +def page_title(soup: BeautifulSoup) -> str: + title = soup.find("title") + return (clean(title.get_text()) if isinstance(title, Tag) else None) or "" + + +def classify(html: str, soup: BeautifulSoup | None = None) -> PageKind: + """Classify an Equasis response body.""" + soup = soup if soup is not None else make_soup(html) + if soup.find("input", attrs={"name": "j_password"}) is not None: + return PageKind.LOGIN + if "CtrlGeneralError" in page_title(soup): + text = (clean(soup.get_text(" ")) or "").lower() + if "has been found" in text: + return PageKind.NOT_FOUND + return PageKind.ERROR + return PageKind.CONTENT + + +def is_logged_in(html: str) -> bool: + """True if the page was rendered for an authenticated user.""" + soup = make_soup(html) + return classify(html, soup) is not PageKind.LOGIN and "Logout" in html + + +def require_content(soup: BeautifulSoup, html: str, *, page: str) -> None: + """Raise a specific error unless ``html`` is a regular content page.""" + kind = classify(html, soup) + if kind is PageKind.LOGIN: + raise SessionExpiredError(f"Equasis returned the login page instead of {page}") + if kind is PageKind.NOT_FOUND: + raise NotFoundError(f"Equasis found no record for the {page} request") + if kind is PageKind.ERROR: + raise EquasisError(f"Equasis returned an error page for the {page} request") diff --git a/src/equasis_cli/parsing/search.py b/src/equasis_cli/parsing/search.py new file mode 100644 index 0000000..8d7c8a7 --- /dev/null +++ b/src/equasis_cli/parsing/search.py @@ -0,0 +1,109 @@ +"""Parser for the search results page (ships and companies).""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TypeVar + +from bs4 import BeautifulSoup, Tag + +from ..models import CompanySummary, ShipSummary +from .html import iter_rows, make_soup, parse_int, text_of +from .page import require_content + +_RESULTS = re.compile(r"(\d[\d,]*)\s+results? found", re.IGNORECASE) +_PAGE_LINK = re.compile(r"P_PAGE_(SHIP|COMP)\.value\s*=\s*(\d+)") +_SHIP_SECTION = "ShipResultId" +_COMPANY_SECTION = "CompanyResultId" + +_T = TypeVar("_T") + + +@dataclass +class SearchPage: + """One page of search results.""" + + ships: list[ShipSummary] = field(default_factory=list) + companies: list[CompanySummary] = field(default_factory=list) + total_ships: int | None = None + total_companies: int | None = None + last_ship_page: int = 1 + last_company_page: int = 1 + + +def parse_search(html: str) -> SearchPage: + """Parse a search results page.""" + soup = make_soup(html) + require_content(soup, html, page="Search") + page = SearchPage() + + ship_table = _section_table(soup, _SHIP_SECTION) + if ship_table is not None: + for row in iter_rows(ship_table, required=("imo number", "name of ship")): + imo = row.text("imo number") + name = row.text("name of ship") + if not imo or not name: + continue + page.ships.append( + ShipSummary( + imo=imo, + name=name, + gross_tonnage=parse_int(row.text("gross tonnage")), + ship_type=row.text("type of ship"), + year_built=parse_int(row.text("year of build")), + flag=row.text("flag"), + ) + ) + + company_table = _section_table(soup, _COMPANY_SECTION) + if company_table is not None: + for row in iter_rows(company_table, required=("company number", "name of company")): + company_id = row.text("company number") + name = row.text("name of company") + if not company_id or not name: + continue + page.companies.append( + CompanySummary(company_id=company_id, name=name, address=row.text("address")) + ) + + for results in soup.find_all("div", class_="results"): + match = _RESULTS.search(text_of(results) or "") + section = results.find_previous(id=(_SHIP_SECTION, _COMPANY_SECTION)) + if not match or not isinstance(section, Tag): + continue + total = parse_int(match.group(1)) + if section.get("id") == _SHIP_SECTION: + page.total_ships = total + else: + page.total_companies = total + + for kind, number in _PAGE_LINK.findall(html): + if kind == "SHIP": + page.last_ship_page = max(page.last_ship_page, int(number)) + else: + page.last_company_page = max(page.last_company_page, int(number)) + + page.ships = _dedupe(page.ships, key=lambda s: s.imo) + page.companies = _dedupe(page.companies, key=lambda c: c.company_id) + return page + + +def _section_table(soup: BeautifulSoup, section_id: str) -> Tag | None: + section = soup.find(id=section_id) + if not isinstance(section, Tag): + return None + table = section.find("table") + return table if isinstance(table, Tag) else None + + +def _dedupe(items: list[_T], key: Callable[[_T], str]) -> list[_T]: + seen: set[str] = set() + unique: list[_T] = [] + for item in items: + identity = key(item) + if identity not in seen: + seen.add(identity) + unique.append(item) + return unique diff --git a/src/equasis_cli/parsing/ship.py b/src/equasis_cli/parsing/ship.py new file mode 100644 index 0000000..5fb38d9 --- /dev/null +++ b/src/equasis_cli/parsing/ship.py @@ -0,0 +1,447 @@ +"""Parsers for the Ship Info, Inspections and Ship History pages.""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from datetime import date + +from bs4 import Tag + +from ..exceptions import LayoutChangedError +from ..models import ( + ClassChange, + ClassStatus, + ClassSurvey, + CompanyChange, + CompanyRole, + FlagChange, + FlagPerformance, + HumanElementDeficiency, + Inspection, + InspectionReport, + NameChange, + PandIInsurer, + SafetyCertificate, + Sighting, + Vessel, +) +from .html import ( + class_list, + clean, + desktop_table, + find_panel, + iter_rows, + make_soup, + normalize_header, + paragraph_texts, + parse_date, + parse_float, + parse_int, + text_of, +) +from .page import require_content + +_IMO_IN_HEADING = re.compile(r"IMO\D*(\d{7})") +_FLAG_IMAGE = re.compile(r"/flags/([A-Z]{3})\.", re.IGNORECASE) +_ONCLICK_ID = re.compile(r"P_(?:COMP|INSP)\.value\s*=\s*'(\d+)'") + +# Equasis cannot serve COM.png (a reserved file name on Windows), so the Comoros +# flag image is published as XCM.png. +_FLAG_CODE_ALIASES = {"XCM": "COM"} + +# ------------------------------------------------------------------------- ship info + + +def parse_ship_info(html: str) -> Vessel: + """Parse the Ship Info page into a :class:`Vessel` (without inspections or history).""" + soup = make_soup(html) + require_content(soup, html, page="Ship Info") + + heading = soup.find("h4", class_="color-gris-bleu-copyright") + if not isinstance(heading, Tag): + raise LayoutChangedError("Ship Info page has no vessel heading") + name_tag = heading.find("b") + imo_match = _IMO_IN_HEADING.search(heading.get_text(" ")) + name = text_of(name_tag if isinstance(name_tag, Tag) else None) + if not name or not imo_match: + raise LayoutChangedError("Ship Info heading does not contain a name and IMO number") + + vessel = Vessel(imo=imo_match.group(1), name=name) + _parse_particulars(heading, vessel) + + vessel.flag_performance = _parse_overview(find_panel(soup, "overview")) + vessel.management = _parse_management(find_panel(soup, "management detail")) + vessel.class_status, vessel.class_surveys = _parse_classification( + find_panel(soup, "classification") + ) + vessel.safety_certificates = _parse_safety_certificates( + find_panel(soup, "safety management certificate") + ) + vessel.pandi = _parse_pandi(find_panel(soup, "p&i information")) + vessel.sightings = _parse_sightings(find_panel(soup, "geographical information")) + return vessel + + +def _parse_particulars(heading: Tag, vessel: Vessel) -> None: + container = heading.find_parent("div", class_="col-lg-8") or heading.find_parent("div") + if not isinstance(container, Tag): + return + + for row in container.find_all("div", class_="row"): + columns = [ + col + for col in row.find_all("div", recursive=False) + if "hidden-lg" not in class_list(col) + ] + if len(columns) < 2 or not columns[0].find("b"): + continue + label = normalize_header(columns[0].get_text(" ")) + value = text_of(columns[1]) + extra = text_of(columns[2]) if len(columns) > 2 else None + + if label == "flag": + image = columns[1].find("img") + if isinstance(image, Tag): + code_match = _FLAG_IMAGE.search(str(image.get("src", ""))) + if code_match: + code = code_match.group(1).upper() + vessel.flag_code = _FLAG_CODE_ALIASES.get(code, code) + vessel.flag = _unwrap_parentheses(extra) or value + elif label == "call sign": + vessel.call_sign = value + elif label == "mmsi": + vessel.mmsi = value + elif label == "gross tonnage": + vessel.gross_tonnage = parse_int(value) + elif label == "dwt": + vessel.deadweight = parse_int(value) + elif label == "type of ship": + vessel.ship_type = value + elif label in ("year of build", "year built"): + vessel.year_built = parse_int(value) + elif label == "status": + vessel.status = value + vessel.status_since = parse_date(extra) + + badge = container.find("p", class_="badge-notification") + if isinstance(badge, Tag): + vessel.particulars_updated = parse_date(badge.get_text(" ")) + + +def _unwrap_parentheses(text: str | None) -> str | None: + """Turn ``"(Palau (Republic of))"`` into ``"Palau (Republic of)"``.""" + if text and text.startswith("(") and text.endswith(")"): + return clean(text[1:-1]) + return clean(text) + + +def _parse_overview(panel: Tag | None) -> FlagPerformance: + performance = FlagPerformance() + if panel is None: + return performance + text = text_of(panel) or "" + performance.iacs_classed = "classed by (at least) one of the IACS" in text + rate = re.search(r"(\d+(?:\.\d+)?)\s*%\s*Of inspections having led to a detention", text, re.I) + if rate: + performance.detention_rate_36_months = parse_float(rate.group(1)) + + for badge in panel.find_all("div", class_="badge"): + parts = paragraph_texts(badge) + if not parts: + continue + label = parts[0].lower() + if label.startswith("paris mou") and len(parts) > 1: + performance.paris_mou = parts[-1] + elif label.startswith("tokyo mou") and len(parts) > 1: + performance.tokyo_mou = parts[-1] + elif label.startswith("uscg"): + performance.uscg_targeted = "not targeted" not in label + return performance + + +def _parse_management(panel: Tag | None) -> list[CompanyRole]: + table = desktop_table(panel) + if table is None: + return [] + companies = [] + for row in iter_rows(table, required=("imo number", "role", "name of company")): + name = row.text("name of company") + if not name: + continue + companies.append( + CompanyRole( + company_id=row.text("imo number"), + name=name, + role=row.text("role") or "", + address=row.text("address"), + since=parse_date(row.text("date of effect")), + ) + ) + return companies + + +def _parse_classification(panel: Tag | None) -> tuple[list[ClassStatus], list[ClassSurvey]]: + statuses: list[ClassStatus] = [] + surveys: list[ClassSurvey] = [] + if panel is None: + return statuses, surveys + + section = None + for element in panel.find_all(["h5", "div"]): + if element.name == "h5": + section = normalize_header(element.get_text(" ")) + continue + if "access-body" not in class_list(element) or element.find("h5"): + continue + paragraphs = paragraph_texts(element) + if not paragraphs: + continue + + if section == "status": + badge = element.find("span", class_="badge") + status = text_of(badge) if isinstance(badge, Tag) else None + reason_tag = element.find("i") + reason = text_of(reason_tag) if isinstance(reason_tag, Tag) else None + since = next( + (parse_date(p) for p in paragraphs if p.startswith(("since", "during"))), None + ) + statuses.append( + ClassStatus(society=paragraphs[0], status=status, since=since, reason=reason) + ) + elif section == "surveys": + survey = ClassSurvey(society=paragraphs[0]) + for paragraph in paragraphs[1:]: + lowered = paragraph.lower() + if lowered.startswith("last renewal"): + survey.last_renewal = parse_date(paragraph) + elif lowered.startswith("next renewal"): + survey.next_renewal = parse_date(paragraph) + surveys.append(survey) + return statuses, surveys + + +def _parse_safety_certificates(panel: Tag | None) -> list[SafetyCertificate]: + table = desktop_table(panel) + if table is None: + return [] + certificates = [] + for row in iter_rows(table, required=("classification society",)): + society = row.text("classification society") + if not society: + continue + certificates.append( + SafetyCertificate( + society=society, + survey_date=parse_date(row.text("date survey")), + expiry_date=parse_date(row.text("date expiry")), + status_change_date=parse_date(row.text("date change status")), + status=row.text("status"), + reason=row.text("reason"), + convention=row.text("top c/v"), + ) + ) + return certificates + + +def _parse_pandi(panel: Tag | None) -> list[PandIInsurer]: + if panel is None: + return [] + insurers = [] + for body in panel.find_all("div", class_="access-body"): + paragraphs = paragraph_texts(body) + if not paragraphs: + continue + inception = next((parse_date(p) for p in paragraphs[1:] if "inception" in p.lower()), None) + insurers.append(PandIInsurer(name=paragraphs[0], inception=inception)) + return insurers + + +def _parse_sightings(panel: Tag | None) -> list[Sighting]: + table = desktop_table(panel) + if table is None: + return [] + sightings = [] + for row in iter_rows(table, required=("date of record", "area where the ship was seen")): + period = row.text("date of record") + if not period: + continue + areas = [a.strip() for a in (row.text("area where the ship was seen") or "").split(",")] + sightings.append( + Sighting(period=period, areas=[a for a in areas if a], source=row.text("source")) + ) + return sightings + + +# ------------------------------------------------------------------------ inspections + + +def parse_inspections(html: str) -> tuple[list[Inspection], list[HumanElementDeficiency]]: + """Parse the Inspections page. + + Returns the port state control inspections (newest first) and the human + element deficiencies table. + """ + soup = make_soup(html) + require_content(soup, html, page="Inspections") + + inspections: list[Inspection] = [] + table = desktop_table(find_panel(soup, "list of port state controls")) + if table is not None: + required = ("authority", "port of inspection", "date of report", "detention") + for row in iter_rows(table, required=required): + report = InspectionReport( + psc_organisation=row.text("psc organisation"), + inspection_type=row.text("type of inspection"), + duration_days=parse_int(row.text("duration (days)")), + deficiencies=parse_int(row.text("number of deficiencies")), + inspection_id=_onclick_id(row.cells.get("details")), + ) + if row.continuation and inspections: + inspections[-1].reports.append(report) + continue + detention = (row.text("detention") or "").strip().upper() + inspections.append( + Inspection( + authority=row.text("authority"), + port=row.text("port of inspection"), + date=parse_date(row.text("date of report")), + detained={"Y": True, "YES": True, "N": False, "NO": False}.get(detention), + reports=[report], + ) + ) + + human_element: list[HumanElementDeficiency] = [] + human_table = desktop_table(find_panel(soup, "human element deficiencies")) + if human_table is not None: + for row in iter_rows(human_table, required=("psc organisation", "date of report")): + human_element.append( + HumanElementDeficiency( + psc_organisation=row.text("psc organisation"), + authority=row.text("authority"), + port=row.text("port of inspection"), + inspection_type=row.text("type of inspection"), + date=parse_date(row.text("date of report")), + deficiencies=parse_int(row.text("human element deficiencies")), + ) + ) + return inspections, human_element + + +def _onclick_id(cell: Tag | None) -> str | None: + if cell is None: + return None + for element in [cell, *cell.find_all(onclick=True)]: + match = _ONCLICK_ID.search(str(element.get("onclick", ""))) + if match: + return match.group(1) + return None + + +# ---------------------------------------------------------------------------- history + + +def parse_history( + html: str, +) -> tuple[list[NameChange], list[FlagChange], list[ClassChange], list[CompanyChange]]: + """Parse the Ship History page (newest records first).""" + soup = make_soup(html) + require_content(soup, html, page="Ship History") + + names: list[NameChange] = [] + table = desktop_table(find_panel(soup, "current and former name")) + if table is not None: + for row in iter_rows(table, required=("name of ship", "date of effect")): + if value := row.text("name of ship"): + names.append( + NameChange( + name=value, + since=parse_date(row.text("date of effect")), + source=row.text("source(s)"), + ) + ) + _derive_until(names) + + flags: list[FlagChange] = [] + table = desktop_table(find_panel(soup, "current and former flag")) + if table is not None: + for row in iter_rows(table, required=("flag", "date of effect")): + if value := row.text("flag"): + flags.append( + FlagChange( + flag=value, + since=parse_date(row.text("date of effect")), + source=row.text("source(s)"), + ) + ) + _derive_until(flags) + + classes: list[ClassChange] = [] + table = desktop_table(find_panel(soup, "list of class renewal surveys")) + if table is not None: + for row in iter_rows(table, required=("classification society",)): + if value := row.text("classification society"): + classes.append( + ClassChange( + society=value, + survey_date=parse_date(row.text("date of survey")), + source=row.text("source(s)"), + ) + ) + + companies: list[CompanyChange] = [] + table = desktop_table(find_panel(soup, "company")) + if table is not None: + for row in iter_rows(table, required=("company", "role", "date of effect")): + if value := row.text("company"): + companies.append( + CompanyChange( + company=value, + role=row.text("role") or "", + since=parse_date(row.text("date of effect")), + source=row.text("source(s)"), + ) + ) + for role in {c.role for c in companies}: + _derive_until([c for c in companies if c.role == role]) + + return names, flags, classes, companies + + +def _derive_until(records: Sequence[NameChange | FlagChange | CompanyChange]) -> None: + """Set each record's ``until`` to the start of the next newer record. + + Records are ordered newest first, so the newer record precedes it. + """ + newer_since: date | None = None + for index, record in enumerate(records): + if index > 0: + record.until = newer_since + newer_since = record.since + + +def merge_vessel( + vessel: Vessel, + inspections: tuple[list[Inspection], list[HumanElementDeficiency]] | None, + history: tuple[list[NameChange], list[FlagChange], list[ClassChange], list[CompanyChange]] + | None, +) -> Vessel: + """Attach inspections and history results to ``vessel`` and return it.""" + if inspections is not None: + vessel.inspections, vessel.human_element_deficiencies = inspections + if history is not None: + ( + vessel.name_history, + vessel.flag_history, + vessel.class_history, + vessel.company_history, + ) = history + return vessel + + +__all__ = [ + "merge_vessel", + "parse_history", + "parse_inspections", + "parse_ship_info", +] diff --git a/src/equasis_cli/service.py b/src/equasis_cli/service.py new file mode 100644 index 0000000..50603f2 --- /dev/null +++ b/src/equasis_cli/service.py @@ -0,0 +1,203 @@ +"""Operations shared by the command-line and interactive interfaces. + +Both interfaces call these functions, so validation, batch behaviour and company +disambiguation are identical whichever way equasis-cli is used. +""" + +from __future__ import annotations + +import enum +import time +from collections.abc import Callable, Iterator, Sequence +from dataclasses import dataclass, field + +from .client import EquasisClient +from .exceptions import ( + AuthenticationError, + EquasisError, + InvalidInputError, + NotFoundError, +) +from .models import BatchItem, CompanySummary, Fleet, ItemStatus, Vessel +from .validation import imo_check_digit_valid, normalize_company_id, normalize_imo + +LARGE_BATCH_WARNING_THRESHOLD = 100 +"""Batches larger than this trigger a reminder about Equasis usage limits.""" + +StartCallback = Callable[[int, int, str], None] +"""Called as ``callback(position, total, query)`` before each batch item starts.""" + + +def imo_warnings(imo: str) -> list[str]: + """Non-fatal problems with a well-formed IMO number.""" + if imo_check_digit_valid(imo): + return [] + return [f"IMO {imo} fails the IMO check digit; it may contain a typo"] + + +# ----------------------------------------------------------------------------- vessels + + +def iter_vessels( + client: EquasisClient, + queries: Sequence[str], + *, + fail_fast: bool = False, + on_start: StartCallback | None = None, +) -> Iterator[BatchItem[Vessel]]: + """Look up vessels one at a time, yielding a result for every query. + + Invalid IMO numbers and missing vessels are reported per item rather than + stopping the batch, unless ``fail_fast`` is set. Authentication failures + always stop the batch because every later lookup would fail too. + """ + total = len(queries) + for position, query in enumerate(queries, start=1): + if on_start: + on_start(position, total, query) + started = time.monotonic() + item: BatchItem[Vessel] + warnings: list[str] = [] + try: + imo = normalize_imo(query) + warnings = imo_warnings(imo) + vessel = client.get_vessel(imo) + item = BatchItem(query=query, status=ItemStatus.OK, result=vessel) + warnings += vessel.warnings + except AuthenticationError: + raise + except NotFoundError as exc: + item = BatchItem(query=query, status=ItemStatus.NOT_FOUND, error=str(exc)) + except EquasisError as exc: + item = BatchItem(query=query, status=ItemStatus.ERROR, error=str(exc)) + item.warnings = warnings + item.elapsed_seconds = time.monotonic() - started + yield item + if fail_fast and item.status is not ItemStatus.OK: + return + + +# ---------------------------------------------------------------------------- companies + + +class Resolution(enum.Enum): + RESOLVED = "resolved" + AMBIGUOUS = "ambiguous" + NOT_FOUND = "not_found" + + +@dataclass +class CompanyMatch: + """Outcome of turning a company name into an Equasis company number.""" + + query: str + resolution: Resolution + company: CompanySummary | None = None + candidates: list[CompanySummary] = field(default_factory=list) + + +def is_company_id(query: str) -> bool: + try: + normalize_company_id(query) + except InvalidInputError: + return False + return True + + +def resolve_company(client: EquasisClient, query: str) -> CompanyMatch: + """Find the company a user means. + + A case-insensitive exact name match wins, as does a search with a single + result. Otherwise the match is ambiguous and ``candidates`` lists the options, + exact matches first. + """ + query = query.strip() + if not query: + raise InvalidInputError("company name must not be empty") + companies = client.search(query, max_pages=None, ships=False).companies + if not companies: + return CompanyMatch(query=query, resolution=Resolution.NOT_FOUND) + + wanted = query.casefold() + exact = [c for c in companies if c.name.casefold() == wanted] + if len(exact) == 1: + return CompanyMatch(query, Resolution.RESOLVED, company=exact[0], candidates=companies) + if len(companies) == 1: + return CompanyMatch(query, Resolution.RESOLVED, company=companies[0], candidates=companies) + ordered = exact + [c for c in companies if c not in exact] + return CompanyMatch(query=query, resolution=Resolution.AMBIGUOUS, candidates=ordered) + + +def describe_candidates(candidates: Sequence[CompanySummary], limit: int = 5) -> str: + shown = ", ".join(f"{c.name} ({c.company_id})" for c in candidates[:limit]) + more = len(candidates) - limit + return shown + (f", and {more} more" if more > 0 else "") + + +def iter_fleets( + client: EquasisClient, + queries: Sequence[str], + *, + first_match: bool = False, + fail_fast: bool = False, + max_pages: int | None = None, + on_start: StartCallback | None = None, +) -> Iterator[BatchItem[Fleet]]: + """Retrieve fleets for company names or 7-digit company numbers. + + Ambiguous names are reported as ``ambiguous`` with the candidates listed in + the error, unless ``first_match`` is set, in which case the best candidate is + used and a warning is recorded. + """ + total = len(queries) + for position, query in enumerate(queries, start=1): + if on_start: + on_start(position, total, query) + started = time.monotonic() + item: BatchItem[Fleet] + try: + warnings: list[str] = [] + if is_company_id(query): + company_id = normalize_company_id(query) + else: + match = resolve_company(client, query) + if match.resolution is Resolution.NOT_FOUND: + raise NotFoundError(f"no company matches '{query}'") + if match.resolution is Resolution.AMBIGUOUS: + if not first_match: + item = BatchItem( + query=query, + status=ItemStatus.AMBIGUOUS, + error=( + f"{len(match.candidates)} companies match '{query}': " + f"{describe_candidates(match.candidates)}; " + "use a company number or --first-match" + ), + ) + item.elapsed_seconds = time.monotonic() - started + yield item + if fail_fast: + return + continue + chosen = match.candidates[0] + warnings.append( + f"'{query}' matched {len(match.candidates)} companies; " + f"using {chosen.name} ({chosen.company_id})" + ) + elif match.company is not None: + chosen = match.company + else: # pragma: no cover - RESOLVED always carries a company + raise EquasisError(f"could not resolve company '{query}'") + company_id = chosen.company_id + fleet = client.get_fleet(company_id, max_pages=max_pages) + item = BatchItem(query=query, status=ItemStatus.OK, result=fleet, warnings=warnings) + except AuthenticationError: + raise + except NotFoundError as exc: + item = BatchItem(query=query, status=ItemStatus.NOT_FOUND, error=str(exc)) + except EquasisError as exc: + item = BatchItem(query=query, status=ItemStatus.ERROR, error=str(exc)) + item.elapsed_seconds = time.monotonic() - started + yield item + if fail_fast and item.status is not ItemStatus.OK: + return diff --git a/src/equasis_cli/transport.py b/src/equasis_cli/transport.py new file mode 100644 index 0000000..ed0991a --- /dev/null +++ b/src/equasis_cli/transport.py @@ -0,0 +1,208 @@ +"""HTTP transport: session handling, pacing, retries, and response capture.""" + +from __future__ import annotations + +import email.utils +import logging +import random +import re +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +import requests + +from ._version import __version__ +from .exceptions import NetworkError, RateLimitedError + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.equasis.org/EquasisWeb/" +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + f"(KHTML, like Gecko) Chrome/128.0 Safari/537.36 equasis-cli/{__version__}" +) + +_RETRYABLE_EXCEPTIONS = ( + requests.ConnectionError, + requests.Timeout, + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ContentDecodingError, +) + + +@dataclass(frozen=True) +class RetryPolicy: + """Exponential backoff with jitter for transient failures. + + Only network errors and the status codes in ``retry_statuses`` are retried; + other HTTP errors (for example 403 or 404) fail immediately. + """ + + max_retries: int = 3 + base_delay: float = 2.0 + backoff: float = 2.0 + max_delay: float = 60.0 + jitter: float = 0.25 + retry_statuses: frozenset[int] = field( + default_factory=lambda: frozenset({429, 500, 502, 503, 504, 520, 521, 522, 523, 524}) + ) + + def delay(self, attempt: int, retry_after: float | None = None) -> float: + """Seconds to wait before retry number ``attempt`` (starting at 0).""" + delay = min(self.max_delay, self.base_delay * self.backoff**attempt) + if self.jitter: + delay *= 1 + random.uniform(-self.jitter, self.jitter) # noqa: S311 - not security related + if retry_after is not None: + delay = max(delay, min(retry_after, self.max_delay)) + return max(0.0, delay) + + +class Transport: + """Thin wrapper around :class:`requests.Session` tailored to Equasis. + + Args: + min_interval: Minimum seconds between the start of consecutive requests. + Keeps load on Equasis low and reduces the risk of account throttling. + timeout: ``(connect, read)`` timeout in seconds. + save_html_dir: If set, every response body is written to this directory + to help diagnose parsing problems. Saved pages include the name of + the logged-in Equasis account. + """ + + def __init__( + self, + *, + base_url: str = BASE_URL, + min_interval: float = 1.0, + timeout: tuple[float, float] = (10.0, 60.0), + retry: RetryPolicy | None = None, + session: requests.Session | None = None, + save_html_dir: Path | None = None, + user_agent: str = DEFAULT_USER_AGENT, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self.base_url = base_url if base_url.endswith("/") else base_url + "/" + self.min_interval = min_interval + self.timeout = timeout + self.retry = retry or RetryPolicy() + self.session = session or requests.Session() + self.session.headers.update( + { + "User-Agent": user_agent, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + } + ) + self.save_html_dir = save_html_dir + self._sleep = sleep + self._clock = clock + self._last_request: float | None = None + self._saved = 0 + + def get(self, path: str, params: Mapping[str, str] | None = None) -> str: + return self.request("GET", path, params=params) + + def post(self, path: str, data: Mapping[str, str]) -> str: + return self.request("POST", path, data=data) + + def request( + self, + method: str, + path: str, + *, + params: Mapping[str, str] | None = None, + data: Mapping[str, str] | None = None, + ) -> str: + """Perform a request and return the decoded body. + + Raises: + RateLimitedError: if Equasis keeps answering HTTP 429. + NetworkError: for connection failures and non-retryable HTTP errors. + """ + url = self.base_url + path.lstrip("/") + attempt = 0 + while True: + self._pace() + started = self._clock() + try: + response = self.session.request( + method, url, params=params, data=data, timeout=self.timeout + ) + except _RETRYABLE_EXCEPTIONS as exc: + if attempt >= self.retry.max_retries: + raise NetworkError(f"could not reach Equasis: {exc}") from exc + delay = self.retry.delay(attempt) + logger.warning( + "%s %s failed (%s); retrying in %.1fs", method, path, type(exc).__name__, delay + ) + except requests.RequestException as exc: + raise NetworkError(f"request to Equasis failed: {exc}") from exc + else: + elapsed = self._clock() - started + logger.debug("%s %s -> %s in %.2fs", method, path, response.status_code, elapsed) + status = response.status_code + if status < 400: + body = response.text + self._save(method, path, body) + return body + if status not in self.retry.retry_statuses: + raise NetworkError( + f"Equasis returned HTTP {status} for {path}", status_code=status + ) + if attempt >= self.retry.max_retries: + if status == 429: + raise RateLimitedError( + "Equasis is rate limiting requests (HTTP 429); wait and try again " + "with a larger --delay", + status_code=status, + ) + raise NetworkError( + f"Equasis returned HTTP {status} for {path} after {attempt + 1} attempts", + status_code=status, + ) + delay = self.retry.delay(attempt, _retry_after(response)) + logger.warning( + "%s %s returned HTTP %s; retrying in %.1fs", method, path, status, delay + ) + attempt += 1 + self._sleep(delay) + + def close(self) -> None: + self.session.close() + + def _pace(self) -> None: + if self._last_request is not None and self.min_interval > 0: + wait = self._last_request + self.min_interval - self._clock() + if wait > 0: + self._sleep(wait) + self._last_request = self._clock() + + def _save(self, method: str, path: str, body: str) -> None: + if self.save_html_dir is None: + return + self.save_html_dir.mkdir(parents=True, exist_ok=True) + self._saved += 1 + slug = re.sub(r"[^A-Za-z0-9]+", "-", path.split("?", 1)[0]).strip("-") or "page" + target = self.save_html_dir / f"{self._saved:03d}-{method.lower()}-{slug}.html" + target.write_text(body, encoding="utf-8") + logger.info("saved response to %s", target) + + +def _retry_after(response: requests.Response) -> float | None: + """Parse a ``Retry-After`` header given in seconds or as an HTTP date.""" + value = response.headers.get("Retry-After") + if not value: + return None + if value.strip().isdigit(): + return float(value) + try: + when = email.utils.parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + return max(0.0, (when - datetime.now(timezone.utc)).total_seconds()) diff --git a/src/equasis_cli/tui/__init__.py b/src/equasis_cli/tui/__init__.py new file mode 100644 index 0000000..460ce35 --- /dev/null +++ b/src/equasis_cli/tui/__init__.py @@ -0,0 +1,6 @@ +"""Interactive shell.""" + +from .app import InteractiveShell +from .commands import CommandRunner, parse_command + +__all__ = ["CommandRunner", "InteractiveShell", "parse_command"] diff --git a/src/equasis_cli/tui/app.py b/src/equasis_cli/tui/app.py new file mode 100644 index 0000000..26ed1b0 --- /dev/null +++ b/src/equasis_cli/tui/app.py @@ -0,0 +1,513 @@ +"""Full-screen interactive shell built with prompt_toolkit. + +Output scrolls above a fixed input line and a status bar. Commands run in a +worker thread so the interface stays responsive; results are posted back to the +event loop, which owns all interface state. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from collections.abc import Callable + +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.filters import Condition +from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.input import Input +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout import ( + ConditionalContainer, + HSplit, + Layout, + ScrollbarMargin, + VSplit, + Window, +) +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.output import Output +from prompt_toolkit.styles import Style + +from ..banner import ASCII_ART, interactive_banner +from .commands import COMMANDS, CommandRunner, split_words + +MAX_OUTPUT_LINES = 5000 +PROMPT = "❯ " +SPINNER = ("◐", "◓", "◑", "◒") +MENU_HEIGHT = 9 + +HELP_OVERLAY = ( + ("/", "show commands, or parameters after a command"), + ("Tab", "complete the highlighted menu item"), + ("Up/Down", "move in the menu, or recall previous commands"), + ("Esc", "close the menu, or scroll the output (i or q to return)"), + ("PgUp/PgDn", "scroll the output (also Shift+Up/Down, Ctrl+K/J)"), + ("Ctrl+C", "clear the input line"), + ("Ctrl+D", "exit"), +) + +STYLE = Style.from_dict( + { + "separator": "fg:#5c6370", + "prompt": "fg:#61afef bold", + "echo": "fg:#5c6370", + "banner": "fg:#5c6370", + "banner-art": "fg:#61afef", + "error": "fg:#e06c75", + "warning": "fg:#e5c07b", + "success": "fg:#98c379", + "toolbar": "fg:#abb2bf", + "toolbar.connected": "fg:#98c379", + "toolbar.disconnected": "fg:#e06c75", + "toolbar.dim": "fg:#5c6370", + "toolbar.format": "fg:#61afef", + "menu": "fg:#5c6370", + "menu.selected": "fg:#61afef bold", + "loading": "fg:#61afef", + "loading.detail": "fg:#5c6370", + } +) + + +class OutputLexer(Lexer): + """Colors the banner, echoed commands, errors, warnings, and confirmations.""" + + def __init__(self, banner_lines: int, art_lines: int) -> None: + self.banner_lines = banner_lines + self.art_lines = art_lines + + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + lines = document.lines + + def line_style(number: int) -> StyleAndTextTuples: + if number >= len(lines): + return [] + line = lines[number] + if number < self.art_lines: + return [("class:banner-art", line)] + if number < self.banner_lines: + return [("class:banner", line)] + if line.startswith(PROMPT): + return [("class:echo", line)] + stripped = line.lstrip() + if stripped.startswith(("Error:", "Login failed:")): + return [("class:error", line)] + if stripped.startswith(("Warning:", "warning:", "Note:")): + return [("class:warning", line)] + if stripped.startswith("Saved "): + return [("class:success", line)] + return [("", line)] + + return line_style + + +class _ShellLogHandler(logging.Handler): + def __init__(self, shell: InteractiveShell) -> None: + super().__init__(level=logging.WARNING) + self.shell = shell + + def emit(self, record: logging.LogRecord) -> None: + try: + self.shell.write(f"Warning: {record.getMessage()}") + except Exception: # logging must never break the interface + self.handleError(record) + + +class InteractiveShell: + """The ``equasis`` interactive shell.""" + + def __init__( + self, + *, + runner: CommandRunner | None = None, + input: Input | None = None, # mirrors prompt_toolkit + output: Output | None = None, + ) -> None: + self.runner = runner or CommandRunner(self.write, self.set_status) + self.runner.write = self.write + self.runner.status = self.set_status + + self.busy = False + self.status_text = "" + self.show_help = False + self.menu_index = 0 + self.menu_dismissed = False + self._spinner = 0 + self._loop: asyncio.AbstractEventLoop | None = None + self._loop_thread: int | None = None + + banner = interactive_banner() + self.lexer = OutputLexer( + banner_lines=len(banner.splitlines()), art_lines=len(ASCII_ART.splitlines()) + ) + initial = f"{banner}\n\n" + self.output_buffer = Buffer( + document=Document(initial, len(initial)), read_only=True, multiline=True + ) + self.input_buffer = Buffer( + multiline=False, history=InMemoryHistory(), on_text_changed=self._on_input_changed + ) + + self.app: Application[None] = Application( + layout=self._layout(), + key_bindings=self._key_bindings(), + style=STYLE, + full_screen=True, + mouse_support=False, + input=input, + output=output, + ) + + # ---------------------------------------------------------------- output + + def write(self, text: str) -> None: + """Append text to the output area; safe to call from any thread.""" + if self._loop is not None and threading.get_ident() != self._loop_thread: + self._loop.call_soon_threadsafe(self._append, text) + else: + self._append(text) + + def set_status(self, text: str) -> None: + """Update the loading line; safe to call from any thread.""" + if self._loop is not None and threading.get_ident() != self._loop_thread: + self._loop.call_soon_threadsafe(self._set_status, text) + else: + self._set_status(text) + + def _set_status(self, text: str) -> None: + self.status_text = text + self._invalidate() + + def _append(self, text: str) -> None: + current = self.output_buffer.text + combined = f"{current}{text}\n" if current else f"{text}\n" + lines = combined.split("\n") + if len(lines) > MAX_OUTPUT_LINES: + combined = "\n".join(lines[-MAX_OUTPUT_LINES:]) + scrolling = self.app.layout.has_focus(self.output_buffer) + position = ( + min(self.output_buffer.cursor_position, len(combined)) if scrolling else len(combined) + ) + self.output_buffer.set_document(Document(combined, position), bypass_readonly=True) + self._invalidate() + + def clear(self) -> None: + self.output_buffer.set_document(Document("", 0), bypass_readonly=True) + + def _invalidate(self) -> None: + if self.app.is_running: + self.app.invalidate() + + # ------------------------------------------------------------------ menus + + def menu_items(self) -> list[tuple[str, str]]: + """Commands or parameters matching what is being typed.""" + text = self.input_buffer.document.text_before_cursor + words = split_words(text) + ends_with_space = text.endswith(" ") + if not words: + return [] + if len(words) == 1 and not ends_with_space and text.lstrip().startswith("/"): + prefix = words[0][1:].lower() + return [ + (name, spec.summary) for name, spec in COMMANDS.items() if name.startswith(prefix) + ] + last = words[-1] + spec = COMMANDS.get(words[0].lower().lstrip("/")) + if spec and len(words) > 1 and not ends_with_space and last.startswith("/"): + prefix = last[1:].lower() + used = {w[1:].lower() for w in words[1:-1] if w.startswith("/")} + return [ + (f"/{p.name}", p.help) + for p in spec.params + if p.name.startswith(prefix) and p.name not in used + ] + return [] + + @property + def menu_visible(self) -> bool: + return ( + not self.menu_dismissed + and self.app.layout.has_focus(self.input_buffer) + and bool(self.menu_items()) + ) + + def _on_input_changed(self, _: Buffer) -> None: + self.menu_dismissed = False + self.menu_index = 0 + if self.input_buffer.text: + self.show_help = False + + def accept_menu_item(self) -> bool: + items = self.menu_items() + if not items: + return False + choice = items[min(self.menu_index, len(items) - 1)][0] + document = self.input_buffer.document + words_before = document.text_before_cursor + start = len(words_before) - len(words_before.split(" ")[-1]) + new_text = words_before[:start] + choice + " " + document.text_after_cursor.lstrip() + self.input_buffer.document = Document(new_text, start + len(choice) + 1) + self.menu_dismissed = True + return True + + def complete_command(self) -> None: + """Tab without a menu: complete a partially typed command name.""" + text = self.input_buffer.text + if " " in text or not text: + return + matches = [name for name in COMMANDS if name.startswith(text.lower())] + if len(matches) == 1: + self.input_buffer.document = Document(matches[0] + " ", len(matches[0]) + 1) + elif matches: + self.input_buffer.document = Document("/" + text, len(text) + 1) + + # ---------------------------------------------------------------- running + + def submit(self) -> None: + line = self.input_buffer.text.strip() + if not line: + return + if self.busy: + self.write("Warning: wait for the current command to finish") + return + self.input_buffer.history.append_string(line) + self.input_buffer.reset() + self.show_help = False + self.write(f"{PROMPT}{line}") + + word = line.split()[0].lower().lstrip("/") + if word in ("exit", "quit"): + self.app.exit() + return + if word == "clear": + self.clear() + return + self.app.create_background_task(self._run(line)) + + async def _run(self, line: str) -> None: + self.busy = True + self.status_text = "Working..." + animation = asyncio.ensure_future(self._animate()) + try: + loop = asyncio.get_running_loop() + keep_running = await loop.run_in_executor(None, self.runner.run, line) + finally: + self.busy = False + self.status_text = "" + animation.cancel() + self._invalidate() + if not keep_running: + self.app.exit() + + async def _animate(self) -> None: + while True: + await asyncio.sleep(0.15) + self._spinner += 1 + self._invalidate() + + def start(self) -> None: + """Run the shell until the user exits.""" + package_logger = logging.getLogger("equasis_cli") + handler = _ShellLogHandler(self) + propagate = package_logger.propagate + package_logger.addHandler(handler) + package_logger.propagate = False + + def pre_run() -> None: + self._loop = asyncio.get_running_loop() + self._loop_thread = threading.get_ident() + + try: + self.app.run(pre_run=pre_run) + except (KeyboardInterrupt, EOFError): + pass + finally: + package_logger.removeHandler(handler) + package_logger.propagate = propagate + self.runner.close() + + # ----------------------------------------------------------------- layout + + def _toolbar(self) -> StyleAndTextTuples: + parts: StyleAndTextTuples = [] + if self.runner.connected: + parts.append(("class:toolbar.connected", "● Connected")) + else: + parts.append(("class:toolbar.disconnected", "○ Not connected")) + parts.append(("class:toolbar.dim", " Format: ")) + parts.append(("class:toolbar.format", self.runner.state.output_format)) + if self.app.layout.has_focus(self.output_buffer): + hint = "Scrolling: arrows, j/k, PgUp/PgDn; i or q to type" + else: + hint = "? for keys, help for commands" + parts.append(("class:toolbar.dim", f" {hint}")) + return parts + + def _loading(self) -> StyleAndTextTuples: + spinner = SPINNER[self._spinner % len(SPINNER)] + return [("class:loading", f"{spinner} "), ("class:loading.detail", self.status_text)] + + def _menu(self) -> StyleAndTextTuples: + items = self.menu_items()[:MENU_HEIGHT] + if not items: + return [] + self.menu_index = min(self.menu_index, len(items) - 1) + width = max(len(name) for name, _ in items) + 3 + fragments: StyleAndTextTuples = [] + for index, (name, description) in enumerate(items): + style = "class:menu.selected" if index == self.menu_index else "class:menu" + fragments.append((style, f"{name.ljust(width)}{description}\n")) + return fragments + + def _help_overlay(self) -> StyleAndTextTuples: + width = max(len(key) for key, _ in HELP_OVERLAY) + 3 + return [("class:menu", f"{key.ljust(width)}{text}\n") for key, text in HELP_OVERLAY] + + def _layout(self) -> Layout: + output_window = Window( + BufferControl(buffer=self.output_buffer, lexer=self.lexer, focusable=True), + wrap_lines=True, + right_margins=[ScrollbarMargin(display_arrows=True)], + ) + + separator = Window(height=1, char="─", style="class:separator") + input_line = VSplit( + [ + Window( + FormattedTextControl([("class:prompt", PROMPT)]), + width=len(PROMPT), + dont_extend_width=True, + ), + Window(BufferControl(buffer=self.input_buffer), height=1), + ] + ) + return Layout( + HSplit( + [ + output_window, + separator, + ConditionalContainer( + Window(FormattedTextControl(self._loading), height=1), + filter=Condition(lambda: self.busy), + ), + input_line, + ConditionalContainer( + Window(FormattedTextControl(self._menu), height=MENU_HEIGHT), + filter=Condition(lambda: self.menu_visible), + ), + ConditionalContainer( + Window(FormattedTextControl(self._help_overlay), height=len(HELP_OVERLAY)), + filter=Condition(lambda: self.show_help and not self.menu_visible), + ), + separator, + Window(FormattedTextControl(self._toolbar), height=1, style="class:toolbar"), + ] + ), + focused_element=self.input_buffer, + ) + + def _key_bindings(self) -> KeyBindings: + bindings = KeyBindings() + scrolling = Condition(lambda: self.app.layout.has_focus(self.output_buffer)) + typing = ~scrolling + menu = Condition(lambda: self.menu_visible) + + def scroll(lines: int) -> None: + buffer = self.output_buffer + if lines < 0: + buffer.cursor_up(count=-lines) + else: + buffer.cursor_down(count=lines) + + @bindings.add("enter", filter=typing) + def _submit(event: KeyPressEvent) -> None: + self.submit() + + @bindings.add("tab", filter=typing) + def _tab(event: KeyPressEvent) -> None: + if not self.accept_menu_item(): + self.complete_command() + + @bindings.add("up", filter=typing & menu) + def _menu_up(event: KeyPressEvent) -> None: + self.menu_index = max(0, self.menu_index - 1) + + @bindings.add("down", filter=typing & menu) + def _menu_down(event: KeyPressEvent) -> None: + self.menu_index = min(len(self.menu_items()) - 1, self.menu_index + 1) + + @bindings.add("up", filter=typing & ~menu) + def _history_back(event: KeyPressEvent) -> None: + self.input_buffer.history_backward() + + @bindings.add("down", filter=typing & ~menu) + def _history_forward(event: KeyPressEvent) -> None: + self.input_buffer.history_forward() + + @bindings.add("escape", filter=typing, eager=True) + def _escape(event: KeyPressEvent) -> None: + if self.menu_visible: + self.menu_dismissed = True + elif self.show_help: + self.show_help = False + else: + event.app.layout.focus(self.output_buffer) + + @bindings.add("?", filter=typing) + def _question(event: KeyPressEvent) -> None: + if self.input_buffer.text: + self.input_buffer.insert_text("?") + else: + self.show_help = not self.show_help + + @bindings.add("c-c") + def _cancel(event: KeyPressEvent) -> None: + if self.busy and not self.input_buffer.text: + self.write("Warning: the running command cannot be interrupted; it will finish") + self.input_buffer.reset() + self.show_help = False + event.app.layout.focus(self.input_buffer) + + @bindings.add("c-d") + def _exit(event: KeyPressEvent) -> None: + event.app.exit() + + for key in ("i", "q", "escape", "enter"): + + @bindings.add(key, filter=scrolling) + def _return_to_input(event: KeyPressEvent) -> None: + event.app.layout.focus(self.input_buffer) + + @bindings.add(Keys.Any, filter=scrolling) + def _type_to_return(event: KeyPressEvent) -> None: + event.app.layout.focus(self.input_buffer) + if event.data.isprintable(): + self.input_buffer.insert_text(event.data) + + for key, lines in (("up", -1), ("k", -1), ("down", 1), ("j", 1)): + + @bindings.add(key, filter=scrolling) + def _scroll_line(event: KeyPressEvent, lines: int = lines) -> None: + scroll(lines) + + for key, lines in ( + ("pageup", -15), + ("pagedown", 15), + ("s-up", -1), + ("s-down", 1), + ("c-k", -5), + ("c-j", 5), + ): + + @bindings.add(key) + def _scroll_page(event: KeyPressEvent, lines: int = lines) -> None: + scroll(lines) + + return bindings diff --git a/src/equasis_cli/tui/commands.py b/src/equasis_cli/tui/commands.py new file mode 100644 index 0000000..5fce6ae --- /dev/null +++ b/src/equasis_cli/tui/commands.py @@ -0,0 +1,588 @@ +"""Command parsing and execution for the interactive shell. + +This module has no terminal user interface code, so every command can be tested +directly. The shell application (``tui.app``) only handles display and input. +""" + +from __future__ import annotations + +import re +import shlex +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +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 ( + AuthenticationError, + EquasisError, + InvalidInputError, + LayoutChangedError, +) +from ..models import BatchItem, Fleet, ItemStatus, SearchResults, Vessel +from ..service import ( + LARGE_BATCH_WARNING_THRESHOLD, + Resolution, + imo_warnings, + is_company_id, + iter_fleets, + iter_vessels, + resolve_company, +) +from ..validation import normalize_company_id, normalize_imo, parse_list, split_values + +Writer = Callable[[str], None] +StatusUpdater = Callable[[str], None] +ClientFactory = Callable[[], EquasisClient] + +_QUOTES = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"}) + + +class CommandError(Exception): + """A problem with how a command was typed.""" + + +# ------------------------------------------------------------------------ specification + + +@dataclass(frozen=True) +class Param: + name: str + help: str + takes_value: bool = True + + +@dataclass(frozen=True) +class CommandSpec: + name: str + summary: str + usage: str + params: tuple[Param, ...] = () + details: tuple[str, ...] = () + + def param(self, name: str) -> Param | None: + return next((p for p in self.params if p.name == name), None) + + +FORMAT = Param("format", "output format: table, json, jsonl, csv") +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 + for spec in ( + CommandSpec( + "vessel", + "Full vessel profile by IMO number", + "vessel /imo IMO [/refresh] [/format FORMAT] [/output FILE]", + (Param("imo", "IMO number (required)"), REFRESH, FORMAT, OUTPUT), + ("Includes management, classification, PSC inspections and history.",), + ), + CommandSpec( + "search", + "Search ships and companies", + "search /name NAME | /imo IMO | /mmsi MMSI | /callsign CALLSIGN " + "[/type all|ships|companies] [/pages N] [/refresh] [/format FORMAT] [/output FILE]", + ( + Param("name", "name to search for (partial matches)"), + Param("imo", "exact IMO number"), + Param("mmsi", "exact MMSI"), + Param("callsign", "exact call sign"), + Param("type", "all, ships, or companies (name searches)"), + PAGES, + REFRESH, + FORMAT, + OUTPUT, + ), + ), + CommandSpec( + "fleet", + "Vessels associated with a company", + "fleet /company NAME | /id NUMBER [/first] [/pages N] [/refresh] [/format FORMAT] " + "[/output FILE]", + ( + Param("company", "company name"), + Param("id", "7-digit Equasis company number"), + FIRST, + PAGES, + REFRESH, + FORMAT, + OUTPUT, + ), + ( + "When a name matches several companies the matches are listed; repeat " + "the command with /id NUMBER or add /first.", + ), + ), + CommandSpec( + "batch", + "Look up many vessels or companies", + 'batch /imos "IMO,IMO" | /file FILE | /companies "NAME,NAME" | /company-file FILE ' + "[/fail-fast] [/first] [/refresh] [/format FORMAT] [/output FILE]", + ( + Param("imos", "comma-separated IMO numbers"), + Param("file", "file with one IMO number per line"), + Param("companies", "comma-separated company names or numbers"), + 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]"), + CommandSpec("exit", "Leave the interactive shell", "exit"), + ) +} +ALIASES = {"quit": "exit"} + + +# ------------------------------------------------------------------------------ parsing + + +@dataclass +class ParsedCommand: + name: str + params: dict[str, str | bool] = field(default_factory=dict) + arguments: list[str] = field(default_factory=list) + + def text(self, name: str) -> str | None: + value = self.params.get(name) + return value if isinstance(value, str) else None + + def flag(self, name: str) -> bool: + return self.params.get(name) is True + + +def parse_command(line: str) -> ParsedCommand | None: + """Parse ``command /param value /flag``. + + Values may be quoted with straight or typographic quotes. Parameters that take a + value consume the next word even if it starts with ``/`` (for example a file path). + + Raises: + CommandError: for unknown commands or parameters and missing values. + """ + text = line.translate(_QUOTES).strip() + if not text: + return None + try: + lexer = shlex.shlex(text, posix=True) + lexer.whitespace_split = True + lexer.escape = "" # keep backslashes literal, as in Windows paths + lexer.commenters = "" + tokens = list(lexer) + except ValueError as exc: + raise CommandError(f"could not read the command: {exc}") from None + + name = tokens[0].lower().lstrip("/") + name = ALIASES.get(name, name) + spec = COMMANDS.get(name) + if spec is None: + raise CommandError(f"unknown command '{tokens[0]}'; type 'help' for commands") + + parsed = ParsedCommand(name=name) + index = 1 + while index < len(tokens): + token = tokens[index] + if token.startswith("/") and spec.params: + param = spec.param(token[1:].lower()) + if param is None: + known = ", ".join(f"/{p.name}" for p in spec.params) + raise CommandError(f"'{name}' has no parameter {token}; use {known}") + if not param.takes_value: + parsed.params[param.name] = True + index += 1 + continue + if index + 1 >= len(tokens): + raise CommandError(f"{token} needs a value") + parsed.params[param.name] = tokens[index + 1] + index += 2 + else: + parsed.arguments.append(token) + index += 1 + return parsed + + +# ---------------------------------------------------------------------------- execution + + +@dataclass +class ShellState: + output_format: str = "table" + client: EquasisClient | None = None + username: str | None = None + + +class CommandRunner: + """Runs parsed commands and reports results through ``write`` and ``status``.""" + + def __init__( + self, + write: Writer, + status: StatusUpdater, + *, + 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, cache=self.cache) + ) + + # ------------------------------------------------------------------- helpers + + @property + def connected(self) -> bool: + return self.state.client is not None and self.state.client.logged_in + + def client(self) -> EquasisClient: + if self.state.client is None: + credentials = self.store.resolve() + if credentials is None: + raise CommandError( + "no Equasis credentials found; run 'equasis configure --setup' in a " + "terminal, or set EQUASIS_USERNAME and EQUASIS_PASSWORD" + ) + self.state.client = self.client_factory(credentials.username, credentials.password) + self.state.username = credentials.username + self.state.client.refresh = self.refresh + return self.state.client + + def close(self) -> None: + if self.state.client is not None: + self.state.client.close() + + def _format_and_target(self, command: ParsedCommand) -> tuple[str, str | None]: + explicit = command.text("format") + target = command.text("output") + if target and target.lower() in output.FORMATS and not Path(target).suffix: + explicit, target = target.lower(), None # "/output json" selects a format + if explicit and explicit.lower() not in output.FORMATS: + raise CommandError(f"unknown format '{explicit}'; use {', '.join(output.FORMATS)}") + fmt = output.resolve_format( + explicit.lower() if explicit else None, target, default=self.state.output_format + ) + return fmt, target + + def _emit(self, result: Vessel | SearchResults | Fleet, command: ParsedCommand) -> None: + fmt, target = self._format_and_target(command) + text = output.render(result, fmt) + if target: + path = output.write_output(text, target) + self.write(f"Saved {fmt} output to {path}") + else: + self.write(text.rstrip("\n")) + + @staticmethod + def _read_list(path: str) -> list[str]: + try: + with open(Path(path).expanduser(), encoding="utf-8") as handle: + return parse_list(handle) + except OSError as exc: + raise CommandError(f"cannot read {path}: {exc.strerror or exc}") from None + + @staticmethod + def _pages(command: ParsedCommand, default: int | None) -> int | None: + value = command.text("pages") + if value is None: + return default + if value.lower() == "all": + return None + if not value.isdigit() or int(value) < 1: + raise CommandError("/pages must be a positive number or 'all'") + return int(value) + + # ------------------------------------------------------------------ dispatch + + def run(self, line: str) -> bool: + """Execute one line. Returns ``False`` when the shell should exit.""" + try: + command = parse_command(line) + if command is None: + return True + if command.name == "exit": + return False + handler: Callable[[ParsedCommand], None] = getattr(self, f"_cmd_{command.name}") + 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: + self.state.client = None + self.write(f"Login failed: {exc}") + except InvalidInputError as exc: + self.write(f"Error: {exc}") + except LayoutChangedError as exc: + self.write( + f"Error: could not read the Equasis page ({exc}). Equasis may have changed " + "its website; please report it at https://github.com/rhinonix/equasis-cli/issues" + ) + except EquasisError as exc: + self.write(f"Error: {exc}") + except OSError as exc: + self.write(f"Error: {exc}") + return True + + # ------------------------------------------------------------------ commands + + def _cmd_vessel(self, command: ParsedCommand) -> None: + value = command.text("imo") or (command.arguments[0] if command.arguments else None) + if not value: + raise CommandError(f"missing /imo\nUsage: {COMMANDS['vessel'].usage}") + imo = normalize_imo(value) + self._format_and_target(command) # validate before making requests + for warning in imo_warnings(imo): + self.write(f"Warning: {warning}") + client = self.client() + self.status(f"Retrieving IMO {imo}...") + vessel = client.get_vessel(imo) + for warning in vessel.warnings: + self.write(f"Warning: {warning}") + self._emit(vessel, command) + + def _cmd_search(self, command: ParsedCommand) -> None: + name = command.text("name") or " ".join(command.arguments) or None + identifiers = {k: command.text(k) for k in ("imo", "mmsi", "callsign")} + if name and any(identifiers.values()): + raise CommandError("search by /name or by an identifier, not both") + if not name and not any(identifiers.values()): + raise CommandError(f"missing search terms\nUsage: {COMMANDS['search'].usage}") + kind = (command.text("type") or "all").lower() + if kind not in ("all", "ships", "companies"): + raise CommandError("/type must be all, ships, or companies") + pages = self._pages(command, DEFAULT_SEARCH_PAGES) + self._format_and_target(command) + + client = self.client() + if name: + self.status(f"Searching for '{name}'...") + results = client.search( + name, + max_pages=pages, + ships=kind in ("all", "ships"), + companies=kind in ("all", "companies"), + ) + else: + self.status("Searching...") + results = client.search_ships( + imo=identifiers["imo"], mmsi=identifiers["mmsi"], call_sign=identifiers["callsign"] + ) + if results.total_ships and results.total_ships > len(results.ships): + self.write( + f"Note: showing {len(results.ships)} of {results.total_ships} ships; " + "add /pages N or /pages all for more" + ) + self._emit(results, command) + + def _cmd_fleet(self, command: ParsedCommand) -> None: + company_id = command.text("id") + name = command.text("company") or " ".join(command.arguments) or None + if company_id and name: + raise CommandError("use either /company or /id, not both") + if not company_id and not name: + raise CommandError(f"missing /company or /id\nUsage: {COMMANDS['fleet'].usage}") + pages = self._pages(command, None) + self._format_and_target(command) + + client = self.client() + if name and is_company_id(name): + company_id = name + if company_id: + company_id = normalize_company_id(company_id) + elif name: + self.status(f"Looking up companies matching '{name}'...") + match = resolve_company(client, name) + if match.resolution is Resolution.NOT_FOUND: + self.write(f"No company matches '{name}'.") + return + if match.resolution is Resolution.AMBIGUOUS and not command.flag("first"): + self.write(f"'{name}' matches {len(match.candidates)} companies:") + self.write(output.render_company_choices(match.candidates)) + self.write("Run 'fleet /id NUMBER' with the company number, or add /first.") + return + chosen = match.company or match.candidates[0] + if match.resolution is Resolution.AMBIGUOUS: + self.write( + f"Using {chosen.name} ({chosen.company_id}), the best of " + f"{len(match.candidates)} matches." + ) + company_id = chosen.company_id + + if company_id is None: + return + self.status(f"Retrieving fleet of company {company_id}...") + fleet = client.get_fleet(company_id, max_pages=pages) + self._emit(fleet, command) + + def _cmd_batch(self, command: ParsedCommand) -> None: + sources = {key: command.text(key) for key in ("imos", "file", "companies", "company-file")} + given = [key for key, value in sources.items() if value] + if len(given) != 1: + raise CommandError( + "use exactly one of /imos, /file, /companies, or /company-file\n" + f"Usage: {COMMANDS['batch'].usage}" + ) + source = given[0] + value = sources[source] or "" + if source == "imos": + kind, queries = "vessels", split_values([value]) + elif source == "file": + kind, queries = "vessels", self._read_list(value) + elif source == "companies": + kind, queries = "fleets", split_values([value]) + else: + kind, queries = "fleets", self._read_list(value) + if not queries: + raise CommandError("the batch is empty") + + fmt, target = self._format_and_target(command) + noun = "vessels" if kind == "vessels" else "companies" + if len(queries) > LARGE_BATCH_WARNING_THRESHOLD: + self.write( + f"Warning: {len(queries)} {noun} is a large batch; very large batches can lead " + "Equasis to restrict your account." + ) + self.write(f"Processing {len(queries)} {noun}...") + + def on_start(position: int, total: int, query: str) -> None: + self.status(f"[{position}/{total}] {query}") + + client = self.client() + items: Any + if kind == "vessels": + items = iter_vessels( + client, queries, fail_fast=command.flag("fail-fast"), on_start=on_start + ) + else: + items = iter_fleets( + client, + queries, + first_match=command.flag("first"), + fail_fast=command.flag("fail-fast"), + on_start=on_start, + ) + + report = output.BatchReport(kind="vessels" if kind == "vessels" else "fleets") + for item in items: + report.items.append(item) + self._report_item(item) + + text = output.render(report, fmt) + if target: + path = output.write_output(text, target) + self.write(f"Saved {fmt} output to {path}") + else: + self.write(text.rstrip("\n")) + self.write(f"{report.succeeded} of {len(report.items)} {noun} retrieved.") + + def _report_item(self, item: BatchItem[Any]) -> None: + mark = "ok" if item.status is ItemStatus.OK else item.status.value.replace("_", " ") + line = f" {item.query}: {mark}" + if item.error: + line += f" - {item.error}" + self.write(line) + for warning in item.warnings: + self.write(f" warning: {warning}") + + def _cmd_format(self, command: ParsedCommand) -> None: + if not command.arguments: + self.write(f"Default output format: {self.state.output_format}") + return + choice = command.arguments[0].lower() + if choice not in output.FORMATS: + raise CommandError(f"unknown format '{choice}'; use {', '.join(output.FORMATS)}") + self.state.output_format = choice + self.write(f"Default output format set to {choice}") + + def _cmd_status(self, command: ParsedCommand) -> None: + credentials = self.store.resolve() + account = self.state.username or (credentials.username if credentials else None) + lines = [ + "Session status", + f" Connected: {'yes' if self.connected else 'no'}", + f" Account: {account or 'not configured'}", + f" Credentials: {credentials.source.value if credentials else 'not found'}", + f" Default format: {self.state.output_format}", + ] + 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 + + def _cmd_help(self, command: ParsedCommand) -> None: + self.write(help_text(command.arguments[0] if command.arguments else None)) + + +def help_text(topic: str | None = None) -> str: + """Help for all commands, or detailed help for one.""" + if topic: + name = ALIASES.get(topic.lower().lstrip("/"), topic.lower().lstrip("/")) + spec = COMMANDS.get(name) + if spec is None: + return f"No help for '{topic}'. Commands: {', '.join(COMMANDS)}" + lines = [f"{spec.name}: {spec.summary}", "", f"Usage: {spec.usage}"] + if spec.params: + lines.append("") + width = max(len(p.name) for p in spec.params) + 1 + lines += [f" /{p.name.ljust(width)} {p.help}" for p in spec.params] + if spec.details: + lines += ["", *spec.details] + return "\n".join(lines) + + width = max(len(name) for name in COMMANDS) + lines = ["Commands:"] + lines += [f" {spec.name.ljust(width)} {spec.summary}" for spec in COMMANDS.values()] + lines += [ + "", + "Type 'help COMMAND' for parameters, for example 'help batch'.", + 'Values with spaces need quotes: search /name "EVER GIVEN"', + "/output FILE saves results; the format follows the extension (.json, .jsonl, .csv).", + ] + return "\n".join(lines) + + +def split_words(line: str) -> Sequence[str]: + """Whitespace-separated words, used for menus and completion.""" + return re.split(r"\s+", line.strip()) if line.strip() else [] diff --git a/src/equasis_cli/validation.py b/src/equasis_cli/validation.py new file mode 100644 index 0000000..ad4311c --- /dev/null +++ b/src/equasis_cli/validation.py @@ -0,0 +1,83 @@ +"""Validation and normalization of user-supplied identifiers and input lists.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable + +from .exceptions import InvalidInputError + +_IMO = re.compile(r"^(?:IMO[\s:-]*)?(\d{7})$", re.IGNORECASE) +_COMPANY_ID = re.compile(r"^\d{7}$") +_MMSI = re.compile(r"^\d{9}$") + + +def normalize_imo(value: str) -> str: + """Return the 7-digit IMO number in ``value`` (``"IMO 9811000"`` is accepted). + + Raises: + InvalidInputError: if ``value`` is not a 7-digit number. + """ + match = _IMO.match(value.strip()) + if not match: + raise InvalidInputError(f"'{value}' is not a valid IMO number (expected 7 digits)") + return match.group(1) + + +def imo_check_digit_valid(imo: str) -> bool: + """Verify the IMO check digit. + + The first six digits are multiplied by 7, 6, 5, 4, 3 and 2; the last digit of + the sum must equal the seventh digit. A failure usually indicates a typo. + """ + if not (len(imo) == 7 and imo.isdigit()): + return False + total = sum(int(digit) * weight for digit, weight in zip(imo[:6], range(7, 1, -1), strict=True)) + return total % 10 == int(imo[6]) + + +def normalize_company_id(value: str) -> str: + """Return a 7-digit Equasis company number. + + Raises: + InvalidInputError: if ``value`` is not a 7-digit number. + """ + cleaned = value.strip() + if not _COMPANY_ID.match(cleaned): + raise InvalidInputError( + f"'{value}' is not a valid Equasis company number (expected 7 digits)" + ) + return cleaned + + +def normalize_mmsi(value: str) -> str: + """Return a 9-digit MMSI. + + Raises: + InvalidInputError: if ``value`` is not a 9-digit number. + """ + cleaned = value.strip() + if not _MMSI.match(cleaned): + raise InvalidInputError(f"'{value}' is not a valid MMSI (expected 9 digits)") + return cleaned + + +def parse_list(lines: Iterable[str]) -> list[str]: + """Parse list input: one item per line, ``#`` comments, blank lines ignored. + + Inline comments are supported (``9811000 # EVER GIVEN``). Duplicates are + removed while preserving order. + """ + items: list[str] = [] + seen: set[str] = set() + for line in lines: + item = line.split("#", 1)[0].strip() + if item and item not in seen: + seen.add(item) + items.append(item) + return items + + +def split_values(values: Iterable[str]) -> list[str]: + """Flatten values that may themselves be comma-separated lists.""" + return parse_list(part for value in values for part in value.split(",")) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 883a292..55427b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,13 @@ from __future__ import annotations import os +from collections.abc import Callable +from pathlib import Path import pytest +FIXTURES = Path(__file__).parent / "fixtures" + def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: """Skip tests marked ``live`` unless explicitly enabled.""" @@ -15,3 +19,13 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item for item in items: if "live" in item.keywords: item.add_marker(skip_live) + + +@pytest.fixture +def fixture_html() -> Callable[[str], str]: + """Return a loader for anonymized Equasis pages in ``tests/fixtures``.""" + + def load(name: str) -> str: + return (FIXTURES / f"{name}.html").read_text(encoding="utf-8") + + return load diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..27caf1d --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,136 @@ +"""In-memory stand-ins for the Equasis client.""" + +from __future__ import annotations + +import copy +from functools import lru_cache +from types import TracebackType + +from equasis_cli.exceptions import AuthenticationError, NotFoundError +from equasis_cli.models import CompanySummary, Fleet, SearchResults, ShipSummary, Vessel +from equasis_cli.parsing import ( + merge_vessel, + parse_fleet, + parse_history, + parse_inspections, + parse_ship_info, +) +from equasis_cli.validation import normalize_imo +from tests.conftest import FIXTURES + + +def load(name: str) -> str: + return (FIXTURES / f"{name}.html").read_text(encoding="utf-8") + + +def fixture_vessel(imo: str = "9074729") -> Vessel: + return copy.deepcopy(_parsed_vessel(imo)) + + +def fixture_fleet() -> Fleet: + return copy.deepcopy(_parsed_fleet()) + + +@lru_cache +def _parsed_vessel(imo: str) -> Vessel: + return merge_vessel( + parse_ship_info(load(f"ship_info_{imo}")), + parse_inspections(load(f"inspections_{imo}")), + parse_history(load(f"history_{imo}")), + ) + + +@lru_cache +def _parsed_fleet() -> Fleet: + page = parse_fleet(load("fleet_torm")) + return Fleet(company=page.company, vessels=page.vessels, total_vessels=page.total_vessels) + + +class FakeClient: + """Serves vessels and fleets from fixtures and records calls.""" + + def __init__( + self, + *, + vessels: dict[str, Vessel] | None = None, + companies: list[CompanySummary] | None = None, + fleets: dict[str, Fleet] | None = None, + reject_login: bool = False, + ) -> None: + self.vessels = vessels if vessels is not None else {"9074729": fixture_vessel()} + self.companies = companies if companies is not None else [] + self.fleets = fleets if fleets is not None else {} + 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: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + self.closed = True + + def login(self) -> None: + self.calls.append(("login", None)) + if self.reject_login: + 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)) + self._ensure_login() + imo = normalize_imo(imo) + if imo not in self.vessels: + raise NotFoundError(f"no vessel with IMO {imo} in Equasis") + return self.vessels[imo] + + 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() + if query.lower() in v.name.lower() + ] + matching_companies = [c for c in self.companies if query.lower() in c.name.lower()] + return SearchResults( + query=query, + ships=matching_ships if ships else [], + companies=matching_companies if companies else [], + total_ships=len(matching_ships) if ships else None, + ) + + 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() + if v.imo == imo or (mmsi and v.mmsi == mmsi) or (call_sign and v.call_sign == call_sign) + ] + return SearchResults(query=str(imo or mmsi or call_sign), ships=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/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..650e2a0 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,25 @@ +# Test fixtures + +Pages recorded from equasis.org on 2026-09-16 and processed with +`tests/tools/make_fixture.py`, which: + +- removes scripts, styles, images (except flag images), select options, and comments +- replaces the logged-in account's display name with `Test User` +- collapses whitespace while keeping line breaks + +Parsing a fixture gives the same result as parsing the original page; re-check +this whenever a fixture is regenerated. + +| Fixture | Page | +| --- | --- | +| `ship_info_9811000.html`, `inspections_9811000.html`, `history_9811000.html` | EVER GIVEN: all Ship Info panels, including P&I and safety management certificates | +| `ship_info_9074729.html`, `inspections_9074729.html`, `history_9074729.html` | KAVITA: black-listed flag, withdrawn class, detentions, inspections reported to two PSC regimes, name and flag changes | +| `ship_not_found.html` | Error page for an IMO number that does not exist | +| `login_page.html` | Page returned for an expired session or rejected login | +| `home_logged_in.html` | Home page after a successful login | +| `search_torm_page1.html`, `search_torm_page2.html` | Name search with two pages of ship results and company results | +| `search_no_results.html` | Name search without matches | +| `search_by_mmsi.html` | Advanced search by MMSI | +| `fleet_torm.html` | Single-page fleet (94 vessels) | +| `fleet_msc_page1.html`, `fleet_msc_page2.html` | First two pages of a 443-vessel fleet, trimmed to 5 vessels each | +| `fleet_unknown_company.html` | Error page for a company number that does not exist | diff --git a/tests/fixtures/fleet_msc_page1.html b/tests/fixtures/fleet_msc_page1.html new file mode 100644 index 0000000..89b414d --- /dev/null +++ b/tests/fixtures/fleet_msc_page1.html @@ -0,0 +1,11612 @@ + + + + + + + + + + +Equasis - FleetInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/fleet_msc_page2.html b/tests/fixtures/fleet_msc_page2.html new file mode 100644 index 0000000..5449895 --- /dev/null +++ b/tests/fixtures/fleet_msc_page2.html @@ -0,0 +1,11663 @@ + + + + + + + + + + +Equasis - FleetInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/fleet_torm.html b/tests/fixtures/fleet_torm.html new file mode 100644 index 0000000..9e114cd --- /dev/null +++ b/tests/fixtures/fleet_torm.html @@ -0,0 +1,15069 @@ + + + + + + + + + + +Equasis - FleetInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/fleet_unknown_company.html b/tests/fixtures/fleet_unknown_company.html new file mode 100644 index 0000000..b514821 --- /dev/null +++ b/tests/fixtures/fleet_unknown_company.html @@ -0,0 +1,701 @@ + + + + + + + + + + +Equasis - CtrlGeneralError + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + +
+
+
+
+
+
+
+
+ + + + + + + + + + + +

+We're sorry... +

+

it seems that we can't find the page you're looking for.

+ + +
+

You can:

+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/history_9074729.html b/tests/fixtures/history_9074729.html new file mode 100644 index 0000000..835b303 --- /dev/null +++ b/tests/fixtures/history_9074729.html @@ -0,0 +1,2324 @@ + + + + + + + + + + +Equasis - ShipHistory + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/history_9811000.html b/tests/fixtures/history_9811000.html new file mode 100644 index 0000000..8815585 --- /dev/null +++ b/tests/fixtures/history_9811000.html @@ -0,0 +1,1649 @@ + + + + + + + + + + +Equasis - ShipHistory + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/fixtures/home_logged_in.html b/tests/fixtures/home_logged_in.html new file mode 100644 index 0000000..88f36d6 --- /dev/null +++ b/tests/fixtures/home_logged_in.html @@ -0,0 +1,870 @@ + + + + + + + + + + +Equasis - HomePage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + +
+
+
+ +
+
+
+

+Welcome to Equasis, a public site promoting maritime safety and quality

+ +
+
+
+ + + +
+
+
+
+
+
+

+More news + +

+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+ + +
+ + +
+
+
+
+
+
+
+
+

LAST DATA UPDATE :

+
+
+
+
+
+
+
+
+
+
+4 hour(s) ago +

Indian Ocean MoU

+
+
+4 hour(s) ago +

Türk Loydu

+
+
+4 hour(s) ago +

Mediterranean MoU

+
+
+4 hour(s) ago +

Paris MoU

+
+
+4 hour(s) ago +

Nippon Kaiji Kyokai

+
+
+
+
+5 hour(s) ago +

Polish Register of Shipping

+
+
+5 hour(s) ago +

US Coast Guard - QualShip 21

+
+
+5 hour(s) ago +

US Coast Guard - E_ZERO

+
+
+6 hour(s) ago +

US Coast Guard

+
+
+6 hour(s) ago +

American Bureau of Shipping

+
+
+
+
+
+
+
+ +
+
+
+
+
+

USER ACTIVITY :

+
+
+
+
+
+
+
+
+

Connections : 99,824,313 since 2007

+

Users connected : 21255

+ + + + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/inspections_9074729.html b/tests/fixtures/inspections_9074729.html new file mode 100644 index 0000000..db72585 --- /dev/null +++ b/tests/fixtures/inspections_9074729.html @@ -0,0 +1,9495 @@ + + + + + + + + + + +Equasis - ShipInspection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/inspections_9811000.html b/tests/fixtures/inspections_9811000.html new file mode 100644 index 0000000..8736a20 --- /dev/null +++ b/tests/fixtures/inspections_9811000.html @@ -0,0 +1,2586 @@ + + + + + + + + + + +Equasis - ShipInspection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/login_page.html b/tests/fixtures/login_page.html new file mode 100644 index 0000000..980222c --- /dev/null +++ b/tests/fixtures/login_page.html @@ -0,0 +1,875 @@ + + + + + + + + + + +Equasis - HomePage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+ +
+
+
+

+Welcome to Equasis, a public site promoting maritime safety and quality

+ +
+
+
+ + + +
+
+
+
+
+
+

+More news + +

+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+

LAST DATA UPDATE :

+
+
+
+
+
+
+
+
+
+
+4 hour(s) ago +

Indian Ocean MoU

+
+
+4 hour(s) ago +

Türk Loydu

+
+
+4 hour(s) ago +

Mediterranean MoU

+
+
+4 hour(s) ago +

Paris MoU

+
+
+4 hour(s) ago +

Nippon Kaiji Kyokai

+
+
+
+
+5 hour(s) ago +

Polish Register of Shipping

+
+
+5 hour(s) ago +

US Coast Guard - QualShip 21

+
+
+5 hour(s) ago +

US Coast Guard - E_ZERO

+
+
+5 hour(s) ago +

US Coast Guard

+
+
+6 hour(s) ago +

American Bureau of Shipping

+
+
+
+
+
+
+
+ +
+
+
+
+
+

USER ACTIVITY :

+
+
+
+
+
+
+
+
+

Connections : 99,823,667 since 2007

+

Users connected : 21558

+ + + + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/search_by_mmsi.html b/tests/fixtures/search_by_mmsi.html new file mode 100644 index 0000000..5e2d943 --- /dev/null +++ b/tests/fixtures/search_by_mmsi.html @@ -0,0 +1,2580 @@ + + + + + + + + + + +Equasis - Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + +
+
+
+
+
+ +

+Advanced Search +

+
+
+
+
+
+ + + +
+
+
+
+
+ + +

+Ship +

+
+
+
+
+ + +

+Company +

+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/search_no_results.html b/tests/fixtures/search_no_results.html new file mode 100644 index 0000000..6405d78 --- /dev/null +++ b/tests/fixtures/search_no_results.html @@ -0,0 +1,2548 @@ + + + + + + + + + + +Equasis - Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+ +

+Advanced Search +

+
+
+
+
+
+ + + +
+
+
+
+
+ + +

+Ship +

+
+
+
+
+ + +

+Company +

+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/search_torm_page1.html b/tests/fixtures/search_torm_page1.html new file mode 100644 index 0000000..18bcb9e --- /dev/null +++ b/tests/fixtures/search_torm_page1.html @@ -0,0 +1,8218 @@ + + + + + + + + + + +Equasis - Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + +
+
+
+
+
+ +

+Advanced Search +

+
+
+
+
+
+ + + +
+
+
+
+
+ + +

+Ship +

+
+
+
+
+ + +

+Company +

+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/search_torm_page2.html b/tests/fixtures/search_torm_page2.html new file mode 100644 index 0000000..b359c08 --- /dev/null +++ b/tests/fixtures/search_torm_page2.html @@ -0,0 +1,3087 @@ + + + + + + + + + + +Equasis - Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + +
+
+
+
+
+ +

+Advanced Search +

+
+
+
+
+
+ + + +
+
+
+
+
+ + +

+Ship +

+
+
+
+
+ + +

+Company +

+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/ship_info_9074729.html b/tests/fixtures/ship_info_9074729.html new file mode 100644 index 0000000..4a902d9 --- /dev/null +++ b/tests/fixtures/ship_info_9074729.html @@ -0,0 +1,9062 @@ + + + + + + + + + + +Equasis - ShipInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+0.0% Of inspections having led to a detention in last 36 months +
+
+
+

+Flag performance +

+
+
+
+

Paris MOU

+
+
+
+
+
+

Black

+
+
+
+ +
+
+
+

Tokyo MOU

+
+
+
+
+
+

Black

+
+
+
+
+
+

+Flag targeting +

+
+
+
+

USCG: not targeted

+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
Status +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

International Register of Shipping (IS)

+
+
+

+Delivered +

+
+
+

during 09/2024

+
+
+
+
+
+
+
+
+
+
+
+
+
+

Registro Italiano Navale (IACS)

+
+
+

+Withdrawn +

+
+
+

since 31/05/2024

+
+
+

Non-compliance with conditions of class / recommendations

+
+
+
+
+
+
+
+
+
+
+
+
+
+

Bureau Veritas (IACS)

+
+
+

+Suspended +

+
+
+

since 20/10/2015

+
+
+

Survey overdue

+
+
+
+
+
+
+
+
+
+
+
+
+
+

Nippon Kaiji Kyokai (IACS)

+
+
+

+Withdrawn +

+
+
+

since 22/07/2008

+
+
+

Transfer of class to another IACS member

+
+
+
+
+
+
+
+
+
+
+
+Surveys +
+
+
+
+
+
+
+
+
+
+
+

+Registro Italiano Navale (IACS) +

+
+
+

+Last renewal survey + +

+
+
+

+Next renewal survey + +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/ship_info_9811000.html b/tests/fixtures/ship_info_9811000.html new file mode 100644 index 0000000..a06fdba --- /dev/null +++ b/tests/fixtures/ship_info_9811000.html @@ -0,0 +1,12317 @@ + + + + + + + + + + +Equasis - ShipInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+The ship is classed by (at least) one of the IACS member societies +
+0.0% Of inspections having led to a detention in last 36 months +
+
+
+

+Flag performance +

+
+
+
+

Paris MOU

+
+
+
+
+
+

White

+
+
+
+ +
+
+
+

Tokyo MOU

+
+
+
+
+
+

White

+
+
+
+
+
+

+Flag targeting +

+
+
+
+

USCG: targeted flag

+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
Status +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

American Bureau of Shipping (IACS)

+
+
+

+Delivered +

+
+
+

since 25/10/2018

+
+
+
+
+
+
+
+
+
+
+
+Surveys +
+
+
+
+
+
+
+
+
+
+
+

+American Bureau of Shipping (IACS) + + + +

+
+
+

+Last renewal survey + +

+
+
+

+Next renewal survey + +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

UK P&I Club

+
+
+

Inception at 25/09/2018

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/tests/fixtures/ship_not_found.html b/tests/fixtures/ship_not_found.html new file mode 100644 index 0000000..f59b943 --- /dev/null +++ b/tests/fixtures/ship_not_found.html @@ -0,0 +1,722 @@ + + + + + + + + + + +Equasis - CtrlGeneralError + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ + + + +
+
+

You don't have Cookies enabled in your browser.
Cookies help us deliver our services. By using our services, you agree to our use of Cookies. +
The Equasis Website use Cookies as a part of its core functionality, and if you browse this website without Cookies enabled then you won't have the full experience that you normally would.
This website may not work properly.

+
+
+
+
+
+
+ + + + +
+
+

Your browser is not supported by Equasis Website. +

+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+
+
+ + + + + + + + + + + +

+We're sorry... +

+

it seems that we can't find the page you're looking for.

+ + +
+

You can:

+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+ + 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 new file mode 100644 index 0000000..8455f02 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,295 @@ +"""End-to-end tests of the command-line interface with a fake Equasis client.""" + +from __future__ import annotations + +import csv +import io +import json +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from equasis_cli.cli import ExitCode, main +from equasis_cli.credentials import CredentialStore +from equasis_cli.models import CompanySummary +from equasis_cli.transport import Transport +from tests.fakes import FakeClient, fixture_fleet + +TORM = CompanySummary(company_id="0310062", name="TORM A/S", address="Hellerup") +TORM_NORWAY = CompanySummary(company_id="5313763", name="TORM NORWAY AS") + + +@dataclass +class Result: + code: int + stdout: str + stderr: str + + +class TTYStringIO(io.StringIO): + def isatty(self) -> bool: + return True + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CredentialStore: + monkeypatch.delenv("EQUASIS_USERNAME", raising=False) + monkeypatch.delenv("EQUASIS_PASSWORD", raising=False) + monkeypatch.setenv("NO_COLOR", "1") + credential_store = CredentialStore(tmp_path / "config") + credential_store.save("user@example.com", "secret") + return credential_store + + +@pytest.fixture +def fake() -> FakeClient: + return FakeClient(companies=[TORM, TORM_NORWAY], fleets={"0310062": fixture_fleet()}) + + +@pytest.fixture +def run(store: CredentialStore, fake: FakeClient): # type: ignore[no-untyped-def] + def invoke(*argv: str, stdin: str = "", tty: bool = False) -> Result: + stdout = io.StringIO() + stderr = TTYStringIO() if tty else io.StringIO() + stdin_stream = TTYStringIO(stdin) if tty else io.StringIO(stdin) + seen: list[Transport] = [] + + def factory(username: str, password: str, **options: object) -> FakeClient: + assert (username, password) == ("user@example.com", "secret") + seen.append(options["transport"]) # type: ignore[arg-type] + return fake + + code = main( + list(argv), + stdout=stdout, + stderr=stderr, + stdin=stdin_stream, + store=store, + client_factory=factory, # type: ignore[arg-type] + ) + return Result(int(code), stdout.getvalue(), stderr.getvalue()) + + return invoke + + +def test_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as raised: + main(["--version"]) + assert raised.value.code == 0 + assert capsys.readouterr().out.startswith("equasis ") + + +def test_no_arguments_without_terminal_prints_help() -> None: + stderr = io.StringIO() + code = main([], stdout=io.StringIO(), stdin=io.StringIO(), stderr=stderr) + assert code == ExitCode.USAGE + assert "usage: equasis" in stderr.getvalue() + + +def test_usage_errors_exit_2(run) -> None: # type: ignore[no-untyped-def] + with pytest.raises(SystemExit) as raised: + run("vessel", "--bogus") + assert raised.value.code == ExitCode.USAGE + + +def test_vessel_table_to_stdout_and_messages_to_stderr(run) -> None: # type: ignore[no-untyped-def] + result = run("vessel", "9074729") + assert result.code == ExitCode.OK + assert result.stdout.startswith("KAVITA (IMO 9074729)") + assert "Retrieving IMO 9074729" in result.stderr + + +@pytest.mark.parametrize( + "argv", + [ + ("vessel", "9074729", "--format", "json"), + ("--format", "json", "vessel", "9074729"), + ("--output", "json", "vessel", "--imo", "9074729"), + ("vessel", "-f", "json", "--imo", "9074729", "--quiet"), + ], +) +def test_format_options_work_before_or_after_the_command(run, argv) -> None: # type: ignore[no-untyped-def] + result = run(*argv) + assert result.code == ExitCode.OK + assert json.loads(result.stdout)["vessel"]["imo"] == "9074729" + + +def test_output_file_format_is_inferred_from_extension(run, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + target = tmp_path / "out" / "vessel.csv" + result = run("vessel", "9074729", "-o", str(target)) + + assert result.code == ExitCode.OK + assert result.stdout == "" + rows = list(csv.DictReader(io.StringIO(target.read_text(encoding="utf-8")))) + assert rows[0]["name"] == "KAVITA" + assert f"Saved to {target}" in result.stderr + + +def test_vessel_not_found_and_invalid_input(run) -> None: # type: ignore[no-untyped-def] + missing = run("vessel", "1234567") + assert missing.code == ExitCode.NOT_FOUND + assert "no vessel with IMO 1234567" in missing.stderr + + invalid = run("vessel", "EVERGIVEN") + assert invalid.code == ExitCode.USAGE + assert "not a valid IMO number" in invalid.stderr + + +def test_missing_credentials(run, store: CredentialStore) -> None: # type: ignore[no-untyped-def] + store.clear() + result = run("vessel", "9074729") + assert result.code == ExitCode.AUTHENTICATION + assert "equasis configure --setup" in result.stderr + + +def test_rejected_login(run, fake: FakeClient) -> None: # type: ignore[no-untyped-def] + fake.reject_login = True + result = run("vessel", "9074729") + assert result.code == ExitCode.AUTHENTICATION + + +def test_batch_from_file_reports_partial_failure(run, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + imo_file = tmp_path / "imos.txt" + imo_file.write_text("# fleet\n9074729 # KAVITA\n\n1234567\nnot-an-imo\n", encoding="utf-8") + + result = run("vessel", "--imo-file", str(imo_file), "--format", "json") + + assert result.code == ExitCode.PARTIAL_FAILURE + document = json.loads(result.stdout) + assert [r["status"] for r in document["results"]] == ["ok", "not_found", "error"] + assert "[2/3] 1234567" in result.stderr + assert "1 of 3 vessels retrieved" in result.stderr + + +def test_batch_jsonl_streams_to_file(run, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + target = tmp_path / "results.jsonl" + result = run("vessel", "9074729", "1234567", "-o", str(target)) + + assert result.code == ExitCode.PARTIAL_FAILURE + records = [json.loads(line) for line in target.read_text(encoding="utf-8").splitlines()] + assert [r["query"] for r in records] == ["9074729", "1234567"] + assert records[0]["vessel"]["name"] == "KAVITA" + + +def test_batch_from_stdin_with_fail_fast(run) -> None: # type: ignore[no-untyped-def] + result = run( + "vessel", "--imo-file", "-", "--fail-fast", "-f", "csv", stdin="1234567\n9074729\n" + ) + assert result.code == ExitCode.PARTIAL_FAILURE + rows = list(csv.DictReader(io.StringIO(result.stdout))) + assert [r["query"] for r in rows] == ["1234567"] + + +def test_unreadable_list_file(run, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + result = run("vessel", "--imo-file", str(tmp_path / "missing.txt")) + assert result.code == ExitCode.USAGE + assert "cannot read" in result.stderr + + +def test_search_by_name_and_identifier(run) -> None: # type: ignore[no-untyped-def] + by_name = run("search", "kavita", "-f", "json") + assert by_name.code == ExitCode.OK + assert json.loads(by_name.stdout)["search"]["ships"][0]["imo"] == "9074729" + + by_imo = run("search", "--imo", "9074729") + assert by_imo.code == ExitCode.OK + assert "KAVITA" in by_imo.stdout + + nothing = run("search", "zzz") + assert nothing.code == ExitCode.NOT_FOUND + + both = run("search", "kavita", "--imo", "9074729") + assert both.code == ExitCode.USAGE + + +def test_fleet_by_exact_name_and_company_id(run, fake: FakeClient) -> None: # type: ignore[no-untyped-def] + by_name = run("fleet", "TORM A/S", "-f", "csv") + assert by_name.code == ExitCode.OK + assert len(list(csv.DictReader(io.StringIO(by_name.stdout)))) == 94 + + by_id = run("fleet", "--company-id", "0310062", "-f", "json") + assert json.loads(by_id.stdout)["fleet"]["company"]["name"] == "TORM A/S" + + +def test_ambiguous_fleet_without_terminal_lists_candidates(run) -> None: # type: ignore[no-untyped-def] + result = run("fleet", "--company", "TORM") + assert result.code == ExitCode.NOT_FOUND + assert "TORM A/S (0310062)" in result.stderr + assert "--first-match" in result.stderr + + +def test_ambiguous_fleet_first_match(run) -> None: # type: ignore[no-untyped-def] + result = run("fleet", "TORM", "--first-match", "-f", "json") + assert result.code == ExitCode.OK + assert "using TORM A/S (0310062)" in result.stderr + + +def test_ambiguous_fleet_prompts_on_a_terminal(run) -> None: # type: ignore[no-untyped-def] + result = run("fleet", "TORM", "-f", "json", stdin="9\n1\n", tty=True) + assert result.code == ExitCode.OK + assert "Choose 1-2" in result.stderr + assert json.loads(result.stdout)["fleet"]["company"]["company_id"] == "0310062" + + +def test_fleet_batch(run) -> None: # type: ignore[no-untyped-def] + result = run("fleet", "0310062", "TORM", "-f", "json") + assert result.code == ExitCode.PARTIAL_FAILURE + statuses = [r["status"] for r in json.loads(result.stdout)["results"]] + assert statuses == ["ok", "ambiguous"] + + +def test_configure_show_test_and_clear(run, store: CredentialStore) -> None: # type: ignore[no-untyped-def] + shown = run("configure", "--show") + assert "username user@example.com" in shown.stdout + assert "secret" not in shown.stdout + + tested = run("configure", "--test") + assert tested.code == ExitCode.OK + assert "Logged in as user@example.com" in tested.stderr + + cleared = run("configure", "--clear") + assert cleared.code == ExitCode.OK + assert not store.path.exists() + + +def test_password_option_warns(run) -> None: # type: ignore[no-untyped-def] + result = run("vessel", "9074729", "--username", "user@example.com", "--password", "secret") + assert result.code == ExitCode.OK + assert "visible in shell history" in result.stderr + + +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, **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), "--no-cache"], + stdout=io.StringIO(), + stderr=io.StringIO(), + stdin=io.StringIO(), + store=store, + client_factory=factory, # type: ignore[arg-type] + ) + 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_client.py b/tests/test_client.py new file mode 100644 index 0000000..a2855c9 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,222 @@ +"""Tests for the Equasis client against recorded pages.""" + +from __future__ import annotations + +from collections.abc import Callable +from urllib.parse import parse_qs + +import pytest +import responses + +from equasis_cli.client import EquasisClient +from equasis_cli.exceptions import AuthenticationError, NotFoundError, SessionExpiredError +from equasis_cli.transport import BASE_URL, RetryPolicy, Transport + +Loader = Callable[[str], str] +LOGIN_URL = BASE_URL + "authen/HomePage?fs=HomePage" +SEARCH_URL = BASE_URL + "restricted/Search?fs=Search" +FLEET_URL = BASE_URL + "restricted/FleetInfo?fs=CompanyInfo" + + +def ship_url(page: str, imo: str) -> str: + return f"{BASE_URL}restricted/{page}?fs=ShipInfo&P_IMO={imo}" + + +@pytest.fixture +def client() -> EquasisClient: + transport = Transport(min_interval=0, retry=RetryPolicy(max_retries=0), sleep=lambda _: None) + return EquasisClient("user@example.com", "secret", transport=transport) + + +def mock_login(fixture_html: Loader, *, succeed: bool = True) -> None: + responses.get(LOGIN_URL, body=fixture_html("login_page")) + responses.post(LOGIN_URL, body=fixture_html("home_logged_in" if succeed else "login_page")) + + +def form(call_index: int) -> dict[str, str]: + body = responses.calls[call_index].request.body + text = body.decode() if isinstance(body, bytes) else str(body) + return {key: values[0] for key, values in parse_qs(text).items()} + + +@responses.activate +def test_login_posts_credentials(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + client.login() + assert client.logged_in + assert form(1) == {"j_email": "user@example.com", "j_password": "secret", "submit": "Login"} + + +@responses.activate +def test_rejected_login_raises(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html, succeed=False) + with pytest.raises(AuthenticationError): + client.login() + assert not client.logged_in + + +def test_credentials_are_required() -> None: + with pytest.raises(AuthenticationError): + EquasisClient("", "") + + +@responses.activate +def test_get_vessel_combines_all_pages(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.get(ship_url("ShipInfo", "9074729"), body=fixture_html("ship_info_9074729")) + responses.get(ship_url("ShipInspection", "9074729"), body=fixture_html("inspections_9074729")) + responses.get(ship_url("ShipHistory", "9074729"), body=fixture_html("history_9074729")) + + vessel = client.get_vessel("IMO 9074729") + + assert vessel.name == "KAVITA" + assert len(vessel.inspections) == 83 + assert vessel.name_history[0].name == "KAVITA" + assert vessel.company_history + assert vessel.warnings == [] + + +@responses.activate +def test_expired_session_is_renewed_transparently( + client: EquasisClient, fixture_html: Loader +) -> None: + mock_login(fixture_html) + mock_login(fixture_html) + responses.get(ship_url("ShipInfo", "9811000"), body=fixture_html("login_page")) + responses.get(ship_url("ShipInfo", "9811000"), body=fixture_html("ship_info_9811000")) + responses.get(ship_url("ShipInspection", "9811000"), body=fixture_html("inspections_9811000")) + responses.get(ship_url("ShipHistory", "9811000"), body=fixture_html("history_9811000")) + + vessel = client.get_vessel("9811000") + + assert vessel.name == "EVER GIVEN" + login_posts = [c for c in responses.calls if c.request.method == "POST"] + assert len(login_posts) == 2 + + +@responses.activate +def test_session_that_cannot_be_renewed_raises(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + mock_login(fixture_html) + responses.get(ship_url("ShipInfo", "9811000"), body=fixture_html("login_page")) + responses.get(ship_url("ShipInfo", "9811000"), body=fixture_html("login_page")) + + with pytest.raises(SessionExpiredError): + client.get_vessel("9811000") + + +@responses.activate +def test_unknown_vessel_raises_not_found(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.get(ship_url("ShipInfo", "1234567"), body=fixture_html("ship_not_found")) + + with pytest.raises(NotFoundError, match="1234567"): + client.get_vessel("1234567") + + +@responses.activate +def test_unparseable_secondary_page_becomes_a_warning( + client: EquasisClient, fixture_html: Loader +) -> None: + mock_login(fixture_html) + broken = fixture_html("inspections_9811000").replace("Date of report", "Reported") + responses.get(ship_url("ShipInfo", "9811000"), body=fixture_html("ship_info_9811000")) + responses.get(ship_url("ShipInspection", "9811000"), body=broken) + responses.get(ship_url("ShipHistory", "9811000"), body=fixture_html("history_9811000")) + + vessel = client.get_vessel("9811000") + + assert vessel.inspections == [] + assert vessel.flag_history + assert len(vessel.warnings) == 1 + assert "inspections unavailable" in vessel.warnings[0] + + +@responses.activate +def test_search_follows_ship_pagination(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.post(SEARCH_URL, body=fixture_html("search_torm_page1")) + responses.post(SEARCH_URL, body=fixture_html("search_torm_page2")) + + results = client.search("TORM") + + assert len(results.ships) == 105 + assert results.total_ships == 105 + assert len(results.companies) == 8 + assert form(2)["P_ENTREE_ENTETE"] == "TORM" + follow_up = form(3) + assert follow_up["P_PAGE_SHIP"] == "2" + assert follow_up["P_ENTREE"] == "TORM" + assert follow_up["ongletActifSC"] == "ship" + + +@responses.activate +def test_search_respects_max_pages(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.post(SEARCH_URL, body=fixture_html("search_torm_page1")) + + results = client.search("TORM", max_pages=1) + + assert len(results.ships) == 100 + assert results.total_ships == 105 + + +@responses.activate +def test_company_only_search_skips_ship_pages(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.post(SEARCH_URL, body=fixture_html("search_torm_page1")) + + results = client.search("TORM", max_pages=None, ships=False) + + assert results.ships == [] + assert len(results.companies) == 8 + assert len(responses.calls) == 3 + + +@responses.activate +def test_search_ships_by_mmsi(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.post(SEARCH_URL, body=fixture_html("search_by_mmsi")) + + results = client.search_ships(mmsi="353136000") + + assert [s.name for s in results.ships] == ["EVER GIVEN"] + sent = form(2) + assert sent["P_MMSI"] == "353136000" + assert sent["buttonAdvancedSearch"] == "advancedOk" + + +@responses.activate +def test_fleet_pagination_stops_when_pages_repeat( + client: EquasisClient, fixture_html: Loader +) -> None: + mock_login(fixture_html) + responses.post(FLEET_URL, body=fixture_html("fleet_msc_page1")) + responses.post(FLEET_URL, body=fixture_html("fleet_msc_page2")) + responses.post(FLEET_URL, body=fixture_html("fleet_msc_page2")) + + fleet = client.get_fleet("0152944") + + assert fleet.company.name == "MSC MEDITERRANEAN SHIPPING CO" + assert fleet.total_vessels == 443 + assert len(fleet.vessels) == 10 + assert [form(i)["P_PAGE"] for i in (2, 3, 4)] == ["1", "2", "3"] + + +@responses.activate +def test_fleet_max_pages(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.post(FLEET_URL, body=fixture_html("fleet_msc_page1")) + + fleet = client.get_fleet("0152944", max_pages=1) + + assert len(fleet.vessels) == 5 + + +@responses.activate +def test_unknown_company_raises_not_found(client: EquasisClient, fixture_html: Loader) -> None: + mock_login(fixture_html) + responses.post(FLEET_URL, body=fixture_html("fleet_unknown_company")) + + with pytest.raises(NotFoundError, match="9999999"): + client.get_fleet("9999999") diff --git a/tests/test_completions.py b/tests/test_completions.py new file mode 100644 index 0000000..06814a6 --- /dev/null +++ b/tests/test_completions.py @@ -0,0 +1,40 @@ +"""Keep the shell completion scripts in sync with the argument parser.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest + +from equasis_cli.cli import build_parser + +COMPLETIONS = Path(__file__).parent.parent / "completions" + + +def visible_options(parser: argparse.ArgumentParser) -> set[str]: + options = set() + for action in parser._actions: + if action.help == argparse.SUPPRESS: + continue + options.update(o for o in action.option_strings if o.startswith("--")) + return options + + +def parsers() -> dict[str, argparse.ArgumentParser]: + root = build_parser() + found = {"": root} + for action in root._actions: + if isinstance(action, argparse._SubParsersAction): + found.update(action.choices) + return found + + +@pytest.mark.parametrize("script", ["_equasis", "equasis.bash"]) +def test_completion_scripts_cover_every_command_and_option(script: str) -> None: + content = (COMPLETIONS / script).read_text(encoding="utf-8") + for name, parser in parsers().items(): + if name: + assert name in content, f"{script} is missing the '{name}' command" + for option in visible_options(parser) - {"--help", "--version"}: + assert option in content, f"{script} is missing {option} ({name or 'global'})" diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..58d848a --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,99 @@ +"""Tests for credential resolution and storage.""" + +from __future__ import annotations + +import json +import os +import stat +import sys +from pathlib import Path + +import pytest + +from equasis_cli.credentials import ( + CredentialSource, + CredentialStore, + default_config_dir, +) + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CredentialStore: + monkeypatch.delenv("EQUASIS_USERNAME", raising=False) + monkeypatch.delenv("EQUASIS_PASSWORD", raising=False) + return CredentialStore(tmp_path / "config") + + +def test_resolution_order(store: CredentialStore, monkeypatch: pytest.MonkeyPatch) -> None: + assert store.resolve() is None + + store.save("file@example.com", "file-secret") + creds = store.resolve() + assert creds is not None + assert (creds.username, creds.source) == ("file@example.com", CredentialSource.CONFIG_FILE) + + monkeypatch.setenv("EQUASIS_USERNAME", "env@example.com") + monkeypatch.setenv("EQUASIS_PASSWORD", "env-secret") + creds = store.resolve() + assert creds is not None + assert creds.source is CredentialSource.ENVIRONMENT + + creds = store.resolve("arg@example.com", "arg-secret") + assert creds is not None + assert creds.source is CredentialSource.ARGUMENTS + + +def test_password_is_not_in_repr(store: CredentialStore) -> None: + store.save("user@example.com", "hunter2") + assert "hunter2" not in repr(store.resolve()) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions") +def test_saved_file_is_private(store: CredentialStore) -> None: + path = store.save("user@example.com", "secret") + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert json.loads(path.read_text(encoding="utf-8")) == { + "username": "user@example.com", + "password": "secret", + } + + +def test_legacy_file_with_extra_keys_is_accepted(store: CredentialStore) -> None: + store.config_dir.mkdir(parents=True) + store.path.write_text( + json.dumps({"username": "u", "password": "p", "created_by": "equasis-cli", "note": "x"}), + encoding="utf-8", + ) + assert store.load() == ("u", "p") + + +def test_corrupt_file_is_ignored(store: CredentialStore) -> None: + store.config_dir.mkdir(parents=True) + store.path.write_text("{not json", encoding="utf-8") + assert store.load() is None + + +def test_clear_and_describe(store: CredentialStore) -> None: + assert store.clear() is False + store.save("user@example.com", "secret") + info = store.describe() + assert info["file"]["complete"] is True + assert info["file"]["username"] == "user@example.com" + assert "secret" not in json.dumps(info) + assert store.clear() is True + assert not store.path.exists() + + +def test_default_config_dir_honours_xdg(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert default_config_dir() == tmp_path / "equasis-cli" + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions") +def test_group_readable_file_triggers_warning( + store: CredentialStore, caplog: pytest.LogCaptureFixture +) -> None: + path = store.save("user@example.com", "secret") + path.chmod(0o644) + store.load() + assert "readable by other users" in caplog.text 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_live.py b/tests/test_live.py new file mode 100644 index 0000000..f96a380 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,55 @@ +"""Checks against the real Equasis website. + +These tests are skipped unless ``EQUASIS_LIVE_TESTS=1`` is set and credentials are +available. They make a handful of rate-limited requests and exist to detect +changes to the Equasis website early. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from equasis_cli.client import EquasisClient +from equasis_cli.credentials import CredentialStore +from equasis_cli.exceptions import NotFoundError + +pytestmark = pytest.mark.live + + +@pytest.fixture(scope="module") +def client() -> Iterator[EquasisClient]: + credentials = CredentialStore().resolve() + if credentials is None: + pytest.skip("no Equasis credentials configured") + with EquasisClient(credentials.username, credentials.password) as live_client: + yield live_client + + +def test_vessel_profile(client: EquasisClient) -> None: + vessel = client.get_vessel("9811000") + assert vessel.name == "EVER GIVEN" + assert vessel.flag_code + assert vessel.gross_tonnage + assert vessel.management + assert vessel.inspections + assert vessel.name_history + assert vessel.warnings == [] + + +def test_unknown_vessel(client: EquasisClient) -> None: + with pytest.raises(NotFoundError): + client.get_vessel("1234567") + + +def test_search(client: EquasisClient) -> None: + results = client.search("TORM", max_pages=1) + assert results.ships + assert any(c.company_id == "0310062" for c in results.companies) + + +def test_fleet(client: EquasisClient) -> None: + fleet = client.get_fleet("0310062") + assert fleet.company.name == "TORM A/S" + assert len(fleet.vessels) > 10 diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..73c0252 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,184 @@ +"""Tests for output rendering and file writing.""" + +from __future__ import annotations + +import csv +import io +import json +import os +import stat +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from equasis_cli import output +from equasis_cli.models import BatchItem, CompanySummary, ItemStatus, SearchResults, ShipSummary +from tests.fakes import fixture_fleet, fixture_vessel + +MOMENT = datetime(2026, 9, 16, 12, 0, tzinfo=timezone.utc) + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("out.json", "json"), + ("OUT.CSV", "csv"), + ("dir/out.jsonl", "jsonl"), + ("x.ndjson", "jsonl"), + ("x.txt", "table"), + ("x.xlsx", None), + (None, None), + ], +) +def test_infer_format(path: str | None, expected: str | None) -> None: + assert output.infer_format(path) == expected + + +def test_resolve_format_prefers_explicit_then_extension() -> None: + assert output.resolve_format("csv", "out.json") == "csv" + assert output.resolve_format(None, "out.json") == "json" + assert output.resolve_format(None, None, default="json") == "json" + + +def test_vessel_json_envelope_is_valid_and_complete() -> None: + fixture = fixture_vessel() + fixture.retrieved_at = MOMENT + document = json.loads(output.render(fixture, "json")) + + assert document["schema_version"] == 1 + assert document["type"] == "vessel" + assert document["retrieved_at"] == "2026-09-16T12:00:00Z" + vessel = document["vessel"] + assert vessel["retrieved_at"] == "2026-09-16T12:00:00Z" + assert vessel["imo"] == "9074729" + assert vessel["gross_tonnage"] == 15899 + assert vessel["status_since"] == "2025-09-25" + assert len(vessel["inspections"]) == 83 + assert vessel["inspections"][0]["deficiencies"] == 8 + assert vessel["inspections"][0]["detained"] is False + assert vessel["name_history"][1]["until"] == "2022-02-01" + + +def test_vessel_jsonl_is_a_single_line() -> None: + text = output.render(fixture_vessel(), "jsonl", retrieved_at=MOMENT) + assert text.count("\n") == 1 + assert json.loads(text)["vessel"]["name"] == "KAVITA" + + +def test_vessel_csv_has_header_and_summary_row() -> None: + rows = list(csv.DictReader(io.StringIO(output.render(fixture_vessel(), "csv")))) + + assert len(rows) == 1 + row = rows[0] + assert row["imo"] == "9074729" + assert row["flag"] == "Palau (Republic of)" + assert row["registered_owner"] == "CASSINI SHIP OWNING CO" + assert row["ism_manager"] == "COSTALINA SHIP MANAGEMENT FZC" + assert row["inspections"] == "83" + assert row["detentions"] == "4" + assert row["last_inspection"] == "2025-01-08" + assert row["uscg_targeted"] == "false" + assert row["name_changes"] == "4" + + +def test_vessel_table_is_readable() -> None: + text = output.render(fixture_vessel(), "table") + assert text.startswith("KAVITA (IMO 9074729)\n") + assert "PSC inspections (83 total, 4 with detention)" in text + assert "73 older inspections" in text + assert "Detention: N" not in text + + +def test_csv_quotes_values_containing_commas() -> None: + results = SearchResults( + query="x", + companies=[CompanySummary(company_id="1", name="ACME, INC.", address='1 "Dock" Rd, Port')], + ) + rows = list(csv.reader(io.StringIO(output.render(results, "csv")))) + assert rows[1][3] == "ACME, INC." + assert rows[1][8] == '1 "Dock" Rd, Port' + + +def test_search_jsonl_labels_record_kinds() -> None: + results = SearchResults( + query="TORM", + ships=[ShipSummary(imo="9465992", name="TORM AGNES")], + companies=[CompanySummary(company_id="0310062", name="TORM A/S")], + ) + records = [json.loads(line) for line in output.render(results, "jsonl").splitlines()] + assert [r["kind"] for r in records] == ["ship", "company"] + + +def test_search_table_without_results() -> None: + assert "No ships or companies match" in output.render(SearchResults(query="zz"), "table") + + +def test_fleet_formats() -> None: + fleet = fixture_fleet() + rows = list(csv.DictReader(io.StringIO(output.render(fleet, "csv")))) + assert len(rows) == 94 + assert rows[0]["company_name"] == "TORM A/S" + assert "ISM Manager (since 2008-02-26)" in rows[0]["roles"] + + lines = output.render(fleet, "jsonl").splitlines() + assert json.loads(lines[0])["company_id"] == "0310062" + + assert "TORM A/S (company no. 0310062)" in output.render(fleet, "table") + + +def test_batch_formats() -> None: + report = output.BatchReport( + kind="vessels", + items=[ + BatchItem(query="9074729", status=ItemStatus.OK, result=fixture_vessel()), + BatchItem(query="1234567", status=ItemStatus.NOT_FOUND, error="no vessel"), + ], + ) + document = json.loads(output.render(report, "json", retrieved_at=MOMENT)) + assert document["type"] == "batch_vessels" + assert document["summary"] == { + "total": 2, + "succeeded": 1, + "failed": 1, + "started_at": output._timestamp(report.started_at), + } + assert document["results"][1] == { + "query": "1234567", + "status": "not_found", + "error": "no vessel", + "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] == [ + ("9074729", "ok", "KAVITA"), + ("1234567", "not_found", ""), + ] + assert "1 of 2 vessel lookups succeeded" in output.render(report, "table") + + +def test_render_rejects_unknown_format() -> None: + with pytest.raises(ValueError, match="unknown output format"): + output.render(SearchResults(query="x"), "xml") + + +def test_write_output_is_atomic_and_utf8(tmp_path: Path) -> None: + target = tmp_path / "nested" / "out.json" + output.write_output('{"name": "Ålesund"}', target) + assert target.read_text(encoding="utf-8") == '{"name": "Ålesund"}\n' + assert list(target.parent.iterdir()) == [target] + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions") +def test_write_output_respects_umask(tmp_path: Path) -> None: + previous = os.umask(0o022) + try: + target = output.write_output("x", tmp_path / "out.txt") + finally: + os.umask(previous) + assert stat.S_IMODE(target.stat().st_mode) == 0o644 diff --git a/tests/test_parsing_helpers.py b/tests/test_parsing_helpers.py new file mode 100644 index 0000000..b45bf3a --- /dev/null +++ b/tests/test_parsing_helpers.py @@ -0,0 +1,74 @@ +"""Tests for the low-level HTML helpers.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from equasis_cli.exceptions import LayoutChangedError +from equasis_cli.parsing.html import iter_rows, make_soup, parse_date, parse_float, parse_int + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("since 01/02/2022", date(2022, 2, 1)), + ("25/09/2018", date(2018, 9, 25)), + ("2025-01-08", date(2025, 1, 8)), + ("during 09/2024", date(2024, 9, 1)), + ("Inception at 25/09/2018", date(2018, 9, 25)), + ("Last update of ship particulars 15/09/2026", date(2026, 9, 15)), + ("31/02/2020", None), + ("", None), + (None, None), + ("September 2026", None), + ], +) +def test_parse_date(text: str | None, expected: date | None) -> None: + assert parse_date(text) == expected + + +@pytest.mark.parametrize( + ("text", "expected"), + [("219079", 219079), ("1,234", 1234), (" 8 ", 8), ("", None), ("N/A", None)], +) +def test_parse_int(text: str, expected: int | None) -> None: + assert parse_int(text) == expected + + +def test_parse_float() -> None: + assert parse_float("12.5%") == 12.5 + assert parse_float("none") is None + + +TABLE = """ + + + + + + + + +
ABC
a1b1c1
b2c2
a3b3c3
+""" + + +def test_iter_rows_expands_nested_rowspan_rows_and_skips_mobile_duplicates() -> None: + table = make_soup(TABLE).find("table") + rows = list(iter_rows(table, required=("a", "b"))) # type: ignore[arg-type] + + assert [(r.text("a"), r.text("b"), r.text("c")) for r in rows] == [ + ("a1", "b1", "c1"), + ("a1", "b2", "c2"), + ("a3", "b3", "c3"), + ] + assert [r.continuation for r in rows] == [False, True, False] + assert rows[1].own_text("a") is None + + +def test_iter_rows_reports_missing_columns() -> None: + table = make_soup(TABLE).find("table") + with pytest.raises(LayoutChangedError, match="missing expected columns"): + list(iter_rows(table, required=("a", "deficiencies"))) # type: ignore[arg-type] diff --git a/tests/test_parsing_pages.py b/tests/test_parsing_pages.py new file mode 100644 index 0000000..b8cf9e0 --- /dev/null +++ b/tests/test_parsing_pages.py @@ -0,0 +1,47 @@ +"""Tests for page classification and fixture hygiene.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from equasis_cli.exceptions import NotFoundError, SessionExpiredError +from equasis_cli.parsing import PageKind, classify, is_logged_in, parse_ship_info +from tests.conftest import FIXTURES + + +@pytest.mark.parametrize( + ("fixture", "kind"), + [ + ("login_page", PageKind.LOGIN), + ("ship_not_found", PageKind.NOT_FOUND), + ("home_logged_in", PageKind.CONTENT), + ("ship_info_9811000", PageKind.CONTENT), + ("search_no_results", PageKind.CONTENT), + ], +) +def test_classify(fixture_html: Callable[[str], str], fixture: str, kind: PageKind) -> None: + assert classify(fixture_html(fixture)) is kind + + +def test_is_logged_in(fixture_html: Callable[[str], str]) -> None: + assert is_logged_in(fixture_html("home_logged_in")) + assert not is_logged_in(fixture_html("login_page")) + + +def test_parsers_raise_specific_errors(fixture_html: Callable[[str], str]) -> None: + with pytest.raises(SessionExpiredError): + parse_ship_info(fixture_html("login_page")) + with pytest.raises(NotFoundError): + parse_ship_info(fixture_html("ship_not_found")) + + +def test_fixtures_are_anonymized() -> None: + from equasis_cli.parsing.html import make_soup + + for path in sorted(FIXTURES.glob("*.html")): + soup = make_soup(path.read_text(encoding="utf-8")) + names = {h.get_text(strip=True) for h in soup.find_all("h3", class_="bleu-equasis")} + assert names <= {"Test User"}, f"{path.name} contains an account name" + assert not soup.find_all("script"), f"{path.name} was not minimized" diff --git a/tests/test_parsing_search_fleet.py b/tests/test_parsing_search_fleet.py new file mode 100644 index 0000000..6d851a9 --- /dev/null +++ b/tests/test_parsing_search_fleet.py @@ -0,0 +1,84 @@ +"""Tests for the search results and Fleet info parsers.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import date + +from equasis_cli.models import CompanySummary, RoleSince, ShipSummary +from equasis_cli.parsing import parse_fleet, parse_search + +Loader = Callable[[str], str] + + +def test_search_first_page(fixture_html: Loader) -> None: + page = parse_search(fixture_html("search_torm_page1")) + + assert len(page.ships) == 100 # mobile duplicate rows are ignored + assert page.total_ships == 105 + assert page.last_ship_page == 2 + assert page.ships[0] == ShipSummary( + imo="9465992", + name="TORM AGNES", + gross_tonnage=30302, + ship_type="Chemical/Oil Products Tanker", + year_built=2011, + flag="Denmark (DIS) (DIS)", + ) + assert page.companies[0] == CompanySummary( + company_id="0310062", + name="TORM A/S", + address="Tuborg Havnevej 18, 2900, Hellerup, Denmark.", + ) + assert len({c.company_id for c in page.companies}) == len(page.companies) == 8 + + +def test_search_last_page(fixture_html: Loader) -> None: + page = parse_search(fixture_html("search_torm_page2")) + assert len(page.ships) == 5 + assert page.total_ships == 105 + + +def test_search_without_results(fixture_html: Loader) -> None: + page = parse_search(fixture_html("search_no_results")) + assert page.ships == [] + assert page.companies == [] + assert page.total_ships is None + + +def test_advanced_search_by_mmsi(fixture_html: Loader) -> None: + page = parse_search(fixture_html("search_by_mmsi")) + assert [s.imo for s in page.ships] == ["9811000"] + + +def test_fleet_single_page(fixture_html: Loader) -> None: + page = parse_fleet(fixture_html("fleet_torm")) + + assert page.company.company_id == "0310062" + assert page.company.name == "TORM A/S" + assert page.company.status == "Active" + assert page.company.address == "Tuborg Havnevej 18, 2900, Hellerup, Denmark." + assert page.company.last_update == date(2026, 9, 15) + assert len(page.vessels) == page.total_vessels == 94 + assert page.last_page == 1 + + first = page.vessels[0] + assert (first.imo, first.name, first.gross_tonnage) == ("9307798", "TORM VENTURE", 42162) + assert first.flag == "Denmark (DIS)" + assert first.class_societies == ["LRS"] + assert first.detentions_all_companies_3y == 0 + assert RoleSince(role="ISM Manager", since=date(2008, 2, 26)) in first.roles + assert len({v.imo for v in page.vessels}) == 94 + + +def test_fleet_pagination_metadata(fixture_html: Loader) -> None: + page = parse_fleet(fixture_html("fleet_msc_page1")) + assert page.company.company_id == "0152944" + assert page.total_vessels == 443 + assert page.last_page == 5 + + +def test_fleet_role_with_year_only(fixture_html: Loader) -> None: + page = parse_fleet(fixture_html("fleet_msc_page2")) + roles = [role for vessel in page.vessels for role in vessel.roles] + assert RoleSince(role="Ship manager/Commercial manager", since=None) in roles diff --git a/tests/test_parsing_ship.py b/tests/test_parsing_ship.py new file mode 100644 index 0000000..ed7dae2 --- /dev/null +++ b/tests/test_parsing_ship.py @@ -0,0 +1,178 @@ +"""Tests for the Ship Info, Inspections and Ship History parsers.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import date + +import pytest + +from equasis_cli.exceptions import LayoutChangedError +from equasis_cli.models import ( + ClassStatus, + ClassSurvey, + CompanyChange, + CompanyRole, + FlagChange, + FlagPerformance, + NameChange, + PandIInsurer, + SafetyCertificate, +) +from equasis_cli.parsing import parse_history, parse_inspections, parse_ship_info + +Loader = Callable[[str], str] + + +def test_ship_info_particulars(fixture_html: Loader) -> None: + vessel = parse_ship_info(fixture_html("ship_info_9811000")) + + assert vessel.imo == "9811000" + assert vessel.name == "EVER GIVEN" + assert vessel.flag == "Panama" + assert vessel.flag_code == "PAN" + assert vessel.call_sign == "H3RC" + assert vessel.mmsi == "353136000" + assert vessel.gross_tonnage == 219079 + assert vessel.deadweight == 199489 + assert vessel.ship_type == "Container Ship" + assert vessel.year_built == 2018 + assert vessel.status == "In Service/Commission" + assert vessel.status_since == date(2021, 11, 14) + assert vessel.particulars_updated == date(2026, 9, 15) + + +def test_ship_info_panels(fixture_html: Loader) -> None: + vessel = parse_ship_info(fixture_html("ship_info_9811000")) + + assert vessel.flag_performance == FlagPerformance( + paris_mou="White", + tokyo_mou="White", + uscg_targeted=True, + iacs_classed=True, + detention_rate_36_months=0.0, + ) + assert vessel.management[0] == CompanyRole( + company_id="1135348", + name="BERNHARD SCHULTE-HKG LP", + role="Ship manager/Commercial manager", + address="Room 2602, K Wah Centre, 191, Java Road, North Point, Hong Kong, China.", + since=date(2021, 3, 1), + ) + assert {c.role for c in vessel.management} >= {"ISM Manager", "Registered owner"} + assert vessel.class_status == [ + ClassStatus( + society="American Bureau of Shipping (IACS)", + status="Delivered", + since=date(2018, 10, 25), + ) + ] + assert vessel.class_surveys == [ + ClassSurvey( + society="American Bureau of Shipping (IACS)", + last_renewal=date(2023, 7, 12), + next_renewal=date(2028, 9, 24), + ) + ] + assert vessel.safety_certificates == [ + SafetyCertificate( + society="American Bureau of Shipping (IACS)", + survey_date=date(2023, 12, 4), + expiry_date=date(2029, 1, 31), + status_change_date=date(2018, 9, 25), + status="Delivered", + convention="Convention", + ) + ] + assert vessel.pandi == [PandIInsurer(name="UK P&I Club", inception=date(2018, 9, 25))] + assert vessel.sightings[1].areas == ["West Africa", "West Europe"] + assert vessel.sightings[1].source == "VesselTracker" + + +def test_ship_info_black_listed_flag_and_withdrawn_class(fixture_html: Loader) -> None: + vessel = parse_ship_info(fixture_html("ship_info_9074729")) + + assert vessel.flag == "Palau (Republic of)" + assert vessel.flag_code == "PLW" + assert vessel.status == "Broken Up" + assert vessel.flag_performance.paris_mou == "Black" + assert vessel.flag_performance.uscg_targeted is False + assert vessel.flag_performance.iacs_classed is False + withdrawn = vessel.class_status[1] + assert withdrawn.status == "Withdrawn" + assert withdrawn.reason == "Non-compliance with conditions of class / recommendations" + assert vessel.class_status[0].since == date(2024, 9, 1) # "during 09/2024" + assert vessel.pandi == [] + + +def test_comoros_flag_image_alias(fixture_html: Loader) -> None: + html = fixture_html("ship_info_9811000").replace("flags/PAN.png", "flags/XCM.png") + assert parse_ship_info(html).flag_code == "COM" + + +def test_ship_info_without_heading_is_a_layout_change(fixture_html: Loader) -> None: + html = fixture_html("ship_info_9811000").replace("color-gris-bleu-copyright", "renamed") + with pytest.raises(LayoutChangedError): + parse_ship_info(html) + + +def test_inspections_with_detentions_and_multi_regime_reports(fixture_html: Loader) -> None: + inspections, human_element = parse_inspections(fixture_html("inspections_9074729")) + + assert len(inspections) == 83 + assert sum(1 for i in inspections if i.detained) == 4 + assert all(i.detained is not None for i in inspections) + + latest = inspections[0] + assert (latest.authority, latest.port, latest.date) == ("Oman", "Sohar", date(2025, 1, 8)) + assert latest.detained is False + assert latest.deficiencies == 8 + assert latest.reports[0].psc_organisation == "Indian Ocean MoU" + assert latest.reports[0].inspection_type == "Initial inspection" + assert latest.reports[0].inspection_id + + # One inspection reported to two regimes is a single inspection, not two. + romania = next(i for i in inspections if i.date == date(2022, 2, 20)) + assert romania.authority == "Romania" + assert [r.psc_organisation for r in romania.reports] == ["Black Sea MoU", "Paris MoU"] + assert all(i.authority not in {"Paris MoU", "Black Sea MoU"} for i in inspections) + assert all(i.date is not None for i in inspections) + + assert human_element[0].psc_organisation == "Indian Ocean MoU" + assert human_element[0].date == date(2025, 1, 8) + assert human_element[0].deficiencies == 2 + + +def test_inspections_without_human_element_panel(fixture_html: Loader) -> None: + inspections, human_element = parse_inspections(fixture_html("inspections_9811000")) + assert inspections + assert human_element == [] + + +def test_history(fixture_html: Loader) -> None: + names, flags, classes, companies = parse_history(fixture_html("history_9074729")) + + assert names[:3] == [ + NameChange(name="KAVITA", since=date(2022, 2, 1), until=None, source="IHS Maritime"), + NameChange( + name="Diamond A", since=date(2016, 7, 1), until=date(2022, 2, 1), source="IHS Maritime" + ), + NameChange( + name="Venta", since=date(2008, 5, 1), until=date(2016, 7, 1), source="IHS Maritime" + ), + ] + assert flags[0] == FlagChange( + flag="Palau (Republic of)", since=date(2016, 7, 1), until=None, source="IHS Maritime" + ) + assert flags[1].until == date(2016, 7, 1) + assert classes[0].society == "Registro Italiano Navale (IACS)" + assert classes[0].survey_date == date(2022, 2, 18) + assert companies[0] == CompanyChange( + company="COSTALINA SHIP MANAGEMENT FZC", + role="ISM Manager", + since=date(2024, 4, 9), + until=None, + source="IHS Maritime", + ) + ism_managers = [c for c in companies if c.role == "ISM Manager"] + assert ism_managers[1].until == ism_managers[0].since diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..2b9c04e --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,99 @@ +"""Tests for batch processing and company resolution shared by both interfaces.""" + +from __future__ import annotations + +import pytest + +from equasis_cli.exceptions import AuthenticationError +from equasis_cli.models import CompanySummary, ItemStatus +from equasis_cli.service import Resolution, iter_fleets, iter_vessels, resolve_company +from tests.fakes import FakeClient, fixture_fleet + +TORM = CompanySummary(company_id="0310062", name="TORM A/S") +TORM_NORWAY = CompanySummary(company_id="5313763", name="TORM NORWAY AS") + + +def test_iter_vessels_reports_every_item() -> None: + client = FakeClient() + started: list[tuple[int, int, str]] = [] + + items = list( + iter_vessels( + client, # type: ignore[arg-type] + ["9074729", "EVER GIVEN", "1234567", "9074728"], + on_start=lambda *args: started.append(args), + ) + ) + + assert [i.status for i in items] == [ + ItemStatus.OK, + ItemStatus.ERROR, + ItemStatus.NOT_FOUND, + ItemStatus.NOT_FOUND, + ] + assert items[0].result is not None + assert items[0].result.name == "KAVITA" + assert "not a valid IMO number" in (items[1].error or "") + assert "may contain a typo" in items[3].warnings[0] # explains the likely cause + assert [s[0] for s in started] == [1, 2, 3, 4] + + +def test_iter_vessels_warns_about_check_digit() -> None: + vessel = FakeClient().vessels["9074729"] + client = FakeClient(vessels={"9074728": vessel}) + [item] = iter_vessels(client, ["9074728"]) # type: ignore[arg-type] + assert item.status is ItemStatus.OK + assert "check digit" in item.warnings[0] + + +def test_iter_vessels_fail_fast_stops_after_first_failure() -> None: + items = list(iter_vessels(FakeClient(), ["1234567", "9074729"], fail_fast=True)) # type: ignore[arg-type] + assert [i.query for i in items] == ["1234567"] + + +def test_authentication_failure_aborts_batch() -> None: + with pytest.raises(AuthenticationError): + list(iter_vessels(FakeClient(reject_login=True), ["9074729"])) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("query", "companies", "resolution", "company"), + [ + ("torm a/s", [TORM, TORM_NORWAY], Resolution.RESOLVED, TORM), + ("TORM NORWAY", [TORM_NORWAY], Resolution.RESOLVED, TORM_NORWAY), + ("TORM", [TORM, TORM_NORWAY], Resolution.AMBIGUOUS, None), + ("MAERSK", [TORM], Resolution.NOT_FOUND, None), + ], +) +def test_resolve_company( + query: str, + companies: list[CompanySummary], + resolution: Resolution, + company: CompanySummary | None, +) -> None: + match = resolve_company(FakeClient(companies=companies), query) # type: ignore[arg-type] + assert match.resolution is resolution + assert match.company == company + + +def test_iter_fleets_handles_ids_names_and_ambiguity() -> None: + client = FakeClient(companies=[TORM, TORM_NORWAY], fleets={"0310062": fixture_fleet()}) + + items = list(iter_fleets(client, ["0310062", "TORM A/S", "TORM", "NOBODY"])) # type: ignore[arg-type] + + assert [i.status for i in items] == [ + ItemStatus.OK, + ItemStatus.OK, + ItemStatus.AMBIGUOUS, + ItemStatus.NOT_FOUND, + ] + assert "TORM A/S (0310062)" in (items[2].error or "") + + +def test_iter_fleets_first_match_records_warning() -> None: + client = FakeClient(companies=[TORM, TORM_NORWAY], fleets={"0310062": fixture_fleet()}) + + [item] = iter_fleets(client, ["TORM"], first_match=True) # type: ignore[arg-type] + + assert item.status is ItemStatus.OK + assert "using TORM A/S (0310062)" in item.warnings[0] diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..12cbc6d --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,130 @@ +"""Tests for HTTP pacing, retries, and response capture.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import requests +import responses + +from equasis_cli.exceptions import NetworkError, RateLimitedError +from equasis_cli.transport import BASE_URL, RetryPolicy, Transport + + +class FakeClock: + def __init__(self) -> None: + self.now = 100.0 + self.sleeps: list[float] = [] + + def time(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +def make_transport(clock: FakeClock, **kwargs: object) -> Transport: + policy = RetryPolicy(max_retries=2, base_delay=1.0, jitter=0.0) + return Transport(sleep=clock.sleep, clock=clock.time, retry=policy, **kwargs) # type: ignore[arg-type] + + +@responses.activate +def test_successful_request_returns_body_and_sends_parameters() -> None: + clock = FakeClock() + responses.get( + BASE_URL + "restricted/ShipInfo?fs=ShipInfo&P_IMO=9811000", body="ok" + ) + transport = make_transport(clock, min_interval=0) + + body = transport.get("restricted/ShipInfo", params={"fs": "ShipInfo", "P_IMO": "9811000"}) + + assert body == "ok" + assert "equasis-cli/" in responses.calls[0].request.headers["User-Agent"] + + +@responses.activate +def test_requests_are_paced() -> None: + clock = FakeClock() + responses.get(BASE_URL + "page", body="a") + transport = make_transport(clock, min_interval=1.5) + + transport.get("page") + clock.now += 0.5 + transport.get("page") + + assert clock.sleeps == [pytest.approx(1.0)] + + +@responses.activate +@pytest.mark.parametrize("status", [500, 502, 503, 504, 522]) +def test_transient_statuses_are_retried(status: int) -> None: + clock = FakeClock() + responses.get(BASE_URL + "page", status=status) + responses.get(BASE_URL + "page", body="recovered") + transport = make_transport(clock, min_interval=0) + + assert transport.get("page") == "recovered" + assert clock.sleeps == [1.0] + + +@responses.activate +@pytest.mark.parametrize("status", [400, 401, 403, 404]) +def test_client_errors_are_not_retried(status: int) -> None: + clock = FakeClock() + responses.get(BASE_URL + "page", status=status) + transport = make_transport(clock, min_interval=0) + + with pytest.raises(NetworkError) as raised: + transport.get("page") + + assert raised.value.status_code == status + assert len(responses.calls) == 1 + assert clock.sleeps == [] + + +@responses.activate +def test_retry_after_header_is_honoured_and_429_raises_rate_limited() -> None: + clock = FakeClock() + for _ in range(3): + responses.get(BASE_URL + "page", status=429, headers={"Retry-After": "7"}) + transport = make_transport(clock, min_interval=0) + + with pytest.raises(RateLimitedError): + transport.get("page") + + assert clock.sleeps == [7.0, 7.0] + assert len(responses.calls) == 3 + + +@responses.activate +def test_connection_errors_are_retried_then_reported() -> None: + clock = FakeClock() + responses.get(BASE_URL + "page", body=requests.ConnectionError("boom")) + transport = make_transport(clock, min_interval=0) + + with pytest.raises(NetworkError, match="could not reach Equasis"): + transport.get("page") + + assert len(responses.calls) == 3 + assert clock.sleeps == [1.0, 2.0] + + +@responses.activate +def test_save_html_writes_each_response(tmp_path: Path) -> None: + clock = FakeClock() + responses.post(BASE_URL + "restricted/Search?fs=Search", body="results") + transport = make_transport(clock, min_interval=0, save_html_dir=tmp_path / "pages") + + transport.post("restricted/Search?fs=Search", data={"P_ENTREE": "TORM"}) + + saved = list((tmp_path / "pages").iterdir()) + assert [p.name for p in saved] == ["001-post-restricted-Search.html"] + assert saved[0].read_text(encoding="utf-8") == "results" + + +def test_retry_delay_grows_and_is_capped() -> None: + policy = RetryPolicy(base_delay=2.0, backoff=2.0, max_delay=10.0, jitter=0.0) + assert [policy.delay(n) for n in range(4)] == [2.0, 4.0, 8.0, 10.0] + assert policy.delay(0, retry_after=300) == 10.0 diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py new file mode 100644 index 0000000..03d3045 --- /dev/null +++ b/tests/test_tui_app.py @@ -0,0 +1,154 @@ +"""Tests for the prompt_toolkit shell: menus, input handling, and command dispatch.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from pathlib import Path + +import pytest +from prompt_toolkit.application import create_app_session +from prompt_toolkit.document import Document +from prompt_toolkit.input import create_pipe_input +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 +from tests.fakes import FakeClient + + +@pytest.fixture +def pipe() -> Iterator[PipeInput]: + with ( + create_pipe_input() as pipe_input, + create_app_session(input=pipe_input, output=DummyOutput()), + ): + yield pipe_input + + +@pytest.fixture +def shell(pipe: PipeInput, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> InteractiveShell: + monkeypatch.delenv("EQUASIS_USERNAME", raising=False) + monkeypatch.delenv("EQUASIS_PASSWORD", raising=False) + store = CredentialStore(tmp_path) + store.save("user@example.com", "secret") + fake = FakeClient() + runner = CommandRunner( + print, + 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()) + + +def type_text(shell: InteractiveShell, text: str) -> None: + shell.input_buffer.document = Document(text, len(text)) + + +def test_banner_is_shown(shell: InteractiveShell) -> None: + assert "Press ? for quick help." in shell.output_buffer.text + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "/", + [ + "vessel", + "search", + "fleet", + "batch", + "format", + "cache", + "status", + "clear", + "help", + "exit", + ], + ), + ("/ba", ["batch"]), + ("batch /co", ["/companies", "/company-file"]), + ("vessel /imo 1 /", ["/refresh", "/format", "/output"]), + ("vessel ", []), + ("ves", []), + ], +) +def test_menu_items(shell: InteractiveShell, text: str, expected: list[str]) -> None: + type_text(shell, text) + assert [name for name, _ in shell.menu_items()] == expected + + +def test_accepting_menu_items(shell: InteractiveShell) -> None: + type_text(shell, "/fl") + assert shell.accept_menu_item() + assert shell.input_buffer.text == "fleet " + type_text(shell, "fleet /c") + shell.accept_menu_item() + assert shell.input_buffer.text == "fleet /company " + + +def test_tab_completes_command_names(shell: InteractiveShell) -> None: + type_text(shell, "ba") + shell.complete_command() + assert shell.input_buffer.text == "batch " + type_text(shell, "s") + shell.complete_command() + assert shell.input_buffer.text == "/s" + + +def test_clear_and_echo(shell: InteractiveShell) -> None: + type_text(shell, "clear") + shell.submit() + assert shell.output_buffer.text == "" + shell.write("Error: example") + assert shell.output_buffer.text == "Error: example\n" + + +def test_output_is_capped(shell: InteractiveShell) -> None: + shell.clear() + shell.write("\n".join(str(n) for n in range(MAX_OUTPUT_LINES + 10))) + assert len(shell.output_buffer.text.split("\n")) <= MAX_OUTPUT_LINES + + +def test_lexer_highlights_messages() -> None: + lexer = OutputLexer(banner_lines=0, art_lines=0) + document = Document( + f"{PROMPT}vessel\nError: bad\nWarning: careful\nSaved json output to x\nplain" + ) + styles = lexer.lex_document(document) + assert [styles(n)[0][0] for n in range(5)] == [ + "class:echo", + "class:error", + "class:warning", + "class:success", + "", + ] + + +def test_commands_run_through_the_application(shell: InteractiveShell, pipe: PipeInput) -> None: + pipe.send_text("vessel /imo 9074729\r") + + async def exit_when_done() -> None: + for _ in range(200): + await asyncio.sleep(0.02) + if "KAVITA (IMO 9074729)" in shell.output_buffer.text and not shell.busy: + break + shell.app.exit() + + shell.app.pre_run_callables.append(lambda: shell.app.create_background_task(exit_when_done())) + shell.start() + + assert f"{PROMPT}vessel /imo 9074729" in shell.output_buffer.text + assert "KAVITA (IMO 9074729)" in shell.output_buffer.text + + +def test_exit_command_stops_the_application(shell: InteractiveShell, pipe: PipeInput) -> None: + pipe.send_text("exit\r") + shell.start() + assert f"{PROMPT}exit" in shell.output_buffer.text diff --git a/tests/test_tui_commands.py b/tests/test_tui_commands.py new file mode 100644 index 0000000..1c8aae3 --- /dev/null +++ b/tests/test_tui_commands.py @@ -0,0 +1,270 @@ +"""Tests for interactive shell command parsing and execution.""" + +from __future__ import annotations + +import csv +import io +import json +from pathlib import Path + +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 +from tests.fakes import FakeClient, fixture_fleet + +TORM = CompanySummary(company_id="0310062", name="TORM A/S", address="Hellerup") +TORM_NORWAY = CompanySummary(company_id="5313763", name="TORM NORWAY AS") + + +# ------------------------------------------------------------------------------ parsing + + +def test_parse_quoted_values_and_flags() -> None: + command = parse_command('batch /companies "TORM A/S,0152944" /first /output out.csv') + assert command is not None + assert command.name == "batch" + assert command.text("companies") == "TORM A/S,0152944" + assert command.flag("first") + assert command.text("output") == "out.csv" + + +def test_parse_typographic_quotes_and_hyphenated_parameters() -> None: + command = parse_command("batch /company-file “my companies.txt”") + assert command is not None + assert command.text("company-file") == "my companies.txt" + + +def test_parameter_values_may_start_with_a_slash() -> None: + command = parse_command("vessel /imo 9811000 /output /data/vessel.json") + assert command is not None + assert command.text("output") == "/data/vessel.json" + + +def test_backslashes_in_windows_paths_are_kept() -> None: + command = parse_command(r'batch /file C:\Users\me\imos.txt /output "C:\My Data\out #1.csv"') + assert command is not None + assert command.text("file") == r"C:\Users\me\imos.txt" + assert command.text("output") == r"C:\My Data\out #1.csv" + + +@pytest.mark.parametrize( + ("line", "message"), + [ + ("launch /imo 1", "unknown command"), + ("vessel /name X", "has no parameter /name"), + ("vessel /imo", "needs a value"), + ('search /name "unterminated', "could not read"), + ], +) +def test_parse_errors(line: str, message: str) -> None: + with pytest.raises(CommandError, match=message): + parse_command(line) + + +def test_aliases_and_blank_lines() -> None: + assert parse_command(" ") is None + command = parse_command("quit") + assert command is not None + assert command.name == "exit" + + +def test_help_text() -> None: + assert "batch" in help_text() + assert "/company-file" in help_text("batch") + assert "No help for" in help_text("nothing") + + +# ---------------------------------------------------------------------------- execution + + +class Recorder: + def __init__(self) -> None: + self.lines: list[str] = [] + self.statuses: list[str] = [] + + @property + def text(self) -> str: + return "\n".join(self.lines) + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CredentialStore: + monkeypatch.delenv("EQUASIS_USERNAME", raising=False) + monkeypatch.delenv("EQUASIS_PASSWORD", raising=False) + credential_store = CredentialStore(tmp_path / "config") + credential_store.save("user@example.com", "secret") + return credential_store + + +@pytest.fixture +def fake() -> FakeClient: + return FakeClient(companies=[TORM, TORM_NORWAY], fleets={"0310062": fixture_fleet()}) + + +@pytest.fixture +def recorder() -> Recorder: + return Recorder() + + +@pytest.fixture +def runner(store: CredentialStore, fake: FakeClient, recorder: Recorder) -> CommandRunner: + return CommandRunner( + recorder.lines.append, + recorder.statuses.append, + store=store, + client_factory=lambda username, password: fake, # type: ignore[arg-type,return-value] + cache=PageCache(store.config_dir.parent / "cache"), + ) + + +def test_exit_returns_false(runner: CommandRunner) -> None: + assert runner.run("exit") is False + assert runner.run("help") is True + + +def test_vessel_table(runner: CommandRunner, recorder: Recorder) -> None: + runner.run("vessel /imo 9074729") + assert "KAVITA (IMO 9074729)" in recorder.text + assert "Retrieving IMO 9074729..." in recorder.statuses + assert runner.connected + + +@pytest.mark.parametrize(("extension", "reader"), [("json", "json"), ("csv", "csv")]) +def test_vessel_output_file_uses_the_extension_format( + runner: CommandRunner, recorder: Recorder, tmp_path: Path, extension: str, reader: str +) -> None: + """Regression test for issue #17: files were written as table text.""" + target = tmp_path / f"vessel_data.{extension}" + runner.run(f"vessel /imo 9074729 /output {target}") + + content = target.read_text(encoding="utf-8") + if reader == "json": + assert json.loads(content)["vessel"]["name"] == "KAVITA" + else: + assert next(csv.DictReader(io.StringIO(content)))["name"] == "KAVITA" + assert f"Saved {extension} output to {target}" in recorder.text + + +def test_output_with_a_format_name_selects_the_format( + runner: CommandRunner, recorder: Recorder +) -> None: + runner.run("vessel /imo 9074729 /output json") + assert json.loads(recorder.lines[-1])["vessel"]["imo"] == "9074729" + + +def test_default_format_command(runner: CommandRunner, recorder: Recorder) -> None: + runner.run("format csv") + runner.run("vessel 9074729") + assert recorder.lines[-1].startswith("imo,name,flag") + runner.run("format xml") + assert "unknown format 'xml'" in recorder.lines[-1] + + +def test_batch_imos(runner: CommandRunner, recorder: Recorder) -> None: + """Regression test for issue #16: batch was not implemented.""" + runner.run('batch /imos "9074729, 1234567"') + + assert "Processing 2 vessels..." in recorder.text + assert "9074729: ok" in recorder.text + assert "1234567: not found" in recorder.text + assert "1 of 2 vessels retrieved." in recorder.text + assert "[2/2] 1234567" in recorder.statuses + + +def test_batch_file_to_json(runner: CommandRunner, recorder: Recorder, tmp_path: Path) -> None: + imo_file = tmp_path / "fleet.txt" + imo_file.write_text("# list\n9074729 # KAVITA\n", encoding="utf-8") + target = tmp_path / "results.json" + + runner.run(f"batch /file {imo_file} /output {target}") + + document = json.loads(target.read_text(encoding="utf-8")) + assert document["summary"]["succeeded"] == 1 + + +def test_batch_companies(runner: CommandRunner, recorder: Recorder) -> None: + runner.run('batch /companies "TORM A/S,TORM" /format csv') + assert "TORM A/S: ok" in recorder.text + assert "TORM: ambiguous" in recorder.text + + +def test_batch_argument_errors(runner: CommandRunner, recorder: Recorder, tmp_path: Path) -> None: + runner.run("batch") + assert "use exactly one of" in recorder.lines[-1] + runner.run(f"batch /file {tmp_path / 'missing.txt'}") + assert "cannot read" in recorder.lines[-1] + + +def test_fleet_by_name(runner: CommandRunner, recorder: Recorder) -> None: + """Regression test for issue #18: fleet was not implemented.""" + runner.run('fleet /company "TORM A/S"') + assert "TORM A/S (company no. 0310062)" in recorder.text + + +def test_ambiguous_fleet_lists_candidates_then_accepts_id( + runner: CommandRunner, recorder: Recorder +) -> None: + runner.run("fleet /company TORM") + assert "'TORM' matches 2 companies" in recorder.text + assert "fleet /id NUMBER" in recorder.text + + runner.run("fleet /id 0310062 /format json") + assert json.loads(recorder.lines[-1])["fleet"]["company"]["name"] == "TORM A/S" + + +def test_ambiguous_fleet_with_first(runner: CommandRunner, recorder: Recorder) -> None: + runner.run("fleet /company TORM /first") + assert "Using TORM A/S (0310062)" in recorder.text + + +def test_search_commands(runner: CommandRunner, recorder: Recorder) -> None: + runner.run('search /name "kavita"') + assert "Ships matching 'kavita'" in recorder.text + runner.run("search /imo 9074729 /name kavita") + assert "not both" in recorder.lines[-1] + runner.run("search /name x /type boats") + assert "/type must be" in recorder.lines[-1] + + +def test_missing_credentials( + runner: CommandRunner, recorder: Recorder, store: CredentialStore +) -> None: + store.clear() + runner.run("vessel /imo 9074729") + assert "no Equasis credentials found" in recorder.lines[-1] + + +def test_rejected_login_resets_the_client( + runner: CommandRunner, recorder: Recorder, fake: FakeClient +) -> None: + fake.reject_login = True + runner.run("vessel /imo 9074729") + assert recorder.lines[-1].startswith("Login failed") + assert runner.state.client is None + + +def test_invalid_imo_and_status(runner: CommandRunner, recorder: Recorder) -> None: + runner.run("vessel /imo EVERGIVEN") + 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] diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..21f5571 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,51 @@ +"""Tests for identifier validation and list parsing.""" + +from __future__ import annotations + +import pytest + +from equasis_cli.exceptions import InvalidInputError +from equasis_cli.validation import ( + imo_check_digit_valid, + normalize_company_id, + normalize_imo, + normalize_mmsi, + parse_list, + split_values, +) + + +@pytest.mark.parametrize("value", ["9811000", " 9811000 ", "IMO 9811000", "imo:9811000"]) +def test_normalize_imo_accepts_common_forms(value: str) -> None: + assert normalize_imo(value) == "9811000" + + +@pytest.mark.parametrize("value", ["981100", "98110000", "EVER GIVEN", ""]) +def test_normalize_imo_rejects_malformed_values(value: str) -> None: + with pytest.raises(InvalidInputError): + normalize_imo(value) + + +@pytest.mark.parametrize( + ("imo", "valid"), [("9811000", True), ("9074729", True), ("9074728", False), ("abc", False)] +) +def test_imo_check_digit(imo: str, valid: bool) -> None: + assert imo_check_digit_valid(imo) is valid + + +def test_company_id_and_mmsi() -> None: + assert normalize_company_id("0310062") == "0310062" + assert normalize_mmsi("353136000") == "353136000" + with pytest.raises(InvalidInputError): + normalize_company_id("TORM") + with pytest.raises(InvalidInputError): + normalize_mmsi("3531360") + + +def test_parse_list_handles_comments_blanks_and_duplicates() -> None: + lines = ["# fleet list", "9811000 # EVER GIVEN", "", " 9074729", "9811000"] + assert parse_list(lines) == ["9811000", "9074729"] + + +def test_split_values_flattens_comma_separated_input() -> None: + assert split_values(["9811000,9074729", " 9811000 "]) == ["9811000", "9074729"] diff --git a/tests/tools/make_fixture.py b/tests/tools/make_fixture.py new file mode 100644 index 0000000..f619f6b --- /dev/null +++ b/tests/tools/make_fixture.py @@ -0,0 +1,79 @@ +"""Turn raw Equasis HTML into a small, anonymized test fixture. + +Raw pages can be captured with ``equasis --save-html DIR ...``. Captured pages +contain the logged-in account's display name, so they must never be committed +as-is. This tool removes everything the parsers do not need and replaces the +account name with a placeholder. + +Usage:: + + python tests/tools/make_fixture.py RAW.html tests/fixtures/NAME.html [--max-rows N] +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +from bs4 import BeautifulSoup, Comment, NavigableString, Tag + +PLACEHOLDER_NAME = "Test User" +REMOVED_TAGS = ("script", "style", "link", "meta", "noscript", "svg", "iframe", "img", "option") +WHITESPACE = re.compile(r"\s+") + + +def scrub(html: str, *, max_rows: int | None = None) -> str: + """Return a minimized copy of ``html`` with account details removed.""" + soup = BeautifulSoup(html, "html.parser") + + for tag in soup.find_all(REMOVED_TAGS): + # Flag images carry the country code the parser needs; keep those. + if tag.name == "img" and "/flags/" in str(tag.get("src", "")): + del tag["class"] + continue + tag.decompose() + for comment in soup.find_all(string=lambda s: isinstance(s, Comment)): + comment.extract() + + # The account display name is rendered in

blocks. + for heading in soup.find_all("h3", class_="bleu-equasis"): + heading.string = PLACEHOLDER_NAME + + for tag in soup.find_all(True): + for attribute in ("style", "title", "data-toggle", "data-placement", "aria-hidden"): + if isinstance(tag, Tag) and attribute in tag.attrs: + del tag.attrs[attribute] + + if max_rows is not None: + for tbody in soup.find_all("tbody"): + desktop_rows = [ + row + for row in tbody.find_all("tr", recursive=False) + if "hidden-md" not in (row.get("class") or []) + ] + for row in desktop_rows[max_rows:]: + row.decompose() + + for text in soup.find_all(string=True): + if isinstance(text, NavigableString) and not isinstance(text, Comment): + # Keep line breaks: parsers must cope with text that spans lines. + collapsed = WHITESPACE.sub(lambda m: "\n" if "\n" in m.group() else " ", str(text)) + if collapsed != text: + text.replace_with(collapsed) + + return str(soup) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("source", type=Path) + parser.add_argument("destination", type=Path) + parser.add_argument("--max-rows", type=int, help="keep at most N rows per table body") + args = parser.parse_args() + html = args.source.read_text(encoding="utf-8") + args.destination.write_text(scrub(html, max_rows=args.max_rows), encoding="utf-8") + + +if __name__ == "__main__": + main()