1
1

Compare commits

...

67 Commits

Author SHA1 Message Date
gandalf3
c24bb59a44 Handle special categories 2017-08-29 06:30:31 -07:00
gandalf3
b35e5240f6 fix utils 2017-08-29 06:11:47 -07:00
gandalf3
0a5353e853 documentation 2017-08-29 05:15:45 -07:00
gandalf3
74a19feba8 pep8-ify bpkg/types.py 2017-08-29 05:12:39 -07:00
gandalf3
0f17f1937f Use isinstance 2017-08-29 05:10:11 -07:00
gandalf3
280b80349a pep8-ify bpkg/subproc.py 2017-08-29 05:01:45 -07:00
gandalf3
8e815f4ce2 Use typing.Optional 2017-08-29 04:59:11 -07:00
gandalf3
e881a0e2a4 Document non-none types 2017-08-29 04:51:13 -07:00
gandalf3
03a4bd6132 More precise error messages 2017-08-29 04:45:14 -07:00
gandalf3
1ab4d5fec4 Document reason for dict conversion 2017-08-29 04:39:08 -07:00
gandalf3
2be8b4de2a Write own error message 2017-08-29 02:50:19 -07:00
gandalf3
1d6bcff706 Disable uninstall buttons on addons installed outside user dir 2017-08-29 02:45:10 -07:00
gandalf3
7fbb720265 Remove unneccesary None 2017-08-29 01:56:07 -07:00
gandalf3
b259a8597c Build list once 2017-08-29 01:54:54 -07:00
gandalf3
2cccd9341b Actually display progress 2017-08-29 01:50:58 -07:00
gandalf3
c2ed145322 pep8-ify bpkg/messages.py 2017-08-29 00:24:00 -07:00
gandalf3
a2301ec260 Remove unused message, make sure all errors inherit from SubprocError 2017-08-29 00:23:28 -07:00
gandalf3
688cb2d6e0 Clean up bpkg/display.py 2017-08-29 00:20:36 -07:00
gandalf3
ce0396c878 pep8-ify bpkg/actions.py 2017-08-29 00:17:10 -07:00
gandalf3
01a4ea98c5 Remove unneccesary parens 2017-08-29 00:09:43 -07:00
gandalf3
6c3786713e Split code for installing zipped and unzipped packages into separate functions 2017-08-29 00:05:41 -07:00
gandalf3
90ea56b2fb Break exception chain 2017-08-28 23:50:47 -07:00
gandalf3
b62b62d51b Remove old commented code 2017-08-28 23:43:36 -07:00
gandalf3
16da5d84c0 Wait until we've determined url is a real url to derive filenames from it 2017-08-28 23:41:54 -07:00
gandalf3
6d382129f9 Make sure classes tuple is only set when it should be 2017-08-28 23:16:52 -07:00
gandalf3
b9338dde5a Improve docstring 2017-08-28 22:54:39 -07:00
gandalf3
b42bee90f9 Improve commentation 2017-08-28 22:52:28 -07:00
gandalf3
552545d6a4 Remove old commented code 2017-08-28 22:50:21 -07:00
gandalf3
705695bf4d pep8-ify bl_operators/package.py 2017-08-28 22:46:30 -07:00
gandalf3
0c4fd02c27 pep8-ify bpkg/__init__.py 2017-08-28 22:43:11 -07:00
gandalf3
ba99e26e7f Remove fold markers 2017-08-28 22:40:04 -07:00
gandalf3
d5daa2705a Bring back sorting 2017-08-28 22:36:23 -07:00
gandalf3
6be55223dc Improve documentation and remove extraneous function 2017-08-28 22:35:54 -07:00
gandalf3
da7efd96f7 Package name is not allowed to be None, no need to check for it 2017-08-28 22:28:32 -07:00
gandalf3
1bc66c6397 Remove old OrderedDict 2017-08-28 22:25:01 -07:00
gandalf3
6058b5cbaa Clarify TODO note 2017-08-28 22:23:28 -07:00
gandalf3
233d8f2faf Remove old loggers, document refresh parameter 2017-08-28 22:20:24 -07:00
gandalf3
0415d04617 Move enable/disable into Package class 2017-08-28 22:16:39 -07:00
gandalf3
abd9c4ed11 Remove old debug logging and excess whitespace 2017-08-28 22:03:30 -07:00
gandalf3
08ce79dd85 Shorten if, remove commented code 2017-08-28 21:59:32 -07:00
gandalf3
3907c90c53 Remove commented code 2017-08-28 21:57:08 -07:00
gandalf3
ab139c834d Override _finish instead of cancel 2017-08-28 21:56:00 -07:00
gandalf3
f226dd3030 simplify if 2017-08-28 21:54:22 -07:00
gandalf3
94c48c8f92 Simplify package list refreshing
Currently no need for caching system, just regenerate it when asked
2017-08-28 21:49:16 -07:00
gandalf3
684d870dc4 Replace hardcoded 10s with constant 2017-08-28 21:10:08 -07:00
gandalf3
480060696d Remove old abort method 2017-08-28 21:02:53 -07:00
gandalf3
bfd4b73707 Remove fold markers 2017-08-28 21:00:42 -07:00
gandalf3
88756edd2a Update comment 2017-08-28 20:59:13 -07:00
gandalf3
c9ec8ba88f Add explanation for multiprocessing context 2017-08-28 20:57:24 -07:00
gandalf3
a17a32e6ef Remove commented code 2017-08-28 20:50:53 -07:00
gandalf3
65a44d64c1 Fix update display, get rid of unnecessary ifs in filters 2017-08-28 18:35:10 -07:00
gandalf3
f5428b3213 Fix a number of issues caused by new repository representation 2017-08-28 18:05:07 -07:00
gandalf3
6debd2134a remove print statement 2017-08-28 16:06:32 -07:00
gandalf3
9cec3f7212 Use EnumProperty for repository list
Make UIlist editable and move it to a dialog
2017-08-28 16:04:43 -07:00
gandalf3
956d8e790c Revert accidentally committed partial change 2017-08-28 01:07:35 -07:00
gandalf3
47c68a54ad Shorten import 2017-08-27 22:22:34 -07:00
gandalf3
dbe6331a3b Refactor Package class
* Use property decorator
* Display an error when encountering an incorrectly formatted bl_info
* General cleanup
2017-08-27 22:14:18 -07:00
gandalf3
339c51ab17 Move install/download code out of utils 2017-08-27 16:26:08 -07:00
gandalf3
9f34503f2f Un-remove accidentally removed function 2017-08-27 14:48:45 -07:00
gandalf3
fca155031f Only show enable/disable operator for installed packages 2017-08-27 14:40:40 -07:00
gandalf3
ed3eb48729 Don't rebuild package list every draw() 2017-08-27 14:36:06 -07:00
gandalf3
b0d9153dd3 Remove commented code and fix some stuff broken by de-addonification 2017-08-27 14:25:17 -07:00
gandalf3
6a21120023 Hide addons section 2017-08-26 17:18:23 -07:00
gandalf3
e34888b80d Merge branch 'master' of git.blender.org:blender into soc-2017-package_manager 2017-08-26 02:09:25 -07:00
gandalf3
0ff4627729 Move package manager code out of addon
Code from addon repository: https://developer.blender.org/diffusion/BPMA/
2017-08-26 02:07:18 -07:00
gandalf3
d81cabbaa8 Merge branch 'master' of git.blender.org:blender into soc-2017-package_manager 2017-08-02 23:00:24 -07:00
ab1c980914 Add a tab to user preferences
Not sure if this is fully correct; getting crashes with non-factory
settings.
2017-06-10 00:01:05 -07:00
18 changed files with 2890 additions and 6 deletions

View File

@@ -0,0 +1,86 @@
from . import utils
from . import types
from . import display
from . import exceptions
from pathlib import Path
import logging
# Global package dict, keyed by package name. Use refresh_packages() to update it
packages = {}
def get_repo_storage_path() -> Path:
"""Return Path to the directory in which downloaded repository indices are
stored"""
import bpy
return Path(bpy.utils.user_resource('CONFIG', 'repositories'))
def get_repositories() -> list:
"""
Get list of downloaded repositories and update wm.package_repositories
"""
storage_path = get_repo_storage_path()
repos = utils.load_repositories(storage_path)
return repos
def refresh_repository_props():
"""Create RepositoryProperty collection from repository files"""
# TODO: store repository props in .blend so enabled/disabled state can be remembered
import bpy
wm = bpy.context.window_manager
repos = get_repositories()
wm.package_repositories.clear()
for repo in repos:
repo_prop = wm.package_repositories.add()
repo_prop.name = repo.name
repo_prop.enabled = True
repo_prop.url = repo.url
repo_prop.filepath = str(repo.filepath)
def get_installed_packages(refresh=False) -> list:
"""Get list of packages installed on disk. If refresh == True, re-scan for new packages"""
import addon_utils
installed_pkgs = []
# TODO: Do recursive search for packages to allow for user-customized addon
# layout on filesystem. Just use addon_utils for now
for mod in addon_utils.modules(refresh=refresh):
try:
pkg = types.Package.from_module(mod)
except exceptions.PackageException as err:
msg = "Error parsing package \"{}\" ({}): {}".format(
mod.__name__, mod.__file__, err)
display.pkg_errors.append(msg)
else:
pkg.installed = True
installed_pkgs.append(pkg)
return installed_pkgs
def refresh_packages():
"""Update bpkg.packages, a dict of ConsolidatedPackages from known repositories and
installed packages, keyed by package name"""
global packages
masterlist = {}
display.pkg_errors.clear()
installed_packages = get_installed_packages(refresh=True)
known_repositories = get_repositories()
for repo in known_repositories:
for pkg in repo.packages:
pkg.repositories.add(repo)
if pkg.name in masterlist:
masterlist[pkg.name].add_version(pkg)
else:
masterlist[pkg.name] = types.ConsolidatedPackage(pkg)
for pkg in installed_packages:
if pkg.name in masterlist:
masterlist[pkg.name].add_version(pkg)
else:
masterlist[pkg.name] = types.ConsolidatedPackage(pkg)
packages = masterlist

View File

