From 1f1765ccef89e034fd06fceb9b379bcb89091f97 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Wed, 9 May 2012 13:08:44 +0100 Subject: [PATCH] Added experimental app support Apps work a bit like aliases, except that they store the selections XML. This allows them to start faster. --- 0alias | 24 +------ tests/basetest.py | 3 +- zeroinstall/apps.py | 160 +++++++++++++++++++++++++++++++++++++++++ zeroinstall/cmd/__init__.py | 2 +- zeroinstall/cmd/add.py | 33 +++++++++ zeroinstall/cmd/destroy.py | 22 ++++++ zeroinstall/cmd/run.py | 18 +++-- zeroinstall/injector/config.py | 11 ++- 8 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 zeroinstall/apps.py create mode 100644 zeroinstall/cmd/add.py create mode 100644 zeroinstall/cmd/destroy.py diff --git a/0alias b/0alias index 681fa3d..faac6b0 100755 --- a/0alias +++ b/0alias @@ -20,7 +20,7 @@ from optparse import OptionParser from zeroinstall.injector import reader, model from zeroinstall.injector.config import load_config -from zeroinstall import support, alias, helpers, _ +from zeroinstall import support, alias, helpers, apps, _ from zeroinstall.support import basedir config = load_config() @@ -32,30 +32,10 @@ def export(name, value): return "setenv %s %s" % (name, value) return "export %s=%s" % (name, value) -def find_path(paths): - """Find the first writable path in the list, - skipping /bin, /sbin and everything under /usr except /usr/local/bin""" - for path in paths: - if path.startswith('/usr/') and not path.startswith('/usr/local/bin'): - # (/usr/local/bin is OK if we're running as root) - pass - elif path.startswith('/bin') or path.startswith('/sbin'): - pass - elif os.path.realpath(path).startswith(basedir.xdg_cache_home): - pass # print "Skipping cache", first_path - elif not os.access(path, os.W_OK): - pass # print "No access", first_path - else: - break - else: - return None - - return path - # Do this here so we can include it in the help message. # But, don't abort if there isn't one because we might # be doing something else (e.g. --manpage) -first_path = find_path(os.environ['PATH'].split(':')) +first_path = apps.find_bin_dir() in_path = first_path is not None if not in_path: first_path = os.path.expanduser('~/bin/') diff --git a/tests/basetest.py b/tests/basetest.py index f74ca2e..8016f07 100755 --- a/tests/basetest.py +++ b/tests/basetest.py @@ -15,7 +15,7 @@ sys.path.insert(0, '..') from zeroinstall.injector import qdom from zeroinstall.injector import iface_cache, download, distro, model, handler, policy, reader, trust from zeroinstall.zerostore import NotStored, Store, Stores; Store._add_with_helper = lambda *unused: False -from zeroinstall import support +from zeroinstall import support, apps from zeroinstall.support import basedir, tasks dpkgdir = os.path.join(os.path.dirname(__file__), 'dpkg') @@ -155,6 +155,7 @@ class TestConfig: self.fetcher = TestFetcher(self) self.trust_db = trust.trust_db self.trust_mgr = trust.TrustMgr(self) + self.app_mgr = apps.AppManager(self) class BaseTest(unittest.TestCase): def setUp(self): diff --git a/zeroinstall/apps.py b/zeroinstall/apps.py new file mode 100644 index 0000000..b0b10b1 --- /dev/null +++ b/zeroinstall/apps.py @@ -0,0 +1,160 @@ +""" +Support for managing apps (as created with "0install add"). +@since: 1.8 +""" + +# Copyright (C) 2012, Thomas Leonard +# See the README file for details, or visit http://0install.net. + +from zeroinstall import _, SafeException +from zeroinstall.support import basedir +from zeroinstall.injector import namespaces, selections, qdom +from logging import warn +import re, os, time + +# Avoid characters that are likely to cause problems (reject : and ; everywhere +# so that apps can be portable between POSIX and Windows). +valid_name = re.compile(r'''^[^./\\:=;'"][^/\\:=;'"]*$''') + +def validate_name(name): + if valid_name.match(name): return + raise SafeException("Invalid application name '{name}'".format(name = name)) + +def find_bin_dir(paths = None): + """Find the first writable path in the list (default $PATH), + skipping /bin, /sbin and everything under /usr except /usr/local/bin""" + if paths is None: + paths = os.environ['PATH'].split(os.pathsep) + for path in paths: + if path.startswith('/usr/') and not path.startswith('/usr/local/bin'): + # (/usr/local/bin is OK if we're running as root) + pass + elif path.startswith('/bin') or path.startswith('/sbin'): + pass + elif os.path.realpath(path).startswith(basedir.xdg_cache_home): + pass # print "Skipping cache", first_path + elif not os.access(path, os.W_OK): + pass # print "No access", first_path + else: + break + else: + return None + + return path + +_command_template = """#!/bin/sh +exec 0install run {app} "$@" +""" + +class App: + def __init__(self, config, path): + self.config = config + self.path = path + + def set_selections(self, sels): + sels_file = os.path.join(self.path, 'selections.xml') + dom = sels.toDOM() + with open(sels_file, 'w') as stream: + dom.writexml(stream, addindent=" ", newl="\n", encoding = 'utf-8') + + def get_selections(self): + sels_file = os.path.join(self.path, 'selections.xml') + with open(sels_file) as stream: + sels = selections.Selections(qdom.parse(stream)) + + stores = self.config.stores + + for iface, sel in sels.selections.iteritems(): + #print iface, sel + if sel.id.startswith('package:'): + pass # TODO: check version is the same + elif not sel.is_available(stores): + print "missing", sel # TODO: download + + # Check the selections are still available and up-to-date + timestamp_path = os.path.join(self.path, 'last-check') + try: + utime = os.stat(timestamp_path).st_mtime + #print "Staleness", time.time() - utime + need_update = False + except Exception as ex: + warn("Failed to get time-stamp of %s: %s", timestamp_path, ex) + need_update = True + + # TODO: update if need_update + + return sels + + def set_last_check(self): + timestamp_path = os.path.join(self.path, 'last-check') + fd = os.open(timestamp_path, os.O_WRONLY | os.O_CREAT, 0o644) + os.close(fd) + os.utime(timestamp_path, None) # In case file already exists + + def destroy(self): + # Check for shell command + # TODO: remember which commands we own instead of guessing + name = self.get_name() + bin_dir = find_bin_dir() + launcher = os.path.join(bin_dir, name) + expanded_template = _command_template.format(app = name) + if os.path.exists(launcher) and os.path.getsize(launcher) == len(expanded_template): + with open(launcher, 'r') as stream: + contents = stream.read() + if contents == expanded_template: + #print "rm", launcher + os.unlink(launcher) + + # Remove the app itself + import shutil + shutil.rmtree(self.path) + + def integrate_shell(self, name): + # TODO: remember which commands we create + if not valid_name.match(name): + raise SafeException("Invalid shell command name '{name}'".format(name = name)) + bin_dir = find_bin_dir() + launcher = os.path.join(bin_dir, name) + if os.path.exists(launcher): + raise SafeException("Command already exists: {path}".format(path = launcher)) + + with open(launcher, 'w') as stream: + stream.write(_command_template.format(app = self.get_name())) + # Make new script executable + os.chmod(launcher, 0o111 | os.fstat(stream.fileno()).st_mode) + + def get_name(self): + return os.path.basename(self.path) + +class AppManager: + def __init__(self, config): + self.config = config + + def create_app(self, name): + validate_name(name) + apps_dir = basedir.save_config_path(namespaces.config_site, "apps") + app_dir = os.path.join(apps_dir, name) + if os.path.isdir(app_dir): + raise SafeException(_("Application '{name}' already exists: {path}").format(name = name, path = app_dir)) + os.mkdir(app_dir) + app = App(self.config, app_dir) + app.set_last_check() + return app + + def lookup_app(self, name, missing_ok = False): + """Get the App for name. + Returns None if name is not an application (doesn't exist or is not a valid name). + Since / and : are not valid name characters, it is generally safe to try this + before calling L{model.canonical_iface_uri}.""" + if not valid_name.match(name): + if missing_ok: + return None + else: + raise SafeException("Invalid application name '{name}'".format(name = name)) + app_dir = basedir.load_first_config(namespaces.config_site, "apps", name) + if app_dir: + return App(self.config, app_dir) + if missing_ok: + return None + else: + raise SafeException("No such application '{name}'".format(name = name)) diff --git a/zeroinstall/cmd/__init__.py b/zeroinstall/cmd/__init__.py index cf4f1c1..f78bb61 100644 --- a/zeroinstall/cmd/__init__.py +++ b/zeroinstall/cmd/__init__.py @@ -14,7 +14,7 @@ import logging from zeroinstall import SafeException -valid_commands = ['select', 'download', 'run', 'update', +valid_commands = ['add', 'select', 'download', 'run', 'update', 'destroy', 'config', 'import', 'list', 'add-feed', 'remove-feed', 'list-feeds', 'digest'] diff --git a/zeroinstall/cmd/add.py b/zeroinstall/cmd/add.py new file mode 100644 index 0000000..9df9816 --- /dev/null +++ b/zeroinstall/cmd/add.py @@ -0,0 +1,33 @@ +""" +The B{0install add} command-line interface. +""" + +# Copyright (C) 2012, Thomas Leonard +# See the README file for details, or visit http://0install.net. + +from __future__ import print_function + +from zeroinstall import SafeException, _ +from zeroinstall.cmd import UsageError, select +from zeroinstall.injector import model + +syntax = "PET-NAME INTERFACE" + +def add_options(parser): + # TODO: allow passing options to control the selection + pass #select.add_options(parser) + +def handle(config, options, args): + if len(args) != 2: + raise UsageError() + + pet_name = args[0] + iface_uri = model.canonical_iface_uri(args[1]) + + sels = select.get_selections(config, options, iface_uri, select_only = False, download_only = True, test_callback = None) + if not sels: + sys.exit(1) # Aborted by user + + app = config.app_mgr.create_app(pet_name) + app.set_selections(sels) + app.integrate_shell(pet_name) diff --git a/zeroinstall/cmd/destroy.py b/zeroinstall/cmd/destroy.py new file mode 100644 index 0000000..f778e8c --- /dev/null +++ b/zeroinstall/cmd/destroy.py @@ -0,0 +1,22 @@ +""" +The B{0install destroy} command-line interface. +""" + +# Copyright (C) 2012, Thomas Leonard +# See the README file for details, or visit http://0install.net. + +from zeroinstall.cmd import UsageError + +syntax = "PET-NAME" + +def add_options(parser): + pass + +def handle(config, options, args): + if len(args) != 1: + raise UsageError() + + pet_name = args[0] + + app = config.app_mgr.lookup_app(pet_name) + app.destroy() diff --git a/zeroinstall/cmd/run.py b/zeroinstall/cmd/run.py index 69d0c69..6e2da5d 100644 --- a/zeroinstall/cmd/run.py +++ b/zeroinstall/cmd/run.py @@ -22,7 +22,7 @@ def add_options(parser): def handle(config, options, args): if len(args) < 1: raise UsageError() - iface_uri = model.canonical_iface_uri(args[0]) + prog_args = args[1:] def test_callback(sels): @@ -31,11 +31,17 @@ def handle(config, options, args): False, # dry-run options.main) - sels = select.get_selections(config, options, iface_uri, - select_only = False, download_only = False, - test_callback = test_callback) - if not sels: - sys.exit(1) # Aborted by user + app = config.app_mgr.lookup_app(args[0], missing_ok = True) + if app is not None: + sels = app.get_selections() + else: + iface_uri = model.canonical_iface_uri(args[0]) + + sels = select.get_selections(config, options, iface_uri, + select_only = False, download_only = False, + test_callback = test_callback) + if not sels: + sys.exit(1) # Aborted by user from zeroinstall.injector import run run.execute_selections(sels, prog_args, dry_run = options.dry_run, main = options.main, wrapper = options.wrapper, stores = config.stores) diff --git a/zeroinstall/injector/config.py b/zeroinstall/injector/config.py index e688d90..67182f7 100644 --- a/zeroinstall/injector/config.py +++ b/zeroinstall/injector/config.py @@ -33,14 +33,14 @@ class Config(object): """ __slots__ = ['help_with_testing', 'freshness', 'network_use', 'feed_mirror', 'key_info_server', 'auto_approve_keys', - '_fetcher', '_stores', '_iface_cache', '_handler', '_trust_mgr', '_trust_db'] + '_fetcher', '_stores', '_iface_cache', '_handler', '_trust_mgr', '_trust_db', '_app_mgr'] def __init__(self, handler = None): self.help_with_testing = False self.freshness = 60 * 60 * 24 * 30 self.network_use = network_full self._handler = handler - self._fetcher = self._stores = self._iface_cache = self._trust_mgr = self._trust_db = None + self._app_mgr = self._fetcher = self._stores = self._iface_cache = self._trust_mgr = self._trust_db = None self.feed_mirror = DEFAULT_FEED_MIRROR self.key_info_server = DEFAULT_KEY_LOOKUP_SERVER self.auto_approve_keys = True @@ -88,6 +88,13 @@ class Config(object): self._handler = handler.Handler() return self._handler + @property + def app_mgr(self): + if not self._app_mgr: + from zeroinstall import apps + self._app_mgr = apps.AppManager(self) + return self._app_mgr + def save_globals(self): """Write global settings.""" parser = ConfigParser.ConfigParser() -- 2.11.4.GIT