2017-07-02 15:15:48 -07:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
import argparse
|
|
|
|
import zipfile
|
|
|
|
import ast
|
|
|
|
import json
|
|
|
|
import logging
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
REQUIRED_KEYS = set(['name', 'blender', 'version'])
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
|
|
|
|
class BadAddon(Exception):
|
|
|
|
pass
|
|
|
|
|
2017-07-19 16:51:32 -07:00
|
|
|
def iter_addons(path: Path) -> (Path, dict, list):
|
2017-07-02 15:15:48 -07:00
|
|
|
"""
|
2017-07-19 16:51:32 -07:00
|
|
|
Generator, yields (path, bl_info, filelist) of blender addons in `path`.
|
2017-07-02 15:15:48 -07:00
|
|
|
"""
|
2017-07-02 16:55:17 -07:00
|
|
|
for item in path.iterdir():
|
|
|
|
try:
|
2017-07-19 16:51:32 -07:00
|
|
|
yield (item, extract_blinfo(item), get_filelist(item))
|
2017-07-02 16:55:17 -07:00
|
|
|
except BadAddon as err:
|
2017-07-19 16:51:32 -07:00
|
|
|
log.info("Skipping '{}': {}".format(item.name, err))
|
2017-07-02 15:15:48 -07:00
|
|
|
|
|
|
|
def parse_blinfo(source: str) -> dict:
|
|
|
|
"""Parse a python file and return its bl_info dict, if there is one (else return None)"""
|
|
|
|
|
|
|
|
try:
|
|
|
|
tree = ast.parse(source)
|
|
|
|
except SyntaxError as e:
|
|
|
|
raise BadAddon("Syntax error") from e
|
|
|
|
|
|
|
|
for body in tree.body:
|
|
|
|
if body.__class__ != ast.Assign:
|
|
|
|
continue
|
|
|
|
if len(body.targets) != 1:
|
|
|
|
continue
|
|
|
|
if getattr(body.targets[0], 'id', '') != 'bl_info':
|
|
|
|
continue
|
|
|
|
|
|
|
|
return ast.literal_eval(body.value)
|
|
|
|
|
|
|
|
raise BadAddon('No bl_info found')
|
|
|
|
|
2017-07-19 16:51:32 -07:00
|
|
|
def filelist_from_zip(zippath: Path) -> list:
|
|
|
|
"""Return list of files in the root of a zipfile"""
|
|
|
|
rootlist = []
|
|
|
|
with zipfile.ZipFile(str(zippath), 'r') as z:
|
|
|
|
for f in z.namelist():
|
|
|
|
# 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:
|
2017-07-20 18:06:42 -07:00
|
|
|
rootlist.append(f.rstrip('/'))
|
2017-07-19 16:51:32 -07:00
|
|
|
|
|
|
|
return rootlist
|
|
|
|
|
|
|
|
def get_filelist(addon_path: Path) -> list:
|
|
|
|
"""
|
|
|
|
Extract filelist from addon at path (can be single file, python package, or zip)
|
|
|
|
"""
|
|
|
|
|
|
|
|
if not addon_path.exists():
|
|
|
|
raise FileNotFoundError("Cannot extract blinfo from '%s'; no such file or directory" % addon_path)
|
|
|
|
|
|
|
|
if addon_path.is_dir():
|
2017-07-19 19:18:20 -07:00
|
|
|
return [addon_path.name]
|
2017-07-19 16:51:32 -07:00
|
|
|
|
|
|
|
if addon_path.is_file():
|
|
|
|
ext = addon_path.suffix.lower()
|
|
|
|
|
|
|
|
if ext == '.zip':
|
|
|
|
return filelist_from_zip(addon_path)
|
|
|
|
elif ext == '.py':
|
2017-07-19 19:18:20 -07:00
|
|
|
return [addon_path.name]
|
2017-07-19 16:51:32 -07:00
|
|
|
else:
|
|
|
|
raise BadAddon("File '%s' doesn't have a .zip or .py extension; not an addon" % addon_path)
|
|
|
|
|
|
|
|
# This should not happen
|
|
|
|
raise RuntimeError("Could not read addon '%s'" % addon_path.name)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-05 18:40:32 -07:00
|
|
|
def blinfo_from_zip(item: Path) -> dict:
|
|
|
|
try:
|
|
|
|
with zipfile.ZipFile(str(item), 'r') as z:
|
|
|
|
if len(z.namelist()) == 1:
|
|
|
|
# zipfile with one item: just read that item
|
|
|
|
return parse_blinfo(z.read(z.namelist()[0]))
|
|
|
|
else:
|
|
|
|
# zipfile with multiple items: try all __init__.py files
|
|
|
|
for fname in z.namelist():
|
|
|
|
# TODO: zips with multiple bl_infos might be a problem,
|
|
|
|
# not sure how such cases should be handled (if at all)
|
|
|
|
# for now we just break after the first one
|
|
|
|
if fname.endswith('__init__.py'):
|
|
|
|
try:
|
|
|
|
return parse_blinfo(z.read(fname))
|
|
|
|
except BadAddon:
|
|
|
|
continue
|
|
|
|
|
|
|
|
raise BadAddon("Zipfile '%s' doesn't contain a readable bl_info dict" % item)
|
|
|
|
|
|
|
|
except zipfile.BadZipFile as err:
|
|
|
|
raise BadAddon("Bad zipfile '%s'" % item) from err
|
|
|
|
|
|
|
|
|
|
|
|
def blinfo_from_py(item: Path) -> dict:
|
|
|
|
with item.open() as f:
|
|
|
|
return parse_blinfo(f.read())
|
|
|
|
|
|
|
|
def blinfo_from_dir(item: Path) -> dict:
|
|
|
|
try:
|
|
|
|
f = (item / '__init__.py').open("r")
|
|
|
|
except FileNotFoundError as err:
|
|
|
|
raise BadAddon("Directory '%s' doesn't contain __init__.py; not a python package" % item) from err
|
|
|
|
with f:
|
|
|
|
return parse_blinfo(f.read())
|
|
|
|
|
2017-07-02 15:15:48 -07:00
|
|
|
def extract_blinfo(item: Path) -> dict:
|
|
|
|
"""
|
|
|
|
Extract bl_info dict from addon at path (can be single file, python package, or zip)
|
|
|
|
"""
|
|
|
|
|
|
|
|
if not item.exists():
|
|
|
|
raise FileNotFoundError("Cannot extract blinfo from '%s'; no such file or directory" % item)
|
|
|
|
|
|
|
|
if item.is_dir():
|
2017-07-05 18:40:32 -07:00
|
|
|
return blinfo_from_dir(item)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-05 18:40:32 -07:00
|
|
|
if item.is_file():
|
2017-07-04 23:56:19 -07:00
|
|
|
ext = item.suffix.lower()
|
|
|
|
|
2017-07-05 18:40:32 -07:00
|
|
|
if ext == '.zip':
|
|
|
|
return blinfo_from_zip(item)
|
2017-07-04 23:56:19 -07:00
|
|
|
elif ext == '.py':
|
2017-07-05 18:40:32 -07:00
|
|
|
return blinfo_from_py(item)
|
2017-07-04 23:56:19 -07:00
|
|
|
else:
|
|
|
|
raise BadAddon("File '%s' doesn't have a .zip or .py extension; not an addon" % item)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
|
|
|
# This should not happen
|
2017-07-05 18:40:32 -07:00
|
|
|
raise RuntimeError("Could not read addon '%s'" % item.name)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
class Package:
|
2017-07-05 18:40:32 -07:00
|
|
|
"""
|
|
|
|
Stores package path and metadata
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, path: Path, bl_info: dict):
|
2017-07-04 23:56:19 -07:00
|
|
|
self.bl_info = bl_info
|
|
|
|
self.path = path
|
2017-07-19 16:51:32 -07:00
|
|
|
self.url = ""
|
|
|
|
self.files = []
|
2017-07-04 23:56:19 -07:00
|
|
|
|
2017-07-05 18:40:32 -07:00
|
|
|
def to_dict(self) -> dict:
|
|
|
|
"""
|
|
|
|
Return a dict representation of the package
|
|
|
|
"""
|
2017-07-04 23:56:19 -07:00
|
|
|
return {
|
|
|
|
'bl_info': self.bl_info,
|
|
|
|
'url': self.url,
|
2017-07-19 16:51:32 -07:00
|
|
|
'files': self.files,
|
2017-07-04 23:56:19 -07:00
|
|
|
}
|
|
|
|
|
2017-07-19 16:51:32 -07:00
|
|
|
# def raise_for_missing(self):
|
|
|
|
# """Ensure all fields are set"""
|
|
|
|
# if len(self.files) == 0:
|
|
|
|
# log.warning("Filelist for %s is empty", self.bl_info['name'])
|
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
class Repository:
|
2017-07-05 18:40:32 -07:00
|
|
|
"""
|
|
|
|
Stores repository metadata (including a list of packages)
|
|
|
|
"""
|
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
def __init__(self, name: str):
|
|
|
|
self.name = name
|
|
|
|
self.url = None
|
|
|
|
self.packages = []
|
|
|
|
|
|
|
|
def add_package(self, pkg: Package):
|
2017-07-05 18:40:32 -07:00
|
|
|
"""
|
|
|
|
Add a package to the repository
|
|
|
|
"""
|
2017-07-04 23:56:19 -07:00
|
|
|
# if pkg.url is None:
|
|
|
|
# pkg.url =
|
|
|
|
self.packages.append(pkg)
|
|
|
|
|
2017-07-05 18:40:32 -07:00
|
|
|
def to_dict(self) -> dict:
|
|
|
|
"""
|
|
|
|
Return a dict representation of the repository
|
|
|
|
"""
|
2017-07-04 23:56:19 -07:00
|
|
|
return {
|
|
|
|
'name': self.name,
|
2017-07-05 18:40:32 -07:00
|
|
|
'packages': [p.to_dict() for p in self.packages],
|
2017-07-04 23:56:19 -07:00
|
|
|
'url': self.url,
|
|
|
|
}
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
def dump(self, path: Path):
|
2017-07-05 18:40:32 -07:00
|
|
|
"""
|
|
|
|
Dump repository as a repo.json file in 'path'
|
|
|
|
"""
|
2017-07-04 23:56:19 -07:00
|
|
|
with (path / 'repo.json').open('w', encoding='utf-8') as repo_file:
|
2017-07-05 18:40:32 -07:00
|
|
|
json.dump(self.to_dict(), repo_file, indent=4, sort_keys=True)
|
2017-07-04 23:56:19 -07:00
|
|
|
log.info("repo.json written to %s" % path)
|
|
|
|
|
|
|
|
|
2017-07-14 00:56:34 -07:00
|
|
|
def make_repo(path: Path, name: str, baseurl: str) -> Repository:
|
2017-07-02 16:55:17 -07:00
|
|
|
"""Make repo.json for files in directory 'path'"""
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
repo = Repository(name)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-02 16:55:17 -07:00
|
|
|
if not path.is_dir():
|
|
|
|
raise FileNotFoundError(path)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-19 16:51:32 -07:00
|
|
|
for addon_path, bl_info, filelist in iter_addons(path):
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
# Check if we have all bl_info fields we want
|
2017-07-02 15:15:48 -07:00
|
|
|
if not REQUIRED_KEYS.issubset(set(bl_info)):
|
|
|
|
log.warning(
|
2017-07-19 16:51:32 -07:00
|
|
|
"Skipping '{}': Required key(s) '{}' not found in bl_info".format(
|
|
|
|
addon_path,
|
|
|
|
"', '".join(REQUIRED_KEYS.difference(set(bl_info)))
|
|
|
|
)
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
|
|
|
|
if addon_path.is_dir():
|
|
|
|
log.warning(
|
|
|
|
"Skipping '{}': Addon not zipped".format(addon_path)
|
2017-07-02 15:15:48 -07:00
|
|
|
)
|
2017-07-19 16:51:32 -07:00
|
|
|
continue
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-19 16:51:32 -07:00
|
|
|
package = Package(addon_path, bl_info)
|
2017-07-14 00:56:34 -07:00
|
|
|
package.url = baseurl + package.path.name
|
2017-07-19 16:51:32 -07:00
|
|
|
package.files = filelist
|
|
|
|
|
2017-07-04 23:56:19 -07:00
|
|
|
repo.add_package(package)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
|
|
|
log.info("Repository generation successful")
|
2017-07-04 23:56:19 -07:00
|
|
|
return repo
|
2017-07-02 15:15:48 -07:00
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
parser = argparse.ArgumentParser(description='Generate a blender package repository from a directory of addons')
|
|
|
|
|
|
|
|
parser.add_argument('-v', '--verbose',
|
|
|
|
help="Increase verbosity (can be used multiple times)",
|
|
|
|
action="count",
|
|
|
|
default=0)
|
2017-07-14 00:56:34 -07:00
|
|
|
parser.add_argument('-u', '--baseurl',
|
|
|
|
#TODO: support relative URLs
|
|
|
|
help="Component of URL leading up to the package filename.")
|
2017-07-04 23:56:19 -07:00
|
|
|
parser.add_argument('-n', '--name',
|
|
|
|
help="Name of repo (defaults to basename of 'path')")
|
|
|
|
parser.add_argument('-o', '--output',
|
|
|
|
help="Directory in which to write repo.json file",
|
|
|
|
type=Path,
|
|
|
|
default=Path.cwd())
|
2017-07-02 15:15:48 -07:00
|
|
|
parser.add_argument('path',
|
|
|
|
type=Path,
|
|
|
|
help="Path to addon directory")
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
2017-07-04 23:56:19 -07:00
|
|
|
|
2017-07-19 16:51:32 -07:00
|
|
|
logging.basicConfig(format='%(levelname)8s: %(message)s', level=logging.WARNING)
|
2017-07-04 23:56:19 -07:00
|
|
|
log.level += args.verbose
|
2017-07-02 15:15:48 -07:00
|
|
|
|
2017-07-05 18:40:32 -07:00
|
|
|
if args.name is None:
|
|
|
|
args.name = args.path.name
|
|
|
|
|
2017-07-14 00:56:34 -07:00
|
|
|
if args.baseurl is None:
|
|
|
|
log.warning("No baseurl given, package URLs will just be filenames")
|
|
|
|
args.baseurl = ""
|
2017-07-05 18:40:32 -07:00
|
|
|
log.debug("Repository name: '%s'" % args.name)
|
|
|
|
|
2017-07-14 00:56:34 -07:00
|
|
|
repo = make_repo(args.path, args.name, args.baseurl)
|
2017-07-04 23:56:19 -07:00
|
|
|
repo.dump(args.output)
|
2017-07-02 15:15:48 -07:00
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
main()
|