Skip to content

Commit c91abd8

Browse files
committed
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.
1 parent f54d29f commit c91abd8

File tree

6 files changed

+77
-19
lines changed

6 files changed

+77
-19
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ jobs:
1414
- ubuntu-latest
1515
python-version:
1616
["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10]
17+
include:
18+
# Windows: Test lowest and highest supported Python versions
19+
- os: windows-latest
20+
python-version: "3.9"
21+
- os: windows-latest
22+
python-version: "3.14"
1723

1824
steps:
1925
- uses: actions/checkout@v6

tests/test_cli.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import os
2+
import sys
23
from pathlib import Path
34
from typing import Optional
45

56
import pytest
6-
import sh
77

88
import dotenv
99
from dotenv.cli import cli as dotenv_cli
1010
from dotenv.version import __version__
1111

12+
if sys.platform != "win32":
13+
import sh
14+
1215

1316
@pytest.mark.parametrize(
1417
"output_format,content,expected",
@@ -173,6 +176,7 @@ def test_set_no_file(cli):
173176
assert "Missing argument" in result.output
174177

175178

179+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
176180
def test_get_default_path(tmp_path):
177181
with sh.pushd(tmp_path):
178182
(tmp_path / ".env").write_text("a=b")
@@ -182,6 +186,7 @@ def test_get_default_path(tmp_path):
182186
assert result == "b\n"
183187

184188

189+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
185190
def test_run(tmp_path):
186191
with sh.pushd(tmp_path):
187192
(tmp_path / ".env").write_text("a=b")
@@ -191,6 +196,7 @@ def test_run(tmp_path):
191196
assert result == "b\n"
192197

193198

199+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
194200
def test_run_with_existing_variable(tmp_path):
195201
with sh.pushd(tmp_path):
196202
(tmp_path / ".env").write_text("a=b")
@@ -202,6 +208,7 @@ def test_run_with_existing_variable(tmp_path):
202208
assert result == "b\n"
203209

204210

211+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
205212
def test_run_with_existing_variable_not_overridden(tmp_path):
206213
with sh.pushd(tmp_path):
207214
(tmp_path / ".env").write_text("a=b")
@@ -213,6 +220,7 @@ def test_run_with_existing_variable_not_overridden(tmp_path):
213220
assert result == "c\n"
214221

215222

223+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
216224
def test_run_with_none_value(tmp_path):
217225
with sh.pushd(tmp_path):
218226
(tmp_path / ".env").write_text("a=b\nc")
@@ -222,6 +230,7 @@ def test_run_with_none_value(tmp_path):
222230
assert result == "b\n"
223231

224232

233+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
225234
def test_run_with_other_env(dotenv_path):
226235
dotenv_path.write_text("a=b")
227236

tests/test_fifo_dotenv.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77

88
from dotenv import load_dotenv
99

10-
pytestmark = pytest.mark.skipif(
11-
sys.platform.startswith("win"), reason="FIFOs are Unix-only"
12-
)
10+
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="FIFOs are Unix-only")
1311

1412

1513
def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch):

tests/test_ipython.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import os
2+
import sys
23
from unittest import mock
34

45
import pytest
56

67
pytest.importorskip("IPython")
78

89

10+
@pytest.mark.skipif(
11+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
12+
)
913
@mock.patch.dict(os.environ, {}, clear=True)
1014
def test_ipython_existing_variable_no_override(tmp_path):
1115
from IPython.terminal.embed import InteractiveShellEmbed
@@ -22,6 +26,9 @@ def test_ipython_existing_variable_no_override(tmp_path):
2226
assert os.environ == {"a": "c"}
2327

2428

29+
@pytest.mark.skipif(
30+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
31+
)
2532
@mock.patch.dict(os.environ, {}, clear=True)
2633
def test_ipython_existing_variable_override(tmp_path):
2734
from IPython.terminal.embed import InteractiveShellEmbed
@@ -38,6 +45,9 @@ def test_ipython_existing_variable_override(tmp_path):
3845
assert os.environ == {"a": "b"}
3946

4047

48+
@pytest.mark.skipif(
49+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
50+
)
4151
@mock.patch.dict(os.environ, {}, clear=True)
4252
def test_ipython_new_variable(tmp_path):
4353
from IPython.terminal.embed import InteractiveShellEmbed

tests/test_main.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import io
22
import logging
33
import os
4+
import stat
45
import sys
56
import textwrap
67
from unittest import mock
78

89
import pytest
9-
import sh
1010

1111
import dotenv
1212

13+
if sys.platform != "win32":
14+
import sh
15+
1316

1417
def test_set_key_no_file(tmp_path):
1518
nx_path = tmp_path / "nx"
@@ -62,15 +65,25 @@ def test_set_key_encoding(dotenv_path):
6265

6366

6467
@pytest.mark.skipif(
65-
os.geteuid() == 0, reason="Root user can access files even with 000 permissions."
68+
sys.platform != "win32" and os.geteuid() == 0,
69+
reason="Root user can access files even with 000 permissions.",
6670
)
6771
def test_set_key_permission_error(dotenv_path):
68-
dotenv_path.chmod(0o000)
72+
if sys.platform == "win32":
73+
# On Windows, make file read-only
74+
dotenv_path.chmod(stat.S_IREAD)
75+
else:
76+
# On Unix, remove all permissions
77+
dotenv_path.chmod(0o000)
6978

