Check for staleness
[zeroinstall/solver.git] / zeroinstall / apps.py
blob4a678b15c17c55de2d0221a0af09bcdd85f300e0
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, info
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 staleness = time.time() - utime
98 info("Staleness of app %s is %d hours", self, staleness / (60 * 60))
99 need_update = False
100 except Exception as ex:
101 warn("Failed to get time-stamp of %s: %s", timestamp_path, ex)
102 need_update = True
104 # TODO: update if need_update
106 return sels
108 def set_last_check(self):
109 timestamp_path = os.path.join(self.path, 'last-check')
110 fd = os.open(timestamp_path, os.O_WRONLY | os.O_CREAT, 0o644)
111 os.close(fd)
112 os.utime(timestamp_path, None) # In case file already exists
114 def destroy(self):
115 # Check for shell command
116 # TODO: remember which commands we own instead of guessing
117 name = self.get_name()
118 bin_dir = find_bin_dir()
119 launcher = os.path.join(bin_dir, name)
120 expanded_template = _command_template.format(app = name)
121 if os.path.exists(launcher) and os.path.getsize(launcher) == len(expanded_template):
122 with open(launcher, 'r') as stream:
123 contents = stream.read()
124 if contents == expanded_template:
125 #print "rm", launcher
126 os.unlink(launcher)
128 # Remove the app itself
129 import shutil
130 shutil.rmtree(self.path)
132 def integrate_shell(self, name):
133 # TODO: remember which commands we create
134 if not valid_name.match(name):
135 raise SafeException("Invalid shell command name '{name}'".format(name = name))
136 bin_dir = find_bin_dir()
137 launcher = os.path.join(bin_dir, name)
138 if os.path.exists(launcher):
139 raise SafeException("Command already exists: {path}".format(path = launcher))
141 with open(launcher, 'w') as stream:
142 stream.write(_command_template.format(app = self.get_name()))
143 # Make new script executable
144 os.chmod(launcher, 0o111 | os.fstat(stream.fileno()).st_mode)
146 def get_name(self):
147 return os.path.basename(self.path)
149 def __str__(self):
150 return '<app ' + self.get_name() + '>'
152 class AppManager:
153 def __init__(self, config):
154 self.config = config
156 def create_app(self, name):
157 validate_name(name)
158 apps_dir = basedir.save_config_path(namespaces.config_site, "apps")
159 app_dir = os.path.join(apps_dir, name)
160 if os.path.isdir(app_dir):
161 raise SafeException(_("Application '{name}' already exists: {path}").format(name = name, path = app_dir))
162 os.mkdir(app_dir)
163 app = App(self.config, app_dir)
164 app.set_last_check()
165 return app
167 def lookup_app(self, name, missing_ok = False):
168 """Get the App for name.
169 Returns None if name is not an application (doesn't exist or is not a valid name).
170 Since / and : are not valid name characters, it is generally safe to try this
171 before calling L{model.canonical_iface_uri}."""
172 if not valid_name.match(name):
173 if missing_ok:
174 return None
175 else:
176 raise SafeException("Invalid application name '{name}'".format(name = name))
177 app_dir = basedir.load_first_config(namespaces.config_site, "apps", name)
178 if app_dir:
179 return App(self.config, app_dir)
180 if missing_ok:
181 return None
182 else:
183 raise SafeException("No such application '{name}'".format(name = name))