Repository: Tinche/aiofiles

Issue:
What is the purpose of binding each file to a particular loop?
It's somewhat inconvenient that `AsyncBase._loop` exists - it makes it hard to implement a global stdout object via a module-level `stdout = aiofiles.threadpool.wrap(sys.stdout)`. What is the purpose of this attribute? If it is purely for performance reasons, would you accept a PR that changed all references to `self._loop` to `self._loop or asyncio.get_running_loop()`?

Comments:

Patch:
diff --git a/README.rst b/README.rst
index 5ddf859..b7905a0 100644
--- a/README.rst
+++ b/README.rst
@@ -96,6 +96,11 @@ and delegate to an executor:
 
 In case of failure, one of the usual exceptions will be raised.
 
+``aiofiles.stdin``, ``aiofiles.stdout``, ``aiofiles.stderr``,
+``aiofiles.stdin_bytes``, ``aiofiles.stdout_bytes``, and
+``aiofiles.stderr_bytes`` provide async access to ``sys.stdin``,
+``sys.stdout``, ``sys.stderr``, and their corresponding ``.buffer`` properties.
+
 The ``aiofiles.os`` module contains executor-enabled coroutine versions of
 several useful ``os`` functions that deal with files:
 
@@ -180,6 +185,8 @@ History
   `#146 <https://github.com/Tinche/aiofiles/pull/146>`_
 * Removed ``aiofiles.tempfile.temptypes.AsyncSpooledTemporaryFile.softspace``.
   `#151 <https://github.com/Tinche/aiofiles/pull/151>`_
