https://github.com/python/cpython/commit/1d6e7d5987e86ef58703ea698c5a04f0bd249e21
commit: 1d6e7d5987e86ef58703ea698c5a04f0bd249e21
branch: main
author: Gregory P. Smith <[email protected]>
committer: gpshead <[email protected]>
date: 2026-09-10T06:58:02-07:00
summary:
gh-157146: Let linecache read sources from zip archives on sys.path (GH-157147)
linecache.getline() now works for a .zip archive on sys.path without the caller
having to pass module_globals. Callers that only have a file name, such as pdb,
warnings, and doctest, now get source. Reads of such files go through the
get_data() method of the path entry finder registered for the archive in
sys.path_importer_cache.
Prior to this: os.stat() would fail on the archive-internal path, the loader
lookup needed the globals, and the sys.path search would only handle relative
names and assumed a filesystem rather than using an importer.
files:
A Misc/NEWS.d/next/Library/2026-09-07-23-40-00.gh-issue-157146.lczip1.rst
M Lib/linecache.py
M Lib/test/test_linecache.py
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.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]