Skip to main content

pytest-django-admin-kit

A pytest toolkit for writing conscious tests against the Django admin.

Tests describe admin pages, their fields, rows, permissions and errors, using ordinary assert statements, and stay unchanged across supported Django versions.

def test_product_admin(admin_ui, admin_user, product):
    admin_ui.login(admin_user)

    assert admin_ui.list(Product).works
    assert admin_ui.edit(product).works

Install it as pytest-django-admin-kit; import it as django_admin_kit.

Status

Version 1.0.0 implements the API written up in specification.md, and its public API is stable. What changed in each release is in CHANGELOG.md, and what comes next is the roadmap in section 33 of the specification.

What it solves today

Logging in as any user, without knowing a password. Passwords are stored hashed, and a user created for a test may have none at all.

admin_ui.login(user)

Telling whether a page opened, was refused, or is not there. The admin refuses in several ways, and only some of them change the URL.

page = admin_ui.edit(product)

assert page.works        # or page.denied, page.missing, page.redirected

Reading what a page calls itself. The title and subtitle the admin renders, so a test can say which page it is looking at without matching markup.

page = admin_ui.edit(product)

assert page.title == "Change product"
assert page.subtitle == "Widget"

Getting admin URLs without hardcoding /admin/. URLs come from the site under test, so a project that mounts its admin elsewhere changes nothing.

admin_ui.url.edit(product)

Testing the login page itself. Typing into the form is an opt-in, never the default.

admin_ui.login(user, password="secret")

Opening a changelist and reading its count and columns. The count is what the page says, not the rows on one page of it; columns come by label and by the name the admin configures them with; and a refused user gets denied rather than a count.

page = admin_ui.list(Product)

assert page.works
assert page.count == 3
assert page.summary == "3 products"
assert not page.empty
assert page.headers == ["Name", "Sku", "Price", "Is active", "Released on"]
assert page.columns == ["name", "sku", "price", "is_active", "released_on"]
assert page.has_header("Price")
assert page.has_column("is_active")

Reading and matching changelist rows. A row is addressed by position and a cell by the name its column is configured with. A cell's value is what the user reads, normalized only where the admin drew something other than text; contains checks one row is there and match checks the whole changelist in order, and both print the expected pattern and the actual rows when they fail.

from django_admin_kit.matching import ANY, ANY_ROW
from django_admin_kit.rendered import Link

page = admin_ui.list(Product)

assert page.rows[0]["price"].value == "10.00"
assert page.rows[0]["is_active"].value is True
assert page.rows[0]["name"].links == [("Bolt", admin_ui.url.edit(bolt))]
assert page.rows[0].object == bolt

assert page.contains(("Bolt", "SKU-1", "10.00", True))
assert page.contains({"name": Link("Bolt", admin_ui.url.edit(bolt)), "is_active": ANY})
assert page.match([
    ("Bolt", "SKU-1", "10.00", True),
    ANY_ROW,
    {"name": "Washer", "price": lambda row, cell: cell.value.endswith(".00")},
])

Reading form fields. The create and edit pages list their fields by name, in the order the admin presents them, whether the user may fill them or only read them. A field the admin renders hidden is not shown, so it is not listed. Whether a field is required is read the way the user sees it, from the form the admin renders, so a ModelForm that disagrees with the model wins. A field's label is what the user sees next to it, without the colon. Its value is what its control holds: text as typed, a checkbox as a boolean, a select as the choice made, with the value the form posts and the label the user reads; a rendered-only field reads as a changelist cell does.

page = admin_ui.edit(product)

assert list(page.fields) == [
    "name",
    "sku",
    "price",
    "quantity",
    "is_active",
    "featured",
    "released_on",
    "category",
    "price_with_tax",
]
assert "name" in page.fields
assert page.fields["name"].required
assert not page.fields["price_with_tax"].editable
assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
assert page.optional_fields == {"is_active", "featured", "category"}

assert page.fields["name"].label == "Name"
assert page.fields["name"].value == "Widget"
assert page.fields["is_active"].value is True
assert page.fields["category"].value == (str(tools.pk), "Tools")
assert page.fields["category"].value.label == "Tools"
assert page.fields["category"].choices == [("", "---------"), (str(tools.pk), "Tools")]
assert page.fields["price_with_tax"].value == "12.000"
page.fields["name"].native.locator("input").fill("Gadget")

admin_ui.login(viewer)    # may only view, so every field is rendered only
page = admin_ui.edit(product)

assert page.fields["released_on"].value == "Jan. 15, 2026"
assert page.fields["category"].links == [("Tools", admin_ui.url.edit(tools))]

Filling a field the way a user would. Each control is written in the terms it is read back in, so a value goes in as the test has it rather than as the markup wants it.

page = admin_ui.create(Product)

page.fields["name"].fill("Widget")
page.fields["price"].fill(Decimal("19.99"))
page.fields["quantity"].fill(3)
page.fields["released_on"].fill(date(2026, 1, 15))
page.fields["is_active"].fill(False)
page.fields["featured"].fill(True)
page.fields["category"].fill(str(tools.pk))

assert page.fields["price"].value == "19.99"
assert page.fields["featured"].value == ("true", "Yes")

A select takes back a choice it offered, page.fields["category"].fill(choice), and None puts it on its blank option. A value no option has raises ValueError naming the options there are, and a field the user may only read raises LookupError.

Filling a whole form in one call. A dictionary carries the shared data and keywords carry the values this one test is about. Fields that are named nowhere keep the values the admin rendered them with.

page = admin_ui.create(Product)

page.populate({
    "name": "Widget",
    "sku": "SKU-1",
    "price": "19.99",
})
page.populate(name="Gadget", is_active=False)

assert page.fields["name"].value == "Gadget"
assert page.fields["quantity"].value == "1"    # untouched, as the admin rendered it

Only the form's own fields are filled, so a key the form has no field for changes nothing, and price_with_tax, which the admin renders read-only, is passed over.

A mode, PagePopulationMode from django_admin_kit.pages, narrows what is filled to the fields the form requires or to the ones it leaves open, so one data set serves a test about either:

page.populate(data, PagePopulationMode.REQUIRED)    # name, sku, price, quantity, released_on
page.populate(data, PagePopulationMode.OPTIONAL)    # is_active, featured, category

The source can be an object instead, read by attribute, which makes a saved record a form's worth of data:

page.populate(product)                      # its category picks that option
page.populate(product, name="Renamed")      # everything it has, with one value replaced
page.populate(SimpleNamespace(name="Widget", price="19.99"))

Teaching the package a widget. The package reads and fills checkboxes, selects, and any single control named after its field. For any other widget, such as one your project draws itself, write a widget class and list it in your settings. From then on value, fill, choices and populate work on that field as on any other.

# shop/kit_widgets.py
from django_admin_kit.fields import FieldChoice


class SwatchPickerWidget:
    def marker(self, field):
        return ".swatch-picker"

    def read(self, field):
        pressed = field.native.locator('button[aria-pressed="true"]')
        return self._choice(pressed) if pressed.count() else None

    def fill(self, field, value):
        field.native.locator(f'button[data-value="{value}"]').click()

    def choices(self, field):
        return [self._choice(button) for button in field.native.locator("button").all()]

    def _choice(self, button):
        return FieldChoice(button.get_attribute("data-value"), button.inner_text())
DJANGO_ADMIN_KIT = {
    "widgets": [
        "shop.kit_widgets.SwatchPickerWidget",
    ],
}
page.fields["color"].fill("green")

assert page.fields["color"].value == ("green", "Green")
assert page.fields["color"].choices == [("red", "Red"), ("green", "Green"), ("blue", "Blue")]

The package handles a field in two steps:

  1. Which class handles the field? The package sends every class's marker to the browser in one call. The first one that matches something inside the field's box picks the class. What it matched is not kept.
  2. Read or fill it. The package calls read(field) or fill(field, value) on that class. field.native is the whole box, label included, and each method finds the parts it needs there.

So a marker only has to recognize the widget. It can be a wrapper, or a part that only some renderings have. Take django-file-resubmit: its image widget looks like a plain file input until a save fails. Then it adds a hidden image_cache_key input and the cached file's name, so the user doesn't have to pick the file again. A class for that case is marked by the hidden input, and its fill works on the file input next to it:

class ResubmitFileWidget:
    def marker(self, field):        # only a form rendered back with a cached upload
        return f'input[type="hidden"][name="{field.name}_cache_key"]'

    def read(self, field):            # the cached file's name
        ...

    def fill(self, field, value):     # a new upload replaces the cached one
        field.native.locator(f'input[type="file"][name="{field.name}"]').set_input_files(value)

Here field.native holds the label, the file input, the "Currently" link, the cached file's name and the hidden input. A marker is plain CSS as the browser reads it, so Playwright's own syntax, such as text=, belongs in read and fill. To combine conditions, use :has(): ':scope:has(input[name="image_cache_key"]) input[type="file"]'.

Your classes are tried first, in the order you list them, then the package's own: CheckboxWidget, SelectWidget and TextWidget, from django_admin_kit.widgets. Two rules follow from that:

  • A marker matches what is particular to its widget. A class marked by input[type="file"] would take every file field in the project.
  • Narrower classes come before broader ones, since the first match wins. ResubmitFileWidget goes before a class for any file input.

To read a control the package knows differently, put a subclass of its class in front of it:

class BareSelectWidget(SelectWidget):
    def read(self, field):
        return super().read(field).value    # "3" rather than ("3", "Tools")

marker, read and fill are required. choices is optional, and field.choices is [] without it. A class never calls value, fill or choices on the field it gets, since that asks the widget classes again and never ends. marker runs for every field read or filled, so it builds a selector and doesn't read the page.

A widget made of several inputs, such as the admin's split date-time, is one field and one class. Parts drawn outside the box are reached through field.native.page, and parts in a frame through field.native.frame_locator. A field no class recognizes raises LookupError right away, naming the widgets setting. Widget classes only apply to fields the user can edit; a field the user may only read is read by the rules described below.

Knowing what a form lets the user do. An action is a button that submits the form, named exactly by what it posts, which is what the admin's view checks for. The admin's own are constants in FormAction, from django_admin_kit.pages, and each has its own check.

page = admin_ui.edit(product)

assert page.actions == {
    FormAction.SAVE,
    FormAction.SAVE_AND_CONTINUE,
    FormAction.SAVE_AND_ADD_ANOTHER,
}
assert page.has_action("_continue")
assert page.can_save
assert not page.can_save_as_new
assert page.can_delete

A button a project adds is an action under the name it posts, such as "_refresh" on the test project's feeds. Deleting is a link rather than a button, so it is not in actions and has its own check.

Submitting a form. Filling a form and submitting it are separate steps. Each of the admin's buttons has a method, save(), save_and_continue(), save_and_add_another(), and save_as_new() on an edit page, and submit presses any button by the name it posts. An action the page does not offer raises LookupError naming the ones it does, and submits nothing.

page = admin_ui.create(Product)
page.populate(
    name="Widget",
    sku="SKU-1",
    price="10.00",
    released_on=date(2026, 1, 15),
)

result = page.save()

assert result.success
assert result.redirected_to_list(Product)
assert result.page.count == 1

success is whether the admin redirected, which it does after every save it accepts. redirected_to(url) takes any admin_ui.url path, and redirected_to_index(), redirected_to_list(model), redirected_to_create(model) and redirected_to_edit(instance) are shorthands for the usual ones. A form the admin rejects is shown again at its own URL without a redirect, so it is never redirected there.

result.page is the page the browser shows afterwards. When the browser stays on the submitted page, as with a rejected form or "Save and continue", it is that same page object; otherwise it is a new page of the type its URL names, or a plain AdminPage for a page the package does not model.

page = admin_ui.create(Product)
page.populate(name="", price="10.00")

result = page.save()

assert not result.success
assert result.page is page
assert page.fields["price"].value == "10.00"

Finding out why a form was rejected. The admin shows errors at three levels, and each is read on its own: the notice above the form, the errors of the form as a whole, and the errors of each field. A field can fail several rules at once, so its errors are always a list.

page = admin_ui.create(Product)
page.populate(
    name="Widget",
    price="10.00",
)

result = page.save()

assert result.page.errors.banner == "Please correct the errors below."
assert result.page.errors.non_field == []
assert result.page.fields["sku"].errors == ["This field is required."]
assert result.page.errors.fields == {
    "sku": ["This field is required."],
    "released_on": ["This field is required."],
}

