1
0
mirror of https://github.com/gsi-upm/senpy synced 2024-11-22 00:02:28 +00:00

Merge branch '39-plugin-tests-missing'

This commit is contained in:
J. Fernando Sánchez 2017-08-23 15:56:43 +02:00
commit 9a2932b569
16 changed files with 298 additions and 131 deletions

View File

@ -70,8 +70,13 @@ dev: dev-$(PYMAIN)
test-all: $(addprefix test-,$(PYVERSIONS)) 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-%: 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) test: test-$(PYMAIN)

View File

@ -15,25 +15,12 @@ from threading import Thread
import os import os
import copy import copy
import fnmatch
import inspect
import sys
import importlib
import logging import logging
import traceback import traceback
import yaml
import subprocess
logger = logging.getLogger(__name__) 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): class Senpy(object):
""" Default Senpy extension for Flask """ """ Default Senpy extension for Flask """
@ -330,84 +317,6 @@ class Senpy(object):
th.start() th.start()
return th 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): def teardown(self, exception):
pass pass
@ -415,7 +324,8 @@ class Senpy(object):
def plugins(self): def plugins(self):
""" Return the plugins registered for a given application. """ """ Return the plugins registered for a given application. """
if self._outdated: 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 return self._plugin_list
def filter_plugins(self, **kwargs): def filter_plugins(self, **kwargs):

View File

@ -218,12 +218,11 @@ class BaseModel(SenpyMixin, dict):
super(BaseModel, self).__init__(temp) super(BaseModel, self).__init__(temp)
def _get_key(self, key): def _get_key(self, key):
if key is 'id':
key = '@id'
key = key.replace("__", ":", 1) key = key.replace("__", ":", 1)
return key return key
def __setitem__(self, key, value):
dict.__setitem__(self, key, value)
def __delitem__(self, key): def __delitem__(self, key):
dict.__delitem__(self, key) dict.__delitem__(self, key)
@ -244,8 +243,6 @@ class BaseModel(SenpyMixin, dict):
def _plain_dict(self): def _plain_dict(self):
d = {k: v for (k, v) in self.items() if k[0] != "_"} d = {k: v for (k, v) in self.items() if k[0] != "_"}
if 'id' in d:
d["@id"] = d.pop('id')
return d return d

View File

@ -1,14 +1,21 @@
from future import standard_library from future import standard_library
standard_library.install_aliases() standard_library.install_aliases()
import inspect
import os.path import os.path
import os import os
import pickle import pickle
import logging import logging
import tempfile import tempfile
import copy 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 from ..api import API_PARAMS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,6 +44,21 @@ class Plugin(models.Plugin):
def deactivate(self): def deactivate(self):
pass 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 SenpyPlugin = Plugin
@ -160,3 +182,86 @@ def pfilter(plugins, **kwargs):
if kwargs: if kwargs:
candidates = filter(matches, candidates) candidates = filter(matches, candidates)
return {p.name: p for p in 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

View File

@ -100,3 +100,54 @@ class CentroidConversion(EmotionConversionPlugin):
else: else:
raise Error('EMOTION MODEL NOT KNOWN') raise Error('EMOTION MODEL NOT KNOWN')
yield e 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"

View File

@ -1,7 +1,7 @@
import random import random
from senpy.plugins import EmotionPlugin from senpy.plugins import EmotionPlugin
from senpy.models import EmotionSet, Emotion from senpy.models import EmotionSet, Emotion, Entry
class RmoRandPlugin(EmotionPlugin): class RmoRandPlugin(EmotionPlugin):
@ -16,3 +16,11 @@ class RmoRandPlugin(EmotionPlugin):
emotionSet.prov__wasGeneratedBy = self.id emotionSet.prov__wasGeneratedBy = self.id
entry.emotions.append(emotionSet) entry.emotions.append(emotionSet)
yield entry 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'])

View File

@ -1,7 +1,7 @@
import random import random
from senpy.plugins import SentimentPlugin from senpy.plugins import SentimentPlugin
from senpy.models import Sentiment from senpy.models import Sentiment, Entry
class RandPlugin(SentimentPlugin): class RandPlugin(SentimentPlugin):
@ -22,3 +22,13 @@ class RandPlugin(SentimentPlugin):
entry.sentiments.append(sentiment) entry.sentiments.append(sentiment)
entry.language = lang entry.language = lang
yield entry 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

View File

@ -12,7 +12,7 @@ class Sentiment140Plugin(SentimentPlugin):
json.dumps({ json.dumps({
"language": lang, "language": lang,
"data": [{ "data": [{
"text": entry.text "text": entry.nif__isString
}] }]
})) }))
p = params.get("prefix", None) p = params.get("prefix", None)
@ -34,3 +34,20 @@ class Sentiment140Plugin(SentimentPlugin):
entry.sentiments.append(sentiment) entry.sentiments.append(sentiment)
entry.language = lang entry.language = lang
yield entry yield entry
test_cases = [
{
'entry': {
'nif:isString': 'I love Titanic'
},
'params': {},
'expected': {
"nif:isString": "I love Titanic",
'sentiments': [
{
'marl:hasPolarity': 'marl:Positive',
}
]
}
}
]

