From 648027204feb549afa321f21a4f1f6834ea6323c Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Wed, 8 Mar 2023 00:17:11 -0800 Subject: [PATCH] [FEATURE] Persisted subscription logs (#512) --- docs/config.rst | 18 ++++ src/ytdl_sub/cli/main.py | 79 +++++++++++--- src/ytdl_sub/config/config_file.py | 9 ++ src/ytdl_sub/config/config_validator.py | 76 +++++++++++++- src/ytdl_sub/main.py | 17 +-- src/ytdl_sub/utils/logger.py | 53 ++++++++-- tests/conftest.py | 82 ++++++++++++++- tests/e2e/conftest.py | 67 ------------ tests/unit/cli/test_main.py | 132 +++++++++++++++++++++--- tests/unit/main/test_main.py | 14 +++ tests/unit/prebuilt_presets/conftest.py | 37 +++---- tests/unit/utils/test_logger.py | 9 +- 12 files changed, 447 insertions(+), 146 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 4e3325a0..6862ba8b 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -29,7 +29,25 @@ and subscriptions. .. autoclass:: ytdl_sub.config.config_validator.ConfigOptions() :members: :member-order: bysource + :exclude-members: persist_logs +persist_logs +"""""""""""" +Within ``configuration``, define whether logs from subscription downloads +should be persisted. + +.. code-block:: yaml + + configuration: + persist_logs: + logs_directory: "/path/to/log/directory" + +Log files are stored as +``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. + +.. autoclass:: ytdl_sub.config.config_validator.PersistLogsValidator() + :members: + :member-order: bysource presets ^^^^^^^ diff --git a/src/ytdl_sub/cli/main.py b/src/ytdl_sub/cli/main.py index bb40c7b1..2e9d1f48 100644 --- a/src/ytdl_sub/cli/main.py +++ b/src/ytdl_sub/cli/main.py @@ -1,13 +1,18 @@ -import argparse import gc import sys +from datetime import datetime +from pathlib import Path from typing import List +from typing import Optional from typing import Tuple +from yt_dlp.utils import sanitize_filename + from ytdl_sub.cli.download_args_parser import DownloadArgsParser from ytdl_sub.cli.main_args_parser import parser from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.subscriptions.subscription import Subscription +from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_lock import working_directory_lock from ytdl_sub.utils.logger import Logger @@ -19,8 +24,41 @@ logger = Logger.get() _VIEW_EXTRA_ARGS_FORMATTER = "--preset _view --overrides.url {}" +def _maybe_write_subscription_log_file( + config: ConfigFile, + subscription: Subscription, + dry_run: bool, + exception: Optional[Exception] = None, +) -> None: + success: bool = exception is None + + # If dry-run, do nothing + if dry_run: + return + + # If persisting logs is disabled, do nothing + if not config.config_options.persist_logs: + return + + # If persisting successful logs is disabled, do nothing + if success and not config.config_options.persist_logs.keep_successful_logs: + return + + log_time = datetime.now().strftime("%Y-%m-%d-%H%M%S") + log_subscription_name = sanitize_filename(subscription.name).lower().replace(" ", "_") + log_success = "success" if success else "error" + + log_filename = f"{log_time}.{log_subscription_name}.{log_success}.log" + persist_log_path = Path(config.config_options.persist_logs.logs_directory) / log_filename + + if not success: + Logger.log_exit_exception(exception=exception, log_filepath=persist_log_path) + + FileHandler.copy(Logger.debug_log_filename(), persist_log_path) + + def _download_subscriptions_from_yaml_files( - config: ConfigFile, args: argparse.Namespace + config: ConfigFile, subscription_paths: List[str], dry_run: bool ) -> List[Tuple[Subscription, FileHandlerTransactionLog]]: """ Downloads all subscriptions from one or many subscription yaml files. @@ -29,8 +67,10 @@ def _download_subscriptions_from_yaml_files( ---------- config Configuration file - args - Arguments from argparse + subscription_paths + Path to subscription files to download + dry_run + Whether to dry run or not Returns ------- @@ -38,9 +78,9 @@ def _download_subscriptions_from_yaml_files( Raises ------ - Validation exception if main arg is specified as a subscription path + Exception + Any exception during download """ - subscription_paths: List[str] = args.subscription_paths subscriptions: List[Subscription] = [] output: List[Tuple[Subscription, FileHandlerTransactionLog]] = [] @@ -51,15 +91,26 @@ def _download_subscriptions_from_yaml_files( for subscription in subscriptions: logger.info( "Beginning subscription %s for %s", - ("dry run" if args.dry_run else "download"), + ("dry run" if dry_run else "download"), subscription.name, ) logger.debug("Subscription full yaml:\n%s", subscription.as_yaml()) - transaction_log = subscription.download(dry_run=args.dry_run) - output.append((subscription, transaction_log)) - gc.collect() # Garbage collect after each subscription download - Logger.cleanup() # Cleanup logger after each successful subscription download + try: + transaction_log = subscription.download(dry_run=dry_run) + except Exception as exc: # pylint: disable=broad-except + _maybe_write_subscription_log_file( + config=config, subscription=subscription, dry_run=dry_run, exception=exc + ) + raise + else: + output.append((subscription, transaction_log)) + _maybe_write_subscription_log_file( + config=config, subscription=subscription, dry_run=dry_run + ) + Logger.cleanup() # Cleanup logger after each successful subscription download + finally: + gc.collect() # Garbage collect after each subscription download return output @@ -136,7 +187,11 @@ def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]: with working_directory_lock(config=config): if args.subparser == "sub": - transaction_logs = _download_subscriptions_from_yaml_files(config=config, args=args) + transaction_logs = _download_subscriptions_from_yaml_files( + config=config, + subscription_paths=args.subscription_paths, + dry_run=args.dry_run, + ) # One-off download elif args.subparser == "dl": diff --git a/src/ytdl_sub/config/config_file.py b/src/ytdl_sub/config/config_file.py index 00a03efe..d7874e28 100644 --- a/src/ytdl_sub/config/config_file.py +++ b/src/ytdl_sub/config/config_file.py @@ -1,5 +1,6 @@ import os from typing import Any +from typing import Dict from ytdl_sub.config.config_validator import ConfigValidator from ytdl_sub.config.preset import Preset @@ -65,3 +66,11 @@ class ConfigFile(ConfigValidator): """ config_dict = load_yaml(file_path=config_path) return ConfigFile.from_dict(config_dict) + + def as_dict(self) -> Dict[str, Any]: + """ + Returns + ------- + The config in its dict form + """ + return self._value diff --git a/src/ytdl_sub/config/config_validator.py b/src/ytdl_sub/config/config_validator.py index 255000e2..04510b30 100644 --- a/src/ytdl_sub/config/config_validator.py +++ b/src/ytdl_sub/config/config_validator.py @@ -3,12 +3,14 @@ from typing import Dict from typing import Optional from mergedeep import mergedeep +from yt_dlp.utils import datetime_from_str from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS from ytdl_sub.utils.system import IS_WINDOWS from ytdl_sub.validators.file_path_validators import FFmpegFileValidator from ytdl_sub.validators.file_path_validators import FFprobeFileValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator +from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import StringValidator @@ -22,9 +24,71 @@ else: _DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe" +class PersistLogsValidator(StrictDictValidator): + _required_keys = {"logs_directory"} + _optional_keys = {"keep_logs_after", "keep_successful_logs"} + + def __init__(self, name: str, value: Any): + super().__init__(name, value) + + self._logs_directory = self._validate_key(key="logs_directory", validator=StringValidator) + + self._keep_logs_after: Optional[str] = None + if keep_logs_validator := self._validate_key_if_present( + key="keep_logs_after", validator=StringValidator + ): + try: + self._keep_logs_after = datetime_from_str(keep_logs_validator.value) + except Exception as exc: + raise self._validation_exception(f"Invalid datetime string: {str(exc)}") + + self._keep_successful_logs = self._validate_key( + key="keep_successful_logs", validator=BoolValidator, default=True + ) + + @property + def logs_directory(self) -> str: + """ + Required. The directory to store the logs in. + """ + return self._logs_directory.value + + # pylint: disable=line-too-long + # @property + # def keep_logs_after(self) -> Optional[str]: + # """ + # Optional. Keep logs after this date, in yt-dlp datetime format. + # + # .. code-block:: Markdown + # + # A string in the format YYYYMMDD or + # (now|today|yesterday|date)[+-][0-9](microsecond|second|minute|hour|day|week|month|year)(s) + # + # For example, ``today-1week`` means keep 1 week's worth of logs. By default, ytdl-sub will + # keep all log files. + # """ + # return self._keep_logs_after + + # pylint: enable=line-too-long + + @property + def keep_successful_logs(self) -> bool: + """ + Optional. Whether to store logs when downloading is successful. Defaults to True. + """ + return self._keep_successful_logs.value + + class ConfigOptions(StrictDictValidator): _required_keys = {"working_directory"} - _optional_keys = {"umask", "dl_aliases", "lock_directory", "ffmpeg_path", "ffprobe_path"} + _optional_keys = { + "umask", + "dl_aliases", + "persist_logs", + "lock_directory", + "ffmpeg_path", + "ffprobe_path", + } def __init__(self, name: str, value: Any): super().__init__(name, value) @@ -38,6 +102,9 @@ class ConfigOptions(StrictDictValidator): self._dl_aliases = self._validate_key_if_present( key="dl_aliases", validator=LiteralDictValidator ) + self._persist_logs = self._validate_key_if_present( + key="persist_logs", validator=PersistLogsValidator + ) self._lock_directory = self._validate_key( key="lock_directory", validator=StringValidator, default=_DEFAULT_LOCK_DIRECTORY ) @@ -93,6 +160,13 @@ class ConfigOptions(StrictDictValidator): return self._dl_aliases.dict return {} + @property + def persist_logs(self) -> Optional[PersistLogsValidator]: + """ + Persist logs validator. readthedocs in the validator itself! + """ + return self._persist_logs + @property def lock_directory(self) -> str: """ diff --git a/src/ytdl_sub/main.py b/src/ytdl_sub/main.py index 1f07ca53..6a5fd38c 100644 --- a/src/ytdl_sub/main.py +++ b/src/ytdl_sub/main.py @@ -1,8 +1,6 @@ import sys -from ytdl_sub import __local_version__ from ytdl_sub.cli.main_args_parser import parser -from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.logger import Logger @@ -24,22 +22,11 @@ def main(): """ Entrypoint for ytdl-sub """ - logger = Logger.get() try: _main() Logger.cleanup() # Ran successfully, so we can delete the debug file - except ValidationException as validation_exception: - logger.error(validation_exception) - sys.exit(1) - except Exception: # pylint: disable=broad-except - logger.exception("An uncaught error occurred:") - logger.error( - "Version %s\nPlease upload the error log file '%s' and make a Github " - "issue at https://github.com/jmbannon/ytdl-sub/issues with your config and " - "command/subscription yaml file to reproduce. Thanks for trying ytdl-sub!", - __local_version__, - Logger.debug_log_filename(), - ) + except Exception as exc: # pylint: disable=broad-except + Logger.log_exit_exception(exception=exc) sys.exit(1) sys.exit(0) diff --git a/src/ytdl_sub/utils/logger.py b/src/ytdl_sub/utils/logger.py index 3112f918..eb684049 100644 --- a/src/ytdl_sub/utils/logger.py +++ b/src/ytdl_sub/utils/logger.py @@ -4,9 +4,12 @@ import logging import sys import tempfile from dataclasses import dataclass +from pathlib import Path from typing import List from typing import Optional +from ytdl_sub import __local_version__ +from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.file_handler import FileHandler @@ -90,6 +93,9 @@ class Logger: _DEBUG_LOGGER_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.", delete=False) # pylint: enable=R1732 + # Whether the final exception lines were added to the debug log + _LOGGED_EXIT_EXCEPTION: bool = False + # Keep track of all Loggers created _LOGGERS: List[logging.Logger] = [] @@ -190,19 +196,48 @@ class Logger: ) with StreamToLogger(logger=logger) as redirect_stream: - with contextlib.redirect_stdout(new_target=redirect_stream): - with contextlib.redirect_stderr(new_target=redirect_stream): - yield + try: + with contextlib.redirect_stdout(new_target=redirect_stream): + with contextlib.redirect_stderr(new_target=redirect_stream): + yield + finally: + redirect_stream.flush() @classmethod - def cleanup(cls, delete_debug_file: bool = True): + def log_exit_exception(cls, exception: Exception, log_filepath: Optional[Path] = None): """ - Cleans up any log files left behind + Performs the final log before exiting from an error Parameters ---------- - delete_debug_file - Whether to delete the debug log file. Defaults to True. + exception + The exception to log + log_filepath + Optional. The filepath to the debug logs + """ + if not cls._LOGGED_EXIT_EXCEPTION: + logger = cls.get() + + # Log validation exceptions as-is + if isinstance(exception, ValidationException): + logger.error(exception) + # For other uncaught errors, log as bug: + else: + logger.exception("An uncaught error occurred:") + logger.error( + "Version %s\nPlease upload the error log file '%s' and make a Github " + "issue at https://github.com/jmbannon/ytdl-sub/issues with your config and " + "command/subscription yaml file to reproduce. Thanks for trying ytdl-sub!", + __local_version__, + log_filepath if log_filepath else Logger.debug_log_filename(), + ) + + cls._LOGGED_EXIT_EXCEPTION = True + + @classmethod + def cleanup(cls): + """ + Cleans up debug log file left behind """ for logger in cls._LOGGERS: for handler in logger.handlers: @@ -210,5 +245,5 @@ class Logger: cls._DEBUG_LOGGER_FILE.close() - if delete_debug_file: - FileHandler.delete(cls.debug_log_filename()) + FileHandler.delete(cls.debug_log_filename()) + cls._LOGGED_EXIT_EXCEPTION = False diff --git a/tests/conftest.py b/tests/conftest.py index a38e8c03..0571d612 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import contextlib import json import logging import os +import shutil import tempfile from pathlib import Path from typing import Any @@ -11,15 +12,52 @@ from typing import List from unittest.mock import patch import pytest +from expected_download import _get_files_in_directory +from ytdl_sub.config.config_file import ConfigFile +from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.logger import Logger +from ytdl_sub.utils.logger import LoggerLevels +from ytdl_sub.utils.yaml import load_yaml + + +@pytest.fixture(autouse=True) +def cleanup_debug_file(): + """ + Clean logs after every test + """ + Logger.set_log_level(log_level_name=LoggerLevels.DEBUG.name) + yield + Logger.cleanup() @pytest.fixture -def working_directory() -> Path: +def working_directory() -> str: + """ + Any time the working directory is used, ensure no files remain on cleaning it up + """ + logger = Logger.get("test") + with tempfile.TemporaryDirectory() as temp_dir: - yield temp_dir + + def _assert_working_directory_empty(self, is_error: bool = False): + files = [str(file_path) for file_path in _get_files_in_directory(temp_dir)] + num_files = len(files) + if os.path.isdir(temp_dir): + shutil.rmtree(temp_dir) + + if not is_error: + if num_files > 0: + logger.error("left-over files in working dir:\n%s", "\n".join(files)) + assert num_files == 0 + + with patch.object( + SubscriptionDownload, + "_delete_working_directory", + new=_assert_working_directory_empty, + ): + yield temp_dir @pytest.fixture() @@ -95,3 +133,43 @@ def preset_dict_to_subscription_yaml_generator() -> Callable: FileHandler.delete(tmp_file.name) return _preset_dict_to_subscription_yaml_generator + + +################################################################################################### +# Example config fixtures + + +def _load_config(config_path: Path, working_directory: str) -> ConfigFile: + config_dict = load_yaml(file_path=config_path) + config_dict["configuration"]["working_directory"] = working_directory + + return ConfigFile.from_dict(config_dict) + + +@pytest.fixture() +def music_video_config_path() -> Path: + return Path("examples/music_videos_config.yaml") + + +@pytest.fixture() +def music_video_config(music_video_config_path, working_directory) -> ConfigFile: + return _load_config(music_video_config_path, working_directory) + + +@pytest.fixture() +def music_video_subscription_path() -> Path: + return Path("examples/music_videos_subscriptions.yaml") + + +@pytest.fixture() +def channel_as_tv_show_config(working_directory) -> ConfigFile: + return _load_config( + config_path=Path("examples/tv_show_config.yaml"), working_directory=working_directory + ) + + +@pytest.fixture() +def music_audio_config(working_directory) -> ConfigFile: + return _load_config( + config_path=Path("examples/music_audio_config.yaml"), working_directory=working_directory + ) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index e307abd5..0cb271bb 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,69 +1,16 @@ import json -import os -import shutil import sys import tempfile -from pathlib import Path from typing import List from typing import Tuple from unittest.mock import patch import pytest -from expected_download import _get_files_in_directory from ytdl_sub.cli.main import main -from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.subscriptions.subscription import Subscription -from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandlerTransactionLog -from ytdl_sub.utils.logger import Logger -from ytdl_sub.utils.yaml import load_yaml - -logger = Logger.get("test") - - -@pytest.fixture -def working_directory() -> str: - """ - Any time the working directory is used, ensure no files remain on cleaning it up - """ - with tempfile.TemporaryDirectory() as temp_dir: - - def _assert_working_directory_empty(self, is_error: bool = False): - files = [str(file_path) for file_path in _get_files_in_directory(temp_dir)] - num_files = len(files) - if os.path.isdir(temp_dir): - shutil.rmtree(temp_dir) - - if not is_error: - if num_files > 0: - logger.error("left-over files in working dir:\n%s", "\n".join(files)) - assert num_files == 0 - - with patch.object( - SubscriptionDownload, - "_delete_working_directory", - new=_assert_working_directory_empty, - ): - yield temp_dir - - -@pytest.fixture() -def music_video_config_path() -> Path: - return Path("examples/music_videos_config.yaml") - - -def _load_config(config_path: Path, working_directory: Path) -> ConfigFile: - config_dict = load_yaml(file_path=config_path) - config_dict["configuration"]["working_directory"] = working_directory - - return ConfigFile.from_dict(config_dict) - - -@pytest.fixture() -def music_video_config(music_video_config_path, working_directory) -> ConfigFile: - return _load_config(music_video_config_path, working_directory) @pytest.fixture() @@ -77,20 +24,6 @@ def music_video_config_for_cli(music_video_config) -> str: FileHandler.delete(tmp_file.name) -@pytest.fixture() -def channel_as_tv_show_config(working_directory) -> ConfigFile: - return _load_config( - config_path=Path("examples/tv_show_config.yaml"), working_directory=working_directory - ) - - -@pytest.fixture() -def music_audio_config(working_directory) -> ConfigFile: - return _load_config( - config_path=Path("examples/music_audio_config.yaml"), working_directory=working_directory - ) - - @pytest.fixture def timestamps_file_path(): timestamps = [ diff --git a/tests/unit/cli/test_main.py b/tests/unit/cli/test_main.py index f7e700d8..50d74f81 100644 --- a/tests/unit/cli/test_main.py +++ b/tests/unit/cli/test_main.py @@ -1,21 +1,125 @@ -import sys +import re +import tempfile +import time +from pathlib import Path +from typing import Callable from unittest.mock import patch +import mergedeep import pytest -from ytdl_sub.cli.main import main -from ytdl_sub.utils.exceptions import ValidationException +from ytdl_sub.cli.main import _download_subscriptions_from_yaml_files +from ytdl_sub.config.config_file import ConfigFile +from ytdl_sub.subscriptions.subscription import Subscription +from ytdl_sub.utils.file_handler import FileHandlerTransactionLog +from ytdl_sub.utils.logger import Logger -def test_args_after_sub_work(): - with patch.object( - sys, - "argv", - ["ytdl-sub", "-c", "examples/tv_show_config.yaml", "sub", "--log-level", "debug"], - ), patch("ytdl_sub.cli.main._download_subscriptions_from_yaml_files") as mock_sub: - main() +@pytest.fixture +def persist_logs_directory(): + with tempfile.TemporaryDirectory() as temp_dir: + yield temp_dir - assert mock_sub.call_count == 1 - assert mock_sub.call_args.kwargs["args"].config == "examples/tv_show_config.yaml" - assert mock_sub.call_args.kwargs["args"].subscription_paths == ["subscriptions.yaml"] - assert mock_sub.call_args.kwargs["args"].ytdl_sub_log_level == "debug" + +@pytest.fixture +def persist_logs_config_factory( + music_video_config: ConfigFile, persist_logs_directory: str +) -> Callable: + def _persist_logs_config_factory(keep_successful_logs: bool) -> ConfigFile: + return ConfigFile.from_dict( + dict( + mergedeep.merge( + music_video_config.as_dict(), + { + "configuration": { + "persist_logs": { + "logs_directory": persist_logs_directory, + "keep_successful_logs": keep_successful_logs, + }, + } + }, + ) + ) + ) + + return _persist_logs_config_factory + + +@pytest.fixture +def mock_subscription_download_factory(): + def _mock_subscription_download_factory(mock_success_output: bool) -> Callable: + def _mock_download(self: Subscription, dry_run: bool) -> FileHandlerTransactionLog: + Logger.get().info( + "name=%s success=%s dry_run=%s", self.name, mock_success_output, dry_run + ) + time.sleep(1) + if not mock_success_output: + raise ValueError("error") + return FileHandlerTransactionLog() + + return _mock_download + + return _mock_subscription_download_factory + + +class TestPersistLogs: + @pytest.mark.parametrize("dry_run", [True, False]) + @pytest.mark.parametrize("mock_success_output", [True, False]) + @pytest.mark.parametrize("keep_successful_logs", [True, False]) + def test_subscription_logs_write_to_file( + self, + persist_logs_directory: str, + persist_logs_config_factory: Callable, + mock_subscription_download_factory: Callable, + music_video_subscription_path: Path, + dry_run: bool, + mock_success_output: bool, + keep_successful_logs: bool, + ): + num_subscriptions = 2 + config = persist_logs_config_factory(keep_successful_logs=keep_successful_logs) + subscription_paths = [str(music_video_subscription_path)] * num_subscriptions + + with patch.object( + Subscription, + "download", + new=mock_subscription_download_factory(mock_success_output=mock_success_output), + ): + try: + _download_subscriptions_from_yaml_files( + config=config, subscription_paths=subscription_paths, dry_run=dry_run + ) + except ValueError: + assert not mock_success_output + + log_directory_files = list(Path(persist_logs_directory).rglob("*")) + + # If dry run or success but success logging disabled, expect 0 log files + if dry_run or (mock_success_output and not keep_successful_logs): + assert len(log_directory_files) == 0 + return + # If not success, expect 1 log file + elif not mock_success_output: + assert len(log_directory_files) == 1 + log_path = log_directory_files[0] + assert bool(re.match(r"\d{4}-\d{2}-\d{2}-\d{6}\.john_smith\.error\.log", log_path.name)) + with open(log_path, "r", encoding="utf-8") as log_file: + assert log_file.readlines()[-1] == ( + f"Please upload the error log file '{str(log_path)}' and make a Github issue " + f"at https://github.com/jmbannon/ytdl-sub/issues with your config and " + f"command/subscription yaml file to reproduce. Thanks for trying ytdl-sub!\n" + ) + # If success and success logging, expect 3 log files + else: + assert len(log_directory_files) == num_subscriptions + for log_file_path in log_directory_files: + assert bool( + re.match( + r"\d{4}-\d{2}-\d{2}-\d{6}\.john_smith\.success\.log", log_file_path.name + ) + ) + with open(log_file_path, "r", encoding="utf-8") as log_file: + assert ( + log_file.readlines()[-1] + == "[ytdl-sub] name=john_smith success=True dry_run=False\n" + ) diff --git a/tests/unit/main/test_main.py b/tests/unit/main/test_main.py index 6e6a3e1a..368d2def 100644 --- a/tests/unit/main/test_main.py +++ b/tests/unit/main/test_main.py @@ -9,6 +9,7 @@ from src.ytdl_sub import __local_version__ from src.ytdl_sub.main import main from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.logger import Logger +from ytdl_sub.utils.logger import LoggerLevels @pytest.fixture @@ -66,3 +67,16 @@ def test_main_uncaught_error(capsys, mock_sys_exit, expected_uncaught_error_mess assert mock_error.call_args.args[0] == expected_uncaught_error_message assert mock_error.call_args.args[1] == __local_version__ assert mock_error.call_args.args[2] == Logger.debug_log_filename() + + +def test_args_after_sub_work(mock_sys_exit): + with mock_sys_exit(expected_exit_code=0), patch.object( + sys, + "argv", + ["ytdl-sub", "-c", "examples/tv_show_config.yaml", "sub", "--log-level", "verbose"], + ), patch("ytdl_sub.cli.main._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 Logger._LOGGER_LEVEL == LoggerLevels.VERBOSE diff --git a/tests/unit/prebuilt_presets/conftest.py b/tests/unit/prebuilt_presets/conftest.py index 9401b3d2..e1f8ad14 100644 --- a/tests/unit/prebuilt_presets/conftest.py +++ b/tests/unit/prebuilt_presets/conftest.py @@ -56,6 +56,7 @@ def mock_entry_dict_factory(mock_downloaded_file_path) -> Callable: playlist_index: int = 1, playlist_count: int = 1, is_youtube_channel: bool = False, + mock_download_to_working_dir: bool = True, ) -> Dict: entry_dict = { UID: uid, @@ -84,12 +85,14 @@ def mock_entry_dict_factory(mock_downloaded_file_path) -> Callable: ] # Create mock video file - copy_file_fixture( - fixture_name="sample_vid.mp4", output_file_path=mock_downloaded_file_path(f"{uid}.mp4") - ) - copy_file_fixture( - fixture_name="thumb.jpg", output_file_path=mock_downloaded_file_path(f"{uid}.jpg") - ) + if mock_download_to_working_dir: + copy_file_fixture( + fixture_name="sample_vid.mp4", + output_file_path=mock_downloaded_file_path(f"{uid}.mp4"), + ) + copy_file_fixture( + fixture_name="thumb.jpg", output_file_path=mock_downloaded_file_path(f"{uid}.jpg") + ) return entry_dict return _mock_entry_dict_factory @@ -124,36 +127,33 @@ def mock_download_collection_entries( ): @contextlib.contextmanager def _mock_download_collection_entries_factory(is_youtube_channel: bool): - def _(**kwargs): - return mock_entry_dict_factory(**kwargs) - def _write_entries_to_working_dir(*args, **kwargs) -> List[Dict]: if (len(args[0].collection.urls.list) == 1) or ( "season.2" in kwargs["url"] and len(args[0].download_options.urls.list) > 1 ): return [ - _( + mock_entry_dict_factory( uid="21-1", upload_date="20210808", playlist_index=1, playlist_count=4, is_youtube_channel=is_youtube_channel, ), # 1 - _( + mock_entry_dict_factory( uid="20-1", upload_date="20200808", playlist_index=2, playlist_count=4, is_youtube_channel=is_youtube_channel, ), # 2 98 - _( + mock_entry_dict_factory( uid="20-2", upload_date="20200808", playlist_index=3, playlist_count=4, is_youtube_channel=is_youtube_channel, ), # 1 99 - _( + mock_entry_dict_factory( uid="20-3", upload_date="20200807", playlist_index=4, @@ -163,35 +163,36 @@ def mock_download_collection_entries( ] return [ # 20-3 should resolve to collection 1 (which is season 2) - _( + mock_entry_dict_factory( uid="20-3", upload_date="20200807", playlist_index=1, playlist_count=5, is_youtube_channel=is_youtube_channel, + mock_download_to_working_dir=False, ), - _( + mock_entry_dict_factory( uid="20-4", upload_date="20200806", playlist_index=2, playlist_count=5, is_youtube_channel=is_youtube_channel, ), - _( + mock_entry_dict_factory( uid="20-5", upload_date="20200706", playlist_index=3, playlist_count=5, is_youtube_channel=is_youtube_channel, ), - _( + mock_entry_dict_factory( uid="20-6", upload_date="20200706", playlist_index=4, playlist_count=5, is_youtube_channel=is_youtube_channel, ), - _( + mock_entry_dict_factory( uid="20-7", upload_date="20200606", playlist_index=5, diff --git a/tests/unit/utils/test_logger.py b/tests/unit/utils/test_logger.py index 9509b546..0062b27a 100644 --- a/tests/unit/utils/test_logger.py +++ b/tests/unit/utils/test_logger.py @@ -6,13 +6,6 @@ from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import LoggerLevels -@pytest.fixture(autouse=True) -def cleanup_debug_file(): - Logger.set_log_level(log_level_name=LoggerLevels.DEBUG.name) - yield - Logger.cleanup() - - class TestLogger: @pytest.mark.parametrize( "log_level", @@ -114,7 +107,7 @@ class TestLogger: "[ytdl-sub:name_test] debug test\n", ] - Logger.cleanup(delete_debug_file=True) + Logger.cleanup() assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name) @pytest.mark.parametrize(