Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

17 and 18: Fix Target weirdness. #19

Merged
merged 4 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ split-on-trailing-comma = false
"PLR", # likewise using specific numbers and strings in tests.
]
"__version__.py" = ["D"]
"*.pyi" = [
"PLR0913", # it's not our fault they have too many parameters!
]


################################################################################
Expand Down
55 changes: 41 additions & 14 deletions screenpy_playwright/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Pattern, Tuple, TypedDict, Union

from playwright.sync_api import Locator
from playwright.sync_api import FrameLocator, Locator

from .abilities import BrowseTheWebSynchronously
from .exceptions import TargetingError
Expand Down Expand Up @@ -44,6 +44,10 @@ class _Manipulation(UserString):
This class allows the ScreenPy Playwright Target to behave just like a
Playwright Locator, which has a robust, chainable API for describing
elements.

This approach necessary because Locators are built from a Page base. We
don't have a Page to build from until the Actor is provided during the
:meth:`Target.found_by` call.
"""

target: Target
Expand Down Expand Up @@ -73,7 +77,7 @@ def __call__(
self.kwargs = kwargs
return self.target

def __repr__(self) -> str:
def get_locator(self) -> str:
"""Reconstruct the locator function/attribute string."""
args = kwargs = left_paren = right_paren = comma = ""
if self.args is not None or self.kwargs is not None:
Expand All @@ -87,11 +91,15 @@ def __repr__(self) -> str:
comma = ", "
return f"{self.name}{left_paren}{args}{comma}{kwargs}{right_paren}"

def __repr__(self) -> str:
"""Return the Target for representation."""
return repr(self.target)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change handles the Target being correctly logged. Before, for a Target like:

t = Target.the("Ossuary door").located_by("graveyard > building door").first

Logging would be like:

Tor knocks on the first

rather than what it is now:

Tor knocks on the Ossuary door


# make sure we handle str here as well
__str__ = __repr__


class Target:
class Target(Locator, FrameLocator):
"""A described element on a webpage.

Uses Playwright's Locator API to describe an element on a webpage, with a
Expand Down Expand Up @@ -138,15 +146,26 @@ def in_frame(self, frame_locator: str) -> Target:
)
return self

def __getattr__(self, name: str) -> _Manipulation:
def __getattribute__(self, name: str) -> _Manipulation:
"""Convert a Playwright Locator strategy into a Manipulation."""
if not hasattr(Locator, name):
msg = f"'{name}' is not a valid Playwright Locator strategy."
raise AttributeError(msg)
is_private = name.startswith("_")
superclass_has_attr = hasattr(Locator, name) or hasattr(FrameLocator, name)
if not is_private and superclass_has_attr:
attr = getattr(Locator, name) or getattr(FrameLocator, name)
is_property = type(attr) is property
r_type = getattr(attr, "__annotations__", {}).get("return")
is_right_type = r_type is Locator or r_type is FrameLocator
is_right_str = r_type in ["Locator", "FrameLocator"]

if is_property or is_right_type or is_right_str:
manipulation = _Manipulation(self, name)
self.manipulations.append(manipulation)
return manipulation

msg = f"'{name}' cannot be accessed until `found_by` is called."
raise TargetingError(msg)

manipulation = _Manipulation(self, name)
self.manipulations.append(manipulation)
return manipulation
return super().__getattribute__(name)

@property
def target_name(self) -> str:
Expand All @@ -161,7 +180,7 @@ def target_name(self) -> str:
if self._description:
target_name = self._description
elif self.manipulations:
target_name = ".".join(map(repr, self.manipulations))
target_name = ".".join(m.get_locator() for m in self.manipulations)
else:
target_name = "None"
return target_name
Expand Down Expand Up @@ -196,9 +215,17 @@ def found_by(self, the_actor: Actor) -> Locator:
if manipulation.args is None and manipulation.kwargs is None:
locator = getattr(locator, manipulation.name)
else:
locator = getattr(locator, manipulation.name)(
*manipulation.args, **manipulation.kwargs
)
args = []
for arg in manipulation.args:
arg_to_append = arg
if isinstance(arg, Target):
arg_to_append = arg.found_by(the_actor)
args.append(arg_to_append)
kwargs = {
k: v.found_by(the_actor) if isinstance(v, Target) else v
for k, v in manipulation.kwargs.items()
}
locator = getattr(locator, manipulation.name)(*args, **kwargs)

return locator

Expand Down
122 changes: 122 additions & 0 deletions screenpy_playwright/target.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Used to overwrite type hints for Locator methods from Playwright.

