Repository: pylint-dev/astroid

Issue:
modutils._get_relative_base_path returns incorrect result when directory name starts with directory name in path_to_check
Consider following call: `astroid.modutils._get_relative_base_path('something', os.path.join(os.getcwd(), 'some'))`.
I would expect this to return `None` by design. However, this returns `'thing'` instead.

This results in issues with running pylint on a project with following directory structure:
```
some
\- __init__.py
something
\- __init__.py
thing
\- __init__.py
```
I have observed this in astroid version 3.1.0, but it seems to me that the code in master is still the same.


Discussion / hints:
Hi @eugene57 

Can I work on this issue?
Sure, I assigned you to the issue @pavan-msys :)



Fix (patch):
diff --git a/ChangeLog b/ChangeLog
index 22ed00a05..d8fdfd63e 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -38,6 +38,10 @@ Release date: TBA
 
 * Modify ``astroid.bases`` and ``tests.test_nodes`` to reflect that `enum.property` was added in Python 3.11, not 3.10
 
+* Fix incorrect result in `_get_relative_base_path` when the target directory name starts with the base path
+
+  Closes #2608
+
 What's New in astroid 3.3.11?
 =============================
 Release date: TBA
diff --git a/astroid/modutils.py b/astroid/modutils.py
index 6029e33c1..6b84fe761 100644
--- a/astroid/modutils.py
+++ b/astroid/modutils.py
@@ -236,6 +236,14 @@ def check_modpath_has_init(path: str, mod_path: list[str]) -> bool:
     return True
 
 
+def _is_subpath(path: str, base: str) -> bool:
+    path = os.path.normcase(os.path.normpath(path))
+    base = os.path.normcase(os.path.normpath(base))
+    if not path.startswith(base):
+        return False
+    return (len(path) == len(base)) or (path[len(base)] == os.path.sep)
+
+
 def _get_relative_base_path(filename: str, path_to_check: str) -> list[str] | None:
     """Extracts the relative mod path of the file to import from.
 
@@ -252,19 +260,18 @@ def _get_relative_base_path(filename: str, path_to_check: str) -> list[str] | No
         _get_relative_base_path("/a/b/c/d.py", "/a/b") ->  ["c","d"]
         _get_relative_base_path("/a/b/c/d.py", "/dev") ->  None
     """
-    importable_path = None
-    path_to_check = os.path.normcase(path_to_check)
+    path_to_check = os.path.normcase(os.path.normpath(path_to_check))
+
     abs_filename = os.path.abspath(filename)
-    if os.path.normcase(abs_filename).startswith(path_to_check):
-        importable_path = abs_filename
+    if _is_subpath(abs_filename, path_to_check):
+        base_path = os.path.splitext(abs_filename)[0]
+        relative_base_path = base_path[len(path_to_check) :].lstrip(os.path.sep)
+        return [pkg for pkg in relative_base_path.split(os.sep) if pkg]
 
     real_filename = os.path.realpath(filename)
-    if os.path.normcase(real_filename).startswith(path_to_check):
-        importable_path = real_filename
-
-    if importable_path:
-        base_path = os.path.splitext(importable_path)[0]
-        relative_base_path = base_path[len(path_to_check) :]
+    if _is_subpath(real_filename, path_to_check):
+        base_path = os.path.splitext(real_filename)[0]
+        relative_base_path = base_path[len(path_to_check) :].lstrip(os.path.sep)
         return [pkg for pkg in relative_base_path.split(os.sep) if pkg]
 
     return None


Test changes:
diff --git a/tests/test_get_relative_base_path.py b/tests/test_get_relative_base_path.py
new file mode 100644
index 000000000..ddf35588a
--- /dev/null
+++ b/tests/test_get_relative_base_path.py
@@ -0,0 +1,119 @@
+# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
+# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE
+# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt
+import os
+import tempfile
+import unittest
+
+from astroid import modutils
+
+
+class TestModUtilsRelativePath(unittest.TestCase):
+
+    def setUp(self):
+        self.cwd = os.getcwd()
+
+    def _run_relative_path_test(self, target, base, expected):
+        if not target or not base:
+            result = None
+        else:
+            base_dir = os.path.join(self.cwd, base)
+            target_path = os.path.join(self.cwd, target)
+            result = modutils._get_relative_base_path(target_path, base_dir)
+        self.assertEqual(result, expected)
+
+    def test_similar_prefixes_no_match(self):
+
+        cases = [
+            ("something", "some", None),
+            ("some-thing", "some", None),
+            ("some2", "some", None),
+            ("somedir", "some", None),
+            ("some_thing", "some", None),
+            ("some.dir", "some", None),
+        ]
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_valid_subdirectories(self):
+
+        cases = [
+            ("some/sub", "some", ["sub"]),
+            ("some/foo/bar", "some", ["foo", "bar"]),
+            ("some/foo-bar", "some", ["foo-bar"]),
+            ("some/foo/bar-ext", "some/foo", ["bar-ext"]),
+            ("something/sub", "something", ["sub"]),
+        ]
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_path_format_variations(self):
+
+        cases = [
+            ("some", "some", []),
+            ("some/", "some", []),
+            ("../some", "some", None),
+        ]
+
+        if os.path.isabs("/abs/path"):
+            cases.append(("/abs/path/some", "/abs/path", ["some"]))
+
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_case_sensitivity(self):
+
+        cases = [
+            ("Some/sub", "some", None if os.path.sep == "/" else ["sub"]),
+            ("some/Sub", "some", ["Sub"]),
+        ]
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_special_path_components(self):
+
+        cases = [
+            ("some/.hidden", "some", [".hidden"]),
+            ("some/with space", "some", ["with space"]),
+            ("some/unicode_ø", "some", ["unicode_ø"]),
+        ]
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_nonexistent_paths(self):
+
+        cases = [("nonexistent", "some", None), ("some/sub", "nonexistent", None)]
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_empty_paths(self):
+
+        cases = [("", "some", None), ("some", "", None), ("", "", None)]
+        for target, base, expected in cases:
+            with self.subTest(target=target, base=base):
+                self._run_relative_path_test(target, base, expected)
+
+    def test_symlink_resolution(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            base_dir = os.path.join(tmpdir, "some")
+            os.makedirs(base_dir, exist_ok=True)
+
+            real_file = os.path.join(base_dir, "real.py")
+            with open(real_file, "w", encoding="utf-8") as f:
+                f.write("# dummy content")
+
+            symlink_path = os.path.join(tmpdir, "symlink.py")
+            os.symlink(real_file, symlink_path)
+
+            result = modutils._get_relative_base_path(symlink_path, base_dir)
+            self.assertEqual(result, ["real"])
+
+
+if __name__ == "__main__":
+    unittest.main()