#!/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 addon_name = item.name if not item.exists(): raise FileNotFoundError("Cannot extract blinfo from '%s'; no such file or directory" % item) if item.is_dir(): fname = item / '__init__.py' try: with fname.open("r") as f: blinfo = parse_blinfo(f.read()) except FileNotFoundError as err: # directory with no __init__.py: not an addon raise BadAddon("Directory '%s' doesn't contain __init__.py; not a python package" % item) from err elif item.is_file(): 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: # If it's not a valid zip, assume file is just a normal file # TODO: this probably blows up inelegantly on corrupted zips with item.open() as f: blinfo = parse_blinfo(f.read()) # This should not happen if blinfo == None: raise RuntimeError("Could not read addon '%s'" % addon_name) return blinfo def make_repo(path: Path): """Make repo.json for files in directory 'path'""" repo_data = {} package_data = [] if not path.is_dir(): raise FileNotFoundError(path) for addon, bl_info in iter_addons(path): package_datum = {} 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_datum['bl_info'] = bl_info package_datum['type'] = 'addon' package_data.append(package_datum) repo_data['packages'] = package_data log.info("Repository generation successful") cwd = Path.cwd() dump_repo(cwd, repo_data) log.info("repo.json written to %s" % cwd) 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('path', type=Path, nargs='?', default=Path.cwd(), help="Path to addon directory") args = parser.parse_args() log.level = args.verbose logging.basicConfig(level=logging.INFO, format='%(levelname)8s: %(message)s') make_repo(args.path) if __name__ == '__main__': main()