A page the admin has not rejected shows no errors, so assert not page.errors holds on one that was never submitted. The notice's wording depends on how many errors there are, so a test that only cares that the form was rejected asserts result.page.errors.banner is there.

Reading what the admin reported. Every page reads the messages the admin shows after an operation, each with its level and its text. A level is the tag Django draws the message with, so a project that renames or adds levels through MESSAGE_TAGS reads them by its own tags.

page = admin_ui.create(Product)
page.populate(
    name="Widget",
    sku="SKU-1",
    price="10.00",
    released_on=date(2026, 1, 15),
)

result = page.save()

assert result.page.messages == [
    ("success", "The product “Widget” was added successfully."),
]
assert result.page.messages[0].level == "success"
assert result.page.messages.of_level("success") == [
    "The product “Widget” was added successfully.",
]
assert not result.page.messages.of_level("error")

of_level raises KeyError listing the project's levels for a name it does not have, so a level the project renamed never reads as "no messages". Messages are also read from a page that did not open, since the admin says there why: an object that no longer exists sends the user to the index with a warning, and page.messages on that page reads it.

Reading is live. Nothing read from a page is kept, so every read is the page as it is now, including what the admin's own scripts changed in place. A field, row or cell taken from a page is an address, read each time it is used: a field by its name, a row by its position. Once the browser shows another page, reading the old page object, or anything taken from it, fails at once and says where the browser is.

result = page.save()                 # the admin redirects to the changelist

page.fields["name"].value            # LookupError: The browser no longer shows this page; ...
result.page.rows[0]["name"].value    # read the page the browser shows now

Deleting. The admin deletes in two steps, and so does the package: delete() follows an edit page's delete link to the confirmation, and confirm_deleting() there deletes.

confirmation = admin_ui.edit(product).delete()

assert confirmation.can_confirm_deleting

result = confirmation.confirm_deleting()

assert result.redirected_to_list(Product)

When deleting would also remove objects that are protected, or that the user may not delete, the admin offers no confirmation: a category a product uses reads can_confirm_deleting as False, and confirm_deleting() raises LookupError.

If the admin refuses a confirmation it offered, because the object became protected or the user may no longer delete it after the page opened, the result reads not result.success.

Knowing what a deletion will remove, and what stops it. The confirmation lists the object and every related object it takes with it, as the page writes them, and counts them per model.

page = admin_ui.delete(product)

assert page.deletions == [
    "Product: Widget",
    "Review: Great",
    "Review: Fine",
]
assert page.deletion_counts == {
    "Products": 1,
    "Reviews": 2,
}

A page that cannot be confirmed lists what blocks the deletion instead: the objects that protect it, or the kinds of object the user may not delete. intro is the sentence the page starts with, which says which of the two it is.

page = admin_ui.delete(category)

assert page.blockers == ["Product: Widget"]
assert page.deletions == []

Entries are texts, since the admin links an object only when its model is registered. deletion_counts is keyed by the labels the page shows, and reading a model the deletion removes none of raises KeyError naming the models it does count. The wording of intro depends on the language and the Django version, which changed its quote marks in 6.0.

Knowing what the index shows a user. Models and apps a user may not see are not listed, and the lists come back in the admin's order.

page = admin_ui.index()

assert Product in page.models
assert page.models == [Group, User, Product]
assert page.apps == ["Authentication and Authorization", "Shop"]
assert page.models_for("Shop") == [Product]

Reading values as your project wants them. A cell, and a field the user may only read, is read by a rule for its kind. Every default returns the text the user reads, except boolean, which reads the admin's icon as True, False or None. Replace a rule in your settings and it receives only the values of its kind.

DJANGO_ADMIN_KIT = {
    "normalizers": {
        "date": "shop.rules.as_date",
    },
}
# shop/rules.py, for a project whose DATE_FORMAT is "Y-m-d"
from datetime import date


def as_date(rendered):
    return date.fromisoformat(rendered.text)

A missing date never reaches it: the admin shows its empty display there, which the empty rule reads.

assert page.rows[0]["released_on"].value == date(2026, 1, 15)

The kind comes from the model field behind the value, then from what the admin drew. The first that applies wins:

  • empty: the text the admin shows for a value it has none of there;
  • choice: a field with choices, shown as its label;
  • boolean, date, datetime, time or number: by the field's class, the most specific first, so a date-time field is a datetime;
  • boolean for a lone boolean icon, then link for a value that renders links;
  • text.

