Skip to content

Commit 362f36d

Browse files
authored
Merge pull request #9461 from tk0miya/9445_class_property
Close #9445: Support class properties
2 parents 445c340 + 38d80c3 commit 362f36d

File tree

9 files changed

+109
-14
lines changed

9 files changed

+109
-14
lines changed

CHANGES

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Deprecated
1313
Features added
1414
--------------
1515

16+
* #9445: autodoc: Support class properties
17+
* #9445: py domain: ``:py:property:`` directive supports ``:classmethod:``
18+
option to describe the class property
19+
1620
Bugs fixed
1721
----------
1822

doc/usage/restructuredtext/domains.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,13 @@ The following directives are provided for module and class contents:
329329
330330
Indicate the property is abstract.
331331
332+
.. rst:directive:option:: classmethod
333+
:type: no value
334+
335+
Indicate the property is a classmethod.
336+
337+
.. versionaddedd: 4.2
338+
332339
.. rst:directive:option:: type: type of the property
333340
:type: text
334341

sphinx/domains/python.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,7 @@ class PyProperty(PyObject):
852852
option_spec = PyObject.option_spec.copy()
853853
option_spec.update({
854854
'abstractmethod': directives.flag,
855+
'classmethod': directives.flag,
855856
'type': directives.unchanged,
856857
})
857858

@@ -865,10 +866,13 @@ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]
865866
return fullname, prefix
866867

867868
def get_signature_prefix(self, sig: str) -> str:
868-
prefix = ['property']
869+
prefix = []
869870
if 'abstractmethod' in self.options:
870-
prefix.insert(0, 'abstract')
871+
prefix.append('abstract')
872+
if 'classmethod' in self.options:
873+
prefix.append('class')
871874

875+
prefix.append('property')
872876
return ' '.join(prefix) + ' '
873877

874878
def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str:

sphinx/ext/autodoc/__init__.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,7 @@ def is_filtered_inherited_member(name: str, obj: Any) -> bool:
718718
isattr = False
719719

720720
doc = getdoc(member, self.get_attr, self.config.autodoc_inherit_docstrings,
721-
self.parent, self.object_name)
721+
self.object, membername)
722722
if not isinstance(doc, str):
723723
# Ignore non-string __doc__
724724
doc = None
@@ -2661,7 +2661,32 @@ class PropertyDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter): #
26612661
@classmethod
26622662
def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any
26632663
) -> bool:
2664-
return inspect.isproperty(member) and isinstance(parent, ClassDocumenter)
2664+
if isinstance(parent, ClassDocumenter):
2665+
if inspect.isproperty(member):
2666+
return True
2667+
else:
2668+
__dict__ = safe_getattr(parent.object, '__dict__', {})
2669+
obj = __dict__.get(membername)
2670+
return isinstance(obj, classmethod) and inspect.isproperty(obj.__func__)
2671+
else:
2672+
return False
2673+
2674+
def import_object(self, raiseerror: bool = False) -> bool:
2675+
"""Check the exisitence of uninitialized instance attribute when failed to import
2676+
the attribute."""
2677+
ret = super().import_object(raiseerror)
2678+
if ret and not inspect.isproperty(self.object):
2679+
__dict__ = safe_getattr(self.parent, '__dict__', {})
2680+
obj = __dict__.get(self.objpath[-1])
2681+
if isinstance(obj, classmethod) and inspect.isproperty(obj.__func__):
2682+
self.object = obj.__func__
2683+
self.isclassmethod = True
2684+
return True
2685+
else:
2686+
return False
2687+
2688+
self.isclassmethod = False
2689+
return ret
26652690

26662691
def document_members(self, all_members: bool = False) -> None:
26672692
pass
@@ -2675,6 +2700,8 @@ def add_directive_header(self, sig: str) -> None:
26752700
sourcename = self.get_sourcename()
26762701
if inspect.isabstractmethod(self.object):
26772702
self.add_line(' :abstractmethod:', sourcename)
2703+
if self.isclassmethod:
2704+
self.add_line(' :classmethod:', sourcename)
26782705

26792706
if safe_getattr(self.object, 'fget', None) and self.config.autodoc_typehints != 'none':
26802707
try:

sphinx/util/inspect.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,17 @@ def ispartial(obj: Any) -> bool:
245245
return isinstance(obj, (partial, partialmethod))
246246

247247

248-
def isclassmethod(obj: Any) -> bool:
248+
def isclassmethod(obj: Any, cls: Any = None, name: str = None) -> bool:
249249
"""Check if the object is classmethod."""
250250
if isinstance(obj, classmethod):
251251
return True
252252
elif inspect.ismethod(obj) and obj.__self__ is not None and isclass(obj.__self__):
253253
return True
254+
elif cls and name:
255+
for basecls in getmro(cls):
256+
meth = basecls.__dict__.get(name)
257+
if meth:
258+
return isclassmethod(meth)
254259

