Repository: rstudio/vetiver-python (python)

Issue:
Unable to run dockerfile: app.py being renamed to app
When I attempt to run the Dockerfile generated in the [deploy-with-docker page of the docs](https://vetiver.rstudio.com/learn-more/deploy-with-docker.html), I get the error:

```
ModuleNotFoundError: no module named app
```

I shelled into the container with the command `docker run -it -p 8080:8080 superbowlads /bin/bash`, and it looks like `app.py` gets renamed to `app`.

It seems like tweaking [this line](https://github.com/rstudio/vetiver-python/blob/main/vetiver/write_docker.py#L82) to name it app.py should fix? (But I could also be doing something wrong..!)

Pull request description:
ci testing to run generated dockerfile
closes #135
adds tests to make sure dockerfile generation runs!

Patch:
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 3f9c028..e721ff8 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -75,7 +75,7 @@ Model Handlers
    :toctree: reference/
    :caption: Model Handlers
 
-   ~BaseHandler
+   ~handlers.BaseHandler
    ~SKLearnHandler
    ~TorchHandler
    ~StatsmodelsHandler
diff --git a/script/setup-docker/.gitignore b/script/setup-docker/.gitignore
new file mode 100644
index 0000000..a052380
--- /dev/null
+++ b/script/setup-docker/.gitignore
@@ -0,0 +1,4 @@
+vetiver_requirements.txt
+app.py
+Dockerfile
+pinsboard
diff --git a/script/setup-docker/docker.py b/script/setup-docker/docker.py
new file mode 100644
index 0000000..b612a0f
--- /dev/null
+++ b/script/setup-docker/docker.py
@@ -0,0 +1,14 @@
+import vetiver
+import pins
+
+X, y = vetiver.get_mock_data()
+model = vetiver.get_mock_model().fit(X, y)
+
+board = pins.board_folder("pinsboard", allow_pickle_read=True)
+
+v = vetiver.VetiverModel(model, "mymodel", ptype_data=X)
+
+vetiver.vetiver_pin_write(board, v)
+vetiver.load_pkgs(v, path="vetiver_")
+vetiver.write_app(board, "mymodel")
+vetiver.write_docker()
diff --git a/vetiver/rsconnect.py b/vetiver/rsconnect.py
index 993c289..1152b44 100644
--- a/vetiver/rsconnect.py
+++ b/vetiver/rsconnect.py
@@ -1,9 +1,8 @@
-import os
-import shutil
 import tempfile
 import typing
-
 from rsconnect.actions import deploy_python_fastapi
+import shutil
+import os
 
 from .write_fastapi import write_app
 
@@ -23,58 +22,57 @@ def deploy_rsconnect(
     log_callback: typing.Callable = None,
     image: str = None,
 ):
-    """Deploy to Posit Connect
+    """Deploy to RSConnect
 
     Parameters
     ----------
-    connect_server : rsconnect.api.RSConnectServer
-        Posit Connect Server
-    board :
-        Pins board
-    pin_name : str
-        Name of pin
-    version : str
-        Version of pin
-    extra_files : typing.List[str]
-        Any extra files to include
-    new : bool
-        Force as a new deploy
-    app_id : int
-        ID of an existing application to deploy new files for.
-    title : str
-        Optional title for the deploy.
-    python : str
-        Optional name of a Python executable
-    conda_mode : bool
-        Use conda to build an environment.yml
-    force_generate : bool
-        Force generating requirements.txt or environment.yml
-    log_callback : typing.Callable
-        Callback to use to write the log to
-    image : str
-        Docker image to be specified for off-host execution
+        connect_server: rsconnect.api.RSConnectServer
+            RSConnect Server
+        board:
+            Pins board
+        pin_name: str
+            Name of pin
+        version: str
+            Version of pin
+        extra_files: typing.List[str]
+            Any extra files to include in
+        new:
+            Force as a new deploy
+        app_id:
+            ID of an existing application to deploy new files for.
+        title: str
+            Optional title for the deploy.
+        python: str
+            Optional name of a Python executable
+        conda_mode: bool
+            Use conda to build an environment.yml
+        force_generate: bool
+            Force generating "requirements.txt" or "environment.yml"
+        log_callback: typing.Callable
+            Callback to use to write the log to
+        image: str
+            Docker image to be specified for off-host execution
 
-    Example
-    -------
-    >>> import vetiver
-    >>> import pins
-    >>> import rsconnect
-    >>> board = pins.board_temp(allow_pickle_read=True)
-    >>> connect_server = rsconnect.api.RSConnectServer(
-    ...    url = url,
-    ...    api_key = api_key)      # doctest: +SKIP
-    >>> X, y = vetiver.get_mock_data()
-    >>> model = vetiver.get_mock_model().fit(X, y)
-    >>> v = vetiver.VetiverModel(model = model,
-    ...    model_name = "my_model",
-    ...    ptype_data = X)
-    >>> vetiver.deploy_rsconnect(
-    ...    connect_server = connect_server,
-    ...    board = board,
-    ...    pin_name = "my_model"
-    ... )      # doctest: +SKIP
+        Example
+        -------
+        >>> import vetiver
+        >>> import pins
+        >>> import rsconnect
+        >>> board = pins.board_temp(allow_pickle_read=True)
+        >>> connect_server = rsconnect.api.RSConnectServer(
+        ...    url = url,
+        ...    api_key = api_key)      # doctest: +SKIP
+        >>> X, y = vetiver.get_mock_data()
+        >>> model = vetiver.get_mock_model().fit(X, y)
+        >>> v = vetiver.VetiverModel(model = model,
+        ...    model_name = "my_model",
+        ...    ptype_data = X)
+        >>> vetiver.deploy_rsconnect(
+        ...    connect_server = connect_server,
+        ...    board = board,
+        ...    pin_name = "my_model"
+        ... )      # doctest: +SKIP
     """
-
     if not title:
         title = pin_name + "_vetiver"
 
diff --git a/vetiver/server.py b/vetiver/server.py
index 8805482..02492ec 100644
--- a/vetiver/server.py
+++ b/vetiver/server.py
@@ -1,16 +1,16 @@
-from typing import Callable, List, Union, Any, Dict
-from urllib.parse import urljoin
-
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.openapi.utils import get_openapi
+from fastapi import testclient
 import httpx
-import pandas as pd
-import requests
+
 import uvicorn
-from fastapi import FastAPI, Request, testclient
-from fastapi.openapi.utils import get_openapi
-from fastapi.responses import HTMLResponse, RedirectResponse
+import requests
+import pandas as pd
+from typing import Callable, Union, List
 
-from .utils import _jupyter_nb
 from .vetiver_model import VetiverModel
+from .utils import _jupyter_nb
 
 
 class VetiverAPI:
@@ -45,12 +45,10 @@ class VetiverAPI:
         self.model = model
         self.check_ptype = check_ptype
         self.app_factory = app_factory
-        self.app = app_factory()
-
-        self._init_app()
+        self.app = self._init_app()
 
     def _init_app(self):
-        app = self.app
+        app = self.app_factory()
         app.openapi = self._custom_openapi
 
         @app.get("/", include_in_schema=False)
@@ -70,13 +68,38 @@ class VetiverAPI:
         async def ping():
             return {"ping": "pong"}
 
-        self.vetiver_post(
-            self.model.handler_predict, "predict", check_ptype=self.check_ptype
-        )
+        if self.check_ptype is True:
+
+            @app.post("/predict")
+            async def prediction(
+                input_data: Union[self.model.ptype, List[self.model.ptype]]
+            ):
+                if isinstance(input_data, List):
+                    served_data = _batch_data(input_data)
+                else:
+                    served_data = _prepare_data(input_data)
+
+                y = self.model.handler_predict(
+                    served_data, check_ptype=self.check_ptype
+                )
+
+                return {"prediction": y.tolist()}
+
+        elif self.check_ptype is False:
+
+            @app.post("/predict")
+            async def prediction(input_data: Request):
+                y = await input_data.json()
+
+                prediction = self.model.handler_predict(y, check_ptype=self.check_ptype)
+
+                return {"prediction": prediction.tolist()}
+
+        else:
+            raise ValueError("cannot determine `check_ptype`")
 
         @app.get("/__docs__", response_class=HTMLResponse, include_in_schema=False)
         async def rapidoc():
-            # save as html html.tpl, .format {spec_url}
             return f"""
                     <!doctype html>
                     <html>
@@ -114,7 +137,9 @@ class VetiverAPI:
 
         return app
 
-    def vetiver_post(self, endpoint_fx: Callable, endpoint_name: str = None, **kw):
+    def vetiver_post(
+        self, endpoint_fx: Callable, endpoint_name: str = "custom_endpoint"
+    ):
         """Create new POST endpoint that is aware of model input data
 
         Parameters
@@ -126,39 +151,29 @@ class VetiverAPI:
 
         Example
         -------
-        >>> import vetiver as vt
-        >>> X, y = vt.get_mock_data()
-        >>> model = vt.get_mock_model().fit(X, y)
-        >>> v = vt.VetiverModel(model = model, model_name = "model", ptype_data = X)
-        >>> v_api = vt.VetiverAPI(model = v, check_ptype = True)
+        >>> import vetiver
+        >>> X, y = vetiver.get_mock_data()
+        >>> model = vetiver.get_mock_model().fit(X, y)
+        >>> v = vetiver.VetiverModel(model = model, model_name = "model", ptype_data = X)
+        >>> v_api = vetiver.VetiverAPI(model = v, check_ptype = True)
         >>> def sum_values(x):
         ...     return x.sum()
         >>> v_api.vetiver_post(sum_values, "sums")
         """
-        if not endpoint_name:
-            endpoint_name = endpoint_fx.__name__
-
         if self.check_ptype is True:
 
-            @self.app.post(urljoin("/", endpoint_name), name=endpoint_name)
-            async def custom_endpoint(
-                input_data: Union[self.model.ptype, List[self.model.ptype]]
-            ):
-
-                if isinstance(input_data, List):
-                    served_data = _batch_data(input_data)
-                else:
-                    served_data = _prepare_data(input_data)
-
-                new = endpoint_fx(served_data, **kw)
+            @self.app.post("/" + endpoint_name)
+            async def custom_endpoint(input_data: self.model.ptype):
+                y = _prepare_data(input_data)
+                new = endpoint_fx(pd.DataFrame(y))
                 return {endpoint_name: new.tolist()}
 
         else:
 
-            @self.app.post(urljoin("/", endpoint_name))
+            @self.app.post("/" + endpoint_name)
             async def custom_endpoint(input_data: Request):
-                served_data = await input_data.json()
-                new = endpoint_fx(served_data, **kw)
+                y = await input_data.json()
+                new = endpoint_fx(pd.DataFrame(y))
 
                 return {endpoint_name: new.tolist()}
 
@@ -199,11 +214,10 @@ class VetiverAPI:
         )
         openapi_schema["info"]["x-logo"] = {"url": "../docs/figures/logo.svg"}
         self.app.openapi_schema = openapi_schema
-
         return self.app.openapi_schema
 
 
-def predict(endpoint, data: Union[dict, pd.DataFrame, pd.Series], **kw) -> pd.DataFrame:
+def predict(endpoint, data: Union[dict, pd.DataFrame, pd.Series], **kw):
     """Make a prediction from model endpoint
 
     Parameters
@@ -232,7 +246,6 @@ def predict(endpoint, data: Union[dict, pd.DataFrame, pd.Series], **kw) -> pd.Da
         requester = requests
 
     # TO DO: arrow format
-    # TO DO: dispatch
 
     if isinstance(data, pd.DataFrame):
         data_json = data.to_json(orient="records")
@@ -261,14 +274,14 @@ def predict(endpoint, data: Union[dict, pd.DataFrame, pd.Series], **kw) -> pd.Da
     return response_df
 
 
-def _prepare_data(pred_data: Dict[str, Any]) -> List[Any]:
+def _prepare_data(pred_data):
     served_data = []
     for key, value in pred_data:
         served_data.append(value)
     return served_data
 
 
-def _batch_data(pred_data: List[Any]) -> pd.DataFrame:
+def _batch_data(pred_data):
     columns = pred_data[0].dict().keys()
 
     data = [line.dict() for line in pred_data]
@@ -277,7 +290,7 @@ def _batch_data(pred_data: List[Any]) -> pd.DataFrame:
     return served_data
 
 
-def vetiver_endpoint(url: str = "http://127.0.0.1:8000/predict") -> str:
+def vetiver_endpoint(url="http://127.0.0.1:8000/predict"):
     """Wrap url where VetiverModel will be deployed
 
     Parameters
diff --git a/vetiver/write_docker.py b/vetiver/write_docker.py
index 5d328a5..d35752e 100644
--- a/vetiver/write_docker.py
+++ b/vetiver/write_docker.py
@@ -79,7 +79,7 @@ COPY vetiver_requirements.txt /vetiver/requirements.txt
 RUN pip install --no-cache-dir --upgrade -r /vetiver/requirements.txt
 
 # copy app file
-COPY {app_file} /vetiver/app
+COPY {app_file} /vetiver/app/{app_file}
 
 # expose port
 EXPOSE {port}


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8126c5f..f1781a7 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -68,6 +68,26 @@ jobs:
         run: |
           pytest vetiver -m 'rsc_test'
 
+  test-docker:
+      name: "Test Docker"
+      runs-on: ubuntu-latest
+      steps:
+        - uses: actions/checkout@v2
+        - uses: actions/setup-python@v2
+          with:
+            python-version: 3.8
+        - name: Install dependencies
+          run: |
+            python -m pip install --upgrade pip
+            python -m pip install -e ".[dev]"
+        - name: run Docker
+          run: |
+            python script/setup-docker/docker.py
+            docker build -t mock .
+            docker run -d -v $PWD/pinsboard:/vetiver/pinsboard -p 8080:8080 mock
+            sleep 5
+            curl -s --retry 10 --retry-connrefused http://0.0.0.0:8080
+
   test-no-extras:
     name: "Test no exra ml frameworks"
     runs-on: ubuntu-latest
diff --git a/vetiver/tests/test_add_endpoint.py b/vetiver/tests/test_add_endpoint.py
index af876c3..67bb729 100644
--- a/vetiver/tests/test_add_endpoint.py
+++ b/vetiver/tests/test_add_endpoint.py
@@ -1,16 +1,8 @@
-import pytest
-
-import numpy as np
-import pandas as pd
-from fastapi.testclient import TestClient
-
 from vetiver import mock, VetiverModel, VetiverAPI
-import vetiver
+from fastapi.testclient import TestClient
 
 
-@pytest.fixture
-def vetiver_model():
-    np.random.seed(500)
+def _start_application(check_ptype):
     X, y = mock.get_mock_data()
     model = mock.get_mock_model().fit(X, y)
     v = VetiverModel(
@@ -21,55 +13,31 @@ def vetiver_model():
         description="A regression model for testing purposes",
     )
 
-    return v
-
-
-def sum_values(x):
-    return x.sum()
-
-
-def sum_dict(x):
-    x = pd.DataFrame(x)
-    return x.sum()
-
+    def sum_values(x):
+        return x.sum()
 
-@pytest.fixture
-def vetiver_client(vetiver_model):  # With check_ptype=True
+    app = VetiverAPI(v, check_ptype=check_ptype)
 
-    app = VetiverAPI(vetiver_model, check_ptype=True)
     app.vetiver_post(sum_values, "sum")
 
-    app.app.root_path = "/sum"
-    client = TestClient(app.app)
-
-    return client
-
-
-@pytest.fixture
-def vetiver_client_check_ptype_false(vetiver_model):  # With check_ptype=False
-
-    app = VetiverAPI(vetiver_model, check_ptype=False)
-    app.vetiver_post(sum_dict, "sum")
-
-    app.app.root_path = "/sum"
-    client = TestClient(app.app)
-
-    return client
-
-
-def test_endpoint_adds_ptype(vetiver_client):
+    return app
 
-    data = pd.DataFrame({"B": [1, 1, 1], "C": [2, 2, 2], "D": [3, 3, 3]})
-    response = vetiver.predict(endpoint=vetiver_client, data=data)
 
-    assert isinstance(response, pd.DataFrame)
-    assert response.to_json() == '{"sum":{"0":3,"1":6,"2":9}}', response.to_json()
+def test_endpoint_adds_ptype():
+    app = _start_application(check_ptype=True).app
 
+    client = TestClient(app)
+    data = {"B": 0, "C": 0, "D": 0}
+    response = client.post("/sum", json=data)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"sum": [0]}, response.json()
 
