#!/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`. """ 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 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()) 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(): return blinfo_from_dir(item) if item.is_file(): ext = item.suffix.lower() if ext == '.zip': return blinfo_from_zip(item) elif ext == '.py': return blinfo_from_py(item) else: raise BadAddon("File '%s' doesn't have a .zip or .py extension; not an addon" % item) # This should not happen raise RuntimeError("Could not read addon '%s'" % item.name) class Package: """ Stores package path and metadata """ def __init__(self, path: Path, bl_info: dict): self.bl_info = bl_info self.path = path self.url = None def to_dict(self) -> dict: """ Return a dict representation of the package """ return { 'bl_info': self.bl_info, 'url': self.url, } class Repository: """ Stores repository metadata (including a list of packages) """ def __init__(self, name: str): self.name = name self.url = None self.packages = [] def add_package(self, pkg: Package): """ Add a package to the repository """ # if pkg.url is None: # pkg.url = self.packages.append(pkg) def to_dict(self) -> dict: """ Return a dict representation of the repository """ return { 'name': self.name, 'packages': [p.to_dict() for p in self.packages], 'url': self.url, } def dump(self, path: Path): """ Dump repository as a repo.json file in 'path' """ with (path / 'repo.json').open('w', encoding='utf-8') as repo_file: json.dump(self.to_dict(), repo_file, indent=4, sort_keys=True) log.info("repo.json written to %s" % path) 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() logging.basicConfig(format='%(levelname)8s: %(message)s', level=logging.INFO) log.level += args.verbose if args.name is None: args.name = args.path.name log.debug("Repository name: '%s'" % args.name) repo = make_repo(args.path, args.name) repo.dump(args.output) if __name__ == '__main__': main()