diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..e0631fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- The IPython `%dotenv` magic now accepts quoted file paths containing spaces, including relative, absolute, and home-directory paths. - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] - `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] - `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] diff --git a/src/dotenv/ipython.py b/src/dotenv/ipython.py index 4e7edbbf..013aad91 100644 --- a/src/dotenv/ipython.py +++ b/src/dotenv/ipython.py @@ -1,3 +1,5 @@ +import sys + from IPython.core.magic import Magics, line_magic, magics_class # type: ignore from IPython.core.magic_arguments import ( argument, @@ -35,6 +37,15 @@ def dotenv(self, line): args = parse_argstring(self.dotenv, line) # Locate the .env file dotenv_path = args.dotenv_path + # IPython's POSIX parser keeps grouping quotes. On Windows they are + # already removed, and any remaining apostrophes are part of the path. + if ( + sys.platform != "win32" + and len(dotenv_path) >= 2 + and dotenv_path[0] == dotenv_path[-1] + and dotenv_path[0] in ("'", '"') + ): + dotenv_path = dotenv_path[1:-1] try: dotenv_path = find_dotenv(dotenv_path, True, True) except IOError: diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 93bffd66..ac948d3f 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -76,3 +76,95 @@ def test_ipython_path_expands_user(tmp_path, monkeypatch): ipshell.run_line_magic("dotenv", "~/.env") assert os.environ["dotenv_ipython_home"] == "b" + + +@pytest.mark.parametrize("quote", ['"', "'"]) +@pytest.mark.parametrize("path_kind", ["relative", "absolute", "home"]) +def test_ipython_quoted_path(tmp_path, monkeypatch, quote, path_kind): + from IPython.core.error import UsageError + from IPython.terminal.embed import InteractiveShellEmbed + + directory = tmp_path / "my project" + directory.mkdir() + dotenv_file = directory / "app.env" + dotenv_file.write_text("DOTENV_QUOTED_PATH=loaded\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("DOTENV_QUOTED_PATH", "original") + paths = { + "relative": "my project/app.env", + "absolute": str(dotenv_file), + "home": "~/my project/app.env", + } + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + if sys.platform == "win32" and quote == "'": + # CommandLineToArgvW does not use single quotes to group arguments. + with pytest.raises(UsageError): + ipshell.run_line_magic("dotenv", f"-o {quote}{paths[path_kind]}{quote}") + assert os.environ["DOTENV_QUOTED_PATH"] == "original" + return + ipshell.run_line_magic("dotenv", f"-o {quote}{paths[path_kind]}{quote}") + + assert os.environ["DOTENV_QUOTED_PATH"] == "loaded" + + +@pytest.mark.parametrize( + "argument,expected", + [ + (r'"C:\Users\my project\.env"', r"C:\Users\my project\.env"), + (r"'C:\Users\my project\.env'", r"C:\Users\my project\.env"), + (r"C:\Users\project\.env", r"C:\Users\project\.env"), + ('"my project\'s/.env"', "my project's/.env"), + ("project's/.env", "project's/.env"), + ], +) +def test_ipython_path_preserves_literal_characters(argument, expected): + from IPython.core.error import UsageError + + from dotenv.ipython import IPythonDotEnv + + with mock.patch("dotenv.ipython.find_dotenv", return_value=expected) as find: + with mock.patch("dotenv.ipython.load_dotenv") as load: + if sys.platform == "win32" and argument.startswith("'"): + with pytest.raises(UsageError): + IPythonDotEnv().dotenv(argument) + find.assert_not_called() + load.assert_not_called() + return + IPythonDotEnv().dotenv(argument) + + find.assert_called_once_with(expected, True, True) + load.assert_called_once_with(expected, verbose=False, override=False) + + +def test_ipython_path_with_literal_outer_apostrophes(tmp_path, monkeypatch): + from IPython.terminal.embed import InteractiveShellEmbed + + (tmp_path / "'app.env'").write_text("DOTENV_APOSTROPHE_PATH=loaded\n") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DOTENV_APOSTROPHE_PATH", raising=False) + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "\"'app.env'\"") + + assert os.environ["DOTENV_APOSTROPHE_PATH"] == "loaded" + + +@pytest.mark.parametrize( + "path", ["'app.env'", "my project/app.env", r"C:\Users\app.env"] +) +def test_ipython_windows_parsed_path_is_unchanged(path): + from dotenv.ipython import IPythonDotEnv + + args = mock.Mock(dotenv_path=path, verbose=False, override=False) + with mock.patch("dotenv.ipython.sys", platform="win32", create=True): + with mock.patch("dotenv.ipython.parse_argstring", return_value=args): + with mock.patch("dotenv.ipython.find_dotenv", return_value=path) as find: + with mock.patch("dotenv.ipython.load_dotenv") as load: + IPythonDotEnv().dotenv("") + + find.assert_called_once_with(path, True, True) + load.assert_called_once_with(path, verbose=False, override=False)