Metadata-Version: 2.5
Name: pytest-django-admin-kit
Version: 1.0.0
Summary: A pytest toolkit for writing conscious tests against the Django admin.
Project-URL: Homepage, https://github.com/JeyKip/pytest-django-admin-kit
Project-URL: Source, https://github.com/JeyKip/pytest-django-admin-kit
Project-URL: Changelog, https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md
Author: Yevhen Nosolenko
License: MIT License
        
        Copyright (c) 2026 Yevhen Nosolenko
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: admin,django,pytest,testing
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Django
Classifier: Framework :: Django :: 3.2
Classifier: Framework :: Django :: 4.0
Classifier: Framework :: Django :: 4.1
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.8
Requires-Dist: django>=3.2
Requires-Dist: playwright>=1.48
Requires-Dist: pytest-django>=4.5
Requires-Dist: pytest-playwright>=0.5.2
Requires-Dist: pytest>=7.0
Description-Content-Type: text/markdown

# 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.

```python
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](specification.md), and its
public API is stable. What changed in each release is in [CHANGELOG.md](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.

```python
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.

```python
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.

```python
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.

```python
admin_ui.url.edit(product)
```

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

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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:

```python
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:

```python
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.

```python
# 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())
```

```python
DJANGO_ADMIN_KIT = {
    "widgets": [
        "shop.kit_widgets.SwatchPickerWidget",
    ],
}
```

```python
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:

```python
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:

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
DJANGO_ADMIN_KIT = {
    "normalizers": {
        "date": "shop.rules.as_date",
    },
}
```

```python
# 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.

```python
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.

```python
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:

```python
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](https://docs.astral.sh/uv/) for environments, dependencies and builds. If
you don't have it:

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

## Setting up

```bash
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:

```bash
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:

```bash
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:

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

Before you push:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.

```bash
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`:

```bash
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:

```bash
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:

```bash
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](RELEASING.md).

## Building

```bash
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](LICENSE).
