Skip to content

Commit 04732ca

Browse files
authored
bpo-43105: Importlib now resolves relative paths when creating module spec objects from file locations (GH-25121)
1 parent b57e045 commit 04732ca

File tree

9 files changed

+2721
-2516
lines changed

9 files changed

+2721
-2516
lines changed

Lib/importlib/_bootstrap_external.py

+62-19
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
# Assumption made in _path_join()
4646
assert all(len(sep) == 1 for sep in path_separators)
4747
path_sep = path_separators[0]
48+
path_sep_tuple = tuple(path_separators)
4849
path_separators = ''.join(path_separators)
4950
_pathseps_with_colon = {f':{s}' for s in path_separators}
5051

@@ -91,22 +92,49 @@ def _unpack_uint16(data):
9192
return int.from_bytes(data, 'little')
9293

9394

94-
def _path_join(*path_parts):
95-
"""Replacement for os.path.join()."""
96-
return path_sep.join([part.rstrip(path_separators)
97-
for part in path_parts if part])
95+
if _MS_WINDOWS:
96+
def _path_join(*path_parts):
97+
"""Replacement for os.path.join()."""
98+
if not path_parts:
99+
return ""
100+
if len(path_parts) == 1:
101+
return path_parts[0]
102+
root = ""
103+
path = []
104+
for new_root, tail in map(_os._path_splitroot, path_parts):
105+
if new_root.startswith(path_sep_tuple) or new_root.endswith(path_sep_tuple):
106+
root = new_root.rstrip(path_separators) or root
107+
path = [path_sep + tail]
108+
elif new_root.endswith(':'):
109+
if root.casefold() != new_root.casefold():
110+
# Drive relative paths have to be resolved by the OS, so we reset the
111+
# tail but do not add a path_sep prefix.
112+
root = new_root
113+
path = [tail]
114+
else:
115+
path.append(tail)
116+
else:
117+
root = new_root or root
118+
path.append(tail)
119+
path = [p.rstrip(path_separators) for p in path if p]
120+
if len(path) == 1 and not path[0]:
121+
# Avoid losing the root's trailing separator when joining with nothing
122+
return root + path_sep
123+
return root + path_sep.join(path)
124+
125+
else:
126+
def _path_join(*path_parts):
127+
"""Replacement for os.path.join()."""
128+
return path_sep.join([part.rstrip(path_separators)
129+
for part in path_parts if part])
98130

99131

100132
def _path_split(path):
101133
"""Replacement for os.path.split()."""
102-
if len(path_separators) == 1:
103-
front, _, tail = path.rpartition(path_sep)
104-
return front, tail
105-
for x in reversed(path):
106-
if x in path_separators:
107-
front, tail = path.rsplit(x, maxsplit=1)
108-
return front, tail
109-
return '', path
134+
i = max(path.rfind(p) for p in path_separators)
135+
if i < 0:
136+
return '', path
137+
return path[:i], path[i + 1:]
110138

111139

112140
def _path_stat(path):
@@ -140,13 +168,18 @@ def _path_isdir(path):
140168
return _path_is_mode_type(path, 0o040000)
141169

142170

143-
def _path_isabs(path):
144-
"""Replacement for os.path.isabs.
171+
if _MS_WINDOWS:
172+
def _path_isabs(path):
173+
"""Replacement for os.path.isabs."""
174+
if not path:
175+
return False
176+
root = _os._path_splitroot(path)[0].replace('/', '\\')
177+
return len(root) > 1 and (root.startswith('\\\\') or root.endswith('\\'))
145178

146-
Considers a Windows drive-relative path (no drive, but starts with slash) to
147-
still be "absolute".
148-
"""
149-
return path.startswith(path_separators) or path[1:3] in _pathseps_with_colon
179+
else:
180+
def _path_isabs(path):
181+
"""Replacement for os.path.isabs."""
182+
return path.startswith(path_separators)
150183

151184

