From e45cba48285870d3fdf95629e02ea37773bc0714 Mon Sep 17 00:00:00 2001 From: baturayo Date: Thu, 1 Oct 2020 20:36:44 +0200 Subject: [PATCH 01/11] open api integration --- dploy_kickstart/cmd.py | 105 ++++++++++++++----------- dploy_kickstart/server.py | 45 +++++------ dploy_kickstart/transformers.py | 11 ++- dploy_kickstart/wrapper.py | 15 ++-- poetry.lock | 122 +++++++++++++++++++++++++++++- pyproject.toml | 4 +- tests/test_callable_annotation.py | 2 +- tests/test_server.py | 2 +- 8 files changed, 216 insertions(+), 90 deletions(-) diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index 53b911c..aea416d 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -10,7 +10,7 @@ from dploy_kickstart import deps as pd from dploy_kickstart import server as ps - +import uvicorn log = logging.getLogger(__name__) @@ -21,36 +21,36 @@ def cli() -> None: pass -@cli.command(help="run dploy_kickstart server") -@click.option( - "-e", "--entrypoint", required=True, help=".py or .ipynb to use as entrypoint" -) -@click.option( - "-l", - "--location", - required=True, - help="location of the script or notebook (and that will " - + "be used as execution context)", -) -@click.option( - "-d", - "--deps", - help="install dependencies; comma separated paths to either requirements.txt " - + "or setup.py files. note that this can be run seperately via the " - + "'install-deps' command", -) -@click.option( - "--wsgi/--no-wsgi", - default=True, - help="Use Waitress as a WSGI server, defaults to True," - + " else launches a Flask debug server.", -) -@click.option( - "-h", "--host", help="Host to serve on, defaults to '0.0.0.0'", default="0.0.0.0" -) -@click.option( - "-p", "--port", help="Port to serve on, defaults to '8080'", default=8080, type=int -) +# @cli.command(help="run dploy_kickstart server") +# @click.option( +# "-e", "--entrypoint", required=True, help=".py or .ipynb to use as entrypoint" +# ) +# @click.option( +# "-l", +# "--location", +# required=True, +# help="location of the script or notebook (and that will " +# + "be used as execution context)", +# ) +# @click.option( +# "-d", +# "--deps", +# help="install dependencies; comma separated paths to either requirements.txt " +# + "or setup.py files. note that this can be run seperately via the " +# + "'install-deps' command", +# ) +# @click.option( +# "--wsgi/--no-wsgi", +# default=True, +# help="Use Waitress as a WSGI server, defaults to True," +# + " else launches a Flask debug server.", +# ) +# @click.option( +# "-h", "--host", help="Host to serve on, defaults to '0.0.0.0'", default="0.0.0.0" +# ) +# @click.option( +# "-p", "--port", help="Port to serve on, defaults to '8080'", default=8080, type=int +# ) def serve( entrypoint: str, location: str, deps: str, wsgi: bool, host: str, port: int ) -> typing.Any: @@ -62,19 +62,24 @@ def serve( app = ps.generate_app() app = ps.append_entrypoint(app, entrypoint, os.path.abspath(location)) - if not wsgi: - click.echo("Starting Flask Development server") - app.run( - host=os.getenv("DPLOY_KICKSTART_HOST", "0.0.0.0"), - port=int(os.getenv("DPLOY_KICKSTART_PORT", 8080)), - ) - else: - click.echo("Starting Waitress server") - waitress_serve( - TransLogger(app, setup_console_handler=False), - host=os.getenv("dploy_kickstart_HOST", "0.0.0.0"), - port=int(os.getenv("dploy_kickstart_PORT", 8080)), - ) + uvicorn.run( + app, + host=os.getenv("DPLOY_KICKSTART_HOST", "0.0.0.0"), + port=int(os.getenv("DPLOY_KICKSTART_PORT", 8080)), + ) + # if not wsgi: + # click.echo("Starting Flask Development server") + # app.run( + # host=os.getenv("DPLOY_KICKSTART_HOST", "0.0.0.0"), + # port=int(os.getenv("DPLOY_KICKSTART_PORT", 8080)), + # ) + # else: + # click.echo("Starting Waitress server") + # waitress_serve( + # TransLogger(app, setup_console_handler=False), + # host=os.getenv("dploy_kickstart_HOST", "0.0.0.0"), + # port=int(os.getenv("dploy_kickstart_PORT", 8080)), + # ) @cli.command(help="install dependencies") @@ -82,7 +87,8 @@ def serve( "-d", "--deps", required=True, - help="comma separated paths to either requirements.txt or setup.py files", + help="comma separated paths to either requirements.txt," + " setup.py or poetry.lock files", ) @click.option( "-l", @@ -111,5 +117,12 @@ def _deps(deps: str, location: str) -> None: raise Exception( "unsupported dependency install defined: {}. " "Supported formats: " - "requirements.txt, setup.py".format(r) + "requirements.txt, setup.py, poetry.lock".format(r) ) + + +if __name__ == "__main__": + _entrypoint = "ml_skeleton_py/model/predict.py" + _location = "/Users/baturayofluoglu/PycharmProjects/ml-skeleton-py/" + _deps = False + serve(_entrypoint, _location, _deps, wsgi=False, host="0.0.0.0", port=80) diff --git a/dploy_kickstart/server.py b/dploy_kickstart/server.py index 058e33d..72f6e56 100644 --- a/dploy_kickstart/server.py +++ b/dploy_kickstart/server.py @@ -2,8 +2,7 @@ import logging import typing - -from flask import Flask, jsonify +from fastapi import FastAPI, APIRouter import dploy_kickstart.wrapper as pw import dploy_kickstart.errors as pe @@ -12,9 +11,7 @@ log = logging.getLogger(__name__) -def append_entrypoint( - app: typing.Generic, entrypoint: str, location: str -) -> typing.Generic: +def append_entrypoint(app: FastAPI, entrypoint: str, location: str) -> FastAPI: """Add routes/functions defined in entrypoint.""" mod = pw.import_entrypoint(entrypoint, location) fm = pw.get_func_annotations(mod) @@ -22,7 +19,8 @@ def append_entrypoint( if not any([e.endpoint for e in fm]): raise Exception("no endpoints defined") - openapi_spec = po.base_spec(title=entrypoint) + api_router = APIRouter() + # iterate over annotations in usercode for f in fm: if f.endpoint: @@ -30,36 +28,27 @@ def append_entrypoint( f"adding endpoint for func: {f.__name__} (func_args: {f.comment_args})" ) - app.add_url_rule( - f.endpoint_path, - f.endpoint_path, - pw.func_wrapper(f), + api_router.add_api_route( methods=[f.request_method.upper()], - strict_slashes=False + path=f.endpoint_path, + endpoint=pw.func_wrapper(f), ) - # add info about endpoint to api spec - po.path_spec(openapi_spec, f) - - app.add_url_rule( - "/openapi.yaml", "/openapi.yaml", openapi_spec.to_yaml, methods=["GET"], - ) - + app.include_router(api_router) return app def generate_app() -> typing.Generic: - """Generate a Flask app.""" - app = Flask(__name__) + """Generate a FastApi app.""" + app = FastAPI() - @app.route("/healthz/", methods=["GET"]) + @app.get("/healthz/", status_code=200) def health_check() -> None: - return "healthy", 200 - - @app.errorhandler(pe.ServerException) - def handle_server_exception(error: Exception) -> None: - response = jsonify(error.to_dict()) - response.status_code = error.status_code - return response + return "healthy" + # @app.errorhandler(pe.ServerException) + # def handle_server_exception(error: Exception) -> None: + # response = jsonify(error.to_dict()) + # response.status_code = error.status_code + # return response return app diff --git a/dploy_kickstart/transformers.py b/dploy_kickstart/transformers.py index 9bb6ae2..b601bc3 100644 --- a/dploy_kickstart/transformers.py +++ b/dploy_kickstart/transformers.py @@ -1,22 +1,21 @@ """Utilities to transform requests and responses.""" import typing -from flask import jsonify, Response, Request - +from fastapi import Response import dploy_kickstart.annotations as da def json_resp(func_result: typing.Any) -> Response: """Transform json response.""" - return jsonify(func_result) + return func_result -def json_req(f: da.AnnotatedCallable, req: Request): +def json_req(f: da.AnnotatedCallable, body: dict): """Preprocess application/json request.""" if f.json_to_kwargs: - return f(**req.json) + return f(**body) else: - return f(req.json) + return f(body) MIME_TYPE_REQ_MAPPER = { diff --git a/dploy_kickstart/wrapper.py b/dploy_kickstart/wrapper.py index 51e16c0..450c7c4 100644 --- a/dploy_kickstart/wrapper.py +++ b/dploy_kickstart/wrapper.py @@ -10,7 +10,8 @@ import typing import traceback -from flask import request +from fastapi import Request, Body + import dploy_kickstart.errors as pe import dploy_kickstart.transformers as pt import dploy_kickstart.annotations as pa @@ -61,7 +62,7 @@ def get_func_annotations(mod: typing.Generic) -> typing.Dict: def import_entrypoint(entrypoint: str, location: str) -> typing.Generic: - """Import entryoint from user code.""" + """Import entrypoint from user code.""" # assert if entrypoint contains a path prefix and if so add it to location if os.path.dirname(entrypoint) != "": location = os.path.join(location, os.path.dirname(entrypoint)) @@ -101,9 +102,9 @@ def import_entrypoint(entrypoint: str, location: str) -> typing.Generic: def func_wrapper(f: pa.AnnotatedCallable) -> typing.Callable: """Wrap functions with request logic.""" - def exposed_func() -> typing.Callable: + def exposed_func(request: Request, body=Body(...)) -> typing.Callable: # some sanity checking - if request.content_type.lower() != f.request_content_type: + if request.headers["content-type"].lower() != f.request_content_type: raise pe.UnsupportedMediaType( "Please provide a valid 'Content-Type' header, valid: {}".format( f.request_content_type @@ -112,7 +113,7 @@ def exposed_func() -> typing.Callable: # preprocess input for callable try: - res = pt.MIME_TYPE_REQ_MAPPER[f.response_mime_type](f, request) + res = pt.MIME_TYPE_REQ_MAPPER[f.response_mime_type](f, body) except Exception: raise pe.UserApplicationError( message=f"error in executing '{f.__name__}'", @@ -120,7 +121,9 @@ def exposed_func() -> typing.Callable: ) # determine whether or not to process response before sending it back to caller - wrapped_res = pt.MIME_TYPE_RES_MAPPER[request.content_type](res) + wrapped_res = pt.MIME_TYPE_RES_MAPPER[request.headers["content-type"].lower()]( + res + ) return wrapped_res diff --git a/poetry.lock b/poetry.lock index a284e06..2a24d2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -154,6 +154,24 @@ optional = false python-versions = ">=2.7" version = "0.3" +[[package]] +category = "main" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +name = "fastapi" +optional = false +python-versions = ">=3.6" +version = "0.61.1" + +[package.dependencies] +pydantic = ">=1.0.0,<2.0.0" +starlette = "0.13.6" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "aiofiles (>=0.5.0,<0.6.0)", "jinja2 (>=2.11.2,<3.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<2.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "graphene (>=2.1.8,<3.0.0)", "ujson (>=3.0.0,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn (>=0.11.5,<0.12.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)"] +dev = ["python-jose (>=3.1.0,<4.0.0)", "passlib (>=1.7.2,<2.0.0)", "autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn (>=0.11.5,<0.12.0)", "graphene (>=2.1.8,<3.0.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.5.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.2.0)", "typer (>=0.3.0,<0.4.0)", "typer-cli (>=0.0.9,<0.0.10)", "pyyaml (>=5.3.1,<6.0.0)"] +test = ["pytest (5.4.3)", "pytest-cov (2.10.0)", "pytest-asyncio (>=0.14.0,<0.15.0)", "mypy (0.782)", "flake8 (>=3.8.3,<4.0.0)", "black (19.10b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.15.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "databases (>=0.3.2,<0.4.0)", "orjson (>=3.2.1,<4.0.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "aiofiles (>=0.5.0,<0.6.0)", "flask (>=1.1.2,<2.0.0)"] + [[package]] category = "dev" description = "the modular source code checker: pep8, pyflakes and co" @@ -198,6 +216,14 @@ version = "0.46.1" [package.dependencies] astor = "*" +[[package]] +category = "main" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +name = "h11" +optional = false +python-versions = "*" +version = "0.10.0" + [[package]] category = "dev" description = "Internationalized Domain Names in Applications (IDNA)" @@ -438,6 +464,19 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.5.0" +[[package]] +category = "main" +description = "Data validation and settings management using python 3.6 type hinting" +name = "pydantic" +optional = false +python-versions = ">=3.6" +version = "1.6.1" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] + [[package]] category = "dev" description = "passive checker of Python programs" @@ -557,6 +596,17 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.14.0" +[[package]] +category = "main" +description = "The little ASGI library that shines." +name = "starlette" +optional = false +python-versions = ">=3.6" +version = "0.13.6" + +[package.extras] +full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] + [[package]] category = "dev" description = "Test utilities for code working with files and commands" @@ -600,6 +650,15 @@ optional = false python-versions = "*" version = "1.4.1" +[[package]] +category = "main" +description = "Backported and Experimental Type Hints for Python 3.5+" +marker = "python_version < \"3.8\"" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4.3" + [[package]] category = "dev" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -613,6 +672,25 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +[[package]] +category = "main" +description = "The lightning-fast ASGI server." +name = "uvicorn" +optional = false +python-versions = "*" +version = "0.12.1" + +[package.dependencies] +click = ">=7.0.0,<8.0.0" +h11 = ">=0.8" + +[package.dependencies.typing-extensions] +python = "<3.8" +version = "*" + +[package.extras] +standard = ["websockets (>=8.0.0,<9.0.0)", "watchgod (>=0.6,<0.7)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "httptools (>=0.1.0,<0.2.0)", "uvloop (>=0.14.0)", "colorama (>=0.4)"] + [[package]] category = "main" description = "Waitress WSGI server" @@ -667,7 +745,8 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "52c0add7a62ee3a049a8c61b7214889d934ac39789869cdefa080f5e6d778938" +content-hash = "8d44d2e2734403fc69b6ee95685378b47039ff2ef31b1118a3a5b461bd41cb50" +lock-version = "1.0" python-versions = "^3.7" [metadata.files] @@ -760,6 +839,10 @@ entrypoints = [ {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, ] +fastapi = [ + {file = "fastapi-0.61.1-py3-none-any.whl", hash = "sha256:6cc31bb555dd8ca956d1d227477d661e4ac012337242a41d36214ffbda78bfe9"}, + {file = "fastapi-0.61.1.tar.gz", hash = "sha256:61ed73b4304413a2ea618d1b95ea866ee386e0e62dd8659c4f5059286f4a39c2"}, +] flake8 = [ {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, @@ -772,6 +855,10 @@ flynt = [ {file = "flynt-0.46.1-py3-none-any.whl", hash = "sha256:4c8351cddaa9cb87afe7f29b6824131e62824f29931ca1838ad5b0914877b1b1"}, {file = "flynt-0.46.1.tar.gz", hash = "sha256:71c4076a31d72266e61a4fe7d12b58be254c75b01cb136b73d112334dd392619"}, ] +h11 = [ + {file = "h11-0.10.0-py2.py3-none-any.whl", hash = "sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"}, + {file = "h11-0.10.0.tar.gz", hash = "sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd"}, +] idna = [ {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, @@ -882,6 +969,25 @@ pycodestyle = [ {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, ] +pydantic = [ + {file = "pydantic-1.6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:418b84654b60e44c0cdd5384294b0e4bc1ebf42d6e873819424f3b78b8690614"}, + {file = "pydantic-1.6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4900b8820b687c9a3ed753684337979574df20e6ebe4227381d04b3c3c628f99"}, + {file = "pydantic-1.6.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b49c86aecde15cde33835d5d6360e55f5e0067bb7143a8303bf03b872935c75b"}, + {file = "pydantic-1.6.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2de562a456c4ecdc80cf1a8c3e70c666625f7d02d89a6174ecf63754c734592e"}, + {file = "pydantic-1.6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1"}, + {file = "pydantic-1.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2dc946b07cf24bee4737ced0ae77e2ea6bc97489ba5a035b603bd1b40ad81f7e"}, + {file = "pydantic-1.6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:36dbf6f1be212ab37b5fda07667461a9219c956181aa5570a00edfb0acdfe4a1"}, + {file = "pydantic-1.6.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c"}, + {file = "pydantic-1.6.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cf3933c98cb5e808b62fae509f74f209730b180b1e3c3954ee3f7949e083a7df"}, + {file = "pydantic-1.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b"}, + {file = "pydantic-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:40d765fa2d31d5be8e29c1794657ad46f5ee583a565c83cea56630d3ae5878b9"}, + {file = "pydantic-1.6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3fa799f3cfff3e5f536cbd389368fc96a44bb30308f258c94ee76b73bd60531d"}, + {file = "pydantic-1.6.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6c3f162ba175678218629f446a947e3356415b6b09122dcb364e58c442c645a7"}, + {file = "pydantic-1.6.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:eb75dc1809875d5738df14b6566ccf9fd9c0bcde4f36b72870f318f16b9f5c20"}, + {file = "pydantic-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:530d7222a2786a97bc59ee0e0ebbe23728f82974b1f1ad9a11cd966143410633"}, + {file = "pydantic-1.6.1-py36.py37.py38-none-any.whl", hash = "sha256:b5b3489cb303d0f41ad4a7390cf606a5f2c7a94dcba20c051cd1c653694cb14d"}, + {file = "pydantic-1.6.1.tar.gz", hash = "sha256:54122a8ed6b75fe1dd80797f8251ad2063ea348a03b77218d73ea9fe19bd4e73"}, +] pyflakes = [ {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, @@ -943,6 +1049,7 @@ regex = [ {file = "regex-2020.5.7.tar.gz", hash = "sha256:73a10404867b835f1b8a64253e4621908f0d71150eb4e97ab2e7e441b53e9451"}, ] requests = [ + {file = "requests-2.23.0-py2.7.egg", hash = "sha256:5d2d0ffbb515f39417009a46c14256291061ac01ba8f875b90cad137de83beb4"}, {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] @@ -950,6 +1057,10 @@ six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, ] +starlette = [ + {file = "starlette-0.13.6-py3-none-any.whl", hash = "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9"}, + {file = "starlette-0.13.6.tar.gz", hash = "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc"}, +] testpath = [ {file = "testpath-0.4.4-py2.py3-none-any.whl", hash = "sha256:bfcf9411ef4bf3db7579063e0546938b1edda3d69f4e1fb8756991f5951f85d4"}, {file = "testpath-0.4.4.tar.gz", hash = "sha256:60e0a3261c149755f4399a1fff7d37523179a70fdc3abdf78de9fc2604aeec7e"}, @@ -986,10 +1097,19 @@ typed-ast = [ {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] urllib3 = [ {file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, ] +uvicorn = [ + {file = "uvicorn-0.12.1-py3-none-any.whl", hash = "sha256:d06a25caa8dc680ad92eb3ec67363f5281c092059613a1cc0100acba37fc0f45"}, + {file = "uvicorn-0.12.1.tar.gz", hash = "sha256:a461e76406088f448f36323f5ac774d50e5a552b6ccb54e4fca8d83ef614a7c2"}, +] waitress = [ {file = "waitress-1.4.3-py2.py3-none-any.whl", hash = "sha256:77ff3f3226931a1d7d8624c5371de07c8e90c7e5d80c5cc660d72659aaf23f38"}, {file = "waitress-1.4.3.tar.gz", hash = "sha256:045b3efc3d97c93362173ab1dfc159b52cfa22b46c3334ffc805dbdbf0e4309e"}, diff --git a/pyproject.toml b/pyproject.toml index 8b6aed9..a1471c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "dploy-kickstart" -version = "0.1.5" +version = "0.1.6" description = "Expose your functions as HTTP endpoints." authors = ["Bart Smeets "] license = "MIT" @@ -17,6 +17,8 @@ waitress = "^1.4.3" paste = "^3.4.0" flask = "^1.1.2" apispec = "^3.3.0" +fastapi = "^0.61.1" +uvicorn = "^0.12.1" [tool.poetry.dev-dependencies] pytest = "^5.2" diff --git a/tests/test_callable_annotation.py b/tests/test_callable_annotation.py index e199edc..1f281e4 100644 --- a/tests/test_callable_annotation.py +++ b/tests/test_callable_annotation.py @@ -37,13 +37,13 @@ def t5(): def t6(): return t1() + # root path / endpoint # @dploy endpoint def t7(): return t1() - @pytest.mark.parametrize( "callable,endpoint,endpoint_path,has_args,output, error", [ diff --git a/tests/test_server.py b/tests/test_server.py index 7d51a83..6144535 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -31,7 +31,7 @@ def test_client(): ( "server_t1.py", "post", - "/predict", # test without trailing slash + "/predict", # test without trailing slash {"val": 1}, 1, "application/json", From 7dbdb04e0963db89253954a5bc56f024807bfcc1 Mon Sep 17 00:00:00 2001 From: baturayo Date: Thu, 1 Oct 2020 20:40:07 +0200 Subject: [PATCH 02/11] enable click --- dploy_kickstart/cmd.py | 70 +++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index aea416d..65144ff 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -21,36 +21,36 @@ def cli() -> None: pass -# @cli.command(help="run dploy_kickstart server") -# @click.option( -# "-e", "--entrypoint", required=True, help=".py or .ipynb to use as entrypoint" -# ) -# @click.option( -# "-l", -# "--location", -# required=True, -# help="location of the script or notebook (and that will " -# + "be used as execution context)", -# ) -# @click.option( -# "-d", -# "--deps", -# help="install dependencies; comma separated paths to either requirements.txt " -# + "or setup.py files. note that this can be run seperately via the " -# + "'install-deps' command", -# ) -# @click.option( -# "--wsgi/--no-wsgi", -# default=True, -# help="Use Waitress as a WSGI server, defaults to True," -# + " else launches a Flask debug server.", -# ) -# @click.option( -# "-h", "--host", help="Host to serve on, defaults to '0.0.0.0'", default="0.0.0.0" -# ) -# @click.option( -# "-p", "--port", help="Port to serve on, defaults to '8080'", default=8080, type=int -# ) +@cli.command(help="run dploy_kickstart server") +@click.option( + "-e", "--entrypoint", required=True, help=".py or .ipynb to use as entrypoint" +) +@click.option( + "-l", + "--location", + required=True, + help="location of the script or notebook (and that will " + + "be used as execution context)", +) +@click.option( + "-d", + "--deps", + help="install dependencies; comma separated paths to either requirements.txt " + + "or setup.py files. note that this can be run seperately via the " + + "'install-deps' command", +) +@click.option( + "--wsgi/--no-wsgi", + default=True, + help="Use Waitress as a WSGI server, defaults to True," + + " else launches a Flask debug server.", +) +@click.option( + "-h", "--host", help="Host to serve on, defaults to '0.0.0.0'", default="0.0.0.0" +) +@click.option( + "-p", "--port", help="Port to serve on, defaults to '8080'", default=8080, type=int +) def serve( entrypoint: str, location: str, deps: str, wsgi: bool, host: str, port: int ) -> typing.Any: @@ -121,8 +121,8 @@ def _deps(deps: str, location: str) -> None: ) -if __name__ == "__main__": - _entrypoint = "ml_skeleton_py/model/predict.py" - _location = "/Users/baturayofluoglu/PycharmProjects/ml-skeleton-py/" - _deps = False - serve(_entrypoint, _location, _deps, wsgi=False, host="0.0.0.0", port=80) +# if __name__ == "__main__": +# _entrypoint = "ml_skeleton_py/model/predict.py" +# _location = "/Users/baturayofluoglu/PycharmProjects/ml-skeleton-py/" +# _deps = False +# serve(_entrypoint, _location, _deps, wsgi=False, host="0.0.0.0", port=80) From a2d14d51595cb2efd390609f4b51ed0190c1036d Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 09:54:05 +0200 Subject: [PATCH 03/11] remove poetry comments --- dploy_kickstart/cmd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index 65144ff..b6b2cbd 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -87,8 +87,8 @@ def serve( "-d", "--deps", required=True, - help="comma separated paths to either requirements.txt," - " setup.py or poetry.lock files", + help="comma separated paths to either requirements.txt" + " or setup.py files", ) @click.option( "-l", @@ -117,7 +117,7 @@ def _deps(deps: str, location: str) -> None: raise Exception( "unsupported dependency install defined: {}. " "Supported formats: " - "requirements.txt, setup.py, poetry.lock".format(r) + "requirements.txt, setup.py".format(r) ) From c2370b4261c65d2a024204e04154138ff144825f Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 10:07:41 +0200 Subject: [PATCH 04/11] refac --- dploy_kickstart/annotations.py | 4 ++-- dploy_kickstart/cmd.py | 13 ------------- dploy_kickstart/deps.py | 3 +-- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/dploy_kickstart/annotations.py b/dploy_kickstart/annotations.py index a4f5eb3..4d66d36 100644 --- a/dploy_kickstart/annotations.py +++ b/dploy_kickstart/annotations.py @@ -35,7 +35,7 @@ def __init__(self, callble: typing.Callable) -> None: self.evaluate_comment_args() @staticmethod - def parse_comments(cs: str) -> None: + def parse_comments(cs: str) -> typing.List[str]: """Parse comments.""" args = [] if not cs: @@ -115,7 +115,7 @@ def slice_n_dice(s: str) -> list: if slice_start >= i: continue - elif s[i : i + 2] in [' "', '" ']: + elif s[i: i + 2] in [' "', '" ']: slice_points.append((slice_start, i)) in_quotes = not in_quotes slice_start = i + 2 diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index b6b2cbd..541ff59 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -67,19 +67,6 @@ def serve( host=os.getenv("DPLOY_KICKSTART_HOST", "0.0.0.0"), port=int(os.getenv("DPLOY_KICKSTART_PORT", 8080)), ) - # if not wsgi: - # click.echo("Starting Flask Development server") - # app.run( - # host=os.getenv("DPLOY_KICKSTART_HOST", "0.0.0.0"), - # port=int(os.getenv("DPLOY_KICKSTART_PORT", 8080)), - # ) - # else: - # click.echo("Starting Waitress server") - # waitress_serve( - # TransLogger(app, setup_console_handler=False), - # host=os.getenv("dploy_kickstart_HOST", "0.0.0.0"), - # port=int(os.getenv("dploy_kickstart_PORT", 8080)), - # ) @cli.command(help="install dependencies") diff --git a/dploy_kickstart/deps.py b/dploy_kickstart/deps.py index 4267288..3505ede 100644 --- a/dploy_kickstart/deps.py +++ b/dploy_kickstart/deps.py @@ -28,8 +28,7 @@ def install_requirements_txt(requirements_txt_location: str) -> None: ) cmd = f"{sys.executable} -m pip install -r {requirements_txt_location}" - print("##") - print(cmd) + c = execute_cmd(cmd) if c != 0: raise RequirementsInstallException(requirements_txt_location) From b0820eea53cab2bf6993a8f5dcba508c1e44cb2e Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 10:24:33 +0200 Subject: [PATCH 05/11] refac --- dploy_kickstart/annotations.py | 2 +- dploy_kickstart/cmd.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/dploy_kickstart/annotations.py b/dploy_kickstart/annotations.py index 4d66d36..b611ffb 100644 --- a/dploy_kickstart/annotations.py +++ b/dploy_kickstart/annotations.py @@ -115,7 +115,7 @@ def slice_n_dice(s: str) -> list: if slice_start >= i: continue - elif s[i: i + 2] in [' "', '" ']: + elif s[i : i + 2] in [' "', '" ']: slice_points.append((slice_start, i)) in_quotes = not in_quotes slice_start = i + 2 diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index 541ff59..01db9aa 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -5,8 +5,6 @@ import typing import click -from waitress import serve as waitress_serve -from paste.translogger import TransLogger from dploy_kickstart import deps as pd from dploy_kickstart import server as ps @@ -74,8 +72,7 @@ def serve( "-d", "--deps", required=True, - help="comma separated paths to either requirements.txt" - " or setup.py files", + help="comma separated paths to either requirements.txt" " or setup.py files", ) @click.option( "-l", From 30135988af2abd06311f0f6dab7f16a556129f5d Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 12:21:09 +0200 Subject: [PATCH 06/11] Switch from wsgi to asgi --- dploy_kickstart/cmd.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index 01db9aa..2a88812 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -2,7 +2,6 @@ import os import logging -import typing import click @@ -38,10 +37,10 @@ def cli() -> None: + "'install-deps' command", ) @click.option( - "--wsgi/--no-wsgi", + "--asgi/--no-asgi", default=True, - help="Use Waitress as a WSGI server, defaults to True," - + " else launches a Flask debug server.", + help="Use ASGI server, defaults to True," + + " else launches a FastAPI debug server.", ) @click.option( "-h", "--host", help="Host to serve on, defaults to '0.0.0.0'", default="0.0.0.0" @@ -50,14 +49,14 @@ def cli() -> None: "-p", "--port", help="Port to serve on, defaults to '8080'", default=8080, type=int ) def serve( - entrypoint: str, location: str, deps: str, wsgi: bool, host: str, port: int -) -> typing.Any: + entrypoint: str, location: str, deps: str, asgi: bool, host: str, port: int +) -> None: """CLI serve.""" if deps: click.echo(f"Installing deps: {deps}") _deps(deps, location) - app = ps.generate_app() + app = ps.generate_app(debug=not asgi) app = ps.append_entrypoint(app, entrypoint, os.path.abspath(location)) uvicorn.run( @@ -105,8 +104,9 @@ def _deps(deps: str, location: str) -> None: ) -# if __name__ == "__main__": -# _entrypoint = "ml_skeleton_py/model/predict.py" -# _location = "/Users/baturayofluoglu/PycharmProjects/ml-skeleton-py/" -# _deps = False -# serve(_entrypoint, _location, _deps, wsgi=False, host="0.0.0.0", port=80) +if __name__ == "__main__": + _entrypoint = "cbt_v2/deployment.py" + _location = "/Users/baturayofluoglu/Workspace/ai-email-cbt-tfidf" + _deps = False + os.environ["LANGUAGE"] = "en" + serve(_entrypoint, _location, _deps, asgi=True, host="0.0.0.0", port=80) From e46c060ce53911f92f6824d3e50af968f583bdd5 Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 12:21:28 +0200 Subject: [PATCH 07/11] fix type hint --- dploy_kickstart/annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dploy_kickstart/annotations.py b/dploy_kickstart/annotations.py index b611ffb..33acdc5 100644 --- a/dploy_kickstart/annotations.py +++ b/dploy_kickstart/annotations.py @@ -105,7 +105,7 @@ def __name__(self) -> str: return self.callble.__name__ -def slice_n_dice(s: str) -> list: +def slice_n_dice(s: str) -> typing.List[str]: """Parse quotes within strings as separate slices.""" in_quotes = False slice_points = [] From c5729424a7ff670055327f38e91fca280b877815 Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 12:21:47 +0200 Subject: [PATCH 08/11] refac deps.py --- dploy_kickstart/deps.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dploy_kickstart/deps.py b/dploy_kickstart/deps.py index 3505ede..e3f0db6 100644 --- a/dploy_kickstart/deps.py +++ b/dploy_kickstart/deps.py @@ -1,6 +1,4 @@ """Dependency installation logic.""" - - import sys import os import subprocess From bbd193e2cec45e4e51b3e3c5eec7c542718ebd39 Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 12:22:03 +0200 Subject: [PATCH 09/11] delete openapi.py since it's autogenerated --- dploy_kickstart/openapi.py | 23 ----------------------- dploy_kickstart/server.py | 19 ++++++++++--------- dploy_kickstart/wrapper.py | 2 +- 3 files changed, 11 insertions(+), 33 deletions(-) delete mode 100644 dploy_kickstart/openapi.py diff --git a/dploy_kickstart/openapi.py b/dploy_kickstart/openapi.py deleted file mode 100644 index 2ff019b..0000000 --- a/dploy_kickstart/openapi.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Automagically generate APISpec.""" - -from apispec import APISpec -from dploy_kickstart.annotations import AnnotatedCallable - - -def base_spec(title: str) -> APISpec: - """Generate a base APISpec.""" - spec = APISpec(title=title, version="1.0.0", openapi_version="3.0.2") - return spec - - -def path_spec(spec: APISpec, ac: AnnotatedCallable) -> None: - """Add a path to the APISpec.""" - spec.path( - path=ac.endpoint_path, - operations={ - ac.request_method.lower(): dict( - requestBody={"content": {"application/json": {}}}, - responses={"200": {"content": {"application/json": {}}}}, - ) - }, - ) diff --git a/dploy_kickstart/server.py b/dploy_kickstart/server.py index 72f6e56..c90f9b6 100644 --- a/dploy_kickstart/server.py +++ b/dploy_kickstart/server.py @@ -3,10 +3,11 @@ import logging import typing from fastapi import FastAPI, APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse import dploy_kickstart.wrapper as pw import dploy_kickstart.errors as pe -import dploy_kickstart.openapi as po log = logging.getLogger(__name__) @@ -38,17 +39,17 @@ def append_entrypoint(app: FastAPI, entrypoint: str, location: str) -> FastAPI: return app -def generate_app() -> typing.Generic: +def generate_app(debug: bool) -> typing.Generic: """Generate a FastApi app.""" - app = FastAPI() + app = FastAPI(debug=debug) @app.get("/healthz/", status_code=200) - def health_check() -> None: + async def health_check() -> None: return "healthy" - # @app.errorhandler(pe.ServerException) - # def handle_server_exception(error: Exception) -> None: - # response = jsonify(error.to_dict()) - # response.status_code = error.status_code - # return response + @app.exception_handler(pe.ServerException) + async def handle_server_exception(_, exc: pe.ServerException) -> None: + response = jsonable_encoder(exc) + return JSONResponse(status_code=exc.status_code, content=response) + return app diff --git a/dploy_kickstart/wrapper.py b/dploy_kickstart/wrapper.py index 450c7c4..50e0238 100644 --- a/dploy_kickstart/wrapper.py +++ b/dploy_kickstart/wrapper.py @@ -102,7 +102,7 @@ def import_entrypoint(entrypoint: str, location: str) -> typing.Generic: def func_wrapper(f: pa.AnnotatedCallable) -> typing.Callable: """Wrap functions with request logic.""" - def exposed_func(request: Request, body=Body(...)) -> typing.Callable: + async def exposed_func(request: Request, body=Body(...)) -> typing.Callable: # some sanity checking if request.headers["content-type"].lower() != f.request_content_type: raise pe.UnsupportedMediaType( From 289eae22543f237af2bec0dc85b21c7c5b82fee0 Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 12:28:53 +0200 Subject: [PATCH 10/11] delete openapi gen test --- tests/test_openapi_gen.py | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 tests/test_openapi_gen.py diff --git a/tests/test_openapi_gen.py b/tests/test_openapi_gen.py deleted file mode 100644 index 822fc35..0000000 --- a/tests/test_openapi_gen.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import logging - -from pprint import pprint - -import pytest -import dploy_kickstart.annotations as pa -import dploy_kickstart.openapi as po - - -# irrelevant comment line -#' @dploy endpoint predict -def t1(): - return 1 - - -#' @dploy endpoint bladiebla -def t2(): - return 2 - - -@pytest.mark.parametrize( - "callable", [t1, t2,], -) -def test_openapi_generation(callable): - ca = pa.AnnotatedCallable(callable) - - s = po.base_spec("test") - po.path_spec(s, ca) - - assert len(s.to_dict()) > 0 From 135bb033542efcc270480427f362e676fc1c315e Mon Sep 17 00:00:00 2001 From: baturayo Date: Fri, 2 Oct 2020 12:29:14 +0200 Subject: [PATCH 11/11] remove debug server --- dploy_kickstart/cmd.py | 2 +- dploy_kickstart/server.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dploy_kickstart/cmd.py b/dploy_kickstart/cmd.py index 2a88812..f38b196 100644 --- a/dploy_kickstart/cmd.py +++ b/dploy_kickstart/cmd.py @@ -56,7 +56,7 @@ def serve( click.echo(f"Installing deps: {deps}") _deps(deps, location) - app = ps.generate_app(debug=not asgi) + app = ps.generate_app() app = ps.append_entrypoint(app, entrypoint, os.path.abspath(location)) uvicorn.run( diff --git a/dploy_kickstart/server.py b/dploy_kickstart/server.py index c90f9b6..26970c6 100644 --- a/dploy_kickstart/server.py +++ b/dploy_kickstart/server.py @@ -39,9 +39,9 @@ def append_entrypoint(app: FastAPI, entrypoint: str, location: str) -> FastAPI: return app -def generate_app(debug: bool) -> typing.Generic: +def generate_app() -> typing.Generic: """Generate a FastApi app.""" - app = FastAPI(debug=debug) + app = FastAPI() @app.get("/healthz/", status_code=200) async def health_check() -> None: