diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f0712b8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +python: + - "3.5" + - "3.6" + - "3.5-dev" + - "3.6-dev" +addons: + apt: + packages: + - portaudio19-dev +install: + - python setup.py install +script: + - make test \ No newline at end of file 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..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`: @@ -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/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..0bd9ae1 --- /dev/null +++ b/auroraapi/api/__init__.py @@ -0,0 +1,85 @@ +from auroraapi.api.backend import CallParams, Credentials +from auroraapi.audio import AudioFile + +DIALOG_URL = "/v1/dialog/" +INTERPRET_URL = "/v1/interpret/" +STT_URL = "/v1/stt/" +TTS_URL = "/v1/tts/" + +def get_tts(config, text): + """ 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): + """ 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): + """ 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()) + )) + +def get_dialog(config, id): + """ 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 new file mode 100644 index 0000000..c36286d --- /dev/null +++ b/auroraapi/api/backend/__init__.py @@ -0,0 +1,104 @@ +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, + "X-Device-ID": self.device_id, + } + + @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): + """ 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 new file mode 100644 index 0000000..c9f7a59 --- /dev/null +++ b/auroraapi/api/backend/aurora.py @@ -0,0 +1,56 @@ +import requests, functools, json, inspect +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, + 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: + if "application/json" in r.headers["content-type"]: + raise APIException(**r.json()) + # 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) + 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/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 4b162af..b4ef38e 100644 --- a/auroraapi/dialog/context.py +++ b/auroraapi/dialog/context.py @@ -1,32 +1,90 @@ class DialogContext(object): - def __init__(self, on_update = lambda ctx: None): - self.step = {} - self.user = {} - self.udfs = {} - self.current_step = None - self.on_update = on_update - - def set_step_data(self, key, value): - self.step[key] = value - self.on_update(self) + """ The current context of the dialog - def get_step_data(self, key, default=None): - if not key in self.step: - return default - return self.step[key] - - def set_user_data(self, key, value): - self.user[key] = value - self.on_update(self) - - def get_user_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 - def set_current_step(self, step): - self.current_step = step - self.on_update(self) - - def get_current_step(self): - return self.current_step + 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): + """ 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): + """ 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 94549de..94b9747 100644 --- a/auroraapi/dialog/dialog.py +++ b/auroraapi/dialog/dialog.py @@ -1,35 +1,88 @@ -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, dateCreated, dateModified, graph, **kwargs): - self.id = id - self.name = name - self.desc = desc - self.app_id = appId - 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(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): - 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) + """ 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 5be4739..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,9 +34,26 @@ 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) 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..cfb6c1b 100644 --- a/auroraapi/dialog/step/speech.py +++ b/auroraapi/dialog/step/speech.py @@ -1,44 +1,93 @@ -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(".") obj = None if step == "user": obj = context.user - elif step in context.step: - obj = context.step[step].context_dict() + 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: + obj = context.steps[step] if not is_iterable(obj): return None while len(components) > 0: + print(obj, components) 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 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 obj = obj[curr] return obj 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 execute(self, context, edge): + + 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 = [] 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), 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): + """ 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 - 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) + 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() return edge.next() diff --git a/auroraapi/dialog/step/step.py b/auroraapi/dialog/step/step.py index 6184b38..ace057c 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 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 6b2fc16..45182fe 100644 --- a/auroraapi/dialog/step/udf.py +++ b/auroraapi/dialog/step/udf.py @@ -1,23 +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 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 + 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_data(self.udf_id, UDFResponse(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/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 new file mode 100644 index 0000000..1081154 --- /dev/null +++ b/auroraapi/errors.py @@ -0,0 +1,31 @@ +import json + +class APIException(Exception): + """ 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 b259d03..5531e30 100644 --- a/auroraapi/globals.py +++ b/auroraapi/globals.py @@ -1,7 +1,24 @@ +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 + """ 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 597e959..f6b6bf7 100644 --- a/auroraapi/speech.py +++ b/auroraapi/speech.py @@ -1,91 +1,112 @@ import functools from auroraapi.audio import AudioFile, record, stream from auroraapi.api import get_stt +from auroraapi.globals import _config ########################################################### ## Speech to Text ## ########################################################### 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(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(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 e3bfc32..fbd3c41 100644 --- a/auroraapi/text.py +++ b/auroraapi/text.py @@ -1,36 +1,48 @@ from auroraapi.api import get_tts, get_interpret +from auroraapi.audio import AudioFile +from auroraapi.globals import _config ########################################################### ## Text to Speech ## ########################################################### 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(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)) - - 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 } 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..133f63d --- /dev/null +++ b/tests/api/backend/test_aurora.py @@ -0,0 +1,71 @@ +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): + 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 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 0000000..91be6ce Binary files /dev/null and b/tests/assets/empty.wav differ diff --git a/tests/dialog/step/test_listen_step.py b/tests/dialog/step/test_listen_step.py new file mode 100644 index 0000000..e8e17d6 --- /dev/null +++ b/tests/dialog/step/test_listen_step.py @@ -0,0 +1,106 @@ +import pytest, mock +from auroraapi.dialog.context import DialogContext +from auroraapi.dialog.graph import GraphEdge +from auroraapi.dialog.step.listen import ListenStep +from auroraapi.globals import _config +from auroraapi.interpret import Interpret +from auroraapi.text import Text +from tests.mocks import * + +class ContextWrapper(object): + def __init__(self, val): + self.val = val + def context_dict(self): + return self.val + +LISTEN_TEXT = { + "id": "listen_id", + "type": "listen", + "data": { + "model": "general", + "interpret": False, + "stepName": "listen_name", + "length": "", + "silenceLen": "", + }, +} + +LISTEN_TEXT_CUSTOM = { + "id": "listen_id", + "type": "listen", + "data": { + "model": "general", + "interpret": False, + "stepName": "listen_name", + "length": "3.5", + "silenceLen": "1", + }, +} + +LISTEN_INTERPRET = { + "id": "listen_id", + "type": "listen", + "data": { + "model": "general", + "interpret": True, + "stepName": "listen_name", + "length": "", + "silenceLen": "", + }, +} + +class TestListenStep(object): + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + + def teardown(self): + _config.backend = self.orig_backend + + def test_create_default_text(self): + s = ListenStep(LISTEN_TEXT) + assert s.model == LISTEN_TEXT["data"]["model"] + assert s.step_name == LISTEN_TEXT["data"]["stepName"] + assert s.interpret == LISTEN_TEXT["data"]["interpret"] + assert s.listen_settings["length"] == 0 + assert s.listen_settings["silence_len"] == 0.5 + + def test_create_custom_text(self): + s = ListenStep(LISTEN_TEXT_CUSTOM) + assert s.listen_settings["length"] == 3.5 + assert s.listen_settings["silence_len"] == 1 + + def test_create_default_interpret(self): + s = ListenStep(LISTEN_INTERPRET) + assert s.interpret + assert s.listen_settings["length"] == 0 + assert s.listen_settings["silence_len"] == 0.5 + + def test_execute_text(self): + _config.backend.set_expected_response(200, { "transcript": "hello" }) + c = DialogContext() + s = ListenStep(LISTEN_TEXT) + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + assert s.execute(c, GraphEdge()) == None + assert isinstance(c.get_step("listen_name"), Text) + assert c.get_step("listen_name").text == "hello" + + def test_execute_interpret(self): + _config.backend.set_expected_responses( + (200, { + "transcript": "hello" + }), + (200, { + "text": "hello", + "intent": "greeting", + "entities": {}, + }), + ) + c = DialogContext() + s = ListenStep(LISTEN_INTERPRET) + with mock.patch('auroraapi.audio._pyaudio_record', new=mock_pyaudio_record): + assert s.execute(c, GraphEdge()) == None + assert isinstance(c.get_step("listen_name"), Interpret) + assert c.get_step("listen_name").text == "hello" + assert c.get_step("listen_name").intent == "greeting" + assert c.get_step("listen_name").entities == {} diff --git a/tests/dialog/step/test_speech_step.py b/tests/dialog/step/test_speech_step.py new file mode 100644 index 0000000..d09affb --- /dev/null +++ b/tests/dialog/step/test_speech_step.py @@ -0,0 +1,153 @@ +import pytest, mock +from auroraapi.audio import AudioFile +from auroraapi.dialog.context import DialogContext +from auroraapi.dialog.graph import GraphEdge +from auroraapi.dialog.step.speech import SpeechStep, resolve_path +from auroraapi.globals import _config +from auroraapi.speech import Speech +from tests.mocks import * + +class ContextWrapper(object): + def __init__(self, val): + self.val = val + def context_dict(self): + return self.val + +class TestResolvePath(object): + def test_bad_user(self): + c = DialogContext() + assert resolve_path(c, "user.bad_key") == None + + def test_bad_step(self): + c = DialogContext() + assert resolve_path(c, "bad_step") == None + + def test_bad_path_obj(self): + c = DialogContext() + c.set_step("test", ContextWrapper({ "a": { "c": 2 } })) + assert resolve_path(c, "test.a.b") == None + + def test_bad_path_array(self): + c = DialogContext() + c.set_step("test", ContextWrapper({ "a": [ "b", "c" ], "d": "e", "f": 10 })) + assert resolve_path(c, "test.a.2") == None + assert resolve_path(c, "test.a.1a") == None + assert resolve_path(c, "test.a.a") == None + assert resolve_path(c, "test.d.e") == None + assert resolve_path(c, "test.d.1") == None + assert resolve_path(c, "test.f.a") == None + + def test_step(self): + c = DialogContext() + data = { "a": { "b": [1,2] } } + c.set_step("test", ContextWrapper(data)) + assert resolve_path(c, "test") == data + assert resolve_path(c, "test.a") == data["a"] + assert resolve_path(c, "test.a.b") == data["a"]["b"] + assert resolve_path(c, "test.a.b.0") == data["a"]["b"][0] + assert resolve_path(c, "test.a.b.1") == data["a"]["b"][1] + + def test_user(self): + c = DialogContext() + data = { "a": { "b": [1,2] } } + c.set_data("test", "data") + assert resolve_path(c, "user") == { "test": "data" } + assert resolve_path(c, "user.test") == "data" + + c.set_data("test", data) + assert resolve_path(c, "user") == { "test": data } + assert resolve_path(c, "user.test") == data + assert resolve_path(c, "user.test.a") == data["a"] + assert resolve_path(c, "user.test.a.b") == data["a"]["b"] + assert resolve_path(c, "user.test.a.b.0") == data["a"]["b"][0] + assert resolve_path(c, "user.test.a.b.1") == data["a"]["b"][1] + +SPEECH = { + "id": "speech_id", + "type": "speech", + "data": { + "text": "Hello", + "stepName": "speech_name", + }, +} + +SPEECH_WITH_USER_TEMPLATE = { + "id": "speech_id", + "type": "speech", + "data": { + "text": "Hello ${user.profile.first} ${user.profile.last}. How are you?", + "stepName": "speech_name", + }, +} + +SPEECH_WITH_STEP_TEMPLATE = { + "id": "speech_id", + "type": "speech", + "data": { + "text": "Hello ${listen_step.text}. How are you?", + "stepName": "speech_name", + }, +} + +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 + _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_create(self): + s = SpeechStep(SPEECH) + assert s.step_name == SPEECH["data"]["stepName"] + assert s.text == SPEECH["data"]["text"] + + def test_get_text_no_template(self): + s = SpeechStep(SPEECH) + assert s.get_text(None) == s.text + + def test_get_text_step_template(self): + c = DialogContext() + c.set_step("listen_step", ContextWrapper({ "text": "name" })) + s = SpeechStep(SPEECH_WITH_STEP_TEMPLATE) + assert s.get_text(c) == "Hello name. How are you?" + + def test_get_text_user_template(self): + c = DialogContext() + c.set_data("profile", { "first": "first", "last": "last" }) + 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" }) + s = SpeechStep(SPEECH_WITH_USER_TEMPLATE) + assert s.get_text(c) == "Hello first None. How are you?" + + def test_execute(self): + _config.backend.set_expected_response(200, self.audio_data) + c = DialogContext() + + with mock.patch('pyaudio.PyAudio', new=MockPyAudio): + s = SpeechStep(SPEECH) + assert s.execute(c, GraphEdge()) == None + assert isinstance(c.get_step(s.step_name), Speech) + assert c.get_step(s.step_name).audio.audio == AudioFile(self.audio_data).audio diff --git a/tests/dialog/step/test_step.py b/tests/dialog/step/test_step.py new file mode 100644 index 0000000..fa34f6c --- /dev/null +++ b/tests/dialog/step/test_step.py @@ -0,0 +1,36 @@ +import json, pytest +from auroraapi.dialog.step.step import ContextWrapper, Step + +STEP = { + "id": "test", + "type": "test", + "data": { + "stepName": "test_name", + }, +} + +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) + + assert s.id == STEP["id"] + assert s.type == STEP["type"] + assert s.raw == STEP + + def test___repr__(self): + s = Step(STEP) + assert repr(s) == json.dumps(STEP, indent=2) + + def test_execute(self): + s = Step(STEP) + with pytest.raises(NotImplementedError): + s.execute(None, None) \ No newline at end of file diff --git a/tests/dialog/step/test_udf_step.py b/tests/dialog/step/test_udf_step.py new file mode 100644 index 0000000..226acdf --- /dev/null +++ b/tests/dialog/step/test_udf_step.py @@ -0,0 +1,53 @@ +import pytest +from auroraapi.dialog.context import DialogContext +from auroraapi.dialog.graph import GraphEdge +from auroraapi.dialog.step.udf import UDFStep + +UDF = { + "id": "udf_id", + "type": "udf", + "data": { + "stepName": "udf_name", + "branchEnable": False, + }, +} + +class TestUDFStep(object): + def test_create(self): + u = UDFStep(UDF) + assert u.id == UDF["id"] + assert u.type == UDF["type"] + assert u.udf_id == UDF["data"]["stepName"] + assert u.branch_enable == UDF["data"]["branchEnable"] + + def test_execute_no_function_registered(self): + c = DialogContext() + u = UDFStep(UDF) + with pytest.raises(RuntimeError): + u.execute(c, GraphEdge()) + + def test_execute_branch_disabled(self): + e = GraphEdge("left", "right", "prev") + c = DialogContext() + 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"]) == False + + def test_execute_branch_enabled_left(self): + e = GraphEdge("left", "right", "prev") + c = DialogContext() + c.udfs[UDF["data"]["stepName"]] = lambda ctx: True + UDF["data"]["branchEnable"] = True + u = UDFStep(UDF) + assert u.execute(c, e) == e.left + assert c.get_step(UDF["data"]["stepName"]) == True + + def test_execute_branch_enabled_left(self): + e = GraphEdge("left", "right", "prev") + c = DialogContext() + c.udfs[UDF["data"]["stepName"]] = lambda ctx: False + UDF["data"]["branchEnable"] = True + u = UDFStep(UDF) + assert u.execute(c, e) == e.right + assert c.get_step(UDF["data"]["stepName"]) == False diff --git a/tests/dialog/test_context.py b/tests/dialog/test_context.py new file mode 100644 index 0000000..b0bf18f --- /dev/null +++ b/tests/dialog/test_context.py @@ -0,0 +1,60 @@ +from auroraapi.dialog.context import DialogContext + +def on_update_creater(): + d = { "called": False } + def on_update(ctx): + d["called"] = True + d["fn"] = on_update + return d + +class TestDialogContext(object): + def test_create(self): + d = DialogContext() + assert d.on_update(0) == None + + d = DialogContext(lambda x: x) + assert d.on_update(0) == 0 + + def test_set_step(self): + f = on_update_creater() + d = DialogContext(on_update=f["fn"]) + d.set_step("test_id", 123) + assert d.steps["test_id"] == 123 + assert f["called"] + + def test_get_step(self): + d = DialogContext() + d.set_step("test_id", 123) + assert d.get_step("test_id") == 123 + + def test_get_step_nonexistant(self): + d = DialogContext() + assert d.get_step("test_id") == None + assert d.get_step("test_id", default=0) == 0 + + def test_set_data(self): + f = on_update_creater() + d = DialogContext(on_update=f["fn"]) + d.set_data("test_id", 123) + assert d.user["test_id"] == 123 + assert f["called"] + + def test_get_data(self): + d = DialogContext() + d.set_data("test_id", 123) + assert d.get_data("test_id") == 123 + + def test_get_data_nonexistant(self): + d = DialogContext() + assert d.get_data("test_id") == None + assert d.get_data("test_id", default=0) == 0 + + def test_set_current_step(self): + d = DialogContext() + d.set_current_step("test1") + assert d.get_current_step() == "test1" + assert d.get_previous_step() == None + + d.set_current_step("test2") + assert d.get_current_step() == "test2" + assert d.get_previous_step() == "test1" diff --git a/tests/dialog/test_dialog.py b/tests/dialog/test_dialog.py new file mode 100644 index 0000000..e178b88 --- /dev/null +++ b/tests/dialog/test_dialog.py @@ -0,0 +1,99 @@ +import pytest +from auroraapi.dialog.dialog import Dialog, DialogProperties +from auroraapi.dialog.graph import Graph, GraphEdge +from auroraapi.dialog.step.udf import UDFStep +from auroraapi.dialog.util import parse_date +from auroraapi.globals import _config +from tests.mocks.backend import MockBackend + +EMPTY_GRAPH = { + "dialog": { + "id": "id", + "name": "Test", + "desc": "Test", + "appId": "appId", + "runForever": False, + "dateCreated": "2015-10-14T12:25:32.333Z", + "dateModified": "2015-10-14T12:25:32.333Z", + "graph": { + "start": "", + "edges": {}, + "nodes": {}, + }, + }, +} + +TEST_GRAPH = { + "dialog": { + **EMPTY_GRAPH["dialog"], + "graph": { + "start": "test", + "edges": { + "test": {}, + }, + "nodes": { + "test": { + "id": "test", + "type": "udf", + "data": { + "stepName": "test_udf", + "branchEnable": False, + } + } + } + } + } +} + +class TestDialog(object): + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + _config.backend.set_expected_response(200, EMPTY_GRAPH) + + def teardown(self): + _config.backend = self.orig_backend + + def test_create(self): + d = Dialog("id") + + assert isinstance(d.dialog, DialogProperties) + assert d.dialog.id == EMPTY_GRAPH["dialog"]["id"] + assert d.dialog.name == EMPTY_GRAPH["dialog"]["name"] + assert d.dialog.desc == EMPTY_GRAPH["dialog"]["desc"] + assert d.dialog.app_id == EMPTY_GRAPH["dialog"]["appId"] + assert d.dialog.run_forever == EMPTY_GRAPH["dialog"]["runForever"] + assert d.dialog.date_created == parse_date(EMPTY_GRAPH["dialog"]["dateCreated"]) + assert d.dialog.date_modified == parse_date(EMPTY_GRAPH["dialog"]["dateModified"]) + + assert isinstance(d.dialog.graph, Graph) + assert d.dialog.graph.start == EMPTY_GRAPH["dialog"]["graph"]["start"] + assert d.dialog.graph.edges == EMPTY_GRAPH["dialog"]["graph"]["edges"] + assert d.dialog.graph.nodes == EMPTY_GRAPH["dialog"]["graph"]["nodes"] + + def test_create_with_context_fn(self): + update_fn = lambda ctx: 1 + d = Dialog("id", on_context_update=update_fn) + + assert d.context.on_update == update_fn + + def test_create_with_invalid_fn(self): + with pytest.raises(RuntimeError): + d = Dialog("id", on_context_update=[]) + + def test_set_function(self): + f = lambda ctx: None + d = Dialog("id") + d.set_function("test", f) + assert d.context.udfs["test"] == f + + def test_set_function_invalid(self): + d = Dialog("id") + with pytest.raises(RuntimeError): + d.set_function("test", []) + + def test_run(self): + _config.backend.set_expected_response(200, TEST_GRAPH) + d = Dialog("id") + d.set_function("test_udf", lambda ctx: None) + d.run() diff --git a/tests/dialog/test_graph.py b/tests/dialog/test_graph.py new file mode 100644 index 0000000..809d04e --- /dev/null +++ b/tests/dialog/test_graph.py @@ -0,0 +1,135 @@ +import pytest +from auroraapi.dialog.graph import Graph, GraphEdge +from auroraapi.dialog.step.udf import UDFStep + +class TestGraphEdge(object): + def test_create(self): + g = GraphEdge("a","b","c") + assert g.left == "a" + assert g.right == "b" + assert g.prev == "c" + + def test_next_with_only_left(self): + g = GraphEdge("a") + assert g.next() == "a" + + def test_next_with_only_right(self): + g = GraphEdge("", "b") + assert g.next() == "b" + + def test_next_with_both(self): + g = GraphEdge("a", "b") + assert g.next() == "a" + + def test_next_cond_with_only_left(self): + g = GraphEdge("a") + assert g.next_cond(True) == "a" + assert g.next_cond(False) == None + + def test_next_cond_with_only_right(self): + g = GraphEdge("", "b") + assert g.next_cond(True) == None + assert g.next_cond(False) == "b" + + def test_next_cond_with_both(self): + g = GraphEdge("a", "b") + assert g.next_cond(True) == "a" + assert g.next_cond(False) == "b" + +class TestGraph(object): + def test_create_empty(self): + g = Graph({ + "start": "", + "edges": {}, + "nodes": {}, + }) + + assert g.start == "" + assert g.edges == {} + assert g.nodes == {} + + def test_create_valid(self): + g = Graph({ + "start": "a", + "edges": { + "a": { + "left": "b", + "right": "c", + "prev": "", + }, + "b": { + "left": "d", + "right": "", + "prev": "a", + }, + "c": { + "left": "", + "right": "", + "prev": "a", + }, + "d": { + "left": "", + "right": "", + "prev": "b", + }, + }, + "nodes": { + "a": { + "id": "a", + "type": "udf", + "data": { + "stepName": "udf_a", + "branchEnable": True, + }, + }, + "b": { + "id": "b", + "type": "udf", + "data": { + "stepName": "udf_b", + "branchEnable": False + } + }, + "c": { + "id": "c", + "type": "udf", + "data": { + "stepName": "udf_c", + "branchEnable": False + } + }, + "d": { + "id": "d", + "type": "udf", + "data": { + "stepName": "udf_d", + "branchEnable": False + } + }, + } + }) + + assert g.start == "a" + assert len(g.edges) == 4 + assert len(g.nodes) == 4 + assert all(isinstance(e, GraphEdge) for e in g.edges.values()) + assert all(isinstance(n, UDFStep) for n in g.nodes.values()) + + def test_create_invalid(self): + with pytest.raises(KeyError): + g = Graph({ + "start": "a", + "edges": { + "a": { + "left": "", + "right": "", + "prev": "", + }, + }, + "nodes": { + "a": { + "id": "a", + "type": "invalid", + }, + }, + }) \ No newline at end of file diff --git a/tests/dialog/test_util.py b/tests/dialog/test_util.py new file mode 100644 index 0000000..e635fca --- /dev/null +++ b/tests/dialog/test_util.py @@ -0,0 +1,57 @@ +import datetime, pytest +from auroraapi.dialog.util import * + +class TestParseDate(object): + def test_valid_date(self): + d = parse_date("2015-10-14T12:25:32.333Z") + assert isinstance(d, datetime.datetime) + + def test_invalid_date(self): + d = parse_date("2015-10-14 12:25:32.333Z") + assert isinstance(d, str) + assert d == "2015-10-14 12:25:32.333Z" + +class TestAssertCallable(object): + def test_callable(self): + assert assert_callable(lambda: None) != "" + + def test_not_callable(self): + with pytest.raises(RuntimeError) as e: + assert_callable([]) + +class TestIsIterable(object): + def test_iterable(self): + iters = [list(), set(), dict(), str()] + for i in iters: + assert is_iterable(i) + + def test_not_iterable(self): + niters = [lambda: None] + for i in niters: + assert not is_iterable(i) + +class TestParseOptional(object): + def test_valid(self): + parse = [ + ("34", int, 34), + (34, str, "34"), + ((1,2,3), list, [1,2,3]), + ] + for i in parse: + assert parse_optional(i[0], i[1]) == i[2] + + def test_invalid_default(self): + parse = [ + ("abc", int, None), + (123, list, None), + ] + for i in parse: + assert parse_optional(i[0], i[1]) == i[2] + + def test_invalid_custom(self): + parse = [ + ("abc", int, 0), + (123, list, [1,2,3]), + ] + for i in parse: + assert parse_optional(i[0], i[1], default=i[2]) == i[2] diff --git a/tests/mocks.py b/tests/mocks.py deleted file mode 100644 index 10fbff1..0000000 --- a/tests/mocks.py +++ /dev/null @@ -1,47 +0,0 @@ -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/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..2616fc2 --- /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/tests/mocks/backend.py b/tests/mocks/backend.py new file mode 100644 index 0000000..1252265 --- /dev/null +++ b/tests/mocks/backend.py @@ -0,0 +1,22 @@ +from auroraapi.api.backend import Backend +from auroraapi.errors import APIException + +class MockBackend(Backend): + def __init__(self): + self.responses = [] + + def set_expected_response(self, code, data): + self.responses = [(code, data)] + + def set_expected_responses(self, *res): + self.responses = list(res) + + def call(self, params): + code, data = self.responses.pop(0) + if code != 200: + try: + e = APIException(**data) + except: + e = APIException(message=data, status=code) + raise e + return data diff --git a/tests/mocks/requests.py b/tests/mocks/requests.py new file mode 100644 index 0000000..96e1c0d --- /dev/null +++ b/tests/mocks/requests.py @@ -0,0 +1,28 @@ +import json + +class raw(object): + def __init__(self, data): + self.data = data if isinstance(data, bytes) else bytes(str(data), "utf-8") + + def read(self, **kwargs): + return self.data + +class request(object): + def __init__(self, code, data, content_type): + self.code = code + self.data = data + self.content_type = content_type + + def __call__(self, method, url, **kwargs): + return response(self.code, self.data, self.content_type, kwargs) + +class response(object): + def __init__(self, code, data, content_type, req_args): + self.status_code = code + self.text = data + self.raw = raw(data) + self.headers = { "content-type": content_type } + self.req_args = req_args + + def json(self): + return json.loads(self.text) \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 1b12326..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,107 +0,0 @@ -import json, pytest -from auroraapi.api import * -from auroraapi.globals import _config - -class MockResponse(object): - def __init__(self): - self.status_code = 200 - self.headers = {} - self.text = "" - - def json(self): - return json.loads(self.text) - -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_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 - -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/test_audio.py b/tests/test_audio.py index 42211ec..28d1ec1 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -4,175 +4,175 @@ from auroraapi.audio import _is_silent, _pyaudio_record class TestAudioFile(object): - def setup(self): - with open("tests/assets/hw.wav", "rb") as f: - self.wav_data = f.read() + def setup(self): + with open("tests/assets/hw.wav", "rb") as f: + self.wav_data = f.read() - def test_create(self): - a = AudioFile(self.wav_data) - assert isinstance(a, AudioFile) - assert len(a.audio.raw_data) + 44 == len(self.wav_data) - - def test_write_to_file(self): - path = os.path.join(tempfile.gettempdir(), "out.wav") - a1 = AudioFile(self.wav_data) - a1.write_to_file(path) + def test_create(self): + a = AudioFile(self.wav_data) + assert isinstance(a, AudioFile) + assert len(a.audio.raw_data) + 44 == len(self.wav_data) + + def test_write_to_file(self): + path = os.path.join(tempfile.gettempdir(), "out.wav") + a1 = AudioFile(self.wav_data) + a1.write_to_file(path) - with open(path, "rb") as f: - d2 = f.read() - a2 = AudioFile(d2) - assert d2 == self.wav_data - assert a1.audio == a2.audio + with open(path, "rb") as f: + d2 = f.read() + a2 = AudioFile(d2) + assert d2 == self.wav_data + assert a1.audio == a2.audio - os.remove(path) + os.remove(path) - def test_get_wav(self): - a = AudioFile(self.wav_data) - wav = a.get_wav() - assert self.wav_data == wav - - def test_pad(self): - a = AudioFile(self.wav_data) - # adds 1s of padding to each side (2 x (16000 Hz * 16 bits/sample * 1 byte/8bits)) - pa = a.pad(1) - wav = pa.get_wav() - assert len(wav) == len(self.wav_data) + 64000 - assert wav[-1] == 0 - assert wav[44] == 0 - assert wav[44 + 32000] == self.wav_data[44] + def test_get_wav(self): + a = AudioFile(self.wav_data) + wav = a.get_wav() + assert self.wav_data == wav + + def test_pad(self): + a = AudioFile(self.wav_data) + # adds 1s of padding to each side (2 x (16000 Hz * 16 bits/sample * 1 byte/8bits)) + pa = a.pad(1) + wav = pa.get_wav() + assert len(wav) == len(self.wav_data) + 64000 + assert wav[-1] == 0 + assert wav[44] == 0 + assert wav[44 + 32000] == self.wav_data[44] - def test_pad_left(self): - a = AudioFile(self.wav_data) - # adds 1s of padding to left side (1 x (16000 Hz * 16 bits/sample * 1 byte/8bits)) - pa = a.pad_left(1) - wav = pa.get_wav() - assert len(wav) == len(self.wav_data) + 32000 - assert wav[-1] == self.wav_data[-1] - assert wav[44] == 0 - assert wav[44 + 32000] == self.wav_data[44] - - def test_pad_right(self): - a = AudioFile(self.wav_data) - # adds 1s of padding to right side (1 x (16000 Hz * 16 bits/sample * 1 byte/8bits)) - pa = a.pad_right(1) - wav = pa.get_wav() - assert len(wav) == len(self.wav_data) + 32000 - assert wav[-1] == 0 - assert wav[44] == self.wav_data[44] - assert wav[-32000] == 0 - - def test_trim_silent(self): - # replace all data with zeros: - # d = [x for x in self.wav_data[44:]] - d = self.wav_data[0:44] + bytes(r'\0' * len(self.wav_data[44:]), 'utf8') - a = AudioFile(d) - t = a.trim_silent() - # TODO: actually add a test here. for now, it just checks for compilation - # also, for some reason, trim_silent doesn't work, so figure that out - - def test_play(self): - with mock.patch('pyaudio.PyAudio', new=MockPyAudio): - a = AudioFile(self.wav_data) - a.play() + def test_pad_left(self): + a = AudioFile(self.wav_data) + # adds 1s of padding to left side (1 x (16000 Hz * 16 bits/sample * 1 byte/8bits)) + pa = a.pad_left(1) + wav = pa.get_wav() + assert len(wav) == len(self.wav_data) + 32000 + assert wav[-1] == self.wav_data[-1] + assert wav[44] == 0 + assert wav[44 + 32000] == self.wav_data[44] + + def test_pad_right(self): + a = AudioFile(self.wav_data) + # adds 1s of padding to right side (1 x (16000 Hz * 16 bits/sample * 1 byte/8bits)) + pa = a.pad_right(1) + wav = pa.get_wav() + assert len(wav) == len(self.wav_data) + 32000 + assert wav[-1] == 0 + assert wav[44] == self.wav_data[44] + assert wav[-32000] == 0 + + def test_trim_silent(self): + # replace all data with zeros: + # d = [x for x in self.wav_data[44:]] + d = self.wav_data[0:44] + bytes(r'\0' * len(self.wav_data[44:]), 'utf8') + a = AudioFile(d) + t = a.trim_silent() + # TODO: actually add a test here. for now, it just checks for compilation + # also, for some reason, trim_silent doesn't work, so figure that out + + def test_play(self): + with mock.patch('pyaudio.PyAudio', new=MockPyAudio): + a = AudioFile(self.wav_data) + a.play() - # 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() + # 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 new file mode 100644 index 0000000..806fc2c --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,29 @@ +import json +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_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 diff --git a/tests/test_globals.py b/tests/test_globals.py index 91d0e59..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 = "test" - c.app_token = "test123" + 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 == "test" - assert c.app_token == "test123" - 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 85d4f22..ae51ef5 100644 --- a/tests/test_interpret.py +++ b/tests/test_interpret.py @@ -1,25 +1,37 @@ -import pytest +import json, pytest 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 = { "intent": "test", "entities": {} } - i = Interpret(d) - assert isinstance(i, Interpret) - assert i.intent == "test" - 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 = { "intent": "test", "entities": { "abc": "123" } } - i = Interpret(d) - assert isinstance(i, Interpret) - assert i.intent == "test" - assert len(i.entities) == 1 - assert i.entities["abc"] == "123" \ No newline at end of file + 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_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 033c2d6..c406dd4 100644 --- a/tests/test_speech.py +++ b/tests/test_speech.py @@ -1,96 +1,86 @@ -import os, json, array, pytest, mock -import auroraapi -from tests.mocks import * +import pytest, mock from auroraapi.globals import _config -from auroraapi.api import APIException from auroraapi.audio import * -from auroraapi.text import Text +from auroraapi.errors import APIException from auroraapi.speech import * +from auroraapi.text import Text +from tests.mocks import * class TestSpeech(object): - def setup(self): - try: - _config.app_id = os.environ["APP_ID"] - _config.app_token = os.environ["APP_TOKEN"] - _config.device_id = os.environ["DEVICE_ID"] - except: - pass - - def teardown(self): - _config.app_id = None - _config.app_token = None - _config.device_id = None - - 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): - with open("tests/assets/hw.wav", "rb") as f: - Speech(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 test_text(self): - with open("tests/assets/hw.wav", "rb") as f: - s = Speech(AudioFile(f.read())) - t = s.text() + 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 - assert isinstance(t, Text) - assert t.text.lower().strip() == "hello world" + def test_text(self): + _config.backend.set_expected_response(200, { "transcript": "hello world" }) + s = Speech(self.audio) + t = s.text() -class TestSpeechNoCreds(object): - def test_text(self): - with pytest.raises(APIException): - with open("tests/assets/hw.wav", "rb") as f: - Speech(AudioFile(f.read())).text() + assert isinstance(t, Text) + assert t.text.lower().strip() == "hello world" class TestListen(object): - def setup(self): - with open("tests/assets/hw.wav", "rb") as f: - self.audio_file = AudioFile(f.read()) - try: - _config.app_id = os.environ["APP_ID"] - _config.app_token = os.environ["APP_TOKEN"] - _config.device_id = os.environ["DEVICE_ID"] - except: - pass - - def teardown(self): - _config.app_id = None - _config.app_token = None - _config.device_id = None + 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): - 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): - 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 5f597a2..55ca049 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1,75 +1,57 @@ import os, json, pytest from auroraapi.globals import _config -from auroraapi.api import APIException from auroraapi.audio import AudioFile -from auroraapi.text import Text -from auroraapi.speech import Speech +from auroraapi.errors import APIException from auroraapi.interpret import Interpret +from auroraapi.speech import Speech +from auroraapi.text import Text +from tests.mocks.backend import MockBackend class TestText(object): - def test_create_no_argument(self): - with pytest.raises(TypeError): - t = Text() - - def test_create(self): - t = Text("test") - assert isinstance(t, Text) - assert t.text == "test" - -class TestTextNoCreds(object): - def test_interpret(self): - with pytest.raises(APIException): - Text("test").interpret() - - def test_speech(self): - with pytest.raises(APIException): - Text("test").speech() + 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): - try: - _config.app_id = os.environ["APP_ID"] - _config.app_token = os.environ["APP_TOKEN"] - _config.device_id = os.environ["DEVICE_ID"] - except: - pass - - def teardown(self): - _config.app_id = None - _config.app_token = None - _config.device_id = None + def setup(self): + self.orig_backend = _config.backend + _config.backend = MockBackend() + + def teardown(self): + _config.backend = self.orig_backend - def test_interpret(self): - t = Text("hello") - i = t.interpret() - assert isinstance(i, Interpret) - assert i.intent == "greeting" - - def test_interpret_empty_string(self): - with pytest.raises(APIException): - Text("").interpret() + 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): - try: - _config.app_id = os.environ["APP_ID"] - _config.app_token = os.environ["APP_TOKEN"] - _config.device_id = os.environ["DEVICE_ID"] - except: - pass - - def teardown(self): - _config.app_id = None - _config.app_token = None - _config.device_id = None - - def test_speech(self): - t = Text("hello") - s = t.speech() - assert isinstance(s, Speech) - assert isinstance(s.audio, AudioFile) - assert len(s.audio.audio) > 0 - - def test_speech_empty_string(self): - with pytest.raises(APIException): - Text("").speech() + 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