├── .dockerignore ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── .prettierignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── cspell.json ├── docs ├── .prettierrc ├── assets │ └── images │ │ ├── favicon.png │ │ └── inboard-logo.svg ├── authentication.md ├── changelog.md ├── contributing.md ├── docker.md ├── environment.md ├── index.md └── logging.md ├── inboard ├── __init__.py ├── app │ ├── __init__.py │ ├── main_base.py │ ├── main_fastapi.py │ ├── main_starlette.py │ ├── prestart.py │ ├── utilities_fastapi.py │ └── utilities_starlette.py ├── gunicorn_conf.py ├── gunicorn_workers.py ├── logging_conf.py ├── py.typed ├── start.py └── types.py ├── mkdocs.yml ├── pyproject.toml ├── tests ├── __init__.py ├── app │ ├── __init__.py │ └── test_main.py ├── conftest.py ├── test_gunicorn_conf.py ├── test_gunicorn_workers.py ├── test_logging_conf.py ├── test_start.py └── test_types.py └── vercel.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*cache* 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at bws@bws.bio. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 125 | at [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 129 | [mozilla coc]: https://github.com/mozilla/diversity 130 | [faq]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Guidelines for contributing 2 | 3 | See the [documentation](https://inboard.bws.bio/contributing) for contribution guidelines. 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | This repository needs 4 | 5 | The commits in this pull request will 6 | 7 | ## Changes 8 | 9 | - Imperative commit or change title (commit ID) 10 | - Add some bullet points to explain the change 11 | - Next commit or change (commit ID) 12 | 13 | ## Related 14 | 15 | Relates to organization/repo#number 16 | 17 | 18 | 19 | Closes organization/repo#number 20 | 21 | 22 | 23 | - [x] I have reviewed the [Guidelines for Contributing](https://github.com/br3ndonland/inboard/blob/develop/.github/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/br3ndonland/inboard/blob/develop/.github/CODE_OF_CONDUCT.md). 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: codeql 2 | 3 | on: 4 | push: 5 | branches: [develop, main] 6 | pull_request: 7 | branches: [develop, main] 8 | schedule: 9 | - cron: "0 13 * * 1" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | analyze: 14 | permissions: 15 | actions: read 16 | contents: read 17 | security-events: write 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.12" 24 | - uses: github/codeql-action/init@v3 25 | with: 26 | languages: python 27 | setup-python-dependencies: false 28 | - uses: github/codeql-action/analyze@v3 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,python,virtualenv,visualstudiocode 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,python,virtualenv,visualstudiocode 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | .com.apple.timemachine.donotpresent 24 | 25 | # Directories potentially created on remote AFP share 26 | .AppleDB 27 | .AppleDesktop 28 | Network Trash Folder 29 | Temporary Items 30 | .apdisk 31 | 32 | ### Python ### 33 | # Byte-compiled / optimized / DLL files 34 | __pycache__/ 35 | *.py[cod] 36 | *$py.class 37 | 38 | # C extensions 39 | *.so 40 | 41 | # Distribution / packaging 42 | .Python 43 | build/ 44 | develop-eggs/ 45 | dist/ 46 | downloads/ 47 | eggs/ 48 | .eggs/ 49 | lib/ 50 | lib64/ 51 | parts/ 52 | sdist/ 53 | var/ 54 | wheels/ 55 | pip-wheel-metadata/ 56 | share/python-wheels/ 57 | *.egg-info/ 58 | .installed.cfg 59 | *.egg 60 | MANIFEST 61 | 62 | # PyInstaller 63 | # Usually these files are written by a python script from a template 64 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 65 | *.manifest 66 | *.spec 67 | 68 | # Installer logs 69 | pip-log.txt 70 | pip-delete-this-directory.txt 71 | 72 | # Unit test / coverage reports 73 | htmlcov/ 74 | .tox/ 75 | .nox/ 76 | .coverage 77 | .coverage.* 78 | .cache 79 | nosetests.xml 80 | coverage.xml 81 | *.cover 82 | *.py,cover 83 | .hypothesis/ 84 | .pytest_cache/ 85 | 86 | # Translations 87 | *.mo 88 | *.pot 89 | 90 | # Django stuff: 91 | *.log 92 | local_settings.py 93 | db.sqlite3 94 | db.sqlite3-journal 95 | 96 | # Flask stuff: 97 | instance/ 98 | .webassets-cache 99 | 100 | # Scrapy stuff: 101 | .scrapy 102 | 103 | # Sphinx documentation 104 | docs/_build/ 105 | 106 | # PyBuilder 107 | target/ 108 | 109 | # Jupyter Notebook 110 | .ipynb_checkpoints 111 | 112 | # IPython 113 | profile_default/ 114 | ipython_config.py 115 | 116 | # pyenv 117 | .python-version 118 | 119 | # pipenv 120 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 121 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 122 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 123 | # install all needed dependencies. 124 | #Pipfile.lock 125 | 126 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 127 | __pypackages__/ 128 | 129 | # Celery stuff 130 | celerybeat-schedule 131 | celerybeat.pid 132 | 133 | # SageMath parsed files 134 | *.sage.py 135 | 136 | # Environments 137 | .env 138 | .hatch* 139 | .venv* 140 | .virtualenv* 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /public 156 | /site 157 | 158 | # mypy 159 | .mypy_cache/ 160 | .dmypy.json 161 | dmypy.json 162 | 163 | # Pyre type checker 164 | .pyre/ 165 | 166 | # pytype static type analyzer 167 | .pytype/ 168 | 169 | ### VirtualEnv ### 170 | # Virtualenv 171 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 172 | [Bb]in 173 | [Ii]nclude 174 | [Ll]ib 175 | [Ll]ib64 176 | [Ll]ocal 177 | [Ss]cripts 178 | pyvenv.cfg 179 | pip-selfcheck.json 180 | 181 | ### VisualStudioCode ### 182 | .vscode/* 183 | !.vscode/tasks.json 184 | !.vscode/launch.json 185 | !.vscode/extensions.json 186 | *.code-workspace 187 | 188 | ### VisualStudioCode Patch ### 189 | # Ignore all local history of files 190 | .history 191 | 192 | # End of https://www.toptal.com/developers/gitignore/api/macos,python,virtualenv,visualstudiocode 193 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *cache* 2 | *venv* 3 | htmlcov 4 | public 5 | site 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | // inboard uvicorn debug config 5 | { 6 | "name": "inboard", 7 | "type": "python", 8 | "request": "launch", 9 | "stopOnEntry": false, 10 | "python": "${command:python.interpreterPath}", 11 | "module": "inboard.start", 12 | "env": { 13 | "APP_MODULE": "inboard.app.main_fastapi:app", 14 | "BASIC_AUTH_USERNAME": "test_user", 15 | "BASIC_AUTH_PASSWORD": "r4ndom_bUt_memorable", 16 | "LOG_FORMAT": "uvicorn", 17 | "LOG_LEVEL": "debug", 18 | "PORT": "8000", 19 | "PRE_START_PATH": "${workspaceRoot}/inboard/app/prestart.py", 20 | "PROCESS_MANAGER": "uvicorn", 21 | "RELOAD_DIRS": "inboard", 22 | "WITH_RELOAD": "true" 23 | } 24 | }, 25 | // FastAPI debugger config from VSCode Python extension 26 | { 27 | "name": "FastAPI", 28 | "type": "python", 29 | "request": "launch", 30 | "module": "uvicorn", 31 | "args": [ 32 | "inboard.app.main_fastapi:app", 33 | "--log-level", 34 | "debug", 35 | "--reload", 36 | "--reload-dir", 37 | "inboard" 38 | ], 39 | "env": { 40 | "BASIC_AUTH_USERNAME": "test_user", 41 | "BASIC_AUTH_PASSWORD": "r4ndom_bUt_memorable", 42 | "WITH_RELOAD": "true" 43 | }, 44 | "jinja": true 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | ARG PYTHON_VERSION=3.12 LINUX_VERSION= 3 | FROM python:${PYTHON_VERSION}${LINUX_VERSION:+-$LINUX_VERSION} AS builder 4 | LABEL org.opencontainers.image.authors="Brendon Smith " 5 | LABEL org.opencontainers.image.description="Docker images and utilities to power your Python APIs and help you ship faster." 6 | LABEL org.opencontainers.image.licenses="MIT" 7 | LABEL org.opencontainers.image.source="https://github.com/br3ndonland/inboard" 8 | LABEL org.opencontainers.image.title="inboard" 9 | LABEL org.opencontainers.image.url="https://github.com/br3ndonland/inboard/pkgs/container/inboard" 10 | ARG \ 11 | HATCH_VERSION=1.14.0 \ 12 | LINUX_VERSION \ 13 | PIPX_VERSION=1.7.1 14 | ENV \ 15 | HATCH_ENV_TYPE_VIRTUAL_PATH=.venv \ 16 | HATCH_VERSION=$HATCH_VERSION \ 17 | LINUX_VERSION=$LINUX_VERSION \ 18 | PATH=/opt/pipx/bin:/app/.venv/bin:$PATH \ 19 | PIPX_BIN_DIR=/opt/pipx/bin \ 20 | PIPX_HOME=/opt/pipx/home \ 21 | PIPX_VERSION=$PIPX_VERSION \ 22 | PYTHONPATH=/app 23 | COPY --link pyproject.toml README.md /app/ 24 | WORKDIR /app 25 | RUN < 4 | 5 | _Docker images and utilities to power your Python APIs and help you ship faster._ 6 | 7 | [![PyPI](https://img.shields.io/pypi/v/inboard?color=success)](https://pypi.org/project/inboard/) 8 | [![GitHub Container Registry](https://img.shields.io/badge/github%20container%20registry-inboard-success)](https://github.com/br3ndonland/inboard/pkgs/container/inboard) 9 | [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=pytest&logoColor=white)](https://coverage.readthedocs.io/en/latest/) 10 | [![ci](https://github.com/br3ndonland/inboard/workflows/ci/badge.svg)](https://github.com/br3ndonland/inboard/actions) 11 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/ef2798f758e03fa659d1ba2973ddd59515400978/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 12 | 13 | [![Mentioned in Awesome FastAPI](https://awesome.re/mentioned-badge-flat.svg)](https://github.com/mjhea0/awesome-fastapi) 14 | 15 | ## Description 16 | 17 | This repository provides [Docker images](https://github.com/br3ndonland/inboard/pkgs/container/inboard) and a [PyPI package](https://pypi.org/project/inboard/) with useful utilities for Python web servers. It runs [Uvicorn with Gunicorn](https://www.uvicorn.org/), and can be used to build applications with [Starlette](https://www.starlette.io/) and [FastAPI](https://fastapi.tiangolo.com/). 18 | 19 | ## Justification 20 | 21 | _Why use this project?_ You might want to try out inboard because it: 22 | 23 | - **Offers a Python package and Docker images that work together**. Python packages and Docker images don't automatically share the same versioning systems, but inboard can help with this. You might install the Python package with a minor version constraint. You can also pull the corresponding Docker image by specifying the minor version in the Docker tag (`FROM ghcr.io/br3ndonland/inboard:`). 24 | - **Tests everything**. inboard performs unit testing of 100% of the Python code, and also runs smoke tests of the Docker images each time they are built. 25 | - **Sets sane defaults, but allows configuration**. Configure a variety of settings with environment variables. Or run it as-is and it just works. 26 | - **Configures logging extensibly**. inboard simplifies logging by handling all its Python log streams with a single logging config. It also offers the ability to filter health check endpoints out of the access logs. Don't like it? No problem. You can easily extend or override the logging behavior. 27 | 28 | ## Quickstart 29 | 30 | [Get started with Docker](https://www.docker.com/get-started), pull and run an image, and try an API endpoint. 31 | 32 | ```sh 33 | docker pull ghcr.io/br3ndonland/inboard 34 | docker run -d -p 80:80 --platform linux/amd64 ghcr.io/br3ndonland/inboard 35 | http :80 # HTTPie: https://httpie.io/ 36 | ``` 37 | 38 | ## Documentation 39 | 40 | Documentation is built with [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), deployed on [Vercel](https://vercel.com/), and available at [inboard.bws.bio](https://inboard.bws.bio) and [inboard.vercel.app](https://inboard.vercel.app). 41 | 42 | [Vercel build configuration](https://vercel.com/docs/build-step): 43 | 44 | - Build command: `python3 -m pip install mkdocs-material && mkdocs build --site-dir public` 45 | - Output directory: `public` (default) 46 | 47 | [Vercel site configuration](https://vercel.com/docs/configuration) is specified in _vercel.json_. 48 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": ["CHANGELOG.md", "**/*changelog.md"], 3 | "ignoreRegExpList": ["/(`{3})[\\s\\S]+?\\1/g", "/`([\\s\\S]+?)`/g"], 4 | "languageSettings": [ 5 | { 6 | "languageId": "python", 7 | "includeRegExpList": ["/#.*/", "/('''|\"\"\")[^\\1]+?\\1/g"] 8 | }, 9 | { 10 | "languageId": "javascript", 11 | "includeRegExpList": ["CStyleComment"] 12 | }, 13 | { 14 | "languageId": "json", 15 | "includeRegExpList": ["CStyleComment"] 16 | }, 17 | { 18 | "languageId": "typescript", 19 | "includeRegExpList": ["CStyleComment"] 20 | } 21 | ], 22 | "useGitignore": true, 23 | "words": [ 24 | "anyio", 25 | "asgi", 26 | "asgiref", 27 | "asyncio", 28 | "autoformat", 29 | "autoformatter", 30 | "autoformatters", 31 | "autoformatting", 32 | "benoitc", 33 | "br3ndonland", 34 | "brendon", 35 | "ci", 36 | "cicd", 37 | "codespace", 38 | "codespaces", 39 | "codium", 40 | "containerd", 41 | "dependabot", 42 | "dockerfiles", 43 | "dotenv", 44 | "dotfiles", 45 | "extensibly", 46 | "fastapi's", 47 | "fastapi", 48 | "ghcr", 49 | "gunicorn", 50 | "heredoc", 51 | "heredocs", 52 | "hoppscotch", 53 | "httpx", 54 | "isort", 55 | "kwargs", 56 | "loguru", 57 | "macos", 58 | "mkdocs", 59 | "monkeypatch", 60 | "monkeypatches", 61 | "monkeypatching", 62 | "mypy", 63 | "ndonland", 64 | "noqa", 65 | "opencontainers", 66 | "pipx", 67 | "postinstallation", 68 | "prereleases", 69 | "prestart", 70 | "prettier", 71 | "prettierignore", 72 | "pydantic", 73 | "pypa", 74 | "pypi", 75 | "pypoetry", 76 | "pyproject", 77 | "pytest", 78 | "pythonpath", 79 | "quickstart", 80 | "redeclare", 81 | "redeclared", 82 | "redeclares", 83 | "redeclaring", 84 | "regexes", 85 | "repo", 86 | "repos", 87 | "roadmap", 88 | "rustup", 89 | "setuptools", 90 | "sha", 91 | "shas", 92 | "sourcery", 93 | "starlette's", 94 | "starlette", 95 | "tiangolo", 96 | "typeshed", 97 | "unittests", 98 | "uvicorn", 99 | "vendorizes", 100 | "venv", 101 | "virtualenv", 102 | "virtualenvs" 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4 3 | } 4 | -------------------------------------------------------------------------------- /docs/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/br3ndonland/inboard/f801c7931377ddbb47d5cb4f9692c9bdd5657db5/docs/assets/images/favicon.png -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | ## HTTP Basic auth 4 | 5 | ### Configuration 6 | 7 | The [HTTP authentication standard](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) includes HTTP Basic authentication, which, as the name implies, is just a basic method that accepts a username and password. As the [MDN documentation recommends](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme), HTTP Basic auth should always be used with TLS. 8 | 9 | inboard provides utilities for configuring HTTP Basic auth. 10 | 11 | For Starlette applications, inboard provides [middleware](https://www.starlette.io/middleware/) for HTTP Basic auth. Starlette middleware are applied to every request. 12 | 13 | !!! example "Example of HTTP Basic auth with Starlette middleware" 14 | 15 | ```py 16 | from inboard import StarletteBasicAuth 17 | from starlette.applications import Starlette 18 | from starlette.middleware.authentication import AuthenticationMiddleware 19 | 20 | app = Starlette() 21 | app.add_middleware(AuthenticationMiddleware, backend=StarletteBasicAuth()) 22 | ``` 23 | 24 | FastAPI is built on Starlette, so a FastAPI app can be configured with middleware as above, substituting `FastAPI()` for `Starlette()`. inboard also provides a [FastAPI dependency](https://fastapi.tiangolo.com/tutorial/dependencies/), which can be applied to specific API endpoints or [`APIRouter` objects](https://fastapi.tiangolo.com/tutorial/bigger-applications/). 25 | 26 | !!! example "Example of HTTP Basic auth with a FastAPI dependency" 27 | 28 | ```py 29 | from typing import Annotated, Optional 30 | 31 | from fastapi import Depends, FastAPI, status 32 | from pydantic import BaseModel 33 | 34 | from inboard import fastapi_basic_auth 35 | 36 | 37 | class GetHealth(BaseModel): 38 | application: str 39 | status: str 40 | message: Optional[str] 41 | 42 | 43 | BasicAuth = Annotated[str, Depends(fastapi_basic_auth)] 44 | app = FastAPI(title="Example FastAPI app") 45 | 46 | 47 | @app.get("/health", status_code=status.HTTP_200_OK) 48 | async def get_health(auth: BasicAuth) -> GetHealth: 49 | return GetHealth(application=app.title, status="active") 50 | ``` 51 | 52 | ### Usage 53 | 54 | As described in the [environment variable reference](environment.md) and [contribution guide](contributing.md), when starting the inboard server, the environment variables `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` can be set. The values of these variables can then be passed in with client requests to authenticate. 55 | 56 | Server: 57 | 58 | ```sh 59 | docker pull ghcr.io/br3ndonland/inboard 60 | docker run -d -p 80:80 --platform linux/amd64 \ 61 | -e "BASIC_AUTH_USERNAME=test_user" \ 62 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 63 | ghcr.io/br3ndonland/inboard 64 | ``` 65 | 66 | Client (using [HTTPie](https://httpie.io/)): 67 | 68 | ```sh 69 | http :80/health -a "test_user":"r4ndom_bUt_memorable" 70 | ``` 71 | 72 | HTTP clients, such as [Hoppscotch](https://hoppscotch.io/) (formerly known as Postwoman), [HTTPie](https://httpie.io/docs#authentication), [Insomnia](https://support.insomnia.rest/article/174-authentication), and [Postman](https://learning.postman.com/docs/sending-requests/authorization/) provide support for HTTP Basic auth. 73 | 74 | HTTP Basic auth can also be useful for load balancer health checks in deployed applications. In AWS, [load balancer health checks](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html) don't have HTTP Basic auth capabilities, so it is common to configure authentication bypasses for these checks. However, health checks can also be configured to expect a response of `401` instead of `200` for endpoints requiring authentication. Successful health checks therefore provide two pieces of information: the endpoint is up, and authentication is working. Conversely, if the health check endpoint returns `200`, this is an indication that basic auth is no longer working, and the service will be taken down immediately. 75 | 76 | ## Further info 77 | 78 | For more details on how HTTP Basic auth was implemented, see [br3ndonland/inboard#32](https://github.com/br3ndonland/inboard/pull/32). 79 | 80 | For more advanced security, consider [OAuth2](https://oauth.net/2/) with [JSON Web Tokens](https://jwt.io/) (JWT), as described in the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/). 81 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Summary 4 | 5 | **PRs welcome!** 6 | 7 | - **Consider starting a [discussion](https://github.com/br3ndonland/inboard/discussions) to see if there's interest in what you want to do.** 8 | - **Submit PRs from feature branches on forks to the `develop` branch.** 9 | - **Ensure PRs pass all CI checks.** 10 | - **Maintain test coverage at 100%.** 11 | 12 | ## Git 13 | 14 | - _[Why use Git?](https://www.git-scm.com/about)_ Git enables creation of multiple versions of a code repository called branches, with the ability to track and undo changes in detail. 15 | - Install Git by [downloading](https://www.git-scm.com/downloads) from the website, or with a package manager like [Homebrew](https://brew.sh/). 16 | - [Configure Git to connect to GitHub with SSH](https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh). 17 | - [Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) this repo. 18 | - Create a [branch](https://www.git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell) in your fork. 19 | - Commit your changes with a [properly-formatted Git commit message](https://chris.beams.io/posts/git-commit/). 20 | - Create a [pull request (PR)](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) to incorporate your changes into the upstream project you forked. 21 | 22 | ## Python 23 | 24 | ### Hatch 25 | 26 | This project uses [Hatch](https://hatch.pypa.io/latest/) for dependency management and packaging. 27 | 28 | #### Highlights 29 | 30 | - **Automatic virtual environment management**: [Hatch automatically manages the application environment](https://hatch.pypa.io/latest/environment/). 31 | - **Dependency resolution**: Hatch will automatically resolve any dependency version conflicts using the [`pip` dependency resolver](https://pip.pypa.io/en/stable/topics/dependency-resolution/). 32 | - **Dependency separation**: [Hatch supports separate lists of optional dependencies in the _pyproject.toml_](https://hatch.pypa.io/latest/config/dependency/). Production installs can skip optional dependencies for speed. 33 | - **Builds**: [Hatch has features for easily building the project into a Python package](https://hatch.pypa.io/latest/build/) and [publishing the package to PyPI](https://hatch.pypa.io/latest/publish/). 34 | 35 | #### Installation 36 | 37 | [Hatch can be installed with Homebrew or `pipx`](https://hatch.pypa.io/latest/install/). 38 | 39 | **Install project with all dependencies: `hatch env create`**. 40 | 41 | #### Key commands 42 | 43 | ```sh 44 | # Basic usage: https://hatch.pypa.io/latest/cli/reference/ 45 | hatch env create # create virtual environment and install dependencies 46 | hatch env find # show path to virtual environment 47 | hatch env show # show info about available virtual environments 48 | hatch run COMMAND # run a command within the virtual environment 49 | hatch shell # activate the virtual environment, like source venv/bin/activate 50 | hatch version # list or update version of this package 51 | export HATCH_ENV_TYPE_VIRTUAL_PATH=.venv # install virtualenvs into .venv 52 | ``` 53 | 54 | ### Running the development server 55 | 56 | The easiest way to get started is to run the development server locally with the [VSCode debugger](https://code.visualstudio.com/docs/python/debugging). The debugger config is stored in _[launch.json](https://github.com/br3ndonland/inboard/blob/HEAD/.vscode/launch.json)_. After installing the virtual environment as described above, start the debugger. Uvicorn enables hot-reloading and addition of debug breakpoints while the server is running. The Microsoft VSCode Python extension also offers a FastAPI debugger configuration, [added in version 2020.12.0](https://github.com/microsoft/vscode-python/blob/main/CHANGELOG.md#2020120-14-december-2020), which has been customized and included in _launch.json_. To use it, simply select the FastAPI config and start the debugger. 57 | 58 | As explained in the [VSCode docs](https://code.visualstudio.com/docs/containers/python-user-rights), if developing on Linux, note that non-root users may not be able to expose ports less than 1024. 59 | 60 | ### Testing with pytest 61 | 62 | - Tests are in the _tests/_ directory. 63 | - Run tests by [invoking `pytest` from the command-line](https://docs.pytest.org/en/latest/how-to/usage.html) within the virtual environment in the root directory of the repo. 64 | - [pytest](https://docs.pytest.org/en/latest/) features used include: 65 | - [capturing `stdout` with `capfd`](https://docs.pytest.org/en/latest/how-to/capture-stdout-stderr.html) 66 | - [fixtures](https://docs.pytest.org/en/latest/how-to/fixtures.html) 67 | - [monkeypatch](https://docs.pytest.org/en/latest/how-to/monkeypatch.html) 68 | - [parametrize](https://docs.pytest.org/en/latest/how-to/parametrize.html) 69 | - [temporary directories and files (`tmp_path` and `tmp_dir`)](https://docs.pytest.org/en/latest/how-to/tmpdir.html) 70 | - [pytest plugins](https://docs.pytest.org/en/latest/how-to/plugins.html) include: 71 | - [pytest-mock](https://github.com/pytest-dev/pytest-mock) 72 | - [pytest configuration](https://docs.pytest.org/en/latest/reference/customize.html) is in _[pyproject.toml](https://github.com/br3ndonland/inboard/blob/develop/pyproject.toml)_. 73 | - [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/). 74 | - Test coverage reports are generated by [coverage.py](https://github.com/nedbat/coveragepy). To generate test coverage reports, first run tests with `coverage run`, then generate a report with `coverage report`. To see interactive HTML coverage reports, run `coverage html` instead of `coverage report`. 75 | - Some of the tests start separate subprocesses. These tests are more complex in some ways, and can take longer, than the standard single-process tests. A [pytest mark](https://docs.pytest.org/en/stable/example/markers.html) is included to help control the behavior of subprocess tests. To run the test suite without subprocess tests, select tests with `coverage run -m pytest -m "not subprocess"`. Note that test coverage will be lower without the subprocess tests. 76 | 77 | ## Docker 78 | 79 | ### Docker basics 80 | 81 | - **[Docker](https://www.docker.com/)** is a technology for running lightweight virtual machines called **containers**. 82 | - An **image** is the executable set of files read by Docker. 83 | - A **container** is a running image. 84 | - The **[Dockerfile](https://docs.docker.com/engine/reference/builder/)** tells Docker how to build the container. 85 | - To [get started with Docker](https://www.docker.com/get-started): 86 | - Linux: follow the [Docker Engine installation instructions](https://docs.docker.com/engine/install/), making sure to follow the [postinstallation steps](https://docs.docker.com/engine/install/linux-postinstall/) to activate the Docker daemon. 87 | - macOS: install [Docker Desktop](https://www.docker.com/products/docker-desktop) (available [via Homebrew](https://formulae.brew.sh/cask/docker) with `brew install --cask docker`) or an alternative like [OrbStack](https://orbstack.dev/) (available [via Homebrew](https://formulae.brew.sh/cask/orbstack) with `brew install --cask orbstack`). 88 | - Windows: install [Docker Desktop](https://www.docker.com/products/docker-desktop). 89 | 90 |
Expand this details element for more useful Docker commands. 91 | 92 | ```sh 93 | # Log in with Docker Hub credentials to pull images 94 | docker login 95 | # List images 96 | docker images 97 | # List running containers: can also use `docker container ls` 98 | docker ps 99 | # View logs for the most recently started container 100 | docker logs -f $(docker ps -q -n 1) 101 | # View logs for all running containers 102 | docker logs -f $(docker ps -aq) 103 | # Inspect a container (web in this example) and return the IP Address 104 | docker inspect web | grep IPAddress 105 | # Stop a container 106 | docker stop # container hash 107 | # Stop all running containers 108 | docker stop $(docker ps -aq) 109 | # Remove a downloaded image 110 | docker image rm # image hash or name 111 | # Remove a container 112 | docker container rm # container hash 113 | # Prune images 114 | docker image prune 115 | # Prune stopped containers (completely wipes them and resets their state) 116 | docker container prune 117 | # Prune everything 118 | docker system prune 119 | # Open a shell in the most recently started container (like SSH) 120 | docker exec -it $(docker ps -q -n 1) /bin/bash 121 | # Or, connect as root: 122 | docker exec -u 0 -it $(docker ps -q -n 1) /bin/bash 123 | # Copy file to/from container: 124 | docker cp [container_name]:/path/to/file destination.file 125 | ``` 126 | 127 |
128 | 129 | ### Building development images 130 | 131 | Note that Docker builds use BuildKit. See the [BuildKit docs](https://github.com/moby/buildkit/blob/HEAD/frontend/dockerfile/docs/reference.md) and [Docker docs](https://docs.docker.com/build/buildkit/). 132 | 133 | To build the Docker images for each stage: 134 | 135 | ```sh 136 | git clone git@github.com:br3ndonland/inboard.git 137 | 138 | cd inboard 139 | 140 | export DOCKER_BUILDKIT=1 141 | 142 | docker build . --rm --target base -t localhost/br3ndonland/inboard:base && \ 143 | docker build . --rm --target fastapi -t localhost/br3ndonland/inboard:fastapi && \ 144 | docker build . --rm --target starlette -t localhost/br3ndonland/inboard:starlette 145 | ``` 146 | 147 | ### Running development containers 148 | 149 | ```sh 150 | # Run Docker container with Uvicorn and reloading 151 | cd inboard 152 | 153 | docker run -d -p 80:80 --platform linux/amd64 \ 154 | -e "BASIC_AUTH_USERNAME=test_user" \ 155 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 156 | -e "LOG_LEVEL=debug" \ 157 | -e "PROCESS_MANAGER=uvicorn" \ 158 | -e "WITH_RELOAD=true" \ 159 | -v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard:base 160 | 161 | docker run -d -p 80:80 --platform linux/amd64 \ 162 | -e "BASIC_AUTH_USERNAME=test_user" \ 163 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 164 | -e "LOG_LEVEL=debug" \ 165 | -e "PROCESS_MANAGER=uvicorn" \ 166 | -e "WITH_RELOAD=true" \ 167 | -v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard:fastapi 168 | 169 | docker run -d -p 80:80 --platform linux/amd64 \ 170 | -e "BASIC_AUTH_USERNAME=test_user" \ 171 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 172 | -e "LOG_LEVEL=debug" \ 173 | -e "PROCESS_MANAGER=uvicorn" \ 174 | -e "WITH_RELOAD=true" \ 175 | -v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard:starlette 176 | 177 | # Run Docker container with Gunicorn and Uvicorn 178 | docker run -d -p 80:80 --platform linux/amd64 \ 179 | -e "BASIC_AUTH_USERNAME=test_user" \ 180 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 181 | localhost/br3ndonland/inboard:base 182 | docker run -d -p 80:80 --platform linux/amd64 \ 183 | -e "BASIC_AUTH_USERNAME=test_user" \ 184 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 185 | localhost/br3ndonland/inboard:fastapi 186 | docker run -d -p 80:80 --platform linux/amd64 \ 187 | -e "BASIC_AUTH_USERNAME=test_user" \ 188 | -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ 189 | localhost/br3ndonland/inboard:starlette 190 | 191 | # Test HTTP Basic auth when running the FastAPI or Starlette images: 192 | http :80/status -a test_user:r4ndom_bUt_memorable 193 | ``` 194 | 195 | Change the port numbers to run multiple containers simultaneously (`-p 81:80`). 196 | 197 | ## Code quality 198 | 199 | ### Running code quality checks 200 | 201 | Code quality checks can be run using the Hatch scripts in _pyproject.toml_. 202 | 203 | - Check: `hatch run check` 204 | - Format: `hatch run format` 205 | 206 | ### Code style 207 | 208 | - Python code is formatted with [Ruff](https://docs.astral.sh/ruff/). Ruff configuration is stored in _pyproject.toml_. 209 | - Other web code (JSON, Markdown, YAML) is formatted with [Prettier](https://prettier.io/). 210 | 211 | ### Static type checking 212 | 213 | - To learn type annotation basics, see the [Python typing module docs](https://docs.python.org/3/library/typing.html), [Python type annotations how-to](https://docs.python.org/3/howto/annotations.html), the [Real Python type checking tutorial](https://realpython.com/python-type-checking/), and [this gist](https://gist.github.com/987bdc6263217895d4bf03d0a5ff114c). 214 | - Type annotations are not used at runtime. The standard library `typing` module includes a `TYPE_CHECKING` constant that is `False` at runtime, but `True` when conducting static type checking prior to runtime. Type imports are included under `if TYPE_CHECKING:` conditions so that they are not imported at runtime. These conditions are ignored when calculating test coverage. 215 | - Type annotations can be provided inline or in separate stub files. Much of the Python standard library is annotated with stubs. For example, the Python standard library [`logging.config` module uses type stubs](https://github.com/python/typeshed/blob/main/stdlib/logging/config.pyi). The typeshed types for the `logging.config` module are used solely for type-checking usage of the `logging.config` module itself. They cannot be imported and used to type annotate other modules. 216 | - The standard library `typing` module includes a `NoReturn` type. This would seem useful for [unreachable code](https://typing.readthedocs.io/en/stable/source/unreachable.html), including functions that do not return a value, such as test functions. Unfortunately mypy reports an error when using `NoReturn`, "Implicit return in function which does not return (misc)." To avoid headaches from the opaque "misc" category of [mypy errors](https://mypy.readthedocs.io/en/stable/error_code_list.html), these functions are annotated as returning `None`. 217 | - [Mypy](https://mypy.readthedocs.io/en/stable/) is used for type-checking. [Mypy configuration](https://mypy.readthedocs.io/en/stable/config_file.html) is included in _pyproject.toml_. 218 | - Mypy strict mode is enabled. Strict includes `--no-explicit-reexport` (`implicit_reexport = false`), which means that objects imported into a module will not be re-exported for import into other modules. Imports can be made into explicit exports with the syntax `from module import x as x` (i.e., changing from `import logging` to `import logging as logging`), or by including imports in `__all__`. This explicit import syntax can be confusing. Another option is to apply mypy overrides to any modules that need to leverage implicit exports. 219 | 220 | ### Spell check 221 | 222 | Spell check is performed with [CSpell](https://cspell.org/). The CSpell command is included in the Hatch script for code quality checks (`hatch run check`). 223 | 224 | ## GitHub Actions workflows 225 | 226 | [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 [Getting the Gist of GitHub Actions](https://gist.github.com/br3ndonland/f9c753eb27381f97336aa21b8d932be6) for more info. 227 | 228 | ## Maintainers 229 | 230 | ### Merges 231 | 232 | - **The default branch is `develop`.** 233 | - **PRs should be merged into `develop`.** Head branches are deleted automatically after PRs are merged. 234 | - **The only merges to `main` should be fast-forward merges from `develop`.** 235 | - **Branch protection is enabled on `develop` and `main`.** 236 | - `develop`: 237 | - Require signed commits 238 | - Include administrators 239 | - Allow force pushes 240 | - `main`: 241 | - Require signed commits 242 | - Include administrators 243 | - Do not allow force pushes 244 | - Require status checks to pass before merging (commits must have previously been pushed to `develop` and passed all checks) 245 | 246 | ### Releases 247 | 248 | - **Each minor version release of FastAPI or Uvicorn should get its own corresponding minor version release of inboard.** FastAPI depends on Starlette, so any updates to the Starlette version required by FastAPI should be included with updates to the FastAPI version. 249 | - **To create a release:** 250 | - Bump the version number in `inboard.__version__` with `hatch version` and commit the changes to `develop`. 251 | - Follow [SemVer](https://semver.org/) guidelines when choosing a version number. Note that [PEP 440](https://peps.python.org/pep-0440/) Python version specifiers and SemVer version specifiers differ, particularly with regard to specifying prereleases. Use syntax compatible with both. 252 | - The PEP 440 default (like `1.0.0a0`) is different from SemVer. Hatch and PyPI will use this syntax by default. 253 | - An alternative form of the Python prerelease syntax permitted in PEP 440 (like `1.0.0-alpha.0`) is compatible with SemVer, and this form should be used when tagging releases. As Hatch uses PEP 440 syntax by default, prerelease versions need to be written directly into `inboard.__version__`. 254 | - Examples of acceptable tag names: `1.0.0`, `1.0.0-alpha.0`, `1.0.0-beta.1` 255 | - Push to `develop` and verify all CI checks pass. 256 | - Fast-forward merge to `main`, push, and verify all CI checks pass. 257 | - Create an [annotated and signed Git tag](https://www.git-scm.com/book/en/v2/Git-Basics-Tagging). 258 | - List PRs and commits in the tag message: 259 | ```sh 260 | git log --pretty=format:"- %s (%h)" \ 261 | "$(git describe --abbrev=0 --tags)"..HEAD 262 | ``` 263 | - Omit the leading `v` (use `1.0.0` instead of `v1.0.0`) 264 | - Example: `git tag -a -s 1.0.0` 265 | - Push the tag. GitHub Actions will build and push the Python package and Docker images, and open a PR to update the changelog. 266 | - Squash and merge the changelog PR, removing any [`Co-authored-by` trailers](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/creating-a-commit-with-multiple-authors) before merging. 267 | 268 | ### Deployments 269 | 270 | Documentation is built with [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), deployed on [Vercel](https://vercel.com/), and available at [inboard.bws.bio](https://inboard.bws.bio) and [inboard.vercel.app](https://inboard.vercel.app). 271 | 272 | [Vercel build configuration](https://vercel.com/docs/build-step): 273 | 274 | - Build command: `python3 -m pip install 'mkdocs-material>=9,<10' && mkdocs build --site-dir public`. **The version of `mkdocs-material` installed on Vercel is independent of the version listed in _pyproject.toml_. If the version of `mkdocs-material` is updated in _pyproject.toml_, it must also be updated in the Vercel build configuration**. 275 | - Output directory: `public` (default) 276 | 277 | [Vercel site configuration](https://vercel.com/docs/configuration) is specified in _vercel.json_. 278 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Docker images 2 | 3 | ## Pull images 4 | 5 | Docker images are stored in [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) (GHCR), which is a Docker registry like Docker Hub. Public Docker images can be pulled anonymously from `ghcr.io`. The inboard images are based on the [official Python Docker images](https://hub.docker.com/_/python). 6 | 7 | Simply running `docker pull ghcr.io/br3ndonland/inboard` will pull the latest FastAPI image (Docker uses the `latest` tag by default). If specific versions of inboard or Python are desired, specify the version number at the beginning of the Docker tag as shown below _(new in inboard version 0.6.0)_. All the available images are also provided with [Alpine Linux](https://alpinelinux.org/) builds, which are available by appending `-alpine`, and Debian "slim" builds, which are available by appending `-slim` _(new in inboard version 0.11.0)_. Alpine and Debian slim users should be aware of their [limitations](#linux-distributions). 8 | 9 | Please see [inboard Git tags](https://github.com/br3ndonland/inboard/tags), [inboard PyPI release history](https://pypi.org/project/inboard/#history), and [inboard Docker images on GHCR](https://github.com/br3ndonland/inboard/pkgs/container/inboard) for the latest version numbers and available Docker tags. 10 | 11 | !!! example "Example Docker tags" 12 | 13 | ```{ .sh .no-copy } 14 | # Pull latest FastAPI image (Docker automatically appends the `latest` tag) 15 | docker pull ghcr.io/br3ndonland/inboard 16 | 17 | # Pull latest version of each image 18 | docker pull ghcr.io/br3ndonland/inboard:base 19 | docker pull ghcr.io/br3ndonland/inboard:fastapi 20 | docker pull ghcr.io/br3ndonland/inboard:starlette 21 | 22 | # Pull image from specific release 23 | docker pull ghcr.io/br3ndonland/inboard:0.67.0-fastapi 24 | 25 | # Pull image from latest minor version release (new in inboard 0.22.0) 26 | docker pull ghcr.io/br3ndonland/inboard:0.67-fastapi 27 | 28 | # Pull image with specific Python version 29 | docker pull ghcr.io/br3ndonland/inboard:fastapi-python3.12 30 | 31 | # Pull image from latest minor release and with specific Python version 32 | docker pull ghcr.io/br3ndonland/inboard:0.67-fastapi-python3.12 33 | 34 | # Append `-alpine` to image tags for Alpine Linux (new in inboard 0.11.0) 35 | docker pull ghcr.io/br3ndonland/inboard:latest-alpine 36 | docker pull ghcr.io/br3ndonland/inboard:0.67-fastapi-alpine 37 | 38 | # Append `-slim` to any of the above for Debian slim (new in inboard 0.11.0) 39 | docker pull ghcr.io/br3ndonland/inboard:latest-slim 40 | docker pull ghcr.io/br3ndonland/inboard:0.67-fastapi-slim 41 | ``` 42 | 43 | ## Use images in a _Dockerfile_ 44 | 45 | For a [Hatch](https://hatch.pypa.io/latest/) project with the following directory structure: 46 | 47 | - `repo/` 48 | - `package_name/` 49 | - `__init__.py` 50 | - `main.py` 51 | - `prestart.py` 52 | - `tests/` 53 | - `Dockerfile` 54 | - `pyproject.toml` 55 | - `README.md` 56 | 57 | The _pyproject.toml_ could look like this: 58 | 59 | !!! example "Example _pyproject.toml_ for Hatch project" 60 | 61 | ```toml 62 | [build-system] 63 | build-backend = "hatchling.build" 64 | requires = ["hatchling"] 65 | 66 | [project] 67 | authors = [{email = "you@example.com", name = "Your Name"}] 68 | dependencies = [ 69 | "inboard[fastapi]", 70 | ] 71 | description = "Your project description here." 72 | dynamic = ["version"] 73 | license = "MIT" 74 | name = "package-name" 75 | readme = "README.md" 76 | requires-python = ">=3.9,<4" 77 | 78 | [project.optional-dependencies] 79 | checks = [ 80 | "mypy", 81 | "ruff", 82 | ] 83 | docs = [ 84 | "mkdocs-material", 85 | ] 86 | tests = [ 87 | "coverage[toml]", 88 | "httpx", 89 | "pytest", 90 | "pytest-mock", 91 | "pytest-timeout", 92 | ] 93 | 94 | [tool.coverage.report] 95 | exclude_lines = ["if TYPE_CHECKING:", "pragma: no cover"] 96 | fail_under = 100 97 | show_missing = true 98 | 99 | [tool.coverage.run] 100 | command_line = "-m pytest" 101 | source = ["package_name", "tests"] 102 | 103 | [tool.hatch.build.targets.sdist] 104 | include = ["/package_name"] 105 | 106 | [tool.hatch.build.targets.wheel] 107 | packages = ["package_name"] 108 | 109 | [tool.hatch.envs.ci] 110 | dev-mode = false 111 | features = [ 112 | "checks", 113 | "tests", 114 | ] 115 | path = ".venv" 116 | 117 | [tool.hatch.envs.default] 118 | dev-mode = true 119 | features = [ 120 | "checks", 121 | "docs", 122 | "tests", 123 | ] 124 | path = ".venv" 125 | 126 | [tool.hatch.envs.production] 127 | dev-mode = false 128 | features = [] 129 | path = ".venv" 130 | 131 | [tool.hatch.version] 132 | path = "package_name/__init__.py" 133 | 134 | [tool.mypy] 135 | files = ["**/*.py"] 136 | plugins = "pydantic.mypy" 137 | show_error_codes = true 138 | strict = true 139 | 140 | [tool.pytest.ini_options] 141 | addopts = "-q" 142 | minversion = "6.0" 143 | testpaths = ["tests"] 144 | 145 | [tool.ruff] 146 | extend-select = ["I"] 147 | src = ["package_name", "tests"] 148 | 149 | [tool.ruff.format] 150 | docstring-code-format = true 151 | 152 | [tool.ruff.lint.isort] 153 | known-first-party = ["package_name", "tests"] 154 | 155 | ``` 156 | 157 | The _Dockerfile_ could look like this: 158 | 159 | !!! example "Example _Dockerfile_ for Hatch project" 160 | 161 | ```dockerfile 162 | # syntax=docker/dockerfile:1 163 | FROM ghcr.io/br3ndonland/inboard:fastapi 164 | 165 | # Set environment variables 166 | ENV APP_MODULE=package_name.main:app 167 | 168 | # Install Python app 169 | COPY --link pyproject.toml README.md /app/ 170 | COPY --link package_name /app/package_name 171 | WORKDIR /app 172 | 173 | # Install Hatch environment 174 | RUN hatch env prune && hatch env create production 175 | 176 | # ENTRYPOINT and CMD already included in base image 177 | ``` 178 | 179 | !!! tip "Syncing dependencies with Hatch" 180 | 181 | Hatch does not have a direct command for syncing dependencies, and `hatch env create` won't always sync dependencies if they're being installed into the same virtual environment directory (as they would be in a Docker image). Running `hatch env prune && hatch env create ` should do the trick. 182 | 183 | For further info, see [pypa/hatch#650](https://github.com/pypa/hatch/issues/650) and [pypa/hatch#1094](https://github.com/pypa/hatch/pull/1094). 184 | 185 | For a standard `pip` install: 186 | 187 | - `repo/` 188 | - `package_name/` 189 | - `__init__.py` 190 | - `main.py` 191 | - `prestart.py` 192 | - `tests/` 193 | - `Dockerfile` 194 | - `requirements.txt` 195 | - `README.md` 196 | 197 | Packaging would be set up separately as described in the [Python packaging user guide](https://packaging.python.org/en/latest/). 198 | 199 | The _requirements.txt_ could look like this: 200 | 201 | !!! example "Example _requirements.txt_ for `pip` project" 202 | 203 | ```text 204 | inboard[fastapi] 205 | ``` 206 | 207 | The _Dockerfile_ could look like this: 208 | 209 | !!! example "Example _Dockerfile_ for `pip` project" 210 | 211 | ```dockerfile 212 | # syntax=docker/dockerfile:1 213 | FROM ghcr.io/br3ndonland/inboard:fastapi 214 | 215 | # Set environment variables 216 | ENV APP_MODULE=package_name.main:app 217 | 218 | # Install Python app 219 | COPY --link requirements.txt /app/ 220 | COPY --link package_name /app/package_name 221 | WORKDIR /app 222 | 223 | # Install Python requirements 224 | RUN python -m pip install -r requirements.txt 225 | 226 | # ENTRYPOINT and CMD already included in base image 227 | ``` 228 | 229 | Organizing the _Dockerfile_ this way helps [leverage the Docker build cache](https://docs.docker.com/build/cache/). Files and commands that change most frequently are added last to the _Dockerfile_. Next time the image is built, Docker will skip any layers that didn't change, speeding up builds. 230 | 231 | The image could then be built with: 232 | 233 | ```sh 234 | cd /path/to/repo 235 | docker build --platform linux/amd64 -t imagename:latest . 236 | ``` 237 | 238 | Replace `imagename` with your image name. 239 | 240 | !!! info "Docker platforms" 241 | 242 | [Docker supports multiple platforms](https://docs.docker.com/build/building/multi-platform/) (combinations of operating system and CPU architecture) that can be supplied to the Docker CLI with the `--platform` argument. inboard currently only builds for the `linux/amd64` platform, also known as `x86_64`. See [containerd/platforms](https://github.com/containerd/platforms) and [opencontainers/image-spec](https://github.com/opencontainers/image-spec/blob/036563a4a268d7c08b51a08f05a02a0fe74c7268/specs-go/v1/descriptor.go#L52-L72) for more details on available platforms. 243 | 244 | ## Run containers 245 | 246 | Run container: 247 | 248 | ```sh 249 | docker run -d -p 80:80 --platform linux/amd64 imagename 250 | ``` 251 | 252 | Run container with mounted volume and Uvicorn reloading for development: 253 | 254 | ```sh 255 | cd /path/to/repo 256 | docker run -d -p 80:80 --platform linux/amd64 \ 257 | -e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \ 258 | -v $(pwd)/package:/app/package imagename 259 | ``` 260 | 261 | Details on the `docker run` command: 262 | 263 | - `-e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true"` will instruct `start.py` to run Uvicorn with reloading and without Gunicorn. The Gunicorn configuration won't apply, but these environment variables will still work as [described](environment.md): 264 | - `APP_MODULE` 265 | - `HOST` 266 | - `PORT` 267 | - `LOG_COLORS` 268 | - `LOG_FORMAT` 269 | - `LOG_LEVEL` 270 | - `RELOAD_DIRS` 271 | - `WITH_RELOAD` 272 | - `-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. 273 | 274 | ## Docker and Hatch 275 | 276 | This project uses [Hatch](https://hatch.pypa.io/latest/) for Python dependency management and packaging, and uses [`pipx`](https://pypa.github.io/pipx/) to install Hatch in Docker: 277 | 278 | - `ENV PATH=/opt/pipx/bin:/app/.venv/bin:$PATH` is set first to prepare the `$PATH`. 279 | - `pip` is used to install `pipx`. 280 | - `pipx` is used to install Hatch, with `PIPX_BIN_DIR=/opt/pipx/bin` used to specify the location where `pipx` installs the Hatch command-line application, and `PIPX_HOME=/opt/pipx/home` used to specify the location for `pipx` itself. 281 | - `hatch env create` is used with `HATCH_ENV_TYPE_VIRTUAL_PATH=.venv` and `WORKDIR=/app` to create the virtualenv at `/app/.venv` and install the project's packages into the virtualenv. 282 | 283 | With this approach: 284 | 285 | - Subsequent `python` commands use the executable at `app/.venv/bin/python`. 286 | - As long as `HATCH_ENV_TYPE_VIRTUAL_PATH=.venv` and `WORKDIR /app` are retained, subsequent Hatch commands use the same virtual environment at `/app/.venv`. 287 | 288 | ## Docker and Poetry 289 | 290 | This project now uses Hatch for Python dependency management and packaging. Poetry 1.1 was used before Hatch. If you have a downstream project using the inboard Docker images with Poetry, you can add `RUN pipx install poetry` to your Dockerfile to install Poetry for your project. 291 | 292 | As explained in [python-poetry/poetry#1879](https://github.com/python-poetry/poetry/discussions/1879#discussioncomment-346113), there were two conflicting conventions to consider when working with Poetry in Docker: 293 | 294 | 1. Docker's convention is to not use virtualenvs, because containers themselves provide sufficient isolation. 295 | 2. Poetry's convention is to always use virtualenvs, because of the reasons given in [python-poetry/poetry#3209](https://github.com/python-poetry/poetry/pull/3209#issuecomment-710678083). 296 | 297 | This project used [`pipx`](https://pypa.github.io/pipx/) to install Poetry in Docker: 298 | 299 | - `ENV PATH=/opt/pipx/bin:/app/.venv/bin:$PATH` was set first to prepare the `$PATH`. 300 | - `pip` was used to install `pipx`. 301 | - `pipx` was used to install Poetry. 302 | - `poetry install` was used with `POETRY_VIRTUALENVS_CREATE=true`, `POETRY_VIRTUALENVS_IN_PROJECT=true` and `WORKDIR /app` to install the project's packages into the virtualenv at `/app/.venv`. 303 | 304 | With this approach: 305 | 306 | - Subsequent `python` commands used the executable at `app/.venv/bin/python`. 307 | - As long as `POETRY_VIRTUALENVS_IN_PROJECT=true` and `WORKDIR /app` were retained, subsequent Poetry commands used the same virtual environment at `/app/.venv`. 308 | 309 | ## Linux distributions 310 | 311 | ### Alpine 312 | 313 | The [official Python Docker image](https://hub.docker.com/_/python) is built on [Debian Linux](https://www.debian.org/) by default, with [Alpine Linux](https://alpinelinux.org/) builds also provided. Alpine is known for its security and small Docker image sizes. 314 | 315 | !!! info "Runtime determination of the Linux distribution" 316 | 317 | To determine the Linux distribution at runtime, it can be helpful to source `/etc/os-release`, which contains an `ID` variable specifying the distribution (`alpine`, `debian`, etc). 318 | 319 | Alpine differs from Debian in some important ways, including: 320 | 321 | - Shell (Alpine does not use Bash by default) 322 | - Packages (Alpine uses [`apk`](https://docs.alpinelinux.org/user-handbook/0.1a/Working/apk.html) as its package manager, and does not include some common packages like `curl` by default) 323 | - C standard library (Alpine uses [`musl`](https://musl.libc.org/) instead of [`gcc`](https://gcc.gnu.org/)) 324 | 325 | The different C standard library is of particular note for Python packages, because [binary package distributions](https://packaging.python.org/guides/packaging-binary-extensions/) may not be available for Alpine Linux. To work with these packages, their build dependencies must be installed, then the packages must be built from source. Users will typically then delete the build dependencies to keep the final Docker image size small. 326 | 327 | The basic build dependencies used by inboard include `gcc`, `libc-dev`, and `make`. These may not be adequate to build all packages. For example, to [install `psycopg`](https://www.psycopg.org/docs/install.html), it may be necessary to add more build dependencies, build the package, (optionally delete the build dependencies) and then include its `libpq` runtime dependency in the final image. A set of build dependencies for this scenario might look like the following: 328 | 329 | !!! example "Example Alpine Linux _Dockerfile_ for PostgreSQL project" 330 | 331 | ```dockerfile 332 | # syntax=docker/dockerfile:1 333 | ARG INBOARD_DOCKER_TAG=fastapi-alpine 334 | FROM ghcr.io/br3ndonland/inboard:${INBOARD_DOCKER_TAG} 335 | ENV APP_MODULE=mypackage.main:app 336 | COPY --link pyproject.toml README.md /app/ 337 | COPY --link mypackage /app/mypackage 338 | WORKDIR /app 339 | RUN < 26 | - Python module with app instance. 27 | - Default: The appropriate app module from inboard. 28 | - Custom: For a module at `/app/package/custom/module.py` and app instance object `api`, either `APP_MODULE` (like `APP_MODULE="package.custom.module:api"`) or `UVICORN_APP` (like `UVICORN_APP="package.custom.module:api"`, _new in inboard 0.62_) 29 | 30 | !!! example "Example of a custom FastAPI app module" 31 | 32 | ```py 33 | # /app/package/custom/module.py 34 | from fastapi import FastAPI 35 | 36 | api = FastAPI() 37 | 38 | @api.get("/") 39 | def read_root(): 40 | return {"message": "Hello World!"} 41 | ``` 42 | 43 | !!! note 44 | 45 | The base Docker image sets the environment variable `PYTHONPATH=/app`, so the module name will be relative to `/app` unless you supply a custom [`PYTHONPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH). 46 | 47 | `PRE_START_PATH` 48 | 49 | 50 | - Path to a pre-start script. 51 | - inboard optionally runs a pre-start script before starting the server. The path to a pre-start script can be specified with the environment variable `PRE_START_PATH`. If the environment variable is set to a nonzero value, inboard will run the script at the provided path, using the [`subprocess`](https://docs.python.org/3/library/subprocess.html) standard library package. 52 | - If the pre-start script exits with an error, inboard will not start the server. 53 | - Default: `"/app/inboard/prestart.py"` (provided with inboard) 54 | - Custom: 55 | 56 | - `PRE_START_PATH="/app/package/custom_script.sh"` 57 | - `PRE_START_PATH= ` (set to an empty value) to disable 58 | 59 | !!! tip 60 | 61 | 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. 62 | 63 | [`PYTHONPATH`](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH) 64 | 65 | - Python's search path for module files. 66 | - Default: `PYTHONPATH="/app"` 67 | - Custom: `PYTHONPATH="/app/custom"` 68 | 69 | ## Gunicorn 70 | 71 | ### Configuration file 72 | 73 | `GUNICORN_CONF` 74 | 75 | - Path to a [Gunicorn configuration file](https://docs.gunicorn.org/en/latest/settings.html#config-file). Gunicorn accepts either file paths or module paths. 76 | - Default: 77 | - `"python:inboard.gunicorn_conf"` (provided with inboard) 78 | - Custom: 79 | - `GUNICORN_CONF="/app/package/custom_gunicorn_conf.py"` (file path) 80 | - `GUNICORN_CONF="python:package.custom_gunicorn_conf"` (module paths accepted with the `python:` prefix) 81 | 82 | ### Process management 83 | 84 | !!! info 85 | 86 | As originally described in the Uvicorn docs, "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." 87 | 88 | Uvicorn deprecated the workers module in version 0.30.0. It is now available here at `inboard.gunicorn_workers`. 89 | 90 | `PROCESS_MANAGER` 91 | 92 | - Manager for Uvicorn worker processes. 93 | - Default: `"gunicorn"` (run Uvicorn with Gunicorn as the process manager) 94 | - Custom: `"uvicorn"` (run Uvicorn alone for local development) 95 | 96 | [`WORKER_CLASS`](https://docs.gunicorn.org/en/latest/settings.html#worker-processes) 97 | 98 | - Uvicorn worker class for Gunicorn to use. 99 | - Default: `inboard.gunicorn_workers.UvicornWorker` (provided with inboard) 100 | - Custom: For the alternate Uvicorn worker, `WORKER_CLASS="inboard.gunicorn_workers.UvicornH11Worker"` _(the H11 worker is provided for [PyPy](https://www.pypy.org/))_ 101 | 102 | ### Worker process calculation 103 | 104 | !!! info 105 | 106 | The number of [Gunicorn worker processes](https://docs.gunicorn.org/en/latest/settings.html#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). 107 | 108 | `MAX_WORKERS` 109 | 110 | - Maximum number of workers, independent of number of CPU cores. 111 | - Default: not set (unlimited) 112 | - Custom: `MAX_WORKERS="24"` 113 | 114 | `WEB_CONCURRENCY` 115 | 116 | - Total number of workers, independent of number of CPU cores. 117 | - Default: not set 118 | - Custom: `WEB_CONCURRENCY="4"` 119 | 120 | `WORKERS_PER_CORE` 121 | 122 | 123 | - Number of Gunicorn workers per CPU core. Overridden if `WEB_CONCURRENCY` is set. 124 | - Default: 1 125 | - Custom: 126 | 127 | - `WORKERS_PER_CORE="2"`: Run 2 worker processes per core (8 worker processes on a server with 4 cores). 128 | - `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). 129 | 130 | !!! note 131 | 132 | - 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). 133 | - If both `MAX_WORKERS` and `WEB_CONCURRENCY` are set, the least of the two will be used as the total number of workers. 134 | - 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. 135 | 136 | ### Worker timeouts 137 | 138 | [`GRACEFUL_TIMEOUT`](https://docs.gunicorn.org/en/stable/settings.html#graceful-timeout) 139 | 140 | - Number of seconds to wait for workers to finish serving requests before restart. 141 | - Default: `"120"` 142 | - Custom: `GRACEFUL_TIMEOUT="20"` 143 | 144 | [`TIMEOUT`](https://docs.gunicorn.org/en/stable/settings.html#timeout) 145 | 146 | - Workers silent for more than this many seconds are killed and restarted. 147 | - Default: `"120"` 148 | - Custom: `TIMEOUT="20"` 149 | 150 | [`KEEP_ALIVE`](https://docs.gunicorn.org/en/stable/settings.html#keepalive) 151 | 152 | - Number of seconds to wait for workers to finish serving requests on a Keep-Alive connection. 153 | - Default: `"5"` 154 | - Custom: `KEEP_ALIVE="20"` 155 | 156 | ### Host networking 157 | 158 | `HOST` 159 | 160 | - Host IP address (inside of the container) where Gunicorn will listen for requests. 161 | - Default: `"0.0.0.0"` 162 | - Custom: n/a 163 | 164 | `PORT` 165 | 166 | - Port the container should listen on. 167 | - Default: `"80"` 168 | - Custom: `PORT="8080"` 169 | 170 | [`BIND`](https://docs.gunicorn.org/en/latest/settings.html#server-socket) 171 | 172 | - The actual host and port passed to Gunicorn. 173 | - Default: `HOST:PORT` (`"0.0.0.0:80"`) 174 | - Custom: `BIND="0.0.0.0:8080"` (if custom `BIND` is set, overrides `HOST` and `PORT`) 175 | 176 | ### Runtime configuration 177 | 178 | `GUNICORN_CMD_ARGS` 179 | 180 | - Additional [command-line arguments for Gunicorn](https://docs.gunicorn.org/en/stable/settings.html). Gunicorn looks for the `GUNICORN_CMD_ARGS` environment variable automatically, and gives these settings precedence over other environment variables and Gunicorn config files. 181 | - Custom: To use a custom TLS certificate, copy or mount the certificate and private key into the Docker image, and set [`--keyfile` and `--certfile`](http://docs.gunicorn.org/en/latest/settings.html#ssl) to the location of the files. 182 | 183 | ```sh 184 | CERTS="--keyfile=/secrets/key.pem --certfile=/secrets/cert.pem" 185 | docker run -d -p 443:443 \ 186 | -e GUNICORN_CMD_ARGS="$CERTS" \ 187 | -e PORT=443 myimage 188 | ``` 189 | 190 | ## Uvicorn 191 | 192 | !!! info 193 | 194 | These settings are mostly used for local development. 195 | 196 | [Uvicorn supports environment variables named with the `UVICORN_` prefix](https://www.uvicorn.org/settings/) (via the [Click `auto_envvar_prefix` feature](https://click.palletsprojects.com/en/7.x/options/#dynamic-defaults-for-prompts)), but these environment variables are only read when running from the CLI. inboard runs Uvicorn programmatically with `uvicorn.run()` instead of running with the CLI, so most of these variables will not apply. The exception is `UVICORN_APP`, as explained in the [general section](#general). 197 | 198 | `WITH_RELOAD` 199 | 200 | 201 | - Configure the [Uvicorn auto-reload setting](https://www.uvicorn.org/settings/). 202 | - Default: `"false"` (don't auto-reload when files change) 203 | - Custom: `"true"` (watch files and auto-reload when files change). 204 | 205 | !!! note 206 | 207 | [Auto-reloading is useful for local development](https://www.uvicorn.org/settings/#development). 208 | 209 | `RELOAD_DIRS` _(new in inboard 0.7)_ 210 | 211 | 212 | - Directories and files to watch for changes, formatted as comma-separated string. 213 | - Default: watch all directories under project root. 214 | - Custom: 215 | 216 | - `"inboard"` (one directory) 217 | - `"inboard, tests"` (two directories) 218 | - `"inboard, tests, Dockerfile"` (two directories and a file) _(watching non-Python files requires `watchfiles`, installed with the `inboard[uvicorn-standard]` extra)_ 219 | 220 | !!! note 221 | 222 | On the command-line, this [Uvicorn setting](https://www.uvicorn.org/settings/) is configured by passing `--reload-dir`, and can be passed multiple times, with one directory each. 223 | 224 | However, when running Uvicorn programmatically, `uvicorn.run` accepts a list of strings (`uvicorn.run(reload_dirs=["dir1", "dir2"])`), so inboard will parse the environment variable, send the list to Uvicorn, and Uvicorn will watch each directory or file specified. 225 | 226 | `RELOAD_DELAY` _(new in inboard 0.11)_ 227 | 228 | 229 | - Floating point value specifying the time, in seconds, to wait before reloading files. 230 | - Default: not set (the value is set by `uvicorn.config.Config`) 231 | - Custom: `"0.5"` 232 | 233 | !!! note 234 | 235 | - `uvicorn.run` equivalent: `reload_delay` 236 | - Uvicorn CLI equivalent: `--reload-delay` 237 | 238 | `RELOAD_EXCLUDES` _(new in inboard 0.11)_ 239 | 240 | 241 | - Glob pattern indicating files to exclude when watching for changes, formatted as comma-separated string. 242 | - Default: not set (the value is set by `uvicorn.config.Config`) 243 | - Custom: `"*[Dd]ockerfile"` _(watching non-Python files requires `watchfiles`, installed with the `inboard[uvicorn-standard]` extra)_ 244 | 245 | !!! note 246 | 247 | - Parsed into a list of strings in the same manner as for `RELOAD_DIRS`. 248 | - `uvicorn.run` equivalent: `reload_excludes` 249 | - Uvicorn CLI equivalent: `--reload-exclude` 250 | 251 | `RELOAD_INCLUDES` _(new in inboard 0.11)_ 252 | 253 | 254 | - Glob pattern indicating files to include when watching for changes, formatted as comma-separated string. 255 | - Default: not set (the value is set by `uvicorn.config.Config`) 256 | - Custom: `"*.py, *.md"` _(watching non-Python files requires `watchfiles`, installed with the `inboard[uvicorn-standard]` extra)_ 257 | 258 | !!! note 259 | 260 | - Parsed into a list of strings in the same manner as for `RELOAD_DIRS`. 261 | - `uvicorn.run` equivalent: `reload_includes` 262 | - Uvicorn CLI equivalent: `--reload-include` 263 | 264 | `UVICORN_APP` _(new in inboard 0.62)_ 265 | 266 | - `UVICORN_APP` can be used interchangeably with `APP_MODULE`. 267 | - See the [general section](#general) for further details. 268 | 269 | `UVICORN_CONFIG_OPTIONS` _(advanced usage, new in inboard 0.11)_ 270 | 271 | - JSON-formatted string containing arguments to pass directly to Uvicorn. 272 | - Default: not set 273 | - Custom: `UVICORN_CONFIG_OPTIONS='{"reload": true, "reload_delay": null}'` 274 | 275 | The idea here is to allow a catch-all Uvicorn config variable in the spirit of `GUNICORN_CMD_ARGS`, so that advanced users can specify the full range of Uvicorn options even if inboard has not directly implemented them. The `inboard.start` module will run the `UVICORN_CONFIG_OPTIONS` environment variable value through `json.loads()`, and then pass the resultant dictionary through to Uvicorn. If the same option is set with an individual environment variable (such as `WITH_RELOAD`) and with a JSON value in `UVICORN_CONFIG_OPTIONS`, the JSON value will take precedence. 276 | 277 | `json.loads()` converts data types from JSON to Python, and returns a Python dictionary. See the guide to [understanding JSON schema](https://json-schema.org/understanding-json-schema/index.html) for many helpful examples of how JSON data types correspond to Python data types. If the Uvicorn options are already available as a Python dictionary, dump them to a JSON-formatted string with `json.dumps()`, and set that as an environment variable. 278 | 279 | !!! example "Example of how to format `UVICORN_CONFIG_OPTIONS` as valid JSON" 280 | 281 | ```py 282 | import json 283 | import os 284 | uvicorn_config_dict = dict(host="0.0.0.0", port=80, log_config=None, log_level="info", reload=False) 285 | json.dumps(uvicorn_config_dict) 286 | # '{"host": "0.0.0.0", "port": 80, "log_config": null, "log_level": "info", "reload": false}' 287 | os.environ["UVICORN_CONFIG_OPTIONS"] = json.dumps(uvicorn_config_dict) 288 | json.loads(os.environ["UVICORN_CONFIG_OPTIONS"]) == uvicorn_config_dict 289 | # True 290 | ``` 291 | 292 | !!! warning 293 | 294 | The `UVICORN_CONFIG_OPTIONS` environment variable is suggested for advanced usage because it requires some knowledge of `uvicorn.config.Config`. Other than the JSON -> Python dictionary conversion, no additional type conversions or validations are performed on `UVICORN_CONFIG_OPTIONS`. All options should be able to be passed directly to `uvicorn.config.Config`. 295 | 296 | In the example below, `reload` will be passed through with the correct type (because it was formatted with the correct JSON type initially), but `access_log` will have an incorrect type (because it was formatted as a string instead of as a Boolean). 297 | 298 | ```py 299 | import json 300 | import os 301 | os.environ["UVICORN_CONFIG_OPTIONS_INCORRECT"] = '{"access_log": "false", "reload": true}' 302 | json.loads(os.environ["UVICORN_CONFIG_OPTIONS_INCORRECT"]) 303 | # {'access_log': "false", 'reload': True} 304 | ``` 305 | 306 | ## Logging 307 | 308 | `LOGGING_CONF` 309 | 310 | - Python module containing a logging [configuration dictionary object](https://docs.python.org/3/library/logging.config.html) named `LOGGING_CONFIG`. Can be either a module path (`inboard.logging_conf`) or a file path (`/app/inboard/logging_conf.py`). The `LOGGING_CONFIG` dictionary will be loaded and passed to [`logging.config.dictConfig()`](https://docs.python.org/3/library/logging.config.html). 311 | - Default: `"inboard.logging_conf"` (the default module provided with inboard) 312 | - Custom: For a logging config module at `/app/package/custom_logging.py`, `LOGGING_CONF="package.custom_logging"` or `LOGGING_CONF="/app/package/custom_logging.py"`. 313 | 314 | `LOG_COLORS` 315 | 316 | - Whether or not to color log messages. Currently only supported for `LOG_FORMAT="uvicorn"`. 317 | - Default: 318 | - Auto-detected based on [`sys.stdout.isatty()`](https://docs.python.org/3/library/sys.html#sys.stdout). 319 | - Custom: 320 | - `LOG_COLORS="true"` 321 | - `LOG_COLORS="false"` 322 | 323 | `LOG_FILTERS` 324 | 325 | - Comma-separated string identifying log records to filter out. The string will be split on commas and converted to a set. Each log message will then be checked for each filter in the set. If any matches are present in the log message, the logger will not log that message. 326 | - Default: `None` (don't filter out any log records, just log every record) 327 | - Custom: `LOG_FILTERS="/health, /heartbeat"` (filter out log messages that contain either the string `"/health"` or the string `"/heartbeat"`, to avoid logging health checks) 328 | - See also: 329 | - [AWS Builders' Library: Implementing health checks](https://aws.amazon.com/builders-library/implementing-health-checks/) 330 | - [AWS Elastic Load Balancing docs: Target groups - Health checks for your target groups](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html) 331 | - [benoitc/gunicorn#1781](https://github.com/benoitc/gunicorn/issues/1781) 332 | - [Python 3 docs: How-To - Logging Cookbook - Using Filters to impart contextual information](https://docs.python.org/3/howto/logging-cookbook.html#using-filters-to-impart-contextual-information) 333 | - [Python 3 docs: What's new in Python 3.2 - logging](https://docs.python.org/3/whatsnew/3.2.html#logging) 334 | - [Django 4.0 docs: Topics - Logging](https://docs.djangoproject.com/en/4.0/topics/logging/) 335 | 336 | `LOG_FORMAT` 337 | 338 | 339 | - [Python logging format](https://docs.python.org/3/library/logging.html#formatter-objects). 340 | - Default: 341 | - `"simple"`: Simply the log level and message. 342 | - Custom: 343 | 344 | - `"verbose"`: The most informative format, with the first 80 characters providing metadata, and the remainder supplying the log message. 345 | - `"gunicorn"`: Gunicorn's default format. 346 | - `"uvicorn"`: Uvicorn's default format, similar to `simple`, with support for `LOG_COLORS`. Note that Uvicorn's `access` formatter is not supported here, because it frequently throws errors related to [ASGI scope](https://asgi.readthedocs.io/en/latest/specs/lifespan.html). 347 | 348 | !!! example "Example log message in different formats" 349 | 350 | ```{ .log .no-copy } 351 | # simple 352 | INFO Started server process [19012] 353 | 354 | # verbose 355 | 2020-08-19 21:07:31 -0400 19012 uvicorn.error main INFO Started server process [19012] 356 | 357 | # gunicorn 358 | [2020-08-19 21:07:31 -0400] [19012] [INFO] Started server process [19012] 359 | 360 | # uvicorn (can also be colored) 361 | INFO: Started server process [19012] 362 | ``` 363 | 364 | `LOG_LEVEL` 365 | 366 | - Log level for [Gunicorn](https://docs.gunicorn.org/en/latest/settings.html#logging) or [Uvicorn](https://www.uvicorn.org/settings/#logging). 367 | - Default: `"info"` 368 | - Custom (organized from greatest to least amount of logging): 369 | - `LOG_LEVEL="debug"` 370 | - `LOG_LEVEL="info"` 371 | - `LOG_LEVEL="warning"` 372 | - `LOG_LEVEL="error"` 373 | - `LOG_LEVEL="critical"` 374 | 375 | `ACCESS_LOG` 376 | 377 | - Access log file to which to write. 378 | - Default: `"-"` (`stdout`, print in Docker logs) 379 | - Custom: 380 | - `ACCESS_LOG="./path/to/accesslogfile.txt"` 381 | - `ACCESS_LOG= ` (set to an empty value) to disable 382 | 383 | `ERROR_LOG` 384 | 385 | - Error log file to which to write. 386 | - Default: `"-"` (`stdout`, print in Docker logs) 387 | - Custom: 388 | - `ERROR_LOG="./path/to/errorlogfile.txt"` 389 | - `ERROR_LOG= ` (set to an empty value) to disable 390 | 391 | See the [logging reference](logging.md) for further info. 392 | 393 | ## Authentication 394 | 395 | `BASIC_AUTH_USERNAME` 396 | 397 | - Username for HTTP Basic auth. 398 | - Default: not set 399 | - Custom: `BASIC_AUTH_USERNAME=test_user` 400 | 401 | `BASIC_AUTH_PASSWORD` 402 | 403 | - Password for HTTP Basic auth. 404 | - Default: not set 405 | - Custom: `BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable` 406 | 407 | See the [authentication reference](authentication.md) for further info. 408 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 🚢 inboard 🐳 2 | 3 | inboard logo 4 | 5 | _Docker images and utilities to power your Python APIs and help you ship faster._ 6 | 7 | [![PyPI](https://img.shields.io/pypi/v/inboard?color=success)](https://pypi.org/project/inboard/) 8 | [![GitHub Container Registry](https://img.shields.io/badge/github%20container%20registry-inboard-success)](https://github.com/br3ndonland/inboard/pkgs/container/inboard) 9 | [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=pytest&logoColor=white)](https://coverage.readthedocs.io/en/latest/) 10 | [![ci](https://github.com/br3ndonland/inboard/workflows/ci/badge.svg)](https://github.com/br3ndonland/inboard/actions) 11 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/ef2798f758e03fa659d1ba2973ddd59515400978/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 12 | 13 | [![Mentioned in Awesome FastAPI](https://awesome.re/mentioned-badge-flat.svg)](https://github.com/mjhea0/awesome-fastapi) 14 | 15 | ## Description 16 | 17 | This project provides [Docker images](https://github.com/br3ndonland/inboard/pkgs/container/inboard) and a [PyPI package](https://pypi.org/project/inboard/) with useful utilities for Python web servers. It runs [Uvicorn with Gunicorn](https://www.uvicorn.org/), and can be used to build applications with [Starlette](https://www.starlette.io/) and [FastAPI](https://fastapi.tiangolo.com/). 18 | 19 | ## Justification 20 | 21 | _Why use this project?_ You might want to try out inboard because it: 22 | 23 | - **Offers a Python package and Docker images that work together**. Python packages and Docker images don't automatically share the same versioning systems, but inboard can help with this. You might install the Python package with a minor version constraint. You can also pull the corresponding Docker image by specifying the minor version in the Docker tag (`FROM ghcr.io/br3ndonland/inboard:`). 24 | - **Tests everything**. inboard performs unit testing of 100% of the Python code, and also runs smoke tests of the Docker images each time they are built. 25 | - **Sets sane defaults, but allows configuration**. Configure a variety of settings with [environment variables](environment.md). Or run it as-is and it just works. 26 | - **Configures logging extensibly**. [inboard simplifies logging](logging.md) by handling all its Python log streams with a single logging config. It also offers the ability to [filter health check endpoints out of the access logs](logging.md#filtering-log-messages). Don't like it? No problem. You can easily [extend](logging.md#extending-the-logging-config) or [override](logging.md#overriding-the-logging-config) the logging behavior. 27 | 28 | ## Quickstart 29 | 30 | [Get started with Docker](https://www.docker.com/get-started), pull and run an image, and try an API endpoint. 31 | 32 | ```sh 33 | docker pull ghcr.io/br3ndonland/inboard 34 | docker run -d -p 80:80 --platform linux/amd64 ghcr.io/br3ndonland/inboard 35 | http :80 # HTTPie: https://httpie.io/ 36 | ``` 37 | 38 | ## Background 39 | 40 | **I built this project to use as a production Python web server layer.** I was working on several different software applications, and wanted a way to centrally manage the web server layer, so I didn't have to configure the server separately for each application. I also found it difficult to keep up with all the changes to the associated Python packages, including Uvicorn, Starlette, and FastAPI. I realized that I needed to abstract the web server layer into a separate project, so that when working on software applications, I could simply focus on building the applications themselves. This project is the result. It's been very helpful to me, and I hope it's helpful to you also. 41 | 42 | This project was inspired in part by [tiangolo/uvicorn-gunicorn-docker](https://github.com/tiangolo/uvicorn-gunicorn-docker). In addition to the benefits described in the [justification section](#justification), inboard also has the following advantages: 43 | 44 | - **One repo**. The tiangolo/uvicorn-gunicorn images are in at least three separate repos ([tiangolo/uvicorn-gunicorn-docker](https://github.com/tiangolo/uvicorn-gunicorn-docker), [tiangolo/uvicorn-gunicorn-fastapi-docker](https://github.com/tiangolo/uvicorn-gunicorn-fastapi-docker), and [tiangolo/uvicorn-gunicorn-starlette-docker](https://github.com/tiangolo/uvicorn-gunicorn-starlette-docker)), with large amounts of code duplication, making maintenance difficult for an [already-busy maintainer](https://github.com/encode/uvicorn/pull/705#issuecomment-660042305). This repo combines three into one. 45 | - **One _Dockerfile_.** This project leverages [multi-stage builds](https://docs.docker.com/build/building/multi-stage/) to produce multiple Docker images from one _Dockerfile_. 46 | - **One Python requirements file.** This project uses [Hatch](https://hatch.pypa.io/latest/) for dependency management with a single _pyproject.toml_. 47 | - **One logging configuration.** Logging a Uvicorn+Gunicorn+Starlette/FastAPI stack is unnecessarily complicated. Uvicorn and Gunicorn use different logging configurations, and it can be difficult to unify the log streams. In this repo, Uvicorn, Gunicorn, and FastAPI log streams are propagated to the root logger, and handled by the custom root logging config. Developers can also supply their own custom logging configurations. 48 | - **One programming language.** Pure Python with no shell scripts. 49 | - **One platform.** You're already on GitHub. Why not [pull Docker images from GitHub Container Registry](https://github.blog/2020-09-01-introducing-github-container-registry/)? 50 | 51 | The PyPI package is useful if you want to use or extend any of the inboard Python modules, such as the logging configuration. 52 | -------------------------------------------------------------------------------- /docs/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | ## Configuration variables 4 | 5 | See [environment variable reference](environment.md). 6 | 7 | ## Default logging behavior 8 | 9 | - inboard's logging configuration logic is located in [`logging_conf.py`](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/logging_conf.py). By default, inboard will load the `LOGGING_CONFIG` dictionary in this module. The dictionary was named for consistency with [Uvicorn's logging configuration dictionary](https://github.com/encode/uvicorn/blob/HEAD/uvicorn/config.py). 10 | - When running Uvicorn alone, logging is configured programmatically from within the [`start.py` start script](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/start.py), by passing the `LOGGING_CONFIG` dictionary to `uvicorn.run()`. 11 | - When running Gunicorn with the Uvicorn worker, the logging configuration dictionary is specified within the [`gunicorn_conf.py`](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/gunicorn_conf.py) configuration file. 12 | 13 | ## Filtering log messages 14 | 15 | [Filters](https://docs.python.org/3/howto/logging-cookbook.html#using-filters-to-impart-contextual-information) identify log messages to filter out, so that the logger does not log messages containing any of the filters. If any matches are present in a log message, the logger will not output the message. 16 | 17 | One of the primary use cases for log message filters is health checks. When applications with APIs are deployed, it is common to perform "health checks" on them. Health checks are usually performed by making HTTP requests to a designated API endpoint. These checks are made at frequent intervals, and so they can fill up the access logs with large numbers of unnecessary log records. To avoid logging health checks, add those endpoints to the `LOG_FILTERS` environment variable. 18 | 19 | The `LOG_FILTERS` environment variable can be used to specify filters as a comma-separated string, like `LOG_FILTERS="/health, /heartbeat"`. To then add the filters to a class instance, the `LogFilter.set_filters()` method can produce the set of filters from the environment variable value. 20 | 21 | ```{ .sh .no-copy } 22 | # start a REPL session in a venv in which inboard is installed 23 | .venv ❯ python 24 | ``` 25 | 26 | ```py 27 | import os 28 | import inboard 29 | os.environ["LOG_FILTERS"] = "/health, /heartbeat" 30 | inboard.LogFilter.set_filters() 31 | # {'/heartbeat', '/health'} 32 | ``` 33 | 34 | inboard will do this automatically by reading the `LOG_FILTERS` environment variable. 35 | 36 | Let's see this in action by using one of the inboard Docker images. We'll have one terminal instance open to see the server logs from Uvicorn, and another one open to make client HTTP requests. 37 | 38 | Start the server: 39 | 40 | ```sh 41 | docker pull ghcr.io/br3ndonland/inboard 42 | docker run \ 43 | -d \ 44 | -e BASIC_AUTH_USERNAME=test_user \ 45 | -e BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable \ 46 | -e LOG_FILTERS="/health, /heartbeat" \ 47 | -e LOG_LEVEL=debug \ 48 | -e PROCESS_MANAGER=uvicorn \ 49 | -p 8000:80 \ 50 | --platform linux/amd64 \ 51 | ghcr.io/br3ndonland/inboard 52 | docker logs -f $(docker ps -q -n 1) 53 | ``` 54 | 55 | ```{ .log .no-copy } 56 | DEBUG Logging dict config loaded from inboard.logging_conf. 57 | DEBUG Checking for pre-start script. 58 | DEBUG No pre-start script specified. 59 | DEBUG App module set to inboard.app.main_fastapi:app. 60 | DEBUG Running Uvicorn without Gunicorn. 61 | INFO Started server process [1] 62 | INFO Waiting for application startup. 63 | INFO Application startup complete. 64 | INFO Uvicorn running on http://0.0.0.0:80 (Press CTRL+C to quit) 65 | ``` 66 | 67 | Make a request to an endpoint that should be logged, using an HTTP client like [HTTPie](https://httpie.io/) or the [HTTPX CLI](https://www.python-httpx.org/): 68 | 69 | ```sh 70 | ❯ http :8000 -b 71 | { 72 | "Hello": "World" 73 | } 74 | 75 | ``` 76 | 77 | The request will be logged through `uvicorn.access`: 78 | 79 | ```{ .log .no-copy } 80 | INFO 172.17.0.1:65026 - "GET / HTTP/1.1" 200 81 | ``` 82 | 83 | Next, make a request to an endpoint that should be filtered out of the logs. The username and password you see here are just test values. 84 | 85 | ```sh 86 | ❯ http :8000/health -a test_user:r4ndom_bUt_memorable -b 87 | { 88 | "application": "inboard", 89 | "message": null, 90 | "status": "active" 91 | } 92 | ``` 93 | 94 | The server does not display a log message. 95 | 96 | ## Extending the logging config 97 | 98 | If the inboard Python package is installed from PyPI, the logging configuration can be easily customized as explained in the [Python logging configuration docs](https://docs.python.org/3/library/logging.config.html). 99 | 100 | !!! example "Example of a custom logging module" 101 | 102 | ```py 103 | # /app/package/custom_logging.py: set with LOGGING_CONF=package.custom_logging 104 | import logging 105 | import os 106 | 107 | from inboard import LOGGING_CONFIG 108 | 109 | # add a custom logging format: set with LOG_FORMAT=mycustomformat 110 | LOGGING_CONFIG["formatters"]["mycustomformat"] = { 111 | "format": "[%(name)s] %(levelname)s %(message)s" 112 | } 113 | 114 | 115 | class MyFormatterClass(logging.Formatter): 116 | """Define a custom logging format class.""" 117 | 118 | def __init__(self) -> None: 119 | super().__init__(fmt="[%(name)s] %(levelname)s %(message)s") 120 | 121 | 122 | # use a custom logging format class: set with LOG_FORMAT=mycustomclass 123 | LOGGING_CONFIG["formatters"]["mycustomclass"] = { 124 | "()": "package.custom_logging.MyFormatterClass", 125 | } 126 | 127 | # only show access logs when running Uvicorn with LOG_LEVEL=debug 128 | LOGGING_CONFIG["loggers"]["gunicorn.access"] = {"propagate": False} 129 | LOGGING_CONFIG["loggers"]["uvicorn.access"] = { 130 | "propagate": str(os.getenv("LOG_LEVEL")) == "debug" 131 | } 132 | 133 | # don't propagate boto logs 134 | LOGGING_CONFIG["loggers"]["boto3"] = {"propagate": False} 135 | LOGGING_CONFIG["loggers"]["botocore"] = {"propagate": False} 136 | LOGGING_CONFIG["loggers"]["s3transfer"] = {"propagate": False} 137 | 138 | ``` 139 | 140 | ## Overriding the logging config 141 | 142 | Want to override inboard's entire logging config? No problem. Set up a separate `LOGGING_CONFIG` dictionary, and pass inboard the path to the module containing the dictionary. Try something like this: 143 | 144 | !!! example "Example of a complete custom logging config" 145 | 146 | ```py 147 | # /app/package/custom_logging.py: set with LOGGING_CONF=package.custom_logging 148 | from uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG 149 | 150 | LOGGING_CONFIG = { 151 | "version": 1, 152 | # Disable other loggers not specified in the configuration 153 | "disable_existing_loggers": True, 154 | "formatters": { 155 | "gunicorn.access": { 156 | "class": "logging.Formatter", 157 | "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s", 158 | "datefmt": "[%Y-%m-%d %H:%M:%S %z]", 159 | }, 160 | # Format Uvicorn loggers with Uvicorn's config directly 161 | "uvicorn.access": { 162 | "()": UVICORN_LOGGING_CONFIG["formatters"]["access"]["()"], 163 | "format": UVICORN_LOGGING_CONFIG["formatters"]["access"]["fmt"], 164 | }, 165 | "uvicorn.default": { 166 | "()": UVICORN_LOGGING_CONFIG["formatters"]["default"]["()"], 167 | "format": UVICORN_LOGGING_CONFIG["formatters"]["default"]["fmt"], 168 | }, 169 | }, 170 | "handlers": { 171 | "default": { 172 | "class": "logging.StreamHandler", 173 | "formatter": "uvicorn.default", 174 | "level": "INFO", 175 | "stream": "ext://sys.stdout", 176 | }, 177 | # Add a separate handler for stderr 178 | "error": { 179 | "class": "logging.StreamHandler", 180 | "formatter": "uvicorn.default", 181 | "stream": "ext://sys.stderr", 182 | }, 183 | # Add a separate handler just for gunicorn.access 184 | "gunicorn.access": { 185 | "class": "logging.StreamHandler", 186 | "formatter": "gunicorn.access", 187 | "stream": "ext://sys.stdout", 188 | }, 189 | # Add a separate handler just for uvicorn.access 190 | "uvicorn.access": { 191 | "class": "logging.StreamHandler", 192 | "formatter": "uvicorn.access", 193 | "stream": "ext://sys.stdout", 194 | }, 195 | }, 196 | "loggers": { 197 | "fastapi": {"propagate": True}, 198 | # Use the gunicorn.access handler, and don't propagate to root 199 | "gunicorn.access": {"handlers": ["gunicorn.access"], "propagate": False}, 200 | # Use the error handler to output to stderr, and don't propagate to root 201 | "gunicorn.error": { 202 | "handlers": ["error"], 203 | "level": "INFO", 204 | "propagate": False, 205 | }, 206 | # Use the uvicorn.access handler, and don't propagate to root 207 | "uvicorn.access": { 208 | "handlers": ["uvicorn.access"], 209 | "level": "INFO", 210 | "propagate": False, 211 | }, 212 | # Use the error handler to output to stderr, and don't propagate to root 213 | "uvicorn.error": { 214 | "handlers": ["error"], 215 | "level": "INFO", 216 | "propagate": False, 217 | }, 218 | }, 219 | # Use the uvicorn.default formatter for root 220 | "root": {"handlers": ["default"], "level": "INFO"}, 221 | } 222 | 223 | ``` 224 | 225 | ## Design decisions 226 | 227 | ### Simplify logging 228 | 229 | **Logging is complicated in general, but logging a Uvicorn+Gunicorn+Starlette/FastAPI stack is particularly, and unnecessarily, complicated**. Uvicorn and Gunicorn use different logging configurations, and it can be difficult to unify the log streams. 230 | 231 | Gunicorn's API for loading [logging configuration dictionaries](https://docs.python.org/3/library/logging.config.html) has some problems: 232 | 233 | - Gunicorn does not have a clearly-documented interface for running programmatically from within a Python module, like `uvicorn.run()`, so `subprocess.run()` can be used instead. There isn't a clear way to pass logging configuration dictionaries to Gunicorn from the command line, unless you `json.dumps()` a logging configuration dictionary. 234 | - As of Gunicorn version 20, Gunicorn accepted a command-line argument `--log-config-dict`, but it didn't work, and [the maintainers removed it](https://github.com/benoitc/gunicorn/pull/2476). 235 | 236 | Uvicorn's API for loading logging configurations is confusing and poorly documented: 237 | 238 | - The [settings documentation as of version 0.11.8](https://github.com/encode/uvicorn/blob/4597b90ffcfb99e44dae6c7d8cc05e1f368e0624/docs/settings.md) (the version available when this project started) said, "`--log-config ` - Logging configuration file," but there was no information given on file format. 239 | - [encode/uvicorn#665](https://github.com/encode/uvicorn/pull/665) and [Uvicorn 0.12.0](https://github.com/encode/uvicorn/releases/tag/0.12.0) added support for loading JSON and YAML configuration files, but not `.py` files. 240 | - Uvicorn's own logging configuration is a dictionary, `LOGGING_CONFIG`, in [`config.py`](https://github.com/encode/uvicorn/blob/HEAD/uvicorn/config.py), but there's no information provided on how to supply a custom dictionary config. It is possible to pass a dictionary config to Uvicorn when running programmatically, such as `uvicorn.run(log_config=your_dict_config)`, although so far, this capability is only documented in the [changelog](https://github.com/encode/uvicorn/blob/HEAD/CHANGELOG.md) for version 0.10.0. 241 | 242 | **The inboard project eliminates this complication and confusion**. Uvicorn, Gunicorn, and FastAPI log streams are propagated to the root logger, and handled by the custom root logging config. 243 | 244 | ### Require dict configs 245 | 246 | The project initially also had support for the old-format `.conf`/`.ini` files, and YAML files, but this was later dropped, because: 247 | 248 | - **Dict configs are the newer, recommended format**, as explained in the [`logging.config` docs](https://docs.python.org/3/library/logging.config.html): 249 | 250 | > The `fileConfig()` API is older than the `dictConfig()` API and does not provide functionality to cover certain aspects of logging. For example, you cannot configure Filter objects, which provide for filtering of messages beyond simple integer levels, using `fileConfig()`. If you need to have instances of Filter in your logging configuration, you will need to use `dictConfig()`. Note that future enhancements to configuration functionality will be added to `dictConfig()`, so it’s worth considering transitioning to this newer API when it’s convenient to do so. 251 | 252 | - **Dict configs allow programmatic control of logging settings** (see how log level is set in [`logging_conf.py`](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/logging_conf.py) for an example). 253 | - **Gunicorn and Uvicorn both use dict configs in `.py` files for their own logging configurations**. 254 | - **Gunicorn prefers dict configs** specified with the [`logconfig_dict` option](https://docs.gunicorn.org/en/latest/settings.html#logconfig-dict). 255 | - **Uvicorn accepts dict configs when running programmatically**, like `uvicorn.run(log_config=your_dict_config)`. 256 | - **Relying on Python dictionaries reduces testing burden** (only have to write unit tests for `.py` files) 257 | - **YAML isn't a Python data structure**. YAML is confusingly used for examples in the documentation, but isn't actually a recommended format. There's no built-in YAML data structure in Python, so the YAML must be parsed by PyYAML and converted into a dictionary, then passed to `logging.config.dictConfig()`. **Why not just make the logging config a dictionary in the first place?** 258 | 259 | ## Further info 260 | 261 | For more details on how logging was implemented initially, see [br3ndonland/inboard#3](https://github.com/br3ndonland/inboard/pull/3). 262 | 263 | For more information on Python logging configuration, see the [Python `logging` how-to](https://docs.python.org/3/howto/logging.html), [Python `logging` cookbook](https://docs.python.org/3/howto/logging-cookbook.html), [Python `logging` module docs](https://docs.python.org/3/library/logging.html), and [Python `logging.config` module docs](https://docs.python.org/3/library/logging.config.html). Also consider [Loguru](https://loguru.readthedocs.io/en/stable/index.html), an alternative logging module with many improvements over the standard library `logging` module. 264 | -------------------------------------------------------------------------------- /inboard/__init__.py: -------------------------------------------------------------------------------- 1 | """inboard 2 | 3 | Docker images and utilities to power your Python APIs and help you ship faster. 4 | 5 | https://github.com/br3ndonland/inboard 6 | """ 7 | 8 | # FastAPI and Starlette are optional, and will raise ImportErrors if not installed. 9 | try: 10 | from .app.utilities_fastapi import basic_auth as fastapi_basic_auth 11 | except ImportError: # pragma: no cover 12 | pass 13 | try: 14 | from .app.utilities_starlette import BasicAuth as StarletteBasicAuth 15 | except ImportError: # pragma: no cover 16 | pass 17 | from .logging_conf import LOGGING_CONFIG, LogFilter, configure_logging 18 | 19 | __all__ = ( 20 | "LOGGING_CONFIG", 21 | "LogFilter", 22 | "StarletteBasicAuth", 23 | "configure_logging", 24 | "fastapi_basic_auth", 25 | ) 26 | 27 | __version__ = "0.72.3" 28 | -------------------------------------------------------------------------------- /inboard/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/br3ndonland/inboard/f801c7931377ddbb47d5cb4f9692c9bdd5657db5/inboard/app/__init__.py -------------------------------------------------------------------------------- /inboard/app/main_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from uvicorn._types import ( 9 | ASGIReceiveCallable, 10 | ASGISendCallable, 11 | HTTPResponseBodyEvent, 12 | HTTPResponseStartEvent, 13 | Scope, 14 | ) 15 | 16 | 17 | def _compose_message() -> str: 18 | version = ( 19 | f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" 20 | ) 21 | process_manager = os.getenv("PROCESS_MANAGER", "gunicorn") 22 | if process_manager not in {"gunicorn", "uvicorn"}: 23 | raise NameError("Process manager needs to be either uvicorn or gunicorn.") 24 | server = "Uvicorn" if process_manager == "uvicorn" else "Uvicorn, Gunicorn," 25 | return f"Hello World, from {server} and Python {version}!" 26 | 27 | 28 | async def app( 29 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 30 | ) -> None: 31 | """Define a simple ASGI 3 application for use with Uvicorn. 32 | 33 | https://asgi.readthedocs.io/en/stable/introduction.html 34 | https://asgi.readthedocs.io/en/stable/specs/main.html#applications 35 | https://www.uvicorn.org/ 36 | """ 37 | assert scope["type"] == "http" 38 | message = _compose_message() 39 | start_event: HTTPResponseStartEvent = { 40 | "type": "http.response.start", 41 | "status": 200, 42 | "headers": [(b"content-type", b"text/plain")], 43 | "trailers": False, 44 | } 45 | body_event: HTTPResponseBodyEvent = { 46 | "type": "http.response.body", 47 | "body": message.encode("utf-8"), 48 | "more_body": False, 49 | } 50 | await send(start_event) 51 | await send(body_event) 52 | -------------------------------------------------------------------------------- /inboard/app/main_fastapi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Annotated, Optional 4 | 5 | from fastapi import Depends, FastAPI, status 6 | from fastapi.middleware import Middleware 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from pydantic import BaseModel 9 | 10 | from inboard.app.utilities_fastapi import basic_auth as fastapi_basic_auth 11 | 12 | BasicAuth = Annotated[str, Depends(fastapi_basic_auth)] 13 | origin_regex = r"^(https?:\/\/)(localhost|([\w\.]+\.)?br3ndon.land)(:[0-9]+)?$" 14 | server = ( 15 | "Uvicorn" 16 | if (value := os.getenv("PROCESS_MANAGER")) and value.title() == "Uvicorn" 17 | else "Uvicorn, Gunicorn" 18 | ) 19 | version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" 20 | 21 | 22 | class GetRoot(BaseModel): 23 | Hello: str = "World" 24 | 25 | 26 | class GetStatus(BaseModel): 27 | application: str 28 | status: str 29 | message: Optional[str] 30 | 31 | 32 | class GetUser(BaseModel): 33 | username: str 34 | 35 | 36 | middleware = [ 37 | Middleware( 38 | CORSMiddleware, 39 | allow_credentials=True, 40 | allow_headers=["*"], 41 | allow_methods=["*"], 42 | allow_origin_regex=origin_regex, 43 | ) 44 | ] 45 | app = FastAPI(middleware=middleware, title="inboard") 46 | 47 | 48 | @app.get("/", status_code=status.HTTP_200_OK) 49 | async def get_root() -> GetRoot: 50 | return GetRoot() 51 | 52 | 53 | @app.get("/health", status_code=status.HTTP_200_OK) 54 | async def get_health(auth: BasicAuth) -> GetStatus: 55 | return GetStatus(application=app.title, status="active", message=None) 56 | 57 | 58 | @app.get("/status", status_code=status.HTTP_200_OK) 59 | async def get_status(auth: BasicAuth) -> GetStatus: 60 | return GetStatus( 61 | application=app.title, 62 | status="active", 63 | message=f"Hello World, from {server}, FastAPI, and Python {version}!", 64 | ) 65 | 66 | 67 | @app.get("/users/me", status_code=status.HTTP_200_OK) 68 | async def get_current_user(auth: BasicAuth) -> GetUser: 69 | return GetUser(username=auth) 70 | -------------------------------------------------------------------------------- /inboard/app/main_starlette.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from starlette.applications import Starlette 5 | from starlette.authentication import requires 6 | from starlette.middleware import Middleware 7 | from starlette.middleware.authentication import AuthenticationMiddleware 8 | from starlette.middleware.cors import CORSMiddleware 9 | from starlette.requests import Request 10 | from starlette.responses import JSONResponse 11 | from starlette.routing import Route 12 | 13 | from inboard.app.utilities_starlette import BasicAuth 14 | 15 | origin_regex = r"^(https?:\/\/)(localhost|([\w\.]+\.)?br3ndon.land)(:[0-9]+)?$" 16 | server = ( 17 | "Uvicorn" 18 | if (value := os.getenv("PROCESS_MANAGER")) and value.title() == "Uvicorn" 19 | else "Uvicorn, Gunicorn" 20 | ) 21 | version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" 22 | 23 | 24 | def on_auth_error(request: Request, e: Exception) -> JSONResponse: 25 | return JSONResponse( 26 | {"error": "Incorrect username or password", "detail": str(e)}, status_code=401 27 | ) 28 | 29 | 30 | async def get_root(request: Request) -> JSONResponse: 31 | return JSONResponse({"Hello": "World"}) 32 | 33 | 34 | @requires("authenticated") 35 | async def get_health(request: Request) -> JSONResponse: 36 | return JSONResponse({"application": "inboard", "status": "active"}) 37 | 38 | 39 | @requires("authenticated") 40 | async def get_status(request: Request) -> JSONResponse: 41 | message = f"Hello World, from {server}, Starlette, and Python {version}!" 42 | return JSONResponse( 43 | {"application": "inboard", "status": "active", "message": message} 44 | ) 45 | 46 | 47 | @requires("authenticated") 48 | async def get_current_user(request: Request) -> JSONResponse: 49 | return JSONResponse({"username": request.user.display_name}) 50 | 51 | 52 | middleware = [ 53 | Middleware( 54 | AuthenticationMiddleware, 55 | backend=BasicAuth(), 56 | on_error=on_auth_error, 57 | ), 58 | Middleware( 59 | CORSMiddleware, 60 | allow_credentials=True, 61 | allow_headers=["*"], 62 | allow_methods=["*"], 63 | allow_origin_regex=origin_regex, 64 | ), 65 | ] 66 | routes = [ 67 | Route("/", endpoint=get_root), 68 | Route("/health", endpoint=get_health), 69 | Route("/status", endpoint=get_status), 70 | Route("/users/me", endpoint=get_current_user), 71 | ] 72 | app = Starlette(middleware=middleware, routes=routes) 73 | -------------------------------------------------------------------------------- /inboard/app/prestart.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from pathlib import Path 3 | 4 | print( 5 | f"[{Path(__file__).stem}] Hello World, from prestart.py!", 6 | "Add database migrations and other scripts here.", 7 | ) 8 | -------------------------------------------------------------------------------- /inboard/app/utilities_fastapi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | from typing import Annotated 4 | 5 | from fastapi import Depends, HTTPException, status 6 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 7 | 8 | HTTPBasicCredentialsDependency = Annotated[HTTPBasicCredentials, Depends(HTTPBasic())] 9 | 10 | 11 | async def basic_auth(credentials: HTTPBasicCredentialsDependency) -> str: 12 | """Authenticate a FastAPI request with HTTP Basic auth.""" 13 | basic_auth_username = os.getenv("BASIC_AUTH_USERNAME") 14 | basic_auth_password = os.getenv("BASIC_AUTH_PASSWORD") 15 | if not (basic_auth_username and basic_auth_password): 16 | raise HTTPException( 17 | status_code=status.HTTP_401_UNAUTHORIZED, 18 | detail="Server HTTP Basic auth credentials not set", 19 | headers={"WWW-Authenticate": "Basic"}, 20 | ) 21 | correct_username = secrets.compare_digest(credentials.username, basic_auth_username) 22 | correct_password = secrets.compare_digest(credentials.password, basic_auth_password) 23 | if not (correct_username and correct_password): 24 | raise HTTPException( 25 | status_code=status.HTTP_401_UNAUTHORIZED, 26 | detail="HTTP Basic auth credentials not correct", 27 | headers={"WWW-Authenticate": "Basic"}, 28 | ) 29 | return credentials.username 30 | -------------------------------------------------------------------------------- /inboard/app/utilities_starlette.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import os 5 | import secrets 6 | 7 | from starlette.authentication import ( 8 | AuthCredentials, 9 | AuthenticationBackend, 10 | AuthenticationError, 11 | SimpleUser, 12 | ) 13 | from starlette.requests import HTTPConnection 14 | 15 | 16 | class BasicAuth(AuthenticationBackend): 17 | """Configure HTTP Basic auth for Starlette.""" 18 | 19 | async def authenticate( 20 | self, request: HTTPConnection 21 | ) -> tuple[AuthCredentials, SimpleUser] | None: 22 | """Authenticate a Starlette request with HTTP Basic auth.""" 23 | if "Authorization" not in request.headers: 24 | return None 25 | try: 26 | auth = request.headers["Authorization"] 27 | basic_auth_username = os.getenv("BASIC_AUTH_USERNAME") 28 | basic_auth_password = os.getenv("BASIC_AUTH_PASSWORD") 29 | if not (basic_auth_username and basic_auth_password): 30 | raise AuthenticationError("Server HTTP Basic auth credentials not set") 31 | scheme, credentials = auth.split() 32 | decoded = base64.b64decode(credentials).decode("ascii") 33 | username, _, password = decoded.partition(":") 34 | correct_username = secrets.compare_digest(username, basic_auth_username) 35 | correct_password = secrets.compare_digest(password, basic_auth_password) 36 | if not (correct_username and correct_password): 37 | raise AuthenticationError("HTTP Basic auth credentials not correct") 38 | return AuthCredentials(["authenticated"]), SimpleUser(username) 39 | except Exception: 40 | raise 41 | -------------------------------------------------------------------------------- /inboard/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import multiprocessing 4 | import os 5 | 6 | from inboard.logging_conf import configure_logging 7 | 8 | 9 | def calculate_workers( 10 | max_workers: str | None = None, 11 | total_workers: str | None = None, 12 | workers_per_core: str = "1", 13 | ) -> int: 14 | """Calculate the number of Gunicorn worker processes.""" 15 | cores = multiprocessing.cpu_count() 16 | default = max(int(float(workers_per_core) * cores), 2) 17 | use_max = m if max_workers and (m := int(max_workers)) > 0 else False 18 | use_total = t if total_workers and (t := int(total_workers)) > 0 else False 19 | use_least = min(use_max, use_total) if use_max and use_total else False 20 | use_default = min(use_max, default) if use_max else default 21 | return use_least or use_total or use_default 22 | 23 | 24 | # Gunicorn settings 25 | bind = os.getenv("BIND") or f"{os.getenv('HOST', '0.0.0.0')}:{os.getenv('PORT', '80')}" 26 | accesslog = os.getenv("ACCESS_LOG", "-") 27 | errorlog = os.getenv("ERROR_LOG", "-") 28 | graceful_timeout = int(os.getenv("GRACEFUL_TIMEOUT", "120")) 29 | keepalive = int(os.getenv("KEEP_ALIVE", "5")) 30 | logconfig_dict = configure_logging() 31 | loglevel = os.getenv("LOG_LEVEL", "info") 32 | timeout = int(os.getenv("TIMEOUT", "120")) 33 | worker_tmp_dir = "/dev/shm" 34 | workers = calculate_workers( 35 | os.getenv("MAX_WORKERS"), 36 | os.getenv("WEB_CONCURRENCY"), 37 | workers_per_core=os.getenv("WORKERS_PER_CORE", "1"), 38 | ) 39 | -------------------------------------------------------------------------------- /inboard/gunicorn_workers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright © 2017-present, [Encode OSS Ltd](https://www.encode.io/). 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | """ 30 | 31 | import asyncio 32 | import logging 33 | import signal 34 | import sys 35 | from typing import Any 36 | 37 | from gunicorn.arbiter import Arbiter # type: ignore[import-untyped] 38 | from gunicorn.workers.base import Worker # type: ignore[import-untyped] 39 | from uvicorn.config import Config 40 | from uvicorn.server import Server 41 | 42 | 43 | class UvicornWorker(Worker): # type: ignore[misc] 44 | """ 45 | A worker class for Gunicorn that interfaces with an ASGI consumer callable, 46 | rather than a WSGI callable. 47 | """ 48 | 49 | CONFIG_KWARGS: dict[str, Any] = {"loop": "auto", "http": "auto"} 50 | 51 | def __init__(self, *args: Any, **kwargs: Any) -> None: 52 | super().__init__(*args, **kwargs) 53 | 54 | logger = logging.getLogger("uvicorn.error") 55 | logger.handlers = self.log.error_log.handlers 56 | logger.setLevel(self.log.error_log.level) 57 | logger.propagate = False 58 | 59 | logger = logging.getLogger("uvicorn.access") 60 | logger.handlers = self.log.access_log.handlers 61 | logger.setLevel(self.log.access_log.level) 62 | logger.propagate = False 63 | 64 | config_kwargs: dict[str, Any] = { 65 | "app": None, 66 | "log_config": None, 67 | "timeout_keep_alive": self.cfg.keepalive, 68 | "timeout_notify": self.timeout, 69 | "callback_notify": self.callback_notify, 70 | "limit_max_requests": self.max_requests, 71 | "forwarded_allow_ips": self.cfg.forwarded_allow_ips, 72 | } 73 | 74 | if self.cfg.is_ssl: 75 | ssl_kwargs = { 76 | "ssl_keyfile": self.cfg.ssl_options.get("keyfile"), 77 | "ssl_certfile": self.cfg.ssl_options.get("certfile"), 78 | "ssl_keyfile_password": self.cfg.ssl_options.get("password"), 79 | "ssl_version": self.cfg.ssl_options.get("ssl_version"), 80 | "ssl_cert_reqs": self.cfg.ssl_options.get("cert_reqs"), 81 | "ssl_ca_certs": self.cfg.ssl_options.get("ca_certs"), 82 | "ssl_ciphers": self.cfg.ssl_options.get("ciphers"), 83 | } 84 | config_kwargs.update(ssl_kwargs) 85 | 86 | if self.cfg.settings["backlog"].value: 87 | config_kwargs["backlog"] = self.cfg.settings["backlog"].value 88 | 89 | config_kwargs.update(self.CONFIG_KWARGS) 90 | 91 | self.config = Config(**config_kwargs) 92 | 93 | def init_process(self) -> None: 94 | self.config.setup_event_loop() 95 | super().init_process() 96 | 97 | def init_signals(self) -> None: 98 | # Reset signals so Gunicorn doesn't swallow subprocess return codes 99 | # other signals are set up by Server.install_signal_handlers() 100 | # See: https://github.com/encode/uvicorn/issues/894 101 | for s in self.SIGNALS: 102 | signal.signal(s, signal.SIG_DFL) 103 | 104 | signal.signal(signal.SIGUSR1, self.handle_usr1) 105 | # Don't let SIGUSR1 disturb active requests by interrupting system calls 106 | signal.siginterrupt(signal.SIGUSR1, False) 107 | 108 | def _install_sigquit_handler(self) -> None: 109 | """Install a SIGQUIT handler on workers. 110 | 111 | - https://github.com/encode/uvicorn/issues/1116 112 | - https://github.com/benoitc/gunicorn/issues/2604 113 | """ 114 | 115 | loop = asyncio.get_running_loop() 116 | loop.add_signal_handler(signal.SIGQUIT, self.handle_exit, signal.SIGQUIT, None) 117 | 118 | async def _serve(self) -> None: 119 | self.config.app = self.wsgi 120 | server = Server(config=self.config) 121 | self._install_sigquit_handler() 122 | await server.serve(sockets=self.sockets) 123 | if not server.started: 124 | sys.exit(Arbiter.WORKER_BOOT_ERROR) 125 | 126 | def run(self) -> None: 127 | return asyncio.run(self._serve()) 128 | 129 | async def callback_notify(self) -> None: 130 | self.notify() 131 | 132 | 133 | class UvicornH11Worker(UvicornWorker): 134 | CONFIG_KWARGS = {"loop": "asyncio", "http": "h11"} 135 | -------------------------------------------------------------------------------- /inboard/logging_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.util 4 | import logging 5 | import logging.config 6 | import os 7 | import sys 8 | from pathlib import Path 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from inboard.types import DictConfig 13 | 14 | 15 | def find_and_load_logging_conf(logging_conf: str) -> DictConfig: 16 | """Find and load a logging configuration module or file.""" 17 | logging_conf_path = Path(logging_conf) 18 | spec = ( 19 | importlib.util.spec_from_file_location("confspec", logging_conf_path) 20 | if logging_conf_path.is_file() and logging_conf_path.suffix == ".py" 21 | else importlib.util.find_spec(logging_conf) 22 | ) 23 | if not spec: 24 | raise ImportError(f"Unable to import {logging_conf_path}") 25 | logging_conf_module = importlib.util.module_from_spec(spec) 26 | exec_module = getattr(spec.loader, "exec_module") 27 | exec_module(logging_conf_module) 28 | if not hasattr(logging_conf_module, "LOGGING_CONFIG"): 29 | raise AttributeError(f"No LOGGING_CONFIG in {logging_conf_module.__name__}") 30 | logging_conf_dict: DictConfig = getattr(logging_conf_module, "LOGGING_CONFIG") 31 | if not isinstance(logging_conf_dict, dict): 32 | raise TypeError("LOGGING_CONFIG is not a dictionary instance") 33 | return logging_conf_dict 34 | 35 | 36 | def configure_logging( 37 | logger: logging.Logger = logging.getLogger(), 38 | logging_conf: str | None = os.getenv("LOGGING_CONF"), 39 | ) -> DictConfig: 40 | """Configure Python logging given the name of a logging module or file.""" 41 | try: 42 | if not logging_conf: 43 | logging_conf_path = __name__ 44 | logging_conf_dict: DictConfig = LOGGING_CONFIG 45 | else: 46 | logging_conf_path = logging_conf 47 | logging_conf_dict = find_and_load_logging_conf(logging_conf_path) 48 | logging.config.dictConfig(logging_conf_dict) # type: ignore[arg-type] 49 | logger.debug(f"Logging dict config loaded from {logging_conf_path}.") 50 | return logging_conf_dict 51 | except Exception as e: 52 | logger.error(f"Error when setting logging module: {e.__class__.__name__} {e}.") 53 | raise 54 | 55 | 56 | class LogFilter(logging.Filter): 57 | """Subclass of `logging.Filter` used to filter log messages. 58 | --- 59 | 60 | Filters identify log messages to filter out, so that the logger does not log 61 | messages containing any of the filters. If any matches are present in a log 62 | message, the logger will not output the message. 63 | 64 | The environment variable `LOG_FILTERS` can be used to specify filters as a 65 | comma-separated string, like `LOG_FILTERS="/health, /heartbeat"`. To then 66 | add the filters to a class instance, the `LogFilter.set_filters()` 67 | method can produce the set of filters from the environment variable value. 68 | """ 69 | 70 | __slots__ = "name", "nlen", "filters" 71 | 72 | def __init__( 73 | self, 74 | name: str = "", 75 | filters: set[str] | None = None, 76 | ) -> None: 77 | """Initialize a filter.""" 78 | self.name = name 79 | self.nlen = len(name) 80 | self.filters = filters 81 | 82 | def filter(self, record: logging.LogRecord) -> bool: 83 | """Determine if the specified record is to be logged. 84 | 85 | Returns True if the record should be logged, or False otherwise. 86 | """ 87 | if self.filters is None: 88 | return True 89 | message = record.getMessage() 90 | return all(match not in message for match in self.filters) 91 | 92 | @staticmethod 93 | def set_filters(input_filters: str | None = None) -> set[str] | None: 94 | """Set log message filters. 95 | 96 | Filters identify log messages to filter out, so that the logger does not 97 | log messages containing any of the filters. The argument to this method 98 | should be supplied as a comma-separated string. The string will be split 99 | on commas and converted to a set of strings. 100 | 101 | This method is provided as a `staticmethod`, instead of as part of `__init__`, 102 | so that it only runs once when setting the `LOG_FILTERS` module-level constant. 103 | In contrast, the `__init__` method runs each time a logger is instantiated. 104 | """ 105 | return ( 106 | {log_filter.strip() for log_filter in str(log_filters).split(sep=",")} 107 | if (log_filters := input_filters or os.getenv("LOG_FILTERS")) 108 | else None 109 | ) 110 | 111 | 112 | LOG_COLORS = ( 113 | True 114 | if (value := os.getenv("LOG_COLORS")) and value.lower() == "true" 115 | else False 116 | if value and value.lower() == "false" 117 | else sys.stdout.isatty() 118 | ) 119 | LOG_FILTERS = LogFilter.set_filters() 120 | LOG_FORMAT = str(os.getenv("LOG_FORMAT", "simple")) 121 | LOG_LEVEL = str(os.getenv("LOG_LEVEL", "info")).upper() 122 | LOGGING_CONFIG: DictConfig = { 123 | "version": 1, 124 | "disable_existing_loggers": False, 125 | "filters": { 126 | "filter_log_message": {"()": LogFilter, "filters": LOG_FILTERS}, 127 | }, 128 | "formatters": { 129 | "simple": { 130 | "class": "logging.Formatter", 131 | "format": "%(levelname)-10s %(message)s", 132 | }, 133 | "verbose": { 134 | "class": "logging.Formatter", 135 | "format": ( 136 | "%(asctime)-30s %(process)-10d %(name)-15s " 137 | "%(module)-15s %(levelname)-10s %(message)s" 138 | ), 139 | "datefmt": "%Y-%m-%d %H:%M:%S %z", 140 | }, 141 | "gunicorn": { 142 | "class": "logging.Formatter", 143 | "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s", 144 | "datefmt": "[%Y-%m-%d %H:%M:%S %z]", 145 | }, 146 | "uvicorn": { 147 | "()": "uvicorn.logging.DefaultFormatter", 148 | "format": "%(levelprefix)s %(message)s", 149 | "use_colors": LOG_COLORS, 150 | }, 151 | }, 152 | "handlers": { 153 | "default": { 154 | "class": "logging.StreamHandler", 155 | "filters": ["filter_log_message"], 156 | "formatter": LOG_FORMAT, 157 | "level": LOG_LEVEL, 158 | "stream": "ext://sys.stdout", 159 | } 160 | }, 161 | "root": {"handlers": ["default"], "level": LOG_LEVEL}, 162 | "loggers": { 163 | "fastapi": {"propagate": True}, 164 | "gunicorn.access": {"handlers": ["default"], "propagate": True}, 165 | "gunicorn.error": {"propagate": True}, 166 | "uvicorn": {"propagate": True}, 167 | "uvicorn.access": {"propagate": True}, 168 | "uvicorn.asgi": {"propagate": True}, 169 | "uvicorn.error": {"propagate": True}, 170 | }, 171 | } 172 | -------------------------------------------------------------------------------- /inboard/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/br3ndonland/inboard/f801c7931377ddbb47d5cb4f9692c9bdd5657db5/inboard/py.typed -------------------------------------------------------------------------------- /inboard/start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import importlib.util 5 | import json 6 | import logging 7 | import os 8 | import subprocess 9 | from pathlib import Path 10 | from typing import TYPE_CHECKING 11 | 12 | import uvicorn 13 | 14 | from inboard.logging_conf import configure_logging 15 | 16 | if TYPE_CHECKING: 17 | from inboard.types import DictConfig, UvicornOptions 18 | 19 | 20 | def run_pre_start_script(logger: logging.Logger = logging.getLogger()) -> str: 21 | """Run a pre-start script at the provided path.""" 22 | pre_start_path = os.getenv("PRE_START_PATH") 23 | logger.debug("Checking for pre-start script.") 24 | if not pre_start_path: 25 | message = "No pre-start script specified." 26 | elif Path(pre_start_path).is_file(): 27 | process = "python" if Path(pre_start_path).suffix == ".py" else "sh" 28 | run_message = f"Running pre-start script with {process} {pre_start_path}." 29 | logger.debug(run_message) 30 | subprocess.run([process, pre_start_path], check=True) 31 | message = f"Ran pre-start script with {process} {pre_start_path}." 32 | else: 33 | message = "No pre-start script found." 34 | logger.debug(message) 35 | return message 36 | 37 | 38 | def set_app_module(logger: logging.Logger = logging.getLogger()) -> str: 39 | """Set the name of the Python module with the app instance to run.""" 40 | try: 41 | app_module = os.getenv("APP_MODULE") or os.getenv("UVICORN_APP") 42 | if not app_module: 43 | raise ValueError("Please set the APP_MODULE environment variable") 44 | if not importlib.util.find_spec(module := app_module.split(sep=":")[0]): 45 | raise ImportError(f"Unable to find or import {module}") 46 | logger.debug(f"App module set to {app_module}.") 47 | return app_module 48 | except Exception as e: 49 | logger.error(f"Error when setting app module: {e.__class__.__name__} {e}.") 50 | raise 51 | 52 | 53 | def set_gunicorn_options(app_module: str) -> list[str]: 54 | """Set options for running the Gunicorn server.""" 55 | gunicorn_conf_path = os.getenv("GUNICORN_CONF", "python:inboard.gunicorn_conf") 56 | worker_class = os.getenv("WORKER_CLASS", "inboard.gunicorn_workers.UvicornWorker") 57 | if "python:" not in gunicorn_conf_path and not Path(gunicorn_conf_path).is_file(): 58 | raise FileNotFoundError(f"Unable to find {gunicorn_conf_path}") 59 | return ["gunicorn", "-k", worker_class, "-c", gunicorn_conf_path, app_module] 60 | 61 | 62 | def _split_uvicorn_option(option: str) -> list[str] | None: 63 | return ( 64 | [option_item.strip() for option_item in str(option_value).split(sep=",")] 65 | if (option_value := os.getenv(option.upper())) 66 | else None 67 | ) 68 | 69 | 70 | def _update_uvicorn_options(uvicorn_options: UvicornOptions) -> UvicornOptions: 71 | if uvicorn.__version__ >= "0.15.0": 72 | reload_delay = float(value) if (value := os.getenv("RELOAD_DELAY")) else 0.25 73 | reload_excludes = _split_uvicorn_option("RELOAD_EXCLUDES") 74 | reload_includes = _split_uvicorn_option("RELOAD_INCLUDES") 75 | uvicorn_options["reload_delay"] = reload_delay 76 | uvicorn_options["reload_includes"] = reload_includes 77 | uvicorn_options["reload_excludes"] = reload_excludes 78 | if value := os.getenv("UVICORN_CONFIG_OPTIONS"): 79 | uvicorn_options_json = json.loads(value) 80 | uvicorn_options.update(uvicorn_options_json) 81 | return uvicorn_options 82 | 83 | 84 | def set_uvicorn_options( 85 | app_module: str, 86 | log_config: DictConfig | None = None, 87 | ) -> UvicornOptions: 88 | """Set options for running the Uvicorn server.""" 89 | host = os.getenv("HOST", "0.0.0.0") 90 | port = int(os.getenv("PORT", "80")) 91 | log_level = os.getenv("LOG_LEVEL", "info") 92 | reload_dirs = _split_uvicorn_option("RELOAD_DIRS") 93 | use_reload = bool((value := os.getenv("WITH_RELOAD")) and value.lower() == "true") 94 | uvicorn_options: UvicornOptions = dict( 95 | app=app_module, 96 | host=host, 97 | port=port, 98 | log_config=log_config, 99 | log_level=log_level, 100 | reload=use_reload, 101 | reload_dirs=reload_dirs, 102 | ) 103 | return _update_uvicorn_options(uvicorn_options) 104 | 105 | 106 | def start_server( 107 | process_manager: str, 108 | app_module: str, 109 | logger: logging.Logger = logging.getLogger(), 110 | logging_conf_dict: DictConfig | None = None, 111 | ) -> None: 112 | """Start the Uvicorn or Gunicorn server.""" 113 | try: 114 | if process_manager == "gunicorn": 115 | logger.debug("Running Uvicorn with Gunicorn.") 116 | gunicorn_options: list[str] = set_gunicorn_options(app_module) 117 | subprocess.run(gunicorn_options) 118 | elif process_manager == "uvicorn": 119 | logger.debug("Running Uvicorn without Gunicorn.") 120 | uvicorn_options: UvicornOptions = set_uvicorn_options( 121 | app_module, log_config=logging_conf_dict 122 | ) 123 | uvicorn.run(**uvicorn_options) # type: ignore[arg-type] 124 | else: 125 | raise NameError("Process manager needs to be either uvicorn or gunicorn") 126 | except Exception as e: 127 | logger.error(f"Error when starting server: {e.__class__.__name__} {e}.") 128 | raise 129 | 130 | 131 | if __name__ == "__main__": # pragma: no cover 132 | logger = logging.getLogger() 133 | logging_conf_dict = configure_logging(logger=logger) 134 | run_pre_start_script(logger=logger) 135 | start_server( 136 | str(os.getenv("PROCESS_MANAGER", "gunicorn")), 137 | app_module=set_app_module(logger=logger), 138 | logger=logger, 139 | logging_conf_dict=logging_conf_dict, 140 | ) 141 | -------------------------------------------------------------------------------- /inboard/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypedDict 4 | 5 | if TYPE_CHECKING: 6 | import sys 7 | from asyncio import Protocol 8 | from collections.abc import Sequence 9 | from os import PathLike 10 | from typing import Any, Literal 11 | 12 | if sys.version_info < (3, 11): 13 | from typing_extensions import Required 14 | else: 15 | from typing import Required 16 | 17 | from uvicorn._types import ASGIApplication 18 | from uvicorn.config import ( 19 | HTTPProtocolType, 20 | InterfaceType, 21 | LifespanType, 22 | LoopSetupType, 23 | WSProtocolType, 24 | ) 25 | 26 | 27 | class _RootLoggerConfiguration(TypedDict, total=False): 28 | level: int | str 29 | filters: Sequence[str] 30 | handlers: Sequence[str] 31 | 32 | 33 | class _LoggerConfiguration(_RootLoggerConfiguration, TypedDict, total=False): 34 | propagate: bool 35 | 36 | 37 | class _OptionalDictConfigArgs(TypedDict, total=False): 38 | formatters: dict[str, dict[str, Any]] 39 | filters: dict[str, dict[str, Any]] 40 | handlers: dict[str, dict[str, Any]] 41 | loggers: dict[str, _LoggerConfiguration] 42 | root: _RootLoggerConfiguration | None 43 | incremental: bool 44 | disable_existing_loggers: bool 45 | 46 | 47 | class DictConfig(_OptionalDictConfigArgs, TypedDict): 48 | """Python standard library logging module dict config type. 49 | --- 50 | 51 | https://docs.python.org/3/library/logging.config.html 52 | https://github.com/python/typeshed/blob/main/stdlib/logging/config.pyi 53 | """ 54 | 55 | version: Literal[1] 56 | 57 | 58 | class UvicornOptions(TypedDict, total=False): 59 | """Type for options passed to `uvicorn.run` and `uvicorn.Config`. 60 | --- 61 | 62 | "Options" are positional or keyword arguments passed to `uvicorn.run()` or 63 | `uvicorn.Config.__init__()`. The signatures of the two functions are not exactly 64 | the same ([encode/uvicorn#1545]). This type is primarily intended to match the 65 | arguments to `uvicorn.run()`. 66 | 67 | The `app` argument to `uvicorn.run()` accepts a `Callable` because Uvicorn tests use 68 | callables ([encode/uvicorn#1067]). It is not necessary for other packages to accept 69 | `Callable`, so `Callable` is not accepted in the `app` field of this type. 70 | 71 | The `log_config` argument in this type uses the inboard `DictConfig` type 72 | instead of `dict[str, Any]` for stricter type checking. 73 | 74 | It would be convenient to generate this type dynamically from `uvicorn.run` 75 | by accessing its [annotations dict][type annotation practices] 76 | with `getattr(uvicorn.run, "__annotations__")` (Python 3.9 or earlier) 77 | or `inspect.get_annotations(uvicorn.run)` (Python 3.10 or later). 78 | 79 | It could look like this with [`TypedDict` functional syntax][typing docs]: 80 | 81 | ```py 82 | UvicornArgs = TypedDict( # type: ignore[misc] 83 | "UvicornArgs", 84 | inspect.get_annotations(uvicorn.run), 85 | total=False, 86 | ) 87 | ``` 88 | 89 | Note the `type: ignore[misc]` comment. Mypy raises a `misc` error: 90 | `TypedDict() expects a dictionary literal as the second argument`. 91 | Unfortunately, `TypedDict` types are not intended to be generated 92 | dynamically, because they exist for the benefit of static type checking 93 | ([python/mypy#3932], [python/mypy#4128], [python/mypy#13940]). 94 | 95 | [encode/uvicorn#1067]: https://github.com/encode/uvicorn/pull/1067 96 | [encode/uvicorn#1545]: https://github.com/encode/uvicorn/pull/1545 97 | [python/mypy#3932]: https://github.com/python/mypy/issues/3932 98 | [python/mypy#4128]: https://github.com/python/mypy/issues/4128 99 | [python/mypy#13940]: https://github.com/python/mypy/issues/13940 100 | [type annotation practices]: https://docs.python.org/3/howto/annotations.html 101 | [typing docs]: https://docs.python.org/3/library/typing.html#typing.TypedDict 102 | """ 103 | 104 | app: Required[ASGIApplication | str] 105 | host: str 106 | port: int 107 | uds: str | None 108 | fd: int | None 109 | loop: LoopSetupType 110 | http: type[Protocol] | HTTPProtocolType 111 | ws: type[Protocol] | WSProtocolType 112 | ws_max_queue: int 113 | ws_max_size: int 114 | ws_ping_interval: float | None 115 | ws_ping_timeout: float | None 116 | ws_per_message_deflate: bool 117 | lifespan: LifespanType 118 | interface: InterfaceType 119 | reload: bool 120 | reload_dirs: list[str] | str | None 121 | reload_includes: list[str] | str | None 122 | reload_excludes: list[str] | str | None 123 | reload_delay: float 124 | workers: int | None 125 | env_file: str | PathLike[str] | None 126 | log_config: DictConfig | None 127 | log_level: str | int | None 128 | access_log: bool 129 | proxy_headers: bool 130 | server_header: bool 131 | date_header: bool 132 | forwarded_allow_ips: list[str] | str | None 133 | root_path: str 134 | limit_concurrency: int | None 135 | backlog: int 136 | limit_max_requests: int | None 137 | timeout_keep_alive: int 138 | timeout_graceful_shutdown: int | None 139 | ssl_keyfile: str | None 140 | ssl_certfile: str | PathLike[str] | None 141 | ssl_keyfile_password: str | None 142 | ssl_version: int 143 | ssl_cert_reqs: int 144 | ssl_ca_certs: str | None 145 | ssl_ciphers: str 146 | headers: list[tuple[str, str]] | None 147 | use_colors: bool | None 148 | app_dir: str | None 149 | factory: bool 150 | h11_max_incomplete_event_size: int 151 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | edit_uri: "" 2 | markdown_extensions: 3 | - admonition 4 | - pymdownx.inlinehilite 5 | - pymdownx.magiclink: 6 | user: br3ndonland 7 | repo: inboard 8 | repo_url_shorthand: true 9 | - pymdownx.snippets 10 | - pymdownx.superfences 11 | - toc: 12 | permalink: true 13 | toc_depth: 3 14 | nav: 15 | - "index.md" 16 | - "docker.md" 17 | - "environment.md" 18 | - "logging.md" 19 | - "authentication.md" 20 | - "changelog.md" 21 | - "contributing.md" 22 | repo_name: br3ndonland/inboard 23 | repo_url: https://github.com/br3ndonland/inboard 24 | site_name: inboard docs 25 | site_url: "" 26 | theme: 27 | favicon: assets/images/favicon.png 28 | features: 29 | - content.code.copy 30 | - header.autohide 31 | - navigation.instant 32 | - navigation.top 33 | font: false 34 | icon: 35 | logo: fontawesome/brands/docker 36 | repo: fontawesome/brands/github 37 | name: material 38 | palette: 39 | - media: "(prefers-color-scheme: dark)" 40 | accent: blue 41 | primary: blue 42 | scheme: slate 43 | toggle: 44 | icon: material/weather-night 45 | name: Switch to light mode 46 | - media: "(prefers-color-scheme: light)" 47 | accent: blue 48 | primary: blue 49 | scheme: default 50 | toggle: 51 | icon: material/weather-sunny 52 | name: Switch to dark mode 53 | use_directory_urls: false 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling>=1.26.3,<2"] 4 | 5 | [project] 6 | authors = [{email = "bws@bws.bio", name = "Brendon Smith"}] 7 | classifiers = [ 8 | "Framework :: FastAPI", 9 | "Natural Language :: English", 10 | "Programming Language :: Python :: 3", 11 | "Programming Language :: Python :: 3.9", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Topic :: Internet :: Log Analysis", 16 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 17 | "Topic :: Internet :: WWW/HTTP :: WSGI", 18 | "Topic :: Software Development :: Libraries :: Application Frameworks", 19 | "Topic :: System :: Software Distribution", 20 | "Topic :: Utilities", 21 | "Typing :: Typed", 22 | ] 23 | dependencies = [ 24 | "gunicorn==23.0.0", 25 | "uvicorn==0.28.1", 26 | ] 27 | description = "Docker images and utilities to power your Python APIs and help you ship faster." 28 | dynamic = ["version"] 29 | keywords = ["asgi", "docker", "fastapi", "gunicorn", "uvicorn"] 30 | license = "MIT" 31 | name = "inboard" 32 | readme = "README.md" 33 | requires-python = ">=3.9,<4" 34 | 35 | [project.optional-dependencies] 36 | checks = [ 37 | "mypy==1.15.0", 38 | "ruff>=0.11,<0.12", 39 | ] 40 | docs = [ 41 | "mkdocs-material>=9,<10", 42 | ] 43 | fastapi = [ 44 | "fastapi==0.115.12", 45 | ] 46 | starlette = [ 47 | "starlette>=0.40.0,<0.47.0", 48 | ] 49 | tests = [ 50 | "coverage[toml]>=7,<8", 51 | "coverage_enable_subprocess==1.0", 52 | "httpx>=0.23,<1", 53 | "pytest>=8.1.1,<9", 54 | "pytest-mock>=3,<4", 55 | "pytest-timeout>=2,<3", 56 | "trustme>=1.2,<2", 57 | ] 58 | uvicorn-fast = [ 59 | "httptools>=0.5.0", 60 | "uvloop>=0.14.0,!=0.15.0,!=0.15.1; sys_platform != 'win32' and (sys_platform != 'cygwin' and platform_python_implementation != 'PyPy')", 61 | "websockets>=10.4", 62 | ] 63 | uvicorn-standard = [ 64 | "uvicorn[standard]==0.28.1", 65 | ] 66 | 67 | [project.urls] 68 | Docker = "https://github.com/br3ndonland/inboard/pkgs/container/inboard" 69 | Documentation = "https://inboard.bws.bio" 70 | Homepage = "https://github.com/br3ndonland/inboard" 71 | Repository = "https://github.com/br3ndonland/inboard" 72 | 73 | [tool.coverage.report] 74 | exclude_lines = ["if TYPE_CHECKING:", "pragma: no cover"] 75 | fail_under = 100 76 | show_missing = true 77 | 78 | [tool.coverage.run] 79 | command_line = "-m pytest" 80 | parallel = true 81 | source = ["inboard", "tests"] 82 | 83 | [tool.hatch.build.targets.sdist] 84 | include = ["/inboard"] 85 | 86 | [tool.hatch.build.targets.wheel] 87 | packages = ["inboard"] 88 | 89 | [tool.hatch.envs.base] 90 | dev-mode = false 91 | features = [] 92 | path = ".venv" 93 | 94 | [tool.hatch.envs.ci] 95 | dev-mode = false 96 | features = [ 97 | "checks", 98 | "fastapi", 99 | "tests", 100 | "uvicorn-fast", 101 | ] 102 | path = ".venv" 103 | 104 | [tool.hatch.envs.default] 105 | dev-mode = true 106 | features = [ 107 | "checks", 108 | "docs", 109 | "fastapi", 110 | "tests", 111 | "uvicorn-fast", 112 | ] 113 | path = ".venv" 114 | 115 | [tool.hatch.envs.default.scripts] 116 | check = [ 117 | "ruff check", 118 | "ruff format --check", 119 | "mypy", 120 | "npx -s -y prettier@'^3.4' . --check", 121 | "npx -s -y cspell --dot --gitignore *.md **/*.md", 122 | ] 123 | format = [ 124 | "ruff check --fix", 125 | "ruff format", 126 | "npx -s -y prettier@'^3.4' . --write", 127 | ] 128 | 129 | [tool.hatch.envs.docs] 130 | dev-mode = false 131 | features = [ 132 | "docs", 133 | ] 134 | 135 | [tool.hatch.envs.fastapi] 136 | dev-mode = false 137 | features = [ 138 | "fastapi", 139 | ] 140 | path = ".venv" 141 | 142 | [tool.hatch.envs.starlette] 143 | dev-mode = false 144 | features = [ 145 | "starlette", 146 | ] 147 | path = ".venv" 148 | 149 | [tool.hatch.version] 150 | path = "inboard/__init__.py" 151 | 152 | [tool.mypy] 153 | files = ["**/*.py"] 154 | plugins = "pydantic.mypy" 155 | show_error_codes = true 156 | strict = true 157 | 158 | [tool.pytest.ini_options] 159 | addopts = "-q" 160 | markers = [ 161 | "subprocess: test requires a subprocess (deselect with '-m \"not subprocess\"')", 162 | ] 163 | minversion = "6.0" 164 | testpaths = ["tests"] 165 | 166 | [tool.ruff] 167 | src = ["inboard", "tests"] 168 | 169 | [tool.ruff.format] 170 | docstring-code-format = true 171 | 172 | [tool.ruff.lint] 173 | extend-select = ["I", "UP"] 174 | 175 | [tool.ruff.lint.isort] 176 | known-first-party = ["inboard", "tests"] 177 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/br3ndonland/inboard/f801c7931377ddbb47d5cb4f9692c9bdd5657db5/tests/__init__.py -------------------------------------------------------------------------------- /tests/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/br3ndonland/inboard/f801c7931377ddbb47d5cb4f9692c9bdd5657db5/tests/app/__init__.py -------------------------------------------------------------------------------- /tests/app/test_main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | import pytest 6 | from fastapi import FastAPI 7 | from fastapi.testclient import TestClient 8 | from starlette.applications import Starlette 9 | 10 | 11 | class TestCors: 12 | """Test CORS middleware integration. 13 | --- 14 | See the [FastAPI CORS tutorial](https://fastapi.tiangolo.com/tutorial/cors/) and 15 | [Starlette CORS docs](https://www.starlette.io/middleware/#corsmiddleware). 16 | """ 17 | 18 | origins: dict[str, list[str]] = { 19 | "allowed": [ 20 | "http://br3ndon.land", 21 | "https://br3ndon.land", 22 | "https://inboard.br3ndon.land", 23 | "https://inboard.docker.br3ndon.land", 24 | "https://www.br3ndon.land", 25 | "http://localhost:8000", 26 | ], 27 | "disallowed": [ 28 | "https://br3ndon.com", 29 | "https://inboar.dbr3ndon.land", 30 | "https://example.land", 31 | "htttp://localhost:8000", 32 | "httpss://br3ndon.land", 33 | "othersite.com", 34 | ], 35 | } 36 | 37 | @pytest.mark.parametrize("allowed_origin", origins["allowed"]) 38 | def test_cors_preflight_response_allowed( 39 | self, allowed_origin: str, client: TestClient 40 | ) -> None: 41 | """Test pre-flight response to cross-origin request from allowed origin.""" 42 | headers: dict[str, str] = { 43 | "Origin": allowed_origin, 44 | "Access-Control-Request-Method": "GET", 45 | "Access-Control-Request-Headers": "X-Example", 46 | } 47 | response = client.options("/", headers=headers) 48 | assert response.status_code == 200, response.text 49 | assert response.text == "OK" 50 | assert response.headers["access-control-allow-origin"] == allowed_origin 51 | assert response.headers["access-control-allow-headers"] == "X-Example" 52 | 53 | @pytest.mark.parametrize("disallowed_origin", origins["disallowed"]) 54 | def test_cors_preflight_response_disallowed( 55 | self, disallowed_origin: str, client: TestClient 56 | ) -> None: 57 | """Test pre-flight response to cross-origin request from disallowed origin.""" 58 | headers: dict[str, str] = { 59 | "Origin": disallowed_origin, 60 | "Access-Control-Request-Method": "GET", 61 | "Access-Control-Request-Headers": "X-Example", 62 | } 63 | response = client.options("/", headers=headers) 64 | assert response.status_code >= 400 65 | assert "Disallowed CORS origin" in response.text 66 | assert not response.headers.get("access-control-allow-origin") 67 | 68 | @pytest.mark.parametrize("allowed_origin", origins["allowed"]) 69 | def test_cors_response_allowed( 70 | self, allowed_origin: str, client: TestClient 71 | ) -> None: 72 | """Test response to cross-origin request from allowed origin.""" 73 | headers = {"Origin": allowed_origin} 74 | response = client.get("/", headers=headers) 75 | assert response.status_code == 200, response.text 76 | assert response.json() == {"Hello": "World"} 77 | assert response.headers["access-control-allow-origin"] == allowed_origin 78 | 79 | @pytest.mark.parametrize("disallowed_origin", origins["disallowed"]) 80 | def test_cors_response_disallowed( 81 | self, disallowed_origin: str, client: TestClient 82 | ) -> None: 83 | """Test response to cross-origin request from disallowed origin. 84 | As explained in the Starlette test suite in tests/middleware/`test_cors.py`, 85 | enforcement of CORS allowed origins is the responsibility of the client. 86 | On the server side, the "disallowed-ness" results in lack of an 87 | "Access-Control-Allow-Origin" header in the response. 88 | """ 89 | headers = {"Origin": disallowed_origin} 90 | response = client.get("/", headers=headers) 91 | assert response.status_code == 200 92 | assert not response.headers.get("access-control-allow-origin") 93 | 94 | def test_non_cors(self, client: TestClient) -> None: 95 | """Test non-CORS response.""" 96 | response = client.get("/") 97 | assert response.status_code == 200, response.text 98 | assert response.json() == {"Hello": "World"} 99 | assert "access-control-allow-origin" not in response.headers 100 | 101 | 102 | class TestEndpoints: 103 | """Test API endpoints. 104 | --- 105 | See the [FastAPI testing docs](https://fastapi.tiangolo.com/tutorial/testing/), 106 | [Starlette TestClient docs](https://www.starlette.io/testclient/), and the 107 | [pytest docs](https://docs.pytest.org/en/latest/how-to/parametrize.html). 108 | """ 109 | 110 | def test_get_asgi_uvicorn( 111 | self, client_asgi: TestClient, monkeypatch: pytest.MonkeyPatch 112 | ) -> None: 113 | """Test `GET` request to base ASGI app set for Uvicorn without Gunicorn.""" 114 | monkeypatch.setenv("PROCESS_MANAGER", "uvicorn") 115 | monkeypatch.setenv("WITH_RELOAD", "false") 116 | version = sys.version_info 117 | response = client_asgi.get("/") 118 | assert response.status_code == 200 119 | assert response.text == ( 120 | f"Hello World, from Uvicorn and Python " 121 | f"{version.major}.{version.minor}.{version.micro}!" 122 | ) 123 | 124 | def test_get_asgi_uvicorn_gunicorn( 125 | self, client_asgi: TestClient, monkeypatch: pytest.MonkeyPatch 126 | ) -> None: 127 | """Test `GET` request to base ASGI app set for Uvicorn with Gunicorn.""" 128 | monkeypatch.setenv("PROCESS_MANAGER", "gunicorn") 129 | monkeypatch.setenv("WITH_RELOAD", "false") 130 | version = sys.version_info 131 | response = client_asgi.get("/") 132 | assert response.status_code == 200 133 | assert response.text == ( 134 | f"Hello World, from Uvicorn, Gunicorn, and Python " 135 | f"{version.major}.{version.minor}.{version.micro}!" 136 | ) 137 | 138 | def test_get_asgi_incorrect_process_manager( 139 | self, client_asgi: TestClient, monkeypatch: pytest.MonkeyPatch 140 | ) -> None: 141 | """Test `GET` request to base ASGI app with incorrect `PROCESS_MANAGER`.""" 142 | monkeypatch.setenv("PROCESS_MANAGER", "incorrect") 143 | monkeypatch.setenv("WITH_RELOAD", "false") 144 | with pytest.raises(NameError) as e: 145 | client_asgi.get("/") 146 | assert "Process manager needs to be either uvicorn or gunicorn" in str(e.value) 147 | 148 | def test_get_root(self, client: TestClient) -> None: 149 | """Test a `GET` request to the root endpoint.""" 150 | response = client.get("/") 151 | assert response.status_code == 200 152 | assert response.json() == {"Hello": "World"} 153 | 154 | @pytest.mark.parametrize("endpoint", ("/health", "/status")) 155 | def test_gets_with_basic_auth( 156 | self, basic_auth: tuple[str, str], client: TestClient, endpoint: str 157 | ) -> None: 158 | """Test `GET` requests to endpoints that require HTTP Basic auth.""" 159 | error_response = client.get(endpoint) 160 | response = client.get(endpoint, auth=basic_auth) 161 | response_json = response.json() 162 | assert error_response.status_code in {401, 403} 163 | assert response.status_code == 200 164 | assert "application" in response_json 165 | assert "status" in response_json 166 | assert response_json["application"] == "inboard" 167 | assert response_json["status"] == "active" 168 | 169 | @pytest.mark.parametrize("endpoint", ("/health", "/status")) 170 | def test_gets_with_basic_auth_no_credentials( 171 | self, client: TestClient, endpoint: str 172 | ) -> None: 173 | """Test `GET` requests without HTTP Basic auth credentials set.""" 174 | error_response = client.get(endpoint, auth=("user", "pass")) 175 | error_response_json = error_response.json() 176 | assert error_response.status_code in {401, 403} 177 | if isinstance(client.app, FastAPI): 178 | expected_json = {"detail": "Server HTTP Basic auth credentials not set"} 179 | elif isinstance(client.app, Starlette): 180 | expected_json = { 181 | "detail": "Server HTTP Basic auth credentials not set", 182 | "error": "Incorrect username or password", 183 | } 184 | else: # pragma: no cover 185 | raise AssertionError("TestClient should have a FastAPI or Starlette app.") 186 | assert error_response_json == expected_json 187 | 188 | @pytest.mark.parametrize( 189 | "basic_auth_incorrect", 190 | ( 191 | ("incorrect_username", "incorrect_password"), 192 | ("incorrect_username", "r4ndom_bUt_memorable"), 193 | ("test_user", "incorrect_password"), 194 | ), 195 | ) 196 | @pytest.mark.parametrize("endpoint", ("/health", "/status")) 197 | def test_gets_with_basic_auth_incorrect_credentials( 198 | self, 199 | basic_auth_incorrect: tuple[str, str], 200 | client: TestClient, 201 | endpoint: str, 202 | monkeypatch: pytest.MonkeyPatch, 203 | ) -> None: 204 | """Test `GET` requests with incorrect HTTP Basic auth credentials.""" 205 | monkeypatch.setenv("BASIC_AUTH_USERNAME", "test_user") 206 | monkeypatch.setenv("BASIC_AUTH_PASSWORD", "r4ndom_bUt_memorable") 207 | error_response = client.get(endpoint, auth=basic_auth_incorrect) 208 | error_response_json = error_response.json() 209 | assert error_response.status_code in {401, 403} 210 | if isinstance(client.app, FastAPI): 211 | expected_json = {"detail": "HTTP Basic auth credentials not correct"} 212 | elif isinstance(client.app, Starlette): 213 | expected_json = { 214 | "detail": "HTTP Basic auth credentials not correct", 215 | "error": "Incorrect username or password", 216 | } 217 | else: # pragma: no cover 218 | raise AssertionError("TestClient should have a FastAPI or Starlette app.") 219 | assert error_response_json == expected_json 220 | 221 | def test_get_status_message( 222 | self, 223 | basic_auth: tuple[str, str], 224 | client: TestClient, 225 | endpoint: str = "/status", 226 | ) -> None: 227 | """Test the message returned by a `GET` request to a status endpoint.""" 228 | error_response = client.get(endpoint) 229 | response = client.get(endpoint, auth=basic_auth) 230 | response_json = response.json() 231 | assert error_response.status_code in {401, 403} 232 | assert response.status_code == 200 233 | assert "message" in response_json 234 | for word in ("Hello", "World", "Uvicorn", "Python"): 235 | assert word in response_json["message"] 236 | if isinstance(client.app, FastAPI): 237 | assert "FastAPI" in response_json["message"] 238 | elif isinstance(client.app, Starlette): 239 | assert "Starlette" in response_json["message"] 240 | else: # pragma: no cover 241 | raise AssertionError("TestClient should have a FastAPI or Starlette app.") 242 | 243 | def test_get_user( 244 | self, 245 | basic_auth: tuple[str, str], 246 | client: TestClient, 247 | endpoint: str = "/users/me", 248 | ) -> None: 249 | """Test a `GET` request to an endpoint providing user information.""" 250 | error_response = client.get(endpoint) 251 | response = client.get(endpoint, auth=basic_auth) 252 | response_json = response.json() 253 | assert error_response.status_code in {401, 403} 254 | assert response.status_code == 200 255 | assert "application" not in response_json 256 | assert "status" not in response_json 257 | assert response_json["username"] == "test_user" 258 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import shutil 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | import pytest 9 | from fastapi.testclient import TestClient 10 | 11 | from inboard import gunicorn_conf as gunicorn_conf_module 12 | from inboard import logging_conf as logging_conf_module 13 | from inboard.app import prestart as pre_start_module 14 | from inboard.app.main_base import app as base_app 15 | from inboard.app.main_fastapi import app as fastapi_app 16 | from inboard.app.main_starlette import app as starlette_app 17 | 18 | if TYPE_CHECKING: 19 | from pytest_mock import MockerFixture 20 | 21 | from inboard.types import DictConfig, UvicornOptions 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def app_module_tmp_path(tmp_path_factory: pytest.TempPathFactory) -> Path: 26 | """Copy app modules to temporary directory to test custom app module paths.""" 27 | tmp_dir = tmp_path_factory.mktemp("app") 28 | shutil.copytree(Path(pre_start_module.__file__).parent, Path(f"{tmp_dir}/tmp_app")) 29 | return tmp_dir 30 | 31 | 32 | @pytest.fixture 33 | def basic_auth( 34 | monkeypatch: pytest.MonkeyPatch, 35 | username: str = "test_user", 36 | password: str = "r4ndom_bUt_memorable", 37 | ) -> tuple[str, str]: 38 | """Set username and password for HTTP Basic auth.""" 39 | monkeypatch.setenv("BASIC_AUTH_USERNAME", username) 40 | monkeypatch.setenv("BASIC_AUTH_PASSWORD", password) 41 | assert os.getenv("BASIC_AUTH_USERNAME") == username 42 | assert os.getenv("BASIC_AUTH_PASSWORD") == password 43 | return username, password 44 | 45 | 46 | @pytest.fixture(scope="session") 47 | def client_asgi() -> TestClient: 48 | """Instantiate test client with a plain ASGI app instance. 49 | 50 | Note that Uvicorn and Starlette use different types. 51 | The type signature expected by the Starlette/FastAPI `TestClient` 52 | therefore does not match `uvicorn._types.ASGIApplication`. A mypy 53 | `type: ignore[arg-type]` comment is used to resolve this difference. 54 | 55 | https://asgi.readthedocs.io/en/stable/specs/main.html#applications 56 | """ 57 | return TestClient(base_app) # type: ignore[arg-type] 58 | 59 | 60 | @pytest.fixture(params=(fastapi_app, starlette_app), scope="session") 61 | def client(request: pytest.FixtureRequest) -> TestClient: 62 | """Instantiate test client with an app instance. 63 | 64 | This is a parametrized fixture. When the fixture is used in a test, the test 65 | will be automatically parametrized, running once for each fixture parameter. 66 | https://docs.pytest.org/en/latest/how-to/fixtures.html 67 | """ 68 | app = getattr(request, "param") 69 | return TestClient(app) 70 | 71 | 72 | @pytest.fixture( 73 | params=(gunicorn_conf_module.__file__, "python:inboard.gunicorn_conf"), 74 | scope="session", 75 | ) 76 | def gunicorn_conf_path(request: pytest.FixtureRequest) -> str: 77 | """Set path to default Gunicorn configuration file. 78 | 79 | This is a parametrized fixture. When the fixture is used in a test, the test 80 | will be automatically parametrized, running once for each fixture parameter. 81 | https://docs.pytest.org/en/latest/how-to/fixtures.html 82 | """ 83 | request_param = getattr(request, "param") 84 | path = str(request_param) 85 | if "python:" not in path: 86 | assert Path(path).is_file() 87 | return path 88 | 89 | 90 | @pytest.fixture(scope="session") 91 | def gunicorn_conf_tmp_file_path(tmp_path_factory: pytest.TempPathFactory) -> Path: 92 | """Copy gunicorn configuration file to temporary directory.""" 93 | gunicorn_conf_tmp_path = tmp_path_factory.mktemp("gunicorn") 94 | tmp_file = Path(f"{gunicorn_conf_tmp_path}/gunicorn_conf.py") 95 | shutil.copy(Path(gunicorn_conf_module.__file__), tmp_file) 96 | return tmp_file 97 | 98 | 99 | @pytest.fixture 100 | def logging_conf_dict(mocker: MockerFixture) -> DictConfig: 101 | """Load logging configuration dictionary from logging configuration module.""" 102 | dict_config: DictConfig = mocker.patch.dict(logging_conf_module.LOGGING_CONFIG) 103 | return dict_config 104 | 105 | 106 | @pytest.fixture 107 | def logging_conf_file_path(monkeypatch: pytest.MonkeyPatch) -> Path: 108 | """Set path to default logging configuration file.""" 109 | path = Path(logging_conf_module.__file__) 110 | monkeypatch.setenv("LOGGING_CONF", str(path)) 111 | assert os.getenv("LOGGING_CONF") == str(path) 112 | return path 113 | 114 | 115 | @pytest.fixture 116 | def logging_conf_module_path(monkeypatch: pytest.MonkeyPatch) -> str: 117 | """Set module path to logging_conf.py.""" 118 | path = "inboard.logging_conf" 119 | monkeypatch.setenv("LOGGING_CONF", path) 120 | assert os.getenv("LOGGING_CONF") == path 121 | return path 122 | 123 | 124 | @pytest.fixture(scope="session") 125 | def logging_conf_tmp_file_path(tmp_path_factory: pytest.TempPathFactory) -> Path: 126 | """Copy logging configuration module to custom temporary location.""" 127 | tmp_dir = tmp_path_factory.mktemp("tmp_log") 128 | shutil.copy(Path(logging_conf_module.__file__), Path(f"{tmp_dir}/tmp_log.py")) 129 | return tmp_dir 130 | 131 | 132 | @pytest.fixture(scope="session") 133 | def logging_conf_tmp_path_no_dict(tmp_path_factory: pytest.TempPathFactory) -> Path: 134 | """Create temporary logging config file without logging config dict.""" 135 | tmp_dir = tmp_path_factory.mktemp("tmp_log_no_dict") 136 | tmp_file = tmp_dir / "no_dict.py" 137 | with open(Path(tmp_file), "x") as f: 138 | f.write("print('Hello, World!')\n") 139 | return tmp_dir 140 | 141 | 142 | @pytest.fixture(scope="session") 143 | def logging_conf_tmp_path_incorrect_extension( 144 | tmp_path_factory: pytest.TempPathFactory, 145 | ) -> Path: 146 | """Create custom temporary logging config file with incorrect extension.""" 147 | tmp_dir = tmp_path_factory.mktemp("tmp_log_incorrect_extension") 148 | tmp_file = tmp_dir / "tmp_logging_conf" 149 | with open(Path(tmp_file), "x") as f: 150 | f.write("This file doesn't have the correct extension.\n") 151 | return tmp_dir 152 | 153 | 154 | @pytest.fixture(scope="session") 155 | def logging_conf_tmp_path_incorrect_type( 156 | tmp_path_factory: pytest.TempPathFactory, 157 | ) -> Path: 158 | """Create temporary logging config file with incorrect LOGGING_CONFIG type.""" 159 | tmp_dir = tmp_path_factory.mktemp("tmp_log_incorrect_type") 160 | tmp_file = tmp_dir / "incorrect_type.py" 161 | with open(Path(tmp_file), "x") as f: 162 | f.write("LOGGING_CONFIG: list = ['Hello', 'World']\n") 163 | return tmp_dir 164 | 165 | 166 | @pytest.fixture 167 | def pre_start_script_tmp_py(tmp_path: Path) -> Path: 168 | """Copy pre-start script to custom temporary file.""" 169 | tmp_file = shutil.copy(Path(pre_start_module.__file__), tmp_path) 170 | return Path(tmp_file) 171 | 172 | 173 | @pytest.fixture 174 | def pre_start_script_tmp_sh(tmp_path: Path) -> Path: 175 | """Create custom temporary pre-start shell script.""" 176 | tmp_file = tmp_path / "prestart.sh" 177 | with open(Path(tmp_file), "x") as f: 178 | f.write('echo "Hello World, from a temporary pre-start shell script"\n') 179 | return Path(tmp_file) 180 | 181 | 182 | @pytest.fixture( 183 | params=( 184 | ( 185 | "prestart_error.py", 186 | 'raise RuntimeError("Testing pre-start script error behavior")\n', 187 | ), 188 | ("prestart_error.sh", "exit 1\n"), 189 | ) 190 | ) 191 | def pre_start_script_error(request: pytest.FixtureRequest, tmp_path: Path) -> Path: 192 | """Create custom temporary pre-start scripts for testing errors. 193 | 194 | This is a parametrized fixture. When the fixture is used in a test, the test 195 | will be automatically parametrized, running once for each fixture parameter. 196 | https://docs.pytest.org/en/latest/how-to/fixtures.html 197 | """ 198 | file_name, file_content = getattr(request, "param") 199 | tmp_file = tmp_path / file_name 200 | with open(Path(tmp_file), "x") as f: 201 | f.write(file_content) 202 | return Path(tmp_file) 203 | 204 | 205 | @pytest.fixture(scope="session") 206 | def uvicorn_options_default() -> UvicornOptions: 207 | """Return default options used by `uvicorn.run()` for use in test assertions.""" 208 | return dict( 209 | app="inboard.app.main_base:app", 210 | host="0.0.0.0", 211 | port=80, 212 | log_config=None, 213 | log_level="info", 214 | reload=False, 215 | reload_delay=0.25, 216 | reload_dirs=None, 217 | reload_excludes=None, 218 | reload_includes=None, 219 | ) 220 | 221 | 222 | @pytest.fixture 223 | def uvicorn_options_custom(logging_conf_dict: DictConfig) -> UvicornOptions: 224 | """Return custom options used by `uvicorn.run()` for use in test assertions.""" 225 | return dict( 226 | app="inboard.app.main_fastapi:app", 227 | host="0.0.0.0", 228 | port=80, 229 | log_config=logging_conf_dict, 230 | log_level="debug", 231 | reload=True, 232 | reload_delay=0.5, 233 | reload_dirs=["inboard", "tests"], 234 | reload_excludes=["*[Dd]ockerfile"], 235 | reload_includes=["*.py", "*.md"], 236 | ) 237 | -------------------------------------------------------------------------------- /tests/test_gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import multiprocessing 4 | import subprocess 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | from inboard import gunicorn_conf 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | 15 | class TestCalculateWorkers: 16 | """Test calculation of the number of Gunicorn worker processes. 17 | --- 18 | """ 19 | 20 | def test_calculate_workers_default(self) -> None: 21 | """Test default number of Gunicorn worker processes.""" 22 | cores = multiprocessing.cpu_count() 23 | assert gunicorn_conf.workers >= 2 24 | assert gunicorn_conf.workers == max(cores, 2) 25 | 26 | @pytest.mark.parametrize("max_workers", (None, "1", "2", "5", "10")) 27 | def test_calculate_workers_max(self, max_workers: str | None) -> None: 28 | """Test Gunicorn worker process calculation with custom maximum.""" 29 | cores = multiprocessing.cpu_count() 30 | default = max(cores, 2) 31 | result = gunicorn_conf.calculate_workers(max_workers, None) 32 | if max_workers and default > (m := int(max_workers)): 33 | assert result == m 34 | else: 35 | assert result == default 36 | 37 | @pytest.mark.parametrize("total_workers", (None, "1", "2", "5", "10")) 38 | def test_calculate_workers_total(self, total_workers: str | None) -> None: 39 | """Test Gunicorn worker process calculation with custom total.""" 40 | cores = multiprocessing.cpu_count() 41 | result = gunicorn_conf.calculate_workers(None, total_workers) 42 | assert result == int(total_workers) if total_workers else max(cores, 2) 43 | 44 | @pytest.mark.parametrize("workers_per_core", ("0.5", "1.5", "5", "10")) 45 | def test_calculate_workers_per_core(self, workers_per_core: str) -> None: 46 | """Test Gunicorn worker process calculation with custom workers per core. 47 | Worker number should be the greater of 2 or the workers per core setting. 48 | """ 49 | cores = multiprocessing.cpu_count() 50 | result = gunicorn_conf.calculate_workers(workers_per_core=workers_per_core) 51 | assert result == max(int(float(workers_per_core) * cores), 2) 52 | 53 | @pytest.mark.parametrize("max_workers", ("1", "2", "5", "10")) 54 | @pytest.mark.parametrize("total_workers", ("1", "2", "5", "10")) 55 | def test_calculate_workers_both_max_and_total( 56 | self, max_workers: str, total_workers: str 57 | ) -> None: 58 | """Test Gunicorn worker process calculation if max workers and total workers 59 | (web concurrency) are both set. Worker number should be the lesser of the two. 60 | """ 61 | result = gunicorn_conf.calculate_workers(max_workers, total_workers) 62 | assert result == min(int(max_workers), int(total_workers)) 63 | 64 | @pytest.mark.parametrize("max_workers", ("1", "2", "5", "10")) 65 | @pytest.mark.parametrize("workers_per_core", ("0.5", "1.5", "5", "10")) 66 | def test_calculate_workers_both_max_and_workers_per_core( 67 | self, max_workers: str, workers_per_core: str 68 | ) -> None: 69 | """Test Gunicorn worker process calculation if max workers and workers per core 70 | are both set. Worker number should always be less than the maximum. 71 | """ 72 | result = gunicorn_conf.calculate_workers( 73 | max_workers, None, workers_per_core=workers_per_core 74 | ) 75 | assert result <= int(max_workers) 76 | 77 | 78 | class TestGunicornSettings: 79 | """Test Gunicorn configuration setup and settings. 80 | --- 81 | """ 82 | 83 | @pytest.mark.parametrize("module", ("base", "fastapi", "starlette")) 84 | @pytest.mark.timeout(2) 85 | def test_gunicorn_config( 86 | self, capfd: pytest.CaptureFixture[str], gunicorn_conf_path: str, module: str 87 | ) -> None: 88 | """Load Gunicorn configuration file and verify output.""" 89 | app_module = f"inboard.app.main_{module}:app" 90 | gunicorn_conf_path = gunicorn_conf.__file__ 91 | gunicorn_options = [ 92 | "gunicorn", 93 | "--print-config", 94 | "-c", 95 | gunicorn_conf_path, 96 | "-k", 97 | "inboard.gunicorn_workers.UvicornWorker", 98 | app_module, 99 | ] 100 | subprocess.run(gunicorn_options) 101 | captured = capfd.readouterr() 102 | captured_and_cleaned = captured.out.replace(" ", "").splitlines() 103 | assert app_module in captured.out 104 | assert gunicorn_conf_path in captured.out 105 | assert "INFO" in captured.out 106 | assert "uvicorn.logging.DefaultFormatter" in captured.out 107 | assert "graceful_timeout=120" in captured_and_cleaned 108 | assert "keepalive=5" in captured_and_cleaned 109 | assert "loglevel=info" in captured_and_cleaned 110 | assert "timeout=120" in captured_and_cleaned 111 | assert f"workers={max(multiprocessing.cpu_count(), 2)}" in captured_and_cleaned 112 | 113 | @pytest.mark.parametrize("module", ("base", "fastapi", "starlette")) 114 | @pytest.mark.timeout(2) 115 | def test_gunicorn_config_with_custom_options( 116 | self, 117 | capfd: pytest.CaptureFixture[str], 118 | gunicorn_conf_tmp_file_path: Path, 119 | logging_conf_tmp_file_path: Path, 120 | monkeypatch: pytest.MonkeyPatch, 121 | module: str, 122 | ) -> None: 123 | """Customize options, load Gunicorn configuration file and verify output.""" 124 | app_module = f"inboard.app.main_{module}:app" 125 | gunicorn_conf_path = str(gunicorn_conf_tmp_file_path) 126 | logging_conf_file = f"{logging_conf_tmp_file_path}/tmp_log.py" 127 | monkeypatch.setenv("GRACEFUL_TIMEOUT", "240") 128 | monkeypatch.setenv("KEEP_ALIVE", "10") 129 | monkeypatch.setenv("LOG_FORMAT", "verbose") 130 | monkeypatch.setenv("LOG_LEVEL", "debug") 131 | monkeypatch.setenv("LOGGING_CONF", logging_conf_file) 132 | monkeypatch.setenv("MAX_WORKERS", "10") 133 | monkeypatch.setenv("TIMEOUT", "240") 134 | monkeypatch.setenv("WEB_CONCURRENCY", "15") 135 | gunicorn_options = [ 136 | "gunicorn", 137 | "--print-config", 138 | "-c", 139 | gunicorn_conf_path, 140 | "-k", 141 | "inboard.gunicorn_workers.UvicornWorker", 142 | app_module, 143 | ] 144 | subprocess.run(gunicorn_options) 145 | captured = capfd.readouterr() 146 | captured_and_cleaned = captured.out.replace(" ", "").splitlines() 147 | assert app_module in captured.out 148 | assert gunicorn_conf_path in captured.out 149 | assert "DEBUG" in captured.out 150 | assert "uvicorn.logging.DefaultFormatter" in captured.out 151 | assert "graceful_timeout=240" in captured_and_cleaned 152 | assert "keepalive=10" in captured_and_cleaned 153 | assert "loglevel=debug" in captured_and_cleaned 154 | assert "timeout=240" in captured_and_cleaned 155 | assert "workers=10" in captured_and_cleaned 156 | -------------------------------------------------------------------------------- /tests/test_gunicorn_workers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import signal 5 | import socket 6 | import ssl 7 | import subprocess 8 | import sys 9 | import tempfile 10 | import time 11 | from typing import TYPE_CHECKING 12 | 13 | import httpx 14 | import pytest 15 | import trustme 16 | 17 | if TYPE_CHECKING: 18 | from collections.abc import Generator 19 | from ssl import SSLContext 20 | from typing import IO 21 | 22 | from uvicorn._types import ( 23 | ASGIReceiveCallable, 24 | ASGISendCallable, 25 | HTTPResponseBodyEvent, 26 | HTTPResponseStartEvent, 27 | LifespanStartupFailedEvent, 28 | Scope, 29 | ) 30 | 31 | pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="requires unix") 32 | gunicorn_arbiter = pytest.importorskip("gunicorn.arbiter", reason="requires gunicorn") 33 | gunicorn_workers = pytest.importorskip( 34 | "inboard.gunicorn_workers", reason="requires gunicorn" 35 | ) 36 | 37 | 38 | class Process(subprocess.Popen[str]): 39 | client: httpx.Client 40 | output: IO[bytes] 41 | 42 | def read_output(self) -> str: 43 | self.output.seek(0) 44 | return self.output.read().decode() 45 | 46 | 47 | async def app( 48 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 49 | ) -> None: 50 | """An ASGI app for testing requests to Gunicorn workers.""" 51 | assert scope["type"] == "http" 52 | start_event: HTTPResponseStartEvent = { 53 | "type": "http.response.start", 54 | "status": 204, 55 | "headers": [], 56 | } 57 | body_event: HTTPResponseBodyEvent = { 58 | "type": "http.response.body", 59 | "body": b"", 60 | "more_body": False, 61 | } 62 | await send(start_event) 63 | await send(body_event) 64 | 65 | 66 | async def app_with_lifespan_startup_failure( 67 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 68 | ) -> None: 69 | """An ASGI app for testing Gunicorn worker boot errors.""" 70 | if scope["type"] == "lifespan": 71 | message = await receive() 72 | if message["type"] == "lifespan.startup": 73 | lifespan_startup_failed_event: LifespanStartupFailedEvent = { 74 | "type": "lifespan.startup.failed", 75 | "message": "ASGI application failed to start", 76 | } 77 | await send(lifespan_startup_failed_event) 78 | 79 | 80 | @pytest.fixture 81 | def tls_certificate_authority() -> trustme.CA: 82 | return trustme.CA() 83 | 84 | 85 | @pytest.fixture 86 | def tls_ca_certificate_pem_path( 87 | tls_certificate_authority: trustme.CA, 88 | ) -> Generator[str, None, None]: 89 | with tls_certificate_authority.cert_pem.tempfile() as ca_pem_path: 90 | yield ca_pem_path 91 | 92 | 93 | @pytest.fixture 94 | def tls_ca_ssl_context(tls_certificate_authority: trustme.CA) -> ssl.SSLContext: 95 | ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 96 | tls_certificate_authority.configure_trust(ssl_ctx) 97 | return ssl_ctx 98 | 99 | 100 | @pytest.fixture 101 | def tls_certificate(tls_certificate_authority: trustme.CA) -> trustme.LeafCert: 102 | return tls_certificate_authority.issue_cert("localhost", "127.0.0.1", "::1") 103 | 104 | 105 | @pytest.fixture 106 | def tls_certificate_private_key_path( 107 | tls_certificate: trustme.CA, 108 | ) -> Generator[str, None, None]: 109 | with tls_certificate.private_key_pem.tempfile() as private_key_path: 110 | yield private_key_path 111 | 112 | 113 | @pytest.fixture 114 | def tls_certificate_server_cert_path( 115 | tls_certificate: trustme.LeafCert, 116 | ) -> Generator[str, None, None]: 117 | with tls_certificate.cert_chain_pems[0].tempfile() as cert_pem_path: 118 | yield cert_pem_path 119 | 120 | 121 | def _unused_port(socket_type: int) -> int: 122 | with contextlib.closing(socket.socket(type=socket_type)) as sock: 123 | sock.bind(("127.0.0.1", 0)) 124 | return int(sock.getsockname()[1]) 125 | 126 | 127 | @pytest.fixture 128 | def unused_tcp_port() -> int: 129 | return _unused_port(socket.SOCK_STREAM) 130 | 131 | 132 | @pytest.fixture( 133 | params=( 134 | pytest.param(gunicorn_workers.UvicornWorker, marks=pytest.mark.subprocess), 135 | pytest.param(gunicorn_workers.UvicornH11Worker, marks=pytest.mark.subprocess), 136 | ) 137 | ) 138 | def worker_class(request: pytest.FixtureRequest) -> str: 139 | """Gunicorn worker class names to test. 140 | 141 | This is a parametrized fixture. When the fixture is used in a test, the test 142 | will be automatically parametrized, running once for each fixture parameter. All 143 | tests using the fixture will be automatically marked with `pytest.mark.subprocess`. 144 | 145 | https://docs.pytest.org/en/latest/how-to/fixtures.html 146 | https://docs.pytest.org/en/latest/proposals/parametrize_with_fixtures.html 147 | """ 148 | worker_class = request.param 149 | return f"{worker_class.__module__}.{worker_class.__name__}" 150 | 151 | 152 | @pytest.fixture( 153 | params=( 154 | pytest.param(False, id="TLS off"), 155 | pytest.param(True, id="TLS on"), 156 | ) 157 | ) 158 | def gunicorn_process( 159 | request: pytest.FixtureRequest, 160 | tls_ca_certificate_pem_path: str, 161 | tls_ca_ssl_context: SSLContext, 162 | tls_certificate_private_key_path: str, 163 | tls_certificate_server_cert_path: str, 164 | unused_tcp_port: int, 165 | worker_class: str, 166 | ) -> Generator[Process, None, None]: 167 | """Yield a subprocess running a Gunicorn arbiter with a Uvicorn worker. 168 | 169 | An instance of `httpx.Client` is available on the `client` attribute. 170 | Output is saved to a temporary file and accessed with `read_output()`. 171 | """ 172 | app_module = f"{__name__}:{app.__name__}" 173 | bind = f"127.0.0.1:{unused_tcp_port}" 174 | use_tls: bool = request.param 175 | args = [ 176 | "gunicorn", 177 | "--bind", 178 | bind, 179 | "--graceful-timeout", 180 | "1", 181 | "--log-level", 182 | "debug", 183 | "--worker-class", 184 | worker_class, 185 | "--workers", 186 | "1", 187 | ] 188 | if use_tls is True: 189 | args_for_tls = [ 190 | "--ca-certs", 191 | tls_ca_certificate_pem_path, 192 | "--certfile", 193 | tls_certificate_server_cert_path, 194 | "--keyfile", 195 | tls_certificate_private_key_path, 196 | ] 197 | args.extend(args_for_tls) 198 | base_url = f"https://{bind}" 199 | verify: SSLContext | bool = tls_ca_ssl_context 200 | else: 201 | base_url = f"http://{bind}" 202 | verify = False 203 | args.append(app_module) 204 | with ( 205 | httpx.Client(base_url=base_url, verify=verify) as client, 206 | tempfile.TemporaryFile() as output, 207 | ): 208 | with Process(args, stdout=output, stderr=output) as process: 209 | time.sleep(2) 210 | assert not process.poll() 211 | process.client = client 212 | process.output = output 213 | yield process 214 | process.send_signal(signal.SIGQUIT) 215 | process.wait(timeout=5) 216 | 217 | 218 | @pytest.fixture 219 | def gunicorn_process_with_sigterm( 220 | unused_tcp_port: int, 221 | ) -> Generator[Process, None, None]: 222 | """Yield a subprocess running a Gunicorn arbiter with a Uvicorn worker. 223 | 224 | An instance of `httpx.Client` is available on the `client` attribute. 225 | Output is saved to a temporary file and accessed with `read_output()`. 226 | 227 | This pytest fixture provides a simplified worker configuration that exits 228 | with `SIGTERM` instead of `SIGQUIT`. Exiting with `SIGTERM` seems to help 229 | coverage.py report the correct code coverage, even without the configuration 230 | setting `sigterm = true` on `coverage.run`, but test processes also seem to 231 | re-spawn unexpectedly. Coverage.py continues generating `.coverage.*` files, 232 | even after the test run has concluded. This is why `SIGQUIT` is used instead 233 | of `SIGTERM` in the more complex `gunicorn_process` fixture. The idea is to 234 | use `SIGTERM` as little as possible to avoid these re-spawning subprocesses. 235 | """ 236 | worker_class = ( 237 | f"{gunicorn_workers.UvicornWorker.__module__}." 238 | f"{gunicorn_workers.UvicornWorker.__name__}" 239 | ) 240 | app_module = f"{__name__}:{app.__name__}" 241 | args = [ 242 | "gunicorn", 243 | "--bind", 244 | f"127.0.0.1:{unused_tcp_port}", 245 | "--graceful-timeout", 246 | "1", 247 | "--log-level", 248 | "debug", 249 | "--worker-class", 250 | worker_class, 251 | "--workers", 252 | "1", 253 | app_module, 254 | ] 255 | bind = f"127.0.0.1:{unused_tcp_port}" 256 | base_url = f"http://{bind}" 257 | verify = False 258 | with ( 259 | httpx.Client(base_url=base_url, verify=verify) as client, 260 | tempfile.TemporaryFile() as output, 261 | ): 262 | with Process(args, stdout=output, stderr=output) as process: 263 | time.sleep(2) 264 | process.client = client 265 | process.output = output 266 | yield process 267 | process.terminate() 268 | process.wait(timeout=5) 269 | 270 | 271 | @pytest.fixture 272 | def gunicorn_process_with_lifespan_startup_failure( 273 | unused_tcp_port: int, worker_class: str 274 | ) -> Generator[Process, None, None]: 275 | """Yield a subprocess running a Gunicorn arbiter with a Uvicorn worker. 276 | 277 | Output is saved to a temporary file and accessed with `read_output()`. 278 | The lifespan startup error in the ASGI app helps test worker boot errors. 279 | """ 280 | app_module = f"{__name__}:{app_with_lifespan_startup_failure.__name__}" 281 | args = [ 282 | "gunicorn", 283 | "--bind", 284 | f"127.0.0.1:{unused_tcp_port}", 285 | "--graceful-timeout", 286 | "1", 287 | "--log-level", 288 | "debug", 289 | "--worker-class", 290 | worker_class, 291 | "--workers", 292 | "1", 293 | app_module, 294 | ] 295 | with tempfile.TemporaryFile() as output: 296 | with Process(args, stdout=output, stderr=output) as process: 297 | time.sleep(2) 298 | process.output = output 299 | yield process 300 | process.terminate() 301 | process.wait(timeout=5) 302 | 303 | 304 | @pytest.mark.parametrize("signal_to_send", gunicorn_arbiter.Arbiter.SIGNALS) 305 | def test_gunicorn_arbiter_signal_handling( 306 | gunicorn_process: Process, signal_to_send: signal.Signals 307 | ) -> None: 308 | """Test Gunicorn arbiter signal handling. 309 | 310 | This test iterates over the signals handled by the Gunicorn arbiter, 311 | sends each signal to the process running the arbiter, and asserts that 312 | Gunicorn handles the signal and logs the signal handling event accordingly. 313 | 314 | https://docs.gunicorn.org/en/latest/signals.html 315 | """ 316 | signal_abbreviation = gunicorn_arbiter.Arbiter.SIG_NAMES[signal_to_send] 317 | expected_text = f"Handling signal: {signal_abbreviation}" 318 | gunicorn_process.send_signal(signal_to_send) 319 | time.sleep(2) 320 | output_text = gunicorn_process.read_output() 321 | try: 322 | assert expected_text in output_text 323 | except AssertionError: # pragma: no cover 324 | # occasional flakes are seen with certain signals 325 | flaky_signals = [ 326 | getattr(signal, "SIGHUP", None), 327 | getattr(signal, "SIGTERM", None), 328 | getattr(signal, "SIGTTIN", None), 329 | getattr(signal, "SIGTTOU", None), 330 | getattr(signal, "SIGUSR2", None), 331 | getattr(signal, "SIGWINCH", None), 332 | ] 333 | if signal_to_send not in flaky_signals: 334 | time.sleep(5) 335 | output_text = gunicorn_process.read_output() 336 | assert expected_text in output_text 337 | 338 | 339 | def test_uvicorn_worker_boot_error( 340 | gunicorn_process_with_lifespan_startup_failure: Process, 341 | ) -> None: 342 | """Test Gunicorn arbiter shutdown behavior after Uvicorn worker boot errors. 343 | 344 | Previously, if Uvicorn workers raised exceptions during startup, 345 | Gunicorn continued trying to boot workers ([#1066]). To avoid this, 346 | the Uvicorn worker was updated to exit with `Arbiter.WORKER_BOOT_ERROR`, 347 | but no tests were included at that time ([#1077]). This test verifies 348 | that Gunicorn shuts down appropriately after a Uvicorn worker boot error. 349 | 350 | When a worker exits with `Arbiter.WORKER_BOOT_ERROR`, the Gunicorn arbiter will 351 | also terminate, so there is no need to send a separate signal to the arbiter. 352 | 353 | [#1066]: https://github.com/encode/uvicorn/issues/1066 354 | [#1077]: https://github.com/encode/uvicorn/pull/1077 355 | """ 356 | output_text = gunicorn_process_with_lifespan_startup_failure.read_output() 357 | gunicorn_process_with_lifespan_startup_failure.wait(timeout=5) 358 | assert gunicorn_process_with_lifespan_startup_failure.poll() is not None 359 | assert "Worker failed to boot" in output_text 360 | 361 | 362 | def test_uvicorn_worker_get_request(gunicorn_process: Process) -> None: 363 | """Test a GET request to the Gunicorn Uvicorn worker's ASGI app.""" 364 | response = gunicorn_process.client.get("/") 365 | output_text = gunicorn_process.read_output() 366 | assert response.status_code == 204 367 | assert "inboard.gunicorn_workers", "startup complete" in output_text 368 | 369 | 370 | def test_uvicorn_worker_get_request_with_sigterm( 371 | gunicorn_process_with_sigterm: Process, 372 | ) -> None: 373 | """Test a GET request to the Gunicorn Uvicorn worker's ASGI app 374 | when `SIGTERM` is used to stop the process instead of `SIGQUIT`. 375 | """ 376 | response = gunicorn_process_with_sigterm.client.get("/") 377 | output_text = gunicorn_process_with_sigterm.read_output() 378 | assert response.status_code == 204 379 | assert "inboard.gunicorn_workers", "startup complete" in output_text 380 | -------------------------------------------------------------------------------- /tests/test_logging_conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | from inboard import logging_conf 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | from pytest_mock import MockerFixture 15 | 16 | 17 | class TestConfigureLogging: 18 | """Test logging configuration method. 19 | --- 20 | """ 21 | 22 | def test_configure_logging_file( 23 | self, logging_conf_file_path: Path, mocker: MockerFixture 24 | ) -> None: 25 | """Test logging configuration with correct logging config file path.""" 26 | logger = mocker.patch.object(logging, "root", autospec=True) 27 | logging_conf.configure_logging( 28 | logger=logger, logging_conf=str(logging_conf_file_path) 29 | ) 30 | logger.debug.assert_called_once_with( 31 | f"Logging dict config loaded from {logging_conf_file_path}." 32 | ) 33 | 34 | def test_configure_logging_module( 35 | self, logging_conf_module_path: str, mocker: MockerFixture 36 | ) -> None: 37 | """Test logging configuration with correct logging config module path.""" 38 | logger = mocker.patch.object(logging, "root", autospec=True) 39 | logging_conf.configure_logging( 40 | logger=logger, logging_conf=logging_conf_module_path 41 | ) 42 | logger.debug.assert_called_once_with( 43 | f"Logging dict config loaded from {logging_conf_module_path}." 44 | ) 45 | 46 | def test_configure_logging_module_incorrect(self, mocker: MockerFixture) -> None: 47 | """Test logging configuration with incorrect logging config module path.""" 48 | logger = mocker.patch.object(logging, "root", autospec=True) 49 | logger_error_msg = "Error when setting logging module" 50 | with pytest.raises(ModuleNotFoundError): 51 | logging_conf.configure_logging(logger=logger, logging_conf="no.module.here") 52 | assert logger_error_msg in logger.error.call_args.args[0] 53 | assert "ModuleNotFoundError" in logger.error.call_args.args[0] 54 | 55 | def test_configure_logging_tmp_file( 56 | self, logging_conf_tmp_file_path: Path, mocker: MockerFixture 57 | ) -> None: 58 | """Test logging configuration with temporary logging config file path.""" 59 | logger = mocker.patch.object(logging, "root", autospec=True) 60 | logging_conf_file = f"{logging_conf_tmp_file_path}/tmp_log.py" 61 | logging_conf.configure_logging(logger=logger, logging_conf=logging_conf_file) 62 | logger.debug.assert_called_once_with( 63 | f"Logging dict config loaded from {logging_conf_file}." 64 | ) 65 | 66 | def test_configure_logging_tmp_file_incorrect_extension( 67 | self, 68 | logging_conf_tmp_path_incorrect_extension: Path, 69 | mocker: MockerFixture, 70 | ) -> None: 71 | """Test logging configuration with incorrect temporary file type.""" 72 | logger = mocker.patch.object(logging, "root", autospec=True) 73 | incorrect_logging_conf = logging_conf_tmp_path_incorrect_extension.joinpath( 74 | "tmp_logging_conf" 75 | ) 76 | logger_error_msg = "Error when setting logging module" 77 | import_error_msg = f"Unable to import {incorrect_logging_conf}" 78 | with pytest.raises(ImportError) as e: 79 | logging_conf.configure_logging( 80 | logger=logger, 81 | logging_conf=str(incorrect_logging_conf), 82 | ) 83 | assert str(e.value) in import_error_msg 84 | logger.error.assert_called_once_with( 85 | f"{logger_error_msg}: ImportError {import_error_msg}." 86 | ) 87 | with open(incorrect_logging_conf) as f: 88 | contents = f.read() 89 | assert "This file doesn't have the correct extension" in contents 90 | 91 | def test_configure_logging_tmp_module( 92 | self, 93 | logging_conf_tmp_file_path: Path, 94 | mocker: MockerFixture, 95 | monkeypatch: pytest.MonkeyPatch, 96 | ) -> None: 97 | """Test logging configuration with temporary logging config path.""" 98 | logger = mocker.patch.object(logging, "root", autospec=True) 99 | monkeypatch.syspath_prepend(logging_conf_tmp_file_path) 100 | monkeypatch.setenv("LOGGING_CONF", "tmp_log") 101 | assert os.getenv("LOGGING_CONF") == "tmp_log" 102 | logging_conf.configure_logging(logger=logger, logging_conf="tmp_log") 103 | logger.debug.assert_called_once_with("Logging dict config loaded from tmp_log.") 104 | 105 | def test_configure_logging_tmp_module_incorrect_type( 106 | self, 107 | logging_conf_tmp_path_incorrect_type: Path, 108 | mocker: MockerFixture, 109 | monkeypatch: pytest.MonkeyPatch, 110 | ) -> None: 111 | """Test logging configuration with temporary logging config path. 112 | - Correct module name 113 | - `LOGGING_CONFIG` object with incorrect type 114 | """ 115 | logger = mocker.patch.object(logging, "root", autospec=True) 116 | monkeypatch.syspath_prepend(logging_conf_tmp_path_incorrect_type) 117 | monkeypatch.setenv("LOGGING_CONF", "incorrect_type") 118 | logger_error_msg = "Error when setting logging module" 119 | type_error_msg = "LOGGING_CONFIG is not a dictionary instance" 120 | assert os.getenv("LOGGING_CONF") == "incorrect_type" 121 | with pytest.raises(TypeError): 122 | logging_conf.configure_logging(logger=logger, logging_conf="incorrect_type") 123 | logger.error.assert_called_once_with( 124 | f"{logger_error_msg}: TypeError {type_error_msg}." 125 | ) 126 | 127 | def test_configure_logging_tmp_module_no_dict( 128 | self, 129 | logging_conf_tmp_path_no_dict: Path, 130 | mocker: MockerFixture, 131 | monkeypatch: pytest.MonkeyPatch, 132 | ) -> None: 133 | """Test logging configuration with temporary logging config path. 134 | - Correct module name 135 | - No `LOGGING_CONFIG` object 136 | """ 137 | logger = mocker.patch.object(logging, "root", autospec=True) 138 | monkeypatch.syspath_prepend(logging_conf_tmp_path_no_dict) 139 | monkeypatch.setenv("LOGGING_CONF", "no_dict") 140 | logger_error_msg = "Error when setting logging module" 141 | attribute_error_msg = "No LOGGING_CONFIG in no_dict" 142 | assert os.getenv("LOGGING_CONF") == "no_dict" 143 | with pytest.raises(AttributeError): 144 | logging_conf.configure_logging(logger=logger, logging_conf="no_dict") 145 | logger.error.assert_called_once_with( 146 | f"{logger_error_msg}: AttributeError {attribute_error_msg}." 147 | ) 148 | 149 | 150 | class TestLoggingOutput: 151 | """Test logger output after configuring logging. 152 | --- 153 | """ 154 | 155 | def _uvicorn_access_log_args(self, path: str) -> tuple[int | str, ...]: 156 | return ('%s - "%s %s HTTP/%s" %d', "127.0.0.1:60364", "GET", path, "1.1", 200) 157 | 158 | def test_logging_output_default(self, capfd: pytest.CaptureFixture[str]) -> None: 159 | """Test logger output with default format.""" 160 | logger = logging.getLogger() 161 | logging_conf.configure_logging() 162 | logger.info("Hello, World!") 163 | captured = capfd.readouterr() 164 | assert "INFO" in captured.out 165 | assert "Hello, World!" in captured.out 166 | 167 | @pytest.mark.parametrize( 168 | "log_format,log_level_output", 169 | (("gunicorn", "[DEBUG]"), ("uvicorn", "DEBUG: "), ("verbose", "DEBUG ")), 170 | ) 171 | def test_logging_output_custom_format( 172 | self, 173 | capfd: pytest.CaptureFixture[str], 174 | log_format: str, 175 | log_level_output: str, 176 | logging_conf_tmp_file_path: Path, 177 | monkeypatch: pytest.MonkeyPatch, 178 | ) -> None: 179 | """Test logger output with custom format.""" 180 | logging_conf_file = f"{logging_conf_tmp_file_path}/tmp_log.py" 181 | monkeypatch.setenv("LOG_FORMAT", log_format) 182 | monkeypatch.setenv("LOG_LEVEL", "debug") 183 | logger = logging.getLogger() 184 | logging_conf.configure_logging(logging_conf=logging_conf_file) 185 | logger.debug("Hello, Customized World!") 186 | captured = capfd.readouterr() 187 | assert log_format not in captured.out 188 | assert log_level_output in captured.out 189 | assert f"Logging dict config loaded from {logging_conf_file}." in captured.out 190 | assert "Hello, Customized World!" in captured.out 191 | 192 | @pytest.mark.parametrize( 193 | "log_filters_input,log_filters_output", 194 | ( 195 | ("/health", {"/health"}), 196 | ("/health, /heartbeat", {"/health", "/heartbeat"}), 197 | ("foo, bar, baz", {"foo", "bar", "baz"}), 198 | ), 199 | ) 200 | def test_logging_filters( 201 | self, 202 | capfd: pytest.CaptureFixture[str], 203 | log_filters_input: str, 204 | log_filters_output: set[str], 205 | mocker: MockerFixture, 206 | monkeypatch: pytest.MonkeyPatch, 207 | ) -> None: 208 | """Test that log message filters are applied as expected.""" 209 | monkeypatch.setenv("LOG_FILTERS", log_filters_input) 210 | mocker.patch.object( 211 | logging_conf, "LOG_FILTERS", logging_conf.LogFilter.set_filters() 212 | ) 213 | mocker.patch.dict( 214 | logging_conf.LOGGING_CONFIG["filters"]["filter_log_message"], 215 | {"()": logging_conf.LogFilter, "filters": logging_conf.LOG_FILTERS}, 216 | clear=True, 217 | ) 218 | path_to_log = "/status" 219 | logger = logging.getLogger("test.logging_conf.output.filters") 220 | logging_conf.configure_logging(logger=logger) 221 | logger.info(*self._uvicorn_access_log_args(path_to_log)) 222 | logger.info(log_filters_input) 223 | for log_filter in log_filters_output: 224 | logger.info(*self._uvicorn_access_log_args(log_filter)) 225 | captured = capfd.readouterr() 226 | assert logging_conf.LOG_FILTERS == log_filters_output 227 | assert path_to_log in captured.out 228 | for log_filter in log_filters_output: 229 | assert log_filter not in captured.out 230 | 231 | @pytest.mark.parametrize( 232 | "log_filters_input", ("/health", "/healthy /heartbeat", "/healthy foo bar") 233 | ) 234 | def test_logging_filters_with_known_limitations( 235 | self, 236 | capfd: pytest.CaptureFixture[str], 237 | log_filters_input: str, 238 | mocker: MockerFixture, 239 | ) -> None: 240 | """Test known limitations of log message filters. 241 | 242 | - Filters in the input string should be separated with commas, not spaces. 243 | - Filters are applied with string matching, so a filter of `/health` will 244 | also filter out messages including `/healthy`. 245 | """ 246 | filters = logging_conf.LogFilter.set_filters(log_filters_input) 247 | mocker.patch.dict( 248 | logging_conf.LOGGING_CONFIG["filters"]["filter_log_message"], 249 | {"()": logging_conf.LogFilter, "filters": filters}, 250 | clear=True, 251 | ) 252 | logger = logging.getLogger("test.logging_conf.output.filtererrors") 253 | logging_conf.configure_logging(logger=logger) 254 | logger.info(log_filters_input) 255 | logger.info("/healthy") 256 | captured = capfd.readouterr() 257 | assert log_filters_input not in captured.out 258 | if log_filters_input == "/health": 259 | assert "/healthy" not in captured.out 260 | else: 261 | assert "/healthy" in captured.out 262 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | import inboard.types 4 | 5 | 6 | def test_type_checking_attrs() -> None: 7 | """Verify basic import functionality and attributes of the inboard type module. 8 | 9 | Type annotations are not used at runtime. The standard library `typing` module 10 | includes a `TYPE_CHECKING` constant that is `False` at runtime, or `True` when 11 | conducting static type checking prior to runtime. The inboard type module will 12 | therefore have `TYPE_CHECKING == False` when tests are running, but should 13 | still make its public types available for other modules to import. 14 | 15 | The `type: ignore[attr-defined]` comment is needed to allow `implicit_reexport` 16 | for mypy. The inboard types module imports `typing.TYPE_CHECKING`, but does not 17 | re-export it. Mypy would therefore raise an error in strict mode. 18 | 19 | https://docs.python.org/3/library/typing.html 20 | https://mypy.readthedocs.io/en/stable/config_file.html 21 | """ 22 | assert inboard.types.TYPE_CHECKING is False # type: ignore[attr-defined] 23 | for attr in ("DictConfig", "UvicornOptions"): 24 | assert hasattr(inboard.types, attr) 25 | 26 | 27 | def test_uvicorn_options_type_matches_uvicorn_args() -> None: 28 | """Test that fields in the inboard Uvicorn options type match arguments passed to 29 | `uvicorn.run()` and `uvicorn.Config.__init__()`. 30 | 31 | The inboard Uvicorn options type can be used to type-check arguments passed 32 | to `uvicorn.run()`. `uvicorn.run()` then uses these arguments to instantiate 33 | `uvicorn.Config`. 34 | 35 | Prior to Uvicorn 0.18.0, `uvicorn.run()` didn't enumerate keyword arguments, 36 | but instead accepted `kwargs` and passed them to `uvicorn.Config.__init__()` 37 | ([encode/uvicorn#1423]). Even after Uvicorn 0.18.0, the signatures of the two 38 | functions are not exactly the same ([encode/uvicorn#1545]). This test normalizes 39 | the differences, by also considering which options are only in the config. 40 | This should match with the upstream test `test_run_match_config_params` 41 | ([uvicorn/testmain]). 42 | 43 | `uvicorn.run()` is a function with a return type, so its `__annotations__` dict 44 | will include an item `return` to specify its return type. The inboard Uvicorn 45 | options type is not a function, so it will not have this `return` item. 46 | 47 | While it is straightforward to compare keys in the `__annotations__` dicts, 48 | it is less straightforward to compare the values (the type annotations). 49 | This is partially because `from __future__ import annotations`, which is used 50 | by inboard but not by Uvicorn, "stringizes" the annotations into `ForwardRef`s. 51 | 52 | [encode/uvicorn#1423]: https://github.com/encode/uvicorn/pull/1423 53 | [encode/uvicorn#1545]: https://github.com/encode/uvicorn/pull/1545 54 | [uvicorn/testmain]: https://github.com/encode/uvicorn/blob/master/tests/test_main.py 55 | """ 56 | options_for_config_only = ("callback_notify", "timeout_notify") 57 | options_for_run_only = ("app_dir",) 58 | inboard_keys = list(sorted(inboard.types.UvicornOptions.__annotations__.keys())) 59 | uvicorn_config_keys = list(sorted(uvicorn.Config.__init__.__annotations__.keys())) 60 | uvicorn_run_keys = list(sorted(uvicorn.run.__annotations__.keys())) 61 | if "return" in uvicorn_run_keys: 62 | uvicorn_run_keys.remove("return") 63 | for option in options_for_config_only: 64 | assert option not in inboard_keys 65 | assert option in uvicorn_config_keys 66 | assert option not in uvicorn_run_keys 67 | uvicorn_config_keys.remove(option) 68 | for option in options_for_run_only: 69 | assert option in inboard_keys 70 | assert option not in uvicorn_config_keys 71 | assert option in uvicorn_run_keys 72 | inboard_keys.remove(option) 73 | uvicorn_run_keys.remove(option) 74 | assert inboard_keys == uvicorn_config_keys == uvicorn_run_keys 75 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "cleanUrls": true, 3 | "trailingSlash": false 4 | } 5 | --------------------------------------------------------------------------------