jenkins-bot has submitted this change. ( 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1193468?usp=email )

Change subject: IMPR: rename deprecate_positional to deprecated_signature
......................................................................

IMPR: rename deprecate_positional to deprecated_signature

Also
- deprecate keyword arguments used for positional only parameters (PEP 570)
- added some tests

Change-Id: I3d54e0e26d17aea0ff8c973869cf9fc57f63b2c2
---
M pywikibot/_wbtypes.py
M pywikibot/daemonize.py
M pywikibot/page/_basepage.py
M pywikibot/site/_apisite.py
M pywikibot/site/_generators.py
M pywikibot/throttle.py
M pywikibot/tools/__init__.py
M pywikibot/tools/_deprecate.py
M tests/tools_deprecate_tests.py
9 files changed, 174 insertions(+), 83 deletions(-)

Approvals:
  jenkins-bot: Verified
  Xqt: Looks good to me, approved




diff --git a/pywikibot/_wbtypes.py b/pywikibot/_wbtypes.py
index 3713931..89dea48 100644
--- a/pywikibot/_wbtypes.py
+++ b/pywikibot/_wbtypes.py
@@ -21,7 +21,7 @@
 from pywikibot.backports import Iterator
 from pywikibot.time import Timestamp
 from pywikibot.tools import (
-    deprecate_positionals,
+    deprecated_signature,
     issue_deprecation_warning,
     remove_last_args,
 )
@@ -113,7 +113,7 @@

     _items = ('lat', 'lon', 'entity')

-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def __init__(
         self,
         lat: float,
@@ -326,7 +326,7 @@
         )
         return self._dim

-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def get_globe_item(self, repo: DataSite | None = None, *,
                        lazy_load: bool = False) -> pywikibot.ItemPage:
         """Return the ItemPage corresponding to the globe.
@@ -436,7 +436,7 @@
     _timestr_re = re.compile(
         r'([-+]?\d{1,16})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z')

-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def __init__(
         self,
         year: int,
@@ -651,7 +651,7 @@
         return self._getSecondsAdjusted() == other._getSecondsAdjusted()

     @classmethod
-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def fromTimestr(
         cls,
         datetimestr: str,
@@ -703,7 +703,7 @@
                    timezone=timezone, calendarmodel=calendarmodel, site=site)

     @classmethod
-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def fromTimestamp(
         cls,
         timestamp: Timestamp,
diff --git a/pywikibot/daemonize.py b/pywikibot/daemonize.py
index 9bdc9e5..a00c131 100644
--- a/pywikibot/daemonize.py
+++ b/pywikibot/daemonize.py
@@ -64,7 +64,7 @@
 from enum import IntEnum
 from pathlib import Path

-from pywikibot.tools import deprecate_positionals
+from pywikibot.tools import deprecated_signature


 class StandardFD(IntEnum):
@@ -79,7 +79,7 @@
 is_daemon = False


-@deprecate_positionals(since='10.6.0')
+@deprecated_signature(since='10.6.0')
 def daemonize(*,
               close_fd: bool = True,
               chdir: bool = True,
diff --git a/pywikibot/page/_basepage.py b/pywikibot/page/_basepage.py
index f10c1d1..93af7cf 100644
--- a/pywikibot/page/_basepage.py
+++ b/pywikibot/page/_basepage.py
@@ -38,9 +38,9 @@
 from pywikibot.tools import (
     ComparableMixin,
     cached,
-    deprecate_positionals,
     deprecated,
     deprecated_args,
+    deprecated_signature,
     first_upper,
 )

@@ -1457,7 +1457,7 @@
                   force=force, asynchronous=asynchronous, callback=callback,
                   **kwargs)

-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def watch(
         self, *,
         unwatch: bool = False,
@@ -1660,7 +1660,7 @@
         """Convenience function to get the Wikibase item of a page."""
         return pywikibot.ItemPage.fromPage(self)

-    @deprecate_positionals(since='9.2')
+    @deprecated_signature(since='9.2')
     def templates(self,
                   *,
                   content: bool = False,
@@ -1705,7 +1705,7 @@

         return list(self._templates)

-    @deprecate_positionals(since='9.2')
+    @deprecated_signature(since='9.2')
     def itertemplates(
         self,
         total: int | None = None,
diff --git a/pywikibot/site/_apisite.py b/pywikibot/site/_apisite.py
index ec61c45..a796814 100644
--- a/pywikibot/site/_apisite.py
+++ b/pywikibot/site/_apisite.py
@@ -73,8 +73,8 @@
     MediaWikiVersion,
     cached,
     deprecate_arg,
-    deprecate_positionals,
     deprecated,
+    deprecated_signature,
     issue_deprecation_warning,
     merge_unique_dicts,
     normalize_username,
@@ -2995,7 +2995,7 @@
         return req.submit()

     @need_right('editmywatchlist')
-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def watch(
         self,
         pages: BasePage | str | list[BasePage | str],
diff --git a/pywikibot/site/_generators.py b/pywikibot/site/_generators.py
index 9901495..8bdc721 100644
--- a/pywikibot/site/_generators.py
+++ b/pywikibot/site/_generators.py
@@ -26,7 +26,7 @@
 )
 from pywikibot.site._decorators import need_right
 from pywikibot.site._namespace import NamespaceArgType
-from pywikibot.tools import deprecate_arg, deprecate_positionals, is_ip_address
+from pywikibot.tools import deprecate_arg, deprecated_signature, is_ip_address
 from pywikibot.tools.itertools import filter_unique


@@ -925,7 +925,7 @@
             for linkdata in pageitem['extlinks']:
                 yield linkdata['*']

-    @deprecate_positionals(since='10.4.0')
+    @deprecated_signature(since='10.4.0')
     def allpages(
         self,
         start: str = '!', *,
diff --git a/pywikibot/throttle.py b/pywikibot/throttle.py
index 9d8c7da..eb270fb 100644
--- a/pywikibot/throttle.py
+++ b/pywikibot/throttle.py
@@ -27,7 +27,7 @@
 import pywikibot
 from pywikibot import config
 from pywikibot.backports import Counter as CounterType
-from pywikibot.tools import deprecate_positionals, deprecated, deprecated_args
+from pywikibot.tools import deprecated, deprecated_args, deprecated_signature


 FORMAT_LINE = '{module_id} {pid} {time} {site}\n'
@@ -343,7 +343,7 @@
         time.sleep(seconds)

     @deprecated_args(requestsize=None)  # since: 10.3.0
-    @deprecate_positionals(since='10.3.0')
+    @deprecated_signature(since='10.3.0')
     def __call__(self, *, requestsize: int = 1, write: bool = False) -> None:
         """Apply throttling based on delay rules and request type.

