diff --git a/src/ytdl_sub/plugins/plugin.py b/src/ytdl_sub/plugins/plugin.py index fa17191e..16767b00 100644 --- a/src/ytdl_sub/plugins/plugin.py +++ b/src/ytdl_sub/plugins/plugin.py @@ -43,13 +43,28 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC): # TODO pass yaml snake case name in the class somewhere, and use it for the logger self._logger = Logger.get(self.__class__.__name__) - def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]: + def modify_entry(self, entry: Entry) -> Optional[Entry]: """ - For each file downloaded, apply post processing to it. + For each entry downloaded, modify the entry in some way before sending it to + post-processing. Parameters ---------- - entry: + entry + Entry to modify + + Returns + ------- + The entry or None, indicating not to move it to the output directory + """ + + def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]: + """ + For each entry downloaded, apply post processing to it. + + Parameters + ---------- + entry Entry to post process Returns diff --git a/src/ytdl_sub/validators/regex_validator.py b/src/ytdl_sub/validators/regex_validator.py new file mode 100644 index 00000000..b6a2317f --- /dev/null +++ b/src/ytdl_sub/validators/regex_validator.py @@ -0,0 +1,101 @@ +import re +from typing import List +from typing import Optional + +from ytdl_sub.validators.validators import ListValidator +from ytdl_sub.validators.validators import StringValidator + + +class RegexValidator(StringValidator): + _expected_value_type_name = "regex" + + def __init__(self, name, value): + super().__init__(name, value) + + try: + self._compiled_regex = re.compile(self.value) + except Exception as exc: + raise self._validation_exception( + error_message=f"invalid regex: '{self.value}'" + ) from exc + + @property + def num_capture_groups(self) -> int: + """ + Returns + ------- + Number of capture groups in the regex + """ + return self._compiled_regex.groups + + def is_match(self, input_str: str) -> bool: + """ + Parameters + ---------- + input_str + String to match against the regex + + Returns + ------- + True if input_str matches. False otherwise. + """ + return self._compiled_regex.search(input_str) is not None + + def capture(self, input_str: str) -> Optional[List[str]]: + """ + Parameters + ---------- + input_str + String to try to regex capture from + + Returns + ------- + List of captures (will always be >= 1). None if there are no captures. + """ + if match := self._compiled_regex.search(input_str): + if len(to_return := list(match.groups())) > 0: + return to_return + return None + + +class RegexListValidator(ListValidator[RegexValidator]): + _expected_value_type_name = "regex list" + _inner_list_type = RegexValidator + + def __init__(self, name, value): + super().__init__(name, value) + + if len(set(reg.num_capture_groups for reg in self._list)) > 1: + raise self._validation_exception( + "each regex in a list must have the same number of capture groups" + ) + + def matches_any(self, input_str: str) -> bool: + """ + Parameters + ---------- + input_str + String to match against any regexes in the list + + Returns + ------- + True if at least one matches. False if none match + """ + return any(reg.is_match(input_str) for reg in self._list) + + def capture_any(self, input_str: str) -> Optional[List[str]]: + """ + Parameters + ---------- + input_str + String to try to regex capture from any of the regexes in the list + + Returns + ------- + List of captures (will always be >= 1) on the first regex that matches. None if + no regexes match. + """ + for reg in self._list: + if maybe_capture := reg.capture(input_str): + return maybe_capture + return None diff --git a/src/ytdl_sub/validators/validators.py b/src/ytdl_sub/validators/validators.py index 3b9c2fc2..e684cb8c 100644 --- a/src/ytdl_sub/validators/validators.py +++ b/src/ytdl_sub/validators/validators.py @@ -2,6 +2,7 @@ import copy from abc import ABC from typing import Any from typing import Dict +from typing import Generic from typing import List from typing import Optional from typing import Type @@ -94,6 +95,24 @@ class StringValidator(Validator): T = TypeVar("T", bound=Validator) +class ListValidator(Validator, ABC, Generic[T]): + """ + Validates a list of objects to validate + """ + + _expected_value_type = list + _expected_value_type_name = "list" + + _inner_list_type: Type[T] + + def __init__(self, name, value): + super().__init__(name, value) + self._list: List[T] = [ + self._inner_list_type(name=f"{name}.{i+1}", value=val) + for i, val in enumerate(self._value) + ] + + class DictValidator(Validator): """ Validates dictionary-based fields. Errors to them as 'object's since this could be validating diff --git a/tests/unit/validators/test_regex_validator.py b/tests/unit/validators/test_regex_validator.py new file mode 100644 index 00000000..ab49af6c --- /dev/null +++ b/tests/unit/validators/test_regex_validator.py @@ -0,0 +1,76 @@ +import pytest + +from ytdl_sub.utils.exceptions import ValidationException +from ytdl_sub.validators.regex_validator import RegexListValidator +from ytdl_sub.validators.regex_validator import RegexValidator + + +@pytest.mark.parametrize( + "regex_value, input_str, matches, captures", + [ + ("^match this$", "match this", True, None), + ("^my (.+) cap", "my first cap", True, ["first"]), + (".* (.+) - (.+) two", "my other - capped two", True, ["other", "capped"]), + ("failed match", "nope", False, None), + ], +) +def test_regex_validator(regex_value, input_str, matches, captures): + regex_validator = RegexValidator(name="good_regex_validator", value=regex_value) + assert regex_validator._name == "good_regex_validator" + assert regex_validator._value == regex_value + + assert regex_validator.is_match(input_str=input_str) is matches + assert regex_validator.capture(input_str=input_str) == captures + + +@pytest.mark.parametrize("regex_value", ["(", "??"]) +def test_regex_validator_fails_bad_value(regex_value): + with pytest.raises(ValidationException, match="Validation error in fail: invalid regex"): + _ = RegexValidator(name="fail", value=regex_value) + + +def test_regex_list_validator(): + regex_list_validator_raw_value = ["try matching this", "try matching that", "how about this"] + + regex_list = RegexListValidator(name="list val", value=regex_list_validator_raw_value) + + for raw_val in regex_list_validator_raw_value: + assert regex_list.matches_any(raw_val) + assert regex_list.capture_any(raw_val) is None + + +def test_regex_list_validator_capture(): + regex_list_validator_raw_value = ["try (.+) this", "try (.+) that", "how (.+) this"] + + regex_list = RegexListValidator(name="list val", value=regex_list_validator_raw_value) + captures_test = [ + ("try first this", "first"), + ("try second that", "second"), + ("how third this", "third"), + ] + + for capture_str, expected_capture in captures_test: + assert regex_list.matches_any(capture_str) + assert regex_list.capture_any(capture_str) == [expected_capture] + + +def test_regex_list_validator_invalid_regex(): + with pytest.raises(ValidationException, match="Validation error in fail\.2: invalid regex"): + _ = RegexListValidator(name="fail", value=["first is good", "second ( bad", "third okay"]) + + +def test_regex_list_validator_inconsistent_capture_groups(): + with pytest.raises( + ValidationException, + match=( + "Validation error in fail: " + "each regex in a list must have the same number of capture groups" + ), + ): + _ = RegexListValidator( + name="fail", + value=[ + "first with one (.+) cap group", + "two (.+) has two (.) cap groups", + ], + )