@@ -0,0 +1,158 @@
from pathlib import Path
from . import exceptions
from . import utils
import shutil
import logging
def download(url: str, destination: Path, progress_callback=None) -> Path:
"""
Downloads file at the given url, and if progress_callback is specified,
repeatedly calls progress_callback with an argument between 0 and 1, or
infinity if progress cannot be determined. Raises DownloadException if an
error occurs with the download.
:returns: path to the downloaded file, or None if not modified
"""
import requests
log = logging.getLogger('%s.download' % __name__)
if progress_callback is None:
# assign to do-nothing function
def progress_callback(x): return None
progress_callback(0)
log.info('Downloading %s ', url)
resp = requests.get(url, stream=True, verify=True)
try:
resp.raise_for_status()
except requests.HTTPError as err:
raise exceptions.DownloadException(resp.status_code, str(err))
if resp.status_code == requests.codes.not_modified:
log.info("Server responded 'Not Modified', not downloading")
progress_callback(1)
return None
# determine destination filename from url, but only after we've determined it works as a real url
# derive filename from url if given `destination` is an existing directory,
# otherwise use `destination` directly
if destination.is_dir():
# TODO: get filename from Content-Disposition header, if available.
from urllib.parse import urlsplit, urlunsplit
parsed_url = urlsplit(url)
local_filename = Path(parsed_url.path).name or 'download.tmp'
local_fpath = destination / local_filename
else:
local_fpath = destination
try:
content_length = int(resp.headers['content-length'])
except KeyError:
log.warning(
'Server did not send content length, cannot report progress.')
content_length = 0
try:
downloaded_length = 0
with local_fpath.open('wb') as outfile:
for chunk in resp.iter_content(chunk_size=1024 ** 2):
if not chunk: # filter out keep-alive new chunks
continue
outfile.write(chunk)
downloaded_length += len(chunk)
try:
progress_callback(downloaded_length / content_length)
except ZeroDivisionError:
pass
except OSError as err:
raise exceptions.DownloadException("Encountered an error while writing file to '%s', are you sure there's enough space?" % local_fpath) from err
except PermissionError as err:
raise exceptions.DownloadException("No permissions to write to '%s'" % local_fpath)
progress_callback(1)
return local_fpath
def install(src_file: Path, dest_dir: Path):
"""Extracts/moves package at `src_file` to `dest_dir`"""
import zipfile
log = logging.getLogger('%s.install' % __name__)
log.error("Starting installation")
if not src_file.is_file():
raise exceptions.InstallException("Package isn't a file")
if not dest_dir.is_dir():
raise exceptions.InstallException("Destination is not a directory")
# TODO: check to make sure addon/package isn't already installed elsewhere
def install_zip(src_zip, dest_dir):
"""Extract src_zip to dest_dir"""
try:
file_to_extract = zipfile.ZipFile(str(src_zip), 'r')
except Exception as err:
raise exceptions.InstallException(
"Failed to read zip file: %s" % err) from err
def root_files(filelist: list) -> list:
"""Some string parsing to get a list of the root contents of a zip from its namelist"""
rootlist = []
for f in filelist:
# Get all names which have no path separators (root level files)
# or have a single path separator at the end (root level directories).
if len(f.rstrip('/').split('/')) == 1:
rootlist.append(f)
return rootlist
conflicts = [
dest_dir / f for f in root_files(file_to_extract.namelist()) if (dest_dir / f).exists()]
backups = []
for conflict in conflicts:
log.debug("Creating backup of conflict %s", conflict)
backups.append(utils.InplaceBackup(conflict))
try:
file_to_extract.extractall(str(dest_dir))
except Exception as err:
for backup in backups:
backup.restore()
raise exceptions.InstallException(
"Failed to extract zip file to '%s': %s" % (dest_dir, err)) from err
for backup in backups:
backup.remove()
def install_py(src_file, dest_dir):
"""Move src_file to dest_dir)"""
dest_file = dest_dir / src_file.name
backup = None
if dest_file.exists():
backup = utils.InplaceBackup(dest_file)
try:
shutil.copyfile(str(src_file), str(dest_file))
except Exception as err:
backup.restore()
raise exceptions.InstallException(
"Failed to copy file to '%s': %s" % (dest_dir, err)) from err
if backup:
backup.remove()
if zipfile.is_zipfile(str(src_file)):
install_zip(src_file, dest_dir)
else:
install_py(src_file, dest_dir)
log.debug("Installation succeeded")

View File

@@ -0,0 +1,552 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2005-2010 ActiveState Software Inc.
# Copyright (c) 2013 Eddy Petrișor
"""Utilities for determining application-specific dirs.
See <http://github.com/ActiveState/appdirs> for details and usage.
"""
# Dev Notes:
# - MSDN on where to store app data files:
# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
__version_info__ = (1, 4, 0)
__version__ = '.'.join(map(str, __version_info__))
import sys
import os
PY3 = sys.version_info[0] == 3
if PY3:
unicode = str
if sys.platform.startswith('java'):
import platform
os_name = platform.java_ver()[3][0]
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
system = 'win32'
elif os_name.startswith('Mac'): # "Mac OS X", etc.
system = 'darwin'
else: # "Linux", "SunOS", "FreeBSD", etc.
# Setting this to "linux2" is not ideal, but only Windows or Mac
# are actually checked for and the rest of the module expects
# *sys.platform* style strings.
system = 'linux2'
else:
system = sys.platform
def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
r"""Return full path to the user-specific data dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"roaming" (boolean, default False) can be set True to use the Windows
roaming appdata directory. That means that for users on a Windows
network setup for roaming profiles, this user data will be
sync'd on login. See
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
for a discussion of issues.
Typical user data directories are:
Mac OS X: ~/Library/Application Support/<AppName>
Unix: ~/.local/share/<AppName> # or in $XDG_DATA_HOME, if defined
Win XP (not roaming): C:\Documents and Settings\<username>\Application Data\<AppAuthor>\<AppName>
Win XP (roaming): C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>
Win 7 (not roaming): C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>
Win 7 (roaming): C:\Users\<username>\AppData\Roaming\<AppAuthor>\<AppName>
For Unix, we follow the XDG spec and support $XDG_DATA_HOME.
That means, by default "~/.local/share/<AppName>".
"""
if system == "win32":
if appauthor is None:
appauthor = appname
const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA"
path = os.path.normpath(_get_win_folder(const))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
elif system == 'darwin':
path = os.path.expanduser('~/Library/Application Support/')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
"""Return full path to the user-shared data dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"multipath" is an optional parameter only applicable to *nix
which indicates that the entire list of data dirs should be
returned. By default, the first item from XDG_DATA_DIRS is
returned, or '/usr/local/share/<AppName>',
if XDG_DATA_DIRS is not set
Typical user data directories are:
Mac OS X: /Library/Application Support/<AppName>
Unix: /usr/local/share/<AppName> or /usr/share/<AppName>
Win XP: C:\Documents and Settings\All Users\Application Data\<AppAuthor>\<AppName>
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
Win 7: C:\ProgramData\<AppAuthor>\<AppName> # Hidden, but writeable on Win 7.
For Unix, this is using the $XDG_DATA_DIRS[0] default.
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
"""
if system == "win32":
if appauthor is None:
appauthor = appname
path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA"))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
elif system == 'darwin':
path = os.path.expanduser('/Library/Application Support')
if appname:
path = os.path.join(path, appname)
else:
# XDG default for $XDG_DATA_DIRS
# only first, if multipath is False
path = os.getenv('XDG_DATA_DIRS',
os.pathsep.join(['/usr/local/share', '/usr/share']))
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
if appname:
if version:
appname = os.path.join(appname, version)
pathlist = [os.sep.join([x, appname]) for x in pathlist]
if multipath:
path = os.pathsep.join(pathlist)
else:
path = pathlist[0]
return path
if appname and version:
path = os.path.join(path, version)
return path
def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
r"""Return full path to the user-specific config dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"roaming" (boolean, default False) can be set True to use the Windows
roaming appdata directory. That means that for users on a Windows
network setup for roaming profiles, this user data will be
sync'd on login. See
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
for a discussion of issues.
Typical user data directories are:
Mac OS X: same as user_data_dir
Unix: ~/.config/<AppName> # or in $XDG_CONFIG_HOME, if defined
Win *: same as user_data_dir
For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME.
That means, by deafult "~/.config/<AppName>".
"""
if system in ["win32", "darwin"]:
path = user_data_dir(appname, appauthor, None, roaming)
else:
path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
def site_config_dir(appname=None, appauthor=None, version=None, multipath=False):
"""Return full path to the user-shared data dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"multipath" is an optional parameter only applicable to *nix
which indicates that the entire list of config dirs should be
returned. By default, the first item from XDG_CONFIG_DIRS is
returned, or '/etc/xdg/<AppName>', if XDG_CONFIG_DIRS is not set
Typical user data directories are:
Mac OS X: same as site_data_dir
Unix: /etc/xdg/<AppName> or $XDG_CONFIG_DIRS[i]/<AppName> for each value in
$XDG_CONFIG_DIRS
Win *: same as site_data_dir
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
"""
if system in ["win32", "darwin"]:
path = site_data_dir(appname, appauthor)
if appname and version:
path = os.path.join(path, version)
else:
# XDG default for $XDG_CONFIG_DIRS
# only first, if multipath is False
path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
if appname:
if version:
appname = os.path.join(appname, version)
pathlist = [os.sep.join([x, appname]) for x in pathlist]
if multipath:
path = os.pathsep.join(pathlist)
else:
path = pathlist[0]
return path
def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
r"""Return full path to the user-specific cache dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"opinion" (boolean) can be False to disable the appending of
"Cache" to the base app data dir for Windows. See
discussion below.
Typical user cache directories are:
Mac OS X: ~/Library/Caches/<AppName>
Unix: ~/.cache/<AppName> (XDG default)
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Cache
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Cache
On Windows the only suggestion in the MSDN docs is that local settings go in
the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
app data dir (the default returned by `user_data_dir` above). Apps typically
put cache data somewhere *under* the given dir here. Some examples:
...\Mozilla\Firefox\Profiles\<ProfileName>\Cache
...\Acme\SuperApp\Cache\1.0
OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
This can be disabled with the `opinion=False` option.
"""
if system == "win32":
if appauthor is None:
appauthor = appname
path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
if opinion:
path = os.path.join(path, "Cache")
elif system == 'darwin':
path = os.path.expanduser('~/Library/Caches')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
if appname:
path = os.path.join(path, appname.lower().replace(' ', '-'))
if appname and version:
path = os.path.join(path, version)
return path
def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
r"""Return full path to the user-specific log dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"opinion" (boolean) can be False to disable the appending of
"Logs" to the base app data dir for Windows, and "log" to the
base cache dir for Unix. See discussion below.
Typical user cache directories are:
Mac OS X: ~/Library/Logs/<AppName>
Unix: ~/.cache/<AppName>/log # or under $XDG_CACHE_HOME if defined
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Logs
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Logs
On Windows the only suggestion in the MSDN docs is that local settings
go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in
examples of what some windows apps use for a logs dir.)
OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA`
value for Windows and appends "log" to the user cache dir for Unix.
This can be disabled with the `opinion=False` option.
"""
if system == "darwin":
path = os.path.join(
os.path.expanduser('~/Library/Logs'),
appname)
elif system == "win32":
path = user_data_dir(appname, appauthor, version)
version = False
if opinion:
path = os.path.join(path, "Logs")
else:
path = user_cache_dir(appname, appauthor, version)
version = False
if opinion:
path = os.path.join(path, "log")
if appname and version:
path = os.path.join(path, version)
return path
class AppDirs(object):
"""Convenience wrapper for getting application dirs."""
def __init__(self, appname, appauthor=None, version=None, roaming=False,
multipath=False):
self.appname = appname
self.appauthor = appauthor
self.version = version
self.roaming = roaming
self.multipath = multipath
@property
def user_data_dir(self):
return user_data_dir(self.appname, self.appauthor,
version=self.version, roaming=self.roaming)
@property
def site_data_dir(self):
return site_data_dir(self.appname, self.appauthor,
version=self.version, multipath=self.multipath)
@property
def user_config_dir(self):
return user_config_dir(self.appname, self.appauthor,
version=self.version, roaming=self.roaming)
@property
def site_config_dir(self):
return site_config_dir(self.appname, self.appauthor,
version=self.version, multipath=self.multipath)
@property
def user_cache_dir(self):
return user_cache_dir(self.appname, self.appauthor,
version=self.version)
@property
def user_log_dir(self):
return user_log_dir(self.appname, self.appauthor,
version=self.version)
#---- internal support stuff
def _get_win_folder_from_registry(csidl_name):
"""This is a fallback technique at best. I'm not sure if using the
registry for this guarantees us the correct answer for all CSIDL_*
names.
"""
import _winreg
shell_folder_name = {
"CSIDL_APPDATA": "AppData",
"CSIDL_COMMON_APPDATA": "Common AppData",
"CSIDL_LOCAL_APPDATA": "Local AppData",
}[csidl_name]
key = _winreg.OpenKey(
_winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
)
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
return dir
def _get_win_folder_with_pywin32(csidl_name):
from win32com.shell import shellcon, shell
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
# Try to make this a unicode path because SHGetFolderPath does
# not return unicode strings when there is unicode data in the
# path.
try:
dir = unicode(dir)
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in dir:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
try:
import win32api
dir = win32api.GetShortPathName(dir)
except ImportError:
pass
except UnicodeError:
pass
return dir
def _get_win_folder_with_ctypes(csidl_name):
import ctypes
csidl_const = {
"CSIDL_APPDATA": 26,
"CSIDL_COMMON_APPDATA": 35,
"CSIDL_LOCAL_APPDATA": 28,
}[csidl_name]
buf = ctypes.create_unicode_buffer(1024)
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in buf:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf2 = ctypes.create_unicode_buffer(1024)
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
buf = buf2
return buf.value
def _get_win_folder_with_jna(csidl_name):
import array
from com.sun import jna
from com.sun.jna.platform import win32
buf_size = win32.WinDef.MAX_PATH * 2
buf = array.zeros('c', buf_size)
shell = win32.Shell32.INSTANCE
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in dir:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf = array.zeros('c', buf_size)
kernel = win32.Kernel32.INSTANCE
if kernal.GetShortPathName(dir, buf, buf_size):
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
return dir
if system == "win32":
try:
import win32com.shell
_get_win_folder = _get_win_folder_with_pywin32
except ImportError:
try:
from ctypes import windll
_get_win_folder = _get_win_folder_with_ctypes
except ImportError:
try:
import com.sun.jna
_get_win_folder = _get_win_folder_with_jna
except ImportError:
_get_win_folder = _get_win_folder_from_registry
#---- self test code
if __name__ == "__main__":
appname = "MyApp"
appauthor = "MyCompany"
props = ("user_data_dir", "site_data_dir",
"user_config_dir", "site_config_dir",
"user_cache_dir", "user_log_dir")
print("-- app dirs (with optional 'version')")
dirs = AppDirs(appname, appauthor, version="1.0")
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))
print("\n-- app dirs (without optional 'version')")
dirs = AppDirs(appname, appauthor)
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))
print("\n-- app dirs (without optional 'appauthor')")
dirs = AppDirs(appname)
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))
print("\n-- app dirs (with disabled 'appauthor')")
dirs = AppDirs(appname, appauthor=False)
for prop in props:
print("%s: %s" % (prop, getattr(dirs, prop)))

View File

@@ -0,0 +1,49 @@
# ##### 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 2
# 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, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
import os
import logging
import pathlib
from . import appdirs
log = logging.getLogger(__name__)
def cache_directory(*subdirs) -> pathlib.Path:
"""Returns an OS-specifc cache location, and ensures it exists.
Should be replaced with a call to bpy.utils.user_resource('CACHE', ...)
once https://developer.blender.org/T47684 is finished.
:param subdirs: extra subdirectories inside the cache directory.
>>> cache_directory()
'.../blender_cloud/your_username'
>>> cache_directory('sub1', 'sub2')
'.../blender_cloud/your_username/sub1/sub2'
"""
# TODO: use bpy.utils.user_resource('CACHE', ...)
# once https://developer.blender.org/T47684 is finished.
user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False)
cache_dir = pathlib.Path(user_cache_dir) / 'blender_package_manager' / pathlib.Path(*subdirs)
cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
return cache_dir

View File

@@ -0,0 +1,30 @@
"""A global storage space for display related stuff which needs to be accessible to operators"""
# list of names of packages currently displayed (matching filters)
displayed_packages = []
# list of names of packages currently expanded
expanded_packages = []
# name of package who's preferences are shown
preference_package = None
def repository_items(self, context) -> list:
"""Return displayed repository enum items"""
import bpy
try:
repos = context.window_manager['package_repositories']
except KeyError:
return []
repolist = []
for repo in repos:
try:
repolist.append((repo['name'], repo['name'],
"{} ({})".format(repo['name'], repo['url'])))
except KeyError: # name may not be set before refresh() finishes execution, in which case leave it out
pass
return repolist
# List of error messages from errors encountered while handling packages
# Used to display such errors in the UI
pkg_errors = []

View File

@@ -0,0 +1,14 @@
class BpkgException(Exception):
"""Superclass for all package manager exceptions"""
class InstallException(BpkgException):
"""Raised when there is an error during installation"""
class DownloadException(BpkgException):
"""Raised when there is an error downloading something"""
class BadRepositoryException(BpkgException):
"""Raised when there is an error while reading or manipulating a repository"""
class PackageException(BpkgException):
"""Raised when there is an error while manipulating a package"""

View File

@@ -0,0 +1,75 @@
from .types import Repository
class Message:
"""Superclass for all message sent over pipes."""
# Blender messages
class BlenderMessage(Message):
"""Superclass for all messages sent from Blender to the subprocess."""
class Abort(BlenderMessage):
"""Sent when the user requests abortion of a task."""
# Subproc messages
class SubprocMessage(Message):
"""Superclass for all messages sent from the subprocess to Blender."""
class Progress(SubprocMessage):
"""Send from subprocess to Blender to report progress.
:ivar progress: the progress percentage, from 0-1.
"""
def __init__(self, progress: float):
self.progress = progress
class Success(SubprocMessage):
"""Sent when an operation finished sucessfully."""
class RepositoryResult(SubprocMessage):
"""Sent when an operation returns a repository to be used on the parent process."""
def __init__(self, repository_name: str):
self.repository = repository
class Aborted(SubprocMessage):
"""Sent as response to Abort message."""
# subproc errors
class SubprocError(SubprocMessage):
"""Superclass for all fatal error messages sent from the subprocess."""
def __init__(self, message: str):
self.message = message
class InstallError(SubprocError):
"""Sent when there was an error installing something."""
class UninstallError(SubprocError):
"""Sent when there was an error uninstalling something."""
class BadRepositoryError(SubprocError):
"""Sent when a repository can't be used for some reason"""
class DownloadError(SubprocError):
"""Sent when there was an error downloading something."""
def __init__(self, message: str, status_code: int = None):
self.status_code = status_code
self.message = message

View File

@@ -0,0 +1,96 @@
"""Functions to be executed in a subprocess"""
from pathlib import Path
from . import (
messages,
exceptions,
utils,
)
from .types import (
Package,
Repository,
)
import logging
def download_and_install_package(pipe_to_blender, package: Package, install_path: Path):
"""Downloads and installs the given package."""
log = logging.getLogger(__name__ + '.download_and_install')
def prog(p: float) -> float:
pipe_to_blender.send(messages.Progress(p))
from . import cache
cache_dir = cache.cache_directory('downloads')
try:
package.install(install_path, cache_dir, progress_callback=prog)
except exceptions.DownloadException as err:
pipe_to_blender.send(messages.DownloadError(err))
log.exception(err)
return
except exceptions.InstallException as err:
pipe_to_blender.send(messages.InstallError(err))
log.exception(err)
return
pipe_to_blender.send(messages.Success())
def uninstall_package(pipe_to_blender, package: Package, install_path: Path):
"""Deletes the given package's files from the install directory"""
# TODO: move package to cache and present an "undo" button to user, to give nicer UX on misclicks
log = logging.getLogger(__name__ + ".uninstall_package")
files_to_remove = [install_path / Path(p) for p in package.files]
for pkgfile in files_to_remove:
if not pkgfile.exists():
pipe_to_blender.send(messages.UninstallError(
"Could not find file owned by package: '%s'. Refusing to uninstall." % pkgfile))
return
try:
for pkgfile in files_to_remove:
utils.rm(pkgfile)
except Exception as err:
msg = "Failed to remove file '%s', see console for details" % pkgfile
pipe_to_blender.send(messages.UninstallError(msg))
log.exception(err)
return
pipe_to_blender.send(messages.Success())
def refresh_repositories(pipe_to_blender, repo_storage_path: Path, repository_urls: str):
"""Downloads and stores the given repository"""
log = logging.getLogger(__name__ + '.refresh_repository')
def progress_callback(p: float) -> float:
progress_callback._progress += p
pipe_to_blender.send(messages.Progress(progress_callback._progress))
progress_callback._progress = 0.0
repos = utils.load_repositories(repo_storage_path)
def prog(p: float):
progress_callback(p / len(repos))
known_repo_urls = [repo.url for repo in repos]
for repo_url in repository_urls:
if repo_url not in known_repo_urls:
repos.append(Repository(repo_url))
for repo in repos:
try:
repo.refresh(repo_storage_path, progress_callback=prog)
except exceptions.DownloadException as err:
pipe_to_blender.send(messages.DownloadError(err))
log.exception("Download error")
except exceptions.BadRepositoryException as err:
pipe_to_blender.send(messages.BadRepositoryError(err))
log.exception("Bad repository")
pipe_to_blender.send(messages.Success())

View File

@@ -0,0 +1,655 @@
import logging
import json
from pathlib import Path
from . import exceptions
from . import utils
from . import actions
from . import display
class Package:
"""
Stores package methods and metadata
"""
log = logging.getLogger(__name__ + ".Package")
def __init__(self):
self._bl_info = dict()
## bl_infos ##
# required fields
self.name = str()
self.version = tuple()
self.blender = tuple()
# optional fields
self.description = str()
self.author = str()
self.category = str()
self.location = str()
self.support = 'COMMUNITY'
self.warning = str()
self.wiki_url = str()
self.tracker_url = str()
## package stuff ##
self.url = str()
self.files = list()
## package stuff which is not stored in repo ##
self.installed = False
# contains Path() when not None
self.installed_location = None
self.is_user = False
self.enabled = False
self.repositories = set()
## other ##
# contains str() when not None
self.module_name = None
def set_from_dict(self, package_dict: dict):
"""
Get attributes from a dict such as produced by `to_dict`
"""
if package_dict is None:
raise PackageException("Can't set package from None")
self.files = package_dict['files']
self.url = package_dict['url']
self.bl_info = package_dict['bl_info']
@classmethod
def from_dict(cls, package_dict: dict):
"""
Return a Package with values from dict
Used to read the package from json format
"""
pkg = cls()
pkg.set_from_dict(package_dict)
return pkg
@classmethod
def from_blinfo(cls, blinfo: dict):
"""
Return a Package with bl_info filled in
"""
return cls.from_dict({'bl_info': blinfo})
@classmethod
def from_module(cls, module):
"""
Return a Package object from an addon module
"""
from pathlib import Path
filepath = Path(module.__file__)
if filepath.name == '__init__.py':
filepath = filepath.parent
pkg = cls()
pkg.files = [filepath.name]
pkg.installed_location = str(filepath)
pkg.module_name = module.__name__
try:
pkg.bl_info = module.bl_info
except AttributeError as err:
raise exceptions.PackageException(
"Module does not appear to be an addon; no bl_info attribute") from err
return pkg
def to_dict(self) -> dict:
"""
Return a dict representation of the package
Used to store the package in json format
"""
return {
'bl_info': self.bl_info,
'url': self.url,
'files': self.files,
}
import typing
# bl_info properties
# required fields
@property
def name(self) -> typing.Optional[str]:
"""Get name from bl_info"""
return self._bl_info.get('name')
@name.setter
def name(self, name: str) -> typing.Optional[str]:
if not isinstance(name, str):
raise exceptions.PackageException(
"refusing to set name to non str %r" % name)
self._bl_info['name'] = name
@property
def version(self) -> typing.Optional[tuple]:
"""Get version from bl_info"""
return tuple(self._bl_info.get('version'))
@version.setter
def version(self, version: tuple) -> typing.Optional[tuple]:
if isinstance(version, str):
raise exceptions.PackageException(
"Refusing to set version to non tuple %r" % version)
self._bl_info['version'] = version
@property
def blender(self) -> typing.Optional[tuple]:
"""Get blender from bl_info"""
return self._bl_info.get('blender')
@blender.setter
def blender(self, blender: tuple):
if isinstance(blender, str):
raise exceptions.PackageException(
"Refusing to set blender to non tuple %r" % blender)
self._bl_info['blender'] = blender
# optional fields
@property
def description(self) -> typing.Optional[str]:
"""Get description from bl_info"""
return self._bl_info.get('description')
@description.setter
def description(self, description: str):
self._bl_info['description'] = description
@property
def author(self) -> typing.Optional[str]:
"""Get author from bl_info"""
return self._bl_info.get('author')
@author.setter
def author(self, author: str):
self._bl_info['author'] = author
@property
def category(self) -> typing.Optional[str]:
"""Get category from bl_info"""
return self._bl_info.get('category')
@category.setter
def category(self, category: str):
self._bl_info['category'] = category
@property
def location(self) -> typing.Optional[str]:
"""Get location from bl_info"""
return self._bl_info.get('location')
@location.setter
def location(self, location: str):
self._bl_info['location'] = location
@property
def support(self) -> typing.Optional[str]:
"""Get support from bl_info"""
return self._bl_info.get('support')
@support.setter
def support(self, support: str):
self._bl_info['support'] = support
@property
def warning(self) -> typing.Optional[str]:
"""Get warning from bl_info"""
return self._bl_info.get('warning')
@warning.setter
def warning(self, warning: str):
self._bl_info['warning'] = warning
@property
def wiki_url(self) -> typing.Optional[str]:
"""Get wiki_url from bl_info"""
return self._bl_info.get('wiki_url')
@wiki_url.setter
def wiki_url(self, wiki_url: str):
self._bl_info['wiki_url'] = wiki_url
@property
def tracker_url(self) -> typing.Optional[str]:
"""Get tracker_url from bl_info"""
return self._bl_info.get('tracker_url')
@tracker_url.setter
def tracker_url(self, tracker_url: str):
self._bl_info['tracker_url'] = tracker_url
# useful for handling whole bl_info at once
@property
def bl_info(self) -> dict:
"""bl_info dict of package"""
return {
"name": self.name,
"version": self.version,
"blender": self.blender,
"description": self.description,
"author": self.author,
"category": self.category,
"location": self.location,
"support": self.support,
"warning": self.warning,
"wiki_url": self.wiki_url,
"tracker_url": self.tracker_url,
}
@bl_info.setter
def bl_info(self, blinfo: dict):
self.name = blinfo["name"]
self.version = blinfo["version"]
self.blender = blinfo["blender"]
self.description = blinfo.get("description", self.description)
self.author = blinfo.get("author", self.author)
self.category = blinfo.get("category", self.category)
self.location = blinfo.get("location", self.location)
self.support = blinfo.get("support", self.support)
self.warning = blinfo.get("warning", self.warning)
self.wiki_url = blinfo.get("wiki_url", self.wiki_url)
self.tracker_url = blinfo.get("tracker_url", self.tracker_url)
def test_is_user(self) -> bool:
"""Return true if package's install location is in user or preferences scripts path"""
import bpy
user_script_path = bpy.utils.script_path_user()
prefs_script_path = bpy.utils.script_path_pref()
if user_script_path is not None:
in_user = Path(user_script_path) in Path(
self.installed_location).parents
else:
in_user = False
if prefs_script_path is not None:
in_prefs = Path(prefs_script_path) in Path(
self.installed_location).parents
else:
in_prefs = False
return in_user or in_prefs
def test_enabled(self) -> bool:
"""Return true if package is enabled"""
import bpy
if self.module_name is not None:
return (self.module_name in bpy.context.user_preferences.addons)
else:
return False
def enable(self):
"""Enable package"""
# TODO: just use addon_utils for now
if not self.module_name:
raise PackageException(
"Cannot enable package with unset module_name")
import addon_utils
addon_utils.enable(self.module_name, default_set=True)
self.enabled = True
def disable(self):
"""Disable package"""
if not self.module_name:
raise PackageException(
"Cannot disable package with unset module_name")
import addon_utils
addon_utils.enable(self.module_name, default_set=True)
self.enabled = False
def test_installed(self) -> bool:
"""Return true if package is installed"""
import addon_utils
return len([Package.from_module(mod) for mod in addon_utils.modules(refresh=False) if
addon_utils.module_bl_info(mod)['name'] == self.name and
addon_utils.module_bl_info(mod)['version'] == self.version]) > 0
def set_installed_metadata(self, installed_pkg):
"""Sets metadata specific to installed packages from the Package given as `installed_pkg`"""
self.installed = installed_pkg.test_installed()
self.enabled = installed_pkg.test_enabled()
self.is_user = installed_pkg.test_is_user()
self.module_name = installed_pkg.module_name
self.installed_location = installed_pkg.installed_location
def download(self, dest: Path, progress_callback=None) -> Path:
"""Downloads package to `dest`"""
if not self.url:
raise ValueError("Cannot download package without a URL")
return actions.download(self.url, dest, progress_callback)
def install(self, dest_dir: Path, cache_dir: Path, progress_callback=None):
"""Downloads package to `cache_dir`, then extracts/moves package to `dest_dir`"""
log = logging.getLogger('%s.install' % __name__)
downloaded = self.download(cache_dir, progress_callback)
if not downloaded:
log.debug('Download returned None, not going to install anything.')
return
actions.install(downloaded, dest_dir)
utils.rm(downloaded)
def __eq__(self, other):
return self.name == other.name and self.version == other.version
def __lt__(self, other):
return self.version < other.version
def __hash__(self):
return hash((self.name, self.version))
def __repr__(self) -> str:
# return self.name
return "Package('name': {}, 'version': {})".format(self.name, self.version)
class ConsolidatedPackage:
"""
Stores a grouping of different versions of the same package
"""
log = logging.getLogger(__name__ + ".ConsolidatedPackage")
def __init__(self, pkg=None):
self.versions = []
# self.updateable = False
if pkg is not None:
self.add_version(pkg)
@property
def installed(self) -> bool:
"""Return true if any version of this package is installed"""
for pkg in self.versions:
if pkg.installed:
return True
return False
@property
def name(self) -> str:
"""
Return name of this package. All package versions in a
ConsolidatedPackage should have the same name by definition
Returns None if there are no versions
"""
try:
return self.versions[0].name
except IndexError:
return None
def get_latest_installed_version(self) -> Package:
"""
Return the installed package with the highest version number.
If no packages are installed, return None.
"""
# self.versions is always sorted newer -> older, so we can just grab the first we find
for pkg in self.versions:
if pkg.installed:
return pkg
return None
def get_latest_version(self) -> Package:
"""Return package with highest version number, returns None if there are no versions"""
try:
# this is always sorted with the highest on top
return self.versions[0]
except IndexError:
return None
def get_display_version(self) -> Package:
"""
Return installed package with highest version number.
If no version is installed, return highest uninstalled version.
"""
pkg = self.get_latest_installed_version()
if pkg is None:
pkg = self.get_latest_version()
return pkg
def test_updateable(self) -> bool:
"""Return true if latest installed version of package is older than latest known version"""
latest = self.get_latest_version()
latest_installed = self.get_latest_installed_version()
if latest is None or latest_installed is None:
return False
return latest_installed.version < latest.version
def add_version(self, newpkg: Package):
"""Adds a package to the collection of versions"""
if self.name and newpkg.name != self.name:
raise exceptions.PackageException(
"Name mismatch, refusing to add %s to %s" % (newpkg, self))
for pkg in self:
if pkg == newpkg:
pkg.repositories.union(newpkg.repositories)
if newpkg.installed:
pkg.set_installed_metadata(newpkg)
return
self.versions.append(newpkg)
self.versions.sort(key=lambda v: v.version, reverse=True)
# self.updateable = self.test_updateable()
def __iter__(self):
return (pkg for pkg in self.versions)
def __repr__(self):
return ("ConsolidatedPackage<name={}>".format(self.name))
class Repository:
"""
Stores repository metadata (including packages)
"""
log = logging.getLogger(__name__ + ".Repository")
def __init__(self, url=None):
self.name = str()
self.url = url if url is not None else str()
self.packages = list()
self.filepath = Path()
self._headers = dict()
def refresh(self, storage_path: Path, progress_callback=None):
"""
Requests repo.json from URL and embeds etag/last-modification headers
"""
import requests
if progress_callback is None:
def progress_callback(x): return None
progress_callback(0.0)
if self.url is None:
raise ValueError("Cannot refresh repository without a URL")
url = utils.add_repojson_to_url(self.url)
self.log.debug("Refreshing repository from %s", self.url)
req_headers = {}
# Do things this way to avoid adding empty objects/None to the req_headers dict
try:
req_headers['If-None-Match'] = self._headers['etag']
except KeyError:
pass
try:
req_headers['If-Modified-Since'] = self._headers['last-modified']
except KeyError:
pass
try:
resp = requests.get(url, headers=req_headers, timeout=60)
except requests.exceptions.InvalidSchema as err:
raise exceptions.DownloadException(
"Invalid schema. Did you mean to use http://?") from err
except requests.exceptions.ConnectionError as err:
raise exceptions.DownloadException(
"Failed to connect. Are you sure '%s' is the correct URL?" % url) from err
except requests.exceptions.RequestException as err:
raise exceptions.DownloadException(err) from err
try:
resp.raise_for_status()
except requests.HTTPError as err:
self.log.error('Error downloading %s: %s', url, err)
raise exceptions.DownloadException(
resp.status_code, resp.reason) from err
if resp.status_code == requests.codes.not_modified:
self.log.debug("Packagelist not modified")
progress_callback(1.0)
return
resp_headers = {}
try:
resp_headers['etag'] = resp.headers['etag']
except KeyError:
pass
try:
resp_headers['last-modified'] = resp.headers['last-modified']
except KeyError:
pass
self.log.debug("Found headers: %s", resp_headers)
progress_callback(0.7)
try:
repodict = resp.json()
except json.decoder.JSONDecodeError:
self.log.exception("Failed to parse downloaded repository")
raise exceptions.BadRepositoryException(
"Could not parse repository downloaded from '%s'. Are you sure this is the correct URL?" % url
)
repodict['_headers'] = resp_headers
repodict['url'] = self.url
self.set_from_dict(repodict)
self.to_file(storage_path / utils.format_filename(self.name, ".json"))
progress_callback(1.0)
def to_dict(self, sort=False, ids=False) -> dict:
"""
Return a dict representation of the repository
Used to store the repository in json format
"""
packages = [p.to_dict() for p in self.packages]
if sort:
packages.sort(key=lambda p: p['bl_info']['name'].lower())
if ids:
for pkg in packages:
# hash may be too big for a C int
pkg['id'] = str(hash(pkg['url'] + pkg['bl_info']
['name'] + self.name + self.url))
return {
'name': self.name,
'packages': packages,
'url': self.url,
'_headers': self._headers,
}
def set_from_dict(self, repodict: dict):
"""
Get repository attributes from a dict such as produced by `to_dict`
Used to read the repository from json format
"""
try:
name = repodict['name']
except KeyError as err:
raise exceptions.BadRepositoryException(
"Cannot set repository from dict; missing name") from err
try:
url = repodict['url']
except KeyError as err:
raise exceptions.BadRepositoryException(
"Cannot set repository from dict; missing url") from err
try:
pkg_dicts = repodict['packages']
except KeyError as err:
raise exceptions.BadRepositoryException(
"Cannot set repository from dict; missing packages") from err
headers = repodict.get('_headers', {})
self.name = name
self.url = url
for pkg_dict in pkg_dicts:
try:
pkg = Package.from_dict(pkg_dict)
except exceptions.PackageException as err:
msg = "Error parsing package {} in repository {}: {}".format(
pkg_dict['bl_info'].get('name'), self.name, err)
display.pkg_errors.append(msg)
else:
self.add_package(pkg)
self._headers = headers
@classmethod
def from_dict(cls, repodict: dict):
"""
Like `set_from_dict`, but immutable
"""
repo = cls()
repo.set_from_dict(repodict)
return repo
def to_file(self, path: Path):
"""
Dump repository to a json file at `path`.
"""
if len(self.packages) <= 0:
self.log.warning("Writing an empty repository")
self.log.debug("URL is %s", self.url)
with path.open('w', encoding='utf-8') as repo_file:
json.dump(self.to_dict(), repo_file, indent=4, sort_keys=True)
self.log.debug("Repository written to %s" % path)
@classmethod
def from_file(cls, path: Path):
"""
Read repository from a json file at `path`.
"""
repo_file = path.open('r', encoding='utf-8')
with repo_file:
try:
repo = cls.from_dict(json.load(repo_file))
except json.JSONDecodeError as err:
raise exceptions.BadRepositoryException(err) from err
if repo.url is None or len(repo.url) == 0:
raise exceptions.BadRepositoryException(
"Repository missing URL")
repo.filepath = path
cls.log.debug("Repository read from %s", path)
return repo
def add_package(self, pkg: Package):
"""Add package to repository instance"""
# TODO: check if package exists
self.packages.append(pkg)
def __repr__(self):
return "Repository({}, {})".format(self.name, self.url)

View File

@@ -0,0 +1,104 @@
from pathlib import Path
import shutil
import logging
def fmt_version(version_number: tuple) -> str:
"""Take version number as a tuple and format it as a string"""
vstr = str(version_number[0])
for component in version_number[1:]:
vstr += "." + str(component)
return vstr
def format_filename(s: str, ext=None) -> str:
"""Take a string and turn it into a reasonable filename"""
import string
if ext is None:
ext = ""
valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
filename = ''.join(char for char in s if char in valid_chars)
filename = filename.replace(' ', '_')
filename.lower()
filename += ext
return filename
def sanitize_repository_url(url: str) -> str:
"""Sanitize repository url"""
from urllib.parse import urlsplit, urlunsplit
parsed_url = urlsplit(url)
new_path = parsed_url.path.rstrip("repo.json")
return urlunsplit((parsed_url.scheme, parsed_url.netloc, new_path, parsed_url.query, parsed_url.fragment))
def add_repojson_to_url(url: str) -> str:
"""Add `repo.json` to the path component of a url"""
from urllib.parse import urlsplit, urlunsplit
parsed_url = urlsplit(url)
new_path = parsed_url.path.rstrip('/')
new_path += "/repo.json"
return urlunsplit((parsed_url.scheme, parsed_url.netloc, new_path, parsed_url.query, parsed_url.fragment))
def load_repositories(repo_storage_path: Path) -> list:
"""Load all json files in repo storage path"""
repositories = []
from .types import Repository
for repofile in repo_storage_path.glob('*.json'):
repo = Repository.from_file(repofile)
repositories.append(repo)
return repositories
def rm(path: Path):
"""Delete whatever is specified by `path`"""
if path.is_dir():
shutil.rmtree(str(path))
else:
path.unlink()
class InplaceBackup:
"""Utility class for moving a file out of the way by appending a '~'"""
log = logging.getLogger('%s.inplace-backup' % __name__)
def __init__(self, path: Path):
self.path = path
# contains Path() when not None
self.backup_path = None
self.backup()
def backup(self):
"""Move 'path' to 'path~'"""
if not self.path.exists():
raise FileNotFoundError("Can't backup path which doesn't exist")
self.backup_path = self.path.with_name(self.path.name + '~')
if self.backup_path.exists():
self.log.warning(
"Overwriting existing backup '{}'".format(self.backup_path))
rm(self.backup_path)
shutil.move(str(self.path), str(self.backup_path))
def restore(self):
"""Move 'path~' to 'path'"""
if not self.backup_path:
raise RuntimeError(
"Can't restore file before backing it up") from err
if not self.backup_path.exists():
raise FileNotFoundError("Can't restore backup which doesn't exist")
if self.path.exists():
self.log.warning(
"Overwriting '{0}' with backup file".format(self.path))
rm(self.path)
shutil.move(str(self.backup_path), str(self.path))
def remove(self):
"""Remove 'path~'"""
rm(self.backup_path)

View File

@@ -38,6 +38,7 @@ _modules = [
"object",
"object_randomize_transform",
"object_quick_effects",
"package",
"presets",
"rigidbody",
"screen_play_rendered_anim",

View File

@@ -0,0 +1,612 @@
# HACK:
# due to lack of fork() on windows, multiprocessing will re-execute this module
# in a new process and `import bpy` will fail. In such cases we only need
# subproc, everything else is only used to spawn the subprocess in the first
# place.
try:
import bpy
from bpy.types import Operator
except ImportError:
from bpkg import subproc
else:
import logging
import bpkg
from bpkg import (
subproc,
messages,
)
from bpkg.types import (
Package,
ConsolidatedPackage,
)
from pathlib import Path
from collections import OrderedDict
import multiprocessing
# Under windows, multiprocessing must start a new process entirely. It
# expects sys.executable to point to python, but in blender sys.executable
# points to blender's executable. We can override this with set_executable,
# but this acts globally unless we make a special context.
# Also see:
# https://docs.python.org/3.6/library/multiprocessing.html#multiprocessing.set_executable
mp_context = multiprocessing.get_context()
mp_context.set_executable(bpy.app.binary_path_python)
class SubprocMixin:
"""Mix-in class for things that need to be run in a subprocess."""
log = logging.getLogger(__name__ + '.SubprocMixin')
_state = 'INITIALIZING'
# time at which we stop waiting for an abort response and just terminate the process
_abort_timeout = 0
# how long to wait (in seconds) to forcibly terminate subprocess after quit
_abort_wait = 10
# Mapping from message type (see bpkg.messages) to handler function.
# Should be constructed before modal() gets called.
msg_handlers = {}
def execute(self, context):
return self.invoke(context, None)
def quit(self):
"""Signals the state machine to stop this operator from running."""
self._state = 'QUIT'
def invoke(self, context, event):
self.pipe_blender, self.pipe_subproc = multiprocessing.Pipe()
# The subprocess should just be terminated when Blender quits. Without this,
# Blender would hang while closing, until the subprocess terminates itself.
# TODO: Perhaps it would be better to fork when blender exits?
self.process = self.create_subprocess()
self.process.daemon = True
self.process.start()
self._state = 'RUNNING'
wm = context.window_manager
wm.modal_handler_add(self)
self.timer = wm.event_timer_add(0.1, context.window)
return {'RUNNING_MODAL'}
def modal(self, context, event):
import time
if event.type != 'TIMER':
return {'PASS_THROUGH'}
if self._state == 'ABORTING' and time.time() > self._abort_timeout:
self.log.error(
'No response from subprocess to abort request, terminating it.')
self.report(
{'ERROR'}, 'No response from subprocess to abort request, terminating it.')
self.process.terminate()
self._finish(context)
return {'CANCELLED'}
while self.pipe_blender.poll():
self.handle_received_data()
if self._state == 'QUIT':
self._finish(context)
return {'FINISHED'}
if not self.process.is_alive():
self.report_process_died()
self._finish(context)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
def abort(self):
import time
# Allow the subprocess 10 seconds to repsond to our abort message.
self._abort_timeout = time.time() + self._abort_wait
self._state = 'ABORTING'
self.pipe_blender.send(messages.Abort())
def _finish(self, context):
try:
self.cancel(context)
except AttributeError:
pass
global bpkg_operation_running
context.window_manager.event_timer_remove(self.timer)
bpkg_operation_running = False
if self.process and self.process.is_alive():
self.log.debug('Waiting for subprocess to quit')
try:
self.process.join(timeout=self._abort_wait)
except multiprocessing.TimeoutError:
self.log.warning(
'Subprocess is hanging, terminating it forcefully.')
self.process.terminate()
else:
self.log.debug(
'Subprocess stopped with exit code %i', self.process.exitcode)
def handle_received_data(self):
recvd = self.pipe_blender.recv()
self.log.debug('Received message from subprocess: %s', recvd)
try:
handler = self.msg_handlers[type(recvd)]
except KeyError:
self.log.error('Unable to handle received message %s', recvd)
# Maybe we shouldn't show this to the user?
self.report(
{'WARNING'}, 'Unable to handle received message %s' % recvd)
return
handler(recvd)
def create_subprocess(self):
"""Implement this in a subclass.
:rtype: multiprocessing.Process
"""
raise NotImplementedError()
def report_process_died(self):
"""Provides the user with sensible information when the process has died.
Implement this in a subclass.
"""
raise NotImplementedError()
class PACKAGE_OT_install(SubprocMixin, Operator):
bl_idname = 'package.install'
bl_label = 'Install package'
bl_description = 'Downloads and installs a Blender add-on package'
bl_options = {'REGISTER'}
package_name = bpy.props.StringProperty(
name='package_name',
description='The name of the package to install'
)
log = logging.getLogger(__name__ + '.PACKAGE_OT_install')
def invoke(self, context, event):
if not self.package_name:
self.report({'ERROR'}, 'Package name not given')
return {'CANCELLED'}
return super().invoke(context, event)
def create_subprocess(self):
"""Starts the download process.
Also registers the message handlers.
:rtype: multiprocessing.Process
"""
self.msg_handlers = {
messages.Progress: self._subproc_progress,
messages.DownloadError: self._subproc_download_error,
messages.InstallError: self._subproc_install_error,
messages.Success: self._subproc_success,
messages.Aborted: self._subproc_aborted,
}
wm = bpy.context.window_manager
wm.progress_begin(0, 1)
package = bpkg.packages[self.package_name].get_latest_version()
import pathlib
# TODO: We need other paths besides this one on subprocess end, so it might be better to pass them all at once.
# For now, just pass this one.
install_path = pathlib.Path(
bpy.utils.user_resource('SCRIPTS', 'addons', create=True))
self.log.debug("Using %s as install path", install_path)
import addon_utils
proc = mp_context.Process(target=subproc.download_and_install_package,
args=(self.pipe_subproc, package, install_path))
return proc
def _subproc_progress(self, progmsg: messages.Progress):
wm = bpy.context.window_manager
wm.progress_update(progmsg.progress)
def _subproc_download_error(self, error: messages.DownloadError):
self.report({'ERROR'}, 'Unable to download package: %s' %
error.message)
self.quit()
def _subproc_install_error(self, error: messages.InstallError):
self.report({'ERROR'}, 'Unable to install package: %s' %
error.message)
self.quit()
def _subproc_success(self, success: messages.Success):
self.report({'INFO'}, 'Package installed successfully')
bpkg.refresh_packages()
bpy.context.area.tag_redraw()
wm = bpy.context.window_manager
wm.progress_end()
self.quit()
def _subproc_aborted(self, aborted: messages.Aborted):
self.report(
{'ERROR'}, 'Package installation aborted per your request')
self.quit()
def report_process_died(self):
if self.process.exitcode:
self.log.error(
'Process died without telling us! Exit code was %i', self.process.exitcode)
self.report(
{'ERROR'}, 'Error downloading package, exit code %i' % self.process.exitcode)
else:
self.log.error(
'Process died without telling us! Exit code was 0 though')
self.report(
{'WARNING'}, 'Error downloading package, but process finished OK. This is weird.')
class PACKAGE_OT_uninstall(SubprocMixin, Operator):
bl_idname = 'package.uninstall'
bl_label = 'Install package'
bl_description = "Remove installed package files from filesystem"
bl_options = {'REGISTER'}
package_name = bpy.props.StringProperty(
name='package_name', description='The name of the package to uninstall')
log = logging.getLogger(__name__ + '.PACKAGE_OT_uninstall')
def invoke(self, context, event):
if not self.package_name:
self.report({'ERROR'}, 'Package name not given')
return {'CANCELLED'}
return super().invoke(context, event)
def create_subprocess(self):
"""Starts the uninstall process and registers the message handlers.
:rtype: multiprocessing.Process
"""
self.msg_handlers = {
messages.UninstallError: self._subproc_uninstall_error,
messages.Success: self._subproc_success,
}
import pathlib
install_path = pathlib.Path(
bpy.utils.user_resource('SCRIPTS', 'addons', create=True))
package = bpkg.packages[self.package_name].get_latest_version()
proc = mp_context.Process(target=subproc.uninstall_package,
args=(self.pipe_subproc, package, install_path))
return proc
def _subproc_uninstall_error(self, error: messages.InstallError):
self.report({'ERROR'}, error.message)
self.quit()
def _subproc_success(self, success: messages.Success):
self.report({'INFO'}, 'Package uninstalled successfully')
bpkg.refresh_packages()
bpy.context.area.tag_redraw()
self.quit()
def report_process_died(self):
if self.process.exitcode:
self.log.error(
'Process died without telling us! Exit code was %i', self.process.exitcode)
self.report(
{'ERROR'}, 'Error downloading package, exit code %i' % self.process.exitcode)
else:
self.log.error(
'Process died without telling us! Exit code was 0 though')
self.report(
{'WARNING'}, 'Error downloading package, but process finished OK. This is weird.')
class PACKAGE_OT_refresh(SubprocMixin, Operator):
bl_idname = "package.refresh"
bl_label = "Refresh"
bl_description = 'Check repositories for new and updated packages'
bl_options = {'REGISTER'}
log = logging.getLogger(__name__ + ".PACKAGE_OT_refresh")
_running = False
def invoke(self, context, event):
wm = context.window_manager
self.repositories = wm.package_repositories
if not self.repositories:
bpkg.refresh_packages()
return {'FINISHED'}
PACKAGE_OT_refresh._running = True
return super().invoke(context, event)
@classmethod
def poll(cls, context):
return not cls._running
def _finish(self, context):
super()._finish(context)
PACKAGE_OT_refresh._running = False
context.area.tag_redraw()
def create_subprocess(self):
"""Starts the download process.
Also registers the message handlers.
:rtype: multiprocessing.Process
"""
wm = bpy.context.window_manager
wm.progress_begin(0, 1)
self.msg_handlers = {
messages.Progress: self._subproc_progress,
messages.SubprocError: self._subproc_error,
messages.DownloadError: self._subproc_download_error,
messages.Success: self._subproc_success,
messages.BadRepositoryError: self._subproc_repository_error,
messages.Aborted: self._subproc_aborted,
}
import pathlib
storage_path = pathlib.Path(bpy.utils.user_resource(
'CONFIG', 'repositories', create=True))
repository_urls = [repo.url for repo in self.repositories]
self.log.debug("Repository urls %s", repository_urls)
proc = mp_context.Process(target=subproc.refresh_repositories,
args=(self.pipe_subproc, storage_path, repository_urls))
return proc
def _subproc_progress(self, progmsg: messages.Progress):
wm = bpy.context.window_manager
wm.progress_update(progmsg.progress)
def _subproc_error(self, error: messages.SubprocError):
self.report(
{'ERROR'}, 'Unable to refresh package list: %s' % error.message)
self.quit()
def _subproc_download_error(self, error: messages.DownloadError):
self.report(
{'ERROR'}, 'Unable to download package list: %s' % error.message)
self.quit()
def _subproc_repository_error(self, error: messages.BadRepositoryError):
self.report({'ERROR'}, str(error.message))
self.quit()
def _subproc_success(self, success: messages.Success):
self.report({'INFO'}, 'Finished refreshing lists')
bpkg.refresh_repository_props()
bpkg.refresh_packages()
wm = bpy.context.window_manager
wm.progress_end()
self.quit()
def _subproc_aborted(self, aborted: messages.Aborted):
self.report(
{'ERROR'}, 'Package list retrieval aborted per your request')
self.quit()
def report_process_died(self):
if self.process.exitcode:
self.log.error(
'Refresh process died without telling us! Exit code was %i', self.process.exitcode)
self.report(
{'ERROR'}, 'Error refreshing package lists, exit code %i' % self.process.exitcode)
else:
self.log.error(
'Refresh process died without telling us! Exit code was 0 though')
self.report(
{'WARNING'}, 'Error refreshing package lists, but process finished OK. This is weird.')
class PACKAGE_UL_repositories(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
layout.alignment = 'LEFT'
layout.prop(item, "enabled", text="")
if len(item.name) == 0:
layout.label(item['url'])
else:
layout.label(item.name)
class PACKAGE_OT_add_repository(Operator):
bl_idname = "package.add_repository"
bl_label = "Add Repository"
url = bpy.props.StringProperty(name="Repository URL")
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self)
def execute(self, context):
wm = context.window_manager
if not self.url:
self.report({'ERROR'}, "Repository URL not specified")
return {'CANCELLED'}
for repo in wm.package_repositories:
if repo['url'] == self.url:
self.report({'ERROR'}, "Repository already added")
return {'CANCELLED'}
repo = wm.package_repositories.add()
repo.url = bpkg.utils.sanitize_repository_url(self.url)
context.area.tag_redraw()
return {'FINISHED'}
class PACKAGE_OT_remove_repository(Operator):
bl_idname = "package.remove_repository"
bl_label = "Remove Repository"
def execute(self, context):
wm = context.window_manager
try:
repo = wm.package_repositories[wm.package_active_repository]
except AttributeError:
return {'CANCELLED'}
try:
filepath = Path(repo['filepath'])
except KeyError:
pass
else:
if not filepath.exists():
raise ValueError("Failed find repository file")
filepath.unlink()
wm.package_repositories.remove(wm.package_active_repository)
context.area.tag_redraw()
return {'FINISHED'}
class PACKAGE_OT_edit_repositories(Operator):
bl_idname = "package.edit_repositories"
bl_label = "Edit Repositories"
def check(self, context):
# TODO: always refresh settings for now
return True
def execute(self, context):
bpy.ops.package.refresh()
return {'FINISHED'}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=500, height=300)
def draw(self, context):
layout = self.layout
wm = context.window_manager
row = layout.row()
row.template_list("PACKAGE_UL_repositories", "", wm,
"package_repositories", wm, "package_active_repository")
col = row.column(align=True)
col.operator("package.add_repository", text="", icon='ZOOMIN')
col.operator("package.remove_repository", text="", icon='ZOOMOUT')
class WM_OT_package_toggle_expand(Operator):
bl_idname = "wm.package_toggle_expand"
bl_label = ""
bl_description = "Toggle display of extended information for given package (hold shift to collapse all other packages)"
bl_options = {'INTERNAL'}
log = logging.getLogger(__name__ + ".WM_OT_package_toggle_expand")
package_name = bpy.props.StringProperty(
name="Package Name",
description="Name of package to expand/collapse",
)
def invoke(self, context, event):
if event.shift:
bpkg.display.expanded_packages.clear()
if self.package_name in bpkg.display.expanded_packages:
bpkg.display.expanded_packages.remove(self.package_name)
else:
bpkg.display.expanded_packages.append(self.package_name)
return {'FINISHED'}
class WM_OT_package_toggle_preferences(Operator):
bl_idname = "wm.package_toggle_preferences"
bl_label = ""
bl_description = "Toggle display of package preferences"
bl_options = {'INTERNAL'}
package_name = bpy.props.StringProperty(
name="Package Name",
description="Name of package whos preferences to display",
)
def invoke(self, context, event):
if bpkg.display.preference_package == self.package_name:
bpkg.display.preference_package = None
else:
bpkg.display.preference_package = self.package_name
return {'FINISHED'}
class PACKAGE_OT_toggle_enabled(Operator):
bl_idname = "package.toggle_enabled"
bl_label = ""
bl_description = "Enable given package if it's disabled, and vice versa if it's enabled"
log = logging.getLogger(__name__ + ".PACKAGE_OT_toggle_enabled")
package_name = bpy.props.StringProperty(
name="Package Name",
description="Name of package to enable",
)
def execute(self, context):
import addon_utils
metapkg = bpkg.packages[self.package_name]
if not metapkg.installed:
self.report(
{'ERROR'}, "Can't enable package which isn't installed")
return {'CANCELLED'}
pkg = metapkg.get_latest_installed_version()
if pkg.enabled:
pkg.disable()
else:
pkg.enable()
return {'FINISHED'}
class PACKAGE_OT_disable(Operator):
bl_idname = "package.disable"
bl_label = ""
bl_description = "Disable given package"
log = logging.getLogger(__name__ + ".PACKAGE_OT_disable")
package_name = bpy.props.StringProperty(
name="Package Name",
description="Name of package to disable",
)
def execute(self, context):
package = bpkg.packages[self.package_name].get_display_version()
if not package.module_name:
self.log.error("Can't disable package without a module name")
return {'CANCELLED'}
ret = bpy.ops.wm.addon_disable(package.module_name)
if ret == {'FINISHED'}:
bpkg.packages[self.package_name].enabled = False
return ret
classes = (
PACKAGE_OT_install,
PACKAGE_OT_uninstall,
PACKAGE_OT_toggle_enabled,
PACKAGE_OT_refresh,
WM_OT_package_toggle_expand,
WM_OT_package_toggle_preferences,
PACKAGE_OT_add_repository,
PACKAGE_OT_remove_repository,
PACKAGE_OT_edit_repositories,
PACKAGE_UL_repositories,
)

