Skip to content

Commit

Permalink
pythongh-123339: Fix cases of inconsistency of __module__ and __first…
Browse files Browse the repository at this point in the history
…lineno__ in classes (pythonGH-123613)

* Setting the __module__ attribute for a class now removes the
  __firstlineno__ item from the type's dict.
* The _collections_abc and _pydecimal modules now completely replace the
  collections.abc and decimal modules after importing them. This
  allows to get the source of classes and functions defined in these
  modules.
* inspect.findsource() now checks whether the first line number for a
  class is out of bound.
  • Loading branch information
serhiy-storchaka authored Sep 28, 2024
1 parent dc12237 commit 69a4063
Show file tree
Hide file tree
Showing 11 changed files with 110 additions and 12 deletions.
5 changes: 4 additions & 1 deletion Doc/reference/datamodel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1080,7 +1080,10 @@ Special attributes
.. versionadded:: 3.13

* - .. attribute:: type.__firstlineno__
- The line number of the first line of the class definition, including decorators.
- The line number of the first line of the class definition,
including decorators.
Setting the :attr:`__module__` attribute removes the
:attr:`!__firstlineno__` item from the type's dictionary.

.. versionadded:: 3.13

Expand Down
6 changes: 3 additions & 3 deletions Lib/collections/abc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from _collections_abc import *
from _collections_abc import __all__ # noqa: F401
from _collections_abc import _CallableGenericAlias # noqa: F401
import _collections_abc
import sys
sys.modules[__name__] = _collections_abc
7 changes: 4 additions & 3 deletions Lib/decimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
from _decimal import __version__ # noqa: F401
from _decimal import __libmpdec_version__ # noqa: F401
except ImportError:
from _pydecimal import *
from _pydecimal import __version__ # noqa: F401
from _pydecimal import __libmpdec_version__ # noqa: F401
import _pydecimal
import sys
_pydecimal.__doc__ = __doc__
sys.modules[__name__] = _pydecimal
6 changes: 4 additions & 2 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,10 +970,12 @@ def findsource(object):

if isclass(object):
try:
firstlineno = vars(object)['__firstlineno__']
lnum = vars(object)['__firstlineno__'] - 1
except (TypeError, KeyError):
raise OSError('source code not available')
return lines, firstlineno - 1
if lnum >= len(lines):
raise OSError('lineno is out of bounds')
return lines, lnum

if ismethod(object):
object = object.__func__
Expand Down
12 changes: 12 additions & 0 deletions Lib/test/test_builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2607,6 +2607,7 @@ def test_new_type(self):
self.assertEqual(A.__module__, __name__)
self.assertEqual(A.__bases__, (object,))
self.assertIs(A.__base__, object)
self.assertNotIn('__firstlineno__', A.__dict__)
x = A()
self.assertIs(type(x), A)
self.assertIs(x.__class__, A)
Expand Down Expand Up @@ -2685,6 +2686,17 @@ def test_type_qualname(self):
A.__qualname__ = b'B'
self.assertEqual(A.__qualname__, 'D.E')

def test_type_firstlineno(self):
A = type('A', (), {'__firstlineno__': 42})
self.assertEqual(A.__name__, 'A')
self.assertEqual(A.__module__, __name__)
self.assertEqual(A.__dict__['__firstlineno__'], 42)
A.__module__ = 'testmodule'
self.assertEqual(A.__module__, 'testmodule')
self.assertNotIn('__firstlineno__', A.__dict__)
A.__firstlineno__ = 43
self.assertEqual(A.__dict__['__firstlineno__'], 43)

def test_type_typeparams(self):
class A[T]:
pass
Expand Down
3 changes: 2 additions & 1 deletion Lib/test/test_decimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -4381,7 +4381,8 @@ def test_module_attributes(self):

self.assertEqual(C.__version__, P.__version__)

self.assertEqual(dir(C), dir(P))
self.assertLessEqual(set(dir(C)), set(dir(P)))
self.assertEqual([n for n in dir(C) if n[:2] != '__'], sorted(P.__all__))

def test_context_attributes(self):

Expand Down
12 changes: 12 additions & 0 deletions Lib/test/test_inspect/inspect_fodder2.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,15 @@ class td354(typing.TypedDict):

# line 358
td359 = typing.TypedDict('td359', (('x', int), ('y', int)))

import dataclasses

# line 363
@dataclasses.dataclass
class dc364:
x: int
y: int

# line 369
dc370 = dataclasses.make_dataclass('dc370', (('x', int), ('y', int)))
dc371 = dataclasses.make_dataclass('dc370', (('x', int), ('y', int)), module=__name__)
61 changes: 59 additions & 2 deletions Lib/test/test_inspect/test_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,47 @@ class C:
nonlocal __firstlineno__
self.assertRaises(OSError, inspect.getsource, C)

