From 37a7115c1817fc5f694d1703127f2f4ca0fc249f Mon Sep 17 00:00:00 2001 From: Nicolas Ledez <247138+nledez@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:49:14 +0100 Subject: [PATCH 1/5] Fix 'ModuleNotFoundError: No module named 'pkg_resources'' install error --- setup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 776524c..5b3fe65 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,9 @@ import os import sys -from pkg_resources import get_distribution, parse_version +from importlib.metadata import version as get_version +from packaging.version import Version + from setuptools import find_packages, setup from setuptools.command.test import test as TestCommand @@ -36,7 +38,7 @@ def finalize_options(self): TestCommand.finalize_options(self) # https://bitbucket.org/pypa/setuptools/commits/cf565b6 - if get_distribution("setuptools").parsed_version < parse_version("18.4"): + if Version(get_version("setuptools")) < Version("18.4"): self.test_args = [] self.test_suite = True From 1874e55a5ef35770610f836d90516cf8393e9a6a Mon Sep 17 00:00:00 2001 From: Nicolas Ledez <247138+nledez@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:09:34 +0100 Subject: [PATCH 2/5] Update version --- pyproject.toml | 3 +++ setup.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ffff549 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "packaging"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 5b3fe65..bb0dae3 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,6 @@ def run_tests(self): platforms="any", tests_require=["pytest"], url="https://github.com/numberly/matterhook", - version="0.2", + version="0.3", zip_safe=True, ) From dd643daf71c85f3154c5290cc8ac8ead8c5797dd Mon Sep 17 00:00:00 2001 From: Nicolas Ledez <247138+nledez@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:42:03 +0100 Subject: [PATCH 3/5] Remove useless tests --- pyproject.toml | 2 +- setup.py | 30 ------------------------------ 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffff549..fed528d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools", "packaging"] +requires = ["setuptools"] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index bb0dae3..8241c6e 100644 --- a/setup.py +++ b/setup.py @@ -3,13 +3,8 @@ """ import os -import sys - -from importlib.metadata import version as get_version -from packaging.version import Version from setuptools import find_packages, setup -from setuptools.command.test import test as TestCommand def read(fname: str): @@ -27,29 +22,6 @@ def read(fname: str): return open(os.path.join(os.path.dirname(__file__), fname)).read() -class PyTest(TestCommand): - user_options = [("pytest-args=", "a", "Arguments to pass to py.test")] - - def initialize_options(self): - TestCommand.initialize_options(self) - self.pytest_args = [] - - def finalize_options(self): - TestCommand.finalize_options(self) - - # https://bitbucket.org/pypa/setuptools/commits/cf565b6 - if Version(get_version("setuptools")) < Version("18.4"): - self.test_args = [] - self.test_suite = True - - def run_tests(self): - # import here, cause outside the eggs aren't loaded - import pytest - - errno = pytest.main(self.pytest_args) - sys.exit(errno) - - setup( author="numberly", classifiers=[ @@ -64,7 +36,6 @@ def run_tests(self): "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Python Modules", ], - cmdclass={"test": PyTest}, description="Interact with Mattermost incoming webhooks easily.", download_url="https://github.com/numberly/matterhook/tags", include_package_data=True, @@ -74,7 +45,6 @@ def run_tests(self): name="matterhook", packages=find_packages(), platforms="any", - tests_require=["pytest"], url="https://github.com/numberly/matterhook", version="0.3", zip_safe=True, From b35140ab0bee53f982587b72a4e3794a393ed280 Mon Sep 17 00:00:00 2001 From: Nicolas Ledez <247138+nledez@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:46:49 +0100 Subject: [PATCH 4/5] test: add unit tests with 100% code coverage and CI pipeline Add comprehensive test suite for Webhook, Attachment, and module exports with mocked HTTP interactions. Add GitHub Actions CI workflow running tests with coverage across Python 3.5 to 3.14. Document test commands in README. --- .github/workflows/ci.yml | 53 +++++++++++ README.rst | 23 ++++- tests/__init__.py | 0 tests/test_attachments.py | 137 +++++++++++++++++++++++++++ tests/test_incoming.py | 191 ++++++++++++++++++++++++++++++++++++++ tests/test_init.py | 21 +++++ 6 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_attachments.py create mode 100644 tests/test_incoming.py create mode 100644 tests/test_init.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..feaa50e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - python-version: "3.5" + os: ubuntu-20.04 + - python-version: "3.6" + os: ubuntu-20.04 + - 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" + os: ubuntu-latest + - python-version: "3.12" + os: ubuntu-latest + - python-version: "3.13" + os: ubuntu-latest + - python-version: "3.14" + os: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run tests with coverage + run: pytest tests/ --cov=matterhook --cov-report=term-missing diff --git a/README.rst b/README.rst index 62a7c40..548073a 100644 --- a/README.rst +++ b/README.rst @@ -71,4 +71,25 @@ Advanced usage ''' message['text'] = markdown_msg attachments.append(message) - mwh.send(attachments=attachments) \ No newline at end of file + mwh.send(attachments=attachments) + +Testing +======= + +Install the test dependencies first: + +.. code-block:: bash + + pip install pytest pytest-cov + +Run the tests: + +.. code-block:: bash + + pytest tests/ + +Run the tests with code coverage: + +.. code-block:: bash + + pytest tests/ --cov=matterhook --cov-report=term-missing \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..aadf1c9 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,137 @@ +from matterhook.attachments import Attachment + + +class TestAttachmentInit: + def test_minimal(self): + att = Attachment(fallback="fallback text") + assert att.fallback == "fallback text" + assert att.color is None + assert att.pretext is None + assert att.text is None + assert att.author_name is None + assert att.author_link is None + assert att.author_icon is None + assert att.title is None + assert att.title_link is None + assert att.fields is None + assert att.image_url is None + assert att.thumb_url is None + + def test_all_params(self): + fields = [{"short": True, "title": "F", "value": "V"}] + att = Attachment( + fallback="fb", + color="#FF0000", + pretext="pre", + text="body", + author_name="author", + author_link="https://author.com", + author_icon="https://author.com/icon.png", + title="Title", + title_link="https://title.com", + fields=fields, + image_url="https://image.png", + thumb_url="https://thumb.png", + ) + assert att.fallback == "fb" + assert att.color == "#FF0000" + assert att.pretext == "pre" + assert att.text == "body" + assert att.author_name == "author" + assert att.author_link == "https://author.com" + assert att.author_icon == "https://author.com/icon.png" + assert att.title == "Title" + assert att.title_link == "https://title.com" + assert att.fields == fields + assert att.image_url == "https://image.png" + assert att.thumb_url == "https://thumb.png" + + +class TestAttachmentPayload: + def test_minimal_payload(self): + att = Attachment(fallback="fb") + assert att.payload == {"fallback": "fb"} + + def test_full_payload(self): + fields = [{"short": True, "title": "F", "value": "V"}] + att = Attachment( + fallback="fb", + color="#FF0000", + pretext="pre", + text="body", + author_name="author", + author_link="https://author.com", + author_icon="https://author.com/icon.png", + title="Title", + title_link="https://title.com", + fields=fields, + image_url="https://image.png", + thumb_url="https://thumb.png", + ) + assert att.payload == { + "fallback": "fb", + "color": "#FF0000", + "pretext": "pre", + "text": "body", + "author_name": "author", + "author_link": "https://author.com", + "author_icon": "https://author.com/icon.png", + "title": "Title", + "title_link": "https://title.com", + "fields": fields, + "image_url": "https://image.png", + "thumb_url": "https://thumb.png", + } + + def test_partial_payload_only_color(self): + att = Attachment(fallback="fb", color="#00FF00") + assert att.payload == {"fallback": "fb", "color": "#00FF00"} + + def test_partial_payload_only_text(self): + att = Attachment(fallback="fb", text="hello") + assert att.payload == {"fallback": "fb", "text": "hello"} + + def test_partial_payload_author_fields(self): + att = Attachment( + fallback="fb", + author_name="a", + author_link="https://a.com", + author_icon="https://a.com/i.png", + ) + assert att.payload == { + "fallback": "fb", + "author_name": "a", + "author_link": "https://a.com", + "author_icon": "https://a.com/i.png", + } + + def test_partial_payload_title_fields(self): + att = Attachment( + fallback="fb", title="T", title_link="https://t.com" + ) + assert att.payload == { + "fallback": "fb", + "title": "T", + "title_link": "https://t.com", + } + + def test_partial_payload_image_fields(self): + att = Attachment( + fallback="fb", + image_url="https://img.png", + thumb_url="https://thumb.png", + ) + assert att.payload == { + "fallback": "fb", + "image_url": "https://img.png", + "thumb_url": "https://thumb.png", + } + + def test_partial_payload_pretext(self): + att = Attachment(fallback="fb", pretext="before") + assert att.payload == {"fallback": "fb", "pretext": "before"} + + def test_partial_payload_fields(self): + fields = [{"short": False, "title": "X", "value": "Y"}] + att = Attachment(fallback="fb", fields=fields) + assert att.payload == {"fallback": "fb", "fields": fields} diff --git a/tests/test_incoming.py b/tests/test_incoming.py new file mode 100644 index 0000000..2b4bb8b --- /dev/null +++ b/tests/test_incoming.py @@ -0,0 +1,191 @@ +from unittest.mock import patch, MagicMock + +import pytest + +from matterhook.incoming import Webhook, InvalidPayload, HTTPError + + +@pytest.fixture +def webhook(): + return Webhook("https://mattermost.example.com", "abc123") + + +@pytest.fixture +def full_webhook(): + return Webhook( + "https://mattermost.example.com", + "abc123", + channel="town-square", + icon_url="https://example.com/icon.png", + username="bot", + attachments=[{"fallback": "test"}], + ) + + +class TestWebhookInit: + def test_minimal(self, webhook): + assert webhook.url == "https://mattermost.example.com" + assert webhook.api_key == "abc123" + assert webhook.channel is None + assert webhook.icon_url is None + assert webhook.username is None + assert webhook.attachments is None + + def test_full(self, full_webhook): + assert full_webhook.channel == "town-square" + assert full_webhook.icon_url == "https://example.com/icon.png" + assert full_webhook.username == "bot" + assert full_webhook.attachments == [{"fallback": "test"}] + + +class TestIncomingHookUrl: + def test_formats_url(self, webhook): + assert ( + webhook.incoming_hook_url + == "https://mattermost.example.com/hooks/abc123" + ) + + +class TestSend: + @patch("matterhook.incoming.requests.post") + def test_send_simple_message(self, mock_post, webhook): + mock_post.return_value = MagicMock(status_code=200) + webhook.send("hello") + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={"text": "hello"}, + ) + + @patch("matterhook.incoming.requests.post") + def test_send_with_all_params(self, mock_post, webhook): + mock_post.return_value = MagicMock(status_code=200) + webhook.send( + "hello", + channel="general", + icon_url="https://icon.png", + username="mybot", + attachments=[{"fallback": "fb"}], + ) + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={ + "text": "hello", + "channel": "general", + "icon_url": "https://icon.png", + "username": "mybot", + "attachments": [{"fallback": "fb"}], + }, + ) + + @patch("matterhook.incoming.requests.post") + def test_send_uses_defaults(self, mock_post, full_webhook): + mock_post.return_value = MagicMock(status_code=200) + full_webhook.send("hello") + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={ + "text": "hello", + "channel": "town-square", + "icon_url": "https://example.com/icon.png", + "username": "bot", + "attachments": [{"fallback": "test"}], + }, + ) + + @patch("matterhook.incoming.requests.post") + def test_send_params_override_defaults(self, mock_post, full_webhook): + mock_post.return_value = MagicMock(status_code=200) + full_webhook.send( + "hello", + channel="off-topic", + icon_url="https://other.png", + username="other", + attachments=[{"fallback": "other"}], + ) + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={ + "text": "hello", + "channel": "off-topic", + "icon_url": "https://other.png", + "username": "other", + "attachments": [{"fallback": "other"}], + }, + ) + + @patch("matterhook.incoming.requests.post") + def test_send_no_message(self, mock_post, webhook): + mock_post.return_value = MagicMock(status_code=200) + webhook.send() + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={"text": None}, + ) + + @patch("matterhook.incoming.requests.post") + def test_send_raises_http_error(self, mock_post, webhook): + mock_post.return_value = MagicMock( + status_code=500, text="Internal Server Error" + ) + with pytest.raises(HTTPError, match="Internal Server Error"): + webhook.send("hello") + + @patch("matterhook.incoming.requests.post") + def test_send_raises_http_error_404(self, mock_post, webhook): + mock_post.return_value = MagicMock( + status_code=404, text="Not Found" + ) + with pytest.raises(HTTPError, match="Not Found"): + webhook.send("hello") + + +class TestSetItem: + @patch("matterhook.incoming.requests.post") + def test_setitem_string_payload(self, mock_post, webhook): + mock_post.return_value = MagicMock(status_code=200) + webhook["general"] = "hello" + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={"text": "hello"}, + ) + + @patch("matterhook.incoming.requests.post") + def test_setitem_dict_payload(self, mock_post, webhook): + mock_post.return_value = MagicMock(status_code=200) + webhook["general"] = {"text": "hello", "username": "mybot"} + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={"text": "hello", "username": "mybot"}, + ) + + @patch("matterhook.incoming.requests.post") + def test_setitem_dict_with_icon_url(self, mock_post, webhook): + mock_post.return_value = MagicMock(status_code=200) + webhook["general"] = { + "text": "hello", + "icon_url": "https://icon.png", + } + mock_post.assert_called_once_with( + "https://mattermost.example.com/hooks/abc123", + json={"text": "hello", "icon_url": "https://icon.png"}, + ) + + def test_setitem_dict_missing_text(self, webhook): + with pytest.raises(InvalidPayload, match='missing "text" key'): + webhook["general"] = {"username": "bot"} + + +class TestExceptions: + def test_invalid_payload_is_exception(self): + assert issubclass(InvalidPayload, Exception) + + def test_http_error_is_exception(self): + assert issubclass(HTTPError, Exception) + + def test_invalid_payload_message(self): + err = InvalidPayload("test error") + assert str(err) == "test error" + + def test_http_error_message(self): + err = HTTPError("server error") + assert str(err) == "server error" diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..1755fe5 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,21 @@ +import matterhook +from matterhook import Webhook, Attachment +from matterhook.incoming import Webhook as IncomingWebhook +from matterhook.attachments import Attachment as AttachmentsAttachment + + +class TestModuleExports: + def test_webhook_exported(self): + assert Webhook is IncomingWebhook + + def test_attachment_exported(self): + assert Attachment is AttachmentsAttachment + + def test_all_contains_webhook(self): + assert "Webhook" in matterhook.__all__ + + def test_all_contains_attachment(self): + assert "Attachment" in matterhook.__all__ + + def test_all_length(self): + assert len(matterhook.__all__) == 2 From ccfcdb6074e6851f3791fa51dff5815cc55632d5 Mon Sep 17 00:00:00 2001 From: Nicolas Ledez <247138+nledez@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:30:31 +0100 Subject: [PATCH 5/5] ci: add local test script and update Python version support to 3.8-3.14 Add run_tests.sh using uv to run tests across Python 3.8 to 3.14. Drop Python 3.5-3.7 from CI matrix and add 3.10-3.14 to setup.py classifiers. --- .github/workflows/ci.yml | 6 ------ run_tests.sh | 25 +++++++++++++++++++++++++ setup.py | 5 +++++ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100755 run_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feaa50e..52c4941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,6 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.5" - os: ubuntu-20.04 - - python-version: "3.6" - os: ubuntu-20.04 - - python-version: "3.7" - os: ubuntu-latest - python-version: "3.8" os: ubuntu-latest - python-version: "3.9" diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..743abdf --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +VERSIONS="3.8 3.9 3.10 3.11 3.12 3.13 3.14" +FAILED="" + +for v in $VERSIONS; do + echo "=== Python $v ===" + if uv run --python "$v" --with pytest,pytest-cov,requests \ + pytest tests/ --cov=matterhook --cov-report=term-missing; then + echo "=== Python $v: OK ===" + else + echo "=== Python $v: FAILED ===" + FAILED="$FAILED $v" + fi + echo +done + +if [ -n "$FAILED" ]; then + echo "FAILED versions:$FAILED" + exit 1 +else + echo "All versions passed." +fi diff --git a/setup.py b/setup.py index 8241c6e..0890e67 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,11 @@ def read(fname: str): "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ], description="Interact with Mattermost incoming webhooks easily.",