Add separate migration logging, logmigr callback, and log= override
- Split transaction logger (kanta.changes) and migration logger (kanta.migrations). - Migrations.apply() now returns MigrationResult instead of logging. - Kanta.open() emits one info summary per DB and debug transaction per migration. - Add open(log=...) to suppress/redirect default migration logging. - Add @kanta.logmigr callback for custom migration logging/summaries. - Add transaction(log=...) to suppress/redirect transaction logging. - Update tests for the new MigrationResult API and logging behaviour.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
@@ -6,6 +7,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from kanta.exceptions import DatabaseError, DataIntegrityError, FileLockError
|
||||
from kanta.migrations import MigrationResult
|
||||
from kanta.serialization import struct_to_dict
|
||||
|
||||
from .support import (
|
||||
@@ -72,9 +74,7 @@ async def test_new_file_persists_initial_state_for_roundtrip(tmp_path, format_co
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reopen_without_changes_does_not_force_snapshot(
|
||||
tmp_path, format_config
|
||||
):
|
||||
async def test_reopen_without_changes_does_not_force_snapshot(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
kanta = make_kanta(path, Data(counter=5), format_config)
|
||||
await kanta.open()
|
||||
@@ -576,6 +576,123 @@ async def test_migration_with_changes_records_diff_and_snapshot(
|
||||
assert snap.state == {"counter": 2, "users": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_summary_log_includes_filename(tmp_path, format_config, caplog):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
|
||||
|
||||
mod = type(sys)("test_migrations_log")
|
||||
|
||||
def migrate_v1(d, kanta):
|
||||
"""Bump counter."""
|
||||
d["counter"] = 2
|
||||
|
||||
mod.__dict__["migrate_v1"] = migrate_v1
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="kanta.migrations"):
|
||||
kanta = make_kanta(path, Data, format_config, migrations=mod)
|
||||
await kanta.open()
|
||||
assert kanta.version == 1
|
||||
await kanta.close()
|
||||
|
||||
info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert len(info_messages) == 1
|
||||
assert str(path) in info_messages[0]
|
||||
assert "v0 -> v1" in info_messages[0]
|
||||
assert "migrate_v1 (Bump counter)" in info_messages[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_log_false_suppresses_migration_log(tmp_path, format_config, caplog):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
|
||||
|
||||
mod = type(sys)("test_migrations_silent")
|
||||
|
||||
def migrate_v1(d, kanta):
|
||||
d["counter"] = 2
|
||||
|
||||
mod.__dict__["migrate_v1"] = migrate_v1
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="kanta.migrations"):
|
||||
kanta = make_kanta(path, Data, format_config, migrations=mod)
|
||||
await kanta.open(log=False)
|
||||
await kanta.close()
|
||||
|
||||
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert not info_messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logmigr_callback_replaces_default_logging(
|
||||
tmp_path, format_config, caplog
|
||||
):
|
||||
path = tmp_path / "test.db"
|
||||
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
|
||||
|
||||
mod = type(sys)("test_migrations_callback")
|
||||
|
||||
def migrate_v1(d, kanta):
|
||||
"""Bump counter."""
|
||||
d["counter"] = 2
|
||||
|
||||
mod.__dict__["migrate_v1"] = migrate_v1
|
||||
|
||||
summaries = []
|
||||
|
||||
kanta = make_kanta(path, Data, format_config, migrations=mod)
|
||||
|
||||
@kanta.logmigr
|
||||
def collect(summary: MigrationResult):
|
||||
summaries.append(summary)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="kanta.migrations"):
|
||||
await kanta.open()
|
||||
await kanta.close()
|
||||
|
||||
assert len(summaries) == 1
|
||||
assert summaries[0].version == 1
|
||||
assert summaries[0].migrations[0].name == "migrate_v1"
|
||||
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert not info_messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_log_false_suppresses_log(tmp_path, format_config, caplog):
|
||||
path = tmp_path / "test.db"
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
await kanta.open()
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="kanta.changes"):
|
||||
with kanta.transaction(action="inc", log=False) as data:
|
||||
data.counter = 1
|
||||
|
||||
await kanta.close()
|
||||
|
||||
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert not info_messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_log_custom_logger(tmp_path, format_config, caplog):
|
||||
path = tmp_path / "test.db"
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
await kanta.open()
|
||||
|
||||
custom_logger = logging.getLogger("custom.transaction")
|
||||
custom_logger.setLevel(logging.INFO)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="custom.transaction"):
|
||||
with kanta.transaction(action="inc", log=custom_logger) as data:
|
||||
data.counter = 1
|
||||
|
||||
await kanta.close()
|
||||
|
||||
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert len(info_messages) >= 1
|
||||
assert "inc" in info_messages[0].message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_locked_file_raises_filelock_error(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import logging
|
||||
|
||||
from kanta.logging import configure_logging, log_change
|
||||
from kanta.logging import logger
|
||||
from kanta.logging import changes_logger, configure_logging, log_change
|
||||
|
||||
|
||||
def test_configure_logging():
|
||||
configure_logging()
|
||||
assert logger.level == logging.INFO
|
||||
assert changes_logger.level == logging.INFO
|
||||
|
||||
|
||||
def test_log_change_no_diff(capsys):
|
||||
logger.handlers.clear()
|
||||
changes_logger.handlers.clear()
|
||||
configure_logging()
|
||||
log_change("test", {})
|
||||
captured = capsys.readouterr()
|
||||
|
||||
+38
-34
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -25,8 +24,8 @@ def test_register_and_apply():
|
||||
d["version"] = 2
|
||||
|
||||
state = {}
|
||||
new_ver = reg.apply(state, current_version=0, kanta=kanta, silent=True)
|
||||
assert new_ver == 2
|
||||
result = reg.apply(state, current_version=0, kanta=kanta)
|
||||
assert result.version == 2
|
||||
assert state["version"] == 2
|
||||
|
||||
|
||||
@@ -39,8 +38,8 @@ def test_no_migrations_needed():
|
||||
d["x"] = 1
|
||||
|
||||
state = {"x": 1}
|
||||
new_ver = reg.apply(state, current_version=1, kanta=kanta, silent=True)
|
||||
assert new_ver == 1
|
||||
result = reg.apply(state, current_version=1, kanta=kanta)
|
||||
assert result.version == 1
|
||||
|
||||
|
||||
def test_from_module():
|
||||
@@ -60,8 +59,8 @@ def test_from_module():
|
||||
assert reg.dbver == 2
|
||||
|
||||
state = {}
|
||||
new_ver = reg.apply(state, current_version=0, kanta=kanta, silent=True)
|
||||
assert new_ver == 2
|
||||
result = reg.apply(state, current_version=0, kanta=kanta)
|
||||
assert result.version == 2
|
||||
assert state["v"] == 2
|
||||
|
||||
|
||||
@@ -75,8 +74,8 @@ def test_migrations_can_use_kanta_ctx():
|
||||
d["source"] = kanta.ctx.source
|
||||
|
||||
state = {}
|
||||
new_ver = reg.apply(state, current_version=0, kanta=kanta, silent=True)
|
||||
assert new_ver == 1
|
||||
result = reg.apply(state, current_version=0, kanta=kanta)
|
||||
assert result.version == 1
|
||||
assert state["source"] == "migration"
|
||||
assert kanta.ctx.source == "migration"
|
||||
|
||||
@@ -90,8 +89,8 @@ def test_migration_can_omit_kanta_argument():
|
||||
d["x"] = 1
|
||||
|
||||
state = {}
|
||||
new_ver = reg.apply(state, current_version=0, kanta=kanta, silent=True)
|
||||
assert new_ver == 1
|
||||
result = reg.apply(state, current_version=0, kanta=kanta)
|
||||
assert result.version == 1
|
||||
assert state["x"] == 1
|
||||
|
||||
|
||||
@@ -107,7 +106,7 @@ def test_version_too_new():
|
||||
DatabaseError,
|
||||
match="Database version v2 is newer than the highest supported version v1",
|
||||
):
|
||||
reg.apply({}, current_version=2, kanta=kanta, silent=True)
|
||||
reg.apply({}, current_version=2, kanta=kanta)
|
||||
|
||||
|
||||
def test_version_too_old():
|
||||
@@ -122,7 +121,7 @@ def test_version_too_old():
|
||||
DatabaseError,
|
||||
match="Database version v1 is older than the minimum supported version v2",
|
||||
):
|
||||
reg.apply({}, current_version=1, kanta=kanta, silent=True)
|
||||
reg.apply({}, current_version=1, kanta=kanta)
|
||||
|
||||
|
||||
def test_missing_middle_migration_is_skipped():
|
||||
@@ -138,8 +137,8 @@ def test_missing_middle_migration_is_skipped():
|
||||
d["y"] = 3
|
||||
|
||||
state = {"x": 1}
|
||||
new_ver = reg.apply(state, current_version=1, kanta=kanta, silent=True)
|
||||
assert new_ver == 3
|
||||
result = reg.apply(state, current_version=1, kanta=kanta)
|
||||
assert result.version == 3
|
||||
assert state["x"] == 1
|
||||
assert state["y"] == 3
|
||||
|
||||
@@ -153,12 +152,12 @@ def test_old_migrations_deleted_current_supported():
|
||||
d["x"] = 3
|
||||
|
||||
state = {"x": 2}
|
||||
new_ver = reg.apply(state, current_version=2, kanta=kanta, silent=True)
|
||||
assert new_ver == 3
|
||||
result = reg.apply(state, current_version=2, kanta=kanta)
|
||||
assert result.version == 3
|
||||
assert state["x"] == 3
|
||||
|
||||
|
||||
def test_migration_log_only_when_changed(caplog):
|
||||
def test_apply_returns_change_information():
|
||||
reg = Migrations()
|
||||
kanta = _DummyKanta()
|
||||
|
||||
@@ -177,28 +176,33 @@ def test_migration_log_only_when_changed(caplog):
|
||||
"""Set y."""
|
||||
d["y"] = 3
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="kanta.migrations"):
|
||||
reg.apply({}, current_version=0, kanta=kanta)
|
||||
result = reg.apply({}, current_version=0, kanta=kanta)
|
||||
assert result.version == 3
|
||||
assert len(result.migrations) == 3
|
||||
|
||||
messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert len(messages) == 2
|
||||
assert "migrate_v1" in messages[0]
|
||||
assert "Set x" in messages[0]
|
||||
assert "migrate_v3" in messages[1]
|
||||
assert "Set y" in messages[1]
|
||||
assert result.migrations[0].name == "migrate_v1"
|
||||
assert result.migrations[0].description == "Set x"
|
||||
assert result.migrations[0].changed is True
|
||||
assert result.migrations[0].diff == {"$replace": {"x": 1}}
|
||||
|
||||
assert result.migrations[1].name == "migrate_v2"
|
||||
assert result.migrations[1].description == "No-op"
|
||||
assert result.migrations[1].changed is False
|
||||
assert result.migrations[1].diff is None
|
||||
|
||||
assert result.migrations[2].name == "migrate_v3"
|
||||
assert result.migrations[2].description == "Set y"
|
||||
assert result.migrations[2].changed is True
|
||||
assert result.migrations[2].diff == {"y": 3}
|
||||
|
||||
|
||||
def test_no_op_migration_produces_no_log(caplog):
|
||||
def test_description_defaults_to_version_when_no_docstring():
|
||||
reg = Migrations()
|
||||
kanta = _DummyKanta()
|
||||
|
||||
@reg.register
|
||||
def migrate_v1(d):
|
||||
"""No-op."""
|
||||
pass
|
||||
d["x"] = 1
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="kanta.migrations"):
|
||||
reg.apply({}, current_version=0, kanta=kanta)
|
||||
|
||||
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
|
||||
assert not info_messages
|
||||
result = reg.apply({}, current_version=0, kanta=kanta)
|
||||
assert result.migrations[0].description == "v1"
|
||||
|
||||
Reference in New Issue
Block a user