diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..52c4941 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +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.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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fed528d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" 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 776524c..0890e67 100644 --- a/setup.py +++ b/setup.py @@ -3,11 +3,8 @@ """ import os -import sys -from pkg_resources import get_distribution, parse_version from setuptools import find_packages, setup -from setuptools.command.test import test as TestCommand def read(fname: str): @@ -25,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 get_distribution("setuptools").parsed_version < parse_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=[ @@ -60,9 +34,13 @@ def run_tests(self): "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", ], - cmdclass={"test": PyTest}, description="Interact with Mattermost incoming webhooks easily.", download_url="https://github.com/numberly/matterhook/tags", include_package_data=True, @@ -72,8 +50,7 @@ def run_tests(self): name="matterhook", packages=find_packages(), platforms="any", - tests_require=["pytest"], url="https://github.com/numberly/matterhook", - version="0.2", + version="0.3", zip_safe=True, ) 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