Repository: canonical/jubilant
Pull request #79: feat: condense Juju.wait status logging by using gron-style diff
Author: login: benhoyt
Created: 2025-04-03T21:54:03Z | Merged: 2025-04-05T05:43:38Z

The status logging from a long `Juju.wait` sequence, especially for a larger deployment, ends up being huge. Hopefully this will shrink it down significantly. The first status is shown in full, but then each time the status changes, we only show a diff of changed lines (inspired by gron). For example:

```
INFO - wait: status changed:
- .apps['database'].app_status.current = 'active'
+ .apps['database'].app_status.current = 'waiting'
- .apps['database'].relations['db'][0].scope = 'global'
- .apps['database'].relations['db'][1].related_app = 'dummy'
- .apps['database'].relations['db'][1].interface = 'xyz'
- .apps['database'].relations['db'][1].scope = 'foobar'
+ .apps['database'].relations['db'][0].scope = 'testy'"""
```

[Here's a gist](https://gist.github.com/benhoyt/e0067b641461b45158ab83ee9077f568) that shows output from the Jubilant integration test run of `test_charm_basics`.

Fixes #63

Linked issue #63: Condense wait status logging, perhaps by using gron-style diff?
Currently, when deploying a large model and then waiting with `Juju.wait`, the "status changed" logging is huge, tens of hundreds of thousands of lines long. We need to reduce the volume, perhaps with a gron-style diff.

Diff:
diff --git a/jubilant/_juju.py b/jubilant/_juju.py
index 13a72bb8..bfae8952 100644
--- a/jubilant/_juju.py
+++ b/jubilant/_juju.py
@@ -12,7 +12,7 @@
 from collections.abc import Callable, Iterable, Mapping
 from typing import Any, Union, overload
 
-from . import _yaml
+from . import _pretty, _yaml
 from ._task import Task
 from .statustypes import Status
 
@@ -654,7 +654,7 @@ def wait(
             prev_status = status
             status = self.status()
             if status != prev_status:
-                logger.info('wait: status changed:\n%s', status)
+                logger.info('wait: status changed:\n%s', _status_diff(prev_status, status))
 
             if error is not None and error(status):
                 raise WaitError(f'error function {error.__qualname__} returned false\n{status}')
@@ -692,3 +692,26 @@ def _format_config(k: str, v: ConfigValue) -> str:
     if isinstance(v, bool):
         v = 'true' if v else 'false'
     return f'{k}={v}'
+
+
+def _status_diff(old: Status | None, new: Status) -> str:
+    """Return a line-based diff of two status objects."""
+    if old is None:
+        old_lines = []
+    else:
+        old_lines = [line for line in _pretty.gron(old) if _status_line_ok(line)]
+    new_lines = [line for line in _pretty.gron(new) if _status_line_ok(line)]
+    return '\n'.join(_pretty.diff(old_lines, new_lines))
+
+
+def _status_line_ok(line: str) -> bool:
+    """Return whether the status line should be included in the diff."""
+    # Exclude controller timestamp as it changes every update and is just noise.
+    field, _, _ = line.partition(' = ')
+    if field == '.controller.timestamp':
+        return False
+    # Exclude status-updated-since timestamps as they just add noise (and log lines already
+    # include timestamps).
+    if field.endswith('.since'):
+        return False
+    return True
diff --git a/jubilant/_pretty.py b/jubilant/_pretty.py
index f7252388..06a94adb 100644
--- a/jubilant/_pretty.py
+++ b/jubilant/_pretty.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
 import dataclasses
+import difflib
+from collections.abc import Generator, Sequence
 from typing import cast
 
 _MAX_VALUE = 150
@@ -8,7 +10,7 @@
 _SIMPLE_TYPES = (type(None), bool, int, float, str)
 
 
-def _dump(value: object, indent: str = '') -> str:
+def dump(value: object, indent: str = '') -> str:
     """Pretty-print a value with special cases for dataclasses, lists, and dicts.
 
     If *value*'s fields or sub-values are simple scalar types and the result fits in a limited
@@ -28,7 +30,7 @@ def _dump(value: object, indent: str = '') -> str:
                 continue
             if field.default_factory is not dataclasses.MISSING and v == field.default_factory():
                 continue
-            v_str = _dump(v, sub_indent)
+            v_str = dump(v, sub_indent)
             fields.append(f'{field.name}={v_str}')
             is_simple = is_simple and isinstance(v, _SIMPLE_TYPES)
 
@@ -51,7 +53,7 @@ def _dump(value: object, indent: str = '') -> str:
             if len(single_line) <= _MAX_VALUE:
                 return single_line
 
-        items = [_dump(v, sub_indent) for v in value]
+        items = [dump(v, sub_indent) for v in value]
         lines_str = '\n'.join(sub_indent + item + ',' for item in items)
         return f'[\n{lines_str}\n{indent}]'
 
@@ -63,9 +65,79 @@ def _dump(value: object, indent: str = '') -> str:
             if len(single_line) <= _MAX_VALUE:
                 return single_line
 
-        items = [f'{k!r}: {_dump(v, sub_indent)}' for k, v in sorted(value.items())]
+        items = [f'{k!r}: {dump(v, sub_indent)}' for k, v in sorted(value.items())]
         lines_str = '\n'.join(sub_indent + item + ',' for item in items)
         return f'{{\n{lines_str}\n{indent}}}'
 
     else:
         return repr(value)
+
+
+def gron(value: object, prefix: str = '') -> Generator[str, None, None]:
+    """Yield gron-style lines of all fields within value, recursively.
+
+    This handles dataclasses, lists, and dicts. It's inspired by gron:
+    https://github.com/tomnomnom/gron
+
+    Example output for a :class:`Status` instance with ``prefix='status'`` (some lines omitted
+    for brevity)::
+
+        status.model.name = 'tt'
+        status.model.controller = 'microk8s-localhost'
+        status.model.version = '3.6.1'
+        status.apps['database'].charm = 'local:database-0'
+        status.apps['database'].charm_rev = 0
+        status.apps['database'].base.name = 'ubuntu'
+        status.apps['database'].base.channel = '22.04'
+        status.apps['database'].app_status.current = 'active'
+        status.apps['database'].app_status.message = 'relation-created: added new secret'
+        status.apps['database'].app_status.since = '24 Feb 2025 16:59:43+13:00'
+        status.apps['database'].relations['db'][0].related_app = 'webapp'
+        status.apps['database'].relations['db'][0].interface = 'dbi'
+        status.apps['database'].relations['db'][0].scope = 'global'
+        ...
+    """
+    if dataclasses.is_dataclass(value):
+        for field in dataclasses.fields(value):
+            v = getattr(value, field.name)
+            if field.default is not dataclasses.MISSING and v == field.default:
+                continue
+            if field.default_factory is not dataclasses.MISSING and v == field.default_factory():
+                continue
+            yield from gron(v, f'{prefix}.{field.name}')
+
+    elif isinstance(value, list):
+        value = cast('list[object]', value)
+        for i, v in enumerate(value):
+            yield from gron(v, f'{prefix}[{i}]')
+
+    elif isinstance(value, dict):
+        value = cast('dict[str, object]', value)
+        for k, v in sorted(value.items()):
+            yield from gron(v, f'{prefix}[{k!r}]')
+
+    else:
+        yield f'{prefix} = {value!r}'
+
+
+def diff(seq1: Sequence[str], seq2: Sequence[str]) -> Generator[str, None, None]:
+    """Compare seq1 and seq1; yield lines that have been removed, changed, or added.
+
+    Example::
+
+        - .apps['database'].app_status.current = 'active'
+        + .apps['database'].app_status.current = 'waiting'
+        - .apps['database'].relations['db'][0].scope = 'global'
+        - .apps['database'].relations['db'][1].related_app = 'dummy'
+        - .apps['database'].relations['db'][1].interface = 'xyz'
+        - .apps['database'].relations['db'][1].scope = 'foobar'
+        + .apps['database'].relations['db'][0].scope = 'testy'
+    """
+    matcher = difflib.SequenceMatcher(None, seq1, seq2)
+    for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+        if tag in {'replace', 'delete'}:
+            for line in seq1[i1:i2]:
+                yield '- ' + line
+        if tag in {'replace', 'insert'}:
+            for line in seq2[j1:j2]:
+                yield '+ ' + line
diff --git a/jubilant/statustypes.py b/jubilant/statustypes.py
index 9477b1ac..dd7de0c9 100644
--- a/jubilant/statustypes.py
+++ b/jubilant/statustypes.py
@@ -741,7 +741,7 @@ def _from_dict(cls, d: dict[str, Any]) -> Status:
 
     def __repr__(self):
         """Return a pretty-printed version of the status."""
-        return _pretty._dump(self)
+        return _pretty.dump(self)
 
     def __str__(self):
         """Return a pretty-printed version of the status."""
diff --git a/pyproject.toml b/pyproject.toml
index 1ec24119..e4c54304 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -97,6 +97,9 @@ ignore = [
 
     # `subprocess` call: check for execution of untrusted input
     "S603",
+
+    # Return the condition `x` directly
+    "SIM103",
 ]
 
 [tool.ruff.lint.pydocstyle]
diff --git a/tests/unit/test_pretty.py b/tests/unit/test_pretty.py
index c47671a7..c7424d62 100644
--- a/tests/unit/test_pretty.py
+++ b/tests/unit/test_pretty.py
@@ -150,3 +150,31 @@ def test_large():
     )
     assert str(status) == status_repr
     assert eval(status_repr, jubilant.statustypes.__dict__) == status
+
+
+def test_diff():
+    # It's simplest to test _status_diff directly, even though it's not public.
+    old_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json['applications']['database']['application-status']['current'] = 'waiting'
+    new_json['applications']['database']['application-status']['since'] = (
+        '24 Feb 2025 17:59:43+13:00'
+    )
+    new_json['applications']['database']['relations']['db'][0]['scope'] = 'testy'
+    del new_json['applications']['database']['relations']['db'][1]
+    old_status = jubilant.Status._from_dict(old_json)
+    new_status = jubilant.Status._from_dict(new_json)
+
+    diff = jubilant._juju._status_diff(old_status, new_status)
+
+    assert (
+        diff
+        == """\
+- .apps['database'].app_status.current = 'active'
++ .apps['database'].app_status.current = 'waiting'
+- .apps['database'].relations['db'][0].scope = 'global'
+- .apps['database'].relations['db'][1].related_app = 'dummy'
+- .apps['database'].relations['db'][1].interface = 'xyz'
+- .apps['database'].relations['db'][1].scope = 'foobar'
++ .apps['database'].relations['db'][0].scope = 'testy'"""
+    )
diff --git a/tests/unit/test_wait.py b/tests/unit/test_wait.py
index 8d4af68e..6470aa0c 100644
--- a/tests/unit/test_wait.py
+++ b/tests/unit/test_wait.py
@@ -29,8 +29,15 @@ def test_logging(run: mocks.Run, time: mocks.Time, caplog: pytest.LogCaptureFixt
     logs = [r for r in caplog.records if r.msg.startswith('wait:')]
     assert len(logs) == 1  # only logs on first call or when status changes
     message = logs[0].getMessage()
-    assert 'status changed' in message
-    assert 'mdl' in message
+    assert (
+        message
+        == """wait: status changed:
++ .model.name = 'mdl'
++ .model.type = 'typ'
++ .model.controller = 'ctl'
++ .model.cloud = 'aws'
++ .model.version = '3.0.0'"""
+    )
 
 
 def test_with_model(run: mocks.Run, time: mocks.Time):


Reviews:
user: login: dimaqq
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T06:29:00Z
user: login: benhoyt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-05T04:48:26Z
user: login: benhoyt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-05T04:48:35Z
user: login: benhoyt
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-05T04:49:50Z

Review comments:
diff_hunk: @@ -150,3 +150,28 @@ def test_large():
     )
     assert str(status) == status_repr
     assert eval(status_repr, jubilant.statustypes.__dict__) == status
+
+
+def test_diff():
+    # It's simplest to test _status_diff directly, even though it's not public.
+    old_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json['applications']['database']['application-status']['current'] = 'waiting'
+    new_json['applications']['database']['relations']['db'][0]['scope'] = 'testy'
+    del new_json['applications']['database']['relations']['db'][1]
+    old_status = jubilant.Status._from_dict(old_json)
+    new_status = jubilant.Status._from_dict(new_json)
+
+    diff = jubilant._juju._status_diff(old_status, new_status)
+
+    assert (
+        diff
+        == """\
path: tests/unit/test_pretty.py
user: login: dimaqq
body: nitpick: I'm allergic to line continuation characters, so I'd personally do this instead:

```
assert foo == """
text
text
""".strip()
```
created_at: 2025-04-04T06:07:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,3 +150,28 @@ def test_large():
     )
     assert str(status) == status_repr
     assert eval(status_repr, jubilant.statustypes.__dict__) == status
+
+
+def test_diff():
+    # It's simplest to test _status_diff directly, even though it's not public.
+    old_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json['applications']['database']['application-status']['current'] = 'waiting'
+    new_json['applications']['database']['relations']['db'][0]['scope'] = 'testy'
+    del new_json['applications']['database']['relations']['db'][1]
+    old_status = jubilant.Status._from_dict(old_json)
+    new_status = jubilant.Status._from_dict(new_json)
+
+    diff = jubilant._juju._status_diff(old_status, new_status)
+
+    assert (
+        diff
+        == """\
path: tests/unit/test_pretty.py
user: login: dimaqq
body: Some folks like to indent such test vectors and dedent on the fly.

Others move the expected value to a module-level constant to make the test look nicer.

I don't particularly care, just shooting ideas 🙇🏻 
created_at: 2025-04-04T06:09:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -695,3 +695,12 @@ def _format_config(k: str, v: ConfigValue) -> str:
     if isinstance(v, bool):
         v = 'true' if v else 'false'
     return f'{k}={v}'
+
+
+def _status_diff(old: Status | None, new: Status) -> str:
+    if old is None:
+        return '\n'.join('+ ' + x for x in _pretty.gron(new))
path: jubilant/_juju.py
user: login: dimaqq
body: ```suggestion
        return '\n'.join(f'+ {x}' for x in _pretty.gron(new) if not x.startswith('.controller.timestamp'))
```

Or maybe just set `old_lines` to an empty list and let the differ do the formatting.
created_at: 2025-04-04T06:10:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -654,7 +654,7 @@ def wait(
             prev_status = status
             status = self.status()
             if status != prev_status:
-                logger.info('wait: status changed:\n%s', status)
+                logger.info('wait: status changed:\n%s', _status_diff(prev_status, status))
path: jubilant/_juju.py
user: login: dimaqq
body: Do we always exit hard if `juju status` fails?

I've only seen that when a model is removed, but I wonder about the potential of other corner cases.
created_at: 2025-04-04T06:28:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,3 +150,28 @@ def test_large():
     )
     assert str(status) == status_repr
     assert eval(status_repr, jubilant.statustypes.__dict__) == status
+
+
+def test_diff():
+    # It's simplest to test _status_diff directly, even though it's not public.
+    old_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json = json.loads(DATABASE_WEBAPP_JSON)
+    new_json['applications']['database']['application-status']['current'] = 'waiting'
+    new_json['applications']['database']['relations']['db'][0]['scope'] = 'testy'
+    del new_json['applications']['database']['relations']['db'][1]
+    old_status = jubilant.Status._from_dict(old_json)
+    new_status = jubilant.Status._from_dict(new_json)
+
+    diff = jubilant._juju._status_diff(old_status, new_status)
+
+    assert (
+        diff
+        == """\
path: tests/unit/test_pretty.py
user: login: benhoyt
body: That's not bad, though I prefer the direct approach with the `\` (James suggested it originally) rather than dedent or strip, so I'm going to leave this as is. It's also consistent with the other such strings above.
created_at: 2025-04-05T04:48:26Z
author_association: COLLABORATOR
diff_hunk: @@ -695,3 +695,12 @@ def _format_config(k: str, v: ConfigValue) -> str:
     if isinstance(v, bool):
         v = 'true' if v else 'false'
     return f'{k}={v}'
+
+
+def _status_diff(old: Status | None, new: Status) -> str:
+    if old is None:
+        return '\n'.join('+ ' + x for x in _pretty.gron(new))
path: jubilant/_juju.py
user: login: benhoyt
body: Good idea - done.
created_at: 2025-04-05T04:48:35Z
author_association: COLLABORATOR
diff_hunk: @@ -654,7 +654,7 @@ def wait(
             prev_status = status
             status = self.status()
             if status != prev_status:
-                logger.info('wait: status changed:\n%s', status)
+                logger.info('wait: status changed:\n%s', _status_diff(prev_status, status))
path: jubilant/_juju.py
user: login: benhoyt
body: Well, if `juju status` fails we'll raise a `CLIError`. I think that's okay, because like you say, it should only happen if the model's torn down (which will normally only happen after the test finishes). If this is a problem we can always revisit.
created_at: 2025-04-05T04:49:50Z
author_association: COLLABORATOR

PR comments:
user: login: dimaqq
created_at: 2025-04-04T06:31:25Z
body: I wonder if it's cleaner to filter out the `{model,app,juju}_status.since` lines.
author_association: CONTRIBUTOR
user: login: benhoyt
created_at: 2025-04-05T04:50:27Z
body: > I wonder if it's cleaner to filter out the {model,app,juju}_status.since lines.

Good idea -- done.
author_association: COLLABORATOR
user: login: jnsgruk
created_at: 2025-04-05T07:18:35Z
body: Love this, excellent work 💪 
author_association: MEMBER