Base 1 — Django with Poetry

Piotr Zalewa
7 min readJun 23, 2024

--

I like Poetry. It gives me confidence in maintaining the dependencies. My next side project will be done in Django. It’s going to be a bumpy road, as the last time I called django-admin startproject and finished a project, it was still in the version 1.x. Let’s look into it.

Django with Poetry by Dall-E 3

The goal

We will create a minimal Django project installed with Poetry and running from the manage.py runserver, using the built-in Sqlite database. We will also install Pytest and configure it to test Django applications.

You can clone the final project from GitHub:
https://github.com/zalun/djpoe/tree/part-1

Poetry

We can install Poetry globally with pip or with a package manager. I prefer to keep all my global python in ~/.venv. (Just run pip3 install poetry if it’s not your style)

Projects ➜ ~/.venv/bin/pip3 install poetry
Projects ➜ ~/.venv/bin/poetry --version
Poetry (version 1.8.3)
Projects ➜ ln -fs ~/.venv/bin/poetry ~/.local/bin/poetry

Please create a new project, let’s name it djpoe, in the current directory.

Projects ➜ poetry new djpoe
Created package djpoe in djpoe
Projects ➜ cd djpoe
djpoe ➜ tree
.
├── README.md
├── djpoe
│ └── __init__.py
├── pyproject.toml
└── tests
└── __init__.py

I like Poetry to create the virtual environment in .venv the project’s directory

djpoe ➜ poetry config virtualenvs.in-project true --local

A few words about pyproject.toml

pyproject.toml is the unified Python project settings file. It has a similar purpose as setup.py. For now, it contains only the basic information:

