Skip to content

Commit 58cda08

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 58cda08

File tree

2 files changed

+294
-0
lines changed

2 files changed

+294
-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: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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 test_is_interactive_with_ps1(self, tmp_path, monkeypatch):
33+
"""Test that _is_interactive returns True when sys.ps1 exists."""
34+
# Create a test .env file
35+
dotenv_path = tmp_path / ".env"
36+
dotenv_path.write_text("TEST=value")
37+
38+
# Mock sys.ps1 to simulate interactive shell
39+
monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
40+
41+
# Change to directory without .env to force search
42+
test_dir = tmp_path / "subdir"
43+
test_dir.mkdir()
44+
monkeypatch.chdir(test_dir)
45+
46+
# When _is_interactive() returns True, find_dotenv should search from cwd
47+
result = find_dotenv()
48+
assert result == str(dotenv_path)
49+
50+
def test_is_interactive_with_ps2(self, tmp_path, monkeypatch):
51+
"""Test that _is_interactive returns True when sys.ps2 exists."""
52+
# Create a test .env file
53+
dotenv_path = tmp_path / ".env"
54+
dotenv_path.write_text("TEST=value")
55+
56+
# Mock sys.ps2 to simulate multi-line interactive input
57+
monkeypatch.setattr(sys, "ps2", "... ", raising=False)
58+
59+
# Change to directory without .env to force search
60+
test_dir = tmp_path / "subdir"
61+
test_dir.mkdir()
62+
monkeypatch.chdir(test_dir)
63+
64+
# When _is_interactive() returns True, find_dotenv should search from cwd
65+
result = find_dotenv()
66+
assert result == str(dotenv_path)
67+
68+
def test_is_interactive_main_module_not_found(self, tmp_path, monkeypatch):
69+
"""Test that _is_interactive returns False when __main__ module import fails."""
70+
# Remove any ps1/ps2 attributes if they exist
71+
if hasattr(sys, "ps1"):
72+
monkeypatch.delattr(sys, "ps1")
73+
if hasattr(sys, "ps2"):
74+
monkeypatch.delattr(sys, "ps2")
75+
76+
# Mock __import__ to raise ModuleNotFoundError for __main__
77+
original_import = builtins.__import__
78+
79+
def mock_import(name, *args, **kwargs):
80+
if name == "__main__":
81+
raise ModuleNotFoundError("No module named '__main__'")
82+
return original_import(name, *args, **kwargs)
83+
84+
monkeypatch.setattr(builtins, "__import__", mock_import)
85+
86+
# Change to directory and test
87+
monkeypatch.chdir(tmp_path)
88+
89+
# Since _is_interactive() returns False, find_dotenv should not find anything
90+
# without usecwd=True
91+
result = find_dotenv()
92+
assert result == ""
93+
94+
def test_is_interactive_main_without_file(self, tmp_path, monkeypatch):
95+
"""Test that _is_interactive returns True when __main__ has no __file__ attribute."""
96+
# Remove any ps1/ps2 attributes if they exist
97+
if hasattr(sys, "ps1"):
98+
monkeypatch.delattr(sys, "ps1")
99+
if hasattr(sys, "ps2"):
100+
monkeypatch.delattr(sys, "ps2")
101+
102+
# Create a test .env file
103+
dotenv_path = tmp_path / ".env"
104+
dotenv_path.write_text("TEST=value")
105+
106+
# Mock __main__ module without __file__ attribute
107+
mock_main = mock.MagicMock()
108+
del mock_main.__file__ # Remove __file__ attribute
109+
110+
original_import = builtins.__import__
111+
112+
def mock_import(name, *args, **kwargs):
113+
if name == "__main__":
114+
return mock_main
115+
return original_import(name, *args, **kwargs)
116+
117+
monkeypatch.setattr(builtins, "__import__", mock_import)
118+
119+
# Change to directory without .env to force search
120+
test_dir = tmp_path / "subdir"
121+
test_dir.mkdir()
122+
monkeypatch.chdir(test_dir)
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+
# Remove any ps1/ps2 attributes if they exist
131+
if hasattr(sys, "ps1"):
132+
monkeypatch.delattr(sys, "ps1")
133+
if hasattr(sys, "ps2"):
134+
monkeypatch.delattr(sys, "ps2")
135+
136+
# Mock __main__ module with __file__ attribute
137+
mock_main = mock.MagicMock()
138+
mock_main.__file__ = "/path/to/script.py"
139+
140+
original_import = builtins.__import__
141+
142+
def mock_import(name, *args, **kwargs):
143+
if name == "__main__":
144+
return mock_main
145+
return original_import(name, *args, **kwargs)
146+
147+
monkeypatch.setattr(builtins, "__import__", mock_import)
148+
149+
# Change to directory and test
150+
monkeypatch.chdir(tmp_path)
151+
152+
# Since _is_interactive() returns False, find_dotenv should not find anything
153+
# without usecwd=True
154+
result = find_dotenv()
155+
assert result == ""
156+
157+
def test_is_interactive_precedence_ps1_over_main(self, tmp_path, monkeypatch):
158+
"""Test that ps1/ps2 attributes take precedence over __main__ module check."""
159+
# Create a test .env file
160+
dotenv_path = tmp_path / ".env"
161+
dotenv_path.write_text("TEST=value")
162+
163+
# Set ps1 attribute
164+
monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
165+
166+
# Mock __main__ module with __file__ attribute (which would normally return False)
167+
mock_main = mock.MagicMock()
168+
mock_main.__file__ = "/path/to/script.py"
169+
170+
original_import = builtins.__import__
171+
172+
def mock_import(name, *args, **kwargs):
173+
if name == "__main__":
174+
return mock_main
175+
return original_import(name, *args, **kwargs)
176+
177+
monkeypatch.setattr(builtins, "__import__", mock_import)
178+
179+
# Change to directory without .env to force search
180+
test_dir = tmp_path / "subdir"
181+
test_dir.mkdir()
182+
monkeypatch.chdir(test_dir)
183+
184+
# ps1 should take precedence, so _is_interactive() returns True
185+
result = find_dotenv()
186+
assert result == str(dotenv_path)
187+
188+
def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch):
189+
"""Test that _is_interactive returns True when both ps1 and ps2 exist."""
190+
# Create a test .env file
191+
dotenv_path = tmp_path / ".env"
192+
dotenv_path.write_text("TEST=value")
193+
194+
# Set both ps1 and ps2 attributes
195+
monkeypatch.setattr(sys, "ps1", ">>> ", raising=False)
196+
monkeypatch.setattr(sys, "ps2", "... ", raising=False)
197+
198+
# Change to directory without .env to force search
199+
test_dir = tmp_path / "subdir"
200+
test_dir.mkdir()
201+
monkeypatch.chdir(test_dir)
202+
203+
# Should return True with either attribute present
204+
result = find_dotenv()
205+
assert result == str(dotenv_path)
206+
207+
def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch):
208+
"""Test _is_interactive when __main__ has __file__ attribute set to None."""
209+
# Remove any ps1/ps2 attributes if they exist
210+
if hasattr(sys, "ps1"):
211+
monkeypatch.delattr(sys, "ps1")
212+
if hasattr(sys, "ps2"):
213+
monkeypatch.delattr(sys, "ps2")
214+
215+
# Create a test .env file
216+
dotenv_path = tmp_path / ".env"
217+
dotenv_path.write_text("TEST=value")
218+
219+
# Mock __main__ module with __file__ = None
220+
mock_main = mock.MagicMock()
221+
mock_main.__file__ = None
222+
223+
original_import = builtins.__import__
224+
225+
def mock_import(name, *args, **kwargs):
226+
if name == "__main__":
227+
return mock_main
228+
return original_import(name, *args, **kwargs)
229+
230+
monkeypatch.setattr(builtins, "__import__", mock_import)
231+
232+
# Change to directory without .env to force search
233+
test_dir = tmp_path / "subdir"
234+
test_dir.mkdir()
235+
monkeypatch.chdir(test_dir)
236+
237+
# __file__ = None should still be considered non-interactive
238+
result = find_dotenv()
239+
assert result == ""
240+
241+
def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch):
242+
"""Test normal script execution scenario where _is_interactive should return False."""
243+
# Remove any ps1/ps2 attributes if they exist
244+
if hasattr(sys, "ps1"):
245+
monkeypatch.delattr(sys, "ps1")
246+
if hasattr(sys, "ps2"):
247+
monkeypatch.delattr(sys, "ps2")
248+
249+
# Don't mock anything - let it use the real __main__ module
250+
# which should have a __file__ attribute in normal execution
251+
252+
# Change to directory and test
253+
monkeypatch.chdir(tmp_path)
254+
255+
# In normal execution, _is_interactive() should return False
256+
# so find_dotenv should not find anything without usecwd=True
257+
result = find_dotenv()
258+
assert result == ""
259+
260+
def test_is_interactive_with_usecwd_override(self, tmp_path, monkeypatch):
261+
"""Test that usecwd=True overrides _is_interactive behavior."""
262+
# Remove any ps1/ps2 attributes if they exist
263+
if hasattr(sys, "ps1"):
264+
monkeypatch.delattr(sys, "ps1")
265+
if hasattr(sys, "ps2"):
266+
monkeypatch.delattr(sys, "ps2")
267+
268+
# Create a test .env file
269+
dotenv_path = tmp_path / ".env"
270+
dotenv_path.write_text("TEST=value")
271+
272+
# Mock __main__ module with __file__ attribute (non-interactive)
273+
mock_main = mock.MagicMock()
274+
mock_main.__file__ = "/path/to/script.py"
275+
276+
original_import = builtins.__import__
277+
278+
def mock_import(name, *args, **kwargs):
279+
if name == "__main__":
280+
return mock_main
281+
return original_import(name, *args, **kwargs)
282+
283+
monkeypatch.setattr(builtins, "__import__", mock_import)
284+
285+
# Change to directory without .env to force search
286+
test_dir = tmp_path / "subdir"
287+
test_dir.mkdir()
288+
monkeypatch.chdir(test_dir)
289+
290+
# Even though _is_interactive() returns False, usecwd=True should find the file
291+
result = find_dotenv(usecwd=True)
292+
assert result == str(dotenv_path)

0 commit comments

Comments
 (0)