From: Trevor Woerner <twoerner@gmail.com>
To: openembedded-core@lists.openembedded.org
Subject: [PATCH 4/4] oeqa/selftest/liboe: cover oe.path's own path logic
Date: Wed, 15 Jul 2026 18:12:52 -0400 [thread overview]
Message-ID: <20260715221252.3369108-5-twoerner@gmail.com> (raw)
In-Reply-To: <20260715221252.3369108-1-twoerner@gmail.com>
Add in-process coverage for oe.path's join(), is_path_parent(),
symlink(), make_relative_symlink(), canonicalize(), which_wild() and
realpath(), alongside the existing copytree tests.
The cases pin behaviour that is easy to regress: __realpath() falls back
to "not a directory" when os.path.isdir() raises; symlink(force=True)
replaces the literal destination even when its name contains glob
metacharacters, and never removes a pattern sibling; and canonicalize()
skips empty tokens so '', None, and a stray separator do not inject the
current working directory.
AI-Generated: codex/claude-opus 4.8 (xhigh)
Signed-off-by: Trevor Woerner <twoerner@gmail.com>
---
meta/lib/oeqa/selftest/cases/liboe.py | 289 ++++++++++++++++++++++++++
1 file changed, 289 insertions(+)
diff --git a/meta/lib/oeqa/selftest/cases/liboe.py b/meta/lib/oeqa/selftest/cases/liboe.py
index 930354c931a3..fa4902610cc4 100644
--- a/meta/lib/oeqa/selftest/cases/liboe.py
+++ b/meta/lib/oeqa/selftest/cases/liboe.py
@@ -8,6 +8,8 @@ from oeqa.selftest.case import OESelftestTestCase
from oeqa.utils.commands import get_bb_var, get_bb_vars, bitbake, runCmd
import oe.path
import os
+import tempfile
+import unittest.mock
class CopyTreeTests(OESelftestTestCase):
@@ -135,3 +137,290 @@ exit 42
self.assertIn("exit status 42", string)
self.assertIn("Standard Output: Via stdout", string)
self.assertIn("Standard Error: Via stderr", string)
+
+class PathTests(OESelftestTestCase):
+ """
+ In-process coverage for oe.path'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.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self._tmp = tempfile.TemporaryDirectory(prefix="oeqa-oepath-")
+ self.addCleanup(self._tmp.cleanup)
+ self.tmp_path = self._tmp.name
+
+ # --- join --------------------------------------------------------------
+ # join() is os.path.normpath("/".join(paths)); unlike os.path.join it
+ # does not treat an absolute right-hand component specially.
+
+ def test_join_two_paths(self):
+ self.assertEqual(oe.path.join("a", "b"), "a/b")
+
+ def test_join_absolute_rhs_is_not_special(self):
+ # os.path.join("a", "/b") == "/b"; oe.path.join() keeps it relative.
+ self.assertEqual(oe.path.join("a", "/b"), "a/b")
+
+ def test_join_redundant_separators_normalised(self):
+ self.assertEqual(oe.path.join("a//", "b"), "a/b")
+
+ def test_join_pardir_normalised(self):
+ self.assertEqual(oe.path.join("a", "..", "b"), "b")
+
+ def test_join_empty_leading_component_is_absolute(self):
+ # "/".join(["", "b"]) is "/b", which normpath leaves absolute.
+ self.assertEqual(oe.path.join("", "b"), "/b")
+
+ # --- is_path_parent ----------------------------------------------------
+
+ def test_is_path_parent_direct_child(self):
+ self.assertTrue(oe.path.is_path_parent("/usr", "/usr/bin"))
+
+ def test_is_path_parent_unrelated_path(self):
+ self.assertFalse(oe.path.is_path_parent("/usr", "/tmp"))
+
+ def test_is_path_parent_child_is_not_parent_of_its_parent(self):
+ self.assertFalse(oe.path.is_path_parent("/usr/bin", "/usr"))
+
+ def test_is_path_parent_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.
+ self.assertFalse(oe.path.is_path_parent("/usr", "/usrlocal"))
+
+ def test_is_path_parent_no_paths_returns_false(self):
+ self.assertFalse(oe.path.is_path_parent("/usr"))
+
+ def test_is_path_parent_all_paths_must_be_below(self):
+ self.assertTrue(oe.path.is_path_parent("/usr", "/usr/bin", "/usr/lib"))
+
+ def test_is_path_parent_one_path_outside_fails(self):
+ self.assertFalse(oe.path.is_path_parent("/usr", "/usr/bin", "/tmp"))
+
+ # --- symlink -----------------------------------------------------------
+
+ def test_symlink_creates_symlink(self):
+ src = os.path.join(self.tmp_path, "target.txt")
+ with open(src, "w") as f:
+ f.write("content")
+ dst = os.path.join(self.tmp_path, "link")
+ oe.path.symlink(src, dst)
+ self.assertTrue(os.path.islink(dst))
+ self.assertEqual(os.readlink(dst), src)
+
+ def test_symlink_matching_existing_is_idempotent(self):
+ src = os.path.join(self.tmp_path, "target.txt")
+ with open(src, "w") as f:
+ f.write("content")
+ dst = os.path.join(self.tmp_path, "link")
+ oe.path.symlink(src, dst)
+ oe.path.symlink(src, dst)
+ self.assertEqual(os.readlink(dst), src)
+
+ def test_symlink_conflicting_existing_raises(self):
+ src1 = os.path.join(self.tmp_path, "t1.txt")
+ src2 = os.path.join(self.tmp_path, "t2.txt")
+ for p in (src1, src2):
+ with open(p, "w") as f:
+ f.write(p)
+ dst = os.path.join(self.tmp_path, "link")
+ oe.path.symlink(src1, dst)
+ with self.assertRaises(OSError):
+ oe.path.symlink(src2, dst)
+
+ def test_symlink_force_overwrites_existing_symlink(self):
+ src1 = os.path.join(self.tmp_path, "t1")
+ src2 = os.path.join(self.tmp_path, "t2")
+ for p in (src1, src2):
+ with open(p, "w") as f:
+ f.write(p)
+ dst = os.path.join(self.tmp_path, "link")
+ oe.path.symlink(src1, dst)
+ oe.path.symlink(src2, dst, force=True)
+ self.assertEqual(os.readlink(dst), src2)
+
+ def test_symlink_force_replaces_destination_with_glob_metacharacters(self):
+ # A destination whose name contains glob metacharacters must still be
+ # replaced. Routing the removal through remove() (which globs) fails
+ # to match "link[1]" against itself, leaving the stale entry in place.
+ src = os.path.join(self.tmp_path, "target")
+ with open(src, "w") as f:
+ f.write("content")
+ dst = os.path.join(self.tmp_path, "link[1]")
+ with open(dst, "w") as f:
+ f.write("stale regular file")
+ oe.path.symlink(src, dst, force=True)
+ self.assertTrue(os.path.islink(dst))
+ self.assertEqual(os.readlink(dst), src)
+
+ def test_symlink_force_does_not_delete_glob_siblings(self):
+ # 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 = os.path.join(self.tmp_path, "keepX.txt")
+ with open(sibling, "w") as f:
+ f.write("do not delete me")
+ src = os.path.join(self.tmp_path, "target")
+ with open(src, "w") as f:
+ f.write("content")
+ dst = os.path.join(self.tmp_path, "keep?.txt")
+ oe.path.symlink(src, dst, force=True)
+ self.assertTrue(os.path.exists(sibling))
+ self.assertEqual(os.readlink(dst), src)
+
+ def test_symlink_force_replaces_existing_directory(self):
+ # An existing directory at the destination is torn down (EISDIR ->
+ # rmtree) before the link is created.
+ src = os.path.join(self.tmp_path, "target")
+ with open(src, "w") as f:
+ f.write("content")
+ dst = os.path.join(self.tmp_path, "dir")
+ os.mkdir(dst)
+ with open(os.path.join(dst, "child"), "w") as f:
+ f.write("x")
+ oe.path.symlink(src, dst, force=True)
+ self.assertTrue(os.path.islink(dst))
+ self.assertEqual(os.readlink(dst), src)
+
+ # --- make_relative_symlink ---------------------------------------------
+
+ def test_make_relative_symlink_non_symlink_is_ignored(self):
+ regular = os.path.join(self.tmp_path, "file.txt")
+ with open(regular, "w") as f:
+ f.write("content")
+ oe.path.make_relative_symlink(regular)
+ self.assertTrue(os.path.isfile(regular))
+
+ def test_make_relative_symlink_already_relative_unchanged(self):
+ target = os.path.join(self.tmp_path, "target.txt")
+ with open(target, "w") as f:
+ f.write("content")
+ link = os.path.join(self.tmp_path, "link")
+ os.symlink("target.txt", link)
+ oe.path.make_relative_symlink(link)
+ self.assertEqual(os.readlink(link), "target.txt")
+
+ def test_make_relative_symlink_absolute_becomes_relative_and_resolves(self):
+ target = os.path.join(self.tmp_path, "target.txt")
+ with open(target, "w") as f:
+ f.write("content")
+ link = os.path.join(self.tmp_path, "link")
+ os.symlink(target, link)
+ self.assertTrue(os.path.isabs(os.readlink(link)))
+ oe.path.make_relative_symlink(link)
+ result = os.readlink(link)
+ self.assertFalse(os.path.isabs(result))
+ self.assertTrue(os.path.exists(link))
+ with open(link) as f:
+ self.assertEqual(f.read(), "content")
+
+ # --- canonicalize ------------------------------------------------------
+
+ def test_canonicalize_real_path_returned(self):
+ self.assertEqual(oe.path.canonicalize(self.tmp_path),
+ os.path.realpath(self.tmp_path))
+
+ def test_canonicalize_unexpanded_variable_is_skipped(self):
+ self.assertEqual(oe.path.canonicalize("$SOME_VAR"), "")
+
+ def test_canonicalize_variable_token_dropped_leaving_real_path(self):
+ result = oe.path.canonicalize("$VAR," + self.tmp_path)
+ # The "$VAR" token is dropped entirely: no empty placeholder, no
+ # leading separator, just the canonical real path.
+ self.assertEqual(result, os.path.realpath(self.tmp_path))
+
+ def test_canonicalize_empty_string_returns_empty_string(self):
+ # os.path.realpath("") is the cwd; canonicalize("") must not leak it.
+ self.assertEqual(oe.path.canonicalize(""), "")
+
+ def test_canonicalize_none_returns_empty_string(self):
+ self.assertEqual(oe.path.canonicalize(None), "")
+
+ def test_canonicalize_empty_token_between_paths_is_dropped(self):
+ p1 = os.path.join(self.tmp_path, "a")
+ p2 = os.path.join(self.tmp_path, "b")
+ os.mkdir(p1)
+ os.mkdir(p2)
+ # The stray separator in "a,,b" must not inject a cwd entry.
+ result = oe.path.canonicalize("%s,,%s" % (p1, p2))
+ self.assertEqual(result, "%s,%s" % (os.path.realpath(p1),
+ os.path.realpath(p2)))
+
+ def test_canonicalize_trailing_slash_preserved(self):
+ result = oe.path.canonicalize(self.tmp_path + "/")
+ self.assertEqual(result, os.path.realpath(self.tmp_path) + "/")
+
+ # --- which_wild --------------------------------------------------------
+
+ def test_which_wild_missing_tool_returns_empty(self):
+ self.assertEqual(oe.path.which_wild("totally_nonexistent_tool_xyz"), [])
+
+ def test_which_wild_explicit_search_path(self):
+ tool = os.path.join(self.tmp_path, "mytool")
+ with open(tool, "w") as f:
+ f.write("#!/bin/sh\n")
+ os.chmod(tool, 0o755)
+ self.assertEqual(oe.path.which_wild("mytool", path=self.tmp_path),
+ [tool])
+
+ def test_which_wild_wildcard_pattern(self):
+ for name in ("foo-a", "foo-b", "bar"):
+ with open(os.path.join(self.tmp_path, name), "w") as f:
+ f.write("")
+ results = oe.path.which_wild("foo-*", path=self.tmp_path)
+ self.assertEqual(sorted(os.path.basename(p) for p in results),
+ ["foo-a", "foo-b"])
+
+ def test_which_wild_first_match_per_name_wins(self):
+ # A name found in an earlier PATH element shadows the same name later;
+ # reverse=True walks PATH the other way, so the other copy wins.
+ first = os.path.join(self.tmp_path, "first")
+ second = os.path.join(self.tmp_path, "second")
+ os.mkdir(first)
+ os.mkdir(second)
+ for d in (first, second):
+ with open(os.path.join(d, "tool"), "w") as f:
+ f.write("")
+ search = "%s:%s" % (first, second)
+ self.assertEqual(oe.path.which_wild("tool", path=search),
+ [os.path.join(first, "tool")])
+ self.assertEqual(oe.path.which_wild("tool", path=search, reverse=True),
+ [os.path.join(second, "tool")])
+
+ # --- realpath ----------------------------------------------------------
+
+ def test_realpath_resolves_symlink_below_root(self):
+ target = os.path.join(self.tmp_path, "real")
+ os.mkdir(target)
+ link = os.path.join(self.tmp_path, "lnk")
+ os.symlink("real", link)
+ self.assertEqual(oe.path.realpath(link, self.tmp_path), target)
+
+ def test_realpath_plain_path_is_returned(self):
+ sub = os.path.join(self.tmp_path, "d")
+ os.mkdir(sub)
+ self.assertEqual(oe.path.realpath(sub, self.tmp_path), sub)
+
+ def test_realpath_path_outside_root_raises(self):
+ root = os.path.join(self.tmp_path, "root")
+ outside = os.path.join(self.tmp_path, "outside")
+ os.mkdir(root)
+ os.mkdir(outside)
+ with self.assertRaises(OSError):
+ oe.path.realpath(outside, root)
+
+ def test_realpath_isdir_failure_falls_back_without_nameerror(self):
+ # 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 = os.path.join(self.tmp_path, "d")
+ os.mkdir(sub)
+
+ def boom(_path):
+ raise OSError("synthetic stat failure")
+
+ with unittest.mock.patch("os.path.isdir", boom):
+ self.assertEqual(
+ oe.path.realpath(sub, self.tmp_path, use_physdir=False), sub)
--
2.50.0.173.g8b6f19ccfc3a
prev parent reply other threads:[~2026-07-15 22:13 UTC|newest]
Thread overview: 5+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-15 22:12 [PATCH 0/4] oe/path: three fixes plus selftest coverage Trevor Woerner
2026-07-15 22:12 ` [PATCH 1/4] oe/path: fix bare `false` NameError in __realpath's isdir guard Trevor Woerner
2026-07-15 22:12 ` [PATCH 2/4] oe/path: don't glob-expand the destination in symlink(force=True) Trevor Woerner
2026-07-15 22:12 ` [PATCH 3/4] oe/path: canonicalize('') should return '' rather than the cwd Trevor Woerner
2026-07-15 22:12 ` Trevor Woerner [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260715221252.3369108-5-twoerner@gmail.com \
--to=twoerner@gmail.com \
--cc=openembedded-core@lists.openembedded.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.