from aiohttp import web import sys, os, asyncio, json from asyncio import create_subprocess_exec from urllib.parse import urlparse, unquote as urlunquote, quote as urlquote import argparse from asyncio.subprocess import DEVNULL, STDOUT, PIPE import aiohttp import base64 """ Todo: use proper http error responses for errors, see aiohttp/http_exceptions.py """ MAKE = { 'UP_TO_DATE': 0, 'NEEDS_REMAKING': 1, 'NOT_MAKEABLE': 2 } MAKE_BY_CODE = {code: value for value, code in MAKE.items()} async def is_makeable (path, cwd=".", makefile="makefile", make="make"): """ returns: 0=file is up to date, 1=file needs remaking, 2=file is not makeable """ rpath = os.path.relpath(path, cwd) p = await create_subprocess_exec(make, "--question", "-f", makefile, rpath, cwd = cwd, stdout=DEVNULL, stderr=DEVNULL) # retcode 0=file is up to date, 1=file needs remaking, 2=file is not makeable ret = await p.wait() print (f"{make} --question -f {makefile} {path} returned: {MAKE_BY_CODE[ret]}", file=sys.stderr) await log(f"is_makeable {0}: {MAKE_BY_CODE[ret]}".format(path, ret)) return ret async def make (path, cwd=".", makefile="makefile", make="make", force=False): rpath = os.path.relpath(path, cwd) makeargs = [make, "-f", makefile, rpath] if force: makeargs.append("-B") p = await create_subprocess_exec(*makeargs, stdout = PIPE, stderr = STDOUT, cwd = cwd) resp = "" while True: line = await p.stdout.readline() if not line: break resp+=line.decode("utf-8") # log(line) # print (line) ret = await p.wait() == 0 print ("make {0}: {1}".format(path, ret), file=sys.stderr) await log("make {0}: {1}".format(path, ret)) return (ret, resp) textchars = bytearray({7,8,9,10,12,13,27} | set(range(0x20, 0x100)) - {0x7f}) is_binary_string = lambda bytes: bool(bytes.translate(None, textchars)) def is_binary_file (p): """ returns none on ioerror """ try: return not os.path.isdir(p) and is_binary_string(open(p, 'rb').read(1024)) except IOError: return None def editable (path): _, ext = os.path.splitext(path) ext = ext.lower()[1:] # print (f"editable? ext {ext}", file=sys.stderr) return ((ext not in ("html", "htm")) and not os.path.isdir(path) and not is_binary_file(path)) # From aiohttp/web_urldispatcher.py # remove editor_url (no longer being used ?!) def directory_as_html(filepath, directory, prefix, editor_url=None): # returns directory's index as html # sanity check assert os.path.isdir(filepath) # relative_path_to_dir = filepath.relative_to(self._directory) relative_path_to_dir = os.path.relpath(filepath, directory) index_of = "Index of /{}".format(relative_path_to_dir) h1 = "

{}

".format(index_of) index_list = [] dir_index = os.listdir(filepath) for _file in sorted(dir_index): # show file url as relative to static path # rel_path = _file.relative_to(self._directory).as_posix() # rel_path = os.path.relpath(_file, directory) # file_url = prefix + '/' + rel_path fp = os.path.join(filepath, _file) file_url = '/' + urlquote(fp) target = None if editable(fp) and editor_url: file_url = "{0}#{1}".format(editor_url, file_url) target = "editor" # if file is a directory, add '/' to the end of the name if os.path.isdir(_file): file_name = "{}/".format(_file) else: file_name = _file if target: link_src = '
  • {name}
  • '.format( url=file_url, target="editor", name=file_name) else: link_src = '
  • {name}
  • '.format( url=file_url, name=file_name) index_list.append(link_src) ul = "".format('\n'.join(index_list)) body = "\n{}\n{}\n".format(h1, ul) head_str = "\n{}\n\n\n".format(index_of) html = "\n{}\n{}\n".format(head_str, body) return html # Based on aiohttp/web_urldispatcher.py def directory_as_json(filepath, directory, prefix, editor_url=None): # returns directory's index as html # sanity check assert os.path.isdir(filepath) # relative_path_to_dir = filepath.relative_to(self._directory) relative_path_to_dir = os.path.relpath(filepath, directory) # index_of = "Index of /{}".format(relative_path_to_dir) # h1 = "

    {}

    ".format(index_of) index_list = [] dir_index = os.listdir(filepath) for _file in sorted(dir_index): # show file url as relative to static path # rel_path = _file.relative_to(self._directory).as_posix() # rel_path = os.path.relpath(_file, directory) # file_url = prefix + '/' + rel_path if filepath == ".": fp = _file else: fp = os.path.join(filepath, _file) file_url = '/' + urlquote(fp) target = None if editable(fp) and editor_url: file_url = "{0}#{1}".format(editor_url, file_url) target = "editor" # if file is a directory, add '/' to the end of the name is_dir = os.path.isdir(fp) if is_dir: file_name = "{}/".format(_file) else: file_name = _file stat = os.stat(fp) index_list.append({ 'url': file_url, 'name': file_name, 'editable': editable(fp), 'size': stat.st_size, 'mtime': stat.st_mtime, 'atime': stat.st_atime, 'dir': is_dir }) ret = {} ret['filepath'] = filepath ret['rpath'] = relative_path_to_dir ret['children'] = index_list return json.dumps(ret) async def route_get (request): # return FileResponse(filepath, chunk_size=self._chunk_size) # print ("rOUTE_get", request.rel_url, request.rel_url.query) path = urlunquote(urlparse(request.rel_url.raw_path.lstrip("/")).path) print ("GET", path, file=sys.stderr) if path == '': path = '.' makefile = request.app['makefile'] makecmd = request.app['make'] im = await is_makeable(path, makefile=makefile, make=makecmd) remake = 'remake' in request.rel_url.query if path and (im == MAKE['NEEDS_REMAKING'] or \ (im == MAKE['UP_TO_DATE'] and remake)): code, resp = await make(path, makefile=makefile, make=makecmd, force=remake) if not code: resppage = """

    An error occuring making the file {0}. Here's the output:

    {1}

    """.format(path, resp) return web.Response(text=resppage, content_type="text/html") if os.path.exists(path): if os.path.isdir(path): if 'application/json' in request.headers.get("Accept") or 'json' in request.rel_url.query: return web.Response(text=directory_as_json(path, '', '', editor_url=request.app['editor']), content_type="application/json") else: return web.Response(text=directory_as_html(path, '', '', editor_url=request.app['editor']), content_type="text/html") else: return web.FileResponse(path, chunk_size=256*1024) else: raise web.HTTPNotFound() # return web.Response(text="404 on path {0}".format(path)) async def route_post (request): """ write posted text value to file """ path = urlunquote(urlparse(request.rel_url.raw_path.lstrip("/")).path) print ("POST", path, file=sys.stderr) # if os.path.exists(path) and os.path.isfile(path): # POST CAN CLOBBER ANY FILES THAT ARE WRITABLE BY THE USER ! # this is maybe a way too dangerous default ... # data = await request.post() resp = {} # SAVE if 'text' in data: text = data['text'] # doing file io inline here is not strictly speaking very async ;) with open(path, "w") as f: f.write(text) resp['text'] = 'ok' if 'base64' in data: data = data['base64'] # print ("base64", data) # strip optional data header: data:image/png;base64,FFFFFFFF if data.startswith("data:"): data = data.split(",", 1)[1] with open(path, "wb") as f: f.write(base64.b64decode(data)) # RENAME if 'name' in data: newpath = os.path.join(os.path.split(path)[0], data['name']) if path != newpath: os.rename(path, newpath) resp['name'] = 'ok' # DELETE if 'delete' in data: os.remove(path) resp['delete'] = 'ok' return web.Response(text=json.dumps(resp), content_type="application/json") # else: # raise web.HTTPMethodNotAllowed() # return web.Response(text="post not allowed on {0}".format(path)) active_sockets = [] async def log (msg): for ws in active_sockets: await ws.send_str(msg) async def websocket_handler(request): print('Websocket connection starting') ws = web.WebSocketResponse() await ws.prepare(request) print('Websocket connection ready') active_sockets.append(ws) async for msg in ws: print(msg) if msg.type == aiohttp.WSMsgType.TEXT: print(msg.data) if msg.data == 'close': await ws.close() else: await ws.send_str(msg.data + '/answer') active_sockets.remove(ws) print('Websocket connection closed') return ws def main (): ap = argparse.ArgumentParser("make & serve") ap.add_argument("--makefile", "-f", default="Makefile") ap.add_argument("--scons", action="store_true", default=False) ap.add_argument("--host", default="localhost") ap.add_argument("--port", type=int, default=8000) ap.add_argument("--editor", default=None) ap.add_argument("--static", nargs=2, default=None, action="append") args = ap.parse_args() print ("Makeserver Version 3.1.0") if sys.platform == 'win32': # Folowing: https://docs.python.org/3/library/asyncio-subprocess.html # On Windows, the default event loop is SelectorEventLoop which does not support subprocesses. # ProactorEventLoop should be used instead. loop = asyncio.ProactorEventLoop() asyncio.set_event_loop(loop) app = web.Application() app['makefile'] = args.makefile if args.scons or (args.makefile.lower() == "sconstruct.py"): app['make'] = "scons" else: app['make'] = "make" app['editor'] = args.editor app.router.add_route('GET', '/ws', websocket_handler) data = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") htdocs = os.path.join(data, "htdocs") laburl = "__lab__" print (f"Adding static route {laburl}/ -> {htdocs}") app.router.add_static(f"/{laburl}", htdocs, show_index=True) if args.static: for name, path in args.static: print ("Adding static route {0} -> {1}".format(name, path)) app.router.add_static(name, path) app.add_routes([web.get('/{make:.*}', route_get)]) app.add_routes([web.post('/{make:.*}', route_post)]) port = args.port tries = 0 max_tries = 10 # DYNAMIC PORT SELECTION import socket, errno while tries