Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unit test suite #4

Merged
merged 29 commits into from
Aug 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
362387f
Remove example code from template repo
br3ndonland Aug 20, 2020
8e99b98
Install Requests for Starlette test client
br3ndonland Aug 20, 2020
81d5a90
Add utilities module to support basic auth
br3ndonland Aug 20, 2020
a3ffa71
Add Starlette Basic Auth middleware to utilities
br3ndonland Aug 21, 2020
9e352df
Add CORS and Basic Auth endpoints to app modules
br3ndonland Aug 20, 2020
5ba0726
Add Pytest fixtures to conftest.py
br3ndonland Aug 21, 2020
ffbfd5e
Add Pytest fixture for HTTP Basic Auth credentials
br3ndonland Aug 21, 2020
d6cb27b
Add unit tests for API endpoints
br3ndonland Aug 21, 2020
fe0fe7d
Install Docker Python SDK
br3ndonland Aug 21, 2020
9470d52
Upgrade to pytest-mock@^3.3 for type annotations
br3ndonland Aug 25, 2020
4801ae3
Add README reference to logging PR
br3ndonland Aug 25, 2020
a96a7af
Correct Mypy assignment error in gunicorn_conf.py
br3ndonland Aug 27, 2020
aff93b5
Add tests for default configuration file paths
br3ndonland Aug 27, 2020
02a9645
Create temporary configuration files for testing
br3ndonland Aug 29, 2020
ce9268d
Add tests for start.py configure_logging()
br3ndonland Aug 29, 2020
d846f54
Add tests for start.py set_app_module()
br3ndonland Aug 29, 2020
8c1bc23
Add tests for start.py run_pre_start_script()
br3ndonland Aug 29, 2020
5821174
Improve tests for start.py set_app_module()
br3ndonland Aug 29, 2020
92e6b68
Add tests for start.py start_server()
br3ndonland Aug 30, 2020
5607c45
Use mocker.patch.dict for logging config dict
br3ndonland Aug 30, 2020
70b5b91
Add docstrings to conftest.py and test_start.py
br3ndonland Aug 30, 2020
306d520
Upgrade FastAPI to 0.61
br3ndonland Aug 30, 2020
d2220ba
Add info on PROCESS_MANAGER env var to README
br3ndonland Aug 30, 2020
823408d
Improve HTTP Basic Auth middleware
br3ndonland Aug 30, 2020
962176a
Remove reload option when starting Gunicorn server
br3ndonland Aug 30, 2020
3d3333c
Retain package directory structure in Docker image
br3ndonland Aug 31, 2020
4a6d6ae
Test custom app module paths
br3ndonland Aug 31, 2020
183f8e9
Upgrade Black in pre-commit to 20.8b1
br3ndonland Aug 31, 2020
99a01c1
Install FastAPI for GitHub Actions tests workflow
br3ndonland Aug 31, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install poetry
poetry install
poetry install --no-interaction -E fastapi
env:
POETRY_VIRTUALENVS_CREATE: false
- name: Run unit tests
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ minimum_pre_commit_version: 2.6.0
default_stages: [commit, push, manual]
repos:
- repo: https://github.com/psf/black
rev: stable
rev: 20.8b1
hooks:
- id: black
- repo: https://gitlab.com/pycqa/flake8
Expand Down
7 changes: 5 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
"request": "launch",
"stopOnEntry": false,
"pythonPath": "${command:python.interpreterPath}",
"module": "start",
"module": "inboard.start",
"env": {
"APP_MODULE": "app.base.main:app",
"APP_MODULE": "inboard.app.fastapibase.main:app",
"BASIC_AUTH_USERNAME": "test_username",
"BASIC_AUTH_PASSWORD": "plunge-germane-tribal-pillar",
"LOG_FORMAT": "uvicorn",
"LOG_LEVEL": "debug",
"LOGGING_CONF": "logging_conf.py",
"PROCESS_MANAGER": "uvicorn",
"WITH_RELOAD": "true"
},
"console": "integratedTerminal",
Expand Down
13 changes: 7 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
FROM python:3.8 AS base
LABEL maintainer="Brendon Smith"
COPY poetry.lock pyproject.toml /
ENV APP_MODULE=base.main:app POETRY_VIRTUALENVS_CREATE=false PYTHONPATH=/app
ENV APP_MODULE=inboard.app.base.main:app POETRY_VIRTUALENVS_CREATE=false PYTHONPATH=/app
COPY poetry.lock pyproject.toml /app/
WORKDIR /app/
RUN python -m pip install poetry && poetry install --no-dev --no-interaction --no-root -E fastapi
COPY inboard/gunicorn_conf.py inboard/logging_conf.py inboard/start.py inboard/app /
CMD python /start.py
COPY inboard /app/inboard
CMD python /app/inboard/start.py

