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

New schema for parameters

* Add parameters as an entity in the schema
* Update examples to include parameters
* Change the API for processing plugins, params is a parameter again, instead of
only adding the request.
* Update tests
This commit is contained in:
J. Fernando Sánchez 2018-11-23 20:29:08 +01:00
parent 6a1069780b
commit c090501534
29 changed files with 446 additions and 355 deletions

View File

@ -6,13 +6,9 @@
], ],
"entries": [ "entries": [
{ {
"@type": [
"nif:RFC5147String",
"nif:Context"
],
"nif:beginIndex": 0, "nif:beginIndex": 0,
"nif:endIndex": 40, "nif:endIndex": 40,
"nif:isString": "My favourite actress is Natalie Portman" "text": "An entry should have a nif:isString key"
} }
] ]
} }

View File

@ -3,10 +3,21 @@
"@id": "me:Result1", "@id": "me:Result1",
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
"me:SAnalysis1", {
"me:SgAnalysis1", "@id": "_:SAnalysis1_Activity",
"me:EmotionAnalysis1", "@type": "marl:SentimentAnalysis",
"me:NER1" "prov:wasAssociatedWith": "me:SAnalysis1"
},
{
"@id": "_:EmotionAnalysis1_Activity",
"@type": "onyx:EmotionAnalysis",
"prov:wasAssociatedWith": "me:EmotionAnalysis1"
},
{
"@id": "_:NER1_Activity",
"@type": "me:NER",
"prov:wasAssociatedWith": "me:NER1"
}
], ],
"entries": [ "entries": [
{ {
@ -23,7 +34,7 @@
"nif:endIndex": 13, "nif:endIndex": 13,
"nif:anchorOf": "Microsoft", "nif:anchorOf": "Microsoft",
"me:references": "http://dbpedia.org/page/Microsoft", "me:references": "http://dbpedia.org/page/Microsoft",
"prov:wasGeneratedBy": "me:NER1" "prov:wasGeneratedBy": "_:NER1_Activity"
}, },
{ {
"@id": "http://micro.blog/status1#char=25,37", "@id": "http://micro.blog/status1#char=25,37",
@ -31,7 +42,7 @@
"nif:endIndex": 37, "nif:endIndex": 37,
"nif:anchorOf": "Windows Phone", "nif:anchorOf": "Windows Phone",
"me:references": "http://dbpedia.org/page/Windows_Phone", "me:references": "http://dbpedia.org/page/Windows_Phone",
"prov:wasGeneratedBy": "me:NER1" "prov:wasGeneratedBy": "_:NER1_Activity"
} }
], ],
"suggestions": [ "suggestions": [
@ -40,7 +51,7 @@
"nif:beginIndex": 16, "nif:beginIndex": 16,
"nif:endIndex": 77, "nif:endIndex": 77,
"nif:anchorOf": "put your Windows Phone on your newest #open technology program", "nif:anchorOf": "put your Windows Phone on your newest #open technology program",
"prov:wasGeneratedBy": "me:SgAnalysis1" "prov:wasGeneratedBy": "_:SgAnalysis1_Activity"
} }
], ],
"sentiments": [ "sentiments": [
@ -51,14 +62,14 @@
"nif:anchorOf": "You'll be awesome.", "nif:anchorOf": "You'll be awesome.",
"marl:hasPolarity": "marl:Positive", "marl:hasPolarity": "marl:Positive",
"marl:polarityValue": 0.9, "marl:polarityValue": 0.9,
"prov:wasGeneratedBy": "me:SAnalysis1" "prov:wasGeneratedBy": "_:SgAnalysis1_Activity"
} }
], ],
"emotions": [ "emotions": [
{ {
"@id": "http://micro.blog/status1#char=0,109", "@id": "http://micro.blog/status1#char=0,109",
"nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", "nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource",
"prov:wasGeneratedBy": "me:EAnalysis1", "prov:wasGeneratedBy": "_:EmotionAnalysis1_Activity",
"onyx:hasEmotion": [ "onyx:hasEmotion": [
{ {
"onyx:hasEmotionCategory": "wna:liking" "onyx:hasEmotionCategory": "wna:liking"

View File

@ -1,78 +0,0 @@
{
"@context": "http://mixedemotions-project.eu/ns/context.jsonld",
"@id": "me:Result1",
"@type": "results",
"analysis": [
"me:SAnalysis1",
"me:SgAnalysis1",
"me:EmotionAnalysis1",
"me:NER1",
{
"@type": "analysis",
"@id": "anonymous"
}
],
"entries": [
{
"@id": "http://micro.blog/status1",
"@type": [
"nif:RFC5147String",
"nif:Context"
],
"nif:isString": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource",
"entities": [
{
"@id": "http://micro.blog/status1#char=5,13",
"nif:beginIndex": 5,
"nif:endIndex": 13,
"nif:anchorOf": "Microsoft",
"me:references": "http://dbpedia.org/page/Microsoft",
"prov:wasGeneratedBy": "me:NER1"
},
{
"@id": "http://micro.blog/status1#char=25,37",
"nif:beginIndex": 25,
"nif:endIndex": 37,
"nif:anchorOf": "Windows Phone",
"me:references": "http://dbpedia.org/page/Windows_Phone",
"prov:wasGeneratedBy": "me:NER1"
}
],
"suggestions": [
{
"@id": "http://micro.blog/status1#char=16,77",
"nif:beginIndex": 16,
"nif:endIndex": 77,
"nif:anchorOf": "put your Windows Phone on your newest #open technology program",
"prov:wasGeneratedBy": "me:SgAnalysis1"
}
],
"sentiments": [
{
"@id": "http://micro.blog/status1#char=80,97",
"nif:beginIndex": 80,
"nif:endIndex": 97,
"nif:anchorOf": "You'll be awesome.",
"marl:hasPolarity": "marl:Positive",
"marl:polarityValue": 0.9,
"prov:wasGeneratedBy": "me:SAnalysis1"
}
],
"emotions": [
{
"@id": "http://micro.blog/status1#char=0,109",
"nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource",
"prov:wasGeneratedBy": "me:EAnalysis1",
"onyx:hasEmotion": [
{
"onyx:hasEmotionCategory": "wna:liking"
},
{
"onyx:hasEmotionCategory": "wna:excitement"
}
]
}
]
}
]
}

View File

@ -1,9 +1,8 @@
{ {
"@context": "http://mixedemotions-project.eu/ns/context.jsonld", "@context": "http://mixedemotions-project.eu/ns/context.jsonld",
"@id": "http://example.com#NIFExample", "@id": "me:Result1",
"@type": "results", "@type": "results",
"analysis": [ "analysis": [ ],
],
"entries": [ "entries": [
{ {
"@id": "http://example.org#char=0,40", "@id": "http://example.org#char=0,40",

View File

@ -4,22 +4,34 @@
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
{ {
"@id": "me:SAnalysis1", "@id": "_:SAnalysis1_Activity",
"@type": "marl:SentimentAnalysis", "@type": "marl:SentimentAnalysis",
"marl:maxPolarityValue": 1, "prov:wasAssociatedWith": "me:SentimentAnalysis",
"marl:minPolarityValue": 0 "prov:used": [
{
"name": "marl:maxPolarityValue",
"prov:value": "1"
}, },
{ {
"@id": "me:SgAnalysis1", "name": "marl:minPolarityValue",
"prov:value": "0"
}
]
},
{
"@id": "_:SgAnalysis1_Activity",
"prov:wasAssociatedWith": "me:SgAnalysis1",
"@type": "me:SuggestionAnalysis" "@type": "me:SuggestionAnalysis"
}, },
{ {
"@id": "me:EmotionAnalysis1", "@id": "_:EmotionAnalysis1_Activity",
"@type": "me:EmotionAnalysis" "@type": "me:EmotionAnalysis",
"prov:wasAssociatedWith": "me:EmotionAnalysis1"
}, },
{ {
"@id": "me:NER1", "@id": "_:NER1_Activity",
"@type": "me:NER" "@type": "me:NER",
"prov:wasAssociatedWith": "me:EmotionNER1"
} }
], ],
"entries": [ "entries": [

View File

@ -4,8 +4,9 @@
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
{ {
"@id": "me:EmotionAnalysis1", "@id": "me:EmotionAnalysis1_Activity",
"@type": "onyx:EmotionAnalysis" "@type": "me:EmotionAnalysis1",
"prov:wasAssociatedWith": "me:EmotionAnalysis1"
} }
], ],
"entries": [ "entries": [
@ -26,7 +27,7 @@
{ {
"@id": "http://micro.blog/status1#char=0,109", "@id": "http://micro.blog/status1#char=0,109",
"nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", "nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource",
"prov:wasGeneratedBy": "me:EmotionAnalysis1", "prov:wasGeneratedBy": "_:EmotionAnalysis1_Activity",
"onyx:hasEmotion": [ "onyx:hasEmotion": [
{ {
"onyx:hasEmotionCategory": "wna:liking" "onyx:hasEmotionCategory": "wna:liking"

View File

@ -4,8 +4,9 @@
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
{ {
"@id": "me:NER1", "@id": "_:NER1_Activity",
"@type": "me:NERAnalysis" "@type": "me:NERAnalysis",
"prov:wasAssociatedWith": "me:NER1"
} }
], ],
"entries": [ "entries": [

View File

@ -9,9 +9,15 @@
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
{ {
"@id": "me:HesamsAnalysis", "@id": "me:HesamsAnalysis_Activity",
"@type": "onyx:EmotionAnalysis", "@type": "onyx:EmotionAnalysis",
"onyx:usesEmotionModel": "emovoc:pad-dimensions" "prov:wasAssociatedWith": "me:HesamsAnalysis",
"prov:used": [
{
"name": "emotion-model",
"prov:value": "emovoc:pad-dimensions"
}
]
} }
], ],
"entries": [ "entries": [
@ -32,7 +38,7 @@
{ {
"@id": "Entry1#char=0,21", "@id": "Entry1#char=0,21",
"nif:anchorOf": "This is a test string", "nif:anchorOf": "This is a test string",
"prov:wasGeneratedBy": "me:HesamAnalysis", "prov:wasGeneratedBy": "_:HesamAnalysis_Activity",
"onyx:hasEmotion": [ "onyx:hasEmotion": [
{ {
"emovoc:pleasure": 0.5, "emovoc:pleasure": 0.5,

View File

@ -4,10 +4,9 @@
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
{ {
"@id": "me:SAnalysis1", "@id": "_:SAnalysis1_Activity",
"@type": "marl:SentimentAnalysis", "@type": "marl:SentimentAnalysis",
"marl:maxPolarityValue": 1, "prov:wasAssociatedWith": "me:SAnalysis1"
"marl:minPolarityValue": 0
} }
], ],
"entries": [ "entries": [
@ -30,7 +29,7 @@
"nif:anchorOf": "You'll be awesome.", "nif:anchorOf": "You'll be awesome.",
"marl:hasPolarity": "marl:Positive", "marl:hasPolarity": "marl:Positive",
"marl:polarityValue": 0.9, "marl:polarityValue": 0.9,
"prov:wasGeneratedBy": "me:SAnalysis1" "prov:wasGeneratedBy": "_:SAnalysis1_Activity"
} }
], ],
"emotionSets": [ "emotionSets": [

View File

@ -3,7 +3,11 @@
"@id": "me:Result1", "@id": "me:Result1",
"@type": "results", "@type": "results",
"analysis": [ "analysis": [
"me:SgAnalysis1" {
"@id": "_:SgAnalysis1_Activity",
"@type": "me:SuggestionAnalysis",
"prov:wasAssociatedWith": "me:SgAnalysis1"
}
], ],
"entries": [ "entries": [
{ {
@ -12,7 +16,6 @@
"nif:RFC5147String", "nif:RFC5147String",
"nif:Context" "nif:Context"
], ],
"prov:wasGeneratedBy": "me:SAnalysis1",
"nif:isString": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", "nif:isString": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource",
"entities": [ "entities": [
], ],
@ -22,7 +25,7 @@
"nif:beginIndex": 16, "nif:beginIndex": 16,
"nif:endIndex": 77, "nif:endIndex": 77,
"nif:anchorOf": "put your Windows Phone on your newest #open technology program", "nif:anchorOf": "put your Windows Phone on your newest #open technology program",
"prov:wasGeneratedBy": "me:SgAnalysis1" "prov:wasGeneratedBy": "_:SgAnalysis1_Activity"
} }
], ],
"sentiments": [ "sentiments": [

View File

@ -1,5 +1,5 @@
from future.utils import iteritems from future.utils import iteritems
from .models import Error, Results, Entry, from_string from .models import Analysis, Error, Results, Entry, from_string
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -8,7 +8,8 @@ boolean = [True, False]
API_PARAMS = { API_PARAMS = {
"algorithm": { "algorithm": {
"aliases": ["algorithms", "a", "algo"], "aliases": ["algorithms", "a", "algo"],
"required": False, "required": True,
"default": 'default',
"description": ("Algorithms that will be used to process the request." "description": ("Algorithms that will be used to process the request."
"It may be a list of comma-separated names."), "It may be a list of comma-separated names."),
}, },
@ -41,6 +42,14 @@ API_PARAMS = {
"options": boolean, "options": boolean,
"default": False "default": False
}, },
"verbose": {
"@id": "verbose",
"description": "Show all help, including the common API parameters, or only plugin-related info",
"aliases": ["v"],
"required": True,
"options": boolean,
"default": True
},
"emotionModel": { "emotionModel": {
"@id": "emotionModel", "@id": "emotionModel",
"aliases": ["emoModel"], "aliases": ["emoModel"],
@ -168,8 +177,7 @@ def parse_params(indict, *specs):
outdict[param] = options["default"] outdict[param] = options["default"]
elif options.get("required", False): elif options.get("required", False):
wrong_params[param] = spec[param] wrong_params[param] = spec[param]
continue elif "options" in options:
if "options" in options:
if options["options"] == boolean: if options["options"] == boolean:
outdict[param] = str(outdict[param]).lower() in ['true', '1'] outdict[param] = str(outdict[param]).lower() in ['true', '1']
elif outdict[param] not in options["options"]: elif outdict[param] not in options["options"]:
@ -182,8 +190,6 @@ def parse_params(indict, *specs):
parameters=outdict, parameters=outdict,
errors=wrong_params) errors=wrong_params)
raise message raise message
if 'algorithm' in outdict and not isinstance(outdict['algorithm'], tuple):
outdict['algorithm'] = tuple(outdict['algorithm'].split(','))
return outdict return outdict
@ -200,17 +206,15 @@ def get_extra_params(plugins):
'''Get a list of possible parameters given a list of plugins''' '''Get a list of possible parameters given a list of plugins'''
params = {} params = {}
extra_params = {} extra_params = {}
for i, plugin in enumerate(plugins): for plugin in plugins:
this_params = plugin.get('extra_params', {}) this_params = plugin.get('extra_params', {})
for k, v in this_params.items(): for k, v in this_params.items():
if k not in extra_params: if k not in extra_params:
extra_params[k] = [] extra_params[k] = {}
extra_params[k].append(v) extra_params[k][plugin.name] = v
params['{}.{}'.format(plugin.name, k)] = v
params['{}.{}'.format(i, k)] = v
for k, v in extra_params.items(): # Resolve conflicts for k, v in extra_params.items(): # Resolve conflicts
if len(v) == 1: # Add the extra options that do not collide if len(v) == 1: # Add the extra options that do not collide
params[k] = v[0] params[k] = list(v.values())[0]
else: else:
required = False required = False
aliases = None aliases = None
@ -218,7 +222,8 @@ def get_extra_params(plugins):
default = None default = None
nodefault = False # Set when defaults are not compatible nodefault = False # Set when defaults are not compatible
for opt in v: for plugin, opt in v.items():
params['{}.{}'.format(plugin, k)] = opt
required = required or opt.get('required', False) required = required or opt.get('required', False)
newaliases = set(opt.get('aliases', [])) newaliases = set(opt.get('aliases', []))
if aliases is None: if aliases is None:
@ -247,17 +252,20 @@ def get_extra_params(plugins):
return params return params
def parse_extra_params(params, plugins): def parse_analysis(params, plugins):
''' '''
Parse the given parameters individually for each plugin, and get a list of the parameters that Parse the given parameters individually for each plugin, and get a list of the parameters that
belong to each of the plugins. Each item can then be used in the plugin.analyse_entries method. belong to each of the plugins. Each item can then be used in the plugin.analyse_entries method.
''' '''
extra_params = [] analysis_list = []
for i, plugin in enumerate(plugins): for i, plugin in enumerate(plugins):
if not plugin:
continue
this_params = filter_params(params, plugin, i) this_params = filter_params(params, plugin, i)
parsed = parse_params(this_params, plugin.get('extra_params', {})) parsed = parse_params(this_params, plugin.get('extra_params', {}))
extra_params.append(parsed) analysis = plugin.activity(parsed)
return extra_params analysis_list.append(analysis)
return analysis_list
def filter_params(params, plugin, ith=-1): def filter_params(params, plugin, ith=-1):
@ -290,7 +298,8 @@ def filter_params(params, plugin, ith=-1):
def parse_call(params): def parse_call(params):
'''Return a results object based on the parameters used in a call/request. '''
Return a results object based on the parameters used in a call/request.
''' '''
params = parse_params(params, NIF_PARAMS) params = parse_params(params, NIF_PARAMS)
if params['informat'] == 'text': if params['informat'] == 'text':

View File

@ -189,21 +189,27 @@ def basic_api(f):
@basic_api @basic_api
def api_root(plugin): def api_root(plugin):
if plugin: if plugin:
if 'algorithm' in request.parameters: if request.parameters['algorithm'] != api.API_PARAMS['algorithm']['default']:
raise Error('You cannot specify the algorithm with a parameter and a URL variable.' raise Error('You cannot specify the algorithm with a parameter and a URL variable.'
' Please, remove one of them') ' Please, remove one of them')
plugin = plugin.replace('+', '/') request.parameters['algorithm'] = tuple(plugin.replace('+', '/').split('/'))
request.parameters['algorithm'] = tuple(plugin.split('/'))
params = request.parameters
plugin = request.parameters['algorithm']
sp = current_app.senpy
plugins = sp.get_plugins(plugin)
if request.parameters['help']: if request.parameters['help']:
sp = current_app.senpy apis = []
plugins = sp._get_plugins(request) if request.parameters['verbose']:
allparameters = api.get_all_params(plugins, api.WEB_PARAMS, api.API_PARAMS, api.NIF_PARAMS) apis.append(api.BUILTIN_PARAMS)
allparameters = api.get_all_params(plugins, *apis)
response = Help(valid_parameters=allparameters) response = Help(valid_parameters=allparameters)
return response return response
req = api.parse_call(request.parameters) req = api.parse_call(request.parameters)
results = current_app.senpy.analyse(req) analysis = api.parse_analysis(req.parameters, plugins)
results.analysis = set(i.id for i in results.analysis) results = current_app.senpy.analyse(req, analysis)
return results return results

View File

@ -31,10 +31,10 @@ def main_function(argv):
default_plugins = params.get('default-plugins', False) default_plugins = params.get('default-plugins', False)
sp = Senpy(default_plugins=default_plugins, plugin_folder=plugin_folder) sp = Senpy(default_plugins=default_plugins, plugin_folder=plugin_folder)
request = api.parse_call(params) request = api.parse_call(params)
algos = request.parameters.get('algorithm', None) algos = sp.get_plugins(request.parameters.get('algorithm', None))
if algos: if algos:
for algo in algos: for algo in algos:
sp.activate_plugin(algo) sp.activate_plugin(algo.name)
else: else:
sp.activate_all() sp.activate_all()
res = sp.analyse(request) res = sp.analyse(request)

View File

@ -78,27 +78,47 @@ class Senpy(object):
def delete_plugin(self, plugin): def delete_plugin(self, plugin):
del self._plugins[plugin.name.lower()] del self._plugins[plugin.name.lower()]
def plugins(self, **kwargs): def plugins(self, plugin_type=None, is_activated=True, **kwargs):
""" Return the plugins registered for a given application. Filtered by criteria """ """ Return the plugins registered for a given application. Filtered by criteria """
return list(plugins.pfilter(self._plugins, **kwargs)) return list(plugins.pfilter(self._plugins, plugin_type=plugin_type,
is_activated=is_activated, **kwargs))
def get_plugin(self, name, default=None): def get_plugin(self, name, default=None):
if name == 'default': if name == 'default':
return self.default_plugin return self.default_plugin
plugin = name.lower() elif name == 'conversion':
if plugin in self._plugins: return None
return self._plugins[plugin]
results = self.plugins(id='endpoint:plugins/{}'.format(name)) if name.lower() in self._plugins:
return self._plugins[name.lower()]
if not results: results = self.plugins(id='endpoint:plugins/{}'.format(name.lower()),
return Error(message="Plugin not found", status=404) plugin_type=None)
if results:
return results[0] return results[0]
results = self.plugins(id=name,
plugin_type=None)
if results:
return results[0]
msg = ("Plugin not found: '{}'\n"
"Make sure it is ACTIVATED\n"
"Valid algorithms: {}").format(name,
self._plugins.keys())
raise Error(message=msg, status=404)
def get_plugins(self, name):
try:
name = name.split(',')
except AttributeError:
pass # Assume it is a tuple or a list
return tuple(self.get_plugin(n) for n in name)
@property @property
def analysis_plugins(self): def analysis_plugins(self):
""" Return only the analysis plugins """ """ Return only the analysis plugins that are active"""
return self.plugins(plugin_type='analysisPlugin') return self.plugins(plugin_type='analysisPlugin', is_activated=True)
def add_folder(self, folder, from_root=False): def add_folder(self, folder, from_root=False):
""" Find plugins in this folder and add them to this instance """ """ Find plugins in this folder and add them to this instance """
@ -113,38 +133,24 @@ class Senpy(object):
else: else:
raise AttributeError("Not a folder or does not exist: %s", folder) raise AttributeError("Not a folder or does not exist: %s", folder)
def _get_plugins(self, request): # def check_analysis_request(self, analysis):
'''Get a list of plugins that should be run for a specific request''' # '''Check if the analysis request can be fulfilled'''
if not self.analysis_plugins: # if not self.plugins():
raise Error( # raise Error(
status=404, # status=404,
message=("No plugins found." # message=("No plugins found."
" Please install one.")) # " Please install one."))
algos = request.parameters.get('algorithm', None) # for a in analysis:
if not algos: # algo = a.algorithm
if self.default_plugin: # if algo == 'default' and not self.default_plugin:
algos = [self.default_plugin.name, ] # raise Error(
else: # status=404,
raise Error( # message="No default plugin found, and None provided")
status=404, # else:
message="No default plugin found, and None provided") # self.get_plugin(algo)
plugins = list()
for algo in algos:
algo = algo.lower()
if algo == 'conversion':
continue # Allow 'conversion' as a virtual plugin, which does nothing
if algo not in self._plugins:
msg = ("The algorithm '{}' is not valid\n"
"Valid algorithms: {}").format(algo,
self._plugins.keys())
logger.debug(msg)
raise Error(status=404, message=msg)
plugins.append(self._plugins[algo])
return plugins def _process(self, req, pending, done=None):
def _process(self, req, parameters, pending, done=None):
""" """
Recursively process the entries with the first plugin in the list, and pass the results Recursively process the entries with the first plugin in the list, and pass the results
to the rest of the plugins. to the rest of the plugins.
@ -153,27 +159,32 @@ class Senpy(object):
if not pending: if not pending:
return req return req
plugin = pending[0] analysis = pending[0]
req.parameters = parameters[0] results = analysis.run(req)
results = plugin.process(req, conversions_applied=done) results.analysis.append(analysis)
if plugin not in results.analysis: done += analysis
results.analysis.append(plugin) return self._process(results, pending[1:], done)
return self._process(results, parameters[1:], pending[1:], done)
def install_deps(self): def install_deps(self):
plugins.install_deps(*self.plugins()) plugins.install_deps(*self.plugins())
def analyse(self, request): def analyse(self, request, analysis=None):
""" """
Main method that analyses a request, either from CLI or HTTP. Main method that analyses a request, either from CLI or HTTP.
It takes a processed request, provided by the user, as returned It takes a processed request, provided by the user, as returned
by api.parse_call(). by api.parse_call().
""" """
if not self.plugins():
raise Error(
status=404,
message=("No plugins found."
" Please install one."))
if analysis is None:
params = str(request)
plugins = self.get_plugins(request.parameters['algorithm'])
analysis = api.parse_analysis(request.parameters, plugins)
logger.debug("analysing request: {}".format(request)) logger.debug("analysing request: {}".format(request))
plugins = self._get_plugins(request) results = self._process(request, analysis)
parameters = api.parse_extra_params(request.parameters, plugins)
results = self._process(request, parameters, plugins)
logger.debug("Got analysis result: {}".format(results)) logger.debug("Got analysis result: {}".format(results))
results = self.postprocess(results) results = self.postprocess(results)
logger.debug("Returning post-processed result: {}".format(results)) logger.debug("Returning post-processed result: {}".format(results))
@ -189,7 +200,10 @@ class Senpy(object):
""" """
plugins = resp.analysis plugins = resp.analysis
params = resp.parameters if 'parameters' not in resp:
return resp
params = resp['parameters']
toModel = params.get('emotionModel', None) toModel = params.get('emotionModel', None)
if not toModel: if not toModel:
return resp return resp
@ -290,7 +304,10 @@ class Senpy(object):
results = AggregatedEvaluation() results = AggregatedEvaluation()
results.parameters = params results.parameters = params
datasets = self._get_datasets(results) datasets = self._get_datasets(results)
plugins = self._get_plugins(results) plugins = []
for plugname in params.algorithm:
plugins = self.get_plugin(plugname)
for eval in plugins.evaluate(plugins, datasets): for eval in plugins.evaluate(plugins, datasets):
results.evaluations.append(eval) results.evaluations.append(eval)
if 'with_parameters' not in results.parameters: if 'with_parameters' not in results.parameters:

View File

@ -85,6 +85,7 @@ class BaseMeta(ABCMeta):
schema = json.load(f) schema = json.load(f)
resolver = jsonschema.RefResolver(schema_path, schema) resolver = jsonschema.RefResolver(schema_path, schema)
if '@type' not in attrs:
attrs['@type'] = "".join((name[0].lower(), name[1:])) attrs['@type'] = "".join((name[0].lower(), name[1:]))
attrs['_schema_file'] = schema_file attrs['_schema_file'] = schema_file
attrs['schema'] = schema attrs['schema'] = schema
@ -244,10 +245,10 @@ class CustomDict(MutableMapping, object):
return key[0] == '_' return key[0] == '_'
def __str__(self): def __str__(self):
return str(self.serializable()) return json.dumps(self.serializable(), sort_keys=True, indent=4)
def __repr__(self): def __repr__(self):
return str(self.serializable()) return json.dumps(self.serializable(), sort_keys=True, indent=4)
_Alias = namedtuple('Alias', 'indict') _Alias = namedtuple('Alias', 'indict')

View File

@ -121,11 +121,11 @@ class BaseModel(with_metaclass(BaseMeta, CustomDict)):
''' '''
schema_file = DEFINITIONS_FILE # schema_file = DEFINITIONS_FILE
_context = base_context["@context"] _context = base_context["@context"]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
auto_id = kwargs.pop('_auto_id', True) auto_id = kwargs.pop('_auto_id', False)
super(BaseModel, self).__init__(*args, **kwargs) super(BaseModel, self).__init__(*args, **kwargs)
@ -133,7 +133,7 @@ class BaseModel(with_metaclass(BaseMeta, CustomDict)):
self.id self.id
if '@type' not in self: if '@type' not in self:
logger.warn('Created an instance of an unknown model') logger.warning('Created an instance of an unknown model')
@property @property
def id(self): def id(self):
@ -325,7 +325,6 @@ def _add_class_from_schema(*args, **kwargs):
for i in [ for i in [
'aggregatedEvaluation', 'aggregatedEvaluation',
'analysis',
'dataset', 'dataset',
'datasets', 'datasets',
'emotion', 'emotion',
@ -339,7 +338,7 @@ for i in [
'entity', 'entity',
'help', 'help',
'metric', 'metric',
'plugin', 'parameter',
'plugins', 'plugins',
'response', 'response',
'results', 'results',
@ -349,3 +348,55 @@ for i in [
]: ]:
_add_class_from_schema(i) _add_class_from_schema(i)
class Analysis(BaseModel):
schema = 'analysis'
parameters = alias('prov:used')
@property
def params(self):
outdict = {}
outdict['algorithm'] = self.algorithm
for param in self.parameters:
outdict[param['name']] = param['value']
return outdict
@params.setter
def params(self, value):
for k, v in value.items():
for param in self.parameters:
if param.name == k:
param.value = v
break
else:
self.parameters.append(Parameter(name=k, value=v))
@property
def algorithm(self):
return self['prov:wasAssociatedWith']
@property
def plugin(self):
return self._plugin
@plugin.setter
def plugin(self, value):
self._plugin = value
self['prov:wasAssociatedWith'] = value.id
def run(self, request):
return self.plugin.process(request, self.params)
class Plugin(BaseModel):
schema = 'plugin'
def activity(self, parameters):
'''Generate a prov:Activity from this plugin and the '''
a = Analysis()
a.plugin = self
a.params = parameters
return a

View File

@ -132,12 +132,12 @@ class Plugin(with_metaclass(PluginMeta, models.Plugin)):
def deactivate(self): def deactivate(self):
pass pass
def process(self, request, **kwargs): def process(self, request, parameters, **kwargs):
""" """
An implemented plugin should override this method. An implemented plugin should override this method.
Here, we assume that a process_entries method exists.""" Here, we assume that a process_entries method exists."""
newentries = list( newentries = list(
self.process_entries(request.entries, request.parameters)) self.process_entries(request.entries, parameters))
request.entries = newentries request.entries = newentries
return request return request
@ -194,13 +194,13 @@ class Plugin(with_metaclass(PluginMeta, models.Plugin)):
try: try:
request = models.Response() request = models.Response()
request.parameters = api.parse_params(given_parameters, parameters = api.parse_params(given_parameters,
self.extra_params) self.extra_params)
request.entries = [ request.entries = [
entry, entry,
] ]
method = partial(self.process, request) method = partial(self.process, request, parameters)
if mock: if mock:
res = method() res = method()
@ -249,14 +249,14 @@ class Analysis(Plugin):
''' '''
def analyse(self, request, parameters): def analyse(self, request, parameters):
return super(Analysis, self).process(request) return super(Analysis, self).process(request, parameters)
def analyse_entries(self, entries, parameters): def analyse_entries(self, entries, parameters):
for i in super(Analysis, self).process_entries(entries, parameters): for i in super(Analysis, self).process_entries(entries, parameters):
yield i yield i
def process(self, request, **kwargs): def process(self, request, parameters, **kwargs):
return self.analyse(request, request.parameters) return self.analyse(request, parameters)
def process_entries(self, entries, parameters): def process_entries(self, entries, parameters):
for i in self.analyse_entries(entries, parameters): for i in self.analyse_entries(entries, parameters):
@ -279,12 +279,12 @@ class Conversion(Plugin):
e.g. a conversion of emotion models, or normalization of sentiment values. e.g. a conversion of emotion models, or normalization of sentiment values.
''' '''
def process(self, response, plugins=None, **kwargs): def process(self, response, parameters, plugins=None, **kwargs):
plugins = plugins or [] plugins = plugins or []
newentries = [] newentries = []
for entry in response.entries: for entry in response.entries:
newentries.append( newentries.append(
self.convert_entry(entry, response.parameters, plugins)) self.convert_entry(entry, parameters, plugins))
response.entries = newentries response.entries = newentries
return response return response

View File

@ -9,7 +9,20 @@
"@type": { "@type": {
"type": "string", "type": "string",
"description": "Type of the analysis. e.g. marl:SentimentAnalysis" "description": "Type of the analysis. e.g. marl:SentimentAnalysis"
},
"prov:wasAssociatedWith": {
"@type": "string",
"description": "Algorithm/plugin that was used"
},
"prov:used": {
"description": "Parameters of the algorithm",
"@type": "array",
"default": [],
"type": "array",
"items": {
"$ref": "parameter.json"
}
} }
}, },
"required": ["@id", "@type"] "required": ["@type", "prov:wasAssociatedWith"]
} }

View File

@ -41,7 +41,7 @@
"@container": "@set" "@container": "@set"
}, },
"analysis": { "analysis": {
"@id": "AnalysisInvolved", "@id": "prov:wasInformedBy",
"@type": "@id", "@type": "@id",
"@container": "@set" "@container": "@set"
}, },

View File

@ -20,5 +20,5 @@
"description": "The ID of the analysis that generated this Emotion. The full object should be included in the \"analysis\" property of the root object" "description": "The ID of the analysis that generated this Emotion. The full object should be included in the \"analysis\" property of the root object"
} }
}, },
"required": ["@id", "prov:wasGeneratedBy", "onyx:hasEmotion"] "required": ["prov:wasGeneratedBy", "onyx:hasEmotion"]
} }

View File

@ -35,5 +35,5 @@
"default": [] "default": []
} }
}, },
"required": ["@id", "nif:isString"] "required": ["nif:isString"]
} }

View File

@ -0,0 +1,16 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Parameters for a senpy analysis",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the parameter"
},
"prov:value": {
"@type": "any",
"description": "Value of the parameter"
}
},
"required": ["name", "prov:value"]
}

View File

@ -21,13 +21,7 @@
"default": [], "default": [],
"type": "array", "type": "array",
"items": { "items": {
"anyOf": [
{
"$ref": "analysis.json" "$ref": "analysis.json"
},{
"type": "string"
}
]
} }
}, },
"entries": { "entries": {

View File

@ -19,5 +19,5 @@
"description": "The ID of the analysis that generated this Sentiment. The full object should be included in the \"analysis\" property of the root object" "description": "The ID of the analysis that generated this Sentiment. The full object should be included in the \"analysis\" property of the root object"
} }
}, },
"required": ["@id", "prov:wasGeneratedBy"] "required": ["prov:wasGeneratedBy"]
} }

