Skip to content

Commit

Permalink
Merge pull request #11 from ScreenPyHQ/extend-target-capabilities
Browse files Browse the repository at this point in the history
Extend Target to support Playwright Locator API.
  • Loading branch information
perrygoy authored May 7, 2024
2 parents 8967c17 + 5bb7cea commit 04d7c50
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 99 deletions.
3 changes: 2 additions & 1 deletion docs/extended_api/target.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ Target
======

This is the Target class and its API.
Target is used to pair
a `Playwright Locator <https://playwright.dev/python/docs/locators>`_ with a human-readable name.

Target API
----------

.. autoclass:: screenpy_playwright.Target
:members:

4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ ScreenPy Playwright: A ScreenPy Extension
=========================================

Not sure what ScreenPy is?
Check out `ScreenPy's documentation <https://screenpy-docs.readthedocs.io/en/latest/>`__ first!
Check out `ScreenPy's documentation <https://screenpy-docs.readthedocs.io/en/latest/>`_ first!

``screenpy_playwright`` extends ScreenPy
by providing an Ability,
Actions,
and Questions
which interact with `Playwright <https://playwright.dev/>`__.
which interact with `Playwright <https://playwright.dev/>`_.
This enables your Actors
to :class:`~screenpy_playwright.abilities.BrowseTheWebSynchronously`!

Expand Down
131 changes: 112 additions & 19 deletions screenpy_playwright/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,98 @@

from __future__ import annotations

from collections import UserString
from dataclasses import dataclass
from typing import TYPE_CHECKING

from playwright.sync_api import Locator

from .abilities import BrowseTheWebSynchronously
from .exceptions import TargetingError

if TYPE_CHECKING:
from playwright.sync_api import FrameLocator, Locator, Page
from screenpy import Actor
from typing_extensions import Self


@dataclass
class _Manipulation(UserString):
"""Represent one of the Playwright options for creating a locator.
Could be a function or an attribute.
This class allows the ScreenPy Playwright Target to behave just like a
Playwright Locator, which has a robust, chainable API for describing
elements.
"""

target: Target
name: str
args: tuple | None = None
kwargs: dict | None = None

def __hash__(self) -> int:
"""Appear as the name, in case this is an attribute and not a method."""
return hash(self.name)

def __eq__(self, other: object) -> bool:
"""Appear as the name, in case this is an attribute and not a method."""
return self.name == other

def __getattr__(self, name: str) -> Target | _Manipulation:
"""Defer back to the Target for unknown attributes."""
return getattr(self.target, name)

def __call__(self, *args: str, **kwargs: str) -> Target:
"""Add args and kwargs to the manipulation."""
self.args = args
self.kwargs = kwargs
return self.target

def __repr__(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:
left_paren = "("
right_paren = ")"
if self.args:
args = ", ".join(repr(a) for a in self.args)
if self.kwargs:
kwargs = ", ".join(f"{k}={v!r}" for k, v in self.kwargs.items())
if args and kwargs:
comma = ", "
return f"{self.name}{left_paren}{args}{comma}{kwargs}{right_paren}"

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


class Target:
"""A described element on a webpage.
Uses Playwright's Locator API to describe an element on a webpage, with a
few natural-language helpers. See the
`Playwright documentation <https://playwright.dev/python/docs/locators>`_
for more information.
Examples::
# Using CSS
Target.the('"Log In" button').located_by("button:has-text('Log In')")
# Using xpath
Target.the("toast message").located_by("//toast")
Target.the('"Pick up Milk" todo item").located_by(
"_vue=list-item[text *= 'milk' i]"
# Using Target methods with Playwright strategies
Target.the('"Pick up Milk" todo item').in_frame("#todoframe").get_by_text(
"Pick up Milk"
)
Target().located_by("#enter-todo-field")
# Using Playwright strategies directly
Target().frame_locator("#todoframe").get_by_label("todo")
"""

locator: str | None
frame_path: list[str]
manipulations: list[_Manipulation]
_description: str | None

@classmethod
Expand All @@ -39,23 +102,46 @@ def the(cls: type[Self], name: str) -> Self:
return cls(name)

def located_by(self, locator: str) -> Target:
"""Provide the Playwright locator which describes the element."""
self.locator = locator
"""Provide the CSS locator which describes the element."""
self.manipulations.append(
_Manipulation(self, "locator", args=(locator,), kwargs={})
)
return self

def in_frame(self, frame_locator: str) -> Target:
"""Provide the Playwright locator which describes the frame."""
self.frame_path.append(frame_locator)
"""Provide the CSS locator which describes the frame."""
self.manipulations.append(
_Manipulation(self, "frame_locator", args=(frame_locator,), kwargs={})
)
return self

def __getattr__(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)

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

@property
def target_name(self) -> str:
"""Get the name of the Target.
If a description was not provided, an identifier will be created using
the manipulation chain.
Returns:
The text representation of this Target.
"""
return self._description or self.locator or "None"
if self._description:
target_name = self._description
elif self.manipulations:
target_name = ".".join(map(repr, self.manipulations))
else:
target_name = "None"
return target_name

@target_name.setter
def target_name(self, value: str) -> None:
Expand All @@ -79,15 +165,21 @@ def found_by(self, the_actor: Actor) -> Locator:
if browse_the_web.current_page is None:
msg = f"There is no active page! {the_actor} cannot find the {self}."
raise TargetingError(msg)
if self.locator is None:
msg = f"{self} does not have a locator set."
if not self.manipulations:
msg = f"{self} does not have any locator strategy set."
raise TargetingError(msg)

frame: Page | FrameLocator = browse_the_web.current_page
for frame_locator in self.frame_path:
frame = frame.frame_locator(frame_locator)
# Start with a base locator to ease typing. :face_rolling_eyes:
locator = browse_the_web.current_page.locator("html")
for manipulation in self.manipulations:
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
)

return frame.locator(self.locator)
return locator

def __repr__(self) -> str:
"""Get a human-readable representation of this Target.
Expand All @@ -97,7 +189,8 @@ def __repr__(self) -> str:
"""
return self.target_name

__str__ = __repr__

def __init__(self, name: str | None = None) -> None:
self._description = name
self.locator = None
self.frame_path = []
self.manipulations = []
Loading

0 comments on commit 04d7c50

Please sign in to comment.