Skip to content

Commit ee26847

Browse files
authored
Fix modutils._get_relative_base_path returns incorrect result when directory name starts with directory name in path_to_check (#2756)
1 parent 34fbf2e commit ee26847

File tree

3 files changed

+140
-10
lines changed

3 files changed

+140
-10
lines changed

ChangeLog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ Release date: TBA
3838

3939
* Modify ``astroid.bases`` and ``tests.test_nodes`` to reflect that `enum.property` was added in Python 3.11, not 3.10
4040

41+
* Fix incorrect result in `_get_relative_base_path` when the target directory name starts with the base path
42+
43+
Closes #2608
44+
4145
What's New in astroid 3.3.11?
4246
=============================
4347
Release date: TBA

astroid/modutils.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ def check_modpath_has_init(path: str, mod_path: list[str]) -> bool:
236236
return True
237237

238238

239+
def _is_subpath(path: str, base: str) -> bool:
240+
path = os.path.normcase(os.path.normpath(path))
241+
base = os.path.normcase(os.path.normpath(base))
242+
if not path.startswith(base):
243+
return False
244+
return (len(path) == len(base)) or (path[len(base)] == os.path.sep)
245+
246+
239247
def _get_relative_base_path(filename: str, path_to_check: str) -> list[str] | None:
240248
"""Extracts the relative mod path of the file to import from.
241249
@@ -252,19 +260,18 @@ def _get_relative_base_path(filename: str, path_to_check: str) -> list[str] | No
252260
_get_relative_base_path("/a/b/c/d.py", "/a/b") -> ["c","d"]
253261
_get_relative_base_path("/a/b/c/d.py", "/dev") -> None
254262
"""
255-
importable_path = None
256-
path_to_check = os.path.normcase(path_to_check)
263+
path_to_check = os.path.normcase(os.path.normpath(path_to_check))
264+
257265
abs_filename = os.path.abspath(filename)
258-
if os.path.normcase(abs_filename).startswith(path_to_check):
259-
importable_path = abs_filename
266+
if _is_subpath(abs_filename, path_to_check):
267+
base_path = os.path.splitext(abs_filename)[0]
268+
relative_base_path = base_path[len(path_to_check) :].lstrip(os.path.sep)
269+
return [pkg for pkg in relative_base_path.split(os.sep) if pkg]
260270

261271
real_filename = os.path.realpath(filename)
262-
if os.path.normcase(real_filename).startswith(path_to_check):
263-
importable_path = real_filename
264-
265-
if importable_path:
266-
base_path = os.path.splitext(importable_path)[0]
267-
relative_base_path = base_path[len(path_to_check) :]
272+
if _is_subpath(real_filename, path_to_check):
273+
base_path = os.path.splitext(real_filename)[0]
274+
relative_base_path = base_path[len(path_to_check) :].lstrip(os.path.sep)
268275
return [pkg for pkg in relative_base_path.split(os.sep) if pkg]
269276

270277
return None

tests/test_get_relative_base_path.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
2+
# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE
3+
# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt
4+
import os
5+
import tempfile
6+
import unittest
7+
8+
from astroid import modutils
9+
10+
11+
class TestModUtilsRelativePath(unittest.TestCase):
12+
13+
def setUp(self):
14+
self.cwd = os.getcwd()
15+
16+
def _run_relative_path_test(self, target, base, expected):
17+
if not target or not base:
18+
result = None
19+
else:
20+
base_dir = os.path.join(self.cwd, base)
21+
target_path = os.path.join(self.cwd, target)
22+
result = modutils._get_relative_base_path(target_path, base_dir)
23+
self.assertEqual(result, expected)
24+
25+
def test_similar_prefixes_no_match(self):
26+
27+
cases = [
28+
("something", "some", None),
29+
("some-thing", "some", None),
30+
("some2", "some", None),
31+
("somedir", "some", None),
32+
("some_thing", "some", None),
33+
("some.dir", "some", None),
34+
]
35+
for target, base, expected in cases:
36+
with self.subTest(target=target, base=base):
37+
self._run_relative_path_test(target, base, expected)
38+
39+
def test_valid_subdirectories(self):
40+
41+
cases = [
42+
("some/sub", "some", ["sub"]),
43+
("some/foo/bar", "some", ["foo", "bar"]),
44+
("some/foo-bar", "some", ["foo-bar"]),
45+
("some/foo/bar-ext", "some/foo", ["bar-ext"]),
46+
("something/sub", "something", ["sub"]),
47+
]
48+
for target, base, expected in cases:
49+
with self.subTest(target=target, base=base):
50+
self._run_relative_path_test(target, base, expected)
51+
52+
def test_path_format_variations(self):
53+
54+
cases = [
55+
("some", "some", []),
56+
("some/", "some", []),
57+
("../some", "some", None),
58+
]
59+
60+
if os.path.isabs("/abs/path"):
61+
cases.append(("/abs/path/some", "/abs/path", ["some"]))
62+
63+
for target, base, expected in cases:
64+
with self.subTest(target=target, base=base):
65+
self._run_relative_path_test(target, base, expected)
66+
67+
def test_case_sensitivity(self):
68+
69+
cases = [
70+
("Some/sub", "some", None if os.path.sep == "/" else ["sub"]),
71+
("some/Sub", "some", ["Sub"]),
72+
]
73+
for target, base, expected in cases:
74+
with self.subTest(target=target, base=base):
75+
self._run_relative_path_test(target, base, expected)
76+
77+
def test_special_path_components(self):
78+
79+
cases = [
80+
("some/.hidden", "some", [".hidden"]),
81+
("some/with space", "some", ["with space"]),
82+
("some/unicode_ø", "some", ["unicode_ø"]),
83+
]
84+
for target, base, expected in cases:
85+
with self.subTest(target=target, base=base):
86+
self._run_relative_path_test(target, base, expected)
87+
88+
def test_nonexistent_paths(self):
89+
90+
cases = [("nonexistent", "some", None), ("some/sub", "nonexistent", None)]
91+
for target, base, expected in cases:
92+
with self.subTest(target=target, base=base):
93+
self._run_relative_path_test(target, base, expected)
94+
95+
def test_empty_paths(self):
96+
97+
cases = [("", "some", None), ("some", "", None), ("", "", None)]
98+
for target, base, expected in cases:
99+
with self.subTest(target=target, base=base):
100+
self._run_relative_path_test(target, base, expected)
101+
102+
def test_symlink_resolution(self):
103+
with tempfile.TemporaryDirectory() as tmpdir:
104+
base_dir = os.path.join(tmpdir, "some")
105+
os.makedirs(base_dir, exist_ok=True)
106+
107+
real_file = os.path.join(base_dir, "real.py")
108+
with open(real_file, "w", encoding="utf-8") as f:
109+
f.write("# dummy content")
110+
111+
symlink_path = os.path.join(tmpdir, "symlink.py")
112+
os.symlink(real_file, symlink_path)
113+
114+
result = modutils._get_relative_base_path(symlink_path, base_dir)
115+
self.assertEqual(result, ["real"])
116+
117+
118+
if __name__ == "__main__":
119+
unittest.main()

0 commit comments

Comments
 (0)