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,178 @@
|
|
|
1
|
+
"""Matching changelist rows against patterns, and saying what did not match.
|
|
2
|
+
|
|
3
|
+
A failed match raises ``AssertionError`` carrying the expected pattern and the rows
|
|
4
|
+
actually shown, so that a test reads the whole picture under the line that failed, the
|
|
5
|
+
way any assertion helper reports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
from urllib.parse import SplitResult
|
|
12
|
+
|
|
13
|
+
from .rendered import Link
|
|
14
|
+
from .rows import Cell, Row
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Named:
|
|
18
|
+
"""A value that prints as its name: a sentinel, or a callable shown in a failure."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, name: str) -> None:
|
|
21
|
+
self._name = name
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return self._name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ANY = _Named("ANY")
|
|
28
|
+
"""A cell pattern any cell matches: the cell must exist, its contents do not matter."""
|
|
29
|
+
|
|
30
|
+
ANY_ROW = _Named("ANY_ROW")
|
|
31
|
+
"""A row pattern any row matches."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def matches(pattern: Any, row: Row) -> bool:
|
|
35
|
+
"""Whether one row pattern describes ``row``: ``ANY_ROW``, a cell pattern per
|
|
36
|
+
column, or cell patterns by column name, the columns left out unconstrained."""
|
|
37
|
+
if pattern is ANY_ROW:
|
|
38
|
+
return True
|
|
39
|
+
if isinstance(pattern, dict):
|
|
40
|
+
# An unknown name raises from the row, naming its columns: a misspelt
|
|
41
|
+
# column is a mistake to see, not a row that happens not to match.
|
|
42
|
+
return all(_cell_matches(expected, row, row[name]) for name, expected in pattern.items())
|
|
43
|
+
return len(pattern) == len(row) and all(
|
|
44
|
+
_cell_matches(expected, row, cell) for expected, cell in zip(pattern, row)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cell_matches(expected: Any, row: Row, cell: Cell) -> bool:
|
|
49
|
+
if expected is ANY:
|
|
50
|
+
return True
|
|
51
|
+
if callable(expected):
|
|
52
|
+
# The test's own code: whatever it raises is its own bug to see.
|
|
53
|
+
return bool(expected(row, cell))
|
|
54
|
+
# A pattern describes the cell's links or its value, whichever it equals: one link
|
|
55
|
+
# as the cell's only link, a list or tuple of links as all of them. Nothing is read
|
|
56
|
+
# into a pattern's shape, so a project whose own rule puts links into the value
|
|
57
|
+
# matches them the same way.
|
|
58
|
+
if cell.links == [expected]:
|
|
59
|
+
return True
|
|
60
|
+
if isinstance(expected, (tuple, list)) and cell.links == list(expected):
|
|
61
|
+
return True
|
|
62
|
+
# `==` may return something other than a bool for an arbitrary value; only its
|
|
63
|
+
# truth decides the match.
|
|
64
|
+
return bool(expected == cell.value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def contains(pattern: Any, rows: Sequence[Row]) -> bool:
|
|
68
|
+
"""``True`` when some row matches ``pattern``; otherwise the failure, raised."""
|
|
69
|
+
__tracebackhide__ = True
|
|
70
|
+
if any(matches(pattern, row) for row in rows):
|
|
71
|
+
return True
|
|
72
|
+
raise AssertionError(_no_match(pattern, rows))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def match(patterns: Any, rows: Sequence[Row]) -> bool:
|
|
76
|
+
"""``True`` when the rows are exactly ``patterns``, one row per pattern in order;
|
|
77
|
+
otherwise the failure, raised."""
|
|
78
|
+
__tracebackhide__ = True
|
|
79
|
+
if not isinstance(patterns, (tuple, list)):
|
|
80
|
+
raise TypeError(
|
|
81
|
+
"match() takes a list or tuple of row patterns, one per row of the changelist; "
|
|
82
|
+
f"got {patterns!r}. A single row is written as [row]."
|
|
83
|
+
)
|
|
84
|
+
if len(patterns) != len(rows):
|
|
85
|
+
raise AssertionError(_wrong_count(patterns, rows))
|
|
86
|
+
wrong = [
|
|
87
|
+
index
|
|
88
|
+
for index, (pattern, row) in enumerate(zip(patterns, rows))
|
|
89
|
+
if not matches(pattern, row)
|
|
90
|
+
]
|
|
91
|
+
if wrong:
|
|
92
|
+
raise AssertionError(_wrong_rows(patterns, wrong, rows))
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _no_match(pattern: Any, rows: Sequence[Row]) -> str:
|
|
97
|
+
expected = f" {_described(pattern)!r}"
|
|
98
|
+
lines = ["Expected row:", expected, "", "No matching row found.", "", "Actual rows:"]
|
|
99
|
+
return "\n".join(lines + _shown_rows(rows, pattern))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _wrong_count(patterns: Sequence[Any], rows: Sequence[Row]) -> str:
|
|
103
|
+
lines = ["Expected rows:", *_described_rows(patterns), ""]
|
|
104
|
+
lines.append(f"Expected {_count(len(patterns))}, found {len(rows)}.")
|
|
105
|
+
return "\n".join([*lines, "", "Actual rows:", *_shown_rows(rows, None)])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _wrong_rows(patterns: Sequence[Any], wrong: list[int], rows: Sequence[Row]) -> str:
|
|
109
|
+
lines = ["Expected rows:", *_described_rows(patterns)]
|
|
110
|
+
for index in wrong:
|
|
111
|
+
lines += ["", f"Row {index} did not match:"]
|
|
112
|
+
lines.append(f" expected {_described(patterns[index])!r}")
|
|
113
|
+
lines.append(f" actual {_shown(rows[index], patterns[index])!r}")
|
|
114
|
+
return "\n".join([*lines, "", "Actual rows:", *_shown_rows(rows, None)])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _count(number: int) -> str:
|
|
118
|
+
return f"{number} row" if number == 1 else f"{number} rows"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _described_rows(patterns: Sequence[Any]) -> list[str]:
|
|
122
|
+
return [f" {_described(pattern)!r}" for pattern in patterns] or [" (none)"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _shown_rows(rows: Sequence[Row], pattern: Any) -> list[str]:
|
|
126
|
+
return [f" {_shown(row, pattern)!r}" for row in rows] or [" (none)"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _described(pattern: Any) -> Any:
|
|
130
|
+
# A callable prints as its name, since its repr says nothing a reader can use.
|
|
131
|
+
if isinstance(pattern, dict):
|
|
132
|
+
return {name: _described_cell(item) for name, item in pattern.items()}
|
|
133
|
+
if isinstance(pattern, (tuple, list)):
|
|
134
|
+
return type(pattern)(_described_cell(item) for item in pattern)
|
|
135
|
+
return pattern
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _described_cell(expected: Any) -> Any:
|
|
139
|
+
if callable(expected):
|
|
140
|
+
return _Named(getattr(expected, "__name__", repr(expected)))
|
|
141
|
+
return expected
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _shown(row: Row, pattern: Any) -> Any:
|
|
145
|
+
# A row is shown the way the pattern addressed it: as its values in order, or by
|
|
146
|
+
# the columns the pattern named; where the pattern asked about links, the cell's
|
|
147
|
+
# links are shown instead, so the two line up.
|
|
148
|
+
if isinstance(pattern, dict):
|
|
149
|
+
return {name: _shown_cell(row[name], expected) for name, expected in pattern.items()}
|
|
150
|
+
cells = pattern if isinstance(pattern, Sequence) else ()
|
|
151
|
+
return tuple(
|
|
152
|
+
_shown_cell(cell, cells[index] if index < len(cells) else None)
|
|
153
|
+
for index, cell in enumerate(row)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _shown_cell(cell: Cell, expected: Any) -> Any:
|
|
158
|
+
if _asks_for_links(expected):
|
|
159
|
+
return cell.links
|
|
160
|
+
return cell.value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _asks_for_links(expected: Any) -> bool:
|
|
164
|
+
# For display only: a pattern that is a link, a pair, or a list or tuple of them.
|
|
165
|
+
if _is_link(expected):
|
|
166
|
+
return True
|
|
167
|
+
return isinstance(expected, (tuple, list)) and all(_is_link(item) for item in expected)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_link(expected: Any) -> bool:
|
|
171
|
+
if isinstance(expected, Link):
|
|
172
|
+
return True
|
|
173
|
+
return (
|
|
174
|
+
isinstance(expected, (tuple, list))
|
|
175
|
+
and len(expected) == 2
|
|
176
|
+
and isinstance(expected[0], str)
|
|
177
|
+
and isinstance(expected[1], (str, SplitResult))
|
|
178
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The messages the admin shows after an operation, with their level.
|
|
2
|
+
|
|
3
|
+
A level is the tag a message is drawn with, not a fixed name: Django's own tags merged
|
|
4
|
+
with the project's ``MESSAGE_TAGS``, so a project that tags errors ``danger`` reads them
|
|
5
|
+
as ``danger``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Callable, Iterator, Sequence, overload
|
|
11
|
+
|
|
12
|
+
from django.contrib.messages.utils import get_level_tags
|
|
13
|
+
from playwright.sync_api import Locator
|
|
14
|
+
|
|
15
|
+
from .rendered import text_of
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Message:
|
|
19
|
+
"""One message: its ``level``, the tag it is drawn with, and its ``text``.
|
|
20
|
+
|
|
21
|
+
A message compares equal to another message and to a ``(level, text)`` pair, as a
|
|
22
|
+
tuple or a list, and to nothing else.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, level: str, text: str) -> None:
|
|
26
|
+
self._level = level
|
|
27
|
+
self._text = text
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def level(self) -> str:
|
|
31
|
+
"""The level's tag, or ``""`` when the message's level has none."""
|
|
32
|
+
return self._level
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def text(self) -> str:
|
|
36
|
+
return self._text
|
|
37
|
+
|
|
38
|
+
def __eq__(self, other: object) -> bool:
|
|
39
|
+
if isinstance(other, Message):
|
|
40
|
+
return (self._level, self._text) == (other._level, other._text)
|
|
41
|
+
if isinstance(other, (tuple, list)) and len(other) == 2:
|
|
42
|
+
return (self._level, self._text) == tuple(other)
|
|
43
|
+
return NotImplemented
|
|
44
|
+
|
|
45
|
+
def __hash__(self) -> int:
|
|
46
|
+
return hash((self._level, self._text))
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"Message({self._level!r}, {self._text!r})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Messages(Sequence[Message]):
|
|
53
|
+
"""The messages a page shows, in order, read from the page each time they are used.
|
|
54
|
+
|
|
55
|
+
``items`` gives the elements of the messages, and fails when the browser no longer
|
|
56
|
+
shows the page they are on.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, items: Callable[[], Locator]) -> None:
|
|
60
|
+
self._items = items
|
|
61
|
+
|
|
62
|
+
@overload
|
|
63
|
+
def __getitem__(self, index: int) -> Message: ...
|
|
64
|
+
|
|
65
|
+
@overload
|
|
66
|
+
def __getitem__(self, index: slice) -> list[Message]: ...
|
|
67
|
+
|
|
68
|
+
def __getitem__(self, index: int | slice) -> Message | list[Message]:
|
|
69
|
+
return self._read()[index]
|
|
70
|
+
|
|
71
|
+
def __len__(self) -> int:
|
|
72
|
+
return len(self._read())
|
|
73
|
+
|
|
74
|
+
# The page is read once for the whole loop or lookup, rather than once per message as
|
|
75
|
+
# the sequence's own versions would.
|
|
76
|
+
def __iter__(self) -> Iterator[Message]:
|
|
77
|
+
return iter(self._read())
|
|
78
|
+
|
|
79
|
+
def __contains__(self, message: object) -> bool:
|
|
80
|
+
return message in self._read()
|
|
81
|
+
|
|
82
|
+
def __eq__(self, other: object) -> bool:
|
|
83
|
+
if isinstance(other, (Messages, list, tuple)):
|
|
84
|
+
return self._read() == list(other)
|
|
85
|
+
return NotImplemented
|
|
86
|
+
|
|
87
|
+
__hash__ = None # type: ignore[assignment]
|
|
88
|
+
|
|
89
|
+
def of_level(self, level: str) -> list[str]:
|
|
90
|
+
"""The texts of the messages of ``level``, in order; ``[]`` when there are none.
|
|
91
|
+
|
|
92
|
+
``level`` is a tag the project knows, or ``""`` for messages whose level has no
|
|
93
|
+
tag. Any other name raises ``KeyError`` listing the levels there are, because a
|
|
94
|
+
level the project renamed would otherwise read as no messages and let a test pass.
|
|
95
|
+
"""
|
|
96
|
+
# Django lists its own levels first, in their order, then the ones the project adds.
|
|
97
|
+
levels = list(dict.fromkeys(get_level_tags().values()))
|
|
98
|
+
if level and level not in levels:
|
|
99
|
+
raise KeyError(
|
|
100
|
+
f"The project has no message level {level!r}. Levels: "
|
|
101
|
+
f"{', '.join(repr(name) for name in levels)}."
|
|
102
|
+
)
|
|
103
|
+
return [message.text for message in self._read() if message.level == level]
|
|
104
|
+
|
|
105
|
+
def __repr__(self) -> str:
|
|
106
|
+
return f"Messages({self._read()!r})"
|
|
107
|
+
|
|
108
|
+
def _read(self) -> list[Message]:
|
|
109
|
+
# Every message is read with the same levels, so they are looked up once.
|
|
110
|
+
levels = set(get_level_tags().values())
|
|
111
|
+
return [Message(_level(item, levels), text_of(item)) for item in self._items().all()]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _level(item: Locator, levels: set[str]) -> str:
|
|
115
|
+
"""The level tag among the classes of ``item``, or ``""`` when it has none.
|
|
116
|
+
|
|
117
|
+
Django draws a message with its extra tags and then its level tag, so the last class
|
|
118
|
+
that is a level tag is the level, even when an extra tag spells one too.
|
|
119
|
+
"""
|
|
120
|
+
classes = (item.get_attribute("class") or "").split()
|
|
121
|
+
return next((tag for tag in reversed(classes) if tag in levels), "")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""How the text of a rendered value becomes its value.
|
|
2
|
+
|
|
3
|
+
Each kind of value has a rule, a callable that takes the rendered value and returns what a
|
|
4
|
+
test reads. The package picks the rule for a value, so a rule reads one kind and never has to
|
|
5
|
+
tell the kinds apart. Every rule has a default here, and a project replaces any of them in its
|
|
6
|
+
settings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from django.db.models import Field
|
|
16
|
+
from playwright.sync_api import Locator
|
|
17
|
+
|
|
18
|
+
from .rendered import RenderedValue
|
|
19
|
+
|
|
20
|
+
Rule = Callable[["RenderedValue"], Any]
|
|
21
|
+
|
|
22
|
+
_ICONS = {"True": True, "False": False, "None": None}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def boolean(rendered: RenderedValue) -> Any:
|
|
26
|
+
"""A boolean the admin draws as an icon, read from the icon's ``alt``: ``True``,
|
|
27
|
+
``False``, or ``None`` for a nullable boolean that has no value."""
|
|
28
|
+
return _ICONS[_icon(rendered).get_attribute("alt") or ""]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def empty(rendered: RenderedValue) -> Any:
|
|
32
|
+
"""A value the admin has none of, as the text it shows in its place."""
|
|
33
|
+
return rendered.text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def link(rendered: RenderedValue) -> Any:
|
|
37
|
+
"""A value the admin renders as links, as their text; the links are in ``links``."""
|
|
38
|
+
return rendered.text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def date(rendered: RenderedValue) -> Any:
|
|
42
|
+
"""A date, as the text the project's formats render."""
|
|
43
|
+
return rendered.text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def datetime(rendered: RenderedValue) -> Any:
|
|
47
|
+
"""A date and time, as the text the project's formats render."""
|
|
48
|
+
return rendered.text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def time(rendered: RenderedValue) -> Any:
|
|
52
|
+
"""A time of day, as the text the project's formats render."""
|
|
53
|
+
return rendered.text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def number(rendered: RenderedValue) -> Any:
|
|
57
|
+
"""A number, as the text the project's formats render."""
|
|
58
|
+
return rendered.text
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def choice(rendered: RenderedValue) -> Any:
|
|
62
|
+
"""A value from a fixed set of choices, as the label the admin shows for it."""
|
|
63
|
+
return rendered.text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def text(rendered: RenderedValue) -> Any:
|
|
67
|
+
"""Anything else, as its text."""
|
|
68
|
+
return rendered.text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
DEFAULTS: dict[str, Rule] = {
|
|
72
|
+
"boolean": boolean,
|
|
73
|
+
"empty": empty,
|
|
74
|
+
"link": link,
|
|
75
|
+
"date": date,
|
|
76
|
+
"datetime": datetime,
|
|
77
|
+
"time": time,
|
|
78
|
+
"number": number,
|
|
79
|
+
"choice": choice,
|
|
80
|
+
"text": text,
|
|
81
|
+
}
|
|
82
|
+
"""The default rule for each kind, by the name a project replaces it under."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Normalizers:
|
|
86
|
+
"""The rule for each kind of value: the defaults, with the project's replacements."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, replaced: Mapping[str, Rule] | None = None) -> None:
|
|
89
|
+
self._rules = {**DEFAULTS, **(replaced or {})}
|
|
90
|
+
|
|
91
|
+
def read(self, rendered: RenderedValue) -> Any:
|
|
92
|
+
"""The value of ``rendered``, read by the rule for its kind."""
|
|
93
|
+
return self._rules[_kind(rendered)](rendered)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _kind(rendered: RenderedValue) -> str:
|
|
97
|
+
"""The kind of ``rendered``: by the model field behind it where it has one, then by what
|
|
98
|
+
the admin drew."""
|
|
99
|
+
# The admin marks a value it has none of only by the text it shows instead.
|
|
100
|
+
if rendered.text == rendered.empty_display:
|
|
101
|
+
return "empty"
|
|
102
|
+
field = rendered.field
|
|
103
|
+
if field is not None:
|
|
104
|
+
# The admin shows a field with choices as their labels, whatever the field's class.
|
|
105
|
+
if field.choices:
|
|
106
|
+
return "choice"
|
|
107
|
+
kind = _field_kind(field)
|
|
108
|
+
if kind is not None:
|
|
109
|
+
return kind
|
|
110
|
+
if _draws_a_boolean(rendered):
|
|
111
|
+
return "boolean"
|
|
112
|
+
if rendered.links:
|
|
113
|
+
return "link"
|
|
114
|
+
return "text"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _field_kind(field: Field) -> str | None:
|
|
118
|
+
"""The kind of the most specific class ``field`` is that has one, so a date-time field,
|
|
119
|
+
which is also a date field, is a date-time."""
|
|
120
|
+
# Imported here: this module loads at pytest startup, with the settings.
|
|
121
|
+
from django.db import models
|
|
122
|
+
|
|
123
|
+
kinds = {
|
|
124
|
+
models.BooleanField: "boolean",
|
|
125
|
+
models.DateTimeField: "datetime",
|
|
126
|
+
models.DateField: "date",
|
|
127
|
+
models.TimeField: "time",
|
|
128
|
+
models.IntegerField: "number",
|
|
129
|
+
models.DecimalField: "number",
|
|
130
|
+
models.FloatField: "number",
|
|
131
|
+
}
|
|
132
|
+
return next((kinds[cls] for cls in type(field).__mro__ if cls in kinds), None)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _icon(rendered: RenderedValue) -> Locator:
|
|
136
|
+
return rendered.native.locator("img[alt]")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _draws_a_boolean(rendered: RenderedValue) -> bool:
|
|
140
|
+
"""Whether the admin drew nothing but one of its boolean icons."""
|
|
141
|
+
icon = _icon(rendered)
|
|
142
|
+
if rendered.text or icon.count() != 1:
|
|
143
|
+
return False
|
|
144
|
+
return (icon.get_attribute("alt") or "") in _ICONS
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def integer(rendered: str) -> int:
|
|
148
|
+
"""A whole number as the admin renders one, grouped or not.
|
|
149
|
+
|
|
150
|
+
Grouping is the only thing a locale adds to an integer, so dropping everything that
|
|
151
|
+
is not a digit reads it back under any separator.
|
|
152
|
+
"""
|
|
153
|
+
return int(re.sub(r"\D", "", rendered))
|