Base 1 — Django with Poetry
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.
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 asetup.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.
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.