Building a Python package, and a container image with poetry

Yoan Blanc
3 min readFeb 21, 2021

--

This is a follow up of the 2018 article, Building a Python package, and a docker image using Pipenv. Pipenv has been donated to the Python Packaging Authority and did not gain much traction, probably because it predates the PEP 518 that gave us the pyproject.toml as a replacement of the infamous setup.py file.

The aim of using Poetry is to solve the same issues the old fashioned way has: requirements.txt files, virtual environments, version pinning, etc. The big win over Pipenv is that pip 19 natively supports pyproject.toml, hence it will be the only file you manage.

Let’s start by creating a project in the current directory. We ask for a minimal Python version and a dependency we know.

# init the new project in the current directory
% poetry init --python "^3.7" --dependency "Flask:*"
# create the virtual environment (and the poetry.lock file)
% poetry install

Et voilà, let’s put the Flask application.

# hello_world/__init__.py
from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
return "Hello World!"

if __name__ == "__main__":
app.run()

And run the development server.

% export FLASK_APP=hello_world
% export FLASK_ENV=development
% poetry run flask run

The command poetry shell can put you inside the virtual environement if that’s your preferred way of working.

Packaging the application

If you were to publish you application on the internet, e.g. the PyPI (Python Packages Index), providing a package such as a wheel is the way to go.

% poetry build --format wheel

However, there is something important to notice. This package will contain the any flask dependency (Flask:*). Not pinning to a specific version is how it should be done, however a stricter range would be better. E.g. Flask:≥1.1,<1.2

% poetry add "Flask:>=1.1,<1.2"

To pin or not to pin

It depends on the context, when developing, don’t; when deploying, do. This allows to have one generic application and various deployments, that may be using different versions of Flask.

The poetry.lock file holds information about the current version.

# install versions from poetry.lock
% poetry install
# update versions in the poetry.lock
% poetry update --lock

Pip however doesn’t speak poetry.lock and we should rely on the good old requirements.txt file when we want to pin the version of the dependencies.

% poetry export | tee requirements.txt

That is useful when you want to install specific versions without having to rely on installing Poetry first.

Building the container image

One of the best way to run traditional Python web applications is Gunicorn, and we will go with a Ubuntu Focal image.

FROM ubuntu:focal

SHELL ["/bin/bash", "-xe", "-c"]

ARG DEBIAN_FRONTEND=noninteractive

COPY . /app

RUN apt-get update -q \
&& apt-get install -y -q --no-install-recommends \
python3-wheel \
python3-pip \
gunicorn \
&& if [ -e requirements.txt ]; then \
python3 -m pip install --no-cache-dir \
--disable-pip-version-check \
-r requirements.txt; \
fi \
&& python3 -m pip install \
--no-cache-dir --disable-pip-version-check \
/app/ \
&& apt-get remove -y python3-pip python3-wheel \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \
&& useradd _gunicorn --no-create-home --user-group

USER _gunicorn
WORKDIR /app

CMD ["gunicorn", \
"--bind", "0.0.0.0:8000", \
"hello_world:app"]

It installs the application via pip and, when provided, relies on the requirements.txt file for the dependencies. The final image is less then 120MB big. Hopefully, the base ubuntu:focal layer will be shared with other applications.

Going further

Since we are pinning the Python dependencies, we should also pin the docker image and the software installed via apt, such as, gunicorn. Tools like hadolint are great help if you want to follow best practices.

The source code in on GitHub, fork it, build, and run it.

--

--