#!/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 def iter_addons(path: Path) -> (Path, dict, list): """ Generator, yields (path, bl_info, filelist) of blender addons in `path`. """ for item in path.iterdir(): try: yield (item, extract_blinfo(item), get_filelist(item)) except BadAddon as err: log.info("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 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: rootlist.append(f.rstrip('/')) 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(): return [addon_path.name] if addon_path.is_file(): ext = addon_path.suffix.lower() if ext == '.zip': return filelist_from_zip(addon_path) elif ext == '.py': return [addon_path.name] 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) 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 = "" self.files = [] def to_dict(self) -> dict: """ Return a dict representation of the package """ return { 'bl_info': self.bl_info, 'url': self.url, 'files': self.files, } # 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']) 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, baseurl: str) -> Repository: """Make repo.json for files in directory 'path'""" repo = Repository(name) if not path.is_dir(): raise FileNotFoundError(path) for addon_path, bl_info, filelist in iter_addons(path): # Check if we have all bl_info fields we want if not REQUIRED_KEYS.issubset(set(bl_info)): log.warning( "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) ) continue package = Package(addon_path, bl_info) package.url = baseurl + package.path.name package.files = filelist 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('-u', '--baseurl', #TODO: support relative URLs help="Component of URL leading up to the package filename.") 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.WARNING) log.level += args.verbose if args.name is None: args.name = args.path.name if args.baseurl is None: log.warning("No baseurl given, package URLs will just be filenames") args.baseurl = "" log.debug("Repository name: '%s'" % args.name) repo = make_repo(args.path, args.name, args.baseurl) repo.dump(args.output) if __name__ == '__main__': main()