diff --git a/Makefile b/Makefile index da9b78b..cdf9297 100644 --- a/Makefile +++ b/Makefile @@ -70,8 +70,13 @@ dev: dev-$(PYMAIN) test-all: $(addprefix test-,$(PYVERSIONS)) +# Run setup.py from in an isolated container, built from the base image. +# This speeds tests up because the image has most (if not all) of the dependencies already. test-%: - docker run --rm --entrypoint /usr/local/bin/python -w /usr/src/app $(IMAGENAME):python$* setup.py test + docker rm $(NAME)-test-$* || true + docker create -ti --name $(NAME)-test-$* --entrypoint="" -w /usr/src/app/ $(IMAGENAME):python$* python setup.py test + docker cp . $(NAME)-test-$*:/usr/src/app + docker start -a $(NAME)-test-$* test: test-$(PYMAIN) diff --git a/senpy/extensions.py b/senpy/extensions.py index 7f830cf..2d68a57 100644 --- a/senpy/extensions.py +++ b/senpy/extensions.py @@ -15,25 +15,12 @@ from threading import Thread import os import copy -import fnmatch -import inspect -import sys -import importlib import logging import traceback -import yaml -import subprocess logger = logging.getLogger(__name__) -def log_subprocess_output(process): - for line in iter(process.stdout.readline, b''): - logger.info('%r', line) - for line in iter(process.stderr.readline, b''): - logger.error('%r', line) - - class Senpy(object): """ Default Senpy extension for Flask """ @@ -330,84 +317,6 @@ class Senpy(object): th.start() return th - @classmethod - def validate_info(cls, info): - return all(x in info for x in ('name', 'module', 'description', 'version')) - - def install_deps(self): - for i in self.plugins.values(): - self._install_deps(i) - - @classmethod - def _install_deps(cls, info=None): - requirements = info.get('requirements', []) - if requirements: - pip_args = ['pip'] - pip_args.append('install') - pip_args.append('--use-wheel') - for req in requirements: - pip_args.append(req) - logger.info('Installing requirements: ' + str(requirements)) - process = subprocess.Popen(pip_args, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - log_subprocess_output(process) - exitcode = process.wait() - if exitcode != 0: - raise Error("Dependencies not properly installed") - - @classmethod - def _load_module(cls, name, root): - sys.path.append(root) - tmp = importlib.import_module(name) - sys.path.remove(root) - return tmp - - @classmethod - def _load_plugin_from_info(cls, info, root): - if not cls.validate_info(info): - logger.warn('The module info is not valid.\n\t{}'.format(info)) - return None, None - module = info["module"] - name = info["name"] - - cls._install_deps(info) - tmp = cls._load_module(module, root) - - candidate = None - for _, obj in inspect.getmembers(tmp): - if inspect.isclass(obj) and inspect.getmodule(obj) == tmp: - logger.debug(("Found plugin class:" - " {}@{}").format(obj, inspect.getmodule(obj))) - candidate = obj - break - if not candidate: - logger.debug("No valid plugin for: {}".format(module)) - return - module = candidate(info=info) - return name, module - - @classmethod - def _load_plugin(cls, root, filename): - fpath = os.path.join(root, filename) - logger.debug("Loading plugin: {}".format(fpath)) - with open(fpath, 'r') as f: - info = yaml.load(f) - logger.debug("Info: {}".format(info)) - return cls._load_plugin_from_info(info, root) - - def _load_plugins(self): - plugins = {} - for search_folder in self._search_folders: - for root, dirnames, filenames in os.walk(search_folder): - for filename in fnmatch.filter(filenames, '*.senpy'): - name, plugin = self._load_plugin(root, filename) - if plugin and name: - plugins[name] = plugin - - self._outdated = False - return plugins - def teardown(self, exception): pass @@ -415,7 +324,8 @@ class Senpy(object): def plugins(self): """ Return the plugins registered for a given application. """ if self._outdated: - self._plugin_list = self._load_plugins() + self._plugin_list = plugins.load_plugins(self._search_folders) + self._outdated = False return self._plugin_list def filter_plugins(self, **kwargs): diff --git a/senpy/models.py b/senpy/models.py index b47947f..7f967da 100644 --- a/senpy/models.py +++ b/senpy/models.py @@ -218,12 +218,11 @@ class BaseModel(SenpyMixin, dict): super(BaseModel, self).__init__(temp) def _get_key(self, key): + if key is 'id': + key = '@id' key = key.replace("__", ":", 1) return key - def __setitem__(self, key, value): - dict.__setitem__(self, key, value) - def __delitem__(self, key): dict.__delitem__(self, key) @@ -244,8 +243,6 @@ class BaseModel(SenpyMixin, dict): def _plain_dict(self): d = {k: v for (k, v) in self.items() if k[0] != "_"} - if 'id' in d: - d["@id"] = d.pop('id') return d diff --git a/senpy/plugins/__init__.py b/senpy/plugins/__init__.py index 63bb66e..754966a 100644 --- a/senpy/plugins/__init__.py +++ b/senpy/plugins/__init__.py @@ -1,14 +1,21 @@ from future import standard_library standard_library.install_aliases() -import inspect import os.path import os import pickle import logging import tempfile import copy -from .. import models + +import fnmatch +import inspect +import sys +import subprocess +import importlib +import yaml + +from .. import models, utils from ..api import API_PARAMS logger = logging.getLogger(__name__) @@ -37,6 +44,21 @@ class Plugin(models.Plugin): def deactivate(self): pass + def test(self): + if not hasattr(self, 'test_cases'): + import inspect + raise AttributeError(('Plugin {} [{}] does not have any defined ' + 'test cases').format(self.id, inspect.getfile(self.__class__))) + for case in self.test_cases: + res = list(self.analyse_entry(models.Entry(case['entry']), + case['params'])) + exp = case['expected'] + if not isinstance(exp, list): + exp = [exp] + utils.check_template(res, exp) + for r in res: + r.validate() + SenpyPlugin = Plugin @@ -160,3 +182,86 @@ def pfilter(plugins, **kwargs): if kwargs: candidates = filter(matches, candidates) return {p.name: p for p in candidates} + + +def validate_info(info): + return all(x in info for x in ('name', 'module', 'description', 'version')) + + +def load_module(name, root): + sys.path.append(root) + tmp = importlib.import_module(name) + sys.path.remove(root) + return tmp + + +def log_subprocess_output(process): + for line in iter(process.stdout.readline, b''): + logger.info('%r', line) + for line in iter(process.stderr.readline, b''): + logger.error('%r', line) + + +def install_deps(*plugins): + for info in plugins: + requirements = info.get('requirements', []) + if requirements: + pip_args = ['pip'] + pip_args.append('install') + pip_args.append('--use-wheel') + for req in requirements: + pip_args.append(req) + logger.info('Installing requirements: ' + str(requirements)) + process = subprocess.Popen(pip_args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + log_subprocess_output(process) + exitcode = process.wait() + if exitcode != 0: + raise models.Error("Dependencies not properly installed") + + +def load_plugin_from_info(info, root, validator=validate_info): + if not validator(info): + logger.warn('The module info is not valid.\n\t{}'.format(info)) + return None, None + module = info["module"] + name = info["name"] + + install_deps(info) + tmp = load_module(module, root) + + candidate = None + for _, obj in inspect.getmembers(tmp): + if inspect.isclass(obj) and inspect.getmodule(obj) == tmp: + logger.debug(("Found plugin class:" + " {}@{}").format(obj, inspect.getmodule(obj))) + candidate = obj + break + if not candidate: + logger.debug("No valid plugin for: {}".format(module)) + return + module = candidate(info=info) + return name, module + + +def load_plugin(root, filename): + fpath = os.path.join(root, filename) + logger.debug("Loading plugin: {}".format(fpath)) + with open(fpath, 'r') as f: + info = yaml.load(f) + logger.debug("Info: {}".format(info)) + return load_plugin_from_info(info, root) + + +def load_plugins(folders, loader=load_plugin): + plugins = {} + for search_folder in folders: + for root, dirnames, filenames in os.walk(search_folder): + # Do not look for plugins in hidden or special folders + dirnames[:] = [d for d in dirnames if d[0] not in ['.', '_']] + for filename in fnmatch.filter(filenames, '*.senpy'): + name, plugin = loader(root, filename) + if plugin and name: + plugins[name] = plugin + return plugins diff --git a/senpy/plugins/conversion/emotion/centroids.py b/senpy/plugins/conversion/emotion/centroids.py index fe400cd..d8b5b67 100644 --- a/senpy/plugins/conversion/emotion/centroids.py +++ b/senpy/plugins/conversion/emotion/centroids.py @@ -100,3 +100,54 @@ class CentroidConversion(EmotionConversionPlugin): else: raise Error('EMOTION MODEL NOT KNOWN') yield e + + def test(self, info=None): + if not info: + info = { + "name": "CentroidTest", + "description": "Centroid test", + "version": 0, + "centroids": { + "c1": {"V1": 0.5, + "V2": 0.5}, + "c2": {"V1": -0.5, + "V2": 0.5}, + "c3": {"V1": -0.5, + "V2": -0.5}, + "c4": {"V1": 0.5, + "V2": -0.5}}, + "aliases": { + "V1": "X-dimension", + "V2": "Y-dimension" + }, + "centroids_direction": ["emoml:big6", "emoml:fsre-dimensions"] + } + + c = CentroidConversion(info) + + es1 = EmotionSet() + e1 = Emotion() + e1.onyx__hasEmotionCategory = "c1" + es1.onyx__hasEmotion.append(e1) + res = c._forward_conversion(es1) + assert res["X-dimension"] == 0.5 + assert res["Y-dimension"] == 0.5 + + e2 = Emotion() + e2.onyx__hasEmotionCategory = "c2" + es1.onyx__hasEmotion.append(e2) + res = c._forward_conversion(es1) + assert res["X-dimension"] == 0 + assert res["Y-dimension"] == 1 + + e = Emotion() + e["X-dimension"] = -0.2 + e["Y-dimension"] = -0.3 + res = c._backwards_conversion(e) + assert res["onyx:hasEmotionCategory"] == "c3" + + e = Emotion() + e["X-dimension"] = -0.2 + e["Y-dimension"] = 0.3 + res = c._backwards_conversion(e) + assert res["onyx:hasEmotionCategory"] == "c2" diff --git a/senpy/plugins/example/emoRand/emoRand.py b/senpy/plugins/example/emoRand/emoRand.py index 8de8e81..9212353 100644 --- a/senpy/plugins/example/emoRand/emoRand.py +++ b/senpy/plugins/example/emoRand/emoRand.py @@ -1,7 +1,7 @@ import random from senpy.plugins import EmotionPlugin -from senpy.models import EmotionSet, Emotion +from senpy.models import EmotionSet, Emotion, Entry class RmoRandPlugin(EmotionPlugin): @@ -16,3 +16,11 @@ class RmoRandPlugin(EmotionPlugin): emotionSet.prov__wasGeneratedBy = self.id entry.emotions.append(emotionSet) yield entry + + def test(self): + params = dict() + results = list() + for i in range(100): + res = next(self.analyse_entry(Entry(nif__isString="Hello"), params)) + res.validate() + results.append(res.emotions[0]['onyx:hasEmotion'][0]['onyx:hasEmotionCategory']) diff --git a/senpy/plugins/example/rand/rand.py b/senpy/plugins/example/rand/rand.py index aa92c70..645e63b 100644 --- a/senpy/plugins/example/rand/rand.py +++ b/senpy/plugins/example/rand/rand.py @@ -1,7 +1,7 @@ import random from senpy.plugins import SentimentPlugin -from senpy.models import Sentiment +from senpy.models import Sentiment, Entry class RandPlugin(SentimentPlugin): @@ -22,3 +22,13 @@ class RandPlugin(SentimentPlugin): entry.sentiments.append(sentiment) entry.language = lang yield entry + + def test(self): + params = dict() + results = list() + for i in range(100): + res = next(self.analyse_entry(Entry(nif__isString="Hello"), params)) + res.validate() + results.append(res.sentiments[0]['marl:hasPolarity']) + assert 'marl:Positive' in results + assert 'marl:Negative' in results diff --git a/senpy/plugins/sentiment/sentiment140/sentiment140.py b/senpy/plugins/sentiment/sentiment140/sentiment140.py index b8e6d6f..e7af624 100644 --- a/senpy/plugins/sentiment/sentiment140/sentiment140.py +++ b/senpy/plugins/sentiment/sentiment140/sentiment140.py @@ -12,7 +12,7 @@ class Sentiment140Plugin(SentimentPlugin): json.dumps({ "language": lang, "data": [{ - "text": entry.text + "text": entry.nif__isString }] })) p = params.get("prefix", None) @@ -34,3 +34,20 @@ class Sentiment140Plugin(SentimentPlugin): entry.sentiments.append(sentiment) entry.language = lang yield entry + + test_cases = [ + { + 'entry': { + 'nif:isString': 'I love Titanic' + }, + 'params': {}, + 'expected': { + "nif:isString": "I love Titanic", + 'sentiments': [ + { + 'marl:hasPolarity': 'marl:Positive', + } + ] + } + } + ] diff --git a/senpy/schemas/context.jsonld b/senpy/schemas/context.jsonld index 86d6c92..c21de96 100644 --- a/senpy/schemas/context.jsonld +++ b/senpy/schemas/context.jsonld @@ -20,6 +20,9 @@ "@id": "me:hasSuggestions", "@container": "@set" }, + "onyx:hasEmotion": { + "@container": "@set" + }, "emotions": { "@id": "onyx:hasEmotionSet", "@container": "@set" diff --git a/senpy/utils.py b/senpy/utils.py new file mode 100644 index 0000000..6dae8c4 --- /dev/null +++ b/senpy/utils.py @@ -0,0 +1,25 @@ +from . import models + + +def check_template(indict, template): + if isinstance(template, dict) and isinstance(indict, dict): + for k, v in template.items(): + if k not in indict: + return '{} not in {}'.format(k, indict) + check_template(indict[k], v) + elif isinstance(template, list) and isinstance(indict, list): + if len(indict) != len(template): + raise models.Error('Different size for {} and {}'.format(indict, template)) + for e in template: + found = False + for i in indict: + try: + check_template(i, e) + found = True + except models.Error as ex: + continue + if not found: + raise models.Error('{} not found in {}'.format(e, indict)) + else: + if indict != template: + raise models.Error('{} and {} are different'.format(indict, template)) diff --git a/tests/plugins/async_plugin/asyncplugin.py b/tests/plugins/async_plugin/asyncplugin.py index 976e6c8..a37f2cb 100644 --- a/tests/plugins/async_plugin/asyncplugin.py +++ b/tests/plugins/async_plugin/asyncplugin.py @@ -21,3 +21,6 @@ class AsyncPlugin(AnalysisPlugin): values = self._do_async(2) entry.async_values = values yield entry + + def test(self): + pass diff --git a/tests/plugins/dummy_plugin/dummy.py b/tests/plugins/dummy_plugin/dummy.py index b6b3966..e020acc 100644 --- a/tests/plugins/dummy_plugin/dummy.py +++ b/tests/plugins/dummy_plugin/dummy.py @@ -6,3 +6,6 @@ class DummyPlugin(SentimentPlugin): entry.text = entry.text[::-1] entry.reversed = entry.get('reversed', 0) + 1 yield entry + + def test(self): + pass diff --git a/tests/plugins/sleep_plugin/sleep.py b/tests/plugins/sleep_plugin/sleep.py index 30ff9db..770dd3b 100644 --- a/tests/plugins/sleep_plugin/sleep.py +++ b/tests/plugins/sleep_plugin/sleep.py @@ -9,3 +9,6 @@ class SleepPlugin(AnalysisPlugin): def analyse_entry(self, entry, params): sleep(float(params.get("timeout", self.timeout))) yield entry + + def test(self): + pass diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 78b22ce..1e1cd9a 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -17,17 +17,19 @@ def parse_resp(resp): class BlueprintsTest(TestCase): - def setUp(self): - self.app = Flask("test_extensions") - self.app.debug = False - self.client = self.app.test_client() - self.senpy = Senpy() - self.senpy.init_app(self.app) - self.dir = os.path.join(os.path.dirname(__file__), "..") - self.senpy.add_folder(self.dir) - self.senpy.activate_plugin("Dummy", sync=True) - self.senpy.activate_plugin("DummyRequired", sync=True) - self.senpy.default_plugin = 'Dummy' + @classmethod + def setUpClass(cls): + """Set up only once, and re-use in every individual test""" + cls.app = Flask("test_extensions") + cls.app.debug = False + cls.client = cls.app.test_client() + cls.senpy = Senpy() + cls.senpy.init_app(cls.app) + cls.dir = os.path.join(os.path.dirname(__file__), "..") + cls.senpy.add_folder(cls.dir) + cls.senpy.activate_plugin("Dummy", sync=True) + cls.senpy.activate_plugin("DummyRequired", sync=True) + cls.senpy.default_plugin = 'Dummy' def assertCode(self, resp, code): self.assertEqual(resp.status_code, code) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9293fde..131ac70 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10,6 +10,7 @@ except ImportError: from functools import partial from senpy.extensions import Senpy +from senpy import plugins from senpy.models import Error, Results, Entry, EmotionSet, Emotion, Plugin from flask import Flask from unittest import TestCase @@ -18,7 +19,7 @@ from unittest import TestCase class ExtensionsTest(TestCase): def setUp(self): self.app = Flask('test_extensions') - self.dir = os.path.join(os.path.dirname(__file__)) + self.dir = os.path.dirname(__file__) self.senpy = Senpy(plugin_folder=self.dir, app=self.app, default_plugins=False) @@ -38,8 +39,8 @@ class ExtensionsTest(TestCase): print(self.senpy.plugins) assert "Dummy" in self.senpy.plugins - def test_enabling(self): - """ Enabling a plugin """ + def test_installing(self): + """ Installing a plugin """ info = { 'name': 'TestPip', 'module': 'dummy', @@ -48,14 +49,13 @@ class ExtensionsTest(TestCase): 'version': 0 } root = os.path.join(self.dir, 'plugins', 'dummy_plugin') - name, module = self.senpy._load_plugin_from_info(info, root=root) + name, module = plugins.load_plugin_from_info(info, root=root) assert name == 'TestPip' assert module import noop dir(noop) - self.senpy.install_deps() - def test_installing(self): + def test_enabling(self): """ Enabling a plugin """ self.senpy.activate_all(sync=True) assert len(self.senpy.plugins) >= 3 @@ -72,7 +72,7 @@ class ExtensionsTest(TestCase): } root = os.path.join(self.dir, 'plugins', 'dummy_plugin') with self.assertRaises(Error): - name, module = self.senpy._load_plugin_from_info(info, root=root) + name, module = plugins.load_plugin_from_info(info, root=root) def test_disabling(self): """ Disabling a plugin """ @@ -173,7 +173,7 @@ class ExtensionsTest(TestCase): 'onyx:usesEmotionModel': 'emoml:fsre-dimensions' }) eSet1 = EmotionSet() - eSet1.prov__wasGeneratedBy = plugin['id'] + eSet1.prov__wasGeneratedBy = plugin['@id'] eSet1['onyx:hasEmotion'].append(Emotion({ 'emoml:arousal': 1, 'emoml:potency': 0, diff --git a/tests/test_plugins.py b/tests/test_plugins.py index fe8ee1c..0108194 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -7,11 +7,11 @@ import tempfile from unittest import TestCase from senpy.models import Results, Entry, EmotionSet, Emotion -from senpy.plugins import SentimentPlugin, ShelfMixin +from senpy import plugins from senpy.plugins.conversion.emotion.centroids import CentroidConversion -class ShelfDummyPlugin(SentimentPlugin, ShelfMixin): +class ShelfDummyPlugin(plugins.SentimentPlugin, plugins.ShelfMixin): def activate(self, *args, **kwargs): if 'counter' not in self.sh: self.sh['counter'] = 0 @@ -33,7 +33,6 @@ class PluginsTest(TestCase): def tearDown(self): if os.path.exists(self.shelf_dir): shutil.rmtree(self.shelf_dir) - if os.path.isfile(self.shelf_file): os.remove(self.shelf_file) @@ -51,26 +50,29 @@ class PluginsTest(TestCase): def test_shelf(self): ''' A shelf is created and the value is stored ''' + newfile = self.shelf_file + "new" a = ShelfDummyPlugin(info={ 'name': 'shelve', 'version': 'test', - 'shelf_file': self.shelf_file + 'shelf_file': newfile }) assert a.sh == {} a.activate() assert a.sh == {'counter': 0} - assert a.shelf_file == self.shelf_file + assert a.shelf_file == newfile a.sh['a'] = 'fromA' assert a.sh['a'] == 'fromA' a.save() - sh = pickle.load(open(self.shelf_file, 'rb')) + sh = pickle.load(open(newfile, 'rb')) assert sh['a'] == 'fromA' def test_dummy_shelf(self): + with open(self.shelf_file, 'wb') as f: + pickle.dump({'counter': 99}, f) a = ShelfDummyPlugin(info={ 'name': 'DummyShelf', 'shelf_file': self.shelf_file, @@ -80,9 +82,13 @@ class PluginsTest(TestCase): assert a.shelf_file == self.shelf_file res1 = a.analyse(input=1) - assert res1.entries[0].nif__isString == 1 - res2 = a.analyse(input=1) - assert res2.entries[0].nif__isString == 2 + assert res1.entries[0].nif__isString == 100 + a.deactivate() + del a + + with open(self.shelf_file, 'rb') as f: + sh = pickle.load(f) + assert sh['counter'] == 100 def test_corrupt_shelf(self): ''' Reusing the values of a previous shelf ''' @@ -202,3 +208,22 @@ class PluginsTest(TestCase): e["Y-dimension"] = 0.3 res = c._backwards_conversion(e) assert res["onyx:hasEmotionCategory"] == "c2" + + +def make_mini_test(plugin): + def mini_test(self): + plugin.test() + return mini_test + + +def add_tests(): + root = os.path.dirname(__file__) + plugs = plugins.load_plugins(os.path.join(root, "..")) + for k, v in plugs.items(): + t_method = make_mini_test(v) + t_method.__name__ = 'test_plugin_{}'.format(k) + setattr(PluginsTest, t_method.__name__, t_method) + del t_method + + +add_tests()