lib-detect-testenv 3.0.3__tar.gz → 3.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/CHANGELOG.md +12 -0
  2. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/Makefile +2 -2
  3. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/PKG-INFO +3 -3
  4. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/pyproject.toml +3 -3
  5. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__init__conf__.py +1 -1
  6. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/cli.py +2 -1
  7. lib_detect_testenv-3.0.4/src/lib_detect_testenv/safe_console.py +213 -0
  8. lib_detect_testenv-3.0.4/tests/test_safe_console.py +113 -0
  9. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.devcontainer/devcontainer.json +0 -0
  10. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.devcontainer/settings.json +0 -0
  11. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.env.example +0 -0
  12. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/actions/extract-metadata/action.yml +0 -0
  13. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/dependabot.yml +0 -0
  14. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/codeql.yml +0 -0
  15. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/default_cicd_public.yml +0 -0
  16. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/default_release_public.yml +0 -0
  17. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.gitignore +0 -0
  18. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.qlty/qlty.toml +0 -0
  19. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.snyk +0 -0
  20. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/CLAUDE.md +0 -0
  21. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/CONTRIBUTING.md +0 -0
  22. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/DEVELOPMENT.md +0 -0
  23. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/INSTALL.md +0 -0
  24. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/LICENSE +0 -0
  25. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/README.md +0 -0
  26. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/codecov.yml +0 -0
  27. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/concept.md +0 -0
  28. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/docs/systemdesign/module_reference.md +0 -0
  29. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/notebooks/Quickstart.ipynb +0 -0
  30. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/reset_git_history.sh +0 -0
  31. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__init__.py +0 -0
  32. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__main__.py +0 -0
  33. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/behaviors.py +0 -0
  34. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/lib_detect_testenv.py +0 -0
  35. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/py.typed +0 -0
  36. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/typed_click.py +0 -0
  37. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/conftest.py +0 -0
  38. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_behaviors.py +0 -0
  39. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_cli.py +0 -0
  40. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_lib_detect_testenv.py +0 -0
  41. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_metadata.py +0 -0
  42. {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_module_entry.py +0 -0
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.4] 2026-08-01 00:14:59
11
+ ### Fixed
12
+ - **Console output no longer crashes on a legacy codepage.** A Windows console at codepage 1252
13
+ hands Python an `errors="strict"` stream, so writing a non-ASCII character such as a check mark
14
+ raised `UnicodeEncodeError: 'charmap' codec can't encode character` and the command exited
15
+ non-zero *after* its work had already succeeded. Click does not protect against this, and Rich
16
+ raises the same way through its own writer. The new `safe_console` module degrades at the sink:
17
+ a UTF-8 terminal still receives the character, and only a stream that cannot encode it sees
18
+ `[OK]` / `[X]` / `[!]`. Every `click.echo` call site routes through it, and a test fails if a
19
+ module reaches for `click.echo` or builds an unwrapped `Console(file=sys.stdout)` again.
20
+
21
+
10
22
  ## [3.0.3] 2026-07-24 16:01:54
11
23
 
12
24
  ### Fixed
@@ -1,4 +1,4 @@
1
- # BMK MAKEFILE 3.13.0
1
+ # BMK MAKEFILE 3.14.0
2
2
  # do not alter this file - it might be overwritten on new versions of BMK
3
3
  # if You want to alter it, remove the first line # BMK MAKEFILE 1.0 - then it is a custom makefile and will not be overwritten
4
4
  # bmk Makefile - thin wrapper using `uv tool upgrade` for persistent bmk
@@ -186,7 +186,7 @@ endif
186
186
  # BMK_MIN floors the FALLBACK install. The upgrade path does not consult it: uv re-resolves
187
187
  # the requirement recorded at install time, which is itself a `bmk>=...`, so it always lands
188
188
  # on the newest release and clears the floor anyway.
189
- BMK_MIN := 3.13.0
189
+ BMK_MIN := 3.14.0
190
190
 
191
191
  # Absent env, missing check, or damaged env - all three mean "do not upgrade, rebuild".
192
192
  # `test -x` keeps a fresh machine quiet: there is no interpreter to run yet.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lib_detect_testenv
3
- Version: 3.0.3
3
+ Version: 3.0.4
4
4
  Summary: Detect test environment - pytest, doctest, unittest, or regular execution
5
5
  Project-URL: Homepage, https://github.com/bitranox/lib_detect_testenv
6
6
  Project-URL: Repository, https://github.com/bitranox/lib_detect_testenv.git
@@ -34,10 +34,10 @@ Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
34
34
  Requires-Dist: pytest<9,>=8.4.2; (python_version < '3.10') and extra == 'dev'
35
35
  Requires-Dist: pytest>=9.1.1; (python_version >= '3.10') and extra == 'dev'
36
36
  Requires-Dist: rtoml>=0.13.0; extra == 'dev'
37
- Requires-Dist: ruff>=0.16.0; extra == 'dev'
37
+ Requires-Dist: ruff>=0.16.1; extra == 'dev'
38
38
  Requires-Dist: setuptools>=83.0.0; extra == 'dev'
39
39
  Requires-Dist: textual>=8.2.8; extra == 'dev'
40
- Requires-Dist: twine>=6.2.0; extra == 'dev'
40
+ Requires-Dist: twine>=7.0.0; extra == 'dev'
41
41
  Description-Content-Type: text/markdown
42
42
 
43
43
  # lib_detect_testenv
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lib_detect_testenv"
3
- version = "3.0.3"
3
+ version = "3.0.4"
4
4
  description = "Detect test environment - pytest, doctest, unittest, or regular execution"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -33,14 +33,14 @@ dev = [
33
33
  "pytest>=8.4.2,<9; python_version<'3.10'",
34
34
  "pytest>=9.1.1; python_version>='3.10'",
35
35
  "pytest-cov>=7.1.0",
36
- "ruff>=0.16.0",
36
+ "ruff>=0.16.1",
37
37
  "pyright[nodejs]>=1.1.411",
38
38
  "bandit>=1.8.6,<1.9; python_version<'3.10'",
39
39
  "bandit>=1.9.4; python_version>='3.10'",
40
40
  # 1.5.1 was yanked upstream (unintended breaking changes), so 1.5.0 is the newest usable
41
41
  # release; a >=1.5.1 floor has ZERO candidates and makes this extra unresolvable.
42
42
  "build>=1.5.0",
43
- "twine>=6.2.0",
43
+ "twine>=7.0.0",
44
44
  # codecov-cli is disabled, not dropped: it requires click<8.3.0, which drags click back to
45
45
  # 8.2.1 - the version with CVE-2026-7246 (command injection in click.edit(), fixed in 8.3.3).
46
46
  # Nothing else here caps click, so with this line active the audit gate goes red on every
@@ -26,7 +26,7 @@ name = "lib_detect_testenv"
26
26
  #: Human-readable summary shown in CLI help output.
27
27
  title = "Detect test environment - pytest, doctest, unittest, or regular execution"
28
28
  #: Current release version pulled from ``pyproject.toml`` by automation.
29
- version = "3.0.3"
29
+ version = "3.0.4"
30
30
  #: Repository homepage presented to users.
31
31
  homepage = "https://github.com/bitranox/lib_detect_testenv"
32
32
  #: Author attribution surfaced in CLI output.
@@ -46,6 +46,7 @@ from .lib_detect_testenv import (
46
46
  is_setup_test_active,
47
47
  is_testenv_active,
48
48
  )
49
+ from .safe_console import safe_stream
49
50
  from .typed_click import option, version_option
50
51
 
51
52
  if TYPE_CHECKING:
@@ -55,7 +56,7 @@ if TYPE_CHECKING:
55
56
  CLICK_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
56
57
 
57
58
  #: Console for rich output
58
- console = Console()
59
+ console = Console(file=safe_stream())
59
60
 
60
61
 
61
62
  @click.group(
@@ -0,0 +1,213 @@
1
+ """Encode-safe console output.
2
+
3
+ Purpose
4
+ -------
5
+ Wraps :func:`click.echo` so a console whose codepage cannot represent a glyph
6
+ degrades that glyph instead of aborting the command.
7
+
8
+ Why
9
+ ---
10
+ Console output is a sink with an encoding the program does not choose. Python
11
+ hands stdout to a Windows console at codepage 1252 with ``errors="strict"``, so
12
+ writing ``✓`` raises ``UnicodeEncodeError: 'charmap' codec can't encode
13
+ character '\\u2713'`` and the command exits non-zero -- after its real work has
14
+ already succeeded, which is the part that misleads. ``click.echo`` does not
15
+ protect against this; the exception propagates.
16
+
17
+ Degrading at the SINK keeps the glyphs where they are wanted: an email body or
18
+ a UTF-8 terminal still receives ``✓``, and only a stream that genuinely cannot
19
+ encode it sees ``[OK]``. Callers therefore write the glyph they mean and never
20
+ branch on the platform.
21
+
22
+ Contents
23
+ --------
24
+ * :data:`ASCII_FALLBACKS` - the glyph-to-ASCII map
25
+ * :func:`ascii_fallback` - transliterate text for a target encoding
26
+ * :func:`encode_safe` - degrade text only when the encoding rejects it
27
+ * :func:`echo` - the :func:`click.echo` replacement every module uses
28
+ * :func:`safe_stream` - the same protection for a writer this module does not
29
+ own, such as the one a :class:`rich.console.Console` writes through
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import sys
35
+ from typing import IO, Any, Final, TextIO
36
+
37
+ import rich_click as click
38
+
39
+ ASCII_FALLBACKS: Final[dict[str, str]] = {
40
+ "✓": "[OK]", # check mark
41
+ "✔": "[OK]", # heavy check mark
42
+ "✅": "[OK]", # white heavy check mark
43
+ "✗": "[X]", # ballot X
44
+ "✘": "[X]", # heavy ballot X
45
+ "❌": "[X]", # cross mark
46
+ "⚠": "[!]", # warning sign
47
+ "️": "", # variation selector 16, trails an emoji glyph and carries no text
48
+ "•": "-", # bullet
49
+ "≥": ">=",
50
+ "≤": "<=",
51
+ "→": "->",
52
+ "←": "<-",
53
+ # The quotation marks are spelled as escapes on purpose: written literally
54
+ # they are indistinguishable from ASCII ' and " in most editors, which is
55
+ # exactly the confusion ruff's RUF001 exists to flag.
56
+ "\u2018": "'", # left single quotation mark
57
+ "\u2019": "'", # right single quotation mark
58
+ "\u201c": '"', # left double quotation mark
59
+ "\u201d": '"', # right double quotation mark
60
+ "…": "...",
61
+ }
62
+
63
+ #: Encodings that represent every code point, so the check can be skipped.
64
+ _UNIVERSAL_ENCODINGS: Final[frozenset[str]] = frozenset({"utf-8", "utf8", "utf-16", "utf16", "utf-32", "utf32"})
65
+
66
+
67
+ def _stream_encoding(file: IO[Any] | None) -> str | None:
68
+ """Return the target stream's encoding, or None when it cannot be determined.
69
+
70
+ An unknown encoding means the caller gets the original text: guessing would
71
+ degrade output that may well have been fine.
72
+ """
73
+ stream = file if file is not None else click.get_text_stream("stdout")
74
+ encoding = getattr(stream, "encoding", None)
75
+ return encoding if isinstance(encoding, str) else None
76
+
77
+
78
+ def ascii_fallback(text: str, encoding: str) -> str:
79
+ """Rewrite `text` so it survives `encoding`.
80
+
81
+ Known glyphs become their ASCII equivalent from :data:`ASCII_FALLBACKS`;
82
+ anything else the codec still cannot represent becomes ``?``. Text the
83
+ encoding already accepts is returned unchanged.
84
+
85
+ Parameters
86
+ ----------
87
+ text:
88
+ The message as the caller wrote it.
89
+ encoding:
90
+ The target stream's encoding, e.g. ``"cp1252"``.
91
+
92
+ Returns
93
+ -------
94
+ str
95
+ A string that :meth:`str.encode` accepts for `encoding`.
96
+ """
97
+ mapped = "".join(ASCII_FALLBACKS.get(character, character) for character in text)
98
+ return mapped.encode(encoding, errors="replace").decode(encoding)
99
+
100
+
101
+ def encode_safe(text: str, encoding: str | None) -> str:
102
+ """Return `text` if `encoding` accepts it, else its ASCII fallback.
103
+
104
+ The check runs BEFORE the write on purpose. Writing first and catching
105
+ ``UnicodeEncodeError`` would leave the already-encoded prefix on the stream,
106
+ so the retry would duplicate it.
107
+ """
108
+ if encoding is None or encoding.lower() in _UNIVERSAL_ENCODINGS:
109
+ return text
110
+ try:
111
+ text.encode(encoding)
112
+ except UnicodeEncodeError:
113
+ return ascii_fallback(text, encoding)
114
+ return text
115
+
116
+
117
+ def echo(message: object = "", *, file: IO[Any] | None = None, err: bool = False, nl: bool = True) -> None:
118
+ """Write `message` to the console, degrading anything it cannot encode.
119
+
120
+ Drop-in for :func:`click.echo` for the arguments this project uses.
121
+
122
+ Parameters
123
+ ----------
124
+ message:
125
+ The text to write. Non-string values are stringified as click does.
126
+ file:
127
+ Target stream. Defaults to click's stdout (or stderr when `err`).
128
+ err:
129
+ Write to stderr instead of stdout.
130
+ nl:
131
+ Append a newline.
132
+
133
+ Side Effects
134
+ ------------
135
+ Writes to the given stream.
136
+ """
137
+ text = message if isinstance(message, str) else str(message)
138
+ target = file if file is not None else click.get_text_stream("stderr" if err else "stdout")
139
+ click.echo(encode_safe(text, _stream_encoding(target)), file=target, nl=nl)
140
+
141
+
142
+ class _SafeWriter:
143
+ """A text stream that degrades what the wrapped stream cannot encode.
144
+
145
+ Why
146
+ Rich renders through a writer this module does not control, and it
147
+ raises the same ``UnicodeEncodeError`` on a legacy codepage rather than
148
+ substituting. Wrapping the writer applies the fallback to every segment
149
+ rich emits without rich needing to know.
150
+
151
+ With no explicit stream the target is resolved at WRITE time, not at
152
+ construction. A module-level ``Console(file=safe_stream())`` built at
153
+ import would otherwise capture the interpreter's original stdout, and
154
+ anything that later swaps ``sys.stdout`` - click's ``CliRunner``,
155
+ ``contextlib.redirect_stdout``, pytest's capture - would be bypassed
156
+ and its buffer would come back empty.
157
+ """
158
+
159
+ def __init__(self, stream: TextIO | None) -> None:
160
+ self._stream = stream
161
+
162
+ def _target(self) -> TextIO:
163
+ return self._stream if self._stream is not None else sys.stdout
164
+
165
+ def write(self, text: str) -> int:
166
+ """Write `text`, degrading anything the current target cannot encode."""
167
+ target = self._target()
168
+ encoding = getattr(target, "encoding", None)
169
+ return target.write(encode_safe(text, encoding if isinstance(encoding, str) else None))
170
+
171
+ def flush(self) -> None:
172
+ """Flush the current target."""
173
+ self._target().flush()
174
+
175
+ def isatty(self) -> bool:
176
+ """Report the target's tty-ness, so rich keeps its styling."""
177
+ return self._target().isatty()
178
+
179
+ @property
180
+ def encoding(self) -> str | None:
181
+ """Expose the target's encoding; rich inspects it."""
182
+ encoding = getattr(self._target(), "encoding", None)
183
+ return encoding if isinstance(encoding, str) else None
184
+
185
+
186
+ def safe_stream(stream: TextIO | None = None) -> Any: # rich accepts any writer with this shape
187
+ """Wrap a stream so unencodable text degrades instead of raising.
188
+
189
+ Use for a writer handed to a third-party renderer. For this project's own
190
+ output use :func:`echo` instead.
191
+
192
+ Parameters
193
+ ----------
194
+ stream:
195
+ The destination text stream. Omit it (or pass None) to follow
196
+ ``sys.stdout`` as it is at each write, which is what a module-level
197
+ renderer needs so test harnesses can still capture the output.
198
+
199
+ Returns
200
+ -------
201
+ Any
202
+ A writer with ``write``/``flush``/``isatty``/``encoding``.
203
+ """
204
+ return _SafeWriter(stream)
205
+
206
+
207
+ __all__ = [
208
+ "ASCII_FALLBACKS",
209
+ "ascii_fallback",
210
+ "echo",
211
+ "encode_safe",
212
+ "safe_stream",
213
+ ]
@@ -0,0 +1,113 @@
1
+ """Tests for the encode-safe console adapter.
2
+
3
+ These pin the behaviour that a legacy-codepage console (the Windows default,
4
+ cp1252) must never turn a successful command into a crash, and the guard that
5
+ keeps the next output line someone adds covered by construction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from rich.console import Console
15
+
16
+ from lib_detect_testenv import safe_console
17
+
18
+ PKG = Path(__file__).resolve().parent.parent / "src" / "lib_detect_testenv"
19
+
20
+
21
+ def _cp1252_stream() -> io.TextIOWrapper:
22
+ """Build the stream shape a Windows cp1252 console hands to Python."""
23
+ return io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict", newline="")
24
+
25
+
26
+ def _read_back(stream: io.TextIOWrapper) -> str:
27
+ stream.flush()
28
+ buffer = stream.buffer
29
+ assert isinstance(buffer, io.BytesIO)
30
+ return buffer.getvalue().decode("cp1252")
31
+
32
+
33
+ class TestEchoOnALegacyCodepage:
34
+ """A cp1252 console must degrade the character, not raise."""
35
+
36
+ def test_check_mark_does_not_raise(self) -> None:
37
+ stream = _cp1252_stream()
38
+ safe_console.echo("\u2713 done", file=stream)
39
+ assert "[OK]" in _read_back(stream)
40
+
41
+ @pytest.mark.parametrize(
42
+ ("glyph", "expected"),
43
+ [("\u2713", "[OK]"), ("\u2717", "[X]"), ("\u26a0", "[!]"), ("\u2265", ">="), ("\u2192", "->")],
44
+ )
45
+ def test_a_character_cp1252_lacks_degrades_to_its_ascii_form(self, glyph: str, expected: str) -> None:
46
+ stream = _cp1252_stream()
47
+ safe_console.echo(f"{glyph} status", file=stream)
48
+ assert expected in _read_back(stream)
49
+
50
+ @pytest.mark.parametrize("glyph", ["\u2022", "\u2026", "\u2019"])
51
+ def test_a_character_cp1252_has_is_left_alone(self, glyph: str) -> None:
52
+ """Degrade only what the stream cannot take; cp1252 has these."""
53
+ stream = _cp1252_stream()
54
+ safe_console.echo(f"{glyph} status", file=stream)
55
+ assert glyph in _read_back(stream)
56
+
57
+ def test_an_unmapped_character_is_replaced_rather_than_raising(self) -> None:
58
+ stream = _cp1252_stream()
59
+ safe_console.echo("name \u4e2d\u6587 here", file=stream)
60
+ assert "name" in _read_back(stream)
61
+
62
+ def test_the_message_is_written_exactly_once(self) -> None:
63
+ """A retry-after-failure would emit the surviving prefix twice."""
64
+ stream = _cp1252_stream()
65
+ safe_console.echo("\n\u2713 done", file=stream)
66
+ assert _read_back(stream).count("done") == 1
67
+
68
+
69
+ class TestEchoOnAUtf8Console:
70
+ """The common case must be untouched: the character survives verbatim."""
71
+
72
+ def test_character_is_preserved(self) -> None:
73
+ stream = io.TextIOWrapper(io.BytesIO(), encoding="utf-8", errors="strict", newline="")
74
+ safe_console.echo("\u2713 done", file=stream)
75
+ stream.flush()
76
+ buffer = stream.buffer
77
+ assert isinstance(buffer, io.BytesIO)
78
+ assert "\u2713" in buffer.getvalue().decode("utf-8")
79
+
80
+
81
+ class TestSafeStreamProtectsRich:
82
+ """Rich raises on a legacy codepage too; it renders through its own writer."""
83
+
84
+ def test_rich_output_degrades_instead_of_raising(self) -> None:
85
+ stream = _cp1252_stream()
86
+ Console(file=safe_console.safe_stream(stream), legacy_windows=False, width=80).print("check \u2713 done \u2265 90%")
87
+ written = _read_back(stream)
88
+ assert "[OK]" in written
89
+ assert ">= 90%" in written
90
+
91
+
92
+ class TestNoModuleBypassesTheAdapter:
93
+ """The guard that keeps this fixed for the next output line someone adds."""
94
+
95
+ def test_no_module_calls_click_echo_directly(self) -> None:
96
+ offenders = [
97
+ f"{path.relative_to(PKG)}:{number}"
98
+ for path in sorted(PKG.rglob("*.py"))
99
+ if path.name != "safe_console.py"
100
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
101
+ if "click.echo(" in line
102
+ ]
103
+ assert not offenders, f"these call click.echo directly and will crash on a cp1252 console; use safe_console.echo instead: {offenders}"
104
+
105
+ def test_no_module_builds_an_unwrapped_rich_console_on_stdout(self) -> None:
106
+ offenders = [
107
+ f"{path.relative_to(PKG)}:{number}"
108
+ for path in sorted(PKG.rglob("*.py"))
109
+ if path.name != "safe_console.py"
110
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
111
+ if "Console(file=sys.stdout" in line
112
+ ]
113
+ assert not offenders, f"wrap the writer with safe_console.safe_stream(sys.stdout): {offenders}"