pytest-django-admin-kit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_admin_kit/__init__.py +16 -0
- django_admin_kit/config.py +205 -0
- django_admin_kit/errors.py +64 -0
- django_admin_kit/fields.py +246 -0
- django_admin_kit/matching.py +178 -0
- django_admin_kit/messages.py +121 -0
- django_admin_kit/normalize.py +153 -0
- django_admin_kit/pages.py +841 -0
- django_admin_kit/plugin.py +169 -0
- django_admin_kit/py.typed +0 -0
- django_admin_kit/rendered.py +143 -0
- django_admin_kit/results.py +62 -0
- django_admin_kit/rows.py +165 -0
- django_admin_kit/session.py +132 -0
- django_admin_kit/tools.py +27 -0
- django_admin_kit/urls.py +122 -0
- django_admin_kit/widgets.py +172 -0
- pytest_django_admin_kit-1.0.0.dist-info/METADATA +951 -0
- pytest_django_admin_kit-1.0.0.dist-info/RECORD +22 -0
- pytest_django_admin_kit-1.0.0.dist-info/WHEEL +4 -0
- pytest_django_admin_kit-1.0.0.dist-info/entry_points.txt +2 -0
- pytest_django_admin_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-django-admin-kit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A pytest toolkit for writing conscious tests against the Django admin.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JeyKip/pytest-django-admin-kit
|
|
6
|
+
Project-URL: Source, https://github.com/JeyKip/pytest-django-admin-kit
|
|
7
|
+
Project-URL: Changelog, https://github.com/JeyKip/pytest-django-admin-kit/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Yevhen Nosolenko
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Yevhen Nosolenko
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: admin,django,pytest,testing
|
|
32
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
33
|
+
Classifier: Framework :: Django
|
|
34
|
+
Classifier: Framework :: Django :: 3.2
|
|
35
|
+
Classifier: Framework :: Django :: 4.0
|
|
36
|
+
Classifier: Framework :: Django :: 4.1
|
|
37
|
+
Classifier: Framework :: Django :: 4.2
|
|
38
|
+
Classifier: Framework :: Django :: 5.0
|
|
39
|
+
Classifier: Framework :: Django :: 5.1
|
|
40
|
+
Classifier: Framework :: Django :: 5.2
|
|
41
|
+
Classifier: Framework :: Django :: 6.0
|
|
42
|
+
Classifier: Framework :: Django :: 6.1
|
|
43
|
+
Classifier: Framework :: Pytest
|
|
44
|
+
Classifier: Intended Audience :: Developers
|
|
45
|
+
Classifier: Operating System :: OS Independent
|
|
46
|
+
Classifier: Programming Language :: Python :: 3
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
48
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
49
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
50
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
51
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
52
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
53
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
54
|
+
Classifier: Topic :: Software Development :: Testing
|
|
55
|
+
Classifier: Typing :: Typed
|
|
56
|
+
Requires-Python: >=3.8
|
|
57
|
+
Requires-Dist: django>=3.2
|
|
58
|
+
Requires-Dist: playwright>=1.48
|
|
59
|
+
Requires-Dist: pytest-django>=4.5
|
|
60
|
+
Requires-Dist: pytest-playwright>=0.5.2
|
|
61
|
+
Requires-Dist: pytest>=7.0
|
|
62
|
+
Description-Content-Type: text/markdown
|
|
63
|
+
|
|
64
|
+
# pytest-django-admin-kit
|
|
65
|
+
|
|
66
|
+
A pytest toolkit for writing conscious tests against the Django admin.
|
|
67
|
+
|
|
68
|
+
Tests describe admin pages, their fields, rows, permissions and errors, using ordinary `assert`
|
|
69
|
+
statements, and stay unchanged across supported Django versions.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
def test_product_admin(admin_ui, admin_user, product):
|
|
73
|
+
admin_ui.login(admin_user)
|
|
74
|
+
|
|
75
|
+
assert admin_ui.list(Product).works
|
|
76
|
+
assert admin_ui.edit(product).works
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Install it as `pytest-django-admin-kit`; import it as `django_admin_kit`.
|
|
80
|
+
|
|
81
|
+
## Status
|
|
82
|
+
|
|
83
|
+
Version 1.0.0 implements the API written up in [specification.md](specification.md), and its
|
|
84
|
+
public API is stable. What changed in each release is in [CHANGELOG.md](CHANGELOG.md), and what
|
|
85
|
+
comes next is the roadmap in section 33 of the specification.
|
|
86
|
+
|
|
87
|
+
## What it solves today
|
|
88
|
+
|
|
89
|
+
**Logging in as any user, without knowing a password.** Passwords are stored hashed, and a
|
|
90
|
+
user created for a test may have none at all.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
admin_ui.login(user)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Telling whether a page opened, was refused, or is not there.** The admin refuses in several
|
|
97
|
+
ways, and only some of them change the URL.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
page = admin_ui.edit(product)
|
|
101
|
+
|
|
102
|
+
assert page.works # or page.denied, page.missing, page.redirected
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Reading what a page calls itself.** The title and subtitle the admin renders, so a test
|
|
106
|
+
can say which page it is looking at without matching markup.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
page = admin_ui.edit(product)
|
|
110
|
+
|
|
111
|
+
assert page.title == "Change product"
|
|
112
|
+
assert page.subtitle == "Widget"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Getting admin URLs without hardcoding `/admin/`.** URLs come from the site under test, so a
|
|
116
|
+
project that mounts its admin elsewhere changes nothing.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
admin_ui.url.edit(product)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Testing the login page itself.** Typing into the form is an opt-in, never the default.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
admin_ui.login(user, password="secret")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Opening a changelist and reading its count and columns.** The count is what the page says,
|
|
129
|
+
not the rows on one page of it; columns come by label and by the name the admin configures
|
|
130
|
+
them with; and a refused user gets `denied` rather than a count.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
page = admin_ui.list(Product)
|
|
134
|
+
|
|
135
|
+
assert page.works
|
|
136
|
+
assert page.count == 3
|
|
137
|
+
assert page.summary == "3 products"
|
|
138
|
+
assert not page.empty
|
|
139
|
+
assert page.headers == ["Name", "Sku", "Price", "Is active", "Released on"]
|
|
140
|
+
assert page.columns == ["name", "sku", "price", "is_active", "released_on"]
|
|
141
|
+
assert page.has_header("Price")
|
|
142
|
+
assert page.has_column("is_active")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Reading and matching changelist rows.** A row is addressed by position and a cell by the
|
|
146
|
+
name its column is configured with. A cell's `value` is what the user reads, normalized only
|
|
147
|
+
where the admin drew something other than text; `contains` checks one row is there and
|
|
148
|
+
`match` checks the whole changelist in order, and both print the expected pattern and the
|
|
149
|
+
actual rows when they fail.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from django_admin_kit.matching import ANY, ANY_ROW
|
|
153
|
+
from django_admin_kit.rendered import Link
|
|
154
|
+
|
|
155
|
+
page = admin_ui.list(Product)
|
|
156
|
+
|
|
157
|
+
assert page.rows[0]["price"].value == "10.00"
|
|
158
|
+
assert page.rows[0]["is_active"].value is True
|
|
159
|
+
assert page.rows[0]["name"].links == [("Bolt", admin_ui.url.edit(bolt))]
|
|
160
|
+
assert page.rows[0].object == bolt
|
|
161
|
+
|
|
162
|
+
assert page.contains(("Bolt", "SKU-1", "10.00", True))
|
|
163
|
+
assert page.contains({"name": Link("Bolt", admin_ui.url.edit(bolt)), "is_active": ANY})
|
|
164
|
+
assert page.match([
|
|
165
|
+
("Bolt", "SKU-1", "10.00", True),
|
|
166
|
+
ANY_ROW,
|
|
167
|
+
{"name": "Washer", "price": lambda row, cell: cell.value.endswith(".00")},
|
|
168
|
+
])
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Reading form fields.** The create and edit pages list their fields by name, in the order the
|
|
172
|
+
admin presents them, whether the user may fill them or only read them. A field the admin renders
|
|
173
|
+
hidden is not shown, so it is not listed. Whether a field is required is read the way the user
|
|
174
|
+
sees it, from the form the admin renders, so a `ModelForm` that disagrees with the model wins.
|
|
175
|
+
A field's `label` is what the user sees next to it, without the colon. Its `value` is what its
|
|
176
|
+
control holds: text as typed, a checkbox as a boolean, a select as the choice made, with the
|
|
177
|
+
value the form posts and the label the user reads; a rendered-only field reads as a changelist
|
|
178
|
+
cell does.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
page = admin_ui.edit(product)
|
|
182
|
+
|
|
183
|
+
assert list(page.fields) == [
|
|
184
|
+
"name",
|
|
185
|
+
"sku",
|
|
186
|
+
"price",
|
|
187
|
+
"quantity",
|
|
188
|
+
"is_active",
|
|
189
|
+
"featured",
|
|
190
|
+
"released_on",
|
|
191
|
+
"category",
|
|
192
|
+
"price_with_tax",
|
|
193
|
+
]
|
|
194
|
+
assert "name" in page.fields
|
|
195
|
+
assert page.fields["name"].required
|
|
196
|
+
assert not page.fields["price_with_tax"].editable
|
|
197
|
+
assert page.required_fields == {"name", "sku", "price", "quantity", "released_on"}
|
|
198
|
+
assert page.optional_fields == {"is_active", "featured", "category"}
|
|
199
|
+
|
|
200
|
+
assert page.fields["name"].label == "Name"
|
|
201
|
+
assert page.fields["name"].value == "Widget"
|
|
202
|
+
assert page.fields["is_active"].value is True
|
|
203
|
+
assert page.fields["category"].value == (str(tools.pk), "Tools")
|
|
204
|
+
assert page.fields["category"].value.label == "Tools"
|
|
205
|
+
assert page.fields["category"].choices == [("", "---------"), (str(tools.pk), "Tools")]
|
|
206
|
+
assert page.fields["price_with_tax"].value == "12.000"
|
|
207
|
+
page.fields["name"].native.locator("input").fill("Gadget")
|
|
208
|
+
|
|
209
|
+
admin_ui.login(viewer) # may only view, so every field is rendered only
|
|
210
|
+
page = admin_ui.edit(product)
|
|
211
|
+
|
|
212
|
+
assert page.fields["released_on"].value == "Jan. 15, 2026"
|
|
213
|
+
assert page.fields["category"].links == [("Tools", admin_ui.url.edit(tools))]
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Filling a field the way a user would.** Each control is written in the terms it is read
|
|
217
|
+
back in, so a value goes in as the test has it rather than as the markup wants it.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
page = admin_ui.create(Product)
|
|
221
|
+
|
|
222
|
+
page.fields["name"].fill("Widget")
|
|
223
|
+
page.fields["price"].fill(Decimal("19.99"))
|
|
224
|
+
page.fields["quantity"].fill(3)
|
|
225
|
+
page.fields["released_on"].fill(date(2026, 1, 15))
|
|
226
|
+
page.fields["is_active"].fill(False)
|
|
227
|
+
page.fields["featured"].fill(True)
|
|
228
|
+
page.fields["category"].fill(str(tools.pk))
|
|
229
|
+
|
|
230
|
+
assert page.fields["price"].value == "19.99"
|
|
231
|
+
assert page.fields["featured"].value == ("true", "Yes")
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
A select takes back a choice it offered, `page.fields["category"].fill(choice)`, and `None`
|
|
235
|
+
puts it on its blank option. A value no option has raises `ValueError` naming the options
|
|
236
|
+
there are, and a field the user may only read raises `LookupError`.
|
|
237
|
+
|
|
238
|
+
**Filling a whole form in one call.** A dictionary carries the shared data and keywords carry
|
|
239
|
+
the values this one test is about. Fields that are named nowhere keep the values the admin
|
|
240
|
+
rendered them with.
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
page = admin_ui.create(Product)
|
|
244
|
+
|
|
245
|
+
page.populate({
|
|
246
|
+
"name": "Widget",
|
|
247
|
+
"sku": "SKU-1",
|
|
248
|
+
"price": "19.99",
|
|
249
|
+
})
|
|
250
|
+
page.populate(name="Gadget", is_active=False)
|
|
251
|
+
|
|
252
|
+
assert page.fields["name"].value == "Gadget"
|
|
253
|
+
assert page.fields["quantity"].value == "1" # untouched, as the admin rendered it
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Only the form's own fields are filled, so a key the form has no field for changes nothing,
|
|
257
|
+
and `price_with_tax`, which the admin renders read-only, is passed over.
|
|
258
|
+
|
|
259
|
+
A mode, `PagePopulationMode` from `django_admin_kit.pages`, narrows what is filled to the
|
|
260
|
+
fields the form requires or to the ones it leaves open, so one data set serves a test about
|
|
261
|
+
either:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
page.populate(data, PagePopulationMode.REQUIRED) # name, sku, price, quantity, released_on
|
|
265
|
+
page.populate(data, PagePopulationMode.OPTIONAL) # is_active, featured, category
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The source can be an object instead, read by attribute, which makes a saved record a form's
|
|
269
|
+
worth of data:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
page.populate(product) # its category picks that option
|
|
273
|
+
page.populate(product, name="Renamed") # everything it has, with one value replaced
|
|
274
|
+
page.populate(SimpleNamespace(name="Widget", price="19.99"))
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Teaching the package a widget.** The package reads and fills checkboxes, selects, and any
|
|
278
|
+
single control named after its field. For any other widget, such as one your project draws
|
|
279
|
+
itself, write a widget class and list it in your settings. From then on `value`, `fill`,
|
|
280
|
+
`choices` and `populate` work on that field as on any other.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
# shop/kit_widgets.py
|
|
284
|
+
from django_admin_kit.fields import FieldChoice
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SwatchPickerWidget:
|
|
288
|
+
def marker(self, field):
|
|
289
|
+
return ".swatch-picker"
|
|
290
|
+
|
|
291
|
+
def read(self, field):
|
|
292
|
+
pressed = field.native.locator('button[aria-pressed="true"]')
|
|
293
|
+
return self._choice(pressed) if pressed.count() else None
|
|
294
|
+
|
|
295
|
+
def fill(self, field, value):
|
|
296
|
+
field.native.locator(f'button[data-value="{value}"]').click()
|
|
297
|
+
|
|
298
|
+
def choices(self, field):
|
|
299
|
+
return [self._choice(button) for button in field.native.locator("button").all()]
|
|
300
|
+
|
|
301
|
+
def _choice(self, button):
|
|
302
|
+
return FieldChoice(button.get_attribute("data-value"), button.inner_text())
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
DJANGO_ADMIN_KIT = {
|
|
307
|
+
"widgets": [
|
|
308
|
+
"shop.kit_widgets.SwatchPickerWidget",
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
page.fields["color"].fill("green")
|
|
315
|
+
|
|
316
|
+
assert page.fields["color"].value == ("green", "Green")
|
|
317
|
+
assert page.fields["color"].choices == [("red", "Red"), ("green", "Green"), ("blue", "Blue")]
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The package handles a field in two steps:
|
|
321
|
+
|
|
322
|
+
1. **Which class handles the field?** The package sends every class's marker to the browser in
|
|
323
|
+
one call. The first one that matches something inside the field's box picks the class. What
|
|
324
|
+
it matched is not kept.
|
|
325
|
+
2. **Read or fill it.** The package calls `read(field)` or `fill(field, value)` on that class.
|
|
326
|
+
`field.native` is the whole box, label included, and each method finds the parts it needs
|
|
327
|
+
there.
|
|
328
|
+
|
|
329
|
+
So a marker only has to recognize the widget. It can be a wrapper, or a part that only some
|
|
330
|
+
renderings have. Take django-file-resubmit: its image widget looks like a plain file input until
|
|
331
|
+
a save fails. Then it adds a hidden `image_cache_key` input and the cached file's name, so the
|
|
332
|
+
user doesn't have to pick the file again. A class for that case is marked by the hidden input,
|
|
333
|
+
and its `fill` works on the file input next to it:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
class ResubmitFileWidget:
|
|
337
|
+
def marker(self, field): # only a form rendered back with a cached upload
|
|
338
|
+
return f'input[type="hidden"][name="{field.name}_cache_key"]'
|
|
339
|
+
|
|
340
|
+
def read(self, field): # the cached file's name
|
|
341
|
+
...
|
|
342
|
+
|
|
343
|
+
def fill(self, field, value): # a new upload replaces the cached one
|
|
344
|
+
field.native.locator(f'input[type="file"][name="{field.name}"]').set_input_files(value)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Here `field.native` holds the label, the file input, the "Currently" link, the cached file's name
|
|
348
|
+
and the hidden input. A marker is plain CSS as the browser reads it, so Playwright's own syntax,
|
|
349
|
+
such as `text=`, belongs in `read` and `fill`. To combine conditions, use `:has()`:
|
|
350
|
+
`':scope:has(input[name="image_cache_key"]) input[type="file"]'`.
|
|
351
|
+
|
|
352
|
+
Your classes are tried first, in the order you list them, then the package's own:
|
|
353
|
+
`CheckboxWidget`, `SelectWidget` and `TextWidget`, from `django_admin_kit.widgets`. Two rules
|
|
354
|
+
follow from that:
|
|
355
|
+
|
|
356
|
+
- A marker matches what is particular to its widget. A class marked by `input[type="file"]`
|
|
357
|
+
would take every file field in the project.
|
|
358
|
+
- Narrower classes come before broader ones, since the first match wins. `ResubmitFileWidget`
|
|
359
|
+
goes before a class for any file input.
|
|
360
|
+
|
|
361
|
+
To read a control the package knows differently, put a subclass of its class in front of it:
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
class BareSelectWidget(SelectWidget):
|
|
365
|
+
def read(self, field):
|
|
366
|
+
return super().read(field).value # "3" rather than ("3", "Tools")
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`marker`, `read` and `fill` are required. `choices` is optional, and `field.choices` is `[]`
|
|
370
|
+
without it. A class never calls `value`, `fill` or `choices` on the field it gets, since that
|
|
371
|
+
asks the widget classes again and never ends. `marker` runs for every field read or filled, so
|
|
372
|
+
it builds a selector and doesn't read the page.
|
|
373
|
+
|
|
374
|
+
A widget made of several inputs, such as the admin's split date-time, is one field and one class.
|
|
375
|
+
Parts drawn outside the box are reached through `field.native.page`, and parts in a frame
|
|
376
|
+
through `field.native.frame_locator`. A field no class recognizes raises `LookupError` right
|
|
377
|
+
away, naming the `widgets` setting. Widget classes only apply to fields the user can edit; a
|
|
378
|
+
field the user may only read is read by the rules described below.
|
|
379
|
+
|
|
380
|
+
**Knowing what a form lets the user do.** An action is a button that submits the form,
|
|
381
|
+
named exactly by what it posts, which is what the admin's view checks for. The admin's own
|
|
382
|
+
are constants in `FormAction`, from `django_admin_kit.pages`, and each has its own check.
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
page = admin_ui.edit(product)
|
|
386
|
+
|
|
387
|
+
assert page.actions == {
|
|
388
|
+
FormAction.SAVE,
|
|
389
|
+
FormAction.SAVE_AND_CONTINUE,
|
|
390
|
+
FormAction.SAVE_AND_ADD_ANOTHER,
|
|
391
|
+
}
|
|
392
|
+
assert page.has_action("_continue")
|
|
393
|
+
assert page.can_save
|
|
394
|
+
assert not page.can_save_as_new
|
|
395
|
+
assert page.can_delete
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
A button a project adds is an action under the name it posts, such as `"_refresh"` on the
|
|
399
|
+
test project's feeds. Deleting is a link rather than a button, so it is not in `actions` and
|
|
400
|
+
has its own check.
|
|
401
|
+
|
|
402
|
+
**Submitting a form.** Filling a form and submitting it are separate steps. Each of the
|
|
403
|
+
admin's buttons has a method, `save()`, `save_and_continue()`, `save_and_add_another()`, and
|
|
404
|
+
`save_as_new()` on an edit page, and `submit` presses any button by the name it posts. An
|
|
405
|
+
action the page does not offer raises `LookupError` naming the ones it does, and submits
|
|
406
|
+
nothing.
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
page = admin_ui.create(Product)
|
|
410
|
+
page.populate(
|
|
411
|
+
name="Widget",
|
|
412
|
+
sku="SKU-1",
|
|
413
|
+
price="10.00",
|
|
414
|
+
released_on=date(2026, 1, 15),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
result = page.save()
|
|
418
|
+
|
|
419
|
+
assert result.success
|
|
420
|
+
assert result.redirected_to_list(Product)
|
|
421
|
+
assert result.page.count == 1
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`success` is whether the admin redirected, which it does after every save it accepts.
|
|
425
|
+
`redirected_to(url)` takes any `admin_ui.url` path, and `redirected_to_index()`,
|
|
426
|
+
`redirected_to_list(model)`, `redirected_to_create(model)` and `redirected_to_edit(instance)`
|
|
427
|
+
are shorthands for the usual ones. A form the admin rejects is shown again at its own URL
|
|
428
|
+
without a redirect, so it is never redirected there.
|
|
429
|
+
|
|
430
|
+
`result.page` is the page the browser shows afterwards. When the browser stays on the
|
|
431
|
+
submitted page, as with a rejected form or "Save and continue", it is that same page object;
|
|
432
|
+
otherwise it is a new page of the type its URL names, or a plain `AdminPage` for a page the
|
|
433
|
+
package does not model.
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
page = admin_ui.create(Product)
|
|
437
|
+
page.populate(name="", price="10.00")
|
|
438
|
+
|
|
439
|
+
result = page.save()
|
|
440
|
+
|
|
441
|
+
assert not result.success
|
|
442
|
+
assert result.page is page
|
|
443
|
+
assert page.fields["price"].value == "10.00"
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Finding out why a form was rejected.** The admin shows errors at three levels, and each is
|
|
447
|
+
read on its own: the notice above the form, the errors of the form as a whole, and the errors
|
|
448
|
+
of each field. A field can fail several rules at once, so its errors are always a list.
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
page = admin_ui.create(Product)
|
|
452
|
+
page.populate(
|
|
453
|
+
name="Widget",
|
|
454
|
+
price="10.00",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
result = page.save()
|
|
458
|
+
|
|
459
|
+
assert result.page.errors.banner == "Please correct the errors below."
|
|
460
|
+
assert result.page.errors.non_field == []
|
|
461
|
+
assert result.page.fields["sku"].errors == ["This field is required."]
|
|
462
|
+
assert result.page.errors.fields == {
|
|
463
|
+
"sku": ["This field is required."],
|
|
464
|
+
"released_on": ["This field is required."],
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
A page the admin has not rejected shows no errors, so `assert not page.errors` holds on one
|
|
469
|
+
that was never submitted. The notice's wording depends on how many errors there are, so a test
|
|
470
|
+
that only cares that the form was rejected asserts `result.page.errors.banner` is there.
|
|
471
|
+
|
|
472
|
+
**Reading what the admin reported.** Every page reads the messages the admin shows after an
|
|
473
|
+
operation, each with its level and its text. A level is the tag Django draws the message
|
|
474
|
+
with, so a project that renames or adds levels through `MESSAGE_TAGS` reads them by its own
|
|
475
|
+
tags.
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
page = admin_ui.create(Product)
|
|
479
|
+
page.populate(
|
|
480
|
+
name="Widget",
|
|
481
|
+
sku="SKU-1",
|
|
482
|
+
price="10.00",
|
|
483
|
+
released_on=date(2026, 1, 15),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
result = page.save()
|
|
487
|
+
|
|
488
|
+
assert result.page.messages == [
|
|
489
|
+
("success", "The product “Widget” was added successfully."),
|
|
490
|
+
]
|
|
491
|
+
assert result.page.messages[0].level == "success"
|
|
492
|
+
assert result.page.messages.of_level("success") == [
|
|
493
|
+
"The product “Widget” was added successfully.",
|
|
494
|
+
]
|
|
495
|
+
assert not result.page.messages.of_level("error")
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`of_level` raises `KeyError` listing the project's levels for a name it does not have, so a
|
|
499
|
+
level the project renamed never reads as "no messages". Messages are also read from a page
|
|
500
|
+
that did not open, since the admin says there why: an object that no longer exists sends
|
|
501
|
+
the user to the index with a warning, and `page.messages` on that page reads it.
|
|
502
|
+
|
|
503
|
+
**Reading is live.** Nothing read from a page is kept, so every read is the page as it is
|
|
504
|
+
now, including what the admin's own scripts changed in place. A field, row or cell taken from
|
|
505
|
+
a page is an address, read each time it is used: a field by its name, a row by its position.
|
|
506
|
+
Once the browser shows another page, reading the old page object, or anything taken from it,
|
|
507
|
+
fails at once and says where the browser is.
|
|
508
|
+
|
|
509
|
+
```python
|
|
510
|
+
result = page.save() # the admin redirects to the changelist
|
|
511
|
+
|
|
512
|
+
page.fields["name"].value # LookupError: The browser no longer shows this page; ...
|
|
513
|
+
result.page.rows[0]["name"].value # read the page the browser shows now
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**Deleting.** The admin deletes in two steps, and so does the package: `delete()` follows an
|
|
517
|
+
edit page's delete link to the confirmation, and `confirm_deleting()` there deletes.
|
|
518
|
+
|
|
519
|
+
```python
|
|
520
|
+
confirmation = admin_ui.edit(product).delete()
|
|
521
|
+
|
|
522
|
+
assert confirmation.can_confirm_deleting
|
|
523
|
+
|
|
524
|
+
result = confirmation.confirm_deleting()
|
|
525
|
+
|
|
526
|
+
assert result.redirected_to_list(Product)
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
When deleting would also remove objects that are protected, or that the user may not delete,
|
|
530
|
+
the admin offers no confirmation: a category a product uses reads `can_confirm_deleting` as
|
|
531
|
+
`False`, and `confirm_deleting()` raises `LookupError`.
|
|
532
|
+
|
|
533
|
+
If the admin refuses a confirmation it offered, because the object became protected or the user
|
|
534
|
+
may no longer delete it after the page opened, the result reads `not result.success`.
|
|
535
|
+
|
|
536
|
+
**Knowing what a deletion will remove, and what stops it.** The confirmation lists the object
|
|
537
|
+
and every related object it takes with it, as the page writes them, and counts them per model.
|
|
538
|
+
|
|
539
|
+
```python
|
|
540
|
+
page = admin_ui.delete(product)
|
|
541
|
+
|
|
542
|
+
assert page.deletions == [
|
|
543
|
+
"Product: Widget",
|
|
544
|
+
"Review: Great",
|
|
545
|
+
"Review: Fine",
|
|
546
|
+
]
|
|
547
|
+
assert page.deletion_counts == {
|
|
548
|
+
"Products": 1,
|
|
549
|
+
"Reviews": 2,
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
A page that cannot be confirmed lists what blocks the deletion instead: the objects that
|
|
554
|
+
protect it, or the kinds of object the user may not delete. `intro` is the sentence the page
|
|
555
|
+
starts with, which says which of the two it is.
|
|
556
|
+
|
|
557
|
+
```python
|
|
558
|
+
page = admin_ui.delete(category)
|
|
559
|
+
|
|
560
|
+
assert page.blockers == ["Product: Widget"]
|
|
561
|
+
assert page.deletions == []
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Entries are texts, since the admin links an object only when its model is registered.
|
|
565
|
+
`deletion_counts` is keyed by the labels the page shows, and reading a model the deletion
|
|
566
|
+
removes none of raises `KeyError` naming the models it does count. The wording of `intro`
|
|
567
|
+
depends on the language and the Django version, which changed its quote marks in 6.0.
|
|
568
|
+
|
|
569
|
+
**Knowing what the index shows a user.** Models and apps a user may not see are not listed,
|
|
570
|
+
and the lists come back in the admin's order.
|
|
571
|
+
|
|
572
|
+
```python
|
|
573
|
+
page = admin_ui.index()
|
|
574
|
+
|
|
575
|
+
assert Product in page.models
|
|
576
|
+
assert page.models == [Group, User, Product]
|
|
577
|
+
assert page.apps == ["Authentication and Authorization", "Shop"]
|
|
578
|
+
assert page.models_for("Shop") == [Product]
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
**Reading values as your project wants them.** A cell, and a field the user may only read, is
|
|
582
|
+
read by a rule for its kind. Every default returns the text the user reads, except `boolean`,
|
|
583
|
+
which reads the admin's icon as `True`, `False` or `None`. Replace a rule in your settings and it
|
|
584
|
+
receives only the values of its kind.
|
|
585
|
+
|
|
586
|
+
```python
|
|
587
|
+
DJANGO_ADMIN_KIT = {
|
|
588
|
+
"normalizers": {
|
|
589
|
+
"date": "shop.rules.as_date",
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
```python
|
|
595
|
+
# shop/rules.py, for a project whose DATE_FORMAT is "Y-m-d"
|
|
596
|
+
from datetime import date
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def as_date(rendered):
|
|
600
|
+
return date.fromisoformat(rendered.text)
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
A missing date never reaches it: the admin shows its empty display there, which the `empty` rule
|
|
604
|
+
reads.
|
|
605
|
+
|
|
606
|
+
```python
|
|
607
|
+
assert page.rows[0]["released_on"].value == date(2026, 1, 15)
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
The kind comes from the model field behind the value, then from what the admin drew. The first
|
|
611
|
+
that applies wins:
|
|
612
|
+
|
|
613
|
+
- `empty`: the text the admin shows for a value it has none of there;
|
|
614
|
+
- `choice`: a field with choices, shown as its label;
|
|
615
|
+
- `boolean`, `date`, `datetime`, `time` or `number`: by the field's class, the most specific
|
|
616
|
+
first, so a date-time field is a `datetime`;
|
|
617
|
+
- `boolean` for a lone boolean icon, then `link` for a value that renders links;
|
|
618
|
+
- `text`.
|
|
619
|
+
|
|
620
|
+
A rule gets the rendered value: its `text`, `links`, `native`, `field` (the model field, or
|
|
621
|
+
`None` for a method) and `empty_display`. A rule is named by its dotted path, and an unknown
|
|
622
|
+
name or a path that does not import is an error. A form field the user can edit is read from
|
|
623
|
+
its control and goes through no rule.
|
|
624
|
+
|
|
625
|
+
**Reaching anything the package does not model.** Open any admin path, then use the Playwright
|
|
626
|
+
page directly.
|
|
627
|
+
|
|
628
|
+
```python
|
|
629
|
+
page = admin_ui.open(reverse("admin:shop_product_report"))
|
|
630
|
+
|
|
631
|
+
assert page.works
|
|
632
|
+
page.native.click("#download")
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
`admin_ui.native` is the Playwright context, and `admin_ui.absolute(path)` builds a full URL on
|
|
636
|
+
the live server when something outside the package needs one.
|
|
637
|
+
|
|
638
|
+
## Settings
|
|
639
|
+
|
|
640
|
+
Everything lives under one key in Django settings, and a key it does not know is an error:
|
|
641
|
+
|
|
642
|
+
```python
|
|
643
|
+
DJANGO_ADMIN_KIT = {
|
|
644
|
+
"site": "project.ops.ops_site", # dotted path to the AdminSite; default: admin.site
|
|
645
|
+
"timeout": 30_000, # ms for any single browser operation
|
|
646
|
+
"timezone": "Europe/Kyiv", # default: TIME_ZONE
|
|
647
|
+
"locale": "uk", # default: LANGUAGE_CODE
|
|
648
|
+
"normalizers": { # rules that read values, by kind; see above
|
|
649
|
+
"date": "shop.rules.as_date",
|
|
650
|
+
},
|
|
651
|
+
"widgets": [ # widget classes, tried before the package's own
|
|
652
|
+
"shop.kit_widgets.SwatchPickerWidget",
|
|
653
|
+
],
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
`timezone` and `locale` pin what the browser reports, so a date or a label reads the same on
|
|
658
|
+
every machine.
|
|
659
|
+
|
|
660
|
+
If your project already sets `timezone_id` or `locale` in pytest-playwright's
|
|
661
|
+
`browser_context_args`, those win. The package only fills in what `browser_context_args` leaves
|
|
662
|
+
unset, whether the value under `DJANGO_ADMIN_KIT` was written by you or defaulted from
|
|
663
|
+
`TIME_ZONE` and `LANGUAGE_CODE`. So if you want admin tests to run with the same zone and
|
|
664
|
+
language as the rest of your browser tests, keep `timezone` and `locale` out of
|
|
665
|
+
`DJANGO_ADMIN_KIT` and set them in `browser_context_args` only.
|
|
666
|
+
|
|
667
|
+
`admin_ui` is put together from five session fixtures. Override one and the others stay as
|
|
668
|
+
they are:
|
|
669
|
+
|
|
670
|
+
- `admin_ui_config`: the `DJANGO_ADMIN_KIT` settings, read and checked once. An override must
|
|
671
|
+
be session-scoped too.
|
|
672
|
+
- `admin_ui_urls`: the admin URLs for the site under test. Override it to point the session at
|
|
673
|
+
another admin site.
|
|
674
|
+
- `admin_ui_normalizers`: the rules that read values, built once from `normalizers`. Override
|
|
675
|
+
it in a `conftest.py` or a test module to read those tests' values differently, or
|
|
676
|
+
parametrize it for one test, with `Normalizers` from `django_admin_kit.normalize`:
|
|
677
|
+
`@pytest.mark.parametrize("admin_ui_normalizers", [Normalizers({"date": as_date})])`.
|
|
678
|
+
- `admin_ui_widgets`: the widget classes, built once from `widgets`. Override it in a
|
|
679
|
+
`conftest.py` or a test module with `Widgets([...])` of instances to use other classes there,
|
|
680
|
+
or parametrize it for one test the same way; the package's own are added behind them.
|
|
681
|
+
- `admin_ui_driving`: keeps Django's database access working while a browser runs. Leave it
|
|
682
|
+
alone.
|
|
683
|
+
|
|
684
|
+
## Requirements
|
|
685
|
+
|
|
686
|
+
Python 3.8 or newer, Django 3.2 or newer.
|
|
687
|
+
|
|
688
|
+
Development uses [uv](https://docs.astral.sh/uv/) for environments, dependencies and builds. If
|
|
689
|
+
you don't have it:
|
|
690
|
+
|
|
691
|
+
```bash
|
|
692
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
## Setting up
|
|
696
|
+
|
|
697
|
+
```bash
|
|
698
|
+
git clone https://github.com/<your-fork>/pytest-django-admin-kit.git
|
|
699
|
+
cd pytest-django-admin-kit
|
|
700
|
+
|
|
701
|
+
uv sync
|
|
702
|
+
uv run pre-commit install
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
`uv sync` creates `.venv`, installs the package in editable mode, and installs the dev tooling
|
|
706
|
+
from `uv.lock`. That lock file is committed, so everyone gets the same ruff and mypy and
|
|
707
|
+
therefore the same results.
|
|
708
|
+
|
|
709
|
+
The tests drive real browsers, so you need the binaries too:
|
|
710
|
+
|
|
711
|
+
```bash
|
|
712
|
+
uv run playwright install chromium # enough for everyday work
|
|
713
|
+
uv run playwright install firefox webkit # the other two the package supports
|
|
714
|
+
sudo uv run playwright install-deps webkit # webkit also needs system libraries
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
Only chromium is required. A test that needs another browser skips itself by name when that
|
|
718
|
+
browser isn't downloaded, so a partial install costs you coverage rather than a wall of red. CI
|
|
719
|
+
installs all three and fails if anything skips.
|
|
720
|
+
|
|
721
|
+
## Which Python to develop on
|
|
722
|
+
|
|
723
|
+
`uv sync` picks an interpreter for you. If you want to choose, use 3.10, and add a 3.12
|
|
724
|
+
environment when you need to check Django 6.x:
|
|
725
|
+
|
|
726
|
+
```bash
|
|
727
|
+
uv sync --python 3.10
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Two reasons this matters more than usual here.
|
|
731
|
+
|
|
732
|
+
First, don't develop on 3.8. It's been end of life since October 2024, and resolution caps you
|
|
733
|
+
there at Django 4.2, playwright 1.48 and pytest 8.3.5. You couldn't run Django 5.x or 6.x locally
|
|
734
|
+
at all. CI proves the 3.8 floor for you.
|
|
735
|
+
|
|
736
|
+
Second, no single interpreter covers every Django version we support. Django 3.2 stops at Python
|
|
737
|
+
3.10 and Django 6.1 starts at 3.12, so there's no overlap:
|
|
738
|
+
|
|
739
|
+
| Python | Django versions it can run |
|
|
740
|
+
|---|---|
|
|
741
|
+
| 3.8, 3.9 | 3.2 to 4.2 |
|
|
742
|
+
| 3.10 | 3.2 to 5.2 |
|
|
743
|
+
| 3.11 | 4.1 to 5.2 |
|
|
744
|
+
| 3.12 | 4.2 to 6.1 |
|
|
745
|
+
| 3.13, 3.14 | 5.1 to 6.1 |
|
|
746
|
+
|
|
747
|
+
3.10 is the better default because it reaches the Django 3.2 floor, where most version-specific
|
|
748
|
+
breakage turns up. You don't need it installed already; uv will fetch it.
|
|
749
|
+
|
|
750
|
+
## Everyday commands
|
|
751
|
+
|
|
752
|
+
While you're working, this is the whole loop:
|
|
753
|
+
|
|
754
|
+
```bash
|
|
755
|
+
uv run pytest
|
|
756
|
+
uv run pytest -n auto # the same, on every core
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Before you push:
|
|
760
|
+
|
|
761
|
+
```bash
|
|
762
|
+
uv run ruff check . # lint
|
|
763
|
+
uv run ruff format . # format in place
|
|
764
|
+
uv run mypy # strict, src/ only
|
|
765
|
+
uv run pytest
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
`pre-commit install` already runs ruff and the whitespace checks on every commit, so those first
|
|
769
|
+
two mostly buy you faster feedback. To run every hook over the whole tree:
|
|
770
|
+
|
|
771
|
+
```bash
|
|
772
|
+
uv run pre-commit run --all-files
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
## Driving the browser
|
|
776
|
+
|
|
777
|
+
The browser comes from `pytest-playwright`, so its flags work here and mean what they mean
|
|
778
|
+
anywhere else:
|
|
779
|
+
|
|
780
|
+
```bash
|
|
781
|
+
uv run pytest --headed # watch it happen
|
|
782
|
+
uv run pytest --slowmo 200 # and slow it down enough to follow
|
|
783
|
+
uv run pytest --browser firefox # one browser
|
|
784
|
+
uv run pytest --browser chromium --browser firefox # the suite, twice
|
|
785
|
+
uv run pytest --video on --tracing on # artifacts under test-results/
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
Because the browser is theirs, a test's id carries the browser it ran on:
|
|
789
|
+
`test_a_superuser_reaches_the_index[chromium]`. Asking for two browsers runs every admin test
|
|
790
|
+
twice.
|
|
791
|
+
|
|
792
|
+
**One thing to know before you install this package anywhere.** `pytest-playwright` registers an
|
|
793
|
+
autouse fixture that deletes the `--output` directory, `test-results/` by default, at the start
|
|
794
|
+
of **every** pytest session, whether or not the session opens a browser. If your project keeps
|
|
795
|
+
JUnit XML or coverage output there, move it or pass `--output` somewhere else. We inherit that
|
|
796
|
+
behavior by depending on the plugin, and there's no way to switch it off.
|
|
797
|
+
|
|
798
|
+
**Don't pin the live server's port.** `admin_ui` is built on pytest-django's `live_server`, which
|
|
799
|
+
binds a free port for each process, so running the suite in parallel with `pytest-xdist` works
|
|
800
|
+
out of the box. Setting a fixed address with `--liveserver=localhost:8081` or the
|
|
801
|
+
`DJANGO_LIVE_TEST_SERVER_ADDRESS` environment variable makes every worker fight for the same
|
|
802
|
+
port. It passes under a single process and fails under `-n`, looking like a flaky port bug
|
|
803
|
+
rather than a configuration one.
|
|
804
|
+
|
|
805
|
+
## The test project
|
|
806
|
+
|
|
807
|
+
The suite runs against a small Django project in `tests/project/`, wired up by the
|
|
808
|
+
`[tool.pytest.ini_options]` table in `pyproject.toml`. You don't have to do anything to use it —
|
|
809
|
+
no environment variables, no `manage.py`.
|
|
810
|
+
|
|
811
|
+
It uses Django's defaults: the admin is at `/admin/` and the user model is `auth.User`. The
|
|
812
|
+
things a project can customize are each proved by their own tests, which swap the custom
|
|
813
|
+
setup in per test:
|
|
814
|
+
|
|
815
|
+
- `tests/test_custom_user.py` sets `AUTH_USER_MODEL` to `accounts.User`, a model with no
|
|
816
|
+
`username` field that authenticates by email;
|
|
817
|
+
- `tests/test_custom_prefix.py` uses `@pytest.mark.urls("project.urls_backoffice")`, which
|
|
818
|
+
serves the admin at `/backoffice/`;
|
|
819
|
+
- `tests/test_custom_site.py` overrides the `admin_ui_urls` fixture to point at `ops_site`, a
|
|
820
|
+
second `AdminSite` mounted at `/ops/` that registers only `Product`.
|
|
821
|
+
|
|
822
|
+
The swap only works in this direction. `auth.User` is swappable, so a project whose default is
|
|
823
|
+
a custom model never creates the `auth_user` table, and no test could swap the default back in.
|
|
824
|
+
|
|
825
|
+
The database is in-memory SQLite. That is worth knowing when a test feels slow later on: any
|
|
826
|
+
test that uses `live_server` is forced onto `transactional_db` by pytest-django, whatever mark
|
|
827
|
+
it carries, so the database is rebuilt rather than rolled back. SQLite keeps that cheap.
|
|
828
|
+
|
|
829
|
+
If you change a model in `tests/project/`, regenerate its migration:
|
|
830
|
+
|
|
831
|
+
```bash
|
|
832
|
+
PYTHONPATH=tests DJANGO_SETTINGS_MODULE=project.settings uv run django-admin makemigrations
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
Those migration files are excluded from ruff so that what's committed stays byte-identical to
|
|
836
|
+
what Django generates.
|
|
837
|
+
|
|
838
|
+
## Changing dependencies
|
|
839
|
+
|
|
840
|
+
Edit `pyproject.toml`, then:
|
|
841
|
+
|
|
842
|
+
```bash
|
|
843
|
+
uv lock # update uv.lock
|
|
844
|
+
uv sync # apply it to .venv
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
Commit `pyproject.toml` and `uv.lock` together.
|
|
848
|
+
|
|
849
|
+
## The version matrix
|
|
850
|
+
|
|
851
|
+
`tox.toml` defines 18 cells: nine Django minors, each on its oldest supported Python at or above
|
|
852
|
+
the 3.8 floor and on its newest.
|
|
853
|
+
|
|
854
|
+
```bash
|
|
855
|
+
uv run tox # every cell
|
|
856
|
+
uv run tox -p 6 # every cell, six at a time
|
|
857
|
+
uv run tox -e 3.8-dj32 # one cell
|
|
858
|
+
uv run tox list -d # what the cells are called
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
Anything after a bare `--` is passed straight through to pytest, so a failing cell can be
|
|
862
|
+
picked apart without touching `tox.toml`:
|
|
863
|
+
|
|
864
|
+
```bash
|
|
865
|
+
uv run tox -e 3.10-dj52 -- -x -vv # stop at the first failure, verbosely
|
|
866
|
+
uv run tox -e 3.10-dj52 -- tests/test_project.py # just one file
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
The whole sweep takes well under a minute on a warm cache, so there's no reason to leave it to
|
|
870
|
+
CI. On a cold one, budget for downloading five interpreters and a copy of Django and playwright
|
|
871
|
+
per cell.
|
|
872
|
+
|
|
873
|
+
tox-uv means uv builds those environments, which matters most at the floor: `python3.8 -m venv`
|
|
874
|
+
fails with `ensurepip` errors on a lot of systems, and uv fetches its own 3.8 instead.
|
|
875
|
+
|
|
876
|
+
Only `pytest-django` is pinned per cell, because no single release spans Django 3.2 to 6.1.
|
|
877
|
+
Everything else is left to the resolver, which caps itself per interpreter — 3.8 cells end up on
|
|
878
|
+
pytest 8.3.5 and playwright 1.48.0, newer cells on current. That cap is the constraint to design
|
|
879
|
+
against: code has to work on playwright 1.48.
|
|
880
|
+
|
|
881
|
+
There is a second, larger matrix behind the `full` label: the whole cartesian, every Django minor
|
|
882
|
+
against every interpreter it supports, 33 cells. It's too slow to sit in front of every pull
|
|
883
|
+
request, so it gates releases instead. Run it before tagging one:
|
|
884
|
+
|
|
885
|
+
```bash
|
|
886
|
+
uv run tox -m full -p 8
|
|
887
|
+
uv run tox list -m full # the 33 cell names
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
## CI
|
|
891
|
+
|
|
892
|
+
Two workflows, both in `.github/workflows/`.
|
|
893
|
+
|
|
894
|
+
`ci.yml` runs on pull requests and pushes to `main`. It lints, type-checks, runs the 18 cells,
|
|
895
|
+
runs one extra job that exercises firefox and webkit, and one that runs the whole suite under
|
|
896
|
+
`pytest-xdist` with four workers, because parallel safety is something the package promises. `release.yml` runs on a `v*` tag: it
|
|
897
|
+
runs all 33 cells, then builds, then publishes to PyPI, each job gated on the one before it, so
|
|
898
|
+
a matrix failure stops the release rather than merely being recorded next to it.
|
|
899
|
+
|
|
900
|
+
Browsers are not a Django-compatibility dimension, so they don't multiply the matrix. Each cell
|
|
901
|
+
installs chromium — the binary is keyed to that cell's playwright version, which is why tox does
|
|
902
|
+
it rather than the workflow — and the separate job covers the other two once.
|
|
903
|
+
|
|
904
|
+
Neither workflow lists its cells. Both ask tox for them:
|
|
905
|
+
|
|
906
|
+
```bash
|
|
907
|
+
uvx --from 'tox>=4.61' --with tox-uv tox list -d --no-desc -q # what ci.yml runs
|
|
908
|
+
uvx --from 'tox>=4.61' --with tox-uv tox list -m full --no-desc -q # what release.yml runs
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
So editing `tox.toml` changes CI, and the two cannot disagree.
|
|
912
|
+
|
|
913
|
+
The build job refuses to run if the tag doesn't match the `version` in `pyproject.toml`, so bump
|
|
914
|
+
the version in the same commit you tag. The steps of a release are in
|
|
915
|
+
[RELEASING.md](RELEASING.md).
|
|
916
|
+
|
|
917
|
+
## Building
|
|
918
|
+
|
|
919
|
+
```bash
|
|
920
|
+
uv build
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
That produces a wheel and an sdist in `dist/`, using the hatchling backend declared in
|
|
924
|
+
`pyproject.toml`. uv builds the wheel from the sdist, so a broken sdist fails the build rather
|
|
925
|
+
than shipping quietly.
|
|
926
|
+
|
|
927
|
+
## PyCharm
|
|
928
|
+
|
|
929
|
+
Recent PyCharm versions support uv directly: point the interpreter at the project and pick uv, or
|
|
930
|
+
select the existing `.venv` uv created.
|
|
931
|
+
|
|
932
|
+
Mark `tests` as a test source root (right-click it, *Mark Directory as* → *Test Sources Root*).
|
|
933
|
+
PyCharm doesn't read pytest's `pythonpath` setting, so without this it reports `No module named
|
|
934
|
+
project` on the test project's own imports even though everything runs.
|
|
935
|
+
|
|
936
|
+
One thing to leave alone: `pyproject.toml` carries a static `version`, and `__version__` reads it
|
|
937
|
+
back from the installed distribution metadata. There's still only one place to change it, and
|
|
938
|
+
some IDE integrations reject a dynamic version outright.
|
|
939
|
+
|
|
940
|
+
## Layout
|
|
941
|
+
|
|
942
|
+
```
|
|
943
|
+
src/django_admin_kit/ the package
|
|
944
|
+
tests/ its own test suite
|
|
945
|
+
tests/project/ the Django project those tests run against
|
|
946
|
+
specification.md the public API this is built against
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
## License
|
|
950
|
+
|
|
951
|
+
MIT. See [LICENSE](LICENSE).
|