giuliomoro@229: # Copyright 2015,2016 Enzien Audio, Ltd. All Rights Reserved. chris@160: chris@160: import argparse giuliomoro@540: import datetime chris@160: import getpass chris@160: import json chris@160: import os chris@160: import requests chris@160: import shutil chris@160: import stat chris@160: import tempfile chris@160: import time chris@160: import urlparse chris@160: import zipfile chris@160: chris@160: class Colours: chris@160: purple = "\033[95m" chris@160: cyan = "\033[96m" chris@160: dark_cyan = "\033[36m" chris@160: blue = "\033[94m" chris@160: green = "\033[92m" chris@160: yellow = "\033[93m" chris@160: red = "\033[91m" chris@160: bold = "\033[1m" chris@160: underline = "\033[4m" chris@160: end = "\033[0m" chris@160: giuliomoro@403: # the maxmimum file upload size of 1MB giuliomoro@403: __HV_MAX_UPLOAD_SIZE = 1024*1024 giuliomoro@403: chris@160: def __zip_dir(in_dir, zip_path, file_filter=None): giuliomoro@403: """Recursively zip an entire directory with an optional file filter giuliomoro@403: """ chris@160: zf = zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) chris@160: for subdir, dirs, files in os.walk(in_dir): giuliomoro@403: for f in files: giuliomoro@403: if (file_filter is None) or (f.lower().split(".")[-1] in file_filter): chris@160: zf.write( giuliomoro@403: filename=os.path.join(subdir,f), giuliomoro@403: arcname=os.path.relpath(os.path.join(subdir,f), start=in_dir)) chris@160: return zip_path chris@160: chris@160: def __unzip(zip_path, target_dir): chris@160: """Unzip a file to a given directory. All destination files are overwritten. chris@160: """ chris@160: zipfile.ZipFile(zip_path).extractall(target_dir) chris@160: chris@160: def main(): chris@160: parser = argparse.ArgumentParser( chris@160: description="Compiles a Pure Data file.") chris@160: parser.add_argument( chris@160: "input_dir", giuliomoro@229: help="A directory containing _main.pd. All .pd files in the directory structure will be uploaded.") chris@160: parser.add_argument( chris@160: "-n", "--name", chris@160: default="heavy", giuliomoro@229: help="Patch name. If it doesn't exist on the Heavy site, the uploader will fail.") chris@160: parser.add_argument( chris@160: "-g", "--gen", chris@160: nargs="+", chris@160: default=["c"], giuliomoro@229: help="List of generator outputs. Currently supported generators are " giuliomoro@229: "'c', 'js', 'pdext', 'pdext-osx', 'unity', 'unity-osx', " giuliomoro@229: "'unity-win-x86', 'unity-win-x86_64', 'wwise', 'wwise-win-x86_64', " giuliomoro@229: "'vst2' ,'vst2-osx', and 'vst2-win-x86_64'.") chris@160: parser.add_argument( chris@160: "-b", giuliomoro@229: help="All files will be placed in the output directory, placed in their own subdirectory corresponding to the generator name.", giuliomoro@229: action="count") giuliomoro@229: parser.add_argument( giuliomoro@229: "-y", giuliomoro@229: help="Extract only the generated C files. Static files are deleted. " giuliomoro@229: "Only effective for the 'c' generator.", chris@160: action="count") chris@160: parser.add_argument( chris@160: "-o", "--out", chris@160: nargs="+", chris@160: default=["./"], # by default chris@160: help="List of destination directories for retrieved files. Order should be the same as for --gen.") chris@160: parser.add_argument( giuliomoro@403: "-r", "--release", giuliomoro@403: help="Optionally request a specific release of Heavy to use while compiling.") giuliomoro@403: parser.add_argument( chris@160: "-d", "--domain", chris@160: default="https://enzienaudio.com", chris@160: help="Domain. Default is https://enzienaudio.com.") chris@160: parser.add_argument( chris@160: "-x", chris@160: help="Don't save the returned token.", chris@160: action="count") chris@160: parser.add_argument( chris@160: "-z", chris@160: help="Force the use of a password, regardless of saved token.", chris@160: action="count") chris@160: parser.add_argument( chris@160: "--noverify", giuliomoro@229: help="Don't verify the SSL connection. This is generally a very bad idea.", chris@160: action="count") chris@160: parser.add_argument( chris@160: "-v", "--verbose", chris@160: help="Show debugging information.", chris@160: action="count") giuliomoro@229: parser.add_argument( giuliomoro@229: "-t", "--token", giuliomoro@229: help="Use the specified token.", giuliomoro@229: ) chris@160: args = parser.parse_args() chris@160: chris@160: domain = args.domain or "https://enzienaudio.com" chris@160: chris@160: post_data = {} chris@160: chris@160: # token should be stored in ~/.heavy/token chris@160: token_path = os.path.expanduser(os.path.join("~/", ".heavy", "token")) giuliomoro@229: giuliomoro@229: if args.token is not None: giuliomoro@229: # check if token has been passed as a command line arg... giuliomoro@229: post_data["credentials"] = {"token": args.token} giuliomoro@229: elif os.path.exists(token_path) and not args.z: giuliomoro@229: # ...or if it is stored in the user's home directory chris@160: with open(token_path, "r") as f: giuliomoro@229: post_data["credentials"] = {"token": f.read()} chris@160: else: chris@160: # otherwise, get the username and password chris@160: post_data["credentials"] = { chris@160: "username": raw_input("Enter username: "), chris@160: "password": getpass.getpass("Enter password: ") chris@160: } chris@160: chris@160: tick = time.time() chris@160: giuliomoro@403: # parse the optional release argument giuliomoro@403: if args.release: giuliomoro@540: try: giuliomoro@540: # check the validity of the current release giuliomoro@540: releases_json = requests.get(urlparse.urljoin(domain, "/a/releases")).json() giuliomoro@540: if args.release in releases_json: giuliomoro@540: today = datetime.datetime.now() giuliomoro@540: valid_until = datetime.datetime.strptime(releases_json[args.release]["validUntil"], "%Y-%m-%d") giuliomoro@540: if today > valid_until: giuliomoro@540: print "{0}Warning:{1} The release \"{2}\" expired on {3}. It may be removed at any time!".format( giuliomoro@540: Colours.yellow, Colours.end, giuliomoro@540: args.release, giuliomoro@540: releases_json[args.release]["validUntil"]) giuliomoro@540: elif (valid_until - today) <= datetime.timedelta(weeks=4): giuliomoro@540: print "{0}Warning:{1} The release \"{2}\" will expire soon on {3}.".format( giuliomoro@540: Colours.yellow, Colours.end, giuliomoro@540: args.release, giuliomoro@540: releases_json[args.release]["validUntil"]) giuliomoro@540: else: giuliomoro@540: print "{0}Error:{1} The release \"{2}\" is not available. Available releases are:".format( giuliomoro@540: Colours.red, Colours.end, giuliomoro@540: args.release) giuliomoro@540: for k,v in releases_json.items(): giuliomoro@540: print "* {0} ({1})".format( giuliomoro@540: k, giuliomoro@540: v["releaseDate"]) giuliomoro@540: return giuliomoro@540: except: giuliomoro@540: pass # if the /a/releases request fails for whatever reason, just move on giuliomoro@540: giuliomoro@403: post_data["release"] = args.release giuliomoro@403: chris@160: # make a temporary directory chris@160: temp_dir = tempfile.mkdtemp(prefix="lroyal-") chris@160: chris@160: # zip up the pd directory into the temporary directory chris@160: try: chris@160: if not os.path.exists(os.path.join(args.input_dir, "_main.pd")): chris@160: raise Exception("Root Pd directory does not contain a file named _main.pd.") chris@160: zip_path = __zip_dir( chris@160: args.input_dir, chris@160: os.path.join(temp_dir, "archive.zip"), chris@160: file_filter={"pd"}) giuliomoro@403: if os.stat(zip_path).st_size > __HV_MAX_UPLOAD_SIZE: giuliomoro@403: raise Exception("The target directory, zipped, is {0} bytes. The maximum upload size of 1MB.".format( giuliomoro@403: os.stat(zip_path).st_size)) chris@160: except Exception as e: giuliomoro@403: print "{0}Error:{1} {2}".format(Colours.red, Colours.end, e) chris@160: shutil.rmtree(temp_dir) # clean up the temporary directory giuliomoro@522: return chris@160: chris@160: post_data["name"] = args.name chris@160: chris@160: # the outputs to generate (always include c) giuliomoro@229: __SUPPORTED_GENERATOR_SET = { giuliomoro@229: "c", "js", giuliomoro@229: "pdext", "pdext-osx", giuliomoro@229: "unity", "unity-osx", "unity-win-x86", "unity-win-x86_64", giuliomoro@229: "wwise", "wwise-win-x86_64", giuliomoro@229: "vst2", "vst2-osx", "vst2-win-x86_64", giuliomoro@229: } giuliomoro@403: post_data["gen"] = list(({"c"} | {s.lower() for s in set(args.gen)}) & __SUPPORTED_GENERATOR_SET) chris@160: chris@160: # upload the job, get the response back chris@160: # NOTE(mhroth): multipart-encoded file can only be sent as a flat dictionary, chris@160: # but we want to send a json encoded deep dictionary. So we do a bit of a hack. chris@160: r = requests.post( chris@160: urlparse.urljoin(domain, "/a/heavy"), chris@160: data={"json":json.dumps(post_data)}, chris@160: files={"file": (os.path.basename(zip_path), open(zip_path, "rb"), "application/zip")}, chris@160: verify=False if args.noverify else True) chris@160: chris@160: if r.status_code != requests.codes.ok: chris@160: shutil.rmtree(temp_dir) # clean up the temporary directory giuliomoro@229: print "Getting a weird error? Get the latest uploader at https://enzienaudio.com/static/uploader.py" chris@160: r.raise_for_status() # raise an exception chris@160: chris@160: # decode the JSON API response chris@160: r_json = r.json() chris@160: chris@160: """ chris@160: { chris@160: "data": { chris@160: "compileTime": 0.05078411102294922, chris@160: "id": "mhroth/asdf/Edp2G", chris@160: "slug": "Edp2G", chris@160: "index": 3, chris@160: "links": { chris@160: "files": { chris@160: "linkage": [ chris@160: { chris@160: "id": "mhroth/asdf/Edp2G/c", chris@160: "type": "file" chris@160: } chris@160: ], chris@160: "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G/files" chris@160: }, chris@160: "project": { chris@160: "linkage": { chris@160: "id": "mhroth/asdf", chris@160: "type": "project" chris@160: }, chris@160: "self": "https://enzienaudio.com/h/mhroth/asdf" chris@160: }, chris@160: "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G", chris@160: "user": { chris@160: "linkage": { chris@160: "id": "mhroth", chris@160: "type": "user" chris@160: }, chris@160: "self": "https://enzienaudio.com/h/mhroth" chris@160: } chris@160: }, chris@160: "type": "job" chris@160: }, chris@160: "included": [ chris@160: { chris@160: "filename": "file.c.zip", chris@160: "generator": "c", chris@160: "id": "mhroth/asdf/Edp2G/c", chris@160: "links": { chris@160: "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G/c/file.c.zip" chris@160: }, chris@160: "mime": "application/zip", chris@160: "type": "file" chris@160: } chris@160: ], giuliomoro@229: "warnings": [ giuliomoro@229: {"details": "blah blah blah"} giuliomoro@229: ], chris@160: "meta": { chris@160: "token": "11AS0qPRmjTUHEMSovPEvzjodnzB1xaz" chris@160: } chris@160: } chris@160: """ chris@160: reply_json = r.json() chris@160: if args.verbose: chris@160: print json.dumps( chris@160: reply_json, chris@160: sort_keys=True, chris@160: indent=2, chris@160: separators=(",", ": ")) chris@160: chris@160: # update the api token, if present chris@160: if "token" in reply_json.get("meta",{}) and not args.x: giuliomoro@229: if args.token is not None: giuliomoro@229: if reply_json["meta"]["token"] != args.token: giuliomoro@229: print "WARNING: Token returned by API is not the same as the " giuliomoro@229: "token supplied at the command line. (old = %s, new = %s)".format( giuliomoro@229: args.token, giuliomoro@229: reply_json["meta"]["token"]) giuliomoro@229: else: giuliomoro@229: if not os.path.exists(os.path.dirname(token_path)): giuliomoro@229: # ensure that the .heavy directory exists giuliomoro@229: os.makedirs(os.path.dirname(token_path)) giuliomoro@229: with open(token_path, "w") as f: giuliomoro@229: f.write(reply_json["meta"]["token"]) giuliomoro@229: # force rw------- permissions on the file giuliomoro@229: os.chmod(token_path, stat.S_IRUSR | stat.S_IWUSR) chris@160: chris@160: # print any warnings giuliomoro@229: for i,x in enumerate(r_json.get("warnings",[])): giuliomoro@229: print "{3}) {0}Warning:{1} {2}".format( giuliomoro@229: Colours.yellow, Colours.end, x["detail"], i+1) chris@160: chris@160: # check for errors chris@160: if len(r_json.get("errors",[])) > 0: chris@160: shutil.rmtree(temp_dir) # clean up the temporary directory giuliomoro@229: for i,x in enumerate(r_json["errors"]): giuliomoro@229: print "{3}) {0}Error:{1} {2}".format( giuliomoro@229: Colours.red, Colours.end, x["detail"], i+1) giuliomoro@522: return chris@160: chris@160: # retrieve all requested files chris@160: for i,g in enumerate(args.gen): chris@160: file_url = __get_file_url_for_generator(reply_json, g) chris@160: if file_url is not None and (len(args.out) > i or args.b): chris@160: r = requests.get( chris@160: file_url, chris@160: cookies={"token": reply_json["meta"]["token"]}, chris@160: verify=False if args.noverify else True) chris@160: r.raise_for_status() chris@160: chris@160: # write the reply to a temporary file chris@160: c_zip_path = os.path.join(temp_dir, "archive.{0}.zip".format(g)) chris@160: with open(c_zip_path, "wb") as f: chris@160: f.write(r.content) chris@160: chris@160: # unzip the files to where they belong chris@160: if args.b: chris@160: target_dir = os.path.join(os.path.abspath(os.path.expanduser(args.out[0])), g) chris@160: else: chris@160: target_dir = os.path.abspath(os.path.expanduser(args.out[i])) chris@160: if not os.path.exists(target_dir): chris@160: os.makedirs(target_dir) # ensure that the output directory exists chris@160: __unzip(c_zip_path, target_dir) chris@160: giuliomoro@229: if g == "c" and args.y: giuliomoro@229: keep_files = ("_{0}.h".format(args.name), "_{0}.c".format(args.name)) giuliomoro@229: for f in os.listdir(target_dir): giuliomoro@229: if not f.endswith(keep_files): giuliomoro@229: os.remove(os.path.join(target_dir, f)); giuliomoro@229: chris@160: print "{0} files placed in {1}".format(g, target_dir) chris@160: else: chris@160: print "{0}Warning:{1} {2} files could not be retrieved.".format( chris@160: Colours.yellow, Colours.end, chris@160: g) chris@160: chris@160: # delete the temporary directory chris@160: shutil.rmtree(temp_dir) chris@160: giuliomoro@229: print "Job URL:", reply_json["data"]["links"]["self"] chris@160: print "Total request time: {0}ms".format(int(1000.0*(time.time()-tick))) giuliomoro@522: print "Heavy release:", reply_json.get("meta",{}).get("release", "default") chris@160: chris@160: def __get_file_url_for_generator(json_api, g): chris@160: """Returns the file link for a specific generator. chris@160: Returns None if no link could be found. chris@160: """ chris@160: for i in json_api["included"]: chris@160: if g == i["generator"]: chris@160: return i["links"]["self"] chris@160: return None # by default, return None chris@160: chris@160: chris@160: chris@160: if __name__ == "__main__": chris@160: main()