test and readthedocs

This commit is contained in:
Jesse Bannon 2023-03-07 23:54:51 -08:00
parent e7b91e107e
commit 8549f24600
7 changed files with 198 additions and 45 deletions

View file

@ -29,7 +29,22 @@ and subscriptions.
.. autoclass:: ytdl_sub.config.config_validator.ConfigOptions() .. autoclass:: ytdl_sub.config.config_validator.ConfigOptions()
:members: :members:
:member-order: bysource :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"
.. autoclass:: ytdl_sub.config.config_validator.PersistLogsValidator()
:members:
:member-order: bysource
presets presets
^^^^^^^ ^^^^^^^

View file

@ -1,4 +1,3 @@
import argparse
import gc import gc
import sys import sys
from datetime import datetime from datetime import datetime
@ -26,15 +25,24 @@ _VIEW_EXTRA_ARGS_FORMATTER = "--preset _view --overrides.url {}"
def _maybe_write_subscription_log_file( def _maybe_write_subscription_log_file(
config: ConfigFile, subscription: Subscription, success: bool config: ConfigFile,
) -> Optional[Path]: 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 persisting logs is disabled, do nothing
if not config.config_options.persist_logs: if not config.config_options.persist_logs:
return None return
# If persisting successful logs is disabled, do nothing # If persisting successful logs is disabled, do nothing
if success and not config.config_options.persist_logs.keep_successful_logs: if success and not config.config_options.persist_logs.keep_successful_logs:
return None return
log_time = datetime.now().strftime("%Y-%m-%d-%H%M%S") log_time = datetime.now().strftime("%Y-%m-%d-%H%M%S")
log_subscription_name = sanitize_filename(subscription.name).lower().replace(" ", "_") log_subscription_name = sanitize_filename(subscription.name).lower().replace(" ", "_")
@ -43,12 +51,14 @@ def _maybe_write_subscription_log_file(
log_filename = f"{log_time}.{log_subscription_name}.{log_success}.log" log_filename = f"{log_time}.{log_subscription_name}.{log_success}.log"
persist_log_path = Path(config.config_options.persist_logs.logs_directory) / log_filename 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) FileHandler.copy(Logger.debug_log_filename(), persist_log_path)
return persist_log_path
def _download_subscriptions_from_yaml_files( def _download_subscriptions_from_yaml_files(
config: ConfigFile, args: argparse.Namespace config: ConfigFile, subscription_paths: List[str], dry_run: bool
) -> List[Tuple[Subscription, FileHandlerTransactionLog]]: ) -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
""" """
Downloads all subscriptions from one or many subscription yaml files. Downloads all subscriptions from one or many subscription yaml files.
@ -57,8 +67,10 @@ def _download_subscriptions_from_yaml_files(
---------- ----------
config config
Configuration file Configuration file
args subscription_paths
Arguments from argparse Path to subscription files to download
dry_run
Whether to dry run or not
Returns Returns
------- -------
@ -69,7 +81,6 @@ def _download_subscriptions_from_yaml_files(
Exception Exception
Any exception during download Any exception during download
""" """
subscription_paths: List[str] = args.subscription_paths
subscriptions: List[Subscription] = [] subscriptions: List[Subscription] = []
output: List[Tuple[Subscription, FileHandlerTransactionLog]] = [] output: List[Tuple[Subscription, FileHandlerTransactionLog]] = []
@ -80,23 +91,22 @@ def _download_subscriptions_from_yaml_files(
for subscription in subscriptions: for subscription in subscriptions:
logger.info( logger.info(
"Beginning subscription %s for %s", "Beginning subscription %s for %s",
("dry run" if args.dry_run else "download"), ("dry run" if dry_run else "download"),
subscription.name, subscription.name,
) )
logger.debug("Subscription full yaml:\n%s", subscription.as_yaml()) logger.debug("Subscription full yaml:\n%s", subscription.as_yaml())
try: try:
transaction_log = subscription.download(dry_run=args.dry_run) transaction_log = subscription.download(dry_run=dry_run)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
persisted_log_path = _maybe_write_subscription_log_file( _maybe_write_subscription_log_file(
config=config, subscription=subscription, success=False config=config, subscription=subscription, dry_run=dry_run, exception=exc
) )
Logger.log_exit_exception(exception=exc, log_filepath=persisted_log_path)
raise raise
else: else:
output.append((subscription, transaction_log)) output.append((subscription, transaction_log))
_maybe_write_subscription_log_file( _maybe_write_subscription_log_file(
config=config, subscription=subscription, success=True config=config, subscription=subscription, dry_run=dry_run
) )
Logger.cleanup() # Cleanup logger after each successful subscription download Logger.cleanup() # Cleanup logger after each successful subscription download
finally: finally:
@ -177,7 +187,11 @@ def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
with working_directory_lock(config=config): with working_directory_lock(config=config):
if args.subparser == "sub": 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 # One-off download
elif args.subparser == "dl": elif args.subparser == "dl":

View file

@ -1,5 +1,6 @@
import os import os
from typing import Any from typing import Any
from typing import Dict
from ytdl_sub.config.config_validator import ConfigValidator from ytdl_sub.config.config_validator import ConfigValidator
from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset import Preset
@ -65,3 +66,11 @@ class ConfigFile(ConfigValidator):
""" """
config_dict = load_yaml(file_path=config_path) config_dict = load_yaml(file_path=config_path)
return ConfigFile.from_dict(config_dict) return ConfigFile.from_dict(config_dict)
def as_dict(self) -> Dict[str, Any]:
"""
Returns
-------
The config in its dict form
"""
return self._value

View file

@ -48,14 +48,31 @@ class PersistLogsValidator(StrictDictValidator):
@property @property
def logs_directory(self) -> str: def logs_directory(self) -> str:
"""
Required. The directory to store the logs in.
"""
return self._logs_directory.value return self._logs_directory.value
@property @property
def keep_logs_after(self) -> Optional[str]: 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 return self._keep_logs_after
@property @property
def keep_successful_logs(self) -> bool: def keep_successful_logs(self) -> bool:
"""
Optional. Whether to store logs when downloading is successful. Defaults to True.
"""
return self._keep_successful_logs.value return self._keep_successful_logs.value
@ -142,7 +159,9 @@ class ConfigOptions(StrictDictValidator):
@property @property
def persist_logs(self) -> Optional[PersistLogsValidator]: def persist_logs(self) -> Optional[PersistLogsValidator]:
# TODO: nested docstring??? """
Persist logs validator. readthedocs in the validator itself!
"""
return self._persist_logs return self._persist_logs
@property @property

View file

@ -139,7 +139,7 @@ def preset_dict_to_subscription_yaml_generator() -> Callable:
# Example config fixtures # Example config fixtures
def _load_config(config_path: Path, working_directory: Path) -> ConfigFile: def _load_config(config_path: Path, working_directory: str) -> ConfigFile:
config_dict = load_yaml(file_path=config_path) config_dict = load_yaml(file_path=config_path)
config_dict["configuration"]["working_directory"] = working_directory config_dict["configuration"]["working_directory"] = working_directory
@ -156,6 +156,11 @@ def music_video_config(music_video_config_path, working_directory) -> ConfigFile
return _load_config(music_video_config_path, working_directory) 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() @pytest.fixture()
def channel_as_tv_show_config(working_directory) -> ConfigFile: def channel_as_tv_show_config(working_directory) -> ConfigFile:
return _load_config( return _load_config(

View file

@ -1,24 +1,18 @@
import sys import re
import tempfile import tempfile
import time
from pathlib import Path
from typing import Callable
from unittest.mock import patch from unittest.mock import patch
import mergedeep
import pytest import pytest
from ytdl_sub.cli.main import main 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
def test_args_after_sub_work(): from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
with patch.object( from ytdl_sub.utils.logger import Logger
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()
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 @pytest.fixture
@ -28,21 +22,104 @@ def persist_logs_directory():
@pytest.fixture @pytest.fixture
def persist_logs_config(): def persist_logs_config_factory(
pass 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: class TestPersistLogs:
@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])
def test_subscription_logs_write_to_file( def test_subscription_logs_write_to_file(
self, persist_logs_directory: str, mock_success_output: bool, keep_successful_logs: bool 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
config_dict = { with patch.object(
"working_directory": ".", Subscription,
"persist_logs": { "download",
"logs_directory": persist_logs_directory, new=mock_subscription_download_factory(mock_success_output=mock_success_output),
"keep_successful_logs": keep_successful_logs, ):
}, 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"
)

View file

@ -9,6 +9,7 @@ from src.ytdl_sub import __local_version__
from src.ytdl_sub.main import main from src.ytdl_sub.main import main
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.logger import LoggerLevels
@pytest.fixture @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[0] == expected_uncaught_error_message
assert mock_error.call_args.args[1] == __local_version__ assert mock_error.call_args.args[1] == __local_version__
assert mock_error.call_args.args[2] == Logger.debug_log_filename() 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