From ad86b6656baa8c3d375e2cd6e8196939766d2249 Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Sat, 13 Dec 2025 18:18:08 +0000 Subject: [PATCH 1/2] feat: add support for FIFO (named pipe) files Add support for reading .env files from FIFOs (named pipes) which may not have content immediately available when first accessed. This is useful for environments that expose .env data via FIFOs or mounted filesystems that may initially be empty. - Add _wait_for_file_content() function to handle both regular files and FIFOs with proper blocking/waiting logic - Update _get_stream() to use the new function for FIFO-aware file reading - Add select and time imports for FIFO handling --- src/dotenv/main.py | 50 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1d6bf0b0..31f49f5d 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,10 +2,12 @@ import logging import os import pathlib +import select import shutil import stat import sys import tempfile +import time from collections import OrderedDict from contextlib import contextmanager from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union @@ -63,7 +65,11 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): - with open(self.dotenv_path, encoding=self.encoding) as stream: + # Handle files that may need to wait for content to become available + # This includes FIFOs (named pipes) and mounted filesystems that may + # initially be empty or not yet populated when first accessed + stream = _wait_for_file_content(self.dotenv_path, encoding=self.encoding) + with stream: yield stream elif self.stream is not None: yield self.stream @@ -420,6 +426,48 @@ def dotenv_values( ).dict() +def _wait_for_file_content( + path: StrPath, + encoding: Optional[str] = None, + max_wait_time: float = 5.0, +) -> IO[str]: + """ + Wait for file content to be available, handling both regular files and pipes (FIFOs). + + Some environments expose .env data via FIFOs; reading the pipe produces content on demand. + For FIFOs we block until the pipe is readable, then read once and return a StringIO over + the decoded bytes. For regular files we open and return the handle directly. + """ + start_time = time.time() + + try: + st = os.stat(path) + is_fifo = stat.S_ISFIFO(st.st_mode) + except (FileNotFoundError, OSError): + is_fifo = False + + if not is_fifo: + # Regular file path: open once and return immediately + return open(path, encoding=encoding) + + # FIFO path: block until readable (up to max_wait_time), then read once. + # Open unbuffered binary so select() reflects readiness accurately before decoding. + with open(path, "rb", buffering=0) as fifo: + fd = fifo.fileno() + timeout = max_wait_time - (time.time() - start_time) + if timeout < 0: + timeout = 0 + ready, _, _ = select.select([fd], [], [], timeout) + if not ready: + # If it never became readable, return empty content for caller to handle + return io.StringIO("") + + raw = fifo.read() + text = raw.decode(encoding or "utf-8", errors="replace") + # Return a fresh StringIO so caller can read from the start + return io.StringIO(text) + + def _is_file_or_fifo(path: StrPath) -> bool: """ Return True if `path` exists and is either a regular file or a FIFO. From 8c74db59e1bb9a693e46e26c38139d69f4d3de65 Mon Sep 17 00:00:00 2001 From: Luke Oliff Date: Sat, 13 Dec 2025 18:26:14 +0000 Subject: [PATCH 2/2] test: add tests for FIFO file content waiting logic Add tests for _wait_for_file_content() to verify it correctly handles both regular files and FIFOs. The existing FIFO test worked because it started a writer thread before reading, ensuring the FIFO was ready. However, with 1Password's asynchronously mounted FIFO files, the FIFO may exist but not be immediately readable when first accessed. The old code would open FIFOs directly in text mode, which could block indefinitely waiting for a writer. The new _wait_for_file_content() function addresses this by: - Detecting FIFOs using stat.S_ISFIFO() - Using select.select() to wait for the FIFO to become readable - Reading content only when the FIFO is ready These tests verify: - Regular files continue to work correctly (no regression) - FIFOs are properly handled with waiting logic for async-mounted files --- tests/test_fifo_dotenv.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py index 4961adce..e2654d0a 100644 --- a/tests/test_fifo_dotenv.py +++ b/tests/test_fifo_dotenv.py @@ -6,6 +6,7 @@ import pytest from dotenv import load_dotenv +from dotenv.main import _wait_for_file_content pytestmark = pytest.mark.skipif( sys.platform.startswith("win"), reason="FIFOs are Unix-only" @@ -31,3 +32,35 @@ def writer(): assert ok is True assert os.getenv("MY_PASSWORD") == "pipe-secret" + + +def test_wait_for_file_content_with_regular_file(tmp_path: pathlib.Path): + """Test that _wait_for_file_content handles regular files correctly.""" + regular_file = tmp_path / ".env" + regular_file.write_text("KEY=value\n", encoding="utf-8") + + stream = _wait_for_file_content(str(regular_file), encoding="utf-8") + content = stream.read() + + assert content == "KEY=value\n" + stream.close() + + +def test_wait_for_file_content_with_fifo(tmp_path: pathlib.Path): + """Test that _wait_for_file_content handles FIFOs correctly.""" + fifo = tmp_path / ".env" + os.mkfifo(fifo) + + def writer(): + with open(fifo, "w", encoding="utf-8") as w: + w.write("FIFO_KEY=fifo-value\n") + + t = threading.Thread(target=writer) + t.start() + + stream = _wait_for_file_content(str(fifo), encoding="utf-8") + content = stream.read() + + t.join(timeout=2) + + assert content == "FIFO_KEY=fifo-value\n"