A rule gets the rendered value: its text, links, native, field (the model field, or None for a method) and empty_display. A rule is named by its dotted path, and an unknown name or a path that does not import is an error. A form field the user can edit is read from its control and goes through no rule.

Reaching anything the package does not model. Open any admin path, then use the Playwright page directly.

page = admin_ui.open(reverse("admin:shop_product_report"))

assert page.works
page.native.click("#download")

admin_ui.native is the Playwright context, and admin_ui.absolute(path) builds a full URL on the live server when something outside the package needs one.

Settings

Everything lives under one key in Django settings, and a key it does not know is an error:

DJANGO_ADMIN_KIT = {
    "site": "project.ops.ops_site",   # dotted path to the AdminSite; default: admin.site
    "timeout": 30_000,                # ms for any single browser operation
    "timezone": "Europe/Kyiv",        # default: TIME_ZONE
    "locale": "uk",                   # default: LANGUAGE_CODE
    "normalizers": {                  # rules that read values, by kind; see above
        "date": "shop.rules.as_date",
    },
    "widgets": [                      # widget classes, tried before the package's own
        "shop.kit_widgets.SwatchPickerWidget",
    ],
}

timezone and locale pin what the browser reports, so a date or a label reads the same on every machine.

If your project already sets timezone_id or locale in pytest-playwright's browser_context_args, those win. The package only fills in what browser_context_args leaves unset, whether the value under DJANGO_ADMIN_KIT was written by you or defaulted from TIME_ZONE and LANGUAGE_CODE. So if you want admin tests to run with the same zone and language as the rest of your browser tests, keep timezone and locale out of DJANGO_ADMIN_KIT and set them in browser_context_args only.

admin_ui is put together from five session fixtures. Override one and the others stay as they are:

  • admin_ui_config: the DJANGO_ADMIN_KIT settings, read and checked once. An override must be session-scoped too.
  • admin_ui_urls: the admin URLs for the site under test. Override it to point the session at another admin site.
  • admin_ui_normalizers: the rules that read values, built once from normalizers. Override it in a conftest.py or a test module to read those tests' values differently, or parametrize it for one test, with Normalizers from django_admin_kit.normalize: @pytest.mark.parametrize("admin_ui_normalizers", [Normalizers({"date": as_date})]).
  • admin_ui_widgets: the widget classes, built once from widgets. Override it in a conftest.py or a test module with Widgets([...]) of instances to use other classes there, or parametrize it for one test the same way; the package's own are added behind them.
  • admin_ui_driving: keeps Django's database access working while a browser runs. Leave it alone.

Requirements

Python 3.8 or newer, Django 3.2 or newer.

Development uses uv for environments, dependencies and builds. If you don't have it:

curl -LsSf https://astral.sh/uv/install.sh | sh

Setting up

git clone https://github.com/<your-fork>/pytest-django-admin-kit.git
cd pytest-django-admin-kit

uv sync
uv run pre-commit install

uv sync creates .venv, installs the package in editable mode, and installs the dev tooling from uv.lock. That lock file is committed, so everyone gets the same ruff and mypy and therefore the same results.

The tests drive real browsers, so you need the binaries too:

uv run playwright install chromium              # enough for everyday work
uv run playwright install firefox webkit        # the other two the package supports
sudo uv run playwright install-deps webkit      # webkit also needs system libraries

Only chromium is required. A test that needs another browser skips itself by name when that browser isn't downloaded, so a partial install costs you coverage rather than a wall of red. CI installs all three and fails if anything skips.

Which Python to develop on

uv sync picks an interpreter for you. If you want to choose, use 3.10, and add a 3.12 environment when you need to check Django 6.x:

uv sync --python 3.10

Two reasons this matters more than usual here.

First, don't develop on 3.8. It's been end of life since October 2024, and resolution caps you there at Django 4.2, playwright 1.48 and pytest 8.3.5. You couldn't run Django 5.x or 6.x locally at all. CI proves the 3.8 floor for you.

Second, no single interpreter covers every Django version we support. Django 3.2 stops at Python 3.10 and Django 6.1 starts at 3.12, so there's no overlap:

