Skip to content

Commit 202af64

Browse files
committed
fix: ensure find_dotenv work reliably on python 3.13
Not removing the check using the `__main__.__file__` for backward compatibility and ensure we don't break any existing use-cases. I tested and some of the environments like notebooks, VSCode notebook and Google Colab doesn't set the `ps1` attribute of `sys`, let's revisit and remove the extra checks if someone can prove it otherwise. Closes #562 Thanks @burnout-projects @mpounsett
1 parent 01f8997 commit 202af64

File tree

2 files changed

+226
-0
lines changed

2 files changed

+226
-0
lines changed

src/dotenv/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ def find_dotenv(
286286

287287
def _is_interactive():
288288
"""Decide whether this is running in a REPL or IPython notebook"""
289+
if hasattr(sys, "ps1") or hasattr(sys, "ps2"):
290+
return True
289291
try:
290292
main = __import__("__main__", None, None, fromlist=["__file__"])
291293
except ModuleNotFoundError:

tests/test_is_interactive.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import sys
2+
import pytest
3+
import builtins
4+
from unittest import mock
5+
from dotenv.main import find_dotenv
6+
7+
8+
class TestIsInteractive:
9+
"""Tests for the _is_interactive helper function within find_dotenv.
10+
11+
The _is_interactive function is used by find_dotenv to determine if the code
12+
is running in an interactive environment (like a REPL, IPython notebook, etc.)
13+
versus a normal script execution.
14+
15+
Interactive environments include:
16+
- Python REPL (has sys.ps1 or sys.ps2)
17+
- IPython notebooks (no __file__ in __main__)
18+
- Interactive shells
19+
20+
Non-interactive environments include:
21+
- Normal script execution (has __file__ in __main__)
22+
- Module imports
23+
24+
Examples of the behavior:
25+
>>> import sys
26+
>>> # In a REPL:
27+
>>> hasattr(sys, 'ps1') # True
28+
>>> # In a script:
29+
>>> hasattr(sys, 'ps1') # False
30+
"""
31+
32+
def _create_dotenv_file(self, tmp_path):
33+
"""Helper to create a test .env file."""
34+
dotenv_path = tmp_path / ".env"
35+
dotenv_path.write_text("TEST=value")
36+
return dotenv_path
37+
38+
def _setup_subdir_and_chdir(self, tmp_path, monkeypatch):
39+
"""Helper to create subdirectory and change to it."""
40+
test_dir = tmp_path / "subdir"
41+
test_dir.mkdir()
42+
monkeypatch.chdir(test_dir)
43+
return test_dir
44+
45+
def _remove_ps_attributes(self, monkeypatch):
46+
"""Helper to remove ps1/ps2 attributes if they exist."""
47+
if hasattr(sys, "ps1"):
48+
monkeypatch.delattr(sys, "ps1")
49+
if hasattr(sys, "ps2"):
50+
monkeypatch.delattr(sys, "ps2")
51+
52+
def _mock_main_import(self, monkeypatch, mock_main_module):
53+
"""Helper to mock __main__ module import."""
54+
original_import = builtins.__import__
55+
56+
def mock_import(name, *args, **kwargs):
57+
if name == "__main__":
58+
return mock_main_module
59+
return original_import(name, *args, **kwargs)
60+
61+
monkeypatch.setattr(builtins, "__import__", mock_import)
62+
63+
def _mock_main_import_error(self, monkeypatch):
64+
"""Helper to mock __main__ module import that raises ModuleNotFoundError."""
65+
original_import = builtins.__import__
66+
67+
def mock_import(name, *args, **kwargs):
68+
if name == "__main__":
69+
raise ModuleNotFoundError("No module named '__main__'")
70+
return original_import(name, *args, **kwargs)
71+
72+
monkeypatch.setattr(builtins, "__import__", mock_import)
73+
74+
def test_is_interactive_with_ps1(self, tmp_path, monkeypatch):
75+
"""Test that _is_interactive returns True when sys.ps1 exists."""
76+
dotenv_path = self._create_dotenv_file(tmp_path)
77+
78+
# Mock sys.ps1 to simulate interactive shell
79+
monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
80+
81+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
82+
83+
# When _is_interactive() returns True, find_dotenv should search from cwd
84+
result = find_dotenv()
85+
assert result == str(dotenv_path)
86+
87+
def test_is_interactive_with_ps2(self, tmp_path, monkeypatch):
88+
"""Test that _is_interactive returns True when sys.ps2 exists."""
89+
dotenv_path = self._create_dotenv_file(tmp_path)
90+
91+
# Mock sys.ps2 to simulate multi-line interactive input
92+
monkeypatch.setattr(sys, "ps2", "... ", raising=False)
93+
94+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
95+
96+
# When _is_interactive() returns True, find_dotenv should search from cwd
97+
result = find_dotenv()
98+
assert result == str(dotenv_path)
99+
100+
def test_is_interactive_main_module_not_found(self, tmp_path, monkeypatch):
101+
"""Test that _is_interactive returns False when __main__ module import fails."""
102+
self._remove_ps_attributes(monkeypatch)
103+
self._mock_main_import_error(monkeypatch)
104+
105+
# Change to directory and test
106+
monkeypatch.chdir(tmp_path)
107+
108+
# Since _is_interactive() returns False, find_dotenv should not find anything
109+
# without usecwd=True
110+
result = find_dotenv()
111+
assert result == ""
112+
113+
def test_is_interactive_main_without_file(self, tmp_path, monkeypatch):
114+
"""Test that _is_interactive returns True when __main__ has no __file__ attribute."""
115+
self._remove_ps_attributes(monkeypatch)
116+
dotenv_path = self._create_dotenv_file(tmp_path)
117+
118+
# Mock __main__ module without __file__ attribute
119+
mock_main = mock.MagicMock()
120+
del mock_main.__file__ # Remove __file__ attribute
121+
122+
self._mock_main_import(monkeypatch, mock_main)
123+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
124+
125+
# When _is_interactive() returns True, find_dotenv should search from cwd
126+
result = find_dotenv()
127+
assert result == str(dotenv_path)
128+
129+
def test_is_interactive_main_with_file(self, tmp_path, monkeypatch):
130+
"""Test that _is_interactive returns False when __main__ has __file__ attribute."""
131+
self._remove_ps_attributes(monkeypatch)
132+
133+
# Mock __main__ module with __file__ attribute
134+
mock_main = mock.MagicMock()
135+
mock_main.__file__ = "/path/to/script.py"
136+
137+
self._mock_main_import(monkeypatch, mock_main)
138+
139+
# Change to directory and test
140+
monkeypatch.chdir(tmp_path)
141+
142+
# Since _is_interactive() returns False, find_dotenv should not find anything
143+
# without usecwd=True
144+
result = find_dotenv()
145+
assert result == ""
146+
147+
def test_is_interactive_precedence_ps1_over_main(self, tmp_path, monkeypatch):
148+
"""Test that ps1/ps2 attributes take precedence over __main__ module check."""
149+
dotenv_path = self._create_dotenv_file(tmp_path)
150+
151+
# Set ps1 attribute
152+
monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
153+
154+
# Mock __main__ module with __file__ attribute (which would normally return False)
155+
mock_main = mock.MagicMock()
156+
mock_main.__file__ = "/path/to/script.py"
157+
158+
self._mock_main_import(monkeypatch, mock_main)
159+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
160+
161+
# ps1 should take precedence, so _is_interactive() returns True
162+
result = find_dotenv()
163+
assert result == str(dotenv_path)
164+
165+
def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch):
166+
"""Test that _is_interactive returns True when both ps1 and ps2 exist."""
167+
dotenv_path = self._create_dotenv_file(tmp_path)
168+
169+
# Set both ps1 and ps2 attributes
170+
monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
171+
monkeypatch.setattr(sys, "ps2", "... ", raising=False)
172+
173+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
174+
175+
# Should return True with either attribute present
176+
result = find_dotenv()
177+
assert result == str(dotenv_path)
178+
179+
def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch):
180+
"""Test _is_interactive when __main__ has __file__ attribute set to None."""
181+
self._remove_ps_attributes(monkeypatch)
182+
dotenv_path = self._create_dotenv_file(tmp_path)
183+
184+
# Mock __main__ module with __file__ = None
185+
mock_main = mock.MagicMock()
186+
mock_main.__file__ = None
187+
188+
self._mock_main_import(monkeypatch, mock_main)
189+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
190+
191+
# __file__ = None should still be considered non-interactive
192+
result = find_dotenv()
193+
assert result == ""
194+
195+
def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch):
196+
"""Test normal script execution scenario where _is_interactive should return False."""
197+
self._remove_ps_attributes(monkeypatch)
198+
199+
# Don't mock anything - let it use the real __main__ module
200+
# which should have a __file__ attribute in normal execution
201+
202+
# Change to directory and test
203+
monkeypatch.chdir(tmp_path)
204+
205+
# In normal execution, _is_interactive() should return False
206+
# so find_dotenv should not find anything without usecwd=True
207+
result = find_dotenv()
208+
assert result == ""
209+
210+
def test_is_interactive_with_usecwd_override(self, tmp_path, monkeypatch):
211+
"""Test that usecwd=True overrides _is_interactive behavior."""
212+
self._remove_ps_attributes(monkeypatch)
213+
dotenv_path = self._create_dotenv_file(tmp_path)
214+
215+
# Mock __main__ module with __file__ attribute (non-interactive)
216+
mock_main = mock.MagicMock()
217+
mock_main.__file__ = "/path/to/script.py"
218+
219+
self._mock_main_import(monkeypatch, mock_main)
220+
self._setup_subdir_and_chdir(tmp_path, monkeypatch)
221+
222+
# Even though _is_interactive() returns False, usecwd=True should find the file
223+
result = find_dotenv(usecwd=True)
224+
assert result == str(dotenv_path)

0 commit comments

Comments
 (0)