Initial multiple repository support
And lots of code reshuffling which likely should've been done in separate commits..
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
import logging
|
||||
__all__ = (
|
||||
"exceptions",
|
||||
"types",
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from . types import (
|
||||
Package,
|
||||
Repository,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
if 'bpy' in locals():
|
||||
import importlib
|
||||
|
||||
log.debug("Reloading %s", __name__)
|
||||
exceptions = importlib.reload(exceptions)
|
||||
Package = importlib.reload(types.Package)
|
||||
Repository = importlib.reload(types.Repository)
|
||||
|
||||
else:
|
||||
from . import exceptions
|
||||
from .types import (
|
||||
Package,
|
||||
Repository,
|
||||
)
|
||||
def load_repositories(repo_storage_path: Path) -> list:
|
||||
repositories = []
|
||||
for repofile in repo_storage_path.glob('*.json'):
|
||||
# try
|
||||
repo = Repository.from_file(repofile)
|
||||
# except
|
||||
repositories.append(repo)
|
||||
return repositories
|
||||
|
@@ -9,3 +9,6 @@ class DownloadException(BpkgException):
|
||||
|
||||
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"""
|
||||
|
@@ -15,14 +15,57 @@ class Package:
|
||||
self.bl_info = {}
|
||||
self.url = ""
|
||||
self.files = []
|
||||
self.set_from_dict(package_dict)
|
||||
|
||||
self.installed = False
|
||||
self.enabled = False
|
||||
self.repository = None
|
||||
self.repositories = []
|
||||
self.installed_location = None
|
||||
self.module_name = None
|
||||
|
||||
self.set_from_dict(package_dict)
|
||||
|
||||
@property
|
||||
def 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
|
||||
|
||||
@property
|
||||
def 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
|
||||
|
||||
@property
|
||||
def 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.installed
|
||||
# self.enabled = installed_pkg.enabled
|
||||
self.module_name = installed_pkg.module_name
|
||||
self.installed_location = installed_pkg.installed_location
|
||||
# self.is_user = installed_pkg.is_user
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
Return a dict representation of the package
|
||||
@@ -49,91 +92,58 @@ class Package:
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get name from bl_info"""
|
||||
try:
|
||||
return self.bl_info['name']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('name')
|
||||
|
||||
@property
|
||||
def version(self) -> tuple:
|
||||
"""Get version from bl_info"""
|
||||
try:
|
||||
return tuple(self.bl_info['version'])
|
||||
except KeyError:
|
||||
return None
|
||||
return tuple(self.bl_info.get('version'))
|
||||
|
||||
@property
|
||||
def blender(self) -> tuple:
|
||||
"""Get blender from bl_info"""
|
||||
try:
|
||||
return self.bl_info['blender']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('blender')
|
||||
|
||||
# optional fields
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get description from bl_info"""
|
||||
try:
|
||||
return self.bl_info['description']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('description')
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
"""Get author from bl_info"""
|
||||
try:
|
||||
return self.bl_info['author']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('author')
|
||||
|
||||
@property
|
||||
def category(self) -> str:
|
||||
"""Get category from bl_info"""
|
||||
try:
|
||||
return self.bl_info['category']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('category')
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Get location from bl_info"""
|
||||
try:
|
||||
return self.bl_info['location']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('location')
|
||||
|
||||
@property
|
||||
def support(self) -> str:
|
||||
"""Get support from bl_info"""
|
||||
try:
|
||||
return self.bl_info['support']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('support')
|
||||
|
||||
@property
|
||||
def warning(self) -> str:
|
||||
"""Get warning from bl_info"""
|
||||
try:
|
||||
return self.bl_info['warning']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('warning')
|
||||
|
||||
@property
|
||||
def wiki_url(self) -> str:
|
||||
"""Get wiki_url from bl_info"""
|
||||
try:
|
||||
return self.bl_info['wiki_url']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('wiki_url')
|
||||
|
||||
@property
|
||||
def tracker_url(self) -> str:
|
||||
"""Get tracker_url from bl_info"""
|
||||
try:
|
||||
return self.bl_info['tracker_url']
|
||||
except KeyError:
|
||||
return None
|
||||
return self.bl_info.get('tracker_url')
|
||||
# }}}
|
||||
|
||||
# @classmethod
|
||||
@@ -169,7 +179,7 @@ class Package:
|
||||
try:
|
||||
pkg.bl_info = module.bl_info
|
||||
except AttributeError as err:
|
||||
raise BadAddon("Module does not appear to be an addon; no bl_info attribute") from err
|
||||
raise exceptions.BadAddon("Module does not appear to be an addon; no bl_info attribute") from err
|
||||
return pkg
|
||||
|
||||
def download(self, dest: Path, progress_callback=None) -> Path:
|
||||
@@ -193,10 +203,105 @@ class Package:
|
||||
|
||||
utils.install(downloaded, dest_dir)
|
||||
|
||||
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:
|
||||
return self.versions[0] # this is always sorted with the highest on top
|
||||
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 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
|
||||
if newpkg.installed:
|
||||
pkg.set_installed_metadata(newpkg)
|
||||
break
|
||||
|
||||
self.versions.append(newpkg)
|
||||
self.versions.sort(key=lambda v: v.version, reverse=True)
|
||||
|
||||
|
||||
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)
|
||||
@@ -212,15 +317,22 @@ class Repository:
|
||||
# def cleanse_packagelist(self):
|
||||
# """Remove empty packages (no bl_info), packages with no name"""
|
||||
|
||||
def refresh(self):
|
||||
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:
|
||||
progress_callback = lambda x: 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 = {}
|
||||
@@ -235,18 +347,18 @@ class Repository:
|
||||
pass
|
||||
|
||||
try:
|
||||
resp = requests.get(self.url, headers=req_headers, timeout=60)
|
||||
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?" % self.url) from 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', self.url, 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:
|
||||
@@ -265,17 +377,22 @@ class Repository:
|
||||
|
||||
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?" % self.url
|
||||
"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:
|
||||
@@ -304,17 +421,20 @@ class Repository:
|
||||
Get repository attributes from a dict such as produced by `to_dict`
|
||||
"""
|
||||
|
||||
def initialize(item, value):
|
||||
if item is None:
|
||||
return value
|
||||
else:
|
||||
return item
|
||||
# def initialize(item, value):
|
||||
# if item is None:
|
||||
# return value
|
||||
# else:
|
||||
# return item
|
||||
|
||||
#Be certain to initialize everything; downloaded packagelist might contain null values
|
||||
name = initialize(repodict.get('name'), "")
|
||||
url = initialize(repodict.get('url'), "")
|
||||
packages = initialize(repodict.get('packages'), [])
|
||||
headers = initialize(repodict.get('_headers'), {})
|
||||
# url = initialize(repodict.get('url'), "")
|
||||
# packages = initialize(repodict.get('packages'), [])
|
||||
# headers = initialize(repodict.get('_headers'), {})
|
||||
name = repodict.get('name', "")
|
||||
url = repodict.get('url', "")
|
||||
packages = repodict.get('packages', [])
|
||||
headers = repodict.get('_headers', {})
|
||||
|
||||
self.name = name
|
||||
self.url = url
|
||||
@@ -337,10 +457,26 @@ class Repository:
|
||||
if self.packages is None:
|
||||
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)
|
||||
|
||||
# def set_from_file(self, path: Path):
|
||||
# """
|
||||
# Set the current instance's attributes from a json file
|
||||
# """
|
||||
# repo_file = path.open('r', encoding='utf-8')
|
||||
#
|
||||
# with repo_file:
|
||||
# try:
|
||||
# self.set_from_dict(json.load(repo_file))
|
||||
# except Exception as err:
|
||||
# raise BadRepository from err
|
||||
#
|
||||
# self.log.debug("Repository read from %s", path)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path):
|
||||
"""
|
||||
@@ -351,8 +487,13 @@ class Repository:
|
||||
with repo_file:
|
||||
try:
|
||||
repo = cls.from_dict(json.load(repo_file))
|
||||
except Exception as err:
|
||||
raise BadRepository from err
|
||||
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")
|
||||
|
||||
cls.log.debug("Repository read from %s", path)
|
||||
return repo
|
||||
|
||||
def __repr__(self):
|
||||
return "Repository({}, {})".format(self.name, self.url)
|
||||
|
@@ -4,6 +4,18 @@ import shutil
|
||||
import logging
|
||||
|
||||
|
||||
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 download(url: str, destination: Path, progress_callback=None) -> Path:
|
||||
"""
|
||||
Downloads file at the given url, and if progress_callback is specified,
|
||||
@@ -17,12 +29,11 @@ def download(url: str, destination: Path, progress_callback=None) -> Path:
|
||||
log = logging.getLogger('%s.download' % __name__)
|
||||
|
||||
if progress_callback is None:
|
||||
# assing to do nothing function
|
||||
# assign to do-nothing function
|
||||
progress_callback = lambda x: None
|
||||
|
||||
progress_callback(0)
|
||||
|
||||
|
||||
# derive filename from url if `destination` is an existing directory, otherwise use `destination` directly
|
||||
if destination.is_dir():
|
||||
# TODO: get filename from Content-Disposition header, if available.
|
||||
@@ -35,10 +46,10 @@ def download(url: str, destination: Path, progress_callback=None) -> Path:
|
||||
|
||||
log.info('Downloading %s -> %s', url, local_fpath)
|
||||
|
||||
try:
|
||||
resp = requests.get(url, stream=True, verify=True)
|
||||
except requests.exceptions.RequestException as err:
|
||||
raise exceptions.DownloadException(err) from err
|
||||
# try:
|
||||
resp = requests.get(url, stream=True, verify=True)
|
||||
# except requests.exceptions.RequestException as err:
|
||||
# raise exceptions.DownloadException(err) from err
|
||||
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
@@ -187,3 +198,21 @@ def install(src_file: Path, dest_dir: Path):
|
||||
raise exceptions.InstallException("Failed to copy file to '%s': %s" % (dest_dir, err)) from err
|
||||
|
||||
log.debug("Installation succeeded")
|
||||
|
||||
|
||||
# def load_repository(repo_storage_path: Path, repo_name: str) -> Repository:
|
||||
# """Loads <repo_name>.json from <repo_storage_path>"""
|
||||
# pass
|
||||
#
|
||||
# def download_repository(repo_storage_path: Path, repo_name: str):
|
||||
# """Loads <repo_name>.json from <repo_storage_path>"""
|
||||
# pass
|
||||
# this is done in Repository
|
||||
|
||||
|
||||
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 + "/repo.json"
|
||||
return urlunsplit((parsed_url.scheme, parsed_url.netloc, new_path, parsed_url.query, parsed_url.fragment))
|
||||
|
Reference in New Issue
Block a user