1 """
2 Support for managing apps (as created with "0install add").
3 @since: 1.9
4 """
5
6
7
8
9 from zeroinstall import _, SafeException, logger
10 from zeroinstall.support import basedir, portable_rename
11 from zeroinstall.injector import namespaces, selections, qdom, model
12 import re, os, time, tempfile
13
14
15
16 valid_name = re.compile(r'''^[^./\\:=;'"][^/\\:=;'"]*$''')
17
24
26 """Try to guess the command to set an environment variable."""
27 shell = os.environ.get('SHELL', '?')
28 if 'csh' in shell:
29 return "setenv %s %s" % (name, value)
30 return "export %s=%s" % (name, value)
31
33 """Find the first writable path in the list (default $PATH),
34 skipping /bin, /sbin and everything under /usr except /usr/local/bin
35 @type paths: [str] | None
36 @rtype: str"""
37 if paths is None:
38 paths = os.environ['PATH'].split(os.pathsep)
39 for path in paths:
40 if path.startswith('/usr/') and not path.startswith('/usr/local/bin'):
41
42 pass
43 elif path.startswith('/bin') or path.startswith('/sbin'):
44 pass
45 elif os.path.realpath(path).startswith(basedir.xdg_cache_home):
46 pass
47 elif not os.access(path, os.W_OK):
48 pass
49 else:
50 break
51 else:
52 path = os.path.expanduser('~/bin/')
53 logger.warning('%s is not in $PATH. Add it with:\n%s' % (path, _export('PATH', path + ':$PATH')))
54
55 if not os.path.isdir(path):
56 os.makedirs(path)
57 return path
58
59 _command_template = """#!/bin/sh
60 exec 0install run {app} "$@"
61 """
62
67
69 """If stream is a shell script for an application, return the app details.
70 @param stream: the executable file's stream (will seek)
71 @type stream: file-like object
72 @return: the app details, if any
73 @rtype: L{AppScriptInfo} | None
74 @since: 1.12"""
75 try:
76 stream.seek(0)
77 template_header = _command_template[:_command_template.index("{app}")]
78 actual_header = stream.read(len(template_header))
79 stream.seek(0)
80 if template_header == actual_header:
81
82 rest = stream.read()
83 line = rest.split('\n')[1]
84 else:
85 return None
86 except UnicodeDecodeError as ex:
87 logger.info("Not an app script '%s': %s", stream, ex)
88 return None
89
90 info = AppScriptInfo()
91 info.name = line.split()[3]
92 return info
93
99
101 """Store a new set of selections. We include today's date in the filename
102 so that we keep a history of previous selections (max one per day), in case
103 we want to to roll back later.
104 @type sels: L{zeroinstall.injector.selections.Selections}
105 @type set_last_checked: bool"""
106 date = time.strftime('%Y-%m-%d')
107 sels_file = os.path.join(self.path, 'selections-{date}.xml'.format(date = date))
108 dom = sels.toDOM()
109
110 if self.config.handler.dry_run:
111 print(_("[dry-run] would write selections to {file}").format(file = sels_file))
112 else:
113 tmp = tempfile.NamedTemporaryFile(prefix = 'selections.xml-', dir = self.path, delete = False, mode = 'wt')
114 try:
115 dom.writexml(tmp, addindent=" ", newl="\n", encoding = 'utf-8')
116 except:
117 tmp.close()
118 os.unlink(tmp.name)
119 raise
120 tmp.close()
121 portable_rename(tmp.name, sels_file)
122
123 sels_latest = os.path.join(self.path, 'selections.xml')
124 if self.config.handler.dry_run:
125 print(_("[dry-run] would update {link} to point to new selections file").format(link = sels_latest))
126 else:
127 if os.path.exists(sels_latest):
128 os.unlink(sels_latest)
129 if os.name == "nt":
130 import shutil
131 shutil.copyfile(sels_file, sels_latest)
132 else:
133 os.symlink(os.path.basename(sels_file), sels_latest)
134
135 if set_last_checked:
136 self.set_last_checked()
137
138 - def get_selections(self, snapshot_date = None, may_update = False, use_gui = None):
139 """Load the selections.
140 If may_update is True then the returned selections will be cached and available.
141 @param snapshot_date: get a historical snapshot
142 @type snapshot_date: (as returned by L{get_history}) | None
143 @param may_update: whether to check for updates
144 @type may_update: bool
145 @param use_gui: whether to use the GUI for foreground updates
146 @type use_gui: bool | None (never/always/if possible)
147 @return: the selections
148 @rtype: L{selections.Selections}"""
149 if snapshot_date:
150 assert may_update is False, "Can't update a snapshot!"
151 sels_file = os.path.join(self.path, 'selections-' + snapshot_date + '.xml')
152 else:
153 sels_file = os.path.join(self.path, 'selections.xml')
154 with open(sels_file, 'rb') as stream:
155 sels = selections.Selections(qdom.parse(stream))
156
157 if may_update:
158 sels = self._check_for_updates(sels, use_gui)
159
160 return sels
161
162 - def get_history(self):
163 """Get the dates of the available snapshots, starting with the most recent.
164 @rtype: [str]"""
165 date_re = re.compile('selections-(\d\d\d\d-\d\d-\d\d).xml')
166 snapshots = []
167 for f in os.listdir(self.path):
168 match = date_re.match(f)
169 if match:
170 snapshots.append(match.group(1))
171 snapshots.sort(reverse = True)
172 return snapshots
173
175 """Download any missing implementations.
176 @type sels: L{zeroinstall.injector.selections.Selections}
177 @return: a blocker which resolves when all needed implementations are available
178 @rtype: L{tasks.Blocker} | None"""
179 return sels.download_missing(self.config)
180
182 """Check whether the selections need to be updated.
183 If any input feeds have changed, we re-run the solver. If the
184 new selections require a download, we schedule one in the
185 background and return the old selections. Otherwise, we return the
186 new selections. If we can select better versions without downloading,
187 we update the app's selections and return the new selections.
188 If we can't use the current selections, we update in the foreground.
189 We also schedule a background update from time-to-time anyway.
190 @type sels: L{zeroinstall.injector.selections.Selections}
191 @type use_gui: bool
192 @return: the selections to use
193 @rtype: L{selections.Selections}"""
194 need_solve = False
195 need_update = False
196
197 utime = self._get_mtime('last-checked', warn_if_missing = True)
198 last_solve = max(self._get_mtime('last-solve', warn_if_missing = False), utime)
199
200
201
202
203
204
205
206
207
208
209 def get_inputs():
210 for sel in sels.selections.values():
211 logger.info("Checking %s", sel.feed)
212 feed = iface_cache.get_feed(sel.feed)
213 if not feed:
214 raise IOError("Input %s missing; update" % sel.feed)
215 else:
216 if feed.local_path:
217 yield feed.local_path
218 else:
219 yield (feed.url, feed.last_modified)
220
221
222 yield basedir.load_first_config(namespaces.config_site, namespaces.config_prog,
223 'interfaces', model._pretty_escape(sel.interface))
224
225
226 yield basedir.load_first_config(namespaces.config_site, namespaces.config_prog, 'global')
227
228
229 iface_cache = self.config.iface_cache
230 try:
231 for item in get_inputs():
232 if not item: continue
233 if isinstance(item, tuple):
234 path, mtime = item
235 else:
236 path = item
237 try:
238 mtime = os.stat(path).st_mtime
239 except OSError as ex:
240 logger.info("Triggering update to {app} due to error: {ex}".format(
241 app = self, path = path, ex = ex))
242 need_solve = True
243 break
244
245 if mtime and mtime > last_solve:
246 logger.info("Triggering update to %s because %s has changed", self, path)
247 need_solve = True
248 break
249 except Exception as ex:
250 logger.info("Error checking modification times: %s", ex)
251 need_solve = True
252 need_update = True
253
254
255 if not need_update:
256 staleness = time.time() - utime
257 logger.info("Staleness of app %s is %d hours", self, staleness / (60 * 60))
258 freshness_threshold = self.config.freshness
259 if freshness_threshold > 0 and staleness >= freshness_threshold:
260 need_update = True
261
262
263
264 unavailable_selections = sels.get_unavailable_selections(config = self.config, include_packages = True)
265 if unavailable_selections:
266 logger.info("Saved selections are unusable (missing %s)",
267 ', '.join(str(s) for s in unavailable_selections))
268 need_solve = True
269
270 if need_solve:
271 from zeroinstall.injector.driver import Driver
272 driver = Driver(config = self.config, requirements = self.get_requirements())
273 if driver.need_download():
274 if unavailable_selections:
275 return self._foreground_update(driver, use_gui)
276 else:
277
278 need_update = True
279 else:
280 old_sels = sels
281 sels = driver.solver.selections
282 from zeroinstall.support import xmltools
283 if not xmltools.nodes_equal(sels.toDOM(), old_sels.toDOM()):
284 self.set_selections(sels, set_last_checked = False)
285 try:
286 self._touch('last-solve')
287 except OSError as ex:
288 logger.warning("Error checking for updates: %s", ex)
289
290
291 if need_update:
292 last_check_attempt = self._get_mtime('last-check-attempt', warn_if_missing = False)
293 if last_check_attempt and last_check_attempt + 60 * 60 > time.time():
294 logger.info("Tried to check within last hour; not trying again now")
295 need_update = False
296
297 if need_update:
298 try:
299 self.set_last_check_attempt()
300 except OSError as ex:
301 logger.warning("Error checking for updates: %s", ex)
302 else:
303 from zeroinstall.injector import background
304 r = self.get_requirements()
305 background.spawn_background_update2(r, False, self)
306
307 return sels
308
331
333 """@type requirements: L{zeroinstall.injector.requirements.Requirements}"""
334 reqs_file = os.path.join(self.path, 'requirements.json')
335 if self.config.handler.dry_run:
336 print(_("[dry-run] would write {file}").format(file = reqs_file))
337 else:
338 import json
339 tmp = tempfile.NamedTemporaryFile(prefix = 'tmp-requirements-', dir = self.path, delete = False, mode = 'wt')
340 try:
341 json.dump(dict((key, getattr(requirements, key)) for key in requirements.__slots__), tmp)
342 except:
343 tmp.close()
344 os.unlink(tmp.name)
345 raise
346 tmp.close()
347
348 portable_rename(tmp.name, reqs_file)
349
351 """@rtype: L{zeroinstall.injector.requirements.Requirements}"""
352 import json
353 from zeroinstall.injector import requirements
354 r = requirements.Requirements(None)
355 reqs_file = os.path.join(self.path, 'requirements.json')
356 with open(reqs_file, 'rt') as stream:
357 values = json.load(stream)
358
359
360 before = values.pop('before', None)
361 not_before = values.pop('not_before', None)
362 if before or not_before:
363 assert 'extra_restrictions' not in values, values
364 expr = (not_before or '') + '..'
365 if before:
366 expr += '!' + before
367 values['extra_restrictions'] = {values['interface_uri']: expr}
368
369 for k, v in values.items():
370 setattr(r, k, v)
371 return r
372
374 self._touch('last-check-attempt')
375
377 self._touch('last-checked')
378
380 """@type name: str"""
381 timestamp_path = os.path.join(self.path, name)
382 if self.config.handler.dry_run:
383 pass
384 else:
385 fd = os.open(timestamp_path, os.O_WRONLY | os.O_CREAT, 0o644)
386 os.close(fd)
387 os.utime(timestamp_path, None)
388
389 - def _get_mtime(self, name, warn_if_missing = True):
390 """@type name: str
391 @type warn_if_missing: bool
392 @rtype: int"""
393 timestamp_path = os.path.join(self.path, name)
394 try:
395 return os.stat(timestamp_path).st_mtime
396 except Exception as ex:
397 if warn_if_missing:
398 logger.warning("Failed to get time-stamp of %s: %s", timestamp_path, ex)
399 return 0
400
402 """Get the time of the last successful check for updates.
403 @return: the timestamp (or None on error)
404 @rtype: float | None"""
405 return self._get_mtime('last-checked', warn_if_missing = True)
406
408 """Get the time of the last attempted check.
409 @return: the timestamp, or None if we updated successfully.
410 @rtype: float | None"""
411 last_check_attempt = self._get_mtime('last-check-attempt', warn_if_missing = False)
412 if last_check_attempt:
413 last_checked = self.get_last_checked()
414
415 if last_checked < last_check_attempt:
416 return last_check_attempt
417 return None
418
420
421
422 name = self.get_name()
423 bin_dir = find_bin_dir()
424 launcher = os.path.join(bin_dir, name)
425 expanded_template = _command_template.format(app = name)
426 if os.path.exists(launcher) and os.path.getsize(launcher) == len(expanded_template):
427 with open(launcher, 'r') as stream:
428 contents = stream.read()
429 if contents == expanded_template:
430 if self.config.handler.dry_run:
431 print(_("[dry-run] would delete launcher script {file}").format(file = launcher))
432 else:
433 os.unlink(launcher)
434
435 if self.config.handler.dry_run:
436 print(_("[dry-run] would delete directory {path}").format(path = self.path))
437 else:
438
439 import shutil
440 shutil.rmtree(self.path)
441
459
461 """@rtype: str"""
462 return os.path.basename(self.path)
463
465 """@rtype: str"""
466 return '<app ' + self.get_name() + '>'
467
470 """@type config: L{zeroinstall.injector.config.Config}"""
471 self.config = config
472
494
496 """Get the App for name.
497 Returns None if name is not an application (doesn't exist or is not a valid name).
498 Since / and : are not valid name characters, it is generally safe to try this
499 before calling L{injector.model.canonical_iface_uri}.
500 @type name: str
501 @type missing_ok: bool
502 @rtype: L{App}"""
503 if not valid_name.match(name):
504 if missing_ok:
505 return None
506 else:
507 raise SafeException("Invalid application name '{name}'".format(name = name))
508 app_dir = basedir.load_first_config(namespaces.config_site, "apps", name)
509 if app_dir:
510 return App(self.config, app_dir)
511 if missing_ok:
512 return None
513 else:
514 raise SafeException("No such application '{name}'".format(name = name))
515
524