255260
return False
256261

@@ -837,6 +842,12 @@ def getdoc(obj: Any, attrgetter: Callable = safe_getattr,
837842
* inherited docstring
838843
* inherited decorated methods
839844
"""
845+
if cls and name and isclassmethod(obj, cls, name):
846+
for basecls in getmro(cls):
847+
meth = basecls.__dict__.get(name)
848+
if meth:
849+
return getdoc(meth.__func__)
850+
840851
doc = attrgetter(obj, '__doc__', None)
841852
if ispartial(obj) and doc == obj.__class__.__doc__:
842853
return getdoc(obj.func)

tests/roots/test-ext-autodoc/target/properties.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@ class Foo:
22
"""docstring"""
33

44
@property
5-
def prop(self) -> int:
5+
def prop1(self) -> int:
6+
"""docstring"""
7+
8+
@classmethod
9+
@property
10+
def prop2(self) -> int:
611
"""docstring"""

tests/test_domain_py.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -813,24 +813,38 @@ def test_pyattribute(app):
813813
def test_pyproperty(app):
814814
text = (".. py:class:: Class\n"
815815
"\n"
816-
" .. py:property:: prop\n"
816+
" .. py:property:: prop1\n"
817817
" :abstractmethod:\n"
818+
" :type: str\n"
819+
"\n"
820+
" .. py:property:: prop2\n"
821+
" :classmethod:\n"
818822
" :type: str\n")
819823
domain = app.env.get_domain('py')
820824
doctree = restructuredtext.parse(app, text)
821825
assert_node(doctree, (addnodes.index,
822826
[desc, ([desc_signature, ([desc_annotation, "class "],
823827
[desc_name, "Class"])],
824828
[desc_content, (addnodes.index,
829+
desc,
830+
addnodes.index,
825831
desc)])]))
826832
assert_node(doctree[1][1][0], addnodes.index,
827-
entries=[('single', 'prop (Class property)', 'Class.prop', '', None)])
833+
entries=[('single', 'prop1 (Class property)', 'Class.prop1', '', None)])
828834
assert_node(doctree[1][1][1], ([desc_signature, ([desc_annotation, "abstract property "],
829-
[desc_name, "prop"],
835+
[desc_name, "prop1"],
836+
[desc_annotation, ": str"])],
837+
[desc_content, ()]))
838+
assert_node(doctree[1][1][2], addnodes.index,
839+
entries=[('single', 'prop2 (Class property)', 'Class.prop2', '', None)])
840+
assert_node(doctree[1][1][3], ([desc_signature, ([desc_annotation, "class property "],
841+
[desc_name, "prop2"],
830842
[desc_annotation, ": str"])],
831843
[desc_content, ()]))
832-
assert 'Class.prop' in domain.objects
833-
assert domain.objects['Class.prop'] == ('index', 'Class.prop', 'property', False)
844+
assert 'Class.prop1' in domain.objects
845+
assert domain.objects['Class.prop1'] == ('index', 'Class.prop1', 'property', False)
846+
assert 'Class.prop2' in domain.objects
847+
assert domain.objects['Class.prop2'] == ('index', 'Class.prop2', 'property', False)
834848

835849

836850
def test_pydecorator_signature(app):

tests/test_ext_autodoc_autoclass.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,20 @@ def test_properties(app):
212212
' docstring',
213213
'',
214214
'',
215-
' .. py:property:: Foo.prop',
215+
' .. py:property:: Foo.prop1',
216216
' :module: target.properties',
217217
' :type: int',
218218
'',
219219
' docstring',
220220
'',
221+
'',
222+
' .. py:property:: Foo.prop2',
223+
' :module: target.properties',
224+
' :classmethod:',
225+
' :type: int',
226+
'',
227+
' docstring',
228+
'',
221229
]
222230

223231

tests/test_ext_autodoc_autoproperty.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,28 @@
1616

1717
@pytest.mark.sphinx('html', testroot='ext-autodoc')
1818
def test_properties(app):
19-
actual = do_autodoc(app, 'property', 'target.properties.Foo.prop')
19+
actual = do_autodoc(app, 'property', 'target.properties.Foo.prop1')
2020
assert list(actual) == [
2121
'',
22-
'.. py:property:: Foo.prop',
22+
'.. py:property:: Foo.prop1',
2323
' :module: target.properties',
2424
' :type: int',
2525
'',
2626
' docstring',
2727
'',
2828
]
29+
30+
31+
@pytest.mark.sphinx('html', testroot='ext-autodoc')
32+
def test_class_properties(app):
33+
actual = do_autodoc(app, 'property', 'target.properties.Foo.prop2')
34+
assert list(actual) == [
35+
'',
36+
'.. py:property:: Foo.prop2',
37+
' :module: target.properties',
38+
' :classmethod:',
39+
' :type: int',
40+
'',
41+
' docstring',
42+
'',
43+
]

0 commit comments

Comments
 (0)