From a4be4605380a1fbb4acd2db707cfcf8aef9940e0 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Sun, 21 Dec 2014 17:15:56 +0530 Subject: [PATCH] Introduce framework for checking/installing packages - Uses PackageKit dameon, Glib library wrapping packagekit DBUS API and Python bindings for the Glib library. - Implement a decorator to wrap views requiring packages. - Framework allows for parallel operations. However, doing parallel operations hangs because of what appears to be PackageKit backend limitations. --- INSTALL | 2 +- plinth/forms.py | 33 +++++ plinth/package.py | 184 ++++++++++++++++++++++++++ plinth/templates/package_install.html | 82 ++++++++++++ plinth/views.py | 50 ++++++- setup.py | 3 +- 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 plinth/forms.py create mode 100644 plinth/package.py create mode 100644 plinth/templates/package_install.html diff --git a/INSTALL b/INSTALL index 80fe31ab0..14b9dd327 100644 --- a/INSTALL +++ b/INSTALL @@ -7,7 +7,7 @@ $ sudo apt-get install libjs-jquery libjs-modernizr \ libjs-bootstrap make pandoc python3 python3-cherrypy3 \ python3-coverage python3-django python3-bootstrapform \ - python3-setuptools + python3-gi python3-setuptools gir1.2-packagekitglib-1.0 2. Install Plinth: diff --git a/plinth/forms.py b/plinth/forms.py new file mode 100644 index 000000000..aa3ee74b4 --- /dev/null +++ b/plinth/forms.py @@ -0,0 +1,33 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +""" +Plinth framework forms +""" + +from django import forms + + +class PackageInstallForm(forms.Form): + """Prompt for installation of a package. + + XXX: Don't store the package list in a hidden input as it can be + modified on the client side. Use session store to store and retrieve + the package list. It has to be form specific so that multiple + instances of forms don't clash with each other. + """ + package_names = forms.CharField(widget=forms.HiddenInput) diff --git a/plinth/package.py b/plinth/package.py new file mode 100644 index 000000000..e7fd3a344 --- /dev/null +++ b/plinth/package.py @@ -0,0 +1,184 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +""" +Framework for installing and updating distribution packages +""" + +import functools +from gi.repository import PackageKitGlib as packagekit +import logging +import threading + +import plinth + + +logger = logging.getLogger(__name__) +transactions = {} +packages_resolved = {} + + +class Transaction(object): + """Information about an ongoing transaction.""" + + def __init__(self, package_names): + """Initialize transaction object. + + Set most values to None until they are sent as progress update. + """ + self.package_names = package_names + + # Progress + self.allow_cancel = None + self.percentage = None + self.status = None + self.status_string = None + self.flags = None + self.package = None + self.package_id = None + self.item_progress = None + self.role = None + self.caller_active = None + self.download_size_remaining = None + + def get_id(self): + """Return a identifier to use as a key in a map of transactions.""" + return frozenset(self.package_names) + + def __str__(self): + """Return the string representation of the object""" + return ('Transaction(packages={0}, allow_cancel={1}, status={2}, ' + ' percentage={3}, package={4}, item_progress={5})').format( + self.package_names, self.allow_cancel, self.status_string, + self.percentage, self.package, self.item_progress) + + def start_install(self): + """Start a PackageKit transaction to install given list of packages. + + This operation is non-blocking at it spawns a new thread. + """ + thread = threading.Thread(target=self._install) + thread.start() + + def _install(self): + """Run a PackageKit transaction to install given packages.""" + package_ids = [packages_resolved[package_name].get_id() + for package_name in self.package_names] + client = packagekit.Client() + client.set_interactive(False) + client.install_packages(packagekit.TransactionFlagEnum.ONLY_TRUSTED, + package_ids + [None], None, + self.progress_callback, self) + + def progress_callback(self, progress, progress_type, user_data): + """Process progress updates on package resolve operation""" + if progress_type == packagekit.ProgressType.PERCENTAGE: + self.percentage = progress.props.percentage + elif progress_type == packagekit.ProgressType.PACKAGE: + self.package = progress.props.package + elif progress_type == packagekit.ProgressType.ALLOW_CANCEL: + self.allow_cancel = progress.props.allow_cancel + elif progress_type == packagekit.ProgressType.PACKAGE_ID: + self.package_id = progress.props.package_id + elif progress_type == packagekit.ProgressType.ITEM_PROGRESS: + self.item_progress = progress.props.item_progress + elif progress_type == packagekit.ProgressType.STATUS: + self.status = progress.props.status + self.status_string = \ + packagekit.StatusEnum.to_string(progress.props.status) + if self.status == packagekit.StatusEnum.FINISHED: + self.finish() + elif progress_type == packagekit.ProgressType.TRANSACTION_FLAGS: + self.flags = progress.props.transaction_flags + elif progress_type == packagekit.ProgressType.ROLE: + self.role = progress.props.role + elif progress_type == packagekit.ProgressType.CALLER_ACTIVE: + self.caller_active = progress.props.caller_active + elif progress_type == packagekit.ProgressType.DOWNLOAD_SIZE_REMAINING: + self.download_size_remaining = \ + progress.props.download_size_remaining + else: + logger.info('Unhandle packagekit progress callback - %s, %s', + progress, progress_type) + + def finish(self): + """Perform clean up operations on the transaction. + + Remove self from global transactions list. + """ + del transactions[self.get_id()] + + +def required(*package_names): + """Decorate a view to check and install required packages.""" + + def wrapper2(func): + """Return a function to check and install packages.""" + + @functools.wraps(func) + def wrapper(request, *args, **kwargs): + """Check and install packages required by a view.""" + if not is_installing(package_names) and \ + check_installed(package_names): + return func(request, *args, **kwargs) + + view = plinth.views.PackageInstallView.as_view() + return view(request, package_names=package_names, *args, **kwargs) + + return wrapper + + return wrapper2 + + +def check_installed(package_names): + """Return a boolean installed status of package. + + This operation is blocking and waits until the check is finished. + """ + def _callback(progress, progress_type, user_data): + """Process progress updates on package resolve operation.""" + pass + + client = packagekit.Client() + response = client.resolve(packagekit.FilterEnum.INSTALLED, + package_names + (None, ), None, + _callback, None) + + installed_package_names = [] + for package in response.get_package_array(): + if package.get_info() == packagekit.InfoEnum.INSTALLED: + installed_package_names.append(package.get_name()) + + packages_resolved[package.get_name()] = package + + return set(installed_package_names) == set(package_names) + + +def is_installing(package_names): + """Return whether a set of packages are currently being installed.""" + return frozenset(package_names) in transactions + + +def start_install(package_names): + """Start a PackageKit transaction to install given list of packages. + + This operation is non-blocking at it spawns a new thread. + """ + transaction = Transaction(package_names) + transactions[frozenset(package_names)] = transaction + + transaction.start_install() diff --git a/plinth/templates/package_install.html b/plinth/templates/package_install.html new file mode 100644 index 000000000..0624a8898 --- /dev/null +++ b/plinth/templates/package_install.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +{% endcomment %} + +{% load bootstrap %} + + +{% block page_head %} + + {% if is_installing %} + + {% endif %} + +{% endblock %} + + +{% block content %} + +

