Repository: tophat/syrupy

Issue:
Report summary crashes on Windows 10
**Describe the bug**
When printing a summary of snapshots, syrupy formats the text using `colored.fg`. This triggers a crash, as `colored.fg` uses incorrect types when interfacing with the kernel32 dll.

An [issue](https://gitlab.com/dslackw/colored/-/issues/25) and corresponding [merge request](https://gitlab.com/dslackw/colored/-/merge_requests/19) has been created on colored's repository.

Unfortunately, this can not be bypassed via `--snapshot-no-colors` or other environment variables, as the call to `colored.fg` occurs _before_ we check for those.

**To reproduce**
Run any snapshot test on (the above version of) Windows.
Result:
```
-------------- snapshot report summary --------------- 
Traceback (most recent call last):
  File "...\Python310\lib\runpy.py", line 196, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "...\Python310\lib\runpy.py", line 86, in _run_code
    exec(code, run_globals)
  File "...\Scripts\pytest.exe\__main__.py", line 7, in <module>
  File "...\lib\site-packages\_pytest\config\__init__.py", line 185, in console_main
    code = main()
  File "...\lib\site-packages\_pytest\config\__init__.py", line 162, in main       
    ret: Union[ExitCode, int] = config.hook.pytest_cmdline_main(
  File "...\lib\site-packages\pluggy\_hooks.py", line 265, in __call__
    return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
  File "...\lib\site-packages\pluggy\_manager.py", line 80, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  File "...\lib\site-packages\pluggy\_callers.py", line 60, in _multicall
    return outcome.get_result()
  File "...\lib\site-packages\pluggy\_result.py", line 60, in get_result
    raise ex[1].with_traceback(ex[2])
  File "...\lib\site-packages\pluggy\_callers.py", line 39, in _multicall
    res = hook_impl.function(*args)
  File "...\lib\site-packages\_pytest\main.py", line 316, in pytest_cmdline_main   
    return wrap_session(config, _main)
  File "...\lib\site-packages\_pytest\main.py", line 304, in wrap_session
    config.hook.pytest_sessionfinish(
  File "...\lib\site-packages\pluggy\_hooks.py", line 265, in __call__
    return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
  File "...\lib\site-packages\pluggy\_manager.py", line 80, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  File "...\lib\site-packages\pluggy\_callers.py", line 55, in _multicall
    gen.send(outcome)
  File "...\lib\site-packages\_pytest\terminal.py", line 813, in pytest_sessionfinish
    self.config.hook.pytest_terminal_summary(
  File "...\lib\site-packages\pluggy\_hooks.py", line 265, in __call__
    return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
  File "...\lib\site-packages\pluggy\_manager.py", line 80, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  File "...\lib\site-packages\pluggy\_callers.py", line 60, in _multicall
    return outcome.get_result()
  File "...\lib\site-packages\pluggy\_result.py", line 60, in get_result
    raise ex[1].with_traceback(ex[2])
  File "...\lib\site-packages\pluggy\_callers.py", line 39, in _multicall
    res = hook_impl.function(*args)
  File "...\lib\site-packages\syrupy\__init__.py", line 170, in pytest_terminal_summary
    for line in terminalreporter.config._syrupy.report.lines:
  File "...\lib\site-packages\syrupy\report.py", line 277, in lines
    ).format(green(self.num_updated))
  File "...\lib\site-packages\syrupy\terminal.py", line 34, in green
    return _stylize(text, colored.fg("green"))
  File "...\lib\site-packages\colored\colored.py", line 431, in fg
    return colored(color).foreground()
  File "...\lib\site-packages\colored\colored.py", line 23, in __init__
    self.enable_windows_terminal_mode()
  File "...\lib\site-packages\colored\colored.py", line 374, in enable_windows_terminal_mode
    hStdout = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
ctypes.ArgumentError: argument 1: <class 'TypeError'>: 
wrong type
```

**Expected behavior**
No crash

**Environment (please complete the following information):**
- OS: Windows 10 Enterprise 21H2 19044.2130
- Syrupy Version: 3.0.4
- Python Version: Python 3.10.4 (tags/v3.10.4:9d38120, Mar 23 2022, 23:13:41) [MSC v.1929 64 bit (AMD64)] on win32
- (Colored Version: 1.4.3)

**Additional context**
It would be helpful if specifying `NO_COLOR` et al. would pre-empt calling `colored.fg` - that way, we would be able to bypass the issue without any nasty hacks. In lieu of that, the following nasty hack acts as a workaround:

In the test module (or possibly conftest.py, haven't checked), add:
```
import colored

colored.colored.enable_windows_terminal_mode = lambda self: None
```
Then run the tests as normal ( possibly with `--snapshot-no-colors`, although I didn't notice any difference).

Discussion:
noahnu: We'll need at least 1 test to catch any regression of this functionality. Otherwise a future refactor might accidentally revert it.

Alternatively we can add some comments explaining with a link to the GitHub issue.
mhwaage: I added a test for it, feel free to shift it to a more appropriate location

Patch:
diff --git a/src/syrupy/terminal.py b/src/syrupy/terminal.py
index dec1532..f59d696 100644
--- a/src/syrupy/terminal.py
+++ b/src/syrupy/terminal.py
@@ -13,6 +13,24 @@ def _is_color_disabled() -> bool:
     return any(map(get_env_value, DISABLE_COLOR_ENV_VARS))
 
 
+def _attr(color: Any) -> str:
+    if _is_color_disabled():
+        return ""
+    return colored.attr(color)
+
+
+def _fg(color: Any) -> str:
+    if _is_color_disabled():
+        return ""
+    return colored.fg(color)
+
+
+def _bg(color: Any) -> str:
+    if _is_color_disabled():
+        return ""
+    return colored.bg(color)
+
+
 def _stylize(text: Union[str, int], *args: Any) -> str:
     if _is_color_disabled():
         return str(text)
@@ -20,23 +38,23 @@ def _stylize(text: Union[str, int], *args: Any) -> str:
 
 
 def reset(text: Union[str, int]) -> str:
-    return _stylize(text, colored.attr("reset"))
+    return _stylize(text, _attr("reset"))
 
 
 def red(text: Union[str, int]) -> str:
-    return _stylize(text, colored.fg("red"))
+    return _stylize(text, _fg("red"))
 
 
 def yellow(text: Union[str, int]) -> str:
-    return _stylize(text, colored.fg("yellow"))
+    return _stylize(text, _fg("yellow"))
 
 
 def green(text: Union[str, int]) -> str:
-    return _stylize(text, colored.fg("green"))
+    return _stylize(text, _fg("green"))
 
 
 def bold(text: Union[str, int]) -> str:
-    return _stylize(text, colored.attr("bold"))
+    return _stylize(text, _attr("bold"))
 
 
 def error_style(text: Union[str, int]) -> str:
@@ -52,20 +70,20 @@ def success_style(text: Union[str, int]) -> str:
 
 
 def snapshot_style(text: Union[str, int]) -> str:
-    return _stylize(text, colored.bg(225) + colored.fg(90))
+    return _stylize(text, _bg(225) + _fg(90))
 
 
 def snapshot_diff_style(text: Union[str, int]) -> str:
-    return _stylize(text, colored.bg(90) + colored.fg(225))
+    return _stylize(text, _bg(90) + _fg(225))
 
 
 def received_style(text: Union[str, int]) -> str:
-    return _stylize(text, colored.bg(195) + colored.fg(23))
+    return _stylize(text, _bg(195) + _fg(23))
 
 
 def received_diff_style(text: Union[str, int]) -> str:
-    return _stylize(text, colored.bg(23) + colored.fg(195))
+    return _stylize(text, _bg(23) + _fg(195))
 
 
 def context_style(text: Union[str, int]) -> str:
-    return _stylize(text, colored.attr("dim"))
+    return _stylize(text, _attr("dim"))


Test patch:
diff --git a/tests/syrupy/test_terminal.py b/tests/syrupy/test_terminal.py
new file mode 100644
index 0000000..cf9df9f
--- /dev/null
+++ b/tests/syrupy/test_terminal.py
@@ -0,0 +1,58 @@
+from unittest.mock import (
+    NonCallableMock,
+    patch,
+)
+
+import pytest
+
+from syrupy.constants import DISABLE_COLOR_ENV_VAR
+from syrupy.terminal import (
+    bold,
+    context_style,
+    error_style,
+    green,
+    received_diff_style,
+    received_style,
+    red,
+    reset,
+    snapshot_diff_style,
+    snapshot_style,
+    success_style,
+    warning_style,
+    yellow,
+)
+
+
+def test_colors_off_does_not_call_colored():
+    """
+    Test that disabling colors prevents instantiating colored object.
+    Enables workarounds for when instantiating the colored object causes crashes,
+    see issue #633
+    """
+
+    with patch(
+        "syrupy.terminal.colored.colored.__init__", new_callable=NonCallableMock
+    ):
+        with patch.dict("os.environ", {DISABLE_COLOR_ENV_VAR: "true"}):
+            for method in (
+                reset,
+                red,
+                yellow,
+                green,
+                bold,
+                error_style,
+                warning_style,
+                success_style,
+                snapshot_style,
+                snapshot_diff_style,
+                received_style,
+                received_diff_style,
+                context_style,
+            ):
+                _ = method("foo")
+
+        # Prevent test from accidentally passing by patching wrong object
+        with pytest.raises(TypeError) as excinfo:
+            _ = red("foo")
+
+        assert "NonCallableMock" in str(excinfo.value)