From 5d3931f27d4d1febd18c676a6e2586426da8d54d Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Sun, 24 Jun 2018 20:11:08 -0700 Subject: [PATCH 01/18] add run forever suport --- auroraapi/dialog/dialog.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/auroraapi/dialog/dialog.py b/auroraapi/dialog/dialog.py index 94549de..45a71d6 100644 --- a/auroraapi/dialog/dialog.py +++ b/auroraapi/dialog/dialog.py @@ -5,11 +5,12 @@ from auroraapi.dialog.util import assert_callable, parse_date class DialogProperties(object): - def __init__(self, id, name, desc, appId, dateCreated, dateModified, graph, **kwargs): + def __init__(self, id, name, desc, appId, runForever, dateCreated, dateModified, graph, **kwargs): self.id = id self.name = name self.desc = desc self.app_id = appId + self.run_forever = runForever self.date_created = parse_date(dateCreated) self.date_modified = parse_date(dateModified) self.graph = Graph(graph) @@ -27,9 +28,12 @@ def set_function(self, id, func): self.context.udfs[id] = func def run(self): - curr = self.dialog.graph.start - while curr != None and curr in self.dialog.graph.nodes: - step = self.dialog.graph.nodes[curr] - edge = self.dialog.graph.edges[curr] - self.context.set_current_step(step) - curr = step.execute(self.context, edge) + first_run = True + while first_run or self.run_forever: + curr = self.dialog.graph.start + while curr != None and curr in self.dialog.graph.nodes: + step = self.dialog.graph.nodes[curr] + edge = self.dialog.graph.edges[curr] + self.context.set_current_step(step) + curr = step.execute(self.context, edge) + first_run = False From d69b360d6043af204a5ff78d917b71095b57f88d Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Sun, 24 Jun 2018 22:05:52 -0700 Subject: [PATCH 02/18] added backend abstraction, dialog naming changes --- auroraapi/api.py | 72 ------------------------------- auroraapi/api/__init__.py | 40 +++++++++++++++++ auroraapi/api/backend/__init__.py | 38 ++++++++++++++++ auroraapi/api/backend/aurora.py | 35 +++++++++++++++ auroraapi/api/backend/mock.py | 25 +++++++++++ auroraapi/dialog/context.py | 18 ++++---- auroraapi/dialog/dialog.py | 4 +- auroraapi/dialog/step/listen.py | 2 +- auroraapi/dialog/step/speech.py | 6 +-- auroraapi/dialog/step/udf.py | 3 +- auroraapi/errors.py | 20 +++++++++ auroraapi/globals.py | 11 +++-- auroraapi/speech.py | 5 ++- auroraapi/text.py | 5 ++- 14 files changed, 189 insertions(+), 95 deletions(-) delete mode 100644 auroraapi/api.py create mode 100644 auroraapi/api/__init__.py create mode 100644 auroraapi/api/backend/__init__.py create mode 100644 auroraapi/api/backend/aurora.py create mode 100644 auroraapi/api/backend/mock.py create mode 100644 auroraapi/errors.py diff --git a/auroraapi/api.py b/auroraapi/api.py deleted file mode 100644 index 625004b..0000000 --- a/auroraapi/api.py +++ /dev/null @@ -1,72 +0,0 @@ -import requests, functools, json, inspect -from auroraapi.globals import _config -from auroraapi.audio import AudioFile - -BASE_URL = "https://api.auroraapi.com" -# BASE_URL = "http://localhost:3000" -TTS_URL = BASE_URL + "/v1/tts/" -STT_URL = BASE_URL + "/v1/stt/" -INTERPRET_URL = BASE_URL + "/v1/interpret/" -DIALOG_URL = BASE_URL + "/v1/dialog/" - -class APIException(Exception): - """ Raise an exception when querying the API """ - def __init__(self, id=None, status=None, code=None, type=None, message=None): - self.id = id - self.status = status - self.code = code - self.type = type - self.message = message - super(APIException, self).__init__("[{}] {}".format(code if code != None else status, message)) - - def __repr__(self): - return json.dumps({ - "id": self.id, - "status": self.status, - "code": self.code, - "type": self.type, - "message": self.message - }, indent=2) - -def get_headers(): - return { - "X-Application-ID": _config.app_id, - "X-Application-Token": _config.app_token, - "X-Device-ID": _config.device_id, - } - -def handle_error(r): - if r.status_code != requests.codes.ok: - if "application/json" in r.headers["content-type"]: - raise APIException(**r.json()) - if r.status_code == 413: - raise APIException(code="RequestEntityTooLarge", message="Request entity too large", status=413, type="RequestEntityTooLarge") - raise APIException(message=r.text, status=r.status_code) - -def get_tts(text): - r = requests.get(TTS_URL, params=[("text", text)], headers=get_headers(), stream=True) - handle_error(r) - - r.raw.read = functools.partial(r.raw.read, decode_content=True) - return AudioFile(r.raw.read()) - -def get_interpret(text, model): - r = requests.get(INTERPRET_URL, params=[("text", text), ("model", model)], headers=get_headers()) - handle_error(r) - return r.json() - -def get_stt(audio, stream=False): - # audio can either be an AudioFile (in case the all of the data is known) or - # it can be a generator function, which emits data as it gets known. We need - # to modify the request based on whether stream is True, in which case we assume - # that audio is a generator function - - d = audio() if stream else audio.get_wav() - r = requests.post(STT_URL, data=d, headers=get_headers()) - handle_error(r) - return r.json() - -def get_dialog(id): - r = requests.get(DIALOG_URL + id, headers=get_headers()) - handle_error(r) - return r.json() diff --git a/auroraapi/api/__init__.py b/auroraapi/api/__init__.py new file mode 100644 index 0000000..cb1b30b --- /dev/null +++ b/auroraapi/api/__init__.py @@ -0,0 +1,40 @@ +from auroraapi.api.backend import CallParams, Credentials +from auroraapi.audio import AudioFile + +TTS_URL = "/v1/tts/" +STT_URL = "/v1/stt/" +INTERPRET_URL = "/v1/interpret/" +DIALOG_URL = "/v1/dialog/" + +def get_tts(config, text): + return AudioFile(config.backend.call(CallParams( + path=TTS_URL, + credentials=Credentials.from_config(config), + query={ "text": text }, + chunked=True, + response_type="raw", + ))) + +def get_interpret(config, text, model): + return config.backend.call(CallParams( + path=INTERPRET_URL, + credentials=Credentials.from_config(config), + query={ "text": text, "model": model }, + )) + +def get_stt(config, audio, stream=False): + return config.backend.call(CallParams( + path=STT_URL, + credentials=Credentials.from_config(config), + # audio can either be an AudioFile (in case all of the data is known) or + # it can be a generator function, which emits data as it gets known. We need + # to modify the request based on whether stream is True, in which case we assume + # that audio is a generator function + body=(audio() if stream else audio.get_wav()) + )) + +def get_dialog(config, id): + return config.backend.call(CallParams( + path=DIALOG_URL + id, + credentials=Credentials.from_config(config), + )) \ No newline at end of file diff --git a/auroraapi/api/backend/__init__.py b/auroraapi/api/backend/__init__.py new file mode 100644 index 0000000..c653146 --- /dev/null +++ b/auroraapi/api/backend/__init__.py @@ -0,0 +1,38 @@ +class Credentials(object): + def __init__(self, app_id=None, app_token=None, device_id=None): + self.app_id = app_id + self.app_token = app_token + self.device_id = device_id + + @property + def headers(self): + return { + "X-Application-ID": self.app_id, + "X-Application-Token": self.app_token, + "X-Device-ID": self.device_id, + } + + @staticmethod + def from_config(c): + return Credentials(c.app_id, c.app_token, c.device_id) + +class CallParams(object): + def __init__(self, method="GET", path="/", credentials=Credentials(), + headers={}, query={}, body=None, chunked=False, + response_type="json"): + self.method = method + self.path = path + self.credentials = credentials + self.headers = headers + self.query = query + self.body = body + self.chunked = chunked + self.response_type = response_type + +class Backend(object): + def __init__(self, base_url, timeout=60000): + self.base_url = base_url + self.timeout = timeout + + def call(self, params): + raise NotImplementedError() diff --git a/auroraapi/api/backend/aurora.py b/auroraapi/api/backend/aurora.py new file mode 100644 index 0000000..23ae413 --- /dev/null +++ b/auroraapi/api/backend/aurora.py @@ -0,0 +1,35 @@ +import requests, functools, json, inspect +from auroraapi.api.backend import Backend +from auroraapi.errors import APIException + +BASE_URL = "https://api.auroraapi.com/v1" + +class AuroraBackend(Backend): + def __init__(self, base_url=BASE_URL): + super().__init__(base_url) + + def call(self, params): + # TODO: programatically use method other than GET + r = requests.get( + self.base_url + params.path, + data=params.body, + stream=params.chunked, + params=params.query.items(), + headers={ **params.credentials.headers, **params.headers }, + ) + + if r.status_code != requests.codes.ok: + if "application/json" in r.headers["content-type"]: + raise APIException(**r.json()) + # Handle the special case in case nginx doesn't return a JSON response for 413 + if r.status_code == 413: + raise APIException(code="RequestEntityTooLarge", message="Request entity too large", status=413, type="RequestEntityTooLarge") + # A non JSON error occurred (very strange) + raise APIException(message=r.text, status=r.status_code) + + if params.response_type == "json": + return r.json() + if params.response_type == "raw": + r.raw.read = functools.partial(r.raw.read, decode_content=True) + return r.raw.read() + return r.text diff --git a/auroraapi/api/backend/mock.py b/auroraapi/api/backend/mock.py new file mode 100644 index 0000000..a4184b9 --- /dev/null +++ b/auroraapi/api/backend/mock.py @@ -0,0 +1,25 @@ +import requests +from auroraapi.api.backend import Backend +from auroraapi.errors import APIException + +BASE_URL = "https://api.auroraapi.com/v1" + +class MockBackend(Backend): + def __init__(self): + self.response_code = 200 + self.response_data = "" + self.called_params = {} + + def set_expected_response(self, code, data): + self.response_code = code + self.response_data = data + + def call(self, params): + self.called_params = params + if self.response_code != requests.codes.ok: + try: + e = APIException(**self.response_data) + except: + e = APIException(message=self.response_data, status=self.response_code) + raise e + return self.response_data diff --git a/auroraapi/dialog/context.py b/auroraapi/dialog/context.py index 4b162af..6cd7990 100644 --- a/auroraapi/dialog/context.py +++ b/auroraapi/dialog/context.py @@ -1,30 +1,32 @@ class DialogContext(object): def __init__(self, on_update = lambda ctx: None): - self.step = {} + self.steps = {} self.user = {} self.udfs = {} + self.previous_step = None self.current_step = None self.on_update = on_update - def set_step_data(self, key, value): - self.step[key] = value + def set_step(self, key, value): + self.steps[key] = value self.on_update(self) - def get_step_data(self, key, default=None): - if not key in self.step: + def get_step(self, key, default=None): + if not key in self.steps: return default - return self.step[key] + return self.steps[key] - def set_user_data(self, key, value): + def set_data(self, key, value): self.user[key] = value self.on_update(self) - def get_user_data(self, key, default=None): + def get_data(self, key, default=None): if not key in self.user: return default return self.user[key] def set_current_step(self, step): + self.previous_step = self.current_step self.current_step = step self.on_update(self) diff --git a/auroraapi/dialog/dialog.py b/auroraapi/dialog/dialog.py index 45a71d6..a36516c 100644 --- a/auroraapi/dialog/dialog.py +++ b/auroraapi/dialog/dialog.py @@ -1,8 +1,8 @@ -import json from auroraapi.api import get_dialog from auroraapi.dialog.context import DialogContext from auroraapi.dialog.graph import Graph from auroraapi.dialog.util import assert_callable, parse_date +from auroraapi.globals import _config class DialogProperties(object): def __init__(self, id, name, desc, appId, runForever, dateCreated, dateModified, graph, **kwargs): @@ -17,7 +17,7 @@ def __init__(self, id, name, desc, appId, runForever, dateCreated, dateModified, class Dialog(object): def __init__(self, id, on_context_update=None): - self.dialog = DialogProperties(**get_dialog(id)["dialog"]) + self.dialog = DialogProperties(**get_dialog(_config, id)["dialog"]) self.context = DialogContext() if on_context_update != None: assert_callable(on_context_update, "The 'on_context_update' parameter must be a function that accepts one argument") diff --git a/auroraapi/dialog/step/listen.py b/auroraapi/dialog/step/listen.py index 5be4739..89c6078 100644 --- a/auroraapi/dialog/step/listen.py +++ b/auroraapi/dialog/step/listen.py @@ -21,5 +21,5 @@ def execute(self, context, edge): while len(text.text) == 0: text = listen_and_transcribe(**self.listen_settings) res = text.interpret(model=self.model) if self.interpret else text - context.set_step_data(self.step_name, res) + context.set_step(self.step_name, res) return edge.next() diff --git a/auroraapi/dialog/step/speech.py b/auroraapi/dialog/step/speech.py index 8f52f6d..9635bf2 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -8,8 +8,8 @@ def resolve_path(context, path): obj = None if step == "user": obj = context.user - elif step in context.step: - obj = context.step[step].context_dict() + elif step in context.steps: + obj = context.steps[step].context_dict() if not is_iterable(obj): return None @@ -39,6 +39,6 @@ def execute(self, context, edge): text = functools.reduce(lambda t, r: t.replace(r["original"], r["replacement"]), replacements, self.text) sp = Text(text).speech() - context.set_step_data(self.step_name, sp) + context.set_step(self.step_name, sp) sp.audio.play() return edge.next() diff --git a/auroraapi/dialog/step/udf.py b/auroraapi/dialog/step/udf.py index 6b2fc16..4e78ed2 100644 --- a/auroraapi/dialog/step/udf.py +++ b/auroraapi/dialog/step/udf.py @@ -16,8 +16,9 @@ def __init__(self, step): def execute(self, context, edge): if not self.udf_id in context.udfs: raise RuntimeError("No UDF registered for step '{}'".format(self.udf_id)) + val = context.udfs[self.udf_id](context) - context.set_step_data(self.udf_id, UDFResponse(val)) + context.set_step(self.udf_id, UDFResponse(val)) if isinstance(val, (int, bool)) and self.branch_enable: return edge.next_cond(val) return edge.next() diff --git a/auroraapi/errors.py b/auroraapi/errors.py new file mode 100644 index 0000000..9cca2dc --- /dev/null +++ b/auroraapi/errors.py @@ -0,0 +1,20 @@ +import json + +class APIException(Exception): + """ Raise an exception when querying the API """ + def __init__(self, id=None, status=None, code=None, type=None, message=None): + self.id = id + self.status = status + self.code = code + self.type = type + self.message = message + super(APIException, self).__init__("[{}] {}".format(code if code != None else status, message)) + + def __repr__(self): + return json.dumps({ + "id": self.id, + "status": self.status, + "code": self.code, + "type": self.type, + "message": self.message + }, indent=2) diff --git a/auroraapi/globals.py b/auroraapi/globals.py index b259d03..5460b0a 100644 --- a/auroraapi/globals.py +++ b/auroraapi/globals.py @@ -1,7 +1,10 @@ +from auroraapi.api.backend.aurora import AuroraBackend + class Config(object): - def __init__(self): - self.app_id = None - self.app_token = None - self.device_id = None + def __init__(self, app_id=None, app_token=None, device_id=None, backend=AuroraBackend()): + self.app_id = app_id + self.app_token = app_token + self.device_id = device_id + self.backend = backend _config = Config() \ No newline at end of file diff --git a/auroraapi/speech.py b/auroraapi/speech.py index 597e959..93b3ea9 100644 --- a/auroraapi/speech.py +++ b/auroraapi/speech.py @@ -1,6 +1,7 @@ import functools from auroraapi.audio import AudioFile, record, stream from auroraapi.api import get_stt +from auroraapi.globals import _config ########################################################### ## Speech to Text ## @@ -30,7 +31,7 @@ def __init__(self, audio): def text(self): """ Convert speech to text and get the prediction """ from auroraapi.text import Text - return Text(get_stt(self.audio)["transcript"]) + return Text(get_stt(_config, self.audio)["transcript"]) def context_dict(self): return {} @@ -74,7 +75,7 @@ def listen_and_transcribe(length=0, silence_len=0.5): :type silence_len float """ from auroraapi.text import Text - return Text(get_stt(functools.partial(stream, length, silence_len), stream=True)["transcript"]) + return Text(get_stt(_config, functools.partial(stream, length, silence_len), stream=True)["transcript"]) def continuously_listen_and_transcribe(length=0, silence_len=0.5): """ diff --git a/auroraapi/text.py b/auroraapi/text.py index e3bfc32..5cbc7bf 100644 --- a/auroraapi/text.py +++ b/auroraapi/text.py @@ -1,4 +1,5 @@ from auroraapi.api import get_tts, get_interpret +from auroraapi.globals import _config ########################################################### ## Text to Speech ## @@ -25,12 +26,12 @@ def __repr__(self): def speech(self): """ Convert text to speech """ from auroraapi.speech import Speech - return Speech(get_tts(self.text)) + return Speech(get_tts(_config, self.text)) def interpret(self, model="general"): """ Interpret the text and return the results """ from auroraapi.interpret import Interpret - return Interpret(get_interpret(self.text, model)) + return Interpret(get_interpret(_config, self.text, model)) def context_dict(self): return { "text": self.text } From d4696ea59a65f833aab2ebda68867e061e4d5686 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Tue, 26 Jun 2018 21:56:18 +0530 Subject: [PATCH 03/18] fix timeout unit, add http method parameterization, reorganize mocks --- auroraapi/api/backend/__init__.py | 2 +- auroraapi/api/backend/aurora.py | 5 +- tests/mocks/__init__.py | 2 + tests/mocks/audio.py | 47 +++++++++++++++++++ .../backend/mock.py => tests/mocks/backend.py | 5 +- 5 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 tests/mocks/__init__.py create mode 100644 tests/mocks/audio.py rename auroraapi/api/backend/mock.py => tests/mocks/backend.py (84%) diff --git a/auroraapi/api/backend/__init__.py b/auroraapi/api/backend/__init__.py index c653146..f819026 100644 --- a/auroraapi/api/backend/__init__.py +++ b/auroraapi/api/backend/__init__.py @@ -30,7 +30,7 @@ def __init__(self, method="GET", path="/", credentials=Credentials(), self.response_type = response_type class Backend(object): - def __init__(self, base_url, timeout=60000): + def __init__(self, base_url, timeout=60): self.base_url = base_url self.timeout = timeout diff --git a/auroraapi/api/backend/aurora.py b/auroraapi/api/backend/aurora.py index 23ae413..4b22c98 100644 --- a/auroraapi/api/backend/aurora.py +++ b/auroraapi/api/backend/aurora.py @@ -9,13 +9,14 @@ def __init__(self, base_url=BASE_URL): super().__init__(base_url) def call(self, params): - # TODO: programatically use method other than GET - r = requests.get( + r = requests.request( + params.method, self.base_url + params.path, data=params.body, stream=params.chunked, params=params.query.items(), headers={ **params.credentials.headers, **params.headers }, + timeout=self.timeout ) if r.status_code != requests.codes.ok: diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py new file mode 100644 index 0000000..bffbc4f --- /dev/null +++ b/tests/mocks/__init__.py @@ -0,0 +1,2 @@ +from tests.mocks.audio import * +from tests.mocks.backend import * \ No newline at end of file diff --git a/tests/mocks/audio.py b/tests/mocks/audio.py new file mode 100644 index 0000000..10fbff1 --- /dev/null +++ b/tests/mocks/audio.py @@ -0,0 +1,47 @@ +import array, time, random +from auroraapi.audio import AudioFile + +def mock_pyaudio_record(a, b): + with open("tests/assets/hw.wav", "rb") as f: + yield array.array('h', AudioFile(f.read()).audio.raw_data) + +class MockStream(object): + data = [] + start_loud = True + read_mode = "silent" + + def write(self, d): + MockStream.data.extend(d) + # simulate some delay in writing the stream + time.sleep(0.01) + return len(d) + + def read(self, size, **kwargs): + if MockStream.start_loud: + MockStream.start_loud = False + return [random.randint(2048, 8192) for i in range(size)] + if MockStream.read_mode == "silent": + return [random.randint(0, 1023) for i in range(size)] + if MockStream.read_mode == "random": + return [random.randint(0, 4096) for i in range(size)] + if MockStream.read_mode == "loud": + return [random.randint(2048, 8192) for i in range(size)] + return [] + + def stop_stream(self): + return + + def close(self): + return + + @staticmethod + def reset_data(): + MockStream.data = [] + +class MockPyAudio(object): + def open(self, **kwargs): + return MockStream() + def get_format_from_width(self, width): + return width + def terminate(self): + return diff --git a/auroraapi/api/backend/mock.py b/tests/mocks/backend.py similarity index 84% rename from auroraapi/api/backend/mock.py rename to tests/mocks/backend.py index a4184b9..8838af3 100644 --- a/auroraapi/api/backend/mock.py +++ b/tests/mocks/backend.py @@ -1,9 +1,6 @@ -import requests from auroraapi.api.backend import Backend from auroraapi.errors import APIException -BASE_URL = "https://api.auroraapi.com/v1" - class MockBackend(Backend): def __init__(self): self.response_code = 200 @@ -16,7 +13,7 @@ def set_expected_response(self, code, data): def call(self, params): self.called_params = params - if self.response_code != requests.codes.ok: + if self.response_code != 200: try: e = APIException(**self.response_data) except: From 6f97aa2e4cb6e52d141f6865e98fee5a4654ed1b Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Tue, 26 Jun 2018 21:56:57 +0530 Subject: [PATCH 04/18] fix minor bugs, make context path resolution more comprehensive --- auroraapi/dialog/context.py | 3 +++ auroraapi/dialog/dialog.py | 2 +- auroraapi/dialog/step/speech.py | 31 +++++++++++++++++++++---------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/auroraapi/dialog/context.py b/auroraapi/dialog/context.py index 6cd7990..44538b0 100644 --- a/auroraapi/dialog/context.py +++ b/auroraapi/dialog/context.py @@ -32,3 +32,6 @@ def set_current_step(self, step): def get_current_step(self): return self.current_step + + def get_previous_step(self): + return self.previous_step \ No newline at end of file diff --git a/auroraapi/dialog/dialog.py b/auroraapi/dialog/dialog.py index a36516c..146ef9a 100644 --- a/auroraapi/dialog/dialog.py +++ b/auroraapi/dialog/dialog.py @@ -29,7 +29,7 @@ def set_function(self, id, func): def run(self): first_run = True - while first_run or self.run_forever: + while first_run or self.dialog.run_forever: curr = self.dialog.graph.start while curr != None and curr in self.dialog.graph.nodes: step = self.dialog.graph.nodes[curr] diff --git a/auroraapi/dialog/step/speech.py b/auroraapi/dialog/step/speech.py index 9635bf2..f170447 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -1,7 +1,8 @@ -import re, functools +import re +from functools import reduce from auroraapi.text import Text from auroraapi.dialog.step.step import Step -from auroraapi.dialog.util import is_iterable +from auroraapi.dialog.util import is_iterable, parse_optional def resolve_path(context, path): step, *components = path.split(".") @@ -15,9 +16,18 @@ def resolve_path(context, path): while len(components) > 0: curr = components.pop(0) - # TODO: handle arrays - if not is_iterable(obj) or curr not in obj: + # Check if current path object is iterable + if not is_iterable(obj): return None + # Check if the obj is a dict and does not have the given key in it + if isinstance(obj, dict) and curr not in obj: + return None + # Check if the object is a list and if the key is an integer + if isinstance(obj, list) and parse_optional(curr, int, None) != None: + curr = int(curr) + # Check if the object type is a list, the key is an int, but is out of bounds + if curr >= len(obj): + return None obj = obj[curr] return obj @@ -27,18 +37,19 @@ def __init__(self, step): super().__init__(step) self.text = step["data"]["text"] self.step_name = step["data"]["stepName"] - - def execute(self, context, edge): + + def get_text(self): # upon execution, first find all templates and replace them with # the collected value in the conversation replacements = [] for match in re.finditer(r'(\${(.+?)})', self.text): val = resolve_path(context, match.group(2)) - # TODO: do something on data not found - replacements.append({ "original": match.group(1), "replacement": val }) + # TODO: do something if val not found + replacements.append((match.group(1), val)) + return reduce(lambda t, r: t.replace(r[0], r[1]), replacements, self.text) - text = functools.reduce(lambda t, r: t.replace(r["original"], r["replacement"]), replacements, self.text) - sp = Text(text).speech() + def execute(self, context, edge): + sp = Text(self.get_text()).speech() context.set_step(self.step_name, sp) sp.audio.play() return edge.next() From 184ca67543e1346e62b1efa054942fb974f4a29c Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Wed, 27 Jun 2018 14:47:05 +0530 Subject: [PATCH 05/18] fix comment --- auroraapi/api/backend/aurora.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auroraapi/api/backend/aurora.py b/auroraapi/api/backend/aurora.py index 4b22c98..f1b5ab5 100644 --- a/auroraapi/api/backend/aurora.py +++ b/auroraapi/api/backend/aurora.py @@ -22,7 +22,7 @@ def call(self, params): if r.status_code != requests.codes.ok: if "application/json" in r.headers["content-type"]: raise APIException(**r.json()) - # Handle the special case in case nginx doesn't return a JSON response for 413 + # Handle the special case where nginx doesn't return a JSON response for 413 if r.status_code == 413: raise APIException(code="RequestEntityTooLarge", message="Request entity too large", status=413, type="RequestEntityTooLarge") # A non JSON error occurred (very strange) From cc72a8ad92762b82a94862bd64de043a478f1472 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Wed, 27 Jun 2018 14:47:50 +0530 Subject: [PATCH 06/18] fix context path resolution bug, add str support, convert non-str to str for templatized strings --- auroraapi/dialog/step/speech.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/auroraapi/dialog/step/speech.py b/auroraapi/dialog/step/speech.py index f170447..3733c6a 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -22,9 +22,12 @@ def resolve_path(context, path): # Check if the obj is a dict and does not have the given key in it if isinstance(obj, dict) and curr not in obj: return None - # Check if the object is a list and if the key is an integer - if isinstance(obj, list) and parse_optional(curr, int, None) != None: - curr = int(curr) + # Check if the object is a list or string + if isinstance(obj, (list, str)): + # If the object is a list, the key must be an integer + curr = parse_optional(curr, int, None) + if curr == None: + return None # Check if the object type is a list, the key is an int, but is out of bounds if curr >= len(obj): return None @@ -38,18 +41,18 @@ def __init__(self, step): self.text = step["data"]["text"] self.step_name = step["data"]["stepName"] - def get_text(self): + def get_text(self, context): # upon execution, first find all templates and replace them with # the collected value in the conversation replacements = [] for match in re.finditer(r'(\${(.+?)})', self.text): val = resolve_path(context, match.group(2)) # TODO: do something if val not found - replacements.append((match.group(1), val)) + replacements.append((match.group(1), str(val))) return reduce(lambda t, r: t.replace(r[0], r[1]), replacements, self.text) def execute(self, context, edge): - sp = Text(self.get_text()).speech() + sp = Text(self.get_text(context)).speech() context.set_step(self.step_name, sp) sp.audio.play() return edge.next() From 2de65cc81f7621f44a150d5329a1a8847a52d87f Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Wed, 27 Jun 2018 15:02:38 +0530 Subject: [PATCH 07/18] add POST method for stt call --- auroraapi/api/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/auroraapi/api/__init__.py b/auroraapi/api/__init__.py index cb1b30b..d0d60f5 100644 --- a/auroraapi/api/__init__.py +++ b/auroraapi/api/__init__.py @@ -1,10 +1,10 @@ from auroraapi.api.backend import CallParams, Credentials from auroraapi.audio import AudioFile -TTS_URL = "/v1/tts/" -STT_URL = "/v1/stt/" -INTERPRET_URL = "/v1/interpret/" DIALOG_URL = "/v1/dialog/" +INTERPRET_URL = "/v1/interpret/" +STT_URL = "/v1/stt/" +TTS_URL = "/v1/tts/" def get_tts(config, text): return AudioFile(config.backend.call(CallParams( @@ -25,6 +25,7 @@ def get_interpret(config, text, model): def get_stt(config, audio, stream=False): return config.backend.call(CallParams( path=STT_URL, + method="POST" credentials=Credentials.from_config(config), # audio can either be an AudioFile (in case all of the data is known) or # it can be a generator function, which emits data as it gets known. We need From e3ccf0bbf0bf2a832eae5e68e2445a6621038d82 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Wed, 27 Jun 2018 15:03:27 +0530 Subject: [PATCH 08/18] add tests for most functions (96% coverage) --- Makefile | 4 +- README.md | 14 ++- tests/api/backend/test___init__.py | 35 +++++++ tests/api/backend/test_aurora.py | 85 ++++++++++++++++ tests/api/backend/test_mock.py | 44 +++++++++ tests/assets/empty.wav | Bin 0 -> 44 bytes tests/assets/new-empty | Bin 0 -> 44 bytes tests/dialog/step/test_listen_step.py | 106 ++++++++++++++++++++ tests/dialog/step/test_speech_step.py | 137 ++++++++++++++++++++++++++ tests/dialog/step/test_step.py | 27 +++++ tests/dialog/step/test_udf_step.py | 64 ++++++++++++ tests/dialog/test_context.py | 60 +++++++++++ tests/dialog/test_dialog.py | 99 +++++++++++++++++++ tests/dialog/test_graph.py | 135 +++++++++++++++++++++++++ tests/dialog/test_util.py | 57 +++++++++++ tests/mocks.py | 47 --------- tests/mocks/backend.py | 22 ++--- tests/test_api.py | 107 -------------------- tests/test_errors.py | 29 ++++++ tests/test_globals.py | 8 +- tests/test_interpret.py | 24 +++-- tests/test_speech.py | 66 ++++++------- tests/test_text.py | 68 +++++-------- 23 files changed, 975 insertions(+), 263 deletions(-) create mode 100644 tests/api/backend/test___init__.py create mode 100644 tests/api/backend/test_aurora.py create mode 100644 tests/api/backend/test_mock.py create mode 100644 tests/assets/empty.wav create mode 100644 tests/assets/new-empty create mode 100644 tests/dialog/step/test_listen_step.py create mode 100644 tests/dialog/step/test_speech_step.py create mode 100644 tests/dialog/step/test_step.py create mode 100644 tests/dialog/step/test_udf_step.py create mode 100644 tests/dialog/test_context.py create mode 100644 tests/dialog/test_dialog.py create mode 100644 tests/dialog/test_graph.py create mode 100644 tests/dialog/test_util.py delete mode 100644 tests/mocks.py delete mode 100644 tests/test_api.py create mode 100644 tests/test_errors.py diff --git a/Makefile b/Makefile index 22c1076..315acb1 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,8 @@ test: python3 setup.py test clean: - find . -name "*.pyc" -delete - find . -name "__pycache__" -delete + find . -name "*.pyc" -exec rm -rf {} \+ + find . -name "__pycache__" -exec rm -rf {} \+ rm -rf .pytest_cache htmlcov .coverage clean-all: clean diff --git a/README.md b/README.md index 4f6a3e4..ae61909 100644 --- a/README.md +++ b/README.md @@ -254,9 +254,11 @@ from auroraapi.dialog import Dialog def udf(context): # get data for a particular step - data = context.get_step_data("step_id") + data = context.get_step("step_id") # set some custom data - context.set_user_data("id", "some data value") + context.set_data("id", "some data value") + # you can get the data later + assert context.get_data("id") == "some data value" # return True to take the upward branch in the dialog builder return True @@ -275,9 +277,11 @@ from auroraapi.dialog import Dialog def handle_update(context): # this function is called whenever the current step is changed or # whenever the data in the context is updated - # you can get the current dialog step like this - step = context.get_current_step() - print(step, context) + # + # you can get the current and previous dialog steps like this + curr = context.get_current_step() + prev = context.get_current_step() + print(curr, prev, context) dialog = Dialog("DIALOG_ID", on_context_update=handle_update) dialog.run() diff --git a/tests/api/backend/test___init__.py b/tests/api/backend/test___init__.py new file mode 100644 index 0000000..4b16c07 --- /dev/null +++ b/tests/api/backend/test___init__.py @@ -0,0 +1,35 @@ +import pytest +from auroraapi.globals import Config +from auroraapi.api.backend import CallParams, Credentials, Backend + +class TestCredentials(object): + def test_create(self): + c = Credentials("app_id", "app_token", "device_id") + assert c.app_id == "app_id" + assert c.app_token == "app_token" + assert c.device_id == "device_id" + + def test_headers(self): + c = Credentials("app_id", "app_token", "device_id") + assert len(c.headers) == 3 + assert c.headers["X-Application-ID"] == "app_id" + assert c.headers["X-Application-Token"] == "app_token" + assert c.headers["X-Device-ID"] == "device_id" + + def test_from_config(self): + config = Config("app_id", "app_token", "device_id") + c = Credentials.from_config(config) + assert c.app_id == "app_id" + assert c.app_token == "app_token" + assert c.device_id == "device_id" + +class TestBackend(object): + def test_create(self): + b = Backend("base_url", timeout=10000) + assert b.base_url == "base_url" + assert b.timeout == 10000 + + def test_call(self): + with pytest.raises(NotImplementedError): + b = Backend("base_url") + b.call(CallParams()) diff --git a/tests/api/backend/test_aurora.py b/tests/api/backend/test_aurora.py new file mode 100644 index 0000000..8e00867 --- /dev/null +++ b/tests/api/backend/test_aurora.py @@ -0,0 +1,85 @@ +import pytest +from auroraapi.api.backend import CallParams, Credentials, Backend +from auroraapi.api.backend.aurora import AuroraBackend +from auroraapi.errors import APIException + +class TestAuroraBackend(object): + pass + +# class MockResponse(object): +# def __init__(self): +# self.status_code = 200 +# self.headers = {} +# self.text = "" + +# def json(self): +# return json.loads(self.text) + + +# class TestAPIUtils(object): +# def setup(self): +# _config.app_id = "appid" +# _config.app_token = "apptoken" + +# def teardown(self): +# _config.app_id = None +# _config.app_token = None + +# def test_get_headers(self): +# h = get_headers() +# assert h["X-Application-ID"] == _config.app_id +# assert h["X-Application-Token"] == _config.app_token +# assert h["X-Device-ID"] == _config.device_id + +# def test_handle_error_no_error(self): +# handle_error(MockResponse()) + +# def test_handle_error_json(self): +# r = MockResponse() +# r.status_code = 400 +# r.headers = { "content-type": "application/json" } +# r.text = json.dumps({ +# "id": "id", +# "code": "MissingApplicationIDHeader", +# "type": "BadRequest", +# "status": 400, +# "message": "message" +# }) + +# with pytest.raises(APIException) as e: +# handle_error(r) +# assert e.id == "id" +# assert e.code == "MissingApplicationIDHeader" +# assert e.type == "BadRequest" +# assert e.status == 400 +# assert e.message == "message" + +# def test_handle_error_413(self): +# r = MockResponse() +# r.status_code = 413 +# r.headers["content-type"] = "text/html" +# r.text = "Request entity too large" + +# with pytest.raises(APIException) as e: +# handle_error(r) +# assert e.id == None +# assert e.status == 413 +# assert e.type == "RequestEntityTooLarge" +# assert e.code == "RequestEntityTooLarge" +# assert e.message == "Request entity too large" + +# def test_handle_error_other(self): +# r = MockResponse() +# r.status_code = 503 +# r.headers["content-type"] = "text/html" +# r.text = "Service unavailable" + + +# with pytest.raises(APIException) as e: +# handle_error(r) +# assert e.id == None +# assert e.status == 413 +# assert e.type == None +# assert e.code == None +# assert e.message == r.text +# assert str(e) == "[{}] {}".format(r.status_code, r.text) \ No newline at end of file diff --git a/tests/api/backend/test_mock.py b/tests/api/backend/test_mock.py new file mode 100644 index 0000000..bfd89f7 --- /dev/null +++ b/tests/api/backend/test_mock.py @@ -0,0 +1,44 @@ +import pytest +from auroraapi.api.backend import CallParams, Credentials, Backend +from auroraapi.errors import APIException +from tests.mocks.backend import MockBackend + +class TestMockBackend(object): + def test_create(self): + b = MockBackend() + assert b.responses == [] + + def test_call_success(self): + b = MockBackend() + b.set_expected_response(200, { "data": "value" }) + + r = b.call(CallParams()) + assert len(r) == 1 + assert r["data"] == "value" + + def test_call_failure_text(self): + b = MockBackend() + b.set_expected_response(400, "error") + + with pytest.raises(APIException) as e: + r = b.call(CallParams()) + assert e.status == 400 + assert e.code == None + assert e.message == "error" + + def test_call_failure_json(self): + b = MockBackend() + b.set_expected_response(400, { "code": "ErrorCode", "message": "error" }) + + with pytest.raises(APIException) as e: + r = b.call(CallParams()) + assert e.status == 400 + assert e.code == "ErrorCode" + assert e.message == "error" + + def test_call_multiple(self): + b = MockBackend() + b.set_expected_responses((200, "first"), (200, "second")) + + assert b.call(CallParams()) == "first" + assert b.call(CallParams()) == "second" diff --git a/tests/assets/empty.wav b/tests/assets/empty.wav new file mode 100644 index 0000000000000000000000000000000000000000..91be6ce2767714ee6db2b46f757f8db381045a20 GIT binary patch literal 44 vcmWIYbaS(s#lR5m80MOmTcRMqz`(!=gbj8;MlAya6N3OlN@7W(7*GuW%XbJr literal 0 HcmV?d00001 diff --git a/tests/assets/new-empty b/tests/assets/new-empty new file mode 100644 index 0000000000000000000000000000000000000000..7e88bbdca3f9b4505e5045f137e92a09af43dbf6 GIT binary patch literal 44 tcmWIYbaPW+U| 0 - - def test_speech_empty_string(self): - with pytest.raises(APIException): - Text("").speech() From 2d663831cdf4dc87f454d2efbeaa978616c09b00 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Wed, 27 Jun 2018 17:49:07 +0530 Subject: [PATCH 09/18] fix syntax error --- auroraapi/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auroraapi/api/__init__.py b/auroraapi/api/__init__.py index d0d60f5..2d7e4b8 100644 --- a/auroraapi/api/__init__.py +++ b/auroraapi/api/__init__.py @@ -25,7 +25,7 @@ def get_interpret(config, text, model): def get_stt(config, audio, stream=False): return config.backend.call(CallParams( path=STT_URL, - method="POST" + method="POST", credentials=Credentials.from_config(config), # audio can either be an AudioFile (in case all of the data is known) or # it can be a generator function, which emits data as it gets known. We need From 087c459550c29881de3654a32d45600c453cc061 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Wed, 27 Jun 2018 17:49:19 +0530 Subject: [PATCH 10/18] finish tests (99% coverage) --- tests/api/backend/test_aurora.py | 85 ++++++++- tests/assets/new-empty | Bin 44 -> 0 bytes tests/mocks/audio.py | 70 +++---- tests/mocks/requests.py | 28 +++ tests/test_audio.py | 314 +++++++++++++++---------------- tests/test_errors.py | 46 ++--- tests/test_globals.py | 24 +-- tests/test_interpret.py | 60 +++--- tests/test_speech.py | 138 +++++++------- tests/test_text.py | 82 ++++---- 10 files changed, 471 insertions(+), 376 deletions(-) delete mode 100644 tests/assets/new-empty create mode 100644 tests/mocks/requests.py diff --git a/tests/api/backend/test_aurora.py b/tests/api/backend/test_aurora.py index 8e00867..ecb60d7 100644 --- a/tests/api/backend/test_aurora.py +++ b/tests/api/backend/test_aurora.py @@ -1,17 +1,84 @@ -import pytest +import pytest, mock, json from auroraapi.api.backend import CallParams, Credentials, Backend from auroraapi.api.backend.aurora import AuroraBackend from auroraapi.errors import APIException +from tests.mocks.requests import request + + class TestAuroraBackend(object): - pass + def test_create(self): + b = AuroraBackend("base_url") + assert isinstance(b, AuroraBackend) + assert b.base_url == "base_url" + + def test_call_success_json(self): + b = AuroraBackend() + with mock.patch('requests.request', new=request(200, '{"a":1}', "application/json")): + assert b.call(CallParams()) == { "a": 1 } + + def test_call_success_raw(self): + b = AuroraBackend() + with mock.patch('requests.request', new=request(200, b'\0\0\0', "application/binary")): + r = b.call(CallParams(response_type="raw")) + assert r == b'\0\0\0' + + def test_call_success_text(self): + b = AuroraBackend() + with mock.patch('requests.request', new=request(200, 'test', "application/text")): + r = b.call(CallParams(response_type="text")) + assert r == 'test' + + def test_call_failure_api(self): + b = AuroraBackend() + error = { + "id": "id", + "code": "code", + "type": "type", + "status": 400, + "message": "message", + } + with mock.patch('requests.request', new=request(400, json.dumps(error), "application/json")): + with pytest.raises(APIException) as e: + b.call(CallParams()) + assert e.value.id == error["id"] + assert e.value.code == error["code"] + assert e.value.type == error["type"] + assert e.value.status == error["status"] + assert e.value.message == error["message"] + + def test_call_failure_413(self): + b = AuroraBackend() + error = "Request size too large" + with mock.patch('requests.request', new=request(413, error, "text/plain")): + with pytest.raises(APIException) as e: + b.call(CallParams()) + assert isinstance(e.value, APIException) + assert e.value.id == None + assert e.value.code == "RequestEntityTooLarge" + assert e.value.type == "RequestEntityTooLarge" + assert e.value.status == 413 + + def test_call_failure_other(self): + b = AuroraBackend() + error = "unknown error" + with mock.patch('requests.request', new=request(500, error, "text/plain")): + with pytest.raises(APIException) as e: + b.call(CallParams()) + assert isinstance(e.value, APIException) + assert e.value.id == None + assert e.value.code == None + assert e.value.type == None + assert e.value.status == 500 + assert e.value.message == error + # class MockResponse(object): # def __init__(self): # self.status_code = 200 # self.headers = {} # self.text = "" - + # def json(self): # return json.loads(self.text) @@ -20,20 +87,20 @@ class TestAuroraBackend(object): # def setup(self): # _config.app_id = "appid" # _config.app_token = "apptoken" - + # def teardown(self): # _config.app_id = None # _config.app_token = None - + # def test_get_headers(self): # h = get_headers() # assert h["X-Application-ID"] == _config.app_id # assert h["X-Application-Token"] == _config.app_token # assert h["X-Device-ID"] == _config.device_id - + # def test_handle_error_no_error(self): # handle_error(MockResponse()) - + # def test_handle_error_json(self): # r = MockResponse() # r.status_code = 400 @@ -53,7 +120,7 @@ class TestAuroraBackend(object): # assert e.type == "BadRequest" # assert e.status == 400 # assert e.message == "message" - + # def test_handle_error_413(self): # r = MockResponse() # r.status_code = 413 @@ -67,7 +134,7 @@ class TestAuroraBackend(object): # assert e.type == "RequestEntityTooLarge" # assert e.code == "RequestEntityTooLarge" # assert e.message == "Request entity too large" - + # def test_handle_error_other(self): # r = MockResponse() # r.status_code = 503 diff --git a/tests/assets/new-empty b/tests/assets/new-empty deleted file mode 100644 index 7e88bbdca3f9b4505e5045f137e92a09af43dbf6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44 tcmWIYbaPW+U|= len(self.wav_data[44:]) - assert (len(MockStream.data) - len(self.wav_data[44:]))/len(MockStream.data) < 0.001 - MockStream.reset_data() + # for some reason, a.play() plays a couple of extra bytes, so we can't do + # an exact equality check here + assert len(MockStream.data) >= len(self.wav_data[44:]) + assert (len(MockStream.data) - len(self.wav_data[44:]))/len(MockStream.data) < 0.001 + MockStream.reset_data() - def test_play_stop(self): - def stop_audio(timeout, audio): - time.sleep(timeout) - audio.stop() - - def play_audio(audio): - audio.play() + def test_play_stop(self): + def stop_audio(timeout, audio): + time.sleep(timeout) + audio.stop() + + def play_audio(audio): + audio.play() - with mock.patch('pyaudio.PyAudio', new=MockPyAudio): - a = AudioFile(self.wav_data) - - t1 = threading.Thread(target=play_audio, args=(a,)) - t2 = threading.Thread(target=stop_audio, args=(0.1, a)) + with mock.patch('pyaudio.PyAudio', new=MockPyAudio): + a = AudioFile(self.wav_data) + + t1 = threading.Thread(target=play_audio, args=(a,)) + t2 = threading.Thread(target=stop_audio, args=(0.1, a)) - t1.start() - t2.start() - t1.join() - t2.join() + t1.start() + t2.start() + t1.join() + t2.join() - # we stopped playback after 0.1 seconds, so expect the stream audio len - # to be much less than the input audio len (TODO: make this more precise) - assert len(MockStream.data) < len(self.wav_data[44:]) - assert (len(MockStream.data) - len(self.wav_data[44:]))/len(self.wav_data[44:]) < 0.5 - MockStream.reset_data() + # we stopped playback after 0.1 seconds, so expect the stream audio len + # to be much less than the input audio len (TODO: make this more precise) + assert len(MockStream.data) < len(self.wav_data[44:]) + assert (len(MockStream.data) - len(self.wav_data[44:]))/len(self.wav_data[44:]) < 0.5 + MockStream.reset_data() class TestAudio(object): - def setup(self): - with open("tests/assets/hw.wav", "rb") as f: - self.wav_data = f.read() - - def test_record(self): - with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): - a = record() - assert a.get_wav() == self.wav_data + def setup(self): + with open("tests/assets/hw.wav", "rb") as f: + self.wav_data = f.read() + + def test_record(self): + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + a = record() + assert a.get_wav() == self.wav_data - def test_stream(self): - with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): - first = True - header = [] - data = [] - count = 0 - for chunk in stream(): - if first: - header = chunk - first = False - data.extend(chunk) - - assert len(header) == 44 - assert len(data) == len(self.wav_data) - - def test__is_silent_empty(self): - assert _is_silent([]) - - def test__is_silent_quiet(self): - assert _is_silent([random.randint(0, SILENT_THRESH - 1) for i in range(1024)]) - - def test__is_silent_mixed(self): - assert not _is_silent([random.randint(0, 2*SILENT_THRESH) for i in range(1024)]) - - def test__is_silent_loud(self): - assert not _is_silent([random.randint(SILENT_THRESH//2, 3*SILENT_THRESH) for i in range(1024)]) + def test_stream(self): + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + first = True + header = [] + data = [] + count = 0 + for chunk in stream(): + if first: + header = chunk + first = False + data.extend(chunk) + + assert len(header) == 44 + assert len(data) == len(self.wav_data) + + def test__is_silent_empty(self): + assert _is_silent([]) + + def test__is_silent_quiet(self): + assert _is_silent([random.randint(0, SILENT_THRESH - 1) for i in range(1024)]) + + def test__is_silent_mixed(self): + assert not _is_silent([random.randint(0, 2*SILENT_THRESH) for i in range(1024)]) + + def test__is_silent_loud(self): + assert not _is_silent([random.randint(SILENT_THRESH//2, 3*SILENT_THRESH) for i in range(1024)]) - def test__pyaudio_record_silence(self): - # set record mode to silent, and start loud, so that we don't infinitly - # remove silent data - MockStream.read_mode = "silent" - MockStream.start_loud = True - with mock.patch('pyaudio.PyAudio', new=MockPyAudio): - # should record up to 1 second of silence - data = [] - for chunk in _pyaudio_record(0, 1.0): - data.extend(chunk) - assert len(data) == 16384 + def test__pyaudio_record_silence(self): + # set record mode to silent, and start loud, so that we don't infinitly + # remove silent data + MockStream.read_mode = "silent" + MockStream.start_loud = True + with mock.patch('pyaudio.PyAudio', new=MockPyAudio): + # should record up to 1 second of silence + data = [] + for chunk in _pyaudio_record(0, 1.0): + data.extend(chunk) + assert len(data) == 16384 - def test__pyaudio_record_mixed(self): - # set record mode to random noise - MockStream.read_mode = "random" - with mock.patch('pyaudio.PyAudio', new=MockPyAudio): - # should record up to 1 second of silence - data = [] - for chunk in _pyaudio_record(1.0, 0): - data.extend(chunk) - assert len(data) >= 16384 - - def test__pyaudio_record_loud(self): - # set record mode to loud - MockStream.read_mode = "loud" - with mock.patch('pyaudio.PyAudio', new=MockPyAudio): - # should record up to 1 second of silence - data = [] - for chunk in _pyaudio_record(1.0, 0): - data.extend(chunk) - assert len(data) == 16384 - - + def test__pyaudio_record_mixed(self): + # set record mode to random noise + MockStream.read_mode = "random" + with mock.patch('pyaudio.PyAudio', new=MockPyAudio): + # should record up to 1 second of silence + data = [] + for chunk in _pyaudio_record(1.0, 0): + data.extend(chunk) + assert len(data) >= 16384 + + def test__pyaudio_record_loud(self): + # set record mode to loud + MockStream.read_mode = "loud" + with mock.patch('pyaudio.PyAudio', new=MockPyAudio): + # should record up to 1 second of silence + data = [] + for chunk in _pyaudio_record(1.0, 0): + data.extend(chunk) + assert len(data) == 16384 + + diff --git a/tests/test_errors.py b/tests/test_errors.py index b36c486..806fc2c 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,28 +2,28 @@ from auroraapi.errors import APIException class TestAPIException(object): - def test_create(self): - e = APIException("id","status","code","type","message") - assert isinstance(e, APIException) - assert e.id == "id" - assert e.status == "status" - assert e.code == "code" - assert e.type == "type" - assert e.message == "message" - - def test_str(self): - e = APIException("id","status","code","type","message") - assert str(e) == "[{}] {}".format(e.code, e.message) + def test_create(self): + e = APIException("id","status","code","type","message") + assert isinstance(e, APIException) + assert e.id == "id" + assert e.status == "status" + assert e.code == "code" + assert e.type == "type" + assert e.message == "message" + + def test_str(self): + e = APIException("id","status","code","type","message") + assert str(e) == "[{}] {}".format(e.code, e.message) - def test_str_no_code(self): - e = APIException("id","status",None,"type","message") - assert str(e) == "[{}] {}".format(e.status, e.message) + def test_str_no_code(self): + e = APIException("id","status",None,"type","message") + assert str(e) == "[{}] {}".format(e.status, e.message) - def test_repr(self): - e = APIException("id","status","code","type","message") - j = json.loads(repr(e)) - assert j["id"] == e.id - assert j["status"] == e.status - assert j["code"] == e.code - assert j["type"] == e.type - assert j["message"] == e.message + def test_repr(self): + e = APIException("id","status","code","type","message") + j = json.loads(repr(e)) + assert j["id"] == e.id + assert j["status"] == e.status + assert j["code"] == e.code + assert j["type"] == e.type + assert j["message"] == e.message diff --git a/tests/test_globals.py b/tests/test_globals.py index c5d90e7..4ae5238 100644 --- a/tests/test_globals.py +++ b/tests/test_globals.py @@ -1,16 +1,16 @@ from auroraapi.globals import Config class TestGlobals(object): - def test_create_config(self): - c = Config() - assert isinstance(c, Config) - assert c != None - - def test_assign_config(self): - c = Config() - c.app_id = "app_id" - c.app_token = "app_token" + def test_create_config(self): + c = Config() + assert isinstance(c, Config) + assert c != None + + def test_assign_config(self): + c = Config() + c.app_id = "app_id" + c.app_token = "app_token" - assert c.app_id == "app_id" - assert c.app_token == "app_token" - assert c.device_id == None \ No newline at end of file + assert c.app_id == "app_id" + assert c.app_token == "app_token" + assert c.device_id == None \ No newline at end of file diff --git a/tests/test_interpret.py b/tests/test_interpret.py index d407c2e..ae51ef5 100644 --- a/tests/test_interpret.py +++ b/tests/test_interpret.py @@ -2,36 +2,36 @@ from auroraapi.interpret import Interpret class TestInterpret(object): - def test_create_no_arguments(self): - with pytest.raises(TypeError): - Interpret() - - def test_create_wrong_type(self): - with pytest.raises(TypeError): - Interpret("test") - - def test_create(self): - d = { "text": "hello", "intent": "greeting", "entities": {} } - i = Interpret(d) - assert isinstance(i, Interpret) - assert i.text == "hello" - assert i.intent == "greeting" - assert len(i.entities) == 0 + def test_create_no_arguments(self): + with pytest.raises(TypeError): + Interpret() + + def test_create_wrong_type(self): + with pytest.raises(TypeError): + Interpret("test") + + def test_create(self): + d = { "text": "hello", "intent": "greeting", "entities": {} } + i = Interpret(d) + assert isinstance(i, Interpret) + assert i.text == "hello" + assert i.intent == "greeting" + assert len(i.entities) == 0 - d = { "text": "remind me to eat", "intent": "set_reminder", "entities": { "task": "eat" } } - i = Interpret(d) - assert isinstance(i, Interpret) - assert i.text == "remind me to eat" - assert i.intent == "set_reminder" - assert len(i.entities) == 1 - assert i.entities["task"] == "eat" + d = { "text": "remind me to eat", "intent": "set_reminder", "entities": { "task": "eat" } } + i = Interpret(d) + assert isinstance(i, Interpret) + assert i.text == "remind me to eat" + assert i.intent == "set_reminder" + assert len(i.entities) == 1 + assert i.entities["task"] == "eat" - def test___repr__(self): - d = { "text": "remind me to eat", "intent": "set_reminder", "entities": { "task": "eat" } } - i = Interpret(d) - assert repr(i) == json.dumps(d, indent=2) + def test___repr__(self): + d = { "text": "remind me to eat", "intent": "set_reminder", "entities": { "task": "eat" } } + i = Interpret(d) + assert repr(i) == json.dumps(d, indent=2) - def test_context_dict(self): - d = { "text": "remind me to eat", "intent": "set_reminder", "entities": { "task": "eat" } } - i = Interpret(d) - assert json.dumps(i.context_dict()) == json.dumps(d) \ No newline at end of file + def test_context_dict(self): + d = { "text": "remind me to eat", "intent": "set_reminder", "entities": { "task": "eat" } } + i = Interpret(d) + assert json.dumps(i.context_dict()) == json.dumps(d) \ No newline at end of file diff --git a/tests/test_speech.py b/tests/test_speech.py index 6223ed1..c406dd4 100644 --- a/tests/test_speech.py +++ b/tests/test_speech.py @@ -7,80 +7,80 @@ from tests.mocks import * class TestSpeech(object): - def setup(self): - self.orig_backend = _config.backend - _config.backend = MockBackend() - with open("tests/assets/hw.wav", "rb") as f: - self.audio = AudioFile(f.read()) + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + with open("tests/assets/hw.wav", "rb") as f: + self.audio = AudioFile(f.read()) - def teardown(self): - _config.backend = self.orig_backend + def teardown(self): + _config.backend = self.orig_backend - def test_create_no_argument(self): - with pytest.raises(TypeError): - Speech() - - def test_create_none(self): - with pytest.raises(TypeError): - Speech(None) - - def test_create_wrong_type(self): - with pytest.raises(TypeError): - Speech("string") - - def test_create(self): - s = Speech(self.audio) - assert s.audio == self.audio - - def test_context_dict(self): - s = Speech(self.audio) - d = s.context_dict() - assert len(d) == 0 + def test_create_no_argument(self): + with pytest.raises(TypeError): + Speech() + + def test_create_none(self): + with pytest.raises(TypeError): + Speech(None) + + def test_create_wrong_type(self): + with pytest.raises(TypeError): + Speech("string") + + def test_create(self): + s = Speech(self.audio) + assert s.audio == self.audio + + def test_context_dict(self): + s = Speech(self.audio) + d = s.context_dict() + assert len(d) == 0 - def test_text(self): - _config.backend.set_expected_response(200, { "transcript": "hello world" }) - s = Speech(self.audio) - t = s.text() + def test_text(self): + _config.backend.set_expected_response(200, { "transcript": "hello world" }) + s = Speech(self.audio) + t = s.text() - assert isinstance(t, Text) - assert t.text.lower().strip() == "hello world" + assert isinstance(t, Text) + assert t.text.lower().strip() == "hello world" class TestListen(object): - def setup(self): - self.orig_backend = _config.backend - _config.backend = MockBackend() - with open("tests/assets/hw.wav", "rb") as f: - self.audio_file = AudioFile(f.read()) - - def teardown(self): - _config.backend = self.orig_backend + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + with open("tests/assets/hw.wav", "rb") as f: + self.audio_file = AudioFile(f.read()) + + def teardown(self): + _config.backend = self.orig_backend - def test_listen(self): - with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): - s = listen() - assert isinstance(s, Speech) - assert isinstance(s.audio, AudioFile) - assert len(self.audio_file.audio) == len(s.audio.audio) + def test_listen(self): + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + s = listen() + assert isinstance(s, Speech) + assert isinstance(s.audio, AudioFile) + assert len(self.audio_file.audio) == len(s.audio.audio) - def test_continuously_listen(self): - with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): - for s in continuously_listen(): - assert isinstance(s, Speech) - assert isinstance(s.audio, AudioFile) - assert len(self.audio_file.audio) == len(s.audio.audio) - break - - def test_listen_and_transcribe(self): - _config.backend.set_expected_response(200, { "transcript": "hello world" }) - with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): - t = listen_and_transcribe() - assert isinstance(t, Text) - assert t.text.lower().strip() == "hello world" - - def test_continuously_listen_and_transcribe(self): - _config.backend.set_expected_response(200, { "transcript": "hello world" }) - with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): - for t in continuously_listen_and_transcribe(): - assert isinstance(t, Text) - assert t.text.lower().strip() == "hello world" - break + def test_continuously_listen(self): + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + for s in continuously_listen(): + assert isinstance(s, Speech) + assert isinstance(s.audio, AudioFile) + assert len(self.audio_file.audio) == len(s.audio.audio) + break + + def test_listen_and_transcribe(self): + _config.backend.set_expected_response(200, { "transcript": "hello world" }) + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + t = listen_and_transcribe() + assert isinstance(t, Text) + assert t.text.lower().strip() == "hello world" + + def test_continuously_listen_and_transcribe(self): + _config.backend.set_expected_response(200, { "transcript": "hello world" }) + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + for t in continuously_listen_and_transcribe(): + assert isinstance(t, Text) + assert t.text.lower().strip() == "hello world" + break diff --git a/tests/test_text.py b/tests/test_text.py index 6f9898e..55ca049 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,50 +8,50 @@ from tests.mocks.backend import MockBackend class TestText(object): - def test_create(self): - t = Text("test") - assert isinstance(t, Text) - assert t.text == "test" - - def test___repr__(self): - t = Text("test") - assert repr(t) == "test" - - def test_context_dict(self): - t = Text("test") - d = t.context_dict() - assert len(d) == 1 - assert d["text"] == "test" + def test_create(self): + t = Text("test") + assert isinstance(t, Text) + assert t.text == "test" + + def test___repr__(self): + t = Text("test") + assert repr(t) == "test" + + def test_context_dict(self): + t = Text("test") + d = t.context_dict() + assert len(d) == 1 + assert d["text"] == "test" class TestTextInterpret(object): - def setup(self): - self.orig_backend = _config.backend - _config.backend = MockBackend() - - def teardown(self): - _config.backend = self.orig_backend + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + + def teardown(self): + _config.backend = self.orig_backend - def test_interpret(self): - _config.backend.set_expected_response(200, { "text": "hello", "intent": "greeting", "entities": {} }) - t = Text("hello") - i = t.interpret() - assert isinstance(i, Interpret) - assert i.intent == "greeting" + def test_interpret(self): + _config.backend.set_expected_response(200, { "text": "hello", "intent": "greeting", "entities": {} }) + t = Text("hello") + i = t.interpret() + assert isinstance(i, Interpret) + assert i.intent == "greeting" class TestTextSpeech(object): - def setup(self): - self.orig_backend = _config.backend - _config.backend = MockBackend() - with open("tests/assets/hw.wav", "rb") as f: - self.audio_data = f.read() - - def teardown(self): - _config.backend = self.orig_backend + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + with open("tests/assets/hw.wav", "rb") as f: + self.audio_data = f.read() + + def teardown(self): + _config.backend = self.orig_backend - def test_speech(self): - _config.backend.set_expected_response(200, self.audio_data) - t = Text("hello") - s = t.speech() - assert isinstance(s, Speech) - assert isinstance(s.audio, AudioFile) - assert len(s.audio.audio) > 0 + def test_speech(self): + _config.backend.set_expected_response(200, self.audio_data) + t = Text("hello") + s = t.speech() + assert isinstance(s, Speech) + assert isinstance(s.audio, AudioFile) + assert len(s.audio.audio) > 0 From ee1892cde476711579555a6c38b90e7c74d12b38 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 17:48:29 +0530 Subject: [PATCH 11/18] add documentation --- auroraapi/api/__init__.py | 88 +++++-- auroraapi/api/backend/__init__.py | 88 ++++++- auroraapi/api/backend/aurora.py | 20 ++ auroraapi/audio.py | 365 +++++++++++++++--------------- auroraapi/dialog/context.py | 119 +++++++--- auroraapi/dialog/dialog.py | 109 ++++++--- auroraapi/dialog/graph.py | 42 ++++ auroraapi/dialog/step/listen.py | 34 +++ auroraapi/dialog/step/speech.py | 27 +++ auroraapi/dialog/step/step.py | 30 +++ auroraapi/dialog/step/udf.py | 38 +++- auroraapi/dialog/util.py | 40 ++++ auroraapi/errors.py | 45 ++-- auroraapi/globals.py | 24 +- auroraapi/interpret.py | 47 ++-- auroraapi/speech.py | 154 +++++++------ auroraapi/text.py | 65 +++--- 17 files changed, 912 insertions(+), 423 deletions(-) diff --git a/auroraapi/api/__init__.py b/auroraapi/api/__init__.py index 2d7e4b8..0bd9ae1 100644 --- a/auroraapi/api/__init__.py +++ b/auroraapi/api/__init__.py @@ -7,35 +7,79 @@ TTS_URL = "/v1/tts/" def get_tts(config, text): - return AudioFile(config.backend.call(CallParams( - path=TTS_URL, - credentials=Credentials.from_config(config), - query={ "text": text }, - chunked=True, - response_type="raw", - ))) + """ Performs a TTS query + + Args: + config: an instance of globals.Config that contains the backend to be used + and the credentials to be sent along with the request + text: the text to convert to speech + + Returns: + The raw WAV data returned from the TTS service + """ + return config.backend.call(CallParams( + path=TTS_URL, + credentials=Credentials.from_config(config), + query={ "text": text }, + chunked=True, + response_type="raw", + )) def get_interpret(config, text, model): - return config.backend.call(CallParams( - path=INTERPRET_URL, - credentials=Credentials.from_config(config), - query={ "text": text, "model": model }, - )) + """ Performs an Interpret query + + Args: + config: an instance of globals.Config that contains the backend to be used + and the credentials to be sent along with the request + text: the text to interpret + model: the model to use to interpret + + Returns: + A dictionary representation of the JSON response from the Interpret service + """ + return config.backend.call(CallParams( + path=INTERPRET_URL, + credentials=Credentials.from_config(config), + query={ "text": text, "model": model }, + )) def get_stt(config, audio, stream=False): - return config.backend.call(CallParams( - path=STT_URL, - method="POST", - credentials=Credentials.from_config(config), + """ Performs an STT query + + Args: + config: an instance of globals.Config that contains the backend to be used + and the credentials to be sent along with the request + audio: either an instance of an AudioFile or a function that returns a + generator that supplies the audio data to be sent to the backend. If it is + the latter, then the `stream` argument should be set to `True` as well. + stream: pass `True` if the body is a function that returns a generator + + Returns: + A dictionary representation of the JSON response from the STT service + """ + return config.backend.call(CallParams( + path=STT_URL, + method="POST", + credentials=Credentials.from_config(config), # audio can either be an AudioFile (in case all of the data is known) or # it can be a generator function, which emits data as it gets known. We need # to modify the request based on whether stream is True, in which case we assume # that audio is a generator function - body=(audio() if stream else audio.get_wav()) - )) + body=(audio() if stream else audio.get_wav()) + )) def get_dialog(config, id): - return config.backend.call(CallParams( - path=DIALOG_URL + id, - credentials=Credentials.from_config(config), - )) \ No newline at end of file + """ Gets a dialog from the Dialog service + + Args: + config: an instance of globals.Config that contains the backend to be used + and the credentials to be sent along with the request + id: the ID of the dialog to get + + Returns: + A dictionary representation of the JSON response from the Dialog service + """ + return config.backend.call(CallParams( + path=DIALOG_URL + id, + credentials=Credentials.from_config(config), + )) \ No newline at end of file diff --git a/auroraapi/api/backend/__init__.py b/auroraapi/api/backend/__init__.py index f819026..c36286d 100644 --- a/auroraapi/api/backend/__init__.py +++ b/auroraapi/api/backend/__init__.py @@ -1,11 +1,29 @@ class Credentials(object): + """ Wrapper for passing credentials to the backend + + This class contains the credentials to be sent with each API request. Normally + the credentials are specified by the developer through the globals._config object, + but that must be converted to an object of this type to be passed to the backend + because they need to be converted to headers + + Attributes: + app_id: the application ID ('X-Application-ID' header) + app_token: the application token ('X-Application-Token' header) + device_id: the device ID ('X-Device-ID' header) + """ def __init__(self, app_id=None, app_token=None, device_id=None): + """ Creates an instance of Credentials with the given information """ self.app_id = app_id self.app_token = app_token self.device_id = device_id @property def headers(self): + """ + Returns: + A dictionary representation of the credentials to be sent to the + Aurora API service + """ return { "X-Application-ID": self.app_id, "X-Application-Token": self.app_token, @@ -14,25 +32,73 @@ def headers(self): @staticmethod def from_config(c): + """ Creates a Credentials instance from a globals.Config instance """ return Credentials(c.app_id, c.app_token, c.device_id) class CallParams(object): - def __init__(self, method="GET", path="/", credentials=Credentials(), - headers={}, query={}, body=None, chunked=False, - response_type="json"): - self.method = method - self.path = path - self.credentials = credentials - self.headers = headers - self.query = query - self.body = body - self.chunked = chunked - self.response_type = response_type + """ Parameters to be used when constructed a call to the backend + + This class encapsulates the different parameters that can be used to configure + a backend call and provides sensible defaults + + Attributes: + method: the HTTP method to use (default "GET") + path: the path relative to the base URL (default "/") + credentials: an instance of Credentials providing the authorization + credentials to be sent along with the request (default Credentials()) + headers: a dictionary of additional headers to be sent (default {}) + query: a dictionary of querystring paramters to be encoded into the URL + and sent (default {}) + body: any encodeable object or generator function that provides the data + to be sent in the body of the request. If a generator function is provided + then the `chunked` attribute should also be set to True (default None) + chunked: a boolean indicating whether or not to use Transfer-Encoding: chunked + (default False) + response_type: one of ['json', 'raw', 'text']. If 'json', then the call expects + a JSON response and returns a python dictionary containing the JSON data. If + 'raw', then the call reads the raw data from the stream and returns it. If + 'text', then the call returns the data as-is (default 'json') + """ + def __init__(self, **kwargs): + """ Creates a CallParams object from the given keyword arguments + + See the class docstring for the valid keyword arguments, their meanings, and + default values. The attributes and parameters correspond exactly. + """ + self.method = kwargs.get("method", "GET") + self.path = kwargs.get("path", "/") + self.credentials = kwargs.get("credentials", Credentials()) + self.headers = kwargs.get("headers", {}) + self.query = kwargs.get("query", {}) + self.body = kwargs.get("body", None) + self.chunked = kwargs.get("chunked", False) + self.response_type = kwargs.get("response_type", "json") class Backend(object): + """ An abstract class describing how to execute a call on a particular backend + + This class is responsible for executing a call to an arbitary backend given a + CallParams object. It is designed so that it can be swapped out to provide any + kind of behavior and contact any kind of backend (mock, staging, production, etc) + + Attributes: + base_url: The base URL of the service to reach + timeout: the timeout (in seconds) to use for the request (default 60) + + """ def __init__(self, base_url, timeout=60): + """ Creates a Backend object and initializes its attributes """ self.base_url = base_url self.timeout = timeout def call(self, params): + """ Call the backend with the given parameters + + This method must be implemented in subclasses to actually handle a call. If + left unimplemented, it will default to the base class implementation and + raise an exception. + + Args: + params: an instance of CallParams, the parameters to use to call the backend + """ raise NotImplementedError() diff --git a/auroraapi/api/backend/aurora.py b/auroraapi/api/backend/aurora.py index f1b5ab5..c9f7a59 100644 --- a/auroraapi/api/backend/aurora.py +++ b/auroraapi/api/backend/aurora.py @@ -2,13 +2,33 @@ from auroraapi.api.backend import Backend from auroraapi.errors import APIException +# The base URL to use when contacting the Aurora service BASE_URL = "https://api.auroraapi.com/v1" class AuroraBackend(Backend): + """ An implementation of the Backend that calls the Aurora production servers """ + def __init__(self, base_url=BASE_URL): + """ Creates a Backend instance with the production server BASE_URL """ super().__init__(base_url) def call(self, params): + """ Executes a call to the backend with the given parameters + + Args: + params: an instance of CallParams, the parameters to use to construct a + request to the backend + + Returns: + The response from the backend. Its type is dictated by the `response_type` + property on the `params` object. + + Raises: + Any exception that can be the `requests` library can be raised here. In + addition: + + APIException: raised when an API error occurs on the backend (status code != 200) + """ r = requests.request( params.method, self.base_url + params.path, diff --git a/auroraapi/audio.py b/auroraapi/audio.py index 0a3d962..4a27020 100644 --- a/auroraapi/audio.py +++ b/auroraapi/audio.py @@ -4,9 +4,9 @@ from pyaudio import PyAudio try: - from StringIO import StringIO as BytesIO + from StringIO import StringIO as BytesIO except: - from io import BytesIO + from io import BytesIO BUF_SIZE = (2 ** 10) SILENT_THRESH = (2 ** 11) @@ -15,186 +15,195 @@ RATE = 16000 class AudioFile(object): - """ - AudioFile lets you play, manipulate, and create representations of WAV data. - """ - def __init__(self, audio): - """ - Creates an AudioFile. - - :param audio the raw WAV data (including header) - :type string or byte array (anything that pydub.AudioSegment can accept) - """ - self.audio = AudioSegment(data=audio) - self.should_stop = False - self.playing = False - - def write_to_file(self, fname): - """ - Writes the WAV data to the specified location - - :param fname the file path to write to - :type fname string - """ - self.audio.export(fname, format="wav") - - def get_wav(self): - """ - Returns a byte string containing the WAV data encapsulated in this object. - It includes the WAV header, followed by the WAV data. - """ - wav_data = BytesIO() - wav = wave.open(wav_data, "wb") - wav.setparams((self.audio.channels, self.audio.sample_width, self.audio.frame_rate, 0, 'NONE', 'not compressed')) - wav.writeframes(self.audio.raw_data) - wav.close() - return wav_data.getvalue() - - def pad(self, seconds): - """ - Pads both sides of the audio with the specified amount of silence (in seconds) - - :param seconds the amount of silence to add (in seconds) - :type seconds float - """ - self.audio = AudioSegment.silent(duration=seconds*1000, frame_rate=16000) + self.audio + AudioSegment.silent(duration=seconds*1000, frame_rate=16000) - return self - - def pad_left(self, seconds): - """ - Pads the left side of the audio with the specified amount of silence (in seconds) - - :param seconds the amount of silence to add (in seconds) - :type seconds float - """ - self.audio = AudioSegment.silent(duration=seconds*1000, frame_rate=16000) + self.audio - return self - - def pad_right(self, seconds): - """ - Pads the right side of the audio with the specified amount of silence (in seconds) - - :param seconds the amount of silence to add (in seconds) - :type seconds float - """ - self.audio = self.audio + AudioSegment.silent(duration=seconds*1000, frame_rate=16000) - return self - - def trim_silent(self): - """ Trims extraneous silence at the ends of the audio """ - a = AudioSegment.empty() - for seg in silence.detect_nonsilent(self.audio): - a = a.append(self.audio[seg[0]:seg[1]], crossfade=0) - - self.audio = a - return self - - def play(self): - """ - Plays the underlying audio on the default output device. Although this call - blocks, you can stop playback by calling the stop() method - """ - p = pyaudio.PyAudio() - stream = p.open( - rate=self.audio.frame_rate, - format=p.get_format_from_width(self.audio.sample_width), - channels=self.audio.channels, - output=True - ) - - self.playing = True - for chunk in make_chunks(self.audio, 64): - if self.should_stop: - self.should_stop = False - break - stream.write(chunk.raw_data) - - self.playing = False - stream.stop_stream() - stream.close() - p.terminate() - - def stop(self): - """ Stop playback of the audio """ - if self.playing: - self.should_stop = True + """ + AudioFile lets you play, manipulate, and create representations of WAV data. + + Attributes: + audio: the audio data encapsulated by this class + playing: indicates whether or not this AudioFile is currently being played + """ + def __init__(self, audio): + """ Creates an AudioFile. + + Args: + audio: a string or byte array containing raw WAV data (including header) + """ + self.audio = AudioSegment(data=audio) + self.should_stop = False + self.playing = False + + def write_to_file(self, fname): + """ Writes the WAV data to the specified location + + Args: + fname: the file path to write to + """ + self.audio.export(fname, format="wav") + + def get_wav(self): + """ + Returns a byte string containing the WAV data encapsulated in this object. + It includes the WAV header, followed by the WAV data. + """ + wav_data = BytesIO() + wav = wave.open(wav_data, "wb") + wav.setparams((self.audio.channels, self.audio.sample_width, self.audio.frame_rate, 0, 'NONE', 'not compressed')) + wav.writeframes(self.audio.raw_data) + wav.close() + return wav_data.getvalue() + + def pad(self, seconds): + """ + Pads both sides of the audio with the specified amount of silence (in seconds) + + Args: + seconds: the amount of silence to add (a float, in seconds) + """ + self.audio = AudioSegment.silent(duration=seconds*1000, frame_rate=16000) + self.audio + AudioSegment.silent(duration=seconds*1000, frame_rate=16000) + return self + + def pad_left(self, seconds): + """ + Pads the left side of the audio with the specified amount of silence (in seconds) + + Args: + seconds: the amount of silence to add (a float, in seconds) + """ + self.audio = AudioSegment.silent(duration=seconds*1000, frame_rate=16000) + self.audio + return self + + def pad_right(self, seconds): + """ + Pads the right side of the audio with the specified amount of silence (in seconds) + + Args: + seconds: the amount of silence to add (a float, in seconds) + """ + self.audio = self.audio + AudioSegment.silent(duration=seconds*1000, frame_rate=16000) + return self + + def trim_silent(self): + """ Trims extraneous silence at the ends of the audio """ + a = AudioSegment.empty() + for seg in silence.detect_nonsilent(self.audio): + a = a.append(self.audio[seg[0]:seg[1]], crossfade=0) + + self.audio = a + return self + + def play(self): + """ + Plays the underlying audio on the default output device. Although this call + blocks, you can stop playback by calling the `stop` method in another thread + """ + p = pyaudio.PyAudio() + stream = p.open( + rate=self.audio.frame_rate, + format=p.get_format_from_width(self.audio.sample_width), + channels=self.audio.channels, + output=True + ) + + self.playing = True + for chunk in make_chunks(self.audio, 64): + if self.should_stop: + self.should_stop = False + break + stream.write(chunk.raw_data) + + self.playing = False + stream.stop_stream() + stream.close() + p.terminate() + + def stop(self): + """ Stop playback of the audio """ + if self.playing: + self.should_stop = True def record(length=0, silence_len=1.0): - """ - Records audio according to the given parameters and returns an instance of - an AudioFile with the recorded audio - """ - data = array.array('h') - for chunk in _pyaudio_record(length, silence_len): - data.extend(chunk) - - p = pyaudio.PyAudio() - wav_data = BytesIO() - wav = wave.open(wav_data, "wb") - wav.setparams((NUM_CHANNELS, p.get_sample_size(FORMAT), RATE, 0, 'NONE', 'not compressed')) - wav.writeframes(data.tostring()) - wav.close() - return AudioFile(wav_data.getvalue()) - + """ Records audio according to the given parameters + + Args: + length: the length of time (seconds) to record for. If 0, it will record + indefinitely, until the specified amount of silence (default 0.0) + silence_len: the amount of silence (seconds) to allow before stopping the + recording (ignored if length != 0) (default 0.5) + + Returns: + an instance of an AudioFile with the recorded audio + """ + data = array.array('h') + for chunk in _pyaudio_record(length, silence_len): + data.extend(chunk) + + p = pyaudio.PyAudio() + wav_data = BytesIO() + wav = wave.open(wav_data, "wb") + wav.setparams((NUM_CHANNELS, p.get_sample_size(FORMAT), RATE, 0, 'NONE', 'not compressed')) + wav.writeframes(data.tostring()) + wav.close() + return AudioFile(wav_data.getvalue()) + def stream(length=0, silence_len=1.0): - """ - Records audio, just like `record` does, except it doesn't return an AudioFile - upon completion. Instead, it yields the WAV file (header + data) as it becomes - available. Once caveat is that this function does not correctly populate the - data size in the WAV header. As such, a WAV file generated from this should - either be amended or should be read until EOF. - """ - # create fake WAV and yield it to get a WAV header - p = pyaudio.PyAudio() - wav_data = BytesIO() - wav = wave.open(wav_data, "wb") - wav.setparams((NUM_CHANNELS, p.get_sample_size(FORMAT), RATE, 0, 'NONE', 'not compressed')) - wav.close() - yield wav_data.getvalue() - - # yield audio until done listening - for chunk in _pyaudio_record(length, silence_len): - yield chunk.tostring() + """ + Records audio, just like `record` does, except it doesn't return an AudioFile + upon completion. Instead, it yields the WAV file (header + data) as it becomes + available. Once caveat is that this function does not correctly populate the + data size in the WAV header. As such, a WAV file generated from this should + either be amended or should be read until EOF. + """ + # create fake WAV and yield it to get a WAV header + p = pyaudio.PyAudio() + wav_data = BytesIO() + wav = wave.open(wav_data, "wb") + wav.setparams((NUM_CHANNELS, p.get_sample_size(FORMAT), RATE, 0, 'NONE', 'not compressed')) + wav.close() + yield wav_data.getvalue() + + # yield audio until done listening + for chunk in _pyaudio_record(length, silence_len): + yield chunk.tostring() def _is_silent(data): - if len(data) == 0: - return True - return max(data) < SILENT_THRESH + if len(data) == 0: + return True + return max(data) < SILENT_THRESH def _pyaudio_record(length, silence_len): - p = pyaudio.PyAudio() - stream = p.open( - rate=RATE, - format=FORMAT, - channels=NUM_CHANNELS, - frames_per_buffer=BUF_SIZE, - input=True, - output=True, - ) - - data = array.array('h') - while True: - d = array.array('h', stream.read(BUF_SIZE, exception_on_overflow=False)) - if not _is_silent(d): - break - data.extend(d) - if len(data) > 32 * BUF_SIZE: - data = data[BUF_SIZE:] - - yield data - - silent_for = 0 - bytes_read = 0 - while True: - d = array.array('h', stream.read(BUF_SIZE, exception_on_overflow=False)) - silent_for = silent_for + (len(d)/float(RATE)) if _is_silent(d) else 0 - bytes_read += len(d) - yield d - - if length == 0 and silent_for > silence_len: - break - if length > 0 and bytes_read >= length*RATE: - break - - stream.stop_stream() - stream.close() + p = pyaudio.PyAudio() + stream = p.open( + rate=RATE, + format=FORMAT, + channels=NUM_CHANNELS, + frames_per_buffer=BUF_SIZE, + input=True, + output=True, + ) + + data = array.array('h') + while True: + d = array.array('h', stream.read(BUF_SIZE, exception_on_overflow=False)) + if not _is_silent(d): + break + data.extend(d) + if len(data) > 32 * BUF_SIZE: + data = data[BUF_SIZE:] + + yield data + + silent_for = 0 + bytes_read = 0 + while True: + d = array.array('h', stream.read(BUF_SIZE, exception_on_overflow=False)) + silent_for = silent_for + (len(d)/float(RATE)) if _is_silent(d) else 0 + bytes_read += len(d) + yield d + + if length == 0 and silent_for > silence_len: + break + if length > 0 and bytes_read >= length*RATE: + break + + stream.stop_stream() + stream.close() diff --git a/auroraapi/dialog/context.py b/auroraapi/dialog/context.py index 44538b0..b4ef38e 100644 --- a/auroraapi/dialog/context.py +++ b/auroraapi/dialog/context.py @@ -1,37 +1,90 @@ class DialogContext(object): - def __init__(self, on_update = lambda ctx: None): - self.steps = {} - self.user = {} - self.udfs = {} - self.previous_step = None - self.current_step = None - self.on_update = on_update - - def set_step(self, key, value): - self.steps[key] = value - self.on_update(self) + """ The current context of the dialog - def get_step(self, key, default=None): - if not key in self.steps: - return default - return self.steps[key] - - def set_data(self, key, value): - self.user[key] = value - self.on_update(self) - - def get_data(self, key, default=None): - if not key in self.user: - return default - return self.user[key] + DialogContext stores all of the results of the steps in the dialog up until + a point in time. You can access the data stored in the context to find out + the current step, previous step, look at data stored by a previous step, and + store and access your own data. You can access this data both in the Dialog + Builder as well as programatically through UDFs or the context update handler. + + Attributes: + steps: a map of step names to data stored by that step. This varies based on the + step so make sure to look at the documentation for each step to see what gets + stored and how to access it + user: a map of key/value pairs for custom data you manually set through UDFs + udfs: a map of UDF IDs (set from the Dialog Builder) to their handler functions + previous_step: the specific Step subclass that executed in the previous step + current_step: the specific Step subclass that is currently executing + on_update: the function that is called every time the context changes + """ + + def __init__(self, on_update = lambda ctx: None): + """ Initializes a DialogContext object """ + self.steps = {} + self.user = {} + self.udfs = {} + self.previous_step = None + self.current_step = None + self.on_update = on_update + + def set_step(self, step, value): + """ Sets data for a particular step by its name + + Args: + step: the step name + value: the data resulting from running the step + """ + self.steps[step] = value + self.on_update(self) + + def get_step(self, step, default=None): + """ Gets the data for a particular step by its name + + Args: + step: the step name to retrieve the data for + default: (optional) the value to return if the step was not found + + Returns: + The data that resulted from executing the given step name + """ + if not step in self.steps: + return default + return self.steps[step] + + def set_data(self, key, value): + """ Sets some custom data for a particular key that can be accessed later + + Args: + key: the key of the data + value: the value to store for this key + """ + self.user[key] = value + self.on_update(self) + + def get_data(self, key, default=None): + """ Gets the custom data that was set for the given key + + Args: + key: the key of the data to retrieve + default: (optional) the value to return if the key was not found + + Returns: + The data that was set for the given key + """ + if not key in self.user: + return default + return self.user[key] - def set_current_step(self, step): - self.previous_step = self.current_step - self.current_step = step - self.on_update(self) - - def get_current_step(self): - return self.current_step + def set_current_step(self, step): + """ Sets a particular Step to be the currently executing step """ + self.previous_step = self.current_step + self.current_step = step + self.on_update(self) + + def get_current_step(self): + """ Gets the currently executing Step """ + return self.current_step - def get_previous_step(self): - return self.previous_step \ No newline at end of file + def get_previous_step(self): + """ Gets the last executed Step """ + return self.previous_step \ No newline at end of file diff --git a/auroraapi/dialog/dialog.py b/auroraapi/dialog/dialog.py index 146ef9a..94b9747 100644 --- a/auroraapi/dialog/dialog.py +++ b/auroraapi/dialog/dialog.py @@ -5,35 +5,84 @@ from auroraapi.globals import _config class DialogProperties(object): - def __init__(self, id, name, desc, appId, runForever, dateCreated, dateModified, graph, **kwargs): - self.id = id - self.name = name - self.desc = desc - self.app_id = appId - self.run_forever = runForever - self.date_created = parse_date(dateCreated) - self.date_modified = parse_date(dateModified) - self.graph = Graph(graph) + """ The properties of a Dialog object as returned by the Aurora API + + Attributes: + id: the dialog ID + name: the dialog name + desc: the dialog description + app_id: the app ID that this dialog belongs to + run_forever: whether or not to re-run the dialog after completion or exit + date_created: the date that the dialog was created + date_modified: the date that the dialog was last modified + graph: the deserialized graph of the dialog + """ + def __init__(self, **kwargs): + """ Initializes from the fields returned by the API """ + self.id = kwargs.get("id") + self.name = kwargs.get("name") + self.desc = kwargs.get("desc") + self.app_id = kwargs.get("appId") + self.run_forever = kwargs.get("runForever") + self.date_created = parse_date(kwargs.get("dateCreated")) + self.date_modified = parse_date(kwargs.get("dateModified")) + self.graph = Graph(kwargs.get("graph")) class Dialog(object): - def __init__(self, id, on_context_update=None): - self.dialog = DialogProperties(**get_dialog(_config, id)["dialog"]) - self.context = DialogContext() - if on_context_update != None: - assert_callable(on_context_update, "The 'on_context_update' parameter must be a function that accepts one argument") - self.context.on_update = on_context_update - - def set_function(self, id, func): - assert_callable(func, "Function argument to 'set_function' for ID '{}' must be callable and accept one argument".format(id)) - self.context.udfs[id] = func - - def run(self): - first_run = True - while first_run or self.dialog.run_forever: - curr = self.dialog.graph.start - while curr != None and curr in self.dialog.graph.nodes: - step = self.dialog.graph.nodes[curr] - edge = self.dialog.graph.edges[curr] - self.context.set_current_step(step) - curr = step.execute(self.context, edge) - first_run = False + """ A Dialog built with the Dialog Builder + + This class represents the Dialog built with the Dialog Builder. When you create + an object of this class, the ID you specify automatically fetches it from the + server. Then all you have to do is run it. See the Dialog Builder documentation + for more details. + + Attributes: + dialog: the dialog information downloaded from the Aurora service + context: information stored during the execution of the dialog + """ + + def __init__(self, id, on_context_update=None): + """ Creates the Dialog Builder + + Args: + id: the ID of the dialog to download and instantiated + on_context_update: A function that takes one argument (the dialog context) + and is called every time the current step changes or some data in the + context is updated + """ + self.dialog = DialogProperties(**get_dialog(_config, id)["dialog"]) + self.context = DialogContext() + # Check if `on_context_update` is specified and a valid function + if on_context_update != None: + assert_callable(on_context_update, "The 'on_context_update' parameter must be a function that accepts one argument") + self.context.on_update = on_context_update + + def set_function(self, id, func): + """ Assigns a function to a UDF + + If you have any UDFs in the dialog builder, you need to assign a function that gets + called when it's the UDF's turn to execute. + + Args: + id: the UDF ID to assign this function to. This should be the name you give + it in the Dialog Builder + func: the function to run when this UDF runs. It should accept one argument + (the dialog context). If the UDF needs to branch, it should return True to + take the "checkmark"-icon branch and False to take the "cross"-icon branch. + For a non-branching UDF, the value you return gets stored in the dialog + context as '.value' + """ + assert_callable(func, "Function argument to 'set_function' for ID '{}' must be callable and accept one argument".format(id)) + self.context.udfs[id] = func + + def run(self): + """ Runs the dialog """ + first_run = True + while first_run or self.dialog.run_forever: + curr = self.dialog.graph.start + while curr != None and curr in self.dialog.graph.nodes: + step = self.dialog.graph.nodes[curr] + edge = self.dialog.graph.edges[curr] + self.context.set_current_step(step) + curr = step.execute(self.context, edge) + first_run = False diff --git a/auroraapi/dialog/graph.py b/auroraapi/dialog/graph.py index 675566a..9834a8a 100644 --- a/auroraapi/dialog/graph.py +++ b/auroraapi/dialog/graph.py @@ -1,21 +1,63 @@ from auroraapi.dialog.step import DIALOG_STEP_MAP class GraphEdge(object): + """ An edge in the Dialog graph + + This class keeps track of the left and right node IDs for each node, as well + as the previous node ID. + + Attributes: + left: the default next node ID (or the "checkmark"-icon branch if applicable) + right: the "cross"-icon branch node ID (if applicable) + prev: the node ID of the previous node + """ def __init__(self, left = "", right = "", prev = ""): + """ Initializes a GraphEdge with the given left, right, prev node IDs """ self.left = left if len(left) > 0 else None self.right = right if len(right) > 0 else None self.prev = prev if len(prev) > 0 else None def next(self): + """ Gets the next node + + Returns: + By default, the next node will be the `left` one. If for some reason there + isn't a left node, it picks the `right` node. If neither node exists (no + more nodes left in the dialog), it returns None. + """ if self.left != None: return self.left return self.right def next_cond(self, cond): + """ Gets a node based on a condition + + Args: + cond: whether a particular condition is True or False + + Returns: + If the condition was True, it returns the `left` node ID (the one + corresponding to the "checkmark"-icon branch. Otherwise it returns + the one corresponding to the "cross"-icon branch. + """ return self.left if cond else self.right class Graph(object): + """ A deserialized representation of the Dialog graph + + This class takes the serialized JSON representation of the graph built with + the Dialog Builder and deserializes the nodes and edges into python objects + with corresponding properties and methods to execute them. + + Attributes: + start: the ID of the node that starts the dialog + edges: a mapping of node IDs to `GraphEdge` objects. Each `GraphEdge` object + keeps track of the node IDs that are connected to this one. + nodes: a mapping of node IDs to a Step that implements the behavior required + for that node type. + """ def __init__(self, graph): + """ Initializes a Graph from the graph returned by the Dialog API """ self.start = graph["start"] self.edges = { node_id: GraphEdge(**edges) for (node_id, edges) in graph["edges"].items() } self.nodes = { node_id: DIALOG_STEP_MAP[node["type"]](node) for (node_id, node) in graph["nodes"].items() } diff --git a/auroraapi/dialog/step/listen.py b/auroraapi/dialog/step/listen.py index 89c6078..ba77615 100644 --- a/auroraapi/dialog/step/listen.py +++ b/auroraapi/dialog/step/listen.py @@ -5,7 +5,24 @@ from auroraapi.dialog.util import parse_optional class ListenStep(Step): + """ A Step implementing the Listen functionality + + This step listens for audio from the user based on the settings from the + Dialog Builder and converts it to text. If set in the Dialog Builder, it + also interprets the text and makes the intent and entities available to + future steps. + + Attributes: + step_name: the ID that was set for this step in the Dialog Builder + model: the model to use for interpret (if applicable) + interpret: whether or not to interpret the text + listen_settings: maps the Dialog Builder listen settings to the arguments + required for the actual `listen_and_transcribe` function. It also sets + default values if invalid values were specified in the Dialog Builder. + """ + def __init__(self, step): + """ Creates the step from the data from the Dialog Builder """ super().__init__(step) data = step["data"] self.model = data["model"] @@ -17,6 +34,23 @@ def __init__(self, step): } def execute(self, context, edge): + """ Listens to the user and transcribes and/or interprets their speech + + This step first listens until the user says something and then transcribes + their speech to text. If specified in the Dialog Builder, it will interpret + the text with the given model. If `intepret` was NOT enabled, then this step + sets a `Text` object with the transcribed text in the context. If it WAS + enabled, then it sets an `Interpret` object with the text, intent, and entities. + Either way, the results of this step are made available in the dialog context + for future use. + + Args: + context: the dialog context + edge: the GraphEdge connected to this step + + Returns: + the ID of the next node to proceed to + """ text = Text() while len(text.text) == 0: text = listen_and_transcribe(**self.listen_settings) diff --git a/auroraapi/dialog/step/speech.py b/auroraapi/dialog/step/speech.py index 3733c6a..c7482d4 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -36,12 +36,26 @@ def resolve_path(context, path): class SpeechStep(Step): + """ A Step implementing the Speech functionality + + Attributes: + step_name: the ID that was set for this step in the Dialog Builder + text: the text string to speak (from the Dialog Builder). If it contains + templates, they aren't evaluated until this step's `execute` function is + called (lazy evaluation). + """ + def __init__(self, step): + """ Creates the step from the data from the Dialog Builder """ super().__init__(step) self.text = step["data"]["text"] self.step_name = step["data"]["stepName"] def get_text(self, context): + """ + Helper function that evaluates all templates in the text and returns the + new constructed string + """ # upon execution, first find all templates and replace them with # the collected value in the conversation replacements = [] @@ -52,6 +66,19 @@ def get_text(self, context): return reduce(lambda t, r: t.replace(r[0], r[1]), replacements, self.text) def execute(self, context, edge): + """ Converts the text to speech and speaks it + + This step first calls the helper function to evaluate the template strings + in the text based on values stored in the context just as it is about ro run. + Then it converts that text to speech and plays the resulting audio. + + Args: + context: the dialog context + edge: the GraphEdge connected to this step + + Returns: + the ID of the next node to proceed to + """ sp = Text(self.get_text(context)).speech() context.set_step(self.step_name, sp) sp.audio.play() diff --git a/auroraapi/dialog/step/step.py b/auroraapi/dialog/step/step.py index 6184b38..1c3ef81 100644 --- a/auroraapi/dialog/step/step.py +++ b/auroraapi/dialog/step/step.py @@ -1,7 +1,37 @@ import json +class ContextWrapper(object): + """ Wraps a value in a class that has a context_dict function + + For steps that don't save objects with a `context_dict` into the dialog + context, this class provides such a functionality. + + Attributes: + value: the value to wrap + """ + def __init__(self, value): + """ Creates a ContextWrapper for the given value """ + self.value = value + + def context_dict(self): + """ Returns the value in a dictionary similar to other steps """ + return { "value": self.value } + + class Step(object): + """ The abstract base Step class + + Each step in the Dialog Builder is a subclass of this class. It provides + the structure for each step and ensures consistency when the developer + needs to access a step. + + Attributes: + id: the internal ID of the step (this is NOT the step name) + type: the type of node (string representation, e.g. "listen", "speech", "udf") + raw: the raw step JSON from the Dialog Builder + """ def __init__(self, step): + """ Creates a Step from the JSON returned for a particular step """ self.id = step["id"] self.type = step["type"] self.raw = step diff --git a/auroraapi/dialog/step/udf.py b/auroraapi/dialog/step/udf.py index 4e78ed2..84c0f4d 100644 --- a/auroraapi/dialog/step/udf.py +++ b/auroraapi/dialog/step/udf.py @@ -1,24 +1,44 @@ -from auroraapi.dialog.step.step import Step - -class UDFResponse(object): - def __init__(self, value): - self.value = value - - def context_dict(self): - return { "value": self.value } +from auroraapi.dialog.step.step import ContextWrapper, Step class UDFStep(Step): + """ A Step implementing the UDF functionality + + Attributes: + udf_id: the ID of the UDF that was set for this step in the Dialog Builder + branch_enable: if enabled from the Dialog Builder, it will select a branch + to take based on the truthiness of the value returned by the function + registered for this UDF + """ def __init__(self, step): super().__init__(step) self.udf_id = step["data"]["stepName"] self.branch_enable = step["data"]["branchEnable"] def execute(self, context, edge): + """ Executes the UDF and branches based on its return value + + This step executes the function that was registered for this UDF. If branching + is enabled, it chooses a branch based on the boolean value of the function's + return value. Otherwise it proceeds normally. + + This step also sets the value returned from the registered function in the + dialog context under the key "value". For example, if you named this UDF + "udf_1", and your UDF returned a string, you can access it using + "${udf_1.value}". Similarly, if your UDF returned the object { "a": 10 }, you + can access the value `10` using "${udf_1.value.a}". + + Args: + context: the dialog context + edge: the GraphEdge connected to this step + + Returns: + the ID of the next node to proceed to + """ if not self.udf_id in context.udfs: raise RuntimeError("No UDF registered for step '{}'".format(self.udf_id)) val = context.udfs[self.udf_id](context) - context.set_step(self.udf_id, UDFResponse(val)) + context.set_step(self.udf_id, ContextWrapper(val)) if isinstance(val, (int, bool)) and self.branch_enable: return edge.next_cond(val) return edge.next() diff --git a/auroraapi/dialog/util.py b/auroraapi/dialog/util.py index 49f0cb3..2f024b2 100644 --- a/auroraapi/dialog/util.py +++ b/auroraapi/dialog/util.py @@ -1,16 +1,41 @@ import datetime def parse_date(date_str): + """ Attempt to parse a string in 'YYYY-MM-DDTHH:MM:SS.sssZ' format + + Args: + date_str: the string to parse + + Returns: + A `datetime` object if parseable, otherwise the original string + """ try: return datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ") except: return date_str def assert_callable(obj, message="The object is not a function"): + """ Asserts if the given argument is callable and raises RuntimeError otherwise + + Args: + obj: The object to check if is callable + message: The message to raise RuntimeError with + + Raises: + RuntimeError: raised if `obj` is not callable + """ if not callable(obj): raise RuntimeError(message) def is_iterable(obj): + """ Checks if the given argument is iterable + + Args: + obj: The object to check if is iterable + + Returns: + True if `obj` is iterable, False otherwise + """ try: _ = iter(obj) except TypeError: @@ -18,6 +43,21 @@ def is_iterable(obj): return True def parse_optional(val, parser, default=None): + """ Attempts to parse a value with a given parser + + Uses the given parser to parse the given value. If it is parseable (i.e. parsing + does not cause an exception) then the parsed value if returned. Otherwise, the + default value is returned (None by default). + + Args: + val: the value to parse + parser: the function to parse with. It should take one argment and return the + parsed value if parseable and raise an exception otherwise + default: the value to return if `val` is not parseable by `parser` + + Returns: + The parsed value if parseable, otherwise the default value + """ try: return parser(val) except: diff --git a/auroraapi/errors.py b/auroraapi/errors.py index 9cca2dc..1081154 100644 --- a/auroraapi/errors.py +++ b/auroraapi/errors.py @@ -1,20 +1,31 @@ import json class APIException(Exception): - """ Raise an exception when querying the API """ - def __init__(self, id=None, status=None, code=None, type=None, message=None): - self.id = id - self.status = status - self.code = code - self.type = type - self.message = message - super(APIException, self).__init__("[{}] {}".format(code if code != None else status, message)) - - def __repr__(self): - return json.dumps({ - "id": self.id, - "status": self.status, - "code": self.code, - "type": self.type, - "message": self.message - }, indent=2) + """ The exception raised as result of an API error + + Attributes: + id: The ID of the request that caused the error + code: The Aurora-specific code of the error that occurred + status: The HTTP status code + type: A string representation of the HTTP status Code (e.g. BadRequest) + message: A descriptive message detailing the error that occurred and possible + resolutions + """ + def __init__(self, id=None, status=None, code=None, type=None, message=None): + """ Creates an APIException with the given arguments """ + self.id = id + self.status = status + self.code = code + self.type = type + self.message = message + super(APIException, self).__init__("[{}] {}".format(code if code != None else status, message)) + + def __repr__(self): + """ Returns a JSON representation of the exception """ + return json.dumps({ + "id": self.id, + "status": self.status, + "code": self.code, + "type": self.type, + "message": self.message + }, indent=2) diff --git a/auroraapi/globals.py b/auroraapi/globals.py index 5460b0a..5531e30 100644 --- a/auroraapi/globals.py +++ b/auroraapi/globals.py @@ -1,10 +1,24 @@ from auroraapi.api.backend.aurora import AuroraBackend class Config(object): - def __init__(self, app_id=None, app_token=None, device_id=None, backend=AuroraBackend()): - self.app_id = app_id - self.app_token = app_token - self.device_id = device_id - self.backend = backend + """ Global configuration for the SDK + + This class encapsulates the various parameters to be used throughout the SDK, + including the Aurora credentials and backend to use. + Attributes: + app_id: the Aurora application ID ('X-Application-ID' header) + app_token: the Aurora application token ('X-Application-Token' header) + device_id: the ID uniquely identifies this device ('X-Device-ID' header) + backend: the backend to use (default is AuroraBackend, the production server) + """ + + def __init__(self, app_id=None, app_token=None, device_id=None, backend=AuroraBackend()): + """ Creates a Config with the given arguments """ + self.app_id = app_id + self.app_token = app_token + self.device_id = device_id + self.backend = backend + +# The global configuration used throughout the SDK _config = Config() \ No newline at end of file diff --git a/auroraapi/interpret.py b/auroraapi/interpret.py index 43914fb..9b9ad8e 100644 --- a/auroraapi/interpret.py +++ b/auroraapi/interpret.py @@ -1,29 +1,28 @@ import json class Interpret(object): - """ - Interpret is the result of calling interpret() on a Text object. It simply - encapsulates the user's intent and any entities that may have been detected - in the utterance. + """ + Interpret is the result of calling interpret() on a Text object. It simply + encapsulates the user's intent and any entities that may have been detected + in the utterance. - for example: - t = Text("what is the weather in los angeles") - i = t.interpret() - # you can see the user's intent: - print(i.intent) # weather - # you can see any additional entities: - print(i.entites) # { 'location': 'los angeles' } - print(i.entites["location"]) # los angeles - """ - def __init__(self, interpretation): - """ Construct an interpret object from the API response """ - self.text = interpretation["text"] - self.intent = interpretation["intent"] - self.entities = interpretation["entities"] - self.raw = interpretation + Example: + t = Text("what is the weather in los angeles") + i = t.interpret() + # get the user's intent + print(i.intent) # weather + # get the entities in the query + print(i.entites) # { 'location': 'los angeles' } + """ + def __init__(self, interpretation): + """ Construct an interpret object from the API response """ + self.text = interpretation["text"] + self.intent = interpretation["intent"] + self.entities = interpretation["entities"] + self.raw = interpretation - def __repr__(self): - return json.dumps(self.raw, indent=2) - - def context_dict(self): - return self.raw \ No newline at end of file + def __repr__(self): + return json.dumps(self.raw, indent=2) + + def context_dict(self): + return self.raw \ No newline at end of file diff --git a/auroraapi/speech.py b/auroraapi/speech.py index 93b3ea9..f6b6bf7 100644 --- a/auroraapi/speech.py +++ b/auroraapi/speech.py @@ -8,85 +8,105 @@ ########################################################### class Speech(object): - """ - Speech is a high-level object that encapsulates some audio and allows you to - perform actions such as converting it to text, playing and recording audio, - and more. + """ + Speech is a high-level object that encapsulates some audio and allows you to + perform actions such as converting it to text, playing and recording audio, + and more. - Speech objects have an `audio` property, which is an instance of auroraapi. - audio.AudioFile. You can access methods on this to play and stop the audio - """ + Attributes: + audio: an instance of audio.AudioFile. You can access methods on this to + play, stop, and save the audio. + """ - def __init__(self, audio): - """ - Initialize object with some audio - - :param audio an audio file - :type audio auroraapi.audio.AudioFile - """ - if not isinstance(audio, AudioFile): - raise TypeError("audio must be an instance of auroraapi.audio.AudioFile") - self.audio = audio + def __init__(self, audio): + """ Initialize object with some audio + + Args: + audio: an instance of audio.AudioFile + """ + if not isinstance(audio, AudioFile): + raise TypeError("audio must be an instance of auroraapi.audio.AudioFile") + self.audio = audio - def text(self): - """ Convert speech to text and get the prediction """ - from auroraapi.text import Text - return Text(get_stt(_config, self.audio)["transcript"]) - - def context_dict(self): - return {} + def text(self): + """ Convert speech to text and get the prediction + + Returns: + And instance of Text, which contains the text returned by the STT API call. + You can then further use the returned Text object to call other APIs. + """ + from auroraapi.text import Text + return Text(get_stt(_config, self.audio)["transcript"]) + + def context_dict(self): + return {} ########################################################### ## Listening functions ## ########################################################### def listen(length=0, silence_len=0.5): - """ - Listen with the given parameters and return a speech segment - - :param length the length of time (seconds) to record for. If 0, it will record indefinitely, until the specified amount of silence - :type length float - :param silence_len the amount of silence (seconds) to allow before stoping (ignored if length != 0) - :type silence_len float - """ - return Speech(record(length=length, silence_len=silence_len)) + """ Listens with the given parameters and returns a speech segment + + Args: + length: the length of time (seconds) to record for. If 0, it will record + indefinitely, until the specified amount of silence (default 0.0) + silence_len: the amount of silence (seconds) to allow before stopping the + recording (ignored if length != 0) (default 0.5) + + Returns: + A Speech object containing the recorded audio + """ + return Speech(record(length=length, silence_len=silence_len)) def continuously_listen(length=0, silence_len=0.5): - """ - Continually listen and yield speech demarcated by silent periods - - :param length the length of time (seconds) to record for. If 0, it will record indefinitely, until the specified amount of silence - :type length float - :param silence_len the amount of silence (seconds) to allow before stoping (ignored if length != 0) - :type silence_len float - """ - while True: - yield listen(length, silence_len) + """ Continuously listens and yields Speech objects demarcated by silent periods + + Args: + length: the length of time (seconds) to record for. If 0, it will record + indefinitely, until the specified amount of silence (default 0.0) + silence_len: the amount of silence (seconds) to allow before stopping the + recording (ignored if length != 0) (default 0.5) + + Yields: + Speech objects containin the recorded data in each demarcation + """ + while True: + yield listen(length, silence_len) def listen_and_transcribe(length=0, silence_len=0.5): - """ - Listen with the given parameters, but simulaneously stream the audio to the - Aurora API, transcribe, and return a Text object. This reduces latency if - you already know you want to convert the speech to text. - - :param length the length of time (seconds) to record for. If 0, it will record indefinitely, until the specified amount of silence - :type length float - :param silence_len the amount of silence (seconds) to allow before stoping (ignored if length != 0) - :type silence_len float - """ - from auroraapi.text import Text - return Text(get_stt(_config, functools.partial(stream, length, silence_len), stream=True)["transcript"]) + """ + Listen with the given parameters, but simulaneously stream the audio to the + Aurora API, transcribe, and return a Text object. This reduces latency if + you already know you want to convert the speech to text. + + Args: + length: the length of time (seconds) to record for. If 0, it will record + indefinitely, until the specified amount of silence (default 0.0) + silence_len: the amount of silence (seconds) to allow before stopping the + recording (ignored if length != 0) (default 0.5) + + Returns: + A Text object containing the transcription of the recorded audio + """ + from auroraapi.text import Text + return Text(get_stt(_config, functools.partial(stream, length, silence_len), stream=True)["transcript"]) def continuously_listen_and_transcribe(length=0, silence_len=0.5): - """ - Continuously listen with the given parameters, but simulaneously stream the - audio to the Aurora API, transcribe, and return a Text object. This reduces - latency if you already know you want to convert the speech to text. - - :param length the length of time (seconds) to record for. If 0, it will record indefinitely, until the specified amount of silence - :type length float - :param silence_len the amount of silence (seconds) to allow before stoping (ignored if length != 0) - :type silence_len float - """ - while True: - yield listen_and_transcribe(length, silence_len) + """ + Continuously listen with the given parameters, but simulaneously stream the + audio to the Aurora API, transcribe, and return a Text object. This reduces + latency if you already know you want to convert the speech to text. + + Args: + length: the length of time (seconds) to record for. If 0, it will record + indefinitely, until the specified amount of silence (default 0.0) + silence_len: the amount of silence (seconds) to allow before stopping the + recording (ignored if length != 0) (default 0.5) + + Yields: + Text objects containing the transcription of the recorded audio from each + demarcation + """ + while True: + yield listen_and_transcribe(length, silence_len) diff --git a/auroraapi/text.py b/auroraapi/text.py index 5cbc7bf..fbd3c41 100644 --- a/auroraapi/text.py +++ b/auroraapi/text.py @@ -1,4 +1,5 @@ from auroraapi.api import get_tts, get_interpret +from auroraapi.audio import AudioFile from auroraapi.globals import _config ########################################################### @@ -6,32 +7,42 @@ ########################################################### class Text(object): - """ - Text is a high-level object that encapsulates some text and allows you to - perform actions such as converting it to speech, interpretting it, and more. - """ + """ + Text is a high-level object that encapsulates some text and allows you to + perform actions such as converting it to speech, interpretting it, and more. + """ - def __init__(self, text=""): - """ - Initialize with some text - - :param text the text that this object encapsulates - :type text string - """ - self.text = text - - def __repr__(self): - return self.text + def __init__(self, text=""): + """ Initialize with some text + + Args: + text: the text that this object encapsulates + """ + self.text = text + + def __repr__(self): + return self.text - def speech(self): - """ Convert text to speech """ - from auroraapi.speech import Speech - return Speech(get_tts(_config, self.text)) - - def interpret(self, model="general"): - """ Interpret the text and return the results """ - from auroraapi.interpret import Interpret - return Interpret(get_interpret(_config, self.text, model)) - - def context_dict(self): - return { "text": self.text } + def speech(self): + """ Convert text to speech """ + from auroraapi.speech import Speech + return Speech(AudioFile(get_tts(_config, self.text))) + + def interpret(self, model="general"): + """ Interpret the text and return the results + + Calls the Aurora Interpret service on the encapsulated text using the given + model and returns its interpretation + + Args: + model: the specific model to use to interpret (default "general") + + Returns: + An instance of Interpret, which contains the intent and entities parsed + by the API call + """ + from auroraapi.interpret import Interpret + return Interpret(get_interpret(_config, self.text, model)) + + def context_dict(self): + return { "text": self.text } From 81e02eb5fb65d5e647bc0cdf26ebb03a4fc389d7 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 18:07:55 +0530 Subject: [PATCH 12/18] change UDF pattern and update tests --- auroraapi/dialog/step/speech.py | 7 ++- auroraapi/dialog/step/step.py | 2 +- auroraapi/dialog/step/udf.py | 10 ++-- tests/api/backend/test_aurora.py | 81 --------------------------- tests/dialog/step/test_speech_step.py | 16 ++++++ tests/dialog/step/test_udf_step.py | 19 ++----- 6 files changed, 32 insertions(+), 103 deletions(-) diff --git a/auroraapi/dialog/step/speech.py b/auroraapi/dialog/step/speech.py index c7482d4..fd38b57 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -10,11 +10,15 @@ def resolve_path(context, path): if step == "user": obj = context.user elif step in context.steps: - obj = context.steps[step].context_dict() + try: + obj = context.steps[step].context_dict() + except: + obj = context.steps[step] if not is_iterable(obj): return None while len(components) > 0: + print(obj, components) curr = components.pop(0) # Check if current path object is iterable if not is_iterable(obj): @@ -63,6 +67,7 @@ def get_text(self, context): val = resolve_path(context, match.group(2)) # TODO: do something if val not found replacements.append((match.group(1), str(val))) + print(match.group(1), val) return reduce(lambda t, r: t.replace(r[0], r[1]), replacements, self.text) def execute(self, context, edge): diff --git a/auroraapi/dialog/step/step.py b/auroraapi/dialog/step/step.py index 1c3ef81..ace057c 100644 --- a/auroraapi/dialog/step/step.py +++ b/auroraapi/dialog/step/step.py @@ -15,7 +15,7 @@ def __init__(self, value): def context_dict(self): """ Returns the value in a dictionary similar to other steps """ - return { "value": self.value } + return self.value class Step(object): diff --git a/auroraapi/dialog/step/udf.py b/auroraapi/dialog/step/udf.py index 84c0f4d..45182fe 100644 --- a/auroraapi/dialog/step/udf.py +++ b/auroraapi/dialog/step/udf.py @@ -22,10 +22,10 @@ def execute(self, context, edge): return value. Otherwise it proceeds normally. This step also sets the value returned from the registered function in the - dialog context under the key "value". For example, if you named this UDF - "udf_1", and your UDF returned a string, you can access it using - "${udf_1.value}". Similarly, if your UDF returned the object { "a": 10 }, you - can access the value `10` using "${udf_1.value.a}". + dialog context under the UDF ID. For example, if you named this UDF `udf_1`, + and your UDF returned a string, you can access it in the Dialog Builder using + `${udf_1}`. Similarly, if your UDF returned the object `{ "a": 10 }`, you + can access the value `10` using `${udf_1.a}`. Args: context: the dialog context @@ -38,7 +38,7 @@ def execute(self, context, edge): raise RuntimeError("No UDF registered for step '{}'".format(self.udf_id)) val = context.udfs[self.udf_id](context) - context.set_step(self.udf_id, ContextWrapper(val)) + context.set_step(self.udf_id, val) if isinstance(val, (int, bool)) and self.branch_enable: return edge.next_cond(val) return edge.next() diff --git a/tests/api/backend/test_aurora.py b/tests/api/backend/test_aurora.py index ecb60d7..133f63d 100644 --- a/tests/api/backend/test_aurora.py +++ b/tests/api/backend/test_aurora.py @@ -4,8 +4,6 @@ from auroraapi.errors import APIException from tests.mocks.requests import request - - class TestAuroraBackend(object): def test_create(self): b = AuroraBackend("base_url") @@ -71,82 +69,3 @@ def test_call_failure_other(self): assert e.value.type == None assert e.value.status == 500 assert e.value.message == error - - -# class MockResponse(object): -# def __init__(self): -# self.status_code = 200 -# self.headers = {} -# self.text = "" - -# def json(self): -# return json.loads(self.text) - - -# class TestAPIUtils(object): -# def setup(self): -# _config.app_id = "appid" -# _config.app_token = "apptoken" - -# def teardown(self): -# _config.app_id = None -# _config.app_token = None - -# def test_get_headers(self): -# h = get_headers() -# assert h["X-Application-ID"] == _config.app_id -# assert h["X-Application-Token"] == _config.app_token -# assert h["X-Device-ID"] == _config.device_id - -# def test_handle_error_no_error(self): -# handle_error(MockResponse()) - -# def test_handle_error_json(self): -# r = MockResponse() -# r.status_code = 400 -# r.headers = { "content-type": "application/json" } -# r.text = json.dumps({ -# "id": "id", -# "code": "MissingApplicationIDHeader", -# "type": "BadRequest", -# "status": 400, -# "message": "message" -# }) - -# with pytest.raises(APIException) as e: -# handle_error(r) -# assert e.id == "id" -# assert e.code == "MissingApplicationIDHeader" -# assert e.type == "BadRequest" -# assert e.status == 400 -# assert e.message == "message" - -# def test_handle_error_413(self): -# r = MockResponse() -# r.status_code = 413 -# r.headers["content-type"] = "text/html" -# r.text = "Request entity too large" - -# with pytest.raises(APIException) as e: -# handle_error(r) -# assert e.id == None -# assert e.status == 413 -# assert e.type == "RequestEntityTooLarge" -# assert e.code == "RequestEntityTooLarge" -# assert e.message == "Request entity too large" - -# def test_handle_error_other(self): -# r = MockResponse() -# r.status_code = 503 -# r.headers["content-type"] = "text/html" -# r.text = "Service unavailable" - - -# with pytest.raises(APIException) as e: -# handle_error(r) -# assert e.id == None -# assert e.status == 413 -# assert e.type == None -# assert e.code == None -# assert e.message == r.text -# assert str(e) == "[{}] {}".format(r.status_code, r.text) \ No newline at end of file diff --git a/tests/dialog/step/test_speech_step.py b/tests/dialog/step/test_speech_step.py index 57a3dea..fc171d5 100644 --- a/tests/dialog/step/test_speech_step.py +++ b/tests/dialog/step/test_speech_step.py @@ -89,6 +89,15 @@ def test_user(self): }, } +SPEECH_WITH_UDF_TEMPLATE = { + "id": "speech_id", + "type": "speech", + "data": { + "text": "It is ${udf_name1.weather.temp} degrees right now in ${udf_name2}.", + "stepName": "speech_name", + }, +} + class TestSpeechStep(object): def setup(self): self.orig_backend = _config.backend @@ -120,6 +129,13 @@ def test_get_text_user_template(self): s = SpeechStep(SPEECH_WITH_USER_TEMPLATE) assert s.get_text(c) == "Hello first last. How are you?" + def test_get_text_udf_template(self): + c = DialogContext() + c.set_step("udf_name1", { "weather": { "temp": 70, "humidity": "80%" } }) + c.set_step("udf_name2", "los angeles") + s = SpeechStep(SPEECH_WITH_UDF_TEMPLATE) + assert s.get_text(c) == "It is 70 degrees right now in los angeles." + def test_get_text_missing_template(self): c = DialogContext() c.set_data("profile", { "first": "first" }) diff --git a/tests/dialog/step/test_udf_step.py b/tests/dialog/step/test_udf_step.py index a9ec5d0..226acdf 100644 --- a/tests/dialog/step/test_udf_step.py +++ b/tests/dialog/step/test_udf_step.py @@ -1,7 +1,7 @@ import pytest from auroraapi.dialog.context import DialogContext from auroraapi.dialog.graph import GraphEdge -from auroraapi.dialog.step.udf import UDFResponse, UDFStep +from auroraapi.dialog.step.udf import UDFStep UDF = { "id": "udf_id", @@ -12,17 +12,6 @@ }, } -class TestUDFResponse(object): - def test_create(self): - r = UDFResponse("value") - assert r.value == "value" - - def test_context_dict(self): - r = UDFResponse("value") - d = r.context_dict() - assert len(d) == 1 - assert d["value"] == r.value - class TestUDFStep(object): def test_create(self): u = UDFStep(UDF) @@ -43,7 +32,7 @@ def test_execute_branch_disabled(self): c.udfs[UDF["data"]["stepName"]] = lambda ctx: False u = UDFStep(UDF) assert u.execute(c, e) == e.left - assert c.get_step(UDF["data"]["stepName"]).value == False + assert c.get_step(UDF["data"]["stepName"]) == False def test_execute_branch_enabled_left(self): e = GraphEdge("left", "right", "prev") @@ -52,7 +41,7 @@ def test_execute_branch_enabled_left(self): UDF["data"]["branchEnable"] = True u = UDFStep(UDF) assert u.execute(c, e) == e.left - assert c.get_step(UDF["data"]["stepName"]).value == True + assert c.get_step(UDF["data"]["stepName"]) == True def test_execute_branch_enabled_left(self): e = GraphEdge("left", "right", "prev") @@ -61,4 +50,4 @@ def test_execute_branch_enabled_left(self): UDF["data"]["branchEnable"] = True u = UDFStep(UDF) assert u.execute(c, e) == e.right - assert c.get_step(UDF["data"]["stepName"]).value == False + assert c.get_step(UDF["data"]["stepName"]) == False From d569a80957d0e975f9bba3fb92351662f46e298f Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 18:10:30 +0530 Subject: [PATCH 13/18] Add contextwrapper tests --- auroraapi/dialog/step/speech.py | 3 +++ tests/dialog/step/test_step.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/auroraapi/dialog/step/speech.py b/auroraapi/dialog/step/speech.py index fd38b57..cfb6c1b 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -10,6 +10,9 @@ def resolve_path(context, path): if step == "user": obj = context.user elif step in context.steps: + # check if the step has a context_dict() method + # if not, then it's probably a UDF step's value, + # which is just a plain object try: obj = context.steps[step].context_dict() except: diff --git a/tests/dialog/step/test_step.py b/tests/dialog/step/test_step.py index 8b5b0dd..fa34f6c 100644 --- a/tests/dialog/step/test_step.py +++ b/tests/dialog/step/test_step.py @@ -1,5 +1,5 @@ import json, pytest -from auroraapi.dialog.step.step import Step +from auroraapi.dialog.step.step import ContextWrapper, Step STEP = { "id": "test", @@ -9,6 +9,15 @@ }, } +class TestContextWrapper(object): + def test_create(self): + c = ContextWrapper("value") + assert c.value == "value" + + def test_context_dict(self): + c = ContextWrapper({"a": 10}) + assert c.context_dict() == { "a": 10 } + class TestStep(object): def test_create(self): s = Step(STEP) From 0b0b1314d571698a8c2aa1822bd98c82e3a7d719 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 22:51:45 +0530 Subject: [PATCH 14/18] add travis ci --- .travis.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d91d5d3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python +python: + - "2.7" + - "3.4" + - "3.5" + - "3.6" + - "3.5-dev" + - "3.6-dev" + - "3.7-dev" + - "pypy2.7" + - "pypy3.5" +install: + - python setup.py install +script: + - make test \ No newline at end of file From bfa0d902d6e206522379a03fb7be980925cfa593 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 22:59:07 +0530 Subject: [PATCH 15/18] add portaudio dependency --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index d91d5d3..e3b994c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,10 @@ python: - "3.7-dev" - "pypy2.7" - "pypy3.5" +addons: + apt: + packages: + - libportaudio-dev install: - python setup.py install script: From cf95985af120c1f0cef50b5f585fef23d2801625 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 23:05:06 +0530 Subject: [PATCH 16/18] change portaudio dependency in travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e3b994c..cf568e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ python: addons: apt: packages: - - libportaudio-dev + - portaudio19-dev install: - python setup.py install script: From 5acf08e2c732734eef200a3ddaf7319f92b43624 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 23:08:51 +0530 Subject: [PATCH 17/18] change wav file for test --- tests/dialog/step/test_speech_step.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dialog/step/test_speech_step.py b/tests/dialog/step/test_speech_step.py index fc171d5..d09affb 100644 --- a/tests/dialog/step/test_speech_step.py +++ b/tests/dialog/step/test_speech_step.py @@ -102,7 +102,7 @@ class TestSpeechStep(object): def setup(self): self.orig_backend = _config.backend _config.backend = MockBackend() - with open("tests/assets/empty.wav", "rb") as f: + with open("tests/assets/hw.wav", "rb") as f: self.audio_data = f.read() def teardown(self): From eb0c20405d107c49d99c9855f4315b03dbce6d39 Mon Sep 17 00:00:00 2001 From: Nikhil Kansal Date: Thu, 28 Jun 2018 23:17:30 +0530 Subject: [PATCH 18/18] change python versions --- .travis.yml | 5 ----- README.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index cf568e9..f0712b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,9 @@ language: python python: - - "2.7" - - "3.4" - "3.5" - "3.6" - "3.5-dev" - "3.6-dev" - - "3.7-dev" - - "pypy2.7" - - "pypy3.5" addons: apt: packages: diff --git a/README.md b/README.md index ae61909..f7e006c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The SDK is currently in a pre-alpha release phase. Bugs and limited functionalit ## Installation -**The Recommended Python version is 3.0+** +**The Recommended Python version is 3.5+** The Python SDK currently does not bundle the necessary system headers and binaries to interact with audio hardware in a cross-platform manner. For this reason, before using the SDK, you need to install `PortAudio`: