mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-07-30 17:48:04 -04:00
1287 lines
41 KiB
Python
1287 lines
41 KiB
Python
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
#
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import logging
|
|
import os
|
|
from os.path import exists, isfile
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import textwrap
|
|
|
|
import click
|
|
|
|
from . import (
|
|
Apprise,
|
|
AppriseAsset,
|
|
AppriseConfig,
|
|
PersistentStore,
|
|
__copyright__,
|
|
__license__,
|
|
__title__,
|
|
__version__,
|
|
)
|
|
from .common import (
|
|
NOTIFY_FORMATS,
|
|
NOTIFY_TYPES,
|
|
PERSISTENT_STORE_MODES,
|
|
ContentLocation,
|
|
NotifyFormat,
|
|
NotifyType,
|
|
PersistentStoreMode,
|
|
PersistentStoreState,
|
|
)
|
|
from .logger import logger
|
|
from .utils.disk import bytes_to_str, dir_size, path_decode
|
|
from .utils.parse import GET_SCHEMA_RE, parse_list
|
|
|
|
# By default we allow looking 1 level down recursively in Apprise configuration
|
|
# files.
|
|
DEFAULT_RECURSION_DEPTH = 1
|
|
|
|
# Default number of days to prune persistent storage
|
|
DEFAULT_STORAGE_PRUNE_DAYS = int(
|
|
os.environ.get("APPRISE_STORAGE_PRUNE_DAYS", 30)
|
|
)
|
|
|
|
# The default URL ID Length
|
|
DEFAULT_STORAGE_UID_LENGTH = int(
|
|
os.environ.get("APPRISE_STORAGE_UID_LENGTH", 8)
|
|
)
|
|
|
|
# Defines the environment variable to parse if defined. This is ONLY
|
|
# Referenced if:
|
|
# - No Configuration Files were found/loaded/specified
|
|
# - No URLs were provided directly into the CLI Call
|
|
DEFAULT_ENV_APPRISE_URLS = "APPRISE_URLS"
|
|
|
|
# Defines the override path for the configuration files read
|
|
DEFAULT_ENV_APPRISE_CONFIG_PATH = "APPRISE_CONFIG_PATH"
|
|
|
|
# Defines the override path for the plugins to load
|
|
DEFAULT_ENV_APPRISE_PLUGIN_PATH = "APPRISE_PLUGIN_PATH"
|
|
|
|
# Defines the override path for the persistent storage
|
|
DEFAULT_ENV_APPRISE_STORAGE_PATH = "APPRISE_STORAGE_PATH"
|
|
|
|
# Defines our click context settings adding -h to the additional options that
|
|
# can be specified to get the help menu to come up
|
|
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
|
|
# Define our default configuration we use if nothing is otherwise specified
|
|
DEFAULT_CONFIG_PATHS = (
|
|
# Legacy Path Support
|
|
"~/.apprise",
|
|
"~/.apprise.conf",
|
|
"~/.apprise.yml",
|
|
"~/.apprise.yaml",
|
|
"~/.config/apprise",
|
|
"~/.config/apprise.conf",
|
|
"~/.config/apprise.yml",
|
|
"~/.config/apprise.yaml",
|
|
# Plugin Support Extended Directory Search Paths
|
|
"~/.apprise/apprise",
|
|
"~/.apprise/apprise.conf",
|
|
"~/.apprise/apprise.yml",
|
|
"~/.apprise/apprise.yaml",
|
|
"~/.config/apprise/apprise",
|
|
"~/.config/apprise/apprise.conf",
|
|
"~/.config/apprise/apprise.yml",
|
|
"~/.config/apprise/apprise.yaml",
|
|
# Global Configuration File Support
|
|
"/etc/apprise",
|
|
"/etc/apprise.yml",
|
|
"/etc/apprise.yaml",
|
|
"/etc/apprise/apprise",
|
|
"/etc/apprise/apprise.conf",
|
|
"/etc/apprise/apprise.yml",
|
|
"/etc/apprise/apprise.yaml",
|
|
)
|
|
|
|
# Define our paths to search for plugins
|
|
DEFAULT_PLUGIN_PATHS = (
|
|
"~/.apprise/plugins",
|
|
"~/.config/apprise/plugins",
|
|
# Global Plugin Support
|
|
"/var/lib/apprise/plugins",
|
|
)
|
|
|
|
|
|
#
|
|
# General Options and Defaults
|
|
#
|
|
DEFAULT_NOTIFY_TYPE = NotifyType.INFO
|
|
|
|
NOTIFY_TYPE_CHOICES: tuple[NotifyType, ...] = (
|
|
NotifyType.INFO,
|
|
NotifyType.SUCCESS,
|
|
NotifyType.WARNING,
|
|
NotifyType.FAILURE,
|
|
)
|
|
|
|
DEFAULT_NOTIFY_FORMAT = NotifyFormat.TEXT
|
|
|
|
NOTIFY_FORMAT_CHOICES: tuple[NotifyFormat, ...] = (
|
|
NotifyFormat.TEXT,
|
|
NotifyFormat.MARKDOWN,
|
|
NotifyFormat.HTML,
|
|
)
|
|
|
|
#
|
|
# Persistent Storage
|
|
#
|
|
DEFAULT_STORAGE_PATH = "~/.local/share/apprise/cache"
|
|
|
|
# Storage Mode
|
|
DEFAULT_STORAGE_MODE = PersistentStoreMode.AUTO
|
|
|
|
# Create an ordered list of options (first is default)
|
|
PERSISTENT_STORE_MODE_CHOICES: tuple[PersistentStoreMode, ...] = (
|
|
PersistentStoreMode.AUTO,
|
|
PersistentStoreMode.FLUSH,
|
|
PersistentStoreMode.MEMORY,
|
|
)
|
|
|
|
# Detect Windows
|
|
if platform.system() == "Windows":
|
|
# Default Config Search Path for Windows Users
|
|
DEFAULT_CONFIG_PATHS = (
|
|
"%APPDATA%\\Apprise\\apprise",
|
|
"%APPDATA%\\Apprise\\apprise.conf",
|
|
"%APPDATA%\\Apprise\\apprise.yml",
|
|
"%APPDATA%\\Apprise\\apprise.yaml",
|
|
"%LOCALAPPDATA%\\Apprise\\apprise",
|
|
"%LOCALAPPDATA%\\Apprise\\apprise.conf",
|
|
"%LOCALAPPDATA%\\Apprise\\apprise.yml",
|
|
"%LOCALAPPDATA%\\Apprise\\apprise.yaml",
|
|
#
|
|
# Global Support
|
|
#
|
|
# C:\ProgramData\Apprise
|
|
"%ALLUSERSPROFILE%\\Apprise\\apprise",
|
|
"%ALLUSERSPROFILE%\\Apprise\\apprise.conf",
|
|
"%ALLUSERSPROFILE%\\Apprise\\apprise.yml",
|
|
"%ALLUSERSPROFILE%\\Apprise\\apprise.yaml",
|
|
# C:\Program Files\Apprise
|
|
"%PROGRAMFILES%\\Apprise\\apprise",
|
|
"%PROGRAMFILES%\\Apprise\\apprise.conf",
|
|
"%PROGRAMFILES%\\Apprise\\apprise.yml",
|
|
"%PROGRAMFILES%\\Apprise\\apprise.yaml",
|
|
# C:\Program Files\Common Files
|
|
"%COMMONPROGRAMFILES%\\Apprise\\apprise",
|
|
"%COMMONPROGRAMFILES%\\Apprise\\apprise.conf",
|
|
"%COMMONPROGRAMFILES%\\Apprise\\apprise.yml",
|
|
"%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml",
|
|
)
|
|
|
|
# Default Plugin Search Path for Windows Users
|
|
DEFAULT_PLUGIN_PATHS = (
|
|
"%APPDATA%\\Apprise\\plugins",
|
|
"%LOCALAPPDATA%\\Apprise\\plugins",
|
|
#
|
|
# Global Support
|
|
#
|
|
# C:\ProgramData\Apprise\plugins
|
|
"%ALLUSERSPROFILE%\\Apprise\\plugins",
|
|
# C:\Program Files\Apprise\plugins
|
|
"%PROGRAMFILES%\\Apprise\\plugins",
|
|
# C:\Program Files\Common Files
|
|
"%COMMONPROGRAMFILES%\\Apprise\\plugins",
|
|
)
|
|
|
|
#
|
|
# Persistent Storage
|
|
#
|
|
DEFAULT_STORAGE_PATH = "%APPDATA%/Apprise/cache"
|
|
|
|
|
|
class PersistentStorageMode:
|
|
"""Persistent Storage Modes."""
|
|
|
|
# List all detected configuration loaded
|
|
LIST = "list"
|
|
|
|
# Prune persistent storage based on age
|
|
PRUNE = "prune"
|
|
|
|
# Reset all (regardless of age)
|
|
CLEAR = "clear"
|
|
|
|
|
|
# Define the types in a list for validation purposes
|
|
PERSISTENT_STORAGE_MODES = (
|
|
PersistentStorageMode.LIST,
|
|
PersistentStorageMode.PRUNE,
|
|
PersistentStorageMode.CLEAR,
|
|
)
|
|
|
|
if os.environ.get("APPRISE_STORAGE_PATH", "").strip():
|
|
# Override Default Storage Path
|
|
DEFAULT_STORAGE_PATH = os.environ.get("APPRISE_STORAGE_PATH")
|
|
|
|
|
|
def print_version_msg():
|
|
"""Prints version message when -V or --version is specified."""
|
|
result = []
|
|
result.append(f"{__title__} v{__version__}")
|
|
result.append(__copyright__)
|
|
result.append(f"This code is licensed under the {__license__} License.")
|
|
click.echo("\n".join(result))
|
|
|
|
|
|
class CustomHelpCommand(click.Command):
|
|
def format_help(self, ctx, formatter):
|
|
formatter.write_text("Usage:")
|
|
formatter.write_text(
|
|
" apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]"
|
|
)
|
|
formatter.write_text(
|
|
" apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]"
|
|
)
|
|
|
|
# Custom help message
|
|
formatter.write_text("")
|
|
content = (
|
|
(
|
|
"Send a notification to all of the specified servers "
|
|
"identified by their URLs"
|
|
),
|
|
(
|
|
"the content provided within the title, body and "
|
|
"notification-type."
|
|
),
|
|
"",
|
|
(
|
|
"For a list of all of the supported services and information"
|
|
" on how to use "
|
|
),
|
|
"them, check out https://github.com/caronc/apprise",
|
|
)
|
|
|
|
for line in content:
|
|
formatter.write_text(line)
|
|
|
|
# Display options and arguments in the default format
|
|
self.format_options(ctx, formatter)
|
|
self.format_epilog(ctx, formatter)
|
|
|
|
# Custom 'Actions:' section after the 'Options:'
|
|
formatter.write_text("")
|
|
formatter.write_text("Actions:")
|
|
|
|
actions = [
|
|
(
|
|
"storage",
|
|
"Access the persistent storage disk administration",
|
|
[
|
|
(
|
|
"list",
|
|
(
|
|
"List all URL IDs associated with detected"
|
|
" URL(s). This is also the default action"
|
|
" run if nothing is provided"
|
|
),
|
|
),
|
|
(
|
|
"prune",
|
|
(
|
|
"Eliminates stale entries found based on "
|
|
"--storage-prune-days (-SPD)"
|
|
),
|
|
),
|
|
(
|
|
"clean",
|
|
"Removes any persistent data created by Apprise",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
#
|
|
# Some variables
|
|
#
|
|
|
|
# actions are indented this many spaces
|
|
# sub actions double this value
|
|
action_indent = 2
|
|
|
|
# label padding (for alignment)
|
|
action_label_width = 10
|
|
|
|
space = " "
|
|
space_re = re.compile(r"\r*\n")
|
|
cols = 80
|
|
indent = 10
|
|
|
|
# Format each action and its subactions
|
|
for action, description, sub_actions in actions:
|
|
# Our action indent
|
|
ai = " " * action_indent
|
|
# Format the main action description
|
|
formatted_description = space_re.split(
|
|
textwrap.fill(
|
|
description,
|
|
width=(cols - indent - action_indent),
|
|
initial_indent=space * indent,
|
|
subsequent_indent=space * indent,
|
|
)
|
|
)
|
|
for no, line in enumerate(formatted_description):
|
|
if not no:
|
|
formatter.write_text(
|
|
f"{ai}{action:<{action_label_width}}{line}"
|
|
)
|
|
|
|
else: # pragma: no cover
|
|
# Note: no branch is set intentionally since this is not
|
|
# tested since in 2025.08.13 when this was set up
|
|
# it never entered this area of the code. But we
|
|
# know it works because we repeat this process with
|
|
# our sub-options below
|
|
formatter.write_text(
|
|
f"{ai}{space:<{action_label_width}}{line}"
|
|
)
|
|
|
|
# Format each subaction
|
|
ai = " " * (action_indent * 2)
|
|
for action, description in sub_actions:
|
|
formatted_description = space_re.split(
|
|
textwrap.fill(
|
|
description,
|
|
width=(cols - indent - (action_indent * 3)),
|
|
initial_indent=space * (indent - action_indent),
|
|
subsequent_indent=space * (indent - action_indent),
|
|
)
|
|
)
|
|
|
|
for no, line in enumerate(formatted_description):
|
|
if not no:
|
|
formatter.write_text(
|
|
f"{ai}{action:<{action_label_width}}{line}"
|
|
)
|
|
else:
|
|
formatter.write_text(
|
|
f"{ai}{space:<{action_label_width}}{line}"
|
|
)
|
|
|
|
# Include any epilog or additional text
|
|
self.format_epilog(ctx, formatter)
|
|
|
|
|
|
@click.command(context_settings=CONTEXT_SETTINGS, cls=CustomHelpCommand)
|
|
@click.option(
|
|
"--body",
|
|
"-b",
|
|
default=None,
|
|
type=str,
|
|
help=(
|
|
"Specify the message body. If no body is specified then "
|
|
"content is read from <stdin>."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--title",
|
|
"-t",
|
|
default=None,
|
|
type=str,
|
|
help="Specify the message title. This field is completely optional.",
|
|
)
|
|
@click.option(
|
|
"--plugin-path",
|
|
"-P",
|
|
default=None,
|
|
type=str,
|
|
multiple=True,
|
|
metavar="PATH",
|
|
help="Specify one or more plugin paths to scan.",
|
|
)
|
|
@click.option(
|
|
"--storage-path",
|
|
"-S",
|
|
default=DEFAULT_STORAGE_PATH,
|
|
type=str,
|
|
metavar="PATH",
|
|
help=(
|
|
"Specify the path to the persistent storage location "
|
|
f"(default={DEFAULT_STORAGE_PATH})."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--storage-prune-days",
|
|
"-SPD",
|
|
default=DEFAULT_STORAGE_PRUNE_DAYS,
|
|
type=int,
|
|
help=(
|
|
"Define the number of days the storage prune should run using."
|
|
" Setting this to zero (0) will eliminate all accumulated content. By"
|
|
f" default this value is {DEFAULT_STORAGE_PRUNE_DAYS} days."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--storage-uid-length",
|
|
"-SUL",
|
|
default=DEFAULT_STORAGE_UID_LENGTH,
|
|
type=int,
|
|
help=(
|
|
"Define the number of unique characters to store persistent cache in."
|
|
f" By default this value is {DEFAULT_STORAGE_UID_LENGTH} characters."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--storage-mode",
|
|
"-SM",
|
|
default=DEFAULT_STORAGE_MODE.value,
|
|
type=str,
|
|
metavar="MODE",
|
|
help=(
|
|
"Specify the persistent storage operational mode "
|
|
f"(default={DEFAULT_STORAGE_MODE.value}). "
|
|
'Possible values are: "{}".'.format(
|
|
'", "'.join(mode.value for mode in PERSISTENT_STORE_MODE_CHOICES)
|
|
)
|
|
),
|
|
)
|
|
@click.option(
|
|
"--config",
|
|
"-c",
|
|
default=None,
|
|
type=str,
|
|
multiple=True,
|
|
metavar="CONFIG_URL",
|
|
help="Specify one or more configuration locations.",
|
|
)
|
|
@click.option(
|
|
"--attach",
|
|
"-a",
|
|
default=None,
|
|
type=str,
|
|
multiple=True,
|
|
metavar="ATTACHMENT_URL",
|
|
help="Specify one or more attachments.",
|
|
)
|
|
@click.option(
|
|
"--notification-type",
|
|
"-n",
|
|
default=DEFAULT_NOTIFY_TYPE.value,
|
|
type=str,
|
|
metavar="TYPE",
|
|
help=(
|
|
f"Specify the message type (default={DEFAULT_NOTIFY_TYPE.value}). "
|
|
'Possible values are: "{}".'.format(
|
|
'", "'.join(nt.value for nt in NOTIFY_TYPE_CHOICES)
|
|
)
|
|
),
|
|
)
|
|
@click.option(
|
|
"--input-format",
|
|
"-i",
|
|
default=DEFAULT_NOTIFY_FORMAT.value,
|
|
type=str,
|
|
metavar="FORMAT",
|
|
help=(
|
|
f"Specify the message input format "
|
|
f"(default={DEFAULT_NOTIFY_FORMAT.value}). "
|
|
'Possible values are: "{}".'.format(
|
|
'", "'.join(fmt.value for fmt in NOTIFY_FORMAT_CHOICES)
|
|
)
|
|
),
|
|
)
|
|
@click.option(
|
|
"--theme",
|
|
"-T",
|
|
default="default",
|
|
type=str,
|
|
metavar="THEME",
|
|
help="Specify the default theme.",
|
|
)
|
|
@click.option(
|
|
"--tag",
|
|
"-g",
|
|
default=None,
|
|
type=str,
|
|
multiple=True,
|
|
metavar="TAG",
|
|
help=(
|
|
"Specify one or more tags to filter which services to notify. Use "
|
|
"multiple --tag (-g) entries to match ANY tag. Use comma separators "
|
|
"to require ALL tags (strict match). Omit to notify untagged services "
|
|
'only, or use "all" to notify everything.'
|
|
),
|
|
)
|
|
@click.option(
|
|
"--disable-async",
|
|
"-Da",
|
|
is_flag=True,
|
|
help="Send all notifications sequentially",
|
|
)
|
|
@click.option(
|
|
"--dry-run",
|
|
"-d",
|
|
is_flag=True,
|
|
help=(
|
|
"Perform a trial run but only prints the notification "
|
|
"services to-be triggered to stdout. Notifications are never "
|
|
"sent using this mode."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--details",
|
|
"-l",
|
|
is_flag=True,
|
|
help="Prints details about the current services supported by Apprise.",
|
|
)
|
|
@click.option(
|
|
"--recursion-depth",
|
|
"-R",
|
|
default=DEFAULT_RECURSION_DEPTH,
|
|
type=int,
|
|
help=(
|
|
"The number of recursive import entries that can be "
|
|
"loaded from within Apprise configuration. By default "
|
|
f"this is set to {DEFAULT_RECURSION_DEPTH}."
|
|
),
|
|
)
|
|
@click.option(
|
|
"--verbose",
|
|
"-v",
|
|
count=True,
|
|
help=(
|
|
"Makes the operation more talkative. Use multiple v to "
|
|
"increase the verbosity. I.e.: -vvvv"
|
|
),
|
|
)
|
|
@click.option(
|
|
"--interpret-escapes",
|
|
"-e",
|
|
is_flag=True,
|
|
help="Enable interpretation of backslash escapes",
|
|
)
|
|
@click.option(
|
|
"--interpret-emojis",
|
|
"-j",
|
|
is_flag=True,
|
|
help="Enable interpretation of :emoji: definitions",
|
|
)
|
|
@click.option("--debug", "-D", is_flag=True, help="Debug mode")
|
|
@click.option(
|
|
"--version",
|
|
"-V",
|
|
is_flag=True,
|
|
help="Display the apprise version and exit.",
|
|
)
|
|
@click.argument(
|
|
"urls",
|
|
nargs=-1,
|
|
metavar="SERVER_URL [SERVER_URL2 [SERVER_URL3]]",
|
|
)
|
|
@click.pass_context
|
|
def main(
|
|
ctx,
|
|
body,
|
|
title,
|
|
config,
|
|
attach,
|
|
urls,
|
|
notification_type,
|
|
theme,
|
|
tag,
|
|
input_format,
|
|
dry_run,
|
|
recursion_depth,
|
|
verbose,
|
|
disable_async,
|
|
details,
|
|
interpret_escapes,
|
|
interpret_emojis,
|
|
plugin_path,
|
|
storage_path,
|
|
storage_mode,
|
|
storage_prune_days,
|
|
storage_uid_length,
|
|
debug,
|
|
version,
|
|
):
|
|
"""Send a notification to all of the specified servers identified by their
|
|
URLs the content provided within the title, body and notification-type.
|
|
|
|
For a list of all of the supported services and information on how to use
|
|
them, check out https://github.com/caronc/apprise
|
|
"""
|
|
# Note: Click ignores the return values of functions it wraps, If you
|
|
# want to return a specific error code, you must call ctx.exit()
|
|
# as you will see below.
|
|
|
|
debug = bool(debug)
|
|
if debug:
|
|
# Verbosity must be a minimum of 3
|
|
verbose = 3 if verbose < 3 else verbose
|
|
|
|
# Logging
|
|
ch = logging.StreamHandler(sys.stdout)
|
|
if verbose > 3:
|
|
# -vvvv: Most Verbose Debug Logging
|
|
logger.setLevel(logging.TRACE)
|
|
|
|
elif verbose > 2:
|
|
# -vvv: Debug Logging
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
elif verbose > 1:
|
|
# -vv: INFO Messages
|
|
logger.setLevel(logging.INFO)
|
|
|
|
elif verbose > 0:
|
|
# -v: WARNING Messages
|
|
logger.setLevel(logging.WARNING)
|
|
|
|
else:
|
|
# No verbosity means we display ERRORS only AND any deprecation
|
|
# warnings
|
|
logger.setLevel(logging.ERROR)
|
|
|
|
# Format our logger
|
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
|
ch.setFormatter(formatter)
|
|
logger.addHandler(ch)
|
|
|
|
# Update our asyncio logger
|
|
asyncio_logger = logging.getLogger("asyncio")
|
|
for handler in logger.handlers:
|
|
asyncio_logger.addHandler(handler)
|
|
asyncio_logger.setLevel(logger.level)
|
|
|
|
if version:
|
|
print_version_msg()
|
|
ctx.exit(0)
|
|
|
|
# Simple Error Checking
|
|
notification_type = notification_type.strip().lower()
|
|
if notification_type not in NOTIFY_TYPES:
|
|
click.echo(
|
|
f"The --notification-type (-n) value of {notification_type} is not"
|
|
" supported."
|
|
)
|
|
click.echo("Try 'apprise --help' for more information.")
|
|
# 2 is the same exit code returned by Click if there is a parameter
|
|
# issue. For consistency, we also return a 2
|
|
ctx.exit(2)
|
|
|
|
input_format = input_format.strip().lower()
|
|
if input_format not in NOTIFY_FORMATS:
|
|
click.echo(
|
|
f"The --input-format (-i) value of {input_format} is not"
|
|
" supported."
|
|
)
|
|
click.echo("Try 'apprise --help' for more information.")
|
|
# 2 is the same exit code returned by Click if there is a parameter
|
|
# issue. For consistency, we also return a 2
|
|
ctx.exit(2)
|
|
|
|
storage_mode = storage_mode.strip().lower()
|
|
if storage_mode not in PERSISTENT_STORE_MODES:
|
|
click.echo(
|
|
f"The --storage-mode (-SM) value of {storage_mode} is not"
|
|
" supported."
|
|
)
|
|
click.echo("Try 'apprise --help' for more information.")
|
|
# 2 is the same exit code returned by Click if there is a parameter
|
|
# issue. For consistency, we also return a 2
|
|
ctx.exit(2)
|
|
|
|
#
|
|
# Apply Environment Overrides if defined
|
|
#
|
|
config_paths = DEFAULT_CONFIG_PATHS
|
|
if "APPRISE_CONFIG" in os.environ:
|
|
# Deprecate (this was from previous versions of Apprise <= 1.9.1)
|
|
logger.deprecate(
|
|
"APPRISE_CONFIG environment variable has been changed to "
|
|
f"{DEFAULT_ENV_APPRISE_CONFIG_PATH}"
|
|
)
|
|
logger.debug(
|
|
"Loading provided APPRISE_CONFIG (deprecated) environment variable"
|
|
)
|
|
config_paths = (os.environ.get("APPRISE_CONFIG", "").strip(),)
|
|
|
|
elif DEFAULT_ENV_APPRISE_CONFIG_PATH in os.environ:
|
|
logger.debug(
|
|
f"Loading provided {DEFAULT_ENV_APPRISE_CONFIG_PATH} "
|
|
"environment variable"
|
|
)
|
|
config_paths = re.split(
|
|
r"[\r\n;]+",
|
|
os.environ.get(DEFAULT_ENV_APPRISE_CONFIG_PATH).strip(),
|
|
)
|
|
|
|
plugin_paths_ = DEFAULT_PLUGIN_PATHS
|
|
if DEFAULT_ENV_APPRISE_PLUGIN_PATH in os.environ:
|
|
logger.debug(
|
|
f"Loading provided {DEFAULT_ENV_APPRISE_PLUGIN_PATH} environment "
|
|
"variable"
|
|
)
|
|
plugin_paths_ = re.split(
|
|
r"[\r\n;]+",
|
|
os.environ.get(DEFAULT_ENV_APPRISE_PLUGIN_PATH).strip(),
|
|
)
|
|
|
|
if DEFAULT_ENV_APPRISE_STORAGE_PATH in os.environ:
|
|
logger.debug(
|
|
f"Loading provided {DEFAULT_ENV_APPRISE_STORAGE_PATH} environment "
|
|
"variable"
|
|
)
|
|
storage_path = os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH).strip()
|
|
|
|
#
|
|
# Continue with initialization process
|
|
#
|
|
|
|
# Prepare a default set of plugin paths to scan; anything specified
|
|
# on the CLI always trumps
|
|
plugin_paths = (
|
|
plugin_path
|
|
if plugin_path
|
|
else [path for path in plugin_paths_ if exists(path_decode(path))]
|
|
)
|
|
|
|
if storage_uid_length < 2:
|
|
click.echo(
|
|
"The --storage-uid-length (-SUL) value can not be lower "
|
|
"than two (2)."
|
|
)
|
|
click.echo("Try 'apprise --help' for more information.")
|
|
|
|
# 2 is the same exit code returned by Click if there is a
|
|
# parameter issue. For consistency, we also return a 2
|
|
ctx.exit(2)
|
|
|
|
# Prepare our asset
|
|
asset = AppriseAsset(
|
|
# Our body format
|
|
body_format=input_format,
|
|
# Interpret Escapes
|
|
interpret_escapes=interpret_escapes,
|
|
# Interpret Emojis
|
|
interpret_emojis=None if not interpret_emojis else True,
|
|
# Set the theme
|
|
theme=theme,
|
|
# Async mode allows a user to send all of their notifications
|
|
# asynchronously. This was made an option incase there are problems
|
|
# in the future where it is better that everything runs sequentially/
|
|
# synchronously instead.
|
|
async_mode=disable_async is not True,
|
|
# Load our plugins
|
|
plugin_paths=plugin_paths,
|
|
# Load our persistent storage path
|
|
storage_path=path_decode(storage_path),
|
|
# Our storage URL ID Length
|
|
storage_idlen=storage_uid_length,
|
|
# Define if we flush to disk as soon as possible or not when required
|
|
storage_mode=storage_mode,
|
|
)
|
|
|
|
# Create our Apprise object
|
|
a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL)
|
|
|
|
# Track if we are performing a storage action
|
|
storage_action = bool(urls and "storage".startswith(urls[0]))
|
|
|
|
# Save the original state; the storage block below always needs these.
|
|
_in_storage_mode = storage_action
|
|
_raw_urls = urls
|
|
|
|
if storage_action:
|
|
# Scan storage args for URL-filter entries. If any are found,
|
|
# remap urls to only those entries and flip storage_action so the
|
|
# priority chain (rule #1 below) loads them into 'a' (our Apprise
|
|
# instance). This mirrors the sending notification process.
|
|
_sfp = list(urls[1:])
|
|
if _sfp and any(
|
|
m.startswith(_sfp[0]) for m in PERSISTENT_STORAGE_MODES
|
|
):
|
|
# Skip the action word ("list", "prune", "clear")
|
|
_sfp = _sfp[1:]
|
|
_url_filters = [_f for _f in _sfp if GET_SCHEMA_RE.match(_f)]
|
|
if _url_filters:
|
|
# Hand off to rule #1 by replacing urls and clearing the flag.
|
|
urls = _url_filters
|
|
storage_action = False
|
|
|
|
if details:
|
|
# Print details and exit
|
|
results = a.details(show_requirements=True, show_disabled=True)
|
|
|
|
# Sort our results:
|
|
plugins = sorted(
|
|
results["schemas"], key=lambda i: str(i["service_name"])
|
|
)
|
|
for entry in plugins:
|
|
protocols = (
|
|
[]
|
|
if not entry["protocols"]
|
|
else [p for p in entry["protocols"] if isinstance(p, str)]
|
|
)
|
|
protocols.extend(
|
|
[]
|
|
if not entry["secure_protocols"]
|
|
else [
|
|
p for p in entry["secure_protocols"] if isinstance(p, str)
|
|
]
|
|
)
|
|
|
|
if len(protocols) == 1:
|
|
# Simplify view by swapping {schema} with the single
|
|
# protocol value
|
|
|
|
# Convert tuple to list
|
|
entry["details"]["templates"] = list(
|
|
entry["details"]["templates"]
|
|
)
|
|
|
|
for x in range(len(entry["details"]["templates"])):
|
|
entry["details"]["templates"][x] = re.sub(
|
|
r"^[^}]+}://",
|
|
f"{protocols[0]}://",
|
|
entry["details"]["templates"][x],
|
|
)
|
|
|
|
fg = "green" if entry["enabled"] else "red"
|
|
if entry["category"] == "custom":
|
|
# Identify these differently
|
|
fg = "cyan"
|
|
# Flip the enable switch so it forces the requirements
|
|
# to be displayed
|
|
entry["enabled"] = False
|
|
|
|
click.echo(
|
|
click.style(
|
|
"{} {:<30} ".format(
|
|
"+" if entry["enabled"] else "-",
|
|
str(entry["service_name"]),
|
|
),
|
|
fg=fg,
|
|
bold=True,
|
|
),
|
|
nl=(not entry["enabled"] or len(protocols) == 1),
|
|
)
|
|
|
|
if not entry["enabled"]:
|
|
if entry["requirements"]["details"]:
|
|
click.echo(" " + str(entry["requirements"]["details"]))
|
|
|
|
if entry["requirements"]["packages_required"]:
|
|
click.echo(" Python Packages Required:")
|
|
for req in entry["requirements"]["packages_required"]:
|
|
click.echo(" - " + req)
|
|
|
|
if entry["requirements"]["packages_recommended"]:
|
|
click.echo(" Python Packages Recommended:")
|
|
for req in entry["requirements"]["packages_recommended"]:
|
|
click.echo(" - " + req)
|
|
|
|
# new line padding between entries
|
|
if entry["category"] == "native":
|
|
click.echo()
|
|
continue
|
|
|
|
if len(protocols) > 1:
|
|
click.echo(
|
|
"| Schema(s): {}".format(
|
|
", ".join(protocols),
|
|
)
|
|
)
|
|
|
|
prefix = " - "
|
|
click.echo(
|
|
"{}{}".format(
|
|
prefix, f"\n{prefix}".join(entry["details"]["templates"])
|
|
)
|
|
)
|
|
|
|
# new line padding between entries
|
|
click.echo()
|
|
|
|
ctx.exit(0)
|
|
# end if details()
|
|
|
|
# The priorities of what is accepted are parsed in order below:
|
|
# 1. URLs by command line
|
|
# 2. Configuration by command line
|
|
# 3. URLs by environment variable: APPRISE_URLS
|
|
# 4. Default Configuration File(s)
|
|
#
|
|
elif urls and not storage_action:
|
|
if tag:
|
|
# Ignore any tags specified
|
|
logger.warning(
|
|
"--tag (-g) entries are ignored when using specified URLs"
|
|
)
|
|
tag = None
|
|
|
|
# Load our URLs (if any defined)
|
|
for url in urls:
|
|
a.add(url)
|
|
|
|
if config:
|
|
# Provide a warning to the end user if they specified both
|
|
logger.warning(
|
|
"You defined both URLs and a --config (-c) entry; "
|
|
"Only the URLs will be referenced."
|
|
)
|
|
|
|
elif config:
|
|
# We load our configuration file(s) now only if no URLs were specified
|
|
# Specified config entries trump all
|
|
a.add(
|
|
AppriseConfig(paths=config, asset=asset, recursion=recursion_depth)
|
|
)
|
|
|
|
elif os.environ.get(DEFAULT_ENV_APPRISE_URLS, "").strip():
|
|
logger.debug(
|
|
f"Loading provided {DEFAULT_ENV_APPRISE_URLS} environment variable"
|
|
)
|
|
if tag:
|
|
# Ignore any tags specified
|
|
logger.warning(
|
|
"--tag (-g) entries are ignored when using specified URLs"
|
|
)
|
|
tag = None
|
|
|
|
# Attempt to use our APPRISE_URLS environment variable (if populated)
|
|
a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip())
|
|
|
|
else:
|
|
# Load default configuration
|
|
a.add(
|
|
AppriseConfig(
|
|
paths=[f for f in config_paths if isfile(path_decode(f))],
|
|
asset=asset,
|
|
recursion=recursion_depth,
|
|
)
|
|
)
|
|
|
|
# Restore storage_action in case it was flipped above to allow URL-
|
|
# filter entries to be loaded via rule #1.
|
|
storage_action = _in_storage_mode
|
|
|
|
if not dry_run and not (a or storage_action):
|
|
click.echo(
|
|
"You must specify at least one server URL or populated "
|
|
"configuration file."
|
|
)
|
|
click.echo("Try 'apprise --help' for more information.")
|
|
ctx.exit(1)
|
|
|
|
# each --tag entry comprises of a comma separated 'and' list
|
|
# we or each of of the --tag and sets specified.
|
|
tags = None if not tag else [parse_list(t) for t in tag]
|
|
|
|
# Determine if we're dealing with URLs or url_ids based on the first
|
|
# entry provided.
|
|
if storage_action:
|
|
#
|
|
# Storage Mode
|
|
# - urls are now to be interpreted as best matching namespaces
|
|
#
|
|
if storage_prune_days < 0:
|
|
click.echo(
|
|
"The --storage-prune-days (-SPD) value can not be lower "
|
|
"than zero (0)."
|
|
)
|
|
click.echo("Try 'apprise --help' for more information.")
|
|
|
|
# 2 is the same exit code returned by Click if there is a
|
|
# parameter issue. For consistency, we also return a 2
|
|
ctx.exit(2)
|
|
|
|
# Number of columns to assume in the terminal. In future, maybe this
|
|
# can be detected and made dynamic. The actual column count is 80, but
|
|
# 5 characters are already reserved for the counter on the left
|
|
(columns, _) = shutil.get_terminal_size(fallback=(80, 24))
|
|
|
|
# Pop 'storage' off of the head of the original url list.
|
|
# _raw_urls always holds the unmodified list even when urls was
|
|
# remapped to URL-filter entries for the priority chain above.
|
|
filter_uids = _raw_urls[1:]
|
|
|
|
action = PERSISTENT_STORAGE_MODES[0]
|
|
if filter_uids:
|
|
action_ = next( # pragma: no branch
|
|
(
|
|
a
|
|
for a in PERSISTENT_STORAGE_MODES
|
|
if a.startswith(filter_uids[0])
|
|
),
|
|
None,
|
|
)
|
|
|
|
if action_:
|
|
# pop 'action' off the head of our list
|
|
filter_uids = filter_uids[1:]
|
|
action = action_
|
|
|
|
# True when URL-filter entries were present in the storage args;
|
|
# detected by whether urls was remapped to a new list object above.
|
|
_had_url_filters = _raw_urls is not urls
|
|
|
|
# Build the uid-prefix filter list from non-URL entries only.
|
|
# URL-filter entries were already loaded into 'a' by rule #1.
|
|
uid_filter_list = [
|
|
_f.lower() for _f in filter_uids if not GET_SCHEMA_RE.match(_f)
|
|
]
|
|
|
|
# Single pass: collect plugin uids, applying the uid-prefix filter
|
|
uids = {}
|
|
for plugin in a if not tags else a.find(tag=tags):
|
|
id_ = plugin.url_id()
|
|
if not id_:
|
|
# No persistent storage uid; skip this plugin
|
|
continue
|
|
|
|
if uid_filter_list and not next(
|
|
(u for u in uid_filter_list if id_.startswith(u)), None
|
|
):
|
|
# Plugin uid does not match any uid-prefix filter
|
|
logger.trace(
|
|
"Storage: plugin uid '%s' excluded by filter %s",
|
|
id_,
|
|
uid_filter_list,
|
|
)
|
|
continue
|
|
|
|
if id_ not in uids:
|
|
uids[id_] = {
|
|
"plugins": [plugin],
|
|
"state": PersistentStoreState.UNUSED.value,
|
|
"size": 0,
|
|
}
|
|
|
|
else:
|
|
# It's possible to have more than one URL point to the same
|
|
# location (thus match against the same url id more than once
|
|
uids[id_]["plugins"].append(plugin)
|
|
|
|
if action == PersistentStorageMode.LIST:
|
|
# Scope disk_scan based on what filters were provided:
|
|
# - URL filters: scope to resolved plugin uids plus any
|
|
# plain uid-prefix strings; skip scan if none resolved
|
|
# - Plain-string or no filter: namespace=uid_filter_list
|
|
# (empty list means return all on-disk directories)
|
|
if _had_url_filters:
|
|
# Derive namespace from the plugins resolved by URL filters
|
|
_disk_ns = list(uids.keys()) + uid_filter_list
|
|
if not _disk_ns:
|
|
# URL filters yielded no plugins; skip the disk scan
|
|
detected_uid = {}
|
|
else:
|
|
detected_uid = PersistentStore.disk_scan(
|
|
# Use our asset path as already properly parsed
|
|
path=asset.storage_path,
|
|
namespace=_disk_ns,
|
|
)
|
|
else:
|
|
detected_uid = PersistentStore.disk_scan(
|
|
# Use our asset path as it has already been properly
|
|
# parsed
|
|
path=asset.storage_path,
|
|
# Provide filter if specified; empty = all directories
|
|
namespace=uid_filter_list,
|
|
)
|
|
for id_ in detected_uid:
|
|
size, _ = dir_size(os.path.join(asset.storage_path, id_))
|
|
if id_ in uids:
|
|
uids[id_]["state"] = PersistentStoreState.ACTIVE.value
|
|
uids[id_]["size"] = size
|
|
|
|
elif not tags:
|
|
uids[id_] = {
|
|
"plugins": [],
|
|
# No cross reference (wasted space?)
|
|
"state": PersistentStoreState.STALE.value,
|
|
# Acquire disk space
|
|
"size": size,
|
|
}
|
|
|
|
for idx, (uid, meta) in enumerate(uids.items()):
|
|
fg = (
|
|
"green"
|
|
if meta["state"] == PersistentStoreState.ACTIVE.value
|
|
else (
|
|
"red"
|
|
if meta["state"] == PersistentStoreState.STALE.value
|
|
else "white"
|
|
)
|
|
)
|
|
|
|
if idx > 0:
|
|
# New line
|
|
click.echo()
|
|
click.echo(f"{idx + 1: 4d}. ", nl=False)
|
|
click.echo(
|
|
click.style(
|
|
"{:<52} {:<8} {}".format(
|
|
uid, bytes_to_str(meta["size"]), meta["state"]
|
|
),
|
|
fg=fg,
|
|
bold=True,
|
|
)
|
|
)
|
|
|
|
for entry in meta["plugins"]:
|
|
url = entry.url(privacy=True)
|
|
click.echo(
|
|
"{:>7} {}".format(
|
|
"-",
|
|
(
|
|
url
|
|
if len(url) <= (columns - 8)
|
|
else f"{url[: columns - 11]}..."
|
|
),
|
|
)
|
|
)
|
|
|
|
if entry.tags:
|
|
click.echo(
|
|
"{:>10}: {}".format(
|
|
"tags",
|
|
", ".join(str(t) for t in entry.tags),
|
|
)
|
|
)
|
|
|
|
else: # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR
|
|
if action == PersistentStorageMode.CLEAR:
|
|
storage_prune_days = 0
|
|
|
|
# Derive the namespace for disk_prune. The scoping rules
|
|
# mirror those for disk_scan:
|
|
# - URL filters: resolved plugin uids + any uid-prefix
|
|
# strings; empty means no-op (nothing to prune).
|
|
# - Tag filters: same scoping — resolved plugin uids + any
|
|
# uid-prefix strings; empty means no-op.
|
|
# - Plain uid-prefix or no filter: pass through directly.
|
|
if _had_url_filters or tags:
|
|
# Scope to the uids that the filtered plugins resolved
|
|
# to. If none resolved (e.g. unknown tag), exit early
|
|
# -- disk_prune() with namespace=None targets ALL
|
|
# namespaces, so passing None here is not a no-op.
|
|
_prune_ns = list(uids.keys()) + uid_filter_list
|
|
if not _prune_ns:
|
|
ctx.exit(0)
|
|
else:
|
|
_prune_ns = uid_filter_list or None
|
|
|
|
# clean up storage
|
|
results = PersistentStore.disk_prune(
|
|
# Use our asset path as it has already been properly parsed
|
|
path=asset.storage_path,
|
|
# Provide our namespaces if they exist
|
|
namespace=_prune_ns,
|
|
# Convert expiry from days to seconds
|
|
expires=storage_prune_days * 60 * 60 * 24,
|
|
action=not dry_run,
|
|
)
|
|
|
|
ctx.exit(0)
|
|
# end if disk_prune()
|
|
|
|
ctx.exit(0)
|
|
# end if storage()
|
|
|
|
if not dry_run:
|
|
if body is None:
|
|
logger.trace("No --body (-b) specified; reading from stdin")
|
|
# if no body was specified, then read from STDIN
|
|
body = click.get_text_stream("stdin").read()
|
|
|
|
# now print it out
|
|
result = a.notify(
|
|
body=body,
|
|
title=title,
|
|
notify_type=notification_type,
|
|
tag=tags,
|
|
attach=attach,
|
|
)
|
|
else:
|
|
# Number of columns to assume in the terminal. In future, maybe this
|
|
# can be detected and made dynamic. The actual column count is 80, but
|
|
# 5 characters are already reserved for the counter on the left
|
|
(columns, _) = shutil.get_terminal_size(fallback=(80, 24))
|
|
|
|
# Initialize our URL response; This is populated within the for/loop
|
|
# below; but plays a factor at the end when we need to determine if
|
|
# we iterated at least once in the loop.
|
|
url = None
|
|
|
|
for idx, server in enumerate(a.find(tag=tags)):
|
|
url = server.url(privacy=True)
|
|
click.echo(
|
|
"{: 4d}. {}".format(
|
|
idx + 1,
|
|
(
|
|
url
|
|
if len(url) <= (columns - 8)
|
|
else f"{url[: columns - 9]}..."
|
|
),
|
|
)
|
|
)
|
|
|
|
# Share our URL ID
|
|
click.echo(
|
|
"{:>10}: {}".format(
|
|
"uid",
|
|
"- n/a -" if not server.url_id() else server.url_id(),
|
|
)
|
|
)
|
|
|
|
if server.tags:
|
|
click.echo(
|
|
"{:>10}: {}".format(
|
|
"tags",
|
|
", ".join(str(t) for t in server.tags),
|
|
)
|
|
)
|
|
|
|
# Initialize a default response of nothing matched, otherwise
|
|
# if we matched at least one entry, we can return True
|
|
result = None if url is None else True
|
|
|
|
if result is None:
|
|
# There were no notifications set. This is a result of just having
|
|
# empty configuration files and/or being to restrictive when filtering
|
|
# by specific tag(s)
|
|
|
|
# Exit code 3 is used since Click uses exit code 2 if there is an
|
|
# error with the parameters specified
|
|
ctx.exit(3)
|
|
|
|
elif result is False:
|
|
# At least 1 notification service failed to send
|
|
ctx.exit(1)
|
|
|
|
# else: We're good!
|
|
ctx.exit(0)
|