class TestGetsourceStdlib(unittest.TestCase):
# Test Python implementations of the stdlib modules

def test_getsource_stdlib_collections_abc(self):
import collections.abc
lines, lineno = inspect.getsourcelines(collections.abc.Sequence)
self.assertEqual(lines[0], 'class Sequence(Reversible, Collection):\n')
src = inspect.getsource(collections.abc.Sequence)
self.assertEqual(src.splitlines(True), lines)

def test_getsource_stdlib_tomllib(self):
import tomllib
self.assertRaises(OSError, inspect.getsource, tomllib.TOMLDecodeError)
self.assertRaises(OSError, inspect.getsourcelines, tomllib.TOMLDecodeError)

def test_getsource_stdlib_abc(self):
# Pure Python implementation
abc = import_helper.import_fresh_module('abc', blocked=['_abc'])
with support.swap_item(sys.modules, 'abc', abc):
self.assertRaises(OSError, inspect.getsource, abc.ABCMeta)
self.assertRaises(OSError, inspect.getsourcelines, abc.ABCMeta)
# With C acceleration
import abc
try:
src = inspect.getsource(abc.ABCMeta)
lines, lineno = inspect.getsourcelines(abc.ABCMeta)
except OSError:
pass
else:
self.assertEqual(lines[0], ' class ABCMeta(type):\n')
self.assertEqual(src.splitlines(True), lines)

def test_getsource_stdlib_decimal(self):
# Pure Python implementation
decimal = import_helper.import_fresh_module('decimal', blocked=['_decimal'])
with support.swap_item(sys.modules, 'decimal', decimal):
src = inspect.getsource(decimal.Decimal)
lines, lineno = inspect.getsourcelines(decimal.Decimal)
self.assertEqual(lines[0], 'class Decimal(object):\n')
self.assertEqual(src.splitlines(True), lines)

class TestGetsourceInteractive(unittest.TestCase):
def test_getclasses_interactive(self):
# bpo-44648: simulate a REPL session;
Expand Down Expand Up @@ -947,6 +988,11 @@ def test_typeddict(self):
self.assertSourceEqual(mod2.td354, 354, 356)
self.assertRaises(OSError, inspect.getsource, mod2.td359)

def test_dataclass(self):
self.assertSourceEqual(mod2.dc364, 364, 367)
self.assertRaises(OSError, inspect.getsource, mod2.dc370)
self.assertRaises(OSError, inspect.getsource, mod2.dc371)

class TestBlockComments(GetSourceBase):
fodderModule = mod

Expand Down Expand Up @@ -1010,17 +1056,28 @@ def test_findsource_without_filename(self):
self.assertRaises(IOError, inspect.findsource, co)
self.assertRaises(IOError, inspect.getsource, co)

def test_findsource_with_out_of_bounds_lineno(self):
def test_findsource_on_func_with_out_of_bounds_lineno(self):
mod_len = len(inspect.getsource(mod))
src = '\n' * 2* mod_len + "def f(): pass"
co = compile(src, mod.__file__, "exec")
g, l = {}, {}
eval(co, g, l)
func = l['f']
self.assertEqual(func.__code__.co_firstlineno, 1+2*mod_len)
with self.assertRaisesRegex(IOError, "lineno is out of bounds"):
with self.assertRaisesRegex(OSError, "lineno is out of bounds"):
inspect.findsource(func)

def test_findsource_on_class_with_out_of_bounds_lineno(self):
mod_len = len(inspect.getsource(mod))
src = '\n' * 2* mod_len + "class A: pass"
co = compile(src, mod.__file__, "exec")
g, l = {'__name__': mod.__name__}, {}
eval(co, g, l)
cls = l['A']
self.assertEqual(cls.__firstlineno__, 1+2*mod_len)
with self.assertRaisesRegex(OSError, "lineno is out of bounds"):
inspect.findsource(cls)

def test_getsource_on_method(self):
self.assertSourceEqual(mod2.ClassWithMethod.method, 118, 119)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Setting the :attr:`!__module__` attribute for a class now removes the
``__firstlineno__`` item from the type's dict, so they will no longer be
inconsistent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fix :func:`inspect.getsource` for classes in :mod:`collections.abc` and
:mod:`decimal` (for pure Python implementation) modules.
:func:`inspect.getcomments` now raises OSError instead of IndexError if the
``__firstlineno__`` value for a class is out of bound.
3 changes: 3 additions & 0 deletions Objects/typeobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,9 @@ type_set_module(PyTypeObject *type, PyObject *value, void *context)
PyType_Modified(type);

PyObject *dict = lookup_tp_dict(type);
if (PyDict_Pop(dict, &_Py_ID(__firstlineno__), NULL) < 0) {
return -1;
}
return PyDict_SetItem(dict, &_Py_ID(__module__), value);
}

Expand Down

0 comments on commit 69a4063

Please sign in to comment.