entry 100% test coverage
This commit is contained in:
parent
6f41b30ddb
commit
e1d60ab6e9
10 changed files with 297 additions and 17 deletions
|
|
@ -9,3 +9,4 @@ pyYAML==5.4.1
|
||||||
sanitize-filename==1.2.0
|
sanitize-filename==1.2.0
|
||||||
yt-dlp==2021.9.2
|
yt-dlp==2021.9.2
|
||||||
pytest==7.1.1
|
pytest==7.1.1
|
||||||
|
coverage==6.3.2
|
||||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
0
tests/unit/entries/__init__.py
Normal file
0
tests/unit/entries/__init__.py
Normal file
75
tests/unit/entries/conftest.py
Normal file
75
tests/unit/entries/conftest.py
Normal file
|
|
@ -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)
|
||||||
82
tests/unit/entries/test_entry.py
Normal file
82
tests/unit/entries/test_entry.py
Normal file
|
|
@ -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)
|
||||||
89
tests/unit/entries/test_entry_formatter.py
Normal file
89
tests/unit/entries/test_entry_formatter.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -1,9 +1,41 @@
|
||||||
from string import Formatter
|
import re
|
||||||
from typing import Any, Dict, Optional
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, List
|
||||||
|
|
||||||
from sanitize_filename import sanitize
|
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):
|
class Entry(object):
|
||||||
"""
|
"""
|
||||||
Entry object to represent a single media object returned from yt-dlp.
|
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}"
|
return f"{self.uid}.{self.ext}"
|
||||||
|
|
||||||
def file_path(self, relative_directory: str):
|
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:
|
def to_dict(self) -> Dict:
|
||||||
return {
|
return {
|
||||||
|
|
@ -73,18 +105,20 @@ class Entry(object):
|
||||||
"thumbnail_ext": self.thumbnail_ext,
|
"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()
|
entry_dict = self.to_dict()
|
||||||
if overrides:
|
if overrides:
|
||||||
entry_dict = dict(entry_dict, **overrides)
|
entry_dict = dict(entry_dict, **overrides)
|
||||||
|
|
||||||
field_names = [
|
field_names = EntryFormatter(format_string).parse()
|
||||||
fname for _, fname, _, _ in Formatter().parse(format_string) if fname
|
|
||||||
]
|
for field_name in field_names:
|
||||||
for fname in field_names:
|
if field_name not in entry_dict:
|
||||||
if fname not in entry_dict:
|
available_fields = ", ".join(sorted(entry_dict.keys()))
|
||||||
raise KeyError(
|
raise ValueError(
|
||||||
f"Format variable '{fname}' does not exist for {self.__class__.__name__}."
|
f"Format variable '{field_name}' does not exist for {self.__class__.__name__}. Available fields: {available_fields}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return format_string.format(**entry_dict)
|
return format_string.format(**entry_dict)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class SoundcloudAlbumTrack(SoundcloudTrack):
|
||||||
def album_year(self) -> int:
|
def album_year(self) -> int:
|
||||||
return self._album.album_year
|
return self._album.album_year
|
||||||
|
|
||||||
|
|
||||||
class SoundcloudAlbum(Entry):
|
class SoundcloudAlbum(Entry):
|
||||||
def __init__(self, skip_premiere_tracks: bool, **kwargs):
|
def __init__(self, skip_premiere_tracks: bool, **kwargs):
|
||||||
super(SoundcloudAlbum, self).__init__(**kwargs)
|
super(SoundcloudAlbum, self).__init__(**kwargs)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class YoutubeVideo(Entry):
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
# Try to get the track, fall back on title
|
# Try to get the track, fall back on title
|
||||||
if self.kwargs_contains('track'):
|
if self.kwargs_contains("track"):
|
||||||
return self.kwargs('track')
|
return self.kwargs("track")
|
||||||
|
|
||||||
return super(YoutubeVideo, self).title
|
return super(YoutubeVideo, self).title
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class Subscription(object):
|
||||||
self.post_process = post_process
|
self.post_process = post_process
|
||||||
self.overrides = overrides
|
self.overrides = overrides
|
||||||
for k, v in deepcopy(overrides).items():
|
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
|
self.output_path = output_path
|
||||||
|
|
||||||
# Separate each subscription's working directory
|
# 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["outtmpl"] = self.WORKING_DIRECTORY + "/%(id)s.%(ext)s"
|
||||||
self.ytdl_opts["writethumbnail"] = True
|
self.ytdl_opts["writethumbnail"] = True
|
||||||
|
|
||||||
def format_filepath(
|
def format_filepath(self, filepath_formatter: str, entry: Entry, makedirs=False):
|
||||||
self, filepath_formatter: str, entry: Entry, makedirs=False
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Convert a filepath value in the config to an actual filepath.
|
Convert a filepath value in the config to an actual filepath.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue