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.
@@ -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
+ [![PyPI Version](https://img.shields.io/pypi/v/nifty_layout.svg)](https://pypi.python.org/pypi/django-nifty-layout) ![Test with tox](https://github.com/powderflask/django-nifty-layout/actions/workflows/tox.yaml/badge.svg) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ manage.py = nifty_layout:django_manage
@@ -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
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -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,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class NiftyLayoutAppConfig(AppConfig):
5
+ name = "nifty_layout"
@@ -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)