diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 475b8618..18e6d4d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,10 +15,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ['3.9', '3.10', '3.11'] + python: ['3.10', '3.11'] steps: - - uses: compas-dev/compas-actions.build@v3 + - uses: compas-dev/compas-actions.build@v4 with: python: ${{ matrix.python }} invoke_lint: true @@ -33,16 +33,20 @@ jobs: steps: - uses: actions/checkout@v2 - name: Install dependencies + shell: cmd run: | curl -o compas.tar.gz -LJO https://pypi.debian.net/compas/latest curl -o ironpython-pytest.tar.gz -LJO https://pypi.debian.net/ironpython-pytest/latest choco install ironpython --version=2.7.8.1 ipy -X:Frames -m ensurepip - ipy -X:Frames -m pip install --no-deps compas.tar.gz ipy -X:Frames -m pip install --no-deps ironpython-pytest.tar.gz + + rem untar and rename, these cannot be installed using ironpip because they not longer have a setup.py + tar -xf compas.tar.gz && for /d %%i in (compas-*) do ren "%%i" compas + - name: Run tests env: - IRONPYTHONPATH: ./src + IRONPYTHONPATH: ./src;./compas/src run: | ipy -m pytest tests/unit @@ -54,7 +58,7 @@ jobs: run: | docker run -d --name nanomq -p 1883:1883 -p 8083:8083 -p 8883:8883 emqx/nanomq:latest docker ps -a - - uses: compas-dev/compas-actions.build@v3 + - uses: compas-dev/compas-actions.build@v4 with: python: '3.11' invoke_lint: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cb6a5cd..3e15e9b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ['3.9', '3.10', '3.11'] + python: ['3.10', '3.11'] steps: - - uses: compas-dev/compas-actions.build@v3 + - uses: compas-dev/compas-actions.build@v4 with: python: ${{ matrix.python }} invoke_lint: true @@ -31,7 +31,7 @@ jobs: run: | docker run -d --name nanomq -p 1883:1883 -p 8083:8083 -p 8883:8883 emqx/nanomq:latest docker ps -a - - uses: compas-dev/compas-actions.build@v3 + - uses: compas-dev/compas-actions.build@v4 with: python: '3.11' invoke_lint: false @@ -100,7 +100,6 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install wheel - python -m pip install --no-cache-dir -r requirements-dev.txt - uses: NuGet/setup-nuget@v1.0.5 - name: Install dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd84398..98a18f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added support for MQTT-PAHO 2.0 versioned callbacks. + ### Changed +* Updated dependency on `paho-mqtt` to support `>=1, <3` to include version `2.x` with backward compatibility. + ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b074b31..bdb06dba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ In short, this is how that works. 3. Install development dependencies: ```bash - pip install -r requirements-dev.txt + pip install -e .[dev] ``` 4. Make sure all tests pass: diff --git a/MANIFEST.in b/MANIFEST.in index aa3c2a5a..650f9db9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -13,6 +13,8 @@ include AUTHORS.md include CHANGELOG.md include requirements.txt +recursive-include examples *.py + exclude compas_eve.jpg exclude requirements-dev.txt exclude pytest.ini .bumpversion.cfg .editorconfig diff --git a/examples/mqtt_compatibility_demo.py b/examples/mqtt_compatibility_demo.py new file mode 100644 index 00000000..46f227fc --- /dev/null +++ b/examples/mqtt_compatibility_demo.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +""" +Demonstration script to show MQTT-PAHO 2.0 compatibility. + +This script demonstrates that the MqttTransport can work with both +paho-mqtt 1.x and 2.x versions automatically. +""" + +import sys +import os + +# Add the src directory to the path to import compas_eve +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +def main(): + print("COMPAS Eve MQTT-PAHO 2.0 Compatibility Demonstration") + print("=" * 55) + + try: + # Import and check version compatibility + from compas_eve.mqtt.mqtt_paho import PAHO_MQTT_V2_AVAILABLE + import paho.mqtt + + print(f"paho-mqtt version: {paho.mqtt.__version__}") + print(f"MQTT-PAHO 2.0 support available: {PAHO_MQTT_V2_AVAILABLE}") + print() + + # Try to create transport (will fail due to network but shows client creation works) + try: + from compas_eve.mqtt.mqtt_paho import MqttTransport + print("Attempting to create MqttTransport (will fail due to no broker)...") + transport = MqttTransport('nonexistent-broker.local') + print("✓ Transport created successfully") + except Exception as e: + if "No address associated with hostname" in str(e) or "gaierror" in str(e): + print("✓ Client creation successful (expected network error)") + else: + print(f"❌ Unexpected error: {e}") + raise + + print() + print("Compatibility verification:") + if PAHO_MQTT_V2_AVAILABLE: + from paho.mqtt.enums import CallbackAPIVersion + print(f"✓ Using MQTT-PAHO 2.0 with CallbackAPIVersion.VERSION1") + print(f"✓ CallbackAPIVersion available: {hasattr(CallbackAPIVersion, 'VERSION1')}") + else: + print("✓ Using MQTT-PAHO 1.x legacy mode") + print("✓ No CallbackAPIVersion required") + + print() + print("🎉 All compatibility checks passed!") + + except ImportError as e: + print(f"❌ Import error: {e}") + return 1 + except Exception as e: + print(f"❌ Unexpected error: {e}") + return 1 + + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c9d1c1e1..2e3d9172 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ compas>=1.17.6 -paho-mqtt >=1, <2 +paho-mqtt >=1, <3 diff --git a/setup.py b/setup.py index 4d7cfeec..a1dad09e 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,17 @@ def read(*names, **kwargs): long_description = read("README.md") requirements = read("requirements.txt").split("\n") -optional_requirements = {} + +# Read dev requirements for optional dependencies +dev_requirements = [ + line.strip() + for line in read("requirements-dev.txt").split("\n") + if line.strip() and not line.strip().startswith("#") and line.strip() != "-e ." +] + +optional_requirements = { + "dev": dev_requirements, +} setup( name="compas_eve", diff --git a/src/compas_eve/mqtt/mqtt_paho.py b/src/compas_eve/mqtt/mqtt_paho.py index 6ded69ed..d9826a8c 100644 --- a/src/compas_eve/mqtt/mqtt_paho.py +++ b/src/compas_eve/mqtt/mqtt_paho.py @@ -2,6 +2,14 @@ from ..event_emitter import EventEmitterMixin import paho.mqtt.client as mqtt +import uuid + +try: + from paho.mqtt.enums import CallbackAPIVersion + + PAHO_MQTT_V2_AVAILABLE = True +except ImportError: + PAHO_MQTT_V2_AVAILABLE = False class MqttTransport(Transport, EventEmitterMixin): @@ -14,15 +22,23 @@ class MqttTransport(Transport, EventEmitterMixin): you are running a local broker on your machine. port : int MQTT broker port, defaults to ``1883``. + client_id : str, optional + Client ID for the MQTT connection. If not provided, a unique ID will be generated. """ - def __init__(self, host, port=1883, *args, **kwargs): + def __init__(self, host, port=1883, client_id=None, *args, **kwargs): super(MqttTransport, self).__init__(*args, **kwargs) self.host = host self.port = port self._is_connected = False self._local_callbacks = {} - self.client = mqtt.Client() # todo: generate client_id + # Generate client ID if not provided + if client_id is None: + client_id = "compas_eve_{}".format(uuid.uuid4().hex[:8]) + if PAHO_MQTT_V2_AVAILABLE: + self.client = mqtt.Client(client_id=client_id, callback_api_version=CallbackAPIVersion.VERSION1) + else: + self.client = mqtt.Client(client_id=client_id) self.client.on_connect = self._on_connect self.client.connect(self.host, self.port) self.client.loop_start() diff --git a/tests/integration/test_mqtt.py b/tests/integration/test_mqtt.py index 69acc17b..05c9fa9e 100644 --- a/tests/integration/test_mqtt.py +++ b/tests/integration/test_mqtt.py @@ -9,7 +9,16 @@ from compas_eve import set_default_transport from compas_eve.mqtt import MqttTransport -HOST = "broker.hivemq.com" +HOST = "localhost" + + +def test_client_id(): + custom_client_id = "my_custom_client_id" + transport = MqttTransport(HOST, client_id=custom_client_id) + assert transport.client._client_id == custom_client_id.encode("utf-8") + + transport = MqttTransport(HOST, client_id=None) + assert transport.client._client_id.startswith("compas_eve_".encode("utf-8")) def test_default_transport_publishing(): diff --git a/tests/unit/test_mqtt_paho_compatibility.py b/tests/unit/test_mqtt_paho_compatibility.py new file mode 100644 index 00000000..c9b18399 --- /dev/null +++ b/tests/unit/test_mqtt_paho_compatibility.py @@ -0,0 +1,47 @@ +import sys + +if sys.platform != "cli": + import pytest + from unittest.mock import Mock, patch + from compas_eve.mqtt.mqtt_paho import MqttTransport, PAHO_MQTT_V2_AVAILABLE + + def test_paho_mqtt_v1_compatibility(): + with patch("compas_eve.mqtt.mqtt_paho.PAHO_MQTT_V2_AVAILABLE", False), patch( + "paho.mqtt.client.Client" + ) as mock_client_class: + + mock_client = Mock() + mock_client_class.return_value = mock_client + + # This should work as if paho-mqtt 1.x is installed + transport = MqttTransport("localhost") + + # Should have called mqtt.Client() with client_id parameter only (no callback_api_version) + mock_client_class.assert_called_once() + call_args = mock_client_class.call_args + assert "client_id" in call_args.kwargs + assert call_args.kwargs["client_id"].startswith("compas_eve_") + assert "callback_api_version" not in call_args.kwargs + assert transport.client == mock_client + + def test_paho_mqtt_v2_compatibility(): + if not PAHO_MQTT_V2_AVAILABLE: + pytest.skip("paho-mqtt 2.x not available in this environment") + + with patch("paho.mqtt.client.Client") as mock_client_class: + from paho.mqtt.enums import CallbackAPIVersion + + mock_client = Mock() + mock_client_class.return_value = mock_client + + # This should work as if paho-mqtt 2.x is installed + transport = MqttTransport("localhost") + + # Should have called mqtt.Client() with both client_id and callback_api_version parameters + mock_client_class.assert_called_once() + call_args = mock_client_class.call_args + assert "client_id" in call_args.kwargs + assert call_args.kwargs["client_id"].startswith("compas_eve_") + assert "callback_api_version" in call_args.kwargs + assert call_args.kwargs["callback_api_version"] == CallbackAPIVersion.VERSION1 + assert transport.client == mock_client