Hopefully this is only temporary. -pgoy 2024-JUL-12
"""

from collections import UserString
from dataclasses import dataclass
from typing import Pattern, TypedDict

from playwright.sync_api import FrameLocator, Locator
from screenpy import Actor as Actor
from typing_extensions import NotRequired, Self, Unpack

from .abilities import BrowseTheWebSynchronously as BrowseTheWebSynchronously
from .exceptions import TargetingError as TargetingError

_ManipulationArgsType = tuple[str | int | None, ...]

class _ManipulationKwargsType(TypedDict):
has_text: NotRequired[str | Pattern[str] | None]
has_not_text: NotRequired[str | Pattern[str] | None]
has: NotRequired[Locator | None]
has_not: NotRequired[Locator | None]
exact: NotRequired[bool | None]
checked: NotRequired[bool | None]
disabled: NotRequired[bool | None]
expanded: NotRequired[bool | None]
include_hidden: NotRequired[bool | None]
level: NotRequired[int | None]
name: NotRequired[str | Pattern[str] | None]
pressed: NotRequired[bool | None]
selected: NotRequired[bool | None]

@dataclass
class _Manipulation(UserString):
def __hash__(self) -> int: ...
def __eq__(self, other: object) -> bool: ...
def __getattr__(self, name: str) -> Target | _Manipulation: ...
def __call__(
self,
*args: Unpack[_ManipulationArgsType],
**kwargs: Unpack[_ManipulationKwargsType],
) -> Target: ...
def get_locator(self) -> str: ...
def __init__(
self,
target: Target,
name: str,
args: _ManipulationArgsType | None = ...,
kwargs: _ManipulationKwargsType | None = ...,
) -> None: ...

class Target(FrameLocator, Locator):
manipulations: list[_Manipulation]
first: Target
last: Target
owner: Target
content_frame: Target
@classmethod
def the(cls, name: str) -> Self: ...
def located_by(self, locator: str) -> Target: ...
def in_frame(self, frame_locator: str) -> Target: ...
def __getattribute__(self, name: str) -> _Manipulation: ...
@property
def target_name(self) -> str: ...
@target_name.setter
def target_name(self, value: str) -> None: ...
def found_by(self, the_actor: Actor) -> Locator: ...
def __init__(self, name: str | None = None) -> None: ...

# overridden Locator methods
def and_(self, locator: Locator | Target) -> Target: ...
def filter(
self,
*,
has_text: str | Pattern | None = None,
has_not_text: str | Pattern | None = None,
has: Locator | Target | None = None,
has_not: Locator | Target | None = None,
) -> Target: ...
def frame_locator(self, selector: str) -> Target: ...
def get_by_alt_text(
self, text: str | Pattern, *, exact: bool | None = None
) -> Target: ...
def get_by_label(
self, text: str | Pattern, *, exact: bool | None = None
) -> Target: ...
def get_by_placeholder(
self, text: str | Pattern, *, exact: bool | None = None
) -> Target: ...
def get_by_role(
self,
role: str,
*,
checked: bool | None = None,
disabled: bool | None = None,
expanded: bool | None = None,
include_hidden: bool | None = None,
level: int | None = None,
name: str | Pattern | None = None,
pressed: bool | None = None,
selected: bool | None = None,
exact: bool | None = None,
) -> Target: ...
def get_by_test_id(self, test_id: str | Pattern) -> Target: ...
def get_by_text(
self, text: str | Pattern, *, exact: bool | None = None
) -> Target: ...
def get_by_title(
self, text: str | Pattern, *, exact: bool | None = None
) -> Target: ...
def locator(
self,
selector: str | Locator,
*,
has_text: str | Pattern | None = None,
has_not_text: str | Pattern | None = None,
has: Locator | Target | None = None,
has_not: Locator | Target | None = None,
) -> Target: ...
def nth(self, index: int) -> Target: ...
def or_(self, locator: Locator | Target) -> Target: ...
36 changes: 27 additions & 9 deletions tests/test_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ def test_proper_display(self) -> None:
m_with_args_but_no_kwargs = _Manipulation(Target(), name, args=args)
m_with_no_args_but_kwargs = _Manipulation(Target(), name, kwargs=kwargs)

assert repr(m_for_attribute) == name
assert repr(m_with_neither) == f"{name}()"
assert repr(m_with_both) == f"{name}({args_str}, {kwargs_str})"
assert repr(m_with_args_but_no_kwargs) == f"{name}({args_str})"
assert repr(m_with_no_args_but_kwargs) == f"{name}({kwargs_str})"
assert m_for_attribute.get_locator() == name
assert m_with_neither.get_locator() == f"{name}()"
assert m_with_both.get_locator() == f"{name}({args_str}, {kwargs_str})"
assert m_with_args_but_no_kwargs.get_locator() == f"{name}({args_str})"
assert m_with_no_args_but_kwargs.get_locator() == f"{name}({kwargs_str})"

def test_defers_to_target_for_unknown_attributes(self) -> None:
target = Target.the("spam")
Expand Down Expand Up @@ -71,12 +71,33 @@ def test_auto_describe(self) -> None:
t3 = Target()
t4 = Target("").get_by_label("baz")
t5 = Target().located_by("foo").get_by_label("bar").first
t6 = Target("a dead parrot").last

assert t1.target_name == "locator('#yellow')"
assert t2.target_name == "favorite color"
assert t3.target_name == "None"
assert t4.target_name == "get_by_label('baz')"
assert t5.target_name == "locator('foo').get_by_label('bar').first"
assert t6.target_name == "a dead parrot"

def test_invalid_method_raises(self) -> None:
with pytest.raises(AttributeError):
Target("acquired").not_a_real_method()

def test_method_too_soon_raises(self) -> None:
with pytest.raises(TargetingError):
Target("is having a FIRE sale! Oh god!! Help!!!").click()

def test_dunders_are_accessible(self) -> None:
target = Target("Dunder Mifflin, this is Pam.")
redirected_target = Target().get_by_label("Dunder Mifflin, Jim speaking.")
manipulation = Target().first

# dir accesses __dict__ behind the scenes
# (and is how this issue was found)
dir(target)
dir(redirected_target)
dir(manipulation)

def test_found_by(self, Tester: Actor) -> None:
test_locator = "#spam>baked-beans>eggs>sausage+spam"
Expand Down Expand Up @@ -141,11 +162,8 @@ def test_found_by_chain(self, Tester: Actor) -> None:
mocked_btws = Tester.ability_to(BrowseTheWebSynchronously)
mocked_btws.current_page = mock.Mock()

manipulation = Target.the("test").located_by(test_locator).first
# mypy thinks this will be a Target. It will not be.
target = manipulation.get_by_label("foo") # type: ignore[operator]
target = Target.the("test").located_by(test_locator).first.get_by_label("foo")

assert isinstance(manipulation, _Manipulation)
assert isinstance(target, Target)

def test_found_by_raises_if_no_locator(self, Tester: Actor) -> None:
Expand Down