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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.3.1"
1
+ __version__ = version = "0.3.2"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
- velocity/__init__.py,sha256=SOQcuKluU-M9Yg6SQ0kLn2LtIi1F4u-QooAzeAX9hIM,362
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.1.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
239
- velocity_python-0.3.1.dist-info/METADATA,sha256=2b2rUzB3cmMq8gEfQ5FuNsFkNGTQDnz2aR0XsBcehZs,17820
240
- velocity_python-0.3.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
241
- velocity_python-0.3.1.dist-info/entry_points.txt,sha256=nXUKJHkOGQCSPoagdmMNd_qycrle1LVU-GhYknLGM1M,56
242
- velocity_python-0.3.1.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
243
- velocity_python-0.3.1.dist-info/RECORD,,
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,,