Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.9", "3.11", "3.13"]

steps:
Expand Down
19 changes: 16 additions & 3 deletions src/nodrift/sideeffects.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,23 @@
import re
import tempfile

def _slash(text: str) -> str:
"""Use one separator everywhere, so one set of patterns fits both OSes."""
return text.replace("\\", "/")


# A per-run temporary directory, plus the one random segment inside it.
# macOS puts temp files under /var/folders/<2 chars>/<hash>/T/, so matching a
# fixed prefix is not enough — the varying part is deeper than it looks.
# Windows hides it under the user's AppData, which `gettempdir()` covers on
# the running machine; the explicit pattern also catches a recording made
# elsewhere, and is case-insensitive because Windows paths are.
_TEMP_ROOTS = [
r"/private/var/folders(?:/[^/]+){1,2}/T",
r"/var/folders(?:/[^/]+){1,2}/T",
re.escape(tempfile.gettempdir()),
re.escape(_slash(tempfile.gettempdir())),
r"(?i:[A-Z]:/Users/[^/]+/AppData/Local/Temp)",
r"(?i:[A-Z]:/Windows/Temp)",
r"/private/tmp",
r"/tmp",
]
Expand Down Expand Up @@ -90,10 +100,13 @@ def normalise(path: str, root: str | None = None) -> str:
root = root or os.getcwd()
try:
if os.path.commonpath([os.path.abspath(text), root]) == root:
return os.path.relpath(os.path.abspath(text), root)
# Forward slashes on the way out too: a path recorded on one OS
# must not differ from the same path recorded on another purely
# by separator.
return _slash(os.path.relpath(os.path.abspath(text), root))
except (ValueError, OSError):
pass
return _TEMPISH.sub(r"\1/<tmp>", text)
return _TEMPISH.sub(r"\1/<tmp>", _slash(text))


class WriteWatcher:
Expand Down
6 changes: 5 additions & 1 deletion tests/test_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ def recorded(tmp_path):

recording = str(tmp_path / "recording.pkl")
proc = subprocess.run(
[sys.executable, "-m", "pytest", str(project), "-q", "-p", "no:cacheprovider",
[sys.executable, "-m", "pytest", ".", "-q", "-p", "no:cacheprovider",
"--nodrift", "toy", "--nodrift-out", recording],
# Run from inside the project. Given an absolute path, pytest walks up
# looking for a rootdir, and on Windows that reaches C:\ and dies on
# the permission-denied "Documents and Settings" junction.
cwd=str(project),
capture_output=True, text=True,
env=dict(os.environ, PYTHONPATH=str(project)),
)
Expand Down
54 changes: 53 additions & 1 deletion tests/test_sideeffects.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,11 @@ def test_write_change_is_detected_though_return_value_is_identical(tmp_path):
recording = str(tmp_path / "rec.pkl")

proc = subprocess.run(
[sys.executable, "-m", "pytest", str(project), "-q", "-p", "no:cacheprovider",
[sys.executable, "-m", "pytest", ".", "-q", "-p", "no:cacheprovider",
"--nodrift", "app", "--nodrift-out", recording],
# See the note in test_end_to_end.py: an absolute path sends pytest's
# rootdir search up to C:\ on Windows.
cwd=str(project),
capture_output=True, text=True,
env=dict(os.environ, PYTHONPATH=str(project)),
)
Expand All @@ -158,3 +161,52 @@ def test_write_change_is_detected_though_return_value_is_identical(tmp_path):
"a changed file write went unnoticed; the return value is 'ok' either way"
)
assert any("save" in target for target in report["changed"])


# --------------------------------------------------------------------------
# path scrubbing across operating systems
# --------------------------------------------------------------------------

def test_windows_temp_directories_are_scrubbed():
"""The per-run segment has to go, whichever OS wrote the path.

Cause 4 of the false positives fixed in 0.1.0 was a per-run temp directory
surviving into a recorded write path. The patterns that fixed it were
POSIX-only, so on Windows two runs of identical code compared unequal.
Asserted against literal Windows paths so it holds on any host.
"""
from nodrift.sideeffects import _TEMPISH, _slash

def scrub(path):
return _TEMPISH.sub(r"\1/<tmp>", _slash(path))

first = scrub(r"C:\Users\lu\AppData\Local\Temp\nodrift-a1b2\out.txt")
second = scrub(r"C:\Users\lu\AppData\Local\Temp\nodrift-z9y8\out.txt")
assert first == second == "C:/Users/lu/AppData/Local/Temp/<tmp>/out.txt"

# Drive letter and casing vary on Windows; the pattern must not care.
assert scrub(r"d:\users\lu\appdata\local\temp\xyz\out.txt").endswith(
"/<tmp>/out.txt"
)
assert scrub(r"C:\Windows\Temp\abc\log.txt") == "C:/Windows/Temp/<tmp>/log.txt"


def test_posix_temp_directories_are_still_scrubbed():
"""The Windows patterns must not have displaced the existing ones."""
from nodrift.sideeffects import _TEMPISH, _slash

def scrub(path):
return _TEMPISH.sub(r"\1/<tmp>", _slash(path))

assert scrub("/tmp/nodrift-a1b2/out.txt") == "/tmp/<tmp>/out.txt"
assert (
scrub("/private/var/folders/xy/hash1234/T/nodrift-a1/out.txt")
== "/private/var/folders/xy/hash1234/T/<tmp>/out.txt"
)


def test_paths_inside_the_root_come_back_with_forward_slashes(tmp_path):
"""Recordings must not differ by separator alone."""
root = str(tmp_path)
nested = os.path.join(root, "pkg", "data", "out.txt")
assert normalise(nested, root=root) == "pkg/data/out.txt"
5 changes: 4 additions & 1 deletion tests/test_xdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,11 @@ def test_parallel_recording_is_not_empty(tmp_path):
out = str(tmp_path / "rec.pkl")

proc = subprocess.run(
[sys.executable, "-m", "pytest", str(project), "-q", "-p", "no:cacheprovider",
[sys.executable, "-m", "pytest", ".", "-q", "-p", "no:cacheprovider",
"-n", "2", "--nodrift", "calc", "--nodrift-out", out],
# See the note in test_end_to_end.py: an absolute path sends pytest's
# rootdir search up to C:\ on Windows.
cwd=str(project),
capture_output=True, text=True,
env=dict(os.environ, PYTHONPATH=str(project)),
)
Expand Down
Loading