From 0797d3b1dbdf49261cdcbddf59c3caa8f3af4391 Mon Sep 17 00:00:00 2001 From: Ayush Suryawanshi <86666128+GloballyOptimized@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:51:49 +0530 Subject: [PATCH] feat: add start_dir argument to find_dotenv find_dotenv() either searches from the current directory (usecwd=True or an interactive session) or infers the caller's file by walking the stack. There was no way to say where the search should start, short of changing the working directory. `start_dir` sets the starting point explicitly: a directory, or a file whose directory is used (e.g. `find_dotenv(start_dir=__file__)`). When it is given, the stack is not inspected at all, so the search no longer depends on how the code was invoked. `~` is expanded, and a start path that does not exist raises the same IOError as before. Closes #494 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/dotenv/main.py | 16 +++++++++-- tests/test_main.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c9740d..ef2c1836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..e0a5e08a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -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 """ @@ -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: diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..d8bf1c33 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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" )