Files
maigret/tests/test_notify.py
T
Nguyen Thanh DatandGitHub da0fe665b8 Keep result lines and the AI spinner from crashing on a non-UTF-8 stdout (#3056)
* fix: keep result lines from crashing on a non-UTF-8 stdout

#3050 stopped the startup banners from raising UnicodeEncodeError on a
Windows console, but the banner was not the only line the ANSI codepage can
break. Two ordinary things a run prints still went through a bare print():

- the --enrich notifications built in checking.py carry U+2192, which cp1252
  has no slot for (checking.py:1847, 1851, 1859, 1899);
- any profile field maigret extracts carries the script the page was written
  in, so a Cyrillic or CJK fullname breaks the result line that reports it.

Both arrive mid-scan, after the network work is done, so the crash discarded
results the run had already collected -- worse than failing at startup.

Route every write in notify.py through the existing _print_encodable helper.

Verified on a real cp1252 stdout, not a mock: both cases raise before this
change and print with replacement characters after it.

* fix: keep the AI spinner alive on a stderr that cannot encode braille

Second half of #3055. The spinner frames are braille, which cp1252 -- the
ANSI codepage of a stock Windows install -- cannot encode, so the first frame
raised UnicodeEncodeError inside the daemon thread: the thread died with a
traceback printed over the output and the animation stopped for the rest of
the run.

Encoding with replacement would only leave a row of '?' spinning, so pick the
frame set from what the stream can carry instead. A UTF-8 terminal keeps the
braille; anything that cannot hold it gets |/-\ and still animates.

print_streaming() in the same module writes model output the same way and
would raise on any non-Latin reply, but it has no callers anywhere in the
tree, so it is left alone rather than fixed as dead code.
2026-08-31 18:26:21 +02:00

322 lines
11 KiB
Python

from colorama import Fore, Style
from maigret.errors import CheckError
from maigret.notify import QueryNotifyPrint
from maigret.result import MaigretCheckStatus, MaigretCheckResult
def test_notify_illegal():
n = QueryNotifyPrint(color=False)
assert (
n.update(
MaigretCheckResult(
username="test",
status=MaigretCheckStatus.ILLEGAL,
site_name="TEST_SITE",
site_url_user="http://example.com/test",
)
)
== "[-] TEST_SITE: Illegal Username Format For This Site!"
)
def test_notify_claimed():
n = QueryNotifyPrint(color=False)
assert (
n.update(
MaigretCheckResult(
username="test",
status=MaigretCheckStatus.CLAIMED,
site_name="TEST_SITE",
site_url_user="http://example.com/test",
)
)
== "[+] TEST_SITE: http://example.com/test"
)
def test_notify_available():
n = QueryNotifyPrint(color=False)
assert (
n.update(
MaigretCheckResult(
username="test",
status=MaigretCheckStatus.AVAILABLE,
site_name="TEST_SITE",
site_url_user="http://example.com/test",
)
)
== "[-] TEST_SITE: Not found!"
)
def test_notify_unknown():
n = QueryNotifyPrint(color=False)
result = MaigretCheckResult(
username="test",
status=MaigretCheckStatus.UNKNOWN,
site_name="TEST_SITE",
site_url_user="http://example.com/test",
)
result.error = CheckError('Type', 'Reason')
assert n.update(result) == "[?] TEST_SITE: Type error: Reason"
# `warning(message, symbol, advice=None)` was added so that the "Too many
# errors..." summary can render the count line bold and the advice in
# normal weight. The pieces must stay visually distinct because the advice
# is multi-line guidance, not part of the alarm.
def _capture_warning_string(monkeypatch, **warning_kwargs):
"""Patch builtins.print so we capture the *exact* string warning passed —
independent of colorama's terminal/TTY heuristics, which strip ANSI in
capsys mode. We care about what notify generated, not what colorama
decided to do with it on a captured pipe."""
captured = []
import builtins
monkeypatch.setattr(builtins, 'print', lambda *a, **kw: captured.append(a[0] if a else ''))
n = QueryNotifyPrint(color=warning_kwargs.pop('color', True))
n.warning(**warning_kwargs)
assert captured, "warning() did not call print()"
return captured[0]
def test_warning_no_advice_renders_as_single_yellow_bold_line(monkeypatch):
out = _capture_warning_string(
monkeypatch, color=True, message='something happened', symbol='!',
)
# Existing behaviour preserved for the no-advice path
assert out == Style.BRIGHT + Fore.YELLOW + '[!] something happened'
def test_warning_with_advice_keeps_header_bold_and_advice_normal(monkeypatch):
"""Advice must come after Style.NORMAL so terminals stop boldfacing it.
The Fore.YELLOW colour stays — the visual cue is weight, not colour."""
out = _capture_warning_string(
monkeypatch, color=True, message='count line', symbol='!', advice='do the thing',
)
# Header is bold...
assert out.startswith(Style.BRIGHT + Fore.YELLOW + '[!] count line')
# ...advice is preceded by Style.NORMAL so the boldface ends before it
assert Style.NORMAL + '. do the thing' in out
# ...and the whole line resets all SGR state at the end so no styling
# leaks into the next print
assert out.endswith(Style.RESET_ALL)
def test_warning_with_advice_no_color_uses_plain_dot_separator(monkeypatch):
"""In no-colour mode the visual distinction is impossible to render, so
a plain ". " separator between the count line and the advice is enough.
No ANSI codes must leak into the output."""
out = _capture_warning_string(
monkeypatch, color=False, message='count line', symbol='!', advice='do the thing',
)
assert out == '[!] count line. do the thing'
# Defence in depth: no escape codes survived the no-colour branch
assert '\x1b[' not in out
def _capture_print(monkeypatch):
captured = []
import builtins
monkeypatch.setattr(builtins, 'print', lambda *a, **kw: captured.append(a[0] if a else ''))
return captured
def _capture_stdout_writes(monkeypatch):
"""Capture both sys.stdout.write and print — enrich() writes a bare
line-clear escape via stdout.write and then calls print for the message."""
import builtins, sys
captured = []
monkeypatch.setattr(builtins, 'print', lambda *a, **kw: captured.append(a[0] if a else ''))
monkeypatch.setattr(sys.stdout, 'write', lambda s: captured.append(('write', s)))
return captured
def test_enrich_uses_magenta_and_star_symbol(monkeypatch):
captured = _capture_print(monkeypatch)
QueryNotifyPrint(color=True).enrich('hello')
assert Style.BRIGHT + Fore.MAGENTA + '[*] hello' in captured
def test_enrich_no_color_is_plain(monkeypatch):
captured = _capture_print(monkeypatch)
QueryNotifyPrint(color=False).enrich('hello')
assert '[*] hello' in captured
assert all('\x1b[' not in c for c in captured)
def test_enrich_writes_line_clear_before_print(monkeypatch):
"""Clears the alive_progress 'on N:' prefix from the current line before printing.
Colorama strips the ANSI escape when stdout looks non-TTY (monkeypatched
stdout does), collapsing '\\x1b[1K\\r' to '\\r'. Either form is a valid
line-clear signal to the terminal, so accept both."""
captured = _capture_stdout_writes(monkeypatch)
QueryNotifyPrint(color=False).enrich('hello')
op, text = captured[0]
assert op == 'write'
assert text in ('\x1b[1K\r', '\r')
assert '[*] hello' in captured
def test_enrich_verbose_only_hidden_by_default(monkeypatch):
captured = _capture_print(monkeypatch)
QueryNotifyPrint(color=False, verbose=False).enrich('diagnostic', verbose_only=True)
assert captured == []
def test_enrich_verbose_only_shown_when_verbose(monkeypatch):
captured = _capture_print(monkeypatch)
QueryNotifyPrint(color=False, verbose=True).enrich('diagnostic', verbose_only=True)
assert '[*] diagnostic' in captured
def _claimed_result_with_ids(ids_data):
r = MaigretCheckResult(
username="u", site_name="S", site_url_user="https://s/u",
status=MaigretCheckStatus.CLAIMED,
)
r.ids_data = ids_data
return r
def test_update_hides_extractor_field_by_default(monkeypatch):
captured = _capture_print(monkeypatch)
n = QueryNotifyPrint(color=False, verbose=False)
n.update(_claimed_result_with_ids({"uid": "42", "_extractor": "SomeSchemeAPI"}))
out = "\n".join(captured)
assert "uid: 42" in out
assert "_extractor" not in out
def test_update_shows_extractor_field_when_verbose(monkeypatch):
captured = _capture_print(monkeypatch)
n = QueryNotifyPrint(color=False, verbose=True)
n.update(_claimed_result_with_ids({"uid": "42", "_extractor": "SomeSchemeAPI"}))
out = "\n".join(captured)
assert "uid: 42" in out
assert "_extractor: SomeSchemeAPI" in out
def test_banners_survive_a_stdout_that_cannot_encode_them(tmp_path):
"""The banners carry '♥', which the Windows ANSI codepage (cp1252 on a
default install) cannot encode, so printing them raised UnicodeEncodeError
before a single site was checked. --no-color did not help: the character is
in that branch too, and the PyInstaller build ignores PYTHONIOENCODING, so
there was no way around it from outside.
Run the banners in a child process whose stdout really is cp1252 rather
than mocking the failure, so this fails on any machine if the guard goes.
"""
import os
import subprocess
import sys
import textwrap
probe = tmp_path / "banner_probe.py"
probe.write_text(
textwrap.dedent(
"""
from maigret.notify import print_intro_banner, print_donate_banner
print_intro_banner(no_color=True)
print_donate_banner(no_color=True)
print_intro_banner(no_color=False)
print_donate_banner(no_color=False)
"""
),
encoding="utf-8",
)
env = dict(os.environ)
env["PYTHONIOENCODING"] = "cp1252"
result = subprocess.run(
[sys.executable, str(probe)],
capture_output=True,
encoding="cp1252",
errors="replace",
env=env,
cwd=str(tmp_path),
)
assert result.returncode == 0, (
f"the banner must not crash the run: stderr={result.stderr!r}"
)
assert "Support Maigret" in result.stdout, (
f"the banner must still be printed: stdout={result.stdout!r}"
)
def test_result_lines_survive_a_stdout_that_cannot_encode_them(tmp_path):
"""The banner was not the only line the Windows ANSI codepage can break.
Two ordinary things a run prints carry characters cp1252 has no slot for:
the --enrich notifications built in checking.py contain U+2192, and any
profile field extracted from a page carries the script that page is
written in. Both arrive mid-scan, so the crash threw away work already
done rather than failing at startup.
Run them in a child process whose stdout really is cp1252, the same way
the banner test does, so this fails on any machine if the guard goes.
"""
import os
import subprocess
import sys
import textwrap
probe = tmp_path / "result_probe.py"
probe.write_text(
textwrap.dedent(
"""
from maigret.notify import QueryNotifyPrint
from maigret.result import MaigretCheckStatus, MaigretCheckResult
for color in (False, True):
n = QueryNotifyPrint(color=color)
n.enrich("VK: https://vk.com/x → status=404, empty body")
n.update(
MaigretCheckResult(
username="tester",
status=MaigretCheckStatus.CLAIMED,
site_name="VK",
site_url_user="https://vk.com/tester",
ids_data={"fullname": "Иван", "city": "東京"},
)
)
n.warning("2 sites failed", advice="rerun with → --retries 3")
n.info("done")
print("REACHED_END")
"""
),
encoding="utf-8",
)
env = dict(os.environ)
env["PYTHONIOENCODING"] = "cp1252"
result = subprocess.run(
[sys.executable, str(probe)],
capture_output=True,
encoding="cp1252",
errors="replace",
env=env,
cwd=str(tmp_path),
)
assert result.returncode == 0, (
f"a result line must not crash the run: stderr={result.stderr!r}"
)
assert "REACHED_END" in result.stdout, (
f"the run must reach the end: stdout={result.stdout!r}"
)
assert "vk.com/tester" in result.stdout, (
f"the result itself must still be printed: stdout={result.stdout!r}"
)