diff --git a/pywikibot/tools/__init__.py b/pywikibot/tools/__init__.py
index a39cb51..bfc8da1 100644
--- a/pywikibot/tools/__init__.py
+++ b/pywikibot/tools/__init__.py
@@ -29,9 +29,9 @@
     add_decorated_full_name,
     add_full_name,
     deprecate_arg,
-    deprecate_positionals,
     deprecated,
     deprecated_args,
+    deprecated_signature,
     get_wrapper_depth,
     issue_deprecation_warning,
     manage_wrapping,
@@ -55,9 +55,9 @@
     'add_decorated_full_name',
     'add_full_name',
     'deprecate_arg',
-    'deprecate_positionals',
     'deprecated',
     'deprecated_args',
+    'deprecated_signature',
     'get_wrapper_depth',
     'issue_deprecation_warning',
     'manage_wrapping',
diff --git a/pywikibot/tools/_deprecate.py b/pywikibot/tools/_deprecate.py
index 4f6ffe9..e72f514 100644
--- a/pywikibot/tools/_deprecate.py
+++ b/pywikibot/tools/_deprecate.py
@@ -443,28 +443,35 @@
     return decorator


-def deprecate_positionals(since: str = ''):
-    """Decorator for methods that issues warnings for positional arguments.
+def deprecated_signature(since: str = ''):
+    """Decorator handling deprecated changes in function or method signatures.

-    This decorator allows positional arguments after keyword-only
-    argument syntax (:pep:`3102`) but throws a ``FutureWarning``. It
-    automatically maps the provided positional arguments to their
-    corresponding keyword-only parameters before invoking the decorated
-    method.
+    This decorator supports:

-    The intended use is during a deprecation period in which certain
-    parameters should be passed as keyword-only, allowing legacy calls
-    to continue working with a warning rather than immediately raising a
-    ``TypeError``.
+    - Deprecation of positional arguments that have been converted to
+      keyword-only parameters.
+    - Detection of invalid keyword usage for positional-only parameters.

