Skip to content

Commit 839986b

Browse files
authored
feat(emitter): add a log dumping function (#331)
1 parent 790e307 commit 839986b

File tree

4 files changed

+109
-22
lines changed

4 files changed

+109
-22
lines changed

craft_cli/messages.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,18 @@ def error(self, error: errors.CraftError) -> None:
780780
self._report_error(error)
781781
self._stop()
782782

783+
@_active_guard()
784+
def append_to_log(self, file: TextIO, prefix: str = ":: ") -> None:
785+
"""Dump the contents of an external log file into the emitter log.
786+
787+
:param file: A file I/O object to read from
788+
:param prefix: A prefix for every line printed. Defaults to ":: ".
789+
"""
790+
for line in file:
791+
text = f"{prefix}{line}"
792+
self._printer.log.write(text)
793+
self._printer.log.flush()
794+
783795
@_active_guard()
784796
def set_secrets(self, secrets: list[str]) -> None:
785797
"""Set the list of strings that should be masked out in all output."""
@@ -830,6 +842,11 @@ def prompt(self, prompt_text: str, *, hide: bool = False) -> str:
830842
raise errors.CraftError("input cannot be empty")
831843
return val
832844

845+
@property
846+
def log_filepath(self) -> pathlib.Path:
847+
"""The path to the log file."""
848+
return self._log_filepath
849+
833850

834851
# module-level instantiated Emitter; this is the instance all code shall use and Emitter
835852
# shall not be instantiated again for the process' run

docs/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ Breaking changes
1515
- Deprecates support for Python 3.8 and adds support for Python 3.11
1616
and 3.12.
1717

18+
New features
19+
20+
- Add an ``append_to_log`` method to the emitter, which reads from a file
21+
and dumps it directly into the log.
22+
- Add a ``log_filepath`` read-only property to the emitter.
23+
1824
2.15.0 (2025-Jan-23)
1925
--------------------
2026

tests/integration/test_messages_integration.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import logging
2424
import os
25+
import pathlib
2526
import re
2627
import subprocess
2728
import sys
@@ -108,9 +109,9 @@ def compare_lines(expected_lines: Collection[Line], raw_stream: str, std_stream)
108109
else:
109110
# If the terminal doesn't support ANSI escape sequences, we fill the screen
110111
# width and don't terminate lines, so we split lines according to that length
111-
assert (
112-
len(raw_stream) % width == 0
113-
), f"Bad length {len(raw_stream)} ({width=}) {raw_stream=!r}"
112+
assert len(raw_stream) % width == 0, (
113+
f"Bad length {len(raw_stream)} ({width=}) {raw_stream=!r}"
114+
)
114115
args = [iter(raw_stream)] * width
115116
lines = ["".join(x) for x in zip(*args)] # pyright: ignore[reportGeneralTypeIssues]
116117
else:
@@ -909,7 +910,7 @@ def test_simple_errors_debugish(capsys, mode):
909910
@pytest.mark.parametrize("output_is_terminal", [True, False])
910911
def test_error_api_details_quiet(capsys):
911912
"""Somewhat expected API error, final user modes.
912-
913+
913914
Check that "quiet" is indeed quiet.
914915
"""
915916
emit = Emitter()
@@ -929,7 +930,7 @@ def test_error_api_details_quiet(capsys):
929930
]
930931
assert_outputs(capsys, emit, expected_err=expected_err, expected_log=expected_log)
931932

932-
933+
933934
@pytest.mark.parametrize(
934935
"mode",
935936
[
@@ -1684,3 +1685,50 @@ def test_open_stream_no_text(capsys, logger, monkeypatch, init_emitter):
16841685
expected_err=expected_err,
16851686
expected_log=expected_log,
16861687
)
1688+
1689+
1690+
@pytest.mark.parametrize(
1691+
"prefix",
1692+
[
1693+
pytest.param("", id="no_prefix"),
1694+
pytest.param(":: ", id="simple_prefix"),
1695+
pytest.param("2000-01-01 12:00:00.000", id="weird_prefix"),
1696+
],
1697+
)
1698+
@pytest.mark.parametrize(
1699+
"file_contents",
1700+
[
1701+
pytest.param("", id="no_content"),
1702+
pytest.param("\n\n\n", id="only_newlines"),
1703+
pytest.param("one\ntwo\nthree", id="simple_content"),
1704+
pytest.param("one\ntwo\nthree\n", id="simple_with_newline"),
1705+
],
1706+
)
1707+
@pytest.mark.parametrize(
1708+
"output_is_terminal", [pytest.param(True, id="is_term"), pytest.param(False, id="not_term")]
1709+
)
1710+
@pytest.mark.usefixtures("init_emitter")
1711+
def test_append_to_log(tmp_path: pathlib.Path, prefix: str, file_contents: str) -> None:
1712+
file_to_dump = tmp_path / "file.txt"
1713+
file_to_dump.write_text(file_contents)
1714+
1715+
emit = Emitter()
1716+
emit.init(EmitterMode.QUIET, "testapp", GREETING)
1717+
1718+
emit.debug("We're having fun with it now!")
1719+
with file_to_dump.open() as file:
1720+
emit.append_to_log(file=file, prefix=prefix)
1721+
emit.debug("We are no longer having fun.")
1722+
1723+
prefixed_file_contents = [f"{prefix}{line}" for line in file_contents.splitlines()]
1724+
expected_output = [
1725+
rf"{TIMESTAMP_FORMAT}Specific greeting to be ignored",
1726+
rf"{TIMESTAMP_FORMAT}We're having fun with it now\!",
1727+
*prefixed_file_contents,
1728+
rf"{TIMESTAMP_FORMAT}We are no longer having fun\.",
1729+
]
1730+
1731+
log_content = emit.log_filepath.read_text()
1732+
1733+
for expected, actual in zip(expected_output, log_content.splitlines()):
1734+
assert re.match(expected, actual)

tests/unit/test_messages_emitter.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,8 @@
2020
import sys
2121
from unittest import mock
2222
from unittest.mock import call, patch
23-
from typing import cast, Callable
23+
from typing import Any, cast, Callable
2424

25-
import craft_cli
2625
import pytest
2726
import pytest_mock
2827

@@ -88,6 +87,34 @@ def func(mode, *, greeting="default greeting", **kwargs):
8887

8988
yield func
9089

90+
def emitter_methods(init: bool, stop: bool = True, exclude: list[str] = []) -> list[Callable[..., Any]]:
91+
"""Provide a list of all public methods on an Emitter object.
92+
93+
:param init: Whether or not to initialize the emitter first
94+
:param stop: Whether or not to stop the emitter after initialization. Does nothing if init is False.
95+
Defaults to true.
96+
:param exclude: A list of method names to exclude from the final output. Defaults to empty.
97+
"""
98+
emitter = Emitter()
99+
if init:
100+
emitter.init(EmitterMode.QUIET, "testappname", "default greeting")
101+
if stop:
102+
emitter.ended_ok()
103+
104+
# Collect all the public methods in Emitter
105+
all_methods = [item for item in dir(Emitter) if item[0] != "_"]
106+
107+
# Filter out from the exclusion list
108+
all_methods = [item for item in all_methods if item not in exclude]
109+
110+
# Get the actual attributes
111+
all_methods = [getattr(emitter, item) for item in all_methods]
112+
113+
# Filter out anything that isn't actually a method
114+
all_methods = [item for item in all_methods if isinstance(item, Callable)]
115+
116+
return all_methods
117+
91118

92119
# -- tests for init and setting/getting mode
93120

@@ -180,12 +207,9 @@ def test_init_developer_modes(mode, tmp_path, monkeypatch):
180207
(handler,) = [x for x in logger.handlers if isinstance(x, _Handler)]
181208
assert handler.mode == mode
182209

183-
184-
@pytest.mark.parametrize("method_name", [x for x in dir(Emitter) if x[0] != "_" and x != "init"])
185-
def test_needs_init(method_name):
210+
@pytest.mark.parametrize("method", emitter_methods(init=False, exclude=["init"]))
211+
def test_needs_init(method):
186212
"""Check that calling other methods needs emitter first to be initiated."""
187-
emitter = Emitter()
188-
method = getattr(emitter, method_name)
189213
with pytest.raises(RuntimeError, match="Emitter needs to be initiated first"):
190214
method()
191215

@@ -775,17 +799,9 @@ def test_ended_double_after_error(get_initiated_emitter):
775799
emitter.ended_ok()
776800
assert emitter.printer_calls == []
777801

778-
779-
@pytest.mark.parametrize(
780-
"method_name",
781-
[x for x in dir(Emitter) if x[0] != "_" and x not in ("init", "ended_ok", "error")],
782-
)
783-
def test_needs_being_active(get_initiated_emitter, method_name):
802+
@pytest.mark.parametrize("method", emitter_methods(init=True, exclude=["init", "ended_ok", "error"]))
803+
def test_needs_being_active(method):
784804
"""Check that calling public methods needs emitter to not be stopped."""
785-
emitter = get_initiated_emitter(EmitterMode.QUIET)
786-
emitter.ended_ok()
787-
788-
method = getattr(emitter, method_name)
789805
with pytest.raises(RuntimeError, match="Emitter is stopped already"):
790806
method()
791807

0 commit comments

Comments
 (0)