diff --git a/Lib/linecache.py b/Lib/linecache.py index b5bf9dbdd3cbc7..d1391510473ae5 100644 --- a/Lib/linecache.py +++ b/Lib/linecache.py @@ -144,6 +144,7 @@ def updatecache(filename, module_globals=None): lazy_entry = entry if entry is not None and len(entry) == 1 else None if lazy_entry is None: lazy_entry = _make_lazycache_entry(filename, module_globals) + data = None if lazy_entry is not None: try: data = lazy_entry[0]() @@ -154,14 +155,23 @@ def updatecache(filename, module_globals=None): # No luck, the PEP302 loader cannot find the source # for this module. return [] - entry = ( - len(data), - None, - [line + '\n' for line in data.splitlines()], - fullname - ) - cache[filename] = entry - return entry[2] + if data is None: + # The file may be inside an archive on the module search path, + # such as a zip file. + try: + data = _read_from_archive(fullname) + except ImportError: + # Can happen if the interpreter is shutting down. + return [] + if data is not None: + entry = ( + len(data), + None, + [line + '\n' for line in data.splitlines()], + fullname + ) + cache[filename] = entry + return entry[2] # Try looking through the module search path, which is only useful # when handling a relative filename. @@ -197,6 +207,42 @@ def updatecache(filename, module_globals=None): return lines +def _read_from_archive(filename): + """Return the decoded contents of a file inside an archive on sys.path. + + Path entry finders for archives, such as zipimport.zipimporter, have a + get_data() method that reads files by their path below the archive, + which is what __file__ and co_filename contain for modules imported + from it. The archive is one of the parent directories of the file, so + look for a finder registered for one of them. Return None if the file + is not in such an archive. + """ + import os + import sys + importers = sys.path_importer_cache + if importers is None: + # Cleared while the interpreter is shutting down. + return None + path = filename + while True: + parent = os.path.dirname(path) + if parent == path: + return None + path = parent + get_data = getattr(importers.get(path), 'get_data', None) + if get_data is None: + continue + try: + data = get_data(filename) + except (ImportError, OSError): + continue + import importlib.util + try: + return importlib.util.decode_source(data) + except (UnicodeDecodeError, SyntaxError): + return None + + def lazycache(filename, module_globals): """Seed the cache for filename with module_globals. diff --git a/Lib/test/test_linecache.py b/Lib/test/test_linecache.py index fcd94edc611fac..202e30e6f6c07a 100644 --- a/Lib/test/test_linecache.py +++ b/Lib/test/test_linecache.py @@ -1,13 +1,18 @@ """ Tests for the linecache module """ +import importlib import linecache import unittest import os.path +import sys import tempfile import threading import tokenize +import zipfile +import zipimport from importlib.machinery import ModuleSpec from test import support +from test.support import import_helper from test.support import os_helper from test.support import threading_helper from test.support.script_helper import assert_python_ok @@ -356,6 +361,17 @@ def test_linecache_python_string(self): self.assertEqual(stdout, b'') self.assertEqual(stderr, b'') + def test_path_importer_cache_None(self): + # sys.path_importer_cache is set to None while the interpreter is + # shutting down, before objects with a __del__ that may end up here + # are released. + filename = os.path.abspath(os_helper.TESTFN + '.py') + with support.swap_attr(sys, 'path_importer_cache', None): + self.assertEqual(linecache.getlines(filename), []) + self.assertEqual(linecache.getline(filename, 1), '') + self.assertNotIn(filename, linecache.cache) + + class LineCacheInvalidationTests(unittest.TestCase): def setUp(self): super().setUp() @@ -398,6 +414,98 @@ def test_checkcache_with_no_parameter(self): self.assertIn(self.unchanged_file, linecache.cache) +class ZipArchiveTests(unittest.TestCase): + """Sources of modules imported from a zip archive on sys.path.""" + + MODULE_SOURCE = ( + '"""A module inside a zip archive."""\n' + '\n' + 'def f():\n' + ' return "from the zip"\n' + ) + PACKAGE_SOURCE = 'value = 42\n' + LATIN1_SOURCE = ( + '# -*- coding: latin-1 -*-\n' + 'value = "caf\xe9"\n' + ) + + def setUp(self): + linecache.clearcache() + self.addCleanup(linecache.clearcache) + tmpdir = self.enterContext(os_helper.temp_dir()) + self.zip_name = os.path.join(tmpdir, 'sources.zip') + with zipfile.ZipFile(self.zip_name, 'w') as zf: + zf.writestr('zipmod.py', self.MODULE_SOURCE) + zf.writestr('zippkg/__init__.py', self.PACKAGE_SOURCE) + zf.writestr('ziplatin1.py', self.LATIN1_SOURCE.encode('latin-1')) + self.enterContext(import_helper.DirsOnSysPath(self.zip_name)) + for name in 'zipmod', 'zippkg', 'ziplatin1': + self.addCleanup(import_helper.unload, name) + self.addCleanup(sys.path_importer_cache.pop, self.zip_name, None) + self.addCleanup(zipimport._zip_directory_cache.pop, + self.zip_name, None) + self.zipmod = importlib.import_module('zipmod') + + def test_getlines_without_module_globals(self): + filename = self.zipmod.__file__ + self.assertEqual(filename, os.path.join(self.zip_name, 'zipmod.py')) + self.assertFalse(os.path.exists(filename)) + lines = self.MODULE_SOURCE.splitlines(keepends=True) + self.assertEqual(linecache.getlines(filename), lines) + self.assertEqual(linecache.getline(filename, 4), + ' return "from the zip"\n') + self.assertEqual(linecache.getline(filename, 5), '') + code = self.zipmod.f.__code__ + self.assertEqual(code.co_filename, filename) + self.assertEqual(linecache.getline(filename, code.co_firstlineno), + 'def f():\n') + + def test_relative_archive_path(self): + # A relative sys.path entry gives its modules a relative __file__. + tmpdir, zip_base = os.path.split(self.zip_name) + self.addCleanup(sys.path_importer_cache.pop, zip_base, None) + self.addCleanup(zipimport._zip_directory_cache.pop, zip_base, None) + sys.path.insert(0, zip_base) + self.addCleanup(sys.path.remove, zip_base) + with os_helper.change_cwd(tmpdir): + zippkg = importlib.import_module('zippkg') + self.assertEqual(zippkg.__file__, + os.path.join(zip_base, 'zippkg', '__init__.py')) + self.assertEqual(linecache.getlines(zippkg.__file__), + ['value = 42\n']) + + def test_package(self): + zippkg = importlib.import_module('zippkg') + self.assertEqual(linecache.getlines(zippkg.__file__), + ['value = 42\n']) + + def test_encoding_declaration(self): + ziplatin1 = importlib.import_module('ziplatin1') + self.assertEqual(linecache.getlines(ziplatin1.__file__), + self.LATIN1_SOURCE.splitlines(keepends=True)) + + def test_missing_file(self): + filename = os.path.join(self.zip_name, 'missing.py') + self.assertEqual(linecache.getlines(filename), []) + self.assertEqual(linecache.getline(filename, 1), '') + self.assertNotIn(filename, linecache.cache) + + def test_checkcache_and_clearcache(self): + filename = self.zipmod.__file__ + lines = linecache.getlines(filename) + self.assertIn(filename, linecache.cache) + # A file inside an archive has no mtime of its own, so checkcache() + # keeps the entry, as it does for entries loaded through a loader. + self.assertIsNone(linecache.cache[filename][1]) + linecache.checkcache(filename) + linecache.checkcache() + self.assertIn(filename, linecache.cache) + self.assertEqual(linecache.getlines(filename), lines) + linecache.clearcache() + self.assertNotIn(filename, linecache.cache) + self.assertEqual(linecache.getlines(filename), lines) + + class MultiThreadingTest(unittest.TestCase): @threading_helper.reap_threads @threading_helper.requires_working_threading() diff --git a/Misc/NEWS.d/next/Library/2026-09-07-23-40-00.gh-issue-157146.lczip1.rst b/Misc/NEWS.d/next/Library/2026-09-07-23-40-00.gh-issue-157146.lczip1.rst new file mode 100644 index 00000000000000..d8b81798bd64be --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-07-23-40-00.gh-issue-157146.lczip1.rst @@ -0,0 +1,3 @@ +:mod:`linecache` can now read the source of a module that was imported from +a zip archive on :data:`sys.path` when given only the file name, as +:mod:`pdb`, :mod:`warnings` and :mod:`doctest` do.