Python Django versions it can run
3.8, 3.9 3.2 to 4.2
3.10 3.2 to 5.2
3.11 4.1 to 5.2
3.12 4.2 to 6.1
3.13, 3.14 5.1 to 6.1

3.10 is the better default because it reaches the Django 3.2 floor, where most version-specific breakage turns up. You don't need it installed already; uv will fetch it.

Everyday commands

While you're working, this is the whole loop:

uv run pytest
uv run pytest -n auto      # the same, on every core

Before you push:

uv run ruff check .        # lint
uv run ruff format .       # format in place
uv run mypy                # strict, src/ only
uv run pytest

pre-commit install already runs ruff and the whitespace checks on every commit, so those first two mostly buy you faster feedback. To run every hook over the whole tree:

uv run pre-commit run --all-files

Driving the browser

The browser comes from pytest-playwright, so its flags work here and mean what they mean anywhere else:

uv run pytest --headed                          # watch it happen
uv run pytest --slowmo 200                      # and slow it down enough to follow
uv run pytest --browser firefox                 # one browser
uv run pytest --browser chromium --browser firefox   # the suite, twice
uv run pytest --video on --tracing on           # artifacts under test-results/

Because the browser is theirs, a test's id carries the browser it ran on: test_a_superuser_reaches_the_index[chromium]. Asking for two browsers runs every admin test twice.

One thing to know before you install this package anywhere. pytest-playwright registers an autouse fixture that deletes the --output directory, test-results/ by default, at the start of every pytest session, whether or not the session opens a browser. If your project keeps JUnit XML or coverage output there, move it or pass --output somewhere else. We inherit that behavior by depending on the plugin, and there's no way to switch it off.

Don't pin the live server's port. admin_ui is built on pytest-django's live_server, which binds a free port for each process, so running the suite in parallel with pytest-xdist works out of the box. Setting a fixed address with --liveserver=localhost:8081 or the DJANGO_LIVE_TEST_SERVER_ADDRESS environment variable makes every worker fight for the same port. It passes under a single process and fails under -n, looking like a flaky port bug rather than a configuration one.

The test project

The suite runs against a small Django project in tests/project/, wired up by the [tool.pytest.ini_options] table in pyproject.toml. You don't have to do anything to use it — no environment variables, no manage.py.

It uses Django's defaults: the admin is at /admin/ and the user model is auth.User. The things a project can customize are each proved by their own tests, which swap the custom setup in per test:

  • tests/test_custom_user.py sets AUTH_USER_MODEL to accounts.User, a model with no username field that authenticates by email;
  • tests/test_custom_prefix.py uses @pytest.mark.urls("project.urls_backoffice"), which serves the admin at /backoffice/;
  • tests/test_custom_site.py overrides the admin_ui_urls fixture to point at ops_site, a second AdminSite mounted at /ops/ that registers only Product.

The swap only works in this direction. auth.User is swappable, so a project whose default is a custom model never creates the auth_user table, and no test could swap the default back in.

The database is in-memory SQLite. That is worth knowing when a test feels slow later on: any test that uses live_server is forced onto transactional_db by pytest-django, whatever mark it carries, so the database is rebuilt rather than rolled back. SQLite keeps that cheap.

If you change a model in tests/project/, regenerate its migration:

PYTHONPATH=tests DJANGO_SETTINGS_MODULE=project.settings uv run django-admin makemigrations

Those migration files are excluded from ruff so that what's committed stays byte-identical to what Django generates.

Changing dependencies

Edit pyproject.toml, then:

uv lock                    # update uv.lock
uv sync                    # apply it to .venv

Commit pyproject.toml and uv.lock together.

The version matrix

tox.toml defines 18 cells: nine Django minors, each on its oldest supported Python at or above the 3.8 floor and on its newest.

uv run tox                        # every cell
uv run tox -p 6                   # every cell, six at a time
uv run tox -e 3.8-dj32            # one cell
uv run tox list -d                # what the cells are called

Anything after a bare -- is passed straight through to pytest, so a failing cell can be picked apart without touching tox.toml:

uv run tox -e 3.10-dj52 -- -x -vv                     # stop at the first failure, verbosely
uv run tox -e 3.10-dj52 -- tests/test_project.py      # just one file

