diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index cf64afe..8e10c76 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -1,38 +1,18 @@ -name: Python application +name: CI on: push: - branches: [ "master" ] + branches: [master] pull_request: - branches: [ "master" ] + branches: [master] permissions: contents: read -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10"] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true - steps: - - uses: actions/checkout@v3 - - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 - with: - args: 'check' - - uses: chartboost/ruff-action@v1 - with: - args: 'format' - - name: Set up Python ${{matrix.python-version}} - uses: actions/setup-python@v3 - with: - python-version: ${{matrix.python-version}} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements_tests.txt ]; then pip install -r requirements_tests.txt; fi - - name: Test with pytest - run: | - pytest +jobs: + tests: + uses: ./.github/workflows/tests.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3804b5e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Publish to PyPI + +on: + push: + tags: ['v[0-9]*.[0-9]*.[0-9]*'] # bumpversion tags, e.g. v2.6.0 + +permissions: + contents: read + +jobs: + tests: + uses: ./.github/workflows/tests.yml + + build: + needs: tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Build sdist + wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Check artifacts + run: | + python -m pip install twine + twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + id-token: write # required for OIDC Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@v1.14.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..eef4f12 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: Tests + +on: + workflow_call: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + with: + version: 0.3.0 + args: check + - uses: astral-sh/ruff-action@v3 + with: + version: 0.3.0 + args: format --check + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.10', '3.11'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements_tests.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements_tests.txt + - name: Test with pytest + run: pytest diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7065e94..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: python -python: -- 3.8 -- 3.7 -install: pip install -U tox-travis -script: tox -deploy: - provider: pypi - distributions: sdist bdist_wheel - user: __token__ - password: - secure: xu5unRZkE5IXUH4qzR1j8H4nsNw9sJ0mTuEoBS8Zy1WK9K0sZVzY3sgEY0+hOtI+Vv4hMs6MSr0YgTuiNu4iljeHW+mjPEOrX7Ho8d6g0X49bMOzYQEpOvIilcdMcwRR95SeVBrsmLSr0NF8sDFliWTtj8XgpjNEYDv+2uT2i67SEnH33PzMFIfCoOSf0ue91caldYByE5BfQB90WvHhCvND+BWWSVFPWgK8QZHSXRevkIamVrNLRoO+BKUn+ypphULvAzC1jfFBLVVgPODwiJdk8LB6RCU38rzyIC02/XY02IUIOWmwpgfsjoFXg5oZwZkiOq21KEpmDPGSCUy5fT5z5JZCxx7MHn1nghDVx3s8M+lx7a5JdRU4wdvV3LN76EXq/SEpY3bwg/pRBlb8gPooOwDALNoq11jvOtD6p88waAJogyTj7ga5wcReFJaPqJQuHtCUBpvbskNMoNBaPA5vNXS2puBOb8ASiJWJZQYC7+qpwVEXWk/8kEoqoJQgwO673aWw4YpDaPIgIwf0KHUC87cthtk8B/Nzp0C+O32+JI6KA33C0lQeRgXQ6iQDUSju8iMBmW3R/ffJacKgzY25bGcsznucGeGH7oc4ibvCmDlzlCre9t4ofLcpPgZ6qoJCjYe/pMTnL/spOlo2zodXuD13Z6kudgn6SuhUSnQ= - on: - tags: true - repo: blebox/blebox_uniapi - python: 3.8 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ac36cfc..a786f97 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -122,7 +122,11 @@ Make sure all your changes are committed (including an entry in HISTORY.rst). Then run:: $ bump2version patch # possible: major / minor / patch -$ git push -$ git push --tags - -Travis will then deploy to PyPI if tests pass. +$ git push --follow-tags + +bump2version bumps the version in setup.py, blebox_uniapi/__init__.py and +setup.cfg, commits the change and creates a matching git tag (e.g. v2.6.0). +Pushing the tag triggers the "Publish to PyPI" GitHub Actions workflow +(.github/workflows/release.yml), which first runs lint + tests, then builds and +uploads to PyPI via Trusted Publishing (OIDC) from the blebox/blebox_uniapi repo. +The publish step only runs if the tests pass. diff --git a/MANIFEST.in b/MANIFEST.in index be4afa9..70a5ec0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -include AUTHORS.rst include CONTRIBUTING.rst include HISTORY.rst include LICENSE diff --git a/blebox_uniapi/error.py b/blebox_uniapi/error.py index 08f3aa2..dd257d7 100644 --- a/blebox_uniapi/error.py +++ b/blebox_uniapi/error.py @@ -39,7 +39,6 @@ class UnsupportedBoxVersion(BoxError): pass - class BadFieldExceedsMax(BoxError): def __init__(self, dev_name: str, field: str, value: int, max_value: int): self._dev_name = dev_name @@ -103,7 +102,6 @@ def __str__(self) -> str: return f"{self._dev_name}.{self._field} is {self._value} which is not a rgbw string" - # misc errors diff --git a/setup.py b/setup.py index d52fcd3..b3ec48d 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ "Natural Language :: English", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], description="Python API for accessing BleBox smart home devices", install_requires=requirements, diff --git a/tests/conftest.py b/tests/conftest.py index 16a10fd..d12f71d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,7 +95,11 @@ def __call__(self, url, **kwargs): response = _json.dumps(data).encode("utf-8") status = 200 return AiohttpClientMockResponse( - "GET", url, status, response, headers={"content-type": "application/json"} + "GET", + url, + status, + response, + headers={"content-type": "application/json"}, ) mock.get = AsyncMock(side_effect=EffectWhenGet(mock)) @@ -126,7 +130,11 @@ def __call__(self, url, **kwargs): response = _json.dumps(data).encode("utf-8") status = 200 return AiohttpClientMockResponse( - "POST", url, status, response, headers={"content-type": "application/json"} + "POST", + url, + status, + response, + headers={"content-type": "application/json"}, ) mock.post = AsyncMock(side_effect=EffectWhenPost(mock)) diff --git a/tests/test_box.py b/tests/test_box.py index 69e6f6e..13ec341 100644 --- a/tests/test_box.py +++ b/tests/test_box.py @@ -188,7 +188,9 @@ async def test_async_ota_update_calls_session(mock_session, sample_data, config) mock_session.async_api_get.assert_called_once_with("/api/ota/update") -async def test_async_ota_check_updates_firmware_versions(mock_session, sample_data, config): +async def test_async_ota_check_updates_firmware_versions( + mock_session, sample_data, config +): box = Box(mock_session, sample_data, config, None) mock_session.async_api_get_ota = AsyncMock(return_value=None) mock_session.async_api_get = AsyncMock( @@ -211,7 +213,9 @@ async def test_async_ota_check_unwraps_device_key(mock_session, sample_data, con assert box.available_firmware_version == "3.0" -async def test_async_ota_check_raises_on_none_info_response(mock_session, sample_data, config): +async def test_async_ota_check_raises_on_none_info_response( + mock_session, sample_data, config +): box = Box(mock_session, sample_data, config, None) mock_session.async_api_get_ota = AsyncMock(return_value=None) mock_session.async_api_get = AsyncMock(return_value=None) @@ -220,7 +224,9 @@ async def test_async_ota_check_raises_on_none_info_response(mock_session, sample await box.async_ota_check() -async def test_async_ota_check_returns_silently_when_no_available_fv(mock_session, sample_data, config): +async def test_async_ota_check_returns_silently_when_no_available_fv( + mock_session, sample_data, config +): box = Box(mock_session, sample_data, config, None) mock_session.async_api_get_ota = AsyncMock(return_value=None) mock_session.async_api_get = AsyncMock(return_value={"fv": "1.0"}) diff --git a/tests/test_session.py b/tests/test_session.py index a21d8ad..da97fd9 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -218,11 +218,15 @@ async def test_session_api_get_non_json_returns_none(logger, client): assert result is None -async def test_session_api_get_unicode_decode_error_raises_connection_error(logger, client): +async def test_session_api_get_unicode_decode_error_raises_connection_error( + logger, client +): response = Mock(spec_set=aiohttp.ClientResponse) response.status = 200 response.content_type = "application/json" - response.json = AsyncMock(side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid")) + response.json = AsyncMock( + side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid") + ) client.get = AsyncMock(return_value=response) api_session = Session("127.0.0.4", "88", 2, client, None, logger) with pytest.raises(error.ConnectionError, match="Invalid response encoding"): diff --git a/tests/test_update.py b/tests/test_update.py index 53c7fcf..7914cee 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -44,7 +44,9 @@ async def test_calls_ota_check_on_product(self, update, mock_product): await update.async_update() mock_product.async_ota_check.assert_called_once() - async def test_propagates_connection_error_from_ota_check(self, update, mock_product): + async def test_propagates_connection_error_from_ota_check( + self, update, mock_product + ): mock_product.async_ota_check = AsyncMock( side_effect=error.ConnectionError("connection refused") ) @@ -58,7 +60,9 @@ async def test_calls_ota_update_on_product(self, update, mock_product): await update.async_install() mock_product.async_ota_update.assert_called_once() - async def test_propagates_connection_error_from_ota_update(self, update, mock_product): + async def test_propagates_connection_error_from_ota_update( + self, update, mock_product + ): mock_product.async_ota_update = AsyncMock( side_effect=error.ConnectionError("connection refused") )