|
2 | 2 | import logging |
3 | 3 | import os |
4 | 4 | import pathlib |
| 5 | +import select |
5 | 6 | import shutil |
6 | 7 | import stat |
7 | 8 | import sys |
8 | 9 | import tempfile |
| 10 | +import time |
9 | 11 | from collections import OrderedDict |
10 | 12 | from contextlib import contextmanager |
11 | 13 | from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union |
@@ -63,7 +65,11 @@ def __init__( |
63 | 65 | @contextmanager |
64 | 66 | def _get_stream(self) -> Iterator[IO[str]]: |
65 | 67 | if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): |
66 | | - with open(self.dotenv_path, encoding=self.encoding) as stream: |
| 68 | + # Handle files that may need to wait for content to become available |
| 69 | + # This includes FIFOs (named pipes) and mounted filesystems that may |
| 70 | + # initially be empty or not yet populated when first accessed |
| 71 | + stream = _wait_for_file_content(self.dotenv_path, encoding=self.encoding) |
| 72 | + with stream: |
67 | 73 | yield stream |
68 | 74 | elif self.stream is not None: |
69 | 75 | yield self.stream |
@@ -420,6 +426,48 @@ def dotenv_values( |
420 | 426 | ).dict() |
421 | 427 |
|
422 | 428 |
|
| 429 | +def _wait_for_file_content( |
| 430 | + path: StrPath, |
| 431 | + encoding: Optional[str] = None, |
| 432 | + max_wait_time: float = 5.0, |
| 433 | +) -> IO[str]: |
| 434 | + """ |
| 435 | + Wait for file content to be available, handling both regular files and pipes (FIFOs). |
| 436 | +
|
| 437 | + Some environments expose .env data via FIFOs; reading the pipe produces content on demand. |
| 438 | + For FIFOs we block until the pipe is readable, then read once and return a StringIO over |
| 439 | + the decoded bytes. For regular files we open and return the handle directly. |
| 440 | + """ |
| 441 | + start_time = time.time() |
| 442 | + |
| 443 | + try: |
| 444 | + st = os.stat(path) |
| 445 | + is_fifo = stat.S_ISFIFO(st.st_mode) |
| 446 | + except (FileNotFoundError, OSError): |
| 447 | + is_fifo = False |
| 448 | + |
| 449 | + if not is_fifo: |
| 450 | + # Regular file path: open once and return immediately |
| 451 | + return open(path, encoding=encoding) |
| 452 | + |
| 453 | + # FIFO path: block until readable (up to max_wait_time), then read once. |
| 454 | + # Open unbuffered binary so select() reflects readiness accurately before decoding. |
| 455 | + with open(path, "rb", buffering=0) as fifo: |
| 456 | + fd = fifo.fileno() |
| 457 | + timeout = max_wait_time - (time.time() - start_time) |
| 458 | + if timeout < 0: |
| 459 | + timeout = 0 |
| 460 | + ready, _, _ = select.select([fd], [], [], timeout) |
| 461 | + if not ready: |
| 462 | + # If it never became readable, return empty content for caller to handle |
| 463 | + return io.StringIO("") |
| 464 | + |
| 465 | + raw = fifo.read() |
| 466 | + text = raw.decode(encoding or "utf-8", errors="replace") |
| 467 | + # Return a fresh StringIO so caller can read from the start |
| 468 | + return io.StringIO(text) |
| 469 | + |
| 470 | + |
423 | 471 | def _is_file_or_fifo(path: StrPath) -> bool: |
424 | 472 | """ |
425 | 473 | Return True if `path` exists and is either a regular file or a FIFO. |
|
0 commit comments