velocity-python 0.3.1__py3-none-any.whl → 0.3.2__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.
- velocity/__init__.py +1 -1
- velocity/aws/settings.py +1104 -0
- {velocity_python-0.3.1.dist-info → velocity_python-0.3.2.dist-info}/METADATA +1 -1
- {velocity_python-0.3.1.dist-info → velocity_python-0.3.2.dist-info}/RECORD +8 -7
- {velocity_python-0.3.1.dist-info → velocity_python-0.3.2.dist-info}/WHEEL +0 -0
- {velocity_python-0.3.1.dist-info → velocity_python-0.3.2.dist-info}/entry_points.txt +0 -0
- {velocity_python-0.3.1.dist-info → velocity_python-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.3.1.dist-info → velocity_python-0.3.2.dist-info}/top_level.txt +0 -0
velocity/__init__.py
CHANGED
velocity/aws/settings.py
ADDED
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
"""Settings catalogs: declared, validated, resolved through levels, audited.
|
|
2
|
+
|
|
3
|
+
An application describes its business settings once, in a **catalog** (a
|
|
4
|
+
dict or JSON document), and velocity does the generic work around it:
|
|
5
|
+
|
|
6
|
+
- :class:`Catalog` checks the catalog's shape when it loads;
|
|
7
|
+
- :meth:`Catalog.validate` checks and normalizes one value by its type;
|
|
8
|
+
- :func:`resolve` / :func:`resolve_all` walk a **chain of levels** (most
|
|
9
|
+
specific first, e.g. form → organization → platform default) and return
|
|
10
|
+
each value together with the level it came from;
|
|
11
|
+
- :func:`apply` sets or unsets values at one level, validated, and writes one
|
|
12
|
+
history row per call in the shared change-history table (``audit_log``,
|
|
13
|
+
:mod:`velocity.db.audit`), with the actor and the time;
|
|
14
|
+
- :func:`history` reads those rows back for one owner and one setting.
|
|
15
|
+
|
|
16
|
+
Catalog shape::
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
"version": 1,
|
|
20
|
+
"groups": [{"id": "portal", "label": "Portal"}],
|
|
21
|
+
"settings": [
|
|
22
|
+
{"key": "portal.maps", "label": "Maps", "group": "portal",
|
|
23
|
+
"type": "bool", "default": {"value": false},
|
|
24
|
+
"levels": ["organization"], "visibility": "internal",
|
|
25
|
+
"help": "Shows the Maps screen.",
|
|
26
|
+
"storage": {"column": "show_maps"}},
|
|
27
|
+
{"key": "captcha.threshold", "label": "Score threshold",
|
|
28
|
+
"group": "portal", "type": "number", "min": 0, "max": 1, "step": 0.1,
|
|
29
|
+
"default": {"flag": "default_captcha_threshold"},
|
|
30
|
+
"levels": ["platform", "organization", "form"]}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
- ``key``: dotted lower-case segments (``[a-z][a-z0-9_]*``), unique.
|
|
35
|
+
- ``type``: ``bool``, ``choice`` (``choices``: ``[{"value", "label"}]``),
|
|
36
|
+
``number`` / ``integer`` (optional ``min``, ``max``, ``step``), ``text``
|
|
37
|
+
(optional ``max_length``, ``pattern``), ``time_zone`` (an IANA name) or
|
|
38
|
+
``json``. An application adds its own types with ``validators={name:
|
|
39
|
+
fn(setting, value) -> normalized}`` (a ``ValueError`` refuses the value).
|
|
40
|
+
- ``default``: ``{"flag": <column>}`` (the platform row in
|
|
41
|
+
``sys_feature_flags``, read by :func:`platform_level`) or ``{"value": v}``.
|
|
42
|
+
No default means "no platform value": a value must be set somewhere.
|
|
43
|
+
- ``levels``: the level names that may hold a value for this setting.
|
|
44
|
+
- ``visibility``, ``help``, ``storage`` and any other key are carried as-is
|
|
45
|
+
(``Setting.extra`` for unknown keys) for the application and the UI.
|
|
46
|
+
|
|
47
|
+
A **level** (:class:`Level`) is a name and a reader ``read(tx, settings) ->
|
|
48
|
+
{key: value}`` (``None``, ``""`` or a missing key mean "not set here"), plus,
|
|
49
|
+
for a level that can be written, ``write(tx, setting, value)`` and
|
|
50
|
+
``unset(tx, setting)``. :class:`ColumnLevel` stores values in columns of one
|
|
51
|
+
owner row (``storage.column``), :class:`RowLevel` in a key/value table (one
|
|
52
|
+
row per owner and key, JSON value); an application can pass its own.
|
|
53
|
+
|
|
54
|
+
Nothing here knows any application's business: the catalog, the level names
|
|
55
|
+
and the owners are the caller's.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import datetime
|
|
61
|
+
import json
|
|
62
|
+
import math
|
|
63
|
+
import re
|
|
64
|
+
from dataclasses import dataclass, field
|
|
65
|
+
from decimal import Decimal, InvalidOperation
|
|
66
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence
|
|
67
|
+
|
|
68
|
+
from velocity.aws.handlers.exceptions import AlertError
|
|
69
|
+
|
|
70
|
+
AUDIT_TABLE = "audit_log"
|
|
71
|
+
# The operation written on a settings history row.
|
|
72
|
+
OPERATION = "settings"
|
|
73
|
+
|
|
74
|
+
TYPES = ("bool", "choice", "number", "integer", "text", "time_zone", "json")
|
|
75
|
+
VISIBILITIES = ("internal", "owner", "member", "public")
|
|
76
|
+
SET = "set"
|
|
77
|
+
UNSET = "unset"
|
|
78
|
+
|
|
79
|
+
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
|
|
80
|
+
_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
81
|
+
_COLUMN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
82
|
+
MAX_KEY_LENGTH = 120
|
|
83
|
+
MAX_JSON_BYTES = 64 * 1024
|
|
84
|
+
_TRUE = {"true", "t", "yes", "y", "1", "on"}
|
|
85
|
+
_FALSE = {"false", "f", "no", "n", "0", "off"}
|
|
86
|
+
_KNOWN = {
|
|
87
|
+
"key",
|
|
88
|
+
"label",
|
|
89
|
+
"group",
|
|
90
|
+
"type",
|
|
91
|
+
"choices",
|
|
92
|
+
"min",
|
|
93
|
+
"max",
|
|
94
|
+
"step",
|
|
95
|
+
"max_length",
|
|
96
|
+
"pattern",
|
|
97
|
+
"default",
|
|
98
|
+
"levels",
|
|
99
|
+
"visibility",
|
|
100
|
+
"help",
|
|
101
|
+
"storage",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SettingsError(AlertError, ValueError):
|
|
106
|
+
"""A catalog, value or operation that is refused (HTTP 400 from a handler)."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SettingMissingError(SettingsError):
|
|
110
|
+
"""A required setting has no value at any level."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _empty(value) -> bool:
|
|
114
|
+
return value is None or (isinstance(value, str) and not value.strip())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── catalog ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Setting:
|
|
122
|
+
key: str
|
|
123
|
+
label: str
|
|
124
|
+
group: str
|
|
125
|
+
type: str
|
|
126
|
+
levels: tuple
|
|
127
|
+
choices: tuple = ()
|
|
128
|
+
min: Optional[float] = None
|
|
129
|
+
max: Optional[float] = None
|
|
130
|
+
step: Optional[float] = None
|
|
131
|
+
max_length: Optional[int] = None
|
|
132
|
+
pattern: Optional[str] = None
|
|
133
|
+
default: Optional[dict] = None
|
|
134
|
+
visibility: str = "internal"
|
|
135
|
+
help: str = ""
|
|
136
|
+
storage: dict = field(default_factory=dict)
|
|
137
|
+
extra: dict = field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def default_flag(self) -> Optional[str]:
|
|
141
|
+
return (self.default or {}).get("flag")
|
|
142
|
+
|
|
143
|
+
def choice_values(self) -> list:
|
|
144
|
+
return [c["value"] for c in self.choices]
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> dict:
|
|
147
|
+
"""The JSON form a UI renders (catalog order, every declared key)."""
|
|
148
|
+
out = {
|
|
149
|
+
"key": self.key,
|
|
150
|
+
"label": self.label,
|
|
151
|
+
"group": self.group,
|
|
152
|
+
"type": self.type,
|
|
153
|
+
"levels": list(self.levels),
|
|
154
|
+
"visibility": self.visibility,
|
|
155
|
+
"help": self.help,
|
|
156
|
+
}
|
|
157
|
+
if self.choices:
|
|
158
|
+
out["choices"] = [dict(c) for c in self.choices]
|
|
159
|
+
for name in ("min", "max", "step", "max_length", "pattern"):
|
|
160
|
+
if getattr(self, name) is not None:
|
|
161
|
+
out[name] = getattr(self, name)
|
|
162
|
+
if self.default is not None:
|
|
163
|
+
out["default"] = dict(self.default)
|
|
164
|
+
if self.storage:
|
|
165
|
+
out["storage"] = dict(self.storage)
|
|
166
|
+
out.update(self.extra)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _number(value, name, key):
|
|
171
|
+
if value is None:
|
|
172
|
+
return None
|
|
173
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
174
|
+
raise SettingsError(f"setting {key!r}: {name} must be a number")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_setting(raw, groups, custom_types) -> Setting:
|
|
179
|
+
if not isinstance(raw, Mapping):
|
|
180
|
+
raise SettingsError("each setting must be an object")
|
|
181
|
+
key = raw.get("key")
|
|
182
|
+
if not isinstance(key, str) or len(key) > MAX_KEY_LENGTH or not _KEY_RE.match(key):
|
|
183
|
+
raise SettingsError(f"invalid setting key {key!r}")
|
|
184
|
+
stype = raw.get("type")
|
|
185
|
+
if stype not in TYPES and stype not in custom_types:
|
|
186
|
+
raise SettingsError(f"setting {key!r}: unknown type {stype!r}")
|
|
187
|
+
group = raw.get("group")
|
|
188
|
+
if group not in groups:
|
|
189
|
+
raise SettingsError(f"setting {key!r}: unknown group {group!r}")
|
|
190
|
+
label = raw.get("label")
|
|
191
|
+
if not isinstance(label, str) or not label.strip():
|
|
192
|
+
raise SettingsError(f"setting {key!r}: a label is required")
|
|
193
|
+
levels = raw.get("levels")
|
|
194
|
+
if (
|
|
195
|
+
not isinstance(levels, (list, tuple))
|
|
196
|
+
or not levels
|
|
197
|
+
or not all(isinstance(lv, str) and _ID_RE.match(lv) for lv in levels)
|
|
198
|
+
or len(set(levels)) != len(levels)
|
|
199
|
+
):
|
|
200
|
+
raise SettingsError(f"setting {key!r}: levels must be distinct level names")
|
|
201
|
+
choices = raw.get("choices") or ()
|
|
202
|
+
if stype == "choice":
|
|
203
|
+
if not isinstance(choices, (list, tuple)) or not choices:
|
|
204
|
+
raise SettingsError(f"setting {key!r}: a choice needs choices")
|
|
205
|
+
parsed = []
|
|
206
|
+
for choice in choices:
|
|
207
|
+
if isinstance(choice, (str, int, float)) and not isinstance(choice, bool):
|
|
208
|
+
choice = {"value": choice, "label": str(choice)}
|
|
209
|
+
if not isinstance(choice, Mapping) or "value" not in choice:
|
|
210
|
+
raise SettingsError(f"setting {key!r}: invalid choice {choice!r}")
|
|
211
|
+
parsed.append(
|
|
212
|
+
{
|
|
213
|
+
"value": choice["value"],
|
|
214
|
+
"label": str(choice.get("label", choice["value"])),
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
values = [c["value"] for c in parsed]
|
|
218
|
+
if len(set(map(json.dumps, values))) != len(values):
|
|
219
|
+
raise SettingsError(f"setting {key!r}: duplicate choice values")
|
|
220
|
+
choices = tuple(parsed)
|
|
221
|
+
elif choices:
|
|
222
|
+
raise SettingsError(f"setting {key!r}: only a choice has choices")
|
|
223
|
+
default = raw.get("default")
|
|
224
|
+
if default is not None:
|
|
225
|
+
if not isinstance(default, Mapping) or len(default) != 1:
|
|
226
|
+
raise SettingsError(
|
|
227
|
+
f"setting {key!r}: default must be {{'flag': column}} or {{'value': v}}"
|
|
228
|
+
)
|
|
229
|
+
if "flag" in default:
|
|
230
|
+
if not isinstance(default["flag"], str) or not _COLUMN_RE.match(
|
|
231
|
+
default["flag"]
|
|
232
|
+
):
|
|
233
|
+
raise SettingsError(f"setting {key!r}: invalid default flag")
|
|
234
|
+
elif "value" not in default:
|
|
235
|
+
raise SettingsError(
|
|
236
|
+
f"setting {key!r}: default must be {{'flag': column}} or {{'value': v}}"
|
|
237
|
+
)
|
|
238
|
+
default = dict(default)
|
|
239
|
+
visibility = raw.get("visibility", "internal")
|
|
240
|
+
if visibility not in VISIBILITIES:
|
|
241
|
+
raise SettingsError(f"setting {key!r}: unknown visibility {visibility!r}")
|
|
242
|
+
storage = raw.get("storage") or {}
|
|
243
|
+
if not isinstance(storage, Mapping):
|
|
244
|
+
raise SettingsError(f"setting {key!r}: storage must be an object")
|
|
245
|
+
max_length = raw.get("max_length")
|
|
246
|
+
if max_length is not None and (
|
|
247
|
+
isinstance(max_length, bool)
|
|
248
|
+
or not isinstance(max_length, int)
|
|
249
|
+
or max_length < 1
|
|
250
|
+
):
|
|
251
|
+
raise SettingsError(f"setting {key!r}: max_length must be a positive integer")
|
|
252
|
+
pattern = raw.get("pattern")
|
|
253
|
+
if pattern is not None:
|
|
254
|
+
try:
|
|
255
|
+
re.compile(pattern)
|
|
256
|
+
except (re.error, TypeError):
|
|
257
|
+
raise SettingsError(f"setting {key!r}: invalid pattern") from None
|
|
258
|
+
help_text = raw.get("help", "")
|
|
259
|
+
if not isinstance(help_text, str):
|
|
260
|
+
raise SettingsError(f"setting {key!r}: help must be text")
|
|
261
|
+
setting = Setting(
|
|
262
|
+
key=key,
|
|
263
|
+
label=label.strip(),
|
|
264
|
+
group=group,
|
|
265
|
+
type=stype,
|
|
266
|
+
levels=tuple(levels),
|
|
267
|
+
choices=tuple(choices),
|
|
268
|
+
min=_number(raw.get("min"), "min", key),
|
|
269
|
+
max=_number(raw.get("max"), "max", key),
|
|
270
|
+
step=_number(raw.get("step"), "step", key),
|
|
271
|
+
max_length=max_length,
|
|
272
|
+
pattern=pattern,
|
|
273
|
+
default=default,
|
|
274
|
+
visibility=visibility,
|
|
275
|
+
help=help_text,
|
|
276
|
+
storage=dict(storage),
|
|
277
|
+
extra={k: v for k, v in raw.items() if k not in _KNOWN},
|
|
278
|
+
)
|
|
279
|
+
if (
|
|
280
|
+
setting.min is not None
|
|
281
|
+
and setting.max is not None
|
|
282
|
+
and setting.min > setting.max
|
|
283
|
+
):
|
|
284
|
+
raise SettingsError(f"setting {key!r}: min is above max")
|
|
285
|
+
return setting
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class Catalog:
|
|
289
|
+
"""A checked settings catalog. Build it with :meth:`load`."""
|
|
290
|
+
|
|
291
|
+
def __init__(self, groups, settings, *, version=1, validators=None):
|
|
292
|
+
self.version = version
|
|
293
|
+
self.groups = [dict(g) for g in groups]
|
|
294
|
+
self._settings = {s.key: s for s in settings}
|
|
295
|
+
self._order = [s.key for s in settings]
|
|
296
|
+
self.validators = dict(validators or {})
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
def load(cls, source, *, validators=None) -> "Catalog":
|
|
300
|
+
"""A catalog from a dict, a JSON string or a path to a JSON file.
|
|
301
|
+
|
|
302
|
+
``validators`` maps an application type name to ``fn(setting,
|
|
303
|
+
value) -> normalized`` (raise ``ValueError`` to refuse a value).
|
|
304
|
+
Raises :class:`SettingsError` for any shape problem."""
|
|
305
|
+
if isinstance(source, bytes):
|
|
306
|
+
source = source.decode("utf-8")
|
|
307
|
+
if isinstance(source, str):
|
|
308
|
+
if source.lstrip()[:1] in ("{", "["):
|
|
309
|
+
try:
|
|
310
|
+
source = json.loads(source)
|
|
311
|
+
except ValueError:
|
|
312
|
+
raise SettingsError("the catalog is not valid JSON") from None
|
|
313
|
+
else:
|
|
314
|
+
with open(source, encoding="utf-8") as fh:
|
|
315
|
+
source = json.load(fh)
|
|
316
|
+
if not isinstance(source, Mapping):
|
|
317
|
+
raise SettingsError("the catalog must be an object")
|
|
318
|
+
validators = dict(validators or {})
|
|
319
|
+
for name, fn in validators.items():
|
|
320
|
+
if not (isinstance(name, str) and _ID_RE.match(name)) or not callable(fn):
|
|
321
|
+
raise SettingsError(f"invalid validator {name!r}")
|
|
322
|
+
groups = source.get("groups")
|
|
323
|
+
if not isinstance(groups, (list, tuple)) or not groups:
|
|
324
|
+
raise SettingsError("the catalog needs groups")
|
|
325
|
+
group_ids = []
|
|
326
|
+
for group in groups:
|
|
327
|
+
if (
|
|
328
|
+
not isinstance(group, Mapping)
|
|
329
|
+
or not isinstance(group.get("id"), str)
|
|
330
|
+
or not _ID_RE.match(group["id"])
|
|
331
|
+
or not isinstance(group.get("label"), str)
|
|
332
|
+
):
|
|
333
|
+
raise SettingsError(f"invalid group {group!r}")
|
|
334
|
+
group_ids.append(group["id"])
|
|
335
|
+
if len(set(group_ids)) != len(group_ids):
|
|
336
|
+
raise SettingsError("duplicate group ids")
|
|
337
|
+
raw_settings = source.get("settings")
|
|
338
|
+
if not isinstance(raw_settings, (list, tuple)):
|
|
339
|
+
raise SettingsError("the catalog needs a settings list")
|
|
340
|
+
parsed = [
|
|
341
|
+
_parse_setting(raw, set(group_ids), validators) for raw in raw_settings
|
|
342
|
+
]
|
|
343
|
+
keys = [s.key for s in parsed]
|
|
344
|
+
if len(set(keys)) != len(keys):
|
|
345
|
+
raise SettingsError("duplicate setting keys")
|
|
346
|
+
catalog = cls(
|
|
347
|
+
groups,
|
|
348
|
+
parsed,
|
|
349
|
+
version=source.get("version", 1),
|
|
350
|
+
validators=validators,
|
|
351
|
+
)
|
|
352
|
+
for setting in parsed:
|
|
353
|
+
if setting.default and "value" in setting.default:
|
|
354
|
+
catalog.validate(setting.key, setting.default["value"])
|
|
355
|
+
return catalog
|
|
356
|
+
|
|
357
|
+
def __iter__(self):
|
|
358
|
+
return (self._settings[k] for k in self._order)
|
|
359
|
+
|
|
360
|
+
def __len__(self):
|
|
361
|
+
return len(self._order)
|
|
362
|
+
|
|
363
|
+
def __contains__(self, key):
|
|
364
|
+
return key in self._settings
|
|
365
|
+
|
|
366
|
+
def keys(self) -> List[str]:
|
|
367
|
+
return list(self._order)
|
|
368
|
+
|
|
369
|
+
def get(self, key) -> Setting:
|
|
370
|
+
try:
|
|
371
|
+
return self._settings[key]
|
|
372
|
+
except (KeyError, TypeError):
|
|
373
|
+
raise SettingsError(f"unknown setting {key!r}") from None
|
|
374
|
+
|
|
375
|
+
def by_storage(self, name, value) -> Optional[Setting]:
|
|
376
|
+
"""The setting whose ``storage[name] == value`` (e.g. the setting a
|
|
377
|
+
column holds), or None."""
|
|
378
|
+
for setting in self:
|
|
379
|
+
if setting.storage.get(name) == value:
|
|
380
|
+
return setting
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def to_dict(self) -> dict:
|
|
384
|
+
return {
|
|
385
|
+
"version": self.version,
|
|
386
|
+
"groups": [dict(g) for g in self.groups],
|
|
387
|
+
"settings": [s.to_dict() for s in self],
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# ── validation ───────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
def validate(self, key, value):
|
|
393
|
+
"""``value`` normalized for ``key``'s type, or :class:`SettingsError`.
|
|
394
|
+
``None`` / blank text is refused: removing a value is an ``unset``."""
|
|
395
|
+
setting = key if isinstance(key, Setting) else self.get(key)
|
|
396
|
+
if _empty(value):
|
|
397
|
+
raise SettingsError(
|
|
398
|
+
f"{setting.label}: a value is required (unset to clear)"
|
|
399
|
+
)
|
|
400
|
+
custom = self.validators.get(setting.type)
|
|
401
|
+
try:
|
|
402
|
+
if custom is not None:
|
|
403
|
+
return custom(setting, value)
|
|
404
|
+
return _VALIDATORS[setting.type](setting, value)
|
|
405
|
+
except SettingsError:
|
|
406
|
+
raise
|
|
407
|
+
except (ValueError, TypeError, InvalidOperation) as exc:
|
|
408
|
+
raise SettingsError(f"{setting.label}: {exc}") from None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _v_bool(setting, value):
|
|
412
|
+
if isinstance(value, bool):
|
|
413
|
+
return value
|
|
414
|
+
if isinstance(value, int) and value in (0, 1):
|
|
415
|
+
return bool(value)
|
|
416
|
+
if isinstance(value, str):
|
|
417
|
+
text = value.strip().lower()
|
|
418
|
+
if text in _TRUE:
|
|
419
|
+
return True
|
|
420
|
+
if text in _FALSE:
|
|
421
|
+
return False
|
|
422
|
+
raise ValueError("expected true or false")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _v_choice(setting, value):
|
|
426
|
+
for choice in setting.choices:
|
|
427
|
+
if choice["value"] == value:
|
|
428
|
+
return choice["value"]
|
|
429
|
+
if isinstance(value, str):
|
|
430
|
+
for choice in setting.choices:
|
|
431
|
+
if str(choice["value"]) == value.strip():
|
|
432
|
+
return choice["value"]
|
|
433
|
+
raise ValueError(
|
|
434
|
+
"must be one of " + ", ".join(str(v) for v in setting.choice_values())
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _range(setting, number):
|
|
439
|
+
if setting.min is not None and number < setting.min:
|
|
440
|
+
raise ValueError(f"must be at least {setting.min}")
|
|
441
|
+
if setting.max is not None and number > setting.max:
|
|
442
|
+
raise ValueError(f"must be at most {setting.max}")
|
|
443
|
+
return number
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _v_number(setting, value):
|
|
447
|
+
if isinstance(value, bool):
|
|
448
|
+
raise ValueError("expected a number")
|
|
449
|
+
if isinstance(value, str):
|
|
450
|
+
value = Decimal(value.strip())
|
|
451
|
+
number = float(value)
|
|
452
|
+
if not math.isfinite(number):
|
|
453
|
+
raise ValueError("expected a finite number")
|
|
454
|
+
number = _range(setting, number)
|
|
455
|
+
return int(number) if number.is_integer() and setting.step in (None, 1) else number
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _v_integer(setting, value):
|
|
459
|
+
if isinstance(value, bool):
|
|
460
|
+
raise ValueError("expected a whole number")
|
|
461
|
+
if isinstance(value, str):
|
|
462
|
+
value = Decimal(value.strip())
|
|
463
|
+
if isinstance(value, (float, Decimal)) and value != int(value):
|
|
464
|
+
raise ValueError("expected a whole number")
|
|
465
|
+
return _range(setting, int(value))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _v_text(setting, value):
|
|
469
|
+
if not isinstance(value, str):
|
|
470
|
+
raise ValueError("expected text")
|
|
471
|
+
text = value.strip()
|
|
472
|
+
if setting.max_length is not None and len(text) > setting.max_length:
|
|
473
|
+
raise ValueError(f"must be at most {setting.max_length} characters")
|
|
474
|
+
if setting.pattern is not None and not re.fullmatch(setting.pattern, text):
|
|
475
|
+
raise ValueError("is not in the expected format")
|
|
476
|
+
return text
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _v_time_zone(setting, value):
|
|
480
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
481
|
+
|
|
482
|
+
if not isinstance(value, str) or "/" not in value and value.strip() != "UTC":
|
|
483
|
+
raise ValueError("expected a time zone name such as America/Denver")
|
|
484
|
+
name = value.strip()
|
|
485
|
+
try:
|
|
486
|
+
ZoneInfo(name)
|
|
487
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
488
|
+
raise ValueError(f"unknown time zone {name!r}") from None
|
|
489
|
+
return name
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _v_json(setting, value):
|
|
493
|
+
if isinstance(value, str):
|
|
494
|
+
value = json.loads(value)
|
|
495
|
+
text = json.dumps(value)
|
|
496
|
+
if len(text.encode("utf-8")) > MAX_JSON_BYTES:
|
|
497
|
+
raise ValueError("is too large")
|
|
498
|
+
return json.loads(text)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
_VALIDATORS = {
|
|
502
|
+
"bool": _v_bool,
|
|
503
|
+
"choice": _v_choice,
|
|
504
|
+
"number": _v_number,
|
|
505
|
+
"integer": _v_integer,
|
|
506
|
+
"text": _v_text,
|
|
507
|
+
"time_zone": _v_time_zone,
|
|
508
|
+
"json": _v_json,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ── levels ───────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class Level:
|
|
516
|
+
"""One place a setting's value can come from.
|
|
517
|
+
|
|
518
|
+
``read(tx, settings) -> {key: value}`` (blank or missing = not set).
|
|
519
|
+
``write(tx, setting, value)`` / ``unset(tx, setting)`` for a writable
|
|
520
|
+
level. ``ref(setting) -> str`` names where the value lives (shown as the
|
|
521
|
+
source). ``count(tx, settings) -> {key: n}`` (optional) counts the
|
|
522
|
+
owners BELOW this level that set their own value (e.g. forms that
|
|
523
|
+
override an organization's value), for "overridden on N" labels.
|
|
524
|
+
``audit_table_name`` / ``owner_id`` / ``field(setting)`` place this
|
|
525
|
+
level's history rows (see :func:`apply`)."""
|
|
526
|
+
|
|
527
|
+
def __init__(
|
|
528
|
+
self,
|
|
529
|
+
name,
|
|
530
|
+
read,
|
|
531
|
+
*,
|
|
532
|
+
write=None,
|
|
533
|
+
unset=None,
|
|
534
|
+
ref=None,
|
|
535
|
+
count=None,
|
|
536
|
+
audit_table_name=None,
|
|
537
|
+
owner_id=None,
|
|
538
|
+
field=None,
|
|
539
|
+
):
|
|
540
|
+
if not isinstance(name, str) or not _ID_RE.match(name):
|
|
541
|
+
raise SettingsError(f"invalid level name {name!r}")
|
|
542
|
+
self.name = name
|
|
543
|
+
self._read = read
|
|
544
|
+
self._write = write
|
|
545
|
+
self._unset = unset
|
|
546
|
+
self._ref = ref
|
|
547
|
+
self._count = count
|
|
548
|
+
self.audit_table_name = audit_table_name
|
|
549
|
+
self.owner_id = owner_id
|
|
550
|
+
self._field = field
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def writable(self) -> bool:
|
|
554
|
+
return self._write is not None and self._unset is not None
|
|
555
|
+
|
|
556
|
+
def read(self, tx, settings) -> dict:
|
|
557
|
+
values = self._read(tx, list(settings)) or {}
|
|
558
|
+
return {k: v for k, v in values.items() if not _empty(v)}
|
|
559
|
+
|
|
560
|
+
def write(self, tx, setting, value):
|
|
561
|
+
if self._write is None:
|
|
562
|
+
raise SettingsError(f"the {self.name} level cannot be written")
|
|
563
|
+
self._write(tx, setting, value)
|
|
564
|
+
|
|
565
|
+
def unset(self, tx, setting):
|
|
566
|
+
if self._unset is None:
|
|
567
|
+
raise SettingsError(f"the {self.name} level cannot be written")
|
|
568
|
+
self._unset(tx, setting)
|
|
569
|
+
|
|
570
|
+
def ref(self, setting) -> str:
|
|
571
|
+
return self._ref(setting) if self._ref else self.name
|
|
572
|
+
|
|
573
|
+
def count(self, tx, settings) -> dict:
|
|
574
|
+
return (self._count(tx, list(settings)) or {}) if self._count else {}
|
|
575
|
+
|
|
576
|
+
def field(self, setting) -> str:
|
|
577
|
+
"""The name a history row records this setting's change under."""
|
|
578
|
+
return self._field(setting) if self._field else setting.key
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def platform_level(name="platform", *, flags=None) -> Level:
|
|
582
|
+
"""The catalog defaults as a level: ``{"value": v}`` as declared, and
|
|
583
|
+
``{"flag": column}`` from the platform row of ``sys_feature_flags``
|
|
584
|
+
(:func:`velocity.aws.feature_flags.get`, read fresh). ``flags`` replaces
|
|
585
|
+
that read with ``fn(tx) -> dict``."""
|
|
586
|
+
|
|
587
|
+
def read_flags(tx):
|
|
588
|
+
if flags is not None:
|
|
589
|
+
return flags(tx) or {}
|
|
590
|
+
from velocity.aws import feature_flags
|
|
591
|
+
|
|
592
|
+
return feature_flags.get(tx) or {}
|
|
593
|
+
|
|
594
|
+
def read(tx, settings):
|
|
595
|
+
out = {}
|
|
596
|
+
row = None
|
|
597
|
+
for setting in settings:
|
|
598
|
+
default = setting.default or {}
|
|
599
|
+
if "value" in default:
|
|
600
|
+
out[setting.key] = default["value"]
|
|
601
|
+
elif "flag" in default:
|
|
602
|
+
if row is None:
|
|
603
|
+
row = read_flags(tx)
|
|
604
|
+
out[setting.key] = row.get(default["flag"])
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
def ref(setting):
|
|
608
|
+
default = setting.default or {}
|
|
609
|
+
if "flag" in default:
|
|
610
|
+
return f"sys_feature_flags.{default['flag']}"
|
|
611
|
+
return "catalog default"
|
|
612
|
+
|
|
613
|
+
return Level(name, read, ref=ref)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
class ColumnLevel(Level):
|
|
617
|
+
"""Values held in columns of one owner row (``storage[column_key]`` names
|
|
618
|
+
the column; a setting without one is not stored at this level).
|
|
619
|
+
|
|
620
|
+
Writes coerce to the column's declared type where it is text (a number
|
|
621
|
+
or choice stored in a TEXT column is written as text) and read text
|
|
622
|
+
columns back through the catalog's validator, so ``"0.3"`` reads as
|
|
623
|
+
``0.3``. History rows are filed under ``table`` and the owner's sys_id,
|
|
624
|
+
field = the column name, so they sit beside the rows other editors of
|
|
625
|
+
the same table write."""
|
|
626
|
+
|
|
627
|
+
def __init__(
|
|
628
|
+
self,
|
|
629
|
+
name,
|
|
630
|
+
catalog,
|
|
631
|
+
table,
|
|
632
|
+
owner_sys_id,
|
|
633
|
+
*,
|
|
634
|
+
column_key="column",
|
|
635
|
+
count=None,
|
|
636
|
+
):
|
|
637
|
+
if not _COLUMN_RE.match(table or ""):
|
|
638
|
+
raise SettingsError(f"invalid table {table!r}")
|
|
639
|
+
self.catalog = catalog
|
|
640
|
+
self.table = table
|
|
641
|
+
self.column_key = column_key
|
|
642
|
+
self.owner_sys_id = int(owner_sys_id)
|
|
643
|
+
super().__init__(
|
|
644
|
+
name,
|
|
645
|
+
self._read_columns,
|
|
646
|
+
write=self._write_column,
|
|
647
|
+
unset=self._unset_column,
|
|
648
|
+
ref=lambda s: f"{table}.{self.column(s)}",
|
|
649
|
+
count=count,
|
|
650
|
+
audit_table_name=table,
|
|
651
|
+
owner_id=self.owner_sys_id,
|
|
652
|
+
field=self.column,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def column(self, setting) -> Optional[str]:
|
|
656
|
+
column = setting.storage.get(self.column_key)
|
|
657
|
+
if column is not None and not _COLUMN_RE.match(str(column)):
|
|
658
|
+
raise SettingsError(f"setting {setting.key!r}: invalid column")
|
|
659
|
+
return column
|
|
660
|
+
|
|
661
|
+
def _types(self, tx):
|
|
662
|
+
return tx.table(self.table).column_types(cache_ttl=300) or {}
|
|
663
|
+
|
|
664
|
+
def _read_columns(self, tx, settings):
|
|
665
|
+
stored = [s for s in settings if self.column(s)]
|
|
666
|
+
if not stored:
|
|
667
|
+
return {}
|
|
668
|
+
columns = sorted({self.column(s) for s in stored})
|
|
669
|
+
present = set(tx.table(self.table).sys_columns(cache_ttl=300) or ())
|
|
670
|
+
wanted = [c for c in columns if c in present]
|
|
671
|
+
if not wanted:
|
|
672
|
+
return {}
|
|
673
|
+
rows = (
|
|
674
|
+
tx.table(self.table)
|
|
675
|
+
.select(columns=wanted, where={"sys_id": self.owner_sys_id})
|
|
676
|
+
.all()
|
|
677
|
+
)
|
|
678
|
+
if not rows:
|
|
679
|
+
raise SettingsError(f"no {self.table} row {self.owner_sys_id}")
|
|
680
|
+
row = rows[0]
|
|
681
|
+
out = {}
|
|
682
|
+
for setting in stored:
|
|
683
|
+
value = row.get(self.column(setting))
|
|
684
|
+
if _empty(value):
|
|
685
|
+
continue
|
|
686
|
+
try:
|
|
687
|
+
out[setting.key] = self.catalog.validate(setting, value)
|
|
688
|
+
except SettingsError:
|
|
689
|
+
# a stored value the catalog would refuse is still shown as it is
|
|
690
|
+
out[setting.key] = value
|
|
691
|
+
return out
|
|
692
|
+
|
|
693
|
+
def _store_value(self, tx, setting, value):
|
|
694
|
+
column = self.column(setting)
|
|
695
|
+
dtype = str(self._types(tx).get(column, "")).lower()
|
|
696
|
+
if dtype in ("text", "character varying", "varchar", "character", "char"):
|
|
697
|
+
if isinstance(value, bool):
|
|
698
|
+
return "true" if value else "false"
|
|
699
|
+
if isinstance(value, (dict, list)):
|
|
700
|
+
return json.dumps(value)
|
|
701
|
+
return str(value)
|
|
702
|
+
return value
|
|
703
|
+
|
|
704
|
+
def _require_column(self, tx, setting):
|
|
705
|
+
column = self.column(setting)
|
|
706
|
+
if not column:
|
|
707
|
+
raise SettingsError(
|
|
708
|
+
f"{setting.label} is not stored at the {self.name} level"
|
|
709
|
+
)
|
|
710
|
+
return column
|
|
711
|
+
|
|
712
|
+
def _write_column(self, tx, setting, value):
|
|
713
|
+
column = self._require_column(tx, setting)
|
|
714
|
+
tx.table(self.table).update(
|
|
715
|
+
{column: self._store_value(tx, setting, value)},
|
|
716
|
+
where={"sys_id": self.owner_sys_id},
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
def _unset_column(self, tx, setting):
|
|
720
|
+
column = self._require_column(tx, setting)
|
|
721
|
+
tx.table(self.table).update({column: None}, where={"sys_id": self.owner_sys_id})
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class RowLevel(Level):
|
|
725
|
+
"""Values held as rows of a key/value table: one row per owner and key,
|
|
726
|
+
the value as JSON (``ensure_rows_table`` declares it). History rows are
|
|
727
|
+
filed under ``table`` and the owner id, field = the setting key."""
|
|
728
|
+
|
|
729
|
+
def __init__(
|
|
730
|
+
self,
|
|
731
|
+
name,
|
|
732
|
+
table,
|
|
733
|
+
owner_column,
|
|
734
|
+
owner_id,
|
|
735
|
+
*,
|
|
736
|
+
key_column="key",
|
|
737
|
+
value_column="value",
|
|
738
|
+
count=None,
|
|
739
|
+
):
|
|
740
|
+
for ident in (table, owner_column, key_column, value_column):
|
|
741
|
+
if not _COLUMN_RE.match(ident or ""):
|
|
742
|
+
raise SettingsError(f"invalid identifier {ident!r}")
|
|
743
|
+
self.table = table
|
|
744
|
+
self.owner_column = owner_column
|
|
745
|
+
self.key_column = key_column
|
|
746
|
+
self.value_column = value_column
|
|
747
|
+
super().__init__(
|
|
748
|
+
name,
|
|
749
|
+
self._read_rows,
|
|
750
|
+
write=self._write_row,
|
|
751
|
+
unset=self._unset_row,
|
|
752
|
+
ref=lambda s: f"{table}[{s.key}]",
|
|
753
|
+
count=count,
|
|
754
|
+
audit_table_name=table,
|
|
755
|
+
owner_id=owner_id,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
def _read_rows(self, tx, settings):
|
|
759
|
+
keys = [s.key for s in settings]
|
|
760
|
+
if not keys:
|
|
761
|
+
return {}
|
|
762
|
+
rows = (
|
|
763
|
+
tx.table(self.table)
|
|
764
|
+
.select(
|
|
765
|
+
columns=[self.key_column, self.value_column],
|
|
766
|
+
where={self.owner_column: self.owner_id, self.key_column: keys},
|
|
767
|
+
)
|
|
768
|
+
.all()
|
|
769
|
+
)
|
|
770
|
+
out = {}
|
|
771
|
+
for row in rows:
|
|
772
|
+
value = row.get(self.value_column)
|
|
773
|
+
if isinstance(value, str):
|
|
774
|
+
try:
|
|
775
|
+
value = json.loads(value)
|
|
776
|
+
except ValueError:
|
|
777
|
+
pass
|
|
778
|
+
out[row[self.key_column]] = value
|
|
779
|
+
return out
|
|
780
|
+
|
|
781
|
+
def _write_row(self, tx, setting, value):
|
|
782
|
+
tx.table(self.table).merge(
|
|
783
|
+
{
|
|
784
|
+
self.owner_column: self.owner_id,
|
|
785
|
+
self.key_column: setting.key,
|
|
786
|
+
self.value_column: json.dumps(value),
|
|
787
|
+
},
|
|
788
|
+
pk={self.owner_column: self.owner_id, self.key_column: setting.key},
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def _unset_row(self, tx, setting):
|
|
792
|
+
tx.table(self.table).delete(
|
|
793
|
+
where={self.owner_column: self.owner_id, self.key_column: setting.key}
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def rows_table_schema(owner_column="owner_sys_id", owner_type="BIGINT"):
|
|
798
|
+
"""``Table.ensure_schema`` keyword arguments for a :class:`RowLevel`
|
|
799
|
+
table: owner, ``key`` TEXT, ``value`` JSONB, unique (owner, key)."""
|
|
800
|
+
if not _COLUMN_RE.match(owner_column or ""):
|
|
801
|
+
raise SettingsError(f"invalid owner column {owner_column!r}")
|
|
802
|
+
return {
|
|
803
|
+
"columns": {owner_column: owner_type, "key": "TEXT", "value": "JSONB"},
|
|
804
|
+
"unique_indexes": [
|
|
805
|
+
{"columns": [owner_column, "key"], "name": f"uq_{owner_column}_key"}
|
|
806
|
+
],
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# ── resolution ───────────────────────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@dataclass(frozen=True)
|
|
814
|
+
class Resolved:
|
|
815
|
+
key: str
|
|
816
|
+
value: Any
|
|
817
|
+
source: Optional[str]
|
|
818
|
+
source_ref: Optional[str]
|
|
819
|
+
levels: dict
|
|
820
|
+
overrides: dict
|
|
821
|
+
|
|
822
|
+
@property
|
|
823
|
+
def missing(self) -> bool:
|
|
824
|
+
return self.source is None
|
|
825
|
+
|
|
826
|
+
def to_dict(self) -> dict:
|
|
827
|
+
return {
|
|
828
|
+
"key": self.key,
|
|
829
|
+
"value": self.value,
|
|
830
|
+
"source": self.source,
|
|
831
|
+
"source_ref": self.source_ref,
|
|
832
|
+
"missing": self.missing,
|
|
833
|
+
"levels": dict(self.levels),
|
|
834
|
+
"overrides": dict(self.overrides),
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _check_chain(chain: Sequence[Level]):
|
|
839
|
+
names = [level.name for level in chain]
|
|
840
|
+
if not chain or len(set(names)) != len(names):
|
|
841
|
+
raise SettingsError("the level chain needs distinct levels")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def resolve_all(tx, catalog: Catalog, chain: Sequence[Level], keys=None) -> dict:
|
|
845
|
+
"""``{key: Resolved}`` for ``keys`` (default: every setting), each the
|
|
846
|
+
value of the first level in ``chain`` (most specific first) that the
|
|
847
|
+
setting allows and that holds a value. One read per level."""
|
|
848
|
+
_check_chain(chain)
|
|
849
|
+
settings = [catalog.get(k) for k in (keys if keys is not None else catalog.keys())]
|
|
850
|
+
by_level = {}
|
|
851
|
+
counts = {}
|
|
852
|
+
for level in chain:
|
|
853
|
+
allowed = [s for s in settings if level.name in s.levels]
|
|
854
|
+
by_level[level.name] = level.read(tx, allowed) if allowed else {}
|
|
855
|
+
counts[level.name] = level.count(tx, allowed) if allowed else {}
|
|
856
|
+
out = {}
|
|
857
|
+
for setting in settings:
|
|
858
|
+
levels = {}
|
|
859
|
+
value = source = source_ref = None
|
|
860
|
+
for level in chain:
|
|
861
|
+
if level.name not in setting.levels:
|
|
862
|
+
continue
|
|
863
|
+
if setting.key in by_level[level.name]:
|
|
864
|
+
found = by_level[level.name][setting.key]
|
|
865
|
+
levels[level.name] = found
|
|
866
|
+
if source is None:
|
|
867
|
+
value, source, source_ref = found, level.name, level.ref(setting)
|
|
868
|
+
overrides = {
|
|
869
|
+
name: n
|
|
870
|
+
for name, per_key in counts.items()
|
|
871
|
+
for key, n in per_key.items()
|
|
872
|
+
if key == setting.key and n
|
|
873
|
+
}
|
|
874
|
+
out[setting.key] = Resolved(
|
|
875
|
+
setting.key, value, source, source_ref, levels, overrides
|
|
876
|
+
)
|
|
877
|
+
return out
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def resolve(tx, catalog: Catalog, key, chain: Sequence[Level], *, required=False):
|
|
881
|
+
"""The :class:`Resolved` value of one setting. With ``required=True`` a
|
|
882
|
+
setting no level holds raises :class:`SettingMissingError` (report it,
|
|
883
|
+
never guess a value)."""
|
|
884
|
+
resolved = resolve_all(tx, catalog, chain, [key])[key]
|
|
885
|
+
if required and resolved.missing:
|
|
886
|
+
raise SettingMissingError(
|
|
887
|
+
f"{catalog.get(key).label} ({key}) is not set at any level"
|
|
888
|
+
)
|
|
889
|
+
return resolved
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def value(tx, catalog: Catalog, key, chain: Sequence[Level]):
|
|
893
|
+
"""The resolved value of a required setting (see :func:`resolve`)."""
|
|
894
|
+
return resolve(tx, catalog, key, chain, required=True).value
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
# ── audited writes ───────────────────────────────────────────────────────
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _operations(operations):
|
|
901
|
+
if isinstance(operations, Mapping):
|
|
902
|
+
operations = [operations]
|
|
903
|
+
if not isinstance(operations, (list, tuple)) or not operations:
|
|
904
|
+
raise SettingsError("no operations")
|
|
905
|
+
if len(operations) > 100:
|
|
906
|
+
raise SettingsError("too many operations")
|
|
907
|
+
out = []
|
|
908
|
+
for op in operations:
|
|
909
|
+
if not isinstance(op, Mapping) or op.get("op") not in (SET, UNSET):
|
|
910
|
+
raise SettingsError(f"invalid operation {op!r}")
|
|
911
|
+
if op["op"] == SET and "value" not in op:
|
|
912
|
+
raise SettingsError("a set operation needs a value")
|
|
913
|
+
out.append(op)
|
|
914
|
+
return out
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _json_safe(value):
|
|
918
|
+
from velocity.aws.handlers.mixins.audit_trail import json_safe
|
|
919
|
+
|
|
920
|
+
return json_safe(value)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def apply(
|
|
924
|
+
tx,
|
|
925
|
+
catalog: Catalog,
|
|
926
|
+
level: Level,
|
|
927
|
+
operations,
|
|
928
|
+
*,
|
|
929
|
+
by,
|
|
930
|
+
audit_table=AUDIT_TABLE,
|
|
931
|
+
now=None,
|
|
932
|
+
) -> List[dict]:
|
|
933
|
+
"""Set or unset settings at one writable ``level``, validated, in
|
|
934
|
+
``tx``, and record the change.
|
|
935
|
+
|
|
936
|
+
``operations``: ``[{"op": "set", "key": k, "value": v} | {"op":
|
|
937
|
+
"unset", "key": k}]`` (or one such dict). Each key must allow
|
|
938
|
+
``level.name``; each value is normalized by :meth:`Catalog.validate`.
|
|
939
|
+
An operation that changes nothing is skipped. ``by`` (the actor, e.g.
|
|
940
|
+
the signed-in email) is required: it is stamped on the trigger actor
|
|
941
|
+
(:func:`velocity.db.audit.set_audit_actor`) and written, with the time,
|
|
942
|
+
on ONE history row in ``audit_table`` (``table_name`` =
|
|
943
|
+
``level.audit_table_name``, ``row_sys_id`` = ``level.owner_id``,
|
|
944
|
+
``operation`` = ``"settings"``, ``field_diff`` keyed by
|
|
945
|
+
``level.field(setting)``). The history row is part of the same
|
|
946
|
+
transaction: when it cannot be written, nothing is.
|
|
947
|
+
|
|
948
|
+
Returns ``[{"key", "field", "from", "to", "op"}]`` for the changes made.
|
|
949
|
+
"""
|
|
950
|
+
if not level.writable:
|
|
951
|
+
raise SettingsError(f"the {level.name} level cannot be written")
|
|
952
|
+
if not isinstance(by, str) or not by.strip():
|
|
953
|
+
raise SettingsError("an actor is required to change settings")
|
|
954
|
+
if level.audit_table_name is None or level.owner_id is None:
|
|
955
|
+
raise SettingsError("the level does not say where its history is filed")
|
|
956
|
+
ops = _operations(operations)
|
|
957
|
+
planned = []
|
|
958
|
+
for op in ops:
|
|
959
|
+
setting = catalog.get(op.get("key"))
|
|
960
|
+
if level.name not in setting.levels:
|
|
961
|
+
raise SettingsError(
|
|
962
|
+
f"{setting.label} cannot be set at the {level.name} level"
|
|
963
|
+
)
|
|
964
|
+
new = catalog.validate(setting, op["value"]) if op["op"] == SET else None
|
|
965
|
+
planned.append((op["op"], setting, new))
|
|
966
|
+
current = level.read(tx, [s for _, s, _ in planned])
|
|
967
|
+
changes = []
|
|
968
|
+
before, after = {}, {}
|
|
969
|
+
from velocity.db import audit as db_audit
|
|
970
|
+
|
|
971
|
+
db_audit.set_audit_actor(tx, by.strip())
|
|
972
|
+
for op, setting, new in planned:
|
|
973
|
+
old = current.get(setting.key)
|
|
974
|
+
if op == SET and old == new and old is not None:
|
|
975
|
+
continue
|
|
976
|
+
if op == UNSET and old is None:
|
|
977
|
+
continue
|
|
978
|
+
if op == SET:
|
|
979
|
+
level.write(tx, setting, new)
|
|
980
|
+
else:
|
|
981
|
+
level.unset(tx, setting)
|
|
982
|
+
current[setting.key] = new
|
|
983
|
+
name = level.field(setting)
|
|
984
|
+
before[name] = _json_safe(old)
|
|
985
|
+
after[name] = _json_safe(new)
|
|
986
|
+
changes.append(
|
|
987
|
+
{"key": setting.key, "field": name, "from": old, "to": new, "op": op}
|
|
988
|
+
)
|
|
989
|
+
if changes:
|
|
990
|
+
tx.table(audit_table).insert(
|
|
991
|
+
{
|
|
992
|
+
"table_name": level.audit_table_name,
|
|
993
|
+
"row_sys_id": int(level.owner_id),
|
|
994
|
+
"operation": OPERATION,
|
|
995
|
+
"changed_by": by.strip(),
|
|
996
|
+
"changed_at": now or datetime.datetime.now(datetime.timezone.utc),
|
|
997
|
+
"before": before,
|
|
998
|
+
"after": after,
|
|
999
|
+
"field_diff": {
|
|
1000
|
+
name: {"from": before[name], "to": after[name]} for name in after
|
|
1001
|
+
},
|
|
1002
|
+
}
|
|
1003
|
+
)
|
|
1004
|
+
return changes
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _decode(value):
|
|
1008
|
+
if isinstance(value, str):
|
|
1009
|
+
try:
|
|
1010
|
+
return json.loads(value)
|
|
1011
|
+
except ValueError:
|
|
1012
|
+
return None
|
|
1013
|
+
return value
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def history(
|
|
1017
|
+
tx,
|
|
1018
|
+
*,
|
|
1019
|
+
table_name,
|
|
1020
|
+
owner_id,
|
|
1021
|
+
field=None,
|
|
1022
|
+
limit=50,
|
|
1023
|
+
audit_table=AUDIT_TABLE,
|
|
1024
|
+
scan=500,
|
|
1025
|
+
) -> List[dict]:
|
|
1026
|
+
"""The change history of one owner, newest first: settings rows from
|
|
1027
|
+
:func:`apply` and any other history row for the same record (a form
|
|
1028
|
+
save, the audit trigger). With ``field`` only rows that changed it, as
|
|
1029
|
+
``{"changed_at", "changed_by", "operation", "from", "to"}``; without,
|
|
1030
|
+
``{"changed_at", "changed_by", "operation", "changes": {field: {from,
|
|
1031
|
+
to}}}``. Reads at most ``scan`` recent rows of the record."""
|
|
1032
|
+
rows = (
|
|
1033
|
+
tx.table(audit_table)
|
|
1034
|
+
.select(
|
|
1035
|
+
columns=["changed_at", "changed_by", "operation", "field_diff"],
|
|
1036
|
+
where={"table_name": table_name, "row_sys_id": int(owner_id)},
|
|
1037
|
+
orderby="changed_at desc",
|
|
1038
|
+
qty=max(1, min(int(scan), 5000)),
|
|
1039
|
+
)
|
|
1040
|
+
.all()
|
|
1041
|
+
)
|
|
1042
|
+
out = []
|
|
1043
|
+
for row in rows:
|
|
1044
|
+
diff = _decode(row.get("field_diff")) or {}
|
|
1045
|
+
if not isinstance(diff, dict):
|
|
1046
|
+
continue
|
|
1047
|
+
base = {
|
|
1048
|
+
"changed_at": row.get("changed_at"),
|
|
1049
|
+
"changed_by": row.get("changed_by"),
|
|
1050
|
+
"operation": row.get("operation"),
|
|
1051
|
+
}
|
|
1052
|
+
if field is not None:
|
|
1053
|
+
change = diff.get(field)
|
|
1054
|
+
if not isinstance(change, dict):
|
|
1055
|
+
continue
|
|
1056
|
+
out.append({**base, "from": change.get("from"), "to": change.get("to")})
|
|
1057
|
+
elif diff:
|
|
1058
|
+
out.append({**base, "changes": diff})
|
|
1059
|
+
if len(out) >= limit:
|
|
1060
|
+
break
|
|
1061
|
+
return out
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def compare(
|
|
1065
|
+
tx,
|
|
1066
|
+
catalog: Catalog,
|
|
1067
|
+
key,
|
|
1068
|
+
owners: Iterable[Any],
|
|
1069
|
+
chain_for: Callable[[Any], Sequence[Level]],
|
|
1070
|
+
) -> List[dict]:
|
|
1071
|
+
"""One setting across owners: ``[{"owner", **Resolved.to_dict()}]``,
|
|
1072
|
+
``chain_for(owner)`` building each owner's chain. For a large owner set
|
|
1073
|
+
an application reads the stored values in one query instead."""
|
|
1074
|
+
catalog.get(key)
|
|
1075
|
+
return [
|
|
1076
|
+
{"owner": owner, **resolve(tx, catalog, key, chain_for(owner)).to_dict()}
|
|
1077
|
+
for owner in owners
|
|
1078
|
+
]
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
__all__ = [
|
|
1082
|
+
"AUDIT_TABLE",
|
|
1083
|
+
"OPERATION",
|
|
1084
|
+
"SET",
|
|
1085
|
+
"TYPES",
|
|
1086
|
+
"UNSET",
|
|
1087
|
+
"VISIBILITIES",
|
|
1088
|
+
"Catalog",
|
|
1089
|
+
"ColumnLevel",
|
|
1090
|
+
"Level",
|
|
1091
|
+
"Resolved",
|
|
1092
|
+
"RowLevel",
|
|
1093
|
+
"Setting",
|
|
1094
|
+
"SettingMissingError",
|
|
1095
|
+
"SettingsError",
|
|
1096
|
+
"apply",
|
|
1097
|
+
"compare",
|
|
1098
|
+
"history",
|
|
1099
|
+
"platform_level",
|
|
1100
|
+
"resolve",
|
|
1101
|
+
"resolve_all",
|
|
1102
|
+
"rows_table_schema",
|
|
1103
|
+
"value",
|
|
1104
|
+
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
velocity/__init__.py,sha256=
|
|
1
|
+
velocity/__init__.py,sha256=N00pstJ8GXBj7DXMu29lkVjmUMBtgRmMKh443yhzIAs,362
|
|
2
2
|
velocity/cache.py,sha256=pthvw7gDVEXRzkTcQtA5YVf4Q1IxAamWsba4UReT0RE,8933
|
|
3
3
|
velocity/hooks.py,sha256=jY_qtAapNHvptmryFSD-Zc7RvLhV6e5nJEH3x2xTgCw,20575
|
|
4
4
|
velocity/logging.py,sha256=MAsZap1cDSdOBNRc_9ezQxup5dHOS0ARxU9NI_-UK3E,4037
|
|
@@ -21,6 +21,7 @@ velocity/aws/queue_defaults.py,sha256=480V3pDvWBBamJ6hIJyVtPGJKMVmnP43otl3E9qnwf
|
|
|
21
21
|
velocity/aws/s3.py,sha256=Mn6rlK6sXykx32EjptFK2IRRBQZ9xijl9r33DUnKDzQ,30277
|
|
22
22
|
velocity/aws/scheduler.py,sha256=TzhvCcPpgNpRtIdobxQ-A4KsykjAXSM9VUcSr35Lgds,21006
|
|
23
23
|
velocity/aws/schema.py,sha256=JwjCGIQVAHBI8uDXfBljMu8rXAmIGG71JSkeXtQ1ugU,5677
|
|
24
|
+
velocity/aws/settings.py,sha256=aAQL7_dtcC5VSBnk60SX2KwU6jYDmKuyBQVp12Mr5as,39464
|
|
24
25
|
velocity/aws/sms.py,sha256=2CdcLlIEy_V5K5Qx_pZ2vrakvEK7IhP7y1JUttBgyG4,16253
|
|
25
26
|
velocity/aws/ssm_config.py,sha256=xkcBxPuCcdjpeArxdcGnSBiuyG2txlUraSqtCtb_kIo,7987
|
|
26
27
|
velocity/aws/tiles.py,sha256=_38EH9ngxQxLeiZnTHBta-TXg1VkO2hPvaoPHWEKAlc,28011
|
|
@@ -235,9 +236,9 @@ velocity/rules/facts.py,sha256=07GuPDW7sB81jihObfwC29eDrGJ-NRVEskWMo9gl1ME,11619
|
|
|
235
236
|
velocity/rules/storage.py,sha256=dCWhN6g5_kQCTWy66CgpyNmIwrTY1VsVsMPXKoaiO6U,13054
|
|
236
237
|
velocity/testing/__init__.py,sha256=WtYMDHgCLxOq6mlemcOYV5Y9HqF_2QQgSfuAjFRg69Q,731
|
|
237
238
|
velocity/testing/fake_db.py,sha256=phgdulk5owf3noUDpEfI7yFsVcKxRGZ82f2BJPYnaiU,53226
|
|
238
|
-
velocity_python-0.3.
|
|
239
|
-
velocity_python-0.3.
|
|
240
|
-
velocity_python-0.3.
|
|
241
|
-
velocity_python-0.3.
|
|
242
|
-
velocity_python-0.3.
|
|
243
|
-
velocity_python-0.3.
|
|
239
|
+
velocity_python-0.3.2.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
|
|
240
|
+
velocity_python-0.3.2.dist-info/METADATA,sha256=GSH-hyhMyotb4-cRn4ttbtDYZD4mqKkT-KOQNo9Zzck,17820
|
|
241
|
+
velocity_python-0.3.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
242
|
+
velocity_python-0.3.2.dist-info/entry_points.txt,sha256=nXUKJHkOGQCSPoagdmMNd_qycrle1LVU-GhYknLGM1M,56
|
|
243
|
+
velocity_python-0.3.2.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
|
|
244
|
+
velocity_python-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|