152185
def _write_atomic(path, data, mode=0o666):
@@ -707,6 +740,11 @@ def spec_from_file_location(name, location=None, *, loader=None,
707740
pass
708741
else:
709742
location = _os.fspath(location)
743+
if not _path_isabs(location):
744+
try:
745+
location = _path_join(_os.getcwd(), location)
746+
except OSError:
747+
pass
710748

711749
# If the location is on the filesystem, but doesn't actually exist,
712750
# we could return None here, indicating that the location is not
@@ -1451,6 +1489,8 @@ def __init__(self, path, *loader_details):
14511489
self._loaders = loaders
14521490
# Base (directory) path
14531491
self.path = path or '.'
1492+
if not _path_isabs(self.path):
1493+
self.path = _path_join(_os.getcwd(), self.path)
14541494
self._path_mtime = -1
14551495
self._path_cache = set()
14561496
self._relaxed_path_cache = set()
@@ -1516,7 +1556,10 @@ def find_spec(self, fullname, target=None):
15161556
is_namespace = _path_isdir(base_path)
15171557
# Check for a file w/ a proper suffix exists.
15181558
for suffix, loader_class in self._loaders:
1519-
full_path = _path_join(self.path, tail_module + suffix)
1559+
try:
1560+
full_path = _path_join(self.path, tail_module + suffix)
1561+
except ValueError:
1562+
return None
15201563
_bootstrap._verbose_message('trying {}', full_path, verbosity=2)
15211564
if cache_module + suffix in cache:
15221565
if _path_isfile(full_path):

Lib/test/test_import/__init__.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -906,15 +906,15 @@ def test_missing_source_legacy(self):
906906
m = __import__(TESTFN)
907907
try:
908908
self.assertEqual(m.__file__,
909-
os.path.join(os.curdir, os.path.relpath(pyc_file)))
909+
os.path.join(os.getcwd(), os.curdir, os.path.relpath(pyc_file)))
910910
finally:
911911
os.remove(pyc_file)
912912

913913
def test___cached__(self):
914914
# Modules now also have an __cached__ that points to the pyc file.
915915
m = __import__(TESTFN)
916916
pyc_file = importlib.util.cache_from_source(TESTFN + '.py')
917-
self.assertEqual(m.__cached__, os.path.join(os.curdir, pyc_file))
917+
self.assertEqual(m.__cached__, os.path.join(os.getcwd(), os.curdir, pyc_file))
918918

919919
@skip_if_dont_write_bytecode
920920
def test___cached___legacy_pyc(self):
@@ -930,7 +930,7 @@ def test___cached___legacy_pyc(self):
930930
importlib.invalidate_caches()
931931
m = __import__(TESTFN)
932932
self.assertEqual(m.__cached__,
933-
os.path.join(os.curdir, os.path.relpath(pyc_file)))
933+
os.path.join(os.getcwd(), os.curdir, os.path.relpath(pyc_file)))
934934

935935
@skip_if_dont_write_bytecode
936936
def test_package___cached__(self):
@@ -950,10 +950,10 @@ def cleanup():
950950
m = __import__('pep3147.foo')
951951
init_pyc = importlib.util.cache_from_source(
952952
os.path.join('pep3147', '__init__.py'))
953-
self.assertEqual(m.__cached__, os.path.join(os.curdir, init_pyc))
953+
self.assertEqual(m.__cached__, os.path.join(os.getcwd(), os.curdir, init_pyc))
954954
foo_pyc = importlib.util.cache_from_source(os.path.join('pep3147', 'foo.py'))
955955
self.assertEqual(sys.modules['pep3147.foo'].__cached__,
956-
os.path.join(os.curdir, foo_pyc))
956+
os.path.join(os.getcwd(), os.curdir, foo_pyc))
957957

958958
def test_package___cached___from_pyc(self):
959959
# Like test___cached__ but ensuring __cached__ when imported from a
@@ -977,10 +977,10 @@ def cleanup():
977977
m = __import__('pep3147.foo')
978978
init_pyc = importlib.util.cache_from_source(
979979
os.path.join('pep3147', '__init__.py'))
980-
self.assertEqual(m.__cached__, os.path.join(os.curdir, init_pyc))
980+
self.assertEqual(m.__cached__, os.path.join(os.getcwd(), os.curdir, init_pyc))
981981
foo_pyc = importlib.util.cache_from_source(os.path.join('pep3147', 'foo.py'))
982982
self.assertEqual(sys.modules['pep3147.foo'].__cached__,
983-
os.path.join(os.curdir, foo_pyc))
983+
os.path.join(os.getcwd(), os.curdir, foo_pyc))
984984

985985
def test_recompute_pyc_same_second(self):
986986
# Even when the source file doesn't change timestamp, a change in

Lib/test/test_importlib/test_spec.py

+15-4
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ class FactoryTests:
506506

507507
def setUp(self):
508508
self.name = 'spam'
509-
self.path = 'spam.py'
509+
self.path = os.path.abspath('spam.py')
510510
self.cached = self.util.cache_from_source(self.path)
511511
self.loader = TestLoader()
512512
self.fileloader = TestLoader(self.path)
@@ -645,7 +645,7 @@ def test_spec_from_loader_is_package_true_with_fileloader(self):
645645
self.assertEqual(spec.loader, self.fileloader)
646646
self.assertEqual(spec.origin, self.path)
647647
self.assertIs(spec.loader_state, None)
648-
self.assertEqual(spec.submodule_search_locations, [''])
648+
self.assertEqual(spec.submodule_search_locations, [os.getcwd()])
649649
self.assertEqual(spec.cached, self.cached)
650650
self.assertTrue(spec.has_location)
651651

@@ -744,7 +744,7 @@ def test_spec_from_file_location_smsl_empty(self):
744744
self.assertEqual(spec.loader, self.fileloader)
745745
self.assertEqual(spec.origin, self.path)
746746
self.assertIs(spec.loader_state, None)
747-
self.assertEqual(spec.submodule_search_locations, [''])
747+
self.assertEqual(spec.submodule_search_locations, [os.getcwd()])
748748
self.assertEqual(spec.cached, self.cached)
749749
self.assertTrue(spec.has_location)
750750

@@ -769,7 +769,7 @@ def test_spec_from_file_location_smsl_default(self):
769769
self.assertEqual(spec.loader, self.pkgloader)
770770
self.assertEqual(spec.origin, self.path)
771771
self.assertIs(spec.loader_state, None)
772-
self.assertEqual(spec.submodule_search_locations, [''])
772+
self.assertEqual(spec.submodule_search_locations, [os.getcwd()])
773773
self.assertEqual(spec.cached, self.cached)
774774
self.assertTrue(spec.has_location)
775775

@@ -817,6 +817,17 @@ def is_package(self, name):
817817
self.assertEqual(spec.cached, self.cached)
818818
self.assertTrue(spec.has_location)
819819

820+
def test_spec_from_file_location_relative_path(self):
821+
spec = self.util.spec_from_file_location(self.name,
822+
os.path.basename(self.path), loader=self.fileloader)
823+
824+
self.assertEqual(spec.name, self.name)
825+
self.assertEqual(spec.loader, self.fileloader)
826+
self.assertEqual(spec.origin, self.path)
827+
self.assertIs(spec.loader_state, None)
828+
self.assertIs(spec.submodule_search_locations, None)
829+
self.assertEqual(spec.cached, self.cached)
830+
self.assertTrue(spec.has_location)
820831

