Repository: theskumar/python-dotenv

Issue:
load_dotenv fails to find .env file by default under python 3.13
When using python 3.13, load_dotenv() requires an explicit path to a dotenv file in the current directory.

```
% cat .env
FOO="bar"
```

```python
Python 3.12.9 (main, Mar 24 2025, 18:36:22) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import dotenv, dotenv.version
>>> dotenv.version.__version__
'1.0.1'
>>> dotenv.load_dotenv()
True

Python 3.12.9 (main, Mar 24 2025, 18:36:22) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import dotenv, dotenv.version
>>> dotenv.version.__version__
'1.1.0'
>>> dotenv.load_dotenv()
True

Python 3.13.3 (main, May 17 2025, 17:59:15) [Clang 17.0.0 (clang-1700.0.13.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import dotenv, dotenv.version
>>> dotenv.version.__version__
'1.0.1'
>>> dotenv.load_dotenv()
False
>>> dotenv.load_dotenv('.env')
True

Python 3.13.3 (main, May 17 2025, 17:59:15) [Clang 17.0.0 (clang-1700.0.13.3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import dotenv, dotenv.version
>>> dotenv.version.__version__
'1.1.0'
>>> dotenv.load_dotenv()
False
>>> dotenv.load_dotenv('.env')
True
```

Hints from issue comments:
@burnout-projects: For me on Windows 11, I can reproduce this issue on the CLI, but works fine from a script.

```
(.venv) C:\Personal\Projects\python-dotenv>python
Python 3.13.3 (tags/v3.13.3:6280bb5, Apr  8 2025, 14:47:33) [MSC v.1943 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import dotenv
>>> dotenv.load_dotenv()
False
>>> 
```

