From c91abd83e266357179f17450d56abc899ac3a1c8 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jan 2026 19:20:03 +0100 Subject: [PATCH] Add Windows testing to CI Changes for users: none. Notes: - This adds CI testing with lowest and highest Python versions we support. - The main motivation for this is that we have Windows-specific code I'm worried I might break with improvements, like improvements in `dotenv run` error handling (coming soon). - I went for the least intrusive changes for now, and disabled tests which would fail unless they were trivial to adjust. - We have tests using `sh` (Unix-only module) which should be possible to fix later. Those tests are disabled on Windows. - Also tests relying on the fact that environment variables are case sensitive, which isn't the case on Windows. This is going to be more tricky to fix. Those tests are also disabled on Windows. - To check for the platform, I used `sys.platform == "win32"` everywhere, which seems to be the best practice. --- .github/workflows/test.yml | 6 ++++ tests/test_cli.py | 11 ++++++- tests/test_fifo_dotenv.py | 4 +-- tests/test_ipython.py | 10 +++++++ tests/test_main.py | 59 +++++++++++++++++++++++++++++--------- tests/test_zip_imports.py | 6 +++- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f68669d..13c24909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,12 @@ jobs: - ubuntu-latest python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] + include: + # Windows: Test lowest and highest supported Python versions + - os: windows-latest + python-version: "3.9" + - os: windows-latest + python-version: "3.14" steps: - uses: actions/checkout@v6 diff --git a/tests/test_cli.py b/tests/test_cli.py index 343fdb23..1c03b953 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,14 +1,17 @@ import os +import sys from pathlib import Path from typing import Optional import pytest -import sh import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ +if sys.platform != "win32": + import sh + @pytest.mark.parametrize( "output_format,content,expected", @@ -173,6 +176,7 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_get_default_path(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -182,6 +186,7 @@ def test_get_default_path(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -191,6 +196,7 @@ def test_run(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -202,6 +208,7 @@ def test_run_with_existing_variable(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable_not_overridden(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -213,6 +220,7 @@ def test_run_with_existing_variable_not_overridden(tmp_path): assert result == "c\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_none_value(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b\nc") @@ -222,6 +230,7 @@ def test_run_with_none_value(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_other_env(dotenv_path): dotenv_path.write_text("a=b") diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py index 4961adce..2aa31779 100644 --- a/tests/test_fifo_dotenv.py +++ b/tests/test_fifo_dotenv.py @@ -7,9 +7,7 @@ from dotenv import load_dotenv -pytestmark = pytest.mark.skipif( - sys.platform.startswith("win"), reason="FIFOs are Unix-only" -) +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="FIFOs are Unix-only") def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): diff --git a/tests/test_ipython.py b/tests/test_ipython.py index f01b3ad7..6eda086b 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,4 +1,5 @@ import os +import sys from unittest import mock import pytest @@ -6,6 +7,9 @@ pytest.importorskip("IPython") +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_no_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -22,6 +26,9 @@ def test_ipython_existing_variable_no_override(tmp_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -38,6 +45,9 @@ def test_ipython_existing_variable_override(tmp_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_new_variable(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed diff --git a/tests/test_main.py b/tests/test_main.py index 76c1f70e..761bdad3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,15 +1,18 @@ import io import logging import os +import stat import sys import textwrap from unittest import mock import pytest -import sh import dotenv +if sys.platform != "win32": + import sh + def test_set_key_no_file(tmp_path): nx_path = tmp_path / "nx" @@ -62,15 +65,25 @@ def test_set_key_encoding(dotenv_path): @pytest.mark.skipif( - os.geteuid() == 0, reason="Root user can access files even with 000 permissions." + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", ) def test_set_key_permission_error(dotenv_path): - dotenv_path.chmod(0o000) + if sys.platform == "win32": + # On Windows, make file read-only + dotenv_path.chmod(stat.S_IREAD) + else: + # On Unix, remove all permissions + dotenv_path.chmod(0o000) with pytest.raises(PermissionError): dotenv.set_key(dotenv_path, "a", "b") - dotenv_path.chmod(0o600) + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) assert dotenv_path.read_text() == "" @@ -170,16 +183,6 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" -@pytest.mark.skipif( - os.geteuid() == 0, reason="Root user can access files even with 000 permissions." -) -def test_set_key_unauthorized_file(dotenv_path): - dotenv_path.chmod(0o000) - - with pytest.raises(PermissionError): - dotenv.set_key(dotenv_path, "a", "x") - - def test_unset_non_existent_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") @@ -241,6 +244,9 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_existing_file(dotenv_path): dotenv_path.write_text("a=b") @@ -312,6 +318,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "flag_value", [ @@ -395,6 +404,9 @@ def test_load_dotenv_no_file_verbose(): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_no_override(dotenv_path): dotenv_path.write_text("a=b") @@ -405,6 +417,9 @@ def test_load_dotenv_existing_variable_no_override(dotenv_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_override(dotenv_path): dotenv_path.write_text("a=b") @@ -415,6 +430,9 @@ def test_load_dotenv_existing_variable_override(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -425,6 +443,9 @@ def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): assert os.environ == {"a": "c", "d": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -435,6 +456,9 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): assert os.environ == {"a": "b", "d": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_string_io_utf_8(): stream = io.StringIO("a=à") @@ -445,6 +469,9 @@ def test_load_dotenv_string_io_utf_8(): assert os.environ == {"a": "à"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_file_stream(dotenv_path): dotenv_path.write_text("a=b") @@ -456,6 +483,7 @@ def test_load_dotenv_file_stream(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_bytes(b"a=b") @@ -484,6 +512,9 @@ def test_dotenv_values_file(dotenv_path): assert result == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "env,string,interpolate,expected", [ diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 5c0fb88d..0b57a1c5 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -5,7 +5,10 @@ from unittest import mock from zipfile import ZipFile -import sh +import pytest + +if sys.platform != "win32": + import sh def walk_to_root(path: str): @@ -62,6 +65,7 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path,