View File

@@ -46,6 +46,7 @@ _modules = [
"properties_object",
"properties_paint_common",
"properties_grease_pencil_common",
"properties_package",
"properties_particle",
"properties_physics_cloth",
"properties_physics_common",
@@ -99,7 +100,7 @@ def register():
register_class(cls)
# space_userprefs.py
from bpy.props import StringProperty, EnumProperty
from bpy.props import StringProperty, EnumProperty, CollectionProperty, IntProperty
from bpy.types import WindowManager
def addon_filter_items(self, context):
@@ -120,9 +121,9 @@ def register():
items.extend([(cat, cat, "") for cat in sorted(items_unique)])
return items
WindowManager.addon_search = StringProperty(
WindowManager.package_search = StringProperty(
name="Search",
description="Search within the selected filter",
description="Filter packages by name",
options={'TEXTEDIT_UPDATE'},
)
WindowManager.addon_filter = EnumProperty(
@@ -141,6 +142,31 @@ def register():
default={'OFFICIAL', 'COMMUNITY'},
options={'ENUM_FLAG'},
)
WindowManager.package_state_filter = EnumProperty(
items=[('AVAILABLE', "Available", "All packages in selected repositories"),
('INSTALLED', "Installed", "All installed packages"),
('UPDATES', "Updates", "All installed packages for which there is a newer version available")
],
name="Install filter",
default='AVAILABLE',
)
from bpkg import display
WindowManager.package_repository_filter = EnumProperty(
items=display.repository_items,
name="Repository filter",
options={'ENUM_FLAG'},
)
from .properties_package import RepositoryProperty
WindowManager.package_repositories = CollectionProperty(
type=RepositoryProperty,
name="Repositories",
)
WindowManager.package_active_repository = IntProperty()
# done...

View File

@@ -0,0 +1,27 @@
import bpy
from bpy.types import UIList, PropertyGroup
class RepositoryProperty(PropertyGroup):
name = bpy.props.StringProperty(name="Name")
url = bpy.props.StringProperty(name="URL")
filepath = bpy.props.StringProperty(name="Filepath")
status = bpy.props.EnumProperty(name="Status", items=[
("OK", "Okay", "FILE_TICK"),
("NOTFOUND", "Not found", "ERROR"),
("NOCONNECT", "Could not connect", "QUESTION"),
])
enabled = bpy.props.BoolProperty(name="Enabled")
class PACKAGE_UL_repositories(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
try:
layout.label(text=item['name'] + ":", icon='FILE_TICK')
except KeyError: #name not defined while still downloading
layout.label(text="", icon='FILE_REFRESH')
# TODO: for some reason unembossing the following causes blender to become unresponsive when ctrl clicking the url
layout.prop(item, "url", text="")
classes = (
RepositoryProperty,
PACKAGE_UL_repositories,
)

View File

@@ -1312,6 +1312,379 @@ class USERPREF_MT_addons_online_resources(Menu):
).url = bpy.types.WM_OT_doc_view._prefix + "/info_quickstart.html"
layout.operator("wm.url_open", text="Add-on Tutorial", icon='URL',
).url = bpy.types.WM_OT_doc_view._prefix + "/info_tutorial_addon.html"
class USERPREF_PT_packages(Panel):
bl_label = "Package Management"
bl_space_type = 'USER_PREFERENCES'
bl_region_type = 'WINDOW'
bl_options = {'HIDE_HEADER'}
_started = False
@classmethod
def poll(cls, context):
userpref = context.user_preferences
return (userpref.active_section == 'PACKAGES')
def draw(self, context):
import bpkg
import bpkg.display
from bpkg.types import (ConsolidatedPackage, Package)
layout = self.layout
wm = context.window_manager
mainrow = layout.row()
spl = mainrow.split(.2)
sidebar = spl.column(align=True)
pkgzone = spl.column()
sidebar.operator("package.refresh", text="Check for updates")
sidebar.label("Repositories")
col = sidebar.column(align=True)
col.prop(wm, "package_repository_filter")
# row.template_list("PACKAGE_UL_repositories", "", wm, "package_repositories", wm, "package_active_repository")
# col = row.column(align=True)
# col.operator("package.add_repository", text="", icon='ZOOMIN')
# col.operator("package.remove_repository", text="", icon='ZOOMOUT')
# sidebar.separator()
col.operator("package.edit_repositories")
sidebar.separator()
sidebar.label("Category")
sidebar.prop(wm, "addon_filter", text="")
sidebar.separator()
sidebar.label("Support level")
sidebar.prop(wm, "addon_support")
top = pkgzone.row()
spl = top.split(.6)
spl.prop(wm, "package_search", text="", icon='VIEWZOOM')
spl_r = spl.row()
spl_r.prop(wm, "package_state_filter", expand=True)
def filter_packages(filters: dict, packages: dict) -> list:# {{{
"""Returns filtered and sorted list of names of packages which match filters"""
#TODO: using lower() for case-insensitive comparison doesn't work for some languages
def match_contains(pkg: Package) -> bool:
return pkg.name.lower().__contains__(filters['search'].lower())
def match_startswith(pkg: Package) -> bool:
return pkg.name.lower().startswith(filters['search'].lower())
def match_support(pkg: Package) -> bool:
return set((pkg.support,)).issubset(filters['support'])
def match_installstate(metapkg: ConsolidatedPackage) -> bool:
if filters['installstate'] == 'AVAILABLE':
return True
if filters['installstate'] == 'INSTALLED':
return metapkg.installed
if filters['installstate'] == 'UPDATES':
return metapkg.installed and metapkg.test_updateable()
return False
def match_repositories(metapkg) -> bool:
pkg = metapkg.get_display_version()
if pkg.installed:
return True
if len(pkg.repositories) == 0:
return True
pkg_reponames = set(repo.name for repo in pkg.repositories)
if len(pkg_reponames.intersection(filters['repository'])) > 0:
return True
return False
def match_category(pkg: Package) -> bool:
filterstr = filters['category'].lower()
# handle special categories
if filterstr == 'all':
return True
if filterstr == 'user':
return pkg.is_user
if filterstr == 'enabled':
return pkg.enabled
if filterstr == 'disabled':
return not pkg.enabled
if not pkg.category:
return False
if pkg.category.lower() == filterstr:
return True
return False
# use two lists as a simple way of putting "matches from the beginning" on top
contains = []
startswith = []
for pkgname, metapkg in packages.items():
pkg = metapkg.get_display_version()
if match_repositories(metapkg)\
and match_category(pkg)\
and match_support(pkg)\
and match_installstate(metapkg):
if len(filters['search']) == 0:
startswith.append(pkgname)
continue
if match_startswith(pkg):
startswith.append(pkgname)
continue
if match_contains(pkg):
contains.append(pkgname)
continue
return sorted(startswith) + sorted(contains)# }}}
def draw_package(metapkg: ConsolidatedPackage, layout: bpy.types.UILayout): #{{{
"""Draws the given package"""
pkg = metapkg.get_display_version()
def draw_operators(metapkg, layout): # {{{
"""
Draws install, uninstall, update, enable, disable, and preferences
buttons as applicable for the given package
"""
pkg = metapkg.get_display_version()
if metapkg.installed:
if metapkg.test_updateable():
layout.operator(
"package.install",
text="Update to {}".format(bpkg.utils.fmt_version(metapkg.get_latest_version().version)),
).package_name=metapkg.name
layout.separator()
#TODO: only show preferences button if addon has preferences to show
if pkg.enabled:
layout.operator(
"wm.package_toggle_preferences",
text="Preferences",
).package_name=metapkg.name
row = layout.row()
row.alignment='RIGHT'
row.operator(
"package.uninstall",
text="Uninstall",
).package_name=metapkg.name
row.enabled = pkg.is_user
else:
layout.operator(
"package.install",
text="Install",
).package_name=metapkg.name
# }}}
def draw_preferences(pkg: Package, layout: bpy.types.UILayout):
"""Draw the package's preferences in the given layout"""
addon_preferences = context.user_preferences.addons[pkg.module_name].preferences
if addon_preferences is not None:
draw = getattr(addon_preferences, "draw", None)
if draw is not None:
addon_preferences_class = type(addon_preferences)
box_prefs = layout.box()
box_prefs.label("Preferences:")
addon_preferences_class.layout = box_prefs
try:
draw(context)
except:
import traceback
traceback.print_exc()
box_prefs.label(text="Error (see console)", icon='ERROR')
del addon_preferences_class.layout
def collapsed(metapkg, layout):# {{{
"""Draw collapsed version of package layout"""
pkg = metapkg.get_display_version()
# Only 'install' button is shown when package isn't installed,
# so allow more space for title/description.
spl = layout.split(.5 if pkg.installed else .8)
metacol = spl.column(align=True)
buttonrow = spl.row(align=True)
buttonrow.alignment = 'RIGHT'
l1 = metacol.row()
l2 = metacol.row()
draw_operators(metapkg, buttonrow)
if pkg.installed:
metacol.active = pkg.enabled
l1.operator("package.toggle_enabled",
icon='CHECKBOX_HLT' if pkg.enabled else 'CHECKBOX_DEHLT',
text="",
emboss=False,
).package_name = metapkg.name
if pkg.name:
l1.label(text=pkg.name)
if pkg.description:
l2.label(text=pkg.description)
l2.enabled = False #Give name more visual weight
# }}}
def expanded(metapkg, layout, layoutbox):# {{{
"""Draw expanded version of package layout"""
pkg = metapkg.get_display_version()
metacol = layoutbox.column(align=True)
row1 = layout.row(align=True)
if metapkg.installed:
row1.operator("package.toggle_enabled",
icon='CHECKBOX_HLT' if pkg.enabled else 'CHECKBOX_DEHLT',
text="",
emboss=False,
).package_name = metapkg.name
row1.label(pkg.name)
if metapkg.installed:
metacol.active = pkg.enabled
row1.active = pkg.enabled
if pkg.description:
row = metacol.row()
row.label(pkg.description)
def draw_metadatum(label: str, value: str, layout: bpy.types.UILayout):
"""Draw the given key value pair in a new row in given layout container"""
row = layout.row()
row.scale_y = .8
spl = row.split(.15)
spl.label("{}:".format(label))
spl.label(value)
# don't compare against None here; we don't want to display empty arrays/strings either
if pkg.location:
draw_metadatum("Location", pkg.location, metacol)
if pkg.version:
draw_metadatum("Version", bpkg.utils.fmt_version(pkg.version), metacol)
if pkg.blender:
draw_metadatum("Blender version", bpkg.utils.fmt_version(pkg.blender), metacol)
if pkg.category:
draw_metadatum("Category", pkg.category, metacol)
if pkg.author:
draw_metadatum("Author", pkg.author, metacol)
if pkg.support:
draw_metadatum("Support level", pkg.support.title(), metacol)
if pkg.warning:
draw_metadatum("Warning", pkg.warning, metacol)
metacol.separator()
spl = layoutbox.row().split(.35)
urlrow = spl.row()
buttonrow = spl.row(align=True)
urlrow.alignment = 'LEFT'
if pkg.wiki_url:
urlrow.operator("wm.url_open", text="Documentation", icon='HELP').url=pkg.wiki_url
if pkg.tracker_url:
urlrow.operator("wm.url_open", text="Report a Bug", icon='URL').url=pkg.tracker_url
buttonrow.alignment = 'RIGHT'
draw_operators(metapkg, buttonrow)
def draw_version(layout: bpy.types.UILayout, pkg: Package):
"""Draw version of package"""
spl = layout.split(.9)
left = spl.column()
right = spl.column()
right.alignment = 'RIGHT'
left.label(text=bpkg.utils.fmt_version(pkg.version))
for repo in pkg.repositories:
draw_metadatum("Repository", repo.name, left)
if pkg.installed:
right.label(text="Installed")
draw_metadatum("Installed to", str(pkg.installed_location), left)
if len(metapkg.versions) > 1:
row = pkgbox.row()
row.label(text="There are multiple versions of this package:")
for version in metapkg.versions:
subvbox = pkgbox.box()
draw_version(subvbox, version)
# }}}
is_expanded = (metapkg.name in bpkg.display.expanded_packages)
pkgbox = layout.box()
row = pkgbox.row(align=True)
row.operator(
"wm.package_toggle_expand",
icon='TRIA_DOWN' if is_expanded else 'TRIA_RIGHT',
emboss=False,
).package_name=metapkg.name
if is_expanded:
expanded(metapkg, row, pkgbox)
else:
collapsed(metapkg, row)# }}}
if pkg.installed and pkg.enabled and pkg.name == bpkg.display.preference_package:
draw_preferences(pkg, pkgbox)
def center_message(layout, msg: str):
"""draw a label in the center of an extra-tall row"""
row = layout.row()
row.label(text=msg)
row.alignment='CENTER'
row.scale_y = 10
# Things which only should be run once; initialize repository props and
# packages TODO: keeping it here in draw() means it's lazy loaded,
# perhaps it might be better to do asynchronously on startup
if not USERPREF_PT_packages._started:
USERPREF_PT_packages._started = True
bpkg.refresh_repository_props()
bpkg.refresh_packages()
# Enable all repositories by default
wm.package_repository_filter = set(repo['name'] for repo in wm.package_repositories)
if len(bpkg.packages) == 0:
center_message(pkgzone, "No packages found")
return
pkg_errors = bpkg.display.pkg_errors
if len(pkg_errors) > 0:
errbox = pkgzone.box()
for err in pkg_errors:
row = errbox.row()
row.label(text=err, icon='ERROR')
wm = bpy.context.window_manager
filters = {
'category': wm.addon_filter,
'search': wm.package_search,
'support': wm.addon_support,
'repository': wm.package_repository_filter,
'installstate': wm.package_state_filter,
}
bpkg.display.displayed_packages = filter_packages(filters, bpkg.packages)
for pkgname in bpkg.display.displayed_packages:
row = pkgzone.row()
draw_package(bpkg.packages[pkgname], row)
class USERPREF_PT_addons(Panel):
@@ -1559,6 +1932,7 @@ classes = (
USERPREF_MT_splash_footer,
USERPREF_PT_interface,
USERPREF_PT_edit,
USERPREF_PT_packages,
USERPREF_PT_system,
USERPREF_MT_interface_theme_presets,
USERPREF_PT_theme,

View File

@@ -594,7 +594,7 @@ typedef enum eUserPref_Section {
USER_SECTION_SYSTEM = 3,
USER_SECTION_THEME = 4,
USER_SECTION_INPUT = 5,
USER_SECTION_ADDONS = 6,
USER_SECTION_PACKAGES = 6,
} eUserPref_Section;
/* UserDef.flag */

View File

@@ -678,6 +678,7 @@ extern StructRNA RNA_UnknownType;
extern StructRNA RNA_UserPreferences;
extern StructRNA RNA_UserPreferencesEdit;
extern StructRNA RNA_UserPreferencesFilePaths;
extern StructRNA RNA_UserPreferencesPackages;
extern StructRNA RNA_UserPreferencesInput;
extern StructRNA RNA_UserPreferencesSystem;
extern StructRNA RNA_UserPreferencesView;

View File

@@ -339,6 +339,11 @@ static PointerRNA rna_UserDef_filepaths_get(PointerRNA *ptr)
return rna_pointer_inherit_refine(ptr, &RNA_UserPreferencesFilePaths, ptr->data);
}
static PointerRNA rna_UserDef_packages_get(PointerRNA *ptr)
{
return rna_pointer_inherit_refine(ptr, &RNA_UserPreferencesPackages, ptr->data);
}
static PointerRNA rna_UserDef_system_get(PointerRNA *ptr)
{
return rna_pointer_inherit_refine(ptr, &RNA_UserPreferencesSystem, ptr->data);
@@ -4595,6 +4600,18 @@ static void rna_def_userdef_filepaths(BlenderRNA *brna)
"Enables automatic saving of preview images in the .blend file");
}
static void rna_def_userdef_packages(BlenderRNA *brna)
{
PropertyRNA *prop;
StructRNA *srna;
srna = RNA_def_struct(brna, "UserPreferencesPackages", NULL);
RNA_def_struct_sdna(srna, "UserDef");
RNA_def_struct_nested(brna, srna, "UserPreferences");
RNA_def_struct_clear_flag(srna, STRUCT_UNDO);
RNA_def_struct_ui_text(srna, "Packages", "Where packages are managed");
}
static void rna_def_userdef_addon_collection(BlenderRNA *brna, PropertyRNA *cprop)
{
StructRNA *srna;
@@ -4656,7 +4673,7 @@ void RNA_def_userdef(BlenderRNA *brna)
{USER_SECTION_INTERFACE, "INTERFACE", 0, "Interface", ""},
{USER_SECTION_EDIT, "EDITING", 0, "Editing", ""},
{USER_SECTION_INPUT, "INPUT", 0, "Input", ""},
{USER_SECTION_ADDONS, "ADDONS", 0, "Add-ons", ""},
{USER_SECTION_PACKAGES, "PACKAGES", 0, "Packages", ""},
{USER_SECTION_THEME, "THEMES", 0, "Themes", ""},
{USER_SECTION_FILE, "FILES", 0, "File", ""},
{USER_SECTION_SYSTEM, "SYSTEM", 0, "System", ""},
@@ -4730,7 +4747,13 @@ void RNA_def_userdef(BlenderRNA *brna)
RNA_def_property_struct_type(prop, "UserPreferencesFilePaths");
RNA_def_property_pointer_funcs(prop, "rna_UserDef_filepaths_get", NULL, NULL, NULL);
RNA_def_property_ui_text(prop, "File Paths", "Default paths for external files");
prop = RNA_def_property(srna, "packages", PROP_POINTER, PROP_NONE);
RNA_def_property_flag(prop, PROP_NEVER_NULL);
RNA_def_property_struct_type(prop, "UserPreferencesPackages");
RNA_def_property_pointer_funcs(prop, "rna_UserDef_packages_get", NULL, NULL, NULL);
RNA_def_property_ui_text(prop, "Packages", "test");
prop = RNA_def_property(srna, "system", PROP_POINTER, PROP_NONE);
RNA_def_property_flag(prop, PROP_NEVER_NULL);
RNA_def_property_struct_type(prop, "UserPreferencesSystem");
@@ -4747,6 +4770,7 @@ void RNA_def_userdef(BlenderRNA *brna)
rna_def_userdef_edit(brna);
rna_def_userdef_input(brna);
rna_def_userdef_filepaths(brna);
rna_def_userdef_packages(brna);
rna_def_userdef_system(brna);
rna_def_userdef_addon(brna);
rna_def_userdef_addon_pref(brna);