simple-db-settings 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ from simple_db_settings.audit import causer, current_causer
2
+ from simple_db_settings.cache import CacheBackend, NullCache, TTLCache
3
+ from simple_db_settings.store import DEFAULT_GROUP, GroupView, SettingsStore
4
+ from simple_db_settings.exceptions import (
5
+ CodecError,
6
+ InvalidValueError,
7
+ MissingExtraError,
8
+ SimpleSettingsError,
9
+ TransferError,
10
+ UnknownTypeError,
11
+ )
12
+ from simple_db_settings.transfer import (
13
+ export_json,
14
+ export_rows,
15
+ import_json,
16
+ import_rows,
17
+ )
18
+
19
+ __version__ = "1.0.0"
20
+
21
+ __all__ = [
22
+ "causer",
23
+ "current_causer",
24
+ "CacheBackend",
25
+ "NullCache",
26
+ "TTLCache",
27
+ "DEFAULT_GROUP",
28
+ "GroupView",
29
+ "SettingsStore",
30
+ "CodecError",
31
+ "InvalidValueError",
32
+ "MissingExtraError",
33
+ "SimpleSettingsError",
34
+ "TransferError",
35
+ "UnknownTypeError",
36
+ "export_json",
37
+ "export_rows",
38
+ "import_json",
39
+ "import_rows",
40
+ "__version__",
41
+ ]
@@ -0,0 +1,75 @@
1
+ import json
2
+ import logging
3
+ from contextlib import contextmanager
4
+ from contextvars import ContextVar
5
+ from datetime import datetime
6
+ from typing import Any, Iterator
7
+
8
+ from sqlalchemy import Connection, Table, insert
9
+
10
+ logger = logging.getLogger("simple_db_settings")
11
+
12
+ EVENT_CREATED = "created"
13
+ EVENT_UPDATED = "updated"
14
+ EVENT_DELETED = "deleted"
15
+
16
+ _causer_var: ContextVar[tuple[str | None, int | None]] = ContextVar(
17
+ "simple_db_settings_causer", default=(None, None)
18
+ )
19
+
20
+
21
+ @contextmanager
22
+ def causer(causer_type: str, causer_id: int) -> Iterator[None]:
23
+ """Attribute audit rows written inside the block to this actor."""
24
+ token = _causer_var.set((causer_type, causer_id))
25
+ try:
26
+ yield
27
+ finally:
28
+ _causer_var.reset(token)
29
+
30
+
31
+ def current_causer() -> tuple[str | None, int | None]:
32
+ return _causer_var.get()
33
+
34
+
35
+ def payload(value: Any) -> str | None:
36
+ """Audit payload: SQL NULL both for missing values and for None."""
37
+ if value is None:
38
+ return None
39
+ return json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
40
+
41
+
42
+ def record(
43
+ conn: Connection,
44
+ table: Table,
45
+ group: str,
46
+ entries: list[dict[str, Any]],
47
+ now: datetime,
48
+ ) -> None:
49
+ """Write all audit rows of one operation with a single insert.
50
+
51
+ Each entry carries: name, event, old (value or None), new (value or None).
52
+ """
53
+ if not entries:
54
+ return
55
+
56
+ causer_type, causer_id = current_causer()
57
+ rows = [
58
+ {
59
+ "group": group,
60
+ "name": e["name"],
61
+ "event": e["event"],
62
+ "old_payload": payload(e["old"]),
63
+ "new_payload": payload(e["new"]),
64
+ "causer_type": causer_type,
65
+ "causer_id": causer_id,
66
+ "created_at": now,
67
+ }
68
+ for e in entries
69
+ ]
70
+ conn.execute(insert(table).values(rows))
71
+ logger.debug(
72
+ "audit recorded: group=%s events=%s",
73
+ group,
74
+ [e["event"] for e in entries],
75
+ )
@@ -0,0 +1,70 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ from typing import Any, Callable, Protocol
5
+
6
+ logger = logging.getLogger("simple_db_settings")
7
+
8
+ DEFAULT_TTL = 5.0
9
+
10
+
11
+ class CacheBackend(Protocol):
12
+ def get(self, group: str) -> dict[str, Any] | None: ...
13
+
14
+ def set(self, group: str, data: dict[str, Any]) -> None: ...
15
+
16
+ def invalidate(self, group: str) -> None: ...
17
+
18
+
19
+ class NullCache:
20
+ """Cache that never stores anything."""
21
+
22
+ def get(self, group: str) -> dict[str, Any] | None:
23
+ return None
24
+
25
+ def set(self, group: str, data: dict[str, Any]) -> None:
26
+ pass
27
+
28
+ def invalidate(self, group: str) -> None:
29
+ pass
30
+
31
+
32
+ class TTLCache:
33
+ """In-process cache of whole groups with a time-to-live.
34
+
35
+ The TTL bounds staleness across processes: another worker cannot
36
+ invalidate this cache, so entries must expire on their own.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ ttl: float = DEFAULT_TTL,
42
+ clock: Callable[[], float] = time.monotonic,
43
+ ) -> None:
44
+ self._ttl = ttl
45
+ self._clock = clock
46
+ self._lock = threading.Lock()
47
+ self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
48
+
49
+ def get(self, group: str) -> dict[str, Any] | None:
50
+ with self._lock:
51
+ entry = self._entries.get(group)
52
+ if entry is None:
53
+ logger.debug("cache miss: group=%s", group)
54
+ return None
55
+ expires_at, data = entry
56
+ if self._clock() >= expires_at:
57
+ del self._entries[group]
58
+ logger.debug("cache expired: group=%s", group)
59
+ return None
60
+ logger.debug("cache hit: group=%s", group)
61
+ return data
62
+
63
+ def set(self, group: str, data: dict[str, Any]) -> None:
64
+ with self._lock:
65
+ self._entries[group] = (self._clock() + self._ttl, data)
66
+
67
+ def invalidate(self, group: str) -> None:
68
+ with self._lock:
69
+ self._entries.pop(group, None)
70
+ logger.debug("cache invalidated: group=%s", group)
@@ -0,0 +1,183 @@
1
+ """Command line interface for the settings table (extra: cli)."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ from simple_db_settings.exceptions import MissingExtraError, SimpleSettingsError
10
+
11
+ try:
12
+ import typer
13
+ except ImportError as exc:
14
+ raise MissingExtraError("Command line interface", "cli") from exc
15
+
16
+ from sqlalchemy import create_engine
17
+ from sqlalchemy.pool import NullPool
18
+
19
+ from simple_db_settings.schema import DEFAULT_SETTINGS_TABLE
20
+ from simple_db_settings.store import DEFAULT_GROUP, SettingsStore
21
+ from simple_db_settings.transfer import export_json, import_json
22
+
23
+ app = typer.Typer(help="Manage DB-backed settings", no_args_is_help=True)
24
+
25
+ UrlOption = Annotated[
26
+ str,
27
+ typer.Option("--url", envvar="SIMPLE_DB_SETTINGS_URL", help="Database URL"),
28
+ ]
29
+ TableOption = Annotated[
30
+ str, typer.Option("--table", help="Settings table name")
31
+ ]
32
+ GroupOption = Annotated[str, typer.Option("--group", "-g", help="Settings group")]
33
+ VerboseOption = Annotated[bool, typer.Option("--verbose", help="Debug logging")]
34
+
35
+
36
+ def _store(url: str, table: str, verbose: bool) -> SettingsStore:
37
+ logging.basicConfig(level=logging.DEBUG if verbose else logging.WARNING)
38
+ engine = create_engine(url, poolclass=NullPool)
39
+ return SettingsStore(engine, table_name=table)
40
+
41
+
42
+ def _fail(message: str) -> "typer.Exit":
43
+ typer.echo(f"Error: {message}", err=True)
44
+ return typer.Exit(code=1)
45
+
46
+
47
+ @app.command()
48
+ def get(
49
+ key: str,
50
+ url: UrlOption,
51
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
52
+ group: GroupOption = DEFAULT_GROUP,
53
+ verbose: VerboseOption = False,
54
+ ):
55
+ """Print one setting as JSON."""
56
+ store = _store(url, table, verbose)
57
+ try:
58
+ value = store.group(group)[key]
59
+ except KeyError:
60
+ raise _fail(f"Setting {group}.{key} not found") from None
61
+ except SimpleSettingsError as exc:
62
+ raise _fail(str(exc)) from None
63
+ typer.echo(json.dumps(value, ensure_ascii=False))
64
+
65
+
66
+ @app.command("set")
67
+ def set_(
68
+ key: str,
69
+ value: str,
70
+ url: UrlOption,
71
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
72
+ group: GroupOption = DEFAULT_GROUP,
73
+ verbose: VerboseOption = False,
74
+ ):
75
+ """Store a setting. VALUE is parsed as JSON, falling back to a string."""
76
+ store = _store(url, table, verbose)
77
+ try:
78
+ parsed = json.loads(value)
79
+ except ValueError:
80
+ parsed = value
81
+ try:
82
+ store.group(group)[key] = parsed
83
+ except SimpleSettingsError as exc:
84
+ raise _fail(str(exc)) from None
85
+
86
+
87
+ @app.command()
88
+ def delete(
89
+ key: str,
90
+ url: UrlOption,
91
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
92
+ group: GroupOption = DEFAULT_GROUP,
93
+ verbose: VerboseOption = False,
94
+ ):
95
+ """Remove one setting."""
96
+ store = _store(url, table, verbose)
97
+ try:
98
+ del store.group(group)[key]
99
+ except KeyError:
100
+ raise _fail(f"Setting {group}.{key} not found") from None
101
+
102
+
103
+ @app.command("list")
104
+ def list_(
105
+ url: UrlOption,
106
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
107
+ group: Annotated[Optional[str], typer.Option("--group", "-g")] = None,
108
+ verbose: VerboseOption = False,
109
+ ):
110
+ """List raw rows: group, name, type, val."""
111
+ store = _store(url, table, verbose)
112
+ for row in store.rows(group):
113
+ typer.echo(f"{row.group}\t{row.name}\t{row.type}\t{row.val}")
114
+
115
+
116
+ @app.command()
117
+ def groups(
118
+ url: UrlOption,
119
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
120
+ verbose: VerboseOption = False,
121
+ ):
122
+ """List group names."""
123
+ store = _store(url, table, verbose)
124
+ for name in store.groups():
125
+ typer.echo(name)
126
+
127
+
128
+ @app.command()
129
+ def clear(
130
+ url: UrlOption,
131
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
132
+ group: GroupOption = DEFAULT_GROUP,
133
+ verbose: VerboseOption = False,
134
+ ):
135
+ """Remove all settings in a group."""
136
+ store = _store(url, table, verbose)
137
+ store.group(group).clear()
138
+
139
+
140
+ @app.command()
141
+ def export(
142
+ url: UrlOption,
143
+ file: Annotated[Optional[Path], typer.Argument()] = None,
144
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
145
+ group: Annotated[Optional[str], typer.Option("--group", "-g")] = None,
146
+ verbose: VerboseOption = False,
147
+ ):
148
+ """Export settings to JSON (stdout when FILE is omitted)."""
149
+ store = _store(url, table, verbose)
150
+ dump = export_json(store, group)
151
+ if file is None:
152
+ typer.echo(dump)
153
+ else:
154
+ file.write_text(dump)
155
+ typer.echo(f"Exported to {file}")
156
+
157
+
158
+ @app.command("import")
159
+ def import_(
160
+ file: Path,
161
+ url: UrlOption,
162
+ table: TableOption = DEFAULT_SETTINGS_TABLE,
163
+ group: Annotated[Optional[str], typer.Option("--group", "-g")] = None,
164
+ replace: Annotated[bool, typer.Option("--replace")] = False,
165
+ verbose: VerboseOption = False,
166
+ ):
167
+ """Import settings from a JSON file made by export."""
168
+ store = _store(url, table, verbose)
169
+ if not file.is_file():
170
+ raise _fail(f"File not found: {file}")
171
+ try:
172
+ count = import_json(store, file.read_text(), group=group, replace=replace)
173
+ except SimpleSettingsError as exc:
174
+ raise _fail(str(exc)) from None
175
+ typer.echo(f"Imported {count} setting(s)")
176
+
177
+
178
+ def main() -> None:
179
+ app()
180
+
181
+
182
+ if __name__ == "__main__":
183
+ sys.exit(main())
@@ -0,0 +1,67 @@
1
+ """Value codec for the shared settings table.
2
+
3
+ Follows the cross-language storage contract: values are written with
4
+ type='json', rows written by the Laravel package (string, integer, float,
5
+ boolean, array, object, null, legacy double) stay readable forever.
6
+ """
7
+
8
+ import json
9
+
10
+ from simple_db_settings.exceptions import (
11
+ CodecError,
12
+ InvalidValueError,
13
+ UnknownTypeError,
14
+ )
15
+
16
+ JSON_TYPE = "json"
17
+
18
+ _PHP_FALSE_STRINGS = frozenset(("", "0"))
19
+
20
+
21
+ def encode(value: object) -> tuple[str, str]:
22
+ """Serialize a value for storage. Returns (val, type)."""
23
+ try:
24
+ val = json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
25
+ except (TypeError, ValueError) as exc:
26
+ raise InvalidValueError(f"Value is not JSON-serializable: {exc}") from exc
27
+ return val, JSON_TYPE
28
+
29
+
30
+ def decode(val: str | None, type_: str, group: str = "", name: str = "") -> object:
31
+ """Restore a value from a stored (val, type) pair.
32
+
33
+ Dispatch is driven by the type column only, never by val content.
34
+ """
35
+ if val is None:
36
+ val = ""
37
+ kind = type_.strip().lower()
38
+
39
+ if kind == "string":
40
+ return val
41
+ if kind == "boolean":
42
+ return val not in _PHP_FALSE_STRINGS
43
+ if kind == "null":
44
+ return None
45
+ if kind == "integer":
46
+ try:
47
+ return int(val)
48
+ except ValueError as exc:
49
+ raise CodecError(
50
+ f"Bad integer value {val!r} for {group}.{name}"
51
+ ) from exc
52
+ if kind in ("float", "double"):
53
+ try:
54
+ return float(val)
55
+ except ValueError as exc:
56
+ raise CodecError(
57
+ f"Bad float value {val!r} for {group}.{name}"
58
+ ) from exc
59
+ if kind in ("array", "object", "json"):
60
+ try:
61
+ return json.loads(val)
62
+ except ValueError as exc:
63
+ raise CodecError(
64
+ f"Bad JSON value for {group}.{name}: {exc}"
65
+ ) from exc
66
+
67
+ raise UnknownTypeError(group, name, type_)
@@ -0,0 +1,38 @@
1
+ class SimpleSettingsError(Exception):
2
+ """Base error for the simple-db-settings package."""
3
+
4
+
5
+ class MissingExtraError(SimpleSettingsError):
6
+ """Optional dependency is not installed."""
7
+
8
+ def __init__(self, feature: str, extra: str) -> None:
9
+ self.feature = feature
10
+ self.extra = extra
11
+ super().__init__(
12
+ f"{feature} requires an optional dependency. "
13
+ f"Install it with: pip install simple-db-settings[{extra}]"
14
+ )
15
+
16
+
17
+ class InvalidValueError(SimpleSettingsError):
18
+ """Value cannot be serialized for storage."""
19
+
20
+
21
+ class CodecError(SimpleSettingsError):
22
+ """Stored data cannot be decoded."""
23
+
24
+
25
+ class TransferError(SimpleSettingsError):
26
+ """Export or import data does not match the exchange format."""
27
+
28
+
29
+ class UnknownTypeError(SimpleSettingsError):
30
+ """Row has a type value the codec does not recognize."""
31
+
32
+ def __init__(self, group: str, name: str, type_: str) -> None:
33
+ self.group = group
34
+ self.name = name
35
+ self.type = type_
36
+ super().__init__(
37
+ f"Unknown settings type {type_!r} for {group}.{name}"
38
+ )
File without changes
@@ -0,0 +1,50 @@
1
+ from sqlalchemy import (
2
+ CHAR,
3
+ BigInteger,
4
+ Column,
5
+ DateTime,
6
+ Index,
7
+ Integer,
8
+ MetaData,
9
+ String,
10
+ Table,
11
+ Text,
12
+ )
13
+
14
+ DEFAULT_SETTINGS_TABLE = "simple_settings"
15
+ DEFAULT_CHANGES_TABLE = "simple_setting_changes"
16
+
17
+ # Автоинкремент BigInteger не работает на SQLite, вариант Integer его чинит.
18
+ _PK_BIGINT = BigInteger().with_variant(Integer, "sqlite")
19
+
20
+
21
+ def make_settings_table(metadata: MetaData, name: str = DEFAULT_SETTINGS_TABLE) -> Table:
22
+ return Table(
23
+ name,
24
+ metadata,
25
+ Column("group", String(255), primary_key=True),
26
+ Column("name", String(255), primary_key=True),
27
+ Column("val", Text, nullable=False),
28
+ Column("type", CHAR(20), nullable=False, server_default="string"),
29
+ Column("created_at", DateTime, nullable=True),
30
+ Column("updated_at", DateTime, nullable=True),
31
+ )
32
+
33
+
34
+ def make_changes_table(metadata: MetaData, name: str = DEFAULT_CHANGES_TABLE) -> Table:
35
+ return Table(
36
+ name,
37
+ metadata,
38
+ Column("id", _PK_BIGINT, primary_key=True, autoincrement=True),
39
+ Column("group", String(255), nullable=False),
40
+ Column("name", String(255), nullable=False),
41
+ Column("event", String(20), nullable=False),
42
+ Column("old_payload", Text, nullable=True),
43
+ Column("new_payload", Text, nullable=True),
44
+ Column("causer_type", String(255), nullable=True),
45
+ Column("causer_id", BigInteger, nullable=True),
46
+ Column("created_at", DateTime, nullable=True),
47
+ Index(f"{name}_target_idx", "group", "name", "created_at"),
48
+ Index(f"{name}_causer_idx", "causer_type", "causer_id"),
49
+ Index(f"{name}_event_idx", "event"),
50
+ )