Skip to content

Commit

Permalink
Added asyncio support, Improved implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
dolejska-daniel committed Oct 13, 2020
1 parent 6a3d2be commit 49a1ff6
Show file tree
Hide file tree
Showing 18 changed files with 544 additions and 66 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pytest = "*"
twine = "*"

[packages]
aiosocksy = "*"

[requires]
python_version = "3.7"
339 changes: 339 additions & 0 deletions Pipfile.lock

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Python AMCP Client Library
> v0.1.0
> v0.2.0

## Introduction
Expand All @@ -24,6 +24,15 @@ client = Client()
client.connect("caspar-server.local", 6969) # defaults to 127.0.0.1, 5250
```

Support for `asyncio` module:
```python
import asyncio
from amcp_pylib.core import ClientAsync

client = ClientAsync()
asyncio.new_event_loop().run_until_complete(client.connect("caspar-server.local", 6969))
```

### Sending commands

```python
Expand All @@ -41,8 +50,8 @@ print(response)
```

```shell
201(VERSION) ['2.0.7.e9fc25a Stable']
0(EMPTY) ['SERVER SENT NO RESPONSE']
2.0.7.e9fc25a Stable (201 - VERSION)
SERVER SENT NO RESPONSE (0 - EMPTY)
```

All supported protocol commands are listed and documented on CasparCG's [wiki pages](https://github.com/CasparCG/help/wiki/AMCP-Protocol#table-of-contents).
4 changes: 4 additions & 0 deletions amcp_pylib/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from .client import Client
from .client_async import ClientAsync
from .command import Command, command_syntax
from .connection import Connection
from .connection_async import ConnectionAsync

__all__ = [
"Client",
"ClientAsync",
"Command",
"command_syntax",
"Connection",
"ConnectionAsync",
]
7 changes: 3 additions & 4 deletions amcp_pylib/core/client.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
from .connection import Connection
from .command import Command
from .connection import Connection
from .client_base import ClientBase

from amcp_pylib.response import (
Base as ResponseBase,
Factory as ResponseFactory,
)


class Client:
class Client(ClientBase):
""" Connection client class. """

connection: Connection = None

def connect(self, host: str = "127.0.0.1", port: int = 5250):
""" Initialize TCP connection to given host address and port. """
Expand Down
27 changes: 27 additions & 0 deletions amcp_pylib/core/client_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from .command import Command
from .client_base import ClientBase
from .connection_async import ConnectionAsync

from amcp_pylib.response import (
Base as ResponseBase,
Factory as ResponseFactory,
)


class ClientAsync(ClientBase):
connection: ConnectionAsync

async def connect(self, host: str = "127.0.0.1", port: int = 5250):
if not self.connection:
self.connection = ConnectionAsync(host, port)

async def send(self, command: Command) -> ResponseBase:
return await self.send_raw(bytes(command))

async def send_raw(self, data: bytes) -> ResponseBase:
await self.connection.send(data)
return await self.process_response()

async def process_response(self) -> ResponseBase:
data = await self.connection.receive()
return ResponseFactory.create_from_bytes(data)
31 changes: 31 additions & 0 deletions amcp_pylib/core/client_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from abc import ABCMeta, abstractmethod
from .command import Command
from .connection_base import ConnectionBase

from amcp_pylib.response import Base as ResponseBase


class ClientBase(metaclass=ABCMeta):
""" Connection client class. """

connection: ConnectionBase = None

@abstractmethod
def connect(self, host: str = "127.0.0.1", port: int = 5250):
""" Initialize TCP connection to given host address and port. """
pass

@abstractmethod
def send(self, command: Command) -> ResponseBase:
""" Convert command to bytes and then send it via established server connection. """
pass

@abstractmethod
def send_raw(self, data: bytes) -> ResponseBase:
""" Send bytes via established server connection. """
pass

@abstractmethod
def process_response(self) -> ResponseBase:
""" Receive data from server, parse it and create corresponding class. """
pass
23 changes: 9 additions & 14 deletions amcp_pylib/core/connection.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import socket

from .connection_base import ConnectionBase

class Connection:

class Connection(ConnectionBase):
"""
Represents TCP connection to target server.
"""
Expand All @@ -10,36 +12,29 @@ class Connection:
s: socket.socket = None

def __init__(self, host: str, port: int):
""" Initializes Connection class and creates connection to server. """

# Get necessary address information
# get necessary address information
address_info = socket.getaddrinfo(host, port)[0]
# Create connection from information
# create connection from information
self.connect(address_info[0], address_info[4])

def connect(self, address_family: int, address_target: tuple):
""" Creates connection to server. """

# Create required TCP socket
# create required TCP socket
self.s = socket.socket(address_family, socket.SOCK_STREAM)
# Connect to provided target
# connect to provided target
self.s.connect(address_target)

def disconnect(self):
""" Closes active socket. """
self.s.close()

def send(self, data: bytes):
""" Sends data through connection's socket stream. """
self.s.sendall(data)

def receive(self) -> bytes:
""" Reads data from connection's socket stream. """
data = bytes()
while True:
new_data = self.s.recv(1024)
new_data = self.s.recv(1500)
data += new_data
if len(new_data) < 1024:
if len(new_data) < 1500:
break

return data
44 changes: 44 additions & 0 deletions amcp_pylib/core/connection_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import socket
import asyncio
from asyncio import StreamReader, StreamWriter

from .connection_base import ConnectionBase


class ConnectionAsync(ConnectionBase):
"""
Represents TCP connection to target server.
"""

# TCP communication reader
reader: StreamReader = None
# TCP communication writer
writer: StreamWriter = None

def __init__(self, host: str, port: int):
# get necessary address information
address_info = socket.getaddrinfo(host, port)[0]
# create connection from information
self.connect(address_info[0], address_info[4])

async def connect(self, address_family: int, address_target: tuple):
# create required TCP socket
self.reader, self.writer = await asyncio.open_connection()
# connect to provided target
await self.connect(address_family, address_target)

async def disconnect(self):
self.writer.close()

async def send(self, data: bytes):
self.writer.write(data)

async def receive(self) -> bytes:
data = bytes()
while True:
new_data = await self.reader.read(1500)
data += new_data
if len(new_data) < 1500:
break

return data
27 changes: 27 additions & 0 deletions amcp_pylib/core/connection_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from abc import ABCMeta, abstractmethod


class ConnectionBase(metaclass=ABCMeta):
"""
Represents TCP connection to target server.
"""

@abstractmethod
def connect(self, address_family: int, address_target: tuple):
""" Creates connection to server. """
pass

@abstractmethod
def disconnect(self):
""" Closes active socket. """
pass

@abstractmethod
def send(self, data: bytes):
""" Sends data through connection's socket stream. """
pass

@abstractmethod
def receive(self) -> bytes:
""" Reads data from connection's socket stream. """
pass
1 change: 1 addition & 0 deletions amcp_pylib/core/syntax/command_argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def check_value_type(self, value):
allowed_type=self.required_datatype,
)
)

elif self.required_keywords and str(value) not in self.required_keywords:
raise RuntimeError(
"Value '{arg_value}' of argument '{arg_identifier}' is not valid. "
Expand Down
4 changes: 3 additions & 1 deletion amcp_pylib/core/syntax/command_group.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from .token import Token
from .token_types import TokenType
from .command_argument import CommandArgument
Expand All @@ -12,7 +14,7 @@ class CommandGroup:
# other groups within this one
subgroups: list = None
# this groups arguments
arguments: list = None
arguments: typing.List[CommandArgument] = None
# concrete order of elements within this group to be rendered
display_order: list = None
# is this group required?
Expand Down
8 changes: 4 additions & 4 deletions amcp_pylib/core/syntax/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ def get_token(self, token_type=None) -> Token:
"""
Gets next token from scanner. Allows to apply token type constraint.
:param token_type: [int|list]
:param token_type: [TokenType|list]
:returns: Received token instance.
:raises RuntimeError: Received token's type not allowed by specified constraint.
"""
token_types = token_type
if isinstance(token_type, int):
if isinstance(token_type, TokenType):
token_types = [token_type]

token = self.scanner.get_next_token()
Expand All @@ -45,7 +45,7 @@ def try_get_token(self, token_type, return_on_success=False):
"""
Tries to get token of specified type(s). Returns token on unsuccessful attempt (type mismatch).
:param token_type: [int|list] Requested token type(s).
:param token_type: [TokenType|list] Requested token type(s).
:param return_on_success: Returns token even when token of specified type was received.
:returns: True or Token instance on success (depending return_on_success). False on type mismatch.
Expand Down Expand Up @@ -79,7 +79,7 @@ def return_token(self, token: Token):
"""
self.scanner.return_token(token)

def parse(self):
def parse(self) -> CommandGroup:
"""
Tries to parse syntax definition string.
Expand Down
19 changes: 11 additions & 8 deletions amcp_pylib/core/syntax/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,29 +56,32 @@ def get_next_token(self) -> Token:
# match token type
if match["keyword"]:
token_type = TokenType.KEYWORD

elif match["constant"]:
token_type = TokenType.CONSTANT
if token_content_full is ' ':
if token_content_full == ' ':
token_type = TokenType.CONSTANT_SPACE

elif match["identifier"]:
if token_content in ["int", "string", "float"]:
token_type = TokenType.TYPE
else:
token_type = TokenType.IDENTIFIER

elif match["operators"]:
if token_content is '[':
if token_content == '[':
token_type = TokenType.REQUIRED_OPEN
elif token_content is ']':
elif token_content == ']':
token_type = TokenType.REQUIRED_CLOSE
elif token_content is '{':
elif token_content == '{':
token_type = TokenType.OPTIONAL_OPEN
elif token_content is '}':
elif token_content == '}':
token_type = TokenType.OPTIONAL_CLOSE
elif token_content is '|':
elif token_content == '|':
token_type = TokenType.OPERATOR_OR
elif token_content is ':':
elif token_content == ':':
token_type = TokenType.OPERATOR_TYPE
elif token_content is ',':
elif token_content == ',':
token_type = TokenType.OPERATOR_COMMA

return Token(token_type, token_content_full)
Expand Down
4 changes: 2 additions & 2 deletions amcp_pylib/core/syntax/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class Token:
Class representing token instance.
"""

token_type: int = TokenType.CONSTANT
token_type: TokenType = TokenType.CONSTANT
token_content: str = None

def __init__(self, token_type=TokenType.UNDEFINED, token_content=""):
Expand All @@ -21,7 +21,7 @@ def __str__(self) -> str:
type=TokenType.to_str(self.token_type)
)

def get_type(self) -> int:
def get_type(self) -> TokenType:
""" Returns token type. """
return self.token_type

Expand Down
Loading

0 comments on commit 49a1ff6

Please sign in to comment.