
This cleans up make_repo.py a bit, using file extensions to determine file type. This also loosens the testing repo generation, as the existing test required matching a predifed expected output which had to be updated on every change (essentially making it a moot test, as the reference output was obtained from the functions output). The new test just checks if the output has the same number of packages as the input dir has addons. Tips on how best to test these sorts of "higher level" functions (if at all) would be welcome :)
202 lines
6.1 KiB
Python
Executable File
202 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
from pathlib import Path
|
|
import argparse
|
|
import zipfile
|
|
import ast
|
|
import json
|
|
import logging
|
|
from utils import *
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
REQUIRED_KEYS = set(['name', 'blender', 'version'])
|
|
SCHEMA_VERSION = 1
|
|
|
|
class BadAddon(Exception):
|
|
pass
|
|
|
|
def iter_addons(path: Path) -> (Path, dict):
|
|
"""
|
|
Generator, yields (path, bl_info) of blender addons in `path`.
|
|
"""
|
|
pass
|
|
for item in path.iterdir():
|
|
try:
|
|
yield(item, extract_blinfo(item))
|
|
except BadAddon as err:
|
|
log.debug("Skipping '{}': {}".format(item.name, err))
|
|
|
|
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')
|
|
|
|
|
|
def extract_blinfo(item: Path) -> dict:
|
|
"""
|
|
Extract bl_info dict from addon at path (can be single file, python package, or zip)
|
|
"""
|
|
|
|
blinfo = None
|
|
|
|
if not item.exists():
|
|
raise FileNotFoundError("Cannot extract blinfo from '%s'; no such file or directory" % item)
|
|
|
|
if item.is_dir():
|
|
|
|
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:
|
|
blinfo = parse_blinfo(f.read())
|
|
|
|
elif item.is_file():
|
|
ext = item.suffix.lower()
|
|
if ext == '.zip':
|
|
try:
|
|
with zipfile.ZipFile(str(item), 'r') as z:
|
|
if len(z.namelist()) == 1:
|
|
# zipfile with one item: just read that item
|
|
blinfo = 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:
|
|
blinfo = parse_blinfo(z.read(fname))
|
|
break
|
|
except BadAddon:
|
|
continue
|
|
if blinfo is None:
|
|
raise BadAddon("Zipfile '%s' doesn't contain a readable bl_info dict" % item)
|
|
except zipfile.BadZipFile as e:
|
|
raise BadAddon("Bad zipfile '%s'" % item) from e
|
|
|
|
elif ext == '.py':
|
|
with item.open() as f:
|
|
blinfo = parse_blinfo(f.read())
|
|
|
|
else:
|
|
raise BadAddon("File '%s' doesn't have a .zip or .py extension; not an addon" % item)
|
|
|
|
# This should not happen
|
|
if blinfo is None:
|
|
raise RuntimeError("Could not read addon '%s'" % item.name)
|
|
|
|
return blinfo
|
|
|
|
|
|
class Package:
|
|
def __init__(self, path: Path, bl_info: dict, baseurl=None):
|
|
self.bl_info = bl_info
|
|
self.path = path
|
|
self.url = None
|
|
|
|
def dict(self) -> dict:
|
|
return {
|
|
'bl_info': self.bl_info,
|
|
'url': self.url,
|
|
}
|
|
|
|
class Repository:
|
|
def __init__(self, name: str):
|
|
self.name = name
|
|
self.url = None
|
|
self.packages = []
|
|
|
|
def add_package(self, pkg: Package):
|
|
# if pkg.url is None:
|
|
# pkg.url =
|
|
self.packages.append(pkg)
|
|
|
|
def dict(self) -> dict:
|
|
return {
|
|
'name': self.name,
|
|
'packages': [p.dict() for p in self.packages],
|
|
'url': self.url,
|
|
}
|
|
|
|
def dump(self, path: Path):
|
|
with (path / 'repo.json').open('w', encoding='utf-8') as repo_file:
|
|
json.dump(self.dict(), repo_file, indent=4, sort_keys=True)
|
|
log.info("repo.json written to %s" % path)
|
|
|
|
|
|
def json(self) -> str:
|
|
return json.dumps(self.__dict__)
|
|
|
|
|
|
def make_repo(path: Path, name: str) -> Repository:
|
|
"""Make repo.json for files in directory 'path'"""
|
|
|
|
repo = Repository(name)
|
|
|
|
if not path.is_dir():
|
|
raise FileNotFoundError(path)
|
|
|
|
for addon, bl_info in iter_addons(path):
|
|
|
|
# Check if we have all bl_info fields we want
|
|
if not REQUIRED_KEYS.issubset(set(bl_info)):
|
|
log.warning(
|
|
"Required key(s) '{}' not found in bl_info of '{}'".format(
|
|
"', '".join(REQUIRED_KEYS.difference(set(bl_info))), addon)
|
|
)
|
|
|
|
package = Package(addon, bl_info)
|
|
repo.add_package(package)
|
|
|
|
log.info("Repository generation successful")
|
|
return repo
|
|
|
|
|
|
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)
|
|
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())
|
|
parser.add_argument('path',
|
|
type=Path,
|
|
help="Path to addon directory")
|
|
|
|
args = parser.parse_args()
|
|
if args.name is None:
|
|
args.name = args.path.name
|
|
|
|
logging.basicConfig(format='%(levelname)8s: %(message)s', level=logging.INFO)
|
|
log.level += args.verbose
|
|
|
|
repo = make_repo(args.path, args.name)
|
|
repo.dump(args.output)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|