Skip to content

Commit

Permalink
Merge pull request #31 from br3ndonland/conf-testing
Browse files Browse the repository at this point in the history
Refactor configuration files and tests
  • Loading branch information
br3ndonland authored Apr 18, 2021
2 parents 35d6772 + 40baca4 commit c9dd974
Show file tree
Hide file tree
Showing 8 changed files with 422 additions and 320 deletions.
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,10 @@ ENV APP_MODULE="package.custom.module:api" WORKERS_PER_CORE="2"
- Custom:
- `GUNICORN_CONF="/app/package/custom_gunicorn_conf.py"`
- [Gunicorn worker processes](https://docs.gunicorn.org/en/latest/settings.html#worker-processes): The number of Gunicorn worker processes to run is determined based on the `MAX_WORKERS`, `WEB_CONCURRENCY`, and `WORKERS_PER_CORE` environment variables, with a default of 1 worker per CPU core and a default minimum of 2. This is the "performance auto-tuning" feature described in [tiangolo/uvicorn-gunicorn-docker](https://github.com/tiangolo/uvicorn-gunicorn-docker).
- `MAX_WORKERS`: Maximum number of workers to use, independent of number of CPU cores.
- `MAX_WORKERS`: Maximum number of workers, independent of number of CPU cores.
- Default: not set (unlimited)
- Custom: `MAX_WORKERS="24"`
- `WEB_CONCURRENCY`: Set number of workers independently of number of CPU cores.
- `WEB_CONCURRENCY`: Total number of workers, independent of number of CPU cores.
- Default: not set
- Custom: `WEB_CONCURRENCY="4"`
- `WORKERS_PER_CORE`: Number of Gunicorn workers per CPU core. Overridden if `WEB_CONCURRENCY` is set.
Expand All @@ -272,7 +272,7 @@ ENV APP_MODULE="package.custom.module:api" WORKERS_PER_CORE="2"
- `WORKERS_PER_CORE="2"`: Run 2 worker processes per core (8 worker processes on a server with 4 cores).
- `WORKERS_PER_CORE="0.5"` (floating point values permitted): Run 1 worker process for every 2 cores (2 worker processes on a server with 4 cores).
- Notes:
- The default number of workers is the number of CPU cores multiplied by the environment variable `WORKERS_PER_CORE="1"`. On a machine with only 1 CPU core, the default minimum number of workers is 2 to avoid poor performance and blocking, as explained in the release notes for [tiangolo/uvicorn-gunicorn-docker 0.3.0](https://github.com/tiangolo/uvicorn-gunicorn-docker/releases/tag/0.3.0).
- The default number of workers is the number of CPU cores multiplied by the value of the environment variable `WORKERS_PER_CORE` (which defaults to 1). On a machine with only 1 CPU core, the default minimum number of workers is 2 to avoid poor performance and blocking, as explained in the release notes for [tiangolo/uvicorn-gunicorn-docker 0.3.0](https://github.com/tiangolo/uvicorn-gunicorn-docker/releases/tag/0.3.0).
- If both `MAX_WORKERS` and `WEB_CONCURRENCY` are set, the least of the two will be used as the total number of workers.
- If either `MAX_WORKERS` or `WEB_CONCURRENCY` are set to 1, the total number of workers will be 1, overriding the default minimum of 2.
- `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."
Expand Down Expand Up @@ -452,17 +452,18 @@ See _[CONTRIBUTING.md](./.github/CONTRIBUTING.md)_ for general information on ho
### 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) within the Poetry environment in the root directory of the repo.
- Run tests by [invoking `pytest` from the command-line](https://docs.pytest.org/en/latest/how-to/usage.html) within the Poetry environment 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:
- [capturing `stdout` with `capfd`](https://docs.pytest.org/en/latest/how-to/capture-stdout-stderr.html)
- [fixtures](https://docs.pytest.org/en/latest/how-to/fixtures.html)
- [monkeypatch](https://docs.pytest.org/en/latest/how-to/monkeypatch.html)
- [parametrize](https://docs.pytest.org/en/latest/how-to/parametrize.html)
- [temporary directories and files (`tmp_path` and `tmp_dir`)](https://docs.pytest.org/en/latest/how-to/tmpdir.html)
- [pytest plugins](https://docs.pytest.org/en/latest/how-to/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](https://github.com/br3ndonland/inboard/blob/develop/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.
- [pytest configuration](https://docs.pytest.org/en/latest/reference/customize.html) is in _[pyproject.toml](https://github.com/br3ndonland/inboard/blob/develop/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/).
- 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).

Expand Down
68 changes: 23 additions & 45 deletions inboard/gunicorn_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,35 @@
import os
from typing import Optional

from inboard.start import configure_logging
from inboard.logging_conf import configure_logging


def calculate_workers(
max_workers_str: Optional[str],
web_concurrency_str: Optional[str],
workers_per_core_str: str,
cores: int = multiprocessing.cpu_count(),
max_workers: Optional[str] = None,
total_workers: Optional[str] = None,
workers_per_core: str = "1",
) -> int:
"""Calculate the number of Gunicorn worker processes."""
use_default_workers = max(int(float(workers_per_core_str) * cores), 2)
if max_workers_str and int(max_workers_str) > 0:
use_max_workers = int(max_workers_str)
if web_concurrency_str and int(web_concurrency_str) > 0:
use_web_concurrency = int(web_concurrency_str)
return (
min(use_max_workers, use_web_concurrency)
if max_workers_str and web_concurrency_str
else use_web_concurrency
if web_concurrency_str
else use_default_workers
)
cores = multiprocessing.cpu_count()
use_default = max(int(float(workers_per_core) * cores), 2)
use_max = m if max_workers and (m := int(max_workers)) > 0 else False
use_total = t if total_workers and (t := int(total_workers)) > 0 else False
use_least = min(use_max, use_total) if use_max and use_total else False
return use_least or use_max or use_total or use_default


# Gunicorn setup
max_workers_str = os.getenv("MAX_WORKERS")
web_concurrency_str = os.getenv("WEB_CONCURRENCY")
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
workers = calculate_workers(max_workers_str, web_concurrency_str, workers_per_core_str)
# Gunicorn settings
bind = os.getenv("BIND") or f'{os.getenv("HOST", "0.0.0.0")}:{os.getenv("PORT", "80")}'
accesslog = os.getenv("ACCESS_LOG", "-")
errorlog = os.getenv("ERROR_LOG", "-")
graceful_timeout = int(os.getenv("GRACEFUL_TIMEOUT", "120"))
keepalive = int(os.getenv("KEEP_ALIVE", "5"))
logconfig_dict = configure_logging()
loglevel = os.getenv("LOG_LEVEL", "info")
timeout = int(os.getenv("TIMEOUT", "120"))
worker_tmp_dir = "/dev/shm"
host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "80")
bind_env = os.getenv("BIND")
use_bind = bind_env or f"{host}:{port}"
use_loglevel = os.getenv("LOG_LEVEL", "info")
accesslog_var = os.getenv("ACCESS_LOG", "-")
use_accesslog = accesslog_var or None
errorlog_var = os.getenv("ERROR_LOG", "-")
use_errorlog = errorlog_var or None
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
timeout_str = os.getenv("TIMEOUT", "120")
keepalive_str = os.getenv("KEEP_ALIVE", "5")

# Gunicorn config variables
logconfig_dict = configure_logging(
logging_conf=os.getenv("LOGGING_CONF", "inboard.logging_conf")
workers = calculate_workers(
os.getenv("MAX_WORKERS"),
os.getenv("WEB_CONCURRENCY"),
workers_per_core=os.getenv("WORKERS_PER_CORE", "1"),
)
loglevel = use_loglevel
bind = use_bind
errorlog = use_errorlog
accesslog = use_accesslog
graceful_timeout = int(graceful_timeout_str)
timeout = int(timeout_str)
keepalive = int(keepalive_str)
47 changes: 47 additions & 0 deletions inboard/logging_conf.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
import importlib.util
import logging
import logging.config
import os
import sys
from pathlib import Path
from typing import Optional


def find_and_load_logging_conf(logging_conf: str) -> dict:
"""Find and load a logging configuration module or file."""
logging_conf_path = Path(logging_conf)
spec = (
importlib.util.spec_from_file_location("confspec", logging_conf_path)
if logging_conf_path.is_file() and logging_conf_path.suffix == ".py"
else importlib.util.find_spec(logging_conf)
)
if not spec:
raise ImportError(f"Unable to import {logging_conf_path}")
logging_conf_module = importlib.util.module_from_spec(spec)
exec_module = getattr(spec.loader, "exec_module")
exec_module(logging_conf_module)
if not hasattr(logging_conf_module, "LOGGING_CONFIG"):
raise AttributeError(f"No LOGGING_CONFIG in {logging_conf_module.__name__}")
logging_conf_dict = getattr(logging_conf_module, "LOGGING_CONFIG")
if not isinstance(logging_conf_dict, dict):
raise TypeError("LOGGING_CONFIG is not a dictionary instance")
return logging_conf_dict


def configure_logging(
logger: logging.Logger = logging.getLogger(),
logging_conf: Optional[str] = os.getenv("LOGGING_CONF"),
) -> dict:
"""Configure Python logging given the name of a logging module or file."""
try:
if not logging_conf:
logging_conf_path = __name__
logging_conf_dict = LOGGING_CONFIG
else:
logging_conf_path = logging_conf
logging_conf_dict = find_and_load_logging_conf(logging_conf_path)
logging.config.dictConfig(logging_conf_dict)
logger.debug(f"Logging dict config loaded from {logging_conf_path}.")
return logging_conf_dict
except Exception as e:
logger.error(f"Error when setting logging module: {e.__class__.__name__} {e}.")
raise


LOG_COLORS = (
True
Expand Down
58 changes: 16 additions & 42 deletions inboard/start.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,14 @@
#!/usr/bin/env python3
import importlib.util
import logging
import logging.config
import os
import subprocess
from pathlib import Path
from typing import Optional

import uvicorn # type: ignore


def configure_logging(
logger: logging.Logger = logging.getLogger(),
logging_conf: str = os.getenv("LOGGING_CONF", "inboard.logging_conf"),
) -> dict:
"""Configure Python logging based on a path to a logging module or file."""
try:
logging_conf_path = Path(logging_conf)
spec = (
importlib.util.spec_from_file_location("confspec", logging_conf_path)
if logging_conf_path.is_file() and logging_conf_path.suffix == ".py"
else importlib.util.find_spec(logging_conf)
)
if not spec:
raise ImportError(f"Unable to import {logging_conf}")
logging_conf_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(logging_conf_module) # type: ignore[union-attr]
if not hasattr(logging_conf_module, "LOGGING_CONFIG"):
raise AttributeError(f"No LOGGING_CONFIG in {logging_conf_module.__name__}")
logging_conf_dict = getattr(logging_conf_module, "LOGGING_CONFIG")
if not isinstance(logging_conf_dict, dict):
raise TypeError("LOGGING_CONFIG is not a dictionary instance")
logging.config.dictConfig(logging_conf_dict)
logger.debug(f"Logging dict config loaded from {logging_conf_path}.")
return logging_conf_dict
except Exception as e:
logger.error(f"Error when setting logging module: {e.__class__.__name__} {e}.")
raise
from inboard.logging_conf import configure_logging


def run_pre_start_script(logger: logging.Logger = logging.getLogger()) -> str:
Expand Down Expand Up @@ -68,34 +40,37 @@ def set_app_module(logger: logging.Logger = logging.getLogger()) -> str:
raise


def set_gunicorn_options() -> list:
def set_gunicorn_options(app_module: str) -> list:
"""Set options for running the Gunicorn server."""
gunicorn_conf_path = os.getenv("GUNICORN_CONF", "/app/inboard/gunicorn_conf.py")
worker_class = os.getenv("WORKER_CLASS", "uvicorn.workers.UvicornWorker")
if not Path(gunicorn_conf_path).is_file():
raise FileNotFoundError(f"Unable to find {gunicorn_conf_path}")
return ["gunicorn", "-k", worker_class, "-c", gunicorn_conf_path]
return ["gunicorn", "-k", worker_class, "-c", gunicorn_conf_path, app_module]


def set_uvicorn_options(log_config: Optional[dict] = None) -> dict:
"""Set options for running the Uvicorn server."""
with_reload = (
True
if (value := os.getenv("WITH_RELOAD")) and value.lower() == "true"
else False
)
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "80"))
log_level = os.getenv("LOG_LEVEL", "info")
reload_dirs = (
[d.lstrip() for d in str(os.getenv("RELOAD_DIRS")).split(sep=",")]
if os.getenv("RELOAD_DIRS")
else None
)
use_reload = (
True
if (value := os.getenv("WITH_RELOAD")) and value.lower() == "true"
else False
)
return dict(
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "80")),
host=host,
port=port,
log_config=log_config,
log_level=os.getenv("LOG_LEVEL", "info"),
reload=with_reload,
log_level=log_level,
reload_dirs=reload_dirs,
reload=use_reload,
)


Expand All @@ -109,8 +84,7 @@ def start_server(
try:
if process_manager == "gunicorn":
logger.debug("Running Uvicorn with Gunicorn.")
gunicorn_options: list = set_gunicorn_options()
gunicorn_options.append(app_module)
gunicorn_options: list = set_gunicorn_options(app_module)
subprocess.run(gunicorn_options)
elif process_manager == "uvicorn":
logger.debug("Running Uvicorn without Gunicorn.")
Expand Down
2 changes: 1 addition & 1 deletion tests/app/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class TestEndpoints:
---
See the [FastAPI testing docs](https://fastapi.tiangolo.com/tutorial/testing/),
[Starlette TestClient docs](https://www.starlette.io/testclient/), and the
[pytest docs on parametrize](https://docs.pytest.org/en/latest/parametrize.html).
[pytest docs](https://docs.pytest.org/en/latest/how-to/parametrize.html).
"""

def test_get_asgi_uvicorn(
Expand Down
Loading

0 comments on commit c9dd974

Please sign in to comment.