new file mode 100644
@@ -0,0 +1,298 @@
+"""
+Unit tests for wic/oe/path.py, covering wic's own path logic: join,
+is_path_parent, symlink, make_relative_symlink, canonicalize, which_wild,
+and realpath. Thin standard-library wrappers (relative -> os.path.relpath,
+find -> os.walk) are left to the standard library.
+"""
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+_SRC = Path(__file__).resolve().parent.parent.parent / "src"
+if str(_SRC) not in sys.path:
+ sys.path.insert(0, str(_SRC))
+
+from wic.oe.path import (
+ join,
+ is_path_parent,
+ symlink,
+ make_relative_symlink,
+ canonicalize,
+ which_wild,
+ realpath,
+)
+
+
+class TestJoin:
+ """join() is os.path.normpath("/".join(paths)); unlike os.path.join it
+ does not treat an absolute right-hand component specially."""
+
+ def test_two_paths(self):
+ assert join("a", "b") == "a/b"
+
+ def test_three_paths(self):
+ assert join("a", "b", "c") == "a/b/c"
+
+ def test_absolute_rhs_is_not_special(self):
+ # os.path.join("a", "/b") == "/b"; join() keeps it relative.
+ assert join("a", "/b") == "a/b"
+
+ def test_redundant_separators_normalised(self):
+ assert join("a//", "b") == "a/b"
+
+ def test_pardir_normalised(self):
+ assert join("a", "..", "b") == "b"
+
+ def test_curdir_normalised(self):
+ assert join("a", ".", "b") == "a/b"
+
+ def test_single_component(self):
+ assert join("a") == "a"
+
+ def test_empty_leading_component_is_absolute(self):
+ # "/".join(["", "b"]) is "/b", which normpath leaves absolute.
+ assert join("", "b") == "/b"
+
+
+class TestIsPathParent:
+ def test_direct_child(self):
+ assert is_path_parent("/usr", "/usr/bin") is True
+
+ def test_deep_child(self):
+ assert is_path_parent("/usr", "/usr/share/doc/readme") is True
+
+ def test_unrelated_path(self):
+ assert is_path_parent("/usr", "/tmp") is False
+
+ def test_child_is_not_parent_of_its_parent(self):
+ assert is_path_parent("/usr/bin", "/usr") is False
+
+ def test_prefix_string_is_not_a_path_parent(self):
+ # "/usrlocal" shares a string prefix with "/usr" but is not below it;
+ # the trailing-separator handling must reject it.
+ assert is_path_parent("/usr", "/usrlocal") is False
+
+ def test_no_paths_returns_false(self):
+ assert is_path_parent("/usr") is False
+
+ def test_all_paths_must_be_below(self):
+ assert is_path_parent("/usr", "/usr/bin", "/usr/lib") is True
+
+ def test_one_path_outside_fails(self):
+ assert is_path_parent("/usr", "/usr/bin", "/tmp") is False
+
+
+class TestSymlink:
+ def test_creates_symlink(self, tmp_path):
+ src = tmp_path / "target.txt"
+ src.write_text("content")
+ dst = tmp_path / "link"
+ symlink(str(src), str(dst))
+ assert os.path.islink(str(dst))
+ assert os.readlink(str(dst)) == str(src)
+
+ def test_matching_existing_symlink_is_idempotent(self, tmp_path):
+ src = tmp_path / "target.txt"
+ src.write_text("content")
+ dst = tmp_path / "link"
+ symlink(str(src), str(dst))
+ symlink(str(src), str(dst))
+ assert os.readlink(str(dst)) == str(src)
+
+ def test_conflicting_existing_symlink_raises(self, tmp_path):
+ src1 = tmp_path / "t1.txt"
+ src2 = tmp_path / "t2.txt"
+ src1.write_text("a")
+ src2.write_text("b")
+ dst = tmp_path / "link"
+ symlink(str(src1), str(dst))
+ with pytest.raises(OSError):
+ symlink(str(src2), str(dst))
+
+ def test_force_overwrites_existing_symlink(self, tmp_path):
+ src1 = tmp_path / "t1"
+ src2 = tmp_path / "t2"
+ src1.write_text("a")
+ src2.write_text("b")
+ dst = tmp_path / "link"
+ symlink(str(src1), str(dst))
+ symlink(str(src2), str(dst), force=True)
+ assert os.readlink(str(dst)) == str(src2)
+
+ def test_force_replaces_destination_with_glob_metacharacters(self, tmp_path):
+ # A destination whose name contains glob metacharacters must still be
+ # replaced. Routing the removal through glob (the old behaviour) fails
+ # to match "link[1]" against itself, leaving the stale entry in place.
+ src = tmp_path / "target"
+ src.write_text("content")
+ dst = tmp_path / "link[1]"
+ dst.write_text("stale regular file")
+ symlink(str(src), str(dst), force=True)
+ assert os.path.islink(str(dst))
+ assert os.readlink(str(dst)) == str(src)
+
+ def test_force_does_not_delete_glob_siblings(self, tmp_path):
+ # The destination "keep?.txt" does not exist, but a sibling
+ # "keepX.txt" matches it as a glob pattern. force=True must remove
+ # only the literal destination, never a pattern sibling.
+ sibling = tmp_path / "keepX.txt"
+ sibling.write_text("do not delete me")
+ src = tmp_path / "target"
+ src.write_text("content")
+ dst = tmp_path / "keep?.txt"
+ symlink(str(src), str(dst), force=True)
+ assert sibling.exists()
+ assert os.readlink(str(dst)) == str(src)
+
+ def test_force_replaces_existing_directory(self, tmp_path):
+ # An existing directory at the destination is torn down (EISDIR ->
+ # rmtree) before the link is created.
+ src = tmp_path / "target"
+ src.write_text("content")
+ dst = tmp_path / "dir"
+ dst.mkdir()
+ (dst / "child").write_text("x")
+ symlink(str(src), str(dst), force=True)
+ assert os.path.islink(str(dst))
+ assert os.readlink(str(dst)) == str(src)
+
+
+class TestMakeRelativeSymlink:
+ def test_non_symlink_is_ignored(self, tmp_path):
+ regular = tmp_path / "file.txt"
+ regular.write_text("content")
+ make_relative_symlink(str(regular))
+ assert regular.is_file()
+
+ def test_already_relative_symlink_is_unchanged(self, tmp_path):
+ target = tmp_path / "target.txt"
+ target.write_text("content")
+ link = tmp_path / "link"
+ os.symlink("target.txt", str(link))
+ make_relative_symlink(str(link))
+ assert os.readlink(str(link)) == "target.txt"
+
+ def test_absolute_symlink_becomes_relative_and_resolves(self, tmp_path):
+ target = tmp_path / "target.txt"
+ target.write_text("content")
+ link = tmp_path / "link"
+ os.symlink(str(target), str(link))
+ assert os.path.isabs(os.readlink(str(link)))
+ make_relative_symlink(str(link))
+ result = os.readlink(str(link))
+ assert not os.path.isabs(result)
+ assert os.path.exists(str(link))
+ assert Path(str(link)).read_text() == "content"
+
+
+class TestCanonicalize:
+ def test_real_path_returned(self, tmp_path):
+ result = canonicalize(str(tmp_path))
+ assert result == os.path.realpath(str(tmp_path))
+
+ def test_unexpanded_variable_is_skipped(self):
+ assert canonicalize("$SOME_VAR") == ""
+
+ def test_variable_token_dropped_leaving_real_path(self, tmp_path):
+ result = canonicalize("$VAR," + str(tmp_path))
+ # The "$VAR" token is dropped entirely: no empty placeholder, no
+ # leading separator, just the canonical real path.
+ assert result == os.path.realpath(str(tmp_path))
+
+ def test_empty_string_returns_empty_string(self):
+ # os.path.realpath("") is the cwd; canonicalize("") must not leak it.
+ assert canonicalize("") == ""
+
+ def test_none_returns_empty_string(self):
+ assert canonicalize(None) == ""
+
+ def test_empty_token_between_paths_is_dropped(self, tmp_path):
+ p1 = tmp_path / "a"
+ p2 = tmp_path / "b"
+ p1.mkdir()
+ p2.mkdir()
+ # The stray separator in "a,,b" must not inject a cwd entry.
+ result = canonicalize("%s,,%s" % (p1, p2))
+ assert result == "%s,%s" % (
+ os.path.realpath(str(p1)), os.path.realpath(str(p2)))
+
+ def test_trailing_slash_preserved(self, tmp_path):
+ result = canonicalize(str(tmp_path) + "/")
+ assert result == os.path.realpath(str(tmp_path)) + "/"
+
+
+class TestWhichWild:
+ def test_finds_existing_executable(self):
+ results = which_wild("python3")
+ assert results
+ assert all(os.path.isabs(p) for p in results)
+ assert all(os.path.basename(p) == "python3" for p in results)
+
+ def test_missing_tool_returns_empty(self):
+ assert which_wild("totally_nonexistent_tool_xyz") == []
+
+ def test_explicit_search_path(self, tmp_path):
+ tool = tmp_path / "mytool"
+ tool.write_text("#!/bin/sh\n")
+ tool.chmod(0o755)
+ results = which_wild("mytool", path=str(tmp_path))
+ assert results == [str(tool)]
+
+ def test_wildcard_pattern(self, tmp_path):
+ (tmp_path / "foo-a").write_text("")
+ (tmp_path / "foo-b").write_text("")
+ (tmp_path / "bar").write_text("")
+ results = which_wild("foo-*", path=str(tmp_path))
+ assert sorted(os.path.basename(p) for p in results) == ["foo-a", "foo-b"]
+
+ def test_first_match_per_name_wins(self, tmp_path):
+ # A name found in an earlier PATH element shadows the same name later.
+ first = tmp_path / "first"
+ second = tmp_path / "second"
+ first.mkdir()
+ second.mkdir()
+ (first / "tool").write_text("")
+ (second / "tool").write_text("")
+ search = "%s:%s" % (first, second)
+ assert which_wild("tool", path=search) == [str(first / "tool")]
+ # reverse=True walks PATH the other way, so the other copy wins.
+ assert which_wild("tool", path=search, reverse=True) == [str(second / "tool")]
+
+
+class TestRealpath:
+ def test_resolves_symlink_below_root(self, tmp_path):
+ target = tmp_path / "real"
+ target.mkdir()
+ link = tmp_path / "lnk"
+ os.symlink("real", str(link))
+ result = realpath(str(link), str(tmp_path))
+ assert result == str(target)
+
+ def test_plain_path_is_returned(self, tmp_path):
+ sub = tmp_path / "d"
+ sub.mkdir()
+ assert realpath(str(sub), str(tmp_path)) == str(sub)
+
+ def test_path_outside_root_raises(self, tmp_path):
+ root = tmp_path / "root"
+ outside = tmp_path / "outside"
+ root.mkdir()
+ outside.mkdir()
+ with pytest.raises(OSError):
+ realpath(str(outside), str(root))
+
+ def test_isdir_failure_falls_back_without_nameerror(self, tmp_path, monkeypatch):
+ # The final stanza of __realpath tolerates any failure from
+ # os.path.isdir by treating the path as not-a-directory. Force isdir
+ # to raise and confirm the fallback returns the resolved path rather
+ # than raising NameError from an undefined fallback value.
+ sub = tmp_path / "d"
+ sub.mkdir()
+
+ def boom(_path):
+ raise OSError("synthetic stat failure")
+
+ monkeypatch.setattr(os.path, "isdir", boom)
+ assert realpath(str(sub), str(tmp_path), use_physdir=False) == str(sub)
Add unit coverage for the wic-specific behaviour in oe/path.py: - join() keeps an absolute right-hand component relative and normalises redundant separators, '.', and '..'. - is_path_parent() treats containment by path component, so a shared string prefix such as /usrlocal is not below /usr, and every supplied path must be below the parent. - symlink() creates links, is idempotent for a matching link, raises on a conflicting one, and with force=True replaces the literal destination -- including names with glob metacharacters and an existing directory -- without deleting glob siblings. - make_relative_symlink() leaves non-links and already-relative links alone and rewrites an absolute link to a working relative one. - canonicalize() expands real paths, drops '$' and empty tokens, keeps trailing slashes, and returns '' for '' and None. - which_wild() honours an explicit search path, expands wildcards, and returns the first match per name (last with reverse=True). - realpath() resolves links below root, rejects paths outside root, and falls back cleanly when os.path.isdir() raises. AI-Generated: codex/claude-opus 4.8 (xhigh) Signed-off-by: Trevor Woerner <twoerner@gmail.com> --- tests/unit/test_oe_path.py | 298 +++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 tests/unit/test_oe_path.py