1
0
mirror of https://github.com/gsi-upm/senpy synced 2025-08-24 02:22:20 +00:00

WIP: working on a full refactor for v2.0

This is still not functional, because it involves a LOT of changes to
the basic structure of the project. Some of the main changes can be seen
in the CHANGELOG.md file, if you're interested, but it boils down to
simplifying the logic of plugins (no more activation/deactivation
shenanigans), more robust typing and use of schemas (pydantic) to
avoid inconsistencies and user errors.
This commit is contained in:
J. Fernando Sánchez
2024-12-13 00:01:27 +01:00
parent 9414b0e3e6
commit 54e4dcd5d4
54 changed files with 919 additions and 1487 deletions

View File

@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
from unittest import TestCase
from senpy.api import (boolean, parse_params, get_extra_params, parse_analyses,
API_PARAMS, NIF_PARAMS, WEB_PARAMS)
from senpy.models import Error, Plugin
from senpy.errors import Error
from senpy.models import Plugin
class APITest(TestCase):
@@ -72,7 +73,7 @@ class APITest(TestCase):
in2 = {
'apikey': 25
}
extra_params = {
extra_params: dict = {
"apikey": {
"aliases": [
"apikey",
@@ -110,7 +111,7 @@ class APITest(TestCase):
def test_parse_analyses(self):
'''The API should parse user parameters and return them in a format that plugins can use'''
plugins = [
Plugin({
Plugin.parse_obj({
'name': 'plugin1',
'extra_params': {
# Incompatible parameter
@@ -133,7 +134,7 @@ class APITest(TestCase):
'options': ['value2_1', 'value2_2', 'value3_3']
}
}
}), Plugin({
}), Plugin.parse_obj({
'name': 'plugin2',
'extra_params': {
'param0': {
@@ -186,7 +187,7 @@ class APITest(TestCase):
def test_get_extra_params(self):
'''The API should return the list of valid parameters for a set of plugins'''
plugins = [
Plugin({
Plugin.parse_obj({
'name': 'plugin1',
'extra_params': {
# Incompatible parameter
@@ -208,7 +209,7 @@ class APITest(TestCase):
'options': ['value2_1', 'value2_2', 'value3_3']
}
}
}), Plugin({
}), Plugin.parse_obj({
'name': 'plugin2',
'extra_params': {
'param0': {
@@ -234,14 +235,14 @@ class APITest(TestCase):
expected = {
# Overlapping parameters
'plugin1.param0': plugins[0]['extra_params']['param0'],
'plugin1.param1': plugins[0]['extra_params']['param1'],
'plugin2.param0': plugins[1]['extra_params']['param0'],
'plugin2.param1': plugins[1]['extra_params']['param1'],
'plugin1.param0': plugins[0].extra_params['param0'],
'plugin1.param1': plugins[0].extra_params['param1'],
'plugin2.param0': plugins[1].extra_params['param0'],
'plugin2.param1': plugins[1].extra_params['param1'],
# Non-overlapping parameters
'param2': plugins[0]['extra_params']['param2'],
'param3': plugins[1]['extra_params']['param3'],
'param2': plugins[0].extra_params['param2'],
'param3': plugins[1].extra_params['param3'],
# Intersection of overlapping parameters
'param1': {

View File

@@ -38,11 +38,8 @@ class BlueprintsTest(TestCase):
"""Set up only once, and re-use in every individual test"""
cls.app = Flask("test_extensions")
cls.client = cls.app.test_client()
cls.senpy = Senpy(default_plugins=True, strict=False) # Ignore any optional plugins
cls.senpy.init_app(cls.app)
cls.dir = os.path.join(os.path.dirname(__file__), "..")
cls.senpy.add_folder(cls.dir)
cls.senpy.activate_all()
cls.senpy = Senpy(default_plugins=True, app=cls.app, plugin_folders=[cls.dir, "."], strict=False) # Ignore any optional plugins
cls.senpy.default_plugin = 'Dummy'
def setUp(self):

View File

@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
from unittest import TestCase
from senpy.cli import main_function
from senpy.models import Error
from senpy.errors import Error
class CLITest(TestCase):

View File

@@ -18,7 +18,8 @@ from unittest import TestCase
from senpy.testing import patch_requests
from senpy.client import Client
from senpy.models import Results, Plugins, Error
from senpy.models import Results, Plugins
from senpy.errors import Error
from senpy.plugins import AnalysisPlugin

View File

@@ -23,7 +23,8 @@ import logging
from functools import partial
from senpy.extensions import Senpy
from senpy import plugins, config, api
from senpy.models import Error, Results, Entry, EmotionSet, Emotion, Plugin
from senpy.models import Results, Entry, EmotionSet, Emotion, Plugin
from senpy.errors import Error
from flask import Flask
from unittest import TestCase
@@ -41,8 +42,7 @@ class ExtensionsTest(TestCase):
self.senpy = Senpy(plugin_folder=self.examples_dir,
app=self.app,
default_plugins=False)
self.senpy.deactivate_all()
self.senpy.activate_plugin("Dummy", sync=True)
self.senpy.default_plugin = "Dummy"
self.app.config['TESTING'] = True # Tell Flask not to catch Exceptions
def test_init(self):
@@ -62,9 +62,9 @@ class ExtensionsTest(TestCase):
'''Should be able to add and delete new plugins. '''
new = plugins.Analyser(name='new', description='new', version=0)
self.senpy.add_plugin(new)
assert new in self.senpy.plugins(is_activated=False)
assert new in self.senpy.plugins()
self.senpy.delete_plugin(new)
assert new not in self.senpy.plugins(is_activated=False)
assert new not in self.senpy.plugins()
def test_adding_folder(self):
""" It should be possible for senpy to look for plugins in more folders. """
@@ -74,7 +74,7 @@ class ExtensionsTest(TestCase):
default_plugins=False)
assert not senpy.analysis_plugins()
senpy.add_folder(self.examples_dir)
assert senpy.plugins(plugin_type=plugins.Analyser, is_activated=False)
assert senpy.plugins(plugin_type=plugins.Analyser)
self.assertRaises(AttributeError, senpy.add_folder, 'DOES NOT EXIST')
def test_installing(self):
@@ -94,9 +94,8 @@ class ExtensionsTest(TestCase):
def test_enabling(self):
""" Enabling a plugin """
self.senpy.activate_all(sync=True)
assert len(self.senpy.plugins()) >= 3
assert self.senpy.get_plugin("Sleep").is_activated
assert self.senpy.get_plugin("Sleep")
def test_installing_nonexistent(self):
""" Fail if the dependencies cannot be met """
@@ -110,23 +109,14 @@ class ExtensionsTest(TestCase):
with self.assertRaises(Error):
plugins.install_deps(info)
def test_disabling(self):
""" Disabling a plugin """
self.senpy.deactivate_all(sync=True)
assert not self.senpy.get_plugin("dummy").is_activated
assert not self.senpy.get_plugin("sleep").is_activated
def test_default(self):
""" Default plugin should be set """
assert self.senpy.default_plugin
assert self.senpy.default_plugin.name == "dummy"
self.senpy.deactivate_all(sync=True)
logging.debug("Default: {}".format(self.senpy.default_plugin))
assert self.senpy.default_plugin is None
def test_noplugin(self):
""" Don't analyse if there isn't any plugin installed """
self.senpy.deactivate_all(sync=True)
nosenpy = Senpy(default_plugins=False, plugin_folders=[])
self.assertRaises(Error, partial(analyse, self.senpy, input="tupni"))
def test_analyse(self):
@@ -177,7 +167,7 @@ class ExtensionsTest(TestCase):
def test_analyse_error(self):
class ErrorPlugin(plugins.Analyser):
author = 'nobody'
author: str = 'nobody'
version = 0
ex = Error()
@@ -205,17 +195,12 @@ class ExtensionsTest(TestCase):
""" Filtering plugins """
assert len(self.senpy.plugins(name="Dummy")) > 0
assert not len(self.senpy.plugins(name="NotDummy"))
assert self.senpy.plugins(name="Dummy", is_activated=True)
self.senpy.deactivate_plugin("Dummy", sync=True)
assert not len(self.senpy.plugins(name="Dummy",
is_activated=True))
def test_load_default_plugins(self):
senpy = Senpy(plugin_folder=self.examples_dir, default_plugins=True)
assert len(senpy.plugins(is_activated=False)) > 1
assert len(senpy.plugins()) > 1
def test_convert_emotions(self):
self.senpy.activate_all(sync=True)
plugin = Plugin({
'id': 'imaginary',
'onyx:usesEmotionModel': 'emoml:fsre-dimensions'

View File

@@ -26,14 +26,14 @@ from senpy.models import (Analysis,
EmotionAnalysis,
EmotionSet,
Entry,
Error,
ErrorResponse,
Results,
Sentiment,
SentimentPlugin,
Plugins,
from_string,
from_dict,
subtypes)
from_dict)
from senpy.errors import Error
from senpy import plugins
from pprint import pprint
@@ -117,7 +117,7 @@ class ModelsTest(TestCase):
def test_plugins(self):
self.assertRaises(Error, plugins.Plugin)
p = plugins.SentimentPlugin({"name": "dummy",
p = plugins.SentimentPlugin.parse_obj({"name": "dummy",
"description": "I do nothing",
"version": 0,
"extra_params": {
@@ -152,11 +152,6 @@ class ModelsTest(TestCase):
s = str(r)
assert "_testing" not in s
def test_serialize(self):
for k, v in subtypes().items():
e = v()
e.serialize()
def test_turtle(self):
"""Any model should be serializable as a turtle file"""
ana = EmotionAnalysis()

View File

@@ -35,24 +35,23 @@ ROOT = os.path.join(os.path.dirname(__file__), '..')
class ShelfDummyPlugin(plugins.SentimentPlugin, plugins.ShelfMixin):
'''Dummy plugin for tests.'''
name = 'Shelf'
version = 0
author = 'the senpy community'
name: str = 'Shelf'
version: str = '0'
author: str = 'the senpy community'
def activate(self, *args, **kwargs):
def __init__(self, **kwargs):
super().__init__(**kwargs)
if 'counter' not in self.sh:
self.sh['counter'] = 0
self.save()
def deactivate(self, *args, **kwargs):
self.save()
def analyse(self, *args, **kwargs):
self.sh['counter'] = self.sh['counter'] + 1
e = Entry()
e.nif__isString = self.sh['counter']
r = Results()
r.entries.append(e)
self.save()
return r
@@ -82,7 +81,6 @@ class PluginsTest(TestCase):
info={'name': 'default_shelve_file',
'description': 'Dummy plugin for tests',
'version': 'test'})
a.activate()
assert os.path.isfile(a.shelf_file)
os.remove(a.shelf_file)
@@ -114,8 +112,6 @@ class PluginsTest(TestCase):
'version': 'test',
'shelf_file': newfile
})
assert a.sh == {}
a.activate()
assert a.sh == {'counter': 0}
assert a.shelf_file == newfile
@@ -137,12 +133,10 @@ class PluginsTest(TestCase):
'shelf_file': self.shelf_file,
'version': 'test'
})
a.activate()
assert a.shelf_file == self.shelf_file
res1 = a.analyse(input=1)
assert res1.entries[0].nif__isString == 100
a.deactivate()
del a
with open(self.shelf_file, 'rb') as f:
@@ -190,7 +184,6 @@ class PluginsTest(TestCase):
'version': 'test',
'shelf_file': self.shelf_file
})
a.activate()
print('Shelf file: %s' % a.shelf_file)
a.sh['a'] = 'fromA'
a.save()
@@ -201,7 +194,6 @@ class PluginsTest(TestCase):
'version': 'test',
'shelf_file': self.shelf_file
})
b.activate()
assert b.sh['a'] == 'fromA'
b.sh['a'] = 'fromB'
assert b.sh['a'] == 'fromB'
@@ -228,8 +220,8 @@ class PluginsTest(TestCase):
class MyBox(plugins.Box):
''' Vague description'''
author = 'me'
version = 0
author: str = 'me'
version: str = 0
def to_features(self, entry, **kwargs):
return entry.text.split()
@@ -243,7 +235,7 @@ class PluginsTest(TestCase):
entry.myAnnotation = 'DETECTED'
return entry
test_cases = [
test_cases: list[dict] = [
{
'input': "nothing here",
'expected': {'myAnnotation': 'DETECTED'},
@@ -260,8 +252,8 @@ class PluginsTest(TestCase):
class SentimentBox(plugins.SentimentBox):
''' Vague description'''
author = 'me'
version = 0
author: str = 'me'
version: str = 0
def predict_one(self, features, **kwargs):
text = ' '.join(features)
@@ -269,7 +261,7 @@ class PluginsTest(TestCase):
return [1, 0, 0]
return [0, 0, 1]
test_cases = [
test_cases: list[dict] = [
{
'input': 'a happy face :)',
'polarity': 'marl:Positive'
@@ -355,7 +347,7 @@ class PluginsTest(TestCase):
class DummyPlugin(plugins.SentimentBox):
description = 'Plugin to test evaluation'
version = 0
version: str = 0
classes = ['marl:Positive', 'marl:Negative']
@@ -365,7 +357,7 @@ class PluginsTest(TestCase):
class SmartPlugin(plugins.SentimentBox):
description = 'Plugin to test evaluation'
version = 0
version: str = 0
classes = ['marl:Positive', 'marl:Negative']

View File

@@ -1,80 +0,0 @@
#
# Copyright 2014 Grupo de Sistemas Inteligentes (GSI) DIT, UPM
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
from __future__ import print_function
import json
import unittest
import os
from os import path
from fnmatch import fnmatch
from jsonschema import RefResolver, Draft4Validator, ValidationError
from senpy.models import read_schema
root_path = path.join(path.dirname(path.realpath(__file__)), '..')
schema_folder = path.join(root_path, 'senpy', 'schemas')
examples_path = path.join(root_path, 'docs', 'examples')
bad_examples_path = path.join(root_path, 'docs', 'bad-examples')
class JSONSchemaTests(unittest.TestCase):
def test_definitions(self):
read_schema('definitions.json')
def do_create_(jsfile, success):
def do_expected(self):
with open(jsfile) as f:
js = json.load(f)
try:
assert '@type' in js
schema_name = js['@type']
with open(os.path.join(schema_folder, schema_name +
".json")) as file_object:
schema = json.load(file_object)
resolver = RefResolver('file://' + schema_folder + '/', schema)
validator = Draft4Validator(schema, resolver=resolver)
validator.validate(js)
except (AssertionError, ValidationError, KeyError) as ex:
if success:
raise
return
assert success
return do_expected
def add_examples(dirname, success):
for dirpath, dirnames, filenames in os.walk(dirname):
for i in filenames:
if fnmatch(i, '*.json'):
filename = path.join(dirpath, i)
test_method = do_create_(filename, success)
test_method.__name__ = 'test_file_%s_success_%s' % (filename,
success)
test_method.__doc__ = '%s should %svalidate' % (filename, ''
if success else
'not')
setattr(JSONSchemaTests, test_method.__name__, test_method)
del test_method
add_examples(examples_path, True)
add_examples(bad_examples_path, False)
if __name__ == '__main__':
unittest.main()

View File

@@ -42,12 +42,10 @@ class SemanticsTest(TestCase):
"""Set up only once, and re-use in every individual test"""
cls.app = Flask("test_extensions")
cls.client = cls.app.test_client()
cls.senpy = Senpy(default_plugins=True)
cls.senpy = Senpy(plugin_folder=None, default_plugins=True)
cls.senpy.init_app(cls.app)
cls.dir = os.path.join(os.path.dirname(__file__), "..")
cls.senpy.add_folder(cls.dir)
cls.senpy.activate_all()
cls.senpy.default_plugin = 'Dummy'
#cls.dir = os.path.join(os.path.dirname(__file__), "..")
#cls.senpy.add_folder(cls.dir)
def setUp(self):
self.app.config['TESTING'] = True # Tell Flask not to catch Exceptions
@@ -57,10 +55,10 @@ class SemanticsTest(TestCase):
def test_sentiment(self):
"""
A sentiment analysis call in JSON-LD
a sentiment analysis call in json-ld
"""
# We use expanded JSON-LD and ignore the context, because in general
# the context is a URIS to the service and that URI is not
# we use expanded json-ld and ignore the context, because in general
# the context is a uris to the service and that uri is not
# available outside of self.client
params = {
'input': 'hello',
@@ -69,28 +67,28 @@ class SemanticsTest(TestCase):
'expanded': True,
'prefix': 'http://default.example/#'
}
resp = self.client.get("/api/basic?{}".format(urlencode(params)))
resp = self.client.get("/api/sentiment-basic?{}".format(urlencode(params)))
self.assertCode(resp, 200)
g = parse_resp(resp, fmt='json-ld')
print('Got this graph: ', g.serialize(format='ttl'))
assert g
qres = g.query("""
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX marl: <http://www.gsi.upm.es/ontologies/marl/ns#>
PREFIX nif: <http://persistence.uni-leipzig.org/nlp2rdf/ontologies/nif-core#>
PREFIX onyx: <http://www.gsi.upm.es/ontologies/onyx/ns#>
PREFIX senpy: <http://www.gsi.upm.es/onto/senpy/ns#>
prefix prov: <http://www.w3.org/ns/prov#>
prefix marl: <http://www.gsi.upm.es/ontologies/marl/ns#>
prefix nif: <http://persistence.uni-leipzig.org/nlp2rdf/ontologies/nif-core#>
prefix onyx: <http://www.gsi.upm.es/ontologies/onyx/ns#>
prefix senpy: <http://www.gsi.upm.es/ontologies/senpy/ns#>
SELECT DISTINCT ?entry ?text ?sentiment
WHERE {
?entry a senpy:Entry .
?entry marl:hasOpinion ?o .
?entry nif:isString ?text .
?o marl:hasPolarity ?sentiment .
}""")
assert len(qres) == 1
SELECT distinct ?entry ?text ?sentiment
WHERE {
?entry a senpy:Entry .
?entry marl:hasOpinion ?o .
?entry nif:isString ?text .
?o marl:hasPolarity ?sentiment .
}""")
assert len(qres) == 1, "There should only be one result"
entry, text, sentiment = list(qres)[0]
assert entry
assert str(text) == 'hello'
assert str(text) == 'hello', "The returned text does not match the input text."
assert str(sentiment) in ['marl:Positive', 'marl:Neutral', 'marl:Negative']
def test_sentiment_turtle(self):
@@ -104,25 +102,75 @@ class SemanticsTest(TestCase):
'expanded': True,
'prefix': 'http://default.example/#'
}
resp = self.client.get("/api/basic?{}".format(urlencode(params)))
resp = self.client.get("/api/sentiment-basic?{}".format(urlencode(params)))
self.assertCode(resp, 200)
g = parse_resp(resp, 'ttl')
print('Got this graph: ', g.serialize(format='ttl'))
qres = g.query("""
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX marl: <http://www.gsi.upm.es/ontologies/marl/ns#>
PREFIX nif: <http://persistence.uni-leipzig.org/nlp2rdf/ontologies/nif-core#>
PREFIX onyx: <http://www.gsi.upm.es/ontologies/onyx/ns#>
PREFIX senpy: <http://www.gsi.upm.es/onto/senpy/ns#>
PREFIX senpy: <http://www.gsi.upm.es/ontologies/senpy/ns#>
SELECT DISTINCT ?entry ?text ?sentiment
WHERE {
?entry a senpy:Entry .
?entry marl:hasOpinion ?o .
?entry nif:isString ?text .
?o marl:hasPolarity ?sentiment .
?entry a senpy:Entry ;
nif:isString ?text ;
marl:hasOpinion [
marl:hasPolarity ?sentiment
] .
}""")
assert len(qres) == 1, "There should only be one row in the result"
entry, text, sentiment = list(qres)[0]
assert str(text) == 'hello', "Returned text does not match input text"
assert str(sentiment) in ['marl:Positive', 'marl:Neutral', 'marl:Negative']
def test_moral(self):
"""
An example of a moral analysis, adapted from the examples for the AMOR project:
http://www.gsi.upm.es/ontologies/amor/examples
"""
# we use expanded json-ld and ignore the context, because in general
# the context is a uris to the service and that uri is not
# available outside of self.client
params = {
'input': 'hello',
'in-headers': True,
'outformat': 'json-ld',
'expanded': True,
'prefix': 'http://default.example/#'
}
resp = self.client.get("/api/sentiment-basic?{}".format(urlencode(params)))
self.assertCode(resp, 200)
g = parse_resp(resp, fmt='json-ld')
print('Got this graph: ', g.serialize(format='ttl'))
assert g
qres = g.query("""
prefix : <http://www.gsi.upm.es/ontologies/amor/examples#>
prefix amor: <http://www.gsi.upm.es/ontologies/amor/ns#>
prefix amor-bhv: <http://www.gsi.upm.es/ontologies/amor/models/bhv/ns#>
prefix amor-mft: <http://www.gsi.upm.es/ontologies/amor/models/mft/ns#>
prefix bhv: <http://www.gsi.upm.es/ontologies/bhv#>
prefix mft: <http://www.gsi.upm.es/ontologies/mft/ns#>
prefix mls: <http://www.w3.org/ns/mls#>
prefix owl: <http://www.w3.org/2002/07/owl#>
prefix prov: <http://www.w3.org/ns/prov#>
prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix schema: <http://schema.org/>
SELECT ?analysis ?agent ?model ?annotation ?origin ?category
WHERE {
?analysis a amor:MoralValueAnalysis ;
prov:wasAssociatedWith ?agent ;
amor:usedMoralValueModel ?model ;
amor:analysed ?origin ;
prov:generated ?annotation .
?annotation a amor:MoralValueAnnotation ;
amor:hasMoralValueCategory ?category .
}""")
assert len(qres) == 1
entry, text, sentiment = list(qres)[0]
assert entry
assert str(text) == 'hello'
assert str(sentiment) in ['marl:Positive', 'marl:Neutral', 'marl:Negative']
assert str(sentiment) in ['marl:positive', 'marl:neutral', 'marl:negative']