Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added

- Add support for Python 3.15.
- `find_dotenv` accepts a `start_dir` argument to choose the directory (or file) the upward search starts from, instead of inferring it from the caller (#494).

### Fixed

Expand Down
16 changes: 13 additions & 3 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,20 @@ def find_dotenv(
filename: str = ".env",
raise_error_if_not_found: bool = False,
usecwd: bool = False,
start_dir: Optional[StrPath] = None,
) -> str:
"""
Search in increasingly higher folders for the given file

A leading `~` in `filename` is expanded to the user's home directory, and
an absolute `filename` is returned as is if it exists.
The search starts from `start_dir` when it is given. Otherwise it starts
from the current working directory if `usecwd` is true or Python is
running interactively, and from the directory of the calling file in all
other cases. `start_dir` may also be a file, in which case its directory
is used, so `find_dotenv(start_dir=__file__)` searches upwards from the
calling module regardless of how it was invoked.

A leading `~` in `filename` or `start_dir` is expanded to the user's home
directory, and an absolute `filename` is returned as is if it exists.

Returns path to the file if found, or an empty string otherwise
"""
Expand All @@ -401,7 +409,9 @@ def _is_interactive():
def _is_debugger():
return sys.gettrace() is not None

if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False):
if start_dir is not None:
path = os.path.abspath(os.path.expanduser(os.fspath(start_dir)))
elif usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False):
# Should work without __file__, e.g. in REPL or IPython notebook.
path = os.getcwd()
else:
Expand Down
71 changes: 71 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,77 @@ def test_find_dotenv_expands_user(tmp_path, monkeypatch):
assert result == f"{home}/.env"


def test_find_dotenv_start_dir_searches_upwards(tmp_path):
leaf = prepare_file_hierarchy(tmp_path)
dotenv_path = tmp_path / "child1" / ".env"
dotenv_path.write_text("a=b\n")

result = dotenv.find_dotenv(start_dir=leaf)

assert result == str(dotenv_path)


def test_find_dotenv_start_dir_accepts_str_and_file(tmp_path):
leaf = prepare_file_hierarchy(tmp_path)
dotenv_path = tmp_path / ".env"
dotenv_path.write_text("a=b\n")
module = leaf / "app.py"
module.write_text("")

assert dotenv.find_dotenv(start_dir=str(leaf)) == str(dotenv_path)
assert dotenv.find_dotenv(start_dir=module) == str(dotenv_path)


def test_find_dotenv_start_dir_skips_caller_detection(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("a=b\n")

def fail(*args, **kwargs):
raise AssertionError("caller frame inspected")

monkeypatch.setattr(sys, "_getframe", fail)

assert dotenv.find_dotenv(start_dir=tmp_path) == str(tmp_path / ".env")


def test_find_dotenv_start_dir_takes_precedence_over_usecwd(tmp_path, monkeypatch):
cwd = tmp_path / "cwd"
cwd.mkdir()
(cwd / ".env").write_text("a=cwd\n")
start = tmp_path / "start"
start.mkdir()
(start / ".env").write_text("a=start\n")
monkeypatch.chdir(cwd)

result = dotenv.find_dotenv(usecwd=True, start_dir=start)

assert result == str(start / ".env")


def test_find_dotenv_start_dir_expands_user(tmp_path, monkeypatch):
home = tmp_path / "home"
(home / "project").mkdir(parents=True)
(home / ".env").write_text("a=b\n")
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))

result = dotenv.find_dotenv(start_dir="~/project")

assert result == str(home / ".env")


def test_find_dotenv_start_dir_not_found(tmp_path):
leaf = prepare_file_hierarchy(tmp_path)

assert dotenv.find_dotenv(start_dir=leaf) == ""
with pytest.raises(IOError):
dotenv.find_dotenv(start_dir=leaf, raise_error_if_not_found=True)


def test_find_dotenv_start_dir_missing(tmp_path):
with pytest.raises(IOError, match="Starting path not found"):
dotenv.find_dotenv(start_dir=tmp_path / "does-not-exist")


@pytest.mark.skipif(
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
)
Expand Down
Loading