gh-155263: Add the --list option to Argument Clinic (GH-155264)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/2f2c2ced6b2aff74b41115d45e1713266f3e94fe
commit: 2f2c2ced6b2aff74b41115d45e1713266f3e94fe
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-19T11:38:52+03:00
summary:

gh-155263: Add the --list option to Argument Clinic (GH-155264)

It prints the modules, classes and functions which Argument Clinic
defines in the specified files, each function with its signature.

files:
A Misc/NEWS.d/next/Tools-Demos/2026-08-06-08-32-53.gh-issue-155263.RCcjtk.rst
M Lib/test/test_clinic.py
M Tools/clinic/libclinic/cli.py
M Tools/clinic/libclinic/dsl_parser.py
M Tools/clinic/libclinic/function.py

diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py
index 3bf654371b7364..d3e4454348d023 100644
--- a/Lib/test/test_clinic.py
+++ b/Lib/test/test_clinic.py
@@ -3681,6 +3681,163 @@ def test_cli_converters_no_converters(self):
                 f.write("/*[clinic input]\n[clinic start generated code]*/\n")
             self.assertEqual(self.expect_success("--converters", fn), "")
 
+    LIST_CODE = dedent("""
+        /*[clinic input]
+        func
+            a: int
+            /
+
+        Docstring.
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        cloned = func
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        module m
+        class m.C "void *" ""
+        class m.C.D "void *" ""
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        m.C.meth
+            self: self(type="void *")
+            a: object
+            [
+            b: object
+            ]
+            /
+
+        Docstring.
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        @classmethod
+        m.C.__new__
+            a: object
+
+        Docstring.
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        @getter
+        m.C.prop
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        @setter
+        m.C.prop
+        [clinic start generated code]*/
+
+        /*[clinic input]
+        m.C.D.meth
+            self: self(type="void *")
+
+        Docstring.
+        [clinic start generated code]*/
+    """)
+
+    def make_list_file(self, tmp_dir):
+        fn = os.path.join(tmp_dir, "test.c")
+        with open(fn, "w", encoding="utf-8") as f:
+            f.write(self.LIST_CODE)
+        return fn
+
+    LIST_OUTPUT = [
+        "  func($module, a, /)",
+        "  cloned($module, a, /)",
+        "  module m",
+        "    class m.C",
+        # A signature with an option group is only for the docstring.
+        "      m.C.meth(a, [b])",
+        "      m.C(a)",
+        "      getter m.C.prop",
+        "      setter m.C.prop",
+        "      class m.C.D",
+        "        m.C.D.meth($self, /)",
+    ]
+
+    def test_cli_list(self):
+        with os_helper.temp_dir() as tmp_dir:
+            fn = self.make_list_file(tmp_dir)
+            pre_mtime = os.stat(fn).st_mtime_ns
+            out = self.expect_success("--list", fn)
+            self.assertEqual(out.splitlines(), [fn] + self.LIST_OUTPUT)
+            # Nothing is written.
+            with open(fn, encoding="utf-8") as f:
+                self.assertEqual(f.read(), self.LIST_CODE)
+            self.assertEqual(os.stat(fn).st_mtime_ns, pre_mtime)
+            self.assertEqual(os.listdir(tmp_dir), ["test.c"])
+
+    def test_cli_list_no_clinic_block(self):
+        with os_helper.temp_dir() as tmp_dir:
+            fn = os.path.join(tmp_dir, "test.c")
+            with open(fn, "w", encoding="utf-8") as f:
+                f.write("int x;\n")
+            self.assertEqual(self.expect_success("--list", fn), "")
+
+    def test_cli_list_no_definitions(self):
+        with os_helper.temp_dir() as tmp_dir:
+            fn = os.path.join(tmp_dir, "test.c")
+            with open(fn, "w", encoding="utf-8") as f:
+                f.write("/*[clinic input]\n[clinic start generated code]*/\n")
+            self.assertEqual(self.expect_success("--list", fn), "")
+
+    def test_cli_list_make(self):
+        with os_helper.temp_dir() as tmp_dir:
+            fn = self.make_list_file(tmp_dir)
+            out = self.expect_success("--list", "--make", "--srcdir", tmp_dir)
+            self.assertEqual(out.splitlines(), [fn] + self.LIST_OUTPUT)
+            self.assertEqual(os.listdir(tmp_dir), ["test.c"])
+
+    def test_cli_list_verbose(self):
+        with os_helper.temp_dir() as tmp_dir:
+            fn = self.make_list_file(tmp_dir)
+            # The progress does not mix with the report.
+            out, err, code = self.run_clinic("-v", "--list", fn)
+            self.assertEqual(code, 0)
+            self.assertEqual(err.splitlines(), [fn])
+            self.assertEqual(out.splitlines(), [fn] + self.LIST_OUTPUT)
+
+    def test_cli_list_checksum_mismatch(self):
+        with os_helper.temp_dir() as tmp_dir:
+            fn = self.make_list_file(tmp_dir)
+            with open(fn, "a", encoding="utf-8") as f:
+                f.write("/*[clinic end generated code: "
+                        "output=0123456789abcdef input=fedcba9876543210]*/\n")
+            _, err = self.expect_failure("--list", fn)
+            self.assertIn("Checksum mismatch!", err)
+            # The check is skipped with --force.
+            out = self.expect_success("-f", "--list", fn)
+            self.assertEqual(out.splitlines(), [fn] + self.LIST_OUTPUT)
+            self.assertEqual(os.listdir(tmp_dir), ["test.c"])
+
+    def test_cli_list_external(self):
+        # A file which uses getters, setters and nested classes.
+        source = support.findfile('clinic.test.c')
+        out = self.expect_success("--list", source)
+        lines = out.splitlines()
+        self.assertEqual(lines[0], source)
+        for line in ("  class Test",
+                     "    getter Test.property",
+                     "    setter Test.property",
+                     "    Test.class_method($type, /)",
+                     "  module m",
+                     "    class m.T"):
+            with self.subTest(line=line):
+                self.assertIn(line, lines)
+
+    def test_cli_fail_list_and_dry_run(self):
+        for opt in "--dry-run", "--diff":
+            with self.subTest(opt=opt):
+                _, err = self.expect_failure("--list", opt, "test.c")
+                self.assertIn("can't use --dry-run or --diff with --list", err)
+
+    def test_cli_fail_list_and_converters(self):
+        _, err = self.expect_failure("--list", "--converters", "test.c")
+        self.assertIn("can't use --converters with --list", err)
+
     def test_cli_fail_directory(self):
         with os_helper.temp_dir() as tmp_dir:
             subdir = os.path.join(tmp_dir, "test.c")