Installation

+ + {% if not is_installing %} + +

This feature requires addtional packages to be installed. Do you + wish to install them?

+ + + + + + + {% for package in packages %} + + + + + {% endfor %} + +
PackageSummary
{{ package.get_name }}{{ package.get_summary }}
+ +
+ {% csrf_token %} + + {{ form|bootstrap }} + + +
+ + {% else %} + + {% for key, transaction in transactions.items %} +
Installing {{ transaction.package_names|join:", " }}: + {{ transaction.status_string }} +
+
+
+ {{ transaction.percentage }}% complete +
+
+ {% endfor %} + + {% endif %} + +{% endblock %} diff --git a/plinth/views.py b/plinth/views.py index 481911979..81a58c022 100644 --- a/plinth/views.py +++ b/plinth/views.py @@ -19,13 +19,61 @@ Main Plinth views """ +from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import reverse from django.http.response import HttpResponseRedirect +from django.views.generic.edit import FormView + +from plinth import package as package_module +from plinth.forms import PackageInstallForm def index(request): - """Serve the main index page""" + """Serve the main index page.""" if request.user.is_authenticated(): return HttpResponseRedirect(reverse('apps:index')) return HttpResponseRedirect(reverse('help:about')) + + +class PackageInstallView(FormView): + """View to prompt and install packages.""" + template_name = 'package_install.html' + form_class = PackageInstallForm + + def get_context_data(self, **kwargs): + """Return the context data rendering the template.""" + context = super(PackageInstallView, self).get_context_data(**kwargs) + if 'packages_names' not in context: + context['package_names'] = self.kwargs.get('package_names', []) + + # Package details must have been resolved before building the form + context['packages'] = [package_module.packages_resolved[package_name] + for package_name in context['package_names']] + context['is_installing'] = \ + package_module.is_installing(context['package_names']) + context['transactions'] = package_module.transactions + + return context + + def get_initial(self): + """Return the initial data to be filled in the form.""" + initial = super(PackageInstallView, self).get_initial() + try: + initial['package_names'] = ','.join(self.kwargs['package_names']) + except KeyError: + raise ImproperlyConfigured('Argument package_names must be ' + 'provided to PackageInstallView') + + return initial + + def form_valid(self, form): + """Handle successful validation of the form. + + Start the package installation and show this view again. + """ + package_names = form.cleaned_data['package_names'].split(',') + package_module.start_install(package_names) + + return self.render_to_response( + self.get_context_data(package_names=package_names)) diff --git a/setup.py b/setup.py index 970a0f8de..1e0d85247 100755 --- a/setup.py +++ b/setup.py @@ -114,7 +114,8 @@ setuptools.setup( install_requires=[ 'cherrypy >= 3.0', 'django >= 1.7.0', - 'django-bootstrap-form' + 'django-bootstrap-form', + 'pygobject' ], tests_require=['coverage >= 3.7'], include_package_data=True,