blob: 9011cab26c1801ea9804caff62fc60a8b812eee3 [file] [log] [blame]
#!/usr/bin/env python
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""Module to handle downloads for different types of Firefox and Thunderbird builds."""
from datetime import datetime
from optparse import OptionParser, OptionGroup
import os
import re
import sys
import time
import urllib
import urllib2
import mozinfo
from parser import DirectoryParser
from timezones import PacificTimezone
APPLICATIONS = ['b2g', 'firefox', 'thunderbird']
# Base URL for the path to all builds
BASE_URL = 'https://ftp.mozilla.org/pub/mozilla.org'
PLATFORM_FRAGMENTS = {'linux': 'linux-i686',
'linux64': 'linux-x86_64',
'mac': 'mac',
'mac64': 'mac64',
'win32': 'win32',
'win64': 'win64-x86_64'}
DEFAULT_FILE_EXTENSIONS = {'linux': 'tar.bz2',
'linux64': 'tar.bz2',
'mac': 'dmg',
'mac64': 'dmg',
'win32': 'exe',
'win64': 'exe'}
class NotFoundException(Exception):
"""Exception for a resource not being found (e.g. no logs)"""
def __init__(self, message, location):
self.location = location
Exception.__init__(self, ': '.join([message, location]))
class Scraper(object):
"""Generic class to download an application from the Mozilla server"""
def __init__(self, directory, version, platform=None,
application='firefox', locale='en-US', extension=None,
authentication=None, retry_attempts=3, retry_delay=10):
# Private properties for caching
self._target = None
self._binary = None
self.directory = directory
self.locale = locale
self.platform = platform or self.detect_platform()
self.version = version
self.extension = extension or DEFAULT_FILE_EXTENSIONS[self.platform]
self.authentication = authentication
self.retry_attempts = retry_attempts
self.retry_delay = retry_delay
# build the base URL
self.application = application
self.base_url = '/'.join([BASE_URL, self.application])
@property
def binary(self):
"""Return the name of the build"""
if self._binary is None:
# Retrieve all entries from the remote virtual folder
parser = DirectoryParser(self.path)
if not parser.entries:
raise NotFoundException('No entries found', self.path)
# Download the first matched directory entry
pattern = re.compile(self.binary_regex, re.IGNORECASE)
for entry in parser.entries:
try:
self._binary = pattern.match(entry).group()
break
except:
# No match, continue with next entry
continue
if self._binary is None:
raise NotFoundException("Binary not found in folder", self.path)
else:
return self._binary
@property
def binary_regex(self):
"""Return the regex for the binary filename"""
raise NotImplementedError(sys._getframe(0).f_code.co_name)
@property
def final_url(self):
"""Return the final URL of the build"""
return '/'.join([self.path, self.binary])
@property
def path(self):
"""Return the path to the build"""
return '/'.join([self.base_url, self.path_regex])
@property
def path_regex(self):
"""Return the regex for the path to the build"""
raise NotImplementedError(sys._getframe(0).f_code.co_name)
@property
def platform_regex(self):
"""Return the platform fragment of the URL"""
return PLATFORM_FRAGMENTS[self.platform];
@property
def target(self):
"""Return the target file name of the build"""
if self._target is None:
self._target = os.path.join(self.directory,
self.build_filename(self.binary))
return self._target
def build_filename(self, binary):
"""Return the proposed filename with extension for the binary"""
raise NotImplementedError(sys._getframe(0).f_code.co_name)
def detect_platform(self):
"""Detect the current platform"""
# For Mac and Linux 32bit we do not need the bits appended
if mozinfo.os == 'mac' or (mozinfo.os == 'linux' and mozinfo.bits == 32):
return mozinfo.os
else:
return "%s%d" % (mozinfo.os, mozinfo.bits)
def download(self):
"""Download the specified file"""
attempts = 0
if not os.path.isdir(self.directory):
os.makedirs(self.directory)
# Don't re-download the file
if os.path.isfile(os.path.abspath(self.target)):
print "File has already been downloaded: %s" % (self.target)
return
print 'Downloading from: %s' % (urllib.unquote(self.final_url))
tmp_file = self.target + ".part"
if self.authentication \
and self.authentication['username'] \
and self.authentication['password']:
password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
password_mgr.add_password(None,
self.final_url,
self.authentication['username'],
self.authentication['password'])
handler = urllib2.HTTPBasicAuthHandler(password_mgr)
opener = urllib2.build_opener(urllib2.HTTPHandler, handler)
urllib2.install_opener(opener)
while True:
attempts += 1
try:
r = urllib2.urlopen(self.final_url)
CHUNK = 16 * 1024
with open(tmp_file, 'wb') as f:
for chunk in iter(lambda: r.read(CHUNK), ''):
f.write(chunk)
break
except (urllib2.HTTPError, urllib2.URLError):
if tmp_file and os.path.isfile(tmp_file):
os.remove(tmp_file)
print 'Download failed! Retrying... (attempt %s)' % attempts
if attempts >= self.retry_attempts:
raise
time.sleep(self.retry_delay)
os.rename(tmp_file, self.target)
class DailyScraper(Scraper):
"""Class to download a daily build from the Mozilla server"""
def __init__(self, branch='mozilla-central', build_id=None, date=None,
build_number=None, *args, **kwargs):
Scraper.__init__(self, *args, **kwargs)
self.branch = branch
# Internally we access builds via index
if build_number is not None:
self.build_index = int(build_number) - 1
else:
self.build_index = None
if build_id:
# A build id has been specified. Split up its components so the date
# and time can be extracted: '20111212042025' -> '2011-12-12 04:20:25'
self.date = datetime.strptime(build_id, '%Y%m%d%H%M%S')
self.builds, self.build_index = self.get_build_info_for_date(self.date,
has_time=True)
elif date:
# A date (without time) has been specified. Use its value and the
# build index to find the requested build for that day.
self.date = datetime.strptime(date, '%Y-%m-%d')
self.builds, self.build_index = self.get_build_info_for_date(self.date,
build_index=self.build_index)
else:
# If no build id nor date have been specified the lastest available
# build of the given branch has to be identified. We also have to
# retrieve the date of the build via its build id.
url = '%s/nightly/latest-%s/' % (self.base_url, self.branch)
print 'Retrieving the build status file from %s' % url
parser = DirectoryParser(url)
parser.entries = parser.filter(r'.*%s\.txt' % self.platform_regex)
if not parser.entries:
message = 'Status file for %s build cannot be found' % self.platform_regex
raise NotFoundException(message, url)
# Read status file for the platform, retrieve build id, and convert to a date
status_file = url + parser.entries[-1]
f = urllib.urlopen(status_file)
self.date = datetime.strptime(f.readline().strip(), '%Y%m%d%H%M%S')
self.builds, self.build_index = self.get_build_info_for_date(self.date,
has_time=True)
def get_build_info_for_date(self, date, has_time=False, build_index=None):
url = '/'.join([self.base_url, self.monthly_build_list_regex])
print 'Retrieving list of builds from %s' % url
parser = DirectoryParser(url)
regex = r'%(DATE)s-(\d+-)+%(BRANCH)s%(L10N)s$' % {
'DATE': date.strftime('%Y-%m-%d'),
'BRANCH': self.branch,
'L10N': '' if self.locale == 'en-US' else '-l10n'}
parser.entries = parser.filter(regex)
if not parser.entries:
message = 'Folder for builds on %s has not been found' % self.date.strftime('%Y-%m-%d')
raise NotFoundException(message, url)
if has_time:
# If a time is included in the date, use it to determine the build's index
regex = r'.*%s.*' % date.strftime('%H-%M-%S')
build_index = parser.entries.index(parser.filter(regex)[0])
else:
# If no index has been given, set it to the last build of the day.
if build_index is None:
build_index = len(parser.entries) - 1
return (parser.entries, build_index)
@property
def binary_regex(self):
"""Return the regex for the binary"""
regex_base_name = r'^%(APP)s-.*\.%(LOCALE)s\.%(PLATFORM)s'
regex_suffix = {'linux': r'\.%(EXT)s$',
'linux64': r'\.%(EXT)s$',
'mac': r'\.%(EXT)s$',
'mac64': r'\.%(EXT)s$',
'win32': r'(\.installer)\.%(EXT)s$',
'win64': r'(\.installer)\.%(EXT)s$'}
regex = regex_base_name + regex_suffix[self.platform]
return regex % {'APP': self.application,
'LOCALE': self.locale,
'PLATFORM': self.platform_regex,
'EXT': self.extension}
def build_filename(self, binary):
"""Return the proposed filename with extension for the binary"""
try:
# Get exact timestamp of the build to build the local file name
folder = self.builds[self.build_index]
timestamp = re.search('([\d\-]+)-\D.*', folder).group(1)
except:
# If it's not available use the build's date
timestamp = self.date.strftime('%Y-%m-%d')
return '%(TIMESTAMP)s-%(BRANCH)s-%(NAME)s' % {
'TIMESTAMP': timestamp,
'BRANCH': self.branch,
'NAME': binary}
@property
def monthly_build_list_regex(self):
"""Return the regex for the folder which contains the builds of a month."""
# Regex for possible builds for the given date
return r'nightly/%(YEAR)s/%(MONTH)s/' % {
'YEAR': self.date.year,
'MONTH': str(self.date.month).zfill(2) }
@property
def path_regex(self):
"""Return the regex for the path"""
try:
return self.monthly_build_list_regex + self.builds[self.build_index]
except:
raise NotFoundException("Specified sub folder cannot be found",
self.base_url + self.monthly_build_list_regex)
class DirectScraper(Scraper):
"""Class to download a file from a specified URL"""
def __init__(self, url, *args, **kwargs):
Scraper.__init__(self, *args, **kwargs)
self.url = url
@property
def target(self):
return urllib.splitquery(self.final_url)[0].rpartition('/')[-1]
@property
def final_url(self):
return self.url
class ReleaseScraper(Scraper):
"""Class to download a release build from the Mozilla server"""
def __init__(self, *args, **kwargs):
Scraper.__init__(self, *args, **kwargs)
@property
def binary_regex(self):
"""Return the regex for the binary"""
regex = {'linux': r'^%(APP)s-.*\.%(EXT)s$',
'linux64': r'^%(APP)s-.*\.%(EXT)s$',
'mac': r'^%(APP)s.*\.%(EXT)s$',
'mac64': r'^%(APP)s.*\.%(EXT)s$',
'win32': r'^%(APP)s.*\.%(EXT)s$',
'win64': r'^%(APP)s.*\.%(EXT)s$'}
return regex[self.platform] % {'APP': self.application,
'EXT': self.extension}
@property
def path_regex(self):
"""Return the regex for the path"""
regex = r'releases/%(VERSION)s/%(PLATFORM)s/%(LOCALE)s'
return regex % {'LOCALE': self.locale,
'PLATFORM': self.platform_regex,
'VERSION': self.version}
def build_filename(self, binary):
"""Return the proposed filename with extension for the binary"""
template = '%(APP)s-%(VERSION)s.%(LOCALE)s.%(PLATFORM)s.%(EXT)s'
return template % {'APP': self.application,
'VERSION': self.version,
'LOCALE': self.locale,
'PLATFORM': self.platform,
'EXT': self.extension}
class ReleaseCandidateScraper(ReleaseScraper):
"""Class to download a release candidate build from the Mozilla server"""
def __init__(self, build_number=None, no_unsigned=False, *args, **kwargs):
Scraper.__init__(self, *args, **kwargs)
# Internally we access builds via index
if build_number is not None:
self.build_index = int(build_number) - 1
else:
self.build_index = None
self.builds, self.build_index = self.get_build_info_for_version(self.version, self.build_index)
self.no_unsigned = no_unsigned
self.unsigned = False
def get_build_info_for_version(self, version, build_index=None):
url = '/'.join([self.base_url, self.candidate_build_list_regex])
print 'Retrieving list of candidate builds from %s' % url
parser = DirectoryParser(url)
if not parser.entries:
message = 'Folder for specific candidate builds at has not been found'
raise NotFoundException(message, url)
# If no index has been given, set it to the last build of the given version.
if build_index is None:
build_index = len(parser.entries) - 1
return (parser.entries, build_index)
@property
def candidate_build_list_regex(self):
"""Return the regex for the folder which contains the builds of
a candidate build."""
# Regex for possible builds for the given date
return r'nightly/%(VERSION)s-candidates/' % {
'VERSION': self.version }
@property
def path_regex(self):
"""Return the regex for the path"""
regex = r'%(PREFIX)s%(BUILD)s/%(UNSIGNED)s%(PLATFORM)s/%(LOCALE)s'
return regex % {'PREFIX': self.candidate_build_list_regex,
'BUILD': self.builds[self.build_index],
'LOCALE': self.locale,
'PLATFORM': self.platform_regex,
'UNSIGNED': "unsigned/" if self.unsigned else ""}
def build_filename(self, binary):
"""Return the proposed filename with extension for the binary"""
template = '%(APP)s-%(VERSION)s-build%(BUILD)s.%(LOCALE)s.%(PLATFORM)s.%(EXT)s'
return template % {'APP': self.application,
'VERSION': self.version,
'BUILD': self.builds[self.build_index],
'LOCALE': self.locale,
'PLATFORM': self.platform,
'EXT': self.extension}
def download(self):
"""Download the specified file"""
try:
# Try to download the signed candidate build
Scraper.download(self)
except NotFoundException, e:
print str(e)
# If the signed build cannot be downloaded and unsigned builds are
# allowed, try to download the unsigned build instead
if self.no_unsigned:
raise
else:
print "Signed build has not been found. Falling back to unsigned build."
self.unsigned = True
Scraper.download(self)
class TinderboxScraper(Scraper):
"""Class to download a tinderbox build from the Mozilla server.
There are two ways to specify a unique build:
1. If the date (%Y-%m-%d) is given and build_number is given where
the build_number is the index of the build on the date
2. If the build timestamp (UNIX) is given, and matches a specific build.
"""
def __init__(self, branch='mozilla-central', build_number=None, date=None,
debug_build=False, *args, **kwargs):
Scraper.__init__(self, *args, **kwargs)
self.branch = branch
self.debug_build = debug_build
self.locale_build = self.locale != 'en-US'
self.timestamp = None
# Currently any time in RelEng is based on the Pacific time zone.
self.timezone = PacificTimezone();
# Internally we access builds via index
if build_number is not None:
self.build_index = int(build_number) - 1
else:
self.build_index = None
if date is not None:
try:
self.date = datetime.fromtimestamp(float(date), self.timezone)
self.timestamp = date
except:
self.date = datetime.strptime(date, '%Y-%m-%d')
else:
self.date = None
# For localized builds we do not have to retrieve the list of builds
# because only the last build is available
if not self.locale_build:
self.builds, self.build_index = self.get_build_info(self.build_index)
try:
self.timestamp = self.builds[self.build_index]
except:
raise NotFoundException("Specified sub folder cannot be found",
self.base_url + self.monthly_build_list_regex)
@property
def binary_regex(self):
"""Return the regex for the binary"""
regex_base_name = r'^%(APP)s-.*\.%(LOCALE)s\.'
regex_suffix = {'linux': r'.*\.%(EXT)s$',
'linux64': r'.*\.%(EXT)s$',
'mac': r'.*\.%(EXT)s$',
'mac64': r'.*\.%(EXT)s$',
'win32': r'.*(\.installer)\.%(EXT)s$',
'win64': r'.*(\.installer)\.%(EXT)s$'}
regex = regex_base_name + regex_suffix[self.platform]
return regex % {'APP': self.application,
'LOCALE': self.locale,
'EXT': self.extension}
def build_filename(self, binary):
"""Return the proposed filename with extension for the binary"""
return '%(TIMESTAMP)s%(BRANCH)s%(DEBUG)s-%(NAME)s' % {
'TIMESTAMP': self.timestamp + '-' if self.timestamp else '',
'BRANCH': self.branch,
'DEBUG': '-debug' if self.debug_build else '',
'NAME': binary}
@property
def build_list_regex(self):
"""Return the regex for the folder which contains the list of builds"""
regex = 'tinderbox-builds/%(BRANCH)s-%(PLATFORM)s%(L10N)s%(DEBUG)s'
return regex % {'BRANCH': self.branch,
'PLATFORM': '' if self.locale_build else self.platform_regex,
'L10N': 'l10n' if self.locale_build else '',
'DEBUG': '-debug' if self.debug_build else ''}
def date_matches(self, timestamp):
"""Determines whether the timestamp date is equal to the argument date"""
if self.date is None:
return False
timestamp = datetime.fromtimestamp(float(timestamp), self.timezone)
if self.date.date() == timestamp.date():
return True
return False
@property
def date_validation_regex(self):
"""Return the regex for a valid date argument value"""
return r'^\d{4}-\d{1,2}-\d{1,2}$|^\d+$'
def detect_platform(self):
"""Detect the current platform"""
platform = Scraper.detect_platform(self)
# On OS X we have to special case the platform detection code and fallback
# to 64 bit builds for the en-US locale
if mozinfo.os == 'mac' and self.locale == 'en-US' and mozinfo.bits == 64:
platform = "%s%d" % (mozinfo.os, mozinfo.bits)
return platform
def get_build_info(self, build_index=None):
url = '/'.join([self.base_url, self.build_list_regex])
print 'Retrieving list of builds from %s' % url
# If a timestamp is given, retrieve just that build
regex = '^' + self.timestamp + '$' if self.timestamp else r'^\d+$'
parser = DirectoryParser(url)
parser.entries = parser.filter(regex)
# If date is given, retrieve the subset of builds on that date
if self.date is not None:
parser.entries = filter(self.date_matches, parser.entries)
if not parser.entries:
message = 'No builds have been found'
raise NotFoundException(message, url)
# If no index has been given, set it to the last build of the day.
if build_index is None:
build_index = len(parser.entries) - 1
return (parser.entries, build_index)
@property
def path_regex(self):
"""Return the regex for the path"""
if self.locale_build:
return self.build_list_regex
return '/'.join([self.build_list_regex, self.builds[self.build_index]])
@property
def platform_regex(self):
"""Return the platform fragment of the URL"""
PLATFORM_FRAGMENTS = {'linux': 'linux',
'linux64': 'linux64',
'mac': 'macosx',
'mac64': 'macosx64',
'win32': 'win32',
'win64': 'win64'}
return PLATFORM_FRAGMENTS[self.platform]
def cli():
"""Main function for the downloader"""
BUILD_TYPES = {'release': ReleaseScraper,
'candidate': ReleaseCandidateScraper,
'daily': DailyScraper,
'tinderbox': TinderboxScraper }
usage = 'usage: %prog [options]'
parser = OptionParser(usage=usage, description=__doc__)
parser.add_option('--application', '-a',
dest='application',
choices=APPLICATIONS,
default='firefox',
metavar='APPLICATION',
help='The name of the application to download, '
'default: "%default"')
parser.add_option('--directory', '-d',
dest='directory',
default=os.getcwd(),
metavar='DIRECTORY',
help='Target directory for the download, default: '
'current working directory')
parser.add_option('--build-number',
dest='build_number',
default=None,
type="int",
metavar='BUILD_NUMBER',
help='Number of the build (for candidate, daily, '
'and tinderbox builds)')
parser.add_option('--locale', '-l',
dest='locale',
default='en-US',
metavar='LOCALE',
help='Locale of the application, default: "%default"')
parser.add_option('--platform', '-p',
dest='platform',
choices=PLATFORM_FRAGMENTS.keys(),
metavar='PLATFORM',
help='Platform of the application')
parser.add_option('--type', '-t',
dest='type',
choices=BUILD_TYPES.keys(),
default='release',
metavar='BUILD_TYPE',
help='Type of build to download, default: "%default"')
parser.add_option('--url',
dest='url',
default=None,
metavar='URL',
help='URL to download.')
parser.add_option('--version', '-v',
dest='version',
metavar='VERSION',
help='Version of the application to be used by release and\
candidate builds, i.e. "3.6"')
parser.add_option('--extension',
dest='extension',
default=None,
metavar='EXTENSION',
help='File extension of the build (e.g. "zip"), default:\
the standard build extension on the platform.')
parser.add_option('--username',
dest='username',
default=None,
metavar='USERNAME',
help='Username for basic HTTP authentication.')
parser.add_option('--password',
dest='password',
default=None,
metavar='PASSWORD',
help='Password for basic HTTP authentication.')
parser.add_option('--retry-attempts',
dest='retry_attempts',
default=3,
type=int,
metavar='RETRY_ATTEMPTS',
help='Number of times the download will be attempted in '
'the event of a failure, default: %default')
parser.add_option('--retry-delay',
dest='retry_delay',
default=10,
type=int,
metavar='RETRY_DELAY',
help='Amount of time (in seconds) to wait between retry '
'attempts, default: %default')
# Option group for candidate builds
group = OptionGroup(parser, "Candidate builds",
"Extra options for candidate builds.")
group.add_option('--no-unsigned',
dest='no_unsigned',
action="store_true",
help="Don't allow to download unsigned builds if signed\
builds are not available")
parser.add_option_group(group)
# Option group for daily builds
group = OptionGroup(parser, "Daily builds",
"Extra options for daily builds.")
group.add_option('--branch',
dest='branch',
default='mozilla-central',
metavar='BRANCH',
help='Name of the branch, default: "%default"')
group.add_option('--build-id',
dest='build_id',
default=None,
metavar='BUILD_ID',
help='ID of the build to download')
group.add_option('--date',
dest='date',
default=None,
metavar='DATE',
help='Date of the build, default: latest build')
parser.add_option_group(group)
# Option group for tinderbox builds
group = OptionGroup(parser, "Tinderbox builds",
"Extra options for tinderbox builds.")
group.add_option('--debug-build',
dest='debug_build',
action="store_true",
help="Download a debug build")
parser.add_option_group(group)
# TODO: option group for nightly builds
(options, args) = parser.parse_args()
# Check for required options and arguments
# Note: Will be optional when ini file support has been landed
if not options.url \
and not options.type in ['daily', 'tinderbox'] \
and not options.version:
parser.error('The version of the application to download has not been specified.')
# Instantiate scraper and download the build
scraper_keywords = {'application': options.application,
'locale': options.locale,
'platform': options.platform,
'version': options.version,
'directory': options.directory,
'extension': options.extension,
'authentication': {
'username': options.username,
'password': options.password},
'retry_attempts': options.retry_attempts,
'retry_delay': options.retry_delay}
scraper_options = {'candidate': {
'build_number': options.build_number,
'no_unsigned': options.no_unsigned},
'daily': {
'branch': options.branch,
'build_number': options.build_number,
'build_id': options.build_id,
'date': options.date},
'tinderbox': {
'branch': options.branch,
'build_number': options.build_number,
'date': options.date,
'debug_build': options.debug_build}
}
kwargs = scraper_keywords.copy()
kwargs.update(scraper_options.get(options.type, {}))
if options.url:
build = DirectScraper(options.url, **kwargs)
else:
build = BUILD_TYPES[options.type](**kwargs)
build.download()
if __name__ == "__main__":
cli()