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,27 @@
1
+ """What a session hands to every page it opens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .normalize import Normalizers
10
+ from .urls import AdminUrls
11
+ from .widgets import Widgets
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PageTools:
16
+ """What every page of a session reads by: where the admin's pages are, the rules
17
+ that read the values they render, and the widget classes that read and fill their
18
+ controls.
19
+
20
+ Only what is built once from the settings or the session's fixtures, and is the same
21
+ for every page, belongs here. What belongs to one page, row, cell or field is passed
22
+ to it on its own. A page hands the objects it builds only the tools they use.
23
+ """
24
+
25
+ urls: AdminUrls
26
+ normalizers: Normalizers
27
+ widgets: Widgets
@@ -0,0 +1,122 @@
1
+ """Admin URLs, resolved through the site under test.
2
+
3
+ Every URL comes from Django's resolver, so a project that mounts its admin at
4
+ ``/backoffice/`` or runs a second ``AdminSite`` gets correct URLs without changing a
5
+ test. Nothing here assembles a path from string pieces.
6
+
7
+ Resolution says nothing about access. Asking for the URL of a page the current user
8
+ could not open still returns that URL; whether it works is a separate question.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Sequence
14
+
15
+ from django.contrib.admin import ModelAdmin
16
+ from django.contrib.admin.sites import AdminSite
17
+ from django.core.exceptions import ImproperlyConfigured
18
+ from django.urls import reverse
19
+ from django.utils.module_loading import import_string
20
+
21
+
22
+ def registered_models(site: AdminSite) -> list[type[Any]]:
23
+ """The models ``site`` registers, in the order they were registered."""
24
+ return list(_registry(site))
25
+
26
+
27
+ def model_admin(site: AdminSite, model: type[Any]) -> ModelAdmin:
28
+ """The model admin ``site`` registers ``model`` with."""
29
+ # Django 5.0 added a public accessor; before it there is only the registry.
30
+ if hasattr(site, "get_model_admin"):
31
+ return site.get_model_admin(model)
32
+ return _registry(site)[model]
33
+
34
+
35
+ def _registry(site: AdminSite) -> dict[type[Any], ModelAdmin]:
36
+ # The one place the site's registry is read: Django lists the registered models
37
+ # nowhere public on any version.
38
+ registry: dict[type[Any], ModelAdmin] = site._registry
39
+ return registry
40
+
41
+
42
+ def resolve_site(site: str | None = None) -> AdminSite:
43
+ """Turn the ``site`` setting into an ``AdminSite``.
44
+
45
+ Takes a dotted path, or ``None`` for the default ``django.contrib.admin.site``.
46
+ Only a path, because settings are imported before Django's app registry is ready
47
+ and an ``AdminSite`` written into settings raises ``AppRegistryNotReady`` there.
48
+ Code holding a site already can pass it straight to ``AdminUrls``.
49
+ """
50
+ if site is None:
51
+ from django.contrib import admin
52
+
53
+ return admin.site
54
+
55
+ try:
56
+ imported = import_string(site)
57
+ except ImportError as error:
58
+ raise ImproperlyConfigured(
59
+ f"DJANGO_ADMIN_KIT['site'] is {site!r}, which does not import: {error}"
60
+ ) from error
61
+
62
+ if not isinstance(imported, AdminSite):
63
+ raise ImproperlyConfigured(
64
+ f"DJANGO_ADMIN_KIT['site'] is {site!r}, which is a "
65
+ f"{type(imported).__name__}, not an AdminSite."
66
+ )
67
+ return imported
68
+
69
+
70
+ class AdminUrls:
71
+ """Every admin page this package knows, addressable without opening it."""
72
+
73
+ def __init__(self, site: AdminSite) -> None:
74
+ self._site = site
75
+
76
+ @property
77
+ def site(self) -> AdminSite:
78
+ return self._site
79
+
80
+ def index(self) -> str:
81
+ return self._reverse("index")
82
+
83
+ def login(self) -> str:
84
+ return self._reverse("login")
85
+
86
+ def list(self, model: type[Any]) -> str:
87
+ return self._reverse_model(model, "changelist")
88
+
89
+ def create(self, model: type[Any]) -> str:
90
+ return self._reverse_model(model, "add")
91
+
92
+ def edit(self, instance: Any) -> str:
93
+ return self._reverse_model(type(instance), "change", [self._pk(instance, "edit")])
94
+
95
+ def delete(self, instance: Any) -> str:
96
+ return self._reverse_model(type(instance), "delete", [self._pk(instance, "delete")])
97
+
98
+ def _reverse(self, name: str, args: Sequence[Any] | None = None) -> str:
99
+ # The app namespace is always "admin"; the site's own name is the instance
100
+ # namespace, which is what picks between two mounted sites.
101
+ return str(reverse(f"admin:{name}", args=args, current_app=self._site.name))
102
+
103
+ def _reverse_model(
104
+ self, model: type[Any], action: str, args: Sequence[Any] | None = None
105
+ ) -> str:
106
+ if not self._site.is_registered(model):
107
+ labels = sorted(m._meta.label for m in registered_models(self._site))
108
+ raise LookupError(
109
+ f"{model._meta.label} is not registered with the {self._site.name!r} admin "
110
+ f"site, so it has no admin URLs. Registered models: "
111
+ f"{', '.join(labels) or 'none'}."
112
+ )
113
+ return self._reverse(f"{model._meta.app_label}_{model._meta.model_name}_{action}", args)
114
+
115
+ @staticmethod
116
+ def _pk(instance: Any, action: str) -> Any:
117
+ if instance.pk is None:
118
+ raise ValueError(
119
+ f"Cannot build the {action} URL for an unsaved "
120
+ f"{type(instance)._meta.label}: it has no primary key yet."
121
+ )
122
+ return instance.pk
@@ -0,0 +1,172 @@
1
+ """The widget classes that read and fill a form's controls.
2
+
3
+ A widget class recognizes its widget by a marker, a CSS selector found inside a field's
4
+ box, and says how to read the field and how to fill it. The marker only picks the
5
+ class: ``read`` and ``fill`` get the whole field and find their own elements in its box.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Iterable, Protocol
11
+
12
+ from django.db.models import Model
13
+ from playwright.sync_api import Locator
14
+
15
+ from .fields import FieldChoice
16
+ from .rendered import text_of
17
+
18
+ if TYPE_CHECKING:
19
+ from .fields import FormField
20
+
21
+
22
+ class Widget(Protocol):
23
+ """What the package asks of a widget class. ``choices(field)`` is optional."""
24
+
25
+ def marker(self, field: FormField) -> str: ...
26
+
27
+ def read(self, field: FormField) -> Any: ...
28
+
29
+ def fill(self, field: FormField, value: Any) -> None: ...
30
+
31
+
32
+ class CheckboxWidget:
33
+ """A checkbox, read and filled as a boolean."""
34
+
35
+ def marker(self, field: FormField) -> str:
36
+ return f'input[type="checkbox"][name="{field.name}"]'
37
+
38
+ def read(self, field: FormField) -> bool:
39
+ return self._control(field).is_checked()
40
+
41
+ def fill(self, field: FormField, value: Any) -> None:
42
+ self._control(field).set_checked(bool(value))
43
+
44
+ def _control(self, field: FormField) -> Locator:
45
+ return field.native.locator(f'input[type="checkbox"][name="{field.name}"]')
46
+
47
+
48
+ class SelectWidget:
49
+ """A select, read as the ``FieldChoice`` chosen and filled with the option that
50
+ stands for a value."""
51
+
52
+ def marker(self, field: FormField) -> str:
53
+ return f'select[name="{field.name}"]'
54
+
55
+ def read(self, field: FormField) -> FieldChoice:
56
+ option = self._control(field).locator("option:checked")
57
+ return FieldChoice(option.get_attribute("value") or "", text_of(option))
58
+
59
+ def fill(self, field: FormField, value: Any) -> None:
60
+ option = self._option(field, value)
61
+ # An option is asked for by its value, except the blank one, whose value is the
62
+ # empty string: the browser layer reads that as "select nothing" on the versions
63
+ # that still run on Python 3.8, so it is asked for by its label.
64
+ if option.value:
65
+ self._control(field).select_option(value=option.value)
66
+ else:
67
+ self._control(field).select_option(label=option.label)
68
+
69
+ def choices(self, field: FormField) -> list[FieldChoice]:
70
+ """The options the select offers, in order, the blank one included."""
71
+ return [
72
+ FieldChoice(option.get_attribute("value") or "", text_of(option))
73
+ for option in self._control(field).locator("option").all()
74
+ ]
75
+
76
+ def _option(self, field: FormField, value: Any) -> FieldChoice:
77
+ """The option that stands for ``value``, among the ones the select offers."""
78
+ offered = {choice.value: choice for choice in self.choices(field)}
79
+ for spelling in _option_spellings(value):
80
+ if spelling in offered:
81
+ return offered[spelling]
82
+ raise ValueError(
83
+ f"The field {field.name!r} offers no option for {value!r}. Options: "
84
+ f"{', '.join(repr(option) for option in offered) or 'none'}."
85
+ )
86
+
87
+ def _control(self, field: FormField) -> Locator:
88
+ return field.native.locator(f'select[name="{field.name}"]')
89
+
90
+
91
+ class TextWidget:
92
+ """Whatever one control carries the field's name, read and filled as text."""
93
+
94
+ def marker(self, field: FormField) -> str:
95
+ return f'[name="{field.name}"]'
96
+
97
+ def read(self, field: FormField) -> str:
98
+ return self._control(field).input_value()
99
+
100
+ def fill(self, field: FormField, value: Any) -> None:
101
+ self._control(field).fill("" if value is None else str(value))
102
+
103
+ def _control(self, field: FormField) -> Locator:
104
+ return field.native.locator(f'[name="{field.name}"]')
105
+
106
+
107
+ class Widgets:
108
+ """The widget classes a form's fields are read and filled through, in the order
109
+ they are tried: the ``custom`` ones, then the built-in ones.
110
+
111
+ A built-in class is replaced by putting a custom class in front of it that
112
+ recognizes the same control.
113
+ """
114
+
115
+ def __init__(self, custom: Iterable[Widget] = ()) -> None:
116
+ # A text control is any control with the field's name, a checkbox and a select
117
+ # included, so it comes last.
118
+ self._widgets: tuple[Widget, ...] = (
119
+ *custom,
120
+ CheckboxWidget(),
121
+ SelectWidget(),
122
+ TextWidget(),
123
+ )
124
+
125
+ def widget_for(self, field: FormField) -> Widget | None:
126
+ """The first widget class whose marker matches inside the field's box, or
127
+ ``None`` when none of them recognizes the field.
128
+
129
+ Every marker is tried in one browser call, since each call costs a round trip
130
+ whatever it asks.
131
+ """
132
+ index = field.native.evaluate(
133
+ "(box, markers) => markers.findIndex(marker => box.querySelector(marker))",
134
+ [_marker(widget, field) for widget in self._widgets],
135
+ )
136
+ return self._widgets[index] if index >= 0 else None
137
+
138
+
139
+ def _marker(widget: Widget, field: FormField) -> str:
140
+ """The widget class's marker for the field, checked before the browser sees it."""
141
+ marker = widget.marker(field)
142
+ if not isinstance(marker, str) or not marker:
143
+ raise TypeError(
144
+ f"{type(widget).__name__}.marker() must return a non-empty CSS selector, "
145
+ f"not {marker!r}."
146
+ )
147
+ return marker
148
+
149
+
150
+ def _option_spellings(value: Any) -> list[str]:
151
+ """The option values ``value`` may be written as, in the order they are looked for.
152
+
153
+ A select's options are the choice values as ``str()`` renders them, with ``""`` for
154
+ the blank one. The exception is the widget Django gives a nullable boolean, whose
155
+ three options are spelled ``"unknown"``, ``"true"`` and ``"false"``. Both spellings
156
+ are Django's own and the page shows only which options it has, not which widget drew
157
+ them, so a boolean is looked for under either.
158
+ """
159
+ if isinstance(value, FieldChoice):
160
+ return [value.value]
161
+ # A select of related objects offers each one by its primary key, which is what the
162
+ # form posts for it.
163
+ if isinstance(value, Model):
164
+ return [str(value.pk)]
165
+ # `is`, because `1 == True` and a number is no boolean here.
166
+ if value is True:
167
+ return ["True", "true"]
168
+ if value is False:
169
+ return ["False", "false"]
170
+ if value is None:
171
+ return ["", "unknown"]
172
+ return [str(value)]