diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..e3d93656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] - `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] - `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] +- `load_dotenv` now finds a current-directory `.env` when called directly as a + `threading.Thread` target, fixing [#531] reported by [@kazzmir] ## [1.2.4] - 2026-10-01 @@ -443,6 +445,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#454]: https://github.com/theskumar/python-dotenv/issues/454 [#474]: https://github.com/theskumar/python-dotenv/issues/474 [#523]: https://github.com/theskumar/python-dotenv/issues/523 +[#531]: https://github.com/theskumar/python-dotenv/issues/531 [#553]: https://github.com/theskumar/python-dotenv/issues/553 [#569]: https://github.com/theskumar/python-dotenv/issues/569 [#583]: https://github.com/theskumar/python-dotenv/issues/583 @@ -505,6 +508,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@jamalkamaladdin]: https://github.com/jamalkamaladdin [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner +[@kazzmir]: https://github.com/kazzmir [@kurtmckee]: https://github.com/kurtmckee [@larsks]: https://github.com/larsks [@lsmith77]: https://github.com/lsmith77 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..d35dbec7 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -5,6 +5,7 @@ import stat import sys import tempfile +import threading from collections import OrderedDict from contextlib import contextmanager from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union @@ -415,7 +416,11 @@ def _is_debugger(): assert frame.f_back is not None frame = frame.f_back frame_filename = frame.f_code.co_filename - path = os.path.dirname(os.path.abspath(frame_filename)) + path = ( + os.getcwd() + if frame.f_globals is vars(threading) + else os.path.dirname(os.path.abspath(frame_filename)) + ) for dirname in _walk_to_root(path): check_path = os.path.join(dirname, filename) diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..9eb2b6b9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,6 +6,7 @@ import subprocess import sys import textwrap +import threading from unittest import mock import pytest @@ -868,6 +869,99 @@ def test_load_dotenv_in_current_dir(tmp_path): assert result.stdout == "b\n" +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_as_thread_target(tmp_path, monkeypatch): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=b") + monkeypatch.chdir(tmp_path) + + thread = threading.Thread(target=dotenv.load_dotenv) + thread.start() + thread.join() + + assert not thread.is_alive() + assert os.environ.get("a") == "b" + + +def test_find_dotenv_from_module_named_threading(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + dotenv_path = project_dir / ".env" + dotenv_path.write_text("a=b") + module_path = project_dir / "threading.py" + module_path.touch() + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + runner_path = elsewhere / "runner.py" + runner_path.write_text( + textwrap.dedent( + f""" + namespace = {{"__name__": "threading"}} + exec( + compile( + "from dotenv import find_dotenv\\nresult = find_dotenv()\\n", + {str(module_path)!r}, + "exec", + ), + namespace, + ) + print(namespace["result"]) + """ + ) + ) + + result = subprocess.run( + [sys.executable, str(runner_path)], + cwd=elsewhere, + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout == f"{dotenv_path}\n" + + +def test_load_dotenv_from_thread_wrapper_uses_wrapper_location(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".env").write_text("a=b") + wrapper_path = project_dir / "wrapper.py" + wrapper_path.write_text( + "from dotenv import load_dotenv\n\ndef load():\n load_dotenv()\n" + ) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + runner_path = elsewhere / "runner.py" + runner_path.write_text( + textwrap.dedent( + f""" + import os + import sys + import threading + + sys.path.insert(0, {str(project_dir)!r}) + from wrapper import load + + os.environ.pop("a", None) + thread = threading.Thread(target=load) + thread.start() + thread.join() + print(os.environ["a"]) + """ + ) + ) + + result = subprocess.run( + [sys.executable, str(runner_path)], + cwd=elsewhere, + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout == "b\n" + + def test_dotenv_values_file(dotenv_path): dotenv_path.write_text("a=b")