Repository: pallets/click (python)

Issue:
Error in click.utils.echo() when console is unavailable
The `click.utils.echo()` function does not seem to account for the case when the console is not available on Windows, i.e., when running under `pythonw.exe` interpreter instead of the `python.exe` one.

Minimal example: 

```python
# program.py
import sys
import os

try:
    import click
    click.utils.echo("Hello world")
except Exception:
    import traceback
    error_file = os.path.join(os.path.dirname(__file__), "error_log.txt")
    with open(error_file, "w") as fp:
        traceback.print_exc(file=fp)
```

Running this program with `pythonw.exe program.py` produces the `error_log.txt`:
``` 
Traceback (most recent call last):
  File "C:\Users\Rok\Development\pyi-click\program.py", line 6, in <module>
    click.utils.echo("Hello world")
  File "C:\Users\Rok\Development\pyi-click\venv\lib\site-packages\click\utils.py", line 299, in echo
    file.write(out)  # type: ignore
AttributeError: 'NoneType' object has no attribute 'write'
```

In contrast, standard `print()` function gracefully handles situations when console is unavailable and `sys.stdout` and `sys.stderr` are `None`.

This attempt at retrieving stdout/stderr:
https://github.com/pallets/click/blob/c65c6ad18471448c0fcc59ef53088787288c02cc/src/click/utils.py#L250-L254
should be followed by another `None` check, and if the stream is unavailable, the function should become a no-op (exit immediately).

Environment:

- OS: Windows
- Python version: any
- Click version: 8.1.3 (and earlier)


Pull request description:
echo doesn't fail with no streams
When running with `pythonw` on Windows (GUI mode, no console), `sys.stdout` and `stderr` are `None`. This doesn't appear to be documented by Python. In this case, `echo` should return early since there's nowhere to write to. fixes #2415 

`progressbar` and `pager` also use these, they'll fall back to writing to a temporary `StringIO` since it wasn't clear how to make them exit early.

Presumably if you actually wanted to see output when using `pythonw` you would do something like `sys.stdout = open("output.txt", "w")` at the beginning of your program.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index ce2bf8e..639f2f7 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -30,6 +30,8 @@ Unreleased
     bytes with the replacement character (``�``). :issue:`2395`
 -   Remove unnecessary attempt to detect MSYS2 environment. :issue:`2355`
 -   Remove outdated and unnecessary detection of App Engine environment. :pr:`2554`
+-   ``echo()`` does not fail when no streams are attached, such as with ``pythonw`` on
+    Windows. :issue:`2415`
 
 
 Version 8.1.3
diff --git a/src/click/_compat.py b/src/click/_compat.py
index 17cef0e..9153d15 100644
--- a/src/click/_compat.py
+++ b/src/click/_compat.py
@@ -576,12 +576,17 @@ def isatty(stream: t.IO[t.Any]) -> bool:
 
 
 def _make_cached_stream_func(
-    src_func: t.Callable[[], t.TextIO], wrapper_func: t.Callable[[], t.TextIO]
-) -> t.Callable[[], t.TextIO]:
+    src_func: t.Callable[[], t.Optional[t.TextIO]],
+    wrapper_func: t.Callable[[], t.TextIO],
+) -> t.Callable[[], t.Optional[t.TextIO]]:
     cache: t.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary()
 
-    def func() -> t.TextIO:
+    def func() -> t.Optional[t.TextIO]:
         stream = src_func()
+
+        if stream is None:
+            return None
+
         try:
             rv = cache.get(stream)
         except Exception:
diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py
index 38ff171..f744657 100644
--- a/src/click/_termui_impl.py
+++ b/src/click/_termui_impl.py
@@ -10,6 +10,7 @@ import sys
 import time
 import typing as t
 from gettext import gettext as _
+from io import StringIO
 from types import TracebackType
 
 from ._compat import _default_text_stdout
@@ -61,8 +62,15 @@ class ProgressBar(t.Generic[V]):
         self.show_pos = show_pos
         self.item_show_func = item_show_func
         self.label: str = label or ""
+
         if file is None:
             file = _default_text_stdout()
+
+            # There are no standard streams attached to write to. For example,
+            # pythonw on Windows.
+            if file is None:
+                file = StringIO()
+
         self.file = file
         self.color = color
         self.update_min_steps = update_min_steps
@@ -352,6 +360,12 @@ class ProgressBar(t.Generic[V]):
 def pager(generator: t.Iterable[str], color: t.Optional[bool] = None) -> None:
     """Decide what method to use for paging through text."""
     stdout = _default_text_stdout()
+
+    # There are no standard streams attached to write to. For example,
+    # pythonw on Windows.
+    if stdout is None:
+        stdout = StringIO()
+
     if not isatty(sys.stdin) or not isatty(stdout):
         return _nullpager(stdout, generator, color)
     pager_cmd = (os.environ.get("PAGER", None) or "").strip()
diff --git a/src/click/utils.py b/src/click/utils.py
index 3b5d286..d536434 100644
--- a/src/click/utils.py
+++ b/src/click/utils.py
@@ -267,6 +267,11 @@ def echo(
         else:
             file = _default_text_stdout()
 
+        # There are no standard streams attached to write to. For example,
+        # pythonw on Windows.
+        if file is None:
+            return
+
     # Convert non bytes/text into the native string type.
     if message is not None and not isinstance(message, (str, bytes, bytearray)):
         out: t.Optional[t.Union[str, bytes]] = str(message)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6366815..12709a1 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -43,6 +43,15 @@ def test_echo_custom_file():
     assert f.getvalue() == "hello\n"
 
 
+def test_echo_no_streams(monkeypatch, runner):
+    """echo should not fail when stdout and stderr are None with pythonw on Windows."""
+    with runner.isolation():
+        sys.stdout = None
+        sys.stderr = None
+        click.echo("test")
+        click.echo("test", err=True)
+
+
 @pytest.mark.parametrize(
     ("styles", "ref"),
     [