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:
- 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.
- Read or fill it. The package calls
read(field)orfill(field, value)on that class.field.nativeis 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.
ResubmitFileWidgetgoes 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,timeornumber: by the field's class, the most specific first, so a date-time field is adatetime;booleanfor a lone boolean icon, thenlinkfor 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: theDJANGO_ADMIN_KITsettings, 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 fromnormalizers. Override it in aconftest.pyor a test module to read those tests' values differently, or parametrize it for one test, withNormalizersfromdjango_admin_kit.normalize:@pytest.mark.parametrize("admin_ui_normalizers", [Normalizers({"date": as_date})]).admin_ui_widgets: the widget classes, built once fromwidgets. Override it in aconftest.pyor a test module withWidgets([...])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.pysetsAUTH_USER_MODELtoaccounts.User, a model with nousernamefield that authenticates by email;tests/test_custom_prefix.pyuses@pytest.mark.urls("project.urls_backoffice"), which serves the admin at/backoffice/;tests/test_custom_site.pyoverrides theadmin_ui_urlsfixture to point atops_site, a secondAdminSitemounted at/ops/that registers onlyProduct.
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)
| File | Size | Uploaded | |
|---|---|---|---|
| pytest_django_admin_kit-1.0.0.tar.gz | 138.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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