Skip to content

Commit 69a4063

Browse files
gh-123339: Fix cases of inconsistency of __module__ and __firstlineno__ in classes (GH-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.
1 parent dc12237 commit 69a4063

11 files changed

+110
-12
lines changed

Doc/reference/datamodel.rst

+4-1
Original file line numberDiff line numberDiff line change
@@ -1080,7 +1080,10 @@ Special attributes
10801080
.. versionadded:: 3.13
10811081

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

10851088
.. versionadded:: 3.13
10861089

Lib/collections/abc.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from _collections_abc import *
2-
from _collections_abc import __all__ # noqa: F401
3-
from _collections_abc import _CallableGenericAlias # noqa: F401
1+
import _collections_abc
2+
import sys
3+
sys.modules[__name__] = _collections_abc

Lib/decimal.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
from _decimal import __version__ # noqa: F401
104104
from _decimal import __libmpdec_version__ # noqa: F401
105105
except ImportError:
106-
from _pydecimal import *
107-
from _pydecimal import __version__ # noqa: F401
108-
from _pydecimal import __libmpdec_version__ # noqa: F401
106+
import _pydecimal
107+
import sys
108+
_pydecimal.__doc__ = __doc__
109+
sys.modules[__name__] = _pydecimal

Lib/inspect.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -970,10 +970,12 @@ def findsource(object):
970970

971971
if isclass(object):
972972
try:
973-
firstlineno = vars(object)['__firstlineno__']
973+
lnum = vars(object)['__firstlineno__'] - 1
974974
except (TypeError, KeyError):
975975
raise OSError('source code not available')
976-
return lines, firstlineno - 1
976+
if lnum >= len(lines):
977+
raise OSError('lineno is out of bounds')
978+
return lines, lnum
977979

978980
if ismethod(object):
979981
object = object.__func__

Lib/test/test_builtin.py

+12
Original file line numberDiff line numberDiff line change
@@ -2607,6 +2607,7 @@ def test_new_type(self):
26072607
self.assertEqual(A.__module__, __name__)
26082608
self.assertEqual(A.__bases__, (object,))
26092609
self.assertIs(A.__base__, object)
2610+
self.assertNotIn('__firstlineno__', A.__dict__)
26102611
x = A()
26112612
self.assertIs(type(x), A)
26122613
self.assertIs(x.__class__, A)
@@ -2685,6 +2686,17 @@ def test_type_qualname(self):
26852686
A.__qualname__ = b'B'
26862687
self.assertEqual(A.__qualname__, 'D.E')
26872688

2689+
def test_type_firstlineno(self):
2690+
A = type('A', (), {'__firstlineno__': 42})
2691+
self.assertEqual(A.__name__, 'A')
2692+
self.assertEqual(A.__module__, __name__)
2693+
self.assertEqual(A.__dict__['__firstlineno__'], 42)
2694+
A.__module__ = 'testmodule'
2695+
self.assertEqual(A.__module__, 'testmodule')
2696+
self.assertNotIn('__firstlineno__', A.__dict__)
2697+
A.__firstlineno__ = 43
2698+
self.assertEqual(A.__dict__['__firstlineno__'], 43)
2699+
26882700
def test_type_typeparams(self):
26892701
class A[T]:
26902702
pass

Lib/test/test_decimal.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4381,7 +4381,8 @@ def test_module_attributes(self):
43814381

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

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

43864387
def test_context_attributes(self):
43874388

Lib/test/test_inspect/inspect_fodder2.py

+12
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,15 @@ class td354(typing.TypedDict):
357357

358358
# line 358
359359
td359 = typing.TypedDict('td359', (('x', int), ('y', int)))
360+
361+
import dataclasses
362+
363+
# line 363
364+
@dataclasses.dataclass
365+
class dc364:
366+
x: int
367+
y: int
368+
369+
# line 369
370+
dc370 = dataclasses.make_dataclass('dc370', (('x', int), ('y', int)))
371+
dc371 = dataclasses.make_dataclass('dc370', (('x', int), ('y', int)), module=__name__)

Lib/test/test_inspect/test_inspect.py

+59-2
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,47 @@ class C:
835835
nonlocal __firstlineno__
836836
self.assertRaises(OSError, inspect.getsource, C)
837837

838+
class TestGetsourceStdlib(unittest.TestCase):
839+
# Test Python implementations of the stdlib modules
840+
841+
def test_getsource_stdlib_collections_abc(self):
842+
import collections.abc
843+
lines, lineno = inspect.getsourcelines(collections.abc.Sequence)
844+
self.assertEqual(lines[0], 'class Sequence(Reversible, Collection):\n')
845+
src = inspect.getsource(collections.abc.Sequence)
846+
self.assertEqual(src.splitlines(True), lines)
847+
848+
def test_getsource_stdlib_tomllib(self):
849+
import tomllib
850+
self.assertRaises(OSError, inspect.getsource, tomllib.TOMLDecodeError)
851+
self.assertRaises(OSError, inspect.getsourcelines, tomllib.TOMLDecodeError)
852+
853+
def test_getsource_stdlib_abc(self):
854+
# Pure Python implementation
855+
abc = import_helper.import_fresh_module('abc', blocked=['_abc'])
856+
with support.swap_item(sys.modules, 'abc', abc):
857+
self.assertRaises(OSError, inspect.getsource, abc.ABCMeta)
858+
self.assertRaises(OSError, inspect.getsourcelines, abc.ABCMeta)
859+
# With C acceleration
860+
import abc
861+
try:
862+
src = inspect.getsource(abc.ABCMeta)
863+
lines, lineno = inspect.getsourcelines(abc.ABCMeta)
864+
except OSError:
865+
pass
866+
else:
867+
self.assertEqual(lines[0], ' class ABCMeta(type):\n')
868+
self.assertEqual(src.splitlines(True), lines)
869+
870+
def test_getsource_stdlib_decimal(self):
871+
# Pure Python implementation
872+
decimal = import_helper.import_fresh_module('decimal', blocked=['_decimal'])
873+
with support.swap_item(sys.modules, 'decimal', decimal):
874+
src = inspect.getsource(decimal.Decimal)
875+
lines, lineno = inspect.getsourcelines(decimal.Decimal)
876+
self.assertEqual(lines[0], 'class Decimal(object):\n')
877+
self.assertEqual(src.splitlines(True), lines)
878+
838879
class TestGetsourceInteractive(unittest.TestCase):
839880
def test_getclasses_interactive(self):
840881
# bpo-44648: simulate a REPL session;
@@ -947,6 +988,11 @@ def test_typeddict(self):
947988
self.assertSourceEqual(mod2.td354, 354, 356)
948989
self.assertRaises(OSError, inspect.getsource, mod2.td359)
949990

991+
def test_dataclass(self):
992+
self.assertSourceEqual(mod2.dc364, 364, 367)
993+
self.assertRaises(OSError, inspect.getsource, mod2.dc370)
994+
self.assertRaises(OSError, inspect.getsource, mod2.dc371)
995+
950996
class TestBlockComments(GetSourceBase):
951997
fodderModule = mod
952998

@@ -1010,17 +1056,28 @@ def test_findsource_without_filename(self):
10101056
self.assertRaises(IOError, inspect.findsource, co)
10111057
self.assertRaises(IOError, inspect.getsource, co)
10121058

1013-
def test_findsource_with_out_of_bounds_lineno(self):
1059+
def test_findsource_on_func_with_out_of_bounds_lineno(self):
10141060
mod_len = len(inspect.getsource(mod))
10151061
src = '\n' * 2* mod_len + "def f(): pass"
10161062
co = compile(src, mod.__file__, "exec")
10171063
g, l = {}, {}
10181064
eval(co, g, l)
10191065
func = l['f']
10201066
self.assertEqual(func.__code__.co_firstlineno, 1+2*mod_len)
1021-
with self.assertRaisesRegex(IOError, "lineno is out of bounds"):
1067+
with self.assertRaisesRegex(OSError, "lineno is out of bounds"):
10221068
inspect.findsource(func)
10231069

1070+
def test_findsource_on_class_with_out_of_bounds_lineno(self):
1071+
mod_len = len(inspect.getsource(mod))
1072+
src = '\n' * 2* mod_len + "class A: pass"
1073+
co = compile(src, mod.__file__, "exec")
1074+
g, l = {'__name__': mod.__name__}, {}
1075+
eval(co, g, l)
1076+
cls = l['A']
1077+
self.assertEqual(cls.__firstlineno__, 1+2*mod_len)
1078+
with self.assertRaisesRegex(OSError, "lineno is out of bounds"):
1079+
inspect.findsource(cls)
1080+
10241081
def test_getsource_on_method(self):
10251082
self.assertSourceEqual(mod2.ClassWithMethod.method, 118, 119)
10261083

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Setting the :attr:`!__module__` attribute for a class now removes the
2+
``__firstlineno__`` item from the type's dict, so they will no longer be
3+
inconsistent.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix :func:`inspect.getsource` for classes in :mod:`collections.abc` and
2+
:mod:`decimal` (for pure Python implementation) modules.
3+
:func:`inspect.getcomments` now raises OSError instead of IndexError if the
4+
``__firstlineno__`` value for a class is out of bound.

Objects/typeobject.c

+3
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,9 @@ type_set_module(PyTypeObject *type, PyObject *value, void *context)
14351435
PyType_Modified(type);
14361436

14371437
PyObject *dict = lookup_tp_dict(type);
1438+
if (PyDict_Pop(dict, &_Py_ID(__firstlineno__), NULL) < 0) {
1439+
return -1;
1440+
}
14381441
return PyDict_SetItem(dict, &_Py_ID(__module__), value);
14391442
}
14401443

0 commit comments

Comments
 (0)