-    .. important:: This decorator is only supported for instance or
-       class methods. It does not work for standalone functions.
+    Positional-only parameters (introduced in :pep:`570`) must be passed
+    positionally. If such parameters are passed as keyword arguments,
+    this decorator will emit a ``FutureWarning`` and automatically remap
+    them to positional arguments for backward compatibility.
+
+    It allows positional arguments after keyword-only syntax (:pep:`3102`)
+    but emits a ``FutureWarning``. Positional arguments that are now
+    keyword-only are automatically mapped to their corresponding
+    keyword parameters before the decorated function or method is
+    invoked.
+
+    The intended use is during a deprecation period, allowing legacy
+    calls to continue working with a warning instead of raising a
+    ``TypeError`` immediately.

     Example:

         .. code-block:: python

-            @deprecate_positionals(since='9.2.0')
+            @deprecated_signature(since='10.6.0')
             def f(posarg, *, kwarg):
                ...

@@ -473,18 +480,27 @@
         This function call passes but throws a ``FutureWarning``.
         Without the decorator, a ``TypeError`` would be raised.

-    .. caution:: The decorated function must not accept ``*args``. The
-       sequence of keyword-only arguments must match the sequence of the
-       old positional parameters, otherwise argument assignment will
-       fail.
+    .. note::
+       If the parameter name was changed, use :func:`deprecated_args`
+       first.
+
+    .. caution::
+       The decorated function must not accept ``*args``. The order of
+       keyword-only arguments must match the order of the old positional
+       parameters; otherwise, argument assignment may fail.

     .. versionadded:: 9.2
     .. versionchanged:: 10.4
        Raises ``ValueError`` if method has a ``*args`` parameter.
+    .. versionchanged:: 10.6
+       Renamed from ``deprecate_positionals``. Adds handling of
+       positional-only parameters and emits warnings if they are passed
+       as keyword arguments.

-    :param since: Mandatory version string indicating when certain
-        positional parameters were deprecated
-    :raises ValueError: If the method has an *args parameter.
+    :param since: Mandatory version string indicating when signature
+        changed.
+    :raises TypeError: If required positional arguments are missing.
+    :raises ValueError: If the method has an ``*args`` parameter.
     """
     def decorator(func):
         """Outer wrapper. Inspect the parameters of *func*.
