summaryrefslogtreecommitdiffstats
path: root/Tools/clinic
diff options
context:
space:
mode:
authorErlend E. Aasland <erlend@python.org>2023-08-07 11:28:08 (GMT)
committerGitHub <noreply@github.com>2023-08-07 11:28:08 (GMT)
commit33cb0b06efe33968eb32463fa1b02b5a729a17f8 (patch)
tree03943530bbfb086afa1ba2965b544fa53335f1a3 /Tools/clinic
parent3c8e8f3ceeae08fc43d885f5a4c65a3ee4b1a2c8 (diff)
downloadcpython-33cb0b06efe33968eb32463fa1b02b5a729a17f8.zip
cpython-33cb0b06efe33968eb32463fa1b02b5a729a17f8.tar.gz
cpython-33cb0b06efe33968eb32463fa1b02b5a729a17f8.tar.bz2
gh-95065: Add Argument Clinic support for deprecating positional use of parameters (#95151)
It is now possible to deprecate passing parameters positionally with Argument Clinic, using the new '* [from X.Y]' syntax. (To be read as "keyword-only from Python version X.Y") Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com> Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
Diffstat (limited to 'Tools/clinic')
-rwxr-xr-xTools/clinic/clinic.py167
1 files changed, 154 insertions, 13 deletions
diff --git a/Tools/clinic/clinic.py b/Tools/clinic/clinic.py
index 47b5f5a..4dfe90b 100755
--- a/Tools/clinic/clinic.py
+++ b/Tools/clinic/clinic.py
@@ -347,6 +347,13 @@ def suffix_all_lines(s: str, suffix: str) -> str:
return ''.join(final)
+def pprint_words(items: list[str]) -> str:
+ if len(items) <= 2:
+ return " and ".join(items)
+ else:
+ return ", ".join(items[:-1]) + " and " + items[-1]
+
+
def version_splitter(s: str) -> tuple[int, ...]:
"""Splits a version string into a tuple of integers.
@@ -828,6 +835,22 @@ class CLanguage(Language):
#define {methoddef_name}
#endif /* !defined({methoddef_name}) */
""")
+ DEPRECATED_POSITIONAL_PROTOTYPE: Final[str] = r"""
+ #if PY_VERSION_HEX >= 0x{major:02x}{minor:02x}00C0
+ # error "{cpp_message}"
+ #elif PY_VERSION_HEX >= 0x{major:02x}{minor:02x}00A0
+ # ifdef _MSC_VER
+ # pragma message ("{cpp_message}")
+ # else
+ # warning "{cpp_message}"
+ # endif
+ #endif
+ if ({condition}) {{{{
+ if (PyErr_WarnEx(PyExc_DeprecationWarning, "{depr_message}", 1)) {{{{
+ goto exit;
+ }}}}
+ }}}}
+ """
def __init__(self, filename: str) -> None:
super().__init__(filename)
@@ -850,6 +873,64 @@ class CLanguage(Language):
function = o
return self.render_function(clinic, function)
+ def deprecate_positional_use(
+ self,
+ func: Function,
+ params: dict[int, Parameter],
+ ) -> str:
+ assert len(params) > 0
+ names = [repr(p.name) for p in params.values()]
+ first_pos, first_param = next(iter(params.items()))
+ last_pos, last_param = next(reversed(params.items()))
+
+ # Pretty-print list of names.
+ pstr = pprint_words(names)
+
+ # For now, assume there's only one deprecation level.
+ assert first_param.deprecated_positional == last_param.deprecated_positional
+ thenceforth = first_param.deprecated_positional
+ assert thenceforth is not None
+
+ # Format the preprocessor warning and error messages.
+ assert isinstance(self.cpp.filename, str)
+ source = os.path.basename(self.cpp.filename)
+ major, minor = thenceforth
+ cpp_message = (
+ f"In {source}, update parameter(s) {pstr} in the clinic "
+ f"input of {func.full_name!r} to be keyword-only."
+ )
+ # Format the deprecation message.
+ if first_pos == 0:
+ preamble = "Passing positional arguments to "
+ if len(params) == 1:
+ condition = f"nargs == {first_pos+1}"
+ if first_pos:
+ preamble = f"Passing {first_pos+1} positional arguments to "
+ depr_message = preamble + (
+ f"{func.full_name}() is deprecated. Parameter {pstr} will "
+ f"become a keyword-only parameter in Python {major}.{minor}."
+ )
+ else:
+ condition = f"nargs > {first_pos} && nargs <= {last_pos+1}"
+ if first_pos:
+ preamble = (
+ f"Passing more than {first_pos} positional "
+ f"argument{'s' if first_pos != 1 else ''} to "
+ )
+ depr_message = preamble + (
+ f"{func.full_name}() is deprecated. Parameters {pstr} will "
+ f"become keyword-only parameters in Python {major}.{minor}."
+ )
+ # Format and return the code block.
+ code = self.DEPRECATED_POSITIONAL_PROTOTYPE.format(
+ condition=condition,
+ major=major,
+ minor=minor,
+ cpp_message=cpp_message,
+ depr_message=depr_message,
+ )
+ return normalize_snippet(code, indent=4)
+
def docstring_for_c_string(
self,
f: Function
@@ -1199,6 +1280,7 @@ class CLanguage(Language):
flags = 'METH_METHOD|' + flags
parser_prototype = self.PARSER_PROTOTYPE_DEF_CLASS
+ deprecated_positionals: dict[int, Parameter] = {}
add_label: str | None = None
for i, p in enumerate(parameters):
if isinstance(p.converter, defining_class_converter):
@@ -1213,6 +1295,8 @@ class CLanguage(Language):
parser_code.append("%s:" % add_label)
add_label = None
if not p.is_optional():
+ if p.deprecated_positional:
+ deprecated_positionals[i] = p
parser_code.append(normalize_snippet(parsearg, indent=4))
elif i < pos_only:
add_label = 'skip_optional_posonly'
@@ -1242,6 +1326,8 @@ class CLanguage(Language):
goto %s;
}}
""" % add_label, indent=4))
+ if p.deprecated_positional:
+ deprecated_positionals[i] = p
if i + 1 == len(parameters):
parser_code.append(normalize_snippet(parsearg, indent=4))
else:
@@ -1257,6 +1343,12 @@ class CLanguage(Language):
}}
""" % add_label, indent=4))
+ if deprecated_positionals:
+ code = self.deprecate_positional_use(f, deprecated_positionals)
+ assert parser_code is not None
+ # Insert the deprecation code before parameter parsing.
+ parser_code.insert(0, code)
+
if parser_code is not None:
if add_label:
parser_code.append("%s:" % add_label)
@@ -2592,6 +2684,9 @@ class Function:
return f
+VersionTuple = tuple[int, int]
+
+
@dc.dataclass(repr=False, slots=True)
class Parameter:
"""
@@ -2606,6 +2701,8 @@ class Parameter:
annotation: object = inspect.Parameter.empty
docstring: str = ''
group: int = 0
+ # (`None` signifies that there is no deprecation)
+ deprecated_positional: VersionTuple | None = None
right_bracket_count: int = dc.field(init=False, default=0)
def __repr__(self) -> str:
@@ -4430,6 +4527,7 @@ class DSLParser:
state: StateKeeper
keyword_only: bool
positional_only: bool
+ deprecated_positional: VersionTuple | None
group: int
parameter_state: ParamState
indent: IndentStack
@@ -4437,6 +4535,11 @@ class DSLParser:
coexist: bool
parameter_continuation: str
preserve_output: bool
+ star_from_version_re = create_regex(
+ before="* [from ",
+ after="]",
+ word=False,
+ )
def __init__(self, clinic: Clinic) -> None:
self.clinic = clinic
@@ -4460,6 +4563,7 @@ class DSLParser:
self.state = self.state_dsl_start
self.keyword_only = False
self.positional_only = False
+ self.deprecated_positional = None
self.group = 0
self.parameter_state: ParamState = ParamState.START
self.indent = IndentStack()
@@ -4622,7 +4726,7 @@ class DSLParser:
exc.lineno = line_number
raise
- self.do_post_block_processing_cleanup()
+ self.do_post_block_processing_cleanup(line_number)
block.output.extend(self.clinic.language.render(self.clinic, block.signatures))
if self.preserve_output:
@@ -4908,8 +5012,14 @@ class DSLParser:
self.parameter_continuation = line[:-1]
return
+ line = line.lstrip()
+ match = self.star_from_version_re.match(line)
+ if match:
+ self.parse_deprecated_positional(match.group(1))
+ return
+
func = self.function
- match line.lstrip():
+ match line:
case '*':
self.parse_star(func)
case '[':
@@ -5182,7 +5292,9 @@ class DSLParser:
"after 'self'.")
- p = Parameter(parameter_name, kind, function=self.function, converter=converter, default=value, group=self.group)
+ p = Parameter(parameter_name, kind, function=self.function,
+ converter=converter, default=value, group=self.group,
+ deprecated_positional=self.deprecated_positional)
names = [k.name for k in self.function.parameters.values()]
if parameter_name in names[1:]:
@@ -5215,10 +5327,28 @@ class DSLParser:
"Annotations must be either a name, a function call, or a string."
)
+ def parse_deprecated_positional(self, thenceforth: str) -> None:
+ assert isinstance(self.function, Function)
+ fname = self.function.full_name
+
+ if self.keyword_only:
+ fail(f"Function {fname!r}: '* [from ...]' must come before '*'")
+ if self.deprecated_positional:
+ fail(f"Function {fname!r} uses '[from ...]' more than once.")
+ try:
+ major, minor = thenceforth.split(".")
+ self.deprecated_positional = int(major), int(minor)
+ except ValueError:
+ fail(
+ f"Function {fname!r}: expected format '* [from major.minor]' "
+ f"where 'major' and 'minor' are integers; got {thenceforth!r}"
+ )
+
def parse_star(self, function: Function) -> None:
"""Parse keyword-only parameter marker '*'."""
if self.keyword_only:
fail(f"Function {function.name!r} uses '*' more than once.")
+ self.deprecated_positional = None
self.keyword_only = True
def parse_opening_square_bracket(self, function: Function) -> None:
@@ -5586,23 +5716,34 @@ class DSLParser:
return docstring
- def do_post_block_processing_cleanup(self) -> None:
+ def do_post_block_processing_cleanup(self, lineno: int) -> None:
"""
Called when processing the block is done.
"""
if not self.function:
return
- if self.keyword_only:
- values = self.function.parameters.values()
- if not values:
- no_parameter_after_star = True
+ def check_remaining(
+ symbol: str,
+ condition: Callable[[Parameter], bool]
+ ) -> None:
+ assert isinstance(self.function, Function)
+
+ if values := self.function.parameters.values():
+ last_param = next(reversed(values))
+ no_param_after_symbol = condition(last_param)
else:
- last_parameter = next(reversed(list(values)))
- no_parameter_after_star = last_parameter.kind != inspect.Parameter.KEYWORD_ONLY
- if no_parameter_after_star:
- fail(f"Function {self.function.name!r} specifies '*' "
- "without any parameters afterwards.")
+ no_param_after_symbol = True
+ if no_param_after_symbol:
+ fname = self.function.full_name
+ fail(f"Function {fname!r} specifies {symbol!r} "
+ "without any parameters afterwards.", line_number=lineno)
+
+ if self.keyword_only:
+ check_remaining("*", lambda p: p.kind != inspect.Parameter.KEYWORD_ONLY)
+
+ if self.deprecated_positional:
+ check_remaining("* [from ...]", lambda p: not p.deprecated_positional)
self.function.docstring = self.format_docstring()