revert master to blank slate
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,7 +1 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
|
||||||
|
|
||||||
.tox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
|
3813
addons/index.json
3813
addons/index.json
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
|||||||
# ====================== 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 ========================
|
|
||||||
|
|
||||||
bl_info = {
|
|
||||||
"name": "Package Manager",
|
|
||||||
"author": "Peter Cassetta",
|
|
||||||
"version": (1, 0),
|
|
||||||
"blender": (2, 77, 0),
|
|
||||||
"location": "User Preferences > Add-ons > System: Package Manager",
|
|
||||||
"description": "Download new add-ons and update current ones",
|
|
||||||
"support": 'TESTING',
|
|
||||||
"warning": "Early development",
|
|
||||||
"wiki_url": "",
|
|
||||||
"category": "System",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
import bpy
|
|
||||||
import addon_utils
|
|
||||||
from bpy.types import AddonPreferences
|
|
||||||
from bpy.props import StringProperty, IntProperty, CollectionProperty
|
|
||||||
|
|
||||||
# Support reloading
|
|
||||||
if "bpy" in locals():
|
|
||||||
import imp
|
|
||||||
try:
|
|
||||||
imp.reload(networking)
|
|
||||||
except NameError:
|
|
||||||
from . import networking
|
|
||||||
else:
|
|
||||||
from . import networking
|
|
||||||
|
|
||||||
|
|
||||||
class PackageManagerAddon(bpy.types.PropertyGroup):
|
|
||||||
"""PropertyGroup representing an add-on available for download."""
|
|
||||||
|
|
||||||
source = StringProperty()
|
|
||||||
name = StringProperty()
|
|
||||||
description = StringProperty()
|
|
||||||
author = StringProperty()
|
|
||||||
wiki_url = StringProperty()
|
|
||||||
tracker_url = StringProperty()
|
|
||||||
location = StringProperty()
|
|
||||||
category = StringProperty()
|
|
||||||
version = StringProperty()
|
|
||||||
blender = StringProperty()
|
|
||||||
warning = StringProperty()
|
|
||||||
support = StringProperty()
|
|
||||||
module_name = StringProperty()
|
|
||||||
download_url = StringProperty()
|
|
||||||
|
|
||||||
|
|
||||||
class PackageManagerPreferences(AddonPreferences):
|
|
||||||
"""Package Manager's add-on preferences.
|
|
||||||
|
|
||||||
Entire add-on functionality is available from its preferences panel.
|
|
||||||
"""
|
|
||||||
bl_idname = __name__
|
|
||||||
|
|
||||||
pm_addons = CollectionProperty(type=PackageManagerAddon)
|
|
||||||
pm_addons_index = IntProperty()
|
|
||||||
|
|
||||||
def draw(self, context):
|
|
||||||
"""Draw preferences UI."""
|
|
||||||
|
|
||||||
layout = self.layout
|
|
||||||
|
|
||||||
split = layout.split(percentage=1.0/3)
|
|
||||||
if (len(self.pm_addons) == 0 or networking.download_install_status in
|
|
||||||
("Processing response", "Downloading update",
|
|
||||||
"Processing failed", "Update failed")):
|
|
||||||
split.label(text=networking.download_install_status or
|
|
||||||
"Update add-on list.")
|
|
||||||
else:
|
|
||||||
split.label(text="Available add-ons:")
|
|
||||||
split.separator()
|
|
||||||
split.operator("wm.update_index", text="Update List", icon='FILE_REFRESH')
|
|
||||||
|
|
||||||
rows = 1 if len(self.pm_addons) == 0 else 4
|
|
||||||
layout.template_list("UI_UL_list", "addons_list", self, "pm_addons",
|
|
||||||
self, "pm_addons_index", rows=rows)
|
|
||||||
|
|
||||||
if len(self.pm_addons) == 0:
|
|
||||||
# No add-ons, return
|
|
||||||
return
|
|
||||||
|
|
||||||
# Display selected add-on
|
|
||||||
addon = self.pm_addons[self.pm_addons_index]
|
|
||||||
|
|
||||||
installed = any(module.__name__ == addon.module_name for module in addon_utils.modules())
|
|
||||||
|
|
||||||
col_box = layout.column()
|
|
||||||
box = col_box.box()
|
|
||||||
colsub = box.column()
|
|
||||||
|
|
||||||
split = colsub.row().split(percentage=0.25)
|
|
||||||
if installed and addon.version is not None:
|
|
||||||
split.label(text="Installed: Yes, v%s" % addon.version)
|
|
||||||
else:
|
|
||||||
split.label(text="Installed: No")
|
|
||||||
split.separator()
|
|
||||||
|
|
||||||
if (networking.download_install_status not in
|
|
||||||
("Processing response", "Downloading update",
|
|
||||||
"Processing failed", "Update failed")):
|
|
||||||
split.label(text=networking.download_install_status)
|
|
||||||
else:
|
|
||||||
split.separator()
|
|
||||||
|
|
||||||
split.operator("wm.addon_download_install",
|
|
||||||
text="Install from Web",
|
|
||||||
icon='URL').addon = addon.module_name
|
|
||||||
|
|
||||||
if addon.description:
|
|
||||||
split = colsub.row().split(percentage=0.15)
|
|
||||||
split.label(text="Description:")
|
|
||||||
split.label(text=addon.description)
|
|
||||||
if addon.location:
|
|
||||||
split = colsub.row().split(percentage=0.15)
|
|
||||||
split.label(text="Location:")
|
|
||||||
split.label(text=addon.location)
|
|
||||||
if addon.author:
|
|
||||||
split = colsub.row().split(percentage=0.15)
|
|
||||||
split.label(text="Author:")
|
|
||||||
split.label(text=addon.author, translate=False)
|
|
||||||
if addon.version:
|
|
||||||
split = colsub.row().split(percentage=0.15)
|
|
||||||
split.label(text="Version:")
|
|
||||||
split.label(text=addon.version, translate=False)
|
|
||||||
if addon.warning:
|
|
||||||
split = colsub.row().split(percentage=0.15)
|
|
||||||
split.label(text="Warning:")
|
|
||||||
split.label(text=' ' + addon.warning, icon='ERROR')
|
|
||||||
|
|
||||||
tot_row = bool(addon.wiki_url)
|
|
||||||
|
|
||||||
if tot_row:
|
|
||||||
split = colsub.row().split(percentage=0.15)
|
|
||||||
split.label(text="Internet:")
|
|
||||||
if addon.wiki_url:
|
|
||||||
split.operator("wm.url_open", text="Documentation", icon='HELP').url = addon.wiki_url
|
|
||||||
split.operator("wm.url_open", text="Report a Bug", icon='URL').url = addon.get(
|
|
||||||
"tracker_url",
|
|
||||||
"http://developer.blender.org/maniphest/task/create/?project=3&type=Bug")
|
|
||||||
|
|
||||||
for i in range(4 - tot_row):
|
|
||||||
split.separator()
|
|
||||||
|
|
||||||
|
|
||||||
def register():
|
|
||||||
"""Register classes, operators, and preferences."""
|
|
||||||
|
|
||||||
networking.register()
|
|
||||||
bpy.utils.register_class(PackageManagerAddon)
|
|
||||||
bpy.utils.register_class(PackageManagerPreferences)
|
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
|
||||||
"""Unregister classes, operators, and preferences."""
|
|
||||||
|
|
||||||
networking.unregister()
|
|
||||||
bpy.utils.unregister_class(PackageManagerAddon)
|
|
||||||
bpy.utils.unregister_class(PackageManagerPreferences)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
register()
|
|
@@ -1,423 +0,0 @@
|
|||||||
# ====================== 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
|
|
||||||
import addon_utils
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import asyncio
|
|
||||||
import urllib.request
|
|
||||||
import zipfile
|
|
||||||
from bpy.props import StringProperty
|
|
||||||
|
|
||||||
download_install_status = ""
|
|
||||||
log = logging.getLogger('networking')
|
|
||||||
|
|
||||||
INDEX_DOWNLOAD_URL = ("https://git.blender.org/gitweb/gitweb.cgi/"
|
|
||||||
"blender-package-manager-addon.git/blob_plain/HEAD:/addons/index.json")
|
|
||||||
|
|
||||||
class WM_OT_update_index(bpy.types.Operator):
|
|
||||||
"""Check for updated list of add-ons available for download"""
|
|
||||||
bl_idname = "wm.update_index"
|
|
||||||
bl_label = "Check for updated list of add-ons"
|
|
||||||
|
|
||||||
_timer = None
|
|
||||||
_redraw = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def poll(self, context):
|
|
||||||
"""Run operator only if an asynchronous download is not in progress."""
|
|
||||||
|
|
||||||
global download_install_status
|
|
||||||
return (not download_install_status
|
|
||||||
or download_install_status == "Install successful"
|
|
||||||
or "failed" in download_install_status)
|
|
||||||
|
|
||||||
def __init__ (self):
|
|
||||||
"""Init some variables and ensure proper states on run."""
|
|
||||||
|
|
||||||
global download_install_status
|
|
||||||
download_install_status = ""
|
|
||||||
|
|
||||||
self._redraw = False
|
|
||||||
|
|
||||||
self.loop = asyncio.get_event_loop()
|
|
||||||
self.loop.stop()
|
|
||||||
|
|
||||||
def execute(self, context):
|
|
||||||
"""Begin asynchronous execution and modal timer."""
|
|
||||||
|
|
||||||
self.loop.stop()
|
|
||||||
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):
|
|
||||||
"""Check status of list update and terminate operator when complete."""
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Ensure timer and loop are stopped before operator ends."""
|
|
||||||
|
|
||||||
self.loop.stop()
|
|
||||||
|
|
||||||
wm = context.window_manager
|
|
||||||
wm.event_timer_remove(self._timer)
|
|
||||||
|
|
||||||
def status(self, text: str):
|
|
||||||
"""Change list update status for access from main thread, and redraw UI.
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
text -- new status
|
|
||||||
"""
|
|
||||||
|
|
||||||
global download_install_status
|
|
||||||
|
|
||||||
download_install_status = text
|
|
||||||
self._redraw = True
|
|
||||||
|
|
||||||
def update_index(self):
|
|
||||||
"""Download index.json and update add-on list."""
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""Download index.json and return it as a str, or return False if an
|
|
||||||
error occurs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
req = urllib.request.urlopen(INDEX_DOWNLOAD_URL)
|
|
||||||
index_file = req.read().decode('utf-8')
|
|
||||||
req.close()
|
|
||||||
except urllib.error.HTTPError as err:
|
|
||||||
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
|
|
||||||
|
|
||||||
return index_file
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Parse downloaded file
|
|
||||||
try:
|
|
||||||
addon_list = json.loads(index_file)
|
|
||||||
except ValueError as err:
|
|
||||||
log.warning("JSON file could not parse. ValueError: %s", err)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 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():
|
|
||||||
# Skip add-ons not installed to USER path
|
|
||||||
# TODO: support above later
|
|
||||||
for a in set(installed):
|
|
||||||
if name == a.__name__ and user_path not in a.__file__:
|
|
||||||
log.info("Not listing add-on %s, as it is installed to "
|
|
||||||
"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)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
addon.name = content["name"]
|
|
||||||
addon.blender = '.'.join(map(str, content["blender"]))
|
|
||||||
addon.module_name = module_name
|
|
||||||
addon.download_url = content["download_url"]
|
|
||||||
|
|
||||||
optional_keys = ["author", "category", "description", "location",
|
|
||||||
"source", "support", "tracker_url", "warning", "wiki_url"]
|
|
||||||
|
|
||||||
for key in optional_keys:
|
|
||||||
if key in content:
|
|
||||||
addon[key] = content[key]
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
_timer = None
|
|
||||||
_redraw = False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def poll(self, context):
|
|
||||||
"""Run operator only if an asynchronous download is not in progress."""
|
|
||||||
|
|
||||||
global download_install_status
|
|
||||||
return (not download_install_status
|
|
||||||
or download_install_status == "Install successful"
|
|
||||||
or "failed" in download_install_status)
|
|
||||||
|
|
||||||
def __init__ (self):
|
|
||||||
"""Init some variables and ensure proper states on run."""
|
|
||||||
|
|
||||||
global download_install_status
|
|
||||||
download_install_status = ""
|
|
||||||
|
|
||||||
self._redraw = False
|
|
||||||
|
|
||||||
self.loop = asyncio.get_event_loop()
|
|
||||||
self.loop.stop()
|
|
||||||
|
|
||||||
def execute(self, context):
|
|
||||||
"""Perform verification on selected addon, then begin asynchronous
|
|
||||||
execution and modal timer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.addon is None:
|
|
||||||
return {'CANCELLED'}
|
|
||||||
|
|
||||||
# Get the add-on preferences
|
|
||||||
prefs = bpy.context.user_preferences.addons.get("package_manager").preferences
|
|
||||||
|
|
||||||
# Verify add-on is in list and find its download url
|
|
||||||
download_url = ""
|
|
||||||
for addon in prefs.pm_addons:
|
|
||||||
if addon.module_name == self.addon:
|
|
||||||
download_url = addon.download_url
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return {'CANCELLED'}
|
|
||||||
|
|
||||||
ext = os.path.splitext(download_url)[1]
|
|
||||||
|
|
||||||
# Download and install the selected add-on
|
|
||||||
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):
|
|
||||||
"""Check status of download/install, and exit operator when complete."""
|
|
||||||
|
|
||||||
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 == "Install successful":
|
|
||||||
self.cancel(context)
|
|
||||||
return {'FINISHED'}
|
|
||||||
|
|
||||||
return {'PASS_THROUGH'}
|
|
||||||
|
|
||||||
def cancel(self, context):
|
|
||||||
"""Ensure timer and loop are stopped before operator ends."""
|
|
||||||
|
|
||||||
self.loop.stop()
|
|
||||||
|
|
||||||
wm = context.window_manager
|
|
||||||
wm.event_timer_remove(self._timer)
|
|
||||||
|
|
||||||
def status(self, text: str):
|
|
||||||
"""Change install/download status for access from main thread, and
|
|
||||||
redraw UI.
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
text -- new status
|
|
||||||
"""
|
|
||||||
|
|
||||||
global download_install_status
|
|
||||||
|
|
||||||
download_install_status = text
|
|
||||||
self._redraw = True
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.status("Downloading")
|
|
||||||
if self.download(download_url):
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
filetype = os.path.splitext(download_url)[1]
|
|
||||||
|
|
||||||
download_path = bpy.utils.user_resource('SCRIPTS', path="addons/"
|
|
||||||
"package_manager/download%s"
|
|
||||||
% filetype)
|
|
||||||
|
|
||||||
# Download add-on and save to disk
|
|
||||||
try:
|
|
||||||
req = urllib.request.urlopen(download_url)
|
|
||||||
with open(download_path, 'wb') as download_file:
|
|
||||||
shutil.copyfileobj(req, download_file)
|
|
||||||
req.close()
|
|
||||||
except urllib.error.HTTPError as err:
|
|
||||||
log.warning("Download failed with HTTPError: %s %s", str(err.code),
|
|
||||||
err.reason)
|
|
||||||
return False
|
|
||||||
except urllib.error.URLError as err:
|
|
||||||
log.warning("Download failed with URLError: %s", err)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
filename = addon + (filetype if filetype == ".py" else "")
|
|
||||||
|
|
||||||
download_path = bpy.utils.user_resource('SCRIPTS', path="addons/"
|
|
||||||
"package_manager/download%s"
|
|
||||||
% filetype)
|
|
||||||
addon_path = bpy.utils.user_resource('SCRIPTS', path="addons/%s" % filename)
|
|
||||||
|
|
||||||
# Copy downloaded add-on to USER scripts path
|
|
||||||
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)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def register():
|
|
||||||
"""Register operators."""
|
|
||||||
|
|
||||||
bpy.utils.register_class(WM_OT_update_index)
|
|
||||||
bpy.utils.register_class(WM_OT_addon_download_install)
|
|
||||||
|
|
||||||
def unregister():
|
|
||||||
"""Unregister operators."""
|
|
||||||
|
|
||||||
bpy.utils.unregister_class(WM_OT_update_index)
|
|
||||||
bpy.utils.unregister_class(WM_OT_addon_download_install)
|
|
@@ -1,211 +0,0 @@
|
|||||||
# ====================== 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 ========================
|
|
||||||
#
|
|
||||||
# This script is a prototype which generates a JSON file
|
|
||||||
# for use in the package manager add-on.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import ast
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
import urllib.parse
|
|
||||||
import json
|
|
||||||
|
|
||||||
logging.basicConfig(format='%(asctime)-15s %(levelname)8s %(name)s %(message)s',
|
|
||||||
level=logging.INFO)
|
|
||||||
log = logging.getLogger('generate-json')
|
|
||||||
|
|
||||||
REQUIRED_KEYS = ('name', 'blender')
|
|
||||||
RECOMMENDED_KEYS = ('author', 'description', 'location', 'wiki_url', 'category')
|
|
||||||
CURRENT_SCHEMA_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
def iter_addons(addons_dir: str) -> (str, str, str):
|
|
||||||
"""Generator, yields IDs and filenames of addons.
|
|
||||||
|
|
||||||
If the addon is a package, yields its __init__.py as filename.
|
|
||||||
"""
|
|
||||||
|
|
||||||
for item in os.scandir(addons_dir):
|
|
||||||
if item.name.startswith('.'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
base, ext = os.path.splitext(item.name)
|
|
||||||
|
|
||||||
if item.is_dir():
|
|
||||||
fname = os.path.join(item.path, '__init__.py')
|
|
||||||
if not os.path.exists(fname):
|
|
||||||
log.info('Skipping %s, it does not seem to be a Python package', item.path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
yield (base, fname, '.zip')
|
|
||||||
else:
|
|
||||||
yield (base, item.path, '.py')
|
|
||||||
|
|
||||||
|
|
||||||
def parse_blinfo(addon_fname: str) -> dict:
|
|
||||||
"""Parses a Python file, returning its bl_info dict.
|
|
||||||
|
|
||||||
Returns None if the file doesn't contain a bl_info dict.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug('Parsing %s', addon_fname)
|
|
||||||
|
|
||||||
with open(addon_fname) as infile:
|
|
||||||
try:
|
|
||||||
source = infile.read()
|
|
||||||
except UnicodeDecodeError as ex:
|
|
||||||
log.warning('Skipping addon: UnicodeDecodeError in %s: %s', addon_fname, ex)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
tree = ast.parse(source, addon_fname)
|
|
||||||
except SyntaxError as ex:
|
|
||||||
log.warning('Skipping addon: SyntaxError in %s: %s', addon_fname, ex)
|
|
||||||
return None
|
|
||||||
|
|
||||||
for body in tree.body:
|
|
||||||
if body.__class__ != ast.Assign:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if len(body.targets) != 1:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if getattr(body.targets[0], 'id', '') != 'bl_info':
|
|
||||||
continue
|
|
||||||
|
|
||||||
return ast.literal_eval(body.value)
|
|
||||||
|
|
||||||
log.warning('Unable to find bl_info dict in %s', addon_fname)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def blinfo_to_json(bl_info, addon_id, source, url) -> dict:
|
|
||||||
"""Augments the bl_info dict with information for the package manager.
|
|
||||||
|
|
||||||
Also checks for missing required/recommended keys.
|
|
||||||
|
|
||||||
:returns: the augmented dict, or None if there were missing required keys.
|
|
||||||
"""
|
|
||||||
|
|
||||||
missing_req_keys = [key for key in REQUIRED_KEYS
|
|
||||||
if key not in bl_info]
|
|
||||||
if missing_req_keys:
|
|
||||||
log.warning('Addon %s misses required key(s) %s; skipping this addon.',
|
|
||||||
addon_id, ', '.join(missing_req_keys))
|
|
||||||
return None
|
|
||||||
|
|
||||||
missing_rec_keys = [key for key in RECOMMENDED_KEYS
|
|
||||||
if key not in bl_info]
|
|
||||||
if missing_rec_keys:
|
|
||||||
log.info('Addon %s misses recommended key(s) %s',
|
|
||||||
addon_id, ', '.join(missing_rec_keys))
|
|
||||||
|
|
||||||
json_data = bl_info.copy()
|
|
||||||
json_data.update({
|
|
||||||
'download_url': url,
|
|
||||||
'source': source,
|
|
||||||
})
|
|
||||||
|
|
||||||
return json_data
|
|
||||||
|
|
||||||
|
|
||||||
def parse_addons(addons_dir: str, addons_source: str, addons_base_url: str) -> dict:
|
|
||||||
"""Parses info of all addons in the given directory."""
|
|
||||||
|
|
||||||
json_data = {}
|
|
||||||
|
|
||||||
for (addon_id, addon_fname, addon_ext) in iter_addons(addons_dir):
|
|
||||||
bl_info = parse_blinfo(addon_fname)
|
|
||||||
if bl_info is None:
|
|
||||||
# The reason why has already been logged.
|
|
||||||
continue
|
|
||||||
|
|
||||||
url = urllib.parse.urljoin(addons_base_url, addon_id + addon_ext)
|
|
||||||
as_json = blinfo_to_json(bl_info, addon_id, addons_source, url)
|
|
||||||
if as_json is None:
|
|
||||||
# The reason why has already been logged.
|
|
||||||
continue
|
|
||||||
|
|
||||||
json_data[addon_id] = as_json
|
|
||||||
|
|
||||||
return json_data
|
|
||||||
|
|
||||||
|
|
||||||
def parse_existing_index(index_fname: str) -> dict:
|
|
||||||
"""Parses an existing index JSON file, returning its 'addons' dict.
|
|
||||||
|
|
||||||
Raises a ValueError if the schema version is unsupported.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.info('Reading existing %s', index_fname)
|
|
||||||
|
|
||||||
with open(index_fname, 'r', encoding='utf8') as infile:
|
|
||||||
existing_data = json.load(infile)
|
|
||||||
|
|
||||||
# Check the schema version.
|
|
||||||
schema_version = existing_data.get('schema-version', '-missing-')
|
|
||||||
if schema_version != CURRENT_SCHEMA_VERSION:
|
|
||||||
log.fatal('Unable to load existing data, wrong schema version: %s',
|
|
||||||
schema_version)
|
|
||||||
raise ValueError('Unsupported schema %s' % schema_version)
|
|
||||||
|
|
||||||
addon_data = existing_data['addons']
|
|
||||||
return addon_data
|
|
||||||
|
|
||||||
|
|
||||||
def write_index_file(index_fname: str, addon_data: dict):
|
|
||||||
"""Writes the index JSON file."""
|
|
||||||
|
|
||||||
log.info('Writing addon index to %s', index_fname)
|
|
||||||
with open(index_fname, 'w', encoding='utf8') as outfile:
|
|
||||||
json.dump(addon_data, outfile, indent=4, sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='Generate index.json from addons dir.')
|
|
||||||
|
|
||||||
parser.add_argument('--merge', action='store_true', default=False,
|
|
||||||
help='merge with any existing index.json file')
|
|
||||||
parser.add_argument('--source', nargs='?', type=str, default='internal',
|
|
||||||
help='set the source of the addons')
|
|
||||||
parser.add_argument('--base', nargs='?', type=str, default='https://packages.blender.org/',
|
|
||||||
help='set the base download URL of the addons')
|
|
||||||
parser.add_argument('dir', metavar='DIR', type=str,
|
|
||||||
help='addons directory')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Load the existing index.json if requested.
|
|
||||||
if args.merge:
|
|
||||||
addon_data = parse_existing_index('index.json')
|
|
||||||
else:
|
|
||||||
addon_data = {}
|
|
||||||
|
|
||||||
new_addon_data = parse_addons(args.dir, args.source, args.base)
|
|
||||||
addon_data.update(new_addon_data)
|
|
||||||
|
|
||||||
final_json = {
|
|
||||||
'schema-version': CURRENT_SCHEMA_VERSION,
|
|
||||||
'addons': addon_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
write_index_file('index.json', final_json)
|
|
||||||
log.info('Done!')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
Reference in New Issue
Block a user