![Image](https://github.com/user-attachments/assets/c8bd8c84-159a-4fa5-914f-cc481f696e25)@burnout-projects: After a bit of digging, the `_is_interactive()` function that decides if it's running from REPL returns `False` in python 3.13 (and `True` in python 3.12). This then decides the path where to look for `.env` and in most cases not in the current path.

This appears to be the result of the developments that went into the 3.13 version. I think it's time we revisit the `_is_interactive()` function.

A suggestion is to look for `sys.ps1` to determine if interactive or not (as also described in python docs)
https://docs.python.org/3/library/sys.html#sys.ps1

## Python 3.13
```
(.venv) C:\Personal\Projects\python-dotenv>python
Python 3.13.3 (tags/v3.13.3:6280bb5, Apr  8 2025, 14:47:33) [MSC v.1943 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> def _is_interactive():
...     """ Decide whether this is running in a REPL or IPython notebook """
...     try:
...         main = __import__('__main__', None, None, fromlist=['__file__'])
...     except ModuleNotFoundError:
...         return False
...     return not hasattr(main, '__file__')
...
...
>>> _is_interactive()
False
>>>
```

## Python 3.12
```
C:\>python
Python 3.12.4 (tags/v3.12.4:8e8a4ba, Jun  6 2024, 19:30:16) [MSC v.1940 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> def _is_interactive():
...   """ Decide whether this is running in a REPL or IPython notebook """
...   try:
...     main = __import__('__main__', None, None, fromlist=['__file__'])
...   except ModuleNotFoundError:
...     return False
...   return not hasattr(main, '__file__')
...
>>> _is_interactive()
True
>>>
```
@burnout-projects: Updating the function to use `sys.ps1` works perfectly on both py3.13 and py3.12.
```
def _is_interactive():
    """ Decide whether this is running in a REPL or IPython notebook """
    return hasattr(sys, 'ps1')
```

The `sys.ps1` attribute has existed since Python 3.0, making it a reliable indicator of an interactive environment. It's curious why a different approach was initially chosen. Perhaps @theskumar or @freddyaboulton knows the reasoning behind it.@mpounsett: What led me to do the python console test was the same problem in a script... I was just trying to show the simplest possible example of the issue.  Testing with a very simple script is getting me results though, so I'll have to go back to my original application and see if I can figure out what's different.  

It was the same problem in the application though... `load_dotenv()` with no arguments didn't find anything, but `load_dotenv('.env')` did.@burnout-projects: IMO, what you found was a valid issue and after the change I mentioned above, it works perfectly fine on script and REPL as well (with Python 3.13):

```
(.venv) C:\Personal\Projects\python-dotenv>python
Python 3.13.3 (tags/v3.13.3:6280bb5, Apr  8 2025, 14:47:33) [MSC v.1943 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from dotenv import load_dotenv
>>> load_dotenv()
True
>>>
```

Maybe the path of .env is not in the root folder of the project. IIRC, the dotenv lib tries to find the .env file starting with the current directory of the script calling it and one by one all the way up to the top.

Solution patch:
diff --git a/src/dotenv/main.py b/src/dotenv/main.py
index 1848d602..8e6a7cf4 100644
--- a/src/dotenv/main.py
+++ b/src/dotenv/main.py
@@ -286,6 +286,8 @@ def find_dotenv(
 
     def _is_interactive():
         """Decide whether this is running in a REPL or IPython notebook"""
+        if hasattr(sys, "ps1") or hasattr(sys, "ps2"):
+            return True
         try:
             main = __import__("__main__", None, None, fromlist=["__file__"])
         except ModuleNotFoundError:


Test patch:
diff --git a/tests/test_is_interactive.py b/tests/test_is_interactive.py
new file mode 100644
index 00000000..f56378e9
--- /dev/null
+++ b/tests/test_is_interactive.py
@@ -0,0 +1,227 @@
+import sys
+import builtins
+from unittest import mock
+from dotenv.main import find_dotenv
+
+
+class TestIsInteractive:
+    """Tests for the _is_interactive helper function within find_dotenv.
+
+    The _is_interactive function is used by find_dotenv to determine if the code
+    is running in an interactive environment (like a REPL, IPython notebook, etc.)
+    versus a normal script execution.
+
+    Interactive environments include:
+    - Python REPL (has sys.ps1 or sys.ps2)
+    - IPython notebooks (no __file__ in __main__)
+    - Interactive shells
+
+    Non-interactive environments include:
+    - Normal script execution (has __file__ in __main__)
+    - Module imports
+
+    Examples of the behavior:
+    >>> import sys
+    >>> # In a REPL:
+    >>> hasattr(sys, 'ps1')  # True
+    >>> # In a script:
+    >>> hasattr(sys, 'ps1')  # False
+    """
+
+    def _create_dotenv_file(self, tmp_path):
+        """Helper to create a test .env file."""
+        dotenv_path = tmp_path / ".env"
+        dotenv_path.write_text("TEST=value")
+        return dotenv_path
+
+    def _setup_subdir_and_chdir(self, tmp_path, monkeypatch):
+        """Helper to create subdirectory and change to it."""
+        test_dir = tmp_path / "subdir"
+        test_dir.mkdir()
+        monkeypatch.chdir(test_dir)
+        return test_dir
+
+    def _remove_ps_attributes(self, monkeypatch):
+        """Helper to remove ps1/ps2 attributes if they exist."""
+        if hasattr(sys, "ps1"):
+            monkeypatch.delattr(sys, "ps1")
+        if hasattr(sys, "ps2"):
+            monkeypatch.delattr(sys, "ps2")
+
+    def _mock_main_import(self, monkeypatch, mock_main_module):
+        """Helper to mock __main__ module import."""
+        original_import = builtins.__import__
+
+        def mock_import(name, *args, **kwargs):
+            if name == "__main__":
+                return mock_main_module
+            return original_import(name, *args, **kwargs)
+
+        monkeypatch.setattr(builtins, "__import__", mock_import)
+
+    def _mock_main_import_error(self, monkeypatch):
+        """Helper to mock __main__ module import that raises ModuleNotFoundError."""
+        original_import = builtins.__import__
+
+        def mock_import(name, *args, **kwargs):
+            if name == "__main__":
+                raise ModuleNotFoundError("No module named '__main__'")
+            return original_import(name, *args, **kwargs)
+
+        monkeypatch.setattr(builtins, "__import__", mock_import)
+
+    def test_is_interactive_with_ps1(self, tmp_path, monkeypatch):
+        """Test that _is_interactive returns True when sys.ps1 exists."""
+        dotenv_path = self._create_dotenv_file(tmp_path)
+
+        # Mock sys.ps1 to simulate interactive shell
+        monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
+
+        self._setup_subdir_and_chdir(tmp_path, monkeypatch)
+
+        # When _is_interactive() returns True, find_dotenv should search from cwd
+        result = find_dotenv()
+        assert result == str(dotenv_path)
+
+    def test_is_interactive_with_ps2(self, tmp_path, monkeypatch):
+        """Test that _is_interactive returns True when sys.ps2 exists."""
+        dotenv_path = self._create_dotenv_file(tmp_path)
+
+        # Mock sys.ps2 to simulate multi-line interactive input
+        monkeypatch.setattr(sys, "ps2", "... ", raising=False)
+
+        self._setup_subdir_and_chdir(tmp_path, monkeypatch)
+
+        # When _is_interactive() returns True, find_dotenv should search from cwd
+        result = find_dotenv()
+        assert result == str(dotenv_path)
+
+    def test_is_interactive_main_module_not_found(self, tmp_path, monkeypatch):
+        """Test that _is_interactive returns False when __main__ module import fails."""
+        self._remove_ps_attributes(monkeypatch)
+        self._mock_main_import_error(monkeypatch)
+
+        # Change to directory and test
+        monkeypatch.chdir(tmp_path)
+
+        # Since _is_interactive() returns False, find_dotenv should not find anything
+        # without usecwd=True
+        result = find_dotenv()
+        assert result == ""
+
+    def test_is_interactive_main_without_file(self, tmp_path, monkeypatch):
+        """Test that _is_interactive returns True when __main__ has no __file__ attribute."""
+        self._remove_ps_attributes(monkeypatch)
+        dotenv_path = self._create_dotenv_file(tmp_path)
+
+        # Mock __main__ module without __file__ attribute
+        mock_main = mock.MagicMock()
+        del mock_main.__file__  # Remove __file__ attribute
+
+        self._mock_main_import(monkeypatch, mock_main)
+        self._setup_subdir_and_chdir(tmp_path, monkeypatch)
+
+        # When _is_interactive() returns True, find_dotenv should search from cwd
+        result = find_dotenv()
+        assert result == str(dotenv_path)
+
+    def test_is_interactive_main_with_file(self, tmp_path, monkeypatch):
+        """Test that _is_interactive returns False when __main__ has __file__ attribute."""
+        self._remove_ps_attributes(monkeypatch)
+
+        # Mock __main__ module with __file__ attribute
+        mock_main = mock.MagicMock()
+        mock_main.__file__ = "/path/to/script.py"
+
+        self._mock_main_import(monkeypatch, mock_main)
+
+        # Change to directory and test
+        monkeypatch.chdir(tmp_path)
+
+        # Since _is_interactive() returns False, find_dotenv should not find anything
+        # without usecwd=True
+        result = find_dotenv()
+        assert result == ""
+
+    def test_is_interactive_precedence_ps1_over_main(self, tmp_path, monkeypatch):
+        """Test that ps1/ps2 attributes take precedence over __main__ module check."""
+        dotenv_path = self._create_dotenv_file(tmp_path)
+
+        # Set ps1 attribute
+        monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
+
+        # Mock __main__ module with __file__ attribute (which would normally return False)
+        mock_main = mock.MagicMock()
+        mock_main.__file__ = "/path/to/script.py"
+
+        self._mock_main_import(monkeypatch, mock_main)
+        self._setup_subdir_and_chdir(tmp_path, monkeypatch)
+
+        # ps1 should take precedence, so _is_interactive() returns True
+        result = find_dotenv()
+        assert result == str(dotenv_path)
+
+    def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch):
+        """Test that _is_interactive returns True when both ps1 and ps2 exist."""
+        dotenv_path = self._create_dotenv_file(tmp_path)
+
+        # Set both ps1 and ps2 attributes
+        monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
+        monkeypatch.setattr(sys, "ps2", "... ", raising=False)
+
+        self._setup_subdir_and_chdir(tmp_path, monkeypatch)
+
+        # Should return True with either attribute present
+        result = find_dotenv()
+        assert result == str(dotenv_path)
+
+    def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch):
+        """Test _is_interactive when __main__ has __file__ attribute set to None."""
+        self._remove_ps_attributes(monkeypatch)
+
+        # Mock __main__ module with __file__ = None
+        mock_main = mock.MagicMock()
+        mock_main.__file__ = None
+
+        self._mock_main_import(monkeypatch, mock_main)
+
+        # Mock sys.gettrace to ensure debugger detection returns False
+        monkeypatch.setattr("sys.gettrace", lambda: None)
+
+        monkeypatch.chdir(tmp_path)
+
+        # __file__ = None should still be considered non-interactive
+        # and with no debugger, find_dotenv should not search from cwd
+        result = find_dotenv()
+        assert result == ""
+
+    def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch):
+        """Test normal script execution scenario where _is_interactive should return False."""
+        self._remove_ps_attributes(monkeypatch)
+
+        # Don't mock anything - let it use the real __main__ module
+        # which should have a __file__ attribute in normal execution
+
+        # Change to directory and test
+        monkeypatch.chdir(tmp_path)
+
+        # In normal execution, _is_interactive() should return False
+        # so find_dotenv should not find anything without usecwd=True
+        result = find_dotenv()
+        assert result == ""
+
+    def test_is_interactive_with_usecwd_override(self, tmp_path, monkeypatch):
+        """Test that usecwd=True overrides _is_interactive behavior."""
+        self._remove_ps_attributes(monkeypatch)
+        dotenv_path = self._create_dotenv_file(tmp_path)
+
+        # Mock __main__ module with __file__ attribute (non-interactive)
+        mock_main = mock.MagicMock()
+        mock_main.__file__ = "/path/to/script.py"
+
+        self._mock_main_import(monkeypatch, mock_main)
+        self._setup_subdir_and_chdir(tmp_path, monkeypatch)
+
+        # Even though _is_interactive() returns False, usecwd=True should find the file
+        result = find_dotenv(usecwd=True)
+        assert result == str(dotenv_path)