[FEATURE] --match flag to only run subset of subscriptions (#881)

Implements https://github.com/jmbannon/ytdl-sub/issues/880
and maybe fixes https://github.com/jmbannon/ytdl-sub/issues/827

Can now do `ytdl-sub sub --match SubA SubB` or `ytdl-sub sub --match SubA --match SubB`, which will only run subscriptions that contain `SubA` or `SubB` in their names
This commit is contained in:
Jesse Bannon 2024-01-08 13:37:07 -08:00 committed by GitHub
parent d80a63631a
commit 9382be5c42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 117 additions and 25 deletions

View file

@ -67,7 +67,11 @@ def _maybe_write_subscription_log_file(
def _download_subscriptions_from_yaml_files( def _download_subscriptions_from_yaml_files(
config: ConfigFile, subscription_paths: List[str], update_with_info_json: bool, dry_run: bool config: ConfigFile,
subscription_paths: List[str],
subscription_matches: List[str],
update_with_info_json: bool,
dry_run: bool,
) -> List[Subscription]: ) -> List[Subscription]:
""" """
Downloads all subscriptions from one or many subscription yaml files. Downloads all subscriptions from one or many subscription yaml files.
@ -78,6 +82,8 @@ def _download_subscriptions_from_yaml_files(
Configuration file Configuration file
subscription_paths subscription_paths
Path to subscription files to download Path to subscription files to download
subscription_matches
Optional list of substrings to match subscription names to (only run if matched)
update_with_info_json update_with_info_json
Whether to actually download or update using existing info json Whether to actually download or update using existing info json
dry_run dry_run
@ -98,6 +104,12 @@ def _download_subscriptions_from_yaml_files(
for path in subscription_paths: for path in subscription_paths:
subscriptions += Subscription.from_file_path(config=config, subscription_path=path) subscriptions += Subscription.from_file_path(config=config, subscription_path=path)
if subscriptions and subscription_matches:
logger.info("Filtering subscriptions by name based on --match arguments")
subscriptions = [
sub for sub in subscriptions if any(match in sub.name for match in subscription_matches)
]
for subscription in subscriptions: for subscription in subscriptions:
with subscription.exception_handling(): with subscription.exception_handling():
logger.info( logger.info(
@ -119,7 +131,7 @@ def _download_subscriptions_from_yaml_files(
exception=subscription.exception, exception=subscription.exception,
) )
Logger.cleanup(cleanup_error_log=False) Logger.cleanup(has_error=False)
gc.collect() # Garbage collect after each subscription download gc.collect() # Garbage collect after each subscription download
return subscriptions return subscriptions
@ -225,6 +237,7 @@ def main() -> List[Subscription]:
subscriptions = _download_subscriptions_from_yaml_files( subscriptions = _download_subscriptions_from_yaml_files(
config=config, config=config,
subscription_paths=args.subscription_paths, subscription_paths=args.subscription_paths,
subscription_matches=args.match,
update_with_info_json=args.update_with_info_json, update_with_info_json=args.update_with_info_json,
dry_run=args.dry_run, dry_run=args.dry_run,
) )

View file

@ -40,6 +40,10 @@ class MainArguments:
long="--suppress-transaction-log", long="--suppress-transaction-log",
is_positional=True, is_positional=True,
) )
MATCH = CLIArgument(
short="-m",
long="--match",
)
@classmethod @classmethod
def all(cls) -> List[CLIArgument]: def all(cls) -> List[CLIArgument]:
@ -54,6 +58,7 @@ class MainArguments:
cls.LOG_LEVEL, cls.LOG_LEVEL,
cls.TRANSACTION_LOG, cls.TRANSACTION_LOG,
cls.SUPPRESS_TRANSACTION_LOG, cls.SUPPRESS_TRANSACTION_LOG,
cls.MATCH,
] ]
@classmethod @classmethod
@ -124,6 +129,16 @@ def _add_shared_arguments(arg_parser: argparse.ArgumentParser, suppress_defaults
help="do not output transaction logs to console or file", help="do not output transaction logs to console or file",
default=argparse.SUPPRESS if suppress_defaults else False, default=argparse.SUPPRESS if suppress_defaults else False,
) )
arg_parser.add_argument(
MainArguments.MATCH.short,
MainArguments.MATCH.long,
dest="match",
nargs="+",
action="extend",
type=str,
help="match subscription names to one or more substrings, and only run those subscriptions",
default=argparse.SUPPRESS if suppress_defaults else [],
)
################################################################################################### ###################################################################################################

View file

@ -27,7 +27,7 @@ def main():
""" """
try: try:
return_code = _main() return_code = _main()
Logger.cleanup(cleanup_error_log=return_code == 0) Logger.cleanup(has_error=return_code != 0)
sys.exit(return_code) sys.exit(return_code)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
Logger.log_exception(exception=exc) Logger.log_exception(exception=exc)

