giuliomoro@229
|
1 # Copyright 2015,2016 Enzien Audio, Ltd. All Rights Reserved.
|
chris@160
|
2
|
chris@160
|
3 import argparse
|
chris@160
|
4 import getpass
|
chris@160
|
5 import json
|
chris@160
|
6 import os
|
chris@160
|
7 import requests
|
chris@160
|
8 import shutil
|
chris@160
|
9 import stat
|
giuliomoro@504
|
10 import sys
|
chris@160
|
11 import tempfile
|
chris@160
|
12 import time
|
chris@160
|
13 import urlparse
|
chris@160
|
14 import zipfile
|
chris@160
|
15
|
chris@160
|
16 class Colours:
|
chris@160
|
17 purple = "\033[95m"
|
chris@160
|
18 cyan = "\033[96m"
|
chris@160
|
19 dark_cyan = "\033[36m"
|
chris@160
|
20 blue = "\033[94m"
|
chris@160
|
21 green = "\033[92m"
|
chris@160
|
22 yellow = "\033[93m"
|
chris@160
|
23 red = "\033[91m"
|
chris@160
|
24 bold = "\033[1m"
|
chris@160
|
25 underline = "\033[4m"
|
chris@160
|
26 end = "\033[0m"
|
chris@160
|
27
|
giuliomoro@403
|
28 # the maxmimum file upload size of 1MB
|
giuliomoro@403
|
29 __HV_MAX_UPLOAD_SIZE = 1024*1024
|
giuliomoro@403
|
30
|
chris@160
|
31 def __zip_dir(in_dir, zip_path, file_filter=None):
|
giuliomoro@403
|
32 """Recursively zip an entire directory with an optional file filter
|
giuliomoro@403
|
33 """
|
chris@160
|
34 zf = zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED)
|
chris@160
|
35 for subdir, dirs, files in os.walk(in_dir):
|
giuliomoro@403
|
36 for f in files:
|
giuliomoro@403
|
37 if (file_filter is None) or (f.lower().split(".")[-1] in file_filter):
|
chris@160
|
38 zf.write(
|
giuliomoro@403
|
39 filename=os.path.join(subdir,f),
|
giuliomoro@403
|
40 arcname=os.path.relpath(os.path.join(subdir,f), start=in_dir))
|
chris@160
|
41 return zip_path
|
chris@160
|
42
|
chris@160
|
43 def __unzip(zip_path, target_dir):
|
chris@160
|
44 """Unzip a file to a given directory. All destination files are overwritten.
|
chris@160
|
45 """
|
chris@160
|
46 zipfile.ZipFile(zip_path).extractall(target_dir)
|
chris@160
|
47
|
chris@160
|
48 def main():
|
chris@160
|
49 parser = argparse.ArgumentParser(
|
chris@160
|
50 description="Compiles a Pure Data file.")
|
chris@160
|
51 parser.add_argument(
|
chris@160
|
52 "input_dir",
|
giuliomoro@229
|
53 help="A directory containing _main.pd. All .pd files in the directory structure will be uploaded.")
|
chris@160
|
54 parser.add_argument(
|
chris@160
|
55 "-n", "--name",
|
chris@160
|
56 default="heavy",
|
giuliomoro@229
|
57 help="Patch name. If it doesn't exist on the Heavy site, the uploader will fail.")
|
chris@160
|
58 parser.add_argument(
|
chris@160
|
59 "-g", "--gen",
|
chris@160
|
60 nargs="+",
|
chris@160
|
61 default=["c"],
|
giuliomoro@229
|
62 help="List of generator outputs. Currently supported generators are "
|
giuliomoro@229
|
63 "'c', 'js', 'pdext', 'pdext-osx', 'unity', 'unity-osx', "
|
giuliomoro@229
|
64 "'unity-win-x86', 'unity-win-x86_64', 'wwise', 'wwise-win-x86_64', "
|
giuliomoro@229
|
65 "'vst2' ,'vst2-osx', and 'vst2-win-x86_64'.")
|
chris@160
|
66 parser.add_argument(
|
chris@160
|
67 "-b",
|
giuliomoro@229
|
68 help="All files will be placed in the output directory, placed in their own subdirectory corresponding to the generator name.",
|
giuliomoro@229
|
69 action="count")
|
giuliomoro@229
|
70 parser.add_argument(
|
giuliomoro@229
|
71 "-y",
|
giuliomoro@229
|
72 help="Extract only the generated C files. Static files are deleted. "
|
giuliomoro@229
|
73 "Only effective for the 'c' generator.",
|
chris@160
|
74 action="count")
|
chris@160
|
75 parser.add_argument(
|
chris@160
|
76 "-o", "--out",
|
chris@160
|
77 nargs="+",
|
chris@160
|
78 default=["./"], # by default
|
chris@160
|
79 help="List of destination directories for retrieved files. Order should be the same as for --gen.")
|
chris@160
|
80 parser.add_argument(
|
giuliomoro@403
|
81 "-r", "--release",
|
giuliomoro@403
|
82 help="Optionally request a specific release of Heavy to use while compiling.")
|
giuliomoro@403
|
83 parser.add_argument(
|
chris@160
|
84 "-d", "--domain",
|
chris@160
|
85 default="https://enzienaudio.com",
|
chris@160
|
86 help="Domain. Default is https://enzienaudio.com.")
|
chris@160
|
87 parser.add_argument(
|
chris@160
|
88 "-x",
|
chris@160
|
89 help="Don't save the returned token.",
|
chris@160
|
90 action="count")
|
chris@160
|
91 parser.add_argument(
|
chris@160
|
92 "-z",
|
chris@160
|
93 help="Force the use of a password, regardless of saved token.",
|
chris@160
|
94 action="count")
|
chris@160
|
95 parser.add_argument(
|
chris@160
|
96 "--noverify",
|
giuliomoro@229
|
97 help="Don't verify the SSL connection. This is generally a very bad idea.",
|
chris@160
|
98 action="count")
|
chris@160
|
99 parser.add_argument(
|
chris@160
|
100 "-v", "--verbose",
|
chris@160
|
101 help="Show debugging information.",
|
chris@160
|
102 action="count")
|
giuliomoro@229
|
103 parser.add_argument(
|
giuliomoro@229
|
104 "-t", "--token",
|
giuliomoro@229
|
105 help="Use the specified token.",
|
giuliomoro@229
|
106 )
|
chris@160
|
107 args = parser.parse_args()
|
chris@160
|
108
|
chris@160
|
109 domain = args.domain or "https://enzienaudio.com"
|
chris@160
|
110
|
chris@160
|
111 post_data = {}
|
chris@160
|
112
|
chris@160
|
113 # token should be stored in ~/.heavy/token
|
chris@160
|
114 token_path = os.path.expanduser(os.path.join("~/", ".heavy", "token"))
|
giuliomoro@229
|
115
|
giuliomoro@229
|
116 if args.token is not None:
|
giuliomoro@229
|
117 # check if token has been passed as a command line arg...
|
giuliomoro@229
|
118 post_data["credentials"] = {"token": args.token}
|
giuliomoro@229
|
119 elif os.path.exists(token_path) and not args.z:
|
giuliomoro@229
|
120 # ...or if it is stored in the user's home directory
|
chris@160
|
121 with open(token_path, "r") as f:
|
giuliomoro@229
|
122 post_data["credentials"] = {"token": f.read()}
|
chris@160
|
123 else:
|
chris@160
|
124 # otherwise, get the username and password
|
chris@160
|
125 post_data["credentials"] = {
|
chris@160
|
126 "username": raw_input("Enter username: "),
|
chris@160
|
127 "password": getpass.getpass("Enter password: ")
|
chris@160
|
128 }
|
chris@160
|
129
|
chris@160
|
130 tick = time.time()
|
chris@160
|
131
|
giuliomoro@403
|
132 # parse the optional release argument
|
giuliomoro@403
|
133 if args.release:
|
giuliomoro@403
|
134 post_data["release"] = args.release
|
giuliomoro@403
|
135
|
chris@160
|
136 # make a temporary directory
|
chris@160
|
137 temp_dir = tempfile.mkdtemp(prefix="lroyal-")
|
chris@160
|
138
|
chris@160
|
139 # zip up the pd directory into the temporary directory
|
chris@160
|
140 try:
|
chris@160
|
141 if not os.path.exists(os.path.join(args.input_dir, "_main.pd")):
|
chris@160
|
142 raise Exception("Root Pd directory does not contain a file named _main.pd.")
|
chris@160
|
143 zip_path = __zip_dir(
|
chris@160
|
144 args.input_dir,
|
chris@160
|
145 os.path.join(temp_dir, "archive.zip"),
|
chris@160
|
146 file_filter={"pd"})
|
giuliomoro@403
|
147 if os.stat(zip_path).st_size > __HV_MAX_UPLOAD_SIZE:
|
giuliomoro@403
|
148 raise Exception("The target directory, zipped, is {0} bytes. The maximum upload size of 1MB.".format(
|
giuliomoro@403
|
149 os.stat(zip_path).st_size))
|
chris@160
|
150 except Exception as e:
|
giuliomoro@403
|
151 print "{0}Error:{1} {2}".format(Colours.red, Colours.end, e)
|
chris@160
|
152 shutil.rmtree(temp_dir) # clean up the temporary directory
|
giuliomoro@504
|
153 sys.exit(1)
|
chris@160
|
154
|
chris@160
|
155 post_data["name"] = args.name
|
chris@160
|
156
|
chris@160
|
157 # the outputs to generate (always include c)
|
giuliomoro@229
|
158 __SUPPORTED_GENERATOR_SET = {
|
giuliomoro@229
|
159 "c", "js",
|
giuliomoro@229
|
160 "pdext", "pdext-osx",
|
giuliomoro@229
|
161 "unity", "unity-osx", "unity-win-x86", "unity-win-x86_64",
|
giuliomoro@229
|
162 "wwise", "wwise-win-x86_64",
|
giuliomoro@229
|
163 "vst2", "vst2-osx", "vst2-win-x86_64",
|
giuliomoro@229
|
164 }
|
giuliomoro@403
|
165 post_data["gen"] = list(({"c"} | {s.lower() for s in set(args.gen)}) & __SUPPORTED_GENERATOR_SET)
|
chris@160
|
166
|
chris@160
|
167 # upload the job, get the response back
|
chris@160
|
168 # NOTE(mhroth): multipart-encoded file can only be sent as a flat dictionary,
|
chris@160
|
169 # but we want to send a json encoded deep dictionary. So we do a bit of a hack.
|
chris@160
|
170 r = requests.post(
|
chris@160
|
171 urlparse.urljoin(domain, "/a/heavy"),
|
chris@160
|
172 data={"json":json.dumps(post_data)},
|
chris@160
|
173 files={"file": (os.path.basename(zip_path), open(zip_path, "rb"), "application/zip")},
|
chris@160
|
174 verify=False if args.noverify else True)
|
chris@160
|
175
|
chris@160
|
176 if r.status_code != requests.codes.ok:
|
chris@160
|
177 shutil.rmtree(temp_dir) # clean up the temporary directory
|
giuliomoro@229
|
178 print "Getting a weird error? Get the latest uploader at https://enzienaudio.com/static/uploader.py"
|
chris@160
|
179 r.raise_for_status() # raise an exception
|
chris@160
|
180
|
chris@160
|
181 # decode the JSON API response
|
chris@160
|
182 r_json = r.json()
|
chris@160
|
183
|
chris@160
|
184 """
|
chris@160
|
185 {
|
chris@160
|
186 "data": {
|
chris@160
|
187 "compileTime": 0.05078411102294922,
|
chris@160
|
188 "id": "mhroth/asdf/Edp2G",
|
chris@160
|
189 "slug": "Edp2G",
|
chris@160
|
190 "index": 3,
|
chris@160
|
191 "links": {
|
chris@160
|
192 "files": {
|
chris@160
|
193 "linkage": [
|
chris@160
|
194 {
|
chris@160
|
195 "id": "mhroth/asdf/Edp2G/c",
|
chris@160
|
196 "type": "file"
|
chris@160
|
197 }
|
chris@160
|
198 ],
|
chris@160
|
199 "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G/files"
|
chris@160
|
200 },
|
chris@160
|
201 "project": {
|
chris@160
|
202 "linkage": {
|
chris@160
|
203 "id": "mhroth/asdf",
|
chris@160
|
204 "type": "project"
|
chris@160
|
205 },
|
chris@160
|
206 "self": "https://enzienaudio.com/h/mhroth/asdf"
|
chris@160
|
207 },
|
chris@160
|
208 "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G",
|
chris@160
|
209 "user": {
|
chris@160
|
210 "linkage": {
|
chris@160
|
211 "id": "mhroth",
|
chris@160
|
212 "type": "user"
|
chris@160
|
213 },
|
chris@160
|
214 "self": "https://enzienaudio.com/h/mhroth"
|
chris@160
|
215 }
|
chris@160
|
216 },
|
chris@160
|
217 "type": "job"
|
chris@160
|
218 },
|
chris@160
|
219 "included": [
|
chris@160
|
220 {
|
chris@160
|
221 "filename": "file.c.zip",
|
chris@160
|
222 "generator": "c",
|
chris@160
|
223 "id": "mhroth/asdf/Edp2G/c",
|
chris@160
|
224 "links": {
|
chris@160
|
225 "self": "https://enzienaudio.com/h/mhroth/asdf/Edp2G/c/file.c.zip"
|
chris@160
|
226 },
|
chris@160
|
227 "mime": "application/zip",
|
chris@160
|
228 "type": "file"
|
chris@160
|
229 }
|
chris@160
|
230 ],
|
giuliomoro@229
|
231 "warnings": [
|
giuliomoro@229
|
232 {"details": "blah blah blah"}
|
giuliomoro@229
|
233 ],
|
chris@160
|
234 "meta": {
|
chris@160
|
235 "token": "11AS0qPRmjTUHEMSovPEvzjodnzB1xaz"
|
chris@160
|
236 }
|
chris@160
|
237 }
|
chris@160
|
238 """
|
chris@160
|
239 reply_json = r.json()
|
chris@160
|
240 if args.verbose:
|
chris@160
|
241 print json.dumps(
|
chris@160
|
242 reply_json,
|
chris@160
|
243 sort_keys=True,
|
chris@160
|
244 indent=2,
|
chris@160
|
245 separators=(",", ": "))
|
chris@160
|
246
|
chris@160
|
247 # update the api token, if present
|
chris@160
|
248 if "token" in reply_json.get("meta",{}) and not args.x:
|
giuliomoro@229
|
249 if args.token is not None:
|
giuliomoro@229
|
250 if reply_json["meta"]["token"] != args.token:
|
giuliomoro@229
|
251 print "WARNING: Token returned by API is not the same as the "
|
giuliomoro@229
|
252 "token supplied at the command line. (old = %s, new = %s)".format(
|
giuliomoro@229
|
253 args.token,
|
giuliomoro@229
|
254 reply_json["meta"]["token"])
|
giuliomoro@229
|
255 else:
|
giuliomoro@229
|
256 if not os.path.exists(os.path.dirname(token_path)):
|
giuliomoro@229
|
257 # ensure that the .heavy directory exists
|
giuliomoro@229
|
258 os.makedirs(os.path.dirname(token_path))
|
giuliomoro@229
|
259 with open(token_path, "w") as f:
|
giuliomoro@229
|
260 f.write(reply_json["meta"]["token"])
|
giuliomoro@229
|
261 # force rw------- permissions on the file
|
giuliomoro@229
|
262 os.chmod(token_path, stat.S_IRUSR | stat.S_IWUSR)
|
chris@160
|
263
|
chris@160
|
264 # print any warnings
|
giuliomoro@229
|
265 for i,x in enumerate(r_json.get("warnings",[])):
|
giuliomoro@229
|
266 print "{3}) {0}Warning:{1} {2}".format(
|
giuliomoro@229
|
267 Colours.yellow, Colours.end, x["detail"], i+1)
|
chris@160
|
268
|
chris@160
|
269 # check for errors
|
chris@160
|
270 if len(r_json.get("errors",[])) > 0:
|
chris@160
|
271 shutil.rmtree(temp_dir) # clean up the temporary directory
|
giuliomoro@229
|
272 for i,x in enumerate(r_json["errors"]):
|
giuliomoro@229
|
273 print "{3}) {0}Error:{1} {2}".format(
|
giuliomoro@229
|
274 Colours.red, Colours.end, x["detail"], i+1)
|
giuliomoro@504
|
275 sys.exit(2)
|
chris@160
|
276
|
chris@160
|
277 # retrieve all requested files
|
chris@160
|
278 for i,g in enumerate(args.gen):
|
chris@160
|
279 file_url = __get_file_url_for_generator(reply_json, g)
|
chris@160
|
280 if file_url is not None and (len(args.out) > i or args.b):
|
chris@160
|
281 r = requests.get(
|
chris@160
|
282 file_url,
|
chris@160
|
283 cookies={"token": reply_json["meta"]["token"]},
|
chris@160
|
284 verify=False if args.noverify else True)
|
chris@160
|
285 r.raise_for_status()
|
chris@160
|
286
|
chris@160
|
287 # write the reply to a temporary file
|
chris@160
|
288 c_zip_path = os.path.join(temp_dir, "archive.{0}.zip".format(g))
|
chris@160
|
289 with open(c_zip_path, "wb") as f:
|
chris@160
|
290 f.write(r.content)
|
chris@160
|
291
|
chris@160
|
292 # unzip the files to where they belong
|
chris@160
|
293 if args.b:
|
chris@160
|
294 target_dir = os.path.join(os.path.abspath(os.path.expanduser(args.out[0])), g)
|
chris@160
|
295 else:
|
chris@160
|
296 target_dir = os.path.abspath(os.path.expanduser(args.out[i]))
|
chris@160
|
297 if not os.path.exists(target_dir):
|
chris@160
|
298 os.makedirs(target_dir) # ensure that the output directory exists
|
chris@160
|
299 __unzip(c_zip_path, target_dir)
|
chris@160
|
300
|
giuliomoro@229
|
301 if g == "c" and args.y:
|
giuliomoro@229
|
302 keep_files = ("_{0}.h".format(args.name), "_{0}.c".format(args.name))
|
giuliomoro@229
|
303 for f in os.listdir(target_dir):
|
giuliomoro@229
|
304 if not f.endswith(keep_files):
|
giuliomoro@229
|
305 os.remove(os.path.join(target_dir, f));
|
giuliomoro@229
|
306
|
chris@160
|
307 print "{0} files placed in {1}".format(g, target_dir)
|
chris@160
|
308 else:
|
chris@160
|
309 print "{0}Warning:{1} {2} files could not be retrieved.".format(
|
chris@160
|
310 Colours.yellow, Colours.end,
|
chris@160
|
311 g)
|
chris@160
|
312
|
chris@160
|
313 # delete the temporary directory
|
chris@160
|
314 shutil.rmtree(temp_dir)
|
chris@160
|
315
|
giuliomoro@229
|
316 print "Job URL:", reply_json["data"]["links"]["self"]
|
chris@160
|
317 print "Total request time: {0}ms".format(int(1000.0*(time.time()-tick)))
|
giuliomoro@229
|
318 print "Heavy version:", reply_json["meta"]["version"]
|
giuliomoro@504
|
319 sys.exit(0) # success!
|
chris@160
|
320
|
chris@160
|
321 def __get_file_url_for_generator(json_api, g):
|
chris@160
|
322 """Returns the file link for a specific generator.
|
chris@160
|
323 Returns None if no link could be found.
|
chris@160
|
324 """
|
chris@160
|
325 for i in json_api["included"]:
|
chris@160
|
326 if g == i["generator"]:
|
chris@160
|
327 return i["links"]["self"]
|
chris@160
|
328 return None # by default, return None
|
chris@160
|
329
|
chris@160
|
330
|
chris@160
|
331
|
chris@160
|
332 if __name__ == "__main__":
|
chris@160
|
333 main()
|