@@ -502,10 +518,64 @@
             :return: the value returned by the decorated function or
                   method
             """
+            # 1. fix deprecated positional-only usage
+            pos_only_in_kwargs = {
+                name: kwargs[name]
+                for name, p in params.items()
+                if p.kind == const.POSITIONAL_ONLY and name in kwargs
+            }
+
+            if pos_only_in_kwargs:
+                new_args: list[Any] = []
+                args_repr = []  # build representation for deprecation warning
+                idx = 0  # index for args
+
+                for name in arg_keys:
+                    param = params[name]
+
+                    if param.kind != const.POSITIONAL_ONLY:
+                        # append remaining POSITIONAL_OR_KEYWORD arguments
+                        new_args.extend(args[idx:])
+                        break
+
+                    if name in pos_only_in_kwargs:
+                        # Value was passed as keyword → use it
+                        value = kwargs.pop(name)
+                        args_repr.append(repr(value))
+                    elif idx < len(args):
+                        # Value from original args
+                        value = args[idx]
+                        idx += 1
+                        # Add ellipsis once for original args
+                        if name not in ('cls', 'self') and (
+                                not args_repr or args_repr[-1] != '...'):
+                            args_repr.append('...')
+                    elif param.default is not param.empty:
+                        # Value from default → show actual value
+                        value = param.default
+                        args_repr.append(repr(value))
+                    else:
+                        raise TypeError(
+                            f'Missing required positional argument: {name}'
+                        )
+
+                    new_args.append(value)
+
+                args = tuple(new_args)
+
+                args_str = ', '.join(args_repr)
+                issue_deprecation_warning(
+                    f'Passing positional-only arguments as keywords to '
+                    f"{func.__qualname__}(): {', '.join(pos_only_in_kwargs)}",
+                    f'positional arguments like {func.__name__}({args_str})',
+                    since=since
+                )
+
+            # 2.  warn for deprecated keyword-only usage as positional
             if len(args) > positionals:
                 replace_args = list(zip(arg_keys[positionals:],
                                         args[positionals:]))
-                pos_args = "', '".join(name for name, arg in replace_args)
+                pos_args = "', '".join(name for name, _ in replace_args)
                 keyw_args = ', '.join(f'{name}={arg!r}'
                                       for name, arg in replace_args)
                 issue_deprecation_warning(
@@ -520,17 +590,25 @@
             return func(*args, **kwargs)

         sig = inspect.signature(func)
+        params = sig.parameters
         arg_keys = list(sig.parameters)
+        const = inspect.Parameter

         # find the first KEYWORD_ONLY index
+        positionals = 0
         for positionals, key in enumerate(arg_keys):
-            if sig.parameters[key].kind == inspect.Parameter.VAR_POSITIONAL:
+            kind = params[key].kind
+
+            # disallow *args entirely
+            if kind == const.VAR_POSITIONAL:
                 raise ValueError(
                     f'{func.__qualname__} must not have *{key} parameter')

-            if sig.parameters[key].kind in (inspect.Parameter.KEYWORD_ONLY,
-                                            inspect.Parameter.VAR_KEYWORD):
+            # stop counting when we reach keyword-only or **kwargs
+            if kind in (const.KEYWORD_ONLY, const.VAR_KEYWORD):
                 break
+        else:
+            positionals += 1  # all were positional, no keyword found

         return wrapper

diff --git a/tests/tools_deprecate_tests.py b/tests/tools_deprecate_tests.py
index 728bb6f..5657ea3 100755
--- a/tests/tools_deprecate_tests.py
+++ b/tests/tools_deprecate_tests.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 """Tests for deprecation tools."""
 #
-# (C) Pywikibot team, 2014-2024
+# (C) Pywikibot team, 2014-2025
 #
 # Distributed under the terms of the MIT license.
 #
@@ -15,9 +15,9 @@
     PYTHON_VERSION,
     add_full_name,
     deprecate_arg,
-    deprecate_positionals,
     deprecated,
     deprecated_args,
+    deprecated_signature,
     remove_last_args,
 )
 from tests.aspects import DeprecationTestCase
@@ -154,9 +154,9 @@
     return foo


-@deprecate_positionals()
-def positionals_test_function(foo: str, *,
-                              bar: int, baz: str = '') -> tuple[int, str]:
+@deprecated_signature()
+def positionals_test_function(foo: str, /, *,
+                              bar: int, baz: str = '') -> tuple[str, int]:
     """Deprecating positional parameters."""
     return foo + baz, bar ** 2

@@ -240,9 +240,9 @@
         """Deprecating last positional parameter."""
         return foo

-    @deprecate_positionals()
-    def test_method(self, foo: str, *,
-                    bar: int = 5, baz: str = '') -> tuple[int, str]:
+    @deprecated_signature()
+    def test_method(self, foo: str, /, *,
+                    bar: int = 5, baz: str = '') -> tuple[str, int]:
         """Deprecating positional parameters."""
         return foo + baz, bar ** 2

@@ -613,81 +613,94 @@
             " The value(s) provided for 'bar' have been dropped."
         )
 
-    def test_deprecate_positionals(self) -> None:
-        """Test deprecation of positional parameters."""
-        msg = ('Passing {param} as positional argument(s) to {func}() is '
-               'deprecated; use keyword arguments like {instead} instead.')
+    def test_deprecated_signature(self) -> None:
+        """Test deprecation of parameters signature."""
+        msg1 = ('Passing {param} as positional argument(s) to {func}() is '
+                'deprecated; use keyword arguments like {instead} instead.')
+        msg2 = (
+            'Passing positional-only arguments as keywords to {qual}(): '
+            'foo is deprecated; '
+            "use positional arguments like {func}('Pywiki') instead."
+        )

         f = DeprecatedMethodClass().test_method
-        func = 'DeprecatedMethodClass.test_method'
+        qual = f.__qualname__
+        func = f.__name__

         with self.subTest(test=1):
             rv1, rv2 = f('Pywiki', 1, 'bot')
             self.assertEqual(rv1, 'Pywikibot')
             self.assertEqual(rv2, 1)
-            self.assertOneDeprecation(msg.format(param="'bar', 'baz'",
-                                                 func=func,
-                                                 instead="bar=1, baz='bot'"))
+            self.assertOneDeprecation(msg1.format(param="'bar', 'baz'",
+                                                  func=qual,
+                                                  instead="bar=1, baz='bot'"))

         with self.subTest(test=2):
             rv1, rv2 = f('Pywiki', 2)
             self.assertEqual(rv1, 'Pywiki')
             self.assertEqual(rv2, 4)
-            self.assertOneDeprecation(msg.format(param="'bar'",
-                                                 func=func,
-                                                 instead='bar=2'))
+            self.assertOneDeprecation(msg1.format(param="'bar'",
+                                                  func=qual,
+                                                  instead='bar=2'))

         with self.subTest(test=3):
             rv1, rv2 = f('Pywiki', 3, baz='bot')
             self.assertEqual(rv1, 'Pywikibot')
             self.assertEqual(rv2, 9)
-            self.assertOneDeprecation(msg.format(param="'bar'",
-                                                 func=func,
-                                                 instead='bar=3'))
+            self.assertOneDeprecation(msg1.format(param="'bar'",
+                                                  func=qual,
+                                                  instead='bar=3'))

         with self.subTest(test=4):
-            rv1, rv2 = f('Pywiki', bar=4)
+            rv1, rv2 = f(foo='Pywiki')
             self.assertEqual(rv1, 'Pywiki')
-            self.assertEqual(rv2, 16)
-            self.assertNoDeprecation()
+            self.assertEqual(rv2, 25)
+            self.assertOneDeprecation(msg2.format(qual=qual, func=func))

         with self.subTest(test=5):
-            rv1, rv2 = f(foo='Pywiki')
+            rv1, rv2 = f('Pywiki', bar=5)
             self.assertEqual(rv1, 'Pywiki')
             self.assertEqual(rv2, 25)
             self.assertNoDeprecation()

         f = positionals_test_function
-        func = 'positionals_test_function'
+        func = f.__name__
+        qual = f.__qualname__

-        with self.subTest(test=6):
+        with self.subTest(test=1):
             rv1, rv2 = f('Pywiki', 6, 'bot')
             self.assertEqual(rv1, 'Pywikibot')
             self.assertEqual(rv2, 36)
-            self.assertOneDeprecation(msg.format(param="'bar', 'baz'",
-                                                 func=func,
-                                                 instead="bar=6, baz='bot'"))
+            self.assertOneDeprecation(msg1.format(param="'bar', 'baz'",
+                                                  func=qual,
+                                                  instead="bar=6, baz='bot'"))

         with self.subTest(test=7):
             rv1, rv2 = f('Pywiki', 7)
             self.assertEqual(rv1, 'Pywiki')
             self.assertEqual(rv2, 49)
-            self.assertOneDeprecation(msg.format(param="'bar'",
-                                                 func=func,
-                                                 instead='bar=7'))
+            self.assertOneDeprecation(msg1.format(param="'bar'",
+                                                  func=qual,
+                                                  instead='bar=7'))

         with self.subTest(test=8):
             rv1, rv2 = f('Pywiki', 8, baz='bot')
             self.assertEqual(rv1, 'Pywikibot')
             self.assertEqual(rv2, 64)
-            self.assertOneDeprecation(msg.format(param="'bar'",
-                                                 func=func,
-                                                 instead='bar=8'))
+            self.assertOneDeprecation(msg1.format(param="'bar'",
+                                                  func=qual,
+                                                  instead='bar=8'))

         with self.subTest(test=9):
-            rv1, rv2 = f('Pywiki', bar=9)
+            rv1, rv2 = f(foo='Pywiki', bar=9)
             self.assertEqual(rv1, 'Pywiki')
             self.assertEqual(rv2, 81)
+            self.assertOneDeprecation(msg2.format(qual=qual, func=func))
+
+        with self.subTest(test=10):
+            rv1, rv2 = f('Pywiki', bar=10)
+            self.assertEqual(rv1, 'Pywiki')
+            self.assertEqual(rv2, 100)
             self.assertNoDeprecation()

     def test_remove_last_args_invalid(self) -> None:

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1193468?usp=email
To unsubscribe, or for help writing mail filters, visit 
https://gerrit.wikimedia.org/r/settings?usp=email

Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: I3d54e0e26d17aea0ff8c973869cf9fc57f63b2c2
Gerrit-Change-Number: 1193468
Gerrit-PatchSet: 9
Gerrit-Owner: Xqt <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to