diff --git a/.gitignore b/.gitignore
index e53487635..8ea92206f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
/Downloads/
/Old Files/
cyberdrop_dl_debug.log
+.coverage
# Python cache
__pycache__
diff --git a/cyberdrop_dl/config_definitions/config_settings.py b/cyberdrop_dl/config_definitions/config_settings.py
index 843d6a9be..7243efe1e 100644
--- a/cyberdrop_dl/config_definitions/config_settings.py
+++ b/cyberdrop_dl/config_definitions/config_settings.py
@@ -8,7 +8,7 @@
from cyberdrop_dl.utils.data_enums_classes.hash import Hashing
from cyberdrop_dl.utils.data_enums_classes.supported_domains import SUPPORTED_SITES_DOMAINS
-from .custom_types import AliasModel, HttpAppriseURLModel, NonEmptyStr
+from .custom_types import AliasModel, HttpAppriseURL, NonEmptyStr
class DownloadOptions(BaseModel):
@@ -41,7 +41,7 @@ class Files(AliasModel):
class Logs(AliasModel):
log_folder: Path = APP_STORAGE / "Configs" / "{config}" / "Logs"
- webhook: HttpAppriseURLModel | None = Field(validation_alias="webhook_url", default=None)
+ webhook: HttpAppriseURL | None = Field(validation_alias="webhook_url", default=None)
main_log: Path = Field(Path("downloader.log"), validation_alias="main_log_filename")
last_forum_post: Path = Field(Path("Last_Scraped_Forum_Posts.csv"), validation_alias="last_forum_post_filename")
unsupported_urls: Path = Field(Path("Unsupported_URLs.csv"), validation_alias="unsupported_urls_filename")
diff --git a/cyberdrop_dl/config_definitions/custom_types.py b/cyberdrop_dl/config_definitions/custom_types.py
index 51c421450..168d48a97 100644
--- a/cyberdrop_dl/config_definitions/custom_types.py
+++ b/cyberdrop_dl/config_definitions/custom_types.py
@@ -37,7 +37,7 @@ class FrozenModel(BaseModel):
class AppriseURLModel(FrozenModel):
- url: SecretAnyURL
+ url: Secret[AnyUrl]
tags: set[str]
@model_serializer()
@@ -45,66 +45,27 @@ def serialize(self, info: SerializationInfo):
dump_secret = info.mode != "json"
url = self.url.get_secret_value() if dump_secret else self.url
tags = self.tags - set("no_logs")
+ tags = sorted(tags)
return f"{','.join(tags)}{'=' if tags else ''}{url}"
@model_validator(mode="before")
@staticmethod
def parse_input(value: dict | URL | str):
- url_obj = value
- tags = None
- if isinstance(url_obj, dict):
- tags = url_obj.get("tags")
- url_obj = url_obj.get("url")
- if isinstance(value, URL):
- url_obj = str(value)
- url = AppriseURL(url_obj, validate=False)
- return {"url": url._url, "tags": tags or url.tags or set("no_logs")}
-
-
-class AppriseURL:
- _validator = AppriseURLModel
-
- def __init__(self, url: URL | str, tags: set | None = None, *, validate: bool = True):
- self._actual_url = None
- self._url = str(url)
- if validate:
- self._validate()
- else:
- self.parse_str(url, tags)
-
- @property
- def tags(self) -> set[str]:
- return self._tags
-
- @property
- def url(self) -> URL:
- self._validate()
- return self._actual_url
-
- def parse_str(self, url: URL | str, tags: set | None = None):
- self._tags = tags or set("no_logs")
- self._url = str(url)
- self._actual_url = url if isinstance(url, URL) else None
- parts = self._url.split("://", 1)[0].split("=", 1)
- if len(parts) == 2 and not self._actual_url:
- self._tags = set(parts[0].split(","))
- self._url: str = url.split("=", 1)[-1]
-
- def _validate(self):
- if not self._actual_url:
- apprise_model = self._validator(url=self._url)
- self._actual_url = apprise_model.url
-
- def __repr__(self):
- return f"AppriseURL({self._url}, tags={self.tags})"
-
- def __str__(self):
- return f"{','.join(self.tags)}{'=' if self.tags else ''}{self.url}"
-
-
-class HttpAppriseURLModel(AppriseURLModel):
- url: SecretHttpURL
-
-
-class HttpAppriseURL(AppriseURL):
- _validator = HttpAppriseURLModel
+ url = value
+ tags = set()
+ if isinstance(value, dict):
+ tags = value.get("tags") or tags
+ url = value.get("url", "")
+
+ if isinstance(url, URL):
+ url = str(url)
+ parts = url.split("://", 1)[0].split("=", 1)
+ if len(parts) == 2:
+ tags = set(parts[0].split(","))
+ url: str = url.split("=", 1)[-1]
+
+ return {"url": url, "tags": tags}
+
+
+class HttpAppriseURL(AppriseURLModel):
+ url: Secret[HttpURL]
diff --git a/cyberdrop_dl/main.py b/cyberdrop_dl/main.py
index 872193b9e..ffe83f897 100644
--- a/cyberdrop_dl/main.py
+++ b/cyberdrop_dl/main.py
@@ -18,14 +18,10 @@
from cyberdrop_dl.managers.manager import Manager
from cyberdrop_dl.scraper.scraper import ScrapeMapper
from cyberdrop_dl.ui.program_ui import ProgramUI
+from cyberdrop_dl.utils.apprise import send_apprise_notifications
from cyberdrop_dl.utils.logger import RedactedConsole, log, log_spacer, log_with_color, print_to_console
from cyberdrop_dl.utils.sorting import Sorter
-from cyberdrop_dl.utils.utilities import (
- check_latest_pypi,
- check_partials_and_empty_folders,
- send_webhook_message,
- sent_apprise_notifications,
-)
+from cyberdrop_dl.utils.utilities import check_latest_pypi, check_partials_and_empty_folders, send_webhook_message
from cyberdrop_dl.utils.yaml import handle_validation_error
if TYPE_CHECKING:
@@ -58,7 +54,6 @@ def startup() -> Manager:
"AuthSettings": manager.config_manager.authentication_settings,
}
handle_validation_error(e, sources=sources)
- sys.exit(1)
except KeyboardInterrupt:
print_to_console("Exiting...")
@@ -226,7 +221,7 @@ async def director(manager: Manager) -> None:
log_with_color("Finished downloading. Enjoy :)", "green", 20, show_in_stats=False)
await send_webhook_message(manager)
- sent_apprise_notifications(manager)
+ await send_apprise_notifications(manager)
start_time = perf_counter()
diff --git a/cyberdrop_dl/managers/config_manager.py b/cyberdrop_dl/managers/config_manager.py
index bb3c6f49d..ed87292d5 100644
--- a/cyberdrop_dl/managers/config_manager.py
+++ b/cyberdrop_dl/managers/config_manager.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import os
import shutil
from dataclasses import field
from time import sleep
@@ -8,6 +9,7 @@
from cyberdrop_dl.config_definitions import AuthSettings, ConfigSettings, GlobalSettings
from cyberdrop_dl.managers.log_manager import LogManager
from cyberdrop_dl.utils import yaml
+from cyberdrop_dl.utils.apprise import get_apprise_urls
if TYPE_CHECKING:
from pathlib import Path
@@ -15,6 +17,7 @@
from pydantic import BaseModel
from cyberdrop_dl.managers.manager import Manager
+ from cyberdrop_dl.utils.apprise import AppriseURL
class ConfigManager:
@@ -26,6 +29,7 @@ def __init__(self, manager: Manager) -> None:
self.settings: Path = field(init=False)
self.global_settings: Path = field(init=False)
self.deep_scrape: bool = False
+ self.apprise_urls: list[AppriseURL] = []
self.authentication_data: AuthSettings = field(init=False)
self.settings_data: ConfigSettings = field(init=False)
@@ -51,15 +55,16 @@ def startup(self) -> None:
self.settings.parent.mkdir(parents=True, exist_ok=True)
self.pydantic_config = self.manager.cache_manager.get("pydantic_config")
self.load_configs()
- if not self.pydantic_config:
- self.pydantic_config = True
- self.manager.cache_manager.save("pydantic_config", True)
def load_configs(self) -> None:
"""Loads all the configs."""
self._load_authentication_config()
self._load_global_settings_config()
self._load_settings_config()
+ self.apprise_file = self.manager.path_manager.config_folder / self.loaded_config / "apprise.txt"
+ self.apprise_urls = get_apprise_urls(file=self.apprise_file)
+ self._set_apprise_fixed()
+ self._set_pydantic_config()
@staticmethod
def get_model_fields(model: type[BaseModel], *, exclude_unset: bool = True) -> set[str]:
@@ -172,3 +177,18 @@ def change_config(self, config_name: str) -> None:
sleep(1)
self.manager.log_manager = LogManager(self.manager)
sleep(1)
+
+ def _set_apprise_fixed(self):
+ apprise_fixed = self.manager.cache_manager.get("apprise_fixed")
+ if apprise_fixed:
+ return
+ if os.name == "nt":
+ with self.apprise_file.open("a", encoding="utf8") as f:
+ f.write("windows://\n")
+ self.manager.cache_manager.save("apprise_fixed", True)
+
+ def _set_pydantic_config(self):
+ if self.pydantic_config:
+ return
+ self.manager.cache_manager.save("pydantic_config", True)
+ self.pydantic_config = True
diff --git a/cyberdrop_dl/utils/apprise.py b/cyberdrop_dl/utils/apprise.py
new file mode 100644
index 000000000..22967c73e
--- /dev/null
+++ b/cyberdrop_dl/utils/apprise.py
@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+import json
+import shutil
+import tempfile
+from dataclasses import dataclass
+from enum import IntEnum
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import apprise
+import rich
+from pydantic import ValidationError
+from rich.text import Text
+
+from cyberdrop_dl.config_definitions.custom_types import AppriseURLModel
+from cyberdrop_dl.utils import constants
+from cyberdrop_dl.utils.logger import log, log_debug, log_spacer
+from cyberdrop_dl.utils.yaml import handle_validation_error
+
+if TYPE_CHECKING: # pragma: no cover
+ from cyberdrop_dl.managers.manager import Manager
+
+DEFAULT_APPRISE_MESSAGE = {
+ "body": "Finished downloading. Enjoy :)",
+ "title": "Cyberdrop-DL",
+ "body_format": apprise.NotifyFormat.TEXT,
+}
+
+
+@dataclass
+class AppriseURL:
+ url: str
+ tags: set[str]
+
+ @property
+ def raw_url(self):
+ tags = sorted(self.tags)
+ return f"{','.join(tags)}{'=' if tags else ''}{self.url}"
+
+
+OS_URLS = ["windows://", "macosx://", "dbus://", "qt://", "glib://", "kde://"]
+
+
+class LogLevel(IntEnum):
+ NOTSET = 0
+ DEBUG = 10
+ INFO = 20
+ WARNING = 30
+ ERROR = 40
+ CRITICAL = 50
+
+
+LOG_LEVEL_NAMES = [x.name for x in LogLevel]
+
+
+@dataclass
+class LogLine:
+ level: LogLevel = LogLevel.INFO
+ msg: str = ""
+
+
+def get_apprise_urls(*, file: Path | None = None, urls: list[str] | None = None) -> list[AppriseURL]:
+ """
+ Get Apprise URLs from the specified file or directly from a provided URL.
+
+ Args:
+ file (Path, optional): The path to the file containing Apprise URLs.
+ url (str, optional): A single Apprise URL to be processed.
+
+ Returns:
+ list[AppriseURL] | None: A list of processed Apprise URLs, or None if no valid URLs are found.
+ """
+ if not (urls or file):
+ raise ValueError("Neither url of file were supplied")
+ if urls and file:
+ raise ValueError("url of file are mutually exclusive")
+
+ if file:
+ if not file.is_file():
+ return []
+ with file.open(encoding="utf8") as apprise_file:
+ urls = [line.strip() for line in apprise_file if line.strip()]
+
+ if not urls:
+ return []
+ try:
+ return _simplify_urls([AppriseURLModel(url=url) for url in set(urls)])
+
+ except ValidationError as e:
+ sources = {"AppriseURLModel": file} if file else None
+ handle_validation_error(e, sources=sources)
+
+
+def _simplify_urls(apprise_urls: list[AppriseURLModel]) -> list[AppriseURL]:
+ final_urls = []
+ valid_tags = {"no_logs", "attach_logs", "simplified"}
+
+ def use_simplified(url: str) -> bool:
+ special_urls = OS_URLS
+ return any(key in url.casefold() for key in special_urls)
+
+ for apprise_url in apprise_urls:
+ url = str(apprise_url.url.get_secret_value())
+ tags = apprise_url.tags or {"no_logs"}
+ if not any(tag in tags for tag in valid_tags):
+ tags = tags | {"no_logs"}
+ if use_simplified(url):
+ tags = tags - valid_tags | {"simplified"}
+ entry = AppriseURL(url=url, tags=tags)
+ final_urls.append(entry)
+ return sorted(final_urls, key=lambda x: x.url)
+
+
+def _process_results(
+ all_urls: list[str], results: dict[str, bool | None], apprise_logs: str
+) -> tuple[constants.NotificationResult, list[LogLine]]:
+ result = [r for r in results.values() if r is not None]
+ result_dict = {}
+ for key, value in results.items():
+ if value:
+ result_dict[key] = str(constants.NotificationResult.SUCCESS.value)
+ elif value is None:
+ result_dict[key] = str(constants.NotificationResult.NONE.value)
+ else:
+ result_dict[key] = str(constants.NotificationResult.FAILED.value)
+
+ if all(result):
+ final_result = constants.NotificationResult.SUCCESS
+ elif any(result):
+ final_result = constants.NotificationResult.PARTIAL
+ else:
+ final_result = constants.NotificationResult.FAILED
+
+ log_spacer(10, log_to_console=False, log_to_file=not all(result))
+ rich.print("Apprise notifications results:", final_result.value)
+ logger = log_debug if all(result) else log
+ logger(f"Apprise notifications results: {final_result.value}")
+ logger(f"PARSED_APPRISE_URLs: \n{json.dumps(all_urls, indent=4)}\n")
+ logger(f"RESULTS_BY_TAGS: \n{json.dumps(result_dict, indent=4)}")
+ log_spacer(10, log_to_console=False, log_to_file=not all(result))
+ parsed_log_lines = _parse_apprise_logs(apprise_logs)
+ for line in parsed_log_lines:
+ logger(level=line.level.value, message=line.msg)
+ return final_result, parsed_log_lines
+
+
+def _reduce_logs(apprise_logs: str) -> list[str]:
+ lines = apprise_logs.splitlines()
+ to_exclude = ["Running Post-Download Processes For Config"]
+ return [line for line in lines if all(word not in line for word in to_exclude)]
+
+
+def _parse_apprise_logs(apprise_logs: str) -> list[LogLine]:
+ lines = _reduce_logs(apprise_logs)
+ current_line: LogLine = LogLine()
+ parsed_lines: list[LogLine] = []
+ for line in lines:
+ log_level = line[0:8].strip()
+ if log_level and log_level not in LOG_LEVEL_NAMES: # pragma: no cover
+ current_line.msg += f"\n{line}"
+ continue
+
+ if current_line.msg != "":
+ parsed_lines.append(current_line)
+ current_line = LogLine(LogLevel[log_level], line[10::])
+ if lines:
+ parsed_lines.append(current_line)
+ return parsed_lines
+
+
+async def send_apprise_notifications(manager: Manager) -> tuple[constants.NotificationResult, list[LogLine]]:
+ """
+ Send notifications using Apprise based on the URLs set in the manager.
+
+ Args:
+ manager (Manager): The manager instance containing.
+
+ Returns:
+ tuple[NotificationResult, list[LogLine]]: A tuple containing the overall notification result and a list of log lines.
+
+ """
+ apprise_urls = manager.config_manager.apprise_urls
+ if not apprise_urls:
+ return constants.NotificationResult.NONE, [LogLine(msg=constants.NotificationResult.NONE.value.plain)]
+
+ rich.print("\nSending Apprise Notifications.. ")
+ text: Text = constants.LOG_OUTPUT_TEXT
+ constants.LOG_OUTPUT_TEXT = Text("")
+
+ apprise_obj = apprise.Apprise()
+ for apprise_url in apprise_urls:
+ apprise_obj.add(apprise_url.url, tag=apprise_url.tags)
+
+ main_log = manager.path_manager.main_log.resolve()
+ results = {}
+ all_urls = [x.raw_url for x in apprise_urls]
+
+ with (
+ apprise.LogCapture(level=10, fmt="%(levelname)-7s - %(message)s") as capture,
+ tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir,
+ ):
+ temp_dir = Path(temp_dir)
+ temp_main_log = temp_dir / main_log.name
+ notifications_to_send = {
+ "no_logs": {"body": text.plain},
+ "attach_logs": {"body": text.plain},
+ "simplified": {},
+ }
+ attach_file_failed_msg = "Unable to get copy of main log file. 'attach_logs' URLs will be proccessed without it"
+ try:
+ shutil.copy(main_log, temp_main_log)
+ notifications_to_send["attach_logs"]["attach"] = str(temp_main_log.resolve())
+ except OSError:
+ log(attach_file_failed_msg, 40)
+
+ for tag, extras in notifications_to_send.items():
+ msg = DEFAULT_APPRISE_MESSAGE | extras
+ results[tag] = await apprise_obj.async_notify(**msg, tag=tag)
+ apprise_logs = capture.getvalue()
+
+ return _process_results(all_urls, results, apprise_logs)
diff --git a/cyberdrop_dl/utils/args.py b/cyberdrop_dl/utils/args.py
index 0c52f8753..f0bcfcc0b 100644
--- a/cyberdrop_dl/utils/args.py
+++ b/cyberdrop_dl/utils/args.py
@@ -12,7 +12,7 @@
from cyberdrop_dl import __version__
from cyberdrop_dl.config_definitions import ConfigSettings, GlobalSettings
from cyberdrop_dl.config_definitions.custom_types import AliasModel, HttpURL
-from cyberdrop_dl.utils.utilities import handle_validation_error
+from cyberdrop_dl.utils.yaml import handle_validation_error
if TYPE_CHECKING:
from pydantic.fields import FieldInfo
diff --git a/cyberdrop_dl/utils/constants.py b/cyberdrop_dl/utils/constants.py
index 37de01cdb..d1632c7c1 100644
--- a/cyberdrop_dl/utils/constants.py
+++ b/cyberdrop_dl/utils/constants.py
@@ -1,5 +1,5 @@
import re
-from enum import IntEnum, StrEnum, auto
+from enum import Enum, IntEnum, StrEnum, auto
from pathlib import Path
from rich.text import Text
@@ -63,6 +63,13 @@ class BROWSERS(StrEnum):
chromium = auto()
+class NotificationResult(Enum):
+ SUCCESS = Text("Success", "green")
+ FAILED = Text("Failed", "bold red")
+ PARTIAL = Text("Partial Success", "yellow")
+ NONE = Text("No Notifications Sent", "yellow")
+
+
# Pypi
PRERELEASE_TAGS = {
"dev": "Development",
diff --git a/cyberdrop_dl/utils/logger.py b/cyberdrop_dl/utils/logger.py
index 08e2bf151..8883beb07 100644
--- a/cyberdrop_dl/utils/logger.py
+++ b/cyberdrop_dl/utils/logger.py
@@ -57,9 +57,10 @@ def log_with_color(message: str, style: str, level: int, show_in_stats: bool = T
constants.LOG_OUTPUT_TEXT.append_text(text.append("\n"))
-def log_spacer(level: int, char: str = "-", *, log_to_console: bool = True) -> None:
+def log_spacer(level: int, char: str = "-", *, log_to_console: bool = True, log_to_file: bool = True) -> None:
spacer = char * min(int(constants.DEFAULT_CONSOLE_WIDTH / 2), 50)
- log(spacer, level)
+ if log_to_file:
+ log(spacer, level)
if log_to_console and constants.CONSOLE_LEVEL >= 50:
console.print("")
constants.LOG_OUTPUT_TEXT.append("\n", style="black")
diff --git a/cyberdrop_dl/utils/utilities.py b/cyberdrop_dl/utils/utilities.py
index 82820d7dd..338e939f2 100644
--- a/cyberdrop_dl/utils/utilities.py
+++ b/cyberdrop_dl/utils/utilities.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import contextlib
+import json
import os
import platform
import re
@@ -10,18 +11,15 @@
from typing import TYPE_CHECKING
import aiofiles
-import apprise
import rich
from aiohttp import ClientConnectorError, ClientSession, FormData
-from pydantic import ValidationError
from rich.text import Text
from yarl import URL
from cyberdrop_dl.clients.errors import CDLBaseError, NoExtensionError
from cyberdrop_dl.managers.real_debrid.errors import RealDebridError
from cyberdrop_dl.utils import constants
-from cyberdrop_dl.utils.logger import log, log_with_color
-from cyberdrop_dl.utils.yaml import handle_validation_error
+from cyberdrop_dl.utils.logger import log, log_debug, log_spacer, log_with_color
if TYPE_CHECKING:
from collections.abc import Callable
@@ -217,7 +215,6 @@ async def check_partials_and_empty_folders(manager: Manager) -> None:
def check_latest_pypi(log_to_console: bool = True, call_from_ui: bool = False) -> tuple[str, str]:
"""Checks if the current version is the latest version."""
- import json
from requests import request
@@ -284,66 +281,6 @@ def check_prelease_version(current_version: str, releases: list[str]) -> tuple[s
return is_prerelease, latest_testing_version, message
-def sent_apprise_notifications(manager: Manager) -> None:
- apprise_file = manager.path_manager.config_folder / manager.config_manager.loaded_config / "apprise.txt"
- text: Text = constants.LOG_OUTPUT_TEXT
- constants.LOG_OUTPUT_TEXT = Text("")
-
- if not apprise_file.is_file():
- return
-
- from cyberdrop_dl.config_definitions.custom_types import AppriseURL
-
- try:
- with apprise_file.open(encoding="utf8") as file:
- apprise_urls = [AppriseURL(line.strip()) for line in file]
- except ValidationError as e:
- sources = {"AppriseURLModel": apprise_file}
- handle_validation_error(e, sources=sources)
- return
-
- if not apprise_urls:
- return
-
- rich.print("\nSending notifications.. ")
- apprise_obj = apprise.Apprise()
- for apprise_url in apprise_urls:
- apprise_obj.add(apprise_url.url, tag=apprise_url.tags)
-
- results = []
- result = apprise_obj.notify(
- body=text.plain,
- title="Cyberdrop-DL",
- body_format=apprise.NotifyFormat.TEXT,
- tag="no_logs",
- )
-
- if result is not None:
- results.append(result)
-
- result = apprise_obj.notify(
- body=text.plain,
- title="Cyberdrop-DL",
- body_format=apprise.NotifyFormat.TEXT,
- attach=str(manager.path_manager.main_log.resolve()),
- tag="attach_logs",
- )
-
- if result is not None:
- results.append(result)
-
- if not results:
- result = Text("No notifications sent", "yellow")
- if all(results):
- result = Text("Success", "green")
- elif any(results):
- result = Text("Partial Success", "yellow")
- else:
- result = Text("Failed", "bold red")
-
- rich.print("Apprise notifications results:", result)
-
-
async def send_webhook_message(manager: Manager) -> None:
"""Outputs the stats to a code block for webhook messages."""
webhook = manager.config_manager.settings_data.logs.webhook
@@ -351,6 +288,7 @@ async def send_webhook_message(manager: Manager) -> None:
if not webhook:
return
+ rich.print("\nSending Webhook Notifications.. ")
url = webhook.url.get_secret_value()
text: Text = constants.LOG_OUTPUT_TEXT
plain_text = parse_rich_text_by_style(text, constants.STYLE_TO_DIFF_FORMAT_MAP)
@@ -372,7 +310,21 @@ async def send_webhook_message(manager: Manager) -> None:
)
async with ClientSession() as session, session.post(url, data=form) as response:
- await response.text()
+ successful = 200 <= response.status <= 300
+ result = [constants.NotificationResult.SUCCESS.value]
+ result_to_log = result
+ if not successful:
+ json_resp: dict = await response.json()
+ if "content" in json_resp:
+ json_resp.pop("content")
+ json_resp = json.dumps(json_resp, indent=4)
+ result_to_log = constants.NotificationResult.FAILED.value, json_resp
+
+ log_spacer(10, log_to_console=False)
+ rich.print("Webhook Notifications Results:", *result)
+ logger = log_debug if successful else log
+ result_to_log = "\n".join(map(str, result_to_log))
+ logger(f"Webhook Notifications Results: {result_to_log}")
def open_in_text_editor(file_path: Path) -> bool:
diff --git a/cyberdrop_dl/utils/yaml.py b/cyberdrop_dl/utils/yaml.py
index d5b7b4eae..f55896871 100644
--- a/cyberdrop_dl/utils/yaml.py
+++ b/cyberdrop_dl/utils/yaml.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import sys
from datetime import date, timedelta
from enum import Enum
from pathlib import Path, PurePath
@@ -65,9 +66,9 @@ def load(file: Path, *, create: bool = False) -> dict:
raise InvalidYamlError(file, e) from None
-def handle_validation_error(e: ValidationError, *, title: str | None = None, sources: dict | None = None):
+def handle_validation_error(e: ValidationError, *, title: str | None = None, sources: dict[str, Path] | None = None):
error_count = e.error_count()
- source: Path = sources.get(e.title, None) if sources else None
+ source = sources.get(e.title) if sources else None
title = title or e.title
source = f"from {source.resolve()}" if source else ""
msg = f"found {error_count} error{'s' if error_count>1 else ''} parsing {title} {source}"
@@ -85,3 +86,4 @@ def handle_validation_error(e: ValidationError, *, title: str | None = None, sou
f" {error['msg']} (input_value='{error['input']}', input_type='{error['type']}')", style="bold red"
)
print_to_console(VALIDATION_ERROR_FOOTER)
+ sys.exit(1)
diff --git a/docs/reference/notifications.md b/docs/reference/notifications.md
index 6918b9850..82e92f424 100644
--- a/docs/reference/notifications.md
+++ b/docs/reference/notifications.md
@@ -33,6 +33,12 @@ Apprise services also support the `attach_logs=` tag to send the main log as an
Cyberdrop-DL will show you a message at the end of a run telling you if the apprise notifications were successfully sent or not. If you are having trouble getting notifications via Apprise, follow their [troubleshooting guide](https://github.com/caronc/apprise/wiki/Troubleshooting).
+{% hint style="info" %}
+When running on Windows, Cyberdrop-DL will setup OS notifications by default.
+
+You can disable them by deleting the `windows://` line from the default `apprise.txt` file. You can also completely delete the file if you don't have any other notification setup.
+{% endhint %}
+
## Examples
{% tabs %}
@@ -52,6 +58,19 @@ attach_logs=mailto://user:password@domain.com
```
+{% endtab %}
+
+{% tab title="Native OS notifications" %}
+Some operating systems require additional dependencies for notifications to work. Cyberdrop-DL includes the required dependencies for Windows. Follow the url on the OS name to get additional information on how to set them up.
+
+| OS | Syntax|
+| ---- | --- |
+|[Linux (DBus Notifications)](https://github.com/caronc/apprise/wiki/Notify_dbus) | `dbus://`
`qt://`
`glib://`
`kde:// `|
+|[Linux (Gnome Notifications)](https://github.com/caronc/apprise/wiki/Notify_gnome) | `gnome://` |
+|[macOS](https://github.com/caronc/apprise/wiki/Notify_macosx) | `macosx://` |
+|[Windows](https://github.com/caronc/apprise/wiki/Notify_windows)| `windows://` |
+
+
{% endtab %}
{% tab title="Discord + Logs" %}
diff --git a/poetry.lock b/poetry.lock
index 6d9533e53..69a046046 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -479,6 +479,80 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+[[package]]
+name = "coverage"
+version = "7.6.10"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"},
+ {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"},
+ {file = "coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a"},
+ {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165"},
+ {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988"},
+ {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5"},
+ {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3"},
+ {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5"},
+ {file = "coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244"},
+ {file = "coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e"},
+ {file = "coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3"},
+ {file = "coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43"},
+ {file = "coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132"},
+ {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f"},
+ {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994"},
+ {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99"},
+ {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd"},
+ {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377"},
+ {file = "coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8"},
+ {file = "coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609"},
+ {file = "coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853"},
+ {file = "coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078"},
+ {file = "coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0"},
+ {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50"},
+ {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022"},
+ {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b"},
+ {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0"},
+ {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852"},
+ {file = "coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359"},
+ {file = "coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247"},
+ {file = "coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9"},
+ {file = "coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b"},
+ {file = "coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690"},
+ {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18"},
+ {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c"},
+ {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd"},
+ {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e"},
+ {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694"},
+ {file = "coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6"},
+ {file = "coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e"},
+ {file = "coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe"},
+ {file = "coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273"},
+ {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8"},
+ {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098"},
+ {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb"},
+ {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0"},
+ {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf"},
+ {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2"},
+ {file = "coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312"},
+ {file = "coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d"},
+ {file = "coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a"},
+ {file = "coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27"},
+ {file = "coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4"},
+ {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f"},
+ {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25"},
+ {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315"},
+ {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90"},
+ {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d"},
+ {file = "coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18"},
+ {file = "coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59"},
+ {file = "coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f"},
+ {file = "coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
[[package]]
name = "distlib"
version = "0.3.9"
@@ -658,6 +732,17 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
[[package]]
name = "inquirerpy"
version = "0.3.4"
@@ -911,6 +996,17 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
+[[package]]
+name = "packaging"
+version = "24.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
+ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
+]
+
[[package]]
name = "pfzy"
version = "0.3.4"
@@ -1033,6 +1129,21 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.11.2)"]
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
[[package]]
name = "pre-commit"
version = "4.0.1"
@@ -1384,6 +1495,79 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
+[[package]]
+name = "pytest"
+version = "8.3.4"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
+ {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.5,<2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.25.0"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"},
+ {file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"},
+]
+
+[package.dependencies]
+pytest = ">=8.2,<9"
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
+[[package]]
+name = "pytest-cov"
+version = "6.0.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"},
+ {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"},
+]
+
+[package.dependencies]
+coverage = {version = ">=7.5", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pytest-mock"
+version = "3.14.0"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"},
+ {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"},
+]
+
+[package.dependencies]
+pytest = ">=6.2.5"
+
+[package.extras]
+dev = ["pre-commit", "pytest-asyncio", "tox"]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1398,6 +1582,33 @@ files = [
[package.dependencies]
six = ">=1.5"
+[[package]]
+name = "pywin32"
+version = "308"
+description = "Python for Window Extensions"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"},
+ {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"},
+ {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"},
+ {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"},
+ {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"},
+ {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"},
+ {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"},
+ {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"},
+ {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"},
+ {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"},
+ {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"},
+ {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"},
+ {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"},
+ {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"},
+ {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"},
+ {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"},
+ {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"},
+ {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"},
+]
+
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -1930,4 +2141,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.0"
python-versions = ">=3.11,<3.14"
-content-hash = "c1911ea681ab235436cb83baf8ba7089ff7e9fc4642ba27a87633ae13d16c34b"
+content-hash = "2a75d3fa3a38b296f65c3ad827ced3fb3c00231f5619f5f7ab03b7245c88d5e8"
diff --git a/pyproject.toml b/pyproject.toml
index 671bc4e19..5dabf6d21 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,7 @@ platformdirs = "^4.3.6"
pycryptodomex = "^3.21.0"
pydantic = "^2.10.4"
pyyaml = "^6.0.2"
+pywin32 = {version = "^308", platform = "win32"}
rich = "^13.9.4"
send2trash = "^1.8.3"
xxhash = "^3.5.0"
@@ -40,6 +41,12 @@ xxhash = "^3.5.0"
pre-commit = "^4.0.1"
ruff = "^0.8.0"
+[tool.poetry.group.test.dependencies]
+pytest = "^8.3.4"
+pytest-asyncio = "^0.25.0"
+pytest-cov = "^6.0.0"
+pytest-mock = "*"
+
[tool.poetry.scripts]
cyberdrop-dl = "cyberdrop_dl.main:main"
@@ -73,6 +80,12 @@ ignore = [
"COM812", # missing-trailing-comma
]
+[tool.pytest.ini_options]
+minversion = "8.3"
+testpaths = ["tests"]
+asyncio_mode = "auto"
+asyncio_default_fixture_loop_scope = "module"
+
[tool.ruff.lint.pydocstyle]
convention = "google"
diff --git a/tests/fake_classes/managers.py b/tests/fake_classes/managers.py
new file mode 100644
index 000000000..4bdb074b2
--- /dev/null
+++ b/tests/fake_classes/managers.py
@@ -0,0 +1,9 @@
+from cyberdrop_dl.managers.cache_manager import CacheManager
+
+
+class FakeCacheManager(CacheManager):
+ def get(self, _: str) -> True:
+ return True
+
+ def save(self, *_) -> None:
+ return
diff --git a/tests/test_apprise.py b/tests/test_apprise.py
new file mode 100644
index 000000000..5078cd149
--- /dev/null
+++ b/tests/test_apprise.py
@@ -0,0 +1,143 @@
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import pytest
+
+from cyberdrop_dl.managers.config_manager import ConfigManager
+from cyberdrop_dl.managers.manager import Manager
+from cyberdrop_dl.managers.path_manager import PathManager
+from cyberdrop_dl.utils import apprise
+from cyberdrop_dl.utils.constants import NotificationResult
+from tests.fake_classes.managers import FakeCacheManager
+
+TEST_FILES_PATH = Path("tests/test_files/apprise")
+FAKE_MANAGER = Manager()
+FAKE_MANAGER.cache_manager = FakeCacheManager(FAKE_MANAGER)
+FAKE_MANAGER.config_manager = ConfigManager(FAKE_MANAGER)
+
+URL_FAIL = "mailto://test_user:test_email@gmail.com"
+URL_SUCCESS = os.environ.get("APPRISE_TEST_EMAIL_URL", "")
+URL_SUCCESS_ATTACH_LOGS = f"attach_logs={URL_SUCCESS}"
+
+
+@dataclass
+class AppriseTestCase:
+ urls: list[str]
+ result: NotificationResult
+ include: list[str] = field(default_factory=list)
+ exclude: list[str] = field(default_factory=list)
+ file: Path | None = None
+
+
+def test_get_apprise_urls():
+ with pytest.raises(ValueError):
+ apprise.get_apprise_urls()
+
+ with pytest.raises(ValueError):
+ apprise.get_apprise_urls(urls=["url"], file=Path.cwd())
+
+ with pytest.raises(SystemExit):
+ apprise.get_apprise_urls(file=TEST_FILES_PATH / "invalid_single_url.txt")
+
+ with pytest.raises(SystemExit):
+ apprise.get_apprise_urls(file=TEST_FILES_PATH / "invalid_multiple_urls.txt")
+
+ result = apprise.get_apprise_urls(file=TEST_FILES_PATH / "file_that_does_not_exists.txt")
+ assert result == []
+
+ result = apprise.get_apprise_urls(file=TEST_FILES_PATH / "empty_file.txt")
+ assert result == []
+
+ result = apprise.get_apprise_urls(file=TEST_FILES_PATH / "valid_single_url.txt")
+ assert isinstance(result, list), "Result is not a list"
+ assert len(result) == 1, "This should be a single URL"
+ assert isinstance(result[0], apprise.AppriseURL), "Parsed URL is not an AppriseURL"
+
+ result = apprise.get_apprise_urls(file=TEST_FILES_PATH / "valid_multiple_urls.txt")
+ assert isinstance(result, list), "Result is not a list"
+ assert len(result) == 5, "These should be 5 URLs"
+
+ expected_result = [
+ apprise.AppriseURL(url="discord://avatar@webhook_id/webhook_token", tags={"no_logs"}),
+ apprise.AppriseURL(url="enigma2://hostname", tags={"another_tag", "no_logs"}),
+ apprise.AppriseURL(url="mailto://domain.com?user=userid&pass=password", tags={"tag2", "tag_1", "no_logs"}),
+ apprise.AppriseURL(url="reddit://user:password@app_id/app_secret/subreddit", tags={"attach_logs"}),
+ apprise.AppriseURL(url="windows://", tags={"simplified"}),
+ ]
+
+ for index in range(len(result)):
+ got = result[index]
+ expected = expected_result[index]
+ assert isinstance(got, apprise.AppriseURL), f"Parsed URL {got} is not an AppriseURL"
+ assert got == expected, f"Parsed URL: {got.raw_url}, Expected URL: {expected.raw_url}"
+
+
+async def send_notification(test_case: AppriseTestCase):
+ assert URL_SUCCESS, "Email URL should be set on enviroment"
+ FAKE_MANAGER.config_manager.apprise_urls = []
+ if test_case.urls and any(test_case.urls):
+ FAKE_MANAGER.config_manager.apprise_urls = apprise.get_apprise_urls(urls=test_case.urls)
+ FAKE_MANAGER.path_manager = PathManager(FAKE_MANAGER)
+ FAKE_MANAGER.path_manager.main_log = test_case.file or TEST_FILES_PATH / "valid_single_url.txt"
+ result, logs = await apprise.send_apprise_notifications(FAKE_MANAGER)
+ assert result.value == test_case.result.value, f"Result for this case should be {test_case.result.value}"
+ assert isinstance(logs, list), "Invalid return type for logs"
+ assert logs, "Logs can't be empty"
+ logs_as_str = "\n".join([line.msg for line in logs])
+ print(logs_as_str)
+ if test_case.include:
+ assert all(
+ match.casefold() in logs_as_str.casefold() for match in test_case.include
+ ), "Logs do not match expected pattern"
+ if test_case.exclude:
+ assert not any(match in logs_as_str for match in test_case.exclude), "Logs should not match exclude pattern"
+
+
+async def test_failed_notification():
+ test_case = AppriseTestCase(urls=[URL_FAIL], result=NotificationResult.FAILED)
+ await send_notification(test_case)
+
+
+async def test_successful_notification():
+ test_case = AppriseTestCase(
+ urls=[URL_SUCCESS],
+ result=NotificationResult.SUCCESS,
+ include=["Sent Email to"],
+ exclude=["Preparing Email attachment"],
+ )
+ await send_notification(test_case)
+
+
+async def test_successful_notification_with_successful_attachment():
+ test_case = AppriseTestCase(
+ urls=[URL_SUCCESS_ATTACH_LOGS],
+ result=NotificationResult.SUCCESS,
+ include=["Sent Email to", "Preparing Email attachment"],
+ )
+ await send_notification(test_case)
+
+
+async def test_successful_notification_with_failed_attachment():
+ test_case = AppriseTestCase(
+ urls=[URL_SUCCESS_ATTACH_LOGS],
+ result=NotificationResult.SUCCESS,
+ include=["Sent Email to"],
+ exclude=["Preparing Email attachment"],
+ file=TEST_FILES_PATH / "file_that_does_exists.txt",
+ )
+ await send_notification(test_case)
+
+
+async def test_none_notification():
+ test_case = AppriseTestCase(
+ urls=[""], result=NotificationResult.NONE, include=[NotificationResult.NONE.value.plain]
+ )
+ await send_notification(test_case)
+
+
+async def test_partial_notification():
+ test_case = AppriseTestCase(
+ urls=[URL_SUCCESS_ATTACH_LOGS, URL_FAIL], result=NotificationResult.PARTIAL, include=["Sent Email to"]
+ )
+ await send_notification(test_case)
diff --git a/tests/test_files/apprise/empty_file.txt b/tests/test_files/apprise/empty_file.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_files/apprise/invalid_multiple_urls.txt b/tests/test_files/apprise/invalid_multiple_urls.txt
new file mode 100644
index 000000000..3de4713b6
--- /dev/null
+++ b/tests/test_files/apprise/invalid_multiple_urls.txt
@@ -0,0 +1,3 @@
+tgram://bottoken/ChatID
+not_an_url
+also_not_an_url
diff --git a/tests/test_files/apprise/invalid_single_url.txt b/tests/test_files/apprise/invalid_single_url.txt
new file mode 100644
index 000000000..b593756c4
--- /dev/null
+++ b/tests/test_files/apprise/invalid_single_url.txt
@@ -0,0 +1 @@
+not_an_url
diff --git a/tests/test_files/apprise/valid_multiple_urls.txt b/tests/test_files/apprise/valid_multiple_urls.txt
new file mode 100644
index 000000000..bc7add847
--- /dev/null
+++ b/tests/test_files/apprise/valid_multiple_urls.txt
@@ -0,0 +1,5 @@
+discord://avatar@webhook_id/webhook_token
+windows://
+another_tag=enigma2://hostname
+attach_logs=reddit://user:password@app_id/app_secret/subreddit
+tag_1,tag2=mailto://domain.com?user=userid&pass=password
diff --git a/tests/test_files/apprise/valid_single_url.txt b/tests/test_files/apprise/valid_single_url.txt
new file mode 100644
index 000000000..b73a31297
--- /dev/null
+++ b/tests/test_files/apprise/valid_single_url.txt
@@ -0,0 +1 @@
+tgram://bottoken/ChatID