-def test_endpoint_adds_no_ptype(vetiver_client_check_ptype_false):
 
-    data = pd.DataFrame({"B": [1, 1, 1], "C": [2, 2, 2], "D": [3, 3, 3]})
-    response = vetiver.predict(endpoint=vetiver_client_check_ptype_false, data=data)
+def test_endpoint_adds_no_ptype():
+    app = _start_application(check_ptype=False).app
 
-    assert isinstance(response, pd.DataFrame)
-    assert response.to_json() == '{"sum":{"0":3,"1":6,"2":9}}', response.to_json()
+    client = TestClient(app)
+    data = [0, 0, 0]
+    response = client.post("/sum", json=data)
+    assert response.status_code == 200, response.text
+    assert response.json() == {"sum": [0]}, response.json()
diff --git a/vetiver/tests/test_pytorch.py b/vetiver/tests/test_pytorch.py
index ab781a4..0fa6238 100644
--- a/vetiver/tests/test_pytorch.py
+++ b/vetiver/tests/test_pytorch.py
@@ -66,7 +66,7 @@ def test_torch_predict_ptype():
     response = client.post("/predict", json=data)
 
     assert response.status_code == 200, response.text
-    assert response.json() == {"predict": [-4.060722351074219]}, response.text
+    assert response.json() == {"prediction": [-4.060722351074219]}, response.text
 
 
 def test_torch_predict_ptype_batch():
