From abfa3fe865715064da425164e6913d67f85d825f Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 15 Jun 2022 05:39:54 +0000 Subject: [PATCH] Control socket: overhaul implementation This breaks some commands, everything else is better though. --- anonstream/control/commands/__init__.py | 44 ------- anonstream/control/commands/chat.py | 34 ------ anonstream/control/commands/exit.py | 20 ---- anonstream/control/commands/help.py | 38 ------ anonstream/control/commands/title.py | 53 -------- anonstream/control/commands/user.py | 153 ------------------------ anonstream/control/exceptions.py | 20 +--- anonstream/control/parse.py | 92 ++++++-------- anonstream/control/server.py | 14 +-- anonstream/control/spec/__init__.py | 12 ++ anonstream/control/spec/common.py | 40 +++++++ anonstream/control/spec/methods/exit.py | 18 +++ anonstream/control/spec/methods/help.py | 35 ++++++ anonstream/control/spec/utils.py | 14 +++ 14 files changed, 162 insertions(+), 425 deletions(-) delete mode 100644 anonstream/control/commands/__init__.py delete mode 100644 anonstream/control/commands/chat.py delete mode 100644 anonstream/control/commands/exit.py delete mode 100644 anonstream/control/commands/help.py delete mode 100644 anonstream/control/commands/title.py delete mode 100644 anonstream/control/commands/user.py create mode 100644 anonstream/control/spec/__init__.py create mode 100644 anonstream/control/spec/common.py create mode 100644 anonstream/control/spec/methods/exit.py create mode 100644 anonstream/control/spec/methods/help.py create mode 100644 anonstream/control/spec/utils.py diff --git a/anonstream/control/commands/__init__.py b/anonstream/control/commands/__init__.py deleted file mode 100644 index 77bbb08..0000000 --- a/anonstream/control/commands/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -from anonstream.control.commands.help import * -from anonstream.control.commands.exit import * -from anonstream.control.commands.title import * -from anonstream.control.commands.chat import * -from anonstream.control.commands.user import * - -METHOD_HELP = 'help' -METHOD_EXIT = 'exit' -METHOD_TITLE = 'title' -METHOD_CHAT = 'chat' -METHOD_USER = 'user' - -METHOD_COMMAND_FUNCTIONS = { - METHOD_HELP: { - None: command_help, - 'help': command_help_help, - }, - METHOD_EXIT: { - None: command_exit, - 'help': command_exit_help, - }, - METHOD_TITLE: { - None: command_title_show, - 'help': command_title_help, - 'show': command_title_show, - 'set': command_title_set, - }, - METHOD_CHAT: { - None: command_chat_help, - 'help': command_chat_help, - 'delete': command_chat_delete, - }, - METHOD_USER: { - None: command_user_show, - 'help': command_user_help, - 'show': command_user_show, - 'attr': command_user_attr, - 'get': command_user_get, - 'set': command_user_set, - 'eyes': command_user_eyes, - }, -} - - diff --git a/anonstream/control/commands/chat.py b/anonstream/control/commands/chat.py deleted file mode 100644 index 6124ac7..0000000 --- a/anonstream/control/commands/chat.py +++ /dev/null @@ -1,34 +0,0 @@ -from anonstream.control.exceptions import BadArgument, Incomplete, Garbage -from anonstream.chat import delete_chat_messages - -async def command_chat_help(args): - match args: - case []: - normal_options = ['help'] - response = ( - 'Usage: chat {show [MESSAGES] | delete SEQS}\n' - 'Commands:\n' - #' chat show [MESSAGES]......show chat messages\n' - ' chat delete SEQS..........delete chat messages\n' - 'Definitions:\n' - #' MESSAGES..................undefined\n' - ' SEQS......................=SEQ [SEQ...]\n' - ' SEQ.......................a chat message\'s seq, base-10 integer\n' - ) - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_chat_delete(args): - match args: - case []: - raise Incomplete - case _: - try: - seqs = list(map(int, args)) - except ValueError as e: - raise BadArgument('SEQ must be a base-10 integer') from e - delete_chat_messages(seqs) - normal_options = ['delete', *map(str, seqs)] - response = '' - return normal_options, response diff --git a/anonstream/control/commands/exit.py b/anonstream/control/commands/exit.py deleted file mode 100644 index b807bb9..0000000 --- a/anonstream/control/commands/exit.py +++ /dev/null @@ -1,20 +0,0 @@ -from anonstream.control.exceptions import Exit, Garbage - -async def command_exit(args): - match args: - case []: - raise Exit - case [*garbage]: - raise Garbage(garbage) - -async def command_exit_help(args): - match args: - case []: - normal_options = ['help'] - response = ( - 'Usage: exit\n' - 'close the connection\n' - ) - case [*garbage]: - raise Garbage(garbage) - return normal_options, response diff --git a/anonstream/control/commands/help.py b/anonstream/control/commands/help.py deleted file mode 100644 index af17fa5..0000000 --- a/anonstream/control/commands/help.py +++ /dev/null @@ -1,38 +0,0 @@ -from anonstream.control.exceptions import Garbage - -async def command_help(args): - match args: - case []: - normal_options = [] - response = ( - 'Usage: METHOD [COMMAND | help]\n' - 'Examples:\n' - ' help...........................show this help message\n' - ' exit...........................close the control connection\n' - ' title [show]...................show the stream title\n' - ' title set TITLE................set the stream title\n' - ' user [show]....................show a list of users\n' - ' user attr USER.................set an attribute of a user\n' - ' user get USER ATTR.............set an attribute of a user\n' - ' user set USER ATTR VALUE.......set an attribute of a user\n' - #' user kick USERS [FAREWELL].....kick users\n' - ' user eyes USER [show]..........show a list of active video responses\n' - ' user eyes USER delete EYES_ID..end a video response\n' - #' chat show MESSAGES.............show a list of messages\n' - ' chat delete SEQS...............delete a set of messages\n' - ) - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_help_help(args): - match args: - case []: - normal_options = ['help'] - response = ( - 'Usage: help\n' - 'show usage syntax and examples\n' - ) - case [*garbage]: - raise Garbage(garbage) - return normal_options, response diff --git a/anonstream/control/commands/title.py b/anonstream/control/commands/title.py deleted file mode 100644 index 2f44ae7..0000000 --- a/anonstream/control/commands/title.py +++ /dev/null @@ -1,53 +0,0 @@ -import json - -from anonstream.control.exceptions import BadArgument, Incomplete, Garbage, Failed -from anonstream.control.utils import json_dumps_contiguous -from anonstream.stream import get_stream_title, set_stream_title - -async def command_title_help(args): - match args: - case []: - normal_options = ['help'] - response = ( - 'Usage: title [show | set TITLE]\n' - 'Commands:\n' - ' title [show].......show the stream title\n' - ' title set TITLE....set the stream title to TITLE\n' - 'Definitions:\n' - ' TITLE..............a json-encoded string, whitespace must be \\uXXXX-escaped\n' - ) - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_title_show(args): - match args: - case []: - normal_options = ['show'] - response = json.dumps(await get_stream_title()) + '\n' - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_title_set(args): - match args: - case [title_json]: - try: - title = json.loads(title_json) - except json.JSONDecodeError as e: - raise BadArgument('could not decode json') - else: - if not isinstance(title, str): - raise BadArgument('could not decode json as string') - else: - try: - await set_stream_title(title) - except OSError as e: - raise Failed(str(e)) from e - normal_options = ['set', json_dumps_contiguous(title)] - response = '' - case []: - raise Incomplete - case [_, *garbage]: - raise Garbage(garbage) - return normal_options, response diff --git a/anonstream/control/commands/user.py b/anonstream/control/commands/user.py deleted file mode 100644 index 580e8bf..0000000 --- a/anonstream/control/commands/user.py +++ /dev/null @@ -1,153 +0,0 @@ -import json - -from quart import current_app - -from anonstream.control.exceptions import BadArgument, Incomplete, Garbage, Failed -from anonstream.control.utils import json_dumps_contiguous -from anonstream.utils.user import USER_WEBSOCKET_ATTRS - -USERS_BY_TOKEN = current_app.users_by_token -USERS = current_app.users -USERS_UPDATE_BUFFER = current_app.users_update_buffer - -async def command_user_help(args): - match args: - case []: - normal_options = ['help'] - response = ( - 'Usage: user [show | attr USER | get USER ATTR | set USER ATTR VALUE]\n' - 'Commands:\n' - ' user [show].......................show all users\' tokens\n' - ' user attr USER....................show names of a user\'s attributes\n' - ' user get USER ATTR................show an attribute of a user\n' - ' user set USER ATTR................set an attribute of a user\n' - ' user eyes USER [show].............show a user\'s active video responses\n' - ' user eyes USER delete EYES_ID.....end a video response to a user\n' - 'Definitions:\n' - #' USER..............................={token TOKEN | hash HASH}\n' - ' USER..............................=token TOKEN\n' - ' TOKEN..............................a token\n' - #' HASH..............................a token hash\n' - ' ATTR...............................a user attribute, re:[a-z0-9_]+\n' - ' EYES_ID............................a user\'s eyes_id, base 10 integer\n' - ) - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_user_show(args): - match args: - case []: - normal_options = ['show'] - response = json.dumps(tuple(USERS_BY_TOKEN)) + '\n' - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_user_attr(args): - match args: - case []: - raise Incomplete - case ['token', token_json]: - try: - token = json.loads(token_json) - except json.JSONDecodeError: - raise BadArgument('could not decode TOKEN as json') - try: - user = USERS_BY_TOKEN[token] - except KeyError: - raise Failed(f"no user exists with token {token!r}, try 'user show'") - normal_options = ['attr', 'token', json_dumps_contiguous(token)] - response = json.dumps(tuple(user.keys())) + '\n' - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_user_get(args): - match args: - case ['token', token_json, attr]: - try: - token = json.loads(token_json) - except json.JSONDecodeError: - raise BadArgument('could not decode TOKEN as json') - try: - user = USERS_BY_TOKEN[token] - except KeyError: - raise Failed(f"no user exists with token {token!r}, try 'user show'") - try: - value = user[attr] - except KeyError: - raise Failed(f"user has no attribute {attr!r}, try 'user attr token {json_dumps_contiguous(token)}'") - try: - value_json = json.dumps(value) - except TypeError: - raise Failed(f'attribute {attr!r} is not JSON serializable') - normal_options = ['get', 'token', json_dumps_contiguous(token), attr] - response = value_json + '\n' - case []: - raise Incomplete - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_user_set(args): - match args: - case ['token', token_json, attr, value_json]: - try: - token = json.loads(token_json) - except json.JSONDecodeError: - raise BadArgument('could not decode TOKEN as json') - try: - user = USERS_BY_TOKEN[token] - except KeyError: - raise Failed(f"no user exists with token {token!r}, try 'user show'") - try: - value = user[attr] - except KeyError: - raise Failed(f"user has no attribute {attr!r}, try 'user attr token {json_dumps_contiguous(token)}") - try: - value = json.loads(value_json) - except json.JSONDecodeError: - raise Failed('could not decode json') - user[attr] = value - if attr in USER_WEBSOCKET_ATTRS: - USERS_UPDATE_BUFFER.add(token) - normal_options = ['set', 'token', json_dumps_contiguous(token), attr, json_dumps_contiguous(value)] - response = '' - case []: - raise Incomplete - case [*garbage]: - raise Garbage(garbage) - return normal_options, response - -async def command_user_eyes(args): - match args: - case ['token', token_json, *subargs]: - try: - token = json.loads(token_json) - except json.JSONDecodeError: - raise BadArgument('could not decode TOKEN as json') - try: - user = USERS_BY_TOKEN[token] - except KeyError: - raise Failed(f"no user exists with token {token!r}, try 'user show'") - match subargs: - case [] | ['show']: - normal_options = ['eyes', 'token', json_dumps_contiguous(token), 'show'] - response = json.dumps(user['eyes']['current']) + '\n' - case ['delete', eyes_id_json]: - try: - eyes_id = json.loads(eyes_id_json) - except json.JSONDecodeError: - raise BadArgument('could not decode EYES_ID as json') - try: - user['eyes']['current'].pop(eyes_id) - except KeyError: - pass - normal_options = ['eyes', 'token', json_dumps_contiguous(token), 'delete', json_dumps_contiguous(eyes_id)] - response = '' - case []: - raise Incomplete - case [*garbage]: - raise Garbage(garbage) - return normal_options, response diff --git a/anonstream/control/exceptions.py b/anonstream/control/exceptions.py index 8a71cb9..8b2a5a0 100644 --- a/anonstream/control/exceptions.py +++ b/anonstream/control/exceptions.py @@ -1,23 +1,5 @@ class Exit(Exception): pass -class UnknownMethod(Exception): - pass - -class UnknownCommand(Exception): - pass - -class UnknownArgument(Exception): - pass - -class BadArgument(Exception): - pass - -class Incomplete(Exception): - pass - -class Garbage(Exception): - pass - -class Failed(Exception): +class Fail(Exception): pass diff --git a/anonstream/control/parse.py b/anonstream/control/parse.py index ee5bb2c..78862fd 100644 --- a/anonstream/control/parse.py +++ b/anonstream/control/parse.py @@ -1,61 +1,41 @@ -import json +from anonstream.control.spec import NoParse, Ambiguous, Parsed +from anonstream.control.spec.common import Str +#from anonstream.control.spec.methods.chat import SPEC as SPEC_CHAT +from anonstream.control.spec.methods.exit import SPEC as SPEC_EXIT +from anonstream.control.spec.methods.help import SPEC as SPEC_HELP +#from anonstream.control.spec.methods.title import SPEC as SPEC_TITLE +#from anonstream.control.spec.methods.user import SPEC as SPEC_USER -from anonstream.control.exceptions import UnknownMethod, UnknownCommand, BadArgument, Incomplete, Garbage, Failed -from anonstream.control.commands import METHOD_COMMAND_FUNCTIONS +SPEC = Str({ + 'help': SPEC_HELP, + 'exit': SPEC_EXIT, + #'title': SPEC_TITLE, + #'chat': SPEC_CHAT, + #'user': SPEC_USER, +}) -async def parse_request(request): - try: - method, *options = request.split() - except ValueError: - normal, response = (None, []), '' +async def parse(request): + words = request.split() + if not words: + normal, response = None, '' else: - try: - normal, response = await parse(method, options) - except UnknownMethod as e: - unknown_method, *_ = e.args - normal = None - response = f"method {unknown_method!r} is unknown, try 'help'\n" - except UnknownCommand as e: - method, unknown_command, *_ = e.args - normal = None - response = f"command {unknown_command!r} is unknown, try {f'{method} help'!r}\n" - except BadArgument as e: - reason, *_ = e.args - normal = None - response = f"{reason}, try {f'{method} help'!r}\n" - except Incomplete as e: - method, *_ = e.args - normal = None - response = f"command is incomplete, try {f'{method} help'!r}\n" - except Garbage as e: - garbage, *_ = e.args - normal = None - response = f"command has trailing garbage {garbage!r}, try {f'{method} help'!r}\n" - except Failed as e: - reason, *_ = e.args - normal = None - response = reason + '\n' - return normal, response - -async def parse(method, options): - try: - command, *args = options - except ValueError: - command, args = None, [] - try: - functions = METHOD_COMMAND_FUNCTIONS[method] - except KeyError: - raise UnknownMethod(method) - else: - normal_method = method - try: - fn = functions[command] - except KeyError: - raise UnknownCommand(method, command) - else: + spec = SPEC + index = 0 + args = [] + while True: try: - normal_options, response = await fn(args) - except Incomplete as e: - raise Incomplete(method) from e - normal = (normal_method, normal_options) + spec, n_consumed, more_args = spec.consume(words, index) + except NoParse as e: + normal, response = None, e.args[0] + '\n' + break + except Ambiguous as e: + normal, response = None, e.args[0] + '\n' + break + except Parsed as e: + fn, *_ = e.args + normal, response = await fn(*args) + break + else: + index += n_consumed + args.extend(more_args) return normal, response diff --git a/anonstream/control/server.py b/anonstream/control/server.py index 359fa7d..2c10bdc 100644 --- a/anonstream/control/server.py +++ b/anonstream/control/server.py @@ -1,7 +1,7 @@ import asyncio from anonstream.control.exceptions import Exit -from anonstream.control.parse import parse_request +from anonstream.control.parse import parse def start_control_server_at(address): return asyncio.start_unix_server(serve_control_client, address) @@ -14,19 +14,17 @@ async def serve_control_client(reader, writer): normal, response = None, str(e) else: try: - normal, response = await parse_request(request) + normal, response = await parse(request) except Exit: writer.close() break if normal is not None: - normal_method, normal_options = normal - if normal_method is not None: - writer.write(normal_method.encode()) - for arg in normal_options: + for index, word in enumerate(normal): + if index > 0: writer.write(b' ') - writer.write(arg.encode()) - writer.write(b'\n') + writer.write(word.encode()) + writer.write(b'\n') elif response: writer.write(b'error: ') diff --git a/anonstream/control/spec/__init__.py b/anonstream/control/spec/__init__.py new file mode 100644 index 0000000..34e02a2 --- /dev/null +++ b/anonstream/control/spec/__init__.py @@ -0,0 +1,12 @@ +class NoParse(Exception): + pass + +class Ambiguous(Exception): + pass + +class Parsed(Exception): + pass + +class Spec: + def consume(self, words, index): + raise NotImplemented diff --git a/anonstream/control/spec/common.py b/anonstream/control/spec/common.py new file mode 100644 index 0000000..f0b339c --- /dev/null +++ b/anonstream/control/spec/common.py @@ -0,0 +1,40 @@ +from anonstream.control.spec import Spec, NoParse, Ambiguous, Parsed +from anonstream.control.spec.utils import get_item, startswith + +class Str(Spec): + def __init__(self, directives): + self.directives = directives + + def consume(self, words, index): + word = get_item(index, words) + candidates = tuple(filter( + lambda directive: startswith(directive, word), + self.directives, + )) + try: + directive = candidates[0] + except IndexError as e: + if word is None: + reason = f'incomplete: expected one of {set(self.directives)}' + else: + reason = ( + f'bad word at position {index}: ' + f'expected one of {set(self.directives)}, found {word!r}' + ) + raise NoParse(reason) from e + else: + if len(candidates) > 1: + raise Ambiguous( + f'bad word at position {index}: cannot unambiguously ' + f'match {word!r} against {set(self.directives)}' + ) + return self.directives[directive], 1, [] + +class End(Spec): + def __init__(self, fn): + self.fn = fn + + def consume(self, words, index): + if len(words) <= index: + raise Parsed(self.fn) + raise NoParse(f'garbage at position {index}: {words[index:]!r}') diff --git a/anonstream/control/spec/methods/exit.py b/anonstream/control/spec/methods/exit.py new file mode 100644 index 0000000..2b57dda --- /dev/null +++ b/anonstream/control/spec/methods/exit.py @@ -0,0 +1,18 @@ +from anonstream.control.spec.common import Str, End +from anonstream.control.exceptions import Exit + +async def cmd_exit(): + raise Exit + +async def cmd_exit_help(): + normal = ['exit', 'help'] + response = ( + 'Usage: exit\n' + 'close the connection\n' + ) + return normal, response + +SPEC = Str({ + None: End(cmd_exit), + 'help': End(cmd_exit_help), +}) diff --git a/anonstream/control/spec/methods/help.py b/anonstream/control/spec/methods/help.py new file mode 100644 index 0000000..25f6367 --- /dev/null +++ b/anonstream/control/spec/methods/help.py @@ -0,0 +1,35 @@ +from anonstream.control.spec.common import Str, End + +async def cmd_help(): + normal = ['help'] + response = ( + 'Usage: METHOD [COMMAND | help]\n' + 'Examples:\n' + ' help...........................show this help message\n' + ' exit...........................close the control connection\n' + ' title [show]...................show the stream title\n' + ' title set TITLE................set the stream title\n' + ' user [show]....................show a list of users\n' + ' user attr USER.................set an attribute of a user\n' + ' user get USER ATTR.............set an attribute of a user\n' + ' user set USER ATTR VALUE.......set an attribute of a user\n' + #' user kick USERS [FAREWELL].....kick users\n' + ' user eyes USER [show]..........show a list of active video responses\n' + ' user eyes USER delete EYES_ID..end a video response\n' + #' chat show MESSAGES.............show a list of messages\n' + ' chat delete SEQS...............delete a set of messages\n' + ) + return normal, response + +async def cmd_help_help(): + normal = ['help', 'help'] + response = ( + 'Usage: help\n' + 'show usage syntax and examples\n' + ) + return normal, response + +SPEC = Str({ + None: End(cmd_help), + 'help': End(cmd_help_help), +}) diff --git a/anonstream/control/spec/utils.py b/anonstream/control/spec/utils.py new file mode 100644 index 0000000..cc2f2c3 --- /dev/null +++ b/anonstream/control/spec/utils.py @@ -0,0 +1,14 @@ +def get_item(index, words): + try: + word = words[index] + except IndexError: + word = None + else: + if not word: + raise NoParse(f'empty word at position {index}') + return word + +def startswith(string, prefix): + if string is None or prefix is None: + return string is prefix + return string.startswith(prefix)