diff --git a/docs/deprecations.rst b/docs/deprecations.rst new file mode 100644 index 0000000..05ad8fc --- /dev/null +++ b/docs/deprecations.rst @@ -0,0 +1,107 @@ +============ +Deprecations +============ + +This page documents +the major deprecations +in ScreenPy Selenium's life, +and how to adjust your tests +to keep them up to date. + +4.1.0 Deprecations +================== + +Boolean Positional Arguments Deprecation +---------------------------------------- + +The following class constructors +have been marked deprecated +when using a positional boolean argument in the constructor. +Starting with version 5.0.0 +you will be required to provide keywords +for the following boolean arguments. + +While our documentation does not explicitly outline using these Actions in this way, +it's still possible to do so. +If you are using Actions directly from their constructors +like the below examples, +here are the fixes you'll need to implement. + + +:class:`~screenpy_selenium.actions.enter.Enter` + +Before: + +.. code-block:: python + + the_actor.will(Enter("foo", True).into_the(PASSWORD)) + +After: + +.. code-block:: python + + the_actor.will(Enter("foo", mask=True).into_the(PASSWORD)) + + +:class:`~screenpy_selenium.actions.hold_down.HoldDown` + +Before: + +.. code-block:: python + + the_actor.will(Chain(HoldDown(None, True)) + +After: + +.. code-block:: python + + the_actor.will(Chain(HoldDown(None, lmb=True)) + the_actor.will(Chain(HoldDown(lmb=True)) + + +:class:`~screenpy_selenium.actions.release.Release` + +Before: + +.. code-block:: python + + the_actor.will(Release(None, True)) + +After: + +.. code-block:: python + + the_actor.will(Release(None, lmb=True)) + the_actor.will(Release(lmb=True)) + + +:class:`~screenpy_selenium.questions.selected.Selected` + +Before: + +.. code-block:: python + + the_actor.shall(See.the(Selected(TARGET, True), IsEmpty())) + +After: + +.. code-block:: python + + the_actor.shall(See.the(Selected(TARGET, multi=True), IsEmpty())) + + +:class:`~screenpy_selenium.questions.text.Text` + +Before: + +.. code-block:: python + + the_actor.shall(See.the(Text(TARGET, True), IsEqual("foo")) + +After: + +.. code-block:: python + + the_actor.shall(See.the(Text(TARGET, multi=True), IsEqual("foo") + + diff --git a/docs/index.rst b/docs/index.rst index 6c977c5..327b99e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,3 +28,4 @@ to :class:`~screenpy_selenium.abilities.BrowseTheWeb`! extended_api targets cookbook + deprecations diff --git a/pyproject.toml b/pyproject.toml index 7f84720..9cf0ac3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,7 +128,7 @@ select = [ "ERA", # eradicate "F", # Pyflakes "FA", # flake8-future-annotations -# "FBT", # flake8-boolean-trap + "FBT", # flake8-boolean-trap "FIX", # flake8-fixme "FLY", # flynt "I", # isort diff --git a/screenpy_selenium/actions/enter.py b/screenpy_selenium/actions/enter.py index fcdba36..b5c9b0b 100644 --- a/screenpy_selenium/actions/enter.py +++ b/screenpy_selenium/actions/enter.py @@ -9,6 +9,7 @@ from screenpy.pacing import aside, beat from selenium.common.exceptions import WebDriverException +from ..common import pos_args_deprecated from ..speech_tools import KEY_NAMES if TYPE_CHECKING: @@ -152,7 +153,10 @@ def add_to_chain( for key in self.following_keys: send_keys(key) - def __init__(self: SelfEnter, text: str, mask: bool = False) -> None: + @pos_args_deprecated("mask") + def __init__( + self: SelfEnter, text: str, mask: bool = False # noqa: FBT001, FBT002 + ) -> None: self.text = text self.target = None self.following_keys = [] diff --git a/screenpy_selenium/actions/hold_down.py b/screenpy_selenium/actions/hold_down.py index 9acbac5..f19a2f7 100644 --- a/screenpy_selenium/actions/hold_down.py +++ b/screenpy_selenium/actions/hold_down.py @@ -9,6 +9,7 @@ from screenpy.pacing import beat from selenium.webdriver.common.keys import Keys +from ..common import pos_args_deprecated from ..speech_tools import KEY_NAMES if TYPE_CHECKING: @@ -88,7 +89,12 @@ def add_to_chain( msg = "HoldDown must be told what to hold down." raise UnableToAct(msg) - def __init__(self: SelfHoldDown, key: str | None = None, lmb: bool = False) -> None: + @pos_args_deprecated("lmb") + def __init__( + self: SelfHoldDown, + key: str | None = None, + lmb: bool = False, # noqa: FBT001, FBT002 + ) -> None: self.key = key self.lmb = lmb self.target = None diff --git a/screenpy_selenium/actions/release.py b/screenpy_selenium/actions/release.py index d7afc04..ca21f1c 100644 --- a/screenpy_selenium/actions/release.py +++ b/screenpy_selenium/actions/release.py @@ -9,6 +9,7 @@ from screenpy.pacing import beat from selenium.webdriver.common.keys import Keys +from ..common import pos_args_deprecated from ..speech_tools import KEY_NAMES if TYPE_CHECKING: @@ -75,7 +76,12 @@ def add_to_chain(self: SelfRelease, _: Actor, the_chain: ActionChains) -> None: msg = "Release must be told what to release." raise UnableToAct(msg) - def __init__(self: SelfRelease, key: str | None = None, lmb: bool = False) -> None: + @pos_args_deprecated("lmb") + def __init__( + self: SelfRelease, + key: str | None = None, + lmb: bool = False, # noqa: FBT001, FBT002 + ) -> None: self.key = key self.lmb = lmb self.description = "LEFT MOUSE BUTTON" if lmb else KEY_NAMES[key] diff --git a/screenpy_selenium/common.py b/screenpy_selenium/common.py new file mode 100644 index 0000000..bf85295 --- /dev/null +++ b/screenpy_selenium/common.py @@ -0,0 +1,46 @@ +"""Module to hold shared objects.""" + +from __future__ import annotations + +import warnings +from functools import wraps +from typing import TYPE_CHECKING, Callable, TypeVar + +from typing_extensions import ParamSpec + +if TYPE_CHECKING: + P = ParamSpec("P") + T = TypeVar("T") + Function = Callable[P, T] + + +def pos_args_deprecated(*keywords: str) -> Function: + """Warn users which positional arguments should be called via keyword.""" + + def deprecated(func: Function) -> Function: + argnames = func.__code__.co_varnames[: func.__code__.co_argcount] + i = min([argnames.index(kw) for kw in keywords]) + kw_argnames = argnames[i:] + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Function: + # call the function first, to make sure the signature matches + ret_value = func(*args, **kwargs) + + args_that_should_be_kw = args[i:] + if args_that_should_be_kw: + posargnames = ", ".join(kw_argnames) + + msg = ( + f"Warning: positional arguments `{posargnames}` for " + f"`{func.__qualname__}` are deprecated " + f"and will be removed in version 5. " + f"Please use keyword arguments instead." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + + return ret_value + + return wrapper + + return deprecated diff --git a/screenpy_selenium/questions/selected.py b/screenpy_selenium/questions/selected.py index 026f36f..a0030f1 100644 --- a/screenpy_selenium/questions/selected.py +++ b/screenpy_selenium/questions/selected.py @@ -7,6 +7,8 @@ from screenpy.pacing import beat from selenium.webdriver.support.ui import Select as SeleniumSelect +from ..common import pos_args_deprecated + if TYPE_CHECKING: from screenpy import Actor @@ -90,6 +92,9 @@ def answered_by(self: SelfSelected, the_actor: Actor) -> str | list[str]: return [e.text for e in select.all_selected_options] return select.first_selected_option.text - def __init__(self: SelfSelected, target: Target, multi: bool = False) -> None: + @pos_args_deprecated("multi") + def __init__( + self: SelfSelected, target: Target, multi: bool = False # noqa: FBT001, FBT002 + ) -> None: self.target = target self.multi = multi diff --git a/screenpy_selenium/questions/text.py b/screenpy_selenium/questions/text.py index 5e9eab5..5f09e28 100644 --- a/screenpy_selenium/questions/text.py +++ b/screenpy_selenium/questions/text.py @@ -6,6 +6,8 @@ from screenpy.pacing import beat +from ..common import pos_args_deprecated + if TYPE_CHECKING: from screenpy import Actor @@ -70,6 +72,9 @@ def answered_by(self: SelfText, the_actor: Actor) -> str | list[str]: return [e.text for e in self.target.all_found_by(the_actor)] return self.target.found_by(the_actor).text - def __init__(self: SelfText, target: Target, multi: bool = False) -> None: + @pos_args_deprecated("multi") + def __init__( + self: SelfText, target: Target, multi: bool = False # noqa: FBT001, FBT002 + ) -> None: self.target = target self.multi = multi diff --git a/tests/test_actions.py b/tests/test_actions.py index b443858..7e4cf30 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import cast +import warnings +from contextlib import contextmanager +from typing import Generator, cast from unittest import mock import pytest @@ -63,6 +65,20 @@ TARGET = FakeTarget() +@contextmanager +def not_raises(ExpectedException: type[Exception]) -> Generator: + try: + yield + + except ExpectedException as error: + msg = f"Incorrectly Raised {error}" + raise AssertionError(msg) from error + + except Exception as error: # noqa: BLE001 + msg = f"Unexpected exception {error}" + raise AssertionError(msg) from error + + class TestAcceptAlert: def test_can_be_instantiated(self) -> None: aa = AcceptAlert() @@ -435,6 +451,19 @@ def new_method(self) -> bool: assert SubEnter.the_text("blah").new_method() is True + def test_positional_arg_warns(self) -> None: + with pytest.warns(DeprecationWarning): + Enter("", True) + + def test_keyword_arg_does_not_warn(self) -> None: + with not_raises(DeprecationWarning), warnings.catch_warnings(): + warnings.simplefilter("error") + Enter.the_secret("") + + with not_raises(DeprecationWarning), warnings.catch_warnings(): + warnings.simplefilter("error") + Enter("", mask=True) + class TestEnter2FAToken: def test_can_be_instantiated(self) -> None: @@ -619,6 +648,19 @@ def new_method(self) -> bool: assert SubHoldDown.left_mouse_button().new_method() is True + def test_positional_arg_warns(self) -> None: + with pytest.warns(DeprecationWarning): + HoldDown(Keys.LEFT_ALT, True) + + def test_keyword_arg_does_not_warn(self) -> None: + with not_raises(DeprecationWarning), warnings.catch_warnings(): + warnings.simplefilter("error") + HoldDown.left_mouse_button() + + with not_raises(DeprecationWarning), warnings.catch_warnings(): + warnings.simplefilter("error") + HoldDown(lmb=True) + class TestMoveMouse: def test_can_be_instantiated(self) -> None: @@ -884,6 +926,19 @@ def new_method(self) -> bool: assert SubRelease.left_mouse_button().new_method() is True + def test_positional_arg_warns(self) -> None: + with pytest.warns(DeprecationWarning): + Release(Keys.LEFT_ALT, True) + + def test_keyword_arg_does_not_warn(self) -> None: + with not_raises(DeprecationWarning), warnings.catch_warnings(): + warnings.simplefilter("error") + Release.left_mouse_button() + + with not_raises(DeprecationWarning), warnings.catch_warnings(): + warnings.simplefilter("error") + Release(lmb=True) + class TestRespondToThePrompt: def test_can_be_instantiated(self) -> None: diff --git a/tests/test_questions.py b/tests/test_questions.py index 6517afd..23dbd1b 100644 --- a/tests/test_questions.py +++ b/tests/test_questions.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from unittest import mock import pytest @@ -309,6 +310,19 @@ def test_describe(self) -> None: Selected(TARGET).describe() == f"The selected option(s) from the {TARGET}." ) + def test_positional_arg_warns(self) -> None: + with pytest.warns(DeprecationWarning): + Selected(TARGET, True) + + def test_keyword_arg_does_not_warn(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Selected.options_from_the(TARGET) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + Selected(TARGET, multi=True) + class TestText: def test_can_be_instantiated(self) -> None: @@ -379,3 +393,16 @@ def test_ask_for_text_of_the_alert(self, Tester: Actor) -> None: def test_describe(self) -> None: assert TextOfTheAlert().describe() == "The text of the alert." + + def test_positional_arg_warns(self) -> None: + with pytest.warns(DeprecationWarning): + Text(TARGET, True) + + def test_keyword_arg_does_not_warn(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Text.of_all(TARGET) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + Text(TARGET, multi=True)