Skip to content

Commit 6250515

Browse files
committed
Implement API calls and CLI
1 parent 0f9391d commit 6250515

File tree

18 files changed

+494
-13
lines changed

18 files changed

+494
-13
lines changed

.github/workflows/python-ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
- name: Install dependencies
3434
run: |
3535
uv pip install --system -e .
36-
uv pip install --system pytest pytest-cov mypy ruff build
36+
uv pip install --system pytest pytest-cov pytest-mock mypy ruff build
3737
3838
3939
- name: Run linters

antares-python/pyproject.toml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,28 @@
22
name = "antares-python"
33
version = "0.1.0"
44
description = "Python interface for the Antares simulation software"
5-
authors = [{ name = "Juan Sebastian Urrea-Lopez", email = "[email protected]" }]
5+
authors = [
6+
{ name = "Juan Sebastian Urrea-Lopez", email = "[email protected]" },
7+
]
68
readme = "README.md"
79
requires-python = ">=3.13"
8-
dependencies = []
10+
dependencies = [
11+
"pydantic>=2.11.3",
12+
"httpx>=0.28.1",
13+
"typer>=0.15.2",
14+
"rich>=13.0",
15+
"tomli>=2.2.1",
16+
]
17+
18+
[project.scripts]
19+
antares = "antares.cli:app"
920

1021
[build-system]
1122
requires = ["setuptools>=61.0", "wheel"]
1223
build-backend = "setuptools.build_meta"
1324

1425
[tool.ruff]
15-
line-length = 88
26+
line-length = 100
1627
lint.select = ["E", "F", "I", "UP", "B", "PL"]
1728
exclude = ["dist", "build"]
1829

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
from .core import saludar
1+
from .client import AntaresClient
2+
from .models.ship import ShipConfig
23

3-
__all__ = ["saludar"]
4+
__all__ = ["AntaresClient", "ShipConfig"]

