Skip to content
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

Add API to install resolver tool dependencies #3222

Merged
merged 7 commits into from Nov 28, 2016
1 change: 1 addition & 0 deletions lib/galaxy/datatypes/converters/fasta_to_2bit.xml
Expand Up @@ -2,6 +2,7 @@
<!-- <description>__NOT_USED_CURRENTLY_FOR_CONVERTERS__</description> -->
<!-- Used on the metadata edit page. -->
<requirements>
<requirement type="package" version="332">ucsc-fatotwobit</requirement>
<requirement type="package">ucsc_tools</requirement>
</requirements>
<command>faToTwoBit '$input' '$output'</command>
Expand Down
5 changes: 3 additions & 2 deletions lib/galaxy/tools/__init__.py
Expand Up @@ -1306,15 +1306,16 @@ def validate_inputs( input, value, error, parent, context, prefixed_name, prefix
visit_input_values( self.inputs, values, validate_inputs )
return messages

def build_dependency_cache(self):
def build_dependency_cache(self, **kwds):
if isinstance(self.app.toolbox.dependency_manager, CachedDependencyManager):
self.app.toolbox.dependency_manager.build_cache(
requirements=self.requirements,
installed_tool_dependencies=self.installed_tool_dependencies,
tool_dir=self.tool_dir,
job_directory=None,
metadata=False,
tool_instance=self
tool_instance=self,
**kwds
)

def build_dependency_shell_commands( self, job_directory=None, metadata=False ):
Expand Down
7 changes: 7 additions & 0 deletions lib/galaxy/tools/deps/__init__.py
Expand Up @@ -5,6 +5,7 @@
import json
import logging
import os.path
import shutil

from collections import OrderedDict

Expand Down Expand Up @@ -175,6 +176,12 @@ def build_cache(self, requirements, **kwds):
resolved_dependencies = self.requirements_to_dependencies(requirements, **kwds)
cacheable_dependencies = [dep for req, dep in resolved_dependencies.items() if dep.cacheable]
hashed_requirements_dir = self.get_hashed_requirements_path(cacheable_dependencies)
if kwds.get('force_rebuild', False) and os.path.exists(hashed_requirements_dir):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mvdbeek What happens if an admin installs a tool which has the same set of requirements of a previously installed-and-cached environment (and force_rebuild is False)?

Copy link
Member Author

@mvdbeek mvdbeek Dec 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually a bit inconsistent now (given that initially there was no way to build/rebuild a cache):
If you install a tool with the same set of requirements, you do build the cache again, since this was the only way to fix a broken cache or update a cache with new packages (short of deleting the cache).
But now I think we should expose this in the details section of the tool installation and extend the install repository API endpoint for this parameter. Does that sound like a good idea?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact we should have a UI to manage these things independently of the install process. The pieces are all there, but I don't feel up to the task of building a "Manage tool dependencies" page. That would be a huge win IMO. I have a clear picture in my head how this should look like, but looking through the codebase I don't really see something that I could base my work on like I did for the conda install process.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you install a tool with the same set of requirements, you do build the cache again, since this was the only way to fix a broken cache

I think that should be done only when using the new force_rebuild option, and yes, we need an UI for that!

or update a cache with new packages (short of deleting the cache).

Do you mean updating a package that is already in the cached env or adding a new package to it? I don't think the second should happen, because that should have a different hash. And for the first, rebuilding is probably better.

So, I'd change this code to be:

        if os.path.exists(hashed_requirements_dir):
            if kwds.get('force_rebuild', False):
                try:
                    shutil.rmtree(hashed_requirements_dir)
                except Exception:
                    log.warning("Could not delete cached requirements directory '%s'" % hashed_requirements_dir)
                    pass
            else:
                log.debug("Cached environment %s already exists, skipping build", hashed_requirements_dir)
                return
        [dep.build_cache(hashed_requirements_dir) for dep in cacheable_dependencies]

In fact we should have a UI to manage these things independently of the install process. The pieces are all there, but I don't feel up to the task of building a "Manage tool dependencies" page.

I'd love that! Hopefully someone from the core Galaxy Team can help?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I'd change this code to be:

yep, that looks pefect. Do you want to open a PR? Otherwise I can include this with a Documentation update.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank @mvdbeek, I'll open a PR later.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #3295.

try:
shutil.rmtree(hashed_requirements_dir)
except Exception:
log.warning("Could not delete cached requirements directory '%s'" % hashed_requirements_dir)
pass
[dep.build_cache(hashed_requirements_dir) for dep in cacheable_dependencies]

def dependency_shell_commands( self, requirements, **kwds ):
Expand Down
12 changes: 12 additions & 0 deletions lib/galaxy/tools/deps/conda_util.py
Expand Up @@ -214,6 +214,17 @@ def exec_install(self, args):
install_base_args.extend(args)
return self.exec_command("install", install_base_args)

def exec_clean(self, args=[]):
"""
Clean up after conda installation.
"""
clean_base_args = [
"--tarballs",
"-y"
]
clean_base_args.extend(args)
return self.exec_command("clean", clean_base_args)

def export_list(self, name, path):
return self.exec_command("list", [
"--name", name,
Expand Down Expand Up @@ -488,6 +499,7 @@ def build_isolated_environment(

return (path or tempdir_name, exit_code)
finally:
conda_context.exec_clean()
shutil.rmtree(tempdir)


Expand Down
3 changes: 3 additions & 0 deletions lib/galaxy/tools/deps/resolvers/conda.py
Expand Up @@ -98,6 +98,9 @@ def get_option(name):
self.auto_install = auto_install
self.copy_dependencies = copy_dependencies

def clean(self, **kwds):
return self.conda_context.exec_clean()

def resolve(self, name, version, type, **kwds):
# Check for conda just not being there, this way we can enable
# conda by default and just do nothing in not configured.
Expand Down
12 changes: 12 additions & 0 deletions lib/galaxy/tools/deps/views.py
Expand Up @@ -127,3 +127,15 @@ def installable_resolvers(self):

def get_requirements_status(self, requested_requirements, installed_tool_dependencies=None):
return [self.manager_dependency(installed_tool_dependencies=installed_tool_dependencies, **req) for req in requested_requirements]

def clean(self, index=None, **kwds):
if index:
resolver = self._dependency_resolver(index)
if not hasattr(resolver, "clean"):
raise NotImplemented()
else:
resolver.clean()
return "OK"
else:
[resolver.clean(**kwds) for resolver in self._dependency_resolvers if hasattr(resolver, 'clean')]
return "OK"
19 changes: 19 additions & 0 deletions lib/galaxy/webapps/galaxy/api/tool_dependencies.py
Expand Up @@ -162,3 +162,22 @@ def manager_requirements(self, trans, **kwds):
the corresponding resolver (keyed on 'index').
"""
return self._view.manager_requirements()

@expose_api
@require_admin
def clean(self, trans, id=None, **kwds):
"""
POST /api/dependencies_resolver/{index}/clean

Cleans up intermediate files created by resolvers during the dependency
installation.

:type index: int
:param index: index of the dependency resolver

:rtype: dict
:returns: a dictified description of the requirement that could
be resolved (keyed on 'requirement') and the index of
the corresponding resolver (keyed on 'index').
"""
return self._view.clean(id, **kwds)
34 changes: 34 additions & 0 deletions lib/galaxy/webapps/galaxy/api/tools.py
Expand Up @@ -129,6 +129,40 @@ def requirements(self, trans, id, **kwds):
tool = self._get_tool(id)
return tool.tool_requirements_status

@expose_api
@web.require_admin
def install_dependencies(self, trans, id, **kwds):
"""
POST /api/tools/{tool_id}/install_dependencies
Attempts to install requirements via the dependency resolver

parameters:
build_dependency_cache: If true, attempts to cache dependencies for this tool
force_rebuild: If true and chache dir exists, attempts to delete cache dir
"""
tool = self._get_tool(id)
[tool._view.install_dependency(id=None, **req.to_dict()) for req in tool.requirements]
if kwds.get('build_dependency_cache'):
tool.build_dependency_cache(**kwds)
# TODO: rework resolver install system to log and report what has been done.
# _view.install_dependency should return a dict with stdout, stderr and success status
return tool.tool_requirements_status

@expose_api
@web.require_admin
def build_dependency_cache(self, trans, id, **kwds):
"""
POST /api/tools/{tool_id}/build_dependency_cache
Attempts to cache installed dependencies.

parameters:
force_rebuild: If true and chache dir exists, attempts to delete cache dir
"""
tool = self._get_tool(id)
tool.build_dependency_cache(**kwds)
# TODO: Should also have a more meaningful return.
return tool.tool_requirements_status

@expose_api
@web.require_admin
def diagnostics( self, trans, id, **kwd ):
Expand Down
4 changes: 4 additions & 0 deletions lib/galaxy/webapps/galaxy/buildapp.py
Expand Up @@ -262,12 +262,16 @@ def populate_api_routes( webapp, app ):
webapp.mapper.connect( '/api/tools/{id:.+?}/citations', action='citations', controller="tools" )
webapp.mapper.connect( '/api/tools/{id:.+?}/download', action='download', controller="tools" )
webapp.mapper.connect( '/api/tools/{id:.+?}/requirements', action='requirements', controller="tools")
webapp.mapper.connect( '/api/tools/{id:.+?}/install_dependencies', action='install_dependencies', controller="tools", conditions=dict( method=[ "POST" ] ))
webapp.mapper.connect( '/api/tools/{id:.+?}/build_dependency_cache', action='build_dependency_cache', controller="tools", conditions=dict( method=[ "POST" ] ))
webapp.mapper.connect( '/api/tools/{id:.+?}', action='show', controller="tools" )
webapp.mapper.resource( 'tool', 'tools', path_prefix='/api' )

webapp.mapper.connect( '/api/dependency_resolvers/clean', action="clean", controller="tool_dependencies", conditions=dict( method=[ "POST" ]) )
webapp.mapper.connect( '/api/dependency_resolvers/dependency', action="manager_dependency", controller="tool_dependencies", conditions=dict( method=[ "GET" ] ) )
webapp.mapper.connect( '/api/dependency_resolvers/dependency', action="install_dependency", controller="tool_dependencies", conditions=dict( method=[ "POST" ] ) )
webapp.mapper.connect( '/api/dependency_resolvers/requirements', action="manager_requirements", controller="tool_dependencies" )
webapp.mapper.connect( '/api/dependency_resolvers/{id}/clean', action="clean", controller="tool_dependencies", conditions=dict( method=[ "POST" ]) )
webapp.mapper.connect( '/api/dependency_resolvers/{id}/dependency', action="resolver_dependency", controller="tool_dependencies", conditions=dict( method=[ "GET" ] ) )
webapp.mapper.connect( '/api/dependency_resolvers/{id}/dependency', action="install_dependency", controller="tool_dependencies", conditions=dict( method=[ "POST" ] ) )
webapp.mapper.connect( '/api/dependency_resolvers/{id}/requirements', action="resolver_requirements", controller="tool_dependencies" )
Expand Down
20 changes: 20 additions & 0 deletions test/integration/test_resolvers.py
Expand Up @@ -17,6 +17,7 @@ class CondaResolutionIntegrationTestCase(integration_util.IntegrationTestCase, A
@classmethod
def handle_galaxy_config_kwds(cls, config):
cls.conda_tmp_prefix = mkdtemp()
config["use_cached_dep_manager"] = True
config["conda_auto_init"] = True
config["conda_prefix"] = os.path.join(cls.conda_tmp_prefix, 'conda')

Expand Down Expand Up @@ -82,3 +83,22 @@ def test_dependency_status_installed_not_exact( self ):
self._assert_status_code_is( create_response, 200 )
response = create_response.json()
assert response['dependency_type'] == 'conda' and not response['exact']

def test_conda_install_through_tools_api( self ):
tool_id = 'mulled_example_multi_1'
endpoint = "tools/%s/install_dependencies" % tool_id
data = {'id': tool_id}
create_response = self._post(endpoint, data=data, admin=True)
self._assert_status_code_is( create_response, 200 )
response = create_response.json()
assert any([True for d in response if d['dependency_type'] == 'conda'])
endpoint = "tools/%s/build_dependency_cache" % tool_id
create_response = self._post(endpoint, data=data, admin=True)
self._assert_status_code_is( create_response, 200 )

def test_conda_clean( self ):
endpoint = 'dependency_resolvers/clean'
create_response = self._post(endpoint, data={}, admin=True)
self._assert_status_code_is(create_response, 200)
response = create_response.json()
assert response == "OK"