Skip to content

Commit 1d6e7d5

Browse files
authored
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.
1 parent e2311cf commit 1d6e7d5

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
@@ -356,6 +361,17 @@ def test_linecache_python_string(self):
356361
self.assertEqual(stdout, b'')
357362
self.assertEqual(stderr, b'')
358363

364+
def test_path_importer_cache_None(self):
365+
# sys.path_importer_cache is set to None while the interpreter is
366+
# shutting down, before objects with a __del__ that may end up here
367+
# are released.
368+
filename = os.path.abspath(os_helper.TESTFN + '.py')
369+
with support.swap_attr(sys, 'path_importer_cache', None):
370+
self.assertEqual(linecache.getlines(filename), [])
371+
self.assertEqual(linecache.getline(filename, 1), '')
372+
self.assertNotIn(filename, linecache.cache)
373+
374+
359375
class LineCacheInvalidationTests(unittest.TestCase):
360376
def setUp(self):
361377
super().setUp()
@@ -398,6 +414,98 @@ def test_checkcache_with_no_parameter(self):
398414
self.assertIn(self.unchanged_file, linecache.cache)
399415

400416

417+
class ZipArchiveTests(unittest.TestCase):
418+
"""Sources of modules imported from a zip archive on sys.path."""
419+
420+
MODULE_SOURCE = (
421+
'"""A module inside a zip archive."""\n'
422+
'\n'
423+
'def f():\n'
424+
' return "from the zip"\n'
425+
)
426+
PACKAGE_SOURCE = 'value = 42\n'
427+
LATIN1_SOURCE = (
428+
'# -*- coding: latin-1 -*-\n'
429+
'value = "caf\xe9"\n'
430+
)
431+
432+
def setUp(self):
433+
linecache.clearcache()
434+
self.addCleanup(linecache.clearcache)
435+
tmpdir = self.enterContext(os_helper.temp_dir())
436+
self.zip_name = os.path.join(tmpdir, 'sources.zip')
437+
with zipfile.ZipFile(self.zip_name, 'w') as zf:
438+
zf.writestr('zipmod.py', self.MODULE_SOURCE)
439+
zf.writestr('zippkg/__init__.py', self.PACKAGE_SOURCE)
440+
zf.writestr('ziplatin1.py', self.LATIN1_SOURCE.encode('latin-1'))
441+
self.enterContext(import_helper.DirsOnSysPath(self.zip_name))
442+
for name in 'zipmod', 'zippkg', 'ziplatin1':
443+
self.addCleanup(import_helper.unload, name)
444+
self.addCleanup(sys.path_importer_cache.pop, self.zip_name, None)
445+
self.addCleanup(zipimport._zip_directory_cache.pop,
446+
self.zip_name, None)
447+
self.zipmod = importlib.import_module('zipmod')
448+
449+
def test_getlines_without_module_globals(self):
450+
filename = self.zipmod.__file__
451+
self.assertEqual(filename, os.path.join(self.zip_name, 'zipmod.py'))
452+
self.assertFalse(os.path.exists(filename))
453+
lines = self.MODULE_SOURCE.splitlines(keepends=True)
454+
self.assertEqual(linecache.getlines(filename), lines)
455+
self.assertEqual(linecache.getline(filename, 4),
456+
' return "from the zip"\n')
457+
self.assertEqual(linecache.getline(filename, 5), '')
458+
code = self.zipmod.f.__code__
459+
self.assertEqual(code.co_filename, filename)
460+
self.assertEqual(linecache.getline(filename, code.co_firstlineno),
461+
'def f():\n')
462+
463+
def test_relative_archive_path(self):
464+
# A relative sys.path entry gives its modules a relative __file__.
465+
tmpdir, zip_base = os.path.split(self.zip_name)
466+
self.addCleanup(sys.path_importer_cache.pop, zip_base, None)
467+
self.addCleanup(zipimport._zip_directory_cache.pop, zip_base, None)
468+
sys.path.insert(0, zip_base)
469+
self.addCleanup(sys.path.remove, zip_base)
470+
with os_helper.change_cwd(tmpdir):
471+
zippkg = importlib.import_module('zippkg')
472+
self.assertEqual(zippkg.__file__,
473+
os.path.join(zip_base, 'zippkg', '__init__.py'))
474+
self.assertEqual(linecache.getlines(zippkg.__file__),
475+
['value = 42\n'])
476+
477+
def test_package(self):
478+
zippkg = importlib.import_module('zippkg')
479+
self.assertEqual(linecache.getlines(zippkg.__file__),
480+
['value = 42\n'])
481+
482+
def test_encoding_declaration(self):
483+
ziplatin1 = importlib.import_module('ziplatin1')
484+
self.assertEqual(linecache.getlines(ziplatin1.__file__),
485+
self.LATIN1_SOURCE.splitlines(keepends=True))
486+
487+
def test_missing_file(self):
488+
filename = os.path.join(self.zip_name, 'missing.py')
489+
self.assertEqual(linecache.getlines(filename), [])
490+
self.assertEqual(linecache.getline(filename, 1), '')
491+
self.assertNotIn(filename, linecache.cache)
492+
493+
def test_checkcache_and_clearcache(self):
494+
filename = self.zipmod.__file__
495+
lines = linecache.getlines(filename)
496+
self.assertIn(filename, linecache.cache)
497+
# A file inside an archive has no mtime of its own, so checkcache()
498+
# keeps the entry, as it does for entries loaded through a loader.
499+
self.assertIsNone(linecache.cache[filename][1])
500+
linecache.checkcache(filename)
501+
linecache.checkcache()
502+
self.assertIn(filename, linecache.cache)
503+
self.assertEqual(linecache.getlines(filename), lines)
504+
linecache.clearcache()
505+
self.assertNotIn(filename, linecache.cache)
506+
self.assertEqual(linecache.getlines(filename), lines)
507+
508+
401509
class MultiThreadingTest(unittest.TestCase):
402510
@threading_helper.reap_threads
403511
@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)