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
|
||||
yt-dlp==2021.9.2
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue