2016-06-18 18:14:02 -05:00
|
|
|
# ====================== BEGIN GPL LICENSE BLOCK ======================
|
|
|
|
#
|
|
|
|
# This program is free software; you can redistribute it and/or
|
|
|
|
# modify it under the terms of the GNU General Public License
|
|
|
|
# as published by the Free Software Foundation; either version 3
|
|
|
|
# of the License, or (at your option) any later version.
|
|
|
|
#
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
#
|
|
|
|
# ======================= END GPL LICENSE BLOCK ========================
|
|
|
|
|
|
|
|
import bpy
|
2016-06-24 09:18:09 -05:00
|
|
|
import addon_utils
|
2016-06-18 18:14:02 -05:00
|
|
|
import json
|
2016-06-24 09:18:09 -05:00
|
|
|
import logging
|
2016-06-24 13:54:27 -05:00
|
|
|
import os
|
2016-06-24 09:18:09 -05:00
|
|
|
import shutil
|
2016-06-26 17:42:21 -05:00
|
|
|
import asyncio
|
2016-06-24 13:54:27 -05:00
|
|
|
import urllib.request
|
|
|
|
import zipfile
|
2016-06-24 09:18:09 -05:00
|
|
|
from bpy.props import StringProperty
|
2016-06-18 18:14:02 -05:00
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
download_install_status = ""
|
2016-06-24 09:18:09 -05:00
|
|
|
log = logging.getLogger('networking')
|
2016-06-18 18:14:02 -05:00
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
INDEX_DOWNLOAD_URL = ("https://git.blender.org/gitweb/gitweb.cgi/"
|
|
|
|
"blender-package-manager-addon.git/blob_plain/HEAD:/addons/index.json")
|
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
class WM_OT_update_index(bpy.types.Operator):
|
|
|
|
"""Check for updated list of add-ons available for download"""
|
2016-06-18 18:14:02 -05:00
|
|
|
bl_idname = "wm.update_index"
|
2016-06-24 09:18:09 -05:00
|
|
|
bl_label = "Check for updated list of add-ons"
|
2016-06-26 17:42:21 -05:00
|
|
|
|
|
|
|
_timer = None
|
|
|
|
_redraw = False
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def poll(self, context):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Run operator only if an asynchronous download is not in progress."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
return (not download_install_status
|
|
|
|
or download_install_status == "Install successful"
|
|
|
|
or "failed" in download_install_status)
|
|
|
|
|
|
|
|
def __init__ (self):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Init some variables and ensure proper states on run."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
download_install_status = ""
|
|
|
|
|
|
|
|
self._redraw = False
|
|
|
|
|
|
|
|
self.loop = asyncio.get_event_loop()
|
|
|
|
self.loop.stop()
|
2016-06-18 18:14:02 -05:00
|
|
|
|
|
|
|
def execute(self, context):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Begin asynchronous execution and modal timer."""
|
2016-06-26 17:42:21 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
self.loop.stop()
|
2016-06-26 17:42:21 -05:00
|
|
|
self.status("Starting")
|
|
|
|
self.loop.run_in_executor(None, self.update_index)
|
|
|
|
|
|
|
|
wm = context.window_manager
|
|
|
|
wm.modal_handler_add(self)
|
|
|
|
self._timer = wm.event_timer_add(0.0001, context.window)
|
|
|
|
|
|
|
|
return {'RUNNING_MODAL'}
|
|
|
|
|
|
|
|
def modal(self, context, event):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Check status of list update and terminate operator when complete."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
|
|
|
|
if self._redraw:
|
|
|
|
context.area.tag_redraw()
|
|
|
|
self._redraw = False
|
|
|
|
|
|
|
|
if "failed" in download_install_status:
|
|
|
|
self.cancel(context)
|
|
|
|
return {'CANCELLED'}
|
|
|
|
|
|
|
|
if download_install_status == "":
|
|
|
|
self.cancel(context)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
|
|
return {'PASS_THROUGH'}
|
|
|
|
|
|
|
|
def cancel(self, context):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Ensure timer and loop are stopped before operator ends."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
self.loop.stop()
|
|
|
|
|
|
|
|
wm = context.window_manager
|
|
|
|
wm.event_timer_remove(self._timer)
|
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def status(self, text: str):
|
|
|
|
"""Change list update status for access from main thread, and redraw UI.
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
text -- new status
|
|
|
|
"""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
|
|
|
|
download_install_status = text
|
|
|
|
self._redraw = True
|
|
|
|
|
|
|
|
def update_index(self):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Download index.json and update add-on list."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
self.status("Downloading update")
|
|
|
|
index_file = self.download_index()
|
|
|
|
|
|
|
|
if index_file:
|
|
|
|
self.status("Processing response")
|
|
|
|
|
|
|
|
if self.parse_json(index_file):
|
|
|
|
self.status("")
|
|
|
|
return
|
|
|
|
|
|
|
|
self.status("Processing failed")
|
|
|
|
return
|
|
|
|
|
|
|
|
self.status("Update failed")
|
|
|
|
|
|
|
|
def download_index(self):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Download index.json and return it as a str, or return False if an
|
|
|
|
error occurs.
|
|
|
|
"""
|
|
|
|
|
2016-06-18 18:14:02 -05:00
|
|
|
try:
|
2016-06-24 13:54:27 -05:00
|
|
|
req = urllib.request.urlopen(INDEX_DOWNLOAD_URL)
|
2016-06-24 09:18:09 -05:00
|
|
|
index_file = req.read().decode('utf-8')
|
|
|
|
req.close()
|
2016-06-18 18:14:02 -05:00
|
|
|
except urllib.error.HTTPError as err:
|
2016-06-26 17:42:21 -05:00
|
|
|
log.warning("Error requesting update: %s %s", err.code, err.reason)
|
|
|
|
return False
|
|
|
|
except urllib.error.URLError as err:
|
|
|
|
log.warning("Download failed with URLError: %s", err)
|
|
|
|
return False
|
2016-06-18 18:14:02 -05:00
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
return index_file
|
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def parse_json(self, index_file: str) -> bool:
|
|
|
|
"""Parse JSON text and create add-on entries from data, return True on
|
|
|
|
success or False when an error occurs during parsing.
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
index_file -- JSON text
|
|
|
|
"""
|
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
# Parse downloaded file
|
2016-06-18 18:14:02 -05:00
|
|
|
try:
|
|
|
|
addon_list = json.loads(index_file)
|
2016-06-24 09:18:09 -05:00
|
|
|
except ValueError as err:
|
2016-06-26 17:42:21 -05:00
|
|
|
log.warning("JSON file could not parse. ValueError: %s", err)
|
|
|
|
return False
|
2016-06-18 18:14:02 -05:00
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
# Get the add-on preferences
|
|
|
|
prefs = bpy.context.user_preferences.addons.get("package_manager").preferences
|
|
|
|
|
|
|
|
# Clear previous list of add-ons
|
|
|
|
prefs.pm_addons.clear()
|
|
|
|
prefs.pm_addons_index = 0
|
|
|
|
|
|
|
|
user_path = bpy.utils.user_resource('SCRIPTS', path="addons")
|
|
|
|
installed = addon_utils.modules()
|
|
|
|
|
|
|
|
# Loop through every add-on in the parsed json
|
|
|
|
for name, content in addon_list["addons"].items():
|
2016-06-24 13:54:27 -05:00
|
|
|
# Skip add-ons not installed to USER path
|
2016-06-24 09:18:09 -05:00
|
|
|
# TODO: support above later
|
2016-06-26 22:42:07 -05:00
|
|
|
for a in set(installed):
|
2016-06-24 09:18:09 -05:00
|
|
|
if name == a.__name__ and user_path not in a.__file__:
|
2016-06-24 13:54:27 -05:00
|
|
|
log.info("Not listing add-on %s, as it is installed to "
|
2016-06-24 09:18:09 -05:00
|
|
|
"location other than USER path", name)
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
# Add new add-on to the list
|
|
|
|
addon = prefs.pm_addons.add()
|
|
|
|
|
|
|
|
self.load_addon_data(addon, name, content)
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
return True
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def load_addon_data(self, addon: PackageManagerAddon, module_name: str,
|
|
|
|
content: dict):
|
|
|
|
"""Load interpreted JSON data into a PackageManagerAddon object
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
addon -- PackageManagerAddon to populate
|
|
|
|
module_name -- module name of add-on
|
|
|
|
content -- dict with parsed JSON data
|
|
|
|
"""
|
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
addon.name = content["name"]
|
|
|
|
addon.blender = '.'.join(map(str, content["blender"]))
|
|
|
|
addon.module_name = module_name
|
2016-06-24 13:54:27 -05:00
|
|
|
addon.download_url = content["download_url"]
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
optional_keys = ["author", "category", "description", "location",
|
|
|
|
"source", "support", "tracker_url", "warning", "wiki_url"]
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
for key in optional_keys:
|
|
|
|
if key in content:
|
|
|
|
addon[key] = content[key]
|
2016-06-24 09:18:09 -05:00
|
|
|
|
|
|
|
if "version" in content:
|
|
|
|
# TODO: add multi-version functionality
|
|
|
|
addon.version = '.'.join(map(str, content["version"]))
|
|
|
|
|
|
|
|
|
|
|
|
class WM_OT_addon_download_install(bpy.types.Operator):
|
|
|
|
"""Download and install add-on"""
|
|
|
|
bl_idname = "wm.addon_download_install"
|
|
|
|
bl_label = "Download and install selected add-on"
|
|
|
|
|
|
|
|
addon = bpy.props.StringProperty()
|
2016-06-26 17:42:21 -05:00
|
|
|
|
|
|
|
_timer = None
|
|
|
|
_redraw = False
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def poll(self, context):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Run operator only if an asynchronous download is not in progress."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
return (not download_install_status
|
|
|
|
or download_install_status == "Install successful"
|
|
|
|
or "failed" in download_install_status)
|
|
|
|
|
|
|
|
def __init__ (self):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Init some variables and ensure proper states on run."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
download_install_status = ""
|
|
|
|
|
|
|
|
self._redraw = False
|
|
|
|
|
|
|
|
self.loop = asyncio.get_event_loop()
|
|
|
|
self.loop.stop()
|
2016-06-24 09:18:09 -05:00
|
|
|
|
|
|
|
def execute(self, context):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Perform verification on selected addon, then begin asynchronous
|
|
|
|
execution and modal timer.
|
|
|
|
"""
|
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
if self.addon is None:
|
|
|
|
return {'CANCELLED'}
|
|
|
|
|
|
|
|
# Get the add-on preferences
|
|
|
|
prefs = bpy.context.user_preferences.addons.get("package_manager").preferences
|
2016-06-24 13:54:27 -05:00
|
|
|
|
|
|
|
# Verify add-on is in list and find its download url
|
|
|
|
download_url = ""
|
2016-06-24 09:18:09 -05:00
|
|
|
for addon in prefs.pm_addons:
|
|
|
|
if addon.module_name == self.addon:
|
2016-06-24 13:54:27 -05:00
|
|
|
download_url = addon.download_url
|
2016-06-24 09:18:09 -05:00
|
|
|
break
|
|
|
|
else:
|
|
|
|
return {'CANCELLED'}
|
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
ext = os.path.splitext(download_url)[1]
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
# Download and install the selected add-on
|
2016-06-26 17:42:21 -05:00
|
|
|
self.loop.stop()
|
|
|
|
|
|
|
|
self.status("Starting")
|
|
|
|
|
|
|
|
self.loop.run_in_executor(None, self.download_and_install,
|
|
|
|
self.addon, download_url, ext)
|
|
|
|
|
|
|
|
wm = context.window_manager
|
|
|
|
wm.modal_handler_add(self)
|
|
|
|
self._timer = wm.event_timer_add(0.0001, context.window)
|
|
|
|
|
|
|
|
return {'RUNNING_MODAL'}
|
|
|
|
|
|
|
|
def modal(self, context, event):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Check status of download/install, and exit operator when complete."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
if self._redraw:
|
|
|
|
context.area.tag_redraw()
|
|
|
|
self._redraw = False
|
|
|
|
|
|
|
|
if "failed" in download_install_status:
|
|
|
|
self.cancel(context)
|
|
|
|
return {'CANCELLED'}
|
|
|
|
|
|
|
|
if download_install_status == "Install successful":
|
|
|
|
self.cancel(context)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
|
|
return {'PASS_THROUGH'}
|
|
|
|
|
|
|
|
def cancel(self, context):
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Ensure timer and loop are stopped before operator ends."""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
self.loop.stop()
|
|
|
|
|
|
|
|
wm = context.window_manager
|
|
|
|
wm.event_timer_remove(self._timer)
|
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def status(self, text: str):
|
|
|
|
"""Change install/download status for access from main thread, and
|
|
|
|
redraw UI.
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
text -- new status
|
|
|
|
"""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
global download_install_status
|
|
|
|
|
|
|
|
download_install_status = text
|
|
|
|
self._redraw = True
|
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def download_and_install (self, addon: str, download_url: str,
|
|
|
|
filetype: str):
|
|
|
|
"""Download add-on of filetype from download_url and install it.
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
addon -- module name of the add-on to install
|
|
|
|
download_url -- the url to download add-on from
|
|
|
|
filetype -- the filetype of the add-on, .py or .zip
|
|
|
|
"""
|
|
|
|
|
2016-06-26 17:42:21 -05:00
|
|
|
self.status("Downloading")
|
2016-06-26 22:00:03 -05:00
|
|
|
if self.download(download_url):
|
2016-06-26 17:42:21 -05:00
|
|
|
self.status("Installing")
|
|
|
|
if self.install(addon, filetype):
|
|
|
|
self.status("Install successful")
|
|
|
|
return
|
|
|
|
self.status("Install failed")
|
|
|
|
return
|
|
|
|
|
|
|
|
self.status("Download failed")
|
|
|
|
self._redraw = True
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def download(self, download_url: str):
|
|
|
|
"""Download add-on from download_url and save it to disk. Return False
|
|
|
|
on download error, or True if successful.
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
download_url -- the url to download add-on from
|
|
|
|
"""
|
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
filetype = os.path.splitext(download_url)[1]
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
download_path = bpy.utils.user_resource('SCRIPTS', path="addons/"
|
|
|
|
"package_manager/download%s"
|
|
|
|
% filetype)
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
# Download add-on and save to disk
|
2016-06-24 09:18:09 -05:00
|
|
|
try:
|
2016-06-24 13:54:27 -05:00
|
|
|
req = urllib.request.urlopen(download_url)
|
2016-06-24 09:18:09 -05:00
|
|
|
with open(download_path, 'wb') as download_file:
|
|
|
|
shutil.copyfileobj(req, download_file)
|
|
|
|
req.close()
|
|
|
|
except urllib.error.HTTPError as err:
|
2016-06-26 22:00:03 -05:00
|
|
|
log.warning("Download failed with HTTPError: %s %s", str(err.code),
|
|
|
|
err.reason)
|
2016-06-24 09:18:09 -05:00
|
|
|
return False
|
2016-06-26 17:42:21 -05:00
|
|
|
except urllib.error.URLError as err:
|
|
|
|
log.warning("Download failed with URLError: %s", err)
|
|
|
|
return False
|
2016-06-24 09:18:09 -05:00
|
|
|
|
|
|
|
return True
|
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
def install(self, addon: str, filetype: str):
|
|
|
|
"""Install downloaded add-on on disk to USER add-ons path. Return False
|
|
|
|
on installation error, or True if successful.
|
|
|
|
|
|
|
|
Keyword arguments:
|
|
|
|
addon -- module name of the add-on to install
|
|
|
|
filetype -- the filetype of the add-on, .py or .zip
|
|
|
|
"""
|
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
filename = addon + (filetype if filetype == ".py" else "")
|
2016-06-24 09:18:09 -05:00
|
|
|
|
2016-06-26 22:00:03 -05:00
|
|
|
download_path = bpy.utils.user_resource('SCRIPTS', path="addons/"
|
|
|
|
"package_manager/download%s"
|
|
|
|
% filetype)
|
2016-06-24 09:18:09 -05:00
|
|
|
addon_path = bpy.utils.user_resource('SCRIPTS', path="addons/%s" % filename)
|
|
|
|
|
2016-06-24 13:54:27 -05:00
|
|
|
# Copy downloaded add-on to USER scripts path
|
2016-06-26 22:00:03 -05:00
|
|
|
try:
|
|
|
|
if filetype == ".py":
|
|
|
|
shutil.move(download_path, addon_path)
|
|
|
|
elif filetype == ".zip":
|
|
|
|
# Remove existing add-on
|
|
|
|
if os.path.exists(addon_path):
|
|
|
|
shutil.rmtree(addon_path)
|
|
|
|
with zipfile.ZipFile(download_path,"r") as zipped_addon:
|
|
|
|
zipped_addon.extractall(bpy.utils.user_resource('SCRIPTS', path="addons"))
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
except Exception as err:
|
|
|
|
log.warning("Install failed: %s", err)
|
2016-06-26 17:42:21 -05:00
|
|
|
return False
|
2016-06-24 09:18:09 -05:00
|
|
|
|
|
|
|
return True
|
2016-06-18 18:14:02 -05:00
|
|
|
|
|
|
|
|
|
|
|
def register():
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Register operators."""
|
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
bpy.utils.register_class(WM_OT_update_index)
|
|
|
|
bpy.utils.register_class(WM_OT_addon_download_install)
|
2016-06-18 18:14:02 -05:00
|
|
|
|
|
|
|
def unregister():
|
2016-06-26 22:00:03 -05:00
|
|
|
"""Unregister operators."""
|
|
|
|
|
2016-06-24 09:18:09 -05:00
|
|
|
bpy.utils.unregister_class(WM_OT_update_index)
|
|
|
|
bpy.utils.unregister_class(WM_OT_addon_download_install)
|