diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ec8bf98 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: weekly diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..16cccf8 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,26 @@ +name-template: "$RESOLVED_VERSION" +tag-template: "$RESOLVED_VERSION" +change-template: "- #$NUMBER - $TITLE (@$AUTHOR)" +categories: + - title: "⚠ Breaking Changes" + labels: + - "breaking-change" + - title: "⬆️ Dependencies" + collapse-after: 1 + labels: + - "dependencies" + - "ci" +template: | + ## What’s Changed + + $CHANGES +version-resolver: + major: + labels: + - "breaking-change" + - "refactor" + minor: + labels: + - "new-feature" + - "enhancement" + default: patch diff --git a/.github/workflows/auto_approve_dependabot.yml b/.github/workflows/auto_approve_dependabot.yml new file mode 100644 index 0000000..adfed55 --- /dev/null +++ b/.github/workflows/auto_approve_dependabot.yml @@ -0,0 +1,12 @@ +name: Auto approve Dependabot PR's + +on: pull_request_target + +jobs: + auto-approve: + runs-on: ubuntu-latest + permissions: + pull-requests: write + if: github.actor == 'dependabot[bot]' + steps: + - uses: hmarr/auto-approve-action@v4 diff --git a/.github/workflows/pr-labels.yaml b/.github/workflows/pr-labels.yaml new file mode 100644 index 0000000..db9a619 --- /dev/null +++ b/.github/workflows/pr-labels.yaml @@ -0,0 +1,23 @@ +--- +name: PR Labels + +# yamllint disable-line rule:truthy +on: + pull_request: + types: + - synchronize + - labeled + - unlabeled + branches: + - main + +jobs: + pr_labels: + name: Verify + runs-on: ubuntu-latest + steps: + - name: 🏷 Verify PR has a valid label + uses: ludeeus/action-require-labels@1.1.0 + with: + labels: >- + breaking-change, bugfix, refactor, new-feature, maintenance, ci, dependencies diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..68dfb92 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,45 @@ +name: Publish releases to PyPI + +on: + release: + types: [published, prereleased] + +jobs: + build-and-publish-pypi: + name: Builds and publishes releases to PyPI + runs-on: ubuntu-latest + outputs: + version: ${{ steps.vars.outputs.tag }} + steps: + - uses: actions/checkout@v4 + - name: Get tag + id: vars + run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + - name: Set up Python 3.10 + uses: actions/setup-python@v5.1.0 + with: + python-version: "3.10" + - name: Install build + run: >- + pip install build tomli tomli-w + - name: Set Python project version from tag + shell: python + run: |- + import tomli + import tomli_w + + with open("pyproject.toml", "rb") as f: + pyproject = tomli.load(f) + + pyproject["project"]["version"] = "${{ steps.vars.outputs.tag }}" + + with open("pyproject.toml", "wb") as f: + tomli_w.dump(pyproject, f) + - name: Build + run: >- + python3 -m build + - name: Publish release to PyPI + uses: pypa/gh-action-pypi-publish@v1.9.0 + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..18e5267 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,15 @@ +name: Release Drafter + +on: + push: + branches: + - main + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v6.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b172200 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +# This workflow will install Python dependencies, run tests and lint +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Test with Pre-commit + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5.1.0 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip build setuptools + pip install . .[test] + - name: Lint/test with pre-commit + run: pre-commit run --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8db81e..e05b701 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,21 +80,17 @@ repos: always_run: true args: - --branch=main - - id: pylint - name: 🌟 Starring code with pylint - language: system - types: [python] - entry: scripts/run-in-env.sh pylint - id: trailing-whitespace name: ✄ Trim Trailing Whitespace language: system types: [text] entry: scripts/run-in-env.sh trailing-whitespace-fixer stages: [commit, push, manual] - - id: mypy - name: mypy - entry: scripts/run-in-env.sh mypy - language: script - types: [python] - require_serial: true - files: ^(music_assistant|pylint)/.+\.py$ + # TODO: enable mypy (ans solve the issues found) + # - id: mypy + # name: mypy + # entry: scripts/run-in-env.sh mypy + # language: script + # types: [python] + # require_serial: true + # files: ^(aiosonos|pylint)/.+\.py$ diff --git a/aiosonos/api/namespaces/playback_session.py b/aiosonos/api/namespaces/playback_session.py index d743eb2..4b7d2be 100644 --- a/aiosonos/api/namespaces/playback_session.py +++ b/aiosonos/api/namespaces/playback_session.py @@ -2,12 +2,15 @@ from __future__ import annotations -from collections.abc import Container +from typing import TYPE_CHECKING from aiosonos.api.models import SessionStatus, Track from ._base import SonosNameSpace, SubscribeCallbackType, UnsubscribeCallbackType +if TYPE_CHECKING: + from collections.abc import Container + class PlaybackSessionNameSpace(SonosNameSpace): """PlaybackSession Namespace handlers.""" diff --git a/aiosonos/api/websockets.py b/aiosonos/api/websockets.py index b1f34e8..ee5d92b 100644 --- a/aiosonos/api/websockets.py +++ b/aiosonos/api/websockets.py @@ -175,7 +175,8 @@ async def disconnect(self) -> None: self._ws_client = None def _handle_incoming_message( - self, raw: tuple[ResultMessage, dict[str, Any]] + self, + raw: tuple[ResultMessage, dict[str, Any]], ) -> None: """ Handle incoming message. @@ -191,7 +192,7 @@ def _handle_incoming_message( return if future := self._result_futures.get(msg["cmdId"]): future.set_exception( - FailedCommand(msg_data["errorCode"], msg_data.get("reason")) + FailedCommand(msg_data["errorCode"], msg_data.get("reason")), ) return @@ -255,13 +256,16 @@ async def receive_message_or_raise(self) -> tuple[ResultMessage, dict[str, Any]] if self.logger.isEnabledFor(LOG_LEVEL_VERBOSE): self.logger.log( - LOG_LEVEL_VERBOSE, "Received message:\n%s\n", pprint.pformat(ws_msg) + LOG_LEVEL_VERBOSE, + "Received message:\n%s\n", + pprint.pformat(ws_msg), ) return msg async def _send_message( - self, message: tuple[CommandMessage, dict[str, Any]] + self, + message: tuple[CommandMessage, dict[str, Any]], ) -> None: """ Send a message to the server. @@ -273,7 +277,9 @@ async def _send_message( if self.logger.isEnabledFor(LOG_LEVEL_VERBOSE): self.logger.log( - LOG_LEVEL_VERBOSE, "Publishing message:\n%s\n", pprint.pformat(message) + LOG_LEVEL_VERBOSE, + "Publishing message:\n%s\n", + pprint.pformat(message), ) assert self._ws_client diff --git a/aiosonos/client.py b/aiosonos/client.py index 86c1085..b09147f 100644 --- a/aiosonos/client.py +++ b/aiosonos/client.py @@ -88,7 +88,7 @@ def subscribe( Returns function to remove the listener. - Paramaters: + Parameters: - cb_func: callback function or coroutine - event_filter: Optionally only listen for these events - object_id_filter: Optionally only listen for these id's (player id, etc.) @@ -123,7 +123,8 @@ async def connect(self) -> None: self._household_id = discovery_info["householdId"] # Connect to the local websocket API self.api = SonosLocalWebSocketsApi( - discovery_info["websocketUrl"], self._aiohttp_session + discovery_info["websocketUrl"], + self._aiohttp_session, ) # NOTE: connect will raise when connecting failed await self.api.connect() @@ -137,7 +138,8 @@ async def start_listening(self, init_ready: asyncio.Event | None = None) -> None listen_task = asyncio.create_task(self.api.start_listening()) # fetch all initial data and setup subscriptions groups_data = await self.api.groups.get_groups( - self.household_id, include_device_info=True + self.household_id, + include_device_info=True, ) for group_data in groups_data["groups"]: await self._setup_group(group_data) @@ -146,9 +148,7 @@ async def start_listening(self, init_ready: asyncio.Event | None = None) -> None # so we ignore all other player objects. For each Sonos player, # an individual api connection should be set-up to manage the player. # The Cloud API however is able to manage all players in the household. - player_data = next( - x for x in groups_data["players"] if x["id"] == self._player_id - ) + player_data = next(x for x in groups_data["players"] if x["id"] == self._player_id) self._player = player = SonosPlayer(self, player_data) await player.async_init() # setup global groups/player subscription @@ -178,7 +178,9 @@ async def create_group( the new group will not contain any audio. """ await self.api.groups.create_group( - self._household_id, player_ids, music_context_group_id + self._household_id, + player_ids, + music_context_group_id, ) def _handle_groups_event(self, groups_data: GroupsData) -> None: @@ -192,9 +194,7 @@ def _handle_groups_event(self, groups_data: GroupsData) -> None: # a new group was added self._loop.create_task(self._setup_group(group_data)) # check if any groups are removed - removed_groups = set(self._groups.keys()) - { - g["id"] for g in groups_data["groups"] - } + removed_groups = set(self._groups.keys()) - {g["id"] for g in groups_data["groups"]} for group_id in removed_groups: group = self._groups.pop(group_id) self.signal_event( diff --git a/aiosonos/group.py b/aiosonos/group.py index f9ada2a..620e78e 100644 --- a/aiosonos/group.py +++ b/aiosonos/group.py @@ -15,17 +15,12 @@ from typing import TYPE_CHECKING +from aiosonos.api.models import PlayBackState from aiosonos.const import EventType, GroupEvent from aiosonos.exceptions import FailedCommand if TYPE_CHECKING: - from aiosonos.api.models import ( - Container, - MetadataStatus, - PlayBackState, - SessionStatus, - Track, - ) + from aiosonos.api.models import Container, MetadataStatus, SessionStatus, Track from aiosonos.api.models import GroupVolume as GroupVolumeData from aiosonos.api.models import PlaybackStatus as PlaybackStatusData from aiosonos.api.models import PlayModes as PlayModesData @@ -55,9 +50,7 @@ async def async_init(self) -> None: # grab playback data and setup subscription try: self._volume_data = await self.client.api.group_volume.get_volume(self.id) - self._playback_status_data = ( - await self.client.api.playback.get_playback_status(self.id) - ) + self._playback_status_data = await self.client.api.playback.get_playback_status(self.id) self._playback_actions = PlaybackActions( self._playback_status_data["availablePlaybackActions"], ) @@ -68,10 +61,12 @@ async def async_init(self) -> None: ) ) await self.client.api.playback.subscribe( - self.id, self._handle_playback_status_update + self.id, + self._handle_playback_status_update, ) await self.client.api.group_volume.subscribe( - self.id, self._handle_volume_update + self.id, + self._handle_volume_update, ) await self.client.api.playback_metadata.subscribe( self.id, @@ -115,7 +110,7 @@ def playback_state(self) -> PlayBackState: @property def player_ids(self) -> list[str]: - """Return ths id's of this group's members.""" + """Return the id's of this group's members.""" return self._data["playerIds"] @property diff --git a/pyproject.toml b/pyproject.toml index 0c5f157..d6fd54f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ license = {text = "Apache-2.0"} name = "aiosonos" readme = "README.md" requires-python = ">=3.11" -version = "1.0.0" +version = "0.0.0" [project.optional-dependencies] speedups = [ @@ -27,7 +27,6 @@ test = [ "mypy==1.8.0", "pre-commit==3.6.1", "pre-commit-hooks==4.5.0", - "pylint==3.0.3", "pytest==8.0.0", "pytest-aiohttp==1.0.5", "pytest-cov==4.1.0", @@ -56,7 +55,7 @@ convention = "pep257" [tool.ruff.lint.pylint] -max-args = 10 +max-args = 20 max-branches = 25 max-returns = 15 max-statements = 50 diff --git a/test.py b/test.py deleted file mode 100644 index 61dd72c..0000000 --- a/test.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from enum import StrEnum -from typing import NotRequired, Self, TypedDict - -class PlayBackState(StrEnum): - """Enum with possible playback states.""" - - PLAYBACK_STATE_IDLE = "PLAYBACK_STATE_IDLE" - PLAYBACK_STATE_BUFFERING = "PLAYBACK_STATE_BUFFERING" - PLAYBACK_STATE_PAUSED = "PLAYBACK_STATE_PAUSED" - PLAYBACK_STATE_PLAYING = "PLAYBACK_STATE_PLAYING" - - @classmethod - def _missing_(cls: type, value: str) -> Self: # noqa: ARG003 - """Handle unknown enum member.""" - return "UNKOWN" - - -PlayBackState("sambal") -