diff --git a/Misc/NEWS.d/next/Tools-Demos/2026-08-06-08-32-53.gh-issue-155263.RCcjtk.rst b/Misc/NEWS.d/next/Tools-Demos/2026-08-06-08-32-53.gh-issue-155263.RCcjtk.rst
new file mode 100644
index 00000000000000..969efa1af808f5
--- /dev/null
+++ b/Misc/NEWS.d/next/Tools-Demos/2026-08-06-08-32-53.gh-issue-155263.RCcjtk.rst
@@ -0,0 +1,3 @@
+Add the ``--list`` option to Argument Clinic.
+It prints the modules, classes and functions which Argument Clinic defines in
+the specified files, each function with its signature.
diff --git a/Tools/clinic/libclinic/cli.py b/Tools/clinic/libclinic/cli.py
index 290fc3a6e59408..9629c173177945 100644
--- a/Tools/clinic/libclinic/cli.py
+++ b/Tools/clinic/libclinic/cli.py
@@ -22,6 +22,9 @@
     return_converters, ReturnConverterType)
 from libclinic.clanguage import CLanguage
 from libclinic.app import Clinic
+from libclinic.dsl_parser import render_text_signature
+from libclinic.function import (
+    Class, Definition, Module, GETTER, SETTER, walk_definitions)
 
 
 # TODO:
@@ -54,7 +57,7 @@ def parse_file(
         output: str | None = None,
         verify: bool = True,
         writer: libclinic.FileWriter | None = None,
-) -> None:
+) -> Clinic | None:
     if not output:
         output = filename
     if writer is None:
