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

Increase unit test coverage to 100% #8

Merged
merged 13 commits into from
Sep 13, 2020
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ After saving files, changes need to be committed to the Git repository.
- Python 3 (modern Python) was used. Python 2 (legacy Python) is nearing its [end of life](https://pythonclock.org/).
- Python code was linted with [Flake8](https://flake8.readthedocs.io/en/latest/) and autoformatted with [Black](https://black.readthedocs.io/en/stable/).
- Git pre-commit hooks have been installed for the [Black autoformatter](https://black.readthedocs.io/en/stable/version_control_integration.html) and [Flake8 linter](https://flake8.pycqa.org/en/latest/user/using-hooks.html).
- Within Python modules, `import` statements are organized automatically by [isort](https://timothycrosley.github.io/isort/).
- Within Python modules, `import` statements are organized automatically by [isort](https://pycqa.github.io/isort/).
- In general, a [Pythonic](https://docs.python-guide.org/writing/style/) code style following the [Zen of Python](https://www.python.org/dev/peps/pep-0020/) was used. [Foolish consistency](https://pep8.org) was avoided.

### Python virtual environment tools
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Brendon Smith ([br3ndonland](https://github.com/br3ndonland/))
- [Logging](#logging)
- [Development](#development)
- [Code style](#code-style)
- [Testing with pytest](#testing-with-pytest)
- [GitHub Actions workflows](#github-actions-workflows)
- [Building development images](#building-development-images)
- [Running development containers](#running-development-containers)
- [Configuring Docker for GitHub Container Registry](#configuring-docker-for-github-container-registry)
Expand Down Expand Up @@ -338,7 +340,7 @@ For more information on Python logging configuration, see the [Python `logging`
### Code style

- Python code is formatted with [Black](https://black.readthedocs.io/en/stable/). Configuration for Black is stored in _[pyproject.toml](pyproject.toml)_.
- Python imports are organized automatically with [isort](https://timothycrosley.github.io/isort/).
- Python imports are organized automatically with [isort](https://pycqa.github.io/isort/).
- The isort package organizes imports in three sections:
1. Standard library
2. Dependencies
Expand All @@ -347,6 +349,48 @@ For more information on Python logging configuration, see the [Python `logging`
- You can run isort from the command line with `poetry run isort .`.
- Configuration for isort is stored in _[pyproject.toml](pyproject.toml)_.
- Other web code (JSON, Markdown, YAML) is formatted with [Prettier](https://prettier.io/).
- Code style is enforced with [pre-commit](https://pre-commit.com/), which runs [Git hooks](https://www.git-scm.com/book/en/v2/Customizing-Git-Git-Hooks).

- Configuration is stored in _[.pre-commit-config.yaml](.pre-commit-config.yaml)_.
- Pre-commit can run locally before each commit (hence "pre-commit"), or on different Git events like `pre-push`.
- Pre-commit is installed in the Poetry environment. To use:

```sh
# after running `poetry install`
path/to/inboard
❯ poetry shell

# install hooks that run before each commit
path/to/inboard
.venv ❯ pre-commit install

# and/or install hooks that run before each push
path/to/inboard
.venv ❯ pre-commit install --hook-type pre-push
```

- Pre-commit is also useful as a CI tool. The [hooks](.github/workflows/hooks.yml) GitHub Actions workflow runs pre-commit hooks with [GitHub Actions](https://github.com/features/actions).

### Testing with pytest

- Tests are in the _tests/_ directory.
- Run tests by [invoking `pytest` from the command-line](https://docs.pytest.org/en/stable/usage.html) in the root directory of the repo.
- [pytest](https://docs.pytest.org/en/latest/) features used include:
- [fixtures](https://docs.pytest.org/en/latest/fixture.html)
- [monkeypatch](https://docs.pytest.org/en/latest/monkeypatch.html)
- [parametrize](https://docs.pytest.org/en/latest/parametrize.html)
- [`tmp_path`](https://docs.pytest.org/en/latest/tmpdir.html)
- [pytest plugins](https://docs.pytest.org/en/stable/plugins.html) include:
- [pytest-cov](https://github.com/pytest-dev/pytest-cov)
- [pytest-mock](https://github.com/pytest-dev/pytest-mock)
- [pytest configuration](https://docs.pytest.org/en/stable/customize.html) is in _[pyproject.toml](pyproject.toml)_.
- [FastAPI testing](https://fastapi.tiangolo.com/tutorial/testing/) and [Starlette testing](https://www.starlette.io/testclient/) rely on the [Starlette `TestClient`](https://www.starlette.io/testclient/), which uses [Requests](https://requests.readthedocs.io/en/master/) under the hood.
- Test coverage results are reported when invoking `pytest` from the command-line. To see interactive HTML coverage reports, invoke pytest with `pytest --cov-report=html`.
- Test coverage reports are generated within GitHub Actions workflows by [pytest-cov](https://github.com/pytest-dev/pytest-cov) with [coverage.py](https://github.com/nedbat/coveragepy), and uploaded to [Codecov](https://docs.codecov.io/docs) using [codecov/codecov-action](https://github.com/marketplace/actions/codecov). Codecov is then integrated into pull requests with the [Codecov GitHub app](https://github.com/marketplace/codecov).

### GitHub Actions workflows

[GitHub Actions](https://github.com/features/actions) is a continuous integration/continuous deployment (CI/CD) service that runs on GitHub repos. It replaces other services like Travis CI. Actions are grouped into workflows and stored in _.github/workflows_. See my [GitHub Actions Gist](https://gist.github.com/br3ndonland/f9c753eb27381f97336aa21b8d932be6) for more info on GitHub Actions.

### Building development images

Expand Down
4 changes: 2 additions & 2 deletions inboard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from importlib.metadata import version


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

Expand Down
7 changes: 5 additions & 2 deletions inboard/app/base/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ async def __call__(
}
)
version = f"{sys.version_info.major}.{sys.version_info.minor}"
server = "Uvicorn" if bool(os.getenv("WITH_RELOAD")) else "Uvicorn, Gunicorn,"
process_manager = os.getenv("PROCESS_MANAGER")
if process_manager not in ["gunicorn", "uvicorn"]:
raise NameError("Process manager needs to be either uvicorn or gunicorn.")
server = "Uvicorn" if process_manager == "uvicorn" else "Uvicorn, Gunicorn,"
message = f"Hello World, from {server} and Python {version}!"
response: Dict = {"type": "http.response.body", "body": message.encode("utf-8")}
await send(response)
return response


app = App
app: Callable = App
25 changes: 12 additions & 13 deletions inboard/app/utilities.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import base64
import binascii
import os
from secrets import compare_digest
from typing import Tuple, Union
Expand Down Expand Up @@ -27,18 +26,18 @@ async def authenticate(
scheme, credentials = auth.split()
decoded = base64.b64decode(credentials).decode("ascii")
username, _, password = decoded.partition(":")
except (ValueError, UnicodeDecodeError, binascii.Error):
raise AuthenticationError("Unable to parse basic auth credentials")
correct_username = compare_digest(
username, str(os.getenv("BASIC_AUTH_USERNAME", "test_username"))
)
correct_password = compare_digest(
password,
str(os.getenv("BASIC_AUTH_PASSWORD", "plunge-germane-tribal-pillar")),
)
if not (correct_username and correct_password):
raise AuthenticationError("Invalid basic auth credentials")
return AuthCredentials(["authenticated"]), SimpleUser(username)
correct_username = compare_digest(
username, str(os.getenv("BASIC_AUTH_USERNAME", "test_username"))
)
correct_password = compare_digest(
password,
str(os.getenv("BASIC_AUTH_PASSWORD", "plunge-germane-tribal-pillar")),
)
if not (correct_username and correct_password):
raise AuthenticationError("Invalid basic auth credentials")
return AuthCredentials(["authenticated"]), SimpleUser(username)
except Exception:
raise


def basic_auth(credentials: HTTPBasicCredentials = Depends(HTTPBasic())) -> str:
Expand Down
43 changes: 8 additions & 35 deletions inboard/gunicorn_conf.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import json
import multiprocessing
import os
from pathlib import Path

from inboard.start import configure_logging

workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
# Gunicorn setup
max_workers_str = os.getenv("MAX_WORKERS")
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
use_max_workers = None
if max_workers_str:
if max_workers_str and int(max_workers_str) > 0:
use_max_workers = int(max_workers_str)
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)

host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "80")
bind_env = os.getenv("BIND", None)
Expand All @@ -20,9 +18,8 @@
cores = multiprocessing.cpu_count()
workers_per_core = float(workers_per_core_str)
default_web_concurrency = workers_per_core * cores
if web_concurrency_str:
if web_concurrency_str and int(web_concurrency_str) > 0:
web_concurrency = int(web_concurrency_str)
assert web_concurrency > 0
else:
web_concurrency = max(int(default_web_concurrency), 2)
if use_max_workers:
Expand All @@ -36,14 +33,9 @@
keepalive_str = os.getenv("KEEP_ALIVE", "5")

# Gunicorn config variables
try:
logconfig_dict = configure_logging(
logging_conf=os.getenv("LOGGING_CONF", "inboard.logging_conf")
)
except Exception as e:
if use_loglevel == "debug":
msg = "Error loading logging config with Gunicorn:"
print(f"[{Path(__file__).stem}] {msg} {e}")
logconfig_dict = configure_logging(
logging_conf=os.getenv("LOGGING_CONF", "inboard.logging_conf")
)
loglevel = use_loglevel
workers = web_concurrency
bind = use_bind
Expand All @@ -53,22 +45,3 @@
graceful_timeout = int(graceful_timeout_str)
timeout = int(timeout_str)
keepalive = int(keepalive_str)

log_data = {
# General
"host": host,
"port": port,
"use_max_workers": use_max_workers,
"workers_per_core": workers_per_core,
# Gunicorn
"loglevel": loglevel,
"workers": workers,
"bind": bind,
"graceful_timeout": graceful_timeout,
"timeout": timeout,
"keepalive": keepalive,
"errorlog": errorlog,
"accesslog": accesslog,
}
if loglevel == "debug":
print(f"[{Path(__file__).stem}] Custom configuration:", json.dumps(log_data))
44 changes: 17 additions & 27 deletions inboard/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ def set_conf_path(module_stem: str) -> str:
)
if conf_var and Path(conf_var).is_file():
conf_path = conf_var
elif Path(f"/app/inboard/{module_stem}_conf.py").is_file():
conf_path = f"/app/inboard/{module_stem}_conf.py"
else:
raise FileNotFoundError(f"Unable to find {conf_var}")
return conf_path
Expand All @@ -40,8 +38,6 @@ def configure_logging(
if spec:
logging_conf_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(logging_conf_module) # type: ignore[union-attr]
else:
raise ImportError(f"Unable to import {logging_conf}.")
if hasattr(logging_conf_module, "LOGGING_CONFIG"):
logging_conf_dict = getattr(logging_conf_module, "LOGGING_CONFIG")
else:
Expand Down Expand Up @@ -74,22 +70,18 @@ def set_app_module(logger: Logger = logging.getLogger()) -> str:

def run_pre_start_script(logger: Logger = logging.getLogger()) -> str:
"""Run a pre-start script at the provided path."""
try:
logger.debug("Checking for pre-start script.")
pre_start_path = os.getenv("PRE_START_PATH", "/app/inboard/app/prestart.py")
if Path(pre_start_path).is_file():
process = "python" if Path(pre_start_path).suffix == ".py" else "sh"
run_message = f"Running pre-start script with {process} {pre_start_path}."
logger.debug(run_message)
subprocess.run([process, pre_start_path])
message = f"Ran pre-start script with {process} {pre_start_path}."
else:
message = "No pre-start script found."
except Exception as e:
message = f"Error from pre-start script: {e}."
finally:
logger.debug(message)
return message
logger.debug("Checking for pre-start script.")
pre_start_path = os.getenv("PRE_START_PATH", "/app/inboard/app/prestart.py")
if Path(pre_start_path).is_file():
process = "python" if Path(pre_start_path).suffix == ".py" else "sh"
run_message = f"Running pre-start script with {process} {pre_start_path}."
logger.debug(run_message)
subprocess.run([process, pre_start_path])
message = f"Ran pre-start script with {process} {pre_start_path}."
else:
message = "No pre-start script found."
logger.debug(message)
return message


def start_server(
Expand Down Expand Up @@ -126,13 +118,11 @@ def start_server(


if __name__ == "__main__":
logger = logging.getLogger()
logging_conf_dict = configure_logging(logger=logger)
app_module = set_app_module(logger=logger)
run_pre_start_script(logger=logger)
start_server(
logger = logging.getLogger() # pragma: no cover
run_pre_start_script(logger=logger) # pragma: no cover
start_server( # pragma: no cover
str(os.getenv("PROCESS_MANAGER", "gunicorn")),
app_module=app_module,
app_module=set_app_module(logger=logger),
logger=logger,
logging_conf_dict=logging_conf_dict,
logging_conf_dict=configure_logging(logger=logger),
)
52 changes: 52 additions & 0 deletions tests/app/test_main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import os
import re
from typing import Dict, List

import pytest
from _pytest.monkeypatch import MonkeyPatch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.applications import Starlette
Expand Down Expand Up @@ -76,6 +78,42 @@ class TestEndpoints:
- https://docs.pytest.org/en/latest/parametrize.html
"""

def test_get_asgi_uvicorn(
self, client_asgi: TestClient, monkeypatch: MonkeyPatch
) -> None:
"""Test `GET` request to base ASGI app set for Uvicorn without Gunicorn."""
monkeypatch.setenv("PROCESS_MANAGER", "uvicorn")
monkeypatch.setenv("WITH_RELOAD", "false")
assert os.getenv("PROCESS_MANAGER") == "uvicorn"
assert os.getenv("WITH_RELOAD") == "false"
response = client_asgi.get("/")
assert response.status_code == 200
assert response.text == "Hello World, from Uvicorn and Python 3.8!"

def test_get_asgi_uvicorn_gunicorn(
self, client_asgi: TestClient, monkeypatch: MonkeyPatch
) -> None:
"""Test `GET` request to base ASGI app set for Uvicorn with Gunicorn."""
monkeypatch.setenv("PROCESS_MANAGER", "gunicorn")
monkeypatch.setenv("WITH_RELOAD", "false")
assert os.getenv("PROCESS_MANAGER") == "gunicorn"
assert os.getenv("WITH_RELOAD") == "false"
response = client_asgi.get("/")
assert response.status_code == 200
assert response.text == "Hello World, from Uvicorn, Gunicorn, and Python 3.8!"

def test_get_asgi_incorrect_process_manager(
self, client_asgi: TestClient, monkeypatch: MonkeyPatch
) -> None:
"""Test `GET` request to base ASGI app with incorrect `PROCESS_MANAGER`."""
monkeypatch.setenv("PROCESS_MANAGER", "incorrect")
monkeypatch.setenv("WITH_RELOAD", "false")
assert os.getenv("PROCESS_MANAGER") == "incorrect"
assert os.getenv("WITH_RELOAD") == "false"
with pytest.raises(NameError) as e:
client_asgi.get("/")
assert str(e) == "Process manager needs to be either uvicorn or gunicorn."

def test_get_root(self, clients: List[TestClient]) -> None:
"""Test a `GET` request to the root endpoint."""
for client in clients:
Expand Down Expand Up @@ -115,6 +153,20 @@ def test_gets_with_basic_auth_incorrect(
response = client.get(endpoint, auth=basic_auth)
assert response.status_code == 200

@pytest.mark.parametrize("endpoint", ["/health", "/status"])
def test_gets_with_starlette_auth_exception(
self, clients: List[TestClient], endpoint: str
) -> None:
"""Test Starlette `GET` requests with incorrect Basic Auth credentials."""
starlette_client = clients[1]
assert isinstance(starlette_client.app, Starlette)
response = starlette_client.get(endpoint, auth=("user", "pass"))
assert response.status_code in [401, 403]
assert response.json() == {
"detail": "Incorrect username or password",
"error": "Invalid basic auth credentials",
}

def test_get_status_message(
self,
basic_auth: tuple,
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from inboard import gunicorn_conf as gunicorn_conf_module
from inboard import logging_conf as logging_conf_module
from inboard.app import prestart as pre_start_module
from inboard.app.base.main import app as base_app
from inboard.app.fastapibase.main import app as fastapi_app
from inboard.app.starlettebase.main import app as starlette_app

Expand All @@ -39,6 +40,12 @@ def basic_auth(
return username, password


@pytest.fixture(scope="session")
def client_asgi() -> TestClient:
"""Instantiate test client classes."""
return TestClient(base_app)


@pytest.fixture(scope="session")
def clients() -> List[TestClient]:
"""Instantiate test client classes."""
Expand Down
Loading