Skip to content

Commit c31c625

Browse files
DOC: Fix linkcode_resolve for inherited properties
The @inherit_names decorator creates property wrappers at runtime that delegate to the underlying Array class. These wrappers confuse Sphinx's introspection, causing all inherited properties to link to extension.py instead of their actual implementations. We detect this pattern and redirect to the true source location. Fixes #58350
1 parent 7bfef3b commit c31c625

File tree

1 file changed

+124
-0
lines changed

1 file changed

+124
-0
lines changed

doc/source/conf.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,130 @@ def linkcode_resolve(domain, info) -> str | None:
666666
except AttributeError:
667667
return None
668668

669+
# -- Custom logic for inherited properties --
670+
# The @inherit_names decorator creates property wrappers at runtime that
671+
# delegate to the underlying Array class. These wrappers confuse Sphinx's
672+
# introspection, causing all inherited properties to link to extension.py
673+
# instead of their actual implementations. We detect this pattern and
674+
# redirect to the true source location.
675+
try:
676+
if (
677+
isinstance(obj, property)
678+
and hasattr(obj, "fget")
679+
and hasattr(obj.fget, "__qualname__")
680+
and "_inherit_from_data.<locals>.fget" in obj.fget.__qualname__
681+
):
682+
class_name, prop_name = fullname.rsplit(".", 1)
683+
array_class_name = class_name.replace("Index", "Array")
684+
685+
# Dynamically import the corresponding Array class
686+
ArrayClass = None
687+
if "Datetime" in array_class_name:
688+
from pandas.core.arrays.datetimes import DatetimeArray
689+
690+
ArrayClass = DatetimeArray
691+
elif "Timedelta" in array_class_name:
692+
from pandas.core.arrays.timedeltas import TimedeltaArray
693+
694+
ArrayClass = TimedeltaArray
695+
elif "Period" in array_class_name:
696+
from pandas.core.arrays.period import PeriodArray
697+
698+
ArrayClass = PeriodArray
699+
700+
if ArrayClass and hasattr(ArrayClass, prop_name):
701+
fn = inspect.getsourcefile(ArrayClass)
702+
if not fn:
703+
raise RuntimeError("Could not get source file for ArrayClass.")
704+
705+
with open(fn, encoding="utf-8") as f:
706+
lines = f.read().splitlines()
707+
708+
start_line, end_line = None, None
709+
710+
# Strategy 1:
711+
# Look for direct definition (prop_name = _field_accessor(...))
712+
p_accessor = re.compile(
713+
rf"^\s*{re.escape(prop_name)}\s*=\s*_field_accessor\("
714+
)
715+
for i, line in enumerate(lines):
716+
if p_accessor.match(line):
717+
start_line = i
718+
paren_count = line.count("(") - line.count(")")
719+
for j in range(i + 1, len(lines)):
720+
paren_count += lines[j].count("(") - lines[j].count(")")
721+
if paren_count <= 0:
722+
end_line = j
723+
break
724+
break
725+
726+
# Strategy 2:
727+
# Look for @property definition
728+
if start_line is None:
729+
p_decorator = re.compile(r"^\s*@property")
730+
p_def = re.compile(rf"^\s*def\s+{re.escape(prop_name)}\s*\(")
731+
for i, line in enumerate(lines):
732+
if (
733+
p_decorator.match(line)
734+
and (i + 1 < len(lines))
735+
and p_def.match(lines[i + 1])
736+
):
737+
start_line = i
738+
base_indent = len(lines[i + 1]) - len(lines[i + 1].lstrip())
739+
for j in range(i + 2, len(lines)):
740+
line_strip = lines[j].strip()
741+
if line_strip:
742+
current_indent = len(lines[j]) - len(
743+
lines[j].lstrip()
744+
)
745+
if current_indent <= base_indent and (
746+
line_strip.startswith(("def ", "class ", "@"))
747+
):
748+
end_line = j - 1
749+
break
750+
else:
751+
end_line = len(lines) - 1
752+
break
753+
754+
# Strategy 3:
755+
# Look for alias (prop_name = canonical_name)
756+
if start_line is None:
757+
alias_pattern = re.compile(
758+
rf"^\s*{re.escape(prop_name)}\s*=\s*(\w+)\s*$"
759+
)
760+
for i, line in enumerate(lines):
761+
match = alias_pattern.match(line)
762+
if match:
763+
canonical_name = match.group(1)
764+
# Search for canonical definition
765+
p_canonical = re.compile(
766+
rf"^\s*{re.escape(canonical_name)}\s*=\s*_field_accessor\("
767+
)
768+
for j, line2 in enumerate(lines):
769+
if p_canonical.match(line2):
770+
start_line = j
771+
paren_count = line2.count("(") - line2.count(")")
772+
for k in range(j + 1, len(lines)):
773+
paren_count += lines[k].count("(") - lines[
774+
k
775+
].count(")")
776+
if paren_count <= 0:
777+
end_line = k
778+
break
779+
break
780+
break
781+
782+
if start_line is not None and end_line is not None:
783+
linespec = f"#L{start_line + 1}-L{end_line + 1}"
784+
rel_fn = os.path.relpath(fn, start=os.path.dirname(pandas.__file__))
785+
if "+" in pandas.__version__:
786+
return f"https://github.com/pandas-dev/pandas/blob/main/pandas/{rel_fn}{linespec}"
787+
else:
788+
return f"https://github.com/pandas-dev/pandas/blob/v{pandas.__version__}/pandas/{rel_fn}{linespec}"
789+
except Exception:
790+
# If custom logic fails, fall through to the default implementation
791+
pass
792+
669793
try:
670794
fn = inspect.getsourcefile(inspect.unwrap(obj))
671795
except TypeError:

0 commit comments

Comments
 (0)