Package zeroinstall :: Module apps
[frames] | no frames]

Source Code for Module zeroinstall.apps

  1  """ 
  2  Support for managing apps (as created with "0install add"). 
  3  @since: 1.9 
  4  """ 
  5   
  6  # Copyright (C) 2012, Thomas Leonard 
  7  # See the README file for details, or visit http://0install.net. 
  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  # Avoid characters that are likely to cause problems (reject : and ; everywhere 
 15  # so that apps can be portable between POSIX and Windows). 
 16  valid_name = re.compile(r'''^[^./\\:=;'"][^/\\:=;'"]*$''') 
 17   
18 -def validate_name(name):
19 """@type name: str""" 20 if name == '0install': 21 raise SafeException("Creating an app called '0install' would cause trouble; try e.g. '00install' instead") 22 if valid_name.match(name): return 23 raise SafeException("Invalid application name '{name}'".format(name = name))
24
25 -def _export(name, value):
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
32 -def find_bin_dir(paths = None):
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 # (/usr/local/bin is OK if we're running as root) 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 # print "Skipping cache", first_path 47 elif not os.access(path, os.W_OK): 48 pass # print "No access", first_path 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
63 -class AppScriptInfo(object):
64 """@since: 1.12""" 65 name = None 66 command = None
67
68 -def parse_script_header(stream):
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 # If it's a launcher script, it should be quite short! 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
94 -class App(object):
95 - def __init__(self, config, path):
96 """@type path: str""" 97 self.config = config 98 self.path = path
99
100 - def set_selections(self, sels, set_last_checked = True):
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
174 - def download_selections(self, sels):
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) # TODO: package impls
180
181 - def _check_for_updates(self, sels, use_gui):
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 # Rerun solver (cached feeds have changed) 195 need_update = False # Update over the network 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 # Ideally, this would return all the files which were inputs into the solver's 201 # decision. Currently, we approximate with: 202 # - the previously selected feed files (local or cached) 203 # - configuration files for the selected interfaces 204 # - the global configuration 205 # We currently ignore feeds and interfaces which were 206 # considered but not selected. 207 # Can yield None (ignored), paths or (path, mtime) tuples. 208 # If this throws an exception, we will log it and resolve anyway. 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 # Per-feed configuration 222 yield basedir.load_first_config(namespaces.config_site, namespaces.config_prog, 223 'interfaces', model._pretty_escape(sel.interface)) 224 225 # Global configuration 226 yield basedir.load_first_config(namespaces.config_site, namespaces.config_prog, 'global')
227 228 # If any of the feeds we used have been updated since the last check, do a quick re-solve 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 # Is it time for a background update anyway? 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 # If any of the saved selections aren't available then we need 263 # to download right now, not later in the background. 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 # Continue with the current (cached) selections while we download 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 # If we tried to check within the last hour, don't try again. 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
309 - def _foreground_update(self, driver, use_gui):
310 """We can't run with saved selections or solved selections without downloading. 311 Try to open the GUI for a blocking download. If we can't do that, download without the GUI. 312 @type driver: L{zeroinstall.injector.driver.Driver} 313 @rtype: L{zeroinstall.injector.selections.Selections}""" 314 from zeroinstall import helpers 315 from zeroinstall.support import tasks 316 317 gui_args = driver.requirements.get_as_options() + ['--download-only', '--refresh'] 318 sels = helpers.get_selections_gui(driver.requirements.interface_uri, gui_args, 319 test_callback = None, use_gui = use_gui) 320 if sels is None: 321 raise SafeException("Aborted by user") 322 if sels is helpers.DontUseGUI: 323 downloaded = driver.solve_and_download_impls(refresh = True) 324 if downloaded: 325 tasks.wait_for_blocker(downloaded) 326 sels = driver.solver.selections 327 328 self.set_selections(sels, set_last_checked = True) 329 330 return sels
331
332 - def set_requirements(self, requirements):
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
350 - def get_requirements(self):
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 # Update old before/not-before values 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
373 - def set_last_check_attempt(self):
374 self._touch('last-check-attempt')
375
376 - def set_last_checked(self):
377 self._touch('last-checked')
378
379 - def _touch(self, name):
380 """@type name: str""" 381 timestamp_path = os.path.join(self.path, name) 382 if self.config.handler.dry_run: 383 pass #print(_("[dry-run] would update timestamp file {file}").format(file = timestamp_path)) 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) # In case file already exists
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
401 - def get_last_checked(self):
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
407 - def get_last_check_attempt(self):
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
419 - def destroy(self):
420 # Check for shell command 421 # TODO: remember which commands we own instead of guessing 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 # Remove the app itself 439 import shutil 440 shutil.rmtree(self.path)
441
442 - def integrate_shell(self, name):
443 # TODO: remember which commands we create 444 """@type name: str""" 445 if not valid_name.match(name): 446 raise SafeException("Invalid shell command name '{name}'".format(name = name)) 447 bin_dir = find_bin_dir() 448 launcher = os.path.join(bin_dir, name) 449 if os.path.exists(launcher): 450 raise SafeException("Command already exists: {path}".format(path = launcher)) 451 452 if self.config.handler.dry_run: 453 print(_("[dry-run] would write launcher script {path}").format(path = launcher)) 454 else: 455 with open(launcher, 'w') as stream: 456 stream.write(_command_template.format(app = self.get_name())) 457 # Make new script executable 458 os.chmod(launcher, 0o111 | os.fstat(stream.fileno()).st_mode)
459
460 - def get_name(self):
461 """@rtype: str""" 462 return os.path.basename(self.path)
463
464 - def __str__(self):
465 """@rtype: str""" 466 return '<app ' + self.get_name() + '>'
467
468 -class AppManager(object):
469 - def __init__(self, config):
470 """@type config: L{zeroinstall.injector.config.Config}""" 471 self.config = config
472
473 - def create_app(self, name, requirements):
474 """@type name: str 475 @type requirements: L{zeroinstall.injector.requirements.Requirements} 476 @rtype: L{App}""" 477 validate_name(name) 478 479 apps_dir = basedir.save_config_path(namespaces.config_site, "apps") 480 app_dir = os.path.join(apps_dir, name) 481 if os.path.isdir(app_dir): 482 raise SafeException(_("Application '{name}' already exists: {path}").format(name = name, path = app_dir)) 483 484 if self.config.handler.dry_run: 485 print(_("[dry-run] would create directory {path}").format(path = app_dir)) 486 else: 487 os.mkdir(app_dir) 488 489 app = App(self.config, app_dir) 490 app.set_requirements(requirements) 491 app.set_last_checked() 492 493 return app
494
495 - def lookup_app(self, name, missing_ok = False):
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
516 - def iterate_apps(self):
517 seen = set() 518 for apps_dir in basedir.load_config_paths(namespaces.config_site, "apps"): 519 for name in os.listdir(apps_dir): 520 if valid_name.match(name): 521 if name in seen: continue 522 seen.add(name) 523 yield name
524