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" )