@@ -78,7 +81,7 @@ def parse_file(
     # exit quickly if there are no clinic markers in the file
     find_start_re = BlockParser("", language).find_start_re
     if not find_start_re.search(raw):
-        return
+        return None
 
     if LIMITED_CAPI_REGEX.search(raw):
         limited_capi = True
@@ -97,6 +100,31 @@ def parse_file(
     writer.update_times(output,
                         [fn for fn, _ in files if fn != output],
                         any(changed for _, changed in files))
+    return clinic
+
+
+def format_definition(depth: int, name: str, definition: Definition) -> str:
+    indent = "  " * (depth + 1)
+    if isinstance(definition, Module):
+        return f"{indent}module {name}"
+    if isinstance(definition, Class):
+        return f"{indent}class {name}"
+    if definition.kind is GETTER:
+        return f"{indent}getter {name}"
+    if definition.kind is SETTER:
+        return f"{indent}setter {name}"
+    signature = render_text_signature(definition, definition.render_parameters,
+                                      name=name, line_width=None)
+    return indent + signature
+
+
+def print_definitions(clinic: Clinic) -> None:
+    """Print the modules, classes and functions defined in the parsed file."""
+    lines = [format_definition(depth, name, definition)
+             for depth, name, definition in walk_definitions(clinic)]
+    if lines:
+        print(clinic.filename)
+        print("\n".join(lines))
 
 
 def create_cli() -> argparse.ArgumentParser:
@@ -126,6 +154,10 @@ def create_cli() -> argparse.ArgumentParser:
                                "and return converters; if files are "
                                "specified, print only the converters "
                                "which they define"))
