blob: d70ec211bb5005ef005d8a43acd632023fb1460c [file] [log] [blame]
import logging
from apache_conf_parser import ApacheConfParser
from release.platforms import ftp2bouncer
SCHEMA_2_OPTIONAL_ATTRIBUTES_SINGLE_VALUE = (
'showPrompt', 'showNeverForVersion', 'showSurvey', 'licenseUrl',
'billboardURL', 'openURL', 'notificationURL', 'alertURL', 'promptWaitTime',
)
SCHEMA_2_OPTIONAL_ATTRIBUTES_MULTI_VALUE = ('actions',)
SCHEMA_2_OPTIONAL_ATTRIBUTES = SCHEMA_2_OPTIONAL_ATTRIBUTES_SINGLE_VALUE + \
SCHEMA_2_OPTIONAL_ATTRIBUTES_MULTI_VALUE
log = logging.getLogger()
def substitutePath(path, platform=None, locale=None, version=None):
"""Returns a platform/locale specific path based on a generic one."""
subs = {
'platform': platform,
'locale': locale,
'version': version,
'bouncer-platform': ftp2bouncer(platform)
}
for sub, replacement in subs.items():
if '%%%s%%' % sub in path:
if replacement is None:
raise TypeError("No substitution provided for '%s'" % sub)
path = path.replace('%%%s%%' % sub, replacement)
return path
class PatcherConfigError(ValueError):
pass
class PatcherConfig(dict):
def __init__(self, cfg=None):
self['appName'] = None
self['current-update'] = {}
self['release'] = {}
self['past-update'] = []
if cfg:
self.readXml(cfg)
def _stripStringNode(self, n):
"""Returns the value section of a node, even if it contains whitespace."""
return n.split(" ", 1)[1].strip()
def getFromVersions(self):
# From versions come from both the "from" value in current-update
# as well as by analyzing the past-update section. The original patcher
# scripts go so far as to analyze the channels in the past-update
# sections to deal with cases where the list of channels was different
# in earlier versions. These days we don't have that case so we simply
# assume that all of the fromVersions in the past-update lines are
# versions that should have update paths to the latest on all channels.
return tuple(set([self['current-update']['from']] + [v[0] for v in
self['past-update']]))
def getOptionalAttrs(self, version, locale):
if version not in self['release']:
log.debug("%s not found in config" % version)
return {}
schema = self['release'][version]['schema']
# Currently, only schema 2 has optional attributes
attrs = {}
if schema == 2:
if locale in self['current-update'].get('action-locales',
self['release'][version]['locales']):
for attr in SCHEMA_2_OPTIONAL_ATTRIBUTES:
if attr in self['current-update']:
attrs[attr] = substitutePath(self['current-update'][attr],
locale=locale)
return attrs
def getPath(self, version, platform, locale, type_):
if type_ == 'complete':
path = self['current-update']['complete']['path']
else:
if version not in self['current-update']['partials']:
raise PatcherConfigError(
"Couldn't find partial update path for %s" % version)
path = self['current-update']['partials'][version]['path']
return substitutePath(path, platform, locale, version)
def getUrl(self, version, platform, locale, type_, channel):
"""Returns the final URL to a specific update. Completes come from
the <complete> block. Partials come from their version-specific block
inside of <partials>. In both cases if a [channel]-url exists, it
will be returned. If it doesn't exist the generic url will be
returned. If neither exists a PatcherConfigError will be raised.
The returned URL will have %platform% and %locale% substitutions
performed on it."""
# Find a list of potential URLs....
if type_ == 'complete':
urls = self['current-update']['complete']
else:
if version not in self['current-update']['partials']:
raise PatcherConfigError(
"Couldn't find partial update URL for %s" % version)
urls = self['current-update']['partials'][version]
# Look for a channel-specific one. If that doesn't exist, try the
# generic one.
try:
url = urls.get('%s-url' % channel, urls['url'])
except KeyError:
raise PatcherConfigError("Couldn't find URL for (%s, %s, %s, %s, %s)" % (version, platform, locale, type_, channel))
return substitutePath(url, platform, locale, version)
def getUpdatePaths(self):
"""A generator that yields all of the update paths for this config.
Each individual update path is a tuple of:
(fromVersion, platform, locale, updateTypes, channels)
fromVersion, platform, and locale are all single-value strings.
updateTypes is a list of one or both of 'complete' and 'partial'
channels is a list channels the update path is applicable to
"""
if not self['current-update']:
return
fromVersions = self.getFromVersions()
channels = tuple(self['current-update']['testchannel'] +
self['current-update']['channel'])
toLocales = self['release'][self['current-update']['to']]['locales']
toExceptions = self['release'][self['current-update']['to']]['exceptions']
# Now that we know all of the versions that need updates to the latest
# we can start yielding the paths.
for version in fromVersions:
r = self['release'][version]
for platform in r['platforms']:
p = r['platforms'][platform]
for locale in r['locales']:
# The exception lists are a little weird. If a locale is
# not in the exception list at all, it is applicable to all
# platforms. If it *is* in the exception list it is only
# applicable to the platforms listed.
if locale in r['exceptions'] and platform not in r['exceptions'][locale]:
log.debug("Not generating update path for %s %s because it's not in the exception list for old release." % (platform, locale))
continue
# Make sure the locale is in the "to" release. If it's not
# we shouldn't generate an update for it!
if locale not in toLocales:
log.debug('Not generating update path for %s %s %s because %s isn\'t a locale for the "to" version' % (version, platform, locale, locale))
continue
# Make sure the locale isn't an exception in the "to" release
if locale in toExceptions and platform not in toExceptions[locale]:
log.debug("Not generating update path for %s %s because it's not in the exception list for new release." % (platform, locale))
continue
# Some patcher configs will have a <partial> block for
# backwards compatibility purposes. We don't support that
# though, so if a partial update isn't mentioned in
# the partials section we don't know anything about it.
if 'partials' in self['current-update'] and version in self['current-update']['partials']:
types = ('complete', 'partial')
else:
types = ('complete',)
yield (version, platform, locale, channels, types)
def addPastUpdate(self, value):
for existing in self['past-update']:
if value[0] == existing[0]:
raise PatcherConfigError("Found multiple past-updates with duplicate to/from versions: %s" % value)
self['past-update'].append(value)
def addRelease(self, version, value):
if version in self['release']:
raise PatcherConfigError(
"Found multiple releases with same version: %s" % version)
self['release'][version] = value
def readXml(self, cfg):
"""Reads a patcher config file translating into a dictionary.
This method isn't capable of parsing all of the patcher configs
that its Perl ancestor can because we don't need to support some
older features anymore. Scenarios known not to work are:
- Multiple <app> subsections.
- Channel differences between past-update lines
- <partial> is ignored (replaced by <partials>)
- <rc> is ignored
"""
# Read the config, set the appName
c = ApacheConfParser(cfg, infile=False).nodes[0]
if not c.body.nodes:
raise PatcherConfigError("No app found in config.")
if len(c.body.nodes) > 1:
raise PatcherConfigError("Too many apps found (only 1 allowed).")
app = c.body.nodes[0]
self['appName'] = app.name
# Parse the config, with the heavy lifting in helper methods.
for node in app.body.nodes:
if node.name == 'current-update':
if self['current-update']:
raise PatcherConfigError(
"Found multiple current-update blocks.")
self['current-update'] = self.parseCurrentUpdate(
node.body.nodes)
elif node.name == 'past-update':
self.addPastUpdate(self.parsePastUpdate(list(node.arguments)))
elif node.name == 'release':
for releaseNode in node.body.nodes:
self.addRelease(releaseNode.name,
self.parseRelease(releaseNode.body.nodes))
# Now, a bunch of verifications
# Make sure we have a current-update
if not self['current-update']:
raise PatcherConfigError(
"Required section current-update is empty")
# Make sure required nodes exist in <current-update> and <release>
# nodes.
for node in ('channel', 'testchannel', 'complete', 'details', 'from', 'to'):
if node not in self['current-update']:
raise PatcherConfigError(
"Required node '%s' not found in current-update" % node)
for version, releaseNode in self['release'].items():
for node in ('locales', 'version', 'platforms'):
if node not in releaseNode:
raise PatcherConfigError("Required node '%s' not found in release node '%s'" % (node, version))
# Make sure that versions mentioned have a release block.
if self['current-update']['to'] not in self['release']:
raise PatcherConfigError("No release found for version '%s'" %
self['current-update']['to'])
if self['current-update']['from'] not in self['release']:
raise PatcherConfigError("No release found for version '%s'" %
self['current-update']['from'])
for version in self['current-update'].get('partials', {}):
if version not in self['release']:
raise PatcherConfigError(
"No release found for version '%s'" % version)
for version in self['past-update']:
if version[0] not in self['release']:
raise PatcherConfigError(
"No release found for version '%s'" % version[0])
def parsePastUpdate(self, pastUpdate):
# A past-update node is a single block of text in the format:
# past-update from to channel...
if len(pastUpdate) < 3:
raise PatcherConfigError(
"Too few elements in past-update block: %s" % pastUpdate)
return [pastUpdate[0], pastUpdate[1], pastUpdate[2:]]
def parseCurrentUpdate(self, currentUpdate):
cu = {}
for node in currentUpdate:
# force is the only thing we're allowed to have more than once
if node.name != 'force' and node.name in cu:
raise PatcherConfigError("Found multiple entries for '%s' in current-update section" % node.name)
# These are all single-value nodes.
if node.name in ('details', 'from', 'to', 'beta-dir') + \
SCHEMA_2_OPTIONAL_ATTRIBUTES_SINGLE_VALUE:
cu[node.name] = self._stripStringNode(node.content)
# These are potentially multiple value nodes.
elif node.name in ('channel', 'testchannel', 'action-locales') + \
SCHEMA_2_OPTIONAL_ATTRIBUTES_MULTI_VALUE:
cu[node.name] = list(node.arguments)
# Force is weird in that it's a multiple value node, but the
# patcher config bumping script lists it multiple times rather
# than putting all entries on the same line.
elif node.name in ('force',):
if node.name not in cu:
cu[node.name] = []
cu[node.name].append(self._stripStringNode(node.content))
# The rest are subsections which are only allowed once.
elif node.name in ('complete', 'partials'):
cu[node.name] = {}
for subNode in node.body.nodes:
if subNode.name in cu[node.name]:
raise PatcherConfigError("Found multiple entries for '%s' in current-update's %s section" % (subNode.name, node.name))
# The partials sections has an additional level of depth.
# After that level, everything is single-value nodes.
if node.name in ('partials',):
cu[node.name][subNode.name] = {}
for deepNode in subNode.body.nodes:
if deepNode.name in cu[node.name][subNode.name]:
raise PatcherConfigError("Found multiple entries for '%s' in current-update's %s/%s section" % (deepNode.name, node.name, subNode.name))
cu[node.name][subNode.name][deepNode.name] = self._stripStringNode(deepNode.content)
# The other sections only contain single-value nodes.
else:
cu[node.name][subNode.name] = self._stripStringNode(
subNode.content)
return cu
def parseRelease(self, release):
r = {}
for node in release:
if node.name in r:
raise PatcherConfigError("Found multiple entries for '%s' in release section" % node.name)
# These are all single-value nodes.
if node.name in ('checksumsurl', 'completemarurl',
'extension-version', 'prettyVersion', 'version',
'mar-channel-ids'):
r[node.name] = self._stripStringNode(node.content)
elif node.name == 'schema':
r['schema'] = int(node.arguments[0])
# This is a potentially multiple value node.
elif node.name == 'locales':
r['locales'] = list(node.arguments)
# The rest are subsections.
elif node.name in ('exceptions', 'platforms'):
r[node.name] = {}
for subNode in node.body.nodes:
if subNode.name in r[node.name]:
raise PatcherConfigError("Found multiple entries for '%s' in a release's %s section" % (subNode.name, node.name))
# Nodes in this section are comma-separated lists.
# (Unlike every other list in this file.)
if node.name in ('exceptions',):
r[node.name][subNode.name] = [
p.strip(',') for p in subNode.arguments]
# Nodes in this section are space-separated.
elif node.name in ('platforms',):
r[node.name][subNode.name] = int(subNode.arguments[0])
return r