CLI environment variable over-ride support (#1231)

This commit is contained in:
Chris Caron 2024-10-27 14:02:19 -04:00 committed by GitHub
parent 1065c021d3
commit 01c1082ad8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 337 additions and 143 deletions

View File

@ -39,6 +39,7 @@ System Administrators and DevOps who wish to send a notification now no longer n
* [Configuration Files](#cli-configuration-files)
* [File Attachments](#cli-file-attachments)
* [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks)
* [Environment Variables](#cli-environment-variables)
* [Developer API Usage](#developer-api-usage)
* [Configuration Files](#api-configuration-files)
* [File Attachments](#api-file-attachments)
@ -352,6 +353,17 @@ apprise -vv --title 'custom override' \
You can read more about creating your own custom notifications and/or hooks [here](https://github.com/caronc/apprise/wiki/decorator_notify).
## CLI Environment Variables
Those using the Command Line Interface (CLI) can also leverage environment variables to pre-set the default settings:
| Variable | Description |
|------------------------ | ----------------- |
| `APPRISE_URLS` | Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the `--config` (`-c`) is specified, then this will over-rides any reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries.
| `APPRISE_CONFIG_PATH` | Explicitly specify the config search path to use (over-riding the default). The path(s) defined here must point to the absolute filename to open/reference. Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries.
| `APPRISE_PLUGIN_PATH` | Explicitly specify the custom plugin search path to use (over-riding the default). Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries.
| `APPRISE_STORAGE_PATH` | Explicitly specify the persistent storage path to use (over-riding the default).
# Developer API Usage
To send a notification from within your python application, just do the following:

View File

@ -68,6 +68,21 @@ DEFAULT_STORAGE_PRUNE_DAYS = \
DEFAULT_STORAGE_UID_LENGTH = \
int(os.environ.get('APPRISE_STORAGE_UID_LENGTH', 8))
# Defines the envrionment 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 over-ride path for the configuration files read
DEFAULT_ENV_APPRISE_CONFIG_PATH = 'APPRISE_CONFIG_PATH'
# Defines the over-ride path for the plugins to load
DEFAULT_ENV_APPRISE_PLUGIN_PATH = 'APPRISE_PLUGIN_PATH'
# Defines the over-ride 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 = dict(help_option_names=['-h', '--help'])
@ -496,11 +511,53 @@ def main(ctx, body, title, config, attach, urls, notification_type, theme, tag,
# issue. For consistency, we also return a 2
ctx.exit(2)
if not plugin_path:
# Prepare a default set of plugin path
plugin_path = \
[path for path in DEFAULT_PLUGIN_PATHS
if exists(path_decode(path))]
#
# Apply Environment Over-rides 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 = \
[path for path in _plugin_paths if exists(path_decode(path))] \
if not plugin_path else plugin_path
if storage_uid_length < 2:
click.echo(
@ -533,7 +590,7 @@ def main(ctx, body, title, config, attach, urls, notification_type, theme, tag,
async_mode=disable_async is not True,
# Load our plugins
plugin_paths=plugin_path,
plugin_paths=plugin_paths,
# Load our persistent storage path
storage_path=path_decode(storage_path),
@ -636,8 +693,7 @@ def main(ctx, body, title, config, attach, urls, notification_type, theme, tag,
# 1. URLs by command line
# 2. Configuration by command line
# 3. URLs by environment variable: APPRISE_URLS
# 4. Configuration by environment variable: APPRISE_CONFIG
# 5. Default Configuration File(s) (if found)
# 4. Default Configuration File(s)
#
elif urls and not storage_action:
if tag:
@ -662,8 +718,10 @@ def main(ctx, body, title, config, attach, urls, notification_type, theme, tag,
a.add(AppriseConfig(
paths=config, asset=asset, recursion=recursion_depth))
elif os.environ.get('APPRISE_URLS', '').strip():
logger.debug('Loading provided APPRISE_URLS environment variable')
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(
@ -671,19 +729,12 @@ def main(ctx, body, title, config, attach, urls, notification_type, theme, tag,
tag = None
# Attempt to use our APPRISE_URLS environment variable (if populated)
a.add(os.environ['APPRISE_URLS'].strip())
elif os.environ.get('APPRISE_CONFIG', '').strip():
logger.debug('Loading provided APPRISE_CONFIG environment variable')
# Fall back to config environment variable (if populated)
a.add(AppriseConfig(
paths=os.environ['APPRISE_CONFIG'].strip(),
asset=asset, recursion=recursion_depth))
a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip())
else:
# Load default configuration
a.add(AppriseConfig(
paths=[f for f in DEFAULT_CONFIG_PATHS if isfile(path_decode(f))],
paths=[f for f in _config_paths if isfile(path_decode(f))],
asset=asset, recursion=recursion_depth))
if not dry_run and not (a or storage_action):

View File

@ -29,10 +29,8 @@ import copy
import re
import sys
import json
import contextlib
import os
import binascii
import locale
import platform
import typing
import base64
@ -1518,39 +1516,6 @@ def cwe312_url(url):
)
@contextlib.contextmanager
def environ(*remove, **update):
"""
Temporarily updates the ``os.environ`` dictionary in-place.
The ``os.environ`` dictionary is updated in-place so that the modification
is sure to work in all situations.
:param remove: Environment variable(s) to remove.
:param update: Dictionary of environment variables and values to
add/update.
"""
# Create a backup of our environment for restoration purposes
env_orig = os.environ.copy()
loc_orig = locale.getlocale()
try:
os.environ.update(update)
[os.environ.pop(k, None) for k in remove]
yield
finally:
# Restore our snapshot
os.environ = env_orig.copy()
try:
# Restore locale
locale.setlocale(locale.LC_ALL, loc_orig)
except locale.Error:
# Handle this case
pass
def apply_template(template, app_mode=TemplateType.RAW, **kwargs):
"""
Takes a template in a str format and applies all of the keywords

View File

@ -159,6 +159,9 @@ visit the [Apprise GitHub page][serviceurls] and see what's available.
[serviceurls]: https://github.com/caronc/apprise/wiki#notification-services
The **environment variable** of `APPRISE_URLS` (comma/space delimited) can be specified to
provide the default set of URLs you wish to notify if none are otherwise specified.
## EXAMPLES
Send a notification to as many servers as you want to specify as you can
@ -215,8 +218,13 @@ files and loads them:
~/.config/apprise/plugins
/var/lib/apprise/plugins
The **environment variable** of `APPRISE_PLUGIN_PATH` can be specified to override
the list identified above with one of your own. use a semi-colon (`;`), line-feed (`\n`),
and/or carriage return (`\r`) to delimit multiple entries.
Simply create your own python file with the following bare minimum content in
it:
from apprise.decorators import notify
# This example assumes you want your function to trigger on foobar://
@ -263,6 +271,10 @@ in the following local locations for configuration files and loads them:
The **configuration files** specified above can also be identified with a `.yml`
extension or even just entirely removing the `.conf` extension altogether.
The **environment variable** of `APPRISE_CONFIG_PATH` can be specified to override
the list identified above with one of your own. use a semi-colon (`;`), line-feed (`\n`),
and/or carriage return (`\r`) to delimit multiple entries.
If a default configuration file is referenced in any way by the **apprise**
tool, you no longer need to provide it a Service URL. Usage of the **apprise**
tool simplifies to:
@ -281,6 +293,23 @@ configuration that you want and only specifically notify a subset of them:
[tagging]: https://github.com/caronc/apprise/wiki/CLI_Usage#label-leverage-tagging
[pstorage]: https://github.com/caronc/apprise/wiki/persistent_storage
## ENVIRONMENT VARIABLES
`APPRISE_URLS`:
Specify the default URLs to notify IF none are otherwise specified on the command line
explicitly. If the `--config` (`-c`) is specified, then this will over-rides any
reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries.
`APPRISE_CONFIG_PATH`:
Explicitly specify the config search path to use (over-riding the default).
Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries.
`APPRISE_PLUGIN_PATH`:
Explicitly specify the custom plugin search path to use (over-riding the default).
Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries.
`APPRISE_STORAGE_PATH`:
Explicitly specify the persistent storage path to use (over-riding the default).
## BUGS
If you find any bugs, please make them known at:

View File

@ -29,9 +29,11 @@
from .rest import AppriseURLTester
from .asyncio import OuterEventLoop
from .module import reload_plugin
from .environment import environ
__all__ = [
'AppriseURLTester',
'OuterEventLoop',
'reload_plugin',
'environ',
]

View File

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, 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 os
import contextlib
import locale
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
@contextlib.contextmanager
def environ(*remove, **update):
"""
Temporarily updates the ``os.environ`` dictionary in-place.
The ``os.environ`` dictionary is updated in-place so that the modification
is sure to work in all situations.
:param remove: Environment variable(s) to remove.
:param update: Dictionary of environment variables and values to
add/update.
"""
# Create a backup of our environment for restoration purposes
env_orig = os.environ.copy()
loc_orig = locale.getlocale()
try:
os.environ.update(update)
[os.environ.pop(k, None) for k in remove]
yield
finally:
# Restore our snapshot
os.environ = env_orig.copy()
try:
# Restore locale
locale.setlocale(locale.LC_ALL, loc_orig)
except locale.Error:
# Handle this case
pass

View File

@ -40,7 +40,7 @@ from apprise import cli
from apprise import NotifyBase
from apprise import NotificationManager
from click.testing import CliRunner
from apprise.utils import environ
from helpers import environ
from apprise.locale import gettext_lazy as _
from importlib import reload
@ -515,6 +515,22 @@ def test_apprise_cli_nux_env(tmpdir):
assert result.exit_code == 0
with environ(APPRISE_CONFIG=str(t2)):
# Deprecated test case
result = runner.invoke(cli.main, [
'-b', 'has myTag',
'--tag', 'myTag',
])
assert result.exit_code == 0
with environ(APPRISE_CONFIG_PATH=str(t2)):
# Our configuration file will load from our environmment variable
result = runner.invoke(cli.main, [
'-b', 'has myTag',
'--tag', 'myTag',
])
assert result.exit_code == 0
with environ(APPRISE_CONFIG_PATH=str(t2) + ';/another/path'):
# Our configuration file will load from our environmment variable
result = runner.invoke(cli.main, [
'-b', 'has myTag',
@ -677,6 +693,17 @@ def test_apprise_cli_modules(tmpdir):
assert result.exit_code == 0
with environ(
APPRISE_PLUGIN_PATH=str(notify_cmod) + ';' + str(notify_cmod2)):
# Leverage our environment variables to specify the plugin path
result = runner.invoke(cli.main, [
'-b', 'body',
'climod://',
'climod2://',
])
assert result.exit_code == 0
@pytest.mark.skipif(
sys.platform == "win32", reason="Unreliable results to be determined")

View File

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, 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 os
import sys
import helpers
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Ensure we don't create .pyc files for these tests
sys.dont_write_bytecode = True
def test_environ_temporary_change():
"""helpers: environ() testing
"""
# This is a helper function; but it does enough that we want to verify
# our usage of it works correctly; yes... we're testing a test
e_key1 = 'APPRISE_TEMP1'
e_key2 = 'APPRISE_TEMP2'
e_key3 = 'APPRISE_TEMP3'
e_val1 = 'ABCD'
e_val2 = 'DEFG'
e_val3 = 'HIJK'
os.environ[e_key1] = e_val1
os.environ[e_key2] = e_val2
os.environ[e_key3] = e_val3
# Ensure our environment variable stuck
assert e_key1 in os.environ
assert e_val1 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 in os.environ
assert e_val3 in os.environ[e_key3]
with helpers.environ(e_key1, e_key3):
# Eliminates Environment Variable 1 and 3
assert e_key1 not in os.environ
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 not in os.environ
# after with is over, environment is restored to normal
assert e_key1 in os.environ
assert e_val1 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 in os.environ
assert e_val3 in os.environ[e_key3]
d_key = 'APPRISE_NOT_SET'
n_key = 'APPRISE_NEW_KEY'
n_val = 'NEW_VAL'
# Verify that our temporary variables (defined above) are not pre-existing
# environemnt variables as we'll be setting them below
assert n_key not in os.environ
assert d_key not in os.environ
# makes it easier to pass in the arguments
updates = {
e_key1: e_val3,
e_key2: e_val1,
n_key: n_val,
}
with helpers.environ(d_key, e_key3, **updates):
# Attempt to eliminate an undefined key (silently ignored)
# Eliminates Environment Variable 3
# Environment Variable 1 takes on the value of Env 3
# Environment Variable 2 takes on the value of Env 1
# Set a brand new variable that previously didn't exist
assert e_key1 in os.environ
assert e_val3 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val1 in os.environ[e_key2]
assert e_key3 not in os.environ
# Can't delete a variable that doesn't exist; so we're in the same
# state here.
assert d_key not in os.environ
# Our temporary variables will be found now
assert n_key in os.environ
assert n_val in os.environ[n_key]
# after with is over, environment is restored to normal
assert e_key1 in os.environ
assert e_val1 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 in os.environ
assert e_val3 in os.environ[e_key3]
# Even our temporary variables are now missing
assert n_key not in os.environ
assert d_key not in os.environ

View File

@ -34,7 +34,7 @@ import ctypes
import pytest
from apprise import locale
from apprise.utils import environ
from helpers import environ
from importlib import reload
# Disable logging for a cleaner testing output

View File

@ -2545,93 +2545,6 @@ def test_apprise_validate_regex():
"- abcd -", r'-(?P<value>[ABCD]+)-', None, fmt="{value}") is None
def test_environ_temporary_change():
"""utils: environ() testing
"""
e_key1 = 'APPRISE_TEMP1'
e_key2 = 'APPRISE_TEMP2'
e_key3 = 'APPRISE_TEMP3'
e_val1 = 'ABCD'
e_val2 = 'DEFG'
e_val3 = 'HIJK'
os.environ[e_key1] = e_val1
os.environ[e_key2] = e_val2
os.environ[e_key3] = e_val3
# Ensure our environment variable stuck
assert e_key1 in os.environ
assert e_val1 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 in os.environ
assert e_val3 in os.environ[e_key3]
with utils.environ(e_key1, e_key3):
# Eliminates Environment Variable 1 and 3
assert e_key1 not in os.environ
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 not in os.environ
# after with is over, environment is restored to normal
assert e_key1 in os.environ
assert e_val1 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 in os.environ
assert e_val3 in os.environ[e_key3]
d_key = 'APPRISE_NOT_SET'
n_key = 'APPRISE_NEW_KEY'
n_val = 'NEW_VAL'
# Verify that our temporary variables (defined above) are not pre-existing
# environemnt variables as we'll be setting them below
assert n_key not in os.environ
assert d_key not in os.environ
# makes it easier to pass in the arguments
updates = {
e_key1: e_val3,
e_key2: e_val1,
n_key: n_val,
}
with utils.environ(d_key, e_key3, **updates):
# Attempt to eliminate an undefined key (silently ignored)
# Eliminates Environment Variable 3
# Environment Variable 1 takes on the value of Env 3
# Environment Variable 2 takes on the value of Env 1
# Set a brand new variable that previously didn't exist
assert e_key1 in os.environ
assert e_val3 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val1 in os.environ[e_key2]
assert e_key3 not in os.environ
# Can't delete a variable that doesn't exist; so we're in the same
# state here.
assert d_key not in os.environ
# Our temporary variables will be found now
assert n_key in os.environ
assert n_val in os.environ[n_key]
# after with is over, environment is restored to normal
assert e_key1 in os.environ
assert e_val1 in os.environ[e_key1]
assert e_key2 in os.environ
assert e_val2 in os.environ[e_key2]
assert e_key3 in os.environ
assert e_val3 in os.environ[e_key3]
# Even our temporary variables are now missing
assert n_key not in os.environ
assert d_key not in os.environ
def test_apply_templating():
"""utils: apply_template() testing
"""