diff --git a/requirements.txt b/requirements.txt index cd1dd136..8051cb7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ pyYAML==5.4.1 sanitize-filename==1.2.0 yt-dlp==2021.9.2 pytest==7.1.1 +coverage==6.3.2 \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/entries/__init__.py b/tests/unit/entries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/entries/conftest.py b/tests/unit/entries/conftest.py new file mode 100644 index 00000000..a817c126 --- /dev/null +++ b/tests/unit/entries/conftest.py @@ -0,0 +1,75 @@ +import pytest + +from ytdl_subscribe.entries.entry import Entry + + +@pytest.fixture +def uid(): + return "abc123" + + +@pytest.fixture +def title(): + return "entry title" + + +@pytest.fixture +def upload_year(): + return 2021 + + +@pytest.fixture +def upload_date(upload_year): + return f"{upload_year}-01-06" + + +@pytest.fixture +def ext(): + return "mp5" + + +@pytest.fixture +def thumbnail_ext(): + return "yall" + + +@pytest.fixture +def thumbnail(thumbnail_ext): + return f"thumb.{thumbnail_ext}" + + +@pytest.fixture +def download_file_name(uid, ext): + return f"{uid}.{ext}" + + +@pytest.fixture +def mock_entry_to_dict( + uid, title, ext, upload_date, upload_year, thumbnail, thumbnail_ext +): + return { + "uid": uid, + "title": title, + "sanitized_title": title, + "ext": ext, + "upload_date": upload_date, + "upload_year": upload_year, + "thumbnail": thumbnail, + "thumbnail_ext": thumbnail_ext, + } + + +@pytest.fixture +def mock_entry_kwargs(uid, title, ext, upload_date, thumbnail): + return { + "id": uid, + "title": title, + "ext": ext, + "upload_date": upload_date, + "thumbnail": thumbnail, + } + + +@pytest.fixture +def mock_entry(mock_entry_kwargs): + return Entry(**mock_entry_kwargs) diff --git a/tests/unit/entries/test_entry.py b/tests/unit/entries/test_entry.py new file mode 100644 index 00000000..de4cb58a --- /dev/null +++ b/tests/unit/entries/test_entry.py @@ -0,0 +1,82 @@ +import tempfile +import pytest + + +class TestEntry(object): + def test_entry_properties( + self, + mock_entry, + uid, + title, + upload_date, + upload_year, + ext, + thumbnail, + thumbnail_ext, + download_file_name, + ): + assert mock_entry.uid == uid + assert mock_entry.title == title + # Title does not require sanitizing, so should be the same + assert mock_entry.sanitized_title == title + assert mock_entry.upload_date == upload_date + assert mock_entry.upload_year == upload_year + assert mock_entry.ext == ext + assert mock_entry.thumbnail == thumbnail + assert mock_entry.thumbnail_ext == thumbnail_ext + assert mock_entry.download_file_name == download_file_name + + def test_entry_to_dict(self, mock_entry, mock_entry_to_dict): + assert mock_entry.to_dict() == mock_entry_to_dict + + def test_entry_file_path(self, mock_entry, download_file_name): + relative_directory = tempfile.TemporaryDirectory() + + try: + file_path = mock_entry.file_path(relative_directory.name) + assert isinstance(file_path, str) + assert file_path == f"{relative_directory.name}/{download_file_name}" + finally: + relative_directory.cleanup() + + def test_entry_formatter_single_field(self, mock_entry): + format_string = f"prefix {{uid}} suffix" + expected_string = f"prefix {mock_entry.uid} suffix" + + assert mock_entry.apply_formatter(format_string) == expected_string + + def test_entry_formatter_duplicate_fields(self, mock_entry): + format_string = f"prefix {{upload_year}} {{upload_year}} suffix" + expected_string = ( + f"prefix {mock_entry.upload_year} {mock_entry.upload_year} suffix" + ) + + assert mock_entry.apply_formatter(format_string) == expected_string + + def test_entry_formatter_override(self, mock_entry): + new_uid = "my very own uid" + overrides = {"uid": new_uid} + + format_string = f"prefix {{uid}} suffix" + expected_string = f"prefix {new_uid} suffix" + + assert ( + mock_entry.apply_formatter(format_string, overrides=overrides) + == expected_string + ) + + def test_entry_missing_kwarg(self, mock_entry): + key = "dne" + expected_error_msg = f"Expected '{key}' in Entry but does not exist." + + assert mock_entry.kwargs_contains(key) is False + with pytest.raises(KeyError, match=expected_error_msg): + mock_entry.kwargs(key) + + def test_entry_formatter_fails_missing_field(self, mock_entry): + format_string = f"prefix {{bah_humbug}} suffix" + available_fields = ", ".join(sorted(mock_entry.to_dict().keys())) + expected_error_msg = f"Format variable 'bah_humbug' does not exist for Entry. Available fields: {available_fields}" + + with pytest.raises(ValueError, match=expected_error_msg): + assert mock_entry.apply_formatter(format_string) diff --git a/tests/unit/entries/test_entry_formatter.py b/tests/unit/entries/test_entry_formatter.py new file mode 100644 index 00000000..7be76c29 --- /dev/null +++ b/tests/unit/entries/test_entry_formatter.py @@ -0,0 +1,89 @@ +import pytest + +from ytdl_subscribe.entries.entry import EntryFormatter + + +@pytest.fixture +def error_message_unequal_brackets_str(): + return "Brackets are reserved for {variable_names} and should contain a single open and close bracket." + + +@pytest.fixture +def error_message_unequal_regex_matches_str(): + return "{variable_names} should only contain lowercase letters and underscores with a single open and close bracket." + + +@pytest.fixture +def error_message_prefix_generator(): + def _error_message_prefix_generator(format_string): + return f"Format string '{format_string}' is invalid: " + + return _error_message_prefix_generator + + +@pytest.fixture +def error_message_unequal_brackets_generator( + error_message_prefix_generator, error_message_unequal_brackets_str +): + def _error_message_unequal_brackets_generator(format_string): + return f"{error_message_prefix_generator(format_string)}{error_message_unequal_brackets_str}" + + return _error_message_unequal_brackets_generator + + +@pytest.fixture +def error_message_unequal_regex_matches_generator( + error_message_prefix_generator, error_message_unequal_regex_matches_str +): + def _error_message_unequal_regex_matches_generator(format_string): + return f"{error_message_prefix_generator(format_string)}{error_message_unequal_regex_matches_str}" + + return _error_message_unequal_regex_matches_generator + + +class TestEntryFormatter(object): + def test_parse(self): + format_string = "Here is my {var_one} and {var_two} 💩" + assert EntryFormatter(format_string).parse() == ["var_one", "var_two"] + + def test_parse_no_variables(self): + format_string = "No vars 💩" + assert EntryFormatter(format_string).parse() == [] + + @pytest.mark.parametrize( + "format_string", + [ + "Try {var_one{ and {var_two}", + "Single open {", + "Single close }", + "Try }var_one} and {var_one}", + ], + ) + def test_parse_fail_uneven_brackets( + self, format_string, error_message_unequal_brackets_generator + ): + expected_error_msg = error_message_unequal_brackets_generator(format_string) + + with pytest.raises(ValueError, match=expected_error_msg): + assert EntryFormatter(format_string).parse() + + @pytest.mark.parametrize( + "format_string", + [ + "Try {var1} no numbers", + "Try {VAR1} no caps", + "Try {internal{bracket}}", + "Try }backwards{ facing", + "Try {var_1}}{", + "Try {} empty", + ], + ) + def test_parse_fail_variable( + self, format_string, error_message_unequal_regex_matches_generator + ): + expected_error_msg = error_message_unequal_regex_matches_generator( + format_string + ) + + with pytest.raises(ValueError, match=expected_error_msg): + assert EntryFormatter(format_string).parse() diff --git a/ytdl_subscribe/entries/entry.py b/ytdl_subscribe/entries/entry.py index 3537be85..619d4ccc 100644 --- a/ytdl_subscribe/entries/entry.py +++ b/ytdl_subscribe/entries/entry.py @@ -1,9 +1,41 @@ -from string import Formatter -from typing import Any, Dict, Optional +import re +from pathlib import Path +from typing import Any, Dict, Optional, List from sanitize_filename import sanitize +class EntryFormatter(object): + FIELDS_VALIDATOR = re.compile(r"{([a-z_]+?)}") + + def __init__(self, format_string: str): + self.format_string = format_string + self._error_prefix = f"Format string '{self.format_string}' is invalid:" + + def parse(self) -> List[str]: + """ + Returns + ------- + List of fields to format + """ + open_bracket_count = self.format_string.count("{") + close_bracket_count = self.format_string.count("}") + + if open_bracket_count != close_bracket_count: + raise ValueError( + f"{self._error_prefix} Brackets are reserved for {{variable_names}} and should contain a single open and close bracket." + ) + + parsed_fields = re.findall(EntryFormatter.FIELDS_VALIDATOR, self.format_string) + + if len(parsed_fields) != open_bracket_count: + raise ValueError( + f"{self._error_prefix} {{variable_names}} should only contain lowercase letters and underscores with a single open and close bracket." + ) + + return sorted(parsed_fields) + + class Entry(object): """ Entry object to represent a single media object returned from yt-dlp. @@ -59,7 +91,7 @@ class Entry(object): return f"{self.uid}.{self.ext}" def file_path(self, relative_directory: str): - return f"{relative_directory}/{self.download_file_name}" + return str(Path(relative_directory) / self.download_file_name) def to_dict(self) -> Dict: return { @@ -73,18 +105,20 @@ class Entry(object): "thumbnail_ext": self.thumbnail_ext, } - def apply_formatter(self, format_string: str, overrides: Optional[Dict]) -> str: + def apply_formatter( + self, format_string: str, overrides: Optional[Dict] = None + ) -> str: entry_dict = self.to_dict() if overrides: entry_dict = dict(entry_dict, **overrides) - field_names = [ - fname for _, fname, _, _ in Formatter().parse(format_string) if fname - ] - for fname in field_names: - if fname not in entry_dict: - raise KeyError( - f"Format variable '{fname}' does not exist for {self.__class__.__name__}." + field_names = EntryFormatter(format_string).parse() + + for field_name in field_names: + if field_name not in entry_dict: + available_fields = ", ".join(sorted(entry_dict.keys())) + raise ValueError( + f"Format variable '{field_name}' does not exist for {self.__class__.__name__}. Available fields: {available_fields}" ) return format_string.format(**entry_dict) diff --git a/ytdl_subscribe/entries/soundcloud.py b/ytdl_subscribe/entries/soundcloud.py index a2fe9503..0c243cc6 100644 --- a/ytdl_subscribe/entries/soundcloud.py +++ b/ytdl_subscribe/entries/soundcloud.py @@ -62,6 +62,7 @@ class SoundcloudAlbumTrack(SoundcloudTrack): def album_year(self) -> int: return self._album.album_year + class SoundcloudAlbum(Entry): def __init__(self, skip_premiere_tracks: bool, **kwargs): super(SoundcloudAlbum, self).__init__(**kwargs) diff --git a/ytdl_subscribe/entries/youtube.py b/ytdl_subscribe/entries/youtube.py index cfe4f92f..15a79498 100644 --- a/ytdl_subscribe/entries/youtube.py +++ b/ytdl_subscribe/entries/youtube.py @@ -5,7 +5,7 @@ class YoutubeVideo(Entry): @property def title(self) -> str: # Try to get the track, fall back on title - if self.kwargs_contains('track'): - return self.kwargs('track') + if self.kwargs_contains("track"): + return self.kwargs("track") return super(YoutubeVideo, self).title diff --git a/ytdl_subscribe/subscriptions/subscription.py b/ytdl_subscribe/subscriptions/subscription.py index 925efe80..4caafcc0 100644 --- a/ytdl_subscribe/subscriptions/subscription.py +++ b/ytdl_subscribe/subscriptions/subscription.py @@ -40,7 +40,7 @@ class Subscription(object): self.post_process = post_process self.overrides = overrides for k, v in deepcopy(overrides).items(): - self.overrides[f'sanitized_{k}'] = sanitize(v) + self.overrides[f"sanitized_{k}"] = sanitize(v) self.output_path = output_path # Separate each subscription's working directory @@ -50,9 +50,7 @@ class Subscription(object): self.ytdl_opts["outtmpl"] = self.WORKING_DIRECTORY + "/%(id)s.%(ext)s" self.ytdl_opts["writethumbnail"] = True - def format_filepath( - self, filepath_formatter: str, entry: Entry, makedirs=False - ): + def format_filepath(self, filepath_formatter: str, entry: Entry, makedirs=False): """ Convert a filepath value in the config to an actual filepath.