Keep a history of past selections
[zeroinstall/solver.git] / zeroinstall / apps.py
blob113d1b91ddbd8724d44634eae5b410dd469ec7ba
1 """
2 Support for managing apps (as created with "0install add").
3 @since: 1.8
4 """
6 # Copyright (C) 2012, Thomas Leonard
7 # See the README file for details, or visit http://0install.net.
9 from zeroinstall import _, SafeException
10 from zeroinstall.support import basedir, portable_rename
11 from zeroinstall.injector import namespaces, selections, qdom
12 from logging import warn
13 import re, os, time, tempfile
15 # Avoid characters that are likely to cause problems (reject : and ; everywhere
16 # so that apps can be portable between POSIX and Windows).
17 valid_name = re.compile(r'''^[^./\\:=;'"][^/\\:=;'"]*$''')
19 def validate_name(name):
20 if valid_name.match(name): return
21 raise SafeException("Invalid application name '{name}'".format(name = name))
23 def find_bin_dir(paths = None):
24 """Find the first writable path in the list (default $PATH),
25 skipping /bin, /sbin and everything under /usr except /usr/local/bin"""
26 if paths is None:
27 paths = os.environ['PATH'].split(os.pathsep)
28 for path in paths:
29 if path.startswith('/usr/') and not path.startswith('/usr/local/bin'):
30 # (/usr/local/bin is OK if we're running as root)
31 pass
32 elif path.startswith('/bin') or path.startswith('/sbin'):
33 pass
34 elif os.path.realpath(path).startswith(basedir.xdg_cache_home):
35 pass # print "Skipping cache", first_path
36 elif not os.access(path, os.W_OK):
37 pass # print "No access", first_path
38 else:
39 break
40 else:
41 return None
43 return path
45 _command_template = """#!/bin/sh
46 exec 0install run {app} "$@"
47 """
49 class App:
50 def __init__(self, config, path):
51 self.config = config
52 self.path = path
54 def set_selections(self, sels):
55 """Store a new set of selections. We include today's date in the filename
56 so that we keep a history of previous selections (max one per day), in case
57 we want to to roll back later."""
58 date = time.strftime('%Y-%m-%d')
59 sels_file = os.path.join(self.path, 'selections-{date}.xml'.format(date = date))
60 dom = sels.toDOM()
62 tmp = tempfile.NamedTemporaryFile(prefix = 'selections.xml-', dir = self.path, delete = False)
63 try:
64 dom.writexml(tmp, addindent=" ", newl="\n", encoding = 'utf-8')
65 except:
66 tmp.close()
67 os.unlink(tmp.name)
68 raise
69 tmp.close()
70 portable_rename(tmp.name, sels_file)
72 sels_latest = os.path.join(self.path, 'selections.xml')
73 if os.path.exists(sels_latest):
74 os.unlink(sels_latest)
75 os.symlink(os.path.basename(sels_file), sels_latest)
77 self.set_last_check()
79 def get_selections(self):
80 sels_file = os.path.join(self.path, 'selections.xml')
81 with open(sels_file) as stream:
82 sels = selections.Selections(qdom.parse(stream))
84 stores = self.config.stores
86 for iface, sel in sels.selections.iteritems():
87 #print iface, sel
88 if sel.id.startswith('package:'):
89 pass # TODO: check version is the same
90 elif not sel.is_available(stores):
91 print "missing", sel # TODO: download
93 # Check the selections are still available and up-to-date
94 timestamp_path = os.path.join(self.path, 'last-check')
95 try:
96 utime = os.stat(timestamp_path).st_mtime
97 #print "Staleness", time.time() - utime
98 need_update = False
99 except Exception as ex:
100 warn("Failed to get time-stamp of %s: %s", timestamp_path, ex)
101 need_update = True
103 # TODO: update if need_update
105 return sels
107 def set_last_check(self):
108 timestamp_path = os.path.join(self.path, 'last-check')
109 fd = os.open(timestamp_path, os.O_WRONLY | os.O_CREAT, 0o644)
110 os.close(fd)
111 os.utime(timestamp_path, None) # In case file already exists
113 def destroy(self):
114 # Check for shell command
115 # TODO: remember which commands we own instead of guessing
116 name = self.get_name()
117 bin_dir = find_bin_dir()
118 launcher = os.path.join(bin_dir, name)
119 expanded_template = _command_template.format(app = name)
120 if os.path.exists(launcher) and os.path.getsize(launcher) == len(expanded_template):
121 with open(launcher, 'r') as stream:
122 contents = stream.read()
123 if contents == expanded_template:
124 #print "rm", launcher
125 os.unlink(launcher)
127 # Remove the app itself
128 import shutil
129 shutil.rmtree(self.path)
131 def integrate_shell(self, name):
132 # TODO: remember which commands we create
133 if not valid_name.match(name):
134 raise SafeException("Invalid shell command name '{name}'".format(name = name))
135 bin_dir = find_bin_dir()
136 launcher = os.path.join(bin_dir, name)
137 if os.path.exists(launcher):
138 raise SafeException("Command already exists: {path}".format(path = launcher))
140 with open(launcher, 'w') as stream:
141 stream.write(_command_template.format(app = self.get_name()))
142 # Make new script executable
143 os.chmod(launcher, 0o111 | os.fstat(stream.fileno()).st_mode)
145 def get_name(self):
146 return os.path.basename(self.path)
148 class AppManager:
149 def __init__(self, config):
150 self.config = config
152 def create_app(self, name):
153 validate_name(name)
154 apps_dir = basedir.save_config_path(namespaces.config_site, "apps")
155 app_dir = os.path.join(apps_dir, name)
156 if os.path.isdir(app_dir):
157 raise SafeException(_("Application '{name}' already exists: {path}").format(name = name, path = app_dir))
158 os.mkdir(app_dir)
159 app = App(self.config, app_dir)
160 app.set_last_check()
161 return app
163 def lookup_app(self, name, missing_ok = False):
164 """Get the App for name.
165 Returns None if name is not an application (doesn't exist or is not a valid name).
166 Since / and : are not valid name characters, it is generally safe to try this
167 before calling L{model.canonical_iface_uri}."""
168 if not valid_name.match(name):
169 if missing_ok:
170 return None
171 else:
172 raise SafeException("Invalid application name '{name}'".format(name = name))
173 app_dir = basedir.load_first_config(namespaces.config_site, "apps", name)
174 if app_dir:
175 return App(self.config, app_dir)
176 if missing_ok:
177 return None
178 else:
179 raise SafeException("No such application '{name}'".format(name = name))