Add logemit event callbacks and kanta.tty terminal formatting
All change-related output (transactions, bootstrap, migrations) is now described by a mutable LogEvent carrying full state plus the preferred logger and level, and dispatched through emit_event. @kanta.logemit callbacks receive the event and decide what is logged where: falsy return marks it handled, truthy passes it (possibly modified) down the chain, with default_emit - Kanta's own formatting, now just another emitter - as the fallback. Pretty header and diff lines are lazy event properties. New kanta.tty module: Line builder (call to append content, .colorname arms a palette color for the next call with automatic folded reset, width/align padding), a mutable Colors palette storing bare SGR params (0 clears, sequential last-wins stacking), and strip_ansi/displaywidth/pad helpers that count wide chars and emoji correctly.
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
from kanta.logging import _ADD, _DELETE, format_diff
|
||||
from kanta.logging import format_diff
|
||||
from kanta.tty import ESC, colors
|
||||
|
||||
_ADD = f"{ESC}{colors.add}m"
|
||||
_DELETE = f"{ESC}{colors.delete}m"
|
||||
|
||||
|
||||
def test_add():
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from kanta.logging import (
|
||||
LogEvent,
|
||||
bootstrap_logger,
|
||||
configure_logging,
|
||||
emit_event,
|
||||
migration_logger,
|
||||
transaction_logger,
|
||||
)
|
||||
from tests.support import Data, make_kanta
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_kanta_loggers():
|
||||
yield
|
||||
for name in (
|
||||
"kanta",
|
||||
"kanta.transaction",
|
||||
"kanta.transaction.diff",
|
||||
"kanta.bootstrap",
|
||||
"kanta.migration",
|
||||
):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.NOTSET)
|
||||
logger.propagate = True
|
||||
logger.disabled = False
|
||||
logger.handlers.clear()
|
||||
|
||||
|
||||
def _change_event(**kwargs) -> LogEvent:
|
||||
return LogEvent(kind="change", logger=transaction_logger, action="update", **kwargs)
|
||||
|
||||
|
||||
def test_emit_event_falsy_return_stops_chain(capsys):
|
||||
logging.getLogger("kanta").handlers.clear()
|
||||
configure_logging()
|
||||
calls = []
|
||||
|
||||
def first(ev):
|
||||
calls.append("first")
|
||||
return None
|
||||
|
||||
def second(ev):
|
||||
calls.append("second")
|
||||
|
||||
emit_event(_change_event(), [first, second])
|
||||
assert calls == ["first"]
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_emit_event_truthy_return_falls_back_to_default(capsys):
|
||||
logging.getLogger("kanta").handlers.clear()
|
||||
configure_logging()
|
||||
emit_event(_change_event(), [lambda ev: True])
|
||||
assert "update" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_emit_event_mutation_reaches_later_handlers_and_default(capsys):
|
||||
logging.getLogger("kanta").handlers.clear()
|
||||
configure_logging()
|
||||
calls = []
|
||||
|
||||
def first(ev):
|
||||
calls.append("first")
|
||||
ev.extra = "tgt"
|
||||
return True
|
||||
|
||||
def second(ev):
|
||||
calls.append(("second", ev.extra))
|
||||
return True
|
||||
|
||||
emit_event(_change_event(), [first, second])
|
||||
assert calls == ["first", ("second", "tgt")]
|
||||
assert "tgt" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_emit_event_handler_error_falls_back_to_default(capsys):
|
||||
logging.getLogger("kanta").handlers.clear()
|
||||
configure_logging()
|
||||
|
||||
def boom(ev):
|
||||
raise RuntimeError("broken")
|
||||
|
||||
emit_event(_change_event(), [boom])
|
||||
assert "update" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_diff_lines_built_lazily(monkeypatch):
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError("format_diff should not be called")
|
||||
|
||||
monkeypatch.setattr("kanta.logging.format_diff", _boom)
|
||||
ev = _change_event(diff={"counter": 1})
|
||||
emit_event(ev, [lambda ev: None]) # handled without touching the diff
|
||||
monkeypatch.undo()
|
||||
assert len(ev.diff_lines) == 1
|
||||
assert "counter" in ev.diff_lines[0]
|
||||
|
||||
|
||||
def test_default_emit_created_and_migrated(capsys):
|
||||
logging.getLogger("kanta").handlers.clear()
|
||||
configure_logging()
|
||||
emit_event(LogEvent(kind="created", logger=bootstrap_logger, filename="x.kantadb"))
|
||||
emit_event(
|
||||
LogEvent(
|
||||
kind="migrated",
|
||||
logger=migration_logger,
|
||||
filename="x.kantadb",
|
||||
from_version=0,
|
||||
to_version=1,
|
||||
migrations=["migrate_v1 (rename)"],
|
||||
)
|
||||
)
|
||||
err = capsys.readouterr().err
|
||||
assert "Created x.kantadb" in err
|
||||
assert "Migrated x.kantadb v0 -> v1: migrate_v1 (rename)" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logemit_receives_transaction_events(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
events = []
|
||||
kanta.logemit(lambda ev: events.append(ev) or True)
|
||||
await kanta.open()
|
||||
|
||||
with kanta.transaction(action="inc", user="u1", extra="x") as data:
|
||||
data.counter = 1
|
||||
|
||||
await kanta.close()
|
||||
|
||||
change = events[-1]
|
||||
assert change.kind == "change"
|
||||
assert change.action == "inc"
|
||||
assert change.user == "u1"
|
||||
assert change.extra == "x"
|
||||
assert change.diff == {"counter": 1}
|
||||
assert change.logger.name == "kanta.transaction"
|
||||
|
||||
|
||||
def test_logemit_rejects_classes_and_async(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
class NotAFunction:
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
kanta.logemit(NotAFunction)
|
||||
|
||||
async def ahandler(ev):
|
||||
return None
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
kanta.logemit(ahandler)
|
||||
+10
-16
@@ -3,28 +3,22 @@ import logging
|
||||
import pytest
|
||||
|
||||
from kanta.logging import (
|
||||
_ACTION,
|
||||
_RESET,
|
||||
_TARGET,
|
||||
_USER,
|
||||
colorize_header_parts,
|
||||
configure_logging,
|
||||
format_action_header,
|
||||
log_change,
|
||||
)
|
||||
from kanta.tty import ESC
|
||||
|
||||
|
||||
def test_colorize_header_parts():
|
||||
action, user, extra = colorize_header_parts("update", "alice", "tgt")
|
||||
assert action == f"{_ACTION}update{_RESET}"
|
||||
assert user == f"{_USER}alice{_RESET}"
|
||||
assert extra == f"{_TARGET}tgt{_RESET}"
|
||||
def test_format_action_header():
|
||||
header = format_action_header("update", "alice", "tgt")
|
||||
assert header == (
|
||||
f"{ESC}1;34mupdate{ESC}0m {ESC}38;5;250mtgt{ESC}0m by {ESC}34malice{ESC}0m"
|
||||
)
|
||||
|
||||
|
||||
def test_colorize_header_parts_missing_user_and_extra():
|
||||
action, user, extra = colorize_header_parts("update")
|
||||
assert action == f"{_ACTION}update{_RESET}"
|
||||
assert user == ""
|
||||
assert extra == ""
|
||||
def test_format_action_header_action_only():
|
||||
assert format_action_header("update") == f"{ESC}1;34mupdate{ESC}0m"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -84,7 +78,7 @@ def test_log_change_appends_extra_string(capsys):
|
||||
log_change("export", {}, extra="mydb.db")
|
||||
captured = capsys.readouterr()
|
||||
assert "export" in captured.err
|
||||
assert f"{_TARGET}mydb.db{_RESET}" in captured.err
|
||||
assert f"{ESC}38;5;250mmydb.db{ESC}0m" in captured.err
|
||||
|
||||
|
||||
def test_log_change_log_diff_false(capsys, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import pytest
|
||||
|
||||
from kanta.tty import ESC, Colors, Line, colors, displaywidth, pad, strip_ansi
|
||||
|
||||
|
||||
def test_strip_ansi():
|
||||
assert strip_ansi(f"{ESC}1;34mhello{ESC}0m") == "hello"
|
||||
|
||||
|
||||
def test_displaywidth_plain_and_ansi():
|
||||
assert displaywidth("hello") == 5
|
||||
assert displaywidth(f"{ESC}38;5;226mhi{ESC}0m") == 2
|
||||
|
||||
|
||||
def test_displaywidth_wide_and_combining_chars():
|
||||
assert displaywidth("你好") == 4
|
||||
assert displaywidth("🚀") == 2
|
||||
assert displaywidth("é") == 1
|
||||
|
||||
|
||||
def test_pad():
|
||||
assert pad("ab", 4) == "ab "
|
||||
assert pad("ab", 4, align="right") == " ab"
|
||||
assert pad("ab", 5, align="center") == " ab "
|
||||
assert pad("abcdef", 4) == "abcdef"
|
||||
assert pad("你好", 6) == "你好 "
|
||||
|
||||
|
||||
def test_line_plain_and_str_conversion():
|
||||
assert str(Line()("n=", 42)) == "n=42"
|
||||
|
||||
|
||||
def test_line_color_auto_resets_on_next_call():
|
||||
assert str(Line().user("Alice")(" by ")) == f"{ESC}34mAlice{ESC}0m by "
|
||||
|
||||
|
||||
def test_line_str_restores_active_color():
|
||||
assert str(Line().user("Alice")) == f"{ESC}34mAlice{ESC}0m"
|
||||
|
||||
|
||||
def test_line_same_color_not_reemitted():
|
||||
assert str(Line().user("a").user("b")) == f"{ESC}34mab{ESC}0m"
|
||||
|
||||
|
||||
def test_line_transition_folds_reset_into_one_sequence():
|
||||
# bold blue -> plain blue: the bold clear rides in the same sequence
|
||||
assert str(Line().action("a").user("b")) == f"{ESC}1;34ma{ESC}0;34mb{ESC}0m"
|
||||
|
||||
|
||||
def test_line_unknown_color_raises():
|
||||
with pytest.raises(AttributeError, match="unknown color"):
|
||||
Line().nosuchcolor("x")
|
||||
|
||||
|
||||
def test_line_palette_addition(monkeypatch):
|
||||
monkeypatch.setattr(colors, "session", "38;5;226", raising=False)
|
||||
assert str(Line().session("3")) == f"{ESC}38;5;226m3{ESC}0m"
|
||||
|
||||
|
||||
def test_line_palette_override_takes_effect(monkeypatch):
|
||||
monkeypatch.setattr(colors, "user", "36")
|
||||
assert str(Line().user("x")) == f"{ESC}36mx{ESC}0m"
|
||||
|
||||
|
||||
def test_line_custom_palette():
|
||||
palette = Colors()
|
||||
palette.brand = "35"
|
||||
assert str(Line(palette).brand("x")) == f"{ESC}35mx{ESC}0m"
|
||||
|
||||
|
||||
def test_line_width_and_align():
|
||||
assert str(Line()("ab", width=4)) == "ab "
|
||||
assert str(Line()("ab", width=4, align="right")) == " ab"
|
||||
assert str(Line().user("ab", width=4)) == f"{ESC}34mab {ESC}0m"
|
||||
Reference in New Issue
Block a user