antares-python/src/antares/cli.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import asyncio
2+
3+
import typer
4+
from rich.console import Console
5+
from rich.theme import Theme
6+
7+
from antares import AntaresClient, ShipConfig
8+
from antares.config_loader import load_config
9+
10+
app = typer.Typer(help="Antares Simulation CLI")
11+
12+
console = Console(
13+
theme=Theme(
14+
{
15+
"info": "green",
16+
"warn": "yellow",
17+
"error": "bold red",
18+
}
19+
)
20+
)
21+
22+
23+
def build_client(config_path: str | None, verbose: bool) -> AntaresClient:
24+
settings = load_config(config_path)
25+
26+
if verbose:
27+
console.print(f"[info]Using settings: {settings.model_dump()}")
28+
29+
return AntaresClient(
30+
base_url=settings.base_url,
31+
tcp_host=settings.tcp_host,
32+
tcp_port=settings.tcp_port,
33+
timeout=settings.timeout,
34+
auth_token=settings.auth_token,
35+
)
36+
37+
38+
@app.command()
39+
def reset(
40+
config: str = typer.Option(None, help="Path to config TOML file"),
41+
verbose: bool = typer.Option(False, "--verbose", "-v"),
42+
):
43+
"""Reset the simulation."""
44+
client = build_client(config, verbose)
45+
client.reset_simulation()
46+
console.print("[info]✅ Simulation reset.")
47+
48+
49+
@app.command()
50+
def add_ship(
51+
x: float,
52+
y: float,
53+
config: str = typer.Option(None, help="Path to config TOML file"),
54+
verbose: bool = typer.Option(False, "--verbose", "-v"),
55+
):
56+
"""Add a ship to the simulation at (x, y)."""
57+
client = build_client(config, verbose)
58+
ship = ShipConfig(initial_position=(x, y))
59+
client.add_ship(ship)
60+
console.print(f"[info]🚢 Added ship at ({x}, {y})")
61+
62+
63+
@app.command()
64+
def subscribe(
65+
config: str = typer.Option(None, help="Path to config TOML file"),
66+
verbose: bool = typer.Option(False, "--verbose", "-v"),
67+
):
68+
"""Subscribe to simulation data stream."""
69+
client = build_client(config, verbose)
70+
71+
async def _sub():
72+
async for event in client.subscribe():
73+
console.print_json(data=event)
74+
75+
asyncio.run(_sub())
76+
77+
78+
if __name__ == "__main__":
79+
app()
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from collections.abc import AsyncIterator
2+
3+
from antares.client.rest import RestClient
4+
from antares.client.tcp import TCPSubscriber
5+
from antares.config import AntaresSettings
6+
from antares.models.ship import ShipConfig
7+
8+
9+
class AntaresClient:
10+
def __init__(
11+
self,
12+
base_url: str | None = None,
13+
tcp_host: str | None = None,
14+
tcp_port: int | None = None,
15+
timeout: float | None = None,
16+
auth_token: str | None = None,
17+
) -> None:
18+
"""
19+
Public interface for interacting with the Antares simulation engine.
20+
Accepts config overrides directly or falls back to environment-based configuration.
21+
"""
22+
23+
# Merge provided arguments with environment/.env via AntaresSettings
24+
self._settings = AntaresSettings(
25+
base_url=base_url,
26+
tcp_host=tcp_host,
27+
tcp_port=tcp_port,
28+
timeout=timeout,
29+
auth_token=auth_token,
30+
)
31+
32+
self._rest = RestClient(
33+
base_url=self._settings.base_url,
34+
timeout=self._settings.timeout,
35+
auth_token=self._settings.auth_token,
36+
)
37+
self._tcp = TCPSubscriber(
38+
host=self._settings.tcp_host, port=self._settings.tcp_port
39+
)
40+
41+
def reset_simulation(self) -> None:
42+
"""
43+
Sends a request to reset the current simulation state.
44+
"""
45+
return self._rest.reset_simulation()
46+
47+
def add_ship(self, ship: ShipConfig) -> None:
48+
"""
49+
Sends a new ship configuration to the simulation engine.
50+
"""
51+
return self._rest.add_ship(ship)
52+
53+
async def subscribe(self) -> AsyncIterator[dict]:
54+
"""
55+
Subscribes to live simulation data over TCP.
56+
57+
Yields:
58+
Parsed simulation event data as dictionaries.
59+
"""
60+
async for event in self._tcp.subscribe():
61+
yield event
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import httpx
2+
3+
from antares.errors import ConnectionError, SimulationError
4+
from antares.models.ship import ShipConfig
5+
6+
7+
class RestClient:
8+
"""
9+
Internal client for interacting with the Antares simulation REST API.
10+
"""
11+
12+
def __init__(
13+
self, base_url: str, timeout: float = 5.0, auth_token: str | None = None
14+
) -> None:
15+
"""
16+
Initializes the REST client.
17+
18+
Args:
19+
base_url: The root URL of the Antares HTTP API.
20+
timeout: Timeout in seconds for each request.
21+
auth_token: Optional bearer token for authentication.
22+
"""
23+
self.base_url = base_url.rstrip("/")
24+
self.timeout = timeout
25+
self.headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {}
26+
27+
def reset_simulation(self) -> None:
28+
"""
29+
Sends a request to reset the current simulation state.
30+
"""
31+
try:
32+
response = httpx.post(
33+
f"{self.base_url}/simulation/reset",
34+
headers=self.headers,
35+
timeout=self.timeout,
36+
)
37+
response.raise_for_status()
38+
except httpx.RequestError as e:
39+
raise ConnectionError(f"Could not reach Antares API: {e}") from e
40+
except httpx.HTTPStatusError as e:
41+
raise SimulationError(f"Reset failed: {e.response.text}") from e
42+
43+
def add_ship(self, ship: ShipConfig) -> None:
44+
"""
45+
Sends a ship configuration to the simulation engine.
46+
47+
Args:
48+
ship: A validated ShipConfig instance.
49+
"""
50+
try:
51+
response = httpx.post(
52+
f"{self.base_url}/simulation/ships",
53+
json=ship.model_dump(),
54+
headers=self.headers,
55+
timeout=self.timeout,
56+
)
57+
response.raise_for_status()
58+
except httpx.RequestError as e:
59+
raise ConnectionError(f"Could not reach Antares API: {e}") from e
60+
except httpx.HTTPStatusError as e:
61+
raise SimulationError(f"Add ship failed: {e.response.text}") from e
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import asyncio
2+
import json
3+
from collections.abc import AsyncIterator
4+
5+
from antares.errors import SubscriptionError
6+
7+
8+
class TCPSubscriber:
9+
"""
10+
Manages a TCP connection to the Antares simulation for real-time event streaming.
11+
"""
12+
13+
def __init__(self, host: str, port: int, reconnect: bool = True) -> None:
14+
"""
15+
Initializes the TCP subscriber.
16+
17+
Args:
18+
host: The hostname or IP of the TCP server.
19+
port: The port number of the TCP server.
20+
reconnect: Whether to automatically reconnect on disconnect.
21+
"""
22+
self.host = host
23+
self.port = port
24+
self.reconnect = reconnect
25+
26+
async def subscribe(self) -> AsyncIterator[dict]:
27+
"""
28+
Connects to the TCP server and yields simulation events as parsed dictionaries.
29+
This is an infinite async generator until disconnected or cancelled.
30+
31+
Yields:
32+
Parsed simulation events.
33+
"""
34+
while True:
35+
try:
36+
reader, _ = await asyncio.open_connection(self.host, self.port)
37+
while not reader.at_eof():
38+
line = await reader.readline()
39+
if line:
40+
yield json.loads(line.decode())
41+
except (
42+
ConnectionRefusedError,
43+
asyncio.IncompleteReadError,
44+
json.JSONDecodeError,
45+
) as e:
46+
raise SubscriptionError(f"Failed to read from TCP stream: {e}") from e
47+
48+
if not self.reconnect:
49+
break
50+
51+
# Wait before attempting to reconnect
52+
await asyncio.sleep(1)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from pydantic import BaseSettings, Field
2+
3+
4+
class AntaresSettings(BaseSettings):
5+
"""
6+
Application-level configuration for the Antares Python client.
7+
Supports environment variables and `.env` file loading.
8+
"""
9+
10+
base_url: str = Field(..., env="ANTARES_BASE_URL")
11+
tcp_host: str = Field("localhost", env="ANTARES_TCP_HOST")
12+
tcp_port: int = Field(9000, env="ANTARES_TCP_PORT")
13+
timeout: float = Field(5.0, env="ANTARES_TIMEOUT")
14+
auth_token: str | None = Field(None, env="ANTARES_AUTH_TOKEN")
15+
16+
class Config:
17+
env_file = ".env"
18+
case_sensitive = False
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pathlib import Path
2+
3+
import tomli
4+
5+
from antares.config import AntaresSettings
6+
7+
8+
def load_config(path: str | Path | None = None) -> AntaresSettings:
9+
"""Loads AntaresSettings from a TOML config file or defaults to .env + env vars."""
10+
if path is None:
11+
return AntaresSettings()
12+
13+
config_path = Path(path).expanduser()
14+
if not config_path.exists():
15+
raise FileNotFoundError(f"Config file not found: {config_path}")
16+
17+
with config_path.open("rb") as f:
18+
data = tomli.load(f)
19+
20+
return AntaresSettings(**data.get("antares", {}))

antares-python/src/antares/core.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)