Skip to content

Commit

Permalink
Add client foundation (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinHjelmare authored Oct 10, 2021
1 parent 89a35d5 commit 89ba1ff
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 2 deletions.
26 changes: 26 additions & 0 deletions aiovlc/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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="<command>", 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)
79 changes: 79 additions & 0 deletions aiovlc/cli/client.py
Original file line number Diff line number Diff line change
@@ -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="<options>")
@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)
111 changes: 111 additions & 0 deletions aiovlc/client.py
Original file line number Diff line number Diff line change
@@ -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("> ")
4 changes: 4 additions & 0 deletions aiovlc/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Provide common constants."""
import logging

LOGGER = logging.getLogger(__package__)
32 changes: 32 additions & 0 deletions aiovlc/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@

click==8.0.2
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down

0 comments on commit 89ba1ff

Please sign in to comment.