FROM base AS fastapi
ENV APP_MODULE=fastapibase.main:app
ENV APP_MODULE=inboard.app.fastapibase.main:app

FROM base AS starlette
ENV APP_MODULE=starlettebase.main:app
ENV APP_MODULE=inboard.app.starlettebase.main:app
102 changes: 46 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,12 @@ The _Dockerfile_ could look like this:
FROM docker.pkg.github.com/br3ndonland/inboard/fastapi

# Install Python requirements
COPY poetry.lock pyproject.toml /
COPY poetry.lock pyproject.toml /app/
WORKDIR /app/
RUN poetry install --no-dev --no-interaction --no-root

# Install Python app
COPY package /app/
COPY package /app/package

# RUN command already included in base image
```
Expand All @@ -121,11 +122,12 @@ For a standard `pip` install:
FROM docker.pkg.github.com/br3ndonland/inboard/fastapi

# Install Python requirements
COPY requirements.txt /
COPY requirements.txt /app/
WORKDIR /app/
RUN python -m pip install -r requirements.txt

# Install Python app
COPY package /app/
COPY package /app/package

# RUN command already included in base image
```
Expand All @@ -149,19 +151,19 @@ Run container with mounted volume and Uvicorn reloading for development:

```sh
cd /path/to/repo
docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" -v $(pwd)/package:/app imagename
docker run -d -p 80:80 \
-e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \
-v $(pwd)/package:/app/package imagename
```

