diff --git a/Lib/inspect.py b/Lib/inspect.py index 3683f8c3fd53308..c812a5522814306 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -1164,6 +1164,31 @@ def getblock(lines): pass return lines[:blockfinder.last] +def _find_string_start(lines, lnum): + """Return the index of the line where lines[lnum] begins, or the index + of the line where a multiline string that is still open at the start of + lines[lnum] begins. + """ + # A line that starts inside a string must contain the closing quote. + if '"' not in lines[lnum] and "'" not in lines[lnum]: + return lnum + starts = [] + try: + for tok in tokenize.generate_tokens(iter(lines[:lnum + 1]).__next__): + if tok.start[0] > lnum: + break + if tok.type in (tokenize.FSTRING_START, tokenize.TSTRING_START): + starts.append(tok.start[0] - 1) + elif tok.type in (tokenize.FSTRING_END, tokenize.TSTRING_END): + starts.pop() + elif (tok.end[0] > lnum and + tok.type in (tokenize.STRING, tokenize.FSTRING_MIDDLE, + tokenize.TSTRING_MIDDLE)): + return starts[0] if starts else tok.start[0] - 1 + except (tokenize.TokenError, SyntaxError): + pass + return lnum + def getsourcelines(object): """Return a list of source lines and starting line number for an object. @@ -1183,6 +1208,17 @@ def getsourcelines(object): (isframe(object) and object.f_code.co_name == "")): return lines, 0 else: + # A lambda or generator expression may start in the middle of a + # multiline string, which cannot be tokenized without the line + # where the string begins. + if isframe(object): + object = object.f_code + elif ismethod(object): + object = object.__func__ + if isfunction(object): + object = object.__code__ + if iscode(object) and object.co_name.startswith('<'): + lnum = _find_string_start(lines, lnum) return getblock(lines[lnum:]), lnum + 1 def getsource(object): diff --git a/Lib/test/test_inspect/inspect_fodder2.py b/Lib/test/test_inspect/inspect_fodder2.py index 157e12167b5d270..1fcd8ed03d589fe 100644 --- a/Lib/test/test_inspect/inspect_fodder2.py +++ b/Lib/test/test_inspect/inspect_fodder2.py @@ -401,3 +401,29 @@ def func400(): return 401 pass # end of file + +# line 405 +string_then_lambda = ( + """text +""", lambda: None +) + +def after_string_then_lambda(): + """A docstring after the lambda.""" + +# line 414 +string_then_multiline_lambda = ( + '''text +''', lambda: [1, + 2] +) + +string_then_fstring_lambda = ( + f"""text +{1}""", lambda: None +) + +string_then_genexpr = ( + """text +""", (i for i in range(3)) +) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 38c7a9bde823d9d..3c52d03301e4b12 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -1071,6 +1071,16 @@ def test_nested_lambda(self): # Test inspect.getsource with a nested lambda function. self.assertSourceEqual(mod2.nested_lambda, 291, 292) + def test_lambda_after_multiline_string(self): + # gh-158556: the lambda starts on the line where a multiline string + # ends, and a later docstring must not change the result. + self.assertSourceEqual(mod2.string_then_lambda[1], 407, 408) + self.assertSourceEqual(mod2.string_then_multiline_lambda[1], 416, 418) + self.assertSourceEqual(mod2.string_then_fstring_lambda[1], 422, 423) + + def test_genexpr_after_multiline_string(self): + self.assertSourceEqual(mod2.string_then_genexpr[1].gi_code, 427, 428) + def test_onelinefunc(self): # Test inspect.getsource with a regular one-line function. self.assertSourceEqual(mod2.onelinefunc, 37, 37) @@ -1120,6 +1130,21 @@ def test_dataclass(self): self.assertRaises(OSError, inspect.getsource, mod2.dc370) self.assertRaises(OSError, inspect.getsource, mod2.dc371) + def test_lambda_after_multiline_string_end(self): + # gh-158556: a lambda on the same line as the closing delimiter of a + # multiline string must not make getblock() fail while tokenizing. + src = textwrap.dedent('''\ + pair = ( + """text + """, lambda: None + ) + ''') + with ready_to_import('lambda_after_string', src) as (name, path): + module = import_helper.import_module(name) + lines, lnum = inspect.getsourcelines(module.pair[1]) + self.assertEqual(lines, [' """text\n', '""", lambda: None\n']) + self.assertEqual(lnum, 2) + class TestBlockComments(GetSourceBase): fodderModule = mod diff --git a/Misc/NEWS.d/next/Library/2026-10-01-12-00-00.gh-issue-158556.Lm3kQz.rst b/Misc/NEWS.d/next/Library/2026-10-01-12-00-00.gh-issue-158556.Lm3kQz.rst new file mode 100644 index 000000000000000..9d979768eca4ec1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-01-12-00-00.gh-issue-158556.Lm3kQz.rst @@ -0,0 +1,3 @@ +Fix :func:`inspect.getsource` and :func:`inspect.getsourcelines` for a lambda +or generator expression that starts on the line where a multiline string ends. +The returned source now begins on the line where that string starts.