-
Notifications
You must be signed in to change notification settings - Fork 5.3k
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
Add slimmed-down Selenium project code #596
base: master
Are you sure you want to change the base?
Conversation
@bzaczynski this is my attempt to create a slimmed-down version of the project that primarily focuses on Selenium web interactions (now following POM), but also keep the basic functionality of the music player (and a reduced---but existing---separation of concerns). I've tried to build the project in a way so that it can lead into the project that you wrote, with the idea in mind that learners could follow your SbSP as a next step if they're interested in the music player project. At the same time, I tried to keep it simple and reduced enough that learners who are only there for an intro to Selenium would get their money's worth. Hope this works and looking forward to your thoughts! Linking the other PR for reference: #583 |
python-selenium/README.md
Outdated
|
||
## Run the Bandcamp Discover Player | ||
|
||
To run the music placer, navigate to the `src/` folder, then execute the module from your command-line: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✍️ Typo
To run the music placer, navigate to the `src/` folder, then execute the module from your command-line: | |
To run the music player, navigate to the `src/` folder, then execute the module from your command-line: |
python-selenium/README.md
Outdated
(venv) $ cd src/ | ||
(venv) $ python -m bandcamp |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
That doesn't work:
(venv) $ cd src/
(venv) $ python -m bandcamp/
/home/bartek/.virtualenvs/python-selenium/bin/python: No module named bandcamp/
A better option would be to install the package into the venv first. That way, it won't matter where you'll run the module from:
(venv) $ python -m pip install .
(venv) $ python -m bandcamp/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin I left a few comments for you. Let me know what you think.
(venv) $ python -m bandcamp | ||
``` | ||
|
||
You'll see a text-based user interface that allows you to interact with the music player: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤝 Handholding
It seems that you implicitly assume there's a driver for Firefox installed and configured for this to run. I don't have one, and this is what I got:
(venv) $ python -m bandcamp
Traceback (most recent call last):
...
selenium.common.exceptions.InvalidArgumentException: Message: binary is not a Firefox executable
Probably better to give a heads-up or provide instructions on what needs to be installed before.
python-selenium/README.md
Outdated
You'll see a text-based user interface that allows you to interact with the music player: | ||
|
||
``` | ||
Type: [play <track number>], [tracks], [more], [exit] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flagging the message (I'll explain why in another comment.)
python-selenium/pyproject.toml
Outdated
[tool.setuptools.packages.find] | ||
where = ["src"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
These two lines are redundant, as setuptools will automatically discover Python packages following the src-layout.
python-selenium/requirements.txt
Outdated
appdirs==1.4.4 | ||
attrs==24.2.0 | ||
certifi==2024.7.4 | ||
h11==0.14.0 | ||
idna==3.7 | ||
jedi==0.19.1 | ||
outcome==1.3.0.post0 | ||
parso==0.8.4 | ||
prompt_toolkit==3.0.47 | ||
ptpython==3.0.29 | ||
Pygments==2.18.0 | ||
PySocks==1.7.1 | ||
selenium==4.23.1 | ||
sniffio==1.3.1 | ||
sortedcontainers==2.4.0 | ||
trio==0.26.1 | ||
trio-websocket==0.11.1 | ||
typing_extensions==4.12.2 | ||
urllib3==2.2.2 | ||
wcwidth==0.2.13 | ||
websocket-client==1.8.0 | ||
wsproto==1.2.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
Are you sure you haven't accidentally installed any extra packages besides Selenium? Here's what my requirements.txt file looks like:
attrs==24.2.0
certifi==2024.8.30
h11==0.14.0
idna==3.10
outcome==1.3.0.post0
PySocks==1.7.1
selenium==4.25.0
sniffio==1.3.1
sortedcontainers==2.4.0
trio==0.27.0
trio-websocket==0.11.1
typing_extensions==4.12.2
urllib3==2.2.3
websocket-client==1.8.0
wsproto==1.2.0
if __name__ == "__main__": | ||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
Do we need those two lines here? Since you defined the bandcamp-player
script in pyproject.toml
, you can invoke the main()
function directly:
$ bandcamp-player
Unless, you do want to have two ways of running your TUI:
bandcamp-player
python -m bandcamp
from bandcamp.app.player import Player | ||
|
||
|
||
class TUI: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
Since this class has no state, only behaviors, there's no need to define it. I'd strongly suggest replacing your class methods with simple top-level functions. It'll make the code less Java-esque ☕
|
||
|
||
def main(): | ||
"""Provides the main entry point for the app.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
According to the widely-adopted docstring conventions (PEP 257), this sort of comments should use the imperative form, e.g.:
"""Provides the main entry point for the app.""" | |
"""Provide the main entry point for the app.""" |
player.play(track_number) | ||
print(player._current_track._get_track_info()) | ||
|
||
def tracks(self, player): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
Can we use a more descriptive name for this method, such as this?
def tracks(self, player): | |
def display_tracks(self, player): |
It would render the dostring that follows less important 😉
class TUI: | ||
"""Provides a text-based user interface for a Bandcamp music player.""" | ||
|
||
COLUMN_WIDTH = CW = 30 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice aliasing!
class HomePageLocatorMixin: | ||
DISCOVER_RESULTS = (By.CLASS_NAME, "discover-results") | ||
TRACK = (By.CLASS_NAME, "discover-item") | ||
PAGINATION_BUTTON = (By.CLASS_NAME, "item-page") | ||
|
||
|
||
class TrackLocatorMixin: | ||
PLAY_BUTTON = (By.CSS_SELECTOR, "a") | ||
ALBUM = (By.CLASS_NAME, "item-title") | ||
GENRE = (By.CLASS_NAME, "item-genre") | ||
ARTIST = (By.CLASS_NAME, "item-artist") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔧 Technical Recommendation
I really like your approach of using the "mixin" classes to store various element locators. It's a clever way to keep them separate but close at the same time 👏
That being said, I'm not convinced we should call these classes mixins. A mixin class encapsulates behavior, and it typically depends on some attributes defined in the target class, so a mixin can't exist on its own—it needs to be "mixed-in" with another ingredient.
In this case, your classes are just data containers or convenient namespaces for a bunch of constants that can stand on their own. So, I'd suggest to drop the "Mixin" suffix from their names.
Co-authored-by: Bartosz <bartosz.zaczynski@gmail.com>
…ials into python-selenium
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin Perfecto ✨ Thanks for a quick update! Can't wait to review the rest of it 😉
Bandcamp removed the "Discover" section from their main page since we wrote this code. Now, the tracks are only available at the dedicated /discover URL. I restructured the code to target the /discover site instead, which required a couple of changes. Still, the existing POM structure was helpful :) I also (re)introduced a new "pause" option, because that's a bit easier in this new structure. Finally, I only allow loading more songs once, which gives a total of 120 songs to pick from with the default screen setting. Attempting to load more resulted in errors, I think because they'd be outside of the viewport and I didn't want to expand the code more and introduce scrolling more of them into view. Also, not sure whether that'd take earlier ones out of the viewport (etc) so I just decided not to open that can of worms for this "Intro to Selenium" tutorial. LMK if you disagree, otherwise of course you can tackle that in your longer one that builds on top of this one :)
Co-authored-by: Bartosz <bartosz.zaczynski@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin Hi Martin!
The code looks great overall. I only left a few minor comments for your consideration, but no explicit requests for change.
Take care!
|
||
The only direct dependency for this project is [Selenium](https://selenium-python.readthedocs.io/). You should use a Python version of at least 3.10, which is necessary to support [structural pattern matching](https://realpython.com/structural-pattern-matching/). | ||
|
||
You'll need a [Firefox Selenium driver](https://selenium-python.readthedocs.io/installation.html#drivers) called `geckodriver` to run the project as-is. Make sure to [download and install](https://github.com/mozilla/geckodriver/releases) it before running the project. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin For some reason, I was having trouble starting the script due to problems with my Firefox browser. Your script would just hang without showing any output, leaving me a bit puzzled. However, I had a hunch where to look for the problem, so switching to Chrome fixed it for me. I'm wondering if we could give the users some heads up in the README about potential issues and how to troubleshoot them.
def play(player, track_number=None): | ||
"""Play a track and shows info about the track.""" | ||
player.play(track_number) | ||
print(player._current_track._get_track_info()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin It would be nice to include some rudimentary error handling. For example, when I request to play a track outside the valid range, the script crashes abruptly.
return self._parent.find_element(*self.PLAY_BUTTON) | ||
|
||
|
||
class DiscoverTrackList(WebComponent, DiscoverPageLocator, TrackLocator): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin I believe I mentioned this before, but I'm not a big fan of abusing inheritance for code reuse like here, where you inherit the constans. Class mixins are primarily about combining behaviors. When you only have data attributes, it's clearer to use composition and delegation instead.
For example, instead of this:
view_more_button = self._driver.find_element(*self.PAGINATION_BUTTON)
...you'd have that:
class DiscoverTrackList(WebComponent):
# ...
view_more_button = self._driver.find_element(*DiscoverPageLocator.PAGINATION_BUTTON)
While more verbose, this version tells me exactly which web component the locator belongs to, so I don't have to hunt for it in the source code.
self._wait.until( | ||
lambda driver: any( | ||
e.is_displayed() and e.text.strip() | ||
for e in driver.find_elements(*self.ITEM) | ||
), | ||
message="Timeout waiting for track text to load", | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@martin-martin This lambda expression is relatively dense, which is why I'd personally opt for a regular function with a descriptive name so that the code reads more easily:
def _get_available_tracks(self) -> list:
self._wait.until(track_text_loaded,
message="Timeout waiting for track text to load",
)
# ...
def track_text_loaded(driver):
return any(
e.is_displayed() and e.text.strip()
for e in driver.find_elements(*self.ITEM)
)
Co-authored-by: Bartosz <bartosz.zaczynski@gmail.com>
Where to put new files:
my-awesome-article
How to merge your changes: