Skip to content

Commit 6bc2c1e

Browse files
committed
Issue #17911: traceback module overhaul
Provide a way to seed the linecache for a PEP-302 module without actually loading the code. Provide a new object API for traceback, including the ability to not lookup lines at all until the traceback is actually rendered, without any trace of the original objects being kept alive.
1 parent 0bfd0a4 commit 6bc2c1e

File tree

7 files changed

+794
-157
lines changed

7 files changed

+794
-157
lines changed

‎Doc/library/linecache.rst‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ The :mod:`linecache` module defines the following functions:
4343
changed on disk, and you require the updated version. If *filename* is omitted,
4444
it will check all the entries in the cache.
4545

46+
.. function:: lazycache(filename, module_globals)
47+
48+
Capture enough detail about a non-file based module to permit getting its
49+
lines later via :func:`getline` even if *module_globals* is None in the later
50+
call. This avoids doing I/O until a line is actually needed, without having
51+
to carry the module globals around indefinitely.
52+
53+
.. versionadded:: 3.5
4654

4755
Example::
4856

‎Doc/library/traceback.rst‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,130 @@ The module defines the following functions:
136136

137137
.. versionadded:: 3.4
138138

139+
.. function:: walk_stack(f)
140+
141+
Walk a stack following f.f_back from the given frame, yielding the frame and
142+
line number for each frame. If f is None, the current stack is used.
143+
This helper is used with *Stack.extract*.
144+
145+
.. versionadded:: 3.5
146+
147+
.. function:: walk_tb(tb)
148+
149+
Walk a traceback following tb_next yielding the frame and line number for
150+
each frame. This helper is used with *Stack.extract*.
151+
152+
.. versionadded:: 3.5
153+
154+
The module also defines the following classes:
155+
156+
:class:`TracebackException` Objects
157+
-----------------------------------
158+
159+
:class:`.TracebackException` objects are created from actual exceptions to
160+
capture data for later printing in a lightweight fashion.
161+
162+
.. class:: TracebackException(exc_type, exc_value, exc_traceback, limit=None, lookup_lines=True)
163+
164+
Capture an exception for later rendering. limit, lookup_lines are as for
165+
the :class:`.StackSummary` class.
166+
167+
.. versionadded:: 3.5
168+
169+
.. classmethod:: `.from_exception`(exc, limit=None, lookup_lines=True)
170+
171+
Capture an exception for later rendering. limit and lookup_lines
172+
are as for the :class:`.StackSummary` class.
173+
174+
.. versionadded:: 3.5
175+
176+
.. attribute:: `.__cause__` A TracebackException of the original *__cause__*.
177+
178+
.. attribute:: `.__context__` A TracebackException of the original *__context__*.
179+
.. attribute:: `.__suppress_context__` The *__suppress_context__* value from the
180+
original exception.
181+
.. attribute:: `.stack` A `StackSummary` representing the traceback.
182+
.. attribute:: `.exc_type` The class of the original traceback.
183+
.. attribute:: `.filename` For syntax errors - the filename where the error
184+
occured.
185+
.. attribute:: `.lineno` For syntax errors - the linenumber where the error
186+
occured.
187+
.. attribute:: `.text` For syntax errors - the text where the error
188+
occured.
189+
.. attribute:: `.offset` For syntax errors - the offset into the text where the
190+
error occured.
191+
.. attribute:: `.msg` For syntax errors - the compiler error message.
192+
193+
.. method:: TracebackException.format(chain=True)
194+
195+
Format the exception.
196+
197+
If chain is not *True*, *__cause__* and *__context__* will not be formatted.
198+
199+
The return value is a generator of strings, each ending in a newline and
200+
some containing internal newlines. `print_exception` is a wrapper around
201+
this method which just prints the lines to a file.
202+
203+
The message indicating which exception occurred is always the last
204+
string in the output.
205+
206+
.. versionadded:: 3.5
207+
208+
.. method:: TracebackException.format_exception_only()
209+
210+
Format the exception part of the traceback.
211+
212+
The return value is a generator of strings, each ending in a newline.
213+
214+
Normally, the generator emits a single string; however, for
215+
SyntaxError exceptions, it emites several lines that (when
216+
printed) display detailed information about where the syntax
217+
error occurred.
218+
219+
The message indicating which exception occurred is always the last
220+
string in the output.
221+
222+
.. versionadded:: 3.5
223+
224+
225+
:class:`StackSummary` Objects
226+
-----------------------------
227+
228+
:class:`.StackSummary` objects represent a call stack ready for formatting.
229+
230+
.. classmethod:: StackSummary.extract(frame_gen, limit=None, lookup_lines=True)
231+
232+
Construct a StackSummary object from a frame generator (such as is returned by
233+
`walk_stack` or `walk_tb`.
234+
235+
If limit is supplied, only this many frames are taken from frame_gen.
236+
If lookup_lines is False, the returned FrameSummary objects will not have read
237+
their lines in yet, making the cost of creating the StackSummary cheaper (which
238+
may be valuable if it may not actually get formatted).
239+
240+
.. versionadded:: 3.5
241+
242+
.. classmethod:: StackSummary.from_list(a_list)
243+
244+
Construct a StackSummary object from a supplied old-style list of tuples. Each
245+
tuple should be a 4-tuple with filename, lineno, name, line as the elements.
246+
247+
.. versionadded:: 3.5
248+
249+
:class:`FrameSummary` Objects
250+
-----------------------------
251+
252+
FrameSummary objects represent a single frame in a traceback.
253+
254+
.. class:: FrameSummary(filename, lineno, name, lookup_line=True, locals=None, line=None)
255+
:noindex:
256+
257+
Represent a single frame in the traceback or stack that is being formatted
258+
or printed. It may optionally have a stringified version of the frames
259+
locals included in it. If *lookup_line* is False, the source code is not
260+
looked up until the FrameSummary has the :attr:`line` attribute accessed (which
261+
also happens when casting it to a tuple). Line may be directly provided, and
262+
will prevent line lookups happening at all.
139263

140264
.. _traceback-example:
141265

‎Lib/linecache.py‎

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
that name.
66
"""
77

8+
import functools
89
import sys
910
import os
1011
import tokenize
@@ -21,7 +22,9 @@ def getline(filename, lineno, module_globals=None):
2122

2223
# The cache
2324

24-
cache = {} # The cache
25+
# The cache. Maps filenames to either a thunk which will provide source code,
26+
# or a tuple (size, mtime, lines, fullname) once loaded.
27+
cache = {}
2528

2629

2730
def clearcache():
@@ -36,6 +39,9 @@ def getlines(filename, module_globals=None):
3639
Update the cache if it doesn't contain an entry for this file already."""
3740

3841
if filename in cache:
42+
entry = cache[filename]
43+
if len(entry) == 1:
44+
return updatecache(filename, module_globals)
3945
return cache[filename][2]
4046
else:
4147
return updatecache(filename, module_globals)
@@ -54,7 +60,11 @@ def checkcache(filename=None):
5460
return
5561

5662
for filename in filenames:
57-
size, mtime, lines, fullname = cache[filename]
63+
entry = cache[filename]
64+
if len(entry) == 1:
65+
# lazy cache entry, leave it lazy.
66+
continue
67+
size, mtime, lines, fullname = entry
5868
if mtime is None:
5969
continue # no-op for files loaded via a __loader__
6070
try:
@@ -72,7 +82,8 @@ def updatecache(filename, module_globals=None):
7282
and return an empty list."""
7383

7484
if filename in cache:
75-
del cache[filename]
85+
if len(cache[filename]) != 1:
86+
del cache[filename]
7687
if not filename or (filename.startswith('<') and filename.endswith('>')):
7788
return []
7889

@@ -82,27 +93,23 @@ def updatecache(filename, module_globals=None):
8293
except OSError:
8394
basename = filename
8495

85-
# Try for a __loader__, if available
86-
if module_globals and '__loader__' in module_globals:
87-
name = module_globals.get('__name__')
88-
loader = module_globals['__loader__']
89-
get_source = getattr(loader, 'get_source', None)
90-
91-
if name and get_source:
92-
try:
93-
data = get_source(name)
94-
except (ImportError, OSError):
95-
pass
96-
else:
97-
if data is None:
98-
# No luck, the PEP302 loader cannot find the source
99-
# for this module.
100-
return []
101-
cache[filename] = (
102-
len(data), None,
103-
[line+'\n' for line in data.splitlines()], fullname
104-
)
105-
return cache[filename][2]
96+
# Realise a lazy loader based lookup if there is one
97+
# otherwise try to lookup right now.
98+
if lazycache(filename, module_globals):
99+
try:
100+
data = cache[filename][0]()
101+
except (ImportError, OSError):
102+
pass
103+
else:
104+
if data is None:
105+
# No luck, the PEP302 loader cannot find the source
106+
# for this module.
107+
return []
108+
cache[filename] = (
109+
len(data), None,
110+
[line+'\n' for line in data.splitlines()], fullname
111+
)
112+
return cache[filename][2]
106113

107114
# Try looking through the module search path, which is only useful
108115
# when handling a relative filename.
@@ -132,3 +139,36 @@ def updatecache(filename, module_globals=None):
132139
size, mtime = stat.st_size, stat.st_mtime
133140
cache[filename] = size, mtime, lines, fullname
134141
return lines
142+
143+
144+
def lazycache(filename, module_globals):
145+
"""Seed the cache for filename with module_globals.
146+
147+
The module loader will be asked for the source only when getlines is
148+
called, not immediately.
149+
150+
If there is an entry in the cache already, it is not altered.
151+
152+
:return: True if a lazy load is registered in the cache,
153+
otherwise False. To register such a load a module loader with a
154+
get_source method must be found, the filename must be a cachable
155+
filename, and the filename must not be already cached.
156+
"""
157+
if filename in cache:
158+
if len(cache[filename]) == 1:
159+
return True
160+
else:
161+
return False
162+
if not filename or (filename.startswith('<') and filename.endswith('>')):
163+
return False
164+
# Try for a __loader__, if available
165+
if module_globals and '__loader__' in module_globals:
166+
name = module_globals.get('__name__')
167+
loader = module_globals['__loader__']
168+
get_source = getattr(loader, 'get_source', None)
169+
170+
if name and get_source:
171+
get_lines = functools.partial(get_source, name)
172+
cache[filename] = (get_lines,)
173+
return True
174+
return False

‎Lib/test/test_linecache.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88

99
FILENAME = linecache.__file__
10+
NONEXISTENT_FILENAME = FILENAME + '.missing'
1011
INVALID_NAME = '!@$)(!@#_1'
1112
EMPTY = ''
1213
TESTS = 'inspect_fodder inspect_fodder2 mapping_tests'
@@ -126,6 +127,49 @@ def test_checkcache(self):
126127
self.assertEqual(line, getline(source_name, index + 1))
127128
source_list.append(line)
128129

130+
def test_lazycache_no_globals(self):
131+
lines = linecache.getlines(FILENAME)
132+
linecache.clearcache()
133+
self.assertEqual(False, linecache.lazycache(FILENAME, None))
134+
self.assertEqual(lines, linecache.getlines(FILENAME))
135+
136+
def test_lazycache_smoke(self):
137+
lines = linecache.getlines(NONEXISTENT_FILENAME, globals())
138+
linecache.clearcache()
139+
self.assertEqual(
140+
True, linecache.lazycache(NONEXISTENT_FILENAME, globals()))
141+
self.assertEqual(1, len(linecache.cache[NONEXISTENT_FILENAME]))
142+
# Note here that we're looking up a non existant filename with no
143+
# globals: this would error if the lazy value wasn't resolved.
144+
self.assertEqual(lines, linecache.getlines(NONEXISTENT_FILENAME))
145+
146+
def test_lazycache_provide_after_failed_lookup(self):
147+
linecache.clearcache()
148+
lines = linecache.getlines(NONEXISTENT_FILENAME, globals())
149+
linecache.clearcache()
150+
linecache.getlines(NONEXISTENT_FILENAME)
151+
linecache.lazycache(NONEXISTENT_FILENAME, globals())
152+
self.assertEqual(lines, linecache.updatecache(NONEXISTENT_FILENAME))
153+
154+
def test_lazycache_check(self):
155+
linecache.clearcache()
156+
linecache.lazycache(NONEXISTENT_FILENAME, globals())
157+
linecache.checkcache()
158+
159+
def test_lazycache_bad_filename(self):
160+
linecache.clearcache()
161+
self.assertEqual(False, linecache.lazycache('', globals()))
162+
self.assertEqual(False, linecache.lazycache('<foo>', globals()))
163+
164+
def test_lazycache_already_cached(self):
165+
linecache.clearcache()
166+
lines = linecache.getlines(NONEXISTENT_FILENAME, globals())
167+
self.assertEqual(
168+
False,
169+
linecache.lazycache(NONEXISTENT_FILENAME, globals()))
170+
self.assertEqual(4, len(linecache.cache[NONEXISTENT_FILENAME]))
171+
172+
129173
def test_main():
130174
support.run_unittest(LineCacheTests)
131175

0 commit comments

Comments
 (0)