Skip to content

Commit 496967e

Browse files
committed
Add settings modifiers
refs #95
1 parent 64b0494 commit 496967e

File tree

3 files changed

+760
-7
lines changed

3 files changed

+760
-7
lines changed

nanodjango/app.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from django.views import View
2424

2525
from . import app_meta, hookspecs
26+
from .conf import Conf
2627
from .defer import defer
2728
from .exceptions import ConfigurationError, UsageError
2829
from .templatetags import TemplateTagLibrary
@@ -64,6 +65,9 @@ def index(request):
6465
return "Hello World"
6566
"""
6667

68+
# Class attribute: Conf helper for modifying settings
69+
conf = Conf
70+
6771
# Class attribute: list of plugin modules to load - set by click
6872
_plugins = []
6973

@@ -163,13 +167,8 @@ def _config(self, _settings):
163167
self.settings = settings
164168

165169
# Update Django settings with ours
166-
# If a value is callable and the setting already exists, treat it as a callback
167-
# that receives the current value and returns the new value. This allows users
168-
# to modify default settings (e.g. MIDDLEWARE=lambda m: [MyMiddleware] + m)
169-
for key, value in _settings.items():
170-
if callable(value) and hasattr(settings, key):
171-
value = value(getattr(settings, key))
172-
setattr(settings, key, value)
170+
conf = Conf(**_settings)
171+
conf(settings)
173172

174173
# Set WHITENOISE_ROOT if public dir exists
175174
# Do it this way instead of setting WHITENOISE_ROOT directly, because if the dir

nanodjango/conf.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
"""
2+
Helpers to modify settings without defining custom callables
3+
4+
Usage:
5+
6+
from nanodjango import Setting
7+
8+
app = Django(
9+
# Collect a setting from the environment (os.getenv convenience wrapper)
10+
BAR=Django.conf.env("BAR", "default_value"),
11+
12+
# Add values to a list
13+
INSTALLED_APPS=Django.conf.append("django_tagulous"),
14+
15+
# Set dict items
16+
STORAGES=Django.conf(archive={...}), # STORAGES["archive"] = {...}
17+
18+
# Remove a value from a list (or a key from a dict)
19+
SOME_LIST=Django.conf.remove("foo") # removes the value "foo"
20+
SOME_DICT=Django.conf.remove("bar") # removes the key "bar"
21+
22+
# Pass multiple modifiers into Django.conf() to chain them
23+
OTHER_LIST=Django.conf(
24+
Django.conf.append("foo"),
25+
Django.conf.remove("bar"),
26+
),
27+
28+
# Nest Django.conf to modify complex objects
29+
# * modify a list index by passing the keyword _INDEX, eg first item is _0
30+
# * modify dict settings by passing the key as a a keyword
31+
TEMPLATES = Django.conf(
32+
_0=Django.conf(
33+
OPTIONS=Django.conf(
34+
context_processors=Django.conf.append(
35+
"myscript.template_context",
36+
),
37+
),
38+
),
39+
)
40+
41+
# But this can be written more neatly using the __ syntax (expanded internally)
42+
TEMPLATES__0__OPTIONS__context_processors = Django.conf.append(
43+
"myscript.template_context",
44+
)
45+
)
46+
"""
47+
48+
from __future__ import annotations
49+
50+
from os import getenv
51+
from typing import Any, Callable
52+
53+
54+
class ModifierError(Exception):
55+
"""
56+
Exception raised when a modifier fails to apply to a setting.
57+
58+
Tracks the path through nested settings to provide helpful error messages.
59+
"""
60+
61+
def __init__(self, msg: str, attr: Any = None):
62+
self.msg = msg
63+
self.path: list[Any] = []
64+
if attr is not None:
65+
self.path.append(attr)
66+
super().__init__(self._format_message())
67+
68+
def add_parent(self, parent: Any) -> "ModifierError":
69+
"""
70+
Add a parent to the beginning of the path.
71+
72+
This is called as the exception bubbles up through nested Conf calls,
73+
building a path like: TEMPLATES[0]['OPTIONS']['context_processors']
74+
"""
75+
self.path.insert(0, parent)
76+
# Update the exception message with the new path
77+
self.args = (self._format_message(),)
78+
return self
79+
80+
def _format_message(self) -> str:
81+
"""Format the error message with the full path to the problematic setting"""
82+
if not self.path:
83+
return self.msg
84+
85+
# Build path string like: TEMPLATES[0]['OPTIONS']['context_processors']
86+
path_parts = []
87+
for i, part in enumerate(self.path):
88+
if i == 0:
89+
# First part is the root setting name
90+
path_parts.append(str(part))
91+
elif isinstance(part, int):
92+
# List index
93+
path_parts.append(f"[{part}]")
94+
else:
95+
# Dict key
96+
path_parts.append(f"['{part}']")
97+
98+
path_str = "".join(path_parts)
99+
return f"{path_str}: {self.msg}"
100+
101+
102+
class Modifier:
103+
"""
104+
Modify an object in place
105+
"""
106+
107+
def __call__(self, obj: Any) -> Any:
108+
return obj
109+
110+
111+
class Env:
112+
"""
113+
Collect an environment variable (convenience wrapper for os.getenv)
114+
"""
115+
116+
def __init__(self, name: str, default: Any = None):
117+
self.name = name
118+
self.default = default
119+
120+
def __call__(self, obj: Any) -> Any:
121+
return getenv(self.name, self.default)
122+
123+
124+
class Append:
125+
"""
126+
Append one or more values to a list
127+
128+
If the target is a tuple, it will be converted to a list.
129+
"""
130+
131+
def __init__(self, *values: Any):
132+
self.values = values
133+
134+
def __call__(self, obj: Any) -> Any:
135+
if isinstance(obj, tuple):
136+
obj = list(obj)
137+
if not callable(getattr(obj, "append", None)):
138+
raise ModifierError(f"Cannot append to a {type(obj).__name__}")
139+
obj.extend(self.values)
140+
return obj
141+
142+
143+
class Remove:
144+
"""
145+
Remove a value from a list, or a key from a dict
146+
"""
147+
148+
def __init__(self, value: Any):
149+
self.value = value
150+
151+
def __call__(self, obj: Any) -> Any:
152+
if hasattr(obj, "remove"):
153+
obj.remove(self.value)
154+
elif isinstance(obj, dict):
155+
obj.pop(self.value)
156+
else:
157+
raise ModifierError(f"Cannot remove from a {type(obj).__name__}")
158+
return obj
159+
160+
161+
def expand_dunder_path(path: str, value: Any) -> tuple[str, Any]:
162+
parts = path.split("__")
163+
root_conf = conf = Conf()
164+
# Skip the first part - it's the root key we return
165+
for i, part in enumerate(parts[1:]):
166+
if part.isdigit():
167+
part = f"_{part}"
168+
169+
# If this is the last part, set the value directly
170+
if i == len(parts) - 2: # -2 because we sliced off the first part
171+
conf.kwops[part] = value
172+
else:
173+
new_conf = Conf()
174+
conf.kwops[part] = new_conf
175+
conf = new_conf
176+
177+
return parts[0], root_conf
178+
179+
180+
class Conf:
181+
"""
182+
Helper to modify a setting
183+
"""
184+
185+
ops: list[Modifier | Callable]
186+
kwops: dict[str, Modifier | Callable]
187+
188+
# Modifiers
189+
env = Env
190+
append = Append
191+
remove = Remove
192+
193+
def __init__(
194+
self, *ops: Conf | Modifier | Callable, **kwops: Conf | Modifier | Callable
195+
):
196+
self.ops = list(ops)
197+
self.kwops = kwops
198+
199+
def __call__(self, setting: Any) -> Any:
200+
for op in self.ops:
201+
setting = op(setting)
202+
203+
for attr, op in self.kwops.items():
204+
# Expand attr dunder paths
205+
if "__" in attr:
206+
attr, op = expand_dunder_path(attr, op)
207+
208+
if callable(op):
209+
# We have a callable, Conf or Modifier
210+
if attr.startswith("_") and attr[1:].isdigit():
211+
# It's a list index in format _N - expecting a list
212+
try:
213+
attr_int = int(attr[1:])
214+
except Exception as e:
215+
raise ModifierError("not a list index", attr)
216+
217+
if not isinstance(setting, list):
218+
raise ModifierError("not a list", attr_int)
219+
220+
val = setting[attr_int]
221+
222+
try:
223+
setting[attr_int] = op(val)
224+
except ModifierError as e:
225+
e.add_parent(attr_int)
226+
raise
227+
228+
else:
229+
# Top level will be Django settings object
230+
# Check if we can use getattr/setattr (settings-like object)
231+
if hasattr(setting, "__getattr__") and hasattr(
232+
setting, "__setattr__"
233+
):
234+
val = getattr(setting, attr, None)
235+
236+
# If setting doesn't exist and op is a plain callable (not Modifier/Conf),
237+
# set it as-is (backward compatibility for callables like WHITENOISE_ADD_HEADERS_FUNCTION)
238+
if val is None and not isinstance(op, (Modifier, Conf)):
239+
setattr(setting, attr, op)
240+
continue
241+
242+
try:
243+
setattr(setting, attr, op(val))
244+
except ModifierError as e:
245+
e.add_parent(attr)
246+
raise
247+
continue
248+
249+
# Otherwise expecting a dict
250+
if not isinstance(setting, dict):
251+
raise ModifierError("not a dict", attr)
252+
val = setting.get(attr)
253+
254+
# If dict key doesn't exist and op is a plain callable (not Modifier/Conf),
255+
# set it as-is
256+
if val is None and not isinstance(op, (Modifier, Conf)):
257+
setting[attr] = op
258+
continue
259+
260+
try:
261+
setting[attr] = op(val)
262+
except ModifierError as e:
263+
e.add_parent(attr)
264+
raise
265+
266+
else:
267+
# Not callable, op is a value
268+
if hasattr(setting, "__getattr__") and hasattr(setting, "__setattr__"):
269+
setattr(setting, attr, op)
270+
271+
elif isinstance(setting, (dict, list)):
272+
setting[attr] = op
273+
274+
else:
275+
raise ModifierError("not a dict or list", attr)
276+
277+
return setting

0 commit comments

Comments
 (0)