View File

@ -12,6 +12,8 @@ max-line-length = 100
universal=1 universal=1
[tool:pytest] [tool:pytest]
addopts = --cov=senpy --cov-report term-missing addopts = --cov=senpy --cov-report term-missing
filterwarnings =
error
ignore:the matrix subclass:PendingDeprecationWarning
[coverage:report] [coverage:report]
omit = senpy/__main__.py omit = senpy/__main__.py

View File

@ -3,7 +3,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from unittest import TestCase from unittest import TestCase
from senpy.api import (boolean, parse_params, get_extra_params, parse_extra_params, from senpy.api import (boolean, parse_params, get_extra_params, parse_analysis,
API_PARAMS, NIF_PARAMS, WEB_PARAMS) API_PARAMS, NIF_PARAMS, WEB_PARAMS)
from senpy.models import Error, Plugin from senpy.models import Error, Plugin
@ -91,7 +91,7 @@ class APITest(TestCase):
assert 'input' in p assert 'input' in p
assert p['input'] == 'Aloha my friend' assert p['input'] == 'Aloha my friend'
def test_parse_extra_params(self): def test_parse_analysis(self):
'''The API should parse user parameters and return them in a format that plugins can use''' '''The API should parse user parameters and return them in a format that plugins can use'''
plugins = [ plugins = [
Plugin({ Plugin({
@ -161,10 +161,11 @@ class APITest(TestCase):
} }
] ]
p = parse_extra_params(call, plugins) p = parse_analysis(call, plugins)
for i, arg in enumerate(expected): for i, arg in enumerate(expected):
params = p[i].params
for k, v in arg.items(): for k, v in arg.items():
assert p[i][k] == v assert params[k] == v
def test_get_extra_params(self): def test_get_extra_params(self):
'''The API should return the list of valid parameters for a set of plugins''' '''The API should return the list of valid parameters for a set of plugins'''
@ -216,13 +217,11 @@ class APITest(TestCase):
] ]
expected = { expected = {
# Each plugin's parameters # Overlapping parameters
'0.param0': plugins[0]['extra_params']['param0'], 'plugin1.param0': plugins[0]['extra_params']['param0'],
'0.param1': plugins[0]['extra_params']['param1'], 'plugin1.param1': plugins[0]['extra_params']['param1'],
'0.param2': plugins[0]['extra_params']['param2'], 'plugin2.param0': plugins[1]['extra_params']['param0'],
'1.param0': plugins[1]['extra_params']['param0'], 'plugin2.param1': plugins[1]['extra_params']['param1'],
'1.param1': plugins[1]['extra_params']['param1'],
'1.param3': plugins[1]['extra_params']['param3'],
# Non-overlapping parameters # Non-overlapping parameters
'param2': plugins[0]['extra_params']['param2'], 'param2': plugins[0]['extra_params']['param2'],

View File

@ -26,8 +26,7 @@ class BlueprintsTest(TestCase):
cls.senpy.init_app(cls.app) cls.senpy.init_app(cls.app)
cls.dir = os.path.join(os.path.dirname(__file__), "..") cls.dir = os.path.join(os.path.dirname(__file__), "..")
cls.senpy.add_folder(cls.dir) cls.senpy.add_folder(cls.dir)
cls.senpy.activate_plugin("Dummy", sync=True) cls.senpy.activate_all()
cls.senpy.activate_plugin("DummyRequired", sync=True)
cls.senpy.default_plugin = 'Dummy' cls.senpy.default_plugin = 'Dummy'
def setUp(self): def setUp(self):
@ -139,16 +138,27 @@ class BlueprintsTest(TestCase):
# Calling dummy twice, should return the same string # Calling dummy twice, should return the same string
self.assertCode(resp, 200) self.assertCode(resp, 200)
js = parse_resp(resp) js = parse_resp(resp)
assert len(js['analysis']) == 1 assert len(js['analysis']) == 2
assert js['entries'][0]['nif:isString'] == 'My aloha mohame' assert js['entries'][0]['nif:isString'] == 'My aloha mohame'
resp = self.client.get("/api/Dummy+Dummy?i=My aloha mohame") resp = self.client.get("/api/Dummy+Dummy?i=My aloha mohame")
# Same with pluses instead of slashes # Same with pluses instead of slashes
self.assertCode(resp, 200) self.assertCode(resp, 200)
js = parse_resp(resp) js = parse_resp(resp)
assert len(js['analysis']) == 1 assert len(js['analysis']) == 2
assert js['entries'][0]['nif:isString'] == 'My aloha mohame' assert js['entries'][0]['nif:isString'] == 'My aloha mohame'
def test_analysis_chain_required(self):
"""
If a parameter is required and duplicated (because two plugins require it), specifying
it once should suffice
"""
resp = self.client.get("/api/DummyRequired/DummyRequired?i=My aloha mohame&example=a")
js = parse_resp(resp)
assert len(js['analysis']) == 2
assert js['entries'][0]['nif:isString'] == 'My aloha mohame'
assert js['entries'][0]['reversed'] == 2
def test_requirements_chain_help(self): def test_requirements_chain_help(self):
'''The extra parameters of each plugin should be merged if they are in a chain ''' '''The extra parameters of each plugin should be merged if they are in a chain '''
resp = self.client.get("/api/split/DummyRequired?help=true") resp = self.client.get("/api/split/DummyRequired?help=true")
@ -157,6 +167,7 @@ class BlueprintsTest(TestCase):
assert 'valid_parameters' in js assert 'valid_parameters' in js
vp = js['valid_parameters'] vp = js['valid_parameters']
assert 'example' in vp assert 'example' in vp
assert 'delimiter' in vp
def test_requirements_chain_repeat_help(self): def test_requirements_chain_repeat_help(self):
''' '''
@ -168,10 +179,14 @@ class BlueprintsTest(TestCase):
js = parse_resp(resp) js = parse_resp(resp)
assert 'valid_parameters' in js assert 'valid_parameters' in js
vp = js['valid_parameters'] vp = js['valid_parameters']
assert '0.delimiter' in vp
assert '1.delimiter' in vp
assert 'delimiter' in vp assert 'delimiter' in vp
resp = self.client.get("/api/split/split?help=true&verbose=false")
js = parse_resp(resp)
vp = js['valid_parameters']
assert len(vp.keys()) == 1
def test_requirements_chain(self): def test_requirements_chain(self):
""" """
It should be possible to specify different parameters for each step in the chain. It should be possible to specify different parameters for each step in the chain.

View File

@ -11,14 +11,15 @@ 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 import plugins
from senpy.models import Error, Results, Entry, EmotionSet, Emotion, Plugin from senpy.models import Analysis, Error, Results, Entry, EmotionSet, Emotion, Plugin
from senpy import api from senpy import api
from flask import Flask from flask import Flask
from unittest import TestCase from unittest import TestCase
def analyse(instance, **kwargs): def analyse(instance, **kwargs):
request = api.parse_call(kwargs) basic = api.parse_params(kwargs, api.API_PARAMS)
request = api.parse_call(basic)
return instance.analyse(request) return instance.analyse(request)
@ -49,9 +50,9 @@ class ExtensionsTest(TestCase):
'''Should be able to add and delete new plugins. ''' '''Should be able to add and delete new plugins. '''
new = plugins.Analysis(name='new', description='new', version=0) new = plugins.Analysis(name='new', description='new', version=0)
self.senpy.add_plugin(new) self.senpy.add_plugin(new)
assert new in self.senpy.plugins() assert new in self.senpy.plugins(is_activated=False)
self.senpy.delete_plugin(new) self.senpy.delete_plugin(new)
assert new not in self.senpy.plugins() assert new not in self.senpy.plugins(is_activated=False)
def test_adding_folder(self): def test_adding_folder(self):
""" It should be possible for senpy to look for plugins in more folders. """ """ It should be possible for senpy to look for plugins in more folders. """
@ -60,7 +61,7 @@ class ExtensionsTest(TestCase):
default_plugins=False) default_plugins=False)
assert not senpy.analysis_plugins assert not senpy.analysis_plugins
senpy.add_folder(self.examples_dir) senpy.add_folder(self.examples_dir)
assert senpy.analysis_plugins assert senpy.plugins(plugin_type=plugins.AnalysisPlugin, is_activated=False)
self.assertRaises(AttributeError, senpy.add_folder, 'DOES NOT EXIST') self.assertRaises(AttributeError, senpy.add_folder, 'DOES NOT EXIST')
def test_installing(self): def test_installing(self):
@ -121,8 +122,8 @@ class ExtensionsTest(TestCase):
# Leaf (defaultdict with __setattr__ and __getattr__. # Leaf (defaultdict with __setattr__ and __getattr__.
r1 = analyse(self.senpy, algorithm="Dummy", input="tupni", output="tuptuo") r1 = analyse(self.senpy, algorithm="Dummy", input="tupni", output="tuptuo")
r2 = analyse(self.senpy, input="tupni", output="tuptuo") r2 = analyse(self.senpy, input="tupni", output="tuptuo")
assert r1.analysis[0].id == "endpoint:plugins/Dummy_0.1" assert r1.analysis[0].algorithm == "endpoint:plugins/Dummy_0.1"
assert r2.analysis[0].id == "endpoint:plugins/Dummy_0.1" assert r2.analysis[0].algorithm == "endpoint:plugins/Dummy_0.1"
assert r1.entries[0]['nif:isString'] == 'input' assert r1.entries[0]['nif:isString'] == 'input'
def test_analyse_empty(self): def test_analyse_empty(self):
@ -130,7 +131,7 @@ class ExtensionsTest(TestCase):
senpy = Senpy(plugin_folder=None, senpy = Senpy(plugin_folder=None,
app=self.app, app=self.app,
default_plugins=False) default_plugins=False)
self.assertRaises(Error, senpy.analyse, Results()) self.assertRaises(Error, senpy.analyse, Results(), [])
def test_analyse_wrong(self): def test_analyse_wrong(self):
""" Trying to analyse with a non-existent plugin should raise an error.""" """ Trying to analyse with a non-existent plugin should raise an error."""
@ -156,29 +157,32 @@ class ExtensionsTest(TestCase):
r2 = analyse(self.senpy, r2 = analyse(self.senpy,
input="tupni", input="tupni",
output="tuptuo") output="tuptuo")
assert r1.analysis[0].id == "endpoint:plugins/Dummy_0.1" assert r1.analysis[0].algorithm == "endpoint:plugins/Dummy_0.1"
assert r2.analysis[0].id == "endpoint:plugins/Dummy_0.1" assert r2.analysis[0].algorithm == "endpoint:plugins/Dummy_0.1"
assert r1.entries[0]['nif:isString'] == 'input' assert r1.entries[0]['nif:isString'] == 'input'
def test_analyse_error(self): def test_analyse_error(self):
mm = mock.MagicMock() class ErrorPlugin(plugins.Analysis):
mm.id = 'magic_mock' author = 'nobody'
mm.name = 'mock' version = 0
mm.is_activated = True ex = Error()
mm.process.side_effect = Error('error in analysis', status=500)
self.senpy.add_plugin(mm) def process(self, *args, **kwargs):
raise self.ex
m = ErrorPlugin(ex=Error('error in analysis', status=500))
self.senpy.add_plugin(m)
try: try:
analyse(self.senpy, input='nothing', algorithm='MOCK') analyse(self.senpy, input='nothing', algorithm='ErrorPlugin')
assert False assert False
except Error as ex: except Error as ex:
assert 'error in analysis' in ex['message'] assert 'error in analysis' in ex['message']
assert ex['status'] == 500 assert ex['status'] == 500
ex = Exception('generic exception on analysis') m.ex = Exception('generic exception on analysis')
mm.process.side_effect = ex
try: try:
analyse(self.senpy, input='nothing', algorithm='MOCK') analyse(self.senpy, input='nothing', algorithm='ErrorPlugin')
assert False assert False
except Exception as ex: except Exception as ex:
assert 'generic exception on analysis' in str(ex) assert 'generic exception on analysis' in str(ex)
@ -194,7 +198,7 @@ class ExtensionsTest(TestCase):
def test_load_default_plugins(self): def test_load_default_plugins(self):
senpy = Senpy(plugin_folder=self.examples_dir, default_plugins=True) senpy = Senpy(plugin_folder=self.examples_dir, default_plugins=True)
assert len(senpy.plugins()) > 1 assert len(senpy.plugins(is_activated=False)) > 1
def test_convert_emotions(self): def test_convert_emotions(self):
self.senpy.activate_all(sync=True) self.senpy.activate_all(sync=True)