- `WITH_RELOAD=true`: `start.py` will run Uvicorn with reloading and without Gunicorn. The Gunicorn configuration won't apply, but these environment variables will still work as [described](#configuration):
- `MODULE_NAME`
- `VARIABLE_NAME`
- `start.py` will run Uvicorn with reloading and without Gunicorn. The Gunicorn configuration won't apply, but these environment variables will still work as [described](#configuration):
- `APP_MODULE`
- `HOST`
- `PORT`
- `LOG_COLORS`
- `LOG_FORMAT`
- `LOG_LEVEL`
- `-v $(pwd)/package:/app`: the specified directory (`/path/to/repo/package` in this example) will be [mounted as a volume](https://docs.docker.com/engine/reference/run/#volume-shared-filesystems) inside of the container at `/app`. When files in the working directory change, Docker and Uvicorn will sync the files to the running Docker container.
- `-v $(pwd)/package:/app/package`: the specified directory (`/path/to/repo/package` in this example) will be [mounted as a volume](https://docs.docker.com/engine/reference/run/#volume-shared-filesystems) inside of the container at `/app/package`. When files in the working directory change, Docker and Uvicorn will sync the files to the running Docker container.
- The final argument is the Docker image name (`imagename` in this example). Replace with your image name.

Hit an API endpoint:
Expand All @@ -187,29 +189,25 @@ Hello World, from Uvicorn, Gunicorn, and Python 3.8!
To set environment variables when starting the Docker image:

```sh
docker run -d -p 80:80 -e APP_MODULE="custom.module:api" -e WORKERS_PER_CORE="2" myimage
docker run -d -p 80:80 -e APP_MODULE="package.custom.module:api" -e WORKERS_PER_CORE="2" myimage
```

To set environment variables within a _Dockerfile_:

```dockerfile
FROM docker.pkg.github.com/br3ndonland/inboard/fastapi
ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"
ENV APP_MODULE="package.custom.module:api" WORKERS_PER_CORE="2"
```

### General

- `MODULE_NAME`: Python module (file) with app instance. Note that the base image sets the environment variable `PYTHONPATH=/app`, so the module name will be relative to `/app` unless you supply a custom `PYTHONPATH`.
- Default:
- `"main"` if there's a file `/app/main.py`
- Else `"app.main"` if there's a file `/app/app/main.py`
- Custom: For a module at `/app/custom/module.py`, `MODULE_NAME="custom.module"`
- `VARIABLE_NAME`: Variable (object) inside of the Python module that contains the ASGI application instance.
- `APP_MODULE`: Python module with app instance. Note that the base image sets the environment variable `PYTHONPATH=/app`, so the module name will be relative to `/app` unless you supply a custom `PYTHONPATH`.

- Default: `"app"`
- Custom: For an application instance named `api`, `VARIABLE_NAME="api"`
- Default: The appropriate app module from inboard.
- Custom: For a module at `/app/package/custom/module.py` and app instance object `api`, `APP_MODULE="package.custom.module:api"`

```py
# /app/package/custom/module.py
from fastapi import FastAPI

api = FastAPI()
Expand All @@ -219,14 +217,10 @@ ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"
return {"message": "Hello World!"}
```

- `APP_MODULE`: Combination of `MODULE_NAME` and `VARIABLE_NAME` pointing to the app instance.
- Default:
- `MODULE_NAME:VARIABLE_NAME` (`"main:app"` or `"app.main:app"`)
- Custom: For a module at `/app/custom/module.py` and variable `api`, `APP_MODULE="custom.module:api"`
- `PRE_START_PATH`: Path to a pre-start script. Add a file `prestart.py` or `prestart.sh` to the application directory, and copy the directory into the Docker image as described (for a project with the Python application in `repo/package`, `COPY package /app/`). The container will automatically detect and run the prestart script before starting the web server.
- `PRE_START_PATH`: Path to a pre-start script. Add a file `prestart.py` or `prestart.sh` to the application directory, and copy the directory into the Docker image as described (for a project with the Python application in `repo/package`, `COPY package /app/package`). The container will automatically detect and run the prestart script before starting the web server.

- Default: `"/app/prestart.py"`
- Custom: `PRE_START_PATH="/custom/script.sh"`
- Default: `"/app/inboard/prestart.py"` (the default file provided with the Docker image)
- Custom: `PRE_START_PATH="/app/package/custom_script.sh"`

- [`PYTHONPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH): Python's search path for module files.
- Default: `PYTHONPATH="/app"`
Expand All @@ -236,11 +230,9 @@ ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"

- `GUNICORN_CONF`: Path to a [Gunicorn configuration file](https://docs.gunicorn.org/en/latest/settings.html#config-file).
- Default:
- `"/app/gunicorn_conf.py"` if exists
- Else `"/app/app/gunicorn_conf.py"` if exists
- Else `"/gunicorn_conf.py"` (the default file provided with the Docker image)
- `"/app/inboard/gunicorn_conf.py"` (the default file provided with the Docker image)
- Custom:
- `GUNICORN_CONF="/app/custom_gunicorn_conf.py"`
- `GUNICORN_CONF="/app/package/custom_gunicorn_conf.py"`
- Feel free to use the [`gunicorn_conf.py`](./inboard/gunicorn_conf.py) from this repo as a starting point for your own custom configuration.
- `HOST`: Host IP address (inside of the container) where Gunicorn will listen for requests.
- Default: `"0.0.0.0"`
Expand All @@ -251,6 +243,9 @@ ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"
- [`BIND`](https://docs.gunicorn.org/en/latest/settings.html#server-socket): The actual host and port passed to Gunicorn.
- Default: `HOST:PORT` (`"0.0.0.0:80"`)
- Custom: `BIND="0.0.0.0:8080"`
- `PROCESS_MANAGER`: Manager for Uvicorn worker processes. As described in the [Uvicorn docs](https://www.uvicorn.org), "Uvicorn includes a Gunicorn worker class allowing you to run ASGI applications, with all of Uvicorn's performance benefits, while also giving you Gunicorn's fully-featured process management."
- Default: `"gunicorn"` (run Uvicorn with Gunicorn as the process manager)
- Custom: `"uvicorn"` (run Uvicorn alone for local development)
- [`WORKER_CLASS`](https://docs.gunicorn.org/en/latest/settings.html#worker-processes): The class to be used by Gunicorn for the workers.
- Default: `uvicorn.workers.UvicornWorker`
- Custom: For the alternate Uvicorn worker, `WORKER_CLASS="uvicorn.workers.UvicornH11Worker"`
Expand Down Expand Up @@ -289,13 +284,11 @@ ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"

### Logging

- `LOGGING_CONF`: Path to a [Python logging configuration file](https://docs.python.org/3/library/logging.config.html). The configuration must be a new-style `.py` file, containing a configuration dictionary object named `LOGGING_CONFIG`. The `LOGGING_CONFIG` dictionary will be passed to [`logging.config.dictConfig()`](https://docs.python.org/3/library/logging.config.html)
- `LOGGING_CONF`: Path to a [Python logging configuration file](https://docs.python.org/3/library/logging.config.html). The configuration must be a new-style `.py` file, containing a configuration dictionary object named `LOGGING_CONFIG`. The `LOGGING_CONFIG` dictionary will be passed to [`logging.config.dictConfig()`](https://docs.python.org/3/library/logging.config.html). See [br3ndonland/inboard#3](https://github.com/br3ndonland/inboard/pull/3) for more details on this design choice.
- Default:
- `"/app/logging_conf.py"` if exists
- Else `"/app/app/logging_conf.py"` if exists
- Else `"/logging_conf.py"` (the default file provided with the Docker image)
- `"/app/inboard/logging_conf.py"` (the default file provided with the Docker image)
- Custom:
- `LOGGING_CONF="/app/custom_logging.py"`
- `LOGGING_CONF="/app/package/custom_logging.py"`
- `LOG_COLORS`: Whether or not to color log messages. Currently only supported for `LOG_FORMAT="uvicorn"`.
- Default:
- Auto-detected based on [`sys.stdout.isatty()`](https://docs.python.org/3/library/sys.html#sys.stdout).
Expand All @@ -313,7 +306,7 @@ ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"
# simple
INFO Started server process [19012]
# verbose
2020-08-19 20:50:05 -0400 19012 uvicorn.error main INFO Started server process [19012]
2020-08-19 21:07:31 -0400 19012 uvicorn.error main INFO Started server process [19012]
# gunicorn
[2020-08-19 21:07:31 -0400] [19012] [INFO] Started server process [19012]
# uvicorn (can also be colored)
Expand Down Expand Up @@ -372,31 +365,28 @@ docker build . --rm --target starlette -t localhost/br3ndonland/inboard/starlett
### Running development containers

```sh
# Uvicorn with reloading
# Run Docker container with Uvicorn and reloading
cd inboard

docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" \
-v $(pwd)/inboard/gunicorn_conf.py:/gunicorn_conf.py \
-v $(pwd)/inboard/logging_conf.py:/logging_conf.py \
-v $(pwd)/inboard/start.py:/start.py \
-v $(pwd)/inboard/app:/app localhost/br3ndonland/inboard/base

docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" \
-v $(pwd)/inboard/gunicorn_conf.py:/gunicorn_conf.py \
-v $(pwd)/inboard/logging_conf.py:/logging_conf.py \
-v $(pwd)/inboard/start.py:/start.py \
-v $(pwd)/inboard/app:/app localhost/br3ndonland/inboard/fastapi

docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" \
-v $(pwd)/inboard/gunicorn_conf.py:/gunicorn_conf.py \
-v $(pwd)/inboard/logging_conf.py:/logging_conf.py \
-v $(pwd)/inboard/start.py:/start.py \
-v $(pwd)/inboard/app:/app localhost/br3ndonland/inboard/starlette

# Gunicorn and Uvicorn
docker run -d -p 80:80 \
-e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \
-v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard/base

docker run -d -p 80:80 \
-e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \
-v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard/fastapi

docker run -d -p 80:80 \
-e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \
-v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard/starlette

# Run Docker container with Gunicorn and Uvicorn
docker run -d -p 80:80 localhost/br3ndonland/inboard/base
docker run -d -p 80:80 localhost/br3ndonland/inboard/fastapi
docker run -d -p 80:80 localhost/br3ndonland/inboard/starlette

# Test HTTP Basic Auth when running the FastAPI or Starlette images:
http :80/status --auth-type=basic --auth=test_username:plunge-germane-tribal-pillar
```

Change the port numbers to run multiple containers simultaneously (`-p 81:80`).
5 changes: 4 additions & 1 deletion inboard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

def package_version() -> str:
"""Calculate version number based on pyproject.toml"""
return version(__package__)
try:
return version(__package__)
except Exception:
return "Package not found."


__version__ = package_version()
34 changes: 30 additions & 4 deletions inboard/app/fastapibase/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,41 @@
import sys
from typing import Dict

from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware

from inboard.app.utilities import basic_auth

server = "Uvicorn" if bool(os.getenv("WITH_RELOAD")) else "Uvicorn, Gunicorn"
version = f"{sys.version_info.major}.{sys.version_info.minor}"

app = FastAPI()
app = FastAPI(title="inboard")

app.add_middleware(
CORSMiddleware,
allow_origins="https://br3ndon.land",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


@app.get("/")
async def root() -> Dict[str, str]:
async def get_root() -> Dict[str, str]:
return {"Hello": "World"}


@app.get("/health")
def get_health(auth: str = Depends(basic_auth)) -> Dict[str, str]:
return {"application": app.title, "status": "active"}


@app.get("/status")
def get_status(auth: str = Depends(basic_auth)) -> Dict[str, str]:
message = f"Hello World, from {server}, FastAPI, and Python {version}!"
return {"message": message}
return {"application": app.title, "status": "active", "message": message}


@app.get("/users/me")
def get_current_user(username: str = Depends(basic_auth)) -> Dict[str, str]:
return {"username": username}
48 changes: 46 additions & 2 deletions inboard/app/starlettebase/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,60 @@
import sys

from starlette.applications import Starlette
from starlette.authentication import requires
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

from inboard.app.utilities import BasicAuth

server = "Uvicorn" if bool(os.getenv("WITH_RELOAD")) else "Uvicorn, Gunicorn"
version = f"{sys.version_info.major}.{sys.version_info.minor}"


def on_auth_error(request: Request, e: Exception) -> JSONResponse:
return JSONResponse(
{"detail": "Incorrect username or password", "error": str(e)}, status_code=401
)


app = Starlette()
app.add_middleware(
AuthenticationMiddleware,
backend=BasicAuth(),
on_error=on_auth_error,
)
app.add_middleware(
CORSMiddleware,
allow_origins="https://br3ndon.land",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


@app.route("/")
async def homepage(request: Request) -> JSONResponse:
async def get_root(request: Request) -> JSONResponse:
return JSONResponse({"Hello": "World"})


@app.route("/health")
@requires("authenticated")
def get_health(request: Request) -> JSONResponse:
return JSONResponse({"application": "inboard", "status": "active"})


@app.route("/status")
@requires("authenticated")
def get_status(request: Request) -> JSONResponse:
message = f"Hello World, from {server}, Starlette, and Python {version}!"
return JSONResponse({"message": message})
return JSONResponse(
{"application": "inboard", "status": "active", "message": message}
)


@app.route("/users/me")
@requires("authenticated")
def get_current_user(request: Request) -> JSONResponse:
return JSONResponse({"username": request.user.display_name})
Loading