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.
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/CHANGELOG.md +12 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/Makefile +2 -2
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/PKG-INFO +3 -3
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/pyproject.toml +3 -3
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__init__conf__.py +1 -1
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/cli.py +2 -1
- lib_detect_testenv-3.0.4/src/lib_detect_testenv/safe_console.py +213 -0
- lib_detect_testenv-3.0.4/tests/test_safe_console.py +113 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.devcontainer/devcontainer.json +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.devcontainer/settings.json +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.env.example +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/actions/extract-metadata/action.yml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/dependabot.yml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/codeql.yml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/default_cicd_public.yml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/default_release_public.yml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.gitignore +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.qlty/qlty.toml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.snyk +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/CLAUDE.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/CONTRIBUTING.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/DEVELOPMENT.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/INSTALL.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/LICENSE +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/README.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/codecov.yml +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/concept.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/docs/systemdesign/module_reference.md +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/notebooks/Quickstart.ipynb +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/reset_git_history.sh +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__init__.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__main__.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/behaviors.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/lib_detect_testenv.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/py.typed +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/typed_click.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/conftest.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_behaviors.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_cli.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_lib_detect_testenv.py +0 -0
- {lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/tests/test_metadata.py +0 -0
- {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.
|
|
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.
|
|
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
|
+
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.
|
|
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>=
|
|
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
|
+
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.
|
|
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>=
|
|
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
|
{lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/__init__conf__.py
RENAMED
|
@@ -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.
|
|
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}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/actions/extract-metadata/action.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/default_cicd_public.yml
RENAMED
|
File without changes
|
{lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/.github/workflows/default_release_public.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_detect_testenv-3.0.3 → lib_detect_testenv-3.0.4}/src/lib_detect_testenv/lib_detect_testenv.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|