The whole sweep takes well under a minute on a warm cache, so there's no reason to leave it to CI. On a cold one, budget for downloading five interpreters and a copy of Django and playwright per cell.

tox-uv means uv builds those environments, which matters most at the floor: python3.8 -m venv fails with ensurepip errors on a lot of systems, and uv fetches its own 3.8 instead.

Only pytest-django is pinned per cell, because no single release spans Django 3.2 to 6.1. Everything else is left to the resolver, which caps itself per interpreter — 3.8 cells end up on pytest 8.3.5 and playwright 1.48.0, newer cells on current. That cap is the constraint to design against: code has to work on playwright 1.48.

There is a second, larger matrix behind the full label: the whole cartesian, every Django minor against every interpreter it supports, 33 cells. It's too slow to sit in front of every pull request, so it gates releases instead. Run it before tagging one:

uv run tox -m full -p 8
uv run tox list -m full          # the 33 cell names

CI

Two workflows, both in .github/workflows/.

ci.yml runs on pull requests and pushes to main. It lints, type-checks, runs the 18 cells, runs one extra job that exercises firefox and webkit, and one that runs the whole suite under pytest-xdist with four workers, because parallel safety is something the package promises. release.yml runs on a v* tag: it runs all 33 cells, then builds, then publishes to PyPI, each job gated on the one before it, so a matrix failure stops the release rather than merely being recorded next to it.

Browsers are not a Django-compatibility dimension, so they don't multiply the matrix. Each cell installs chromium — the binary is keyed to that cell's playwright version, which is why tox does it rather than the workflow — and the separate job covers the other two once.

Neither workflow lists its cells. Both ask tox for them:

uvx --from 'tox>=4.61' --with tox-uv tox list -d --no-desc -q      # what ci.yml runs
uvx --from 'tox>=4.61' --with tox-uv tox list -m full --no-desc -q # what release.yml runs

So editing tox.toml changes CI, and the two cannot disagree.

The build job refuses to run if the tag doesn't match the version in pyproject.toml, so bump the version in the same commit you tag. The steps of a release are in RELEASING.md.

Building

uv build

That produces a wheel and an sdist in dist/, using the hatchling backend declared in pyproject.toml. uv builds the wheel from the sdist, so a broken sdist fails the build rather than shipping quietly.

PyCharm

Recent PyCharm versions support uv directly: point the interpreter at the project and pick uv, or select the existing .venv uv created.

Mark tests as a test source root (right-click it, Mark Directory as → Test Sources Root). PyCharm doesn't read pytest's pythonpath setting, so without this it reports No module named project on the test project's own imports even though everything runs.

One thing to leave alone: pyproject.toml carries a static version, and __version__ reads it back from the installed distribution metadata. There's still only one place to change it, and some IDE integrations reject a dynamic version outright.

Layout

src/django_admin_kit/    the package
tests/                   its own test suite
tests/project/           the Django project those tests run against
specification.md         the public API this is built against

License

MIT. See LICENSE.

Metadata

Release files for pytest-django-admin-kit 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pytest-django-admin-kit 1.0.0
File Size Uploaded
pytest_django_admin_kit-1.0.0.tar.gz 138.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pytest-django-admin-kit 1.0.0
File Interpreter ABI Platform
pytest_django_admin_kit-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 193.3 kB

Release files / pytest_django_admin_kit-1.0.0.tar.gz

Download URL pytest_django_admin_kit-1.0.0.tar.gz
Size 138.9 kB
Tags Source
SHA-256 checksum
How to use checksums
fea0416fc88dccefbaea96c22387622e31e9a50363645efc2a89af874dd269cb
BLAKE2b-256 checksum
How to use checksums
444381baa798e96200f36ad50418df0f8a98a51bf6bf61dab6d843eb858b7fda
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Release files / pytest_django_admin_kit-1.0.0-py3-none-any.whl

Download URL pytest_django_admin_kit-1.0.0-py3-none-any.whl
Size 54.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c6779fc267e66d523015e63afab60ba4d5c786b1c4feb9e46b6dd55e33f7483a
BLAKE2b-256 checksum
How to use checksums
fc6c859008494d25bd954c1cb981b77dda8afed4aeffcc1f128e02e5332b238c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page