├── requirements.txt ├── tests ├── __init__.py └── test_load.py ├── setup.cfg ├── aserve ├── __init__.py └── aserve.py ├── tox.ini ├── .gitignore ├── deploy.py ├── setup.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ Test folder separated. """ 2 | -------------------------------------------------------------------------------- /tests/test_load.py: -------------------------------------------------------------------------------- 1 | """ Contains py.test tests. """ 2 | 3 | 4 | def test_load(): 5 | from aserve import main 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_rpm] 5 | doc_files = README.md 6 | 7 | [wheel] 8 | universal = 1 -------------------------------------------------------------------------------- /aserve/__init__.py: -------------------------------------------------------------------------------- 1 | """ remember - Catchy catchphrase """ 2 | 3 | __project__ = "aserve" 4 | __version__ = "0.0.9" 5 | 6 | from aserve.aserve import main 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35 3 | 4 | [testenv] 5 | # If you add a new dep here you probably need to add it in setup.py as well 6 | deps = 7 | pytest 8 | -rrequirements.txt 9 | commands = py.test -v aserve/tests/run_tests.py 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *#* 3 | *.DS_STORE 4 | *.log 5 | *Data.fs* 6 | *flymake* 7 | *egg* 8 | build/ 9 | __pycache__/ 10 | /.Python 11 | /bin/ 12 | /include/ 13 | /lib/ 14 | /pip-selfcheck.json 15 | .tox/ 16 | comments/ 17 | dist/ 18 | *silly* 19 | extras/ 20 | .cache/ 21 | .coverage -------------------------------------------------------------------------------- /deploy.py: -------------------------------------------------------------------------------- 1 | """ File unrelated to the package, except for convenience in deploying """ 2 | import re 3 | import sh 4 | import os 5 | 6 | commit_count = sh.git('rev-list', ['--all']).count('\n') 7 | 8 | with open('setup.py') as f: 9 | setup = f.read() 10 | 11 | setup = re.sub("MICRO_VERSION = '[0-9]+'", "MICRO_VERSION = '{}'".format(commit_count), setup) 12 | 13 | major = re.search("MAJOR_VERSION = '([0-9]+)'", setup).groups()[0] 14 | minor = re.search("MINOR_VERSION = '([0-9]+)'", setup).groups()[0] 15 | micro = re.search("MICRO_VERSION = '([0-9]+)'", setup).groups()[0] 16 | version = '{}.{}.{}'.format(major, minor, micro) 17 | 18 | with open('setup.py', 'w') as f: 19 | f.write(setup) 20 | 21 | with open('aserve/__init__.py') as f: 22 | init = f.read() 23 | 24 | with open('aserve/__init__.py', 'w') as f: 25 | f.write( 26 | re.sub('__version__ = "[0-9.]+"', 27 | '__version__ = "{}"'.format(version), init)) 28 | 29 | py_version = "python3.5" if sh.which("python3.5") is not None else "python" 30 | os.system('{} setup.py sdist bdist_wheel upload'.format(py_version)) 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | MAJOR_VERSION = '0' 5 | MINOR_VERSION = '0' 6 | MICRO_VERSION = '9' 7 | VERSION = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION) 8 | 9 | setup(name='aserve', 10 | version=VERSION, 11 | description="Asynchronously serve APIs", 12 | url='https://github.com/kootenpv/aserve', 13 | author='Pascal van Kooten', 14 | author_email='kootenpv@gmail.com', 15 | license='MIT', 16 | packages=find_packages(), 17 | install_requires=[ 18 | 'aiohttp', 19 | ], 20 | entry_points={ 21 | 'console_scripts': ['aserve = aserve.aserve:main'] 22 | }, 23 | 24 | classifiers=[ 25 | 'Environment :: Console', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 28 | 'Operating System :: Microsoft', 29 | 'Operating System :: MacOS :: MacOS X', 30 | 'Operating System :: Unix', 31 | 'Operating System :: POSIX', 32 | 'Programming Language :: Python :: 3.5', 33 | 'Topic :: Software Development', 34 | 'Topic :: Software Development :: Build Tools', 35 | 'Topic :: Software Development :: Debuggers', 36 | 'Topic :: Software Development :: Libraries', 37 | 'Topic :: Software Development :: Libraries :: Python Modules', 38 | 'Topic :: System :: Software Distribution', 39 | 'Topic :: System :: Systems Administration', 40 | 'Topic :: Utilities' 41 | ], 42 | zip_safe=False, 43 | platforms='any') 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## aserve (pre-alpha) 2 | 3 | The goal of aserve is to make it easy to "asynchronously serve an API". If you want to 4 | serve some text, json, or echo a request: aserve enables quick 5 | testing... just run `aserve`. 6 | 7 | - Hackathon: give your buddy an endpoint with a statically served JSON 8 | - Server-to-server: viewing how a request looks like with headers etc 9 | - Send your custom response: use `pdb` to drop in a request and return a response manually 10 | - New: Serve a python file with functions! 11 | 12 | ### Features 13 | 14 | - Easy to use, just a command line required ;) 15 | - Asynchronous 16 | - Serve different types of data 17 | - Automatically supports GET/POST/HEAD/OPTIONS 18 | - Pipe data to aserve to serve it: `cat some.json | aserve` 19 | - **UPDATE**: Serve all functions [automagically](#serving-python-functions) with `aserve my_filename.py` 20 | 21 | ### Installation 22 | 23 | Only works on Python 3.5+ at the moment. 24 | 25 | pip3.5 install aserve 26 | 27 | ### How to: 28 | 29 | ``` 30 | usage: aserve [-h] [--debug] [--port PORT] [--verbose] [--file FILE] [--sleep SLEEP] 31 | 32 | Asynchronously Serve an API with aserve. 33 | 34 | optional arguments: 35 | -h, --help show this help message and exit 36 | --debug, -d Uses "pdb" to drop you into the request, BEFORE replying 37 | --port PORT, -p PORT Port where to host 38 | --verbose, -v Talks.... a lot. 39 | --file FILE, -f FILE Loads file (COMING VERY SOON) 40 | --sleep SLEEP, -s SLEEP seconds to wait before delivering 41 | ``` 42 | 43 | ### Default routes 44 | 45 | ``` 46 | /get_echo 47 | /get_file 48 | /get_json 49 | /get_text 50 | /head_echo 51 | /head_file 52 | /head_json 53 | /head_text 54 | /options_echo 55 | /options_file 56 | /options_json 57 | /options_text 58 | /post_echo 59 | /post_file 60 | /post_json 61 | /post_text 62 | ``` 63 | 64 | ### Serving python functions 65 | 66 | Given `filenamy.py`: 67 | 68 | ```python 69 | def concatenate(x, y): 70 | return x + y 71 | 72 | import asyncio 73 | async def concatenate_async(x, y): 74 | await asyncio.sleep(1) 75 | return x + y 76 | ``` 77 | 78 | this file can be served with aserve using `aserve /path/to/filename.py`. 79 | 80 | You can then GET (with query parameters): 81 | 82 | ```python 83 | import requests 84 | url = "http://localhost:port/filename/concatenate" 85 | requests.get(url + "?x=hello&y=world").text 86 | requests.get(url + "_async" + "?x=hello&y=world").text 87 | ``` 88 | 89 | and POST: 90 | 91 | ```python 92 | import requests 93 | import json 94 | url = "http://localhost:port/filename/concatenate" 95 | requests.get(url, data=json.dumps({"x": "hello", "y": "word"})).json 96 | requests.get(url + "_async", data=json.dumps({"x": "hello", "y": "word"})).json 97 | ``` 98 | 99 | 100 | ### Caveats 101 | 102 | Currently, it uses `aiohttp` and asyncio, and aserve is only available from Python 3.5+. It should be possible in the future to use a different backend. 103 | Reasons for a different backend might be to not require any other installation, or perhaps to support older versions of Python. 104 | -------------------------------------------------------------------------------- /aserve/aserve.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import asyncio 5 | import imp 6 | from aiohttp import web 7 | from inspect import getmembers, isfunction, iscoroutinefunction 8 | from stat import S_ISFIFO 9 | 10 | 11 | def load_module(absolute_path): 12 | import importlib.util 13 | module_name, ext = os.path.splitext(os.path.split(absolute_path)[-1]) 14 | module_root = os.path.dirname(absolute_path) 15 | if not ext.endswith(".py"): 16 | print("I doubt I can load a file not ending with .py: " + absolute_path) 17 | print("Still trying to load.") 18 | os.chdir(module_root) 19 | try: 20 | py_mod = imp.load_source(module_name, absolute_path) 21 | except ImportError as e: 22 | if not e.msg.startswith("No module named"): 23 | raise e 24 | missing_module = e.name 25 | if missing_module + ext not in os.listdir(module_root): 26 | raise ImportError("Could not find '{}' in '{}'".format(missing_module, module_root)) 27 | print("Could not directly load module, including dir: {}".format(module_root)) 28 | sys.path.append(module_root) 29 | spec = importlib.util.spec_from_file_location(module_name, absolute_path) 30 | py_mod = importlib.util.module_from_spec(spec) 31 | spec.loader.exec_module(py_mod) 32 | return py_mod 33 | 34 | 35 | def ensure_bytes(x): 36 | """ Ensure 'x' is going to be served as bytes. """ 37 | if not isinstance(x, bytes): 38 | x = bytes(str(x).encode("utf8")) 39 | return x 40 | 41 | 42 | def echo(request): 43 | """ The response will reflect what the request looks like. """ 44 | dc = getattr(request, "__dict__") 45 | return json.dumps({k: str(v) for k, v in dc.items()}) 46 | 47 | 48 | def route_wrapper(reply, use_pdb=False, printing=False, sleepfor=0): 49 | """ Wrapper to easily create routes. """ 50 | async def route_function(request): 51 | status_code = 200 52 | reason = None 53 | if sleepfor: 54 | await asyncio.sleep(sleepfor) 55 | if use_pdb: 56 | import pdb 57 | pdb.set_trace() 58 | if hasattr(reply, '__call__'): 59 | if iscoroutinefunction(reply): 60 | result, status_code, reason = await reply(request) 61 | else: 62 | result, status_code, reason = reply(request) 63 | result = ensure_bytes(result) 64 | else: 65 | result = ensure_bytes(reply) 66 | if printing: 67 | print("result", result) 68 | return web.Response(body=result, status=status_code, reason=reason) 69 | return route_function 70 | 71 | 72 | def parse_args(args=None): 73 | import argparse 74 | 75 | parser = argparse.ArgumentParser(description='Asynchronously Serve an API with aserve.') 76 | parser.add_argument("python_file", nargs="?") 77 | parser.add_argument('--debug', '-d', action="store_true", 78 | help='Uses "pdb" to drop you into the request, BEFORE replying') 79 | parser.add_argument('--port', '-p', type=int, default=21487, 80 | help='Port where to host') 81 | parser.add_argument('--verbose', '-v', action="store_true", 82 | help='Talks.... a lot.') 83 | parser.add_argument('--file', '-f', nargs=1, 84 | help='Loads file (COMING VERY SOON)') 85 | parser.add_argument('--sleep', '-s', type=int, default=0, 86 | help='seconds to wait before delivering') 87 | args = parser.parse_args(args) if args else parser.parse_args() 88 | 89 | args.file = args.file[0] if args.file else None 90 | if args.verbose: 91 | print("Going to talk a lot.") 92 | print("Debugging mode {}.".format(args.debug)) 93 | if args.sleep: 94 | print("Sleeping between requests: {}s.".format(args.sleep)) 95 | if args.file is not None: 96 | raise NotImplementedError("Planned to come soon. Check back later.") 97 | print("serving file at /file") 98 | return args 99 | 100 | 101 | def fn_to_route(fn): 102 | 103 | async def routed_function(request): 104 | response = None 105 | args = None 106 | # return fn(request) 107 | if request.method == "POST": 108 | try: 109 | post_data = await request.json() 110 | except json.decoder.JSONDecodeError: 111 | post_data = await request.post() 112 | 113 | args = post_data 114 | else: 115 | # just to make it asyncable, but i dont know what i should have here 116 | _ = await request.text() 117 | args = request.GET 118 | # response 119 | status_code = 200 120 | reason = None 121 | try: 122 | if iscoroutinefunction(fn): 123 | response = await fn(**args) 124 | else: 125 | response = fn(**args) 126 | except Exception as e: 127 | response, status_code, reason = "error", 500, e 128 | return response, status_code, reason 129 | 130 | return routed_function 131 | 132 | 133 | def main(args=None): 134 | """ This is the function that is run from commandline with `aserve` """ 135 | app = web.Application() 136 | 137 | args = parse_args(args) 138 | 139 | METHODS = ["GET", "POST", "OPTIONS", "HEAD"] 140 | DTYPES = ["text", "json", "echo", 'file'] 141 | 142 | RESPONSE_HANDLERS = {'text': "Hello, world!", 143 | 'json': json.dumps({"hello": "world!"}), 144 | 'echo': echo, 145 | 'file': json.dumps} 146 | 147 | if args.file: 148 | with open(args.file) as f: 149 | RESPONSE_HANDLERS['file'] = json.dumps(f.read()) 150 | 151 | piped_content = sys.stdin.read() if S_ISFIFO(os.fstat(0).st_mode) else '' 152 | 153 | if piped_content: 154 | try: 155 | RESPONSE_HANDLERS['json'] = json.dumps(json.loads(piped_content)) 156 | except json.decoder.JSONDecodeError: 157 | print("info: cannot serve '{}' as JSON".format(piped_content)) 158 | RESPONSE_HANDLERS['text'] = piped_content 159 | 160 | ROUTER = {} 161 | for m in METHODS: 162 | for d in DTYPES: 163 | route = route_wrapper(RESPONSE_HANDLERS[d], args.debug, args.verbose, args.sleep) 164 | ROUTER[(m.lower(), d)] = route 165 | 166 | if args.python_file is not None: 167 | args.python_file = os.path.abspath(args.python_file) 168 | py_mod = load_module(args.python_file) 169 | for fn_name, fn in getmembers(py_mod, isfunction): 170 | if fn.__module__ == py_mod.__name__: 171 | route_fn = route_wrapper(fn_to_route(fn), args.debug, args.verbose, args.sleep) 172 | app.router.add_route('POST', '/{}/{}'.format(py_mod.__name__, fn_name), route_fn) 173 | app.router.add_route('GET', '/{}/{}'.format(py_mod.__name__, fn_name), route_fn) 174 | 175 | for r, route_fn in ROUTER.items(): 176 | app.router.add_route(r[0], '/' + '_'.join(r), route_fn) 177 | 178 | for m in METHODS: 179 | route_fn = ensure_bytes(json.dumps(sorted(['/' + '_'.join(x) for x in ROUTER]))) 180 | app.router.add_route(m, '/', route_wrapper(route_fn, args.debug, args.verbose, args.sleep)) 181 | 182 | web.run_app(app, port=args.port) 183 | 184 | if __name__ == "__main__": 185 | main() 186 | --------------------------------------------------------------------------------