+* Added ``aiofiles.stdin``, ``aiofiles.stdin_bytes``, and other stdio streams.
+  `#154 <https://github.com/Tinche/aiofiles/pull/154>`_
 
 22.1.0 (2022-09-04)
 ```````````````````
diff --git a/src/aiofiles/__init__.py b/src/aiofiles/__init__.py
index b0114ee..9e75111 100644
--- a/src/aiofiles/__init__.py
+++ b/src/aiofiles/__init__.py
@@ -1,5 +1,22 @@
 """Utilities for asyncio-friendly file handling."""
-from .threadpool import open
+from .threadpool import (
+    open,
+    stdin,
+    stdout,
+    stderr,
+    stdin_bytes,
+    stdout_bytes,
+    stderr_bytes,
+)
 from . import tempfile
 
-__all__ = ["open", "tempfile"]
+__all__ = [
+    "open",
+    "tempfile",
+    "stdin",
+    "stdout",
+    "stderr",
+    "stdin_bytes",
+    "stdout_bytes",
+    "stderr_bytes",
+]
diff --git a/src/aiofiles/base.py b/src/aiofiles/base.py
index f64d00d..6201d95 100644
--- a/src/aiofiles/base.py
+++ b/src/aiofiles/base.py
@@ -1,13 +1,18 @@
 """Various base classes."""
 from types import coroutine
 from collections.abc import Coroutine
+from asyncio import get_running_loop
 
 
 class AsyncBase:
     def __init__(self, file, loop, executor):
         self._file = file
-        self._loop = loop
         self._executor = executor
+        self._ref_loop = loop
+
+    @property
+    def _loop(self):
+        return self._ref_loop or get_running_loop()
 
     def __aiter__(self):
         """We are our own iterator."""
@@ -25,6 +30,21 @@ class AsyncBase:
             raise StopAsyncIteration
 
 
+class AsyncIndirectBase(AsyncBase):
+    def __init__(self, name, loop, executor, indirect):
+        self._indirect = indirect
+        self._name = name
+        super().__init__(None, loop, executor)
+
+    @property
+    def _file(self):
+        return self._indirect()
+
+    @_file.setter
+    def _file(self, v):
+        pass  # discard writes
+
+
 class _ContextManager(Coroutine):
     __slots__ = ("_coro", "_obj")
 
diff --git a/src/aiofiles/threadpool/__init__.py b/src/aiofiles/threadpool/__init__.py
index 7bb18d7..522b251 100644
--- a/src/aiofiles/threadpool/__init__.py
+++ b/src/aiofiles/threadpool/__init__.py
@@ -1,5 +1,6 @@
 """Handle files using a thread pool executor."""
 import asyncio
+import sys
 from types import coroutine
 
 from io import (
@@ -8,16 +9,32 @@ from io import (
     BufferedReader,
     BufferedWriter,
     BufferedRandom,
+    BufferedIOBase,
 )
 from functools import partial, singledispatch
 
-from .binary import AsyncBufferedIOBase, AsyncBufferedReader, AsyncFileIO
-from .text import AsyncTextIOWrapper
+from .binary import (
+    AsyncBufferedIOBase,
+    AsyncBufferedReader,
+    AsyncFileIO,
+    AsyncIndirectBufferedIOBase,
+    AsyncIndirectBufferedReader,
+    AsyncIndirectFileIO,
+)
+from .text import AsyncTextIOWrapper, AsyncTextIndirectIOWrapper
 from ..base import AiofilesContextManager
 
 sync_open = open
 
-__all__ = ("open",)
+__all__ = (
+    "open",
+    "stdin",
+    "stdout",
+    "stderr",
+    "stdin_bytes",
+    "stdout_bytes",
+    "stderr_bytes",
+)
 
 
 def open(
@@ -93,6 +110,7 @@ def _(file, *, loop=None, executor=None):
 
 
 @wrap.register(BufferedWriter)
+@wrap.register(BufferedIOBase)
 def _(file, *, loop=None, executor=None):
     return AsyncBufferedIOBase(file, loop=loop, executor=executor)
 
@@ -105,4 +123,12 @@ def _(file, *, loop=None, executor=None):
 
 @wrap.register(FileIO)
 def _(file, *, loop=None, executor=None):
-    return AsyncFileIO(file, loop, executor)
+    return AsyncFileIO(file, loop=loop, executor=executor)
+
+
+stdin = AsyncTextIndirectIOWrapper('sys.stdin', None, None, indirect=lambda: sys.stdin)
+stdout = AsyncTextIndirectIOWrapper('sys.stdout', None, None, indirect=lambda: sys.stdout)
+stderr = AsyncTextIndirectIOWrapper('sys.stderr', None, None, indirect=lambda: sys.stderr)
+stdin_bytes = AsyncIndirectBufferedIOBase('sys.stdin.buffer', None, None, indirect=lambda: sys.stdin.buffer)
+stdout_bytes = AsyncIndirectBufferedIOBase('sys.stdout.buffer', None, None, indirect=lambda: sys.stdout.buffer)
+stderr_bytes = AsyncIndirectBufferedIOBase('sys.stderr.buffer', None, None, indirect=lambda: sys.stderr.buffer)
diff --git a/src/aiofiles/threadpool/binary.py b/src/aiofiles/threadpool/binary.py
index 3568ed0..52d0cb3 100644
--- a/src/aiofiles/threadpool/binary.py
+++ b/src/aiofiles/threadpool/binary.py
@@ -1,4 +1,4 @@
-from ..base import AsyncBase
+from ..base import AsyncBase, AsyncIndirectBase
 from .utils import (
     delegate_to_executor,
     proxy_method_directly,
@@ -26,7 +26,7 @@ from .utils import (
 @proxy_method_directly("detach", "fileno", "readable")
 @proxy_property_directly("closed", "raw", "name", "mode")
 class AsyncBufferedIOBase(AsyncBase):
-    """The asyncio executor version of io.BufferedWriter."""
+    """The asyncio executor version of io.BufferedWriter and BufferedIOBase."""
 
 
 @delegate_to_executor("peek")
@@ -55,3 +55,54 @@ class AsyncBufferedReader(AsyncBufferedIOBase):
 @proxy_property_directly("closed", "name", "mode")
 class AsyncFileIO(AsyncBase):
     """The asyncio executor version of io.FileIO."""
+
+
+@delegate_to_executor(
+    "close",
+    "flush",
+    "isatty",
+    "read",
+    "read1",
+    "readinto",
+    "readline",
+    "readlines",
+    "seek",
+    "seekable",
+    "tell",
+    "truncate",
+    "writable",
+    "write",
+    "writelines",
+)
+@proxy_method_directly("detach", "fileno", "readable")
+@proxy_property_directly("closed", "raw", "name", "mode")
+class AsyncIndirectBufferedIOBase(AsyncIndirectBase):
+    """The indirect asyncio executor version of io.BufferedWriter and BufferedIOBase."""
+
+
+@delegate_to_executor("peek")
+class AsyncIndirectBufferedReader(AsyncIndirectBufferedIOBase):
+    """The indirect asyncio executor version of io.BufferedReader and Random."""
+
+
+@delegate_to_executor(
+    "close",
+    "flush",
+    "isatty",
+    "read",
+    "readall",
+    "readinto",
+    "readline",
+    "readlines",
+    "seek",
+    "seekable",
+    "tell",
+    "truncate",
+    "writable",
+    "write",
+    "writelines",
+)
+@proxy_method_directly("fileno", "readable")
+@proxy_property_directly("closed", "name", "mode")
+class AsyncIndirectFileIO(AsyncIndirectBase):
+    """The indirect asyncio executor version of io.FileIO."""
diff --git a/src/aiofiles/threadpool/text.py b/src/aiofiles/threadpool/text.py
index 41cdbbf..1323009 100644
--- a/src/aiofiles/threadpool/text.py
+++ b/src/aiofiles/threadpool/text.py
@@ -1,4 +1,4 @@
-from ..base import AsyncBase
+from ..base import AsyncBase, AsyncIndirectBase
 from .utils import (
     delegate_to_executor,
     proxy_method_directly,
@@ -35,3 +35,34 @@ from .utils import (
 )
 class AsyncTextIOWrapper(AsyncBase):
     """The asyncio executor version of io.TextIOWrapper."""
+
+
+@delegate_to_executor(
+    "close",
+    "flush",
+    "isatty",
+    "read",
+    "readable",
+    "readline",
+    "readlines",
+    "seek",
+    "seekable",
+    "tell",
+    "truncate",
+    "write",
+    "writable",
+    "writelines",
+)
+@proxy_method_directly("detach", "fileno", "readable")
+@proxy_property_directly(
+    "buffer",
+    "closed",
+    "encoding",
+    "errors",
+    "line_buffering",
+    "newlines",
+    "name",
+    "mode",
+)
+class AsyncTextIndirectIOWrapper(AsyncIndirectBase):
+    """The indirect asyncio executor version of io.TextIOWrapper."""


Test patch:
diff --git a/tests/test_os.py b/tests/test_os.py
index 2d920d1..34b4b63 100644
--- a/tests/test_os.py
+++ b/tests/test_os.py
@@ -75,9 +75,9 @@ async def test_renames():
     assert exists(old_filename) is False and exists(new_filename)
     await aiofiles.os.renames(new_filename, old_filename)
     assert (
-        exists(old_filename) and
-        exists(new_filename) is False and
-        exists(dirname(new_filename)) is False
+        exists(old_filename)
+        and exists(new_filename) is False
+        and exists(dirname(new_filename)) is False
     )
 
 
@@ -323,6 +323,7 @@ async def test_listdir_dir_with_only_one_file():
     await aiofiles.os.remove(some_file)
     await aiofiles.os.rmdir(some_dir)
 
+
 @pytest.mark.asyncio
 async def test_listdir_dir_with_only_one_dir():
     """Test the listdir call when the dir has one dir."""
@@ -335,6 +336,7 @@ async def test_listdir_dir_with_only_one_dir():
     await aiofiles.os.rmdir(other_dir)
     await aiofiles.os.rmdir(some_dir)
 
+
 @pytest.mark.asyncio
 async def test_listdir_dir_with_multiple_files():
     """Test the listdir call when the dir has multiple files."""
@@ -353,6 +355,7 @@ async def test_listdir_dir_with_multiple_files():
     await aiofiles.os.remove(other_file)
     await aiofiles.os.rmdir(some_dir)
 
+
 @pytest.mark.asyncio
 async def test_listdir_dir_with_a_file_and_a_dir():
     """Test the listdir call when the dir has files and other dirs."""
@@ -406,6 +409,7 @@ async def test_scandir_dir_with_only_one_file():
     await aiofiles.os.remove(some_file)
     await aiofiles.os.rmdir(some_dir)
 
+
 @pytest.mark.asyncio
 async def test_scandir_dir_with_only_one_dir():
     """Test the scandir call when the dir has one dir."""
diff --git a/tests/test_stdio.py b/tests/test_stdio.py
new file mode 100644
index 0000000..61442af
--- /dev/null
+++ b/tests/test_stdio.py
@@ -0,0 +1,25 @@
+import sys
+import pytest
+from aiofiles import stdin, stdout, stderr, stdin_bytes, stdout_bytes, stderr_bytes
+
+
+@pytest.mark.asyncio
+async def test_stdio(capsys):
+    await stdout.write("hello")
+    await stderr.write("world")
+    out, err = capsys.readouterr()
+    assert out == "hello"
+    assert err == "world"
+    with pytest.raises(OSError):
+        await stdin.read()
+
+
+@pytest.mark.asyncio
+async def test_stdio_bytes(capsysbinary):
+    await stdout_bytes.write(b"hello")
+    await stderr_bytes.write(b"world")
+    out, err = capsysbinary.readouterr()
+    assert out == b"hello"
+    assert err == b"world"
+    with pytest.raises(OSError):
+        await stdin_bytes.read()
diff --git a/tests/test_tempfile.py b/tests/test_tempfile.py
index 9bf743e..3ebb149 100644
--- a/tests/test_tempfile.py
+++ b/tests/test_tempfile.py
@@ -69,9 +69,9 @@ async def test_spooled_temporary_file(mode):
 @pytest.mark.skipif(
     sys.version_info < (3, 7),
     reason=(
-       "text-mode SpooledTemporaryFile is implemented with StringIO in py3.6"
-       "it doesn't support `newlines`"
-    )
+        "text-mode SpooledTemporaryFile is implemented with StringIO in py3.6"
+        "it doesn't support `newlines`"
+    ),
 )
 @pytest.mark.parametrize(
     "test_string, newlines", [("LF\n", "\n"), ("CRLF\r\n", "\r\n")]