7079
with pytest.raises(PermissionError):
7180
dotenv.set_key(dotenv_path, "a", "b")
7281

73-
dotenv_path.chmod(0o600)
82+
# Restore permissions
83+
if sys.platform == "win32":
84+
dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD)
85+
else:
86+
dotenv_path.chmod(0o600)
7487
assert dotenv_path.read_text() == ""
7588

7689

@@ -170,16 +183,6 @@ def test_unset_encoding(dotenv_path):
170183
assert dotenv_path.read_text(encoding=encoding) == ""
171184

172185

173-
@pytest.mark.skipif(
174-
os.geteuid() == 0, reason="Root user can access files even with 000 permissions."
175-
)
176-
def test_set_key_unauthorized_file(dotenv_path):
177-
dotenv_path.chmod(0o000)
178-
179-
with pytest.raises(PermissionError):
180-
dotenv.set_key(dotenv_path, "a", "x")
181-
182-
183186
def test_unset_non_existent_file(tmp_path):
184187
nx_path = tmp_path / "nx"
185188
logger = logging.getLogger("dotenv.main")
@@ -241,6 +244,9 @@ def test_find_dotenv_found(tmp_path):
241244
assert result == str(dotenv_path)
242245

243246

247+
@pytest.mark.skipif(
248+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
249+
)
244250
@mock.patch.dict(os.environ, {}, clear=True)
245251
def test_load_dotenv_existing_file(dotenv_path):
246252
dotenv_path.write_text("a=b")
@@ -312,6 +318,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value):
312318
)
313319

314320

321+
@pytest.mark.skipif(
322+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
323+
)
315324
@pytest.mark.parametrize(
316325
"flag_value",
317326
[
@@ -395,6 +404,9 @@ def test_load_dotenv_no_file_verbose():
395404
)
396405

397406

407+
@pytest.mark.skipif(
408+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
409+
)
398410
@mock.patch.dict(os.environ, {"a": "c"}, clear=True)
399411
def test_load_dotenv_existing_variable_no_override(dotenv_path):
400412
dotenv_path.write_text("a=b")
@@ -405,6 +417,9 @@ def test_load_dotenv_existing_variable_no_override(dotenv_path):
405417
assert os.environ == {"a": "c"}
406418

407419

420+
@pytest.mark.skipif(
421+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
422+
)
408423
@mock.patch.dict(os.environ, {"a": "c"}, clear=True)
409424
def test_load_dotenv_existing_variable_override(dotenv_path):
410425
dotenv_path.write_text("a=b")
@@ -415,6 +430,9 @@ def test_load_dotenv_existing_variable_override(dotenv_path):
415430
assert os.environ == {"a": "b"}
416431

417432

433+
@pytest.mark.skipif(
434+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
435+
)
418436
@mock.patch.dict(os.environ, {"a": "c"}, clear=True)
419437
def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path):
420438
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):
425443
assert os.environ == {"a": "c", "d": "c"}
426444

427445

446+
@pytest.mark.skipif(
447+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
448+
)
428449
@mock.patch.dict(os.environ, {"a": "c"}, clear=True)
429450
def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path):
430451
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):
435456
assert os.environ == {"a": "b", "d": "b"}
436457

437458

459+
@pytest.mark.skipif(
460+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
461+
)
438462
@mock.patch.dict(os.environ, {}, clear=True)
439463
def test_load_dotenv_string_io_utf_8():
440464
stream = io.StringIO("a=à")
@@ -445,6 +469,9 @@ def test_load_dotenv_string_io_utf_8():
445469
assert os.environ == {"a": "à"}
446470

447471

472+
@pytest.mark.skipif(
473+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
474+
)
448475
@mock.patch.dict(os.environ, {}, clear=True)
449476
def test_load_dotenv_file_stream(dotenv_path):
450477
dotenv_path.write_text("a=b")
@@ -456,6 +483,7 @@ def test_load_dotenv_file_stream(dotenv_path):
456483
assert os.environ == {"a": "b"}
457484

458485

486+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
459487
def test_load_dotenv_in_current_dir(tmp_path):
460488
dotenv_path = tmp_path / ".env"
461489
dotenv_path.write_bytes(b"a=b")
@@ -484,6 +512,9 @@ def test_dotenv_values_file(dotenv_path):
484512
assert result == {"a": "b"}
485513

486514

515+
@pytest.mark.skipif(
516+
sys.platform == "win32", reason="This test assumes case-sensitive variable names"
517+
)
487518
@pytest.mark.parametrize(
488519
"env,string,interpolate,expected",
489520
[

tests/test_zip_imports.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from unittest import mock
66
from zipfile import ZipFile
77

8-
import sh
8+
import pytest
9+
10+
if sys.platform != "win32":
11+
import sh
912

1013

1114
def walk_to_root(path: str):
@@ -62,6 +65,7 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path):
6265
import child1.child2.test # noqa
6366

6467

68+
@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows")
6569
def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path):
6670
zip_file_path = setup_zipfile(
6771
tmp_path,

0 commit comments

Comments
 (0)