Repository: Textualize/textual

Issue:
Crash when TEXTUAL_DEVTOOLS=1 but no log file supplied
Some of the new devtools code assumes there is a logfile available, but of course the user doesn't need to supplied one. The code should be updated to only write to the logfile if it is available.
```
Traceback (most recent call last):
  File "/Users/darrenburns/.pyenv/versions/3.10.0/lib/python3.10/runpy.py", line 196, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/Users/darrenburns/.pyenv/versions/3.10.0/lib/python3.10/runpy.py", line 86, in _run_code
    exec(code, run_globals)
  File "/Users/darrenburns/code/textual/sandbox/buttons.py", line 15, in <module>
    ButtonsApp.run(css_file="buttons.scss")
  File "/Users/darrenburns/code/textual/src/textual/app.py", line 304, in run
    asyncio.run(run_app())
  File "/Users/darrenburns/.pyenv/versions/3.10.0/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/Users/darrenburns/.pyenv/versions/3.10.0/lib/python3.10/asyncio/base_events.py", line 641, in run_until_complete
    return future.result()
  File "/Users/darrenburns/code/textual/src/textual/app.py", line 290, in run_app
    await app.process_messages()
  File "/Users/darrenburns/code/textual/src/textual/app.py", line 475, in process_messages
    self.log_file.write(
AttributeError: 'NoneType' object has no attribute 'write'
```

Discussion:

Patch:
diff --git a/sandbox/uber.py b/sandbox/uber.py
index 2b470742f..37fd74acb 100644
--- a/sandbox/uber.py
+++ b/sandbox/uber.py
@@ -1,7 +1,9 @@
-from textual.app import App
+import sys
+
 from textual import events
-from textual.widgets import Placeholder
+from textual.app import App
 from textual.widget import Widget
+from textual.widgets import Placeholder
 
 
 class BasicApp(App):
@@ -11,6 +13,7 @@ class BasicApp(App):
         self.bind("q", "quit", "Quit")
         self.bind("d", "dump")
         self.bind("t", "log_tree")
+        self.bind("p", "print")
 
     def on_mount(self):
         """Build layout here."""
@@ -42,5 +45,15 @@ class BasicApp(App):
     def action_log_tree(self):
         self.log(self.screen.tree)
 
+    def action_print(self):
+        print(
+            "Printed using builtin [b blue]print[/] function:",
+            self.screen.tree,
+            sep=" - ",
+        )
+        print(1234, 5678)
+
+        sys.stdout.write("abcdef")
+
 
 BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1)
diff --git a/src/textual/__init__.py b/src/textual/__init__.py
index 4958b9a7d..e78bb2009 100644
--- a/src/textual/__init__.py
+++ b/src/textual/__init__.py
@@ -1,5 +1,4 @@
 import inspect
-from typing import Any
 
 from rich.console import RenderableType
 
diff --git a/src/textual/app.py b/src/textual/app.py
index d2c325cb5..3a63f37d6 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -4,24 +4,23 @@ import asyncio
 import inspect
 import os
 import platform
-from time import perf_counter
+import sys
 import warnings
 from asyncio import AbstractEventLoop
-from pathlib import Path
-from typing import Any, Callable, Iterable, Type, TypeVar, TYPE_CHECKING
+from contextlib import redirect_stdout
+from time import perf_counter
+from typing import Any, Iterable, Type, TYPE_CHECKING
 
-import aiohttp
 import rich
 import rich.repr
 from rich.console import Console, RenderableType
 from rich.control import Control
 from rich.measure import Measurement
-from rich.segment import Segments
 from rich.screen import Screen as ScreenRenderable
+from rich.segment import Segments
 from rich.traceback import Traceback
 
 from . import actions
-
 from . import events
 from . import log
 from . import messages
@@ -29,22 +28,22 @@ from ._animator import Animator
 from ._callback import invoke
 from ._context import active_app
 from ._event_broker import extract_handler_actions, NoHandler
+from ._profile import timer
 from .binding import Bindings, NoBinding
-from .css.stylesheet import Stylesheet
-from .devtools.client import DevtoolsClient, DevtoolsConnectionError
+from .css.stylesheet import Stylesheet, StylesheetError
 from .design import ColorSystem
+from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog
+from .devtools.redirect_output import StdoutRedirector
 from .dom import DOMNode
 from .driver import Driver
 from .file_monitor import FileMonitor
 from .geometry import Offset, Region, Size
 from .layouts.dock import Dock
 from .message_pump import MessagePump
