You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@arrow.apache.org by al...@apache.org on 2023/04/24 06:18:59 UTC

[arrow] branch main updated: GH-34868: [Python] Share docstrings between classes (#34894)

This is an automated email from the ASF dual-hosted git repository.

alenka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new cd14e2019c GH-34868: [Python] Share docstrings between classes (#34894)
cd14e2019c is described below

commit cd14e2019ce93e1d6ffadc2556cc4275eeadd9f2
Author: Dane Pitkin <48...@users.noreply.github.com>
AuthorDate: Mon Apr 24 02:18:52 2023 -0400

    GH-34868: [Python] Share docstrings between classes (#34894)
    
    ### Rationale for this change
    
    Python classes sometimes duplicate docstrings, but change one word such as class name. Add a decorator function as a utility to help deduplicate docstring descriptions. Only works in Python. Does not work in Cython due to this CPython issue https://github.com/python/cpython/issues/91309.
    
    ### What changes are included in this PR?
    
    Add a decorator `@ doc` that can copy, concatenate, and/or format docstrings between classes.
    
    ### Are these changes tested?
    
    Tests added.
    
    ```
    >>> import pyarrow
    >>> from pyarrow.filesystem import FileSystem, LocalFileSystem, DaskFileSystem, S3FSWrapper
    >>> from pyarrow.hdfs import HadoopFileSystem
    >>> for fs in [FileSystem, LocalFileSystem, DaskFileSystem, S3FSWrapper, HadoopFileSystem]:
    ...     print(fs.__name__)
    ...     print(fs.isdir.__doc__)
    ...
    FileSystem
    
            Return True if path is a directory.
    
            Parameters
            ----------
            path : str
                Path to check.
    
    LocalFileSystem
    
    Return True if path is a directory.
    
    Parameters
    ----------
    path : str
        Path to check.
    
    DaskFileSystem
    
    Return True if path is a directory.
    
    Parameters
    ----------
    path : str
        Path to check.
    
    S3FSWrapper
    
    Return True if path is a directory.
    
    Parameters
    ----------
    path : str
        Path to check.
    
    HadoopFileSystem
    
    Return True if path is a directory.
    
    Parameters
    ----------
    path : str
        Path to check.
    
    ```
    Note that `FileSystem.isdir.__doc__` is not dedented because it does not use the `@ doc` decorator.
    
    ### Are there any user-facing changes?
    
    No
    * Closes: #34868
    
    Authored-by: Dane Pitkin <da...@voltrondata.com>
    Signed-off-by: Alenka Frim <fr...@gmail.com>
---
 python/pyarrow/filesystem.py      |  34 ++--
 python/pyarrow/hdfs.py            |  12 +-
 python/pyarrow/tests/test_util.py | 161 ++++++++++++++++-
 python/pyarrow/types.py           | 368 ++++++--------------------------------
 python/pyarrow/util.py            |  66 ++++++-
 5 files changed, 294 insertions(+), 347 deletions(-)

diff --git a/python/pyarrow/filesystem.py b/python/pyarrow/filesystem.py
index c2017e42b2..c1e70a1ee6 100644
--- a/python/pyarrow/filesystem.py
+++ b/python/pyarrow/filesystem.py
@@ -25,7 +25,7 @@ import warnings
 from os.path import join as pjoin
 
 import pyarrow as pa
-from pyarrow.util import implements, _stringify_path, _is_path_like, _DEPR_MSG
+from pyarrow.util import doc, _stringify_path, _is_path_like, _DEPR_MSG
 
 
 _FS_DEPR_MSG = _DEPR_MSG.format(
@@ -260,12 +260,12 @@ class LocalFileSystem(FileSystem):
         warnings.warn(_FS_DEPR_MSG, FutureWarning, stacklevel=2)
         return cls._get_instance()
 
-    @implements(FileSystem.ls)
+    @doc(FileSystem.ls)
     def ls(self, path):
         path = _stringify_path(path)
         return sorted(pjoin(path, x) for x in os.listdir(path))
 
-    @implements(FileSystem.mkdir)
+    @doc(FileSystem.mkdir)
     def mkdir(self, path, create_parents=True):
         path = _stringify_path(path)
         if create_parents:
@@ -273,26 +273,26 @@ class LocalFileSystem(FileSystem):
         else:
             os.mkdir(path)
 
-    @implements(FileSystem.isdir)
+    @doc(FileSystem.isdir)
     def isdir(self, path):
         path = _stringify_path(path)
         return os.path.isdir(path)
 
-    @implements(FileSystem.isfile)
+    @doc(FileSystem.isfile)
     def isfile(self, path):
         path = _stringify_path(path)
         return os.path.isfile(path)
 
-    @implements(FileSystem._isfilestore)
+    @doc(FileSystem._isfilestore)
     def _isfilestore(self):
         return True
 
-    @implements(FileSystem.exists)
+    @doc(FileSystem.exists)
     def exists(self, path):
         path = _stringify_path(path)
         return os.path.exists(path)
 
-    @implements(FileSystem.open)
+    @doc(FileSystem.open)
     def open(self, path, mode='rb'):
         """
         Open file for reading or writing.
@@ -324,15 +324,15 @@ class DaskFileSystem(FileSystem):
             FutureWarning, stacklevel=2)
         self.fs = fs
 
-    @implements(FileSystem.isdir)
+    @doc(FileSystem.isdir)
     def isdir(self, path):
         raise NotImplementedError("Unsupported file system API")
 
-    @implements(FileSystem.isfile)
+    @doc(FileSystem.isfile)
     def isfile(self, path):
         raise NotImplementedError("Unsupported file system API")
 
-    @implements(FileSystem._isfilestore)
+    @doc(FileSystem._isfilestore)
     def _isfilestore(self):
         """
         Object Stores like S3 and GCSFS are based on key lookups, not true
@@ -340,17 +340,17 @@ class DaskFileSystem(FileSystem):
         """
         return False
 
-    @implements(FileSystem.delete)
+    @doc(FileSystem.delete)
     def delete(self, path, recursive=False):
         path = _stringify_path(path)
         return self.fs.rm(path, recursive=recursive)
 
-    @implements(FileSystem.exists)
+    @doc(FileSystem.exists)
     def exists(self, path):
         path = _stringify_path(path)
         return self.fs.exists(path)
 
-    @implements(FileSystem.mkdir)
+    @doc(FileSystem.mkdir)
     def mkdir(self, path, create_parents=True):
         path = _stringify_path(path)
         if create_parents:
@@ -358,7 +358,7 @@ class DaskFileSystem(FileSystem):
         else:
             return self.fs.mkdir(path)
 
-    @implements(FileSystem.open)
+    @doc(FileSystem.open)
     def open(self, path, mode='rb'):
         """
         Open file for reading or writing.
@@ -380,7 +380,7 @@ class DaskFileSystem(FileSystem):
 
 class S3FSWrapper(DaskFileSystem):
 
-    @implements(FileSystem.isdir)
+    @doc(FileSystem.isdir)
     def isdir(self, path):
         path = _sanitize_s3(_stringify_path(path))
         try:
@@ -392,7 +392,7 @@ class S3FSWrapper(DaskFileSystem):
         except OSError:
             return False
 
-    @implements(FileSystem.isfile)
+    @doc(FileSystem.isfile)
     def isfile(self, path):
         path = _sanitize_s3(_stringify_path(path))
         try:
diff --git a/python/pyarrow/hdfs.py b/python/pyarrow/hdfs.py
index 56667bd5df..2e6c387a8f 100644
--- a/python/pyarrow/hdfs.py
+++ b/python/pyarrow/hdfs.py
@@ -21,7 +21,7 @@ import posixpath
 import sys
 import warnings
 
-from pyarrow.util import implements, _DEPR_MSG
+from pyarrow.util import doc, _DEPR_MSG
 from pyarrow.filesystem import FileSystem
 import pyarrow._hdfsio as _hdfsio
 
@@ -58,15 +58,15 @@ class HadoopFileSystem(_hdfsio.HadoopFileSystem, FileSystem):
         """
         return True
 
-    @implements(FileSystem.isdir)
+    @doc(FileSystem.isdir)
     def isdir(self, path):
         return super().isdir(path)
 
-    @implements(FileSystem.isfile)
+    @doc(FileSystem.isfile)
     def isfile(self, path):
         return super().isfile(path)
 
-    @implements(FileSystem.delete)
+    @doc(FileSystem.delete)
     def delete(self, path, recursive=False):
         return super().delete(path, recursive)
 
@@ -85,11 +85,11 @@ class HadoopFileSystem(_hdfsio.HadoopFileSystem, FileSystem):
         """
         return super().mkdir(path)
 
-    @implements(FileSystem.rename)
+    @doc(FileSystem.rename)
     def rename(self, path, new_path):
         return super().rename(path, new_path)
 
-    @implements(FileSystem.exists)
+    @doc(FileSystem.exists)
     def exists(self, path):
         return super().exists(path)
 
diff --git a/python/pyarrow/tests/test_util.py b/python/pyarrow/tests/test_util.py
index 2b351a5344..9fccb76112 100644
--- a/python/pyarrow/tests/test_util.py
+++ b/python/pyarrow/tests/test_util.py
@@ -18,14 +18,171 @@
 import gc
 import signal
 import sys
+import textwrap
 import weakref
 
 import pytest
 
-from pyarrow import util
+from pyarrow.util import doc, _break_traceback_cycle_from_frame
 from pyarrow.tests.util import disabled_gc
 
 
+@doc(method="func_a", operation="A")
+def func_a(whatever):
+    """
+    This is the {method} method.
+
+    It computes {operation}.
+    """
+    pass
+
+
+@doc(
+    func_a,
+    textwrap.dedent(
+        """
+        Examples
+        --------
+
+        >>> func_b()
+        B
+        """
+    ),
+    method="func_b",
+    operation="B",
+)
+def func_b(whatever):
+    pass
+
+
+@doc(
+    func_a,
+    method="func_c",
+    operation="C",
+)
+def func_c(whatever):
+    """
+    Examples
+    --------
+
+    >>> func_c()
+    C
+    """
+    pass
+
+
+@doc(func_a, method="func_d", operation="D")
+def func_d(whatever):
+    pass
+
+
+@doc(func_d, method="func_e", operation="E")
+def func_e(whatever):
+    pass
+
+
+@doc(method="func_f")
+def func_f(whatever):
+    """
+    This is the {method} method.
+
+    {{ We can escape curly braces like this. }}
+
+    Examples
+    --------
+    We should replace curly brace usage in doctests.
+
+    >>> dict(x = "x", y = "y")
+    >>> set((1, 2, 3))
+    """
+    pass
+
+
+def test_docstring_formatting():
+    docstr = textwrap.dedent(
+        """
+        This is the func_a method.
+
+        It computes A.
+        """
+    )
+    assert func_a.__doc__ == docstr
+
+
+def test_docstring_concatenation():
+    docstr = textwrap.dedent(
+        """
+        This is the func_b method.
+
+        It computes B.
+
+        Examples
+        --------
+
+        >>> func_b()
+        B
+        """
+    )
+    assert func_b.__doc__ == docstr
+
+
+def test_docstring_append():
+    docstr = textwrap.dedent(
+        """
+        This is the func_c method.
+
+        It computes C.
+
+        Examples
+        --------
+
+        >>> func_c()
+        C
+        """
+    )
+    assert func_c.__doc__ == docstr
+
+
+def test_docstring_template_from_callable():
+    docstr = textwrap.dedent(
+        """
+        This is the func_d method.
+
+        It computes D.
+        """
+    )
+    assert func_d.__doc__ == docstr
+
+
+def test_inherit_docstring_template_from_callable():
+    docstr = textwrap.dedent(
+        """
+        This is the func_e method.
+
+        It computes E.
+        """
+    )
+    assert func_e.__doc__ == docstr
+
+
+def test_escaping_in_docstring():
+    docstr = textwrap.dedent(
+        """
+        This is the func_f method.
+
+        { We can escape curly braces like this. }
+
+        Examples
+        --------
+        We should replace curly brace usage in doctests.
+
+        >>> dict(x = "x", y = "y")
+        >>> set((1, 2, 3))
+        """
+    )
+    assert func_f.__doc__ == docstr
+
+
 def exhibit_signal_refcycle():
     # Put an object in the frame locals and return a weakref to it.
     # If `signal.getsignal` has a bug where it creates a reference cycle
@@ -48,5 +205,5 @@ def test_signal_refcycle():
     with disabled_gc():
         wr = exhibit_signal_refcycle()
         assert wr() is not None
-        util._break_traceback_cycle_from_frame(sys._getframe(0))
+        _break_traceback_cycle_from_frame(sys._getframe(0))
         assert wr() is None
diff --git a/python/pyarrow/types.py b/python/pyarrow/types.py
index a88ec2ad7e..5d7dbe4b45 100644
--- a/python/pyarrow/types.py
+++ b/python/pyarrow/types.py
@@ -23,6 +23,7 @@ from pyarrow.lib import (is_boolean_value,  # noqa
                          is_float_value)
 
 import pyarrow.lib as lib
+from pyarrow.util import doc
 
 
 _SIGNED_INTEGER_TYPES = {lib.Type_INT8, lib.Type_INT16, lib.Type_INT32,
@@ -43,9 +44,10 @@ _NESTED_TYPES = {lib.Type_LIST, lib.Type_LARGE_LIST, lib.Type_STRUCT,
                  lib.Type_MAP} | _UNION_TYPES
 
 
+@doc(datatype="null")
 def is_null(t):
     """
-    Return True if value is an instance of a null type.
+    Return True if value is an instance of type: {datatype}.
 
     Parameters
     ----------
@@ -54,351 +56,165 @@ def is_null(t):
     return t.id == lib.Type_NA
 
 
+@doc(is_null, datatype="boolean")
 def is_boolean(t):
-    """
-    Return True if value is an instance of a boolean type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_BOOL
 
 
+@doc(is_null, datatype="any integer")
 def is_integer(t):
-    """
-    Return True if value is an instance of any integer type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _INTEGER_TYPES
 
 
+@doc(is_null, datatype="signed integer")
 def is_signed_integer(t):
-    """
-    Return True if value is an instance of any signed integer type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _SIGNED_INTEGER_TYPES
 
 
+@doc(is_null, datatype="unsigned integer")
 def is_unsigned_integer(t):
-    """
-    Return True if value is an instance of any unsigned integer type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _UNSIGNED_INTEGER_TYPES
 
 
+@doc(is_null, datatype="int8")
 def is_int8(t):
-    """
-    Return True if value is an instance of an int8 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_INT8
 
 
+@doc(is_null, datatype="int16")
 def is_int16(t):
-    """
-    Return True if value is an instance of an int16 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_INT16
 
 
+@doc(is_null, datatype="int32")
 def is_int32(t):
-    """
-    Return True if value is an instance of an int32 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_INT32
 
 
+@doc(is_null, datatype="int64")
 def is_int64(t):
-    """
-    Return True if value is an instance of an int64 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_INT64
 
 
+@doc(is_null, datatype="uint8")
 def is_uint8(t):
-    """
-    Return True if value is an instance of an uint8 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_UINT8
 
 
+@doc(is_null, datatype="uint16")
 def is_uint16(t):
-    """
-    Return True if value is an instance of an uint16 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_UINT16
 
 
+@doc(is_null, datatype="uint32")
 def is_uint32(t):
-    """
-    Return True if value is an instance of an uint32 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_UINT32
 
 
+@doc(is_null, datatype="uint64")
 def is_uint64(t):
-    """
-    Return True if value is an instance of an uint64 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_UINT64
 
 
+@doc(is_null, datatype="floating point numeric")
 def is_floating(t):
-    """
-    Return True if value is an instance of a floating point numeric type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _FLOATING_TYPES
 
 
+@doc(is_null, datatype="float16 (half-precision)")
 def is_float16(t):
-    """
-    Return True if value is an instance of a float16 (half-precision) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_HALF_FLOAT
 
 
+@doc(is_null, datatype="float32 (single precision)")
 def is_float32(t):
-    """
-    Return True if value is an instance of a float32 (single precision) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_FLOAT
 
 
+@doc(is_null, datatype="float64 (double precision)")
 def is_float64(t):
-    """
-    Return True if value is an instance of a float64 (double precision) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DOUBLE
 
 
+@doc(is_null, datatype="list")
 def is_list(t):
-    """
-    Return True if value is an instance of a list type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_LIST
 
 
+@doc(is_null, datatype="large list")
 def is_large_list(t):
-    """
-    Return True if value is an instance of a large list type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_LARGE_LIST
 
 
+@doc(is_null, datatype="fixed size list")
 def is_fixed_size_list(t):
-    """
-    Return True if value is an instance of a fixed size list type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_FIXED_SIZE_LIST
 
 
+@doc(is_null, datatype="struct")
 def is_struct(t):
-    """
-    Return True if value is an instance of a struct type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_STRUCT
 
 
+@doc(is_null, datatype="union")
 def is_union(t):
-    """
-    Return True if value is an instance of a union type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _UNION_TYPES
 
 
+@doc(is_null, datatype="nested type")
 def is_nested(t):
-    """
-    Return True if value is an instance of a nested type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _NESTED_TYPES
 
 
+@doc(is_null, datatype="run-end encoded")
 def is_run_end_encoded(t):
-    """
-    Return True if value is an instance of a run-end encoded type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_RUN_END_ENCODED
 
 
+@doc(is_null, datatype="date, time, timestamp or duration")
 def is_temporal(t):
-    """
-    Return True if value is an instance of date, time, timestamp or duration.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _TEMPORAL_TYPES
 
 
+@doc(is_null, datatype="timestamp")
 def is_timestamp(t):
-    """
-    Return True if value is an instance of a timestamp type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_TIMESTAMP
 
 
+@doc(is_null, datatype="duration")
 def is_duration(t):
-    """
-    Return True if value is an instance of a duration type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DURATION
 
 
+@doc(is_null, datatype="time")
 def is_time(t):
-    """
-    Return True if value is an instance of a time type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _TIME_TYPES
 
 
+@doc(is_null, datatype="time32")
 def is_time32(t):
-    """
-    Return True if value is an instance of a time32 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_TIME32
 
 
+@doc(is_null, datatype="time64")
 def is_time64(t):
-    """
-    Return True if value is an instance of a time64 type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_TIME64
 
 
+@doc(is_null, datatype="variable-length binary")
 def is_binary(t):
-    """
-    Return True if value is an instance of a variable-length binary type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_BINARY
 
 
+@doc(is_null, datatype="large variable-length binary")
 def is_large_binary(t):
-    """
-    Return True if value is an instance of a large variable-length
-    binary type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_LARGE_BINARY
 
 
+@doc(method="is_string")
 def is_unicode(t):
     """
-    Alias for is_string.
+    Alias for {method}.
 
     Parameters
     ----------
@@ -407,155 +223,71 @@ def is_unicode(t):
     return is_string(t)
 
 
+@doc(is_null, datatype="string (utf8 unicode)")
 def is_string(t):
-    """
-    Return True if value is an instance of string (utf8 unicode) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_STRING
 
 
+@doc(is_unicode, method="is_large_string")
 def is_large_unicode(t):
-    """
-    Alias for is_large_string.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return is_large_string(t)
 
 
+@doc(is_null, datatype="large string (utf8 unicode)")
 def is_large_string(t):
-    """
-    Return True if value is an instance of large string (utf8 unicode) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_LARGE_STRING
 
 
+@doc(is_null, datatype="fixed size binary")
 def is_fixed_size_binary(t):
-    """
-    Return True if value is an instance of a fixed size binary type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_FIXED_SIZE_BINARY
 
 
+@doc(is_null, datatype="date")
 def is_date(t):
-    """
-    Return True if value is an instance of a date type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _DATE_TYPES
 
 
+@doc(is_null, datatype="date32 (days)")
 def is_date32(t):
-    """
-    Return True if value is an instance of a date32 (days) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DATE32
 
 
+@doc(is_null, datatype="date64 (milliseconds)")
 def is_date64(t):
-    """
-    Return True if value is an instance of a date64 (milliseconds) type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DATE64
 
 
+@doc(is_null, datatype="map")
 def is_map(t):
-    """
-    Return True if value is an instance of a map logical type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_MAP
 
 
+@doc(is_null, datatype="decimal")
 def is_decimal(t):
-    """
-    Return True if value is an instance of a decimal type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id in _DECIMAL_TYPES
 
 
+@doc(is_null, datatype="decimal128")
 def is_decimal128(t):
-    """
-    Return True if value is an instance of a decimal type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DECIMAL128
 
 
+@doc(is_null, datatype="decimal256")
 def is_decimal256(t):
-    """
-    Return True if value is an instance of a decimal type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DECIMAL256
 
 
+@doc(is_null, datatype="dictionary-encoded")
 def is_dictionary(t):
-    """
-    Return True if value is an instance of a dictionary-encoded type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return t.id == lib.Type_DICTIONARY
 
 
+@doc(is_null, datatype="interval")
 def is_interval(t):
-    """
-    Return True if the value is an instance of an interval type.
-
-    Parameters
-    ----------
-    t : DateType
-    """
     return t.id == lib.Type_INTERVAL_MONTH_DAY_NANO
 
 
+@doc(is_null, datatype="primitive type")
 def is_primitive(t):
-    """
-    Return True if the value is an instance of a primitive type.
-
-    Parameters
-    ----------
-    t : DataType
-    """
     return lib._is_primitive(t.id)
diff --git a/python/pyarrow/util.py b/python/pyarrow/util.py
index 0e0f3e7265..4f178aefc5 100644
--- a/python/pyarrow/util.py
+++ b/python/pyarrow/util.py
@@ -23,6 +23,7 @@ import functools
 import gc
 import socket
 import sys
+import textwrap
 import types
 import warnings
 
@@ -32,10 +33,67 @@ _DEPR_MSG = (
 )
 
 
-def implements(f):
-    def decorator(g):
-        g.__doc__ = f.__doc__
-        return g
+def doc(*docstrings, **params):
+    """
+    A decorator that takes docstring templates, concatenates them, and finally
+    performs string substitution on them.
+    This decorator will add a variable "_docstring_components" to the wrapped
+    callable to keep track of the original docstring template for potential future use.
+    If the docstring is a template, it will be saved as a string.
+    Otherwise, it will be saved as a callable and the docstring will be obtained via
+    the __doc__ attribute.
+    This decorator can not be used on Cython classes due to a CPython constraint,
+    which enforces the __doc__ attribute to be read-only.
+    See https://github.com/python/cpython/issues/91309
+
+    Parameters
+    ----------
+    *docstrings : None, str, or callable
+        The string / docstring / docstring template to be prepended in order
+        before the default docstring under the callable.
+    **params
+        The key/value pairs used to format the docstring template.
+    """
+
+    def decorator(decorated):
+        docstring_components = []
+
+        # collect docstrings and docstring templates
+        for docstring in docstrings:
+            if docstring is None:
+                continue
+            if hasattr(docstring, "_docstring_components"):
+                docstring_components.extend(
+                    docstring._docstring_components
+                )
+            elif isinstance(docstring, str) or docstring.__doc__:
+                docstring_components.append(docstring)
+
+        # append the callable's docstring last
+        if decorated.__doc__:
+            docstring_components.append(textwrap.dedent(decorated.__doc__))
+
+        params_applied = [
+            component.format(**params)
+            if isinstance(component, str) and len(params) > 0
+            else component
+            for component in docstring_components
+        ]
+
+        decorated.__doc__ = "".join(
+            [
+                component
+                if isinstance(component, str)
+                else textwrap.dedent(component.__doc__ or "")
+                for component in params_applied
+            ]
+        )
+
+        decorated._docstring_components = (
+            docstring_components
+        )
+        return decorated
+
     return decorator