Skip to content

Commit 6947564

Browse files
authored
[3.14] gh-157146: Let linecache read sources from zip archives on sys.path (GH-157147) (#159213)
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. (cherry picked from commit 1d6e7d5)
1 parent 758278c commit 6947564

3 files changed

Lines changed: 165 additions & 8 deletions

File tree

‎Lib/linecache.py‎

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def updatecache(filename, module_globals=None):
144144
lazy_entry = entry if entry is not None and len(entry) == 1 else None
145145
if lazy_entry is None:
146146
lazy_entry = _make_lazycache_entry(filename, module_globals)
147+
data = None
147148
if lazy_entry is not None:
148149
try:
149150
data = lazy_entry[0]()
@@ -154,14 +155,23 @@ def updatecache(filename, module_globals=None):
154155
# No luck, the PEP302 loader cannot find the source
155156
# for this module.
156157
return []
157-
entry = (
158-
len(data),
159-
None,
160-
[line + '\n' for line in data.splitlines()],
161-
fullname
162-
)
163-
cache[filename] = entry
164-
return entry[2]
158+
if data is None:
159+
# The file may be inside an archive on the module search path,
160+
# such as a zip file.
161+
try:
162+
data = _read_from_archive(fullname)
163+
except ImportError:
164+
# Can happen if the interpreter is shutting down.
165+
return []
166+
if data is not None:
167+
entry = (
168+
len(data),
169+
None,
170+
[line + '\n' for line in data.splitlines()],
171+
fullname
172+
)
173+
cache[filename] = entry
174+
return entry[2]
165175

166176
# Try looking through the module search path, which is only useful
167177
# when handling a relative filename.
@@ -197,6 +207,42 @@ def updatecache(filename, module_globals=None):
197207
return lines
198208

199209

210+
def _read_from_archive(filename):
211+
"""Return the decoded contents of a file inside an archive on sys.path.
212+
213+
Path entry finders for archives, such as zipimport.zipimporter, have a
214+
get_data() method that reads files by their path below the archive,
215+
which is what __file__ and co_filename contain for modules imported
216+
from it. The archive is one of the parent directories of the file, so
217+
look for a finder registered for one of them. Return None if the file
218+
is not in such an archive.
219+
"""
220+
import os
221+
import sys
222+
importers = sys.path_importer_cache
223+
if importers is None:
224+
# Cleared while the interpreter is shutting down.
225+
return None
226+
path = filename
227+
while True:
228+
parent = os.path.dirname(path)
229+
if parent == path:
230+
return None
231+
path = parent
232+
get_data = getattr(importers.get(path), 'get_data', None)
233+
if get_data is None:
234+
continue
235+
try:
236+
data = get_data(filename)
237+
except (ImportError, OSError):
238+
continue
239+
import importlib.util
240+
try:
241+
return importlib.util.decode_source(data)
242+
except (UnicodeDecodeError, SyntaxError):
243+
return None
244+
245+
200246
def lazycache(filename, module_globals):
201247
"""Seed the cache for filename with module_globals.
202248

‎Lib/test/test_linecache.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
""" Tests for the linecache module """
22

3+
import importlib
34
import linecache
45
import unittest
56
import os.path
7+
import sys
68
import tempfile
79
import threading
810
import tokenize
11+
import zipfile
12+
import zipimport
913
from importlib.machinery import ModuleSpec
1014
from test import support
15+
from test.support import import_helper
1116
from test.support import os_helper
1217
from test.support import threading_helper
1318
from test.support.script_helper import assert_python_ok
@@ -334,6 +339,17 @@ def test_linecache_python_string(self):
334339
self.assertEqual(stdout, b'')
335340
self.assertEqual(stderr, b'')
336341

342+
def test_path_importer_cache_None(self):
343+
# sys.path_importer_cache is set to None while the interpreter is
344+
# shutting down, before objects with a __del__ that may end up here
345+
# are released.
346+
filename = os.path.abspath(os_helper.TESTFN + '.py')
347+
with support.swap_attr(sys, 'path_importer_cache', None):
348+
self.assertEqual(linecache.getlines(filename), [])
349+
self.assertEqual(linecache.getline(filename, 1), '')
350+
self.assertNotIn(filename, linecache.cache)
351+
352+
337353
class LineCacheInvalidationTests(unittest.TestCase):
338354
def setUp(self):
339355
super().setUp()
@@ -376,6 +392,98 @@ def test_checkcache_with_no_parameter(self):
376392
self.assertIn(self.unchanged_file, linecache.cache)
377393

378394

395+
class ZipArchiveTests(unittest.TestCase):
396+
"""Sources of modules imported from a zip archive on sys.path."""
397+
398+
MODULE_SOURCE = (
399+
'"""A module inside a zip archive."""\n'
400+
'\n'
401+
'def f():\n'
402+
' return "from the zip"\n'
403+
)
404+
PACKAGE_SOURCE = 'value = 42\n'
405+
LATIN1_SOURCE = (
406+
'# -*- coding: latin-1 -*-\n'
407+
'value = "caf\xe9"\n'
408+
)
409+
410+
def setUp(self):
411+
linecache.clearcache()
412+
self.addCleanup(linecache.clearcache)
413+
tmpdir = self.enterContext(os_helper.temp_dir())
414+
self.zip_name = os.path.join(tmpdir, 'sources.zip')
415+
with zipfile.ZipFile(self.zip_name, 'w') as zf:
416+
zf.writestr('zipmod.py', self.MODULE_SOURCE)
417+
zf.writestr('zippkg/__init__.py', self.PACKAGE_SOURCE)
418+
zf.writestr('ziplatin1.py', self.LATIN1_SOURCE.encode('latin-1'))
419+
self.enterContext(import_helper.DirsOnSysPath(self.zip_name))
420+
for name in 'zipmod', 'zippkg', 'ziplatin1':
421+
self.addCleanup(import_helper.unload, name)
422+
self.addCleanup(sys.path_importer_cache.pop, self.zip_name, None)
423+
self.addCleanup(zipimport._zip_directory_cache.pop,
424+
self.zip_name, None)
425+
self.zipmod = importlib.import_module('zipmod')
426+
427+
def test_getlines_without_module_globals(self):
428+
filename = self.zipmod.__file__
429+
self.assertEqual(filename, os.path.join(self.zip_name, 'zipmod.py'))
430+
self.assertFalse(os.path.exists(filename))
431+
lines = self.MODULE_SOURCE.splitlines(keepends=True)
432+
self.assertEqual(linecache.getlines(filename), lines)
433+
self.assertEqual(linecache.getline(filename, 4),
434+
' return "from the zip"\n')
435+
self.assertEqual(linecache.getline(filename, 5), '')
436+
code = self.zipmod.f.__code__
437+
self.assertEqual(code.co_filename, filename)
438+
self.assertEqual(linecache.getline(filename, code.co_firstlineno),
439+
'def f():\n')
440+
441+
def test_relative_archive_path(self):
442+
# A relative sys.path entry gives its modules a relative __file__.
443+
tmpdir, zip_base = os.path.split(self.zip_name)
444+
self.addCleanup(sys.path_importer_cache.pop, zip_base, None)
445+
self.addCleanup(zipimport._zip_directory_cache.pop, zip_base, None)
446+
sys.path.insert(0, zip_base)
447+
self.addCleanup(sys.path.remove, zip_base)
448+
with os_helper.change_cwd(tmpdir):
449+
zippkg = importlib.import_module('zippkg')
450+
self.assertEqual(zippkg.__file__,
451+
os.path.join(zip_base, 'zippkg', '__init__.py'))
452+
self.assertEqual(linecache.getlines(zippkg.__file__),
453+
['value = 42\n'])
454+
455+
def test_package(self):
456+
zippkg = importlib.import_module('zippkg')
457+
self.assertEqual(linecache.getlines(zippkg.__file__),
458+
['value = 42\n'])
459+
460+
def test_encoding_declaration(self):
461+
ziplatin1 = importlib.import_module('ziplatin1')
462+
self.assertEqual(linecache.getlines(ziplatin1.__file__),
463+
self.LATIN1_SOURCE.splitlines(keepends=True))
464+
465+
def test_missing_file(self):
466+
filename = os.path.join(self.zip_name, 'missing.py')
467+
self.assertEqual(linecache.getlines(filename), [])
468+
self.assertEqual(linecache.getline(filename, 1), '')
469+
self.assertNotIn(filename, linecache.cache)
470+
471+
def test_checkcache_and_clearcache(self):
472+
filename = self.zipmod.__file__
473+
lines = linecache.getlines(filename)
474+
self.assertIn(filename, linecache.cache)
475+
# A file inside an archive has no mtime of its own, so checkcache()
476+
# keeps the entry, as it does for entries loaded through a loader.
477+
self.assertIsNone(linecache.cache[filename][1])
478+
linecache.checkcache(filename)
479+
linecache.checkcache()
480+
self.assertIn(filename, linecache.cache)
481+
self.assertEqual(linecache.getlines(filename), lines)
482+
linecache.clearcache()
483+
self.assertNotIn(filename, linecache.cache)
484+
self.assertEqual(linecache.getlines(filename), lines)
485+
486+
379487
class MultiThreadingTest(unittest.TestCase):
380488
@threading_helper.reap_threads
381489
@threading_helper.requires_working_threading()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:mod:`linecache` can now read the source of a module that was imported from
2+
a zip archive on :data:`sys.path` when given only the file name, as
3+
:mod:`pdb`, :mod:`warnings` and :mod:`doctest` do.

0 commit comments

Comments
 (0)