[tool.poetry]
name = "djpoe"
version = "0.1.0"
description = ""
authors = ["Piotr Zalewa <zaloon@gmail.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

The [build-system] table is used to store build-related data. Initially only one key of the table will be valid and is mandatory for the table: requires. This key must have a value of a list of strings representing PEP 508 dependencies required to execute the build system (currently that means what dependencies are required to execute a setup.py file).

[tool.poetry] section defines the package’s details. You can read about it in the Poetry documentation.

[tool.poetry.dependencies] is the section containing the project’s dependencies.

Basic Poetry commands

poetry install installs all dependencies in the virtual environment.

djpoe ➜ poetry install
Creating virtualenv djpoe in /Users/piotrzalewa/Projects/TEST/djpoe/.venv
Updating dependencies
Resolving dependencies... (0.1s)

Writing lock file

Installing the current project: djpoe (0.1.0)
djpoe ➜ ls .venv
bin lib pyvenv.cfg

poetry run allows us to run commands in the package’s virtual environment.

djpoe ➜ poetry run python --version
Python 3.12.1
djpoe ➜ poetry run which python
/Users/piotrzalewa/Projects/djpoe/.venv/bin/python

poetry add [package-name] installs a package and adds it to the [tool.poetry.dependencies] section. If you want to install a developer dependency, you need to add the --group dev switch.

`poetry.lock` file

This file is similar to the requirements.txt with hashes. It is to be sure that the dependencies installed on the server will be the same as those on the local machine. It grows dramatically with each installed package.

[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "f1352aa895873733ae7b28a33579a57f6e0eb147ae0ace7ffaaaa97485835892"

Installing Pytest

As with all of the packages, we will use the add command:

djpoe ➜ poetry add --group dev pytest
Using version ^8.2.2 for pytest

Updating dependencies
Resolving dependencies... (0.4s)

Package operations: 4 installs, 0 updates, 0 removals

- Installing iniconfig (2.0.0)
- Installing packaging (24.1)
- Installing pluggy (1.5.0)
- Installing pytest (8.2.2)

Writing lock file

We’ve got a new section in pyproject.toml

[tool.poetry.group.dev.dependencies]
pytest = "^8.2.2"

Let’s create a dummy test and run Pytest.

# tests/test_dummy.py
def test_trivia():
assert True
djpoe ➜ poetry run pytest 
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
rootdir: /Users/piotrzalewa/Projects/djpoe
configfile: pyproject.toml
collected 1 item

tests/test_dummy.py . [100%]

============================== 1 passed in 0.01s ===============================

You can (and should) change the test to assert False to check with a failing test if Pytest is really working.

Installing Django

Finally.

djpoe ➜ poetry add django
Using version ^5.0.6 for django

Updating dependencies
Resolving dependencies... (0.2s)

Package operations: 3 installs, 0 updates, 0 removals

- Installing asgiref (3.8.1)
- Installing sqlparse (0.5.0)
- Installing django (5.0.6)

Writing lock file

I like to make use of the typing library. Django does not provide stubs out of the box. We need to install them by hand

djpoe ➜ poetry add django-types django-stubs-ext

Our pyproject.toml has extended the dependencies section:

[tool.poetry.dependencies]
python = "^3.12"
django = "^5.0.6"
django-stubs-ext = "^5.0.2"
django-types = "^0.19.1"

Let’s now start a new Django project. We’ll call it djpoe, so we first need to delete the existing djpoe/ directory. Then, we will call django-admin to migrate the database and create the administrator account.

djpoe ➜ rm -rf djpoe
djpoe ➜ poetry run django-admin startproject djpoe
djpoe ➜ tree djpoe
djpoe
├── djpoe
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── manage.py
djpoe ➜ poetry run python  djpoe/manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying sessions.0001_initial... OK
djpoe ➜ poetry run python djpoe/manage.py createsuperuser 
Username (leave blank to use 'piotrzalewa'): zalun
Email address: x@xx.xx
Password:
Password (again):
Superuser created successfully.

Let’s see if the development server is running

djpoe ➜ poetry run python djpoe/manage.py runserver   
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
June 22, 2024 - 18:17:39
Django version 5.0.6, using settings 'djpoe.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
First view of Django site on http://127.0.0.1:8000

You can navigate to http://127.0.0.1:8000/admin login with the provided credentials and configure it further.

Testing Django

Django provides extensions for pytest. It includes fixtures allowing to render pages and an easy way to mock directory access.

djpoe ➜ poetry add --group dev pytest-django
Using version ^4.8.0 for pytest-django

We need to configure Pytest and provide the path to the Django settings. We use djpoe.djpoe, because the tests directory is on the same level as the Django project.

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = djpoe.djpoe.settings
FAIL_INVALID_TEMPLATE_VARS = True
django_debug_mode = false
django_find_project = false

Testing models

Our site isn’t doing a thing. The first easy thing to check is creating a user. We can do it using the User class:

# tests/test_auth_models_user.py
import pytest
from django.contrib.auth.models import User


@pytest.mark.django_db
def test_user_create():
User.objects.create_user(username="john", password="pass1234")

assert User.objects.count() == 1
user = User.objects.first()
assert user.username == "john"
assert user.is_active is True
assert user.is_staff is False
djpoe poetry run pytest
============================= test session starts ==============================
platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0
django: version: 5.0.6, settings: djpoe.djpoe.settings (from ini)
rootdir: /Users/piotrzalewa/Projects/TEST/djpoe
configfile: pytest.ini
plugins: django-4.8.0
collected 2 items

tests/test_auth_models_user.py . [ 50%]
tests/test_dummy.py . [100%]

============================== 2 passed in 0.93s ===============================

First, we import pytest module and the User class. Then, we decorate the test with a marker providing the Django database @pytest.mark.django_db. This decorator creates a database in memory and deletes it for each test. We can create the next test, create a user there, and the User.objects.count() will return 1 as in the first test.

A user account is created with User.objects.create_user() method. It is then tested.

This isn’t the best option. The User class might not be used for the user account in our app. pytest-django provides us with a fixture called django_user_model. It returns the model defined in the djpoe/djpoe/settings.py with AUTH_USER_MODEL.

Navigate to the pytest-django documentation to see other fixtures.

# tests/test_auth_models_user.py
import pytest


@pytest.mark.django_db
def test_user_create(django_user_model):
django_user_model.objects.create_user(username="john", password="pass1234")

assert django_user_model.objects.count() == 1
user = django_user_model.objects.first()
assert user.username == "john"
assert user.is_active is True
assert user.is_staff is False

Using `client` to test rendered pages

We can also test the rendering of the admin login page.

# tests/test_admin_page.py
import pytest
from django.test import Client
from django.urls import reverse


@pytest.mark.django_db
def test_render_admin_login(client: Client):
url = reverse("admin:login")

response = client.get(url)

assert response.status_code == 200

We use here the client fixture. It is an object pretending to be a browser. Unfortunately, Pytest fails with an error:

FAILED tests/test_admin_page.py::test_render_admin_login -
ModuleNotFoundError: No module named 'djpoe.urls'

This happens because using djpoe.djpoe.settings is more a workaround than a solution. Pytest doesn’t fully understand the project structure and fails on some of the imports. We need to reconfigure Python paths

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = djpoe.settings
django_debug_mode = false
pythonpath = . djpoe

We’ve added . and djpoe to the pythonpath for Pytest only.

djpoe ➜ poetry run pytest                         
============================= test session starts ==============================
...

tests/test_admin_page.py . [ 33%]
tests/test_auth_models_user.py . [ 66%]
tests/test_dummy.py . [100%]

============================== 3 passed in 1.41s ===============================

Now we can check if we can use the admin page as a regular user.

Let’s add these two methods to the tests/test_admin_page.py


@pytest.mark.django_db
def test_admin_redirect_if_not_superuser(client: Client, django_user_model: User):
user = django_user_model.objects.create_user(username="john", password="pass1234")
client.force_login(user)
url = reverse("admin:index")

response = client.get(url)

assert response.status_code == 302
assert response.content == b""
assert response.url == "/admin/login/?next=/admin/"


@pytest.mark.django_db
def test_admin_success_for_superuser(client: Client, django_user_model: User):
user = django_user_model.objects.create_user(
username="john", password="pass1234", is_superuser=True, is_staff=True
)
client.force_login(user)
url = reverse("admin:index")

response = client.get(url)

assert response.status_code == 200
assert b"Django site admin" in response.content

In the first test, we log in as the standard user. Attempting to load the admin index page ends with a redirect to the login page.

In the second test, we create the superuser and check if the admin index page is loaded properly.

We achieved the goal. We can test the code. The next part is about linting.

--

--

Piotr Zalewa

Creator of JSFiddle, ex-Mozilla dev. Software consultant & mentor. I code and write about programming, mostly Python. Open to diverse technologies.