diff --git a/Lib/test/test_clinic.py b/Lib/test/test_clinic.py index cb4507dcac2336..40e6a546a5e600 100644 --- a/Lib/test/test_clinic.py +++ b/Lib/test/test_clinic.py @@ -3471,6 +3471,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 c66084cf314482..448a068ce57544 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 @@ -92,6 +95,31 @@ def parse_file( cooked = clinic.parse(raw) writer.write(output, cooked) + 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: @@ -121,6 +149,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, @@ -247,7 +279,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: @@ -263,6 +295,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") @@ -275,20 +313,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 4dcbc815cc6f25..c5c7e2bb36f181 100644 --- a/Tools/clinic/libclinic/dsl_parser.py +++ b/Tools/clinic/libclinic/dsl_parser.py @@ -1354,184 +1354,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 {GETTER, SETTER}: - # @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}""" @@ -1657,3 +1483,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 {GETTER, SETTER}: + # @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 325633eb010608..149b537cb5be5f 100644 --- a/Tools/clinic/libclinic/function.py +++ b/Tools/clinic/libclinic/function.py @@ -269,6 +269,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]]