[FEATURE] Persisted subscription logs (#512)
This commit is contained in:
parent
022c24cc2c
commit
648027204f
12 changed files with 447 additions and 146 deletions
|
|
@ -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
|
||||
^^^^^^^
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue