Skip to content

Commit 8411987

Browse files
authored
fix: ensure find_dotenv work reliably on python 3.13 (#563)
1 parent 01f8997 commit 8411987

File tree

2 files changed

+229
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)