diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..b100eb6 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,49 @@ +name: Antares Python CI + +on: + push: + paths: + - 'antares-python/**' + pull_request: + paths: + - 'antares-python/**' + +jobs: + test: + name: Run Tests, Lint, Type-check + runs-on: ubuntu-latest + + defaults: + run: + working-directory: antares-python + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + run: | + curl -Ls https://astral.sh/uv/install.sh | bash + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: | + uv pip install --system -e . + uv pip install --system pytest pytest-cov pytest-mock mypy ruff build + + + - name: Run linters + run: python -m ruff check . + + - name: Run formatters + run: python -m ruff format --check . + + - name: Run mypy + run: mypy src/ + + - name: Run tests with coverage + run: pytest --cov=src --cov-report=term-missing --cov-fail-under=80 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..68c6d1c --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,42 @@ +name: Publish antares-python to PyPI + +on: + push: + tags: + - "v*.*.*" + +jobs: + publish: + name: Build and Publish + runs-on: ubuntu-latest + + defaults: + run: + working-directory: antares-python + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + run: | + curl -Ls https://astral.sh/uv/install.sh | bash + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Build package + run: | + uv venv + source .venv/bin/activate + uv pip install -U build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: antares-python/dist/ diff --git a/antares-python/.gitignore b/antares-python/.gitignore new file mode 100644 index 0000000..d229fb3 --- /dev/null +++ b/antares-python/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +.venv/ +dist/ +build/ +*.egg-info/ +.coverage +.coverage.* +htmlcov/ +antares.log diff --git a/antares-python/.python-version b/antares-python/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/antares-python/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/antares-python/LICENSE b/antares-python/LICENSE new file mode 100644 index 0000000..23cda02 --- /dev/null +++ b/antares-python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 The Software Design Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/antares-python/README.md b/antares-python/README.md new file mode 100644 index 0000000..e8b1b23 --- /dev/null +++ b/antares-python/README.md @@ -0,0 +1,270 @@ +# Antares Python Client + +[![CI](https://github.com/TheSoftwareDesignLab/ANTARES/actions/workflows/python-ci.yml/badge.svg)](https://github.com/TheSoftwareDesignLab/ANTARES/actions/workflows/python-ci.yml) +[![codecov](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/TheSoftwareDesignLab/ANTARES) +[![PyPI version](https://img.shields.io/pypi/v/antares-python.svg)](https://pypi.org/project/antares-python/) +[![License](https://img.shields.io/github/license/TheSoftwareDesignLab/ANTARES)](LICENSE) + +> ✨ A modern Python interface for the Antares simulation engine ✨ + +Antares Python Client is a developer-friendly library and CLI tool that allows you to interact with the Antares simulation engine via HTTP and TCP protocols. + +- Provides a high-level Python API to control the simulation +- Automatically maps Python objects to Antares-native requests +- Supports configuration via `.env` and `.toml` files +- Offers a CLI for scripting and manual control +- Built with Pydantic 2, Typer, and fully type-annotated + +Inspired by tools like PySpark, this library acts as a thin but powerful faΓ§ade over the Antares backend. + +--- + +## 🌟 Features + +- Add ships with complex motion patterns to the simulation +- Subscribe to live simulation events over TCP +- Launch the Antares binary locally with config +- Configure everything via `.env` or `.toml` +- Clean CLI with rich output and JSON support + +--- + +## πŸš€ Installation + +### Requirements + +- Python >= 3.13 (tested with 3.13) +- `uv` for isolated dev environments + +### Install from PyPI + +```bash +pip install antares-python +``` + +### Install in editable mode (for development) + +```bash +git clone https://github.com/TheSoftwareDesignLab/ANTARES.git +cd ANTARES/antares-python +uv venv +source .venv/bin/activate +uv pip install -e . +``` + +--- + +## 🚧 CLI Usage (`antares-cli`) + +After installing, the CLI tool `antares-cli` becomes available. + +### Available Commands + +| Command | Description | +|---------------|--------------------------------------------------| +| `add-ship` | Add a ship with specific motion type | +| `reset` | Reset the simulation | +| `subscribe` | Subscribe to simulation event stream | +| `start` | Start the Antares binary with optional config | + +### Common Options + +| Option | Description | +|---------------|-------------------------------------------------| +| `--config` | Path to `.toml` config file | +| `--verbose` | Enable detailed output | +| `--json` | Output results in JSON format | + +Example: + +```bash +antares-cli add-ship --type line --x 0 --y 0 --angle 0.5 --speed 5.0 +``` + +--- + +## πŸ“š Python Usage Example + +```python +import asyncio +from antares.client import AntaresClient +from antares.models.ship import LineShip, CircleShip, RandomShip, StationaryShip +from antares.models.track import Track + + +async def main(): + # Create the Antares client using environment config or .env file + client = AntaresClient() + + # Define ships of each supported type + ships = [ + StationaryShip(initial_position=(0.0, 0.0), type="stationary"), + RandomShip(initial_position=(10.0, -10.0), max_speed=15.0, type="random"), + CircleShip(initial_position=(-30.0, 20.0), radius=25.0, speed=3.0, type="circle"), + LineShip(initial_position=(5.0, 5.0), angle=0.78, speed=4.0, type="line"), + ] + + # Add each ship to the simulation + for ship in ships: + client.add_ship(ship) + print(f"βœ… Added {ship.type} ship at {ship.initial_position}") + + print("\nπŸ“‘ Subscribing to simulation events...\n") + + # Listen to simulation events (TCP stream) + async for event in client.subscribe(): + if isinstance(event, Track): + print( + f"πŸ“ Track #{event.id} - {event.name} at ({event.lat}, {event.long}) β†’ {event.speed} knots" + ) + + +if __name__ == "__main__": + # Run the main async function + asyncio.run(main()) +``` + +--- + +## 🧭 Ship Types + +Ships are classified based on their motion pattern. The `type` field determines which parameters are required. Here's a summary: + +| Type | Required Fields | Description | +|-------------|---------------------------------------------|---------------------------------------------| +| `stationary`| `initial_position` | Does not move at all | +| `random` | `initial_position`, `max_speed` | Moves randomly, up to a max speed | +| `circle` | `initial_position`, `radius`, `speed` | Moves in a circular pattern | +| `line` | `initial_position`, `angle`, `speed` | Moves in a straight line | + +Each ship type corresponds to a specific Pydantic model: + +- `StationaryShip` +- `RandomShip` +- `CircleShip` +- `LineShip` + +You can also use the generic `ShipConfig` union to parse from dynamic input like TOML or JSON. + +--- + +## βš™οΈ Configuration + +The client supports two configuration methods: + +### `.env` File + +The `.env` file allows you to define environment variables: + +```dotenv +ANTARES_HOST=localhost +ANTARES_HTTP_PORT=9000 +ANTARES_TCP_PORT=9001 +ANTARES_TIMEOUT=5.0 +ANTARES_AUTH_TOKEN= +``` + +➑️ See `template.env` for a complete example. + +### `.toml` Config File + +To configure the client and ships via a TOML file: + +```toml +[antares] +host = "localhost" +http_port = 9000 +tcp_port = 9001 +timeout = 5.0 +auth_token = "" + +[[antares.ships.stationary]] +initial_position = [50.0, 50.0] + +[[antares.ships.random]] +initial_position = [-20.0, 20.0] +max_speed = 10.0 + +[[antares.ships.circle]] +initial_position = [30.0, -30.0] +radius = 20.0 +speed = 4.0 + +[[antares.ships.line]] +initial_position = [0.0, 0.0] +angle = 0.785 +speed = 5.0 +``` + +➑️ See `config.example.toml` for a full working example. + +You can pass the config to any CLI command with: + +```bash +antares-cli add-ship --config path/to/config.toml +``` + +Or use it in Python with: + +```python +from antares.config_loader import load_config +settings = load_config("config.toml") +``` + +--- + +## πŸ§ͺ Development & Testing + +This project uses modern Python tooling for fast, isolated, and productive workflows. + +### Setup + +```bash +uv venv +source .venv/bin/activate +uv pip install -e .[dev] +``` + +### Available Tasks (via [`task`](https://taskfile.dev)) + +| Task | Description | +|----------------|---------------------------------------------| +| `task check` | Run linters (ruff, mypy) and formatter check | +| `task test` | Run full test suite | +| `task format` | Auto-format code with ruff + black | +| `task build` | Build the wheel and source dist | +| `task publish` | Publish to PyPI (requires version bump) | + +### Run tests manually + +```bash +pytest +``` + +### View test coverage + +```bash +pytest --cov=antares --cov-report=term-missing +``` + +--- + +## πŸ“„ License + +This project is licensed under the MIT License. See the [LICENSE](../LICENSE) file for details. + +--- + +## 🀝 Contributing + +Contributions are welcome! To contribute: + +1. Fork the repository +2. Create a new branch (`git checkout -b feature/my-feature`) +3. Make your changes +4. Run `task check` and `task test` to ensure quality +5. Submit a pull request πŸš€ + +For significant changes, please open an issue first to discuss what you’d like to do. + +Happy hacking! πŸ› οΈ diff --git a/antares-python/config.example.toml b/antares-python/config.example.toml new file mode 100644 index 0000000..980dda8 --- /dev/null +++ b/antares-python/config.example.toml @@ -0,0 +1,32 @@ +# ============================ +# Antares Simulation Config +# Example TOML configuration +# ============================ + +[antares] +host = "localhost" +http_port = 9000 +tcp_port = 9001 +timeout = 5.0 +auth_token = "" + +# ============================ +# Ships to add at startup +# ============================ + +[[antares.ships.line]] +initial_position = [0.0, 0.0] +angle = 0.785 # radians (approx. 45 degrees) +speed = 5.0 + +[[antares.ships.circle]] +initial_position = [30.0, -30.0] +radius = 20.0 +speed = 4.0 + +[[antares.ships.random]] +initial_position = [-20.0, 20.0] +max_speed = 10.0 + +[[antares.ships.stationary]] +initial_position = [50.0, 50.0] diff --git a/antares-python/main.py b/antares-python/main.py new file mode 100644 index 0000000..9c8e9dc --- /dev/null +++ b/antares-python/main.py @@ -0,0 +1,46 @@ +import asyncio + +from antares import AntaresClient, CircleShip, LineShip, RandomShip, StationaryShip + + +async def main() -> None: + """ + Example of how to use the Antares Python client to add ships and subscribe to events. + This example demonstrates how to create different types of ships and add them to the Antares + simulation. It also shows how to subscribe to simulation events and print the track information. + """ + + # Initialize the Antares client + client = AntaresClient( + host="localhost", + http_port=9000, + tcp_port=9001, + timeout=5.0, + auth_token="my_secret_auth_token", + ) + + # Add ships + ships = [ + StationaryShip(initial_position=(0.0, 0.0), type="stationary"), + RandomShip(initial_position=(10.0, -10.0), max_speed=15.0, type="random"), + CircleShip(initial_position=(-30.0, 20.0), radius=25.0, speed=3.0, type="circle"), + LineShip(initial_position=(5.0, 5.0), angle=0.78, speed=4.0, type="line"), + ] + + for ship in ships: + client.add_ship(ship) + print(f"βœ… Added {ship.type} ship at {ship.initial_position}") + + print("πŸ“‘ Subscribing to simulation events...\n") + + try: + async for track in client.subscribe(): + print( + f"πŸ“ Track #{track.id} - {track.name} @ ({track.lat}, {track.long}) β†’ {track.speed} knots" # noqa: E501 + ) + except KeyboardInterrupt: + print("\nπŸ›‘ Subscription interrupted by user.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/antares-python/pyproject.toml b/antares-python/pyproject.toml new file mode 100644 index 0000000..1b43bea --- /dev/null +++ b/antares-python/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "antares-python" +version = "0.1.2" +description = "Python interface for the Antares simulation software" +authors = [ + { name = "Juan Sebastian Urrea-Lopez", email = "js.urrea@uniandes.edu.co" }, +] +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pydantic>=2.11.3", + "pydantic-settings>=2.8.1", + "httpx>=0.28.1", + "typer>=0.15.2", + "rich>=13.0", + "tomli>=2.2.1", +] + +[project.optional-dependencies] +dev = ["pytest-asyncio>=0.26.0"] + +[project.scripts] +antares-cli = "antares.cli:app" + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 100 +lint.select = ["E", "F", "I", "UP", "B", "PL"] +exclude = ["dist", "build"] + +[tool.mypy] +strict = true +python_version = "3.13" +files = ["src"] + +[tool.pytest.ini_options] +pythonpath = "src" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.taskipy.tasks] +lint = "ruff check . --fix" +format = "ruff format ." +typecheck = "mypy src/" +test = "pytest" +coverage = "pytest -ra -q --cov=src --cov-report=term-missing" +build = "python -m build" +publish = "twine upload dist/* --repository antares-python" +check = "task lint && task format && task typecheck && task coverage" +release = "task check && task build && task publish" diff --git a/antares-python/src/antares/__init__.py b/antares-python/src/antares/__init__.py new file mode 100644 index 0000000..99ffa68 --- /dev/null +++ b/antares-python/src/antares/__init__.py @@ -0,0 +1,4 @@ +from .client import AntaresClient +from .models.ship import CircleShip, LineShip, RandomShip, StationaryShip + +__all__ = ["AntaresClient", "LineShip", "RandomShip", "StationaryShip", "CircleShip"] diff --git a/antares-python/src/antares/cli.py b/antares-python/src/antares/cli.py new file mode 100644 index 0000000..febd8f2 --- /dev/null +++ b/antares-python/src/antares/cli.py @@ -0,0 +1,193 @@ +import asyncio +import json +import logging +import shutil +import subprocess +from pathlib import Path +from typing import NoReturn + +import typer +from pydantic import ValidationError +from rich.console import Console +from rich.theme import Theme + +from antares import AntaresClient +from antares.config_loader import load_config +from antares.errors import ConnectionError, SimulationError, SubscriptionError +from antares.logger import setup_logging +from antares.models.ship import CircleShip, LineShip, RandomShip, ShipConfig, StationaryShip + +app = typer.Typer(name="antares-cli", help="Antares CLI for ship simulation", no_args_is_help=True) +console = Console(theme=Theme({"info": "green", "warn": "yellow", "error": "bold red"})) + + +@app.command() +def start( + executable: str = typer.Option("antares", help="Path to the Antares executable"), + config: str | None = typer.Option(None, help="Path to the TOML configuration file"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), + json_output: bool = typer.Option(False, "--json", help="Output in JSON format"), +) -> None: + """ + Start the Antares simulation engine in the background. + + This command attempts to locate and launch the Antares executable either from the system's PATH + or from the provided path using the --executable option. If a config path is provided, it is + passed to the executable via --config. + + This command does not use the Python client and directly invokes the native binary. + """ + # Locate executable (either absolute path or in system PATH) + path = shutil.which(executable) if not Path(executable).exists() else executable + if path is None: + msg = f"Executable '{executable}' not found in PATH or at specified location." + console.print(f"[error]{msg}") + raise typer.Exit(1) + + # Prepare command + command = [path] + if config: + command += ["--config", config] + + if verbose: + console.print(f"[info]Starting Antares with command: {command}") + + try: + process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + msg = f"Failed to start Antares: {e}" + if json_output: + typer.echo(json.dumps({"error": msg}), err=True) + else: + console.print(f"[error]{msg}") + raise typer.Exit(2) from e + + msg = f"Antares started in background with PID {process.pid}" + if json_output: + typer.echo(json.dumps({"message": msg, "pid": process.pid})) + else: + console.print(f"[success]{msg}") + + +@app.command() +def reset( + config: str = typer.Option(None, help="Path to the TOML configuration file"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), + json_output: bool = typer.Option(False, "--json", help="Output in JSON format"), +) -> None: + """ + Reset the current simulation state. + """ + client = build_client(config, verbose, json_output) + try: + client.reset_simulation() + msg = "βœ… Simulation reset." + typer.echo(json.dumps({"message": msg}) if json_output else msg) + except (ConnectionError, SimulationError) as e: + handle_error(str(e), code=2, json_output=json_output) + + +@app.command() +def add_ship( # noqa: PLR0913 + type: str = typer.Option(..., help="Type of ship: 'line', 'circle', 'random', or 'stationary'"), + x: float = typer.Option(..., help="Initial X coordinate of the ship"), + y: float = typer.Option(..., help="Initial Y coordinate of the ship"), + angle: float = typer.Option(None, help="(line) Movement angle in radians"), + speed: float = typer.Option(None, help="(line/circle) Constant speed"), + radius: float = typer.Option(None, help="(circle) Radius of the circular path"), + max_speed: float = typer.Option(None, help="(random) Maximum possible speed"), + config: str = typer.Option(None, help="Path to the TOML configuration file"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), + json_output: bool = typer.Option(False, "--json", help="Output in JSON format"), +) -> None: + """ + Add a ship to the simulation, specifying its motion pattern and parameters. + """ + client = build_client(config, verbose, json_output) + + base_args = {"initial_position": (x, y)} + + ship: ShipConfig | None = None + try: + if type == "line": + ship = LineShip(**base_args, angle=angle, speed=speed) # type: ignore[arg-type] + elif type == "circle": + ship = CircleShip(**base_args, radius=radius, speed=speed) # type: ignore[arg-type] + elif type == "random": + ship = RandomShip(**base_args, max_speed=max_speed) # type: ignore[arg-type] + elif type == "stationary": + ship = StationaryShip(**base_args) # type: ignore[arg-type] + else: + raise ValueError(f"Invalid ship type: {type!r}") + + except (ValidationError, ValueError, TypeError) as e: + handle_error(f"Invalid ship parameters: {e}", code=2, json_output=json_output) + + try: + client.add_ship(ship) + msg = f"🚒 Added {type} ship at ({x}, {y})" + typer.echo(json.dumps({"message": msg}) if json_output else msg) + except (ConnectionError, SimulationError) as e: + handle_error(str(e), code=2, json_output=json_output) + + +@app.command() +def subscribe( + config: str = typer.Option(None, help="Path to the TOML configuration file"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), + json_output: bool = typer.Option(False, "--json", help="Output in JSON format"), + log_file: str = typer.Option("antares.log", help="Path to log file"), +) -> None: + """ + Subscribe to simulation events and print them to the console. + """ + setup_logging(log_file=log_file, level=logging.DEBUG if verbose else logging.INFO) + logger = logging.getLogger("antares.cli") + + client = build_client(config, verbose, json_output) + + async def _sub() -> None: + try: + async for event in client.subscribe(): + if json_output: + typer.echo(json.dumps(event)) + else: + console.print_json(data=event) + logger.debug("Received event: %s", event) + except SubscriptionError as e: + handle_error(str(e), code=3, json_output=json_output) + + asyncio.run(_sub()) + + +def handle_error(message: str, code: int, json_output: bool = False) -> NoReturn: + """ + Handle errors by logging and printing them to the console. + """ + logger = logging.getLogger("antares.cli") + if json_output: + typer.echo(json.dumps({"error": message}), err=True) + else: + console.print(f"[error]{message}") + logger.error("Exiting with error: %s", message) + raise typer.Exit(code) + + +def build_client(config_path: str | None, verbose: bool, json_output: bool) -> AntaresClient: + """ + Build the Antares client using the provided configuration file. + """ + + try: + settings = load_config(config_path) + if verbose: + console.print(f"[info]Using settings: {settings.model_dump()}") + return AntaresClient( + host=settings.host, + http_port=settings.http_port, + tcp_port=settings.tcp_port, + timeout=settings.timeout, + auth_token=settings.auth_token, + ) + except Exception as e: + handle_error(f"Failed to load configuration: {e}", code=1, json_output=json_output) diff --git a/antares-python/src/antares/client/__init__.py b/antares-python/src/antares/client/__init__.py new file mode 100644 index 0000000..ef86a33 --- /dev/null +++ b/antares-python/src/antares/client/__init__.py @@ -0,0 +1,59 @@ +from collections.abc import AsyncIterator +from typing import Any + +from antares.client.rest import RestClient +from antares.client.tcp import TCPSubscriber +from antares.config import AntaresSettings +from antares.models.ship import ShipConfig +from antares.models.track import Track + + +class AntaresClient: + def __init__( + self, + **kwargs: Any, + ) -> None: + """ + Public interface for interacting with the Antares simulation engine. + Accepts config overrides directly or falls back to environment-based configuration. + """ + + # Only include kwargs that match AntaresSettings fields + valid_fields = AntaresSettings.model_fields.keys() + filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields and v is not None} + + # Merge provided arguments with environment/.env via AntaresSettings + self._settings = AntaresSettings(**filtered_kwargs) + + base_url = f"http://{self._settings.host}:{self._settings.http_port}" + self._rest = RestClient( + base_url=base_url, + timeout=self._settings.timeout, + auth_token=self._settings.auth_token, + ) + self._tcp = TCPSubscriber( + host=self._settings.host, + port=self._settings.tcp_port, + ) + + def reset_simulation(self) -> None: + """ + Sends a request to reset the current simulation state. + """ + return self._rest.reset_simulation() + + def add_ship(self, ship: ShipConfig) -> None: + """ + Sends a new ship configuration to the simulation engine. + """ + return self._rest.add_ship(ship) + + async def subscribe(self) -> AsyncIterator[Track]: + """ + Subscribes to live simulation data over TCP. + + Yields: + Parsed simulation event data as Track objects. + """ + async for event in self._tcp.subscribe(): + yield event diff --git a/antares-python/src/antares/client/rest.py b/antares-python/src/antares/client/rest.py new file mode 100644 index 0000000..bd25a15 --- /dev/null +++ b/antares-python/src/antares/client/rest.py @@ -0,0 +1,59 @@ +import httpx + +from antares.errors import ConnectionError, ShipConfigError, SimulationError +from antares.models.ship import ShipConfig + + +class RestClient: + """ + Internal client for interacting with the Antares simulation REST API. + """ + + def __init__(self, base_url: str, timeout: float = 5.0, auth_token: str | None = None) -> None: + """ + Initializes the REST client. + + Args: + base_url: The root URL of the Antares HTTP API. + timeout: Timeout in seconds for each request. + auth_token: Optional bearer token for authentication. + """ + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {} + + def reset_simulation(self) -> None: + """ + Sends a request to reset the current simulation state. + """ + try: + response = httpx.post( + f"{self.base_url}/simulation/reset", + headers=self.headers, + timeout=self.timeout, + ) + response.raise_for_status() + except httpx.RequestError as e: + raise ConnectionError(f"Could not reach Antares API: {e}") from e + except httpx.HTTPStatusError as e: + raise SimulationError(f"Reset failed: {e.response.text}") from e + + def add_ship(self, ship: ShipConfig) -> None: + """ + Sends a ship configuration to the simulation engine. + + Args: + ship: A validated ShipConfig instance. + """ + try: + response = httpx.post( + f"{self.base_url}/simulation/ships", + json=ship.model_dump(), + headers=self.headers, + timeout=self.timeout, + ) + response.raise_for_status() + except httpx.RequestError as e: + raise ConnectionError(f"Could not reach Antares API: {e}") from e + except httpx.HTTPStatusError as e: + raise ShipConfigError(f"Add ship failed: {e.response.text}") from e diff --git a/antares-python/src/antares/client/tcp.py b/antares-python/src/antares/client/tcp.py new file mode 100644 index 0000000..b3a6494 --- /dev/null +++ b/antares-python/src/antares/client/tcp.py @@ -0,0 +1,61 @@ +import asyncio +import json +import logging +from collections.abc import AsyncIterator + +from antares.errors import SubscriptionError +from antares.models.track import Track + +logger = logging.getLogger(__name__) + + +class TCPSubscriber: + """ + Manages a TCP connection to the Antares simulation for real-time event streaming. + """ + + def __init__(self, host: str, port: int, reconnect: bool = True) -> None: + """ + Initializes the TCP subscriber. + + Args: + host: The hostname or IP of the TCP server. + port: The port number of the TCP server. + reconnect: Whether to automatically reconnect on disconnect. + """ + self.host = host + self.port = port + self.reconnect = reconnect + + async def subscribe(self) -> AsyncIterator[Track]: + """ + Connects to the TCP server and yields simulation events as Track objects. + This is an infinite async generator until disconnected or cancelled. + + Yields: + Parsed simulation events as Track objects. + """ + while True: + try: + reader, _ = await asyncio.open_connection(self.host, self.port) + while not reader.at_eof(): + line = await reader.readline() + if line: + track = Track.from_csv_row(line.decode()) + yield track + except ( + ConnectionRefusedError, + asyncio.IncompleteReadError, + json.JSONDecodeError, + ValueError, + ) as e: + logger.error("TCP stream error: %s", e) + if not self.reconnect: + raise SubscriptionError(f"Failed to read from TCP stream: {e}") from e + + # Stop if not reconnecting + if not self.reconnect: + break + + logger.info("Waiting 1 second before retrying TCP connection...") + await asyncio.sleep(1) diff --git a/antares-python/src/antares/config.py b/antares-python/src/antares/config.py new file mode 100644 index 0000000..feaef48 --- /dev/null +++ b/antares-python/src/antares/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AntaresSettings(BaseSettings): + """ + Application-level configuration for the Antares Python client. + Supports environment variables and `.env` file loading. + """ + + host: str = "localhost" + http_port: int = 9000 + tcp_port: int = 9001 + timeout: float = 5.0 + auth_token: str | None = None + + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="antares_", + case_sensitive=False, + ) diff --git a/antares-python/src/antares/config_loader.py b/antares-python/src/antares/config_loader.py new file mode 100644 index 0000000..5adf0f1 --- /dev/null +++ b/antares-python/src/antares/config_loader.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import tomli + +from antares.config import AntaresSettings + + +def load_config(path: str | Path | None = None) -> AntaresSettings: + """Loads AntaresSettings from a TOML config file or defaults to .env + env vars.""" + if path is None: + return AntaresSettings() + + config_path = Path(path).expanduser() + if not config_path.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with config_path.open("rb") as f: + data = tomli.load(f) + + return AntaresSettings(**data.get("antares", {})) diff --git a/antares-python/src/antares/errors.py b/antares-python/src/antares/errors.py new file mode 100644 index 0000000..9129cb0 --- /dev/null +++ b/antares-python/src/antares/errors.py @@ -0,0 +1,18 @@ +class AntaresError(Exception): + """Base exception for all errors raised by antares-python.""" + + +class ConnectionError(AntaresError): + """Raised when unable to connect to the Antares backend.""" + + +class SimulationError(AntaresError): + """Raised when simulation commands (reset/add_ship) fail.""" + + +class SubscriptionError(AntaresError): + """Raised when subscription to TCP stream fails.""" + + +class ShipConfigError(AntaresError): + """Raised when provided ship configuration is invalid or rejected.""" diff --git a/antares-python/src/antares/logger.py b/antares-python/src/antares/logger.py new file mode 100644 index 0000000..f74abff --- /dev/null +++ b/antares-python/src/antares/logger.py @@ -0,0 +1,19 @@ +import logging +from pathlib import Path + +from rich.logging import RichHandler + + +def setup_logging(log_file: str = "antares.log", level: int = logging.INFO) -> None: + """Configure logging to both rich console and a file.""" + Path(log_file).parent.mkdir(parents=True, exist_ok=True) + + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="[%Y-%m-%d %H:%M:%S]", + handlers=[ + RichHandler(rich_tracebacks=True, show_path=False), + logging.FileHandler(log_file, encoding="utf-8"), + ], + ) diff --git a/antares-python/src/antares/models/ship.py b/antares-python/src/antares/models/ship.py new file mode 100644 index 0000000..3960499 --- /dev/null +++ b/antares-python/src/antares/models/ship.py @@ -0,0 +1,56 @@ +from typing import Annotated, Literal + +from pydantic import BaseModel, Field + + +class BaseShip(BaseModel): + """ + Base class for ship configurations. + """ + + initial_position: tuple[float, float] = Field( + ..., description="Initial (x, y) coordinates of the ship." + ) + + +class LineShip(BaseShip): + """ + Ship that moves in a straight line at a constant speed. + """ + + type: Literal["line"] = "line" + angle: float = Field(..., description="Angle in radians.") + speed: float = Field(..., description="Constant speed.") + + +class CircleShip(BaseShip): + """ + Ship that moves in a circular path at a constant speed. + """ + + type: Literal["circle"] = "circle" + radius: float = Field(..., description="Radius of circular path.") + speed: float = Field(..., description="Constant speed.") + + +class RandomShip(BaseShip): + """ + Ship that moves in a random direction at a constant speed. + """ + + type: Literal["random"] = "random" + max_speed: float = Field(..., description="Maximum possible speed.") + + +class StationaryShip(BaseShip): + """ + Ship that does not move. + """ + + type: Literal["stationary"] = "stationary" + + +# Union of all ship configs +ShipConfig = Annotated[ + LineShip | CircleShip | RandomShip | StationaryShip, Field(discriminator="type") +] diff --git a/antares-python/src/antares/models/track.py b/antares-python/src/antares/models/track.py new file mode 100644 index 0000000..f740cd3 --- /dev/null +++ b/antares-python/src/antares/models/track.py @@ -0,0 +1,84 @@ +from typing import ClassVar + +from pydantic import BaseModel, Field + + +class Track(BaseModel): + id: int + year: int + month: int + day: int + hour: int + minute: int + second: int + millisecond: int + stat: str + type_: str = Field(alias="type") # maps from "type" input + name: str + linemask: int + size: int + range: float + azimuth: float + lat: float + long: float + speed: float + course: float + quality: int + l16quality: int + lacks: int + winrgw: int + winazw: float + stderr: float + + # expected order of fields from TCP stream + __field_order__: ClassVar[list[str]] = [ + "id", + "year", + "month", + "day", + "hour", + "minute", + "second", + "millisecond", + "stat", + "type_", + "name", + "linemask", + "size", + "range", + "azimuth", + "lat", + "long", + "speed", + "course", + "quality", + "l16quality", + "lacks", + "winrgw", + "winazw", + "stderr", + ] + + @classmethod + def from_csv_row(cls, line: str) -> "Track": + parts = line.strip().split(",") + if len(parts) != len(cls.__field_order__): + raise ValueError(f"Expected {len(cls.__field_order__)} fields, got {len(parts)}") + + converted = {} + for field_name, value in zip(cls.__field_order__, parts, strict=True): + field_info = cls.model_fields[field_name] + field_type = field_info.annotation + + if field_type is None: + raise ValueError(f"Field '{field_name}' has no type annotation") + + # Use alias if defined + key = field_info.alias or field_name + try: + # We trust simple coercion here; Pydantic will do final validation + converted[key] = field_type(value) + except Exception as e: + raise ValueError(f"Invalid value for field '{field_name}': {value} ({e})") from e + + return cls(**converted) diff --git a/antares-python/template.env b/antares-python/template.env new file mode 100644 index 0000000..c66163e --- /dev/null +++ b/antares-python/template.env @@ -0,0 +1,23 @@ +# ======================== +# Antares Python Client Environment Template +# To use this template, create a new file named `.env` in the same directory +# and fill in the required values. +# You can use: +# cp template.env .env +# ======================== + +# Host where the Antares simulation engine is running +ANTARES_HOST=localhost + +# Port for the HTTP API +ANTARES_HTTP_PORT=9000 + +# Port for the TCP stream +ANTARES_TCP_PORT=9001 + +# Request timeout in seconds +ANTARES_TIMEOUT=5.0 + +# Optional: Authentication token for the API +# Leave empty if not required +ANTARES_AUTH_TOKEN= diff --git a/antares-python/tests/client/test_client.py b/antares-python/tests/client/test_client.py new file mode 100644 index 0000000..d716647 --- /dev/null +++ b/antares-python/tests/client/test_client.py @@ -0,0 +1,30 @@ +import pytest + +from antares.client import AntaresClient +from antares.models.ship import StationaryShip + + +def test_reset_simulation_delegates(mocker): + mock_reset = mocker.patch("antares.client.rest.RestClient.reset_simulation") + client = AntaresClient(base_url="http://localhost") + client.reset_simulation() + mock_reset.assert_called_once() + + +def test_add_ship_delegates(mocker): + mock_add = mocker.patch("antares.client.rest.RestClient.add_ship") + client = AntaresClient(base_url="http://localhost") + ship = StationaryShip(initial_position=(1.0, 2.0)) + client.add_ship(ship) + mock_add.assert_called_once_with(ship) + + +@pytest.mark.asyncio +async def test_subscribe_delegates(monkeypatch): + async def fake_subscribe(_self): + yield {"event": "test"} + + monkeypatch.setattr("antares.client.tcp.TCPSubscriber.subscribe", fake_subscribe) + client = AntaresClient(base_url="http://localhost") + result = [event async for event in client.subscribe()] + assert result == [{"event": "test"}] diff --git a/antares-python/tests/client/test_rest.py b/antares-python/tests/client/test_rest.py new file mode 100644 index 0000000..d159baa --- /dev/null +++ b/antares-python/tests/client/test_rest.py @@ -0,0 +1,84 @@ +import httpx +import pytest + +from antares.client.rest import RestClient +from antares.errors import ConnectionError, ShipConfigError, SimulationError +from antares.models.ship import CircleShip, LineShip, RandomShip + + +def test_reset_simulation_success(mocker): + mock_request = httpx.Request("POST", "http://localhost/simulation/reset") + mock_post = mocker.patch("httpx.post", return_value=httpx.Response(200, request=mock_request)) + client = RestClient(base_url="http://localhost") + client.reset_simulation() + mock_post.assert_called_once() + + +def test_reset_simulation_failure(mocker): + mocker.patch("httpx.post", side_effect=httpx.ConnectTimeout("timeout")) + client = RestClient(base_url="http://localhost") + with pytest.raises(ConnectionError): + client.reset_simulation() + + +def test_add_ship_success(mocker): + mock_request = httpx.Request("POST", "http://localhost/simulation/ships") + mock_post = mocker.patch("httpx.post", return_value=httpx.Response(200, request=mock_request)) + ship = LineShip(initial_position=(0, 0), angle=0, speed=1) + client = RestClient(base_url="http://localhost") + client.add_ship(ship) + mock_post.assert_called_once() + + +def test_add_ship_invalid_response(mocker): + mock_request = httpx.Request("POST", "http://localhost/simulation/ships") + mocker.patch( + "httpx.post", + return_value=httpx.Response( + 400, + content=b"bad request", + request=mock_request, + ), + ) + ship = CircleShip(initial_position=(0, 0), radius=1, speed=1) + client = RestClient(base_url="http://localhost") + with pytest.raises(ShipConfigError): + client.add_ship(ship) + + +def test_reset_simulation_http_error(mocker): + request = httpx.Request("POST", "http://localhost/simulation/reset") + response = httpx.Response(500, content=b"internal error", request=request) + + mock_post = mocker.patch("httpx.post", return_value=response) + + # .raise_for_status() triggers HTTPStatusError + def raise_error(): + raise httpx.HTTPStatusError("error", request=request, response=response) + + response.raise_for_status = raise_error + + client = RestClient(base_url="http://localhost") + + with pytest.raises(SimulationError) as exc: + client.reset_simulation() + + assert "Reset failed" in str(exc.value) + mock_post.assert_called_once() + + +def test_add_ship_request_error(mocker): + mocker.patch( + "httpx.post", + side_effect=httpx.RequestError( + "connection dropped", request=httpx.Request("POST", "http://localhost/simulation/ships") + ), + ) + + ship = RandomShip(initial_position=(0, 0), max_speed=1) + client = RestClient(base_url="http://localhost") + + with pytest.raises(ConnectionError) as exc: + client.add_ship(ship) + + assert "Could not reach Antares API" in str(exc.value) diff --git a/antares-python/tests/client/test_tcp.py b/antares-python/tests/client/test_tcp.py new file mode 100644 index 0000000..bde9427 --- /dev/null +++ b/antares-python/tests/client/test_tcp.py @@ -0,0 +1,140 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from antares.client.tcp import TCPSubscriber +from antares.errors import SubscriptionError +from antares.models.track import Track + + +@pytest.mark.asyncio +async def test_subscribe_success(monkeypatch, sample_track_line): + # Simulated CSV lines returned from the TCP stream + encoded_lines = [sample_track_line.encode() + b"\n", b""] + + async def fake_readline(): + return encoded_lines.pop(0) + + # Simulate EOF after all lines are read + eof_flags = [False, True] + + fake_reader = AsyncMock() + fake_reader.readline = AsyncMock(side_effect=fake_readline) + fake_reader.at_eof = MagicMock(side_effect=eof_flags) + + # Patch asyncio.open_connection to return our mocked reader + monkeypatch.setattr("asyncio.open_connection", AsyncMock(return_value=(fake_reader, None))) + + subscriber = TCPSubscriber("localhost", 1234, reconnect=False) + + events = [event async for event in subscriber.subscribe()] + expected_lat = -33.45 + assert len(events) == 1 + assert isinstance(events[0], Track) + assert events[0].name == "Eagle-1" + assert events[0].lat == expected_lat + + +@pytest.mark.asyncio +async def test_subscribe_failure(monkeypatch): + monkeypatch.setattr("asyncio.open_connection", AsyncMock(side_effect=ConnectionRefusedError())) + + subscriber = TCPSubscriber("localhost", 1234, reconnect=False) + with pytest.raises(SubscriptionError): + async for _ in subscriber.subscribe(): + pass + + +@pytest.mark.asyncio +async def test_subscribe_reconnects_on_failure(monkeypatch, sample_track_line): + class OneMessageReader: + def __init__(self): + self.called = False + + def at_eof(self): + return self.called + + async def readline(self): + if not self.called: + self.called = True + return sample_track_line.encode() + b"\n" + return b"" + + open_calls = [] + + async def fake_open_connection(host, port): + if not open_calls: + open_calls.append("fail") + raise ConnectionRefusedError("initial fail") + return OneMessageReader(), None + + monkeypatch.setattr("asyncio.open_connection", fake_open_connection) + monkeypatch.setattr("asyncio.sleep", AsyncMock()) + + subscriber = TCPSubscriber("localhost", 1234, reconnect=True) + + events = [] + async for event in subscriber.subscribe(): + events.append(event) + break # exit after first track + + assert len(events) == 1 + assert isinstance(events[0], Track) + assert events[0].name == "Eagle-1" + + +@pytest.mark.asyncio +async def test_subscribe_invalid_field_count(monkeypatch): + invalid_line = "1,2025,4,11" + + async def fake_readline(): + return invalid_line.encode() + b"\n" + + fake_reader = AsyncMock() + fake_reader.readline = AsyncMock(side_effect=fake_readline) + fake_reader.at_eof = MagicMock(side_effect=[False, True]) + + monkeypatch.setattr("asyncio.open_connection", AsyncMock(return_value=(fake_reader, None))) + + subscriber = TCPSubscriber("localhost", 1234, reconnect=False) + + with pytest.raises(SubscriptionError) as excinfo: + async for _ in subscriber.subscribe(): + pass + + assert "Expected 25 fields" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_subscribe_invalid_value(monkeypatch, sample_track_line): + bad_line = sample_track_line.replace("1,", "bad_id,", 1) + + async def fake_readline(): + return bad_line.encode() + b"\n" + + fake_reader = AsyncMock() + fake_reader.readline = AsyncMock(side_effect=fake_readline) + fake_reader.at_eof = MagicMock(side_effect=[False, True]) + + monkeypatch.setattr("asyncio.open_connection", AsyncMock(return_value=(fake_reader, None))) + + subscriber = TCPSubscriber("localhost", 1234, reconnect=False) + + with pytest.raises(SubscriptionError) as excinfo: + async for _ in subscriber.subscribe(): + pass + + assert "Invalid value for field 'id'" in str(excinfo.value) + + +def test_from_csv_field_type_none(monkeypatch): + class FakeTrack(Track): + __field_order__ = ["id"] + id: int + + FakeTrack.model_fields["id"].annotation = None + + with pytest.raises(ValueError) as excinfo: + FakeTrack.from_csv_row("123") + + assert "has no type annotation" in str(excinfo.value) diff --git a/antares-python/tests/conftest.py b/antares-python/tests/conftest.py new file mode 100644 index 0000000..6d60f0e --- /dev/null +++ b/antares-python/tests/conftest.py @@ -0,0 +1,34 @@ +import pytest + + +@pytest.fixture +def sample_track_line() -> str: + return ",".join( + [ + "1", + "2025", + "4", + "11", + "10", + "30", + "15", + "200", + "OK", + "drone", + "Eagle-1", + "255", + "42", + "12.5", + "74.3", + "-33.45", + "-70.66", + "180.0", + "270.0", + "95", + "100", + "0", + "10", + "4.3", + "0.05", + ] + ) diff --git a/antares-python/tests/test_cli.py b/antares-python/tests/test_cli.py new file mode 100644 index 0000000..2bc49df --- /dev/null +++ b/antares-python/tests/test_cli.py @@ -0,0 +1,396 @@ +import asyncio +import subprocess + +import pytest +from typer.testing import CliRunner + +from antares.cli import app +from antares.errors import ConnectionError, SimulationError, SubscriptionError + +runner = CliRunner() + + +@pytest.fixture +def fake_config(tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text(""" +[antares] +host = "localhost" +http_port = 9000 +tcp_port = 9001 +timeout = 2.0 +auth_token = "fake-token" +""") + return str(config_file) + + +def test_cli_reset(mocker, fake_config): + mock_reset = mocker.patch("antares.client.rest.RestClient.reset_simulation") + result = runner.invoke(app, ["reset", "--config", fake_config]) + assert result.exit_code == 0 + assert "Simulation reset" in result.output + mock_reset.assert_called_once() + + +def test_cli_add_stationary_ship_success(mocker, fake_config): + mock_add = mocker.patch("antares.client.rest.RestClient.add_ship") + + result = runner.invoke( + app, + ["add-ship", "--type", "stationary", "--x", "5.0", "--y", "6.0", "--config", fake_config], + ) + + assert result.exit_code == 0 + assert "Added stationary ship at (5.0, 6.0)" in result.output + mock_add.assert_called_once() + + +def test_cli_add_line_ship_success(mocker, fake_config): + mock_add = mocker.patch("antares.client.rest.RestClient.add_ship") + + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "line", + "--x", + "10.0", + "--y", + "20.0", + "--angle", + "0.5", + "--speed", + "3.0", + "--config", + fake_config, + ], + ) + + assert result.exit_code == 0 + assert "Added line ship at (10.0, 20.0)" in result.output + mock_add.assert_called_once() + + +def test_cli_add_circle_ship_success(mocker, fake_config): + mock_add = mocker.patch("antares.client.rest.RestClient.add_ship") + + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "circle", + "--x", + "30.0", + "--y", + "40.0", + "--radius", + "15.0", + "--speed", + "2.5", + "--config", + fake_config, + ], + ) + + assert result.exit_code == 0 + assert "Added circle ship at (30.0, 40.0)" in result.output + mock_add.assert_called_once() + + +def test_cli_add_random_ship_success(mocker, fake_config): + mock_add = mocker.patch("antares.client.rest.RestClient.add_ship") + + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "random", + "--x", + "0.0", + "--y", + "0.0", + "--max-speed", + "12.0", + "--config", + fake_config, + ], + ) + + assert result.exit_code == 0 + assert "Added random ship at (0.0, 0.0)" in result.output + mock_add.assert_called_once() + + +def test_cli_subscribe(monkeypatch, mocker, fake_config): + async def fake_sub(self): + yield {"event": "test-event"} + + monkeypatch.setattr("antares.client.tcp.TCPSubscriber.subscribe", fake_sub) + + # Use a fresh event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result = runner.invoke(app, ["subscribe", "--config", fake_config]) + assert result.exit_code == 0 + assert "test-event" in result.output + + +def test_handle_error_json(monkeypatch): + result = runner.invoke(app, ["reset", "--json"], catch_exceptions=False) + assert result.exit_code in {1, 2} + assert "error" in result.output + + +def test_build_client_fails(mocker): + mocker.patch("antares.config_loader.load_config", side_effect=Exception("broken config")) + result = runner.invoke(app, ["reset", "--config", "invalid.toml"]) + assert result.exit_code == 1 + assert "Failed to load configuration" in result.output + + +def test_cli_reset_error_handling(mocker, fake_config): + mocker.patch( + "antares.client.rest.RestClient.reset_simulation", + side_effect=ConnectionError("cannot connect"), + ) + result = runner.invoke(app, ["reset", "--config", fake_config]) + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "cannot connect" in result.output + + +def test_cli_add_ship_error_handling(mocker, fake_config): + mocker.patch( + "antares.client.rest.RestClient.add_ship", side_effect=SimulationError("ship rejected") + ) + + result = runner.invoke( + app, + ["add-ship", "--type", "stationary", "--x", "1", "--y", "2", "--config", fake_config], + ) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "ship rejected" in result.output + + +def test_cli_add_ship_invalid_type(mocker, fake_config): + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "invalid_type", + "--x", + "10.0", + "--y", + "20.0", + "--config", + fake_config, + ], + ) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "Invalid ship type" in result.output + + +def test_cli_add_stationary_ship_missing_args(fake_config): + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "stationary", + "--x", + "5.0", + "--config", + fake_config, + ], + ) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + + +def test_cli_add_line_ship_missing_args(fake_config): + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "line", + "--x", + "10.0", + "--y", + "20.0", + "--config", + fake_config, + ], + ) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "Invalid ship parameters" in result.output + + +def test_cli_add_circle_missing_radius(mocker, fake_config): + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "circle", + "--x", + "10.0", + "--y", + "20.0", + "--speed", + "2.0", + "--config", + fake_config, + ], + ) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "Invalid ship parameters" in result.output + + +def test_cli_add_random_missing_max_speed(mocker, fake_config): + result = runner.invoke( + app, + [ + "add-ship", + "--type", + "random", + "--x", + "0.0", + "--y", + "0.0", + "--config", + fake_config, + ], + ) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "Invalid ship parameters" in result.output + + +def test_cli_subscribe_error(monkeypatch, fake_config): + class FailingAsyncGenerator: + def __aiter__(self): + return self + + async def __anext__(self): + raise SubscriptionError("stream failed") + + monkeypatch.setattr( + "antares.client.tcp.TCPSubscriber.subscribe", lambda self: FailingAsyncGenerator() + ) + + result = runner.invoke(app, ["subscribe", "--config", fake_config]) + expected_exit_code = 3 + assert result.exit_code == expected_exit_code + assert "stream failed" in result.output + + +def test_cli_verbose_prints_config(mocker, fake_config): + mocker.patch("antares.client.tcp.TCPSubscriber.subscribe", return_value=iter([])) + mocker.patch("antares.client.rest.RestClient.reset_simulation") + + result = runner.invoke(app, ["reset", "--config", fake_config, "--verbose"]) + assert result.exit_code == 0 + assert "Using settings" in result.output + + +def test_cli_subscribe_json(monkeypatch, fake_config): + class OneEventGen: + def __init__(self): + self.done = False + + def __aiter__(self): + return self + + async def __anext__(self): + if not self.done: + self.done = True + return {"event": "test"} + raise StopAsyncIteration + + monkeypatch.setattr("antares.client.tcp.TCPSubscriber.subscribe", lambda self: OneEventGen()) + + result = runner.invoke(app, ["subscribe", "--config", fake_config, "--json"]) + + assert result.exit_code == 0 + assert '{"event": "test"}' in result.output + + +def test_start_success(mocker): + mock_which = mocker.patch("shutil.which", return_value="/usr/local/bin/antares") + mock_popen = mocker.patch("subprocess.Popen", return_value=mocker.Mock(pid=1234)) + + result = runner.invoke(app, ["start"]) + assert result.exit_code == 0 + assert "Antares started in background with PID 1234" in result.output + mock_which.assert_called_once() + mock_popen.assert_called_once_with( + ["/usr/local/bin/antares"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + + +def test_start_executable_not_found(mocker): + mocker.patch("shutil.which", return_value=None) + + result = runner.invoke(app, ["start", "--executable", "fake-antares"]) + assert result.exit_code == 1 + assert "Executable 'fake-antares' not found" in result.output + + +def test_start_popen_failure(mocker): + mocker.patch("shutil.which", return_value="/usr/bin/antares") + mocker.patch("subprocess.Popen", side_effect=OSError("boom")) + + result = runner.invoke(app, ["start"]) + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert "Failed to start Antares" in result.output + + +def test_start_popen_failure_with_json_verbose(mocker): + mocker.patch("shutil.which", return_value="/usr/bin/antares") + mocker.patch("subprocess.Popen", side_effect=OSError("boom")) + + result = runner.invoke(app, ["start", "--json", "-v"]) + + expected_exit_code = 2 + assert result.exit_code == expected_exit_code + assert '{"error":' in result.stdout or result.stderr + assert "Failed to start Antares: boom" in result.output + + +def test_start_with_json_output(mocker): + mocker.patch("shutil.which", return_value="/usr/bin/antares") + mocker.patch("subprocess.Popen", return_value=mocker.Mock(pid=4321)) + + result = runner.invoke(app, ["start", "--json"]) + assert result.exit_code == 0 + assert '{"message":' in result.output + assert '"pid": 4321' in result.output + + +def test_start_with_config(mocker): + mocker.patch("shutil.which", return_value="/usr/local/bin/antares") + mock_popen = mocker.patch("subprocess.Popen", return_value=mocker.Mock(pid=5678)) + + result = runner.invoke(app, ["start", "--config", "config.toml"]) + assert result.exit_code == 0 + mock_popen.assert_called_once_with( + ["/usr/local/bin/antares", "--config", "config.toml"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/naval-radar-simulator/.gitignore b/antares/.gitignore similarity index 100% rename from naval-radar-simulator/.gitignore rename to antares/.gitignore diff --git a/naval-radar-simulator/Cargo.lock b/antares/Cargo.lock similarity index 100% rename from naval-radar-simulator/Cargo.lock rename to antares/Cargo.lock diff --git a/naval-radar-simulator/Cargo.toml b/antares/Cargo.toml similarity index 100% rename from naval-radar-simulator/Cargo.toml rename to antares/Cargo.toml diff --git a/naval-radar-simulator/README.md b/antares/README.md similarity index 100% rename from naval-radar-simulator/README.md rename to antares/README.md diff --git a/naval-radar-simulator/assets/config.toml b/antares/assets/config.toml similarity index 100% rename from naval-radar-simulator/assets/config.toml rename to antares/assets/config.toml diff --git a/naval-radar-simulator/src/config.rs b/antares/src/config.rs similarity index 100% rename from naval-radar-simulator/src/config.rs rename to antares/src/config.rs diff --git a/naval-radar-simulator/src/controller.rs b/antares/src/controller.rs similarity index 100% rename from naval-radar-simulator/src/controller.rs rename to antares/src/controller.rs diff --git a/naval-radar-simulator/src/lib.rs b/antares/src/lib.rs similarity index 100% rename from naval-radar-simulator/src/lib.rs rename to antares/src/lib.rs diff --git a/naval-radar-simulator/src/main.rs b/antares/src/main.rs similarity index 100% rename from naval-radar-simulator/src/main.rs rename to antares/src/main.rs diff --git a/naval-radar-simulator/src/radar/config.rs b/antares/src/radar/config.rs similarity index 100% rename from naval-radar-simulator/src/radar/config.rs rename to antares/src/radar/config.rs diff --git a/naval-radar-simulator/src/radar/detector/detector.rs b/antares/src/radar/detector/detector.rs similarity index 100% rename from naval-radar-simulator/src/radar/detector/detector.rs rename to antares/src/radar/detector/detector.rs diff --git a/naval-radar-simulator/src/radar/detector/mod.rs b/antares/src/radar/detector/mod.rs similarity index 100% rename from naval-radar-simulator/src/radar/detector/mod.rs rename to antares/src/radar/detector/mod.rs diff --git a/naval-radar-simulator/src/radar/detector/plot.rs b/antares/src/radar/detector/plot.rs similarity index 100% rename from naval-radar-simulator/src/radar/detector/plot.rs rename to antares/src/radar/detector/plot.rs diff --git a/naval-radar-simulator/src/radar/mod.rs b/antares/src/radar/mod.rs similarity index 100% rename from naval-radar-simulator/src/radar/mod.rs rename to antares/src/radar/mod.rs diff --git a/naval-radar-simulator/src/radar/protocol/constants/client_command.rs b/antares/src/radar/protocol/constants/client_command.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/constants/client_command.rs rename to antares/src/radar/protocol/constants/client_command.rs diff --git a/naval-radar-simulator/src/radar/protocol/constants/error_message.rs b/antares/src/radar/protocol/constants/error_message.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/constants/error_message.rs rename to antares/src/radar/protocol/constants/error_message.rs diff --git a/naval-radar-simulator/src/radar/protocol/constants/interface_ports.rs b/antares/src/radar/protocol/constants/interface_ports.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/constants/interface_ports.rs rename to antares/src/radar/protocol/constants/interface_ports.rs diff --git a/naval-radar-simulator/src/radar/protocol/constants/mod.rs b/antares/src/radar/protocol/constants/mod.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/constants/mod.rs rename to antares/src/radar/protocol/constants/mod.rs diff --git a/naval-radar-simulator/src/radar/protocol/constants/server_command.rs b/antares/src/radar/protocol/constants/server_command.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/constants/server_command.rs rename to antares/src/radar/protocol/constants/server_command.rs diff --git a/naval-radar-simulator/src/radar/protocol/mod.rs b/antares/src/radar/protocol/mod.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/mod.rs rename to antares/src/radar/protocol/mod.rs diff --git a/naval-radar-simulator/src/radar/protocol/tcp_interfaces/base_track_interface.rs b/antares/src/radar/protocol/tcp_interfaces/base_track_interface.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/tcp_interfaces/base_track_interface.rs rename to antares/src/radar/protocol/tcp_interfaces/base_track_interface.rs diff --git a/naval-radar-simulator/src/radar/protocol/tcp_interfaces/mod.rs b/antares/src/radar/protocol/tcp_interfaces/mod.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/tcp_interfaces/mod.rs rename to antares/src/radar/protocol/tcp_interfaces/mod.rs diff --git a/naval-radar-simulator/src/radar/protocol/tcp_interfaces/track_control_interface.rs b/antares/src/radar/protocol/tcp_interfaces/track_control_interface.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/tcp_interfaces/track_control_interface.rs rename to antares/src/radar/protocol/tcp_interfaces/track_control_interface.rs diff --git a/naval-radar-simulator/src/radar/protocol/tcp_interfaces/track_data_interface.rs b/antares/src/radar/protocol/tcp_interfaces/track_data_interface.rs similarity index 100% rename from naval-radar-simulator/src/radar/protocol/tcp_interfaces/track_data_interface.rs rename to antares/src/radar/protocol/tcp_interfaces/track_data_interface.rs diff --git a/naval-radar-simulator/src/radar/radar.rs b/antares/src/radar/radar.rs similarity index 100% rename from naval-radar-simulator/src/radar/radar.rs rename to antares/src/radar/radar.rs diff --git a/naval-radar-simulator/src/radar/tracker/mod.rs b/antares/src/radar/tracker/mod.rs similarity index 100% rename from naval-radar-simulator/src/radar/tracker/mod.rs rename to antares/src/radar/tracker/mod.rs diff --git a/naval-radar-simulator/src/radar/tracker/track.rs b/antares/src/radar/tracker/track.rs similarity index 100% rename from naval-radar-simulator/src/radar/tracker/track.rs rename to antares/src/radar/tracker/track.rs diff --git a/naval-radar-simulator/src/radar/tracker/tracker.rs b/antares/src/radar/tracker/tracker.rs similarity index 100% rename from naval-radar-simulator/src/radar/tracker/tracker.rs rename to antares/src/radar/tracker/tracker.rs diff --git a/naval-radar-simulator/src/simulation/config.rs b/antares/src/simulation/config.rs similarity index 100% rename from naval-radar-simulator/src/simulation/config.rs rename to antares/src/simulation/config.rs diff --git a/naval-radar-simulator/src/simulation/emitters/emitter.rs b/antares/src/simulation/emitters/emitter.rs similarity index 100% rename from naval-radar-simulator/src/simulation/emitters/emitter.rs rename to antares/src/simulation/emitters/emitter.rs diff --git a/naval-radar-simulator/src/simulation/emitters/mod.rs b/antares/src/simulation/emitters/mod.rs similarity index 100% rename from naval-radar-simulator/src/simulation/emitters/mod.rs rename to antares/src/simulation/emitters/mod.rs diff --git a/naval-radar-simulator/src/simulation/emitters/ship.rs b/antares/src/simulation/emitters/ship.rs similarity index 100% rename from naval-radar-simulator/src/simulation/emitters/ship.rs rename to antares/src/simulation/emitters/ship.rs diff --git a/naval-radar-simulator/src/simulation/environment/mod.rs b/antares/src/simulation/environment/mod.rs similarity index 100% rename from naval-radar-simulator/src/simulation/environment/mod.rs rename to antares/src/simulation/environment/mod.rs diff --git a/naval-radar-simulator/src/simulation/environment/wave.rs b/antares/src/simulation/environment/wave.rs similarity index 100% rename from naval-radar-simulator/src/simulation/environment/wave.rs rename to antares/src/simulation/environment/wave.rs diff --git a/naval-radar-simulator/src/simulation/mod.rs b/antares/src/simulation/mod.rs similarity index 100% rename from naval-radar-simulator/src/simulation/mod.rs rename to antares/src/simulation/mod.rs diff --git a/naval-radar-simulator/src/simulation/movement/circle.rs b/antares/src/simulation/movement/circle.rs similarity index 100% rename from naval-radar-simulator/src/simulation/movement/circle.rs rename to antares/src/simulation/movement/circle.rs diff --git a/naval-radar-simulator/src/simulation/movement/line.rs b/antares/src/simulation/movement/line.rs similarity index 100% rename from naval-radar-simulator/src/simulation/movement/line.rs rename to antares/src/simulation/movement/line.rs diff --git a/naval-radar-simulator/src/simulation/movement/mod.rs b/antares/src/simulation/movement/mod.rs similarity index 100% rename from naval-radar-simulator/src/simulation/movement/mod.rs rename to antares/src/simulation/movement/mod.rs diff --git a/naval-radar-simulator/src/simulation/movement/random.rs b/antares/src/simulation/movement/random.rs similarity index 100% rename from naval-radar-simulator/src/simulation/movement/random.rs rename to antares/src/simulation/movement/random.rs diff --git a/naval-radar-simulator/src/simulation/movement/stationary.rs b/antares/src/simulation/movement/stationary.rs similarity index 100% rename from naval-radar-simulator/src/simulation/movement/stationary.rs rename to antares/src/simulation/movement/stationary.rs diff --git a/naval-radar-simulator/src/simulation/movement/strategy.rs b/antares/src/simulation/movement/strategy.rs similarity index 100% rename from naval-radar-simulator/src/simulation/movement/strategy.rs rename to antares/src/simulation/movement/strategy.rs diff --git a/naval-radar-simulator/src/simulation/simulation.rs b/antares/src/simulation/simulation.rs similarity index 100% rename from naval-radar-simulator/src/simulation/simulation.rs rename to antares/src/simulation/simulation.rs diff --git a/naval-radar-simulator/src/utils/escape_ascii.rs b/antares/src/utils/escape_ascii.rs similarity index 100% rename from naval-radar-simulator/src/utils/escape_ascii.rs rename to antares/src/utils/escape_ascii.rs diff --git a/naval-radar-simulator/src/utils/mod.rs b/antares/src/utils/mod.rs similarity index 100% rename from naval-radar-simulator/src/utils/mod.rs rename to antares/src/utils/mod.rs diff --git a/naval-radar-simulator/src/utils/thread_pool.rs b/antares/src/utils/thread_pool.rs similarity index 100% rename from naval-radar-simulator/src/utils/thread_pool.rs rename to antares/src/utils/thread_pool.rs