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