From f82934a8158260410c74399113353dbb22d43f2b Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Mon, 21 Aug 2023 20:11:26 -0400 Subject: [PATCH] Prevent gettext() from installing to global _ namespace (#821) --- .gitignore | 1 + apprise/AppriseLocale.py | 187 ++++---- apprise/i18n/en/LC_MESSAGES/apprise.po | 369 ++++++++-------- .../redhat/apprise-click67-support.patch | 12 +- setup.cfg | 4 +- setup.py | 4 +- test/test_api.py | 7 +- test/{test_cli.py => test_apprise_cli.py} | 2 + test/test_apprise_translations.py | 398 ++++++++++++++++++ test/test_locale.py | 215 ---------- 10 files changed, 707 insertions(+), 492 deletions(-) rename test/{test_cli.py => test_apprise_cli.py} (99%) create mode 100644 test/test_apprise_translations.py delete mode 100644 test/test_locale.py diff --git a/.gitignore b/.gitignore index 174d28b3..4ae5f588 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ sdist/ *.egg-info/ .installed.cfg *.egg +.local # Generated from Docker Instance .bash_history diff --git a/apprise/AppriseLocale.py b/apprise/AppriseLocale.py index b3b0fab2..49372ca7 100644 --- a/apprise/AppriseLocale.py +++ b/apprise/AppriseLocale.py @@ -40,9 +40,6 @@ from os.path import dirname from os.path import abspath from .logger import logger -# Define our translation domain -DOMAIN = 'apprise' -LOCALE_DIR = abspath(join(dirname(__file__), 'i18n')) # This gets toggled to True if we succeed GETTEXT_LOADED = False @@ -51,43 +48,13 @@ try: # Initialize gettext import gettext - # install() creates a _() in our builtins - gettext.install(DOMAIN, localedir=LOCALE_DIR) - # Toggle our flag GETTEXT_LOADED = True except ImportError: - # gettext isn't available; no problem, just fall back to using - # the library features without multi-language support. - import builtins - builtins.__dict__['_'] = lambda x: x # pragma: no branch - - -class LazyTranslation: - """ - Doesn't translate anything until str() or unicode() references - are made. - - """ - def __init__(self, text, *args, **kwargs): - """ - Store our text - """ - self.text = text - - super().__init__(*args, **kwargs) - - def __str__(self): - return gettext.gettext(self.text) - - -# Lazy translation handling -def gettext_lazy(text): - """ - A dummy function that can be referenced - """ - return LazyTranslation(text=text) + # gettext isn't available; no problem; Use the library features without + # multi-language support. + pass class AppriseLocale: @@ -97,15 +64,24 @@ class AppriseLocale: """ + # Define our translation domain + _domain = 'apprise' + + # The path to our translations + _locale_dir = abspath(join(dirname(__file__), 'i18n')) + # Locale regular expression _local_re = re.compile( - r'^\s*(?P[a-z]{2})([_:]((?P[a-z]{2}))?' - r'(\.(?P[a-z0-9]+))?|.+)?', re.IGNORECASE) + r'^((?PC)|(?P([a-z]{2}))([_:](?P[a-z]{2}))?)' + r'(\.(?P[a-z0-9-]+))?$', re.IGNORECASE) # Define our default encoding _default_encoding = 'utf-8' - # Define our default language + # The function to assign `_` by default + _fn = 'gettext' + + # The language we should fall back to if all else fails _default_language = 'en' def __init__(self, language=None): @@ -123,25 +99,55 @@ class AppriseLocale: # Get our language self.lang = AppriseLocale.detect_language(language) + # Our mapping to our _fn + self.__fn_map = None + if GETTEXT_LOADED is False: # We're done return - if self.lang: + # Add language + self.add(self.lang) + + def add(self, lang=None, set_default=True): + """ + Add a language to our list + """ + lang = lang if lang else self._default_language + if lang not in self._gtobjs: # Load our gettext object and install our language try: - self._gtobjs[self.lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) + self._gtobjs[lang] = gettext.translation( + self._domain, localedir=self._locale_dir, languages=[lang], + fallback=False) + + # The non-intrusive method of applying the gettext change to + # the global namespace only + self.__fn_map = getattr(self._gtobjs[lang], self._fn) + + except FileNotFoundError: + # The translation directory does not exist + logger.debug( + 'Could not load translation path: %s', + join(self._locale_dir, lang)) + + # Fallback (handle case where self.lang does not exist) + if self.lang not in self._gtobjs: + self._gtobjs[self.lang] = gettext + self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) + + return False + + logger.trace('Loaded language %s', lang) - # Install our language - self._gtobjs[self.lang].install() + if set_default: + logger.debug('Language set to %s', lang) + self.lang = lang - except IOError: - # This occurs if we can't access/load our translations - pass + return True @contextlib.contextmanager - def lang_at(self, lang): + def lang_at(self, lang, mapto=_fn): """ The syntax works as: with at.lang_at('fr'): @@ -151,45 +157,31 @@ class AppriseLocale: """ if GETTEXT_LOADED is False: - # yield - yield + # Do nothing + yield None # we're done return # Tidy the language lang = AppriseLocale.detect_language(lang, detect_fallback=False) - - # Now attempt to load it - try: - if lang in self._gtobjs: - if lang != self.lang: - # Install our language only if we aren't using it - # already - self._gtobjs[lang].install() - - else: - self._gtobjs[lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) - - # Install our language - self._gtobjs[lang].install() - + if lang not in self._gtobjs and not self.add(lang, set_default=False): + # Do Nothing + yield getattr(self._gtobjs[self.lang], mapto) + else: # Yield - yield + yield getattr(self._gtobjs[lang], mapto) - except (IOError, KeyError): - # This occurs if we can't access/load our translations - # Yield reguardless - yield + return - finally: - # Fall back to our previous language - if lang != self.lang and lang in self._gtobjs: - # Install our language - self._gtobjs[self.lang].install() + @property + def gettext(self): + """ + Return the current language gettext() function - return + Useful for assigning to `_` + """ + return self._gtobjs[self.lang].gettext @staticmethod def detect_language(lang=None, detect_fallback=True): @@ -227,12 +219,12 @@ class AppriseLocale: # Fallback to posix detection pass - # Linux Handling + # Built in locale library check try: # Acquire our locale lang = locale.getlocale()[0] - except TypeError as e: + except (ValueError, TypeError) as e: # This occurs when an invalid locale was parsed from the # environment variable. While we still return None in this # case, we want to better notify the end user of this. Users @@ -249,8 +241,10 @@ class AppriseLocale: Pickle Support dumps() """ state = self.__dict__.copy() + # Remove the unpicklable entries. del state['_gtobjs'] + del state['_AppriseLocale__fn_map'] return state def __setstate__(self, state): @@ -258,4 +252,39 @@ class AppriseLocale: Pickle Support loads() """ self.__dict__.update(state) + # Our mapping to our _fn + self.__fn_map = None self._gtobjs = {} + self.add(state['lang'], set_default=True) + + +# +# Prepare our default LOCALE Singleton +# +LOCALE = AppriseLocale() + + +class LazyTranslation: + """ + Doesn't translate anything until str() or unicode() references + are made. + + """ + def __init__(self, text, *args, **kwargs): + """ + Store our text + """ + self.text = text + + super().__init__(*args, **kwargs) + + def __str__(self): + return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text + + +# Lazy translation handling +def gettext_lazy(text): + """ + A dummy function that can be referenced + """ + return LazyTranslation(text=text) diff --git a/apprise/i18n/en/LC_MESSAGES/apprise.po b/apprise/i18n/en/LC_MESSAGES/apprise.po index 44451262..65deb777 100644 --- a/apprise/i18n/en/LC_MESSAGES/apprise.po +++ b/apprise/i18n/en/LC_MESSAGES/apprise.po @@ -3,9 +3,10 @@ # This file is distributed under the same license as the apprise project. # Chris Caron , 2019. # -msgid "" +msgid "" msgstr "" -"Project-Id-Version: apprise 0.7.6\n" + +"Project-Id-Version: apprise 1.4.5\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2019-05-28 16:56-0400\n" "PO-Revision-Date: 2019-05-24 20:00-0400\n" @@ -18,276 +19,272 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.6.0\n" -msgid "API Key" -msgstr "" +msgid "API Key" +msgstr "API Key" -msgid "Access Key" -msgstr "" +msgid "Access Key" +msgstr "Access Key" -msgid "Access Key ID" -msgstr "" +msgid "Access Key ID" +msgstr "Access Key ID" -msgid "Access Secret" -msgstr "" +msgid "Access Secret" +msgstr "Access Secret" -msgid "Access Token" -msgstr "" +msgid "Access Token" +msgstr "Access Token" -msgid "Account SID" -msgstr "" +msgid "Account SID" +msgstr "Account SID" -msgid "Add Tokens" -msgstr "" +msgid "Add Tokens" +msgstr "Add Tokens" -msgid "Application Key" -msgstr "" +msgid "Application Key" +msgstr "Application Key" -msgid "Application Secret" -msgstr "" +msgid "Application Secret" +msgstr "Application Secret" -msgid "Auth Token" -msgstr "" +msgid "Auth Token" +msgstr "Auth Token" -msgid "Authorization Token" -msgstr "" +msgid "Authorization Token" +msgstr "Authorization Token" -msgid "Avatar Image" -msgstr "" +msgid "Avatar Image" +msgstr "Avatar Image" -msgid "Bot Name" -msgstr "" +msgid "Bot Name" +msgstr "Bot Name" -msgid "Bot Token" -msgstr "" +msgid "Bot Token" +msgstr "Bot Token" -msgid "Channels" -msgstr "" +msgid "Channels" +msgstr "Channels" -msgid "Consumer Key" -msgstr "" +msgid "Consumer Key" +msgstr "Consumer Key" -msgid "Consumer Secret" -msgstr "" +msgid "Consumer Secret" +msgstr "Consumer Secret" -msgid "Detect Bot Owner" -msgstr "" +msgid "Detect Bot Owner" +msgstr "Detect Bot Owner" -msgid "Device ID" -msgstr "" +msgid "Device ID" +msgstr "Device ID" -msgid "Display Footer" -msgstr "" +msgid "Display Footer" +msgstr "Display Footer" -msgid "Domain" -msgstr "" +msgid "Domain" +msgstr "Domain" -msgid "Duration" -msgstr "" +msgid "Duration" +msgstr "Duration" -msgid "Events" -msgstr "" +msgid "Events" +msgstr "Events" -msgid "Footer Logo" -msgstr "" +msgid "Footer Logo" +msgstr "Footer Logo" -msgid "From Email" -msgstr "" +msgid "From Email" +msgstr "From Email" -msgid "From Name" -msgstr "" +msgid "From Name" +msgstr "From Name" -msgid "From Phone No" -msgstr "" +msgid "From Phone No" +msgstr "From Phone No" -msgid "Group" -msgstr "" +msgid "Group" +msgstr "Group" -msgid "HTTP Header" -msgstr "" +msgid "HTTP Header" +msgstr "HTTP Header" -msgid "Hostname" -msgstr "" +msgid "Hostname" +msgstr "Hostname" -msgid "Include Image" -msgstr "" +msgid "Include Image" +msgstr "Include Image" -msgid "Modal" -msgstr "" +msgid "Modal" +msgstr "Modal" -msgid "Notify Format" -msgstr "" +msgid "Notify Format" +msgstr "Notify Format" -msgid "Organization" -msgstr "" +msgid "Organization" +msgstr "Organization" -msgid "Overflow Mode" -msgstr "" +msgid "Overflow Mode" +msgstr "Overflow Mode" -msgid "Password" -msgstr "" +msgid "Password" +msgstr "Password" -msgid "Port" -msgstr "" +msgid "Port" +msgstr "Port" -msgid "Priority" -msgstr "" +msgid "Priority" +msgstr "Priority" -msgid "Provider Key" -msgstr "" +msgid "Provider Key" +msgstr "Provider Key" -msgid "Region" -msgstr "" +msgid "Region" +msgstr "Region" -msgid "Region Name" -msgstr "" +msgid "Region Name" +msgstr "Region Name" -msgid "Remove Tokens" -msgstr "" +msgid "Remove Tokens" +msgstr "Remove Tokens" -msgid "Rooms" -msgstr "" +msgid "Rooms" +msgstr "Rooms" -msgid "SMTP Server" -msgstr "" +msgid "SMTP Server" +msgstr "SMTP Server" -msgid "Schema" -msgstr "" +msgid "Schema" +msgstr "Schema" -msgid "Secret Access Key" -msgstr "" +msgid "Secret Access Key" +msgstr "Secret Access Key" -msgid "Secret Key" -msgstr "" +msgid "Secret Key" +msgstr "Secret Key" -msgid "Secure Mode" -msgstr "" +msgid "Secure Mode" +msgstr "Secure Mode" -msgid "Server Timeout" -msgstr "" +msgid "Server Timeout" +msgstr "Server Timeout" -msgid "Sound" -msgstr "" +msgid "Sound" +msgstr "Sound" -msgid "Source JID" -msgstr "" +msgid "Source JID" +msgstr "Source JID" -msgid "Target Channel" -msgstr "" +msgid "Target Channel" +msgstr "Target Channel" -msgid "Target Chat ID" -msgstr "" +msgid "Target Chat ID" +msgstr "Target Chat ID" -msgid "Target Device" -msgstr "" +msgid "Target Device" +msgstr "Target Device" -msgid "Target Device ID" -msgstr "" +msgid "Target Device ID" +msgstr "Target Device ID" -msgid "Target Email" -msgstr "" +msgid "Target Email" +msgstr "Target Email" -msgid "Target Emails" -msgstr "" +msgid "Target Emails" +msgstr "Target Emails" -msgid "Target Encoded ID" -msgstr "" +msgid "Target Encoded ID" +msgstr "Target Encoded ID" -msgid "Target JID" -msgstr "" +msgid "Target JID" +msgstr "Target JID" -msgid "Target Phone No" -msgstr "" +msgid "Target Phone No" +msgstr "Target Phone No" -msgid "Target Room Alias" -msgstr "" +msgid "Target Room Alias" +msgstr "Target Room Alias" -msgid "Target Room ID" -msgstr "" +msgid "Target Room ID" +msgstr "Target Room ID" -msgid "Target Short Code" -msgstr "" +msgid "Target Short Code" +msgstr "Target Short Code" -msgid "Target Tag ID" -msgstr "" +msgid "Target Tag ID" +msgstr "Target Tag ID" -msgid "Target Topic" -msgstr "" - -msgid "Target User" -msgstr "" - -msgid "Targets" -msgstr "" +msgid "Target Topic" +msgstr "Target Topic" -msgid "Text To Speech" -msgstr "" +msgid "Target User" +msgstr "Target User" -msgid "To Channel ID" -msgstr "" +msgid "Targets" +msgstr "Targets" -msgid "To Email" -msgstr "" +msgid "Text To Speech" +msgstr "Text To Speech" -msgid "To User ID" -msgstr "" +msgid "To Channel ID" +msgstr "To Channel ID" -msgid "Token" -msgstr "" +msgid "To Email" +msgstr "To Email" -msgid "Token A" -msgstr "" +msgid "To User ID" +msgstr "To User ID" -msgid "Token B" -msgstr "" +msgid "Token" +msgstr "Token" -msgid "Token C" -msgstr "" +msgid "Token A" +msgstr "Token A" -msgid "Urgency" -msgstr "" +msgid "Token B" +msgstr "Token B" -msgid "Use Avatar" -msgstr "" +msgid "Token C" +msgstr "Token C" -msgid "User" -msgstr "" +msgid "Urgency" +msgstr "Urgency" -msgid "User Key" -msgstr "" +msgid "Use Avatar" +msgstr "Use Avatar" -msgid "User Name" -msgstr "" +msgid "User" +msgstr "User" -msgid "Username" -msgstr "" +msgid "User Key" +msgstr "User Key" -msgid "Verify SSL" -msgstr "" +msgid "User Name" +msgstr "User Name" -msgid "Version" -msgstr "" +msgid "Username" +msgstr "Username" -msgid "Webhook" -msgstr "" +msgid "Verify SSL" +msgstr "Verify SSL" -msgid "Webhook ID" -msgstr "" +msgid "Version" +msgstr "Version" -msgid "Webhook Mode" -msgstr "" +msgid "Webhook" +msgstr "Webhook" -msgid "Webhook Token" -msgstr "" +msgid "Webhook ID" +msgstr "Webhook ID" -msgid "X-Axis" -msgstr "" +msgid "Webhook Mode" +msgstr "Webhook Mode" -msgid "XEP" -msgstr "" +msgid "Webhook Token" +msgstr "Webhook Token" -msgid "Y-Axis" -msgstr "" +msgid "X-Axis" +msgstr "X-Axis" -#~ msgid "Access Key Secret" -#~ msgstr "" +msgid "XEP" +msgstr "XEP" +msgid "Y-Axis" +msgstr "Y-Axis" diff --git a/packaging/redhat/apprise-click67-support.patch b/packaging/redhat/apprise-click67-support.patch index fa9079f7..3a52487b 100644 --- a/packaging/redhat/apprise-click67-support.patch +++ b/packaging/redhat/apprise-click67-support.patch @@ -1,7 +1,7 @@ -diff -Naur apprise-1.0.0/test/test_cli.py apprise-1.0.0.patched/test/test_cli.py ---- apprise-1.0.0/test/test_cli.py 2022-07-15 14:52:13.000000000 -0400 -+++ apprise-1.0.0.patched/test/test_cli.py 2022-08-06 13:32:50.796935607 -0400 -@@ -1022,9 +1022,6 @@ +diff -Naur apprise-1.4.5/test/test_apprise_cli.py apprise-1.4.5-patched/test/test_apprise_cli.py +--- apprise-1.4.5/test/test_apprise_cli.py 2023-08-20 11:26:43.000000000 -0400 ++++ apprise-1.4.5-patched/test/test_apprise_cli.py 2023-08-20 16:37:42.922342103 -0400 +@@ -1027,9 +1027,6 @@ # Absolute path to __init__.py is okay assert result.exit_code == 0 @@ -11,7 +11,7 @@ diff -Naur apprise-1.0.0/test/test_cli.py apprise-1.0.0.patched/test/test_cli.py # Clear our working variables so they don't obstruct the next test # This simulates an actual call from the CLI. Unfortunately through # testing were occupying the same memory space so our singleton's -@@ -1044,9 +1041,6 @@ +@@ -1049,9 +1046,6 @@ # an __init__.py is found on the inside of it assert result.exit_code == 0 @@ -21,7 +21,7 @@ diff -Naur apprise-1.0.0/test/test_cli.py apprise-1.0.0.patched/test/test_cli.py # Test double paths that are the same; this ensures we only # load the plugin once result = runner.invoke(cli.main, [ -@@ -1179,15 +1173,6 @@ +@@ -1183,15 +1177,6 @@ # Print our custom details to the screen '--details', ]) diff --git a/setup.cfg b/setup.cfg index 830db591..c4861b4e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,11 +3,11 @@ universal = 0 [metadata] # ensure LICENSE is included in wheel metadata -license_file = LICENSE +license_files = LICENSE [flake8] # We exclude packages we don't maintain -exclude = .eggs,.tox +exclude = .eggs,.tox,.local ignore = E741,E722,W503,W504,W605 statistics = true builtins = _ diff --git a/setup.py b/setup.py index ed3d1e8a..128f9ff7 100755 --- a/setup.py +++ b/setup.py @@ -90,7 +90,7 @@ setup( ], }, install_requires=install_requires, - classifiers=( + classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', @@ -109,7 +109,7 @@ setup( 'License :: OSI Approved :: BSD License', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Software Development :: Libraries :: Application Frameworks', - ), + ], entry_points={'console_scripts': console_scripts}, python_requires='>=3.6', setup_requires=['babel', ], diff --git a/test/test_api.py b/test/test_api.py index 0b53656d..c37d214b 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -53,6 +53,7 @@ from apprise import __version__ from apprise import URLBase from apprise import PrivacyMode from apprise.AppriseLocale import LazyTranslation +from apprise.AppriseLocale import gettext_lazy as _ from apprise import common from apprise.plugins import __load_matrix @@ -1379,7 +1380,8 @@ def test_apprise_details(): assert 'details' in entry['requirements'] assert 'packages_required' in entry['requirements'] assert 'packages_recommended' in entry['requirements'] - assert isinstance(entry['requirements']['details'], str) + assert isinstance(entry['requirements']['details'], ( + str, LazyTranslation)) assert isinstance(entry['requirements']['packages_required'], list) assert isinstance(entry['requirements']['packages_recommended'], list) @@ -1406,7 +1408,8 @@ def test_apprise_details(): assert 'details' in entry['requirements'] assert 'packages_required' in entry['requirements'] assert 'packages_recommended' in entry['requirements'] - assert isinstance(entry['requirements']['details'], str) + assert isinstance(entry['requirements']['details'], ( + str, LazyTranslation)) assert isinstance(entry['requirements']['packages_required'], list) assert isinstance(entry['requirements']['packages_recommended'], list) diff --git a/test/test_cli.py b/test/test_apprise_cli.py similarity index 99% rename from test/test_cli.py rename to test/test_apprise_cli.py index d29a7b2c..9612fdff 100644 --- a/test/test_cli.py +++ b/test/test_apprise_cli.py @@ -49,6 +49,8 @@ from apprise.utils import environ from apprise.plugins import __load_matrix from apprise.plugins import __reset_matrix +from apprise.AppriseLocale import gettext_lazy as _ + from importlib import reload diff --git a/test/test_apprise_translations.py b/test/test_apprise_translations.py new file mode 100644 index 00000000..db2ad6be --- /dev/null +++ b/test/test_apprise_translations.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, Chris Caron +# +# 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. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# 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 +from unittest import mock + +import ctypes +import pytest + +from apprise import AppriseLocale +from apprise.utils import environ +from importlib import reload + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + + +def test_apprise_trans(): + """ + API: Test apprise locale object + """ + lazytrans = AppriseLocale.LazyTranslation('Token') + assert str(lazytrans) == 'Token' + + +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +def test_apprise_trans_gettext_init(): + """ + API: Handle gettext + """ + # Toggle + AppriseLocale.GETTEXT_LOADED = False + + # Objects can still be created + al = AppriseLocale.AppriseLocale() + + with al.lang_at('en') as _: + # functions still behave as normal + assert _ is None + + # Restore the object + AppriseLocale.GETTEXT_LOADED = True + + +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +@mock.patch('gettext.translation') +@mock.patch('locale.getlocale') +def test_apprise_trans_gettext_translations( + mock_getlocale, mock_gettext_trans): + """ + API: Apprise() Gettext translations + + """ + + # Set- our gettext.locale() return value + mock_getlocale.return_value = ('en_US', 'UTF-8') + + mock_gettext_trans.side_effect = FileNotFoundError() + + # This throws internally but we handle it gracefully + al = AppriseLocale.AppriseLocale() + + with al.lang_at('en'): + # functions still behave as normal + pass + + # This throws internally but we handle it gracefully + AppriseLocale.AppriseLocale(language="fr") + + +@pytest.mark.skipif( + hasattr(ctypes, 'windll'), reason="Unique Nux test cases") +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +@mock.patch('locale.getlocale') +def test_apprise_trans_gettext_lang_at(mock_getlocale): + """ + API: Apprise() Gettext lang_at + + """ + + # Set- our gettext.locale() return value + mock_getlocale.return_value = ('en_CA', 'UTF-8') + + # This throws internally but we handle it gracefully + al = AppriseLocale.AppriseLocale() + + # Edge Cases + assert al.add('en', set_default=False) is True + assert al.add('en', set_default=True) is True + + with al.lang_at('en'): + # functions still behave as normal + pass + + # This throws internally but we handle it gracefully + AppriseLocale.AppriseLocale(language="fr") + + with al.lang_at('en') as _: + # functions still behave as normal + assert callable(_) + + with al.lang_at('es') as _: + # functions still behave as normal + assert callable(_) + + with al.lang_at('fr') as _: + # functions still behave as normal + assert callable(_) + + # Test our initialization when our fallback is a language we do + # not have. This is only done to test edge cases when for whatever + # reason the person who set up apprise does not have the languages + # installed. + fallback = AppriseLocale.AppriseLocale._default_language + mock_getlocale.return_value = None + + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG'): + # Our default language + AppriseLocale.AppriseLocale._default_language = 'zz' + + # We will detect the zz since there were no environment variables to + # help us otherwise + assert AppriseLocale.AppriseLocale.detect_language() is None + al = AppriseLocale.AppriseLocale() + + # No Language could be set becuause no locale directory exists for this + assert al.lang is None + + with al.lang_at(None) as _: + # functions still behave as normal + assert callable(_) + + with al.lang_at('en') as _: + # functions still behave as normal + assert callable(_) + + with al.lang_at('es') as _: + # functions still behave as normal + assert callable(_) + + with al.lang_at('fr') as _: + # functions still behave as normal + assert callable(_) + + # We can still perform simple lookups; they access a dummy wrapper: + assert al.gettext('test') == 'test' + + with environ('LANGUAGE', 'LC_CTYPE', LC_ALL='C.UTF-8', LANG="en_CA"): + # the UTF-8 entry is skipped over + AppriseLocale.AppriseLocale._default_language = 'fr' + + # We will detect the english language (found in the LANG= environment + # variable which over-rides the _default + assert AppriseLocale.AppriseLocale.detect_language() == "en" + al = AppriseLocale.AppriseLocale() + assert al.lang == "en" + assert al.gettext('test') == 'test' + + # Test case with set_default set to False (so we're still set to 'fr') + assert al.add('zy', set_default=False) is False + assert al.gettext('test') == 'test' + + al.add('ab', set_default=True) + assert al.gettext('test') == 'test' + + assert al.add('zy', set_default=False) is False + AppriseLocale.AppriseLocale._default_language = fallback + + +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +def test_apprise_trans_add(): + """ + API: Apprise() Gettext add + + """ + + # This throws internally but we handle it gracefully + al = AppriseLocale.AppriseLocale() + + assert al.add('en') is True + + # Double add (copy of above) to access logic that prevents adding it again + assert al.add('en') is True + + # Invalid Language + assert al.add('bad') is False + + +@pytest.mark.skipif( + not hasattr(ctypes, 'windll'), reason="Unique Windows test cases") +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +@mock.patch('locale.getlocale') +def test_apprise_trans_windows_users_win(mock_getlocale): + """ + API: Apprise() Windows Locale Testing (Win version) + + """ + + # Set- our gettext.locale() return value + mock_getlocale.return_value = ('fr_CA', 'UTF-8') + + with mock.patch( + 'ctypes.windll.kernel32.GetUserDefaultUILanguage') as ui_lang: + + # 4105 = en_CA + ui_lang.return_value = 4105 + + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG'): + # Our default language + AppriseLocale.AppriseLocale._default_language = 'zz' + + # We will pick up the windll module and detect english + assert AppriseLocale.AppriseLocale.detect_language() == 'en' + + # The below accesses the windows fallback code + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', LANG="es_AR"): + # Environment Variable Trumps + assert AppriseLocale.AppriseLocale.detect_language() == 'es' + + # No environment variable, then the Windows environment is used + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG'): + # Windows Environment + assert AppriseLocale.AppriseLocale.detect_language() == 'en' + + assert AppriseLocale.AppriseLocale\ + .detect_language(detect_fallback=False) is None + + # 0 = IndexError + ui_lang.return_value = 0 + with environ('LANGUAGE', 'LANG', 'LC_ALL', 'LC_CTYPE'): + # We fall back to posix locale + assert AppriseLocale.AppriseLocale.detect_language() == 'fr' + + +@pytest.mark.skipif( + hasattr(ctypes, 'windll'), reason="Unique Nux test cases") +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +@mock.patch('locale.getlocale') +def test_apprise_trans_windows_users_nux(mock_getlocale): + """ + API: Apprise() Windows Locale Testing (Nux version) + + """ + + # Set- our gettext.locale() return value + mock_getlocale.return_value = ('fr_CA', 'UTF-8') + + # Emulate a windows environment + windll = mock.Mock() + setattr(ctypes, 'windll', windll) + + # 4105 = en_CA + windll.kernel32.GetUserDefaultUILanguage.return_value = 4105 + + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG'): + # Our default language + AppriseLocale.AppriseLocale._default_language = 'zz' + + # We will pick up the windll module and detect english + assert AppriseLocale.AppriseLocale.detect_language() == 'en' + + # The below accesses the windows fallback code + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', LANG="es_AR"): + # Environment Variable Trumps + assert AppriseLocale.AppriseLocale.detect_language() == 'es' + + # No environment variable, then the Windows environment is used + with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG'): + # Windows Environment + assert AppriseLocale.AppriseLocale.detect_language() == 'en' + + assert AppriseLocale.AppriseLocale\ + .detect_language(detect_fallback=False) is None + + # 0 = IndexError + windll.kernel32.GetUserDefaultUILanguage.return_value = 0 + with environ('LANGUAGE', 'LANG', 'LC_ALL', 'LC_CTYPE'): + # We fall back to posix locale + assert AppriseLocale.AppriseLocale.detect_language() == 'fr' + + delattr(ctypes, 'windll') + + +@pytest.mark.skipif(sys.platform == "win32", reason="Unique Nux test cases") +@mock.patch('locale.getlocale') +def test_detect_language_using_env(mock_getlocale): + """ + Test the reading of information from an environment variable + """ + + # Set- our gettext.locale() return value + mock_getlocale.return_value = ('en_CA', 'UTF-8') + + # The below accesses the windows fallback code and fail + # then it will resort to the environment variables. + with environ('LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE'): + # Language can now be detected in this case + assert isinstance( + AppriseLocale.AppriseLocale.detect_language(), str) + + # Detect French language. + with environ('LANGUAGE', 'LC_ALL', LC_CTYPE="garbage", LANG="fr_CA"): + assert AppriseLocale.AppriseLocale.detect_language() == 'fr' + + # The following unsets all environment variables and sets LC_CTYPE + # This was causing Python 2.7 to internally parse UTF-8 as an invalid + # locale and throw an uncaught ValueError; Python v2 support has been + # dropped, but just to ensure this issue does not come back, we keep + # this test: + with environ(*list(os.environ.keys()), LC_CTYPE="UTF-8"): + assert isinstance(AppriseLocale.AppriseLocale.detect_language(), str) + + # Test with absolutely no environment variables what-so-ever + with environ(*list(os.environ.keys())): + assert isinstance(AppriseLocale.AppriseLocale.detect_language(), str) + + # Handle case where getlocale() can't be detected + mock_getlocale.return_value = None + with environ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): + assert AppriseLocale.AppriseLocale.detect_language() is None + + mock_getlocale.return_value = (None, None) + with environ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): + assert AppriseLocale.AppriseLocale.detect_language() is None + + # if detect_language and windows env fail us, then we don't + # set up a default language on first load + AppriseLocale.AppriseLocale() + + +@pytest.mark.skipif( + 'gettext' not in sys.modules, reason="Requires gettext") +def test_apprise_trans_gettext_missing(tmpdir): + """ + Verify we can still operate without the gettext library + """ + + # remove gettext from our system enviroment + del sys.modules["gettext"] + + # Make our new path to a fake gettext (used to over-ride real one) + # have it fail right out of the gate + gettext_dir = tmpdir.mkdir("gettext") + gettext_dir.join("__init__.py").write("") + gettext_dir.join("gettext.py").write("""raise ImportError()""") + + # Update our path to point path to head + sys.path.insert(0, str(gettext_dir)) + + # reload our module (forcing the import error when it tries to load gettext + reload(sys.modules['apprise.AppriseLocale']) + from apprise import AppriseLocale + assert AppriseLocale.GETTEXT_LOADED is False + + # Now roll our changes back + sys.path.pop(0) + + # Reload again (reverting back) + reload(sys.modules['apprise.AppriseLocale']) + from apprise import AppriseLocale + assert AppriseLocale.GETTEXT_LOADED is True diff --git a/test/test_locale.py b/test/test_locale.py deleted file mode 100644 index a16f1785..00000000 --- a/test/test_locale.py +++ /dev/null @@ -1,215 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 3-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2023, Chris Caron -# -# 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. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# 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 -from unittest import mock - -import ctypes -import pytest - -from apprise import AppriseLocale -from apprise.utils import environ -from importlib import reload - - -# Disable logging for a cleaner testing output -import logging -logging.disable(logging.CRITICAL) - - -@mock.patch('gettext.install') -def test_apprise_locale(mock_gettext_install): - """ - API: Test apprise locale object - """ - lazytrans = AppriseLocale.LazyTranslation('Token') - assert str(lazytrans) == 'Token' - - -@mock.patch('gettext.install') -def test_gettext_init(mock_gettext_install): - """ - API: Mock Gettext init - """ - mock_gettext_install.side_effect = ImportError() - # Test our fall back to not supporting translations - reload(AppriseLocale) - - # Objects can still be created - al = AppriseLocale.AppriseLocale() - - with al.lang_at('en'): - # functions still behave as normal - pass - - # restore the object - mock_gettext_install.side_effect = None - reload(AppriseLocale) - - -@mock.patch('gettext.translation') -def test_gettext_translations(mock_gettext_trans): - """ - API: Apprise() Gettext translations - - """ - - mock_gettext_trans.side_effect = IOError() - - # This throws internally but we handle it gracefully - al = AppriseLocale.AppriseLocale() - - with al.lang_at('en'): - # functions still behave as normal - pass - - # This throws internally but we handle it gracefully - AppriseLocale.AppriseLocale(language="fr") - - -@mock.patch('gettext.translation') -def test_gettext_installs(mock_gettext_trans): - """ - API: Apprise() Gettext install - - """ - - mock_lang = mock.Mock() - mock_lang.install.return_value = True - mock_gettext_trans.return_value = mock_lang - - # This throws internally but we handle it gracefully - al = AppriseLocale.AppriseLocale() - - with al.lang_at('en'): - # functions still behave as normal - pass - - # This throws internally but we handle it gracefully - AppriseLocale.AppriseLocale(language="fr") - - # Force a few different languages - al._gtobjs['en'] = mock_lang - al._gtobjs['es'] = mock_lang - al.lang = 'en' - - with al.lang_at('en'): - # functions still behave as normal - pass - - with al.lang_at('es'): - # functions still behave as normal - pass - - with al.lang_at('fr'): - # functions still behave as normal - pass - - -def test_detect_language_windows_users(): - """ - API: Apprise() Detect language - - """ - - if hasattr(ctypes, 'windll'): - from ctypes import windll - - else: - windll = mock.Mock() - # 4105 = en_CA - windll.kernel32.GetUserDefaultUILanguage.return_value = 4105 - setattr(ctypes, 'windll', windll) - - # The below accesses the windows fallback code - with environ('LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', LANG="en_CA"): - assert AppriseLocale.AppriseLocale.detect_language() == 'en' - - assert AppriseLocale.AppriseLocale\ - .detect_language(detect_fallback=False) is None - - # 0 = IndexError - windll.kernel32.GetUserDefaultUILanguage.return_value = 0 - setattr(ctypes, 'windll', windll) - with environ('LANG', 'LC_ALL', 'LC_CTYPE', LANGUAGE="en_CA"): - assert AppriseLocale.AppriseLocale.detect_language() == 'en' - - -def test_detect_language_using_env(): - """ - Test the reading of information from an environment variable - """ - - # The below accesses the windows fallback code and fail - # then it will resort to the environment variables. - with environ('LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE'): - # Language can now be detected in this case - assert isinstance( - AppriseLocale.AppriseLocale.detect_language(), str) - - # Detect French language. - with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', LANG="fr_CA"): - assert AppriseLocale.AppriseLocale.detect_language() == 'fr' - - # The following unsets all environment variables and sets LC_CTYPE - # This was causing Python 2.7 to internally parse UTF-8 as an invalid - # locale and throw an uncaught ValueError; Python v2 support has been - # dropped, but just to ensure this issue does not come back, we keep - # this test: - with environ(*list(os.environ.keys()), LC_CTYPE="UTF-8"): - assert isinstance(AppriseLocale.AppriseLocale.detect_language(), str) - - # Test with absolutely no environment variables what-so-ever - with environ(*list(os.environ.keys())): - assert isinstance(AppriseLocale.AppriseLocale.detect_language(), str) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Does not work on Windows") -@mock.patch('locale.getlocale') -def test_detect_language_locale(mock_getlocale): - """ - API: Apprise() Default locale detection - - """ - # Handle case where getlocale() can't be detected - mock_getlocale.return_value = None - with environ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): - assert AppriseLocale.AppriseLocale.detect_language() is None - - mock_getlocale.return_value = (None, None) - with environ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): - assert AppriseLocale.AppriseLocale.detect_language() is None - - # if detect_language and windows env fail us, then we don't - # set up a default language on first load - AppriseLocale.AppriseLocale()