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

Add slimmed-down Selenium project code #596

Open
wants to merge 14 commits into
base: master
Choose a base branch
from

Conversation

martin-martin
Copy link
Contributor

Where to put new files:

  • New files should go into a top-level subfolder, named after the article slug. For example: my-awesome-article

How to merge your changes:

  1. Make sure the CI code style tests all pass (+ run the automatic code formatter if necessary).
  2. Find an RP Team member on Slack and ask them to review & approve your PR.
  3. Once the PR has one positive ("approved") review, GitHub lets you merge the PR.
  4. 🎉

@martin-martin martin-martin self-assigned this Oct 14, 2024
@martin-martin
Copy link
Contributor Author

@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


## Run the Bandcamp Discover Player

To run the music placer, navigate to the `src/` folder, then execute the module from your command-line:
Copy link
Contributor

Choose a reason for hiding this comment

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

✍️ Typo

Suggested change
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:

Comment on lines 22 to 23
(venv) $ cd src/
(venv) $ python -m bandcamp
Copy link
Contributor

@bzaczynski bzaczynski Oct 23, 2024

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/

Copy link
Contributor

@bzaczynski bzaczynski left a 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:
Copy link
Contributor

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.

You'll see a text-based user interface that allows you to interact with the music player:

```
Type: [play <track number>], [tracks], [more], [exit]
Copy link
Contributor

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.)

Comment on lines 20 to 21
[tool.setuptools.packages.find]
where = ["src"]
Copy link
Contributor

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.

Comment on lines 1 to 22
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
Copy link
Contributor

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

Comment on lines 10 to 11
if __name__ == "__main__":
main()
Copy link
Contributor

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:
Copy link
Contributor

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."""
Copy link
Contributor

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.:

Suggested change
"""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):
Copy link
Contributor

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?

Suggested change
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
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice aliasing!

Comment on lines 4 to 14
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")
Copy link
Contributor

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.

Copy link
Contributor

@bzaczynski bzaczynski left a 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 😉

martin-martin and others added 3 commits March 12, 2025 19:43
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>
Copy link
Contributor

@bzaczynski bzaczynski left a 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.
Copy link
Contributor

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.

Comment on lines 43 to 46
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())
Copy link
Contributor

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):
Copy link
Contributor

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.

Comment on lines 65 to 71
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",
)
Copy link
Contributor

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)
    )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants