From cddd5c4fb3fff9c87c7ca6b3dbc7a112860cfae8 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Sun, 30 Oct 2022 13:31:57 -0700 Subject: [PATCH] CI: Enable testing on macOS and Windows (#707) --- .github/workflows/codeql-analysis.yml | 5 +++ .github/workflows/tests.yml | 61 ++++++++++++++++++++------- apprise/plugins/NotifyMQTT.py | 52 +++++++++++++++-------- requirements.txt | 4 ++ setup.py | 7 ++- test/test_apprise_config.py | 4 +- test/test_attach_file.py | 10 ++++- test/test_config_base.py | 2 +- test/test_config_memory.py | 4 +- test/test_locale.py | 22 +++++++--- test/test_logger.py | 16 ++++--- test/test_plugin_macosx.py | 5 +++ test/test_plugin_syslog.py | 10 +++-- test/test_plugin_windows.py | 6 ++- 14 files changed, 150 insertions(+), 58 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d7604fae..8548df8a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -8,6 +8,11 @@ on: schedule: - cron: '42 15 * * 5' +# Cancel in-progress jobs when pushing to the same branch. +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + jobs: analyze: name: Analyze diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5c23fa49..dcccecb7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,33 +27,62 @@ jobs: # all jobs once the first one fails (true). fail-fast: true + # Define a minimal test matrix, it will be + # expanded using subsequent `include` items. matrix: - os: [ - "ubuntu-latest", - # "macos-latest", - # "windows-latest", - ] - python-version: [ - "3.6", "3.7", "3.8", "3.9", "3.10", "3.11-dev", - "pypy3.6", "pypy3.7", "pypy3.8", "pypy3.9", - ] + os: ["ubuntu-latest"] + python-version: ["3.10"] bare: [false] - # Add another single item to the test matrix. It is the `bare` environment, - # where `all-plugin-requirements.txt` will NOT be installed, in order to - # verify the application also works well without those optional dependencies. include: + + # Within the `bare` environment, `all-plugin-requirements.txt` will NOT be + # installed, to verify the application also works without those dependencies. - os: "ubuntu-latest" python-version: "3.10" bare: true + # Let's save resources and only build a single slot on macOS- and Windows. + - os: "macos-latest" + python-version: "3.10" + - os: "windows-latest" + python-version: "3.10" + + # Test more available versions of CPython on Linux. + - os: "ubuntu-latest" + python-version: "3.6" + - os: "ubuntu-latest" + python-version: "3.7" + - os: "ubuntu-latest" + python-version: "3.8" + - os: "ubuntu-latest" + python-version: "3.9" + - os: "ubuntu-latest" + python-version: "3.10" + - os: "ubuntu-latest" + python-version: "3.11" + + # Test more available versions of PyPy on Linux. + - os: "ubuntu-latest" + python-version: "pypy3.6" + - os: "ubuntu-latest" + python-version: "pypy3.7" + - os: "ubuntu-latest" + python-version: "pypy3.8" + - os: "ubuntu-latest" + python-version: "pypy3.9" + + defaults: + run: + shell: bash + env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python-version }} BARE: ${{ matrix.bare }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Python ${{ matrix.python-version }} on ${{ matrix.os }} (bare=${{ matrix.bare }}) + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} ${{ matrix.bare && '(bare)' || '' }} steps: - name: Acquire sources @@ -88,13 +117,13 @@ jobs: run: | pip install -r all-plugin-requirements.txt - # Installing `dbus-python` will croak on PyPy, so skip it. - [[ $PYTHON != 'pypy'* ]] && pip install dbus-python || true + # Installing `dbus-python` will only work on Linux/CPython. + [[ $RUNNER_OS = "Linux" && $PYTHON != 'pypy'* ]] && pip install dbus-python || true - name: Install project dependencies (Windows) if: runner.os == 'Windows' run: | - pip install -r win-requirements.txt + [[ $PYTHON != 'pypy'* ]] && pip install -r win-requirements.txt || true # Install package in editable mode, # and run project-specific tasks. diff --git a/apprise/plugins/NotifyMQTT.py b/apprise/plugins/NotifyMQTT.py index 79ad0aff..d6d2b0f0 100644 --- a/apprise/plugins/NotifyMQTT.py +++ b/apprise/plugins/NotifyMQTT.py @@ -132,23 +132,6 @@ class NotifyMQTT(NotifyBase): # through their network flow at once. mqtt_inflight_messages = 200 - # Taken from https://golang.org/src/crypto/x509/root_linux.go - # TODO: Maybe migrate to a general utility function? - CA_CERTIFICATE_FILE_LOCATIONS = [ - # Debian/Ubuntu/Gentoo etc. - "/etc/ssl/certs/ca-certificates.crt", - # Fedora/RHEL 6 - "/etc/pki/tls/certs/ca-bundle.crt", - # OpenSUSE - "/etc/ssl/ca-bundle.pem", - # OpenELEC - "/etc/pki/tls/cacert.pem", - # CentOS/RHEL 7 - "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", - # macOS Homebrew; brew install ca-certificates - "/usr/local/etc/ca-certificates/cert.pem", - ] - # Define object templates templates = ( '{schema}://{user}@{host}/{topic}', @@ -534,3 +517,38 @@ class NotifyMQTT(NotifyBase): # return results return results + + @property + def CA_CERTIFICATE_FILE_LOCATIONS(self): + """ + Return possible locations to root certificate authority (CA) bundles. + + Taken from https://golang.org/src/crypto/x509/root_linux.go + TODO: Maybe refactor to a general utility function? + """ + candidates = [ + # Debian/Ubuntu/Gentoo etc. + "/etc/ssl/certs/ca-certificates.crt", + # Fedora/RHEL 6 + "/etc/pki/tls/certs/ca-bundle.crt", + # OpenSUSE + "/etc/ssl/ca-bundle.pem", + # OpenELEC + "/etc/pki/tls/cacert.pem", + # CentOS/RHEL 7 + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + # macOS Homebrew; brew install ca-certificates + "/usr/local/etc/ca-certificates/cert.pem", + ] + + # Certifi provides Mozilla’s carefully curated collection of Root + # Certificates for validating the trustworthiness of SSL certificates + # while verifying the identity of TLS hosts. It has been extracted from + # the Requests project. + try: + import certifi + candidates.append(certifi.where()) + except ImportError: # pragma: no cover + pass + + return candidates diff --git a/requirements.txt b/requirements.txt index 1cd0c763..eda9d456 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ +# Root certificate authority bundle. +certifi + +# Application dependencies. requests requests-oauthlib click >= 5.0 diff --git a/setup.py b/setup.py index 690a2538..529e2e3c 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,8 @@ import re import os import platform +import sys + from setuptools import find_packages, setup cmdclass = {} @@ -43,7 +45,8 @@ except ImportError: install_options = os.environ.get("APPRISE_INSTALL", "").split(",") install_requires = open('requirements.txt').readlines() -if platform.system().lower().startswith('win'): +if platform.system().lower().startswith('win') \ + and not hasattr(sys, "pypy_version_info"): # Windows Notification Support install_requires += open('win-requirements.txt').readlines() @@ -60,7 +63,7 @@ setup( version='1.1.0', description='Push Notifications that work with just about every platform!', license='MIT', - long_description=open('README.md').read(), + long_description=open('README.md', encoding="utf-8").read(), long_description_content_type='text/markdown', cmdclass=cmdclass, url='https://github.com/caronc/apprise', diff --git a/test/test_apprise_config.py b/test/test_apprise_config.py index 4a9cd19b..37b2f2c2 100644 --- a/test/test_apprise_config.py +++ b/test/test_apprise_config.py @@ -783,8 +783,8 @@ json://localhost:8080 include {}""".format(str(cfg01))) cfg02.write(""" -# syslog entry -syslog:// +# json entry +json://localhost:8080 # recursively include ourselves include cfg02.cfg""") diff --git a/test/test_attach_file.py b/test/test_attach_file.py index a34d01e8..383bd7ba 100644 --- a/test/test_attach_file.py +++ b/test/test_attach_file.py @@ -25,6 +25,7 @@ import re import time +import urllib from unittest import mock from os.path import dirname @@ -98,8 +99,13 @@ def test_attach_file(): # Download is successful and has already been called by now; below pulls # results from cache assert response.download() - assert response.url().startswith('file://{}'.format(path)) - # No mime-type and/or filename over-ride was specified, so therefore it + + # On Windows, it is `file://D%3A%5Ca%5Capprise%5Capprise%5Ctest%5Cvar%5Capprise-test.gif`. # noqa E501 + # TODO: Review - is this correct? + path_in_url = urllib.parse.quote(path) + assert response.url().startswith('file://{}'.format(path_in_url)) + + # No mime-type and/or filename over-ride was specified, so it # won't show up in the generated URL assert re.search(r'[?&]mime=', response.url()) is None assert re.search(r'[?&]name=', response.url()) is None diff --git a/test/test_config_base.py b/test/test_config_base.py index ffe25aa6..3f424d58 100644 --- a/test/test_config_base.py +++ b/test/test_config_base.py @@ -182,7 +182,7 @@ version: 1 urls: - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b - mailto://test:password@gmail.com - - syslog://: + - json://localhost: - tag: devops, admin """, asset=AppriseAsset()) diff --git a/test/test_config_memory.py b/test/test_config_memory.py index 546ff986..922b022c 100644 --- a/test/test_config_memory.py +++ b/test/test_config_memory.py @@ -39,7 +39,7 @@ def test_config_memory(): assert ConfigMemory.parse_url('garbage://') is None # Initialize our object - cm = ConfigMemory(content="syslog://", format='text') + cm = ConfigMemory(content="json://localhost", format='text') # one entry added assert len(cm) == 1 @@ -49,7 +49,7 @@ def test_config_memory(): assert isinstance(cm.read(), str) is True # Test situation where an auto-detect is required: - cm = ConfigMemory(content="syslog://") + cm = ConfigMemory(content="json://localhost") # one entry added assert len(cm) == 1 diff --git a/test/test_locale.py b/test/test_locale.py index 77accd4b..eed96b7a 100644 --- a/test/test_locale.py +++ b/test/test_locale.py @@ -24,9 +24,11 @@ # THE SOFTWARE. import os +import sys from unittest import mock import ctypes +import pytest from apprise import AppriseLocale from apprise.utils import environ @@ -133,7 +135,9 @@ def test_detect_language_windows_users(): """ - if not hasattr(ctypes, 'windll'): + if hasattr(ctypes, 'windll'): + from ctypes import windll + else: windll = mock.Mock() # 4105 = en_CA windll.kernel32.GetUserDefaultUILanguage.return_value = 4105 @@ -152,14 +156,22 @@ def test_detect_language_windows_users(): with environ('LANG', 'LC_ALL', 'LC_CTYPE', LANGUAGE="en_CA"): assert AppriseLocale.AppriseLocale.detect_language() == 'en' + +@pytest.mark.skipif(sys.platform == "win32", reason="Does not work on Windows") +def test_detect_language_windows_users_croaks_please_review(): + """ + When enabling CI testing on Windows, those tests did not produce the + correct results. They may want to be reviewed. + """ + # The below accesses the windows fallback code and fail - # then it will resort to the environment variables + # then it will resort to the environment variables. with environ('LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE'): # Language can't be detected assert AppriseLocale.AppriseLocale.detect_language() is None + # Detect French language. with environ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', LANG="fr_CA"): - # Detect french language assert AppriseLocale.AppriseLocale.detect_language() == 'fr' # The following unsets all environment variables and sets LC_CTYPE @@ -174,10 +186,8 @@ def test_detect_language_windows_users(): with environ(*list(os.environ.keys())): assert AppriseLocale.AppriseLocale.detect_language() is None - # Tidy - delattr(ctypes, 'windll') - +@pytest.mark.skipif(sys.platform == "win32", reason="Does not work on Windows") @mock.patch('locale.getdefaultlocale') def test_detect_language_defaultlocale(mock_getlocale): """ diff --git a/test/test_logger.py b/test/test_logger.py index cd6bdff6..7db787c0 100644 --- a/test/test_logger.py +++ b/test/test_logger.py @@ -25,6 +25,8 @@ import re import os +import sys + import pytest import requests from unittest import mock @@ -281,12 +283,16 @@ def test_apprise_log_file_captures(tmpdir): assert len(logs) == 5 - # Remove our file before we exit the with clause - # this causes our delete() call to throw gracefully inside - os.unlink(str(log_file)) + # Concurrent file access is not possible on Windows. + # PermissionError: [WinError 32] The process cannot access the file + # because it is being used by another process. + if sys.platform != "win32": + # Remove our file before we exit the with clause + # this causes our delete() call to throw gracefully inside + os.unlink(str(log_file)) - # Verify file is gone - assert not os.path.isfile(str(log_file)) + # Verify file is gone + assert not os.path.isfile(str(log_file)) # Verify that we did not lose our effective log level even though # the above steps the level up for the duration of the capture diff --git a/test/test_plugin_macosx.py b/test/test_plugin_macosx.py index 023cb3fa..ac6519bc 100644 --- a/test/test_plugin_macosx.py +++ b/test/test_plugin_macosx.py @@ -38,6 +38,11 @@ from helpers import reload_plugin logging.disable(logging.CRITICAL) +if sys.platform not in ["darwin", "linux"]: + pytest.skip("Only makes sense on macOS, but also works on Linux", + allow_module_level=True) + + @pytest.fixture def pretend_macos(mocker): """ diff --git a/test/test_plugin_syslog.py b/test/test_plugin_syslog.py index 59ccbf78..6efb3f45 100644 --- a/test/test_plugin_syslog.py +++ b/test/test_plugin_syslog.py @@ -32,12 +32,16 @@ import socket # Disable logging for a cleaner testing output import logging - -from apprise.plugins.NotifySyslog import NotifySyslog - logging.disable(logging.CRITICAL) +# The `syslog` module is not available on Windows. +# `ModuleNotFoundError: No module named 'syslog'` +NotifySyslog = pytest.importorskip( + "apprise.plugins.NotifySyslog", + reason="`syslog` module not available on Windows").NotifySyslog + + @mock.patch('syslog.syslog') @mock.patch('syslog.openlog') def test_plugin_syslog_by_url(openlog, syslog): diff --git a/test/test_plugin_windows.py b/test/test_plugin_windows.py index 9e4657de..d9ef9f78 100644 --- a/test/test_plugin_windows.py +++ b/test/test_plugin_windows.py @@ -195,8 +195,9 @@ def test_plugin_windows_mocked(): @mock.patch('win32gui.UpdateWindow') @mock.patch('win32gui.Shell_NotifyIcon') @mock.patch('win32gui.LoadImage') -def test_plugin_windows_native( - mock_update_window, mock_loadimage, mock_notify): +def test_plugin_windows_native(mock_loadimage, + mock_notify, + mock_update_window): """ NotifyWindows() General Checks (via Windows platform) @@ -261,6 +262,7 @@ def test_plugin_windows_native( assert obj.duration == obj.default_popup_duration_sec # To avoid slowdowns (for testing), turn it to zero for now + obj = apprise.Apprise.instantiate('windows://', suppress_exceptions=False) obj.duration = 0 # Test our loading of our icon exception; it will still allow the