View File

@ -20,6 +20,9 @@
"@id": "me:hasSuggestions", "@id": "me:hasSuggestions",
"@container": "@set" "@container": "@set"
}, },
"onyx:hasEmotion": {
"@container": "@set"
},
"emotions": { "emotions": {
"@id": "onyx:hasEmotionSet", "@id": "onyx:hasEmotionSet",
"@container": "@set" "@container": "@set"

25
senpy/utils.py Normal file
View File

@ -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))

View File

@ -21,3 +21,6 @@ class AsyncPlugin(AnalysisPlugin):
values = self._do_async(2) values = self._do_async(2)
entry.async_values = values entry.async_values = values
yield entry yield entry
def test(self):
pass

View File

@ -6,3 +6,6 @@ class DummyPlugin(SentimentPlugin):
entry.text = entry.text[::-1] entry.text = entry.text[::-1]
entry.reversed = entry.get('reversed', 0) + 1 entry.reversed = entry.get('reversed', 0) + 1
yield entry yield entry
def test(self):
pass

View File

@ -9,3 +9,6 @@ class SleepPlugin(AnalysisPlugin):
def analyse_entry(self, entry, params): def analyse_entry(self, entry, params):
sleep(float(params.get("timeout", self.timeout))) sleep(float(params.get("timeout", self.timeout)))
yield entry yield entry
def test(self):
pass

View File

@ -17,17 +17,19 @@ def parse_resp(resp):
class BlueprintsTest(TestCase): class BlueprintsTest(TestCase):
def setUp(self): @classmethod
self.app = Flask("test_extensions") def setUpClass(cls):
self.app.debug = False """Set up only once, and re-use in every individual test"""
self.client = self.app.test_client() cls.app = Flask("test_extensions")
self.senpy = Senpy() cls.app.debug = False
self.senpy.init_app(self.app) cls.client = cls.app.test_client()
self.dir = os.path.join(os.path.dirname(__file__), "..") cls.senpy = Senpy()
self.senpy.add_folder(self.dir) cls.senpy.init_app(cls.app)
self.senpy.activate_plugin("Dummy", sync=True) cls.dir = os.path.join(os.path.dirname(__file__), "..")
self.senpy.activate_plugin("DummyRequired", sync=True) cls.senpy.add_folder(cls.dir)
self.senpy.default_plugin = 'Dummy' cls.senpy.activate_plugin("Dummy", sync=True)
cls.senpy.activate_plugin("DummyRequired", sync=True)
cls.senpy.default_plugin = 'Dummy'
def assertCode(self, resp, code): def assertCode(self, resp, code):
self.assertEqual(resp.status_code, code) self.assertEqual(resp.status_code, code)

View File

