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.
@@ -0,0 +1,16 @@
1
+ """A pytest toolkit for writing conscious tests against the Django admin.
2
+
3
+ Public names are imported from the module that defines them, such as
4
+ ``django_admin_kit.pages.AdminPage``. Nothing is re-exported here: this module runs
5
+ at pytest startup in every project that installs the package, and importing the
6
+ session, page or URL modules would load the admin and the browser for all of them.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("pytest-django-admin-kit")
13
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
14
+ __version__ = "0.0.0.dev0"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,205 @@
1
+ """Settings for the package, and the rules that reject a bad one loudly.
2
+
3
+ Values come from the ``DJANGO_ADMIN_KIT`` dict in Django settings, or a default. A
4
+ typo raises rather than being ignored, because a silently dropped key disables a
5
+ setting invisibly and the resulting test failure points nowhere near the cause.
6
+
7
+ Which browser to use, whether to show it, and how far to slow it down are not settings
8
+ here. They belong to the pytest plugin the package drives the browser through, and
9
+ duplicating its flags would give a project two places to say the same thing.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Iterable, Mapping
16
+
17
+ from django.conf import settings as django_settings
18
+ from django.core.exceptions import ImproperlyConfigured
19
+ from django.utils.module_loading import import_string
20
+
21
+ from .normalize import DEFAULTS, Rule
22
+
23
+ SETTINGS_NAME = "DJANGO_ADMIN_KIT"
24
+
25
+ DEFAULT_TIMEOUT = 30_000
26
+
27
+ # Settings this package owns. Anything the browser plugin already governs is absent
28
+ # on purpose; see the module docstring.
29
+ _KNOWN_KEYS = frozenset({"locale", "normalizers", "site", "timeout", "timezone", "widgets"})
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Config:
34
+ # A dotted path, resolved later by `urls.resolve_site`. It cannot be an
35
+ # AdminSite instance: settings are imported before the app registry is ready, so
36
+ # a project that wrote one here would fail with AppRegistryNotReady before this
37
+ # package saw the value.
38
+ site: str | None
39
+ timeout: int
40
+ timezone: str | None
41
+ locale: str | None
42
+ # The rules the project replaces, by kind, already imported. The kinds it leaves out
43
+ # keep their defaults.
44
+ normalizers: Mapping[str, Rule] = field(default_factory=dict)
45
+ # The project's widget classes, one instance of each, in the order they are tried.
46
+ widgets: tuple[Any, ...] = ()
47
+
48
+
49
+ def _quote(values: Iterable[str]) -> str:
50
+ return ", ".join(repr(value) for value in values)
51
+
52
+
53
+ def _positive_int(value: Any, key: str, minimum: int) -> int:
54
+ # bool is a subclass of int, so without the first check `"timeout": True` would
55
+ # pass as a 1ms timeout and every browser operation would fail for no visible
56
+ # reason.
57
+ if isinstance(value, bool) or not isinstance(value, int):
58
+ raise ImproperlyConfigured(
59
+ f"{SETTINGS_NAME}['{key}'] must be an integer, not {type(value).__name__}."
60
+ )
61
+ if value < minimum:
62
+ raise ImproperlyConfigured(f"{SETTINGS_NAME}['{key}'] must be at least {minimum}.")
63
+ return value
64
+
65
+
66
+ def _rules(value: Any) -> dict[str, Rule]:
67
+ """The project's rules by kind, each imported from its dotted path.
68
+
69
+ A rule is named by its path, as the site is, so the settings module never imports
70
+ project code before the app registry is ready.
71
+ """
72
+ key = f"{SETTINGS_NAME}['normalizers']"
73
+ if not isinstance(value, Mapping):
74
+ raise ImproperlyConfigured(
75
+ f"{key} must be a dict of rule names to dotted paths, not {type(value).__name__}."
76
+ )
77
+ unknown = set(value) - set(DEFAULTS)
78
+ if unknown:
79
+ raise ImproperlyConfigured(
80
+ f"Unknown {key} rule(s): {_quote(sorted(unknown))}. "
81
+ f"Valid rules are: {_quote(DEFAULTS)}."
82
+ )
83
+ rules = {}
84
+ for name, path in value.items():
85
+ if not isinstance(path, str):
86
+ raise ImproperlyConfigured(
87
+ f"{key}[{name!r}] must be a dotted path to a callable, not {type(path).__name__}."
88
+ )
89
+ try:
90
+ rule = import_string(path)
91
+ except ImportError as error:
92
+ raise ImproperlyConfigured(
93
+ f"{key}[{name!r}] names {path!r}, which cannot be imported: {error}"
94
+ ) from error
95
+ if not callable(rule):
96
+ raise ImproperlyConfigured(f"{key}[{name!r}] names {path!r}, which is not callable.")
97
+ rules[name] = rule
98
+ return rules
99
+
100
+
101
+ def _widgets(value: Any) -> tuple[Any, ...]:
102
+ """The project's widget classes, each imported from its dotted path and made once.
103
+
104
+ A class is named by its path, as a rule is, and made with no arguments, as Django
105
+ makes an authentication backend.
106
+ """
107
+ key = f"{SETTINGS_NAME}['widgets']"
108
+ if not isinstance(value, (list, tuple)):
109
+ raise ImproperlyConfigured(
110
+ f"{key} must be a list of dotted paths to widget classes, not {type(value).__name__}."
111
+ )
112
+ widgets = []
113
+ for index, path in enumerate(value):
114
+ if not isinstance(path, str):
115
+ raise ImproperlyConfigured(
116
+ f"{key}[{index}] must be a dotted path to a widget class, "
117
+ f"not {type(path).__name__}."
118
+ )
119
+ try:
120
+ widget_class = import_string(path)
121
+ except ImportError as error:
122
+ raise ImproperlyConfigured(
123
+ f"{key}[{index}] names {path!r}, which cannot be imported: {error}"
124
+ ) from error
125
+ if not isinstance(widget_class, type):
126
+ raise ImproperlyConfigured(f"{key}[{index}] names {path!r}, which is not a class.")
127
+ widget = widget_class()
128
+ for method in ("marker", "read", "fill"):
129
+ if not callable(getattr(widget, method, None)):
130
+ raise ImproperlyConfigured(
131
+ f"{key}[{index}] names {path!r}, which has no {method} method."
132
+ )
133
+ # A widget class offers choices only if its widget has a fixed set of them.
134
+ if hasattr(widget, "choices") and not callable(widget.choices):
135
+ raise ImproperlyConfigured(
136
+ f"{key}[{index}] names {path!r}, whose choices is not a method."
137
+ )
138
+ widgets.append(widget)
139
+ return tuple(widgets)
140
+
141
+
142
+ def build_config(
143
+ settings: Mapping[str, Any] | None = None,
144
+ default_timezone: str | None = None,
145
+ default_locale: str | None = None,
146
+ ) -> Config:
147
+ """Resolve the settings dict into one frozen Config."""
148
+ given = dict(settings or {})
149
+
150
+ unknown = set(given) - _KNOWN_KEYS
151
+ if unknown:
152
+ raise ImproperlyConfigured(
153
+ f"Unknown {SETTINGS_NAME} setting(s): {_quote(sorted(unknown))}. "
154
+ f"Valid settings are: {_quote(sorted(_KNOWN_KEYS))}."
155
+ )
156
+
157
+ timeout = _positive_int(given.get("timeout", DEFAULT_TIMEOUT), "timeout", minimum=1)
158
+
159
+ timezone = given.get("timezone", default_timezone)
160
+ if timezone is not None and not isinstance(timezone, str):
161
+ raise ImproperlyConfigured(
162
+ f"{SETTINGS_NAME}['timezone'] must be an IANA zone name or None, "
163
+ f"not {type(timezone).__name__}."
164
+ )
165
+
166
+ locale = given.get("locale", default_locale)
167
+ if locale is not None and not isinstance(locale, str):
168
+ raise ImproperlyConfigured(
169
+ f"{SETTINGS_NAME}['locale'] must be a language tag such as 'en-us' or None, "
170
+ f"not {type(locale).__name__}."
171
+ )
172
+
173
+ site = given.get("site")
174
+ if site is not None and not isinstance(site, str):
175
+ raise ImproperlyConfigured(
176
+ f"{SETTINGS_NAME}['site'] must be a dotted path to an AdminSite, "
177
+ f"not {type(site).__name__}."
178
+ )
179
+
180
+ normalizers = _rules(given.get("normalizers", {}))
181
+ widgets = _widgets(given.get("widgets", []))
182
+
183
+ return Config(
184
+ site=site,
185
+ timeout=timeout,
186
+ timezone=timezone,
187
+ locale=locale,
188
+ normalizers=normalizers,
189
+ widgets=widgets,
190
+ )
191
+
192
+
193
+ def from_django_settings() -> Config:
194
+ """Build the configuration from the project's own Django settings.
195
+
196
+ The time zone defaults to the project's ``TIME_ZONE`` so that a rendered date
197
+ reads the same in the browser as it does in a Django template. The locale
198
+ defaults to ``LANGUAGE_CODE``, so a project whose admin follows the browser's
199
+ ``Accept-Language`` renders the same language and formats on every machine.
200
+ """
201
+ return build_config(
202
+ getattr(django_settings, SETTINGS_NAME, None),
203
+ default_timezone=getattr(django_settings, "TIME_ZONE", None),
204
+ default_locale=getattr(django_settings, "LANGUAGE_CODE", None),
205
+ )
@@ -0,0 +1,64 @@
1
+ """The validation errors a create or edit form shows after the admin rejected it.
2
+
3
+ The admin shows them at three levels, and each is read on its own: the summary
4
+ notice above the form, the errors of the form as a whole, and the errors of each field.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Callable
10
+
11
+ from playwright.sync_api import Locator
12
+
13
+ from .rendered import text_of
14
+
15
+ if TYPE_CHECKING:
16
+ from .fields import Fields
17
+
18
+
19
+ class ValidationErrors:
20
+ """The errors a form shows, read from the page each time they are asked for.
21
+
22
+ ``form`` gives the form element and ``fields`` the form's fields; both fail when the
23
+ browser no longer shows the page the form is on.
24
+ """
25
+
26
+ def __init__(self, form: Callable[[], Locator], fields: Callable[[], Fields]) -> None:
27
+ self._form = form
28
+ self._fields = fields
29
+
30
+ @property
31
+ def banner(self) -> str:
32
+ """The notice the admin shows above a rejected form, or ``""`` when it shows none.
33
+
34
+ Its wording depends on how many errors there are, so a test that only cares that
35
+ the form was rejected asserts that it is there.
36
+ """
37
+ notice = self._form().locator("p.errornote")
38
+ return text_of(notice) if notice.count() else ""
39
+
40
+ @property
41
+ def non_field(self) -> list[str]:
42
+ """The errors of the form as a whole rather than of one field, in order.
43
+
44
+ The notice is not one of them, though the admin shows them right under it.
45
+ """
46
+ # The form's own list is a direct child of the form's one div, which leaves out
47
+ # the lists of the inline formsets next to it.
48
+ items = self._form().locator(":scope > div > ul.errorlist.nonfield > li")
49
+ return [text_of(item) for item in items.all()]
50
+
51
+ @property
52
+ def fields(self) -> dict[str, list[str]]:
53
+ """The errors of each field that has any, by field name, in the order the admin
54
+ presents the fields. One field's errors are read from it: ``field.errors``."""
55
+ return {name: errors for name, field in self._fields().items() if (errors := field.errors)}
56
+
57
+ def __bool__(self) -> bool:
58
+ return bool(self.banner or self.non_field or self.fields)
59
+
60
+ def __repr__(self) -> str:
61
+ return (
62
+ f"ValidationErrors(banner={self.banner!r}, non_field={self.non_field!r}, "
63
+ f"fields={self.fields!r})"
64
+ )
@@ -0,0 +1,246 @@
1
+ """The fields of an admin form, as the page shows them.
2
+
3
+ A field is addressed by its name, never by the label shown next to it. Its element is
4
+ the box the admin draws around label, control and help text, which is the one thing
5
+ every field has whether the user may fill it or only read it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Callable, Dict
11
+
12
+ from django.contrib.admin import ModelAdmin
13
+ from django.utils import translation
14
+ from playwright.sync_api import Locator
15
+
16
+ from .normalize import Normalizers
17
+ from .rendered import Link, RenderedValue, text_of
18
+
19
+ if TYPE_CHECKING:
20
+ from .widgets import Widget, Widgets
21
+
22
+
23
+ class FieldChoice:
24
+ """One option a field offers: its ``value``, what the form posts, and its ``label``,
25
+ what the user reads.
26
+
27
+ A choice compares equal to another choice and to a ``(value, label)`` pair, as a
28
+ tuple or a list, and to nothing else. A bare string is neither part, so a test
29
+ says which one it means: ``field.value.label == "Tools"``.
30
+ """
31
+
32
+ def __init__(self, value: str, label: str) -> None:
33
+ self._value = value
34
+ self._label = label
35
+
36
+ @property
37
+ def value(self) -> str:
38
+ return self._value
39
+
40
+ @property
41
+ def label(self) -> str:
42
+ return self._label
43
+
44
+ def __eq__(self, other: object) -> bool:
45
+ if isinstance(other, FieldChoice):
46
+ return (self._value, self._label) == (other._value, other._label)
47
+ if isinstance(other, (tuple, list)) and len(other) == 2:
48
+ return (self._value, self._label) == tuple(other)
49
+ return NotImplemented
50
+
51
+ def __hash__(self) -> int:
52
+ return hash((self._value, self._label))
53
+
54
+ def __repr__(self) -> str:
55
+ return f"FieldChoice({self._value!r}, {self._label!r})"
56
+
57
+
58
+ class FormField:
59
+ """One field of an admin form.
60
+
61
+ ``language`` is the one the page was rendered in, which decides what the admin put
62
+ after the label. ``check`` is run before every read, and fails when the browser no
63
+ longer shows the page the field is on. ``normalizers`` read the value of a field the
64
+ user may only read, by what stands behind it on the page ``model_admin`` draws, and
65
+ ``widgets`` read and fill the field's control.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ element: Locator,
71
+ name: str,
72
+ *,
73
+ language: str,
74
+ check: Callable[[], None],
75
+ normalizers: Normalizers,
76
+ model_admin: ModelAdmin,
77
+ widgets: Widgets,
78
+ ) -> None:
79
+ self._element = element
80
+ self._name = name
81
+ self._language = language
82
+ self._check = check
83
+ self._normalizers = normalizers
84
+ self._model_admin = model_admin
85
+ self._widgets = widgets
86
+
87
+ @property
88
+ def name(self) -> str:
89
+ """The name the form knows the field by."""
90
+ return self._name
91
+
92
+ @property
93
+ def native(self) -> Locator:
94
+ """The field's box, unwrapped, for anything the package does not model."""
95
+ return self._element
96
+
97
+ @property
98
+ def required(self) -> bool:
99
+ """Whether the form requires a value, as the admin tells the user.
100
+
101
+ The admin marks a required field on its label, from the form field as the form
102
+ finally has it, so a ``ModelForm`` that changes what the model says is read the
103
+ way the user sees it. A field the user may only read is never required.
104
+ """
105
+ self._check()
106
+ return "required" in (self._label.get_attribute("class") or "").split()
107
+
108
+ @property
109
+ def label(self) -> str:
110
+ """The label the admin shows next to the field, as data and never as an address.
111
+
112
+ The suffix the form puts after every label, ``:`` in English and whatever the
113
+ page's language makes of it, is not part of the label and is taken off.
114
+ """
115
+ self._check()
116
+ label = text_of(self._label)
117
+ # The suffix is Django's own translated ":", so the catalog says what the page
118
+ # ends each label with: "Name:" in English, "Name\xa0:" in French, a full-width
119
+ # colon in Traditional Chinese. It is looked up in the page's language, not the
120
+ # test's: a project that picks the language per request renders the page in
121
+ # one while the test thread is in another. Collapsing the suffix as the label
122
+ # was collapsed turns the French "\xa0:" into ":", so "Name :" ends with it
123
+ # and the space left over is dropped too.
124
+ with translation.override(self._language):
125
+ suffix = " ".join(translation.gettext(":").split())
126
+ return label[: -len(suffix)].rstrip() if label.endswith(suffix) else label
127
+
128
+ @property
129
+ def editable(self) -> bool:
130
+ """Whether the field has a control to fill, rather than a value to read."""
131
+ self._check()
132
+ return self._element.locator("div.readonly").count() == 0
133
+
134
+ @property
135
+ def value(self) -> Any:
136
+ """What the field holds right now, as the user reads it.
137
+
138
+ A control is read by its widget class: a checkbox reads as ``True`` or
139
+ ``False``, a select as the ``FieldChoice`` chosen, anything else as the text
140
+ typed into it. A field the user may only read is read as a changelist cell is:
141
+ its text, or the boolean the admin drew as an icon. A control no widget class
142
+ recognizes raises ``LookupError``.
143
+ """
144
+ self._check()
145
+ if not self.editable:
146
+ return self._rendered.value
147
+ return self._widget("read").read(self)
148
+
149
+ @property
150
+ def links(self) -> list[Link]:
151
+ """The links in a rendered-only field's value, in order, like ``Cell.links``.
152
+
153
+ ``[]`` for an editable field: its value is what the control holds, and the
154
+ icons the admin may draw next to a select are not part of it.
155
+ """
156
+ self._check()
157
+ return [] if self.editable else self._rendered.links
158
+
159
+ @property
160
+ def errors(self) -> list[str]:
161
+ """The errors the admin shows for the field, in order; ``[]`` when it shows none.
162
+
163
+ A field can fail several rules at once, so this is always a list.
164
+ """
165
+ self._check()
166
+ items = self._element.locator("ul.errorlist > li").all()
167
+ # A field that shares its line with others has a box of its own. Django 4.2 to 6.0
168
+ # put its errors next to that box, in a wrapper around the two; the others put
169
+ # them inside it. The wrapper holds one field, so its list is this field's.
170
+ if "fieldBox" in (self._element.get_attribute("class") or "").split():
171
+ wrapper = self._element.locator("xpath=..")
172
+ items = wrapper.locator(":scope > ul.errorlist > li").all() + items
173
+ return [text_of(item) for item in items]
174
+
175
+ def fill(self, value: Any) -> None:
176
+ """Put ``value`` into the field, the way a user would.
177
+
178
+ The field's widget class fills it: a checkbox is checked or cleared by the
179
+ value's truth, a select is given the option that stands for the value, and every
180
+ other control is filled with the value's text, ``None`` emptying it. A field the
181
+ user may only read has nothing to fill, and a control no widget class recognizes
182
+ cannot be filled: both raise ``LookupError``.
183
+ """
184
+ self._check()
185
+ if not self.editable:
186
+ raise LookupError(
187
+ f"The field {self._name!r} is rendered only, so there is nothing to fill."
188
+ )
189
+ self._widget("fill").fill(self, value)
190
+
191
+ @property
192
+ def choices(self) -> list[FieldChoice]:
193
+ """The options a select offers, in order, the blank one included.
194
+
195
+ ``[]`` for any other field: a text input has no fixed set of options, and a
196
+ field the user may only read offers none.
197
+ """
198
+ self._check()
199
+ if not self.editable:
200
+ return []
201
+ choices = getattr(self._widgets.widget_for(self), "choices", None)
202
+ return [] if choices is None else choices(self)
203
+
204
+ def _widget(self, verb: str) -> Widget:
205
+ """The widget class that reads and fills the field. A field none of them
206
+ recognizes is reported at once, where waiting for a control would time out."""
207
+ widget = self._widgets.widget_for(self)
208
+ if widget is None:
209
+ raise LookupError(
210
+ f"No widget class can {verb} the field {self._name!r}. Add one for it to "
211
+ "DJANGO_ADMIN_KIT['widgets']."
212
+ )
213
+ return widget
214
+
215
+ @property
216
+ def _label(self) -> Locator:
217
+ # Django labels most fields with `label` and, from 6.0, a widget that groups
218
+ # several inputs with `legend`.
219
+ return self._element.locator("label, legend").first
220
+
221
+ @property
222
+ def _rendered(self) -> RenderedValue:
223
+ return RenderedValue(
224
+ self._element.locator("div.readonly"),
225
+ self._name,
226
+ check=self._check,
227
+ normalizers=self._normalizers,
228
+ model_admin=self._model_admin,
229
+ )
230
+
231
+ def __repr__(self) -> str:
232
+ return f"FormField({self._name!r})"
233
+
234
+
235
+ class Fields(Dict[str, FormField]):
236
+ """The fields of a form by name, in the order the admin presents them.
237
+
238
+ A plain dict, so membership, ``set()`` and ``list()`` are what they always are;
239
+ only a miss says more than a bare ``KeyError`` would.
240
+ """
241
+
242
+ def __missing__(self, key: str) -> FormField:
243
+ raise KeyError(
244
+ f"The form has no field named {key!r}. Fields: "
245
+ f"{', '.join(repr(name) for name in self)}."
246
+ )