View file

@ -210,6 +210,14 @@ class Logger:
finally: finally:
redirect_stream.flush() redirect_stream.flush()
@classmethod
def _append_to_error_log(cls):
# Any time an exception occurs, dump all debug logs into the error log
with open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs, open(
cls.error_log_filename(), mode="a", encoding="utf-8"
) as error_logs:
error_logs.writelines(debug_logs.readlines())
@classmethod @classmethod
def log_exception(cls, exception: Exception, log_filepath: Optional[Path] = None): def log_exception(cls, exception: Exception, log_filepath: Optional[Path] = None):
""" """
@ -248,14 +256,10 @@ class Logger:
log_filepath if log_filepath else Logger.error_log_filename(), log_filepath if log_filepath else Logger.error_log_filename(),
) )
# Any time an exception occurs, dump all debug logs into the error log cls._append_to_error_log()
with open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs, open(
cls.error_log_filename(), mode="a", encoding="utf-8"
) as error_logs:
error_logs.writelines(debug_logs.readlines())
@classmethod @classmethod
def cleanup(cls, cleanup_error_log: bool = False): def cleanup(cls, has_error: bool = False):
""" """
Cleans up debug log file left behind Cleans up debug log file left behind
""" """
@ -263,9 +267,11 @@ class Logger:
for handler in logger.handlers: for handler in logger.handlers:
handler.close() handler.close()
cls._DEBUG_LOGGER_FILE.close() if has_error:
FileHandler.delete(cls.debug_log_filename()) cls._append_to_error_log()
else:
if cleanup_error_log:
cls._ERROR_LOG_FILE.close() cls._ERROR_LOG_FILE.close()
FileHandler.delete(cls.error_log_filename()) FileHandler.delete(cls.error_log_filename())
cls._DEBUG_LOGGER_FILE.close()
FileHandler.delete(cls.debug_log_filename())

View file