-from ._profile import timer
 from .reactive import Reactive
 from .screen import Screen
 from .widget import Widget
 
-
 if TYPE_CHECKING:
     from .css.query import DOMQuery
 
@@ -103,7 +102,6 @@ class App(DOMNode):
         """
         self.console = Console(markup=False, highlight=False, emoji=False)
         self.error_console = Console(markup=False, stderr=True)
-
         self._screen = screen
         self.driver_class = driver_class or self.get_driver_class()
         self._title = title
@@ -125,9 +123,9 @@ class App(DOMNode):
 
         self._log_console: Console | None = None
         if log:
-            self.log_file = open(log, "wt")
+            self._log_file = open(log, "wt")
             self._log_console = Console(
-                file=self.log_file,
+                file=self._log_file,
                 markup=False,
                 emoji=False,
                 highlight=False,
@@ -237,17 +235,16 @@ class App(DOMNode):
         if verbosity > self.log_verbosity:
             return
 
+        if self.devtools.is_connected and not _textual_calling_frame:
+            _textual_calling_frame = inspect.stack()[1]
+
         try:
             if len(objects) == 1 and not kwargs:
                 if self._log_console is not None:
                     self._log_console.print(objects[0])
                 if self.devtools.is_connected:
-                    if not _textual_calling_frame:
-                        _textual_calling_frame = inspect.stack()[1]
-                    calling_path = _textual_calling_frame.filename
-                    calling_lineno = _textual_calling_frame.lineno
                     self.devtools.log(
-                        objects[0], path=calling_path, lineno=calling_lineno
+                        DevtoolsLog(objects, caller=_textual_calling_frame)
                     )
             else:
                 output = " ".join(str(arg) for arg in objects)
@@ -259,11 +256,9 @@ class App(DOMNode):
                 if self._log_console is not None:
                     self._log_console.print(output, soft_wrap=True)
                 if self.devtools.is_connected:
-                    if not _textual_calling_frame:
-                        _textual_calling_frame = inspect.stack()[1]
-                    calling_path = _textual_calling_frame.filename
-                    calling_lineno = _textual_calling_frame.lineno
-                    self.devtools.log(output, path=calling_path, lineno=calling_lineno)
+                    self.devtools.log(
+                        DevtoolsLog(output, caller=_textual_calling_frame)
+                    )
         except Exception:
             pass
 
@@ -336,7 +331,7 @@ class App(DOMNode):
                 elapsed = (perf_counter() - time) * 1000
                 self.log(f"loaded {self.css_file} in {elapsed:.0f}ms")
             except Exception as error:
-                # TODO: catch specific exceptions
+                # TODO: Catch specific exceptions
                 self.console.bell()
                 self.log(error)
             else:
@@ -494,11 +489,15 @@ class App(DOMNode):
         if os.getenv("TEXTUAL_DEVTOOLS") == "1":
             try:
                 await self.devtools.connect()
-                self.log_file.write(f"Connected to devtools ({self.devtools.url})\n")
+                if self._log_console:
+                    self._log_console.print(
+                        f"Connected to devtools ({self.devtools.url})"
+                    )
             except DevtoolsConnectionError:
-                self.log_file.write(
-                    f"Couldn't connect to devtools ({self.devtools.url})\n"
-                )
+                if self._log_console:
+                    self._log_console.print(
+                        f"Couldn't connect to devtools ({self.devtools.url})"
+                    )
         try:
             if self.css_file is not None:
                 self.stylesheet.read(self.css_file)
@@ -526,21 +525,18 @@ class App(DOMNode):
                 mount_event = events.Mount(sender=self)
                 await self.dispatch_message(mount_event)
 
-                self.console = Console()
+                self.console = Console(file=sys.__stdout__)
                 self.title = self._title
                 self.refresh()
                 await self.animator.start()
-                await super().process_messages()
-                log("PROCESS END")
-                if self.devtools.is_connected:
-                    await self._disconnect_devtools()
-                    self.log_file.write(
-                        f"Disconnected from devtools ({self.devtools.url})\n"
-                    )
-                with timer("animator.stop()"):
-                    await self.animator.stop()
-                with timer("self.close_all()"):
-                    await self.close_all()
+
+                with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)):  # type: ignore
+                    await super().process_messages()
+                    log("Message processing stopped")
+                    with timer("animator.stop()"):
+                        await self.animator.stop()
+                    with timer("self.close_all()"):
+                        await self.close_all()
             finally:
                 driver.stop_application_mode()
         except Exception as error:
@@ -549,8 +545,14 @@ class App(DOMNode):
             self._running = False
             if self._exit_renderables:
                 self._print_error_renderables()
-            if self.log_file is not None:
-                self.log_file.close()
+            if self.devtools.is_connected:
+                await self._disconnect_devtools()
+                if self._log_console is not None:
+                    self._log_console.print(
+                        f"Disconnected from devtools ({self.devtools.url})"
+                    )
+            if self._log_file is not None:
+                self._log_file.close()
 
     async def on_idle(self) -> None:
         """Perform actions when there are no messages in the queue."""
diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py
index a9a1a5971..7ec767814 100644
--- a/src/textual/devtools/client.py
+++ b/src/textual/devtools/client.py
@@ -3,22 +3,40 @@ from __future__ import annotations
 import asyncio
 import base64
 import datetime
+import inspect
 import json
 import pickle
 from asyncio import Queue, Task, QueueFull
 from io import StringIO
-from typing import Type, Any
+from typing import Type, Any, NamedTuple
 
 import aiohttp
 from aiohttp import ClientResponseError, ClientConnectorError, ClientWebSocketResponse
 from rich.console import Console
 from rich.segment import Segment
 
+
 DEFAULT_PORT = 8081
 WEBSOCKET_CONNECT_TIMEOUT = 3
 LOG_QUEUE_MAXSIZE = 512
 
 
+class DevtoolsLog(NamedTuple):
+    """A devtools log message.
+
+    Attributes:
+        objects_or_string (tuple[Any, ...]): Corresponds to the data that will
+            ultimately be passed to Console.print in order to generate the log
+            Segments.
+        caller (inspect.FrameInfo): Information about where this log message was
+            created. In other words, where did the user call `print` or `App.log`
+            from. Used to display line number and file name in the devtools window.
+    """
+
+    objects_or_string: tuple[Any, ...] | str
+    caller: inspect.FrameInfo
+
+
 class DevtoolsConsole(Console):
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
@@ -171,16 +189,17 @@ class DevtoolsClient:
             return False
         return not (self.session.closed or self.websocket.closed)
 
-    def log(self, *objects: Any, path: str = "", lineno: int = 0) -> None:
+    def log(self, log: DevtoolsLog) -> None:
         """Queue a log to be sent to the devtools server for display.
 
         Args:
-            *objects (Any): Objects to be logged.
-            path (str): The path of the Python file that this log is associated with (and
-                where the call to this method was made from).
-            lineno (int): The line number this log call was made from.
+            log (DevtoolsLog): The log to write to devtools
         """
-        self.console.print(*objects)
+        if isinstance(log.objects_or_string, str):
+            self.console.print(log.objects_or_string)
+        else:
+            self.console.print(*log.objects_or_string)
+
         segments = self.console.export_segments()
 
         encoded_segments = self._encode_segments(segments)
@@ -189,8 +208,8 @@ class DevtoolsClient:
                 "type": "client_log",
                 "payload": {
                     "timestamp": int(datetime.datetime.utcnow().timestamp()),
-                    "path": path,
-                    "line_number": lineno,
+                    "path": getattr(log.caller, "filename", ""),
+                    "line_number": getattr(log.caller, "lineno", 0),
                     "encoded_segments": encoded_segments,
                 },
             }
diff --git a/src/textual/devtools/redirect_output.py b/src/textual/devtools/redirect_output.py
new file mode 100644
index 000000000..801e52c4b
--- /dev/null
+++ b/src/textual/devtools/redirect_output.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import inspect
+from io import TextIOWrapper
+from typing import TYPE_CHECKING, cast
+from textual.devtools.client import DevtoolsLog
+
+if TYPE_CHECKING:
+    from textual.devtools.client import DevtoolsClient
+
+
+class StdoutRedirector:
+    """
+    A write-only file-like object which redirects anything written to it to the devtools
+    instance associated with the given Textual application. Used within Textual to redirect
+    data written using `print` (or any other stdout writes) to the devtools and/or to the
+    log file.
+    """
+
+    def __init__(
+        self, devtools: DevtoolsClient, log_file: TextIOWrapper | None
+    ) -> None:
+        """
+        Args:
+            devtools (DevtoolsClient): The running Textual app instance.
+            log_file (TextIOWrapper): The log file for the Textual App.
+        """
+        self.devtools = devtools
+        self.log_file = log_file
+        self._buffer: list[DevtoolsLog] = []
+
+    def write(self, string: str) -> None:
+        """Write the log string to the internal buffer. If the string contains
+        a newline character `\n`, the whole string will be buffered and then the
+        buffer will be flushed immediately after.
+
+        Args:
+            string (str): The string to write to the buffer.
+        """
+
+        if not (self.devtools.is_connected or self.log_file is not None):
+            return
+
+        caller = inspect.stack()[1]
+        self._buffer.append(DevtoolsLog(string, caller=caller))
+
+        # By default, `print` adds a "\n" suffix which results in a buffer
+        # flush. You can choose a different suffix with the `end` parameter.
+        # If you modify the `end` parameter to something other than "\n",
+        # then `print` will no longer flush automatically. However, if a
+        # string you are printing contains a "\n", that will trigger
+        # a flush after that string has been buffered, regardless of the value
+        # of `end`.
+        if "\n" in string:
+            self.flush()
+
+    def flush(self) -> None:
+        """Flush the buffer. This will send all buffered log messages to
+        the devtools server and the log file. In the case of the devtools,
+        where possible, log messages will be batched and sent as one.
+        """
+        self._write_to_log_file()
+        self._write_to_devtools()
+        self._buffer.clear()
+
+    def _write_to_devtools(self) -> None:
+        """Send the contents of the buffer to the devtools."""
+        if not self.devtools.is_connected:
+            return
+
+        log_batch: list[DevtoolsLog] = []
+        for log in self._buffer:
+            end_of_batch = log_batch and (
+                log_batch[-1].caller.filename != log.caller.filename
+                or log_batch[-1].caller.lineno != log.caller.lineno
+            )
+            if end_of_batch:
+                self._log_devtools_batched(log_batch)
+                log_batch.clear()
+            log_batch.append(log)
+        if log_batch:
+            self._log_devtools_batched(log_batch)
+
+    def _write_to_log_file(self) -> None:
+        """Write the contents of the buffer to the log file."""
+        if not self.log_file:
+            return
+
+        try:
+            log_text = "".join(str(log.objects_or_string) for log in self._buffer)
+            self.log_file.write(log_text)
+            self.log_file.flush()
+        except OSError:
+            # An error writing to the log file should not be
+            # considered fatal.
+            pass
+
+    def _log_devtools_batched(self, log_batch: list[DevtoolsLog]) -> None:
+        """Write a single batch of logs to devtools. A batch means contiguous logs
+        which have been written from the same line number and file path.
+        A single `print` call may correspond to multiple writes.
+        e.g. `print("a", "b", "c")` is 3 calls to `write`, so we batch
+        up these 3 write calls since they come from the same location, so that
+        they appear inside the same log message in the devtools window
+        rather than a single `print` statement resulting in 3 separate
+        logs being displayed.
+
+        Args:
+            log_batch (list[DevtoolsLog]): A batch of logs to send to the
+                devtools server as one. Log content will be joined together.
+        """
+
+        # This code is only called via stdout.write, and so by this point we know
+        # that the log message content is a string. The cast below tells mypy this.
+        batched_log = "".join(cast(str, log.objects_or_string) for log in log_batch)
+        batched_log = batched_log.rstrip()
+        self.devtools.log(DevtoolsLog(batched_log, caller=log_batch[-1].caller))
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 420ffe3b6..c426cb2bd 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -18,7 +18,6 @@ from . import messages
 
 if TYPE_CHECKING:
     from .app import App
-    from .screen import Screen
 
 
 class NoParent(Exception):


Test patch:
diff --git a/tests/devtools/__init__.py b/tests/devtools/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/conftest.py b/tests/devtools/conftest.py
similarity index 89%
rename from tests/conftest.py
rename to tests/devtools/conftest.py
index 68d1e09c6..92f6253a3 100644
--- a/tests/conftest.py
+++ b/tests/devtools/conftest.py
@@ -1,3 +1,5 @@
+from io import StringIO
+
 import pytest
 
 from textual.devtools.server import _make_devtools_aiohttp_app
@@ -25,3 +27,8 @@ async def devtools(aiohttp_client, server):
     yield devtools
     await devtools.disconnect()
     await client.close()
+
+
+@pytest.fixture
+def in_memory_logfile():
+    yield StringIO()
diff --git a/tests/test_devtools.py b/tests/devtools/test_devtools.py
similarity index 100%
rename from tests/test_devtools.py
rename to tests/devtools/test_devtools.py
diff --git a/tests/test_devtools_client.py b/tests/devtools/test_devtools_client.py
similarity index 76%
rename from tests/test_devtools_client.py
rename to tests/devtools/test_devtools_client.py
index 64e32a687..008ee8f28 100644
--- a/tests/test_devtools_client.py
+++ b/tests/devtools/test_devtools_client.py
@@ -1,4 +1,5 @@
 import json
+import types
 from asyncio import Queue
 from datetime import datetime
 
@@ -9,7 +10,11 @@ from rich.panel import Panel
 
 from tests.utilities.render import wait_for_predicate
 from textual.devtools.client import DevtoolsClient
+from textual.devtools.redirect_output import DevtoolsLog
 
+CALLER_LINENO = 123
+CALLER_PATH = "a/b/c.py"
+CALLER = types.SimpleNamespace(filename=CALLER_PATH, lineno=CALLER_LINENO)
 TIMESTAMP = 1649166819
 
 
@@ -25,15 +30,15 @@ async def test_devtools_client_is_connected(devtools):
 @time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
 async def test_devtools_log_places_encodes_and_queues_message(devtools):
     await devtools._stop_log_queue_processing()
-    devtools.log("Hello, world!")
+    devtools.log(DevtoolsLog("Hello, world!", CALLER))
     queued_log = await devtools.log_queue.get()
     queued_log_json = json.loads(queued_log)
     assert queued_log_json == {
         "type": "client_log",
         "payload": {
             "timestamp": TIMESTAMP,
-            "path": "",
-            "line_number": 0,
+            "path": CALLER_PATH,
+            "line_number": CALLER_LINENO,
             "encoded_segments": "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWA0AAABIZWxsbywgd29ybGQhcQJOTodxA4FxBGgBWAEAAAAKcQVOTodxBoFxB2Uu",
         },
     }
@@ -42,15 +47,15 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools):
 @time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
 async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtools):
     await devtools._stop_log_queue_processing()
-    devtools.log("hello", "world")
+    devtools.log(DevtoolsLog(("hello", "world"), CALLER))
     queued_log = await devtools.log_queue.get()
     queued_log_json = json.loads(queued_log)
     assert queued_log_json == {
         "type": "client_log",
         "payload": {
             "timestamp": TIMESTAMP,
-            "path": "",
-            "line_number": 0,
+            "path": CALLER_PATH,
+            "line_number": CALLER_LINENO,
             "encoded_segments": "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWAsAAABoZWxsbyB3b3JsZHECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg==",
         },
     }
@@ -62,10 +67,10 @@ async def test_devtools_log_spillover(devtools):
     devtools.log_queue = Queue(maxsize=2)
 
     # Force spillover of 2
-    devtools.log(Panel("hello, world"))
-    devtools.log("second message")
-    devtools.log("third message")  # Discarded by rate-limiting
-    devtools.log("fourth message")  # Discarded by rate-limiting
+    devtools.log(DevtoolsLog((Panel("hello, world"),), CALLER))
+    devtools.log(DevtoolsLog("second message", CALLER))
+    devtools.log(DevtoolsLog("third message", CALLER))  # Discarded by rate-limiting
+    devtools.log(DevtoolsLog("fourth message", CALLER))  # Discarded by rate-limiting
 
     assert devtools.spillover == 2
 
@@ -74,7 +79,7 @@ async def test_devtools_log_spillover(devtools):
         await devtools.log_queue.get()
 
     # Add another message now that we're under spillover threshold
-    devtools.log("another message")
+    devtools.log(DevtoolsLog("another message", CALLER))
     await devtools.log_queue.get()
 
     # Ensure we're informing the server of spillover rate-limiting
@@ -89,7 +94,9 @@ async def test_devtools_client_update_console_dimensions(devtools, server):
     """Sending new server info through websocket from server to client should (eventually)
     result in the dimensions of the devtools client console being updated to match.
     """
-    server_to_client: WebSocketResponse = next(iter(server.app["service"].clients)).websocket
+    server_to_client: WebSocketResponse = next(
+        iter(server.app["service"].clients)
+    ).websocket
     server_info = {
         "type": "server_info",
         "payload": {
diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py
new file mode 100644
index 000000000..6974e0d6a
--- /dev/null
+++ b/tests/devtools/test_redirect_output.py
@@ -0,0 +1,132 @@
+import json
+from contextlib import redirect_stdout
+from datetime import datetime
+
+import time_machine
+
+from textual.devtools.redirect_output import StdoutRedirector
+
+TIMESTAMP = 1649166819
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_print_redirect_to_devtools_only(devtools):
+    await devtools._stop_log_queue_processing()
+
+    with redirect_stdout(StdoutRedirector(devtools, None)):  # type: ignore
+        print("Hello, world!")
+
+    assert devtools.log_queue.qsize() == 1
+
+    queued_log = await devtools.log_queue.get()
+    queued_log_json = json.loads(queued_log)
+    payload = queued_log_json["payload"]
+
+    assert queued_log_json["type"] == "client_log"
+    assert payload["timestamp"] == TIMESTAMP
+    assert (
+            payload["encoded_segments"]
+            == "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWA0AAABIZWxsbywgd29ybGQhcQJOTodxA4FxBGgBWAEAAAAKcQVOTodxBoFxB2Uu"
+    )
+
+
+async def test_print_redirect_to_logfile_only(devtools, in_memory_logfile):
+    await devtools.disconnect()
+    with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)):  # type: ignore
+        print("Hello, world!")
+    assert in_memory_logfile.getvalue() == "Hello, world!\n"
+
+
+async def test_print_redirect_to_devtools_and_logfile(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+
+    with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)):  # type: ignore
+        print("Hello, world!")
+
+    assert devtools.log_queue.qsize() == 1
+    assert in_memory_logfile.getvalue() == "Hello, world!\n"
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_print_without_flush_not_sent_to_devtools(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+
+    with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)):  # type: ignore
+        # End is no longer newline character, so print will no longer
+        # flush the output buffer by default.
+        print("Hello, world!", end="")
+
+    assert devtools.log_queue.qsize() == 0
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_print_forced_flush_sent_to_devtools(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+
+    with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)):  # type: ignore
+        print("Hello, world!", end="", flush=True)
+
+    assert devtools.log_queue.qsize() == 1
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+
+    with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)):  # type: ignore
+        # We call print with multiple arguments here, but it
+        # results in a single log added to the log queue.
+        print("Hello", "world", "multiple")
+
+    assert devtools.log_queue.qsize() == 1
+
+    queued_log = await devtools.log_queue.get()
+    queued_log_json = json.loads(queued_log)
+    payload = queued_log_json["payload"]
+
+    assert queued_log_json["type"] == "client_log"
+    assert payload["timestamp"] == TIMESTAMP
+    assert payload[
+               "encoded_segments"] == "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWBQAAABIZWxsbyB3b3JsZCBtdWx0aXBsZXECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg=="
+    assert len(payload["path"]) > 0
+    assert payload["line_number"] != 0
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+    redirector = StdoutRedirector(devtools, in_memory_logfile)
+    with redirect_stdout(redirector):  # type: ignore
+        # This print adds 3 messages to the buffer that can be batched
+        print("The first", "batch", "of logs", end="")
+        # This message cannot be batched with the previous message,
+        # and so it will be the 2nd item added to the log queue.
+        print("I'm in the second batch")
+
+    assert devtools.log_queue.qsize() == 2
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_print_strings_containing_newline_flushed(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+
+    with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)):  # type: ignore
+        # Flushing is disabled since end="", but the first
+        # string will be flushed since it contains a newline
+        print("Hel\nlo", end="")
+        print("world", end="")
+
+    assert devtools.log_queue.qsize() == 1
+
+
+@time_machine.travel(datetime.fromtimestamp(TIMESTAMP))
+async def test_flush_flushes_buffered_logs(devtools, in_memory_logfile):
+    await devtools._stop_log_queue_processing()
+
+    redirector = StdoutRedirector(devtools, in_memory_logfile)
+    with redirect_stdout(redirector):  # type: ignore
+        print("x", end="")
+
+    assert devtools.log_queue.qsize() == 0
+    redirector.flush()
+    assert devtools.log_queue.qsize() == 1