diff --git a/src/ytdl_sub/config/config_validator.py b/src/ytdl_sub/config/config_validator.py index b5d234a9..073eb23a 100644 --- a/src/ytdl_sub/config/config_validator.py +++ b/src/ytdl_sub/config/config_validator.py @@ -12,6 +12,8 @@ from ytdl_sub.config.defaults import DEFAULT_FFPROBE_PATH from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS +from ytdl_sub.utils.exceptions import SubscriptionPermissionError +from ytdl_sub.utils.file_handler import FileHandler 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 @@ -147,6 +149,12 @@ class ConfigOptions(StrictDictValidator): key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES ) + if not FileHandler.is_path_writable(self.working_directory): + raise SubscriptionPermissionError( + "ytdl-sub does not have permissions to the working directory: " + f"{self.working_directory}" + ) + @property def working_directory(self) -> str: """ diff --git a/src/ytdl_sub/config/preset_options.py b/src/ytdl_sub/config/preset_options.py index 2f9e75df..93f36453 100644 --- a/src/ytdl_sub/config/preset_options.py +++ b/src/ytdl_sub/config/preset_options.py @@ -8,6 +8,9 @@ from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.plugin.plugin_operation import PluginOperation from ytdl_sub.config.validators.options import OptionsDictValidator from ytdl_sub.entries.script.variable_definitions import VARIABLES as v +from ytdl_sub.utils.exceptions import SubscriptionPermissionError +from ytdl_sub.utils.exceptions import ValidationException +from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator @@ -57,12 +60,24 @@ class YTDLOptions(UnstructuredOverridesDictFormatterValidator): def to_native_dict(self, overrides: Overrides) -> Dict: """ Materializes the entire ytdl-options dict from OverrideStringFormatters into - native python + native python. """ - return { + out = { key: overrides.apply_overrides_formatter_to_native(val) for key, val in self.dict.items() } + if "cookiefile" in out: + if not FileHandler.is_file_existent(out["cookiefile"]): + raise ValidationException( + f"Specified cookiefile {out['cookiefile']} but it does not exist as a file." + ) + + if not FileHandler.is_file_readable(out["cookiefile"]): + raise SubscriptionPermissionError( + f"Cannot read cookiefile {out['cookiefile']} due to permissions issue." + ) + + return out # Disable for proper docstring formatting diff --git a/src/ytdl_sub/subscriptions/base_subscription.py b/src/ytdl_sub/subscriptions/base_subscription.py index 970f142d..39a8a70a 100644 --- a/src/ytdl_sub/subscriptions/base_subscription.py +++ b/src/ytdl_sub/subscriptions/base_subscription.py @@ -10,6 +10,8 @@ from ytdl_sub.config.preset_options import OutputOptions from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.downloaders.url.validators import MultiUrlValidator from ytdl_sub.entries.variables.override_variables import SubscriptionVariables +from ytdl_sub.utils.exceptions import SubscriptionPermissionError +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.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive @@ -94,6 +96,12 @@ class BaseSubscription(ABC): self._exception: Optional[Exception] = None + if not FileHandler.is_path_writable(self.output_directory): + raise SubscriptionPermissionError( + "ytdl-sub does not have write permissions to the output directory: " + f"{self.output_directory}" + ) + @property def download_archive(self) -> EnhancedDownloadArchive: """ diff --git a/src/ytdl_sub/utils/exceptions.py b/src/ytdl_sub/utils/exceptions.py index bfc222ec..76d80780 100644 --- a/src/ytdl_sub/utils/exceptions.py +++ b/src/ytdl_sub/utils/exceptions.py @@ -40,3 +40,7 @@ class FileNotDownloadedException(ValueError): class ExperimentalFeatureNotEnabled(ValidationException): """Feature is not enabled for usage""" + + +class SubscriptionPermissionError(ValidationException): + """Early-caught permission error""" diff --git a/src/ytdl_sub/utils/file_handler.py b/src/ytdl_sub/utils/file_handler.py index cdf9c099..1db4bb6c 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -379,6 +379,36 @@ class FileHandler: """ return self._file_handler_transaction_log + @classmethod + def is_path_writable(cls, src_file_path: Union[str, Path]) -> bool: + """ + Check whether a path is writable. If it does not exist, try to find the base directory + and check permissions on that. + """ + path = os.path.abspath(src_file_path) + + while not os.path.exists(path): + new_path = os.path.dirname(path) + if new_path == path: # reached root + break + path = new_path + + return os.access(path, os.W_OK) + + @classmethod + def is_file_existent(cls, file_path: Union[str, Path]) -> bool: + """ + Check whether a file exists. + """ + return os.path.isfile(file_path) + + @classmethod + def is_file_readable(cls, file_path: Union[str, Path]) -> bool: + """ + Check whether a file exists and is readable. + """ + return cls.is_file_existent(file_path) and os.access(file_path, os.R_OK) + @classmethod def copy(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]): """ diff --git a/tests/conftest.py b/tests/conftest.py index aabbba8d..449163c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ from typing import Dict from typing import List from typing import Optional from typing import Tuple +from typing import Union from unittest.mock import patch import pytest @@ -165,19 +166,18 @@ def preset_dict_to_dl_args(preset_dict: Dict) -> str: @pytest.fixture -def preset_dict_to_subscription_yaml_generator() -> Callable: +def subscription_yaml_file_generator() -> Callable: @contextlib.contextmanager - def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict): - subscription_dict = {subscription_name: preset_dict} + def _subscription_yaml_file_generator(yaml_dict: Dict): with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file: - tmp_file.write(json.dumps(subscription_dict).encode("utf-8")) + tmp_file.write(json.dumps(yaml_dict).encode("utf-8")) try: yield tmp_file.name finally: FileHandler.delete(tmp_file.name) - return _preset_dict_to_subscription_yaml_generator + return _subscription_yaml_file_generator ################################################################################################### @@ -224,8 +224,14 @@ def _load_config(config_path: Path, working_directory: str) -> ConfigFile: @pytest.fixture() -def music_video_subscription_path() -> Path: - return Path("examples/music_video_subscriptions.yaml") +def music_video_subscription_path( + output_directory: str, subscription_yaml_file_generator: Callable +) -> Path: + yaml = load_yaml("examples/music_video_subscriptions.yaml") + yaml["__preset__"]["overrides"]["music_video_directory"] = output_directory + + with subscription_yaml_file_generator(yaml) as mock_filename: + yield mock_filename @pytest.fixture() @@ -239,13 +245,25 @@ def tv_show_config(working_directory, tv_show_config_path) -> ConfigFile: @pytest.fixture() -def tv_show_subscriptions_path() -> Path: - return Path("examples/tv_show_subscriptions.yaml") +def tv_show_subscriptions_path( + output_directory: str, subscription_yaml_file_generator: Callable +) -> Path: + yaml = load_yaml("examples/tv_show_subscriptions.yaml") + yaml["__preset__"]["overrides"]["tv_show_directory"] = output_directory + + with subscription_yaml_file_generator(yaml) as mock_filename: + yield mock_filename @pytest.fixture() -def advanced_tv_show_subscriptions_path() -> Path: - return Path("examples/advanced/tv_show_subscriptions.yaml") +def advanced_tv_show_subscriptions_path( + output_directory: str, subscription_yaml_file_generator: Callable +) -> Path: + yaml = load_yaml("examples/advanced/tv_show_subscriptions.yaml") + yaml["__preset__"] = {"overrides": {"tv_show_directory": output_directory}} + + with subscription_yaml_file_generator(yaml) as mock_filename: + yield mock_filename @pytest.fixture() @@ -265,8 +283,25 @@ def default_config_path(default_config) -> str: @pytest.fixture() -def music_subscriptions_path() -> Path: - return Path("examples/music_subscriptions.yaml") +def music_subscriptions_path( + output_directory: str, subscription_yaml_file_generator: Callable +) -> Path: + yaml = load_yaml("examples/music_subscriptions.yaml") + yaml["__preset__"]["overrides"]["music_directory"] = output_directory + + with subscription_yaml_file_generator(yaml) as mock_filename: + yield mock_filename + + +@pytest.fixture() +def docker_default_subscription_path( + output_directory: str, subscription_yaml_file_generator: Callable +) -> Path: + yaml = load_yaml("docker/root/defaults/subscriptions.yaml") + yaml["__preset__"]["overrides"]["tv_show_directory"] = output_directory + + with subscription_yaml_file_generator(yaml) as mock_filename: + yield mock_filename def mock_run_from_cli(args: str) -> List[Subscription]: diff --git a/tests/unit/config/test_subscription.py b/tests/unit/config/test_subscription.py index 044509bf..423ff8b0 100644 --- a/tests/unit/config/test_subscription.py +++ b/tests/unit/config/test_subscription.py @@ -540,9 +540,9 @@ def test_music_video_subscriptions(default_config: ConfigFile, music_video_subsc assert gnr.get("url2").native == "https://www.youtube.com/watch?v=OldpIhHPsbs" -def test_default_docker_config_and_subscriptions(): +def test_default_docker_config_and_subscriptions(docker_default_subscription_path: Path): default_config = ConfigFile.from_file_path("docker/root/defaults/config.yaml") default_subs = Subscription.from_file_path( - config=default_config, subscription_path=Path("docker/root/defaults/subscriptions.yaml") + config=default_config, subscription_path=docker_default_subscription_path ) assert len(default_subs) == 1 diff --git a/tests/unit/plugins/test_ytdl_options.py b/tests/unit/plugins/test_ytdl_options.py new file mode 100644 index 00000000..bfeb0ef1 --- /dev/null +++ b/tests/unit/plugins/test_ytdl_options.py @@ -0,0 +1,38 @@ +import re +from typing import Any +from typing import Dict + +import pytest +from conftest import get_match_filters + +from ytdl_sub.config.config_file import ConfigFile +from ytdl_sub.subscriptions.subscription import Subscription +from ytdl_sub.utils.exceptions import ValidationException + + +@pytest.fixture +def preset_dict(output_directory) -> Dict[str, Any]: + return { + "download": "https://your.name.here", + "output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"}, + } + + +class TestYtdlOptions: + def test_cookiefile_does_not_exist( + self, + default_config: ConfigFile, + preset_dict: Dict[str, Any], + ): + preset_dict["ytdl_options"] = { + "cookiefile": "/path/to/nowhere", + } + + error_msg = "Specified cookiefile /path/to/nowhere but it does not exist as a file." + + with pytest.raises(ValidationException, match=re.escape(error_msg)): + Subscription.from_dict( + config=default_config, + preset_name="test_ytdl_options", + preset_dict=preset_dict, + ).download(dry_run=False) diff --git a/tests/unit/utils/test_file_handler.py b/tests/unit/utils/test_file_handler.py new file mode 100644 index 00000000..80505e24 --- /dev/null +++ b/tests/unit/utils/test_file_handler.py @@ -0,0 +1,20 @@ +import pytest + +from ytdl_sub.utils.file_handler import FileHandler +from ytdl_sub.utils.system import IS_WINDOWS + + +class TestFileHandler: + + def test_directory_exists(self): + if IS_WINDOWS: + return + + assert FileHandler.is_path_writable("/tmp") + assert FileHandler.is_path_writable("/tmp/") + assert FileHandler.is_path_writable("/tmp/non-existent") + assert FileHandler.is_path_writable("/tmp/non-existent/") + assert FileHandler.is_path_writable("/tmp/non-existent/nested") + + assert not FileHandler.is_path_writable("/lol-in-root") + assert not FileHandler.is_path_writable("/")