Repository: PrefectHQ/prefect

Issue:
custom wrapper for stdout and stderr breaks run_process with stream output
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

`run_process` with `stream_output=Tue` consumes the process output and forwards it to `sys.stdout` and `sys.stderr` sinks.

In [`stream_text`](https://github.com/PrefectHQ/prefect/blob/7569706a4ebd6280a42a13359e2acbe8cc398622/src/prefect/utilities/processutils.py#L300) Prefect explicitly checks for the type of the sink to determine the correct methods to use on that sink.

This limits the supported sinks to sinks of type `TextSendStream` and `TextIOBase`. However, in certain situations sys.stdout and sys.stderr can be of different types that would also implement write and flush and should be allowed to use.

For example, when using [`halo`](https://pypi.org/project/halo/), a package for spinners in the terminal, sys.stdout and sys.stderr ae overwritten with a `colorama` [`StreamWrapper`](https://github.com/tartley/colorama/blob/7f1a5933851363abbdc24a51e76829bb23a2793b/colorama/ansitowin32.py#L16). This wrapper offers a compatible interface to `TextIOBase` but is no a subclass. Consequently, Prefect will not send any items to it but raise an exception.

imo expected behavior would be to support all types of sinks that implement certain methods, irrespective of their type.

### Reproduction

```python3
TBA
```


### Error

```python3
10:23:18.940 | ERROR   | prefect.flow_runs.worker - An error occurred while monitoring flow run '81d456c9-1a85-4121-a289-5b992ee3098c'. The flow run will not be marked as failed, but an issue may have occurred.
Traceback (most recent call last):
  File "python3.10/site-packages/prefect/workers/base.py", line 896, in _submit_run_and_capture_errors
    result = await self.run(
  File "python3.10/site-packages/prefect/workers/process.py", line 189, in run
    process = await run_process(
  File "python3.10/site-packages/prefect/utilities/processutils.py", line 273, in run_process
    await consume_process_output(
  File "python3.10/site-packages/prefect/utilities/processutils.py", line 287, in consume_process_output
    async with anyio.create_task_group() as tg:
  File "python3.10/site-packages/anyio/_backends/_asyncio.py", line 597, in __aexit__
    raise exceptions[0]
  File "python3.10/site-packages/prefect/utilities/processutils.py", line 315, in stream_text
    raise TypeError(f"Unsupported sink type {type(sink).__name__}")
TypeError: Unsupported sink type StreamWrapper
```


### Versions

```Text
Version:             2.14.8
API version:         0.8.4
Python version:      3.10.8
Git commit:          a56fbabe
Built:               Tue, Nov 28, 2023 5:29 PM
OS/Arch:             linux/x86_64
Server type:         cloud
```


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/src/prefect/utilities/processutils.py b/src/prefect/utilities/processutils.py
--- a/src/prefect/utilities/processutils.py
+++ b/src/prefect/utilities/processutils.py
@@ -6,7 +6,6 @@
 from contextlib import asynccontextmanager
 from dataclasses import dataclass
 from functools import partial
-from io import TextIOBase
 from typing import (
     IO,
     Any,
@@ -299,7 +298,11 @@ async def consume_process_output(
 
 async def stream_text(source: TextReceiveStream, *sinks: TextSink):
     wrapped_sinks = [
-        anyio.wrap_file(sink) if isinstance(sink, TextIOBase) else sink
+        (
+            anyio.wrap_file(sink)
+            if hasattr(sink, "write") and hasattr(sink, "flush")
+            else sink
+        )
         for sink in sinks
     ]
     async for item in source:


Test patch:
diff --git a/tests/utilities/test_processutils.py b/tests/utilities/test_processutils.py
--- a/tests/utilities/test_processutils.py
+++ b/tests/utilities/test_processutils.py
@@ -55,6 +55,34 @@ async def test_run_process_allows_stderr_fd(self, tmp_path):
         assert process.returncode == 0
         assert (tmp_path / "output.txt").read_text().strip() == "hello world"
 
+    class DummyWrapper:
+        def __init__(self, wrapped):
+            self.__wrapped = wrapped
+
+        def __getattr__(self, name):
+            return getattr(self.__wrapped, name)
+
+    async def test_run_process_allows_stdout_wrapper(self, tmp_path):
+        with open(tmp_path / "output.txt", "wt") as fout:
+            process = await run_process(
+                ["echo", "hello world"], stream_output=(self.DummyWrapper(fout), None)
+            )
+
+        assert process.returncode == 0
+        assert (tmp_path / "output.txt").read_text().strip() == "hello world"
+
+    @pytest.mark.skipif(
+        sys.platform == "win32", reason="stderr redirect does not work on Windows"
+    )
+    async def test_run_process_allows_stderr_wrapper(self, tmp_path):
+        with open(tmp_path / "output.txt", "wt") as fout:
+            process = await run_process(
+                ["bash", "-c", ">&2 echo hello world"],
+                stream_output=(None, self.DummyWrapper(fout)),
+            )
+        assert process.returncode == 0
+        assert (tmp_path / "output.txt").read_text().strip() == "hello world"
+
 
 class TestOpenProcess:
     str_cmd = "ls -a"