diff --git a/configure.ac b/configure.ac index b7b348cd..a2088d0c 100644 --- a/configure.ac +++ b/configure.ac @@ -84,6 +84,7 @@ services/console/interface/logviewer/Makefile services/console/interface/terminal/Makefile sugar/Makefile sugar/activity/Makefile +sugar/bundle/Makefile sugar/clipboard/Makefile sugar/graphics/Makefile sugar/objects/Makefile diff --git a/data/sugar.xml.in b/data/sugar.xml.in index 254e5e58..6a7f2537 100644 --- a/data/sugar.xml.in +++ b/data/sugar.xml.in @@ -4,4 +4,8 @@ <_comment>Sugar activity bundle + + <_comment>Sugar content bundle + + \ No newline at end of file diff --git a/sugar/Makefile.am b/sugar/Makefile.am index 4b94ff14..72ddb1b2 100644 --- a/sugar/Makefile.am +++ b/sugar/Makefile.am @@ -1,4 +1,4 @@ -SUBDIRS = activity clipboard graphics objects presence datastore +SUBDIRS = activity bundle clipboard graphics objects presence datastore sugardir = $(pythondir)/sugar sugar_PYTHON = \ diff --git a/sugar/activity/bundle.py b/sugar/activity/bundle.py index cccff5ae..a5231ef6 100644 --- a/sugar/activity/bundle.py +++ b/sugar/activity/bundle.py @@ -29,6 +29,9 @@ import dbus from sugar import env from sugar import activity +from sugar.bundle.bundle import AlreadyInstalledException, \ + NotInstalledException, InvalidPathException, ZipExtractException, \ + RegistrationException, MalformedBundleException _PYTHON_FACTORY='sugar-activity-factory' @@ -36,13 +39,6 @@ _DBUS_SHELL_SERVICE = "org.laptop.Shell" _DBUS_SHELL_PATH = "/org/laptop/Shell" _DBUS_ACTIVITY_REGISTRY_IFACE = "org.laptop.Shell.ActivityRegistry" -class AlreadyInstalledException(Exception): pass -class NotInstalledException(Exception): pass -class InvalidPathException(Exception): pass -class ZipExtractException(Exception): pass -class RegistrationException(Exception): pass -class MalformedBundleException(Exception): pass - class Bundle: """Metadata description of a given application/activity diff --git a/sugar/bundle/Makefile.am b/sugar/bundle/Makefile.am new file mode 100644 index 00000000..2ad09587 --- /dev/null +++ b/sugar/bundle/Makefile.am @@ -0,0 +1,5 @@ +sugardir = $(pythondir)/sugar/bundle +sugar_PYTHON = \ + __init__.py \ + bundle.py \ + contentbundle.py diff --git a/sugar/bundle/__init__.py b/sugar/bundle/__init__.py new file mode 100644 index 00000000..85ebcede --- /dev/null +++ b/sugar/bundle/__init__.py @@ -0,0 +1,16 @@ +# Copyright (C) 2006-2007, Red Hat, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. diff --git a/sugar/bundle/bundle.py b/sugar/bundle/bundle.py new file mode 100644 index 00000000..fa56642d --- /dev/null +++ b/sugar/bundle/bundle.py @@ -0,0 +1,145 @@ +# Copyright (C) 2007, Red Hat, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. + +"""Sugar bundle file handler""" + +import os +import StringIO +import zipfile + +class AlreadyInstalledException(Exception): pass +class NotInstalledException(Exception): pass +class InvalidPathException(Exception): pass +class ZipExtractException(Exception): pass +class RegistrationException(Exception): pass +class MalformedBundleException(Exception): pass + +class Bundle: + """A Sugar activity, content module, etc. + + The bundle itself may be either a zip file or a directory + hierarchy, with metadata about the bundle stored various files + inside it. + + This is an abstract base class. See ActivityBundle and + ContentBundle for more details on those bundle types. + """ + def __init__(self, path): + self._path = path + + if os.path.isdir(self._path): + self._unpacked = True + else: + self._unpacked = False + self._check_zip_bundle() + + # manifest = self._get_file(self._infodir + '/contents') + # if manifest is None: + # raise MalformedBundleException('No manifest file') + # + # signature = self._get_file(self._infodir + '/contents.sig') + # if signature is None: + # raise MalformedBundleException('No signature file') + + def _check_zip_bundle(self): + zip_file = zipfile.ZipFile(self._path) + file_names = zip_file.namelist() + if len(file_names) == 0: + raise MalformedBundleException('Empty zip file') + + self._zip_root_dir = file_names[0].split('/')[0] + if self._unzipped_extension is not None: + (name, ext) = os.path.splitext(self._zip_root_dir) + if ext != self._unzipped_extension: + raise MalformedBundleException( + 'All files in the bundle must be inside a single ' + + 'directory whose name ends with "%s"' % + self._unzipped_extension) + + for file_name in file_names: + if not file_name.startswith(self._zip_root_dir): + raise MalformedBundleException( + 'All files in the bundle must be inside a single ' + + 'top-level directory') + + def _get_file(self, filename): + file = None + + if self._unpacked: + path = os.path.join(self._path, filename) + if os.path.isfile(path): + file = open(path) + else: + zip_file = zipfile.ZipFile(self._path) + path = os.path.join(self._zip_root_dir, filename) + try: + data = zip_file.read(path) + file = StringIO.StringIO(data) + except KeyError: + # == "file not found" + pass + zip_file.close() + + return file + + def get_path(self): + """Get the bundle path.""" + return self._path + + def _unzip(self, install_dir): + if self._unpacked: + raise AlreadyInstalledException + + if not os.path.isdir(install_dir): + os.mkdir(install_dir) + + # zipfile provides API that in theory would let us do this + # correctly by hand, but handling all the oddities of + # Windows/UNIX mappings, extension attributes, deprecated + # features, etc makes it impractical. + # FIXME: use manifest + if os.spawnlp(os.P_WAIT, 'unzip', 'unzip', self._path, + '-d', install_dir): + raise ZipExtractException + + def _zip(self, bundle_path): + if not self._unpacked: + raise NotInstalledException + + # FIXME: use manifest + zip = zipfile.ZipFile(bundle_path, 'w', zipfile.ZIP_DEFLATED) + for root, dirs, files in os.walk(self._path): + for name in files: + zip.write(filename, os.path.join(base_dir, filename)) + zip.close() + + def remove(self): + ext = os.path.splitext(self._path)[1] + if self._unpacked: + if not os.path.isdir(self._path) or ext != self._unzipped_extension: + raise InvalidPathException + for root, dirs, files in os.walk(self._path, topdown=False): + for name in files: + os.remove(os.path.join(root, name)) + for name in dirs: + os.rmdir(os.path.join(root, name)) + os.rmdir(self._path) + else: + if not os.path.isfile(self._path) or ext != self._zipped_extension: + raise InvalidPathException + os.remove(self._path) + diff --git a/sugar/bundle/contentbundle.py b/sugar/bundle/contentbundle.py new file mode 100644 index 00000000..7f9fddbd --- /dev/null +++ b/sugar/bundle/contentbundle.py @@ -0,0 +1,182 @@ +# Copyright (C) 2007, Red Hat, Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. + +"""Sugar content bundles""" + +from ConfigParser import ConfigParser +import os + +from sugar import env +from sugar.bundle.bundle import Bundle + +class ContentBundle(Bundle): + """A Sugar content bundle + + See http://wiki.laptop.org/go/Content_bundles for details + """ + + MIME_TYPE = 'application/vnd.olpc-content' + + _zipped_extension = '.xol' + _unzipped_extension = None + _infodir = 'library' + + def __init__(self, path): + Bundle.__init__(self, path) + + info_file = self._get_file('library/library.info') + if info_file is None: + raise MalformedBundleException('No library.info file') + self._parse_info(info_file) + + if (self._get_file('index.html') is None and + self._get_file('library/library.xml') is None): + raise MalformedBundleException( + 'Content bundle %s has neither index.html nor library.xml' % + self._path) + + def _parse_info(self, info_file): + cp = ConfigParser() + cp.readfp(info_file) + + section = 'Library' + + if cp.has_option(section, 'host_version'): + version = cp.get(section, 'host_version') + try: + if int(version) != 1: + raise MalformedBundleException( + 'Content bundle %s has unknown host_version number %s' % + (self._path, version)) + except ValueError: + raise MalformedBundleException( + 'Content bundle %s has invalid host_version number %s' % + (self._path, version)) + + if cp.has_option(section, 'name'): + self._name = cp.get(section, 'name') + else: + raise MalformedBundleException( + 'Content bundle %s does not specify a name' % self._path) + + if cp.has_option(section, 'library_version'): + version = cp.get(section, 'library_version') + try: + self._library_version = int(version) + except ValueError: + raise MalformedBundleException( + 'Content bundle %s has invalid version number %s' % + (self._path, version)) + + if cp.has_option(section, 'l10n'): + l10n = cp.get(section, 'l10n') + if l10n == 'true': + self._l10n = True + elif l10n == 'false': + self._l10n = False + else: + raise MalformedBundleException( + 'Content bundle %s has invalid l10n key "%s"' % + (self._path, l10n)) + else: + raise MalformedBundleException( + 'Content bundle %s does not specify if it is localized' % + self._path) + + if cp.has_option(section, 'locale'): + self._locale = cp.get(section, 'locale') + else: + raise MalformedBundleException( + 'Content bundle %s does not specify a locale' % self._path) + + if cp.has_option(section, 'category'): + self._category = cp.get(section, 'category') + else: + raise MalformedBundleException( + 'Content bundle %s does not specify a category' % self._path) + + if cp.has_option(section, 'category_icon'): + self._category_icon = cp.get(section, 'category_icon') + else: + self._category_icon = None + + if cp.has_option(section, 'category_class'): + self._category_class = cp.get(section, 'category_class') + else: + self._category_class = None + + if cp.has_option(section, 'subcategory'): + self._subcategory = cp.get(section, 'subcategory') + else: + self._subcategory = None + + if cp.has_option(section, 'bundle_class'): + self._bundle_class = cp.get(section, 'bundle_class') + else: + self._bundle_class = None + + def get_name(self): + return self._name + + def get_library_version(self): + return self._library_version + + def get_l10n(self): + return self._l10n + + def get_locale(self): + return self._locale + + def get_category(self): + return self._category + + def get_category(self): + return self._category + + def get_category_icon(self): + return self._category_icon + + def get_category_class(self): + return self._category_class + + def get_subcategory(self): + return self._subcategory + + def get_bundle_class(self): + return self._bundle_class + + def _run_indexer(self): + os.spawnlp(os.P_WAIT, 'python', + 'python', + os.path.join(env.get_user_library_path(), 'makeIndex.py')) + + def is_installed(self): + if self._unpacked: + return True + elif os.path.isdir(os.path.join(env.get_user_library_path(), + self._zip_root_dir)): + return True + else: + return False + + def install(self): + self._unzip(env.get_user_library_path()) + self._run_indexer() + + def uninstall(self): + self._uninstall() + self._run_indexer() diff --git a/sugar/datastore/datastore.py b/sugar/datastore/datastore.py index eef8499f..6409a8a6 100644 --- a/sugar/datastore/datastore.py +++ b/sugar/datastore/datastore.py @@ -26,6 +26,7 @@ from sugar import activity from sugar.activity.bundle import Bundle from sugar.activity import activityfactory from sugar.activity.activityhandle import ActivityHandle +from sugar.bundle.contentbundle import ContentBundle class DSMetadata(gobject.GObject): __gsignals__ = { @@ -118,6 +119,10 @@ class DSObject(object): return activities + def is_content_bundle(self): + return self.metadata['mime_type'] == ContentBundle.MIME_TYPE + + # FIXME: should become is_activity_bundle() def is_bundle(self): return self.metadata['mime_type'] in ['application/vnd.olpc-x-sugar', 'application/vnd.olpc-sugar'] diff --git a/sugar/env.py b/sugar/env.py index 8707fd9f..e3e0a512 100644 --- a/sugar/env.py +++ b/sugar/env.py @@ -67,6 +67,9 @@ def get_profile_path(path=None): def get_user_activities_path(): return os.path.expanduser('~/Activities') +def get_user_library_path(): + return os.path.expanduser('~/Library') + def get_locale_path(path=None): return _get_prefix_path('share/locale', path)