Metadata-Version: 2.4
Name: simple-db-settings
Version: 1.0.0
Summary: DB-backed settings manager on SQLAlchemy, table-compatible with timurturdyev/simple-settings
Project-URL: Homepage, https://github.com/TimurTurdyev/simple-settings-py
Project-URL: Repository, https://github.com/TimurTurdyev/simple-settings-py
Project-URL: Issues, https://github.com/TimurTurdyev/simple-settings-py/issues
Project-URL: Changelog, https://github.com/TimurTurdyev/simple-settings-py/blob/main/CHANGELOG.md
Author: Timur Turdyev
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: sqlalchemy>=2.0
Provides-Extra: cli
Requires-Dist: typer>=0.12; extra == 'cli'
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: pydantic
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == 'redis'
Provides-Extra: test-db
Requires-Dist: cryptography>=42.0; extra == 'test-db'
Requires-Dist: psycopg[binary]>=3.1; extra == 'test-db'
Requires-Dist: pymysql>=1.1; extra == 'test-db'
Description-Content-Type: text/markdown

# Simple DB Settings

<p align="center">
  <img src="art/banner.svg" alt="Simple DB Settings" width="100%">
</p>

Менеджер настроек в БД для Python на SQLAlchemy 2: группы, TTL-кэш, аудит изменений, типизация через pydantic. Идея взята из Laravel-пакета [timurturdyev/simple-settings](https://github.com/timurturdyev/simple-settings) и совместима с ним по таблице: приложения на Python и PHP могут работать с одними и теми же настройками.

[English](#english)

---

## Требования

- Python 3.11+
- SQLAlchemy 2.0+
- Любая СУБД с драйвером для SQLAlchemy: SQLite, PostgreSQL, MySQL/MariaDB

## Установка

```bash
pip install simple-db-settings
```

Дополнительные возможности ставятся экстрами:

```bash
pip install "simple-db-settings[pydantic]"   # типизированные группы
pip install "simple-db-settings[cli]"        # консольная команда
```

## Быстрый старт

```python
from sqlalchemy import create_engine
from simple_db_settings import SettingsStore

engine = create_engine("sqlite:///app.db")
store = SettingsStore(engine)
store.create_tables()   # или создайте таблицы своей миграцией

site = store.group("site")
site["name"] = "My App"
site["per_page"] = 15

site["name"]                    # 'My App'
site.get("missing", "default")  # 'default'
```

## Группы

Настройки разделены по группам. Группа по умолчанию - `global`.

```python
email = store.group("email")
email["host"] = "smtp.example.com"

store.group("site").get("host")   # None - другая группа
store.groups()                    # ['email', 'site']
```

`GroupView` ведет себя как обычный словарь, работают все привычные операции:

```python
"host" in email    # True
len(email)         # 1
dict(email)        # {'host': 'smtp.example.com'}
del email["host"]  # удалить ключ
email.clear()      # удалить все настройки группы
```

## Типы данных

Значения сериализуются в JSON и восстанавливаются без ручного приведения:

```python
site["count"]   = 42            # int
site["price"]   = 9.99          # float
site["enabled"] = True          # bool
site["tags"]    = ["a", "b"]    # list
site["meta"]    = {"k": "v"}    # dict
site["empty"]   = None          # None
```

## Массовая запись

```python
site.update({
    "name": "My App",
    "url": "https://example.com",
    "per_page": 15,
})
```

Вся пачка уходит в БД одним upsert-запросом в одной транзакции. Валидация идет до записи: невалиден хоть один ключ - не сохранится ни один. Кэш сбрасывается один раз после коммита.

## Кэш

Чтение идет через TTL-кэш (по умолчанию 5 секунд), кэшируется карта группы целиком.

```python
from simple_db_settings import NullCache

store = SettingsStore(engine, cache_ttl=30)        # свой TTL
store = SettingsStore(engine, cache=NullCache())   # без кэша

site.fresh()   # прочитать группу из БД мимо кэша
```

Кэш инвалидируется только после коммита транзакции, поэтому параллельный запрос не затянет в кэш еще не зафиксированные данные. При нескольких процессах (gunicorn, celery) устаревание ограничено TTL.

Свой бэкенд - любой объект с методами `get` / `set` / `invalidate` (протокол `CacheBackend`), например обертка над Redis.

## Транзакции

По умолчанию каждая операция - отдельная короткая транзакция. Чтобы записать настройки атомарно вместе со своими данными, передайте соединение:

```python
with engine.begin() as conn:
    conn.execute(orders_table.insert().values(user_id=1))
    store.with_connection(conn).group("site")["last_order"] = 1
```

`with_connection()` возвращает копию стора на внешнем соединении: она ничего не коммитит сама, коммит общий, кэш сбросится после него.

## Аудит

Опциональная история изменений в таблице `simple_setting_changes`:

```python
from simple_db_settings import SettingsStore, causer

store = SettingsStore(engine, audit=True)

with causer("user", 42):
    store.group("site")["per_page"] = 20
```

Каждая запись и удаление кладет строку: `event` (`created` / `updated` / `deleted`), `old_payload`, `new_payload`, `causer_type`, `causer_id`, `created_at`. Строки аудита пишутся в той же транзакции, что и сами настройки: либо сохранилось все, либо ничего. Вне контекста `causer()` автор будет `NULL`.

Как и в PHP-пакете, `clear()` записей в истории не создает, а холостое удаление (ключа нет) не оставляет следов.

Чтение истории - обычный select:

```python
from sqlalchemy import MetaData, select
from simple_db_settings.schema import make_changes_table

changes = make_changes_table(MetaData())
with engine.connect() as conn:
    rows = conn.execute(
        select(changes)
        .where(changes.c.group == "site", changes.c.name == "per_page")
        .order_by(changes.c.created_at.desc())
        .limit(20)
    ).all()
```

## Типизированные настройки

Экстра `pydantic`. Дефолты живут в коде, БД хранит только отличия от них:

```python
from pydantic import BaseModel

class SiteSettings(BaseModel):
    name: str = "My App"
    per_page: int = 15
    maintenance: bool = False

typed = store.typed(SiteSettings, group="site")

cfg = typed.load()        # дефолты + оверрайды из БД
cfg.per_page = 50
typed.save(cfg)           # в БД уйдет только per_page

typed.reset("per_page")   # вернуть поле к дефолту
typed.reset()             # вернуть все поля
```

`save()` удаляет из БД поля, значение которых совпало с дефолтом, поэтому смена дефолта в коде сразу видна везде, где поле не переопределяли.

## Консольная команда

Экстра `cli`. URL базы передается флагом `--url` или переменной окружения `SIMPLE_DB_SETTINGS_URL`.

```bash
export SIMPLE_DB_SETTINGS_URL="sqlite:///app.db"

# Получить и установить (значение парсится как JSON, иначе строка)
simple-db-settings get site_name
simple-db-settings get host --group email
simple-db-settings set per_page 15
simple-db-settings set tags '["a","b"]'

# Список настроек и групп
simple-db-settings list
simple-db-settings list --group email
simple-db-settings groups

# Удаление
simple-db-settings delete site_name
simple-db-settings clear --group email

# Экспорт и импорт JSON
simple-db-settings export backup.json
simple-db-settings export --group email
simple-db-settings import backup.json
simple-db-settings import backup.json --replace
```

## Экспорт и импорт

```python
from simple_db_settings import export_json, import_json

dump = export_json(store)            # все группы
dump = export_json(store, "email")   # одна группа

import_json(store, dump)                 # merge: существующие ключи перезаписываются
import_json(store, dump, replace=True)   # сначала очистить группы из файла
```

Файл несет сырые `val` и `type`, поэтому типы переносятся без потерь. Формат совместим с `setting:export` / `setting:import` из PHP-пакета в обе стороны. Валидация идет до записи: битая запись в файле - и не импортируется ничего.

## Совместимость с Laravel-пакетом

Пакет работает с той же таблицей, что и timurturdyev/simple-settings v6:

- схема идентична: `(group, name, val, type, created_at, updated_at)`, составной первичный ключ `(group, name)`
- Python читает все типы, которые пишет PHP: `string`, `integer`, `float`, `boolean`, `array`, `object`, `null` (и legacy `double`)
- Python пишет `type = 'json'`; PHP читает его начиная с v6.1
- таблица аудита и формат export/import тоже общие

Одна таблица настроек - разные приложения на разных языках.

## Как это работает

<p align="center">
  <img src="art/flow.svg" alt="Схема взаимодействия" width="100%">
</p>

**Точка входа** - `SettingsStore`: ему отдают engine и, по желанию, имя таблицы, кэш и флаг аудита. Стор сам ничего не хранит, он раздает `GroupView` - живые представления групп, через которые идет вся работа. CLI, типизированный слой и export/import - обертки над теми же `GroupView`, отдельных путей к БД у них нет.

**Чтение.** `site["per_page"]` сначала смотрит в кэш. Если карта группы там и не протухла - БД не трогается вообще. При промахе одним select-ом читается вся группа, каждая строка прогоняется через кодек (по колонке `type`) и готовая карта кладется в кэш. Следующие чтения любой настройки этой группы бесплатны до истечения TTL.

**Запись.** `site["x"] = 1` и `site.update({...})` собирают пачку, кодируют значения в JSON и отправляют одним upsert-запросом: insert с обработкой конфликта по `(group, name)` на диалекте вашей СУБД. Если включен аудит, в той же транзакции читаются старые значения и одним insert-ом пишутся строки истории. Кэш группы сбрасывается строго после коммита.

**Внешняя транзакция.** Стор, полученный через `with_connection()`, выполняет те же операции на вашем соединении и не коммитит: судьбу транзакции решает вызывающий код. Сброс кэша откладывается до реального коммита, а откат не оставляет в кэше мусора.

**Вторая сторона таблицы.** Laravel-приложение с пакетом simple-settings ходит в ту же таблицу со своим кэшем. Оба пакета переживают типы через колонку `type`, поэтому настройка, записанная одним, корректно читается другим.

## Лимиты значений

Колонка `val` создается как `TEXT` - на MySQL/MariaDB это 65 535 байт (~64 KB), на PostgreSQL и SQLite ограничения нет. Для типовых настроек этого с большим запасом. Если уперлись в лимит - это сигнал, что в одну настройку положили что-то не то (каталог товаров, лог, контент). Таким данным нужна своя таблица.

## Схема БД

```
simple_settings
    group       string
    name        string
    val         text
    type        char(20)
    created_at
    updated_at

PRIMARY KEY (group, name)

simple_setting_changes
    id          bigint PK
    group       string
    name        string
    event       string       created / updated / deleted
    old_payload text NULL
    new_payload text NULL
    causer_type string NULL
    causer_id   bigint NULL
    created_at
```

## Справочник API

| Метод | Описание |
|-------|----------|
| `SettingsStore(engine, table_name=..., cache=..., cache_ttl=..., audit=..., changes_table_name=...)` | Создать стор |
| `store.group(name)` | `GroupView` для группы |
| `store.groups()` | Список всех групп |
| `store.rows(group=None)` | Сырые строки с фильтром по группе |
| `store.typed(ModelCls, group=...)` | Типизированная группа (pydantic) |
| `store.with_connection(conn)` | Копия стора на внешнем соединении |
| `store.create_tables()` | Создать таблицы |
| `view[key]` / `view.get(key, default)` | Получить значение |
| `view[key] = value` | Записать значение |
| `del view[key]` | Удалить ключ |
| `view.update(mapping)` | Записать пачку атомарно |
| `view.fresh()` | Прочитать группу мимо кэша |
| `view.clear()` | Удалить все настройки группы |
| `causer(type, id)` | Контекст автора изменений для аудита |
| `export_json(store, group=None)` | Экспорт в JSON-строку |
| `import_json(store, text, group=None, replace=False)` | Импорт из JSON-строки |

## Лицензия

MIT

---

## English

DB-backed settings manager for Python on SQLAlchemy 2: group namespacing, TTL cache, change audit, typed groups via pydantic. Table-compatible with the Laravel package [timurturdyev/simple-settings](https://github.com/timurturdyev/simple-settings): Python and PHP apps can share the same settings table.

**Requirements:** Python 3.11+, SQLAlchemy 2.0+; SQLite, PostgreSQL or MySQL/MariaDB.

**Install:**

```bash
pip install simple-db-settings
pip install "simple-db-settings[pydantic,cli]"   # optional extras
```

**Basic usage:**

```python
from sqlalchemy import create_engine
from simple_db_settings import SettingsStore

store = SettingsStore(create_engine("sqlite:///app.db"))
store.create_tables()

site = store.group("site")
site["per_page"] = 15
site.get("missing", "default")
site.update({"a": 1, "b": 2})   # single upsert, all-or-nothing
site.fresh()                    # bypass cache
```

Values are stored as JSON and restored automatically (int, float, bool, str, list, dict, None). Reads go through a per-group TTL cache invalidated only after commit. Pass a connection via `store.with_connection(conn)` to join your own transaction.

**Audit:** `SettingsStore(engine, audit=True)` records every create/update/delete into `simple_setting_changes` within the same transaction; wrap calls in `causer("user", 42)` to attach the author.

**Typed groups:** defaults live in code, the DB keeps only overrides; `typed.save()` deletes rows that returned to their defaults.

**CLI:** `simple-db-settings get/set/delete/list/groups/clear/export/import` with `--url` or `SIMPLE_DB_SETTINGS_URL`.

**Cross-language:** same table schema as the Laravel package v6; Python reads every PHP type and writes `type = 'json'`, which PHP reads since v6.1. Export files are interchangeable.

For full documentation see the Russian section above.
