django-nifty-layout 0.1.1__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_nifty_layout-0.1.1.dist-info/METADATA +236 -0
- django_nifty_layout-0.1.1.dist-info/RECORD +10 -0
- django_nifty_layout-0.1.1.dist-info/WHEEL +5 -0
- django_nifty_layout-0.1.1.dist-info/entry_points.txt +2 -0
- django_nifty_layout-0.1.1.dist-info/licenses/LICENSE +21 -0
- django_nifty_layout-0.1.1.dist-info/top_level.txt +1 -0
- nifty_layout/__init__.py +1 -0
- nifty_layout/accessor.py +285 -0
- nifty_layout/apps.py +5 -0
- nifty_layout/components.py +382 -0
@@ -0,0 +1,236 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: django-nifty-layout
|
3
|
+
Version: 0.1.1
|
4
|
+
Summary: A flexible data composition tool to simplify writing templates.
|
5
|
+
Author-email: J Fall <email@example.com>
|
6
|
+
License: MIT License
|
7
|
+
|
8
|
+
Copyright (c) 2025 Joseph
|
9
|
+
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
12
|
+
in the Software without restriction, including without limitation the rights
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
15
|
+
furnished to do so, subject to the following conditions:
|
16
|
+
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
18
|
+
copies or substantial portions of the Software.
|
19
|
+
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
26
|
+
SOFTWARE.
|
27
|
+
|
28
|
+
Project-URL: Homepage, https://github.com/powderflask/django-nifty-layout
|
29
|
+
Project-URL: Repository, https://github.com/powderflask/django-nifty-layout
|
30
|
+
Keywords: django-nifty-layout,nifty_layout
|
31
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
33
|
+
Classifier: Intended Audience :: Developers
|
34
|
+
Classifier: Natural Language :: English
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
36
|
+
Classifier: Framework :: Django
|
37
|
+
Requires-Python: <4.0,>=3.10
|
38
|
+
Description-Content-Type: text/markdown
|
39
|
+
License-File: LICENSE
|
40
|
+
Requires-Dist: django
|
41
|
+
Provides-Extra: format
|
42
|
+
Requires-Dist: black; extra == "format"
|
43
|
+
Requires-Dist: isort; extra == "format"
|
44
|
+
Provides-Extra: lint
|
45
|
+
Requires-Dist: flake8; extra == "lint"
|
46
|
+
Requires-Dist: flake8-bugbear; extra == "lint"
|
47
|
+
Provides-Extra: test
|
48
|
+
Requires-Dist: pytest; extra == "test"
|
49
|
+
Requires-Dist: pytest-django; extra == "test"
|
50
|
+
Requires-Dist: pytest-cov; extra == "test"
|
51
|
+
Requires-Dist: pytest-sugar; extra == "test"
|
52
|
+
Provides-Extra: utils
|
53
|
+
Requires-Dist: tox; extra == "utils"
|
54
|
+
Requires-Dist: invoke; extra == "utils"
|
55
|
+
Requires-Dist: bumpver; extra == "utils"
|
56
|
+
Requires-Dist: pip-tools; extra == "utils"
|
57
|
+
Provides-Extra: build
|
58
|
+
Requires-Dist: build; extra == "build"
|
59
|
+
Requires-Dist: twine; extra == "build"
|
60
|
+
Dynamic: license-file
|
61
|
+
|
62
|
+
# django-nifty-layout
|
63
|
+
|
64
|
+
[](https://pypi.python.org/pypi/django-nifty-layout)  [](https://github.com/powderflask/django-nifty-layout)
|
65
|
+
|
66
|
+
Version: 0.1.1
|
67
|
+
|
68
|
+
A simple but flexible layout tool for composing and transforming data for structured template components.
|
69
|
+
Inspired by `crispy-forms` Layout, but without the forms.
|
70
|
+
|
71
|
+
django-nifty-layout is free software distributed under the MIT License.
|
72
|
+
|
73
|
+
|
74
|
+
## Quick Start
|
75
|
+
|
76
|
+
1. Install the `django-nifty-layout` package from PyPI
|
77
|
+
```bash
|
78
|
+
$ pip install django-nifty-layout
|
79
|
+
```
|
80
|
+
|
81
|
+
2. Go grab a drink, you are all done!
|
82
|
+
|
83
|
+
|
84
|
+
## Sample Usage
|
85
|
+
layout.py
|
86
|
+
```
|
87
|
+
from django.utils.formats import date_format
|
88
|
+
|
89
|
+
from nifty_layout.components import (
|
90
|
+
DictCompositeNode as Dct,
|
91
|
+
FieldNode as Fld,
|
92
|
+
Seq,
|
93
|
+
)
|
94
|
+
|
95
|
+
# ----- Safe Date Formatter ----- #
|
96
|
+
date_formatter = lambda val: None if val is None else date_format(val, SHORT_DATE_FORMAT)
|
97
|
+
|
98
|
+
layout = Dct(
|
99
|
+
dict(
|
100
|
+
overview=Seq(
|
101
|
+
"title",
|
102
|
+
Fld("date", formatter=date_formatter),
|
103
|
+
report_type",
|
104
|
+
"location",
|
105
|
+
labeller="Overview",
|
106
|
+
),
|
107
|
+
contacts=Seq(
|
108
|
+
Dct(
|
109
|
+
dict(
|
110
|
+
name="contact_name",
|
111
|
+
contact_methods=Seq("contact_email"),
|
112
|
+
),
|
113
|
+
dict(
|
114
|
+
name="reported_by_name",
|
115
|
+
contact_methods=Seq("reported_by_phone_num", "reported_by_email"),
|
116
|
+
),
|
117
|
+
)
|
118
|
+
),
|
119
|
+
labeller="Contacts",
|
120
|
+
),
|
121
|
+
...
|
122
|
+
)
|
123
|
+
)
|
124
|
+
```
|
125
|
+
|
126
|
+
views.py
|
127
|
+
```
|
128
|
+
def report_view(request, pk):
|
129
|
+
...
|
130
|
+
obj = Report.objects.get(pk=pk)
|
131
|
+
...
|
132
|
+
return render(request, "template.html", dict(report=layout.bind(obj)))
|
133
|
+
```
|
134
|
+
|
135
|
+
template.html
|
136
|
+
```
|
137
|
+
...
|
138
|
+
<div class="report>
|
139
|
+
<h2>report.overview.label</h2>
|
140
|
+
{% for node in report.overview.children %}
|
141
|
+
<div class="row">
|
142
|
+
<div class="col label">{{ node.label }}</div>
|
143
|
+
<div class="col value">{{ node.value|default:"" }}</div>
|
144
|
+
{% endfor %}
|
145
|
+
</div>
|
146
|
+
{% endfor %}
|
147
|
+
<div class="row">
|
148
|
+
{% for contact in report.contacts.children %}
|
149
|
+
<div class="col">
|
150
|
+
{% include "contact_card.html" %}
|
151
|
+
</div>
|
152
|
+
{% endfor %}
|
153
|
+
</div>
|
154
|
+
...
|
155
|
+
</div>
|
156
|
+
```
|
157
|
+
|
158
|
+
## Get Me Some of That
|
159
|
+
* [Source Code](https://github.com/powderflask/django-nifty-layout)
|
160
|
+
|
161
|
+
* [Issues](https://github.com/powderflask/django-nifty-layout/issues)
|
162
|
+
* [PyPI](https://pypi.org/project/django-nifty-layout)
|
163
|
+
|
164
|
+
[MIT License](https://github.com/powderflask/django-nifty-layout/blob/master/LICENSE)
|
165
|
+
|
166
|
+
### Check Out the Demo App ** coming soon **
|
167
|
+
|
168
|
+
1. `pip install -e git+https://github.com/powderflask/django-nifty-layout.git#egg=django-nifty-layout`
|
169
|
+
1. `python django-nifty-layout/manage.py install_demo`
|
170
|
+
1. `python django-nifty-layout/manage.py runserver`
|
171
|
+
|
172
|
+
|
173
|
+
### Acknowledgments
|
174
|
+
This project would be impossible to maintain without the help of our generous [contributors](https://github.com/powderflask/django-nifty-layout/graphs/contributors)
|
175
|
+
|
176
|
+
#### Technology Colophon
|
177
|
+
|
178
|
+
Without django and the django dev team, the universe would have fewer rainbows and ponies.
|
179
|
+
|
180
|
+
This package was originally created with [`cookiecutter`](https://www.cookiecutter.io/)
|
181
|
+
and the [`cookiecutter-powder-pypackage`](https://github.com/JacobTumak/CookiePowder) project template.
|
182
|
+
|
183
|
+
|
184
|
+
## For Developers
|
185
|
+
Install `invoke`, `pip-tools`, `tox` for all the CLI goodness
|
186
|
+
```bash
|
187
|
+
pip install invoke pip-tools tox
|
188
|
+
```
|
189
|
+
|
190
|
+
Initialise the development environment using the invoke task
|
191
|
+
```bash
|
192
|
+
inv tox.venv
|
193
|
+
```
|
194
|
+
Or create it with tox directly
|
195
|
+
```bash
|
196
|
+
tox d -e dev .venv
|
197
|
+
```
|
198
|
+
Or build and install the dev requirements with pip
|
199
|
+
```bash
|
200
|
+
inv deps.compile-dev
|
201
|
+
pip install -r requirements_dev.txt
|
202
|
+
```
|
203
|
+
|
204
|
+
### Tests
|
205
|
+
```bash
|
206
|
+
pytest
|
207
|
+
```
|
208
|
+
or
|
209
|
+
```bash
|
210
|
+
tox r
|
211
|
+
```
|
212
|
+
or run tox environments in parallel using
|
213
|
+
```bash
|
214
|
+
tox p
|
215
|
+
```
|
216
|
+
|
217
|
+
### Code Style / Linting
|
218
|
+
```bash
|
219
|
+
$ isort
|
220
|
+
$ black
|
221
|
+
$ flake8
|
222
|
+
```
|
223
|
+
|
224
|
+
### Versioning
|
225
|
+
* [Semantic Versioning](https://semver.org/)
|
226
|
+
```bash
|
227
|
+
$ bumpver show
|
228
|
+
```
|
229
|
+
|
230
|
+
### Build / Deploy Automation
|
231
|
+
* [invoke](https://www.pyinvoke.org/)
|
232
|
+
```bash
|
233
|
+
$ invoke -l
|
234
|
+
```
|
235
|
+
* [GitHub Actions](https://docs.github.com/en/actions) (see [.github/workflows](https://github.com/powderflask/django-nifty-layout/tree/master/.github/workflows))
|
236
|
+
* [GitHub Webhooks](https://docs.github.com/en/webhooks) (see [settings/hooks](https://github.com/powderflask/django-nifty-layout/settings/hooks))
|
@@ -0,0 +1,10 @@
|
|
1
|
+
django_nifty_layout-0.1.1.dist-info/licenses/LICENSE,sha256=si8VKm8Qp6B1nIFTpCaRMFdHHq57NJ96nomGEANk4hQ,1063
|
2
|
+
nifty_layout/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
3
|
+
nifty_layout/accessor.py,sha256=q6_nfZrPQuA6bjmIIjqbF1Uwu2VNOBCgC0frKB5IkqY,10741
|
4
|
+
nifty_layout/apps.py,sha256=Q400xTUOKrqW30Yx-OzQNiUETp7XgUaa_YEe0_SyCF0,101
|
5
|
+
nifty_layout/components.py,sha256=0GRPGECI4jhp-9wjTZza9Sx49M9S4U-p7yyeLqmnp4s,14695
|
6
|
+
django_nifty_layout-0.1.1.dist-info/METADATA,sha256=GibfEgGmmpB7tyuSSl59I7T2kMOicswIcEYoOKOqqog,7391
|
7
|
+
django_nifty_layout-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
+
django_nifty_layout-0.1.1.dist-info/entry_points.txt,sha256=JyWfou41kFy_j6OE38H9Yc-94NjpkSRaMrQqc9b4kCU,57
|
9
|
+
django_nifty_layout-0.1.1.dist-info/top_level.txt,sha256=N6ONrGRmjlGCM1rVBt2zvALvtOjF1isVimaIjWunhLs,13
|
10
|
+
django_nifty_layout-0.1.1.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Joseph
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1 @@
|
|
1
|
+
nifty_layout
|
nifty_layout/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.1.1"
|
nifty_layout/accessor.py
ADDED
@@ -0,0 +1,285 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import warnings
|
4
|
+
from collections.abc import Iterable, Mapping, Sequence
|
5
|
+
from typing import Any, Optional, Protocol, Type, TypeAlias, TypeVar
|
6
|
+
|
7
|
+
from django.core.exceptions import FieldDoesNotExist
|
8
|
+
from django.db import models
|
9
|
+
|
10
|
+
AccessorContext: TypeAlias = object | Mapping[str, Any] | Sequence
|
11
|
+
|
12
|
+
# Any type that implements the protocol
|
13
|
+
AccessorType = TypeVar("AccessorType", bound="AccessorProtocol")
|
14
|
+
|
15
|
+
# An AccessorSpec allows an accessor to be specified by its string representation
|
16
|
+
AccessorSpec: TypeAlias = str | AccessorType
|
17
|
+
|
18
|
+
|
19
|
+
class AccessorProtocol(Protocol):
|
20
|
+
"""
|
21
|
+
A string that describes how to resolve a value from an arbitrarily deeply nested object, dictionary, or sequence.
|
22
|
+
E.g. hn.helpers.algorithms.Accessor implements this protocol
|
23
|
+
|
24
|
+
Design note: this combines API for a generic Accessor with API for a more specific "ModelFieldAccessor"
|
25
|
+
This violates SR principle a little, but keeps things simple and don't forsee any problems.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, value: str):
|
29
|
+
"""Initialise the accessor with a string specifying the "path" used to resolve access to a value."""
|
30
|
+
...
|
31
|
+
|
32
|
+
def resolve(self, context: AccessorContext) -> Any:
|
33
|
+
"""Return the value of this accessor in the given context."""
|
34
|
+
...
|
35
|
+
|
36
|
+
def get_field(self, model: models.Model | type[models.Model]) -> models.Field:
|
37
|
+
"""Resolve this accessor using given model as context to return the model field rather than its value."""
|
38
|
+
...
|
39
|
+
|
40
|
+
def get_label(self, model: models.Model | type[models.Model]) -> str:
|
41
|
+
"""Resolve this accessor using given model as context to return the model field verbose name."""
|
42
|
+
... # Note: this method is not defined on base tables2 Accessor, see extension below.
|
43
|
+
|
44
|
+
|
45
|
+
#####
|
46
|
+
# BaseAccessor - copied directly from https://github.com/jieter/django-tables2/blob/master/django_tables2/utils.py
|
47
|
+
# to avoid otherwise unnecessary dependency on `table2`, but you should use tables2 anyhow, its awesome !!
|
48
|
+
# Licence: https://github.com/jieter/django-tables2/blob/master/LICENSE
|
49
|
+
#####
|
50
|
+
|
51
|
+
|
52
|
+
class BaseAccessor(str):
|
53
|
+
"""
|
54
|
+
A string describing a path from one object to another via attribute/index
|
55
|
+
accesses. For convenience, the class has an alias `.A` to allow for more concise code.
|
56
|
+
|
57
|
+
Relations are separated by a ``__`` character.
|
58
|
+
|
59
|
+
To support list-of-dicts from ``QuerySet.values()``, if the context is a dictionary,
|
60
|
+
and the accessor is a key in the dictionary, it is returned right away.
|
61
|
+
"""
|
62
|
+
|
63
|
+
LEGACY_SEPARATOR = "."
|
64
|
+
SEPARATOR = "__"
|
65
|
+
|
66
|
+
ALTERS_DATA_ERROR_FMT = "Refusing to call {method}() because `.alters_data = True`"
|
67
|
+
LOOKUP_ERROR_FMT = "Failed lookup for key [{key}] in {context}, when resolving the accessor {accessor}"
|
68
|
+
|
69
|
+
def __init__(self, value, callable_args=None, callable_kwargs=None):
|
70
|
+
self.callable_args = (
|
71
|
+
callable_args or getattr(value, "callable_args", None) or []
|
72
|
+
)
|
73
|
+
self.callable_kwargs = (
|
74
|
+
callable_kwargs or getattr(value, "callable_kwargs", None) or {}
|
75
|
+
)
|
76
|
+
super().__init__()
|
77
|
+
|
78
|
+
def __new__(cls, value, callable_args=None, callable_kwargs=None):
|
79
|
+
instance = super().__new__(cls, value)
|
80
|
+
if cls.LEGACY_SEPARATOR in value:
|
81
|
+
instance.SEPARATOR = cls.LEGACY_SEPARATOR
|
82
|
+
|
83
|
+
message = (
|
84
|
+
f"Use '__' to separate path components, not '.' in accessor '{value}'"
|
85
|
+
" (fallback will be removed in django_tables2 version 3)."
|
86
|
+
)
|
87
|
+
|
88
|
+
warnings.warn(message, DeprecationWarning, stacklevel=3)
|
89
|
+
|
90
|
+
return instance
|
91
|
+
|
92
|
+
def resolve(self, context, safe=True, quiet=False):
|
93
|
+
"""
|
94
|
+
Return an object described by the accessor by traversing the attributes of *context*.
|
95
|
+
|
96
|
+
Lookups are attempted in the following order:
|
97
|
+
|
98
|
+
- dictionary (e.g. ``obj[related]``)
|
99
|
+
- attribute (e.g. ``obj.related``)
|
100
|
+
- list-index lookup (e.g. ``obj[int(related)]``)
|
101
|
+
|
102
|
+
Callable objects are called, and their result is used, before
|
103
|
+
proceeding with the resolving.
|
104
|
+
|
105
|
+
Example::
|
106
|
+
|
107
|
+
>>> x = Accessor("__len__")
|
108
|
+
>>> x.resolve("brad")
|
109
|
+
4
|
110
|
+
>>> x = Accessor("0__upper")
|
111
|
+
>>> x.resolve("brad")
|
112
|
+
"B"
|
113
|
+
|
114
|
+
If the context is a dictionary and the accessor-value is a key in it,
|
115
|
+
the value for that key is immediately returned::
|
116
|
+
|
117
|
+
>>> x = Accessor("user__first_name")
|
118
|
+
>>> x.resolve({"user__first_name": "brad"})
|
119
|
+
"brad"
|
120
|
+
|
121
|
+
|
122
|
+
Arguments:
|
123
|
+
context : The root/first object to traverse.
|
124
|
+
safe (bool): Don't call anything with `alters_data = True`
|
125
|
+
quiet (bool): Smother all exceptions and instead return `None`
|
126
|
+
|
127
|
+
Returns:
|
128
|
+
target object
|
129
|
+
|
130
|
+
Raises:
|
131
|
+
TypeError`, `AttributeError`, `KeyError`, `ValueError`
|
132
|
+
(unless `quiet` == `True`)
|
133
|
+
"""
|
134
|
+
# Short-circuit if the context contains a key with the exact name of the accessor,
|
135
|
+
# supporting list-of-dicts data returned from values_list("related_model__field")
|
136
|
+
if isinstance(context, dict) and self in context:
|
137
|
+
return context[self]
|
138
|
+
|
139
|
+
try:
|
140
|
+
current = context
|
141
|
+
for bit in self.bits:
|
142
|
+
try: # dictionary lookup
|
143
|
+
current = current[bit]
|
144
|
+
except (TypeError, AttributeError, KeyError):
|
145
|
+
try: # attribute lookup
|
146
|
+
current = getattr(current, bit)
|
147
|
+
except (TypeError, AttributeError):
|
148
|
+
try: # list-index lookup
|
149
|
+
current = current[int(bit)]
|
150
|
+
except (
|
151
|
+
IndexError, # list index out of range
|
152
|
+
ValueError, # invalid literal for int()
|
153
|
+
KeyError, # dict without `int(bit)` key
|
154
|
+
TypeError, # unsubscriptable object
|
155
|
+
):
|
156
|
+
current_context = (
|
157
|
+
type(current)
|
158
|
+
if isinstance(current, models.Model)
|
159
|
+
else current
|
160
|
+
)
|
161
|
+
|
162
|
+
raise ValueError(
|
163
|
+
self.LOOKUP_ERROR_FMT.format(
|
164
|
+
key=bit, context=current_context, accessor=self
|
165
|
+
)
|
166
|
+
)
|
167
|
+
if callable(current):
|
168
|
+
if safe and getattr(current, "alters_data", False):
|
169
|
+
raise ValueError(
|
170
|
+
self.ALTERS_DATA_ERROR_FMT.format(method=current.__name__)
|
171
|
+
)
|
172
|
+
if not getattr(current, "do_not_call_in_templates", False):
|
173
|
+
current = current(*self.callable_args, **self.callable_kwargs)
|
174
|
+
# Important that we break in None case, or a relationship
|
175
|
+
# spanning across a null-key will raise an exception in the
|
176
|
+
# next iteration, instead of defaulting.
|
177
|
+
if current is None:
|
178
|
+
break
|
179
|
+
return current
|
180
|
+
except Exception:
|
181
|
+
if not quiet:
|
182
|
+
raise
|
183
|
+
|
184
|
+
@property
|
185
|
+
def bits(self):
|
186
|
+
if self == "":
|
187
|
+
return ()
|
188
|
+
return self.split(self.SEPARATOR)
|
189
|
+
|
190
|
+
def get_field(self, model):
|
191
|
+
"""
|
192
|
+
Return the django model field for model in context, following relations.
|
193
|
+
"""
|
194
|
+
if not hasattr(model, "_meta"):
|
195
|
+
return
|
196
|
+
|
197
|
+
field = None
|
198
|
+
for bit in self.bits:
|
199
|
+
try:
|
200
|
+
field = model._meta.get_field(bit)
|
201
|
+
except FieldDoesNotExist:
|
202
|
+
break
|
203
|
+
|
204
|
+
if hasattr(field, "remote_field"):
|
205
|
+
rel = getattr(field, "remote_field", None)
|
206
|
+
model = getattr(rel, "model", model)
|
207
|
+
|
208
|
+
return field
|
209
|
+
|
210
|
+
def penultimate(self, context, quiet=True):
|
211
|
+
"""
|
212
|
+
Split the accessor on the right-most separator ('__'), return a tuple with:
|
213
|
+
- the resolved left part.
|
214
|
+
- the remainder
|
215
|
+
|
216
|
+
Example::
|
217
|
+
|
218
|
+
>>> Accessor("a__b__c").penultimate({"a": {"a": 1, "b": {"c": 2, "d": 4}}})
|
219
|
+
({"c": 2, "d": 4}, "c")
|
220
|
+
|
221
|
+
"""
|
222
|
+
path, _, remainder = self.rpartition(self.SEPARATOR)
|
223
|
+
return BaseAccessor(path).resolve(context, quiet=quiet), remainder
|
224
|
+
|
225
|
+
|
226
|
+
#####
|
227
|
+
# Accessor - cornerstone building block - defines how to resolve a value from a string description.
|
228
|
+
#####
|
229
|
+
class Accessor(BaseAccessor):
|
230
|
+
"""
|
231
|
+
A string describing a path from one object to another via attribute/index accesses.
|
232
|
+
|
233
|
+
Relations are separated by a ``__`` character.
|
234
|
+
|
235
|
+
Usage::
|
236
|
+
|
237
|
+
>>> x = Accessor("__len__")
|
238
|
+
>>> x.resolve("brad")
|
239
|
+
4
|
240
|
+
>>> x = Accessor("0__upper")
|
241
|
+
>>> x.resolve("brad")
|
242
|
+
"B"
|
243
|
+
|
244
|
+
This class is a placeholder in case we want to eliminate dependency on tables2.
|
245
|
+
While we have this dependency, why re-invent the wheel?
|
246
|
+
"""
|
247
|
+
|
248
|
+
def get_label(self, model: models.Model | type[models.Model]) -> str:
|
249
|
+
"""Resolve this accessor using given model as context to return the model field verbose name."""
|
250
|
+
if isinstance(model, models.Model):
|
251
|
+
model = type(model)
|
252
|
+
field = self.get_field(model)
|
253
|
+
if field and field.verbose_name:
|
254
|
+
return field.verbose_name
|
255
|
+
return self.bits[-1].replace("_", " ").title()
|
256
|
+
|
257
|
+
|
258
|
+
class AccessorTransform:
|
259
|
+
"""Provides a transform from standard accessor names to prefixed accessor names"""
|
260
|
+
|
261
|
+
def __init__(self, prefix: str = None):
|
262
|
+
self.prefix = prefix
|
263
|
+
|
264
|
+
def t(self, accessor: str) -> str:
|
265
|
+
"""Transform key, if it exists in this KeyMap"""
|
266
|
+
return f"{self.prefix}__{accessor}" if self.prefix else accessor
|
267
|
+
|
268
|
+
def __call__(self, accessors: str | dict | Iterable) -> str | dict | list:
|
269
|
+
"""Transform all value(s) in accessors and return an object of the same type"""
|
270
|
+
match accessors:
|
271
|
+
case str():
|
272
|
+
return self.t(accessors)
|
273
|
+
case dict():
|
274
|
+
return {
|
275
|
+
self.t(accessor): value for accessor, value in accessors.items()
|
276
|
+
}
|
277
|
+
case _:
|
278
|
+
return [self.t(accessor) for accessor in accessors]
|
279
|
+
|
280
|
+
|
281
|
+
def get_accessor(
|
282
|
+
accessor: Optional[AccessorSpec], accessor_type: Type[AccessorType] = Accessor
|
283
|
+
) -> AccessorType | None:
|
284
|
+
"""helper: return an Accessor instance from the given spec. Returns None if input accessor is None."""
|
285
|
+
return accessor_type(accessor) if isinstance(accessor, str) else accessor
|
nifty_layout/apps.py
ADDED
@@ -0,0 +1,382 @@
|
|
1
|
+
"""Nifty Layout Components
|
2
|
+
|
3
|
+
A micro-framework for defining re-usable layout components from the fields of an object
|
4
|
+
|
5
|
+
- a "Layout Component" has 2 parts;
|
6
|
+
- Component Nodes define the hierarchical structure for a sub-set of object fields.
|
7
|
+
Each node encapsulates how to access, format, and label a field's value and its children.
|
8
|
+
Once bound to a data object (e.g., a model instance) the "bound node" provides a
|
9
|
+
uniform interface to access the value and label for one or more fields in an encapsulated object.
|
10
|
+
- One or more templates that use the interface defined by a Bound Node to render the component into HTML.
|
11
|
+
|
12
|
+
- most layout components expect to work with django Models,
|
13
|
+
but can be adapted to any object type by injecting custom accessors
|
14
|
+
"""
|
15
|
+
|
16
|
+
from __future__ import annotations
|
17
|
+
|
18
|
+
import abc
|
19
|
+
from collections.abc import Iterable, Mapping, Sequence
|
20
|
+
from contextlib import contextmanager
|
21
|
+
from functools import partial
|
22
|
+
from typing import Any, Callable, Hashable, Iterator, Optional, Type, TypeAlias
|
23
|
+
|
24
|
+
from django.db import models
|
25
|
+
from django.template import Template
|
26
|
+
from django.template.loader import get_template
|
27
|
+
|
28
|
+
from .accessor import Accessor, AccessorSpec, AccessorType, get_accessor
|
29
|
+
|
30
|
+
# A Formatter is a function that takes a value returns a formatted value
|
31
|
+
Formatter: TypeAlias = Callable[[Any, ...], str]
|
32
|
+
|
33
|
+
# A Labeller is a function that takes an accessor and returns string label for the attribute
|
34
|
+
Labeller: TypeAlias = Callable[[AccessorSpec, ...], str]
|
35
|
+
|
36
|
+
|
37
|
+
def field_labeller(obj: object | models.Model, accessor: AccessorSpec, **unused) -> str:
|
38
|
+
"""Return a label for the given accessor of given object, which could be a django Model instance.
|
39
|
+
|
40
|
+
If `obj` is a model instance, returns the verbose name of the accessor field, if accessor is a model field.
|
41
|
+
Otherwise, looks for magic attribute / method: `foo_label` or `get_foo_label` on obj.
|
42
|
+
In either case, if no labelling is provided by obj, defines a title-case label based on accessor.
|
43
|
+
"""
|
44
|
+
accessor = Accessor(accessor) if isinstance(accessor, str) else accessor
|
45
|
+
if isinstance(obj, models.Model):
|
46
|
+
return accessor.get_label(obj)
|
47
|
+
# look for "foo_label" attribute or "get_foo_label" method on object, if there isn't one, use the attribute name
|
48
|
+
attr = accessor.bits[-1]
|
49
|
+
label = getattr(obj, f"{attr}_label", getattr(obj, f"get_{attr}_label", None))
|
50
|
+
if callable(label):
|
51
|
+
label = label()
|
52
|
+
label = label if label is not None else attr.replace("_", " ").title()
|
53
|
+
return label
|
54
|
+
|
55
|
+
|
56
|
+
def get_formatted_labeller(
|
57
|
+
labeller, formatter
|
58
|
+
): # todo - add tests or change labelling/formatting logic
|
59
|
+
def formatted_labeller(*args, **kwargs):
|
60
|
+
return formatter(labeller(*args, **kwargs))
|
61
|
+
|
62
|
+
return formatted_labeller
|
63
|
+
|
64
|
+
|
65
|
+
# Examples:
|
66
|
+
|
67
|
+
|
68
|
+
#########
|
69
|
+
# Component Nodes
|
70
|
+
# A Bound Node is an adapter that provides a uniform interface for access to values and labels of encapsulated object
|
71
|
+
# Its unbound Node defines access and formatting rules.
|
72
|
+
#########
|
73
|
+
#########
|
74
|
+
# Important design note: although we are defining the type hierarchy with inheritance here, the behaviours for each
|
75
|
+
# type are assembled using composition.
|
76
|
+
# E.g., define reusable accessor, labeller, formatter to assemble custom node types.
|
77
|
+
# Custom node types are mainly for convenience - a commonly used assembly - think of them as syntactic sugar
|
78
|
+
# The key here is: don't build a deeply nested inheritance hierarchy! Use composition to customize Node behaviours.
|
79
|
+
#########
|
80
|
+
|
81
|
+
|
82
|
+
# A TemplateType can be a Template object, the string template path, or a callable that takes a single parameter.
|
83
|
+
TemplateType: TypeAlias = Template | str | Callable[["BoundNode"], Template]
|
84
|
+
|
85
|
+
|
86
|
+
class BoundNode(Iterable):
|
87
|
+
"""A node that is bound to a piece of data, usually a dict, iterable, object, or model instance
|
88
|
+
Defines the interface for accessing a "value" and "label" from the data, and iterating / accessing its children.
|
89
|
+
|
90
|
+
If node is a composite, all children are also bound to the same data. Iteration is over the bound fixings nodes
|
91
|
+
Optional template is passed through to context, and **may** be used, if parent template is so configured.
|
92
|
+
"""
|
93
|
+
|
94
|
+
def __init__(
|
95
|
+
self, data: Any, node: BaseNode, template: Optional[TemplateType] = None
|
96
|
+
):
|
97
|
+
self.node = node
|
98
|
+
self.data = data
|
99
|
+
self.children = [child.bind(data) for child in node]
|
100
|
+
self.template = self._get_template(template) if template else None
|
101
|
+
|
102
|
+
def _get_template(self, template: TemplateType) -> Template:
|
103
|
+
"""return a template for rendering this node with."""
|
104
|
+
match template:
|
105
|
+
case Template():
|
106
|
+
return template
|
107
|
+
case str():
|
108
|
+
return get_template(template)
|
109
|
+
case _ if callable(template):
|
110
|
+
return template(self)
|
111
|
+
case _:
|
112
|
+
raise ValueError(f"Unexpected value for template: {template}.")
|
113
|
+
|
114
|
+
@property
|
115
|
+
def value(self):
|
116
|
+
"""Fetch the data value from data object and format it."""
|
117
|
+
return self.node.get_formatted_value(self.node.get_raw_value(self.data))
|
118
|
+
|
119
|
+
@property
|
120
|
+
def label(self):
|
121
|
+
"""Fetch the label from the data object."""
|
122
|
+
return self.node.get_label(self.data)
|
123
|
+
|
124
|
+
# Iterable and Sequence interface
|
125
|
+
|
126
|
+
def __getitem__(self, index) -> BoundNode:
|
127
|
+
"""Return the bound fixings node at given 0 <= index < len(self)"""
|
128
|
+
return self.children[index]
|
129
|
+
|
130
|
+
def __len__(self) -> int:
|
131
|
+
return len(self.children)
|
132
|
+
|
133
|
+
def __iter__(self) -> Iterator[BoundNode]:
|
134
|
+
"""Iterate over bound fixings nodes."""
|
135
|
+
return iter(self.children)
|
136
|
+
|
137
|
+
|
138
|
+
class BoundSequenceNode(BoundNode, Sequence):
|
139
|
+
"""A BoundNode representing a linear sequence of bound nodes."""
|
140
|
+
|
141
|
+
def __init__(
|
142
|
+
self,
|
143
|
+
data: Any,
|
144
|
+
node: SequenceCompositeNode,
|
145
|
+
template: Optional[TemplateType] = None,
|
146
|
+
):
|
147
|
+
"""Narrowing Types only - exactly same as a BoundNode"""
|
148
|
+
self.node = node # only to please the type checker, which otherwise thinks node is a BaseNode
|
149
|
+
super().__init__(data, node, template)
|
150
|
+
|
151
|
+
|
152
|
+
class BoundDictNode(BoundNode, Mapping):
|
153
|
+
"""A BoundNode representing a mapping of keys to bound nodes."""
|
154
|
+
|
155
|
+
def __init__(
|
156
|
+
self,
|
157
|
+
data: Any,
|
158
|
+
node: DictCompositeNode,
|
159
|
+
template: Optional[TemplateType] = None,
|
160
|
+
):
|
161
|
+
self.node = node # only to please the type checker, which otherwise thinks node is a BaseNode
|
162
|
+
with node.as_sequence():
|
163
|
+
super().__init__(data, node, template)
|
164
|
+
self.mapping = {k: v for k, v in zip(self.node.mapping.keys(), self.children)}
|
165
|
+
|
166
|
+
@contextmanager
|
167
|
+
def as_sequence(self):
|
168
|
+
"""A context manager allowing direct iteration and numeric indexing on the fixings nodes"""
|
169
|
+
orig_class = type(self)
|
170
|
+
try:
|
171
|
+
self.__class__ = BoundSequenceNode
|
172
|
+
yield self # Hand back control to the with-block
|
173
|
+
finally:
|
174
|
+
self.__class__ = orig_class
|
175
|
+
|
176
|
+
# Mapping interface
|
177
|
+
def __iter__(self) -> Iterator[Hashable]:
|
178
|
+
"""Iterate over the map's ordered key set."""
|
179
|
+
return iter(self.mapping)
|
180
|
+
|
181
|
+
def __getitem__(self, key: Hashable) -> BoundNode:
|
182
|
+
"""Lookup bound fixings node in dict by key"""
|
183
|
+
return self.mapping[key]
|
184
|
+
|
185
|
+
|
186
|
+
##############
|
187
|
+
# Unbound Nodes are a static description of the logic to access, format, and label a value.
|
188
|
+
# The intent is to define a small, declarative syntax.
|
189
|
+
# `bind` a Node to some data (and optionally a template) to get a BoundNode suitable for rendering.
|
190
|
+
#############
|
191
|
+
|
192
|
+
|
193
|
+
class BaseNode(Iterable, abc.ABC):
|
194
|
+
"""Abstract Base class for all types of Spiffy Component Nodes"""
|
195
|
+
|
196
|
+
default_formatter: Formatter | bool = False # default does no formatting
|
197
|
+
default_labeller: Labeller | str | bool = False # default has no label
|
198
|
+
bound_node_type: type[BoundNode] = (
|
199
|
+
BoundNode # data type returned by `bind` operation
|
200
|
+
)
|
201
|
+
|
202
|
+
def __init__(
|
203
|
+
self,
|
204
|
+
formatter: Optional[Formatter | bool] = None,
|
205
|
+
labeller: Optional[Labeller | str | bool] = None,
|
206
|
+
template: Optional[TemplateType] = None,
|
207
|
+
):
|
208
|
+
"""Parameters passed as None default to Node type's default values."""
|
209
|
+
self.formatter = (
|
210
|
+
formatter if formatter is not None else type(self).default_formatter
|
211
|
+
)
|
212
|
+
self.labeller = (
|
213
|
+
labeller if labeller is not None else type(self).default_labeller
|
214
|
+
)
|
215
|
+
self.template = template
|
216
|
+
|
217
|
+
def bind(self, data: Any, template: Optional[TemplateType] = None) -> BoundNode:
|
218
|
+
"""Return a BoundNode that binds the given data (and template) to this node"""
|
219
|
+
template = template or self.template
|
220
|
+
return self.bound_node_type(data=data, node=self, template=template)
|
221
|
+
|
222
|
+
# Value / Label extraction API
|
223
|
+
|
224
|
+
def get_raw_value(self, data: Any) -> Any:
|
225
|
+
"""Extract and return the raw value for this Node from the given data object"""
|
226
|
+
return data
|
227
|
+
|
228
|
+
def get_formatted_value(self, value: Any) -> str | Any:
|
229
|
+
"""Return formatted version of given raw value"""
|
230
|
+
return self.formatter(value) if self.formatter else value
|
231
|
+
|
232
|
+
def get_label(self, data: Any) -> str | None:
|
233
|
+
"""Return a label for the given data object"""
|
234
|
+
return (
|
235
|
+
self.labeller(data)
|
236
|
+
if callable(self.labeller)
|
237
|
+
else str(self.labeller) if self.labeller else None
|
238
|
+
)
|
239
|
+
|
240
|
+
# Iterable interface
|
241
|
+
|
242
|
+
def __iter__(self):
|
243
|
+
return iter([])
|
244
|
+
|
245
|
+
|
246
|
+
# Concrete Node types fall in 2 basic classes: Atomic and Composite
|
247
|
+
|
248
|
+
|
249
|
+
class AtomicNode(BaseNode, abc.ABC):
|
250
|
+
"""Abstract Base class for an atomic Components (no children)"""
|
251
|
+
|
252
|
+
pass
|
253
|
+
|
254
|
+
|
255
|
+
class FieldNode(AtomicNode):
|
256
|
+
"""Basic atomic node for encapsulating access to the data for a single field using a Accessor"""
|
257
|
+
|
258
|
+
default_accessor_type: Type[AccessorType] = (
|
259
|
+
Accessor # The Accessor type used to wrap naked accessor spec.
|
260
|
+
)
|
261
|
+
default_labeller: Labeller | str | bool = field_labeller
|
262
|
+
|
263
|
+
def __init__(
|
264
|
+
self,
|
265
|
+
accessor: AccessorSpec,
|
266
|
+
formatter: Optional[Formatter | bool] = None,
|
267
|
+
labeller: Optional[Labeller | str | bool] = None,
|
268
|
+
template: Optional[TemplateType] = None,
|
269
|
+
):
|
270
|
+
"""Parameters passed as None default to Node type's default values."""
|
271
|
+
labeller = (
|
272
|
+
labeller
|
273
|
+
if labeller is not None
|
274
|
+
else partial(type(self).default_labeller, accessor=accessor)
|
275
|
+
)
|
276
|
+
super().__init__(formatter=formatter, labeller=labeller, template=template)
|
277
|
+
self.accessor = get_accessor(accessor, type(self).default_accessor_type)
|
278
|
+
|
279
|
+
def get_raw_value(self, data: Any) -> Any:
|
280
|
+
"""Extract and return the raw value for this Node from the given data object"""
|
281
|
+
return self.accessor.resolve(data)
|
282
|
+
|
283
|
+
|
284
|
+
# define extended FieldNodes for common use-cases, assembled with custom labellers and formatters
|
285
|
+
# E.g., CurrencyFieldNode, DecimalFieldNode, DateField, DateTimeField,...
|
286
|
+
|
287
|
+
|
288
|
+
class CompositeNode(BaseNode, abc.ABC):
|
289
|
+
"""Abstract Base class for a composite iterable component with zero or more ordered children"""
|
290
|
+
|
291
|
+
default_child_node_type: type[FieldNode] = (
|
292
|
+
FieldNode # "naked" children are wrapped in this Node type.
|
293
|
+
)
|
294
|
+
|
295
|
+
def __init__(
|
296
|
+
self,
|
297
|
+
*children: BaseNode | AccessorSpec,
|
298
|
+
formatter: Optional[
|
299
|
+
Formatter | bool
|
300
|
+
] = None, # default: use class default_formatter
|
301
|
+
labeller: Optional[
|
302
|
+
Labeller | str | bool
|
303
|
+
] = None, # default: use class default_labeller
|
304
|
+
child_node_type: Optional[type[BaseNode]] = None,
|
305
|
+
**attributes: str,
|
306
|
+
):
|
307
|
+
super().__init__(formatter=formatter, labeller=labeller, **attributes)
|
308
|
+
self.child_node_type = child_node_type or type(self).default_child_node_type
|
309
|
+
self.children = [self.wrap_child(child) for child in children]
|
310
|
+
|
311
|
+
def wrap_child(self, child: BaseNode | AccessorSpec) -> FieldNode:
|
312
|
+
return child if isinstance(child, BaseNode) else self.child_node_type(child)
|
313
|
+
|
314
|
+
# Iterable interface and Sequence interface
|
315
|
+
def __iter__(self):
|
316
|
+
return iter(self.children)
|
317
|
+
|
318
|
+
def __getitem__(self, index: int) -> FieldNode:
|
319
|
+
return self.children[index]
|
320
|
+
|
321
|
+
def __len__(self) -> int:
|
322
|
+
return len(self.children)
|
323
|
+
|
324
|
+
|
325
|
+
class SequenceCompositeNode(CompositeNode, Sequence):
|
326
|
+
"""A Composite node that behaves as an iterable of ordered fixings nodes"""
|
327
|
+
|
328
|
+
bound_node_type = BoundSequenceNode
|
329
|
+
|
330
|
+
|
331
|
+
# A MappingElement can be defined by any dict-like object or by a 2-tuple (key, value)
|
332
|
+
# When used to initialize a DictCompositeNode, all MappingElements are combined, in order, to form a single dict.
|
333
|
+
MappingElement: TypeAlias = (
|
334
|
+
Mapping[Hashable, BaseNode | AccessorSpec]
|
335
|
+
| tuple[Hashable, BaseNode | AccessorSpec]
|
336
|
+
)
|
337
|
+
|
338
|
+
|
339
|
+
class DictCompositeNode(CompositeNode, Mapping):
|
340
|
+
"""A Composite that allows fixings nodes to be looked up with a key. Caution: iteration is over keys not values!"""
|
341
|
+
|
342
|
+
bound_node_type = BoundDictNode
|
343
|
+
|
344
|
+
def __init__(self, *children: MappingElement, **kwargs):
|
345
|
+
"""Children can be looked up by key, and iteration is over keys. dict semantics."""
|
346
|
+
mappings = (
|
347
|
+
child if hasattr(child, "items") else dict((child,)) for child in children
|
348
|
+
)
|
349
|
+
mapping = {k: v for mapping in mappings for k, v in mapping.items()}
|
350
|
+
|
351
|
+
super().__init__(*mapping.values(), **kwargs)
|
352
|
+
assert len(mapping.keys()) == len(self.children)
|
353
|
+
self.mapping = {k: v for k, v in zip(mapping.keys(), self.children)}
|
354
|
+
|
355
|
+
@contextmanager
|
356
|
+
def as_sequence(self):
|
357
|
+
"""A context manager allowing direct iteration and numeric indexing on the fixings nodes"""
|
358
|
+
orig_class = type(self)
|
359
|
+
try:
|
360
|
+
self.__class__ = SequenceCompositeNode
|
361
|
+
yield self # Hand back control to the with-block
|
362
|
+
finally:
|
363
|
+
self.__class__ = orig_class
|
364
|
+
|
365
|
+
# Mapping interface
|
366
|
+
def __iter__(self) -> Iterator[Hashable]:
|
367
|
+
"""Iterate over the map's ordered key set."""
|
368
|
+
return iter(self.mapping)
|
369
|
+
|
370
|
+
def __getitem__(self, key: Hashable) -> FieldNode:
|
371
|
+
"""Lookup fixings node in map by key"""
|
372
|
+
return self.mapping[key]
|
373
|
+
|
374
|
+
|
375
|
+
class Seq(SequenceCompositeNode):
|
376
|
+
|
377
|
+
def __init__(self, *children: BaseNode | AccessorSpec | Iterable, **kwargs):
|
378
|
+
if len(children) == 1 and isinstance(children[0], (list, tuple, set)):
|
379
|
+
# Instead of wrapping the iterable itself in `child_node_class`, we wrap each element individually
|
380
|
+
# enables us to use `child_node_class=Seq` to create a nested sequence
|
381
|
+
children = children[0]
|
382
|
+
super().__init__(*children, **kwargs)
|