@ -2,6 +2,7 @@ import re
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from typing import List
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -22,6 +23,7 @@ from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("mock_success_output", [True, False]) @pytest.mark.parametrize("mock_success_output", [True, False])
@pytest.mark.parametrize("keep_successful_logs", [True, False]) @pytest.mark.parametrize("keep_successful_logs", [True, False])
@pytest.mark.parametrize("match", [[], ["Rick", "Michael"]])
def test_subscription_logs_write_to_file( def test_subscription_logs_write_to_file(
persist_logs_directory: str, persist_logs_directory: str,
persist_logs_config_factory: Callable, persist_logs_config_factory: Callable,
@ -30,8 +32,11 @@ def test_subscription_logs_write_to_file(
dry_run: bool, dry_run: bool,
mock_success_output: bool, mock_success_output: bool,
keep_successful_logs: bool, keep_successful_logs: bool,
match: List[str],
): ):
subscripton_names = ["Rick Astley", "Michael Jackson", "Eric Clapton"] subscription_names = ["Rick Astley", "Michael Jackson", "Eric Clapton"]
if match:
subscription_names = ["Rick Astley", "Michael Jackson"]
num_runs = 2 num_runs = 2
config = persist_logs_config_factory(keep_successful_logs=keep_successful_logs) config = persist_logs_config_factory(keep_successful_logs=keep_successful_logs)
@ -47,6 +52,7 @@ def test_subscription_logs_write_to_file(
_download_subscriptions_from_yaml_files( _download_subscriptions_from_yaml_files(
config=config, config=config,
subscription_paths=subscription_paths, subscription_paths=subscription_paths,
subscription_matches=match,
update_with_info_json=False, update_with_info_json=False,
dry_run=dry_run, dry_run=dry_run,
) )
@ -61,8 +67,8 @@ def test_subscription_logs_write_to_file(
return return
# If not success, expect 2 log files for both sub errors # If not success, expect 2 log files for both sub errors
elif not mock_success_output: elif not mock_success_output:
assert len(log_directory_files) == (num_runs * len(subscripton_names)) assert len(log_directory_files) == (num_runs * len(subscription_names))
for log_path, subscription_name in zip(log_directory_files, subscripton_names): for log_path, subscription_name in zip(log_directory_files, subscription_names):
subscription_log_file_name = subscription_name.lower().replace(" ", "_") subscription_log_file_name = subscription_name.lower().replace(" ", "_")
assert bool(re.match(rf"\d\.{subscription_log_file_name}\.error\.log", log_path.name)) assert bool(re.match(rf"\d\.{subscription_log_file_name}\.error\.log", log_path.name))
@ -74,9 +80,9 @@ def test_subscription_logs_write_to_file(
) )
# If success and success logging, expect 3 log files # If success and success logging, expect 3 log files
else: else:
assert len(log_directory_files) == (num_runs * len(subscripton_names)) assert len(log_directory_files) == (num_runs * len(subscription_names))
for log_file_path, subscription_name in zip( for log_file_path, subscription_name in zip(
log_directory_files, subscripton_names * num_runs log_directory_files, subscription_names * num_runs
): ):
subscription_log_file_name = subscription_name.lower().replace(" ", "_") subscription_log_file_name = subscription_name.lower().replace(" ", "_")

View file

@ -47,8 +47,8 @@ def test_main_exit_code(mock_sys_exit, return_code: int):
main() main()
assert mock_logger_cleanup.call_count == 1 assert mock_logger_cleanup.call_count == 1
assert mock_logger_cleanup.call_args.kwargs["cleanup_error_log"] == ( assert mock_logger_cleanup.call_args.kwargs["has_error"] == (
True if return_code == 0 else False True if return_code != 0 else False
) )
@ -107,6 +107,58 @@ def test_args_after_sub_work(mock_sys_exit, tv_show_config_path):
assert mock_sub.call_count == 1 assert mock_sub.call_count == 1
assert mock_sub.call_args.kwargs["subscription_paths"] == ["subscriptions.yaml"] assert mock_sub.call_args.kwargs["subscription_paths"] == ["subscriptions.yaml"]
assert mock_sub.call_args.kwargs["config"]._name == tv_show_config_path assert mock_sub.call_args.kwargs["config"]._name == tv_show_config_path
assert mock_sub.call_args.kwargs["subscription_matches"] == []
assert Logger._LOGGER_LEVEL == LoggerLevels.VERBOSE
def test_sub_match_arguments_before(mock_sys_exit, tv_show_config_path):
with mock_sys_exit(expected_exit_code=0), patch.object(
sys,
"argv",
[
"ytdl-sub",
"--match",
"testA",
"testB",
"-c",
tv_show_config_path,
"sub",
"--log-level",
"verbose",
],
), patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub:
main()
assert mock_sub.call_count == 1
assert mock_sub.call_args.kwargs["subscription_paths"] == ["subscriptions.yaml"]
assert mock_sub.call_args.kwargs["config"]._name == tv_show_config_path
assert mock_sub.call_args.kwargs["subscription_matches"] == ["testA", "testB"]
assert Logger._LOGGER_LEVEL == LoggerLevels.VERBOSE
def test_sub_match_arguments_after_many(mock_sys_exit, tv_show_config_path):
with mock_sys_exit(expected_exit_code=0), patch.object(
sys,
"argv",
[
"ytdl-sub",
"-c",
tv_show_config_path,
"sub",
"--log-level",
"verbose",
"--match",
"testA",
"--match",
"testB",
],
), patch("ytdl_sub.cli.entrypoint._download_subscriptions_from_yaml_files") as mock_sub:
main()
assert mock_sub.call_count == 1
assert mock_sub.call_args.kwargs["subscription_paths"] == ["subscriptions.yaml"]
assert mock_sub.call_args.kwargs["config"]._name == tv_show_config_path
assert mock_sub.call_args.kwargs["subscription_matches"] == ["testA", "testB"]
assert Logger._LOGGER_LEVEL == LoggerLevels.VERBOSE assert Logger._LOGGER_LEVEL == LoggerLevels.VERBOSE

View file

@ -111,8 +111,8 @@ class TestLogger:
Logger.cleanup() Logger.cleanup()
assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name) assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name)
@pytest.mark.parametrize("clean_error_log", [True, False]) @pytest.mark.parametrize("has_error", [True, False])
def test_logger_can_be_cleaned_during_execution(self, clean_error_log: bool): def test_logger_can_be_cleaned_during_execution(self, has_error: bool):
Logger._LOGGER_LEVEL = LoggerLevels.INFO Logger._LOGGER_LEVEL = LoggerLevels.INFO
logger = Logger.get(name="name_test") logger = Logger.get(name="name_test")
@ -133,11 +133,11 @@ class TestLogger:
except ValueError as exc: except ValueError as exc:
Logger.log_exception(exception=exc) Logger.log_exception(exception=exc)
Logger.cleanup(cleanup_error_log=clean_error_log) Logger.cleanup(has_error=has_error)
assert not os.path.isfile(Logger.debug_log_filename()) assert not os.path.isfile(Logger.debug_log_filename())
assert clean_error_log == (not os.path.isfile(Logger.error_log_filename())) assert not has_error == (not os.path.isfile(Logger.error_log_filename()))
if not clean_error_log: if has_error:
with open(Logger.error_log_filename(), mode="r", encoding="utf-8") as err_file: with open(Logger.error_log_filename(), mode="r", encoding="utf-8") as err_file:
err_logs = err_file.readlines() err_logs = err_file.readlines()
expected = [ expected = [