annotate scripts/hvresources/uploader.py @ 556:ce391098f321 prerelease tip

THIS PROJECT HAS MOVED TO https://github.com/BelaPlatform/bela
author Giulio Moro <giuliomoro@yahoo.it>
date Sat, 25 Jun 2016 20:21:00 +0100
parents ff0e9e827dcd
children
rev   line source
giuliomoro@229 1 # Copyright 2015,2016 Enzien Audio, Ltd. All Rights Reserved.
chris@160 2
chris@160 3 import argparse
giuliomoro@540 4 import datetime
chris@160 5 import getpass
chris@160 6 import json
chris@160 7 import os
giuliomoro@549 8 import requests # http://docs.python-requests.org/en/master/api/#exceptions
chris@160 9 import shutil
chris@160 10 import stat
giuliomoro@549 11 import sys
chris@160 12 import tempfile
chris@160 13 import time
chris@160 14 import urlparse
chris@160 15 import zipfile
chris@160 16
chris@160 17 class Colours:
chris@160 18 purple = "\033[95m"
chris@160 19 cyan = "\033[96m"
chris@160 20 dark_cyan = "\033[36m"
chris@160 21 blue = "\033[94m"
chris@160 22 green = "\033[92m"
chris@160 23 yellow = "\033[93m"
chris@160 24 red = "\033[91m"
chris@160 25 bold = "\033[1m"
chris@160 26 underline = "\033[4m"
chris@160 27 end = "\033[0m"
chris@160 28
giuliomoro@549 29 class ErrorCodes(object):
giuliomoro@549 30 # NOTE(mhroth): this class could inherit from Enum, but we choose not to
giuliomoro@549 31 # as to not require an additional dependency
giuliomoro@549 32 # http://www.tldp.org/LDP/abs/html/exitcodes.html
giuliomoro@549 33 # http://stackoverflow.com/questions/1101957/are-there-any-standard-exit-status-codes-in-linux
giuliomoro@549 34 CODE_OK = 0 # success!
giuliomoro@549 35 CODE_MAIN_NOT_FOUND = 3 # _main.pd not found
giuliomoro@549 36 CODE_HEAVY_COMPILE_ERRORS = 4 # heavy returned compiler errors
giuliomoro@549 37 CODE_UPLOAD_ASSET_TOO_LARGE = 5 # the size of the uploadable asset is too large
giuliomoro@549 38 CODE_RELEASE_NOT_AVAILABLE = 6 # the requested release is not available
giuliomoro@549 39 CODE_CONNECTION_ERROR = 7 # HTTPS connection could not be made to the server
giuliomoro@549 40 CODE_CONNECTION_TIMEOUT = 8 # HTTPS connection has timed out
giuliomoro@549 41 CODE_CONNECTION_400_500 = 9 # a 400 or 500 error has occured
giuliomoro@549 42 CODE_EXCEPTION = 125 # a generic execption has occurred
giuliomoro@549 43
giuliomoro@549 44 class UploaderException(Exception):
giuliomoro@549 45 def __init__(self, code, message=None, e=None):
giuliomoro@549 46 self.code = code
giuliomoro@549 47 self.message = message
giuliomoro@549 48 self.e = e
giuliomoro@549 49
giuliomoro@403 50 # the maxmimum file upload size of 1MB
giuliomoro@549 51 __HV_MAX_UPLOAD_SIZE = 1 * 1024*1024
giuliomoro@403 52
chris@160 53 def __zip_dir(in_dir, zip_path, file_filter=None):
giuliomoro@403 54 """Recursively zip an entire directory with an optional file filter
giuliomoro@403 55 """
chris@160 56 zf = zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED)
chris@160 57 for subdir, dirs, files in os.walk(in_dir):
giuliomoro@403 58 for f in files:
giuliomoro@403 59 if (file_filter is None) or (f.lower().split(".")[-1] in file_filter):
chris@160 60 zf.write(
giuliomoro@403 61 filename=os.path.join(subdir,f),
giuliomoro@403 62 arcname=os.path.relpath(os.path.join(subdir,f), start=in_dir))
chris@160 63 return zip_path
chris@160 64
chris@160 65 def __unzip(zip_path, target_dir):
chris@160 66 """Unzip a file to a given directory. All destination files are overwritten.
chris@160 67 """
chris@160 68 zipfile.ZipFile(zip_path).extractall(target_dir)
chris@160 69
chris@160 70 def main():
chris@160 71 parser = argparse.ArgumentParser(
chris@160 72 description="Compiles a Pure Data file.")
chris@160 73 parser.add_argument(
chris@160 74 "input_dir",
giuliomoro@229 75 help="A directory containing _main.pd. All .pd files in the directory structure will be uploaded.")
chris@160 76 parser.add_argument(
chris@160 77 "-n", "--name",
chris@160 78 default="heavy",
giuliomoro@229 79 help="Patch name. If it doesn't exist on the Heavy site, the uploader will fail.")
chris@160 80 parser.add_argument(
chris@160 81 "-g", "--gen",
chris@160 82 nargs="+",
chris@160 83 default=["c"],
giuliomoro@229 84 help="List of generator outputs. Currently supported generators are "
giuliomoro@229 85 "'c', 'js', 'pdext', 'pdext-osx', 'unity', 'unity-osx', "
giuliomoro@229 86 "'unity-win-x86', 'unity-win-x86_64', 'wwise', 'wwise-win-x86_64', "
giuliomoro@549 87 "'vst2' ,'vst2-osx', 'vst2-win-x86_64', and 'vst2-win-x86'.")
chris@160 88 parser.add_argument(
chris@160 89 "-b",
giuliomoro@229 90 help="All files will be placed in the output directory, placed in their own subdirectory corresponding to the generator name.",
giuliomoro@229 91 action="count")
giuliomoro@229 92 parser.add_argument(
giuliomoro@229 93 "-y",
giuliomoro@229 94 help="Extract only the generated C files. Static files are deleted. "
giuliomoro@229 95 "Only effective for the 'c' generator.",
chris@160 96 action="count")
chris@160 97 parser.add_argument(
chris@160 98 "-o", "--out",
chris@160 99 nargs="+",
chris@160 100 default=["./"], # by default
chris@160 101 help="List of destination directories for retrieved files. Order should be the same as for --gen.")
chris@160 102 parser.add_argument(
giuliomoro@403 103 "-r", "--release",
giuliomoro@403 104 help="Optionally request a specific release of Heavy to use while compiling.")
giuliomoro@403 105 parser.add_argument(
chris@160 106 "-d", "--domain",
chris@160 107 default="https://enzienaudio.com",
chris@160 108 help="Domain. Default is https://enzienaudio.com.")
chris@160 109 parser.add_argument(
chris@160 110 "-x",
chris@160 111 help="Don't save the returned token.",
chris@160 112 action="count")
chris@160 113 parser.add_argument(
chris@160 114 "-z",
chris@160 115 help="Force the use of a password, regardless of saved token.",
chris@160 116 action="count")
chris@160 117 parser.add_argument(
chris@160 118 "--noverify",
giuliomoro@229 119 help="Don't verify the SSL connection. This is generally a very bad idea.",
chris@160 120 action="count")
chris@160 121 parser.add_argument(
chris@160 122 "-v", "--verbose",
chris@160 123 help="Show debugging information.",
chris@160 124 action="count")
giuliomoro@229 125 parser.add_argument(
giuliomoro@229 126 "-t", "--token",
giuliomoro@549 127 help="Use the specified token.")
chris@160 128 args = parser.parse_args()
chris@160 129
giuliomoro@549 130 try:
giuliomoro@549 131 # set default values
giuliomoro@549 132 domain = args.domain or "https://enzienaudio.com"
giuliomoro@549 133 exit_code = ErrorCodes.CODE_OK
giuliomoro@549 134 temp_dir = None
giuliomoro@549 135 post_data = {}
chris@160 136
giuliomoro@549 137 # token should be stored in ~/.heavy/token
giuliomoro@549 138 token_path = os.path.expanduser(os.path.join("~/", ".heavy", "token"))
chris@160 139
giuliomoro@549 140 if args.token is not None:
giuliomoro@549 141 # check if token has been passed as a command line arg...
giuliomoro@549 142 post_data["credentials"] = {"token": args.token}
giuliomoro@549 143 elif os.path.exists(token_path) and not args.z:
giuliomoro@549 144 # ...or if it is stored in the user's home directory
giuliomoro@549 145 with open(token_path, "r") as f:
giuliomoro@549 146 post_data["credentials"] = {"token": f.read()}
giuliomoro@549 147 else:
giuliomoro@549 148 # otherwise, get the username and password
giuliomoro@549 149 post_data["credentials"] = {
giuliomoro@549 150 "username": raw_input("Enter username: "),
giuliomoro@549 151 "password": getpass.getpass("Enter password: ")
giuliomoro@549 152 }
giuliomoro@229 153
giuliomoro@549 154 tick = time.time()
chris@160 155
giuliomoro@549 156 # parse the optional release argument
giuliomoro@549 157 if args.release:
giuliomoro@540 158 # check the validity of the current release
giuliomoro@540 159 releases_json = requests.get(urlparse.urljoin(domain, "/a/releases")).json()
giuliomoro@540 160 if args.release in releases_json:
giuliomoro@540 161 today = datetime.datetime.now()
giuliomoro@540 162 valid_until = datetime.datetime.strptime(releases_json[args.release]["validUntil"], "%Y-%m-%d")
giuliomoro@540 163 if today > valid_until:
giuliomoro@540 164 print "{0}Warning:{1} The release \"{2}\" expired on {3}. It may be removed at any time!".format(
giuliomoro@540 165 Colours.yellow, Colours.end,
giuliomoro@540 166 args.release,
giuliomoro@540 167 releases_json[args.release]["validUntil"])
giuliomoro@540 168 elif (valid_until - today) <= datetime.timedelta(weeks=4):
giuliomoro@540 169 print "{0}Warning:{1} The release \"{2}\" will expire soon on {3}.".format(
giuliomoro@540 170 Colours.yellow, Colours.end,
giuliomoro@540 171 args.release,
giuliomoro@540 172 releases_json[args.release]["validUntil"])
giuliomoro@540 173 else:
giuliomoro@540 174 print "{0}Error:{1} The release \"{2}\" is not available. Available releases are:".format(
giuliomoro@540 175 Colours.red, Colours.end,
giuliomoro@540 176 args.release)
giuliomoro@540 177 for k,v in releases_json.items():
giuliomoro@540 178 print "* {0} ({1})".format(
giuliomoro@540 179 k,
giuliomoro@540 180 v["releaseDate"])
giuliomoro@549 181 raise UploaderException(ErrorCodes.CODE_RELEASE_NOT_AVAILABLE)
giuliomoro@540 182
giuliomoro@549 183 post_data["release"] = args.release
giuliomoro@403 184
giuliomoro@549 185 # make a temporary directory
giuliomoro@549 186 temp_dir = tempfile.mkdtemp(prefix="lroyal-")
chris@160 187
giuliomoro@549 188 # zip up the pd directory into the temporary directory
chris@160 189 if not os.path.exists(os.path.join(args.input_dir, "_main.pd")):
giuliomoro@549 190 raise UploaderException(
giuliomoro@549 191 ErrorCodes.CODE_MAIN_NOT_FOUND,
giuliomoro@549 192 "Root Pd directory does not contain a file named _main.pd.")
chris@160 193 zip_path = __zip_dir(
chris@160 194 args.input_dir,
chris@160 195 os.path.join(temp_dir, "archive.zip"),
chris@160 196 file_filter={"pd"})
giuliomoro@403 197 if os.stat(zip_path).st_size > __HV_MAX_UPLOAD_SIZE:
giuliomoro@549 198 raise UploaderException(
giuliomoro@549 199 ErrorCodes.CODE_UPLOAD_ASSET_TOO_LARGE,
giuliomoro@549 200 "The target directory, zipped, is {0} bytes. The maximum upload size of 1MB.".format(
giuliomoro@549 201 os.stat(zip_path).st_size))
giuliomoro@549 202
giuliomoro@549 203 post_data["name"] = args.name
giuliomoro@549 204
giuliomoro@549 205 # the outputs to generate (always include c)
giuliomoro@549 206 __SUPPORTED_GENERATOR_SET = {
giuliomoro@549 207 "c", "js",
giuliomoro@549 208 "pdext", "pdext-osx",
giuliomoro@549 209 "unity", "unity-osx", "unity-win-x86", "unity-win-x86_64",
giuliomoro@549 210 "wwise", "wwise-win-x86_64",
giuliomoro@549 211 "vst2", "vst2-osx", "vst2-win-x86_64",
giuliomoro@549 212 }
giuliomoro@549 213 post_data["gen"] = list(({"c"} | {s.lower() for s in set(args.gen)}) & __SUPPORTED_GENERATOR_SET)
giuliomoro@549 214
giuliomoro@549 215 # upload the job, get the response back
giuliomoro@549 216 # NOTE(mhroth): multipart-encoded file can only be sent as a flat dictionary,
giuliomoro@549 217 # but we want to send a json encoded deep dictionary. So we do a bit of a hack.
giuliomoro@549 218 r = requests.post(
giuliomoro@549 219 urlparse.urljoin(domain, "/a/heavy"),
giuliomoro@549 220 data={"json":json.dumps(post_data)},
giuliomoro@549 221 files={"file": (os.path.basename(zip_path), open(zip_path, "rb"), "application/zip")},
giuliomoro@549 222 verify=False if args.noverify else True)
giuliomoro@549 223 r.raise_for_status()
giuliomoro@549 224
giuliomoro@549 225 """
giuliomoro@549 226 {
giuliomoro@549 227 "data": {
giuliomoro@549 228 "compileTime": 0.05078411102294922,
giuliomoro@549 229 "id": "mhroth/asdf/Edp2G",
giuliomoro@549 230 "slug": "Edp2G",
giuliomoro@549 231 "index": 3,
giuliomoro@549 232 "links": {
giuliomoro@549 233 "files": {
giuliomoro@549 234 "linkage": [
giuliomoro@549 235 {
giuliomoro@549 236 "id": "mhroth/asdf/Edp2G/c",
giuliomoro@549 237 "type": "file"
giuliomoro@549 238 }
giuliomoro@549 239 ],
giuliomoro@549 240 "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G/files"
giuliomoro@549 241 },
giuliomoro@549 242 "project": {
giuliomoro@549 243 "linkage": {
giuliomoro@549 244 "id": "mhroth/asdf",
giuliomoro@549 245 "type": "project"
giuliomoro@549 246 },
giuliomoro@549 247 "self": "https://enzienaudio.com/h/mhroth/asdf"
giuliomoro@549 248 },
giuliomoro@549 249 "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G",
giuliomoro@549 250 "user": {
giuliomoro@549 251 "linkage": {
giuliomoro@549 252 "id": "mhroth",
giuliomoro@549 253 "type": "user"
giuliomoro@549 254 },
giuliomoro@549 255 "self": "https://enzienaudio.com/h/mhroth"
giuliomoro@549 256 }
giuliomoro@549 257 },
giuliomoro@549 258 "type": "job"
giuliomoro@549 259 },
giuliomoro@549 260 "included": [
giuliomoro@549 261 {
giuliomoro@549 262 "filename": "file.c.zip",
giuliomoro@549 263 "generator": "c",
giuliomoro@549 264 "id": "mhroth/asdf/Edp2G/c",
giuliomoro@549 265 "links": {
giuliomoro@549 266 "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G/c/file.c.zip"
giuliomoro@549 267 },
giuliomoro@549 268 "mime": "application/zip",
giuliomoro@549 269 "type": "file"
giuliomoro@549 270 }
giuliomoro@549 271 ],
giuliomoro@549 272 "warnings": [
giuliomoro@549 273 {"details": "blah blah blah"}
giuliomoro@549 274 ],
giuliomoro@549 275 "meta": {
giuliomoro@549 276 "token": "11AS0qPRmjTUHEMSovPEvzjodnzB1xaz"
giuliomoro@549 277 }
giuliomoro@549 278 }
giuliomoro@549 279 """
giuliomoro@549 280 # decode the JSON API response
giuliomoro@549 281 reply_json = r.json()
giuliomoro@549 282 if args.verbose:
giuliomoro@549 283 print json.dumps(
giuliomoro@549 284 reply_json,
giuliomoro@549 285 sort_keys=True,
giuliomoro@549 286 indent=2,
giuliomoro@549 287 separators=(",", ": "))
giuliomoro@549 288
giuliomoro@549 289 # update the api token, if present
giuliomoro@549 290 if "token" in reply_json.get("meta",{}) and not args.x:
giuliomoro@549 291 if args.token is not None:
giuliomoro@549 292 if reply_json["meta"]["token"] != args.token:
giuliomoro@549 293 print "WARNING: Token returned by API is not the same as the "
giuliomoro@549 294 "token supplied at the command line. (old = %s, new = %s)".format(
giuliomoro@549 295 args.token,
giuliomoro@549 296 reply_json["meta"]["token"])
giuliomoro@549 297 else:
giuliomoro@549 298 if not os.path.exists(os.path.dirname(token_path)):
giuliomoro@549 299 # ensure that the .heavy directory exists
giuliomoro@549 300 os.makedirs(os.path.dirname(token_path))
giuliomoro@549 301 with open(token_path, "w") as f:
giuliomoro@549 302 f.write(reply_json["meta"]["token"])
giuliomoro@549 303 # force rw------- permissions on the file
giuliomoro@549 304 os.chmod(token_path, stat.S_IRUSR | stat.S_IWUSR)
giuliomoro@549 305
giuliomoro@549 306 # print any warnings
giuliomoro@549 307 for i,x in enumerate(reply_json.get("warnings",[])):
giuliomoro@549 308 print "{3}) {0}Warning:{1} {2}".format(
giuliomoro@549 309 Colours.yellow, Colours.end, x["detail"], i+1)
giuliomoro@549 310
giuliomoro@549 311 # check for errors
giuliomoro@549 312 if len(reply_json.get("errors",[])) > 0:
giuliomoro@549 313 for i,x in enumerate(reply_json["errors"]):
giuliomoro@549 314 print "{3}) {0}Error:{1} {2}".format(
giuliomoro@549 315 Colours.red, Colours.end, x["detail"], i+1)
giuliomoro@549 316 raise UploaderException(ErrorCodes.CODE_HEAVY_COMPILE_ERRORS)
giuliomoro@549 317
giuliomoro@549 318 # retrieve all requested files
giuliomoro@549 319 for i,g in enumerate(args.gen):
giuliomoro@549 320 file_url = __get_file_url_for_generator(reply_json, g)
giuliomoro@549 321 if file_url and (len(args.out) > i or args.b):
giuliomoro@549 322 r = requests.get(
giuliomoro@549 323 file_url,
giuliomoro@549 324 cookies={"token": reply_json["meta"]["token"]},
giuliomoro@549 325 verify=False if args.noverify else True)
giuliomoro@549 326 r.raise_for_status()
giuliomoro@549 327
giuliomoro@549 328 # write the reply to a temporary file
giuliomoro@549 329 c_zip_path = os.path.join(temp_dir, "archive.{0}.zip".format(g))
giuliomoro@549 330 with open(c_zip_path, "wb") as f:
giuliomoro@549 331 f.write(r.content)
giuliomoro@549 332
giuliomoro@549 333 # unzip the files to where they belong
giuliomoro@549 334 if args.b:
giuliomoro@549 335 target_dir = os.path.join(os.path.abspath(os.path.expanduser(args.out[0])), g)
giuliomoro@549 336 else:
giuliomoro@549 337 target_dir = os.path.abspath(os.path.expanduser(args.out[i]))
giuliomoro@549 338 if not os.path.exists(target_dir):
giuliomoro@549 339 os.makedirs(target_dir) # ensure that the output directory exists
giuliomoro@549 340 __unzip(c_zip_path, target_dir)
giuliomoro@549 341
giuliomoro@549 342 if g == "c" and args.y:
giuliomoro@549 343 keep_files = ("_{0}.h".format(args.name), "_{0}.c".format(args.name))
giuliomoro@549 344 for f in os.listdir(target_dir):
giuliomoro@549 345 if not f.endswith(keep_files):
giuliomoro@549 346 os.remove(os.path.join(target_dir, f));
giuliomoro@549 347
giuliomoro@549 348 print "{0} files placed in {1}".format(g, target_dir)
giuliomoro@549 349 else:
giuliomoro@549 350 print "{0}Warning:{1} {2} files could not be retrieved.".format(
giuliomoro@549 351 Colours.yellow, Colours.end,
giuliomoro@549 352 g)
giuliomoro@549 353
giuliomoro@549 354 print "Job URL:", reply_json["data"]["links"]["self"]
giuliomoro@549 355 print "Total request time: {0}ms".format(int(1000.0*(time.time()-tick)))
giuliomoro@549 356 print "Heavy release:", reply_json.get("meta",{}).get("release", "default")
giuliomoro@549 357 except UploaderException as e:
giuliomoro@549 358 exit_code = e.code
giuliomoro@549 359 if e.message:
giuliomoro@549 360 print "{0}Error:{1} {2}".format(Colours.red, Colours.end, e.message)
giuliomoro@549 361 except requests.ConnectionError as e:
giuliomoro@549 362 print "{0}Error:{1} Could not connect to server. Is the server down? Is the internet down?\n{2}".format(Colours.red, Colours.end, e)
giuliomoro@549 363 exit_code = ErrorCodes.CODE_CONNECTION_ERROR
giuliomoro@549 364 except requests.ConnectTimeout as e:
giuliomoro@549 365 print "{0}Error:{1} Connection to server timed out. The server might be overloaded. Try again later?\n{2}".format(Colours.red, Colours.end, e)
giuliomoro@549 366 exit_code = ErrorCodes.CODE_CONNECTION_TIMEOUT
giuliomoro@549 367 except requests.HTTPError as e:
giuliomoro@549 368 print "{0}Error:{1} An HTTP error has occurred.\n{2}".format(Colours.red, Colours.end, e)
giuliomoro@549 369 exit_code = ErrorCodes.CODE_CONNECTION_400_500
chris@160 370 except Exception as e:
giuliomoro@549 371 exit_code = ErrorCodes.CODE_EXCEPTION
giuliomoro@403 372 print "{0}Error:{1} {2}".format(Colours.red, Colours.end, e)
giuliomoro@549 373 print "Getting a weird error? Get the latest uploader at https://enzienaudio.com/static/uploader.py"
giuliomoro@549 374 finally:
giuliomoro@549 375 if temp_dir:
giuliomoro@549 376 shutil.rmtree(temp_dir) # delete the temporary directory no matter what
chris@160 377
giuliomoro@549 378 # exit and return the exit code
giuliomoro@549 379 sys.exit(exit_code)
chris@160 380
chris@160 381 def __get_file_url_for_generator(json_api, g):
chris@160 382 """Returns the file link for a specific generator.
chris@160 383 Returns None if no link could be found.
chris@160 384 """
chris@160 385 for i in json_api["included"]:
chris@160 386 if g == i["generator"]:
chris@160 387 return i["links"]["self"]
chris@160 388 return None # by default, return None
chris@160 389
chris@160 390
chris@160 391
chris@160 392 if __name__ == "__main__":
chris@160 393 main()