-
Notifications
You must be signed in to change notification settings - Fork 7
Plugin class #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Plugin class #8
Changes from all commits
c526dfc
151b20b
f431fc3
905eada
01178f7
0ba3041
1723b2c
ca7d9aa
2f45c38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| # ----------------------------------------------------------------------------- | ||
| # Copyright (c) 2014--, The Qiita Development Team. | ||
| # | ||
| # Distributed under the terms of the BSD 3-clause License. | ||
| # | ||
| # The full license is in the file LICENSE, distributed with this software. | ||
| # ----------------------------------------------------------------------------- | ||
|
|
||
| import traceback | ||
| import sys | ||
| from string import ascii_letters, digits | ||
| from random import SystemRandom | ||
| from os.path import exists, join, expanduser | ||
| from os import makedirs, environ | ||
| from future import standard_library | ||
| from json import dumps | ||
| import urllib | ||
|
|
||
| from qiita_client import QiitaClient | ||
|
|
||
| with standard_library.hooks(): | ||
| from configparser import ConfigParser | ||
|
|
||
|
|
||
| class QiitaCommand(object): | ||
| """A plugin command | ||
|
|
||
| Parameters | ||
| ---------- | ||
| name : str | ||
| The command name | ||
| description : str | ||
| The command description | ||
| function : callable | ||
| The function that executes the command. Should be a callable that | ||
| conforms to the signature: | ||
| `(bool, str, [ArtifactInfo] = function(qclient, job_id, job_parameters, | ||
| output_dir)` | ||
| where qclient is an instance of QiitaClient, job_id is a string with | ||
| the job identifier, job_parameters is a dictionary with the parameters | ||
| of the command and output_dir is a string with the output directory. | ||
| The function should return a boolean indicating if the command was | ||
| executed successfully or not, a string containing a message in case | ||
| of error, and a list of ArtifactInfo objects in case of success. | ||
| required_parameters : dict of {str: (str, list of str)} | ||
| The required parameters of the command, keyed by parameter name. The | ||
| values should be a 2-tuple in which the first element is the parameter | ||
| type, and the second parameter is the list of subtypes (if applicable) | ||
| optional_parameters : dict of {str: (str, str)} | ||
| The optional parameters of the command, keyed by parameter name. The | ||
| values should be a 2-tuple in which the first element is the parameter | ||
| name, and the second paramater is the default value | ||
| outputs : dict of {str: str} | ||
| The description of the outputs that this command generated. The | ||
| format is: {output_name: artifact_type} | ||
| default_parameter_sets : dict of {str: dict of {str: str}} | ||
| The default parameter sets of the command, keyed by parameter set name. | ||
| The values should be a dictionary in which keys are the parameter names | ||
| and values are the specific value for each parameter | ||
|
|
||
| Raises | ||
| ------ | ||
| TypeError | ||
| If `function` is not callable | ||
| ValueError | ||
| If `function` does not accept 4 parameters | ||
| """ | ||
| def __init__(self, name, description, function, required_parameters, | ||
| optional_parameters, outputs, default_parameter_sets=None): | ||
| self.name = name | ||
| self.description = description | ||
|
|
||
| # Make sure that `function` is callable | ||
| if not callable(function): | ||
| raise TypeError( | ||
| "Couldn't create command '%s': the provided function is not " | ||
| "callable (type: %s)" % (name, type(function))) | ||
|
|
||
| # `function` will be called with the following Parameters | ||
| # qclient, job_id, job_parameters, output_dir | ||
| # Make sure that `function` can receive 4 parameters | ||
| if function.__code__.co_argcount != 4: | ||
| raise ValueError( | ||
| "Couldn't register command '%s': the provided function does " | ||
| "not accept 4 parameters (number of parameters: %d)" | ||
| % (name, function.__code__.co_argcount)) | ||
|
|
||
| self.function = function | ||
| self.required_parameters = required_parameters | ||
| self.optional_parameters = optional_parameters | ||
| self.default_parameter_sets = default_parameter_sets | ||
| self.outputs = outputs | ||
|
|
||
| def __call__(self, qclient, server_url, job_id, output_dir): | ||
| return self.function(qclient, server_url, job_id, output_dir) | ||
|
|
||
|
|
||
| class QiitaArtifactType(object): | ||
| """A Qiita artifact type | ||
|
|
||
| Parameters | ||
| ---------- | ||
| name : str | ||
| The artifact type name | ||
| description : str | ||
| The artifact type description | ||
| can_be_submitted_to_ebi : bool | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what's the likelihood that external submission or data exchanges will expand in the future? if it is reasonable, then it may make sense to do a more discoverable structure, something like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Uhm, not sure. @antgonza any thoughts? If this can expand we may want to rethink also how to change the current DB structure to support this (although we can refer that for later)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's likely to change but not sure when. I agree with the approach of dealing with it once we know it's actually going to happen. Note that this will also require changes in the main qiita code base ... |
||
| Whether the artifact type can be submitted to EBI or not | ||
| can_be_submitted_to_vamps : bool | ||
| Whether the artifact type can be submitted to VAMPS or not | ||
| filepath_types : list of (str, bool) | ||
| The list filepath types that the new artifact type supports, and | ||
| if they're required or not in an artifact instance of this type""" | ||
| def __init__(self, name, description, can_be_submitted_to_ebi, | ||
| can_be_submitted_to_vamps, filepath_types): | ||
| self.name = name | ||
| self.description = description | ||
| self.ebi = can_be_submitted_to_ebi | ||
| self.vamps = can_be_submitted_to_vamps | ||
| self.fp_types = filepath_types | ||
|
|
||
|
|
||
| class BaseQiitaPlugin(object): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do the methods in this object get tested?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a BaseClass so I'm not instantiating it directly, but all the methods are being executed through the child clases |
||
| def __init__(self, name, version, description, publications=None): | ||
| self.name = name | ||
| self.version = version | ||
| self.description = description | ||
| self.publications = dumps(publications) if publications else "" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there a plugin type which we anticipate wont have a publication or doi? if they don't, we could always require tying a version to zenodo. perhaps unnecessary
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would be surprised if something doesn't have a DOI - I think in previous discussions we agreed to use DOI since it is more common to have a DOI.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is going to be cases we are going to be using tools without publication, the first one that comes to mind is: KneedData. |
||
|
|
||
| # Will hold the different commands | ||
| self.task_dict = {} | ||
|
|
||
| # The configuration file | ||
| conf_dir = environ.get( | ||
| 'QIITA_PLUGINS_DIR', join(expanduser('~'), '.qiita_plugins')) | ||
| self.conf_fp = join(conf_dir, "%s_%s.conf" % (self.name, self.version)) | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would it make sense for an assert over
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The conf_fp doesn't necessarily exists at this point (see the function |
||
| def generate_config(self, env_script, start_script, server_cert=None): | ||
| """Generates the plugin configuration file | ||
|
|
||
| Parameters | ||
| ---------- | ||
| env_script : str | ||
| The CLI call used to load the environment in which the plugin is | ||
| installed | ||
| start_script : str | ||
| The script used to start the plugin | ||
| server_cert : str, optional | ||
| If the Qiita server used does not have a valid certificate, the | ||
| path to the Qiita certificate so the plugin can connect over | ||
| HTTPS to it | ||
| """ | ||
| sr = SystemRandom() | ||
| chars = ascii_letters + digits | ||
| client_id = ''.join(sr.choice(chars) for i in range(50)) | ||
| client_secret = ''.join(sr.choice(chars) for i in range(255)) | ||
|
|
||
| server_cert = server_cert if server_cert else "" | ||
|
|
||
| with open(self.conf_fp, 'w') as f: | ||
| f.write(CONF_TEMPLATE % (self.name, self.version, self.description, | ||
| env_script, start_script, | ||
| self._plugin_type, self.publications, | ||
| server_cert, client_id, client_secret)) | ||
|
|
||
| def _register_command(self, command): | ||
| """Registers a command in the plugin | ||
|
|
||
| Parameters | ||
| ---------- | ||
| command: QiitaCommand | ||
| The command to be added to the plugin | ||
| """ | ||
| self.task_dict[command.name] = command | ||
|
|
||
| def _register(self, qclient): | ||
| """Registers the plugin information in Qiita""" | ||
| # Get the command information from qiita | ||
| info = qclient.get('/qiita_db/plugins/%s/%s/' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused here, qiita already knows about the plugin prior to the install? Should the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, so when Qiita starts the server, it will load the plugin configuration files. The reason behind this is that in order to communicate the plugin (client) and Qiita (server) they need to both know the credentials. So the config file is generating those credentials, so in order to actually add a plugin to the system you need access to the FS to be able to add this configuration file. However, the configuration file only allows to "register" the plugin itself, but not the commands. The commands get added dynamically using the REST api, which is what is done here. Is a bit complex, but I think this is the best balance between security and flexibility. This flexibility in the commands is needed mainly for QIIME 2, which is a system that works with plugins itself, so it is possible that there are commands that are no longer available, while new ones may appear. Regarding the 200, the QiitaClient object (qclient) already performs those checks internally and it will raise a useful error in case that the return code is different from 200. The object returned from the |
||
| % (self.name, self.version)) | ||
|
|
||
| for cmd in self.task_dict.values(): | ||
| if cmd.name in info['commands']: | ||
| qclient.post('/qiita_db/plugins/%s/%s/commands/%s/activate/' | ||
| % (self.name, self.version, | ||
| urllib.parse.quote(cmd.name))) | ||
| else: | ||
| req_params = { | ||
| k: v if v[0] != 'artifact' else ['artifact:%s' | ||
| % dumps(v[1]), None] | ||
| for k, v in cmd.required_parameters.items()} | ||
|
|
||
| data = {'name': cmd.name, | ||
| 'description': cmd.description, | ||
| 'required_parameters': dumps(req_params), | ||
| 'optional_parameters': dumps(cmd.optional_parameters), | ||
| 'default_parameter_sets': dumps( | ||
| cmd.default_parameter_sets)} | ||
| qclient.post('/qiita_db/plugins/%s/%s/commands/' | ||
| % (self.name, self.version), data=data) | ||
|
|
||
| def __call__(self, server_url, job_id, output_dir): | ||
| """Runs the plugin and executed the assigned task | ||
|
|
||
| Parameters | ||
| ---------- | ||
| server_url : str | ||
| The url of the server | ||
| job_id : str | ||
| The job id | ||
| output_dir : str | ||
| The output directory | ||
|
|
||
| Raises | ||
| ------ | ||
| RuntimeError | ||
| If there is a problem gathering the job information | ||
| """ | ||
| # Set up the Qiita Client | ||
| config = ConfigParser() | ||
| with open(self.conf_fp, 'U') as conf_file: | ||
| config.readfp(conf_file) | ||
|
|
||
| qclient = QiitaClient(server_url, config.get('oauth2', 'CLIENT_ID'), | ||
| config.get('oauth2', 'CLIENT_SECRET'), | ||
| server_cert=config.get('oauth2', 'SERVER_CERT')) | ||
|
|
||
| if job_id == 'register': | ||
| self._register(qclient) | ||
| else: | ||
| # Request job information. If there is a problem retrieving the job | ||
| # information, the QiitaClient already raises an error | ||
| job_info = qclient.get_job_info(job_id) | ||
| # Starting the heartbeat | ||
| qclient.start_heartbeat(job_id) | ||
| # Execute the given task | ||
| task_name = job_info['command'] | ||
| task = self.task_dict[task_name] | ||
|
|
||
| if not exists(output_dir): | ||
| makedirs(output_dir) | ||
| try: | ||
| success, artifacts_info, error_msg = task( | ||
| qclient, job_id, job_info['parameters'], output_dir) | ||
| except Exception: | ||
| exc_str = repr(traceback.format_exception(*sys.exc_info())) | ||
| error_msg = ("Error executing %s:\n%s" % (task_name, exc_str)) | ||
| success = False | ||
| artifacts_info = None | ||
| # The job completed | ||
| qclient.complete_job(job_id, success, error_msg=error_msg, | ||
| artifacts_info=artifacts_info) | ||
|
|
||
|
|
||
| class QiitaTypePlugin(BaseQiitaPlugin): | ||
| """Represents a Qiita Type Plugin | ||
|
|
||
| Parameters | ||
| ---------- | ||
| name : str | ||
| The plugin name | ||
| version : str | ||
| The plugin version | ||
| description : str | ||
| The plugin description | ||
| validate_func : callable | ||
| The function used to validate artifacts | ||
| html_generator_func : callable | ||
| The function used to generate the HTML generator | ||
| artifact_types : list of QiitaArtifactType | ||
| The artifact types defined in this plugin | ||
|
|
||
| Notes | ||
| ----- | ||
| Both `validate_func` and `html_generator_func` should be a callable | ||
| that conforms to the signature: | ||
| `(bool, str, [ArtifactInfo] = function(qclient, job_id, job_parameters, | ||
| output_dir)` | ||
| where qclient is an instance of QiitaClient, job_id is a string with | ||
| the job identifier, job_parameters is a dictionary with the parameters | ||
| of the command and output_dir is a string with the output directory. | ||
| The function should return a boolean indicating if the command was | ||
| executed successfully or not, a string containing a message in case | ||
| of error, and a list of ArtifactInfo objects in case of success. | ||
| """ | ||
| _plugin_type = "artifact definition" | ||
|
|
||
| def __init__(self, name, version, description, validate_func, | ||
| html_generator_func, artifact_types, publications=None): | ||
| super(QiitaTypePlugin, self).__init__(name, version, description, | ||
| publications=publications) | ||
|
|
||
| self.artifact_types = artifact_types | ||
|
|
||
| val_cmd = QiitaCommand( | ||
| 'Validate', 'Validates a new artifact', validate_func, | ||
| {'template': ('prep_template', None), | ||
| 'files': ('string', None), | ||
| 'artifact_type': ('string', None)}, {}, None) | ||
|
|
||
| self._register_command(val_cmd) | ||
|
|
||
| html_cmd = QiitaCommand( | ||
| 'Generate HTML summary', 'Generates the HTML summary', | ||
| html_generator_func, | ||
| {'input_data': ('artifact', | ||
| [a.name for a in self.artifact_types])}, {}, None) | ||
|
|
||
| self._register_command(html_cmd) | ||
|
|
||
| def _register(self, qclient): | ||
| """Registers the plugin information in Qiita""" | ||
| for at in self.artifact_types: | ||
| data = {'type_name': at.name, | ||
| 'description': at.description, | ||
| 'can_be_submitted_to_ebi': at.ebi, | ||
| 'can_be_submitted_to_vamps': at.vamps, | ||
| 'filepath_types': dumps(at.fp_types)} | ||
| qclient.post('/qiita_db/artifacts/types/', data=data) | ||
|
|
||
| super(QiitaTypePlugin, self)._register(qclient) | ||
|
|
||
|
|
||
| class QiitaPlugin(BaseQiitaPlugin): | ||
| """Represents a Qiita Plugin""" | ||
|
|
||
| _plugin_type = "artifact transformation" | ||
|
|
||
| def register_command(self, command): | ||
| """Registers a command in the plugin | ||
|
|
||
| Parameters | ||
| ---------- | ||
| command: QiitaCommand | ||
| The command to be added to the plugin | ||
| """ | ||
| self._register_command(command) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it seems like this should just be public?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't want the |
||
|
|
||
|
|
||
| CONF_TEMPLATE = """[main] | ||
| NAME = %s | ||
| VERSION = %s | ||
| DESCRIPTION = %s | ||
| ENVIRONMENT_SCRIPT = %s | ||
| START_SCRIPT = %s | ||
| PLUGIN_TYPE = %s | ||
| PUBLICATIONS = %s | ||
|
|
||
| [oauth2] | ||
| SERVER_CERT = %s | ||
| CLIENT_ID = %s | ||
| CLIENT_SECRET = %s""" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
:/ this is still python 2, right? Annotations would be so awesome here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
py2/py3 compatible. I want this utility library to be as general as possible so we can use it with QIIME1 and QIIME2, although I expect more devs use this to create the plugins of their own tools (which we don't control if they're py2 or py3).