@@ -81,7 +81,7 @@ def test_torch_predict_ptype_batch():
 
     assert response.status_code == 200, response.text
     assert response.json() == {
-        "predict": [[-4.060722351074219], [-4.060722351074219]]
+        "prediction": [[-4.060722351074219], [-4.060722351074219]]
     }, response.text
 
 
@@ -109,7 +109,7 @@ def test_torch_predict_no_ptype_batch():
     response = client.post("/predict", json=data)
     assert response.status_code == 200, response.text
     assert response.json() == {
-        "predict": [[-4.060722351074219], [-4.060722351074219]]
+        "prediction": [[-4.060722351074219], [-4.060722351074219]]
     }, response.text
 
 
@@ -123,4 +123,4 @@ def test_torch_predict_no_ptype():
     data = [[3.3]]
     response = client.post("/predict", json=data)
     assert response.status_code == 200, response.text
-    assert response.json() == {"predict": [[-4.060722351074219]]}, response.text
+    assert response.json() == {"prediction": [[-4.060722351074219]]}, response.text
diff --git a/vetiver/tests/test_sklearn.py b/vetiver/tests/test_sklearn.py
index 58b0f32..28c6a9a 100644
--- a/vetiver/tests/test_sklearn.py
+++ b/vetiver/tests/test_sklearn.py
@@ -26,7 +26,7 @@ def test_predict_endpoint_ptype():
     data = {"B": 0, "C": 0, "D": 0}
     response = client.post("/predict", json=data)
     assert response.status_code == 200, response.text
-    assert response.json() == {"predict": [44.47]}, response.json()
+    assert response.json() == {"prediction": [44.47]}, response.json()
 
 
 def test_predict_endpoint_ptype_batch():
@@ -35,7 +35,7 @@ def test_predict_endpoint_ptype_batch():
     data = [{"B": 0, "C": 0, "D": 0}, {"B": 0, "C": 0, "D": 0}]
     response = client.post("/predict", json=data)
     assert response.status_code == 200, response.text
-    assert response.json() == {"predict": [44.47, 44.47]}, response.json()
+    assert response.json() == {"prediction": [44.47, 44.47]}, response.json()
 
 
 def test_predict_endpoint_ptype_error():
@@ -52,7 +52,7 @@ def test_predict_endpoint_no_ptype():
     data = [{"B": 0, "C": 0, "D": 0}]
     response = client.post("/predict", json=data)
     assert response.status_code == 200, response.text
-    assert response.json() == {"predict": [44.47]}, response.json()
+    assert response.json() == {"prediction": [44.47]}, response.json()
 
 
 def test_predict_endpoint_no_ptype_batch():
@@ -61,7 +61,7 @@ def test_predict_endpoint_no_ptype_batch():
     data = [[0, 0, 0], [0, 0, 0]]
     response = client.post("/predict", json=data)
     assert response.status_code == 200, response.text
-    assert response.json() == {"predict": [44.47, 44.47]}, response.json()
+    assert response.json() == {"prediction": [44.47, 44.47]}, response.json()
 
 
 def test_predict_endpoint_no_ptype_error():
diff --git a/vetiver/tests/test_write_docker.py b/vetiver/tests/test_write_docker.py
index e94b9c6..f8e06b3 100644
--- a/vetiver/tests/test_write_docker.py
+++ b/vetiver/tests/test_write_docker.py
@@ -29,7 +29,7 @@ COPY vetiver_requirements.txt /vetiver/requirements.txt
 RUN pip install --no-cache-dir --upgrade -r /vetiver/requirements.txt
 
 # copy app file
-COPY app.py /vetiver/app
+COPY app.py /vetiver/app/app.py
 
 # expose port
 EXPOSE 8080
@@ -64,7 +64,7 @@ RUN pip config set global.index-url https://colorado.rstudio.com/rspm/pypi/lates
 RUN pip install --no-cache-dir --upgrade -r /vetiver/requirements.txt
 
 # copy app file
-COPY app.py /vetiver/app
+COPY app.py /vetiver/app/app.py
 
 # expose port
 EXPOSE 8080