821832
(Frozen_FactoryTests,
822833
Source_FactoryTests

Lib/test/test_importlib/test_windows.py

+45
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,48 @@ def test_tagged_suffix(self):
126126
(Frozen_WindowsExtensionSuffixTests,
127127
Source_WindowsExtensionSuffixTests
128128
) = test_util.test_both(WindowsExtensionSuffixTests, machinery=machinery)
129+
130+
131+
@unittest.skipUnless(sys.platform.startswith('win'), 'requires Windows')
132+
class WindowsBootstrapPathTests(unittest.TestCase):
133+
def check_join(self, expected, *inputs):
134+
from importlib._bootstrap_external import _path_join
135+
actual = _path_join(*inputs)
136+
if expected.casefold() == actual.casefold():
137+
return
138+
self.assertEqual(expected, actual)
139+
140+
def test_path_join(self):
141+
self.check_join(r"C:\A\B", "C:\\", "A", "B")
142+
self.check_join(r"C:\A\B", "D:\\", "D", "C:\\", "A", "B")
143+
self.check_join(r"C:\A\B", "C:\\", "A", "C:B")
144+
self.check_join(r"C:\A\B", "C:\\", "A\\B")
145+
self.check_join(r"C:\A\B", r"C:\A\B")
146+
147+
self.check_join("D:A", r"D:", "A")
148+
self.check_join("D:A", r"C:\B\C", "D:", "A")
149+
self.check_join("D:A", r"C:\B\C", r"D:A")
150+
151+
self.check_join(r"A\B\C", "A", "B", "C")
152+
self.check_join(r"A\B\C", "A", r"B\C")
153+
self.check_join(r"A\B/C", "A", "B/C")
154+
self.check_join(r"A\B\C", "A/", "B\\", "C")
155+
156+
# Dots are not normalised by this function
157+
self.check_join(r"A\../C", "A", "../C")
158+
self.check_join(r"A.\.\B", "A.", ".", "B")
159+
160+
self.check_join(r"\\Server\Share\A\B\C", r"\\Server\Share", "A", "B", "C")
161+
self.check_join(r"\\Server\Share\A\B\C", r"\\Server\Share", "D", r"\A", "B", "C")
162+
self.check_join(r"\\Server\Share\A\B\C", r"\\Server2\Share2", "D",
163+
r"\\Server\Share", "A", "B", "C")
164+
self.check_join(r"\\Server\Share\A\B\C", r"\\Server", r"\Share", "A", "B", "C")
165+
self.check_join(r"\\Server\Share", r"\\Server\Share")
166+
self.check_join(r"\\Server\Share\\", r"\\Server\Share\\")
167+
168+
# Handle edge cases with empty segments
169+
self.check_join("C:\\A", "C:/A", "")
170+
self.check_join("C:\\", "C:/", "")
171+
self.check_join("C:", "C:", "")
172+
self.check_join("//Server/Share\\", "//Server/Share/", "")
173+
self.check_join("//Server/Share\\", "//Server/Share", "")

Lib/test/test_site.py

+1-49
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def test_addpackage_import_bad_pth_file(self):
173173
pth_dir, pth_fn = self.make_pth("abc\x00def\n")
174174
with captured_stderr() as err_out:
175175
self.assertFalse(site.addpackage(pth_dir, pth_fn, set()))
176+
self.maxDiff = None
176177
self.assertEqual(err_out.getvalue(), "")
177178
for path in sys.path:
178179
if isinstance(path, str):
@@ -408,55 +409,6 @@ def tearDown(self):
408409
"""Restore sys.path"""
409410
sys.path[:] = self.sys_path
410411

411-
def test_abs_paths(self):
412-
# Make sure all imported modules have their __file__ and __cached__
413-
# attributes as absolute paths. Arranging to put the Lib directory on
414-
# PYTHONPATH would cause the os module to have a relative path for
415-
# __file__ if abs_paths() does not get run. sys and builtins (the
416-
# only other modules imported before site.py runs) do not have
417-
# __file__ or __cached__ because they are built-in.
418-
try:
419-
parent = os.path.relpath(os.path.dirname(os.__file__))
420-
cwd = os.getcwd()
421-
except ValueError:
422-
# Failure to get relpath probably means we need to chdir
423-
# to the same drive.
424-
cwd, parent = os.path.split(os.path.dirname(os.__file__))
425-
with change_cwd(cwd):
426-
env = os.environ.copy()
427-
env['PYTHONPATH'] = parent
428-
code = ('import os, sys',
429-
# use ASCII to avoid locale issues with non-ASCII directories
430-
'os_file = os.__file__.encode("ascii", "backslashreplace")',
431-
r'sys.stdout.buffer.write(os_file + b"\n")',
432-
'os_cached = os.__cached__.encode("ascii", "backslashreplace")',
433-
r'sys.stdout.buffer.write(os_cached + b"\n")')
434-
command = '\n'.join(code)
435-
# First, prove that with -S (no 'import site'), the paths are
436-
# relative.
437-
proc = subprocess.Popen([sys.executable, '-S', '-c', command],
438-
env=env,
439-
stdout=subprocess.PIPE)
440-
stdout, stderr = proc.communicate()
441-
442-
self.assertEqual(proc.returncode, 0)
443-
os__file__, os__cached__ = stdout.splitlines()[:2]
444-
self.assertFalse(os.path.isabs(os__file__))
445-
self.assertFalse(os.path.isabs(os__cached__))
446-
# Now, with 'import site', it works.
447-
proc = subprocess.Popen([sys.executable, '-c', command],
448-
env=env,
449-
stdout=subprocess.PIPE)
450-
stdout, stderr = proc.communicate()
451-
self.assertEqual(proc.returncode, 0)
452-
os__file__, os__cached__ = stdout.splitlines()[:2]
453-
self.assertTrue(os.path.isabs(os__file__),
454-
"expected absolute path, got {}"
455-
.format(os__file__.decode('ascii')))
456-
self.assertTrue(os.path.isabs(os__cached__),
457-
"expected absolute path, got {}"
458-
.format(os__cached__.decode('ascii')))
459-
460412
def test_abs_paths_cached_None(self):
461413
"""Test for __cached__ is None.
462414
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Importlib now resolves relative paths when creating module spec objects from
2+
file locations.

0 commit comments

Comments
 (0)