diff --git a/aiovlc/cli/__init__.py b/aiovlc/cli/__init__.py new file mode 100644 index 0000000..67fd964 --- /dev/null +++ b/aiovlc/cli/__init__.py @@ -0,0 +1,26 @@ +"""Provide a CLI for aiovlc.""" +import logging + +import click + +from aiovlc import __version__ + +from .client import client + +SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@click.group( + options_metavar="", subcommand_metavar="", context_settings=SETTINGS +) +@click.option("--debug", is_flag=True, help="Start aiovlc in debug mode.") +@click.version_option(__version__) +def cli(debug: bool) -> None: + """Run aiovlc as an app for testing purposes.""" + if debug: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + + +cli.add_command(client) diff --git a/aiovlc/cli/client.py b/aiovlc/cli/client.py new file mode 100644 index 0000000..4dd9d58 --- /dev/null +++ b/aiovlc/cli/client.py @@ -0,0 +1,79 @@ +"""Provide a TCP client.""" +import asyncio +import logging +from typing import Awaitable, Callable + +import click + +from aiovlc.client import Client +from aiovlc.exceptions import AIOVLCError + +LOGGER = logging.getLogger("aiovlc") + +ClientFactory = Callable[[], Awaitable[Client]] + + +@click.command(options_metavar="") +@click.option( + "--password", + prompt=True, + hide_input=True, + required=True, + help="Password of the connection.", +) +@click.option( + "-H", + "--host", + default="localhost", + show_default=True, + help="Host of the connection.", +) +@click.option( + "-p", + "--port", + default=4212, + show_default=True, + type=int, + help="Port of the connection.", +) +def client(password: str, host: str, port: int) -> None: + """Start a client.""" + + async def client_factory() -> Client: + """Return a client.""" + vlc_client = Client(password, host=host, port=port) + return vlc_client + + run_client(client_factory) + + +def run_client(client_factory: ClientFactory) -> None: + """Run a client.""" + LOGGER.info("Starting client") + try: + asyncio.run(start_client(client_factory)) + except KeyboardInterrupt: + pass + finally: + LOGGER.info("Exiting CLI") + + +async def start_client(client_factory: ClientFactory) -> None: + """Start the client.""" + vlc_client = await client_factory() + + async with vlc_client: + while True: + try: + await handle_client(vlc_client) + except AIOVLCError as err: + LOGGER.error("Error '%s'", err) + break + + +async def handle_client(vlc_client: Client) -> None: + """Handle the client calls.""" + await vlc_client.login() + + async for msg in vlc_client.listen(): # pragma: no cover + LOGGER.debug("Received: %s", msg) diff --git a/aiovlc/client.py b/aiovlc/client.py new file mode 100644 index 0000000..551b9cb --- /dev/null +++ b/aiovlc/client.py @@ -0,0 +1,111 @@ +"""Provide a client for aiovlc.""" +from __future__ import annotations + +import asyncio +from types import TracebackType +from typing import AsyncGenerator + +from .const import LOGGER +from .exceptions import AuthError, CommandError, ConnectError, ConnectReadError + +IAC = bytes([255]) # "Interpret As Command" +TERMINATOR = "\n" + + +class Client: + """Represent a client for aiovlc.""" + + def __init__( + self, password: str, host: str = "localhost", port: int = 4212 + ) -> None: + """Set up the client client.""" + self.host = host + self.password = password + self.port = port + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + + async def __aenter__(self) -> Client: + """Connect the client with context manager.""" + await self.connect() + return self + + async def __aexit__( + self, exc_type: Exception, exc_value: str, traceback: TracebackType + ) -> None: + """Disconnect the client with context manager.""" + await self.disconnect() + + async def connect(self) -> None: + """Connect the client.""" + try: + self.reader, self.writer = await asyncio.open_connection( + host=self.host, port=self.port + ) + except OSError as err: + raise ConnectError(f"Failed to connect: {err}") from err + + async def disconnect(self) -> None: + """Disconnect the client.""" + assert self.writer is not None + try: + self.writer.close() + await self.writer.wait_closed() + except OSError: + pass + + async def read(self, read_until: str = TERMINATOR) -> str: + """Return a decoded message.""" + assert self.reader is not None + + try: + read = await self.reader.readuntil(read_until.encode("utf-8")) + except asyncio.LimitOverrunError as err: + raise ConnectReadError(err) from err + except asyncio.IncompleteReadError as err: + raise ConnectReadError(err, err.partial) from err + except OSError as err: + raise ConnectError(f"Failed to read: {err}") from err + + LOGGER.debug("Bytes read: %s", read) + + # Drop IAC command and read again. + if IAC in read: + return await self.read(read_until) + return read.decode("utf-8") + + async def write(self, command: str) -> None: + """Write a command to the connection.""" + assert self.writer is not None + + try: + self.writer.write(command.encode("utf-8")) + await self.writer.drain() + except OSError as err: + raise ConnectError(f"Failed to write: {err}") from err + + async def listen(self) -> AsyncGenerator[str, None]: + """Listen and yield a message.""" + while True: + message_string = await self.read() + + yield message_string + + async def login(self) -> None: + """Login.""" + await self.read("Password: ") + full_command = f"{self.password}\n" + await self.write(full_command) + for _ in range(2): + command_output = (await self.read("\n")).strip("\r\n") + if command_output: # discard empty line once. + break + parsed_output = command_output.lower() + if "wrong password" in parsed_output: + raise AuthError("Failed to login to VLC.") + if "welcome" not in parsed_output: + raise CommandError(f"Unexpected password response: {command_output}") + if "> " in command_output: + return + # Read until prompt + await self.read("> ") diff --git a/aiovlc/const.py b/aiovlc/const.py new file mode 100644 index 0000000..81e0b48 --- /dev/null +++ b/aiovlc/const.py @@ -0,0 +1,4 @@ +"""Provide common constants.""" +import logging + +LOGGER = logging.getLogger(__package__) diff --git a/aiovlc/exceptions.py b/aiovlc/exceptions.py new file mode 100644 index 0000000..71929a3 --- /dev/null +++ b/aiovlc/exceptions.py @@ -0,0 +1,32 @@ +"""Provide exceptions for aiovlc.""" +from __future__ import annotations + + +class AIOVLCError(Exception): + """Represent a common error for aiovlc.""" + + +class ConnectError(AIOVLCError): + """Represent a connection error for aiovlc.""" + + +class ConnectReadError(ConnectError): + """The client failed to read.""" + + def __init__(self, error: Exception, partial_bytes: bytes | None = None) -> None: + """Set up error.""" + message = f"Failed to read: {error}." + + if partial_bytes is not None: + message = f"{message} Partial bytes read: {partial_bytes!r}" + + super().__init__(message) + self.partial_bytes = partial_bytes + + +class AuthError(AIOVLCError): + """Represent an authentication error.""" + + +class CommandError(AIOVLCError): + """Represent a command error.""" diff --git a/requirements.txt b/requirements.txt index 8b13789..9c049d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ - +click==8.0.2 diff --git a/setup.py b/setup.py index 1c842bb..bad1112 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,9 @@ LONG_DESCRIPTION = README_FILE.read_text(encoding="utf-8") VERSION = (PROJECT_DIR / "aiovlc" / "VERSION").read_text().strip() -REQUIRES = [] +REQUIRES = [ + "click", +] setup( name="aiovlc", @@ -27,6 +29,7 @@ long_description_content_type="text/markdown", python_requires=">=3.8", install_requires=REQUIRES, + entry_points={"console_scripts": ["aiovlc = aiovlc.cli:cli"]}, classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers",