Files
ChocolateyPackages/EthanBrown.SublimeText2.UtilPackages/tools/PackageCache/Package Control/Package Control.py
2013-04-04 08:54:47 -04:00

4811 lines
172 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding=utf-8
import sublime
import sublime_plugin
import os
import sys
import subprocess
import zipfile
import urllib
import urllib2
import json
from fnmatch import fnmatch
import re
import threading
import datetime
import time
import shutil
import tempfile
import httplib
import socket
import hashlib
import base64
import locale
import urlparse
import gzip
import StringIO
import zlib
if os.name == 'nt':
from ctypes import windll, create_unicode_buffer
def add_to_path(path):
# Python 2.x on Windows can't properly import from non-ASCII paths, so
# this code added the DOC 8.3 version of the lib folder to the path in
# case the user's username includes non-ASCII characters
if os.name == 'nt':
buf = create_unicode_buffer(512)
if windll.kernel32.GetShortPathNameW(path, buf, len(buf)):
path = buf.value
if path not in sys.path:
sys.path.append(path)
lib_folder = os.path.join(sublime.packages_path(), 'Package Control', 'lib')
add_to_path(os.path.join(lib_folder, 'all'))
import semver
if os.name == 'nt':
add_to_path(os.path.join(lib_folder, 'windows'))
from ntlm import ntlm
def unicode_from_os(e):
# This is needed as some exceptions coming from the OS are
# already encoded and so just calling unicode(e) will result
# in an UnicodeDecodeError as the string isn't in ascii form.
try:
# Sublime Text on OS X does not seem to report the correct encoding
# so we hard-code that to UTF-8
encoding = 'UTF-8' if os.name == 'darwin' else locale.getpreferredencoding()
return unicode(str(e), encoding)
# If the "correct" encoding did not work, try some defaults, and then just
# obliterate characters that we can't seen to decode properly
except UnicodeDecodeError:
encodings = ['utf-8', 'cp1252']
for encoding in encodings:
try:
return unicode(str(e), encoding, errors='strict')
except:
pass
return unicode(str(e), errors='replace')
def create_cmd(args, basename_binary=False):
if basename_binary:
args[0] = os.path.basename(args[0])
if os.name == 'nt':
return subprocess.list2cmdline(args)
else:
escaped_args = []
for arg in args:
if re.search('^[a-zA-Z0-9/_^\\-\\.:=]+$', arg) == None:
arg = u"'" + arg.replace(u"'", u"'\\''") + u"'"
escaped_args.append(arg)
return u' '.join(escaped_args)
# Monkey patch AbstractBasicAuthHandler to prevent infinite recursion
def non_recursive_http_error_auth_reqed(self, authreq, host, req, headers):
authreq = headers.get(authreq, None)
if not hasattr(self, 'retried'):
self.retried = 0
if self.retried > 5:
raise urllib2.HTTPError(req.get_full_url(), 401, "basic auth failed",
headers, None)
else:
self.retried += 1
if authreq:
mo = urllib2.AbstractBasicAuthHandler.rx.search(authreq)
if mo:
scheme, quote, realm = mo.groups()
if scheme.lower() == 'basic':
return self.retry_http_basic_auth(host, req, realm)
urllib2.AbstractBasicAuthHandler.http_error_auth_reqed = non_recursive_http_error_auth_reqed
class DebuggableHTTPResponse(httplib.HTTPResponse):
"""
A custom HTTPResponse that formats debugging info for Sublime Text
"""
_debug_protocol = 'HTTP'
def __init__(self, sock, debuglevel=0, strict=0, method=None):
# We have to use a positive debuglevel to get it passed to here,
# however we don't want to use it because by default debugging prints
# to the stdout and we can't capture it, so we use a special -1 value
if debuglevel == 5:
debuglevel = -1
httplib.HTTPResponse.__init__(self, sock, debuglevel, strict, method)
def begin(self):
return_value = httplib.HTTPResponse.begin(self)
if self.debuglevel == -1:
print '%s: Urllib2 %s Debug Read' % (__name__, self._debug_protocol)
headers = self.msg.headers
versions = {
9: 'HTTP/0.9',
10: 'HTTP/1.0',
11: 'HTTP/1.1'
}
status_line = versions[self.version] + ' ' + str(self.status) + ' ' + self.reason
headers.insert(0, status_line)
for line in headers:
print u" %s" % line.rstrip()
return return_value
def read(self, *args):
try:
return httplib.HTTPResponse.read(self, *args)
except (httplib.IncompleteRead) as (e):
return e.partial
class DebuggableHTTPSResponse(DebuggableHTTPResponse):
"""
A version of DebuggableHTTPResponse that sets the debug protocol to HTTPS
"""
_debug_protocol = 'HTTPS'
class DebuggableHTTPConnection(httplib.HTTPConnection):
"""
A custom HTTPConnection that formats debugging info for Sublime Text
"""
response_class = DebuggableHTTPResponse
_debug_protocol = 'HTTP'
def __init__(self, host, port=None, strict=None,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT, **kwargs):
self.passwd = kwargs.get('passwd')
# Python 2.6.1 on OS X 10.6 does not include these
self._tunnel_host = None
self._tunnel_port = None
self._tunnel_headers = {}
httplib.HTTPConnection.__init__(self, host, port, strict, timeout)
def connect(self):
if self.debuglevel == -1:
print '%s: Urllib2 %s Debug General' % (__name__, self._debug_protocol)
print u" Connecting to %s on port %s" % (self.host, self.port)
httplib.HTTPConnection.connect(self)
def send(self, string):
# We have to use a positive debuglevel to get it passed to the
# HTTPResponse object, however we don't want to use it because by
# default debugging prints to the stdout and we can't capture it, so
# we temporarily set it to -1 for the standard httplib code
reset_debug = False
if self.debuglevel == 5:
reset_debug = 5
self.debuglevel = -1
httplib.HTTPConnection.send(self, string)
if reset_debug or self.debuglevel == -1:
if len(string.strip()) > 0:
print '%s: Urllib2 %s Debug Write' % (__name__, self._debug_protocol)
for line in string.strip().splitlines():
print ' ' + line
if reset_debug:
self.debuglevel = reset_debug
def request(self, method, url, body=None, headers={}):
original_headers = headers.copy()
# Handles the challenge request response cycle before the real request
proxy_auth = headers.get('Proxy-Authorization')
if os.name == 'nt' and proxy_auth and proxy_auth.lstrip()[0:4] == 'NTLM':
# The default urllib2.AbstractHTTPHandler automatically sets the
# Connection header to close because of urllib.addinfourl(), but in
# this case we are going to do some back and forth first for the NTLM
# proxy auth
headers['Connection'] = 'Keep-Alive'
self._send_request(method, url, body, headers)
response = self.getresponse()
content_length = int(response.getheader('content-length', 0))
if content_length:
response._safe_read(content_length)
proxy_authenticate = response.getheader('proxy-authenticate', None)
if not proxy_authenticate:
raise URLError('Invalid NTLM proxy authentication response')
ntlm_challenge = re.sub('^\s*NTLM\s+', '', proxy_authenticate)
if self.host.find(':') != -1:
host_port = self.host
else:
host_port = "%s:%s" % (self.host, self.port)
username, password = self.passwd.find_user_password(None, host_port)
domain = ''
user = username
if username.find('\\') != -1:
domain, user = username.split('\\', 1)
challenge, negotiate_flags = ntlm.parse_NTLM_CHALLENGE_MESSAGE(ntlm_challenge)
new_proxy_authorization = 'NTLM %s' % ntlm.create_NTLM_AUTHENTICATE_MESSAGE(challenge, user,
domain, password, negotiate_flags)
original_headers['Proxy-Authorization'] = new_proxy_authorization
response.close()
httplib.HTTPConnection.request(self, method, url, body, original_headers)
class DebuggableHTTPHandler(urllib2.HTTPHandler):
"""
A custom HTTPHandler that formats debugging info for Sublime Text
"""
def __init__(self, debuglevel=0, debug=False, **kwargs):
# This is a special value that will not trigger the standard debug
# functionality, but custom code where we can format the output
if debug:
self._debuglevel = 5
else:
self._debuglevel = debuglevel
self.passwd = kwargs.get('passwd')
def http_open(self, req):
def http_class_wrapper(host, **kwargs):
kwargs['passwd'] = self.passwd
return DebuggableHTTPConnection(host, **kwargs)
return self.do_open(http_class_wrapper, req)
class RateLimitException(httplib.HTTPException, urllib2.URLError):
"""
An exception for when the rate limit of an API has been exceeded.
"""
def __init__(self, host, limit):
httplib.HTTPException.__init__(self)
self.host = host
self.limit = limit
def __str__(self):
return ('Rate limit of %s exceeded for %s' % (self.limit, self.host))
if os.name == 'nt':
class ProxyNtlmAuthHandler(urllib2.BaseHandler):
handler_order = 300
auth_header = 'Proxy-Authorization'
def __init__(self, password_manager=None):
if password_manager is None:
password_manager = HTTPPasswordMgr()
self.passwd = password_manager
self.retried = 0
def http_error_407(self, req, fp, code, msg, headers):
proxy_authenticate = headers.get('proxy-authenticate')
if os.name != 'nt' or proxy_authenticate[0:4] != 'NTLM':
return None
type1_flags = ntlm.NTLM_TYPE1_FLAGS
if req.host.find(':') != -1:
host_port = req.host
else:
host_port = "%s:%s" % (req.host, req.port)
username, password = self.passwd.find_user_password(None, host_port)
if not username:
return None
if username.find('\\') == -1:
type1_flags &= ~ntlm.NTLM_NegotiateOemDomainSupplied
negotiate_message = ntlm.create_NTLM_NEGOTIATE_MESSAGE(username, type1_flags)
auth = 'NTLM %s' % negotiate_message
if req.headers.get(self.auth_header, None) == auth:
return None
req.add_unredirected_header(self.auth_header, auth)
return self.parent.open(req, timeout=req.timeout)
# The following code is wrapped in a try because the Linux versions of Sublime
# Text do not include the ssl module due to the fact that different distros
# have different versions
try:
import ssl
class InvalidCertificateException(httplib.HTTPException, urllib2.URLError):
"""
An exception for when an SSL certification is not valid for the URL
it was presented for.
"""
def __init__(self, host, cert, reason):
httplib.HTTPException.__init__(self)
self.host = host
self.cert = cert
self.reason = reason
def __str__(self):
return ('Host %s returned an invalid certificate (%s) %s\n' %
(self.host, self.reason, self.cert))
class ValidatingHTTPSConnection(DebuggableHTTPConnection):
"""
A custom HTTPConnection class that validates SSL certificates, and
allows proxy authentication for HTTPS connections.
"""
default_port = httplib.HTTPS_PORT
response_class = DebuggableHTTPSResponse
_debug_protocol = 'HTTPS'
def __init__(self, host, port=None, key_file=None, cert_file=None,
ca_certs=None, strict=None, **kwargs):
passed_args = {}
if 'timeout' in kwargs:
passed_args['timeout'] = kwargs['timeout']
DebuggableHTTPConnection.__init__(self, host, port, strict, **passed_args)
self.passwd = kwargs.get('passwd')
self.key_file = key_file
self.cert_file = cert_file
self.ca_certs = ca_certs
if 'user_agent' in kwargs:
self.user_agent = kwargs['user_agent']
if self.ca_certs:
self.cert_reqs = ssl.CERT_REQUIRED
else:
self.cert_reqs = ssl.CERT_NONE
def get_valid_hosts_for_cert(self, cert):
"""
Returns a list of valid hostnames for an SSL certificate
:param cert: A dict from SSLSocket.getpeercert()
:return: An array of hostnames
"""
if 'subjectAltName' in cert:
return [x[1] for x in cert['subjectAltName']
if x[0].lower() == 'dns']
else:
return [x[0][1] for x in cert['subject']
if x[0][0].lower() == 'commonname']
def validate_cert_host(self, cert, hostname):
"""
Checks if the cert is valid for the hostname
:param cert: A dict from SSLSocket.getpeercert()
:param hostname: A string hostname to check
:return: A boolean if the cert is valid for the hostname
"""
hosts = self.get_valid_hosts_for_cert(cert)
for host in hosts:
host_re = host.replace('.', '\.').replace('*', '[^.]*')
if re.search('^%s$' % (host_re,), hostname, re.I):
return True
return False
def _tunnel(self, ntlm_follow_up=False):
"""
This custom _tunnel method allows us to read and print the debug
log for the whole response before throwing an error, and adds
support for proxy authentication
"""
self._proxy_host = self.host
self._proxy_port = self.port
self._set_hostport(self._tunnel_host, self._tunnel_port)
self._tunnel_headers['Host'] = u"%s:%s" % (self.host, self.port)
self._tunnel_headers['User-Agent'] = self.user_agent
self._tunnel_headers['Proxy-Connection'] = 'Keep-Alive'
request = "CONNECT %s:%d HTTP/1.1\r\n" % (self.host, self.port)
for header, value in self._tunnel_headers.iteritems():
request += "%s: %s\r\n" % (header, value)
self.send(request + "\r\n")
response = self.response_class(self.sock, strict=self.strict,
method=self._method)
(version, code, message) = response._read_status()
status_line = u"%s %s %s" % (version, code, message.rstrip())
headers = [status_line]
if self.debuglevel in [-1, 5]:
print '%s: Urllib2 %s Debug Read' % (__name__, self._debug_protocol)
print u" %s" % status_line
content_length = 0
close_connection = False
while True:
line = response.fp.readline()
if line == '\r\n': break
headers.append(line.rstrip())
parts = line.rstrip().split(': ', 1)
name = parts[0].lower()
value = parts[1].lower().strip()
if name == 'content-length':
content_length = int(value)
if name in ['connection', 'proxy-connection'] and value == 'close':
close_connection = True
if self.debuglevel in [-1, 5]:
print u" %s" % line.rstrip()
# Handle proxy auth for SSL connections since regular urllib2 punts on this
if code == 407 and self.passwd and ('Proxy-Authorization' not in self._tunnel_headers or ntlm_follow_up):
if content_length:
response._safe_read(content_length)
supported_auth_methods = {}
for line in headers:
parts = line.split(': ', 1)
if parts[0].lower() != 'proxy-authenticate':
continue
details = parts[1].split(' ', 1)
supported_auth_methods[details[0].lower()] = details[1] if len(details) > 1 else ''
username, password = self.passwd.find_user_password(None, "%s:%s" % (
self._proxy_host, self._proxy_port))
do_ntlm_follow_up = False
if 'digest' in supported_auth_methods:
response_value = self.build_digest_response(
supported_auth_methods['digest'], username, password)
if response_value:
self._tunnel_headers['Proxy-Authorization'] = u"Digest %s" % response_value
elif 'basic' in supported_auth_methods:
response_value = u"%s:%s" % (username, password)
response_value = base64.b64encode(response_value).strip()
self._tunnel_headers['Proxy-Authorization'] = u"Basic %s" % response_value
elif 'ntlm' in supported_auth_methods and os.name == 'nt':
ntlm_challenge = supported_auth_methods['ntlm']
if not len(ntlm_challenge):
type1_flags = ntlm.NTLM_TYPE1_FLAGS
if username.find('\\') == -1:
type1_flags &= ~ntlm.NTLM_NegotiateOemDomainSupplied
negotiate_message = ntlm.create_NTLM_NEGOTIATE_MESSAGE(username, type1_flags)
self._tunnel_headers['Proxy-Authorization'] = 'NTLM %s' % negotiate_message
do_ntlm_follow_up = True
else:
domain = ''
user = username
if username.find('\\') != -1:
domain, user = username.split('\\', 1)
challenge, negotiate_flags = ntlm.parse_NTLM_CHALLENGE_MESSAGE(ntlm_challenge)
self._tunnel_headers['Proxy-Authorization'] = 'NTLM %s' % ntlm.create_NTLM_AUTHENTICATE_MESSAGE(challenge, user,
domain, password, negotiate_flags)
if 'Proxy-Authorization' in self._tunnel_headers:
self.host = self._proxy_host
self.port = self._proxy_port
# If the proxy wanted the connection closed, we need to make a new connection
if close_connection:
self.sock.close()
self.sock = socket.create_connection((self.host, self.port), self.timeout)
return self._tunnel(do_ntlm_follow_up)
if code != 200:
self.close()
raise socket.error("Tunnel connection failed: %d %s" % (code,
message.strip()))
def build_digest_response(self, fields, username, password):
"""
Takes a Proxy-Authenticate: Digest header and creates a response
header
:param fields:
The string portion of the Proxy-Authenticate header after
"Digest "
:param username:
The username to use for the response
:param password:
The password to use for the response
:return:
None if invalid Proxy-Authenticate header, otherwise the
string of fields for the Proxy-Authorization: Digest header
"""
fields = urllib2.parse_keqv_list(urllib2.parse_http_list(fields))
realm = fields.get('realm')
nonce = fields.get('nonce')
qop = fields.get('qop')
algorithm = fields.get('algorithm')
if algorithm:
algorithm = algorithm.lower()
opaque = fields.get('opaque')
if algorithm in ['md5', None]:
def hash(string):
return hashlib.md5(string).hexdigest()
elif algorithm == 'sha':
def hash(string):
return hashlib.sha1(string).hexdigest()
else:
return None
host_port = u"%s:%s" % (self.host, self.port)
a1 = "%s:%s:%s" % (username, realm, password)
a2 = "CONNECT:%s" % host_port
ha1 = hash(a1)
ha2 = hash(a2)
if qop == None:
response = hash(u"%s:%s:%s" % (ha1, nonce, ha2))
elif qop == 'auth':
nc = '00000001'
cnonce = hash(urllib2.randombytes(8))[:8]
response = hash(u"%s:%s:%s:%s:%s:%s" % (ha1, nonce, nc, cnonce, qop, ha2))
else:
return None
response_fields = {
'username': username,
'realm': realm,
'nonce': nonce,
'response': response,
'uri': host_port
}
if algorithm:
response_fields['algorithm'] = algorithm
if qop == 'auth':
response_fields['nc'] = nc
response_fields['cnonce'] = cnonce
response_fields['qop'] = qop
if opaque:
response_fields['opaque'] = opaque
return ', '.join([u"%s=\"%s\"" % (field, response_fields[field]) for field in response_fields])
def connect(self):
"""
Adds debugging and SSL certification validation
"""
if self.debuglevel == -1:
print '%s: Urllib2 HTTPS Debug General' % __name__
print u" Connecting to %s on port %s" % (self.host, self.port)
self.sock = socket.create_connection((self.host, self.port), self.timeout)
if self._tunnel_host:
self._tunnel()
if self.debuglevel == -1:
print u"%s: Urllib2 HTTPS Debug General" % __name__
print u" Connecting to %s on port %s" % (self.host, self.port)
print u" CA certs file at %s" % (self.ca_certs)
self.sock = ssl.wrap_socket(self.sock, keyfile=self.key_file,
certfile=self.cert_file, cert_reqs=self.cert_reqs,
ca_certs=self.ca_certs)
if self.debuglevel == -1:
print u" Successfully upgraded connection to %s:%s with SSL" % (
self.host, self.port)
# This debugs and validates the SSL certificate
if self.cert_reqs & ssl.CERT_REQUIRED:
cert = self.sock.getpeercert()
if self.debuglevel == -1:
subjectMap = {
'organizationName': 'O',
'commonName': 'CN',
'organizationalUnitName': 'OU',
'countryName': 'C',
'serialNumber': 'serialNumber',
'commonName': 'CN',
'localityName': 'L',
'stateOrProvinceName': 'S'
}
subject_list = list(cert['subject'])
subject_list.reverse()
subject_parts = []
for pair in subject_list:
if pair[0][0] in subjectMap:
field_name = subjectMap[pair[0][0]]
else:
field_name = pair[0][0]
subject_parts.append(field_name + '=' + pair[0][1])
print u" Server SSL certificate:"
print u" subject: " + ','.join(subject_parts)
if 'subjectAltName' in cert:
print u" common name: " + cert['subjectAltName'][0][1]
if 'notAfter' in cert:
print u" expire date: " + cert['notAfter']
hostname = self.host.split(':', 0)[0]
if not self.validate_cert_host(cert, hostname):
if self.debuglevel == -1:
print u" Certificate INVALID"
raise InvalidCertificateException(hostname, cert,
'hostname mismatch')
if self.debuglevel == -1:
print u" Certificate validated for %s" % hostname
if hasattr(urllib2, 'HTTPSHandler'):
class ValidatingHTTPSHandler(urllib2.HTTPSHandler):
"""
A urllib2 handler that validates SSL certificates for HTTPS requests
"""
def __init__(self, **kwargs):
# This is a special value that will not trigger the standard debug
# functionality, but custom code where we can format the output
self._debuglevel = 0
if 'debug' in kwargs and kwargs['debug']:
self._debuglevel = 5
elif 'debuglevel' in kwargs:
self._debuglevel = kwargs['debuglevel']
self._connection_args = kwargs
def https_open(self, req):
def http_class_wrapper(host, **kwargs):
full_kwargs = dict(self._connection_args)
full_kwargs.update(kwargs)
return ValidatingHTTPSConnection(host, **full_kwargs)
try:
return self.do_open(http_class_wrapper, req)
except urllib2.URLError, e:
if type(e.reason) == ssl.SSLError and e.reason.args[0] == 1:
raise InvalidCertificateException(req.host, '',
e.reason.args[1])
raise
https_request = urllib2.AbstractHTTPHandler.do_request_
except (ImportError):
pass
def preferences_filename():
""":return: The appropriate settings filename based on the version of Sublime Text"""
if int(sublime.version()) >= 2174:
return 'Preferences.sublime-settings'
return 'Global.sublime-settings'
class ThreadProgress():
"""
Animates an indicator, [= ], in the status area while a thread runs
:param thread:
The thread to track for activity
:param message:
The message to display next to the activity indicator
:param success_message:
The message to display once the thread is complete
"""
def __init__(self, thread, message, success_message):
self.thread = thread
self.message = message
self.success_message = success_message
self.addend = 1
self.size = 8
sublime.set_timeout(lambda: self.run(0), 100)
def run(self, i):
if not self.thread.is_alive():
if hasattr(self.thread, 'result') and not self.thread.result:
sublime.status_message('')
return
sublime.status_message(self.success_message)
return
before = i % self.size
after = (self.size - 1) - before
sublime.status_message('%s [%s=%s]' % \
(self.message, ' ' * before, ' ' * after))
if not after:
self.addend = -1
if not before:
self.addend = 1
i += self.addend
sublime.set_timeout(lambda: self.run(i), 100)
class PlatformComparator():
def get_best_platform(self, platforms):
ids = [sublime.platform() + '-' + sublime.arch(), sublime.platform(),
'*']
for id in ids:
if id in platforms:
return id
return None
class ChannelProvider(PlatformComparator):
"""
Retrieves a channel and provides an API into the information
The current channel/repository infrastructure caches repository info into
the channel to improve the Package Control client performance. This also
has the side effect of lessening the load on the GitHub and BitBucket APIs
and getting around not-infrequent HTTP 503 errors from those APIs.
:param channel:
The URL of the channel
:param package_manager:
An instance of :class:`PackageManager` used to download the file
"""
def __init__(self, channel, package_manager):
self.channel_info = None
self.channel = channel
self.package_manager = package_manager
self.unavailable_packages = []
def match_url(self):
"""Indicates if this provider can handle the provided channel"""
return True
def fetch_channel(self):
"""Retrieves and loads the JSON for other methods to use"""
if self.channel_info != None:
return
channel_json = self.package_manager.download_url(self.channel,
'Error downloading channel.')
if channel_json == False:
self.channel_info = False
return
try:
channel_info = json.loads(channel_json)
except (ValueError):
print '%s: Error parsing JSON from channel %s.' % (__name__,
self.channel)
channel_info = False
self.channel_info = channel_info
def get_name_map(self):
""":return: A dict of the mapping for URL slug -> package name"""
self.fetch_channel()
if self.channel_info == False:
return False
return self.channel_info.get('package_name_map', {})
def get_renamed_packages(self):
""":return: A dict of the packages that have been renamed"""
self.fetch_channel()
if self.channel_info == False:
return False
return self.channel_info.get('renamed_packages', {})
def get_repositories(self):
""":return: A list of the repository URLs"""
self.fetch_channel()
if self.channel_info == False:
return False
return self.channel_info['repositories']
def get_certs(self):
"""
Provides a secure way for distribution of SSL CA certificates
Unfortunately Python does not include a bundle of CA certs with urllib2
to perform SSL certificate validation. To circumvent this issue,
Package Control acts as a distributor of the CA certs for all HTTPS
URLs of package downloads.
The default channel scrapes and caches info about all packages
periodically, and in the process it checks the CA certs for all of
the HTTPS URLs listed in the repositories. The contents of the CA cert
files are then hashed, and the CA cert is stored in a filename with
that hash. This is a fingerprint to ensure that Package Control has
the appropriate CA cert for a domain name.
Next, the default channel file serves up a JSON object of the domain
names and the hashes of their current CA cert files. If Package Control
does not have the appropriate hash for a domain, it may retrieve it
from the channel server. To ensure that Package Control is talking to
a trusted authority to get the CA certs from, the CA cert for
sublime.wbond.net is bundled with Package Control. Then when downloading
the channel file, Package Control can ensure that the channel file's
SSL certificate is valid, thus ensuring the resulting CA certs are
legitimate.
As a matter of optimization, the distribution of Package Control also
includes the current CA certs for all known HTTPS domains that are
included in the channel, as of the time when Package Control was
last released.
:return: A dict of {'Domain Name': ['cert_file_hash', 'cert_file_download_url']}
"""
self.fetch_channel()
if self.channel_info == False:
return False
return self.channel_info.get('certs', {})
def get_packages(self, repo):
"""
Provides access to the repository info that is cached in a channel
:param repo:
The URL of the repository to get the cached info of
:return:
A dict in the format:
{
'Package Name': {
# Package details - see example-packages.json for format
},
...
}
or False if there is an error
"""
self.fetch_channel()
if self.channel_info == False:
return False
if self.channel_info.get('packages', False) == False:
return False
if self.channel_info['packages'].get(repo, False) == False:
return False
output = {}
for package in self.channel_info['packages'][repo]:
copy = package.copy()
platforms = copy['platforms'].keys()
best_platform = self.get_best_platform(platforms)
if not best_platform:
self.unavailable_packages.append(copy['name'])
continue
copy['downloads'] = copy['platforms'][best_platform]
del copy['platforms']
copy['url'] = copy['homepage']
del copy['homepage']
output[copy['name']] = copy
return output
def get_unavailable_packages(self):
"""
Provides a list of packages that are unavailable for the current
platform/architecture that Sublime Text is running on.
This list will be empty unless get_packages() is called first.
:return: A list of package names
"""
return self.unavailable_packages
# The providers (in order) to check when trying to download a channel
_channel_providers = [ChannelProvider]
class PackageProvider(PlatformComparator):
"""
Generic repository downloader that fetches package info
With the current channel/repository architecture where the channel file
caches info from all includes repositories, these package providers just
serve the purpose of downloading packages not in the default channel.
The structure of the JSON a repository should contain is located in
example-packages.json.
:param repo:
The URL of the package repository
:param package_manager:
An instance of :class:`PackageManager` used to download the file
"""
def __init__(self, repo, package_manager):
self.repo_info = None
self.repo = repo
self.package_manager = package_manager
self.unavailable_packages = []
def match_url(self):
"""Indicates if this provider can handle the provided repo"""
return True
def fetch_repo(self):
"""Retrieves and loads the JSON for other methods to use"""
if self.repo_info != None:
return
repository_json = self.package_manager.download_url(self.repo,
'Error downloading repository.')
if repository_json == False:
self.repo_info = False
return
try:
self.repo_info = json.loads(repository_json)
except (ValueError):
print '%s: Error parsing JSON from repository %s.' % (__name__,
self.repo)
self.repo_info = False
def get_packages(self):
"""
Provides access to the repository info that is cached in a channel
:return:
A dict in the format:
{
'Package Name': {
# Package details - see example-packages.json for format
},
...
}
or False if there is an error
"""
self.fetch_repo()
if self.repo_info == False:
return False
output = {}
for package in self.repo_info['packages']:
platforms = package['platforms'].keys()
best_platform = self.get_best_platform(platforms)
if not best_platform:
self.unavailable_packages.append(package['name'])
continue
# Rewrites the legacy "zipball" URLs to the new "zip" format
downloads = package['platforms'][best_platform]
rewritten_downloads = []
for download in downloads:
download['url'] = re.sub(
'^(https://nodeload.github.com/[^/]+/[^/]+/)zipball(/.*)$',
'\\1zip\\2', download['url'])
rewritten_downloads.append(download)
info = {
'name': package['name'],
'description': package.get('description'),
'url': package.get('homepage', self.repo),
'author': package.get('author'),
'last_modified': package.get('last_modified'),
'downloads': rewritten_downloads
}
output[package['name']] = info
return output
def get_renamed_packages(self):
""":return: A dict of the packages that have been renamed"""
return self.repo_info.get('renamed_packages', {})
def get_unavailable_packages(self):
"""
Provides a list of packages that are unavailable for the current
platform/architecture that Sublime Text is running on.
This list will be empty unless get_packages() is called first.
:return: A list of package names
"""
return self.unavailable_packages
class NonCachingProvider():
"""
Base for package providers that do not need to cache the JSON
"""
def fetch_json(self, url):
"""
Retrieves and parses the JSON from a URL
:return: A dict or list from the JSON, or False on error
"""
repository_json = self.package_manager.download_url(url,
'Error downloading repository.')
if repository_json == False:
return False
try:
return json.loads(repository_json)
except (ValueError):
print '%s: Error parsing JSON from repository %s.' % (__name__,
url)
return False
def get_unavailable_packages(self):
"""
Method for compatibility with PackageProvider class. These providers
are based on API calls, and thus do not support different platform
downloads, making it impossible for there to be unavailable packages.
:return: An empty list
"""
return []
class GitHubPackageProvider(NonCachingProvider):
"""
Allows using a public GitHub repository as the source for a single package
:param repo:
The public web URL to the GitHub repository. Should be in the format
`https://github.com/user/package` for the master branch, or
`https://github.com/user/package/tree/{branch_name}` for any other
branch.
:param package_manager:
An instance of :class:`PackageManager` used to access the API
"""
def __init__(self, repo, package_manager):
# Clean off the trailing .git to be more forgiving
self.repo = re.sub('\.git$', '', repo)
self.package_manager = package_manager
def match_url(self):
"""Indicates if this provider can handle the provided repo"""
master = re.search('^https?://github.com/[^/]+/[^/]+/?$', self.repo)
branch = re.search('^https?://github.com/[^/]+/[^/]+/tree/[^/]+/?$',
self.repo)
return master != None or branch != None
def get_packages(self):
"""Uses the GitHub API to construct necessary info for a package"""
branch = 'master'
branch_match = re.search(
'^https?://github.com/[^/]+/[^/]+/tree/([^/]+)/?$', self.repo)
if branch_match != None:
branch = branch_match.group(1)
api_url = re.sub('^https?://github.com/([^/]+)/([^/]+)($|/.*$)',
'https://api.github.com/repos/\\1/\\2', self.repo)
repo_info = self.fetch_json(api_url)
if repo_info == False:
return False
# In addition to hitting the main API endpoint for this repo, we
# also have to list the commits to get the timestamp of the last
# commit since we use that to generate a version number
commit_api_url = api_url + '/commits?' + \
urllib.urlencode({'sha': branch, 'per_page': 1})
commit_info = self.fetch_json(commit_api_url)
if commit_info == False:
return False
# We specifically use nodeload.github.com here because the download
# URLs all redirect there, and some of the downloaders don't follow
# HTTP redirect headers
download_url = 'https://nodeload.github.com/' + \
repo_info['owner']['login'] + '/' + \
repo_info['name'] + '/zip/' + urllib.quote(branch)
commit_date = commit_info[0]['commit']['committer']['date']
timestamp = datetime.datetime.strptime(commit_date[0:19],
'%Y-%m-%dT%H:%M:%S')
utc_timestamp = timestamp.strftime(
'%Y.%m.%d.%H.%M.%S')
homepage = repo_info['homepage']
if not homepage:
homepage = repo_info['html_url']
package = {
'name': repo_info['name'],
'description': repo_info['description'] if \
repo_info['description'] else 'No description provided',
'url': homepage,
'author': repo_info['owner']['login'],
'last_modified': timestamp.strftime('%Y-%m-%d %H:%M:%S'),
'downloads': [
{
'version': utc_timestamp,
'url': download_url
}
]
}
return {package['name']: package}
def get_renamed_packages(self):
"""For API-compatibility with :class:`PackageProvider`"""
return {}
class GitHubUserProvider(NonCachingProvider):
"""
Allows using a GitHub user/organization as the source for multiple packages
:param repo:
The public web URL to the GitHub user/org. Should be in the format
`https://github.com/user`.
:param package_manager:
An instance of :class:`PackageManager` used to access the API
"""
def __init__(self, repo, package_manager):
self.repo = repo
self.package_manager = package_manager
def match_url(self):
"""Indicates if this provider can handle the provided repo"""
return re.search('^https?://github.com/[^/]+/?$', self.repo) != None
def get_packages(self):
"""Uses the GitHub API to construct necessary info for all packages"""
user_match = re.search('^https?://github.com/([^/]+)/?$', self.repo)
user = user_match.group(1)
api_url = 'https://api.github.com/users/%s/repos?per_page=100' % user
repo_info = self.fetch_json(api_url)
if repo_info == False:
return False
packages = {}
for package_info in repo_info:
# All packages for the user are made available, and always from
# the master branch. Anything else requires a custom packages.json
commit_api_url = ('https://api.github.com/repos/%s/%s/commits' + \
'?sha=master&per_page=1') % (user, package_info['name'])
commit_info = self.fetch_json(commit_api_url)
if commit_info == False:
return False
commit_date = commit_info[0]['commit']['committer']['date']
timestamp = datetime.datetime.strptime(commit_date[0:19],
'%Y-%m-%dT%H:%M:%S')
utc_timestamp = timestamp.strftime(
'%Y.%m.%d.%H.%M.%S')
homepage = package_info['homepage']
if not homepage:
homepage = package_info['html_url']
package = {
'name': package_info['name'],
'description': package_info['description'] if \
package_info['description'] else 'No description provided',
'url': homepage,
'author': package_info['owner']['login'],
'last_modified': timestamp.strftime('%Y-%m-%d %H:%M:%S'),
'downloads': [
{
'version': utc_timestamp,
# We specifically use nodeload.github.com here because
# the download URLs all redirect there, and some of the
# downloaders don't follow HTTP redirect headers
'url': 'https://nodeload.github.com/' + \
package_info['owner']['login'] + '/' + \
package_info['name'] + '/zip/master'
}
]
}
packages[package['name']] = package
return packages
def get_renamed_packages(self):
"""For API-compatibility with :class:`PackageProvider`"""
return {}
class BitBucketPackageProvider(NonCachingProvider):
"""
Allows using a public BitBucket repository as the source for a single package
:param repo:
The public web URL to the BitBucket repository. Should be in the format
`https://bitbucket.org/user/package`.
:param package_manager:
An instance of :class:`PackageManager` used to access the API
"""
def __init__(self, repo, package_manager):
self.repo = repo
self.package_manager = package_manager
def match_url(self):
"""Indicates if this provider can handle the provided repo"""
return re.search('^https?://bitbucket.org', self.repo) != None
def get_packages(self):
"""Uses the BitBucket API to construct necessary info for a package"""
api_url = re.sub('^https?://bitbucket.org/',
'https://api.bitbucket.org/1.0/repositories/', self.repo)
api_url = api_url.rstrip('/')
repo_info = self.fetch_json(api_url)
if repo_info == False:
return False
# Since HG allows for arbitrary main branch names, we have to hit
# this URL just to get that info
main_branch_url = api_url + '/main-branch/'
main_branch_info = self.fetch_json(main_branch_url)
if main_branch_info == False:
return False
# Grabbing the changesets is necessary because we construct the
# version number from the last commit timestamp
changeset_url = api_url + '/changesets/' + main_branch_info['name']
last_commit = self.fetch_json(changeset_url)
if last_commit == False:
return False
commit_date = last_commit['timestamp']
timestamp = datetime.datetime.strptime(commit_date[0:19],
'%Y-%m-%d %H:%M:%S')
utc_timestamp = timestamp.strftime(
'%Y.%m.%d.%H.%M.%S')
homepage = repo_info['website']
if not homepage:
homepage = self.repo
package = {
'name': repo_info['name'],
'description': repo_info['description'] if \
repo_info['description'] else 'No description provided',
'url': homepage,
'author': repo_info['owner'],
'last_modified': timestamp.strftime('%Y-%m-%d %H:%M:%S'),
'downloads': [
{
'version': utc_timestamp,
'url': self.repo + '/get/' + \
last_commit['node'] + '.zip'
}
]
}
return {package['name']: package}
def get_renamed_packages(self):
"""For API-compatibility with :class:`PackageProvider`"""
return {}
# The providers (in order) to check when trying to download repository info
_package_providers = [BitBucketPackageProvider, GitHubPackageProvider,
GitHubUserProvider, PackageProvider]
class BinaryNotFoundError(Exception):
"""If a necessary executable is not found in the PATH on the system"""
pass
class NonCleanExitError(Exception):
"""
When an subprocess does not exit cleanly
:param returncode:
The command line integer return code of the subprocess
"""
def __init__(self, returncode):
self.returncode = returncode
def __str__(self):
return repr(self.returncode)
class NonHttpError(Exception):
"""If a downloader had a non-clean exit, but it was not due to an HTTP error"""
pass
class Downloader():
"""
A base downloader that actually performs downloading URLs
The SSL module is not included with the bundled Python for Linux
users of Sublime Text, so Linux machines will fall back to using curl
or wget for HTTPS URLs.
"""
def check_certs(self, domain, timeout):
"""
Ensures that the SSL CA cert for a domain is present on the machine
:param domain:
The domain to ensure there is a CA cert for
:param timeout:
The int timeout for downloading the CA cert from the channel
:return:
The CA cert bundle path on success, or False on error
"""
cert_match = False
certs_list = self.settings.get('certs', {})
certs_dir = os.path.join(sublime.packages_path(), 'Package Control',
'certs')
ca_bundle_path = os.path.join(certs_dir, 'ca-bundle.crt')
cert_info = certs_list.get(domain)
if cert_info:
cert_match = self.locate_cert(certs_dir, cert_info[0], cert_info[1])
wildcard_info = certs_list.get('*')
if wildcard_info:
cert_match = self.locate_cert(certs_dir, wildcard_info[0], wildcard_info[1]) or cert_match
if not cert_match:
print '%s: No CA certs available for %s.' % (__name__, domain)
return False
return ca_bundle_path
def locate_cert(self, certs_dir, cert_id, location):
"""
Makes sure the SSL cert specified has been added to the CA cert
bundle that is present on the machine
:param certs_dir:
The path of the folder that contains the cert files
:param cert_id:
The identifier for CA cert(s). For those provided by the channel
system, this will be an md5 of the contents of the cert(s). For
user-provided certs, this is something they provide.
:param location:
An http(s) URL, or absolute filesystem path to the CA cert(s)
:return:
If the cert specified (by cert_id) is present on the machine and
part of the ca-bundle.crt file in the certs_dir
"""
cert_path = os.path.join(certs_dir, cert_id)
if not os.path.exists(cert_path):
if str(location) != '':
if re.match('^https?://', location):
contents = self.download_cert(cert_id, location)
else:
contents = self.load_cert(cert_id, location)
if contents:
self.save_cert(certs_dir, cert_id, contents)
return True
return False
return True
def download_cert(self, cert_id, url):
"""
Downloads CA cert(s) from a URL
:param cert_id:
The identifier for CA cert(s). For those provided by the channel
system, this will be an md5 of the contents of the cert(s). For
user-provided certs, this is something they provide.
:param url:
An http(s) URL to the CA cert(s)
:return:
The contents of the CA cert(s)
"""
cert_downloader = self.__class__(self.settings)
return cert_downloader.download(url,
'Error downloading CA certs for %s.' % (domain), timeout, 1)
def load_cert(self, cert_id, path):
"""
Copies CA cert(s) from a file path
:param cert_id:
The identifier for CA cert(s). For those provided by the channel
system, this will be an md5 of the contents of the cert(s). For
user-provided certs, this is something they provide.
:param path:
The absolute filesystem path to a file containing the CA cert(s)
:return:
The contents of the CA cert(s)
"""
if os.path.exists(path):
with open(path, 'rb') as f:
return f.read()
def save_cert(self, certs_dir, cert_id, contents):
"""
Saves CA cert(s) to the certs_dir (and ca-bundle.crt file)
:param certs_dir:
The path of the folder that contains the cert files
:param cert_id:
The identifier for CA cert(s). For those provided by the channel
system, this will be an md5 of the contents of the cert(s). For
user-provided certs, this is something they provide.
:param contents:
The contents of the CA cert(s)
"""
ca_bundle_path = os.path.join(certs_dir, 'ca-bundle.crt')
cert_path = os.path.join(certs_dir, cert_id)
with open(cert_path, 'wb') as f:
f.write(contents)
with open(ca_bundle_path, 'ab') as f:
f.write("\n" + contents)
def decode_response(self, encoding, response):
if encoding == 'gzip':
return gzip.GzipFile(fileobj=StringIO.StringIO(response)).read()
elif encoding == 'deflate':
decompresser = zlib.decompressobj(-zlib.MAX_WBITS)
return decompresser.decompress(response) + decompresser.flush()
return response
class CliDownloader(Downloader):
"""
Base for downloaders that use a command line program
:param settings:
A dict of the various Package Control settings. The Sublime Text
Settings API is not used because this code is run in a thread.
"""
def __init__(self, settings):
self.settings = settings
def clean_tmp_file(self):
if os.path.exists(self.tmp_file):
os.remove(self.tmp_file)
def find_binary(self, name):
"""
Finds the given executable name in the system PATH
:param name:
The exact name of the executable to find
:return:
The absolute path to the executable
:raises:
BinaryNotFoundError when the executable can not be found
"""
for dir in os.environ['PATH'].split(os.pathsep):
path = os.path.join(dir, name)
if os.path.exists(path):
return path
raise BinaryNotFoundError('The binary %s could not be located' % name)
def execute(self, args):
"""
Runs the executable and args and returns the result
:param args:
A list of the executable path and all arguments to be passed to it
:return:
The text output of the executable
:raises:
NonCleanExitError when the executable exits with an error
"""
if self.settings.get('debug'):
print u"%s: Trying to execute command %s" % (
__name__, create_cmd(args))
proc = subprocess.Popen(args, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output = proc.stdout.read()
self.stderr = proc.stderr.read()
returncode = proc.wait()
if returncode != 0:
error = NonCleanExitError(returncode)
error.stderr = self.stderr
error.stdout = output
raise error
return output
class UrlLib2Downloader(Downloader):
"""
A downloader that uses the Python urllib2 module
:param settings:
A dict of the various Package Control settings. The Sublime Text
Settings API is not used because this code is run in a thread.
"""
def __init__(self, settings):
self.settings = settings
def download(self, url, error_message, timeout, tries):
"""
Downloads a URL and returns the contents
Uses the proxy settings from the Package Control.sublime-settings file,
however there seem to be a decent number of proxies that this code
does not work with. Patches welcome!
:param url:
The URL to download
:param error_message:
A string to include in the console error that is printed
when an error occurs
:param timeout:
The int number of seconds to set the timeout to
:param tries:
The int number of times to try and download the URL in the case of
a timeout or HTTP 503 error
:return:
The string contents of the URL, or False on error
"""
http_proxy = self.settings.get('http_proxy')
https_proxy = self.settings.get('https_proxy')
if http_proxy or https_proxy:
proxies = {}
if http_proxy:
proxies['http'] = http_proxy
if https_proxy:
proxies['https'] = https_proxy
proxy_handler = urllib2.ProxyHandler(proxies)
else:
proxy_handler = urllib2.ProxyHandler()
password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
proxy_username = self.settings.get('proxy_username')
proxy_password = self.settings.get('proxy_password')
if proxy_username and proxy_password:
if http_proxy:
password_manager.add_password(None, http_proxy, proxy_username,
proxy_password)
if https_proxy:
password_manager.add_password(None, https_proxy, proxy_username,
proxy_password)
handlers = [proxy_handler]
if os.name == 'nt':
ntlm_auth_handler = ProxyNtlmAuthHandler(password_manager)
handlers.append(ntlm_auth_handler)
basic_auth_handler = urllib2.ProxyBasicAuthHandler(password_manager)
digest_auth_handler = urllib2.ProxyDigestAuthHandler(password_manager)
handlers.extend([digest_auth_handler, basic_auth_handler])
debug = self.settings.get('debug')
if debug:
print u"%s: Urllib2 Debug Proxy" % __name__
print u" http_proxy: %s" % http_proxy
print u" https_proxy: %s" % https_proxy
print u" proxy_username: %s" % proxy_username
print u" proxy_password: %s" % proxy_password
secure_url_match = re.match('^https://([^/]+)', url)
if secure_url_match != None:
secure_domain = secure_url_match.group(1)
bundle_path = self.check_certs(secure_domain, timeout)
if not bundle_path:
return False
bundle_path = bundle_path.encode(sys.getfilesystemencoding())
handlers.append(ValidatingHTTPSHandler(ca_certs=bundle_path,
debug=debug, passwd=password_manager,
user_agent=self.settings.get('user_agent')))
else:
handlers.append(DebuggableHTTPHandler(debug=debug,
passwd=password_manager))
urllib2.install_opener(urllib2.build_opener(*handlers))
while tries > 0:
tries -= 1
try:
request = urllib2.Request(url, headers={
"User-Agent": self.settings.get('user_agent'),
# Don't be alarmed if the response from the server does not
# select one of these since the server runs a relatively new
# version of OpenSSL which supports compression on the SSL
# layer, and Apache will use that instead of HTTP-level
# encoding.
"Accept-Encoding": "gzip,deflate"})
http_file = urllib2.urlopen(request, timeout=timeout)
self.handle_rate_limit(http_file, url)
result = http_file.read()
encoding = http_file.headers.get('Content-Encoding')
return self.decode_response(encoding, result)
except (httplib.HTTPException) as (e):
print '%s: %s HTTP exception %s (%s) downloading %s.' % (
__name__, error_message, e.__class__.__name__,
unicode_from_os(e), url)
except (urllib2.HTTPError) as (e):
# Make sure we obey Github's rate limiting headers
self.handle_rate_limit(e, url)
# Bitbucket and Github return 503 a decent amount
if unicode_from_os(e.code) == '503':
print ('%s: Downloading %s was rate limited, ' +
'trying again') % (__name__, url)
continue
print '%s: %s HTTP error %s downloading %s.' % (__name__,
error_message, unicode_from_os(e.code), url)
except (urllib2.URLError) as (e):
# Bitbucket and Github timeout a decent amount
if unicode_from_os(e.reason) == 'The read operation timed out' \
or unicode_from_os(e.reason) == 'timed out':
print (u'%s: Downloading %s timed out, trying ' +
u'again') % (__name__, url)
continue
print u'%s: %s URL error %s downloading %s.' % (__name__,
error_message, unicode_from_os(e.reason), url)
break
return False
def handle_rate_limit(self, response, url):
"""
Checks the headers of a respone object to make sure we are obeying the
rate limit
:param response:
The response object that has a headers dict
:param url:
The URL that was requested
:raises:
RateLimitException when the rate limit has been hit
"""
limit_remaining = response.headers.get('X-RateLimit-Remaining', 1)
if str(limit_remaining) == '0':
hostname = urlparse.urlparse(url).hostname
limit = response.headers.get('X-RateLimit-Limit', 1)
raise RateLimitException(hostname, limit)
class WgetDownloader(CliDownloader):
"""
A downloader that uses the command line program wget
:param settings:
A dict of the various Package Control settings. The Sublime Text
Settings API is not used because this code is run in a thread.
"""
def __init__(self, settings):
self.settings = settings
self.debug = settings.get('debug')
self.wget = self.find_binary('wget')
def download(self, url, error_message, timeout, tries):
"""
Downloads a URL and returns the contents
:param url:
The URL to download
:param error_message:
A string to include in the console error that is printed
when an error occurs
:param timeout:
The int number of seconds to set the timeout to
:param tries:
The int number of times to try and download the URL in the case of
a timeout or HTTP 503 error
:return:
The string contents of the URL, or False on error
"""
if not self.wget:
return False
self.tmp_file = tempfile.NamedTemporaryFile().name
command = [self.wget, '--connect-timeout=' + str(int(timeout)), '-o',
self.tmp_file, '-O', '-', '-U',
self.settings.get('user_agent'), '--header',
# Don't be alarmed if the response from the server does not select
# one of these since the server runs a relatively new version of
# OpenSSL which supports compression on the SSL layer, and Apache
# will use that instead of HTTP-level encoding.
'Accept-Encoding: gzip,deflate']
secure_url_match = re.match('^https://([^/]+)', url)
if secure_url_match != None:
secure_domain = secure_url_match.group(1)
bundle_path = self.check_certs(secure_domain, timeout)
if not bundle_path:
return False
command.append(u'--ca-certificate=' + bundle_path)
if self.debug:
command.append('-d')
else:
command.append('-S')
http_proxy = self.settings.get('http_proxy')
https_proxy = self.settings.get('https_proxy')
proxy_username = self.settings.get('proxy_username')
proxy_password = self.settings.get('proxy_password')
if proxy_username:
command.append(u"--proxy-user=%s" % proxy_username)
if proxy_password:
command.append(u"--proxy-password=%s" % proxy_password)
if self.debug:
print u"%s: Wget Debug Proxy" % __name__
print u" http_proxy: %s" % http_proxy
print u" https_proxy: %s" % https_proxy
print u" proxy_username: %s" % proxy_username
print u" proxy_password: %s" % proxy_password
command.append(url)
if http_proxy:
os.putenv('http_proxy', http_proxy)
if https_proxy:
os.putenv('https_proxy', https_proxy)
while tries > 0:
tries -= 1
try:
result = self.execute(command)
general, headers = self.parse_output()
encoding = headers.get('content-encoding')
if encoding:
result = self.decode_response(encoding, result)
return result
except (NonCleanExitError) as (e):
try:
general, headers = self.parse_output()
self.handle_rate_limit(headers, url)
if general['status'] == '503':
# GitHub and BitBucket seem to rate limit via 503
print ('%s: Downloading %s was rate limited' +
', trying again') % (__name__, url)
continue
error_string = 'HTTP error %s %s' % (general['status'],
general['message'])
except (NonHttpError) as (e):
error_string = unicode_from_os(e)
# GitHub and BitBucket seem to time out a lot
if error_string.find('timed out') != -1:
print ('%s: Downloading %s timed out, ' +
'trying again') % (__name__, url)
continue
print (u'%s: %s %s downloading %s.' % (__name__, error_message,
error_string, url)).encode('UTF-8')
break
return False
def parse_output(self):
with open(self.tmp_file, 'r') as f:
output = f.read().splitlines()
self.clean_tmp_file()
error = None
header_lines = []
if self.debug:
section = 'General'
last_section = None
for line in output:
if section == 'General':
if self.skippable_line(line):
continue
# Skip blank lines
if line.strip() == '':
continue
# Error lines
if line[0:5] == 'wget:':
error = line[5:].strip()
if line[0:7] == 'failed:':
error = line[7:].strip()
if line == '---request begin---':
section = 'Write'
continue
elif line == '---request end---':
section = 'General'
continue
elif line == '---response begin---':
section = 'Read'
continue
elif line == '---response end---':
section = 'General'
continue
if section != last_section:
print "%s: Wget HTTP Debug %s" % (__name__, section)
if section == 'Read':
header_lines.append(line)
print ' ' + line
last_section = section
else:
for line in output:
if self.skippable_line(line):
continue
# Check the resolving and connecting to lines for errors
if re.match('(Resolving |Connecting to )', line):
failed_match = re.search(' failed: (.*)$', line)
if failed_match:
error = failed_match.group(1).strip()
# Error lines
if line[0:5] == 'wget:':
error = line[5:].strip()
if line[0:7] == 'failed:':
error = line[7:].strip()
if line[0:2] == ' ':
header_lines.append(line.lstrip())
if error:
raise NonHttpError(error)
return self.parse_headers(header_lines)
def skippable_line(self, line):
# Skip date lines
if re.match('--\d{4}-\d{2}-\d{2}', line):
return True
if re.match('\d{4}-\d{2}-\d{2}', line):
return True
# Skip HTTP status code lines since we already have that info
if re.match('\d{3} ', line):
return True
# Skip Saving to and progress lines
if re.match('(Saving to:|\s*\d+K)', line):
return True
# Skip notice about ignoring body on HTTP error
if re.match('Skipping \d+ byte', line):
return True
def parse_headers(self, output=None):
if not output:
with open(self.tmp_file, 'r') as f:
output = f.read().splitlines()
self.clean_tmp_file()
general = {
'version': '0.9',
'status': '200',
'message': 'OK'
}
headers = {}
for line in output:
# When using the -S option, headers have two spaces before them,
# additionally, valid headers won't have spaces, so this is always
# a safe operation to perform
line = line.lstrip()
if line.find('HTTP/') == 0:
match = re.match('HTTP/(\d\.\d)\s+(\d+)\s+(.*)$', line)
general['version'] = match.group(1)
general['status'] = match.group(2)
general['message'] = match.group(3)
else:
name, value = line.split(':', 1)
headers[name.lower()] = value.strip()
return (general, headers)
def handle_rate_limit(self, headers, url):
limit_remaining = headers.get('x-ratelimit-remaining', '1')
limit = headers.get('x-ratelimit-limit', '1')
if str(limit_remaining) == '0':
hostname = urlparse.urlparse(url).hostname
raise RateLimitException(hostname, limit)
class CurlDownloader(CliDownloader):
"""
A downloader that uses the command line program curl
:param settings:
A dict of the various Package Control settings. The Sublime Text
Settings API is not used because this code is run in a thread.
"""
def __init__(self, settings):
self.settings = settings
self.curl = self.find_binary('curl')
def download(self, url, error_message, timeout, tries):
"""
Downloads a URL and returns the contents
:param url:
The URL to download
:param error_message:
A string to include in the console error that is printed
when an error occurs
:param timeout:
The int number of seconds to set the timeout to
:param tries:
The int number of times to try and download the URL in the case of
a timeout or HTTP 503 error
:return:
The string contents of the URL, or False on error
"""
if not self.curl:
return False
self.tmp_file = tempfile.NamedTemporaryFile().name
command = [self.curl, '--user-agent', self.settings.get('user_agent'),
'--connect-timeout', str(int(timeout)), '-sSL',
# Don't be alarmed if the response from the server does not select
# one of these since the server runs a relatively new version of
# OpenSSL which supports compression on the SSL layer, and Apache
# will use that instead of HTTP-level encoding.
'--compressed',
# We have to capture the headers to check for rate limit info
'--dump-header', self.tmp_file]
secure_url_match = re.match('^https://([^/]+)', url)
if secure_url_match != None:
secure_domain = secure_url_match.group(1)
bundle_path = self.check_certs(secure_domain, timeout)
if not bundle_path:
return False
command.extend(['--cacert', bundle_path])
debug = self.settings.get('debug')
if debug:
command.append('-v')
http_proxy = self.settings.get('http_proxy')
https_proxy = self.settings.get('https_proxy')
proxy_username = self.settings.get('proxy_username')
proxy_password = self.settings.get('proxy_password')
if debug:
print u"%s: Curl Debug Proxy" % __name__
print u" http_proxy: %s" % http_proxy
print u" https_proxy: %s" % https_proxy
print u" proxy_username: %s" % proxy_username
print u" proxy_password: %s" % proxy_password
if http_proxy or https_proxy:
command.append('--proxy-anyauth')
if proxy_username or proxy_password:
command.extend(['-U', u"%s:%s" % (proxy_username, proxy_password)])
if http_proxy:
os.putenv('http_proxy', http_proxy)
if https_proxy:
os.putenv('HTTPS_PROXY', https_proxy)
command.append(url)
while tries > 0:
tries -= 1
try:
output = self.execute(command)
with open(self.tmp_file, 'r') as f:
headers = f.read()
self.clean_tmp_file()
limit = 1
limit_remaining = 1
status = '200 OK'
for header in headers.splitlines():
if header[0:5] == 'HTTP/':
status = re.sub('^HTTP/\d\.\d\s+', '', header)
if header.lower()[0:22] == 'x-ratelimit-remaining:':
limit_remaining = header.lower()[22:].strip()
if header.lower()[0:18] == 'x-ratelimit-limit:':
limit = header.lower()[18:].strip()
if debug:
self.print_debug(self.stderr)
if str(limit_remaining) == '0':
hostname = urlparse.urlparse(url).hostname
raise RateLimitException(hostname, limit)
if status != '200 OK':
e = NonCleanExitError(22)
e.stderr = status
raise e
return output
except (NonCleanExitError) as (e):
# Stderr is used for both the error message and the debug info
# so we need to process it to extra the debug info
if self.settings.get('debug'):
e.stderr = self.print_debug(e.stderr)
self.clean_tmp_file()
if e.returncode == 22:
code = re.sub('^.*?(\d+)([\w\s]+)?$', '\\1', e.stderr)
if code == '503':
# GitHub and BitBucket seem to rate limit via 503
print ('%s: Downloading %s was rate limited' +
', trying again') % (__name__, url)
continue
error_string = 'HTTP error ' + code
elif e.returncode == 6:
error_string = 'URL error host not found'
elif e.returncode == 28:
# GitHub and BitBucket seem to time out a lot
print ('%s: Downloading %s timed out, trying ' +
'again') % (__name__, url)
continue
else:
error_string = e.stderr.rstrip()
print '%s: %s %s downloading %s.' % (__name__, error_message,
error_string, url)
break
return False
def print_debug(self, string):
section = 'General'
last_section = None
output = ''
for line in string.splitlines():
# Placeholder for body of request
if line and line[0:2] == '{ ':
continue
if len(line) > 1:
subtract = 0
if line[0:2] == '* ':
section = 'General'
subtract = 2
elif line[0:2] == '> ':
section = 'Write'
subtract = 2
elif line[0:2] == '< ':
section = 'Read'
subtract = 2
line = line[subtract:]
# If the line does not start with "* ", "< ", "> " or " "
# then it is a real stderr message
if subtract == 0 and line[0:2] != ' ':
output += line
continue
if line.strip() == '':
continue
if section != last_section:
print "%s: Curl HTTP Debug %s" % (__name__, section)
print ' ' + line
last_section = section
return output
# A cache of channel and repository info to allow users to install multiple
# packages without having to wait for the metadata to be downloaded more
# than once. The keys are managed locally by the utilizing code.
_channel_repository_cache = {}
class RepositoryDownloader(threading.Thread):
"""
Downloads information about a repository in the background
:param package_manager:
An instance of :class:`PackageManager` used to download files
:param name_map:
The dict of name mapping for URL slug -> package name
:param repo:
The URL of the repository to download info about
"""
def __init__(self, package_manager, name_map, repo):
self.package_manager = package_manager
self.repo = repo
self.packages = {}
self.name_map = name_map
threading.Thread.__init__(self)
def run(self):
for provider_class in _package_providers:
provider = provider_class(self.repo, self.package_manager)
if provider.match_url():
break
packages = provider.get_packages()
if packages == False:
self.packages = False
return
mapped_packages = {}
for package in packages.keys():
mapped_package = self.name_map.get(package, package)
mapped_packages[mapped_package] = packages[package]
mapped_packages[mapped_package]['name'] = mapped_package
packages = mapped_packages
self.packages = packages
self.renamed_packages = provider.get_renamed_packages()
self.unavailable_packages = provider.get_unavailable_packages()
class VcsUpgrader():
"""
Base class for updating packages that are a version control repository on local disk
:param vcs_binary:
The full filesystem path to the executable for the version control
system. May be set to None to allow the code to try and find it.
:param update_command:
The command to pass to the version control executable to update the
repository.
:param working_copy:
The local path to the working copy/package directory
:param cache_length:
The lenth of time to cache if incoming changesets are available
"""
def __init__(self, vcs_binary, update_command, working_copy, cache_length, debug):
self.binary = vcs_binary
self.update_command = update_command
self.working_copy = working_copy
self.cache_length = cache_length
self.debug = debug
def execute(self, args, dir):
"""
Creates a subprocess with the executable/args
:param args:
A list of the executable path and all arguments to it
:param dir:
The directory in which to run the executable
:return: A string of the executable output
"""
startupinfo = None
if os.name == 'nt':
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
if self.debug:
print u"%s: Trying to execute command %s" % (
__name__, create_cmd(args))
proc = subprocess.Popen(args, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
startupinfo=startupinfo, cwd=dir)
return proc.stdout.read().replace('\r\n', '\n').rstrip(' \n\r')
def find_binary(self, name):
"""
Locates the executable by looking in the PATH and well-known directories
:param name:
The string filename of the executable
:return: The filesystem path to the executable, or None if not found
"""
if self.binary:
if self.debug:
print u"%s: Using \"%s_binary\" from settings \"%s\"" % (
__name__, self.vcs_type, self.binary)
return self.binary
# Try the path first
for dir in os.environ['PATH'].split(os.pathsep):
path = os.path.join(dir, name)
if os.path.exists(path):
if self.debug:
print u"%s: Found %s at \"%s\"" % (__name__, self.vcs_type,
path)
return path
# This is left in for backwards compatibility and for windows
# users who may have the binary, albeit in a common dir that may
# not be part of the PATH
if os.name == 'nt':
dirs = ['C:\\Program Files\\Git\\bin',
'C:\\Program Files (x86)\\Git\\bin',
'C:\\Program Files\\TortoiseGit\\bin',
'C:\\Program Files\\Mercurial',
'C:\\Program Files (x86)\\Mercurial',
'C:\\Program Files (x86)\\TortoiseHg',
'C:\\Program Files\\TortoiseHg',
'C:\\cygwin\\bin']
else:
dirs = ['/usr/local/git/bin']
for dir in dirs:
path = os.path.join(dir, name)
if os.path.exists(path):
if self.debug:
print u"%s: Found %s at \"%s\"" % (__name__, self.vcs_type,
path)
return path
if self.debug:
print u"%s: Could not find %s on your machine" % (__name__,
self.vcs_type)
return None
class GitUpgrader(VcsUpgrader):
"""
Allows upgrading a local git-repository-based package
"""
vcs_type = 'git'
def retrieve_binary(self):
"""
Returns the path to the git executable
:return: The string path to the executable or False on error
"""
name = 'git'
if os.name == 'nt':
name += '.exe'
binary = self.find_binary(name)
if binary and os.path.isdir(binary):
full_path = os.path.join(binary, name)
if os.path.exists(full_path):
binary = full_path
if not binary:
sublime.error_message(('%s: Unable to find %s. ' +
'Please set the git_binary setting by accessing the ' +
'Preferences > Package Settings > %s > ' +
u'Settings User menu entry. The Settings Default entry ' +
'can be used for reference, but changes to that will be ' +
'overwritten upon next upgrade.') % (__name__, name, __name__))
return False
if os.name == 'nt':
tortoise_plink = self.find_binary('TortoisePlink.exe')
if tortoise_plink:
os.environ.setdefault('GIT_SSH', tortoise_plink)
return binary
def run(self):
"""
Updates the repository with remote changes
:return: False or error, or True on success
"""
binary = self.retrieve_binary()
if not binary:
return False
args = [binary]
args.extend(self.update_command)
self.execute(args, self.working_copy)
return True
def incoming(self):
""":return: bool if remote revisions are available"""
cache_key = self.working_copy + '.incoming'
working_copy_cache = _channel_repository_cache.get(cache_key)
if working_copy_cache and working_copy_cache.get('time') > \
time.time():
return working_copy_cache.get('data')
binary = self.retrieve_binary()
if not binary:
return False
self.execute([binary, 'fetch'], self.working_copy)
args = [binary, 'log']
args.append('..' + '/'.join(self.update_command[-2:]))
output = self.execute(args, self.working_copy)
incoming = len(output) > 0
_channel_repository_cache[cache_key] = {
'time': time.time() + self.cache_length,
'data': incoming
}
return incoming
class HgUpgrader(VcsUpgrader):
"""
Allows upgrading a local mercurial-repository-based package
"""
vcs_type = 'hg'
def retrieve_binary(self):
"""
Returns the path to the hg executable
:return: The string path to the executable or False on error
"""
name = 'hg'
if os.name == 'nt':
name += '.exe'
binary = self.find_binary(name)
if binary and os.path.isdir(binary):
full_path = os.path.join(binary, name)
if os.path.exists(full_path):
binary = full_path
if not binary:
sublime.error_message(('%s: Unable to find %s. ' +
'Please set the hg_binary setting by accessing the ' +
'Preferences > Package Settings > %s > ' +
u'Settings User menu entry. The Settings Default entry ' +
'can be used for reference, but changes to that will be ' +
'overwritten upon next upgrade.') % (__name__, name, __name__))
return False
return binary
def run(self):
"""
Updates the repository with remote changes
:return: False or error, or True on success
"""
binary = self.retrieve_binary()
if not binary:
return False
args = [binary]
args.extend(self.update_command)
self.execute(args, self.working_copy)
return True
def incoming(self):
""":return: bool if remote revisions are available"""
cache_key = self.working_copy + '.incoming'
working_copy_cache = _channel_repository_cache.get(cache_key)
if working_copy_cache and working_copy_cache.get('time') > \
time.time():
return working_copy_cache.get('data')
binary = self.retrieve_binary()
if not binary:
return False
args = [binary, 'in', '-q']
args.append(self.update_command[-1])
output = self.execute(args, self.working_copy)
incoming = len(output) > 0
_channel_repository_cache[cache_key] = {
'time': time.time() + self.cache_length,
'data': incoming
}
return incoming
def clear_directory(directory, ignore_paths=None):
was_exception = False
for root, dirs, files in os.walk(directory, topdown=False):
paths = [os.path.join(root, f) for f in files]
paths.extend([os.path.join(root, d) for d in dirs])
for path in paths:
try:
# Don't delete the metadata file, that way we have it
# when the reinstall happens, and the appropriate
# usage info can be sent back to the server
if ignore_paths and path in ignore_paths:
continue
if os.path.isdir(path):
os.rmdir(path)
else:
os.remove(path)
except (OSError, IOError) as (e):
was_exception = True
return not was_exception
class PackageManager():
"""
Allows downloading, creating, installing, upgrading, and deleting packages
Delegates metadata retrieval to the _channel_providers and
_package_providers classes. Uses VcsUpgrader-based classes for handling
git and hg repositories in the Packages folder. Downloader classes are
utilized to fetch contents of URLs.
Also handles displaying package messaging, and sending usage information to
the usage server.
"""
def __init__(self):
# Here we manually copy the settings since sublime doesn't like
# code accessing settings from threads
self.settings = {}
settings = sublime.load_settings(__name__ + '.sublime-settings')
for setting in ['timeout', 'repositories', 'repository_channels',
'package_name_map', 'dirs_to_ignore', 'files_to_ignore',
'package_destination', 'cache_length', 'auto_upgrade',
'files_to_ignore_binary', 'files_to_keep', 'dirs_to_keep',
'git_binary', 'git_update_command', 'hg_binary',
'hg_update_command', 'http_proxy', 'https_proxy',
'auto_upgrade_ignore', 'auto_upgrade_frequency',
'submit_usage', 'submit_url', 'renamed_packages',
'files_to_include', 'files_to_include_binary', 'certs',
'ignore_vcs_packages', 'proxy_username', 'proxy_password',
'debug', 'user_agent']:
if settings.get(setting) == None:
continue
self.settings[setting] = settings.get(setting)
# https_proxy will inherit from http_proxy unless it is set to a
# string value or false
no_https_proxy = self.settings.get('https_proxy') in ["", None]
if no_https_proxy and self.settings.get('http_proxy'):
self.settings['https_proxy'] = self.settings.get('http_proxy')
if self.settings['https_proxy'] == False:
self.settings['https_proxy'] = ''
self.settings['platform'] = sublime.platform()
self.settings['version'] = sublime.version()
def compare_versions(self, version1, version2):
"""
Compares to version strings to see which is greater
Date-based version numbers (used by GitHub and BitBucket providers)
are automatically pre-pended with a 0 so they are always less than
version 1.0.
:return:
-1 if version1 is less than version2
0 if they are equal
1 if version1 is greater than version2
"""
def date_compat(v):
# We prepend 0 to all date-based version numbers so that developers
# may switch to explicit versioning from GitHub/BitBucket
# versioning based on commit dates
date_match = re.match('(\d{4})\.(\d{2})\.(\d{2})\.(\d{2})\.(\d{2})\.(\d{2})$', v)
if date_match:
v = '0.%s.%s.%s.%s.%s.%s' % date_match.groups()
return v
def semver_compat(v):
# When translating dates into semver, the way to get each date
# segment into the version is to treat the year and month as
# minor and patch, and then the rest as a numeric build version
# with four different parts. The result looks like:
# 0.2012.11+10.31.23.59
date_match = re.match('(\d{4}(?:\.\d{2}){2})\.(\d{2}(?:\.\d{2}){3})$', v)
if date_match:
v = '%s+%s' % (date_match.group(1), date_match.group(2))
# Semver must have major, minor, patch
elif re.match('^\d+$', v):
v += '.0.0'
elif re.match('^\d+\.\d+$', v):
v += '.0'
return v
def cmp_compat(v):
return [int(x) for x in re.sub(r'(\.0+)*$', '', v).split(".")]
version1 = date_compat(version1)
version2 = date_compat(version2)
try:
return semver.compare(semver_compat(version1), semver_compat(version2))
except (ValueError):
return cmp(cmp_compat(version1), cmp_compat(version2))
def download_url(self, url, error_message):
"""
Downloads a URL and returns the contents
:param url:
The string URL to download
:param error_message:
The error message to include if the download fails
:return:
The string contents of the URL, or False on error
"""
has_ssl = 'ssl' in sys.modules and hasattr(urllib2, 'HTTPSHandler')
is_ssl = re.search('^https://', url) != None
downloader = None
if (is_ssl and has_ssl) or not is_ssl:
downloader = UrlLib2Downloader(self.settings)
else:
for downloader_class in [CurlDownloader, WgetDownloader]:
try:
downloader = downloader_class(self.settings)
break
except (BinaryNotFoundError):
pass
if not downloader:
sublime.error_message(('%s: Unable to download %s due to no ' +
'ssl module available and no capable program found. Please ' +
'install curl or wget.') % (__name__, url))
return False
url = url.replace(' ', '%20')
hostname = urlparse.urlparse(url).hostname.lower()
timeout = self.settings.get('timeout', 3)
rate_limited_cache = _channel_repository_cache.get('rate_limited_domains', {})
if rate_limited_cache.get('time') and rate_limited_cache.get('time') > time.time():
rate_limited_domains = rate_limited_cache.get('data', [])
else:
rate_limited_domains = []
if self.settings.get('debug'):
try:
ip = socket.gethostbyname(hostname)
except (socket.gaierror) as (e):
ip = unicode_from_os(e)
print u"%s: Download Debug" % __name__
print u" URL: %s" % url
print u" Resolved IP: %s" % ip
print u" Timeout: %s" % str(timeout)
if hostname in rate_limited_domains:
if self.settings.get('debug'):
print u" Skipping due to hitting rate limit for %s" % hostname
return False
try:
return downloader.download(url, error_message, timeout, 3)
except (RateLimitException) as (e):
rate_limited_domains.append(hostname)
_channel_repository_cache['rate_limited_domains'] = {
'data': rate_limited_domains,
'time': time.time() + self.settings.get('cache_length',
300)
}
print ('%s: Hit rate limit of %s for %s, skipping all futher ' +
'download requests for this domain') % (__name__,
e.limit, e.host)
return False
def get_metadata(self, package):
"""
Returns the package metadata for an installed package
:return:
A dict with the keys:
version
url
description
or an empty dict on error
"""
metadata_filename = os.path.join(self.get_package_dir(package),
'package-metadata.json')
if os.path.exists(metadata_filename):
with open(metadata_filename) as f:
try:
return json.load(f)
except (ValueError):
return {}
return {}
def list_repositories(self):
"""
Returns a master list of all repositories pulled from all sources
These repositories come from the channels specified in the
"repository_channels" setting, plus any repositories listed in the
"repositories" setting.
:return:
A list of all available repositories
"""
repositories = self.settings.get('repositories')
repository_channels = self.settings.get('repository_channels')
for channel in repository_channels:
channel = channel.strip()
channel_repositories = None
# Caches various info from channels for performance
cache_key = channel + '.repositories'
repositories_cache = _channel_repository_cache.get(cache_key)
if repositories_cache and repositories_cache.get('time') > \
time.time():
channel_repositories = repositories_cache.get('data')
name_map_cache_key = channel + '.package_name_map'
name_map_cache = _channel_repository_cache.get(
name_map_cache_key)
if name_map_cache and name_map_cache.get('time') > \
time.time():
name_map = name_map_cache.get('data')
name_map.update(self.settings.get('package_name_map', {}))
self.settings['package_name_map'] = name_map
renamed_cache_key = channel + '.renamed_packages'
renamed_cache = _channel_repository_cache.get(
renamed_cache_key)
if renamed_cache and renamed_cache.get('time') > \
time.time():
renamed_packages = renamed_cache.get('data')
renamed_packages.update(self.settings.get('renamed_packages',
{}))
self.settings['renamed_packages'] = renamed_packages
unavailable_cache_key = channel + '.unavailable_packages'
unavailable_cache = _channel_repository_cache.get(
unavailable_cache_key)
if unavailable_cache and unavailable_cache.get('time') > \
time.time():
unavailable_packages = unavailable_cache.get('data')
unavailable_packages.extend(self.settings.get('unavailable_packages',
[]))
self.settings['unavailable_packages'] = unavailable_packages
certs_cache_key = channel + '.certs'
certs_cache = _channel_repository_cache.get(certs_cache_key)
if certs_cache and certs_cache.get('time') > time.time():
certs = self.settings.get('certs', {})
certs.update(certs_cache.get('data'))
self.settings['certs'] = certs
# If any of the info was not retrieved from the cache, we need to
# grab the channel to get it
if channel_repositories == None or \
self.settings.get('package_name_map') == None or \
self.settings.get('renamed_packages') == None:
for provider_class in _channel_providers:
provider = provider_class(channel, self)
if provider.match_url():
break
channel_repositories = provider.get_repositories()
if channel_repositories == False:
continue
_channel_repository_cache[cache_key] = {
'time': time.time() + self.settings.get('cache_length',
300),
'data': channel_repositories
}
for repo in channel_repositories:
if provider.get_packages(repo) == False:
continue
packages_cache_key = repo + '.packages'
_channel_repository_cache[packages_cache_key] = {
'time': time.time() + self.settings.get('cache_length',
300),
'data': provider.get_packages(repo)
}
# Have the local name map override the one from the channel
name_map = provider.get_name_map()
name_map.update(self.settings.get('package_name_map', {}))
self.settings['package_name_map'] = name_map
_channel_repository_cache[name_map_cache_key] = {
'time': time.time() + self.settings.get('cache_length',
300),
'data': name_map
}
renamed_packages = provider.get_renamed_packages()
_channel_repository_cache[renamed_cache_key] = {
'time': time.time() + self.settings.get('cache_length',
300),
'data': renamed_packages
}
if renamed_packages:
self.settings['renamed_packages'] = self.settings.get(
'renamed_packages', {})
self.settings['renamed_packages'].update(renamed_packages)
unavailable_packages = provider.get_unavailable_packages()
_channel_repository_cache[unavailable_cache_key] = {
'time': time.time() + self.settings.get('cache_length',
300),
'data': unavailable_packages
}
if unavailable_packages:
self.settings['unavailable_packages'] = self.settings.get(
'unavailable_packages', [])
self.settings['unavailable_packages'].extend(unavailable_packages)
certs = provider.get_certs()
_channel_repository_cache[certs_cache_key] = {
'time': time.time() + self.settings.get('cache_length',
300),
'data': certs
}
if certs:
self.settings['certs'] = self.settings.get('certs', {})
self.settings['certs'].update(certs)
repositories.extend(channel_repositories)
return [repo.strip() for repo in repositories]
def list_available_packages(self):
"""
Returns a master list of every available package from all sources
:return:
A dict in the format:
{
'Package Name': {
# Package details - see example-packages.json for format
},
...
}
"""
repositories = self.list_repositories()
packages = {}
downloaders = []
grouped_downloaders = {}
# Repositories are run in reverse order so that the ones first
# on the list will overwrite those last on the list
for repo in repositories[::-1]:
repository_packages = None
cache_key = repo + '.packages'
packages_cache = _channel_repository_cache.get(cache_key)
if packages_cache and packages_cache.get('time') > \
time.time():
repository_packages = packages_cache.get('data')
packages.update(repository_packages)
if repository_packages == None:
downloader = RepositoryDownloader(self,
self.settings.get('package_name_map', {}), repo)
domain = re.sub('^https?://[^/]*?(\w+\.\w+)($|/.*$)', '\\1',
repo)
# downloaders are grouped by domain so that multiple can
# be run in parallel without running into API access limits
if not grouped_downloaders.get(domain):
grouped_downloaders[domain] = []
grouped_downloaders[domain].append(downloader)
# Allows creating a separate named function for each downloader
# delay. Not having this contained in a function causes all of the
# schedules to reference the same downloader.start()
def schedule(downloader, delay):
downloader.has_started = False
def inner():
downloader.start()
downloader.has_started = True
sublime.set_timeout(inner, delay)
# Grabs every repo grouped by domain and delays the start
# of each download from that domain by a fixed amount
for domain_downloaders in grouped_downloaders.values():
for i in range(len(domain_downloaders)):
downloader = domain_downloaders[i]
downloaders.append(downloader)
schedule(downloader, i * 150)
complete = []
# Wait for all of the downloaders to finish
while downloaders:
downloader = downloaders.pop()
if downloader.has_started:
downloader.join()
complete.append(downloader)
else:
downloaders.insert(0, downloader)
# Grabs the results and stuff if all in the cache
for downloader in complete:
repository_packages = downloader.packages
if repository_packages == False:
continue
cache_key = downloader.repo + '.packages'
_channel_repository_cache[cache_key] = {
'time': time.time() + self.settings.get('cache_length', 300),
'data': repository_packages
}
packages.update(repository_packages)
renamed_packages = downloader.renamed_packages
if renamed_packages == False:
continue
renamed_cache_key = downloader.repo + '.renamed_packages'
_channel_repository_cache[renamed_cache_key] = {
'time': time.time() + self.settings.get('cache_length', 300),
'data': renamed_packages
}
if renamed_packages:
self.settings['renamed_packages'] = self.settings.get(
'renamed_packages', {})
self.settings['renamed_packages'].update(renamed_packages)
unavailable_packages = downloader.unavailable_packages
unavailable_cache_key = downloader.repo + '.unavailable_packages'
_channel_repository_cache[unavailable_cache_key] = {
'time': time.time() + self.settings.get('cache_length', 300),
'data': unavailable_packages
}
if unavailable_packages:
self.settings['unavailable_packages'] = self.settings.get(
'unavailable_packages', [])
self.settings['unavailable_packages'].extend(unavailable_packages)
return packages
def list_packages(self):
""" :return: A list of all installed, non-default, package names"""
package_names = os.listdir(sublime.packages_path())
package_names = [path for path in package_names if
os.path.isdir(os.path.join(sublime.packages_path(), path))]
# Ignore things to be deleted
ignored = []
for package in package_names:
cleanup_file = os.path.join(sublime.packages_path(), package,
'package-control.cleanup')
if os.path.exists(cleanup_file):
ignored.append(package)
packages = list(set(package_names) - set(ignored) -
set(self.list_default_packages()))
packages = sorted(packages, key=lambda s: s.lower())
return packages
def list_all_packages(self):
""" :return: A list of all installed package names, including default packages"""
packages = os.listdir(sublime.packages_path())
packages = sorted(packages, key=lambda s: s.lower())
return packages
def list_default_packages(self):
""" :return: A list of all default package names"""
files = os.listdir(os.path.join(os.path.dirname(
sublime.packages_path()), 'Pristine Packages'))
files = list(set(files) - set(os.listdir(
sublime.installed_packages_path())))
packages = [file.replace('.sublime-package', '') for file in files]
packages = sorted(packages, key=lambda s: s.lower())
return packages
def get_package_dir(self, package):
""":return: The full filesystem path to the package directory"""
return os.path.join(sublime.packages_path(), package)
def get_mapped_name(self, package):
""":return: The name of the package after passing through mapping rules"""
return self.settings.get('package_name_map', {}).get(package, package)
def create_package(self, package_name, package_destination,
binary_package=False):
"""
Creates a .sublime-package file from the running Packages directory
:param package_name:
The package to create a .sublime-package file for
:param package_destination:
The full filesystem path of the directory to save the new
.sublime-package file in.
:param binary_package:
If the created package should follow the binary package include/
exclude patterns from the settings. These normally include a setup
to exclude .py files and include .pyc files, but that can be
changed via settings.
:return: bool if the package file was successfully created
"""
package_dir = self.get_package_dir(package_name) + '/'
if not os.path.exists(package_dir):
sublime.error_message(('%s: The folder for the package name ' +
'specified, %s, does not exist in %s') %
(__name__, package_name, sublime.packages_path()))
return False
package_filename = package_name + '.sublime-package'
package_path = os.path.join(package_destination,
package_filename)
if not os.path.exists(sublime.installed_packages_path()):
os.mkdir(sublime.installed_packages_path())
if os.path.exists(package_path):
os.remove(package_path)
try:
package_file = zipfile.ZipFile(package_path, "w",
compression=zipfile.ZIP_DEFLATED)
except (OSError, IOError) as (exception):
sublime.error_message(('%s: An error occurred creating the ' +
'package file %s in %s. %s') % (__name__, package_filename,
package_destination, unicode_from_os(exception)))
return False
dirs_to_ignore = self.settings.get('dirs_to_ignore', [])
if not binary_package:
files_to_ignore = self.settings.get('files_to_ignore', [])
files_to_include = self.settings.get('files_to_include', [])
else:
files_to_ignore = self.settings.get('files_to_ignore_binary', [])
files_to_include = self.settings.get('files_to_include_binary', [])
package_dir_regex = re.compile('^' + re.escape(package_dir))
for root, dirs, files in os.walk(package_dir):
[dirs.remove(dir) for dir in dirs if dir in dirs_to_ignore]
paths = dirs
paths.extend(files)
for path in paths:
full_path = os.path.join(root, path)
relative_path = re.sub(package_dir_regex, '', full_path)
ignore_matches = [fnmatch(relative_path, p) for p in files_to_ignore]
include_matches = [fnmatch(relative_path, p) for p in files_to_include]
if any(ignore_matches) and not any(include_matches):
continue
if os.path.isdir(full_path):
continue
package_file.write(full_path, relative_path)
package_file.close()
return True
def install_package(self, package_name):
"""
Downloads and installs (or upgrades) a package
Uses the self.list_available_packages() method to determine where to
retrieve the package file from.
The install process consists of:
1. Finding the package
2. Downloading the .sublime-package/.zip file
3. Extracting the package file
4. Showing install/upgrade messaging
5. Submitting usage info
6. Recording that the package is installed
:param package_name:
The package to download and install
:return: bool if the package was successfully installed
"""
packages = self.list_available_packages()
if package_name in self.settings.get('unavailable_packages', []):
print ('%s: The package "%s" is not available ' +
'on this platform.') % (__name__, package_name)
return False
if package_name not in packages.keys():
sublime.error_message(('%s: The package specified, %s, is ' +
'not available.') % (__name__, package_name))
return False
download = packages[package_name]['downloads'][0]
url = download['url']
package_filename = package_name + \
'.sublime-package'
package_path = os.path.join(sublime.installed_packages_path(),
package_filename)
pristine_package_path = os.path.join(os.path.dirname(
sublime.packages_path()), 'Pristine Packages', package_filename)
package_dir = self.get_package_dir(package_name)
package_metadata_file = os.path.join(package_dir,
'package-metadata.json')
if os.path.exists(os.path.join(package_dir, '.git')):
if self.settings.get('ignore_vcs_packages'):
sublime.error_message(('%s: Skipping git package %s since ' +
'the setting ignore_vcs_packages is set to true') %
(__name__, package_name))
return False
return GitUpgrader(self.settings['git_binary'],
self.settings['git_update_command'], package_dir,
self.settings['cache_length'], self.settings['debug']).run()
elif os.path.exists(os.path.join(package_dir, '.hg')):
if self.settings.get('ignore_vcs_packages'):
sublime.error_message(('%s: Skipping hg package %s since ' +
'the setting ignore_vcs_packages is set to true') %
(__name__, package_name))
return False
return HgUpgrader(self.settings['hg_binary'],
self.settings['hg_update_command'], package_dir,
self.settings['cache_length'], self.settings['debug']).run()
is_upgrade = os.path.exists(package_metadata_file)
old_version = None
if is_upgrade:
old_version = self.get_metadata(package_name).get('version')
package_bytes = self.download_url(url, 'Error downloading package.')
if package_bytes == False:
return False
with open(package_path, "wb") as package_file:
package_file.write(package_bytes)
if not os.path.exists(package_dir):
os.mkdir(package_dir)
# We create a backup copy incase something was edited
else:
try:
backup_dir = os.path.join(os.path.dirname(
sublime.packages_path()), 'Backup',
datetime.datetime.now().strftime('%Y%m%d%H%M%S'))
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
package_backup_dir = os.path.join(backup_dir, package_name)
shutil.copytree(package_dir, package_backup_dir)
except (OSError, IOError) as (exception):
sublime.error_message(('%s: An error occurred while trying ' +
'to backup the package directory for %s. %s') %
(__name__, package_name, unicode_from_os(exception)))
shutil.rmtree(package_backup_dir)
return False
try:
package_zip = zipfile.ZipFile(package_path, 'r')
except (zipfile.BadZipfile):
sublime.error_message(('%s: An error occurred while ' +
'trying to unzip the package file for %s. Please try ' +
'installing the package again.') % (__name__, package_name))
return False
root_level_paths = []
last_path = None
for path in package_zip.namelist():
last_path = path
if path.find('/') in [len(path) - 1, -1]:
root_level_paths.append(path)
if path[0] == '/' or path.find('../') != -1 or path.find('..\\') != -1:
sublime.error_message(('%s: The package specified, %s, ' +
'contains files outside of the package dir and cannot ' +
'be safely installed.') % (__name__, package_name))
return False
if last_path and len(root_level_paths) == 0:
root_level_paths.append(last_path[0:last_path.find('/') + 1])
os.chdir(package_dir)
overwrite_failed = False
# Here we dont use .extractall() since it was having issues on OS X
skip_root_dir = len(root_level_paths) == 1 and \
root_level_paths[0].endswith('/')
extracted_paths = []
for path in package_zip.namelist():
dest = path
try:
if not isinstance(dest, unicode):
dest = unicode(dest, 'utf-8', 'strict')
except (UnicodeDecodeError):
dest = unicode(dest, 'cp1252', 'replace')
if os.name == 'nt':
regex = ':|\*|\?|"|<|>|\|'
if re.search(regex, dest) != None:
print ('%s: Skipping file from package named %s due to ' +
'an invalid filename') % (__name__, path)
continue
# If there was only a single directory in the package, we remove
# that folder name from the paths as we extract entries
if skip_root_dir:
dest = dest[len(root_level_paths[0]):]
if os.name == 'nt':
dest = dest.replace('/', '\\')
else:
dest = dest.replace('\\', '/')
dest = os.path.join(package_dir, dest)
def add_extracted_dirs(dir):
while dir not in extracted_paths:
extracted_paths.append(dir)
dir = os.path.dirname(dir)
if dir == package_dir:
break
if path.endswith('/'):
if not os.path.exists(dest):
os.makedirs(dest)
add_extracted_dirs(dest)
else:
dest_dir = os.path.dirname(dest)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir)
add_extracted_dirs(dest_dir)
extracted_paths.append(dest)
try:
open(dest, 'wb').write(package_zip.read(path))
except (IOError) as (e):
message = unicode_from_os(e)
if re.search('[Ee]rrno 13', message):
overwrite_failed = True
break
print ('%s: Skipping file from package named %s due to ' +
'an invalid filename') % (__name__, path)
except (UnicodeDecodeError):
print ('%s: Skipping file from package named %s due to ' +
'an invalid filename') % (__name__, path)
package_zip.close()
# If upgrading failed, queue the package to upgrade upon next start
if overwrite_failed:
reinstall_file = os.path.join(package_dir, 'package-control.reinstall')
open(reinstall_file, 'w').close()
# Don't delete the metadata file, that way we have it
# when the reinstall happens, and the appropriate
# usage info can be sent back to the server
clear_directory(package_dir, [reinstall_file, package_metadata_file])
sublime.error_message(('%s: An error occurred while trying to ' +
'upgrade %s. Please restart Sublime Text to finish the ' +
'upgrade.') % (__name__, package_name))
return False
# Here we clean out any files that were not just overwritten. It is ok
# if there is an error removing a file. The next time there is an
# upgrade, it should be cleaned out successfully then.
clear_directory(package_dir, extracted_paths)
self.print_messages(package_name, package_dir, is_upgrade, old_version)
with open(package_metadata_file, 'w') as f:
metadata = {
"version": packages[package_name]['downloads'][0]['version'],
"url": packages[package_name]['url'],
"description": packages[package_name]['description']
}
json.dump(metadata, f)
# Submit install and upgrade info
if is_upgrade:
params = {
'package': package_name,
'operation': 'upgrade',
'version': packages[package_name]['downloads'][0]['version'],
'old_version': old_version
}
else:
params = {
'package': package_name,
'operation': 'install',
'version': packages[package_name]['downloads'][0]['version']
}
self.record_usage(params)
# Record the install in the settings file so that you can move
# settings across computers and have the same packages installed
def save_package():
settings = sublime.load_settings(__name__ + '.sublime-settings')
installed_packages = settings.get('installed_packages', [])
if not installed_packages:
installed_packages = []
installed_packages.append(package_name)
installed_packages = list(set(installed_packages))
installed_packages = sorted(installed_packages,
key=lambda s: s.lower())
settings.set('installed_packages', installed_packages)
sublime.save_settings(__name__ + '.sublime-settings')
sublime.set_timeout(save_package, 1)
# Here we delete the package file from the installed packages directory
# since we don't want to accidentally overwrite user changes
os.remove(package_path)
# We have to remove the pristine package too or else Sublime Text 2
# will silently delete the package
if os.path.exists(pristine_package_path):
os.remove(pristine_package_path)
os.chdir(sublime.packages_path())
return True
def print_messages(self, package, package_dir, is_upgrade, old_version):
"""
Prints out package install and upgrade messages
The functionality provided by this allows package maintainers to
show messages to the user when a package is installed, or when
certain version upgrade occur.
:param package:
The name of the package the message is for
:param package_dir:
The full filesystem path to the package directory
:param is_upgrade:
If the install was actually an upgrade
:param old_version:
The string version of the package before the upgrade occurred
"""
messages_file = os.path.join(package_dir, 'messages.json')
if not os.path.exists(messages_file):
return
messages_fp = open(messages_file, 'r')
try:
message_info = json.load(messages_fp)
except (ValueError):
print '%s: Error parsing messages.json for %s' % (__name__, package)
return
messages_fp.close()
output = ''
if not is_upgrade and message_info.get('install'):
install_messages = os.path.join(package_dir,
message_info.get('install'))
message = '\n\n%s:\n%s\n\n ' % (package,
('-' * len(package)))
with open(install_messages, 'r') as f:
message += unicode(f.read(), 'utf-8', errors='replace').replace('\n', '\n ')
output += message + '\n'
elif is_upgrade and old_version:
upgrade_messages = list(set(message_info.keys()) -
set(['install']))
upgrade_messages = sorted(upgrade_messages,
cmp=self.compare_versions, reverse=True)
for version in upgrade_messages:
if self.compare_versions(old_version, version) >= 0:
break
if not output:
message = '\n\n%s:\n%s\n' % (package,
('-' * len(package)))
output += message
upgrade_messages = os.path.join(package_dir,
message_info.get(version))
message = '\n '
with open(upgrade_messages, 'r') as f:
message += unicode(f.read(), 'utf-8', errors='replace').replace('\n', '\n ')
output += message + '\n'
if not output:
return
def print_to_panel():
window = sublime.active_window()
views = window.views()
view = None
for _view in views:
if _view.name() == 'Package Control Messages':
view = _view
break
if not view:
view = window.new_file()
view.set_name('Package Control Messages')
view.set_scratch(True)
def write(string):
edit = view.begin_edit()
view.insert(edit, view.size(), string)
view.end_edit(edit)
if not view.size():
view.settings().set("word_wrap", True)
write('Package Control Messages\n' +
'========================')
write(output)
sublime.set_timeout(print_to_panel, 1)
def remove_package(self, package_name):
"""
Deletes a package
The deletion process consists of:
1. Deleting the directory (or marking it for deletion if deletion fails)
2. Submitting usage info
3. Removing the package from the list of installed packages
:param package_name:
The package to delete
:return: bool if the package was successfully deleted
"""
installed_packages = self.list_packages()
if package_name not in installed_packages:
sublime.error_message(('%s: The package specified, %s, is not ' +
'installed.') % (__name__, package_name))
return False
os.chdir(sublime.packages_path())
# Give Sublime Text some time to ignore the package
time.sleep(1)
package_filename = package_name + '.sublime-package'
package_path = os.path.join(sublime.installed_packages_path(),
package_filename)
installed_package_path = os.path.join(os.path.dirname(
sublime.packages_path()), 'Installed Packages', package_filename)
pristine_package_path = os.path.join(os.path.dirname(
sublime.packages_path()), 'Pristine Packages', package_filename)
package_dir = self.get_package_dir(package_name)
version = self.get_metadata(package_name).get('version')
try:
if os.path.exists(package_path):
os.remove(package_path)
except (OSError, IOError) as (exception):
sublime.error_message(('%s: An error occurred while trying to ' +
'remove the package file for %s. %s') % (__name__,
package_name, unicode_from_os(exception)))
return False
try:
if os.path.exists(installed_package_path):
os.remove(installed_package_path)
except (OSError, IOError) as (exception):
sublime.error_message(('%s: An error occurred while trying to ' +
'remove the installed package file for %s. %s') % (__name__,
package_name, unicode_from_os(exception)))
return False
try:
if os.path.exists(pristine_package_path):
os.remove(pristine_package_path)
except (OSError, IOError) as (exception):
sublime.error_message(('%s: An error occurred while trying to ' +
'remove the pristine package file for %s. %s') % (__name__,
package_name, unicode_from_os(exception)))
return False
# We don't delete the actual package dir immediately due to a bug
# in sublime_plugin.py
can_delete_dir = True
if not clear_directory(package_dir):
# If there is an error deleting now, we will mark it for
# cleanup the next time Sublime Text starts
open(os.path.join(package_dir, 'package-control.cleanup'),
'w').close()
can_delete_dir = False
params = {
'package': package_name,
'operation': 'remove',
'version': version
}
self.record_usage(params)
# Remove the package from the installed packages list
def clear_package():
settings = sublime.load_settings('%s.sublime-settings' % __name__)
installed_packages = settings.get('installed_packages', [])
if not installed_packages:
installed_packages = []
installed_packages.remove(package_name)
settings.set('installed_packages', installed_packages)
sublime.save_settings('%s.sublime-settings' % __name__)
sublime.set_timeout(clear_package, 1)
if can_delete_dir:
os.rmdir(package_dir)
return True
def record_usage(self, params):
"""
Submits install, upgrade and delete actions to a usage server
The usage information is currently displayed on the Package Control
community package list at http://wbond.net/sublime_packages/community
:param params:
A dict of the information to submit
"""
if not self.settings.get('submit_usage'):
return
params['package_control_version'] = \
self.get_metadata('Package Control').get('version')
params['sublime_platform'] = self.settings.get('platform')
params['sublime_version'] = self.settings.get('version')
url = self.settings.get('submit_url') + '?' + urllib.urlencode(params)
result = self.download_url(url, 'Error submitting usage information.')
if result == False:
return
try:
result = json.loads(result)
if result['result'] != 'success':
raise ValueError()
except (ValueError):
print '%s: Error submitting usage information for %s' % (__name__,
params['package'])
class PackageCreator():
"""
Abstract class for commands that create .sublime-package files
"""
def show_panel(self):
"""
Shows a list of packages that can be turned into a .sublime-package file
"""
self.manager = PackageManager()
self.packages = self.manager.list_packages()
if not self.packages:
sublime.error_message(('%s: There are no packages available to ' +
'be packaged.') % (__name__))
return
self.window.show_quick_panel(self.packages, self.on_done)
def get_package_destination(self):
"""
Retrieves the destination for .sublime-package files
:return:
A string - the path to the folder to save .sublime-package files in
"""
destination = self.manager.settings.get('package_destination')
# We check destination via an if statement instead of using
# the dict.get() method since the key may be set, but to a blank value
if not destination:
destination = os.path.join(os.path.expanduser('~'), 'Desktop')
return destination
class CreatePackageCommand(sublime_plugin.WindowCommand, PackageCreator):
"""
Command to create a regular .sublime-package file
"""
def run(self):
self.show_panel()
def on_done(self, picked):
"""
Quick panel user selection handler - processes the user package
selection and create the package file
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
package_name = self.packages[picked]
package_destination = self.get_package_destination()
if self.manager.create_package(package_name, package_destination):
self.window.run_command('open_dir', {"dir":
package_destination, "file": package_name +
'.sublime-package'})
class CreateBinaryPackageCommand(sublime_plugin.WindowCommand, PackageCreator):
"""
Command to create a binary .sublime-package file. Binary packages in
general exclude the .py source files and instead include the .pyc files.
Actual included and excluded files are controlled by settings.
"""
def run(self):
self.show_panel()
def on_done(self, picked):
"""
Quick panel user selection handler - processes the user package
selection and create the package file
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
package_name = self.packages[picked]
package_destination = self.get_package_destination()
if self.manager.create_package(package_name, package_destination,
binary_package=True):
self.window.run_command('open_dir', {"dir":
package_destination, "file": package_name +
'.sublime-package'})
class PackageRenamer():
"""
Class to handle renaming packages via the renamed_packages setting
gathered from channels and repositories.
"""
def load_settings(self):
"""
Loads the list of installed packages from the
Package Control.sublime-settings file.
"""
self.settings_file = '%s.sublime-settings' % __name__
self.settings = sublime.load_settings(self.settings_file)
self.installed_packages = self.settings.get('installed_packages', [])
if not isinstance(self.installed_packages, list):
self.installed_packages = []
def rename_packages(self, installer):
"""
Renames any installed packages that the user has installed.
:param installer:
An instance of :class:`PackageInstaller`
"""
# Fetch the packages since that will pull in the renamed packages list
installer.manager.list_available_packages()
renamed_packages = installer.manager.settings.get('renamed_packages', {})
if not renamed_packages:
renamed_packages = {}
# These are packages that have been tracked as installed
installed_pkgs = self.installed_packages
# There are the packages actually present on the filesystem
present_packages = installer.manager.list_packages()
# Rename directories for packages that have changed names
for package_name in renamed_packages:
package_dir = os.path.join(sublime.packages_path(), package_name)
metadata_path = os.path.join(package_dir, 'package-metadata.json')
if not os.path.exists(metadata_path):
continue
new_package_name = renamed_packages[package_name]
new_package_dir = os.path.join(sublime.packages_path(),
new_package_name)
changing_case = package_name.lower() == new_package_name.lower()
case_insensitive_fs = sublime.platform() in ['windows', 'osx']
# Since Windows and OSX use case-insensitive filesystems, we have to
# scan through the list of installed packages if the rename of the
# package is just changing the case of it. If we don't find the old
# name for it, we continue the loop since os.path.exists() will return
# true due to the case-insensitive nature of the filesystems.
if case_insensitive_fs and changing_case:
has_old = False
for present_package_name in present_packages:
if present_package_name == package_name:
has_old = True
break
if not has_old:
continue
if not os.path.exists(new_package_dir) or (case_insensitive_fs and changing_case):
# Windows will not allow you to rename to the same name with
# a different case, so we work around that with a temporary name
if os.name == 'nt' and changing_case:
temp_package_name = '__' + new_package_name
temp_package_dir = os.path.join(sublime.packages_path(),
temp_package_name)
os.rename(package_dir, temp_package_dir)
package_dir = temp_package_dir
os.rename(package_dir, new_package_dir)
installed_pkgs.append(new_package_name)
print '%s: Renamed %s to %s' % (__name__, package_name,
new_package_name)
else:
installer.manager.remove_package(package_name)
print ('%s: Removed %s since package with new name (%s) ' +
'already exists') % (__name__, package_name,
new_package_name)
try:
installed_pkgs.remove(package_name)
except (ValueError):
pass
sublime.set_timeout(lambda: self.save_packages(installed_pkgs), 10)
def save_packages(self, installed_packages):
"""
Saves the list of installed packages (after having been appropriately
renamed)
:param installed_packages:
The new list of installed packages
"""
installed_packages = list(set(installed_packages))
installed_packages = sorted(installed_packages,
key=lambda s: s.lower())
if installed_packages != self.installed_packages:
self.settings.set('installed_packages', installed_packages)
sublime.save_settings(self.settings_file)
class PackageInstaller():
"""
Provides helper functionality related to installing packages
"""
def __init__(self):
self.manager = PackageManager()
def make_package_list(self, ignore_actions=[], override_action=None,
ignore_packages=[]):
"""
Creates a list of packages and what operation would be performed for
each. Allows filtering by the applicable action or package name.
Returns the information in a format suitable for displaying in the
quick panel.
:param ignore_actions:
A list of actions to ignore packages by. Valid actions include:
`install`, `upgrade`, `downgrade`, `reinstall`, `overwrite`,
`pull` and `none`. `pull` andd `none` are for Git and Hg
repositories. `pull` is present when incoming changes are detected,
where as `none` is selected if no commits are available. `overwrite`
is for packages that do not include version information via the
`package-metadata.json` file.
:param override_action:
A string action name to override the displayed action for all listed
packages.
:param ignore_packages:
A list of packages names that should not be returned in the list
:return:
A list of lists, each containing three strings:
0 - package name
1 - package description
2 - action; [extra info;] package url
"""
packages = self.manager.list_available_packages()
installed_packages = self.manager.list_packages()
package_list = []
for package in sorted(packages.iterkeys(), key=lambda s: s.lower()):
if ignore_packages and package in ignore_packages:
continue
package_entry = [package]
info = packages[package]
download = info['downloads'][0]
if package in installed_packages:
installed = True
metadata = self.manager.get_metadata(package)
if metadata.get('version'):
installed_version = metadata['version']
else:
installed_version = None
else:
installed = False
installed_version_name = 'v' + installed_version if \
installed and installed_version else 'unknown version'
new_version = 'v' + download['version']
vcs = None
package_dir = self.manager.get_package_dir(package)
settings = self.manager.settings
if override_action:
action = override_action
extra = ''
else:
if os.path.exists(os.path.join(sublime.packages_path(),
package, '.git')):
if settings.get('ignore_vcs_packages'):
continue
vcs = 'git'
incoming = GitUpgrader(settings.get('git_binary'),
settings.get('git_update_command'), package_dir,
settings.get('cache_length'), settings.get('debug')
).incoming()
elif os.path.exists(os.path.join(sublime.packages_path(),
package, '.hg')):
if settings.get('ignore_vcs_packages'):
continue
vcs = 'hg'
incoming = HgUpgrader(settings.get('hg_binary'),
settings.get('hg_update_command'), package_dir,
settings.get('cache_length'), settings.get('debug')
).incoming()
if installed:
if not installed_version:
if vcs:
if incoming:
action = 'pull'
extra = ' with ' + vcs
else:
action = 'none'
extra = ''
else:
action = 'overwrite'
extra = ' %s with %s' % (installed_version_name,
new_version)
else:
res = self.manager.compare_versions(
installed_version, download['version'])
if res < 0:
action = 'upgrade'
extra = ' to %s from %s' % (new_version,
installed_version_name)
elif res > 0:
action = 'downgrade'
extra = ' to %s from %s' % (new_version,
installed_version_name)
else:
action = 'reinstall'
extra = ' %s' % new_version
else:
action = 'install'
extra = ' %s' % new_version
extra += ';'
if action in ignore_actions:
continue
description = info.get('description')
if not description:
description = 'No description provided'
package_entry.append(description)
package_entry.append(action + extra + ' ' +
re.sub('^https?://', '', info['url']))
package_list.append(package_entry)
return package_list
def disable_package(self, package):
"""
Disables a package before installing or upgrading to prevent errors
where Sublime Text tries to read files that no longer exist, or read a
half-written file.
:param package: The string package name
"""
# Don't disable Package Control so it does not get stuck disabled
if package == 'Package Control':
return False
settings = sublime.load_settings(preferences_filename())
ignored = settings.get('ignored_packages')
if not ignored:
ignored = []
if not package in ignored:
ignored.append(package)
settings.set('ignored_packages', ignored)
sublime.save_settings(preferences_filename())
return True
return False
def reenable_package(self, package):
"""
Re-enables a package after it has been installed or upgraded
:param package: The string package name
"""
settings = sublime.load_settings(preferences_filename())
ignored = settings.get('ignored_packages')
if not ignored:
return
if package in ignored:
settings.set('ignored_packages',
list(set(ignored) - set([package])))
sublime.save_settings(preferences_filename())
def on_done(self, picked):
"""
Quick panel user selection handler - disables a package, installs or
upgrades it, then re-enables the package
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
name = self.package_list[picked][0]
if self.disable_package(name):
on_complete = lambda: self.reenable_package(name)
else:
on_complete = None
thread = PackageInstallerThread(self.manager, name, on_complete)
thread.start()
ThreadProgress(thread, 'Installing package %s' % name,
'Package %s successfully %s' % (name, self.completion_type))
class PackageInstallerThread(threading.Thread):
"""
A thread to run package install/upgrade operations in so that the main
Sublime Text thread does not get blocked and freeze the UI
"""
def __init__(self, manager, package, on_complete):
"""
:param manager:
An instance of :class:`PackageManager`
:param package:
The string package name to install/upgrade
:param on_complete:
A callback to run after installing/upgrading the package
"""
self.package = package
self.manager = manager
self.on_complete = on_complete
threading.Thread.__init__(self)
def run(self):
try:
self.result = self.manager.install_package(self.package)
finally:
if self.on_complete:
sublime.set_timeout(self.on_complete, 1)
class InstallPackageCommand(sublime_plugin.WindowCommand):
"""
A command that presents the list of available packages and allows the
user to pick one to install.
"""
def run(self):
thread = InstallPackageThread(self.window)
thread.start()
ThreadProgress(thread, 'Loading repositories', '')
class InstallPackageThread(threading.Thread, PackageInstaller):
"""
A thread to run the action of retrieving available packages in. Uses the
default PackageInstaller.on_done quick panel handler.
"""
def __init__(self, window):
"""
:param window:
An instance of :class:`sublime.Window` that represents the Sublime
Text window to show the available package list in.
"""
self.window = window
self.completion_type = 'installed'
threading.Thread.__init__(self)
PackageInstaller.__init__(self)
def run(self):
self.package_list = self.make_package_list(['upgrade', 'downgrade',
'reinstall', 'pull', 'none'])
def show_quick_panel():
if not self.package_list:
sublime.error_message(('%s: There are no packages ' +
'available for installation.') % __name__)
return
self.window.show_quick_panel(self.package_list, self.on_done)
sublime.set_timeout(show_quick_panel, 10)
class DiscoverPackagesCommand(sublime_plugin.WindowCommand):
"""
A command that opens the community package list webpage
"""
def run(self):
self.window.run_command('open_url',
{'url': 'http://wbond.net/sublime_packages/community'})
class UpgradePackageCommand(sublime_plugin.WindowCommand):
"""
A command that presents the list of installed packages that can be upgraded.
"""
def run(self):
package_renamer = PackageRenamer()
package_renamer.load_settings()
thread = UpgradePackageThread(self.window, package_renamer)
thread.start()
ThreadProgress(thread, 'Loading repositories', '')
class UpgradePackageThread(threading.Thread, PackageInstaller):
"""
A thread to run the action of retrieving upgradable packages in.
"""
def __init__(self, window, package_renamer):
"""
:param window:
An instance of :class:`sublime.Window` that represents the Sublime
Text window to show the list of upgradable packages in.
:param package_renamer:
An instance of :class:`PackageRenamer`
"""
self.window = window
self.package_renamer = package_renamer
self.completion_type = 'upgraded'
threading.Thread.__init__(self)
PackageInstaller.__init__(self)
def run(self):
self.package_renamer.rename_packages(self)
self.package_list = self.make_package_list(['install', 'reinstall',
'none'])
def show_quick_panel():
if not self.package_list:
sublime.error_message(('%s: There are no packages ' +
'ready for upgrade.') % __name__)
return
self.window.show_quick_panel(self.package_list, self.on_done)
sublime.set_timeout(show_quick_panel, 10)
def on_done(self, picked):
"""
Quick panel user selection handler - disables a package, upgrades it,
then re-enables the package
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
name = self.package_list[picked][0]
if self.disable_package(name):
on_complete = lambda: self.reenable_package(name)
else:
on_complete = None
thread = PackageInstallerThread(self.manager, name, on_complete)
thread.start()
ThreadProgress(thread, 'Upgrading package %s' % name,
'Package %s successfully %s' % (name, self.completion_type))
class UpgradeAllPackagesCommand(sublime_plugin.WindowCommand):
"""
A command to automatically upgrade all installed packages that are
upgradable.
"""
def run(self):
package_renamer = PackageRenamer()
package_renamer.load_settings()
thread = UpgradeAllPackagesThread(self.window, package_renamer)
thread.start()
ThreadProgress(thread, 'Loading repositories', '')
class UpgradeAllPackagesThread(threading.Thread, PackageInstaller):
"""
A thread to run the action of retrieving upgradable packages in.
"""
def __init__(self, window, package_renamer):
self.window = window
self.package_renamer = package_renamer
self.completion_type = 'upgraded'
threading.Thread.__init__(self)
PackageInstaller.__init__(self)
def run(self):
self.package_renamer.rename_packages(self)
package_list = self.make_package_list(['install', 'reinstall', 'none'])
disabled_packages = {}
def do_upgrades():
# Pause so packages can be disabled
time.sleep(0.5)
# We use a function to generate the on-complete lambda because if
# we don't, the lambda will bind to info at the current scope, and
# thus use the last value of info from the loop
def make_on_complete(name):
return lambda: self.reenable_package(name)
for info in package_list:
if disabled_packages.get(info[0]):
on_complete = make_on_complete(info[0])
else:
on_complete = None
thread = PackageInstallerThread(self.manager, info[0], on_complete)
thread.start()
ThreadProgress(thread, 'Upgrading package %s' % info[0],
'Package %s successfully %s' % (info[0], self.completion_type))
# Disabling a package means changing settings, which can only be done
# in the main thread. We then create a new background thread so that
# the upgrade process does not block the UI.
def disable_packages():
for info in package_list:
disabled_packages[info[0]] = self.disable_package(info[0])
threading.Thread(target=do_upgrades).start()
sublime.set_timeout(disable_packages, 1)
class ExistingPackagesCommand():
"""
Allows listing installed packages and their current version
"""
def __init__(self):
self.manager = PackageManager()
def make_package_list(self, action=''):
"""
Returns a list of installed packages suitable for displaying in the
quick panel.
:param action:
An action to display at the beginning of the third element of the
list returned for each package
:return:
A list of lists, each containing three strings:
0 - package name
1 - package description
2 - [action] installed version; package url
"""
packages = self.manager.list_packages()
if action:
action += ' '
package_list = []
for package in sorted(packages, key=lambda s: s.lower()):
package_entry = [package]
metadata = self.manager.get_metadata(package)
package_dir = os.path.join(sublime.packages_path(), package)
description = metadata.get('description')
if not description:
description = 'No description provided'
package_entry.append(description)
version = metadata.get('version')
if not version and os.path.exists(os.path.join(package_dir,
'.git')):
installed_version = 'git repository'
elif not version and os.path.exists(os.path.join(package_dir,
'.hg')):
installed_version = 'hg repository'
else:
installed_version = 'v' + version if version else \
'unknown version'
url = metadata.get('url')
if url:
url = '; ' + re.sub('^https?://', '', url)
else:
url = ''
package_entry.append(action + installed_version + url)
package_list.append(package_entry)
return package_list
class ListPackagesCommand(sublime_plugin.WindowCommand):
"""
A command that shows a list of all installed packages in the quick panel
"""
def run(self):
ListPackagesThread(self.window).start()
class ListPackagesThread(threading.Thread, ExistingPackagesCommand):
"""
A thread to prevent the listing of existing packages from freezing the UI
"""
def __init__(self, window):
"""
:param window:
An instance of :class:`sublime.Window` that represents the Sublime
Text window to show the list of installed packages in.
"""
self.window = window
threading.Thread.__init__(self)
ExistingPackagesCommand.__init__(self)
def run(self):
self.package_list = self.make_package_list()
def show_quick_panel():
if not self.package_list:
sublime.error_message(('%s: There are no packages ' +
'to list.') % __name__)
return
self.window.show_quick_panel(self.package_list, self.on_done)
sublime.set_timeout(show_quick_panel, 10)
def on_done(self, picked):
"""
Quick panel user selection handler - opens the homepage for any
selected package in the user's browser
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
package_name = self.package_list[picked][0]
def open_dir():
self.window.run_command('open_dir',
{"dir": os.path.join(sublime.packages_path(), package_name)})
sublime.set_timeout(open_dir, 10)
class RemovePackageCommand(sublime_plugin.WindowCommand,
ExistingPackagesCommand):
"""
A command that presents a list of installed packages, allowing the user to
select one to remove
"""
def __init__(self, window):
"""
:param window:
An instance of :class:`sublime.Window` that represents the Sublime
Text window to show the list of installed packages in.
"""
self.window = window
ExistingPackagesCommand.__init__(self)
def run(self):
self.package_list = self.make_package_list('remove')
if not self.package_list:
sublime.error_message(('%s: There are no packages ' +
'that can be removed.') % __name__)
return
self.window.show_quick_panel(self.package_list, self.on_done)
def on_done(self, picked):
"""
Quick panel user selection handler - deletes the selected package
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
package = self.package_list[picked][0]
# Don't disable Package Control so it does not get stuck disabled
if package != 'Package Control':
settings = sublime.load_settings(preferences_filename())
ignored = settings.get('ignored_packages')
if not ignored:
ignored = []
if not package in ignored:
ignored.append(package)
settings.set('ignored_packages', ignored)
sublime.save_settings(preferences_filename())
ignored.remove(package)
thread = RemovePackageThread(self.manager, package,
ignored)
thread.start()
ThreadProgress(thread, 'Removing package %s' % package,
'Package %s successfully removed' % package)
class RemovePackageThread(threading.Thread):
"""
A thread to run the remove package operation in so that the Sublime Text
UI does not become frozen
"""
def __init__(self, manager, package, ignored):
self.manager = manager
self.package = package
self.ignored = ignored
threading.Thread.__init__(self)
def run(self):
self.result = self.manager.remove_package(self.package)
def unignore_package():
settings = sublime.load_settings(preferences_filename())
settings.set('ignored_packages', self.ignored)
sublime.save_settings(preferences_filename())
sublime.set_timeout(unignore_package, 10)
class AddRepositoryChannelCommand(sublime_plugin.WindowCommand):
"""
A command to add a new channel (list of repositories) to the user's machine
"""
def run(self):
self.window.show_input_panel('Channel JSON URL', '',
self.on_done, self.on_change, self.on_cancel)
def on_done(self, input):
"""
Input panel handler - adds the provided URL as a channel
:param input:
A string of the URL to the new channel
"""
settings = sublime.load_settings('%s.sublime-settings' % __name__)
repository_channels = settings.get('repository_channels', [])
if not repository_channels:
repository_channels = []
repository_channels.append(input)
settings.set('repository_channels', repository_channels)
sublime.save_settings('%s.sublime-settings' % __name__)
sublime.status_message(('Channel %s successfully ' +
'added') % input)
def on_change(self, input):
pass
def on_cancel(self):
pass
class AddRepositoryCommand(sublime_plugin.WindowCommand):
"""
A command to add a new repository to the user's machine
"""
def run(self):
self.window.show_input_panel('GitHub or BitBucket Web URL, or Custom' +
' JSON Repository URL', '', self.on_done,
self.on_change, self.on_cancel)
def on_done(self, input):
"""
Input panel handler - adds the provided URL as a repository
:param input:
A string of the URL to the new repository
"""
settings = sublime.load_settings('%s.sublime-settings' % __name__)
repositories = settings.get('repositories', [])
if not repositories:
repositories = []
repositories.append(input)
settings.set('repositories', repositories)
sublime.save_settings('%s.sublime-settings' % __name__)
sublime.status_message('Repository %s successfully added' % input)
def on_change(self, input):
pass
def on_cancel(self):
pass
class DisablePackageCommand(sublime_plugin.WindowCommand):
"""
A command that adds a package to Sublime Text's ignored packages list
"""
def run(self):
manager = PackageManager()
packages = manager.list_all_packages()
self.settings = sublime.load_settings(preferences_filename())
ignored = self.settings.get('ignored_packages')
if not ignored:
ignored = []
self.package_list = list(set(packages) - set(ignored))
self.package_list.sort()
if not self.package_list:
sublime.error_message(('%s: There are no enabled packages' +
'to disable.') % __name__)
return
self.window.show_quick_panel(self.package_list, self.on_done)
def on_done(self, picked):
"""
Quick panel user selection handler - disables the selected package
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
package = self.package_list[picked]
ignored = self.settings.get('ignored_packages')
if not ignored:
ignored = []
ignored.append(package)
self.settings.set('ignored_packages', ignored)
sublime.save_settings(preferences_filename())
sublime.status_message(('Package %s successfully added to list of ' +
'disabled packages - restarting Sublime Text may be required') %
package)
class EnablePackageCommand(sublime_plugin.WindowCommand):
"""
A command that removes a package from Sublime Text's ignored packages list
"""
def run(self):
self.settings = sublime.load_settings(preferences_filename())
self.disabled_packages = self.settings.get('ignored_packages')
self.disabled_packages.sort()
if not self.disabled_packages:
sublime.error_message(('%s: There are no disabled packages ' +
'to enable.') % __name__)
return
self.window.show_quick_panel(self.disabled_packages, self.on_done)
def on_done(self, picked):
"""
Quick panel user selection handler - enables the selected package
:param picked:
An integer of the 0-based package name index from the presented
list. -1 means the user cancelled.
"""
if picked == -1:
return
package = self.disabled_packages[picked]
ignored = self.settings.get('ignored_packages')
self.settings.set('ignored_packages',
list(set(ignored) - set([package])))
sublime.save_settings(preferences_filename())
sublime.status_message(('Package %s successfully removed from list ' +
'of disabled packages - restarting Sublime Text may be required') %
package)
class AutomaticUpgrader(threading.Thread):
"""
Automatically checks for updated packages and installs them. controlled
by the `auto_upgrade`, `auto_upgrade_ignore`, `auto_upgrade_frequency` and
`auto_upgrade_last_run` settings.
"""
def __init__(self, found_packages):
"""
:param found_packages:
A list of package names for the packages that were found to be
installed on the machine.
"""
self.installer = PackageInstaller()
self.manager = self.installer.manager
self.load_settings()
self.package_renamer = PackageRenamer()
self.package_renamer.load_settings()
self.auto_upgrade = self.settings.get('auto_upgrade')
self.auto_upgrade_ignore = self.settings.get('auto_upgrade_ignore')
self.next_run = int(time.time())
self.last_run = None
last_run_file = os.path.join(sublime.packages_path(), 'User',
'Package Control.last-run')
if os.path.isfile(last_run_file):
with open(last_run_file) as fobj:
try:
self.last_run = int(fobj.read())
except ValueError:
pass
frequency = self.settings.get('auto_upgrade_frequency')
if frequency:
if self.last_run:
self.next_run = int(self.last_run) + (frequency * 60 * 60)
else:
self.next_run = time.time()
# Detect if a package is missing that should be installed
self.missing_packages = list(set(self.installed_packages) -
set(found_packages))
if self.auto_upgrade and self.next_run <= time.time():
with open(last_run_file, 'w') as fobj:
fobj.write(str(int(time.time())))
threading.Thread.__init__(self)
def load_settings(self):
"""
Loads the list of installed packages from the
Package Control.sublime-settings file
"""
self.settings_file = '%s.sublime-settings' % __name__
self.settings = sublime.load_settings(self.settings_file)
self.installed_packages = self.settings.get('installed_packages', [])
self.should_install_missing = self.settings.get('install_missing')
if not isinstance(self.installed_packages, list):
self.installed_packages = []
def run(self):
self.install_missing()
if self.next_run > time.time():
self.print_skip()
return
self.upgrade_packages()
def install_missing(self):
"""
Installs all packages that were listed in the list of
`installed_packages` from Package Control.sublime-settings but were not
found on the filesystem and passed as `found_packages`.
"""
if not self.missing_packages or not self.should_install_missing:
return
print '%s: Installing %s missing packages' % \
(__name__, len(self.missing_packages))
for package in self.missing_packages:
if self.installer.manager.install_package(package):
print '%s: Installed missing package %s' % \
(__name__, package)
def print_skip(self):
"""
Prints a notice in the console if the automatic upgrade is skipped
due to already having been run in the last `auto_upgrade_frequency`
hours.
"""
last_run = datetime.datetime.fromtimestamp(self.last_run)
next_run = datetime.datetime.fromtimestamp(self.next_run)
date_format = '%Y-%m-%d %H:%M:%S'
print ('%s: Skipping automatic upgrade, last run at ' +
'%s, next run at %s or after') % (__name__,
last_run.strftime(date_format), next_run.strftime(date_format))
def upgrade_packages(self):
"""
Upgrades all packages that are not currently upgraded to the lastest
version. Also renames any installed packages to their new names.
"""
if not self.auto_upgrade:
return
self.package_renamer.rename_packages(self.installer)
packages = self.installer.make_package_list(['install',
'reinstall', 'downgrade', 'overwrite', 'none'],
ignore_packages=self.auto_upgrade_ignore)
# If Package Control is being upgraded, just do that and restart
for package in packages:
if package[0] != __name__:
continue
def reset_last_run():
settings = sublime.load_settings(self.settings_file)
settings.set('auto_upgrade_last_run', None)
sublime.save_settings(self.settings_file)
sublime.set_timeout(reset_last_run, 1)
packages = [package]
break
if not packages:
print '%s: No updated packages' % __name__
return
print '%s: Installing %s upgrades' % (__name__, len(packages))
for package in packages:
self.installer.manager.install_package(package[0])
version = re.sub('^.*?(v[\d\.]+).*?$', '\\1', package[2])
if version == package[2] and version.find('pull with') != -1:
vcs = re.sub('^pull with (\w+).*?$', '\\1', version)
version = 'latest %s commit' % vcs
print '%s: Upgraded %s to %s' % (__name__, package[0], version)
class PackageCleanup(threading.Thread, PackageRenamer):
"""
Cleans up folders for packages that were removed, but that still have files
in use.
"""
def __init__(self):
self.manager = PackageManager()
self.load_settings()
threading.Thread.__init__(self)
def run(self):
found_pkgs = []
installed_pkgs = self.installed_packages
for package_name in os.listdir(sublime.packages_path()):
package_dir = os.path.join(sublime.packages_path(), package_name)
metadata_path = os.path.join(package_dir, 'package-metadata.json')
# Cleanup packages that could not be removed due to in-use files
cleanup_file = os.path.join(package_dir, 'package-control.cleanup')
if os.path.exists(cleanup_file):
try:
shutil.rmtree(package_dir)
print '%s: Removed old directory for package %s' % \
(__name__, package_name)
except (OSError) as (e):
if not os.path.exists(cleanup_file):
open(cleanup_file, 'w').close()
print ('%s: Unable to remove old directory for package ' +
'%s - deferring until next start: %s') % (__name__,
package_name, unicode_from_os(e))
# Finish reinstalling packages that could not be upgraded due to
# in-use files
reinstall = os.path.join(package_dir, 'package-control.reinstall')
if os.path.exists(reinstall):
if not clear_directory(package_dir, [metadata_path]):
if not os.path.exists(reinstall):
open(reinstall, 'w').close()
# Assigning this here prevents the callback from referencing the value
# of the "package_name" variable when it is executed
restart_message = ('%s: An error occurred while trying to ' +
'finish the upgrade of %s. You will most likely need to ' +
'restart your computer to complete the upgrade.') % (
__name__, package_name)
def show_still_locked():
sublime.error_message(restart_message)
sublime.set_timeout(show_still_locked, 10)
else:
self.manager.install_package(package_name)
# This adds previously installed packages from old versions of PC
if os.path.exists(metadata_path) and \
package_name not in self.installed_packages:
installed_pkgs.append(package_name)
params = {
'package': package_name,
'operation': 'install',
'version': \
self.manager.get_metadata(package_name).get('version')
}
self.manager.record_usage(params)
found_pkgs.append(package_name)
sublime.set_timeout(lambda: self.finish(installed_pkgs, found_pkgs), 10)
def finish(self, installed_pkgs, found_pkgs):
"""
A callback that can be run the main UI thread to perform saving of the
Package Control.sublime-settings file. Also fires off the
:class:`AutomaticUpgrader`.
:param installed_pkgs:
A list of the string package names of all "installed" packages,
even ones that do not appear to be in the filesystem.
:param found_pkgs:
A list of the string package names of all packages that are
currently installed on the filesystem.
"""
self.save_packages(installed_pkgs)
AutomaticUpgrader(found_pkgs).start()
# Start shortly after Sublime starts so package renames don't cause errors
# with keybindings, settings, etc disappearing in the middle of parsing
sublime.set_timeout(lambda: PackageCleanup().start(), 2000)