diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..b985bc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- `load_dotenv` now finds a current-directory `.env` when called directly as a + `threading.Thread` target, fixing [#531] reported by [@kazzmir] - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] ## [1.2.3] - 2026-08-16 @@ -431,6 +433,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 @@ -483,6 +486,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@jadutter]: https://github.com/jadutter [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner +[@kazzmir]: https://github.com/kazzmir [@larsks]: https://github.com/larsks [@lsmith77]: https://github.com/lsmith77 [@matthewfranglen]: https://github.com/matthewfranglen diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..ecf91717 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 @@ -372,7 +373,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 6f9d4c5c..acb28c37 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,6 +5,7 @@ import subprocess import sys import textwrap +import threading from unittest import mock import pytest @@ -676,6 +677,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")