+    cmdline.add_argument("--list", action='store_true',
+                         help=("don't write any file, only list the modules, "
+                               "classes and functions which the specified "
+                               "files define, with their signatures"))
     cmdline.add_argument("--make", action='store_true',
                          help="walk --srcdir to run over all relevant files")
     cmdline.add_argument("--srcdir", type=str, default=os.curdir,
@@ -252,7 +284,7 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
     dry_run = ns.dry_run or ns.diff
     # The report is written to the standard output, so the progress
     # is written to the standard error stream to not mix them.
-    verbose_file = sys.stderr if dry_run else sys.stdout
+    verbose_file = sys.stderr if dry_run or ns.list else sys.stdout
 
     filenames: Iterable[str]
     if ns.make:
@@ -268,6 +300,12 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
             parser.error("can't use -o with multiple filenames")
         filenames = ns.filename
 
+    if ns.list:
+        if dry_run:
+            parser.error("can't use --dry-run or --diff with --list")
+        if ns.converters:
+            parser.error("can't use --converters with --list")
+
     if ns.converters:
         if dry_run:
             parser.error("can't use --dry-run or --diff with --converters")
@@ -280,20 +318,22 @@ def run_clinic(parser: argparse.ArgumentParser, ns: argparse.Namespace) -> None:
         builtin_legacy_converters = dict(legacy_converters)
         builtin_return_converters = dict(return_converters)
 
-    writer = libclinic.FileWriter(dry_run=dry_run or ns.converters)
+    writer = libclinic.FileWriter(dry_run=dry_run or ns.converters or ns.list)
     for filename in filenames:
         if ns.verbose:
             print(filename, file=verbose_file)
-        parse_file(filename, output=ns.output,
-                   verify=not ns.force, limited_capi=ns.limited_capi,
-                   writer=writer)
+        clinic = parse_file(filename, output=ns.output,
+                            verify=not ns.force, limited_capi=ns.limited_capi,
+                            writer=writer)
+        if ns.list and clinic is not None:
+            print_definitions(clinic)
 
     if ns.converters:
         print_converters(
             defined_in_files(converters, builtin_converters),
             defined_in_files(legacy_converters, builtin_legacy_converters),
             defined_in_files(return_converters, builtin_return_converters))
-    else:
+    elif not ns.list:
         report_changes(writer, diff=ns.diff)
 
 
diff --git a/Tools/clinic/libclinic/dsl_parser.py b/Tools/clinic/libclinic/dsl_parser.py
index 75924dcc05a750..b241f58711e68a 100644
--- a/Tools/clinic/libclinic/dsl_parser.py
+++ b/Tools/clinic/libclinic/dsl_parser.py
@@ -1396,184 +1396,10 @@ def state_function_docstring(self, line: str) -> None:
     def format_docstring_signature(
         f: Function, parameters: list[Parameter]
     ) -> str:
-        lines = []
-        lines.append(f.displayname)
-        if f.forced_text_signature:
-            lines.append(f.forced_text_signature)
-        elif f.kind in ACCESSORS:
-            # @getter and @setter do not need signatures like a method or a function.
-            return ''
-        else:
-            lines.append('(')
-
-            # populate "right_bracket_count" field for every parameter
-            assert parameters, "We should always have a self parameter. " + repr(f)
-            assert isinstance(parameters[0].converter, self_converter)
-            # self is always positional-only.
-            assert parameters[0].is_positional_only()
-            assert parameters[0].right_bracket_count == 0
-            positional_only = True
-            for p in parameters[1:]:
-                if not p.is_positional_only():
-                    positional_only = False
-                else:
-                    assert positional_only
-                if positional_only:
-                    p.right_bracket_count = p.group_depth
-                else:
-                    # don't put any right brackets around non-positional-only parameters, ever.
-                    p.right_bracket_count = 0
-
-            right_bracket_count = 0
-            last_group = 0
-
-            def fix_right_bracket_count(desired: int, group: int = 0) -> str:
-                nonlocal right_bracket_count, last_group
-                s = ''
-                if (group != last_group and right_bracket_count and
-                    ((desired >= right_bracket_count) if group < 0 else
-                     (desired <= right_bracket_count))):
-                    # The group is not nested in the previous group,
-                    # close the brackets of the latter first.
-                    s += ']' * right_bracket_count
-                    right_bracket_count = 0
-                last_group = group
-                while right_bracket_count < desired:
-                    s += '['
-                    right_bracket_count += 1
-                while right_bracket_count > desired:
-                    s += ']'
-                    right_bracket_count -= 1
-                return s
-
-            need_slash = False
-            added_slash = False
-            need_a_trailing_slash = False
-
-            # we only need a trailing slash:
-            #   * if this is not a "docstring_only" signature
-            #   * and if the last *shown* parameter is
-            #     positional only
-            if not f.docstring_only:
-                for p in reversed(parameters):
-                    if not p.converter.show_in_signature:
-                        continue
-                    if p.is_positional_only():
-                        need_a_trailing_slash = True
-                    break
-
-
-            added_star = False
-
-            first_parameter = True
-            last_p = parameters[-1]
-            line_length = len(''.join(lines))
-            indent = " " * line_length
-            def add_parameter(text: str) -> None:
-                nonlocal line_length
-                nonlocal first_parameter
-                if first_parameter:
-                    s = text
-                    first_parameter = False
-                else:
-                    s = ' ' + text
-                    if line_length + len(s) >= 72:
-                        lines.extend(["\n", indent])
-                        line_length = len(indent)
-                        s = text
-                line_length += len(s)
-                lines.append(s)
-
-            for p in parameters:
-                if not p.converter.show_in_signature:
-                    continue
-                assert p.name
-
-                is_self = isinstance(p.converter, self_converter)
-                if is_self and f.docstring_only:
-                    # this isn't a real machine-parsable signature,
-                    # so let's not print the "self" parameter
-                    continue
-
-                if p.is_positional_only():
-                    need_slash = not f.docstring_only
-                elif need_slash and not (added_slash or p.is_positional_only()):
-                    added_slash = True
-                    add_parameter('/,')
-
-                if p.is_keyword_only() and not added_star:
-                    added_star = True
-                    add_parameter('*,')
-
-                p_lines = [fix_right_bracket_count(p.right_bracket_count,
-                                                   p.group)]
-
-                if isinstance(p.converter, self_converter):
-                    # annotate first parameter as being a "self".
-                    #
-                    # if inspect.Signature gets this function,
-                    # and it's already bound, the self parameter
-                    # will be stripped off.
-                    #
-                    # if it's not bound, it should be marked
-                    # as positional-only.
-                    #
-                    # note: we don't print "self" for __init__,
-                    # because this isn't actually the signature
-                    # for __init__.  (it can't be, __init__ doesn't
-                    # have a docstring.)  if this is an __init__
-                    # (or __new__), then this signature is for
-                    # calling the class to construct a new instance.
-                    p_lines.append('$')
-
-                if p.is_vararg():
-                    p_lines.append("*")
-                    added_star = True
-                if p.is_var_keyword():
-                    p_lines.append("**")
-
-                name = p.converter.signature_name or p.name
-                p_lines.append(name)
-
-                if not p.is_variable_length() and p.converter.is_optional():
-                    p_lines.append('=')
-                    value = p.converter.py_default
-                    if not value:
-                        value = repr(p.converter.default)
-                    p_lines.append(value)
-
-                if (p != last_p) or need_a_trailing_slash:
-                    p_lines.append(',')
-
-                p_output = "".join(p_lines)
-                add_parameter(p_output)
-
-            lines.append(fix_right_bracket_count(0))
-            if need_a_trailing_slash:
-                add_parameter('/')
-            lines.append(')')
-
-        # PEP 8 says:
-        #
-        #     The Python standard library will not use function annotations
-        #     as that would result in a premature commitment to a particular
-        #     annotation style. Instead, the annotations are left for users
-        #     to discover and experiment with useful annotation styles.
-        #
-        # therefore this is commented out:
-        #
-        # if f.return_converter.py_default:
-        #     lines.append(' -> ')
-        #     lines.append(f.return_converter.py_default)
-
-        if not f.docstring_only:
-            lines.append("\n" + libclinic.SIG_END_MARKER + "\n")
-
-        signature_line = "".join(lines)
-
-        # now fix up the places where the brackets look wrong
-        return signature_line.replace(', ]', ',] ')
-
+        signature = render_text_signature(f, parameters)
+        if signature and not f.docstring_only:
+            signature += "\n" + libclinic.SIG_END_MARKER + "\n"
+        return signature
     @staticmethod
     def format_docstring_parameters(params: list[Parameter]) -> str:
         """Create substitution text for {parameters}"""
@@ -1715,3 +1541,191 @@ def do_post_block_processing_cleanup(self, lineno: int) -> None:
                 exc.lineno = lineno
             exc.filename = self.clinic.filename
             raise
+
+
+def render_text_signature(
+    f: Function,
+    parameters: list[Parameter],
+    *,
+    name: str | None = None,
+    line_width: int | None = 72,
+) -> str:
+    """Render the text signature of the function.
+
+    *name* replaces the name of the function.  *line_width* is the width
+    at which the signature is wrapped, None disables wrapping.
+    """
+    lines = []
+    lines.append(f.displayname if name is None else name)
+    if f.forced_text_signature:
+        lines.append(f.forced_text_signature)
+    elif f.kind in ACCESSORS:
+        # @getter and @setter do not need signatures like a method or a function.
+        return ''
+    else:
+        lines.append('(')
+
+        # populate "right_bracket_count" field for every parameter
+        assert parameters, "We should always have a self parameter. " + repr(f)
+        assert isinstance(parameters[0].converter, self_converter)
+        # self is always positional-only.
+        assert parameters[0].is_positional_only()
+        assert parameters[0].right_bracket_count == 0
+        positional_only = True
+        for p in parameters[1:]:
+            if not p.is_positional_only():
+                positional_only = False
+            else:
+                assert positional_only
+            if positional_only:
+                p.right_bracket_count = p.group_depth
+            else:
+                # don't put any right brackets around non-positional-only parameters, ever.
+                p.right_bracket_count = 0
+
+        right_bracket_count = 0
+        last_group = 0
+
+        def fix_right_bracket_count(desired: int, group: int = 0) -> str:
+            nonlocal right_bracket_count, last_group
+            s = ''
+            if (group != last_group and right_bracket_count and
+                ((desired >= right_bracket_count) if group < 0 else
+                 (desired <= right_bracket_count))):
+                # The group is not nested in the previous group,
+                # close the brackets of the latter first.
+                s += ']' * right_bracket_count
+                right_bracket_count = 0
+            last_group = group
+            while right_bracket_count < desired:
+                s += '['
+                right_bracket_count += 1
+            while right_bracket_count > desired:
+                s += ']'
+                right_bracket_count -= 1
+            return s
+
+        need_slash = False
+        added_slash = False
+        need_a_trailing_slash = False
+
+        # we only need a trailing slash:
+        #   * if this is not a "docstring_only" signature
+        #   * and if the last *shown* parameter is
+        #     positional only
+        if not f.docstring_only:
+            for p in reversed(parameters):
+                if not p.converter.show_in_signature:
+                    continue
+                if p.is_positional_only():
+                    need_a_trailing_slash = True
+                break
+
+
+        added_star = False
+
+        first_parameter = True
+        last_p = parameters[-1]
+        line_length = len(''.join(lines))
+        indent = " " * line_length
+        def add_parameter(text: str) -> None:
+            nonlocal line_length
+            nonlocal first_parameter
+            if first_parameter:
+                s = text
+                first_parameter = False
+            else:
+                s = ' ' + text
+                if line_width is not None and line_length + len(s) >= line_width:
+                    lines.extend(["\n", indent])
+                    line_length = len(indent)
+                    s = text
+            line_length += len(s)
+            lines.append(s)
+
+        for p in parameters:
+            if not p.converter.show_in_signature:
+                continue
+            assert p.name
+
+            is_self = isinstance(p.converter, self_converter)
+            if is_self and f.docstring_only:
+                # this isn't a real machine-parsable signature,
+                # so let's not print the "self" parameter
+                continue
+
+            if p.is_positional_only():
+                need_slash = not f.docstring_only
+            elif need_slash and not (added_slash or p.is_positional_only()):
+                added_slash = True
+                add_parameter('/,')
+
+            if p.is_keyword_only() and not added_star:
+                added_star = True
+                add_parameter('*,')
+
+            p_lines = [fix_right_bracket_count(p.right_bracket_count,
+                                               p.group)]
+
+            if isinstance(p.converter, self_converter):
+                # annotate first parameter as being a "self".
+                #
+                # if inspect.Signature gets this function,
+                # and it's already bound, the self parameter
+                # will be stripped off.
+                #
+                # if it's not bound, it should be marked
+                # as positional-only.
+                #
+                # note: we don't print "self" for __init__,
+                # because this isn't actually the signature
+                # for __init__.  (it can't be, __init__ doesn't
+                # have a docstring.)  if this is an __init__
+                # (or __new__), then this signature is for
+                # calling the class to construct a new instance.
+                p_lines.append('$')
+
+            if p.is_vararg():
+                p_lines.append("*")
+                added_star = True
+            if p.is_var_keyword():
+                p_lines.append("**")
+
+            name = p.converter.signature_name or p.name
+            p_lines.append(name)
+
+            if not p.is_variable_length() and p.converter.is_optional():
+                p_lines.append('=')
+                value = p.converter.py_default
+                if not value:
+                    value = repr(p.converter.default)
+                p_lines.append(value)
+
+            if (p != last_p) or need_a_trailing_slash:
+                p_lines.append(',')
+
+            p_output = "".join(p_lines)
+            add_parameter(p_output)
+
+        lines.append(fix_right_bracket_count(0))
+        if need_a_trailing_slash:
+            add_parameter('/')
+        lines.append(')')
+
+    # PEP 8 says:
+    #
+    #     The Python standard library will not use function annotations
+    #     as that would result in a premature commitment to a particular
+    #     annotation style. Instead, the annotations are left for users
+    #     to discover and experiment with useful annotation styles.
+    #
+    # therefore this is commented out:
+    #
+    # if f.return_converter.py_default:
+    #     lines.append(' -> ')
+    #     lines.append(f.return_converter.py_default)
+
+    signature_line = "".join(lines)
+
+    # now fix up the places where the brackets look wrong
+    return signature_line.replace(', ]', ',] ')
diff --git a/Tools/clinic/libclinic/function.py b/Tools/clinic/libclinic/function.py
index 58b61c6f822196..af858f2e40e75e 100644
--- a/Tools/clinic/libclinic/function.py
+++ b/Tools/clinic/libclinic/function.py
@@ -282,6 +282,36 @@ def render_docstring(self) -> str:
 
 ParamTuple = tuple["Parameter", ...]
 
+Definition = Module | Class | Function
+
+
+def walk_definitions(
+    parent: Clinic | Module | Class,
+    prefix: str = '',
+    depth: int = 0,
+) -> Iterator[tuple[int, str, Definition]]:
+    """Yield (depth, dotted name, definition) for every nested definition.
+
+    The name of a module is already fully qualified, but the name of
+    a class is not, hence the prefix.
+    """
+    for function in parent.functions:
+        if function.kind.new_or_init:
+            # __new__() and __init__() are called as the class itself.
+            name = prefix
+        else:
+            name = f'{prefix}.{function.name}' if prefix else function.name
+        yield depth, name, function
+    for cls in parent.classes.values():
+        name = f'{prefix}.{cls.name}' if prefix else cls.name
+        yield depth, name, cls
+        yield from walk_definitions(cls, name, depth + 1)
+    if not isinstance(parent, Class):
+        # Only a module can contain modules.
+        for module in parent.modules.values():
+            yield depth, module.name, module
+            yield from walk_definitions(module, module.name, depth + 1)
+
 
 def permute_left_option_groups(
     l: Sequence[Iterable[Parameter]]

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.