4 Commits
5 changed files with 38 additions and 19 deletions
+3 -1
View File
@@ -85,7 +85,9 @@ async def main() -> None:
data.total = 99 data.total = 99
raise ValueError("simulated failure") raise ValueError("simulated failure")
except ValueError: except ValueError:
print(f"\nReset rolled back: {data.total=} (we can always read data without tx)\n") print(
f"\nReset rolled back: {data.total=} (we can always read data without tx)\n"
)
with kanta.transaction(action="delete", user="userid002") as data: with kanta.transaction(action="delete", user="userid002") as data:
del data.users["userid001"] del data.users["userid001"]
+27 -13
View File
@@ -124,19 +124,22 @@ class LogEvent(msgspec.Struct, kw_only=True):
def emit_event( def emit_event(
ev: LogEvent, ev: LogEvent,
handlers: Iterable[Callable[[LogEvent], Any]] = (), handlers: Iterable[Callable[[LogEvent], Any]] = (),
*,
fallback: Callable[[LogEvent], None] | None = None,
) -> None: ) -> None:
"""Dispatch *ev* through registered logemit handlers. """Dispatch *ev* through registered logemit handlers.
Each handler receives the event and may log it (or not) as it sees fit. Each handler receives the event and may log it (or not) as it sees fit.
A falsy return value stops the chain: the event is considered handled. A falsy return value stops the chain: the event is considered handled.
A truthy return value passes the event — possibly modified — to the next A truthy return value passes the event — possibly modified — to the next
handler. When all handlers pass, :func:`default_emit` renders the event handler. When all handlers pass, the *fallback* renders the event;
with the built-in formatting. the default fallback is :func:`default_emit` with the built-in formatting.
Logging must never break functionality: a crashing handler is reported Logging must never break functionality: a crashing handler is reported
and the chain falls back to the built-in formatting, and a failure in and the chain falls back to the fallback rendering, and a failure in
the built-in formatting itself is reported and swallowed. the fallback itself is reported and swallowed.
""" """
render = fallback if fallback is not None else default_emit
try: try:
for handler in handlers: for handler in handlers:
try: try:
@@ -146,7 +149,7 @@ def emit_event(
break break
if not proceed: if not proceed:
return return
default_emit(ev) render(ev)
except Exception: except Exception:
_logger.exception("failed to emit %s log event", ev.kind) _logger.exception("failed to emit %s log event", ev.kind)
@@ -187,6 +190,11 @@ def _join_path(path: str, key: str) -> str:
return f"{path}.{key}" return f"{path}.{key}"
def _dim_ellipsis() -> str:
"""Return the truncation ellipsis in the palette's ellipsis color."""
return str(Line().ellipsis(""))
def _format_value( def _format_value(
value: Any, value: Any,
path: str, path: str,
@@ -209,7 +217,7 @@ def _format_value(
if isinstance(value, str): if isinstance(value, str):
value = _UNSAFE_CHARS.sub("", value) value = _UNSAFE_CHARS.sub("", value)
if len(value) > max_len: if len(value) > max_len:
return value[: max_len - 3] + "..." return value[: max_len - 1] + _dim_ellipsis()
return value return value
if isinstance(value, dict): if isinstance(value, dict):
if not value: if not value:
@@ -235,7 +243,7 @@ def _format_value(
return "[" + ", ".join(parts) + "]" return "[" + ", ".join(parts) + "]"
text = str(value) text = str(value)
if len(text) > max_len: if len(text) > max_len:
text = text[: max_len - 3] + "..." text = text[: max_len - 1] + _dim_ellipsis()
return text return text
@@ -361,15 +369,21 @@ def _format_change_lines(
path_str = _format_path(path, logfmt, final_color="add") path_str = _format_path(path, logfmt, final_color="add")
if isinstance(value, dict) and value: if isinstance(value, dict) and value:
lines = [str(Line()(" ", path_str, " ").sep("="))] lines = [str(Line()(" ", path_str, " ").sep("="))]
formatted_items = []
base_path = ".".join(path) base_path = ".".join(path)
for k, v in value.items(): keys = []
for k in value:
key_path = _join_path(base_path, str(k)) key_path = _join_path(base_path, str(k))
key_display = _format_value(k, key_path, max_len=30, logfmt=logfmt) keys.append((k, _format_value(k, key_path, max_len=30, logfmt=logfmt)))
v_str = _format_value(v, key_path, max_len=30, logfmt=logfmt) field_width = max(displaywidth(kd) for _, kd in keys)
formatted_items.append((key_display, v_str))
field_width = max(displaywidth(k) for k, _ in formatted_items)
field_width = max(field_width, 12) field_width = max(field_width, 12)
# Each item line is " {key:{field_width}}: {value}"; budget the
# value so the whole line fits in 80 columns.
value_width = max(80 - 4 - field_width - 2, 20)
formatted_items = []
for (k, key_display), v in zip(keys, value.values()):
key_path = _join_path(base_path, str(k))
v_str = _format_value(v, key_path, max_len=value_width, logfmt=logfmt)
formatted_items.append((key_display, v_str))
return lines + [ return lines + [
str( str(
Line()(" ", k).sep(":")( Line()(" ", k).sep(":")(
+1
View File
@@ -75,6 +75,7 @@ class Colors:
path_final = "38;5;250" # White for the final path element path_final = "38;5;250" # White for the final path element
add = "32" # Green for additions add = "32" # Green for additions
delete = "1;31" # Bold red for deletions delete = "1;31" # Bold red for deletions
ellipsis = "38;5;242" # Dark grey for the truncation ellipsis
colors = Colors() colors = Colors()
+3
View File
@@ -21,6 +21,9 @@ dependencies = [
"msgspec>=0.20.0", "msgspec>=0.20.0",
] ]
[project.scripts]
kanta = "kanta.__main__:main"
[project.optional-dependencies] [project.optional-dependencies]
bin = [ bin = [
"blake3>=1.0.8", "blake3>=1.0.8",
+4 -5
View File
@@ -562,13 +562,12 @@ async def test_migration_with_changes_records_diff_and_snapshot(
records = read_changes(path, format_config) records = read_changes(path, format_config)
migration_records = [r for r in records if r.a.startswith("migrate")] migration_records = [r for r in records if r.a.startswith("migrate")]
assert len(migration_records) == 2 # The version migration and the msgspec normalization that follows it are
# grouped into a single migrate:vN record.
assert len(migration_records) == 1
assert migration_records[0].a == "migrate:v1" assert migration_records[0].a == "migrate:v1"
assert migration_records[0].v == 1 assert migration_records[0].v == 1
assert migration_records[0].diff == {"counter": 2} assert migration_records[0].diff == {"counter": 2, "users": {}}
assert migration_records[1].a == "migrate:msgspec"
assert migration_records[1].v == 1
assert migration_records[1].diff == {"users": {}}
snap = read_last_snapshot(path, format_config) snap = read_last_snapshot(path, format_config)
assert snap is not None assert snap is not None