@ -10,6 +10,7 @@ except ImportError:
from functools import partial from functools import partial
from senpy.extensions import Senpy from senpy.extensions import Senpy
from senpy import plugins
from senpy.models import Error, Results, Entry, EmotionSet, Emotion, Plugin from senpy.models import Error, Results, Entry, EmotionSet, Emotion, Plugin
from flask import Flask from flask import Flask
from unittest import TestCase from unittest import TestCase
@ -18,7 +19,7 @@ from unittest import TestCase
class ExtensionsTest(TestCase): class ExtensionsTest(TestCase):
def setUp(self): def setUp(self):
self.app = Flask('test_extensions') 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, self.senpy = Senpy(plugin_folder=self.dir,
app=self.app, app=self.app,
default_plugins=False) default_plugins=False)
@ -38,8 +39,8 @@ class ExtensionsTest(TestCase):
print(self.senpy.plugins) print(self.senpy.plugins)
assert "Dummy" in self.senpy.plugins assert "Dummy" in self.senpy.plugins
def test_enabling(self): def test_installing(self):
""" Enabling a plugin """ """ Installing a plugin """
info = { info = {
'name': 'TestPip', 'name': 'TestPip',
'module': 'dummy', 'module': 'dummy',
@ -48,14 +49,13 @@ class ExtensionsTest(TestCase):
'version': 0 'version': 0
} }
root = os.path.join(self.dir, 'plugins', 'dummy_plugin') 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 name == 'TestPip'
assert module assert module
import noop import noop
dir(noop) dir(noop)
self.senpy.install_deps()
def test_installing(self): def test_enabling(self):
""" Enabling a plugin """ """ Enabling a plugin """
self.senpy.activate_all(sync=True) self.senpy.activate_all(sync=True)
assert len(self.senpy.plugins) >= 3 assert len(self.senpy.plugins) >= 3
@ -72,7 +72,7 @@ class ExtensionsTest(TestCase):
} }
root = os.path.join(self.dir, 'plugins', 'dummy_plugin') root = os.path.join(self.dir, 'plugins', 'dummy_plugin')
with self.assertRaises(Error): 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): def test_disabling(self):
""" Disabling a plugin """ """ Disabling a plugin """
@ -173,7 +173,7 @@ class ExtensionsTest(TestCase):
'onyx:usesEmotionModel': 'emoml:fsre-dimensions' 'onyx:usesEmotionModel': 'emoml:fsre-dimensions'
}) })
eSet1 = EmotionSet() eSet1 = EmotionSet()
eSet1.prov__wasGeneratedBy = plugin['id'] eSet1.prov__wasGeneratedBy = plugin['@id']
eSet1['onyx:hasEmotion'].append(Emotion({ eSet1['onyx:hasEmotion'].append(Emotion({
'emoml:arousal': 1, 'emoml:arousal': 1,
'emoml:potency': 0, 'emoml:potency': 0,

View File

@ -7,11 +7,11 @@ import tempfile
from unittest import TestCase from unittest import TestCase
from senpy.models import Results, Entry, EmotionSet, Emotion 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 from senpy.plugins.conversion.emotion.centroids import CentroidConversion
class ShelfDummyPlugin(SentimentPlugin, ShelfMixin): class ShelfDummyPlugin(plugins.SentimentPlugin, plugins.ShelfMixin):
def activate(self, *args, **kwargs): def activate(self, *args, **kwargs):
if 'counter' not in self.sh: if 'counter' not in self.sh:
self.sh['counter'] = 0 self.sh['counter'] = 0
@ -33,7 +33,6 @@ class PluginsTest(TestCase):
def tearDown(self): def tearDown(self):
if os.path.exists(self.shelf_dir): if os.path.exists(self.shelf_dir):
shutil.rmtree(self.shelf_dir) shutil.rmtree(self.shelf_dir)
if os.path.isfile(self.shelf_file): if os.path.isfile(self.shelf_file):
os.remove(self.shelf_file) os.remove(self.shelf_file)
@ -51,26 +50,29 @@ class PluginsTest(TestCase):
def test_shelf(self): def test_shelf(self):
''' A shelf is created and the value is stored ''' ''' A shelf is created and the value is stored '''
newfile = self.shelf_file + "new"
a = ShelfDummyPlugin(info={ a = ShelfDummyPlugin(info={
'name': 'shelve', 'name': 'shelve',
'version': 'test', 'version': 'test',
'shelf_file': self.shelf_file 'shelf_file': newfile
}) })
assert a.sh == {} assert a.sh == {}
a.activate() a.activate()
assert a.sh == {'counter': 0} assert a.sh == {'counter': 0}
assert a.shelf_file == self.shelf_file assert a.shelf_file == newfile
a.sh['a'] = 'fromA' a.sh['a'] = 'fromA'
assert a.sh['a'] == 'fromA' assert a.sh['a'] == 'fromA'
a.save() a.save()
sh = pickle.load(open(self.shelf_file, 'rb')) sh = pickle.load(open(newfile, 'rb'))
assert sh['a'] == 'fromA' assert sh['a'] == 'fromA'
def test_dummy_shelf(self): def test_dummy_shelf(self):
with open(self.shelf_file, 'wb') as f:
pickle.dump({'counter': 99}, f)
a = ShelfDummyPlugin(info={ a = ShelfDummyPlugin(info={
'name': 'DummyShelf', 'name': 'DummyShelf',
'shelf_file': self.shelf_file, 'shelf_file': self.shelf_file,
@ -80,9 +82,13 @@ class PluginsTest(TestCase):
assert a.shelf_file == self.shelf_file assert a.shelf_file == self.shelf_file
res1 = a.analyse(input=1) res1 = a.analyse(input=1)
assert res1.entries[0].nif__isString == 1 assert res1.entries[0].nif__isString == 100
res2 = a.analyse(input=1) a.deactivate()
assert res2.entries[0].nif__isString == 2 del a
with open(self.shelf_file, 'rb') as f:
sh = pickle.load(f)
assert sh['counter'] == 100
def test_corrupt_shelf(self): def test_corrupt_shelf(self):
''' Reusing the values of a previous shelf ''' ''' Reusing the values of a previous shelf '''
@ -202,3 +208,22 @@ class PluginsTest(TestCase):
e["Y-dimension"] = 0.3 e["Y-dimension"] = 0.3
res = c._backwards_conversion(e) res = c._backwards_conversion(e)
assert res["onyx:hasEmotionCategory"] == "c2" 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()