pax_global_header00006660000000000000000000000064147340571770014531gustar00rootroot0000000000000052 comment=0729b9f92d7a16bd06c59870012604ccc7978a7c devialet-1.5.7/000077500000000000000000000000001473405717700133405ustar00rootroot00000000000000devialet-1.5.7/.github/000077500000000000000000000000001473405717700147005ustar00rootroot00000000000000devialet-1.5.7/.github/workflows/000077500000000000000000000000001473405717700167355ustar00rootroot00000000000000devialet-1.5.7/.github/workflows/python-publish.yml000066400000000000000000000015131473405717700224450ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.8.11 with: password: ${{ secrets.PYPI_API_TOKEN }} devialet-1.5.7/.gitignore000066400000000000000000000034071473405717700153340ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ devialet-1.5.7/LICENSE000066400000000000000000000020541473405717700143460ustar00rootroot00000000000000MIT License Copyright (c) 2022 fwestenberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. devialet-1.5.7/README.md000066400000000000000000000010751473405717700146220ustar00rootroot00000000000000# Devialet Devialet Python package Please find the documentation Home Assistant documentation [here:](https://www.home-assistant.io/integrations/devialet) **Example:** ```python import asyncio import aiohttp from devialet import DevialetApi async def main(): session = async with aiohttp.ClientSession() as session: client = DevialetApi('192.168.1.10', session) await client.async_update() await client.async_set_volume_level(20) await client.async_media_next_track() await client.async_turn_off() asyncio.run(main()) ```devialet-1.5.7/devialet/000077500000000000000000000000001473405717700151355ustar00rootroot00000000000000devialet-1.5.7/devialet/__init__.py000066400000000000000000000001171473405717700172450ustar00rootroot00000000000000"""The Devialet integration.""" from devialet.devialet_api import DevialetApi devialet-1.5.7/devialet/const.py000066400000000000000000000054431473405717700166430ustar00rootroot00000000000000"""Constants for the Devialet integration.""" import logging from enum import Enum LOGGER = logging.getLogger(__package__) NORMAL_INPUTS = { "Airplay": "airplay2", "Bluetooth": "bluetooth", "Digital left": "digital_left", #Arch only "Digital right": "digital_right", #Arch only "Line": "line", #Arch only "UPnP": "upnp", "Optical": "optical", #Phantom I, Dialog (Mono) "Optical left": "optical_left", #Phantom I (Stereo) "Optical right": "optical_right", #Phantom I (Stereo) "Optical jack": "opticaljack", #Phantom II (Mono) "Optical jack left": "opticaljack_left", #Phantom II (Stereo) "Optical jack right": "opticaljack_right", #Phantom II (Stereo) "Phono": "phono", #Arch only "Raat": "raat", "Spotify Connect": "spotifyconnect", } SPEAKER_POSITIONS = { "FrontLeft": "left", "FrontRight": "right", } AV_TRANSPORT = "urn:schemas-upnp-org:service:AVTransport:2" MEDIA_RENDERER = "urn:schemas-upnp-org:device:MediaRenderer:2" class UrlSuffix(Enum): # Devices commands GET_GENERAL_INFO = "/ipcontrol/v1/devices/current" # Systems commands GET_VOLUME = "/ipcontrol/v1/systems/current/sources/current/soundControl/volume" GET_NIGHT_MODE = "/ipcontrol/v1/systems/current/settings/audio/nightMode" GET_EQUALIZER = "/ipcontrol/v1/systems/current/settings/audio/equalizer" TURN_OFF = "/ipcontrol/v1/systems/current/powerOff" VOLUME_UP = "/ipcontrol/v1/systems/current/sources/current/soundControl/volumeUp" VOLUME_DOWN = ( "/ipcontrol/v1/systems/current/sources/current/soundControl/volumeDown" ) VOLUME_SET = "/ipcontrol/v1/systems/current/sources/current/soundControl/volume" EQUALIZER = "/ipcontrol/v1/systems/current/settings/audio/equalizer" NIGHT_MODE = "/ipcontrol/v1/systems/current/settings/audio/nightMode" SELECT_SOURCE = "/ipcontrol/v1/groups/current/sources/%SOURCE_ID%/playback/play" # Groups commands GET_SOURCES = "/ipcontrol/v1/groups/current/sources" GET_CURRENT_SOURCE = "/ipcontrol/v1/groups/current/sources/current" GET_CURRENT_POSITION = ( "/ipcontrol/v1/groups/current/sources/current/playback/position" ) SEEK = "/ipcontrol/v1/groups/current/sources/current/playback/position" PLAY = "/ipcontrol/v1/groups/current/sources/current/playback/play" PAUSE = "/ipcontrol/v1/groups/current/sources/current/playback/pause" STOP = "/ipcontrol/v1/groups/current/sources/current/playback/pause" PREVIOUS_TRACK = "/ipcontrol/v1/groups/current/sources/current/playback/previous" NEXT_TRACK = "/ipcontrol/v1/groups/current/sources/current/playback/next" MUTE = "/ipcontrol/v1/groups/current/sources/current/playback/mute" UNMUTE = "/ipcontrol/v1/groups/current/sources/current/playback/unmute" def __str__(self): return str(self.value) devialet-1.5.7/devialet/devialet_api.py000066400000000000000000000540451473405717700201450ustar00rootroot00000000000000"""Support for Devialet Phantom speakers1.""" from __future__ import annotations import asyncio import datetime import json import re import aiohttp from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.client_factory import UpnpFactory from async_upnp_client.exceptions import (UpnpActionResponseError, UpnpXmlParseError) from async_upnp_client.profiles.dlna import DmrDevice from async_upnp_client.search import async_search from async_upnp_client.utils import CaseInsensitiveDict from .const import AV_TRANSPORT, LOGGER, NORMAL_INPUTS, MEDIA_RENDERER, SPEAKER_POSITIONS, UrlSuffix UPNP_SEARCH_INTERVAL = 120 class DevialetApi: """Devialet API class.""" def __init__(self, host:str, session:aiohttp.ClientSession): """Initialize the Devialet API.""" self._host = host self._session = session self._general_info = None self._volume = None self._muted = False self._source_state = None self._current_position = 0 self._sources = None self._night_mode = None self._equalizer = None self._source_list = {} self._position_updated_at = 0 self._media_duration = 0 self._device_role = "" self._is_available = False self._upnp_device = None self._dmr_device = None self._last_upnp_search: datetime.datetime = None async def async_update(self) -> bool | None: """Get the latest details from the device.""" if self._general_info is None: self._general_info = await self._async_get_request(UrlSuffix.GET_GENERAL_INFO) # Without general info the device has not been online yet if self._general_info is None: return False self._source_state = await self._async_get_request(UrlSuffix.GET_CURRENT_SOURCE) # The source state call is enough to find out if the device is available (On or Off) if not self._is_available: # Set upnp to none, so discovery will find the new port when it's online again self._upnp_device = None self._dmr_device = None return True if self._sources is None: self._sources = await self._async_get_request(UrlSuffix.GET_SOURCES) self._volume = await self._async_get_request(UrlSuffix.GET_VOLUME) self._night_mode = await self._async_get_request(UrlSuffix.GET_NIGHT_MODE) self._equalizer = await self._async_get_request(UrlSuffix.GET_EQUALIZER) try: self._media_duration = self._source_state["metadata"]["duration"] except (KeyError, TypeError): self._media_duration = None self._current_position = None self._position_updated_at = None if self._media_duration == 0: self._media_duration = None if self._media_duration is not None: position = await self._async_get_request(UrlSuffix.GET_CURRENT_POSITION) try: self._current_position = position["position"] self._position_updated_at = datetime.datetime.now(tz=datetime.timezone.utc).replace(tzinfo=None) except (KeyError, TypeError): self._current_position = None self._position_updated_at = None return True @property def is_available(self) -> bool | None: """Return available.""" return self._is_available @property def upnp_available(self) -> bool | None: """Return available.""" return self._upnp_device is not None @property def device_id(self) -> str | None: """Return the device id.""" try: return self._general_info["deviceId"] except (KeyError, TypeError): return None @property def is_system_leader(self) -> bool | None: """Return the boolean for system leader identification.""" try: return self._general_info["isSystemLeader"] except (KeyError, TypeError): return None @property def serial(self) -> str | None: """Return the serial.""" try: return self._general_info["serial"] except (KeyError, TypeError): return None @property def device_name(self) -> str | None: """Return the device name.""" try: return self._general_info["deviceName"] except (KeyError, TypeError): return None @property def device_role(self) -> str | None: """Return the device role.""" try: return self._general_info["role"] except (KeyError, TypeError): return None @property def model(self) -> str | None: """Return the device model.""" try: return self._general_info["model"] except (KeyError, TypeError): return None @property def version(self) -> str | None: """Return the device version.""" try: return self._general_info["release"]["version"] except (KeyError, TypeError): return None @property def source_state(self) -> any | None: """Return the source state object.""" return self._source_state @property def playing_state(self) -> str | None: """Return the state of the device.""" try: return self._source_state["playingState"] except (KeyError, TypeError): return None @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" try: return self._volume["volume"] * 0.01 except (KeyError, TypeError): return None @property def is_volume_muted(self) -> bool | None: """Return boolean if volume is currently muted.""" try: return self._source_state["muteState"] == "muted" except (KeyError, TypeError): return None @property def dmr_device(self) -> DmrDevice | None: """Return DMR device class.""" return self._dmr_device @property def source_list(self) -> list | None: """Return the list of available input sources.""" if self._sources is None or len(self._source_list) > 0: return sorted(self._source_list) for source in self._sources["sources"]: source_type = source["type"] device_id = source["deviceId"] if self.device_role in SPEAKER_POSITIONS and source_type in ( "optical", "opticaljack", ): # Stereo devices have the role FrontLeft or FrontRight. # Add a suffix to the source to recognize the device. for role, position in SPEAKER_POSITIONS.items(): if (device_id == self.device_id and role == self.device_role) or ( device_id != self.device_id and role != self.device_role ): source_type = source_type + "_" + position for pretty_name, name in NORMAL_INPUTS.items(): if name == source_type: self._source_list[pretty_name] = name return sorted(self._source_list) @property def available_operations(self) -> any | None: """Return the list of available operations for this source.""" try: return self._source_state["availableOperations"] except (KeyError, TypeError): return None @property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" try: return self._source_state["metadata"]["artist"] except (KeyError, TypeError): return None @property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" try: return self._source_state["metadata"]["album"] except (KeyError, TypeError): return None @property def media_title(self) -> str | None: """Return the current media info.""" try: return self._source_state["metadata"]["title"] except (KeyError, TypeError): return None @property def media_image_url(self) -> str | None: """Image url of current playing media, not available for Airplay.""" try: return self._source_state["metadata"]["coverArtUrl"] except (KeyError, TypeError): return None @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._media_duration @property def current_position(self) -> int | None: """Position of current playing media in seconds.""" return self._current_position @property def position_updated_at(self) -> datetime.datetime | None: """When was the position of the current playing media valid.""" return self._position_updated_at @property def source(self) -> str | None: """Return the current input source.""" try: source_id = self._source_state["source"]["sourceId"] device_id = self._source_state["source"]["deviceId"] # Devialet Arch has a different source description. for source in self._sources["sources"]: if source["sourceId"] == source_id and source["deviceId"] == device_id: source_type = source["type"] if ( source_type == "optical" or source_type == "opticaljack" ) and self.device_role in SPEAKER_POSITIONS: for role, position in SPEAKER_POSITIONS.items(): if ( device_id == self.device_id and role == self.device_role ) or ( device_id != self.device_id and role != self.device_role ): source_type = source_type + "_" + position return source_type except (KeyError, TypeError): return None return None @property def night_mode(self) -> bool | None: """Return the current nightmode state.""" try: return self._night_mode["nightMode"] == "on" except (KeyError, TypeError): return None @property def equalizer(self) -> str | None: """Return the current equalizer preset.""" try: if self._equalizer["enabled"]: return self._equalizer["preset"] except (KeyError, TypeError): return None async def async_get_diagnostics(self) -> any | None: """Return the diagnostic data.""" return { "is_available": self._is_available, "general_info": self._general_info, "sources": self._sources, "source_state": self._source_state, "volume": self._volume, "night_mode": self._night_mode, "equalizer": self._equalizer, "source_list": self.source_list, "source": self.source, "upnp_device_type": getattr(self._upnp_device.device_info, 'device_type') if self._upnp_device else "Not available", "upnp_device_url": getattr(self._upnp_device.device_info, 'url') if self._upnp_device else "Not available", } async def async_volume_up(self) -> None: """Volume up media player.""" await self._async_post_request(UrlSuffix.VOLUME_UP) async def async_volume_down(self) -> None: """Volume down media player.""" await self._async_post_request(UrlSuffix.VOLUME_DOWN) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._async_post_request( UrlSuffix.VOLUME_SET, json_body={"volume": volume * 100}, ) async def async_mute_volume(self, mute: bool) -> None: """Mute (true) or unmute (false) media player.""" if mute: await self._async_post_request(UrlSuffix.MUTE) else: await self._async_post_request(UrlSuffix.UNMUTE) async def async_media_play(self) -> None: """Play media player.""" await self._async_post_request(UrlSuffix.PLAY) async def async_media_pause(self) -> None: """Pause media player.""" await self._async_post_request(UrlSuffix.PAUSE) async def async_media_stop(self) -> None: """Pause media player.""" await self._async_post_request(UrlSuffix.PAUSE) async def async_media_next_track(self) -> None: """Send the next track command.""" await self._async_post_request(UrlSuffix.NEXT_TRACK) async def async_media_previous_track(self) -> None: """Send the previous track command.""" await self._async_post_request(UrlSuffix.PREVIOUS_TRACK) async def async_media_seek(self, position: float) -> None: """Send seek command.""" await self._async_post_request( UrlSuffix.SEEK, json_body={"position": int(position)}, ) async def async_set_night_mode(self, night_mode: bool) -> None: """Set the night mode.""" if night_mode: mode = "on" else: mode = "off" await self._async_post_request( UrlSuffix.NIGHT_MODE, json_body={"nightMode": mode}, ) async def async_set_equalizer(self, preset: str) -> None: """Set the equalizer preset.""" await self._async_post_request( UrlSuffix.EQUALIZER, json_body={"preset": preset}, ) async def async_turn_off(self) -> None: """Turn off media player.""" await self._async_post_request(UrlSuffix.TURN_OFF) async def async_select_source(self, source: str) -> None: """Select input source.""" source_id = None try: name = NORMAL_INPUTS[source] except KeyError: LOGGER.error("Unknown source %s selected", source) return if "_" in name: name_split = name.split("_") for role, position in SPEAKER_POSITIONS.items(): if position != name_split[1]: continue if role == self.device_role: for _source in self._sources["sources"]: if ( _source["deviceId"] == self.device_id and _source["type"] == name_split[0] ): source_id = _source["sourceId"] break else: for _source in self._sources["sources"]: if ( _source["deviceId"] != self.device_id and _source["type"] == name_split[0] ): source_id = _source["sourceId"] break break else: for _source in self._sources["sources"]: if _source["type"] == name: source_id = _source["sourceId"] if source_id is None: LOGGER.error("Source %s is not available", source) return await self._async_post_request( str(UrlSuffix.SELECT_SOURCE).replace("%SOURCE_ID%", source_id) ) async def _async_get_request(self, suffix: str) -> any | None: """Generic GET method.""" url = "http://" + self._host + str(suffix) try: async with self._session.get( url=url, allow_redirects=False, timeout=2 ) as response: response = await response.read() LOGGER.debug( "Host %s: HTTP Response data: %s", self._host, response, ) response_json = json.loads(response) self._is_available = True if "error" in response_json: LOGGER.debug(response_json["error"]) return None return response_json except aiohttp.ClientConnectorError as conn_err: LOGGER.debug("Host %s: Connection error %s", self._host, str(conn_err)) self._is_available = False return None except asyncio.TimeoutError: LOGGER.debug("Devialet connection timeout exception. Please check the connection") self._is_available = False return None except (TypeError, json.JSONDecodeError): LOGGER.debug("Get request: JSON error") return None except Exception: # pylint: disable=bare-except LOGGER.debug("Get request: unknown exception occurred") return None async def _async_post_request(self, suffix:str, json_body:str={}) -> bool | None: """Generic POST method.""" url = "http://" + self._host + str(suffix) try: async with self._session.post( url=url, json=json_body, allow_redirects=False, timeout=2 ) as response: response_data = await response.text() LOGGER.debug( "Host %s: HTTP %s Response data: %s", self._host, response.status, response_data, ) return True except aiohttp.ClientConnectorError as conn_err: LOGGER.debug("Host %s: Connection error %s", self._host, str(conn_err)) return False except asyncio.TimeoutError: LOGGER.debug( "Devialet connection timeout exception, please check the connection" ) return False except (TypeError, json.JSONDecodeError): LOGGER.debug("Post request: unknown response type") return False except Exception: # pylint: disable=bare-except LOGGER.debug("Post request: unknown exception occurred") return False async def _async_on_search_response(self, data: CaseInsensitiveDict) -> None: """UPnP device detected.""" location = data['location'] location_regex = re.compile(f"(?<=Location:[ ])*http://{self._host}:(.*)/.*.xml", re.IGNORECASE) location_result = location_regex.search(location) if location_result: requester = AiohttpRequester() factory = UpnpFactory(requester) self._upnp_device = await factory.async_create_device(location) self._dmr_device = DmrDevice(self._upnp_device, None) async def async_search_allowed(self) -> bool: """Conditions to check if UPnP search is allowed.""" if ( self.is_available and not self.upnp_available and self.is_system_leader and ( not self._last_upnp_search or ( datetime.datetime.now() - self._last_upnp_search).total_seconds() >= UPNP_SEARCH_INTERVAL ) ): return True return False async def async_discover_upnp_device(self) -> None: """Discover the UPnP device.""" self._last_upnp_search = datetime.datetime.now() await async_search(async_callback=self._async_on_search_response, timeout=10, search_target=MEDIA_RENDERER, source=("0.0.0.0", 0)) LOGGER.debug("Discovering UPnP device for %s", self._host) async def async_play_url_source(self, media_id: str, mime_type: str, media_title: str, default_title: bool=False) -> bool: """Play media uri over UPnP.""" if not self.upnp_available: LOGGER.error("No UPnP location discovered") return if default_title: media_title = await self.async_get_upnp_media_title(media_id) or media_title metadata = ( await self.dmr_device.construct_play_media_metadata( media_url=media_id, media_title=media_title, default_mime_type=mime_type ) ) service = self._upnp_device.service(AV_TRANSPORT) set_uri = service.action("SetAVTransportURI") try: result = await set_uri.async_call(InstanceID=0, CurrentURI=media_id, CurrentURIMetaData=metadata) LOGGER.debug("Action result: %s", str(result)) return True except UpnpActionResponseError as a: LOGGER.error("Error playing %s: %s", media_title, a.error_desc) return False except UpnpXmlParseError as x: LOGGER.error("Error playing %s %s", media_title, x.text) return False await self.async_upnp_play() async def async_upnp_play(self) -> None: """Send the play command over UPnP.""" if not self.upnp_available: LOGGER.error("No UPnP location discovered") return service = self._upnp_device.service(AV_TRANSPORT) set_uri = service.action("Play") try: await set_uri.async_call(InstanceID=0, Speed="1") return except UpnpActionResponseError: return except UpnpXmlParseError: return async def async_get_upnp_media_title(self, url: str) -> str | None: """Call the media URL with the HEAD method to get ICY metadata.""" try: async with self._session.head( url=url, allow_redirects=False, timeout=2, headers={"Icy-MetaData": "1"} ) as response: LOGGER.debug( "Host %s: HTTP Response data: %s", self._host, response.headers, ) title:str = response.headers.get("icy-description") or response.headers.get("icy-name") return title except aiohttp.ClientConnectorError: return except asyncio.TimeoutError: return except TypeError: return except Exception: # pylint: disable=bare-except return devialet-1.5.7/setup.cfg000066400000000000000000000000501473405717700151540ustar00rootroot00000000000000[metadata] description-file = README.md devialet-1.5.7/setup.py000066400000000000000000000017451473405717700150610ustar00rootroot00000000000000from distutils.core import setup setup( name='devialet', packages=['devialet'], version='1.5.7', license='MIT', description='Devialet API', long_description_content_type="text/markdown", long_description='Devialet API', author='fwestenberg', author_email='', url='https://github.com/fwestenberg/devialet', download_url='https://github.com/fwestenberg/devialet/releases/latest', keywords=['Devialet', 'Home-Assistant'], install_requires=[ 'aiohttp', 'async_upnp_client' ], classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Topic :: Software Development :: Build Tools', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9' ], )