View File

@ -5,7 +5,8 @@ import jsonschema
import json import json
import rdflib import rdflib
from unittest import TestCase from unittest import TestCase
from senpy.models import (Emotion, from senpy.models import (Analysis,
Emotion,
EmotionAnalysis, EmotionAnalysis,
EmotionSet, EmotionSet,
Entry, Entry,
@ -61,7 +62,7 @@ class ModelsTest(TestCase):
def test_id(self): def test_id(self):
""" Adding the id after creation should overwrite the automatic ID """ Adding the id after creation should overwrite the automatic ID
""" """
r = Entry() r = Entry(_auto_id=True)
j = r.jsonld() j = r.jsonld()
assert '@id' in j assert '@id' in j
r.id = "test" r.id = "test"
@ -189,6 +190,19 @@ class ModelsTest(TestCase):
assert isinstance(js['plugins'], list) assert isinstance(js['plugins'], list)
assert js['plugins'][0]['@type'] == 'sentimentPlugin' assert js['plugins'][0]['@type'] == 'sentimentPlugin'
def test_parameters(self):
'''An Analysis should contain the algorithm and the list of parameters to be used'''
a = Analysis()
a.params = {'param1': 1, 'param2': 2}
assert len(a.parameters) == 2
for param in a.parameters:
if param.name == 'param1':
assert param.value == 1
elif param.name == 'param2':
assert param.value == 2
else:
raise Exception('Unknown value %s' % param)
def test_from_string(self): def test_from_string(self):
results = { results = {
'@type': 'results', '@type': 'results',