1025 lines
41 KiB
Python
1025 lines
41 KiB
Python
import sys
|
|
import os
|
|
import re
|
|
import socket
|
|
import json
|
|
import time
|
|
import zipfile
|
|
import shutil
|
|
from fnmatch import fnmatch
|
|
import datetime
|
|
import tempfile
|
|
import locale
|
|
|
|
try:
|
|
# Python 3
|
|
from urllib.parse import urlencode, urlparse
|
|
import compileall
|
|
str_cls = str
|
|
except (ImportError):
|
|
# Python 2
|
|
from urllib import urlencode
|
|
from urlparse import urlparse
|
|
str_cls = unicode
|
|
|
|
import sublime
|
|
|
|
from .show_error import show_error
|
|
from .console_write import console_write
|
|
from .open_compat import open_compat, read_compat
|
|
from .unicode import unicode_from_os
|
|
from .clear_directory import clear_directory
|
|
from .cache import (clear_cache, set_cache, get_cache, merge_cache_under_settings,
|
|
merge_cache_over_settings, set_cache_under_settings, set_cache_over_settings)
|
|
from .versions import version_comparable, version_sort
|
|
from .downloaders.background_downloader import BackgroundDownloader
|
|
from .downloaders.downloader_exception import DownloaderException
|
|
from .providers.provider_exception import ProviderException
|
|
from .clients.client_exception import ClientException
|
|
from .download_manager import downloader
|
|
from .providers.channel_provider import ChannelProvider
|
|
from .upgraders.git_upgrader import GitUpgrader
|
|
from .upgraders.hg_upgrader import HgUpgrader
|
|
from .package_io import read_package_file
|
|
from .providers import CHANNEL_PROVIDERS, REPOSITORY_PROVIDERS
|
|
from . import __version__
|
|
|
|
|
|
class PackageManager():
|
|
"""
|
|
Allows downloading, creating, installing, upgrading, and deleting packages
|
|
|
|
Delegates metadata retrieval to the CHANNEL_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('Package Control.sublime-settings')
|
|
for setting in ['timeout', 'repositories', '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', 'http_cache', 'http_cache_length',
|
|
'install_prereleases', 'openssl_binary']:
|
|
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.get('https_proxy') == False:
|
|
self.settings['https_proxy'] = ''
|
|
|
|
self.settings['platform'] = sublime.platform()
|
|
self.settings['version'] = sublime.version()
|
|
|
|
# Use the cache to see if settings have changed since the last
|
|
# time the package manager was created, and clearing any cached
|
|
# values if they have.
|
|
previous_settings = get_cache('filtered_settings', {})
|
|
|
|
# Reduce the settings down to exclude channel info since that will
|
|
# make the settings always different
|
|
filtered_settings = self.settings.copy()
|
|
for key in ['repositories', 'channels', 'package_name_map', 'cache']:
|
|
if key in filtered_settings:
|
|
del filtered_settings[key]
|
|
|
|
if filtered_settings != previous_settings and previous_settings != {}:
|
|
console_write(u'Settings change detected, clearing cache', True)
|
|
clear_cache()
|
|
set_cache('filtered_settings', filtered_settings)
|
|
|
|
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
|
|
"""
|
|
|
|
try:
|
|
debug = self.settings.get('debug')
|
|
metadata_json = read_package_file(package, 'package-metadata.json', debug=debug)
|
|
if metadata_json:
|
|
return json.loads(metadata_json)
|
|
|
|
except (IOError, ValueError) as e:
|
|
pass
|
|
|
|
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
|
|
"channels" setting, plus any repositories listed in the
|
|
"repositories" setting.
|
|
|
|
:return:
|
|
A list of all available repositories
|
|
"""
|
|
|
|
cache_ttl = self.settings.get('cache_length')
|
|
|
|
repositories = self.settings.get('repositories')[:]
|
|
channels = self.settings.get('channels')
|
|
for channel in channels:
|
|
channel = channel.strip()
|
|
|
|
# Caches various info from channels for performance
|
|
cache_key = channel + '.repositories'
|
|
channel_repositories = get_cache(cache_key)
|
|
|
|
merge_cache_under_settings(self, 'package_name_map', channel)
|
|
merge_cache_under_settings(self, 'renamed_packages', channel)
|
|
merge_cache_under_settings(self, 'unavailable_packages', channel, list_=True)
|
|
|
|
# If any of the info was not retrieved from the cache, we need to
|
|
# grab the channel to get it
|
|
if channel_repositories == None:
|
|
|
|
for provider_class in CHANNEL_PROVIDERS:
|
|
if provider_class.match_url(channel):
|
|
provider = provider_class(channel, self.settings)
|
|
break
|
|
|
|
try:
|
|
channel_repositories = provider.get_repositories()
|
|
set_cache(cache_key, channel_repositories, cache_ttl)
|
|
|
|
for repo in channel_repositories:
|
|
repo_packages = provider.get_packages(repo)
|
|
packages_cache_key = repo + '.packages'
|
|
set_cache(packages_cache_key, repo_packages, cache_ttl)
|
|
|
|
# Have the local name map override the one from the channel
|
|
name_map = provider.get_name_map()
|
|
set_cache_under_settings(self, 'package_name_map', channel, name_map, cache_ttl)
|
|
|
|
renamed_packages = provider.get_renamed_packages()
|
|
set_cache_under_settings(self, 'renamed_packages', channel, renamed_packages, cache_ttl)
|
|
|
|
unavailable_packages = provider.get_unavailable_packages()
|
|
set_cache_under_settings(self, 'unavailable_packages', channel, unavailable_packages, cache_ttl, list_=True)
|
|
|
|
provider_certs = provider.get_certs()
|
|
certs = self.settings.get('certs', {}).copy()
|
|
certs.update(provider_certs)
|
|
# Save the master list of certs, used by downloaders/cert_provider.py
|
|
set_cache('*.certs', certs, cache_ttl)
|
|
|
|
except (DownloaderException, ClientException, ProviderException) as e:
|
|
console_write(e, True)
|
|
continue
|
|
|
|
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
|
|
},
|
|
...
|
|
}
|
|
"""
|
|
|
|
if self.settings.get('debug'):
|
|
console_write(u"Fetching list of available packages", True)
|
|
console_write(u" Platform: %s-%s" % (sublime.platform(),sublime.arch()))
|
|
console_write(u" Sublime Text Version: %s" % sublime.version())
|
|
console_write(u" Package Control Version: %s" % __version__)
|
|
|
|
cache_ttl = self.settings.get('cache_length')
|
|
repositories = self.list_repositories()
|
|
packages = {}
|
|
bg_downloaders = {}
|
|
active = []
|
|
repos_to_download = []
|
|
name_map = self.settings.get('package_name_map', {})
|
|
|
|
# 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]:
|
|
cache_key = repo + '.packages'
|
|
repository_packages = get_cache(cache_key)
|
|
|
|
if repository_packages != None:
|
|
packages.update(repository_packages)
|
|
|
|
else:
|
|
domain = urlparse(repo).hostname
|
|
if domain not in bg_downloaders:
|
|
bg_downloaders[domain] = BackgroundDownloader(
|
|
self.settings, REPOSITORY_PROVIDERS)
|
|
bg_downloaders[domain].add_url(repo)
|
|
repos_to_download.append(repo)
|
|
|
|
for bg_downloader in list(bg_downloaders.values()):
|
|
bg_downloader.start()
|
|
active.append(bg_downloader)
|
|
|
|
# Wait for all of the downloaders to finish
|
|
while active:
|
|
bg_downloader = active.pop()
|
|
bg_downloader.join()
|
|
|
|
# Grabs the results and stuff it all in the cache
|
|
for repo in repos_to_download:
|
|
domain = urlparse(repo).hostname
|
|
bg_downloader = bg_downloaders[domain]
|
|
provider = bg_downloader.get_provider(repo)
|
|
|
|
# Allow name mapping of packages for schema version < 2.0
|
|
repository_packages = {}
|
|
for name, info in provider.get_packages():
|
|
name = name_map.get(name, name)
|
|
info['name'] = name
|
|
repository_packages[name] = info
|
|
|
|
# Display errors we encountered while fetching package info
|
|
for url, exception in provider.get_failed_sources():
|
|
console_write(exception, True)
|
|
for name, exception in provider.get_broken_packages():
|
|
console_write(exception, True)
|
|
|
|
cache_key = repo + '.packages'
|
|
set_cache(cache_key, repository_packages, cache_ttl)
|
|
packages.update(repository_packages)
|
|
|
|
renamed_packages = provider.get_renamed_packages()
|
|
set_cache_under_settings(self, 'renamed_packages', repo, renamed_packages, cache_ttl)
|
|
|
|
unavailable_packages = provider.get_unavailable_packages()
|
|
set_cache_under_settings(self, 'unavailable_packages', repo, unavailable_packages, cache_ttl, list_=True)
|
|
|
|
return packages
|
|
|
|
def list_packages(self, unpacked_only=False):
|
|
"""
|
|
:param unpacked_only:
|
|
Only list packages that are not inside of .sublime-package files
|
|
|
|
: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))]
|
|
|
|
if int(sublime.version()) > 3000 and unpacked_only == False:
|
|
package_files = os.listdir(sublime.installed_packages_path())
|
|
package_names += [f.replace('.sublime-package', '') for f in package_files if re.search('\.sublime-package$', f) != None]
|
|
|
|
# Ignore things to be deleted
|
|
ignored = ['User']
|
|
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 = self.list_default_packages() + self.list_packages()
|
|
packages = sorted(packages, key=lambda s: s.lower())
|
|
return packages
|
|
|
|
def list_default_packages(self):
|
|
""" :return: A list of all default package names"""
|
|
|
|
if int(sublime.version()) > 3000:
|
|
bundled_packages_path = os.path.join(os.path.dirname(sublime.executable_path()),
|
|
'Packages')
|
|
files = os.listdir(bundled_packages_path)
|
|
|
|
else:
|
|
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):
|
|
show_error(u'The folder for the package name specified, %s, does not exist in %s' % (
|
|
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 e:
|
|
show_error(u'An error occurred creating the package file %s in %s.\n\n%s' % (
|
|
package_filename, package_destination, unicode_from_os(e)))
|
|
return False
|
|
|
|
if int(sublime.version()) >= 3000:
|
|
compileall.compile_dir(package_dir, quiet=True, legacy=True, optimize=2)
|
|
|
|
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', [])
|
|
|
|
slash = '\\' if os.name == 'nt' else '/'
|
|
trailing_package_dir = package_dir + slash if package_dir[-1] != slash else package_dir
|
|
package_dir_regex = re.compile('^' + re.escape(trailing_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()
|
|
|
|
is_available = package_name in list(packages.keys())
|
|
is_unavailable = package_name in self.settings.get('unavailable_packages', [])
|
|
|
|
if is_unavailable and not is_available:
|
|
console_write(u'The package "%s" is not available on this platform.' % package_name, True)
|
|
return False
|
|
|
|
if not is_available:
|
|
show_error(u'The package specified, %s, is not available' % package_name)
|
|
return False
|
|
|
|
url = packages[package_name]['download']['url']
|
|
package_filename = package_name + '.sublime-package'
|
|
|
|
tmp_dir = tempfile.mkdtemp()
|
|
|
|
try:
|
|
# This is refers to the zipfile later on, so we define it here so we can
|
|
# close the zip file if set during the finally clause
|
|
package_zip = None
|
|
|
|
tmp_package_path = os.path.join(tmp_dir, package_filename)
|
|
|
|
unpacked_package_dir = self.get_package_dir(package_name)
|
|
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)
|
|
|
|
if os.path.exists(os.path.join(unpacked_package_dir, '.git')):
|
|
if self.settings.get('ignore_vcs_packages'):
|
|
show_error(u'Skipping git package %s since the setting ignore_vcs_packages is set to true' % package_name)
|
|
return False
|
|
return GitUpgrader(self.settings['git_binary'],
|
|
self.settings['git_update_command'], unpacked_package_dir,
|
|
self.settings['cache_length'], self.settings['debug']).run()
|
|
elif os.path.exists(os.path.join(unpacked_package_dir, '.hg')):
|
|
if self.settings.get('ignore_vcs_packages'):
|
|
show_error(u'Skipping hg package %s since the setting ignore_vcs_packages is set to true' % package_name)
|
|
return False
|
|
return HgUpgrader(self.settings['hg_binary'],
|
|
self.settings['hg_update_command'], unpacked_package_dir,
|
|
self.settings['cache_length'], self.settings['debug']).run()
|
|
|
|
old_version = self.get_metadata(package_name).get('version')
|
|
is_upgrade = old_version != None
|
|
|
|
# Download the sublime-package or zip file
|
|
try:
|
|
with downloader(url, self.settings) as manager:
|
|
package_bytes = manager.fetch(url, 'Error downloading package.')
|
|
except (DownloaderException) as e:
|
|
console_write(e, True)
|
|
show_error(u'Unable to download %s. Please view the console for more details.' % package_name)
|
|
return False
|
|
|
|
with open_compat(tmp_package_path, "wb") as package_file:
|
|
package_file.write(package_bytes)
|
|
|
|
# Try to open it as a zip file
|
|
try:
|
|
package_zip = zipfile.ZipFile(tmp_package_path, 'r')
|
|
except (zipfile.BadZipfile):
|
|
show_error(u'An error occurred while trying to unzip the package file for %s. Please try installing the package again.' % package_name)
|
|
return False
|
|
|
|
# Scan through the root level of the zip file to gather some info
|
|
root_level_paths = []
|
|
last_path = None
|
|
for path in package_zip.namelist():
|
|
try:
|
|
if not isinstance(path, str_cls):
|
|
path = path.decode('utf-8', 'strict')
|
|
except (UnicodeDecodeError):
|
|
console_write(u'One or more of the zip file entries in %s is not encoded using UTF-8, aborting' % package_name, True)
|
|
return False
|
|
|
|
last_path = path
|
|
|
|
if path.find('/') in [len(path) - 1, -1]:
|
|
root_level_paths.append(path)
|
|
# Make sure there are no paths that look like security vulnerabilities
|
|
if path[0] == '/' or path.find('../') != -1 or path.find('..\\') != -1:
|
|
show_error(u'The package specified, %s, contains files outside of the package dir and cannot be safely installed.' % package_name)
|
|
return False
|
|
|
|
if last_path and len(root_level_paths) == 0:
|
|
root_level_paths.append(last_path[0:last_path.find('/') + 1])
|
|
|
|
# If there is only a single directory at the top leve, the file
|
|
# is most likely a zip from BitBucket or GitHub and we need
|
|
# to skip the top-level dir when extracting
|
|
skip_root_dir = len(root_level_paths) == 1 and \
|
|
root_level_paths[0].endswith('/')
|
|
|
|
no_package_file_zip_path = '.no-sublime-package'
|
|
if skip_root_dir:
|
|
no_package_file_zip_path = root_level_paths[0] + no_package_file_zip_path
|
|
|
|
# If we should extract unpacked or as a .sublime-package file
|
|
unpack = True
|
|
|
|
# By default, ST3 prefers .sublime-package files since this allows
|
|
# overriding files in the Packages/{package_name}/ folder
|
|
if int(sublime.version()) >= 3000:
|
|
unpack = False
|
|
|
|
# If the package maintainer doesn't want a .sublime-package
|
|
try:
|
|
package_zip.getinfo(no_package_file_zip_path)
|
|
unpack = True
|
|
except (KeyError):
|
|
pass
|
|
|
|
# If we already have a package-metadata.json file in
|
|
# Packages/{package_name}/, the only way to successfully upgrade
|
|
# will be to unpack
|
|
unpacked_metadata_file = os.path.join(unpacked_package_dir,
|
|
'package-metadata.json')
|
|
if os.path.exists(unpacked_metadata_file):
|
|
unpack = True
|
|
|
|
# If we determined it should be unpacked, we extract directly
|
|
# into the Packages/{package_name}/ folder
|
|
if unpack:
|
|
self.backup_package_dir(package_name)
|
|
package_dir = unpacked_package_dir
|
|
|
|
# Otherwise we go into a temp dir since we will be creating a
|
|
# new .sublime-package file later
|
|
else:
|
|
tmp_working_dir = os.path.join(tmp_dir, 'working')
|
|
os.mkdir(tmp_working_dir)
|
|
package_dir = tmp_working_dir
|
|
|
|
package_metadata_file = os.path.join(package_dir,
|
|
'package-metadata.json')
|
|
|
|
if not os.path.exists(package_dir):
|
|
os.mkdir(package_dir)
|
|
|
|
os.chdir(package_dir)
|
|
|
|
# Here we don't use .extractall() since it was having issues on OS X
|
|
overwrite_failed = False
|
|
extracted_paths = []
|
|
for path in package_zip.namelist():
|
|
dest = path
|
|
|
|
try:
|
|
if not isinstance(dest, str_cls):
|
|
dest = dest.decode('utf-8', 'strict')
|
|
except (UnicodeDecodeError):
|
|
console_write(u'One or more of the zip file entries in %s is not encoded using UTF-8, aborting' % package_name, True)
|
|
return False
|
|
|
|
if os.name == 'nt':
|
|
regex = ':|\*|\?|"|<|>|\|'
|
|
if re.search(regex, dest) != None:
|
|
console_write(u'Skipping file from package named %s due to an invalid filename' % package_name, True)
|
|
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_compat(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
|
|
console_write(u'Skipping file from package named %s due to an invalid filename' % package_name, True)
|
|
|
|
except (UnicodeDecodeError):
|
|
console_write(u'Skipping file from package named %s due to an invalid filename' % package_name, True)
|
|
|
|
package_zip.close()
|
|
package_zip = None
|
|
|
|
# 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_compat(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])
|
|
|
|
show_error(u'An error occurred while trying to upgrade %s. Please restart Sublime Text to finish the upgrade.' % 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_compat(package_metadata_file, 'w') as f:
|
|
metadata = {
|
|
"version": packages[package_name]['download']['version'],
|
|
"url": packages[package_name]['homepage'],
|
|
"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]['download']['version'],
|
|
'old_version': old_version
|
|
}
|
|
else:
|
|
params = {
|
|
'package': package_name,
|
|
'operation': 'install',
|
|
'version': packages[package_name]['download']['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('Package Control.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('Package Control.sublime-settings')
|
|
sublime.set_timeout(save_package, 1)
|
|
|
|
# If we didn't extract directly into the Packages/{package_name}/
|
|
# folder, we need to create a .sublime-package file and install it
|
|
if not unpack:
|
|
try:
|
|
# Remove the downloaded file since we are going to overwrite it
|
|
os.remove(tmp_package_path)
|
|
package_zip = zipfile.ZipFile(tmp_package_path, "w",
|
|
compression=zipfile.ZIP_DEFLATED)
|
|
except (OSError, IOError) as e:
|
|
show_error(u'An error occurred creating the package file %s in %s.\n\n%s' % (
|
|
package_filename, tmp_dir, unicode_from_os(e)))
|
|
return False
|
|
|
|
package_dir_regex = re.compile('^' + re.escape(package_dir))
|
|
for root, dirs, files in os.walk(package_dir):
|
|
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)
|
|
if os.path.isdir(full_path):
|
|
continue
|
|
package_zip.write(full_path, relative_path)
|
|
|
|
package_zip.close()
|
|
package_zip = None
|
|
|
|
if os.path.exists(package_path):
|
|
os.remove(package_path)
|
|
shutil.move(tmp_package_path, 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
|
|
|
|
finally:
|
|
# We need to make sure the zipfile is closed to
|
|
# help prevent permissions errors on Windows
|
|
if package_zip:
|
|
package_zip.close()
|
|
|
|
# Try to remove the tmp dir after a second to make sure
|
|
# a virus scanner is holding a reference to the zipfile
|
|
# after we close it.
|
|
def remove_tmp_dir():
|
|
try:
|
|
shutil.rmtree(tmp_dir)
|
|
except (PermissionError):
|
|
# If we can't remove the tmp dir, don't let an uncaught exception
|
|
# fall through and break the install process
|
|
pass
|
|
sublime.set_timeout(remove_tmp_dir, 1000)
|
|
|
|
def backup_package_dir(self, package_name):
|
|
"""
|
|
Does a full backup of the Packages/{package}/ dir to Backup/
|
|
|
|
:param package_name:
|
|
The name of the package to back up
|
|
|
|
:return:
|
|
If the backup succeeded
|
|
"""
|
|
|
|
package_dir = os.path.join(sublime.packages_path(), package_name)
|
|
if not os.path.exists(package_dir):
|
|
return True
|
|
|
|
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)
|
|
if os.path.exists(package_backup_dir):
|
|
console_write(u"FOLDER %s ALREADY EXISTS!" % package_backup_dir)
|
|
shutil.copytree(package_dir, package_backup_dir)
|
|
return True
|
|
|
|
except (OSError, IOError) as e:
|
|
show_error(u'An error occurred while trying to backup the package directory for %s.\n\n%s' % (
|
|
package_name, unicode_from_os(e)))
|
|
if os.path.exists(package_backup_dir):
|
|
shutil.rmtree(package_backup_dir)
|
|
return False
|
|
|
|
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_compat(messages_file, 'r')
|
|
try:
|
|
message_info = json.loads(read_compat(messages_fp))
|
|
except (ValueError):
|
|
console_write(u'Error parsing messages.json for %s' % package, True)
|
|
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_compat(install_messages, 'r') as f:
|
|
message += read_compat(f).replace('\n', '\n ')
|
|
output += message + '\n'
|
|
|
|
elif is_upgrade and old_version:
|
|
upgrade_messages = list(set(message_info.keys()) -
|
|
set(['install']))
|
|
upgrade_messages = version_sort(upgrade_messages, reverse=True)
|
|
old_version_cmp = version_comparable(old_version)
|
|
|
|
for version in upgrade_messages:
|
|
if version_comparable(version) <= old_version_cmp:
|
|
break
|
|
if not output:
|
|
message = '\n\n%s:\n%s\n' % (package,
|
|
('-' * len(package)))
|
|
output += message
|
|
upgrade_message_path = os.path.join(package_dir,
|
|
message_info.get(version))
|
|
message = '\n '
|
|
with open_compat(upgrade_message_path, 'r') as f:
|
|
message += read_compat(f).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):
|
|
view.run_command('package_message', {'string': string})
|
|
|
|
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:
|
|
show_error(u'The package specified, %s, is not installed' % 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'
|
|
installed_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)
|
|
|
|
version = self.get_metadata(package_name).get('version')
|
|
|
|
try:
|
|
if os.path.exists(installed_package_path):
|
|
os.remove(installed_package_path)
|
|
except (OSError, IOError) as e:
|
|
show_error(u'An error occurred while trying to remove the installed package file for %s.\n\n%s' % (
|
|
package_name, unicode_from_os(e)))
|
|
return False
|
|
|
|
try:
|
|
if os.path.exists(pristine_package_path):
|
|
os.remove(pristine_package_path)
|
|
except (OSError, IOError) as e:
|
|
show_error(u'An error occurred while trying to remove the pristine package file for %s.\n\n%s' % (
|
|
package_name, unicode_from_os(e)))
|
|
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_compat(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('Package Control.sublime-settings')
|
|
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('Package Control.sublime-settings')
|
|
sublime.set_timeout(clear_package, 1)
|
|
|
|
if can_delete_dir and os.path.exists(package_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')
|
|
|
|
# For Python 2, we need to explicitly encoding the params
|
|
for param in params:
|
|
if isinstance(params[param], str_cls):
|
|
params[param] = params[param].encode('utf-8')
|
|
|
|
url = self.settings.get('submit_url') + '?' + urlencode(params)
|
|
|
|
try:
|
|
with downloader(url, self.settings) as manager:
|
|
result = manager.fetch(url, 'Error submitting usage information.')
|
|
except (DownloaderException) as e:
|
|
console_write(e, True)
|
|
return
|
|
|
|
try:
|
|
result = json.loads(result.decode('utf-8'))
|
|
if result['result'] != 'success':
|
|
raise ValueError()
|
|
except (ValueError):
|
|
console_write(u'Error submitting usage information for %s' % params['package'], True)
|