diff --git a/README.md b/README.md index 8b36a08..78cbfcd 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,30 @@ [![codecov](https://codecov.io/gh/max-pfeiffer/uvicorn-poetry-project-template/branch/main/graph/badge.svg?token=WQI2SJJLZN)](https://codecov.io/gh/max-pfeiffer/uvicorn-poetry-project-template) ![pipeline workflow](https://github.com/max-pfeiffer/uvicorn-poetry-project-template/actions/workflows/pipeline.yml/badge.svg) # uvicorn-poetry-project-template -[Cookiecutter](https://github.com/cookiecutter/cookiecutter) project template for the -[uvicorn-poetry Docker image](https://github.com/max-pfeiffer/uvicorn-poetry). +[Cookiecutter](https://github.com/cookiecutter/cookiecutter) project template for starting a containerized Fast API project. + +It uses [Poetry](https://python-poetry.org/) for managing dependencies and setting up a virtual environment locally and in the container. + +The project is set up to produce a Docker image to run your application with [Uvicorn](https://github.com/encode/uvicorn) on [Kubernetes](https://kubernetes.io/) container orchestration system. -This Docker image provides a platform to run Python applications with [Uvicorn](https://github.com/encode/uvicorn) on [Kubernetes](https://kubernetes.io/) container orchestration system. -It provides [Poetry](https://python-poetry.org/) for managing dependencies and setting up a virtual environment in the container. ## Quick Start [Install Cookiecutter](https://cookiecutter.readthedocs.io/en/latest/installation.html) on your machine. Then: ```shell cookiecutter https://github.com/max-pfeiffer/uvicorn-poetry-project-template ``` + +### Run with Poetry In project directory install dependencies: ```shell poetry install ``` Run application in project directory: ```shell -poetry run uvicorn --workers 1 --host 0.0.0.0 --port 80 app.main:app +poetry run uvicorn --workers 1 --host 0.0.0.0 --port 8000 app.main:app ``` + +### Build and run Docker image Build the production Docker image: ```shell docker build --tag my-application:1.0.0 . diff --git a/cookiecutter.json b/cookiecutter.json index d523a93..229ce46 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -5,7 +5,7 @@ "project_description": "", "author_name": "", "author_email": "", - "python_version": ["3.12.0", "3.10.13", "3.10.13"], + "python_version": ["3.12.0", "3.11.6", "3.10.13"], "operating_system_variant": ["slim-bookworm", "bookworm"], "_extensions": ["cookiecutter.extensions.SlugifyExtension"] } \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2d9343e..abc9c25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,11 +12,6 @@ def docker_client() -> docker.client: return docker.client.from_env() -@pytest.fixture(scope="session") -def docker_file_name() -> str: - return "Dockerfile" - - @pytest.fixture(scope="session") def docker_image_test_project(cookies_session): result: Result = cookies_session.bake( @@ -29,27 +24,26 @@ def docker_image_test_project(cookies_session): } ) - # Install project dependencies with Poetry + # Create Poetry lock file for building Docker container completed_process: CompletedProcess = run( - ["poetry", "install"], cwd=result.project_path + ["poetry", "lock"], cwd=result.project_path ) if completed_process.returncode > 0: - raise Exception("Dependencies were not installed with Poetry.") + raise Exception("Lock file could not be created with Poetry.") return result @pytest.fixture(scope="session") -def production_image( - docker_client, docker_image_test_project, docker_file_name -) -> str: +def production_image(docker_client, docker_image_test_project) -> str: path: str = str(docker_image_test_project.project_path) tag: str = str(uuid4()) image: Image = docker_client.images.build( path=path, - dockerfile=docker_file_name, + dockerfile="Dockerfile", tag=tag, + target="production-image", )[0] image_tag: str = image.tags[0] yield image_tag diff --git a/tests/test_custom_config.py b/tests/test_custom_config.py index 00943fd..e7f8efb 100644 --- a/tests/test_custom_config.py +++ b/tests/test_custom_config.py @@ -26,6 +26,7 @@ def test_custom_config(cookies) -> None: assert result.project_path.name == "seriously-silly-project-name" assert result.project_path.is_dir() + # Check if pyproject.toml became expanded correctly pyproject_toml_file: Path = result.project_path / "pyproject.toml" toml_data: dict = toml.load(pyproject_toml_file) @@ -39,7 +40,9 @@ def test_custom_config(cookies) -> None: "Jane Doe " ] + # Check if Dockerfile became expanded correctly dockerfile: Path = result.project_path / "Dockerfile" dfp = DockerfileParser(path=str(dockerfile)) - assert "3.10.13-bookworm" in dfp.baseimage + assert dfp.is_multistage + assert all(["3.10.13-bookworm" in image for image in dfp.parent_images]) diff --git a/tests/test_default_config.py b/tests/test_default_config.py index 7200a8c..0739abd 100644 --- a/tests/test_default_config.py +++ b/tests/test_default_config.py @@ -16,6 +16,7 @@ def test_default_config(cookies) -> None: assert result.project_path.name == "project-name" assert result.project_path.is_dir() + # Check if pyproject.toml became expanded correctly pyproject_toml_file: Path = result.project_path / "pyproject.toml" toml_data: dict = toml.load(pyproject_toml_file) @@ -24,7 +25,9 @@ def test_default_config(cookies) -> None: assert toml_data["tool"]["poetry"]["description"] == "" assert toml_data["tool"]["poetry"]["authors"] == [] + # Check if Dockerfile became expanded correctly dockerfile: Path = result.project_path / "Dockerfile" dfp = DockerfileParser(path=str(dockerfile)) - assert "3.12.0-slim-bookworm" in dfp.baseimage + assert dfp.is_multistage + assert all(["3.12.0-slim-bookworm" in image for image in dfp.parent_images]) diff --git a/tests/test_docker_image_builds.py b/tests/test_docker_image_build.py similarity index 100% rename from tests/test_docker_image_builds.py rename to tests/test_docker_image_build.py diff --git a/{{cookiecutter.project_slug}}/Dockerfile b/{{cookiecutter.project_slug}}/Dockerfile index 7986a83..d8d02f0 100644 --- a/{{cookiecutter.project_slug}}/Dockerfile +++ b/{{cookiecutter.project_slug}}/Dockerfile @@ -1,12 +1,59 @@ -# Be aware that you need to specify these arguments before the first FROM -# see: https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact -FROM pfeiffermax/uvicorn-poetry:3.2.0-python{{ cookiecutter.python_version }}-{{ cookiecutter.operating_system_variant }} +# Using an image for dependency build stage which provides Poetry +# see: https://github.com/max-pfeiffer/python-poetry/blob/main/build/Dockerfile +FROM pfeiffermax/python-poetry:1.8.0-poetry1.7.1-python{{ cookiecutter.python_version }}-{{ cookiecutter.operating_system_variant }} as dependencies-build-stage +ENV POETRY_VIRTUALENVS_IN_PROJECT=true \ + POETRY_CACHE_DIR="/application_root/.cache" \ + PYTHONPATH=/application_root + +# Set the WORKDIR to the application root. +# https://www.uvicorn.org/settings/#development +# https://docs.docker.com/engine/reference/builder/#workdir +WORKDIR ${PYTHONPATH} # install [tool.poetry.dependencies] # this will install virtual environment into /.venv because of POETRY_VIRTUALENVS_IN_PROJECT=true # see: https://python-poetry.org/docs/configuration/#virtualenvsin-project -COPY --chown=python_application:python_application ./poetry.lock ./pyproject.toml /application_root/ +COPY ./pyproject.toml ${PYTHONPATH} RUN poetry install --no-interaction --no-root --without dev +# Using the standard Python image here to have the least possible image size +FROM python:{{ cookiecutter.python_version }}-{{ cookiecutter.operating_system_variant }} as production-image +ARG APPLICATION_SERVER_PORT=8000 + + # https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUNBUFFERED +ENV PYTHONUNBUFFERED=1 \ + # https://docs.python.org/3/using/cmdline.html#envvar-PYTHONDONTWRITEBYTECODE + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/application_root \ + VIRTUAL_ENVIRONMENT_PATH="/application_root/.venv" \ + APPLICATION_SERVER_PORT=$APPLICATION_SERVER_PORT + +# Adding the virtual environment to PATH in order to "activate" it. +# https://docs.python.org/3/library/venv.html#how-venvs-work +ENV PATH="$VIRTUAL_ENVIRONMENT_PATH/bin:$PATH" + +# Principle of least privilege: create a new user for running the application +RUN groupadd -g 1001 python_application && \ + useradd -r -u 1001 -g python_application python_application + +# Set the WORKDIR to the application root. +# https://www.uvicorn.org/settings/#development +# https://docs.docker.com/engine/reference/builder/#workdir +WORKDIR ${PYTHONPATH} +RUN chown python_application:python_application ${PYTHONPATH} + +# Copy virtual environment +COPY --from=dependencies-build-stage --chown=python_application:python_application ${VIRTUAL_ENVIRONMENT_PATH} ${VIRTUAL_ENVIRONMENT_PATH} + # Copy application files -COPY --chown=python_application:python_application /app /application_root/app/ +COPY --chown=python_application:python_application /app ${PYTHONPATH}/app/ + +# Document the exposed port +# https://docs.docker.com/engine/reference/builder/#expose +EXPOSE ${APPLICATION_SERVER_PORT} + +# Use the unpriveledged user to run the application +USER 1001 + +# Run the uvicorn application server. +CMD exec uvicorn --workers 1 --host 0.0.0.0 --port $APPLICATION_SERVER_PORT app.main:app diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md new file mode 100644 index 0000000..f60697b --- /dev/null +++ b/{{cookiecutter.project_slug}}/README.md @@ -0,0 +1,2 @@ +# {{ cookiecutter.project_name }} +{{ cookiecutter.project_description }} diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index d63d1f5..4b7397f 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -17,6 +17,7 @@ fastapi = "0.104.1" [tool.poetry.dev-dependencies] black = "23.11.0" coverage = "7.3.3" +httpx = "0.26.0" pre-commit = "3.6.0" pytest = "7.4.3" pytest-cov = "4.1.0"