diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..e2cb6d82a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + workflow_dispatch: # to allow manual re-runs + + +jobs: + linting: + name: "Perform linting checks" + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9"] + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v2" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip poetry + poetry install + - name: "Code formating (black)" + run: | + poetry run pre-commit run black --all-files + - name: "Code formating (flake8)" + run: | + poetry run pre-commit run flake8 --all-files + - name: "Order of imports (isort)" + run: | + poetry run pre-commit run isort --all-files + - name: "Typing checks (mypy)" + run: | + poetry run pre-commit run mypy --all-files + - name: "Run trailing-whitespace" + run: | + poetry run pre-commit run trailing-whitespace --all-files + - name: "Run end-of-file-fixer" + run: | + poetry run pre-commit run end-of-file-fixer --all-files + - name: "Run check-docstring-first" + run: | + poetry run pre-commit run check-docstring-first --all-files + - name: "Run debug-statements" + run: | + poetry run pre-commit run debug-statements --all-files + - name: "Run check-ast" + run: | + poetry run pre-commit run check-ast --all-files + + tests: + name: "Python ${{ matrix.python-version}} on ${{ matrix.os }}" + needs: linting + runs-on: ${{ matrix.os }} + + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "pypy-3.7"] + os: [ubuntu-latest, macos-latest, windows-latest] + # exclude pypy on windows, as the poetry install seems to be very flaky: + # PermissionError(13, 'The process cannot access the file because it is being used by another process')) + # at C:\hostedtoolcache\windows\PyPy\3.7.10\x86\site-packages\requests\models.py:761 in generate + exclude: + - python-version: pypy-3.7 + os: windows-latest + + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v2" + with: + python-version: "${{ matrix.python-version }}" + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip poetry + poetry install + - name: "Run tests" + run: | + poetry run pytest --cov kasa --cov-report xml + - name: "Upload coverage to Codecov" + uses: "codecov/codecov-action@v1" + with: + fail_ci_if_error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39b02f975..a0e1ced0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,13 +10,13 @@ repos: - id: check-ast - repo: https://github.com/asottile/pyupgrade - rev: v2.19.4 + rev: v2.27.0 hooks: - id: pyupgrade args: ['--py36-plus'] - repo: https://github.com/python/black - rev: 21.6b0 + rev: 21.9b0 hooks: - id: black @@ -27,13 +27,13 @@ repos: additional_dependencies: [flake8-docstrings] - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.8.0 + rev: v5.9.3 hooks: - id: isort additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.902 + rev: v0.910 hooks: - id: mypy additional_dependencies: [types-click] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0b089b4..437c756dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ # Changelog -## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-14) +## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) + +**Implemented enhancements:** + +- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) +- Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) ([rytilahti](https://github.com/rytilahti)) +- Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) ([bdraco](https://github.com/bdraco)) +- Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) ([bdraco](https://github.com/bdraco)) +- Add own device type for smartstrip children [\#201](https://github.com/python-kasa/python-kasa/pull/201) ([rytilahti](https://github.com/rytilahti)) +- bulb: allow set\_hsv without v, add fallback ct range [\#200](https://github.com/python-kasa/python-kasa/pull/200) ([rytilahti](https://github.com/rytilahti)) +- Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) ([rytilahti](https://github.com/rytilahti)) +- Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) ([rytilahti](https://github.com/rytilahti)) +- cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) ([JaydenRA](https://github.com/JaydenRA)) + +**Fixed bugs:** + +- KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) ([rytilahti](https://github.com/rytilahti)) + +**Closed issues:** + +- Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) +- Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) +- Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) +- Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) +- Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) +- Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) +- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) +- Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) + +**Merged pull requests:** + +- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) ([rytilahti](https://github.com/rytilahti)) +- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) ([rytilahti](https://github.com/rytilahti)) +- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) ([rytilahti](https://github.com/rytilahti)) +- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) ([rytilahti](https://github.com/rytilahti)) +- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) ([rytilahti](https://github.com/rytilahti)) +- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) ([iprodanovbg](https://github.com/iprodanovbg)) +- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) ([rytilahti](https://github.com/rytilahti)) +- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) ([nbrew](https://github.com/nbrew)) +- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) ([leandroreox](https://github.com/leandroreox)) + +## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) @@ -29,6 +73,7 @@ **Merged pull requests:** +- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) ([rytilahti](https://github.com/rytilahti)) - Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) ([rytilahti](https://github.com/rytilahti)) - Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) ([brianthedavis](https://github.com/brianthedavis)) - Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) ([mdarnol](https://github.com/mdarnol)) diff --git a/README.md b/README.md index 9cb1b3c68..e1136d1f5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # python-kasa [![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) -[![Build Status](https://dev.azure.com/python-kasa/python-kasa/_apis/build/status/python-kasa.python-kasa?branchName=master)](https://dev.azure.com/python-kasa/python-kasa/_build/latest?definitionId=2&branchName=master) -[![Coverage Status](https://coveralls.io/repos/github/python-kasa/python-kasa/badge.svg?branch=master)](https://coveralls.io/github/python-kasa/python-kasa?branch=master) +[![Build Status](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml/badge.svg)](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/python-kasa/python-kasa/branch/master/graph/badge.svg?token=5K7rtN5OmS)](https://codecov.io/gh/python-kasa/python-kasa) [![Documentation Status](https://readthedocs.org/projects/python-kasa/badge/?version=latest)](https://python-kasa.readthedocs.io/en/latest/?badge=latest) python-kasa is a Python library to control TPLink smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. @@ -94,6 +94,15 @@ This will make sure that the checks are passing when you do a commit. You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. +### Running tests + +You can run tests on the library by executing `pytest` in the source directory. +This will run the tests against contributed example responses, but you can also execute the tests against a real device: +``` +pytest --ip
+``` +Note that this will perform state changes on the device. + ### Analyzing network captures The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. @@ -110,6 +119,7 @@ or the `parse_pcap.py` script contained inside the `devtools` directory. * HS105 * HS107 * HS110 +* KP115 ### Power Strips diff --git a/RELEASING.md b/RELEASING.md index 75a775edb..fc01a87de 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,7 +3,7 @@ ```bash # export PREVIOUS_RELEASE=$(git describe --abbrev=0) export PREVIOUS_RELEASE=0.3.5 # generate the full changelog since last pyhs100 release -export NEW_RELEASE=0.4.0.pre1 +export NEW_RELEASE=0.4.0.dev4 ``` 2. Update the version number diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 2b201ae08..000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,118 +0,0 @@ -trigger: -- master -pr: -- master - - -stages: -- stage: "Linting" - jobs: - - job: "LintChecks" - pool: - vmImage: "ubuntu-latest" - strategy: - matrix: - Python 3.8: - python.version: '3.8' - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - displayName: 'Use Python $(python.version)' - - - script: | - python -m pip install --upgrade pip poetry - poetry install - displayName: 'Install dependencies' - - - script: | - poetry run pre-commit run black --all-files - displayName: 'Code formating (black)' - - - script: | - poetry run pre-commit run flake8 --all-files - displayName: 'Code formating (flake8)' - - - script: | - poetry run pre-commit run mypy --all-files - displayName: 'Typing checks (mypy)' - - - script: | - poetry run pre-commit run isort --all-files - displayName: 'Order of imports (isort)' - - - script: | - poetry run pre-commit run trailing-whitespace --all-files - displayName: 'Run trailing-whitespace' - - - script: | - poetry run pre-commit run end-of-file-fixer --all-files - displayName: 'Run end-of-file-fixer' - - - script: | - poetry run pre-commit run check-docstring-first --all-files - displayName: 'Run check-docstring-first' - - - script: | - poetry run pre-commit run check-yaml --all-files - displayName: 'Run check-yaml' - - - script: | - poetry run pre-commit run debug-statements --all-files - displayName: 'Run debug-statements' - - - script: | - poetry run pre-commit run check-ast --all-files - displayName: 'Run check-ast' - - -- stage: "Tests" - jobs: - - job: "Tests" - strategy: - matrix: - Python 3.7 Ubuntu: - python.version: '3.7' - vmImage: 'ubuntu-latest' - - Python 3.8 Ubuntu: - python.version: '3.8' - vmImage: 'ubuntu-latest' - - Python 3.7 Windows: - python.version: '3.7' - vmImage: 'windows-latest' - - Python 3.8 Windows: - python.version: '3.8' - vmImage: 'windows-latest' - - Python 3.7 OSX: - python.version: '3.7' - vmImage: 'macOS-latest' - - Python 3.8 OSX: - python.version: '3.8' - vmImage: 'macOS-latest' - - pool: - vmImage: $(vmImage) - - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - displayName: 'Use Python $(python.version)' - - - script: | - python -m pip install --upgrade pip poetry - poetry install - displayName: 'Install dependencies' - - - script: | - poetry run pytest --cov kasa --cov-report=xml --cov-report=html - displayName: 'Run tests' - - - script: | - poetry run codecov -t $(codecov.token) - displayName: 'Report code coverage' diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 433846d34..9d30f9674 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -41,7 +41,7 @@ def scrub(res): res[k] = scrub(res.get(k)) else: if k in keys_to_scrub: - if k in ["latitude_i", "longitude_i"]: + if k in ["latitude", "latitude_i", "longitude", "longitude_i"]: v = 0 else: v = re.sub(r"\w", "0", v) @@ -62,7 +62,7 @@ def default_to_regular(d): @click.command() @click.argument("host") -@click.option("--debug") +@click.option("-d", "--debug", is_flag=True) def cli(host, debug): """Generate devinfo file for given device.""" if debug: diff --git a/kasa/__init__.py b/kasa/__init__.py index 51b5291b4..fc798fb37 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -14,10 +14,11 @@ from importlib_metadata import version # type: ignore from kasa.discover import Discover +from kasa.emeterstatus import EmeterStatus from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb -from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice +from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug diff --git a/kasa/cli.py b/kasa/cli.py index 0473dc97b..626eadc2b 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -247,6 +247,7 @@ async def alias(dev, new_alias, index): if new_alias is not None: click.echo(f"Setting alias to {new_alias}") click.echo(await dev.set_alias(new_alias)) + await dev.update() click.echo(f"Alias: {dev.alias}") if dev.is_strip: @@ -345,6 +346,9 @@ async def brightness(dev: SmartBulb, brightness: int, transition: int): async def temperature(dev: SmartBulb, temperature: int, transition: int): """Get or set color temperature.""" await dev.update() + if not dev.is_variable_color_temp: + click.echo("Device does not support color temperature") + return if temperature is None: click.echo(f"Color temperature: {dev.color_temp}") valid_temperature_range = dev.valid_temperature_range diff --git a/kasa/discover.py b/kasa/discover.py index 2ee2079ba..b12b79264 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -152,13 +152,14 @@ async def discover( Sends discovery message to 255.255.255.255:9999 in order to detect available supported devices in the local network, and waits for given timeout for answers from devices. + If you have multiple interfaces, you can use target parameter to specify the network for discovery. If given, `on_discovered` coroutine will get passed with the :class:`SmartDevice`-derived object as parameter. The results of the discovery are returned either as a list of :class:`SmartDevice`-derived objects or as raw response dictionaries objects (if `return_raw` is True). - :param target: The target broadcast address (e.g. 192.168.xxx.255). + :param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). :param on_discovered: coroutine to execute on discovery :param timeout: How long to wait for responses, defaults to 5 :param discovery_packets: Number of discovery packets are broadcasted. diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py new file mode 100644 index 000000000..da636551d --- /dev/null +++ b/kasa/emeterstatus.py @@ -0,0 +1,82 @@ +"""Module for emeter container.""" +import logging +from typing import Optional + +_LOGGER = logging.getLogger(__name__) + + +class EmeterStatus(dict): + """Container for converting different representations of emeter data. + + Newer FW/HW versions postfix the variable names with the used units, + where-as the olders do not have this feature. + + This class automatically converts between these two to allow + backwards and forwards compatibility. + """ + + @property + def voltage(self) -> Optional[float]: + """Return voltage in V.""" + try: + return self["voltage"] + except ValueError: + return None + + @property + def power(self) -> Optional[float]: + """Return power in W.""" + try: + return self["power"] + except ValueError: + return None + + @property + def current(self) -> Optional[float]: + """Return current in A.""" + try: + return self["current"] + except ValueError: + return None + + @property + def total(self) -> Optional[float]: + """Return total in kWh.""" + try: + return self["total"] + except ValueError: + return None + + def __repr__(self): + return f"" + + def __getitem__(self, item): + valid_keys = [ + "voltage_mv", + "power_mw", + "current_ma", + "energy_wh", + "total_wh", + "voltage", + "power", + "current", + "total", + "energy", + ] + + # 1. if requested data is available, return it + if item in super().keys(): + return super().__getitem__(item) + # otherwise decide how to convert it + else: + if item not in valid_keys: + raise KeyError(item) + if "_" in item: # upscale + return super().__getitem__(item[: item.find("_")]) * 1000 + else: # downscale + for i in super().keys(): + if i.startswith(item): + return self.__getitem__(i) / 1000 + + _LOGGER.debug(f"Unable to find value for '{item}'") + return None diff --git a/kasa/protocol.py b/kasa/protocol.py index 6ee6f72d6..bbf13b995 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -89,6 +89,13 @@ async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> D # make mypy happy, this should never be reached.. raise SmartDeviceException("Query reached somehow to unreachable") + @staticmethod + def _xor_payload(unencrypted): + key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for unencryptedbyte in unencrypted: + key = key ^ unencryptedbyte + yield key + @staticmethod def encrypt(request: str) -> bytes: """Encrypt a request for a TP-Link Smart Home Device. @@ -96,17 +103,18 @@ def encrypt(request: str) -> bytes: :param request: plaintext request data :return: ciphertext to be send over wire, in bytes """ - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - plainbytes = request.encode() - buffer = bytearray(struct.pack(">I", len(plainbytes))) + return struct.pack(">I", len(plainbytes)) + bytes( + TPLinkSmartHomeProtocol._xor_payload(plainbytes) + ) - for plainbyte in plainbytes: - cipherbyte = key ^ plainbyte + @staticmethod + def _xor_encrypted_payload(ciphertext): + key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for cipherbyte in ciphertext: + plainbyte = key ^ cipherbyte key = cipherbyte - buffer.append(cipherbyte) - - return bytes(buffer) + yield plainbyte @staticmethod def decrypt(ciphertext: bytes) -> str: @@ -115,14 +123,6 @@ def decrypt(ciphertext: bytes) -> str: :param ciphertext: encrypted response data :return: plaintext response """ - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - buffer = [] - - for cipherbyte in ciphertext: - plainbyte = key ^ cipherbyte - key = cipherbyte - buffer.append(plainbyte) - - plaintext = bytes(buffer) - - return plaintext.decode() + return bytes( + TPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) + ).decode() diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 351f916b9..aad2ce8ce 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -1,26 +1,40 @@ """Module for bulbs (LB*, KL*, KB*).""" +import logging import re -from typing import Any, Dict, Tuple, cast +from typing import Any, Dict, NamedTuple, cast + +from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update + + +class ColorTempRange(NamedTuple): + """Color temperature range.""" + + min: int + max: int + + +class HSV(NamedTuple): + """Hue-saturation-value.""" + + hue: int + saturation: int + value: int -from kasa.smartdevice import ( - DeviceType, - SmartDevice, - SmartDeviceException, - requires_update, -) TPLINK_KELVIN = { - "LB130": (2500, 9000), - "LB120": (2700, 6500), - "LB230": (2500, 9000), - "KB130": (2500, 9000), - "KL130": (2500, 9000), - "KL125": (2500, 6500), - r"KL120\(EU\)": (2700, 6500), - r"KL120\(US\)": (2700, 5000), - r"KL430\(US\)": (2500, 9000), + "LB130": ColorTempRange(2500, 9000), + "LB120": ColorTempRange(2700, 6500), + "LB230": ColorTempRange(2500, 9000), + "KB130": ColorTempRange(2500, 9000), + "KL130": ColorTempRange(2500, 9000), + "KL125": ColorTempRange(2500, 6500), + r"KL120\(EU\)": ColorTempRange(2700, 6500), + r"KL120\(US\)": ColorTempRange(2700, 5000), + r"KL430": ColorTempRange(2500, 9000), } +_LOGGER = logging.getLogger(__name__) + class SmartBulb(SmartDevice): """Representation of a TP-Link Smart Bulb. @@ -69,7 +83,7 @@ class SmartBulb(SmartDevice): Bulbs supporting color temperature can be queried to know which range is accepted: >>> bulb.valid_temperature_range - (2500, 9000) + ColorTempRange(min=2500, max=9000) >>> asyncio.run(bulb.set_color_temp(3000)) >>> asyncio.run(bulb.update()) >>> bulb.color_temp @@ -80,7 +94,7 @@ class SmartBulb(SmartDevice): >>> asyncio.run(bulb.set_hsv(180, 100, 80)) >>> asyncio.run(bulb.update()) >>> bulb.hsv - (180, 100, 80) + HSV(hue=180, saturation=100, value=80) If you don't want to use the default transitions, you can pass `transition` in milliseconds. This applies to all transitions (turn_on, turn_off, set_hsv, set_color_temp, set_brightness). @@ -91,6 +105,7 @@ class SmartBulb(SmartDevice): """ LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" + TIME_SERVICE = "smartlife.iot.common.timesetting" SET_LIGHT_METHOD = "transition_light_state" def __init__(self, host: str) -> None: @@ -121,21 +136,21 @@ def is_variable_color_temp(self) -> bool: @property # type: ignore @requires_update - def valid_temperature_range(self) -> Tuple[int, int]: + def valid_temperature_range(self) -> ColorTempRange: """Return the device-specific white temperature range (in Kelvin). :return: White temperature range in Kelvin (minimum, maximum) """ if not self.is_variable_color_temp: raise SmartDeviceException("Color temperature not supported") + for model, temp_range in TPLINK_KELVIN.items(): sys_info = self.sys_info if re.match(model, sys_info["model"]): return temp_range - raise SmartDeviceException( - "Unknown color temperature range, please open an issue on github" - ) + _LOGGER.warning("Unknown color temperature range, fallback to 2700-5000") + return ColorTempRange(2700, 5000) @property # type: ignore @requires_update @@ -199,7 +214,7 @@ async def set_light_state(self, state: Dict, *, transition: int = None) -> Dict: @property # type: ignore @requires_update - def hsv(self) -> Tuple[int, int, int]: + def hsv(self) -> HSV: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -213,7 +228,7 @@ def hsv(self) -> Tuple[int, int, int]: saturation = light_state["saturation"] value = light_state["brightness"] - return hue, saturation, value + return HSV(hue, saturation, value) def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): @@ -223,7 +238,7 @@ def _raise_for_invalid_brightness(self, value): @requires_update async def set_hsv( - self, hue: int, saturation: int, value: int, *, transition: int = None + self, hue: int, saturation: int, value: int = None, *, transition: int = None ) -> Dict: """Set new HSV. @@ -246,15 +261,16 @@ async def set_hsv( "(valid range: 0-100%)".format(saturation) ) - self._raise_for_invalid_brightness(value) - light_state = { "hue": hue, "saturation": saturation, - "brightness": value, "color_temp": 0, } + if value is not None: + self._raise_for_invalid_brightness(value) + light_state["brightness"] = value + return await self.set_light_state(light_state, transition=transition) @property # type: ignore @@ -283,7 +299,7 @@ async def set_color_temp( if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: raise ValueError( "Temperature should be between {} " - "and {}".format(*valid_temperature_range) + "and {}, was {}".format(*valid_temperature_range, temp) ) light_state = {"color_temp": temp} @@ -359,3 +375,12 @@ async def turn_on(self, *, transition: int = None, **kwargs) -> Dict: def has_emeter(self) -> bool: """Return that the bulb has an emeter.""" return True + + async def set_alias(self, alias: str) -> None: + """Set the device name (alias). + + Overridden to use a different module name. + """ + return await self._query_helper( + "smartlife.iot.common.system", "set_dev_alias", {"alias": alias} + ) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 3b21aa010..11c7d1c96 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -11,14 +11,16 @@ You may obtain a copy of the license at http://www.apache.org/licenses/LICENSE-2.0 """ +import collections.abc import functools import inspect import logging from dataclasses import dataclass from datetime import datetime, timedelta -from enum import Enum -from typing import Any, Dict, List, Optional +from enum import Enum, auto +from typing import Any, Dict, List, Optional, Set +from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .protocol import TPLinkSmartHomeProtocol @@ -28,11 +30,12 @@ class DeviceType(Enum): """Device type enum.""" - Plug = 1 - Bulb = 2 - Strip = 3 - Dimmer = 4 - LightStrip = 5 + Plug = auto() + Bulb = auto() + Strip = auto() + StripSocket = auto() + Dimmer = auto() + LightStrip = auto() Unknown = -1 @@ -49,46 +52,14 @@ class WifiNetwork: rssi: Optional[int] = None -class EmeterStatus(dict): - """Container for converting different representations of emeter data. - - Newer FW/HW versions postfix the variable names with the used units, - where-as the olders do not have this feature. - - This class automatically converts between these two to allow - backwards and forwards compatibility. - """ - - def __getitem__(self, item): - valid_keys = [ - "voltage_mv", - "power_mw", - "current_ma", - "energy_wh", - "total_wh", - "voltage", - "power", - "current", - "total", - "energy", - ] - - # 1. if requested data is available, return it - if item in super().keys(): - return super().__getitem__(item) - # otherwise decide how to convert it +def merge(d, u): + """Update dict recursively.""" + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = merge(d.get(k, {}), v) else: - if item not in valid_keys: - raise KeyError(item) - if "_" in item: # upscale - return super().__getitem__(item[: item.find("_")]) * 1000 - else: # downscale - for i in super().keys(): - if i.startswith(item): - return self.__getitem__(i) / 1000 - - _LOGGER.debug(f"Unable to find value for '{item}'") - return None + d[k] = v + return d def requires_update(f): @@ -201,7 +172,7 @@ class SmartDevice: >>> dev.has_emeter True >>> dev.emeter_realtime - {'current': 0.015342, 'err_code': 0, 'power': 0.983971, 'total': 32.448, 'voltage': 235.595234} + >>> dev.emeter_today >>> dev.emeter_this_month @@ -214,6 +185,8 @@ class SmartDevice: """ + TIME_SERVICE = "time" + def __init__(self, host: str) -> None: """Create a new SmartDevice instance. @@ -242,6 +215,11 @@ def _create_request( return request + def _verify_emeter(self) -> None: + """Raise an exception if there is no emeter.""" + if not self.has_emeter: + raise SmartDeviceException("Device has no emeter") + async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None ) -> Any: @@ -278,31 +256,50 @@ async def _query_helper( return result + @property # type: ignore + @requires_update + def features(self) -> Set[str]: + """Return a set of features that the device supports.""" + return set(self.sys_info["feature"].split(":")) + @property # type: ignore @requires_update def has_emeter(self) -> bool: """Return True if device has an energy meter.""" - sys_info = self.sys_info - features = sys_info["feature"].split(":") - return "ENE" in features + return "ENE" in self.features async def get_sys_info(self) -> Dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") async def update(self): - """Update some of the attributes. + """Query the device to update the data. - Needed for methods that are decorated with `requires_update`. + Needed for properties that are decorated with `requires_update`. """ req = {} req.update(self._create_request("system", "get_sysinfo")) - # Check for emeter if we were never updated, or if the device has emeter - if self._last_update is None or self.has_emeter: + # If this is the initial update, check only for the sysinfo + # This is necessary as some devices crash on unexpected modules + # See #105, #120, #161 + if self._last_update is None: + _LOGGER.debug("Performing the initial update to obtain sysinfo") + self._last_update = await self.protocol.query(self.host, req) + self._sys_info = self._last_update["system"]["get_sysinfo"] + # If the device has no emeter, we are done for the initial update + # Otherwise we will follow the regular code path to also query + # the emeter data also during the initial update + if not self.has_emeter: + return + + if self.has_emeter: + _LOGGER.debug( + "The device has emeter, querying its information along sysinfo" + ) req.update(self._create_emeter_request()) + self._last_update = await self.protocol.query(self.host, req) - # TODO: keep accessible for tests self._sys_info = self._last_update["system"]["get_sysinfo"] def update_from_discover_info(self, info): @@ -337,7 +334,7 @@ async def set_alias(self, alias: str) -> None: async def get_time(self) -> Optional[datetime]: """Return current time from the device, if available.""" try: - res = await self._query_helper("time", "get_time") + res = await self._query_helper(self.TIME_SERVICE, "get_time") return datetime( res["year"], res["month"], @@ -351,7 +348,7 @@ async def get_time(self) -> Optional[datetime]: async def get_timezone(self) -> Dict: """Return timezone information.""" - return await self._query_helper("time", "get_timezone") + return await self._query_helper(self.TIME_SERVICE, "get_timezone") @property # type: ignore @requires_update @@ -397,10 +394,8 @@ def location(self) -> Dict: @requires_update def rssi(self) -> Optional[int]: """Return WiFi signal strenth (rssi).""" - sys_info = self.sys_info - if "rssi" in sys_info: - return int(sys_info["rssi"]) - return None + rssi = self.sys_info.get("rssi") + return None if rssi is None else int(rssi) @property # type: ignore @requires_update @@ -433,16 +428,12 @@ async def set_mac(self, mac): @requires_update def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() return EmeterStatus(self._last_update[self.emeter_type]["get_realtime"]) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() return EmeterStatus(await self._query_helper(self.emeter_type, "get_realtime")) def _create_emeter_request(self, year: int = None, month: int = None): @@ -452,23 +443,12 @@ def _create_emeter_request(self, year: int = None, month: int = None): if month is None: month = datetime.now().month - import collections.abc - - def update(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = update(d.get(k, {}), v) - else: - d[k] = v - return d - req: Dict[str, Any] = {} - update(req, self._create_request(self.emeter_type, "get_realtime")) - update( + merge(req, self._create_request(self.emeter_type, "get_realtime")) + merge( req, self._create_request(self.emeter_type, "get_monthstat", {"year": year}) ) - update( + merge( req, self._create_request( self.emeter_type, "get_daystat", {"month": month, "year": year} @@ -481,9 +461,7 @@ def update(d, u): @requires_update def emeter_today(self) -> Optional[float]: """Return today's energy consumption in kWh.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"] data = self._emeter_convert_emeter_data(raw_data) today = datetime.now().day @@ -497,9 +475,7 @@ def emeter_today(self) -> Optional[float]: @requires_update def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() raw_data = self._last_update[self.emeter_type]["get_monthstat"]["month_list"] data = self._emeter_convert_emeter_data(raw_data) current_month = datetime.now().month @@ -539,9 +515,7 @@ async def get_emeter_daily( :param kwh: return usage in kWh (default: True) :return: mapping of day of month to value """ - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() if year is None: year = datetime.now().year if month is None: @@ -561,9 +535,7 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: :param kwh: return usage in kWh (default: True) :return: dict: mapping of month to value """ - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() if year is None: year = datetime.now().year @@ -576,19 +548,15 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: @requires_update async def erase_emeter_stats(self) -> Dict: """Erase energy meter statistics.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() return await self._query_helper(self.emeter_type, "erase_emeter_stat", None) @requires_update async def current_consumption(self) -> float: """Get the current power consumption in Watt.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - + self._verify_emeter() response = EmeterStatus(await self.get_emeter_realtime()) - return response["power"] + return float(response["power"]) async def reboot(self, delay: int = 1) -> None: """Reboot the device. @@ -643,7 +611,8 @@ def state_information(self) -> Dict[str, Any]: def device_id(self) -> str: """Return unique ID for the device. - This is the MAC address of the device. + If not overridden, this is the MAC address of the device. + Individual sockets on strips will override this. """ return self.mac @@ -725,6 +694,11 @@ def is_strip(self) -> bool: """Return True if the device is a strip.""" return self._device_type == DeviceType.Strip + @property + def is_strip_socket(self) -> bool: + """Return True if the device is a strip socket.""" + return self._device_type == DeviceType.StripSocket + @property def is_dimmer(self) -> bool: """Return True if the device is a dimmer.""" diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 9c8064dd5..c1235920d 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -6,6 +6,7 @@ from kasa.smartdevice import ( DeviceType, + EmeterStatus, SmartDevice, SmartDeviceException, requires_update, @@ -15,6 +16,15 @@ _LOGGER = logging.getLogger(__name__) +def merge_sums(dicts): + """Merge the sum of dicts.""" + total_dict: DefaultDict[int, float] = defaultdict(lambda: 0.0) + for sum_dict in dicts: + for day, value in sum_dict.items(): + total_dict[day] += value + return total_dict + + class SmartStrip(SmartDevice): """Representation of a TP-Link Smart Power Strip. @@ -75,11 +85,7 @@ def __init__(self, host: str) -> None: @requires_update def is_on(self) -> bool: """Return if any of the outlets are on.""" - for plug in self.children: - is_on = plug.is_on - if is_on: - return True - return False + return any(plug.is_on for plug in self.children) async def update(self): """Update some of the attributes. @@ -97,15 +103,17 @@ async def update(self): SmartStripPlug(self.host, parent=self, child_id=child["id"]) ) + if self.has_emeter: + for plug in self.children: + await plug.update() + async def turn_on(self, **kwargs): """Turn the strip on.""" await self._query_helper("system", "set_relay_state", {"state": 1}) - await self.update() async def turn_off(self, **kwargs): """Turn the strip off.""" await self._query_helper("system", "set_relay_state", {"state": 0}) - await self.update() @property # type: ignore @requires_update @@ -126,7 +134,6 @@ def led(self) -> bool: async def set_led(self, state: bool): """Set the state of the led (night mode).""" await self._query_helper("system", "set_led_off", {"off": int(not state)}) - await self.update() @property # type: ignore @requires_update @@ -143,16 +150,16 @@ def state_information(self) -> Dict[str, Any]: async def current_consumption(self) -> float: """Get the current power consumption in watts.""" - consumption = sum(await plug.current_consumption() for plug in self.children) - - return consumption + return sum([await plug.current_consumption() for plug in self.children]) - async def set_alias(self, alias: str) -> None: - """Set the alias for the strip. - - :param alias: new alias - """ - return await super().set_alias(alias) + @requires_update + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + emeter_rt = await self._async_get_emeter_sum("get_emeter_realtime", {}) + # Voltage is averaged since each read will result + # in a slightly different voltage since they are not atomic + emeter_rt["voltage_mv"] = int(emeter_rt["voltage_mv"] / len(self.children)) + return EmeterStatus(emeter_rt) @requires_update async def get_emeter_daily( @@ -166,14 +173,9 @@ async def get_emeter_daily( :param kwh: return usage in kWh (default: True) :return: mapping of day of month to value """ - emeter_daily: DefaultDict[int, float] = defaultdict(lambda: 0.0) - for plug in self.children: - plug_emeter_daily = await plug.get_emeter_daily( - year=year, month=month, kwh=kwh - ) - for day, value in plug_emeter_daily.items(): - emeter_daily[day] += value - return emeter_daily + return await self._async_get_emeter_sum( + "get_emeter_daily", {"year": year, "month": month, "kwh": kwh} + ) @requires_update async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: @@ -182,13 +184,16 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: :param year: year for which to retrieve statistics (default: this year) :param kwh: return usage in kWh (default: True) """ - emeter_monthly: DefaultDict[int, float] = defaultdict(lambda: 0.0) - for plug in self.children: - plug_emeter_monthly = await plug.get_emeter_monthly(year=year, kwh=kwh) - for month, value in plug_emeter_monthly: - emeter_monthly[month] += value + return await self._async_get_emeter_sum( + "get_emeter_monthly", {"year": year, "kwh": kwh} + ) - return emeter_monthly + async def _async_get_emeter_sum(self, func: str, kwargs: Dict[str, Any]) -> Dict: + """Retreive emeter stats for a time period from children.""" + self._verify_emeter() + return merge_sums( + [await getattr(plug, func)(**kwargs) for plug in self.children] + ) @requires_update async def erase_emeter_stats(self): @@ -196,6 +201,28 @@ async def erase_emeter_stats(self): for plug in self.children: await plug.erase_emeter_stats() + @property # type: ignore + @requires_update + def emeter_this_month(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + return sum([plug.emeter_this_month for plug in self.children]) + + @property # type: ignore + @requires_update + def emeter_today(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + return sum([plug.emeter_today for plug in self.children]) + + @property # type: ignore + @requires_update + def emeter_realtime(self) -> EmeterStatus: + """Return current energy readings.""" + emeter = merge_sums([plug.emeter_realtime for plug in self.children]) + # Voltage is averaged since each read will result + # in a slightly different voltage since they are not atomic + emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self.children)) + return EmeterStatus(emeter) + class SmartStripPlug(SmartPlug): """Representation of a single socket in a power strip. @@ -214,14 +241,25 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self.child_id = child_id self._last_update = parent._last_update self._sys_info = parent._sys_info + self._device_type = DeviceType.StripSocket async def update(self): - """Override the update to no-op and inform the user.""" - _LOGGER.warning( - "You called update() on a child device, which has no effect." - "Call update() on the parent device instead." + """Query the device to update the data. + + Needed for properties that are decorated with `requires_update`. + """ + self._last_update = await self.parent.protocol.query( + self.host, self._create_emeter_request() ) - return + + def _create_request( + self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + ): + request: Dict[str, Any] = { + "context": {"child_ids": [self.child_id]}, + target: {cmd: arg}, + } + return request async def _query_helper( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None @@ -247,12 +285,6 @@ def led(self) -> bool: """ return False - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Children have no emeter to my knowledge.""" - return False - @property # type: ignore @requires_update def device_id(self) -> str: diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 456fe3ab5..46cb86216 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -30,12 +30,12 @@ BULBS = {"KL60", "LB100", *VARIABLE_TEMP, *COLOR_BULBS, *LIGHT_STRIPS} -PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} +PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210", "EP10", "KP115"} STRIPS = {"HS107", "HS300", "KP303", "KP400"} DIMMERS = {"HS220"} DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", *BULBS, *STRIPS} +WITH_EMETER = {"HS110", "HS300", "KP115", *BULBS} ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) @@ -147,13 +147,13 @@ def get_device_for_file(file): with open(p) as f: sysinfo = json.load(f) model = basename(file) - p = device_for_file(model)(host="123.123.123.123") + p = device_for_file(model)(host="127.0.0.123") p.protocol = FakeTransportProtocol(sysinfo) asyncio.run(p.update()) return p -@pytest.fixture(params=SUPPORTED_DEVICES) +@pytest.fixture(params=SUPPORTED_DEVICES, scope="session") def dev(request): """Device fixture. @@ -168,21 +168,29 @@ def dev(request): asyncio.run(d.update()) if d.model in file: return d - raise Exception("Unable to find type for %s" % ip) + else: + pytest.skip(f"skipping file {file}") return get_device_for_file(file) def pytest_addoption(parser): - parser.addoption("--ip", action="store", default=None, help="run against device") + parser.addoption( + "--ip", action="store", default=None, help="run against device on given ip" + ) def pytest_collection_modifyitems(config, items): if not config.getoption("--ip"): print("Testing against fixtures.") - return else: print("Running against ip %s" % config.getoption("--ip")) + requires_dummy = pytest.mark.skip( + reason="test requires to be run against dummy data" + ) + for item in items: + if "requires_dummy" in item.keywords: + item.add_marker(requires_dummy) # allow mocks to be awaited diff --git a/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json b/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json new file mode 100755 index 000000000..e40543d6b --- /dev/null +++ b/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json @@ -0,0 +1,32 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "167 lamp", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP10(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -64, + "status": "new", + "sw_ver": "1.0.2 Build 200915 Rel.085940", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json b/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json new file mode 100644 index 000000000..a956575be --- /dev/null +++ b/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json @@ -0,0 +1,90 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 16760, + "total_wh": 120 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "Bedroom light strip", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 1, + "enable": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "name": "Aurora 1" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(UN)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "index": 0, + "mode": 1, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 217, + "index": 1, + "mode": 1, + "saturation": 99 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 194, + "index": 2, + "mode": 1, + "saturation": 50 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "index": 3, + "mode": 1, + "saturation": 86 + } + ], + "rssi": -43, + "status": "new", + "sw_ver": "1.0.8 Build 210121 Rel.084339" + } + } +} diff --git a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json b/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json new file mode 100644 index 000000000..790597dc0 --- /dev/null +++ b/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 231561 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "TP-LINK_Smart Plug_330B", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(EU)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 197, + "relay_state": 1, + "rssi": -70, + "status": "new", + "sw_ver": "1.0.16 Build 210205 Rel.163735", + "updating": 0 + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 73eed0f37..a37bb4147 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -124,7 +124,7 @@ def lb_dev_state(x): "dft_on_state": Optional( { "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=2000, max=9000)), + "color_temp": All(int, Range(min=0, max=9000)), "hue": All(int, Range(min=0, max=255)), "mode": str, "saturation": All(int, Range(min=0, max=255)), @@ -150,9 +150,9 @@ def lb_dev_state(x): { "brightness": All(int, Range(min=0, max=100)), "color_temp": int, - "hue": All(int, Range(min=0, max=255)), + "hue": All(int, Range(min=0, max=360)), "index": int, - "saturation": All(int, Range(min=0, max=255)), + "saturation": All(int, Range(min=0, max=100)), } ], } @@ -252,6 +252,27 @@ def success(res): return res +# plugs and bulbs use a different module for time information, +# so we define the contents here to avoid repeating ourselves +TIME_MODULE = { + "get_time": { + "year": 2017, + "month": 1, + "mday": 2, + "hour": 3, + "min": 4, + "sec": 5, + }, + "get_timezone": { + "zone_str": "test", + "dst_offset": -1, + "index": 12, + "tz_str": "test2", + }, + "set_timezone": None, +} + + class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): self.discovery_data = info @@ -393,23 +414,11 @@ def light_state(self, x, *args): "set_light_state": transition_light_state, "get_light_state": light_state, }, - "time": { - "get_time": { - "year": 2017, - "month": 1, - "mday": 2, - "hour": 3, - "min": 4, - "sec": 5, - }, - "get_timezone": { - "zone_str": "test", - "dst_offset": -1, - "index": 12, - "tz_str": "test2", - }, - "set_timezone": None, + "smartlife.iot.common.system": { + "set_dev_alias": set_alias, }, + "time": TIME_MODULE, + "smartlife.iot.common.timesetting": TIME_MODULE, # HS220 brightness, different setter and getter "smartlife.iot.dimmer": { "set_brightness": set_hs220_brightness, diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 7d6e45e02..28fcd4cb7 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -66,6 +66,7 @@ async def test_hsv(dev, turn_on): await dev.set_hsv(hue=1, saturation=1, value=1) + await dev.update() hue, saturation, brightness = dev.hsv assert hue == 1 assert saturation == 1 @@ -134,6 +135,7 @@ async def test_variable_temp_state_information(dev): async def test_try_set_colortemp(dev, turn_on): await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) + await dev.update() assert dev.color_temp == 2700 @@ -146,10 +148,11 @@ async def test_set_color_temp_transition(dev, mocker): @variable_temp -async def test_unknown_temp_range(dev, monkeypatch): - with pytest.raises(SmartDeviceException): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - dev.valid_temperature_range() +async def test_unknown_temp_range(dev, monkeypatch, caplog): + monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") + + assert dev.valid_temperature_range == (2700, 5000) + assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text @variable_temp @@ -179,9 +182,11 @@ async def test_dimmable_brightness(dev, turn_on): assert dev.is_dimmable await dev.set_brightness(50) + await dev.update() assert dev.brightness == 50 await dev.set_brightness(10) + await dev.update() assert dev.brightness == 10 with pytest.raises(ValueError): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 1b94d4897..864adb21d 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -18,7 +18,7 @@ async def test_state(dev, turn_on): await handle_turn_on(dev, turn_on) runner = CliRunner() res = await runner.invoke(state, obj=dev) - print(res.output) + await dev.update() if dev.is_on: assert "Device state: ON" in res.output @@ -32,6 +32,8 @@ async def test_alias(dev): res = await runner.invoke(alias, obj=dev) assert f"Alias: {dev.alias}" in res.output + old_alias = dev.alias + new_alias = "new alias" res = await runner.invoke(alias, [new_alias], obj=dev) assert f"Setting alias to {new_alias}" in res.output @@ -39,6 +41,8 @@ async def test_alias(dev): res = await runner.invoke(alias, obj=dev) assert f"Alias: {new_alias}" in res.output + await dev.set_alias(old_alias) + async def test_raw_command(dev): runner = CliRunner() @@ -63,11 +67,13 @@ async def test_emeter(dev: SmartDevice, mocker): assert "== Emeter ==" in res.output monthly = mocker.patch.object(dev, "get_emeter_monthly") + monthly.return_value = [] res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) assert "For year" in res.output monthly.assert_called() daily = mocker.patch.object(dev, "get_emeter_daily") + daily.return_value = [] res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) assert "For month" in res.output daily.assert_called() diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 529ad8d63..1356892e9 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -8,14 +8,14 @@ @plug async def test_type_detection_plug(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_plug assert d.device_type == DeviceType.Plug @bulb async def test_type_detection_bulb(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it if not d.is_light_strip: assert d.is_bulb @@ -24,21 +24,21 @@ async def test_type_detection_bulb(dev: SmartDevice): @strip async def test_type_detection_strip(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_strip assert d.device_type == DeviceType.Strip @dimmer async def test_type_detection_dimmer(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer @lightstrip async def test_type_detection_lightstrip(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") + d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip assert d.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index 907f24787..b3d567dd6 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -1,6 +1,6 @@ import pytest -from kasa import SmartDeviceException +from kasa import EmeterStatus, SmartDeviceException from .conftest import has_emeter, no_emeter, pytestmark from .newfakes import CURRENT_CONSUMPTION_SCHEMA @@ -22,9 +22,6 @@ async def test_no_emeter(dev): @has_emeter async def test_get_emeter_realtime(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter current_emeter = await dev.get_emeter_realtime() @@ -32,10 +29,8 @@ async def test_get_emeter_realtime(dev): @has_emeter +@pytest.mark.requires_dummy async def test_get_emeter_daily(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter assert await dev.get_emeter_daily(year=1900, month=1) == {} @@ -54,10 +49,8 @@ async def test_get_emeter_daily(dev): @has_emeter +@pytest.mark.requires_dummy async def test_get_emeter_monthly(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter assert await dev.get_emeter_monthly(year=1900) == {} @@ -77,9 +70,6 @@ async def test_get_emeter_monthly(dev): @has_emeter async def test_emeter_status(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - assert dev.has_emeter d = await dev.get_emeter_realtime() @@ -89,7 +79,7 @@ async def test_emeter_status(dev): assert d["power_mw"] == d["power"] * 1000 # bulbs have only power according to tplink simulator. - if not dev.is_bulb: + if not dev.is_bulb and not dev.is_light_strip: assert d["voltage_mv"] == d["voltage"] * 1000 assert d["current_ma"] == d["current"] * 1000 @@ -106,9 +96,6 @@ async def test_erase_emeter_stats(dev): @has_emeter async def test_current_consumption(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - if dev.has_emeter: x = await dev.current_consumption() assert isinstance(x, float) @@ -119,8 +106,6 @@ async def test_current_consumption(dev): async def test_emeterstatus_missing_current(): """KL125 does not report 'current' for emeter.""" - from kasa import EmeterStatus - regular = EmeterStatus( {"err_code": 0, "power_mw": 0, "total_wh": 13, "current_ma": 123} ) diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index a301095e6..3de3a1461 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -20,9 +20,11 @@ async def test_led(dev): original = dev.led await dev.set_led(False) + await dev.update() assert not dev.led await dev.set_led(True) + await dev.update() assert dev.led await dev.set_led(original) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 67081b034..380cdd1fb 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -4,8 +4,9 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import SmartDeviceException +from kasa.smartstrip import SmartStripPlug -from .conftest import handle_turn_on, pytestmark, turn_on +from .conftest import handle_turn_on, has_emeter, no_emeter, pytestmark, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -13,11 +14,29 @@ async def test_state_info(dev): assert isinstance(dev.state_information, dict) +@pytest.mark.requires_dummy async def test_invalid_connection(dev): with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): with pytest.raises(SmartDeviceException): await dev.update() - dev.is_on + + +@has_emeter +async def test_initial_update_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = None + spy = mocker.spy(dev.protocol, "query") + await dev.update() + assert spy.call_count == 2 + len(dev.children) + + +@no_emeter +async def test_initial_update_no_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = None + spy = mocker.spy(dev.protocol, "query") + await dev.update() + assert spy.call_count == 1 async def test_query_helper(dev): @@ -32,18 +51,22 @@ async def test_state(dev, turn_on): orig_state = dev.is_on if orig_state: await dev.turn_off() + await dev.update() assert not dev.is_on assert dev.is_off await dev.turn_on() + await dev.update() assert dev.is_on assert not dev.is_off else: await dev.turn_on() + await dev.update() assert dev.is_on assert not dev.is_off await dev.turn_off() + await dev.update() assert not dev.is_on assert dev.is_off @@ -54,9 +77,11 @@ async def test_alias(dev): assert isinstance(original, str) await dev.set_alias(test_alias) + await dev.update() assert dev.alias == test_alias await dev.set_alias(original) + await dev.update() assert dev.alias == original diff --git a/kasa/tests/test_strip.py b/kasa/tests/test_strip.py index 861b56ed3..21a11e372 100644 --- a/kasa/tests/test_strip.py +++ b/kasa/tests/test_strip.py @@ -15,20 +15,24 @@ async def test_children_change_state(dev, turn_on): orig_state = plug.is_on if orig_state: await plug.turn_off() - assert not plug.is_on - assert plug.is_off + await dev.update() + assert plug.is_on is False + assert plug.is_off is True await plug.turn_on() - assert plug.is_on - assert not plug.is_off + await dev.update() + assert plug.is_on is True + assert plug.is_off is False else: await plug.turn_on() - assert plug.is_on - assert not plug.is_off + await dev.update() + assert plug.is_on is True + assert plug.is_off is False await plug.turn_off() - assert not plug.is_on - assert plug.is_off + await dev.update() + assert plug.is_on is False + assert plug.is_off is True @strip diff --git a/poetry.lock b/poetry.lock index 534afae8b..63bbc2c22 100644 --- a/poetry.lock +++ b/poetry.lock @@ -8,7 +8,7 @@ python-versions = "*" [[package]] name = "anyio" -version = "3.1.0" +version = "3.3.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -24,14 +24,6 @@ doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "asyncclick" version = "7.1.2.3" @@ -80,6 +72,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pytz = ">=2015.7" +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.0" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + [[package]] name = "certifi" version = "2021.5.30" @@ -90,23 +97,26 @@ python-versions = "*" [[package]] name = "cfgv" -version = "3.3.0" +version = "3.3.1" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false python-versions = ">=3.6.1" [[package]] -name = "chardet" -version = "4.0.0" -description = "Universal encoding detector for Python 2 and 3" +name = "charset-normalizer" +version = "2.0.6" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] [[package]] name = "codecov" -version = "2.1.11" +version = "2.1.12" description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" category = "dev" optional = false @@ -137,7 +147,7 @@ toml = ["toml"] [[package]] name = "distlib" -version = "0.3.2" +version = "0.3.3" description = "Distribution utilities" category = "dev" optional = false @@ -161,7 +171,7 @@ python-versions = "*" [[package]] name = "identify" -version = "2.2.10" +version = "2.2.15" description = "File identification library for Python" category = "dev" optional = false @@ -172,11 +182,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "2.10" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "imagesize" @@ -188,7 +198,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.5.0" +version = "4.8.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -200,7 +210,8 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +perf = ["ipython"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "jinja2" @@ -246,7 +257,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.8.0" +version = "8.10.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -262,15 +273,27 @@ python-versions = "*" [[package]] name = "packaging" -version = "20.9" +version = "21.0" description = "Core utilities for Python packages" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" +[[package]] +name = "platformdirs" +version = "2.3.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -287,7 +310,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.13.0" +version = "2.15.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -312,7 +335,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.9.0" +version = "2.10.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = true @@ -363,17 +386,6 @@ pytest = ">=5.4.0" [package.extras] testing = ["coverage", "hypothesis (>=5.7.1)"] -[[package]] -name = "pytest-azurepipelines" -version = "0.8.0" -description = "Formatting PyTest output for Azure Pipelines UI" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -pytest = ">=3.5.0" - [[package]] name = "pytest-cov" version = "2.12.1" @@ -435,21 +447,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "requests" -version = "2.25.1" +version = "2.26.0" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "six" @@ -621,7 +633,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tox" -version = "3.23.1" +version = "3.24.4" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -644,7 +656,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytes [[package]] name = "typing-extensions" -version = "3.10.0.0" +version = "3.10.0.2" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -652,7 +664,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.5" +version = "1.26.7" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -665,26 +677,27 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.4.7" +version = "20.8.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -appdirs = ">=1.4.3,<2" +"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] name = "voluptuous" -version = "0.12.1" +version = "0.12.2" description = "" category = "dev" optional = false @@ -700,7 +713,7 @@ python-versions = "*" [[package]] name = "xdoctest" -version = "0.15.4" +version = "0.15.8" description = "A rewrite of the builtin doctest module" category = "dev" optional = false @@ -710,15 +723,15 @@ python-versions = "*" six = "*" [package.extras] -all = ["six", "pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +all = ["six", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] colors = ["pygments", "colorama"] jupyter = ["nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] optional = ["pygments", "colorama", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] -tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel"] +tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pytest", "pytest-cov", "pytest", "pytest", "pytest-cov", "typing", "nbformat", "nbconvert", "jupyter-client", "ipython", "ipykernel", "pytest", "pytest-cov"] [[package]] name = "zipp" -version = "3.4.1" +version = "3.5.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -726,7 +739,7 @@ python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] @@ -734,7 +747,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "c73c14c7f8588e3be3cd04a1b8cdcbcc32f2d042d8e30b58b7084b2b544ddb90" +content-hash = "e388fa366e9423e60697bfa77c37151094ca0367eb3ed441d61bb8cc7f055675" [metadata.files] alabaster = [ @@ -742,12 +755,8 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] anyio = [ - {file = "anyio-3.1.0-py3-none-any.whl", hash = "sha256:5e335cef65fbd1a422bbfbb4722e8e9a9fadbd8c06d5afe9cd614d12023f6e5a"}, - {file = "anyio-3.1.0.tar.gz", hash = "sha256:43e20711a9d003d858d694c12356dc44ab82c03ccc5290313c3392fa349dad0e"}, -] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, + {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"}, + {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"}, ] asyncclick = [ {file = "asyncclick-7.1.2.3.tar.gz", hash = "sha256:c26962b9957abe7ae09c058afbfea199dedea1b54343c1cc2ae1a6a291fab333"}, @@ -764,22 +773,26 @@ babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, + {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, +] certifi = [ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cfgv = [ - {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, - {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -chardet = [ - {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, - {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +charset-normalizer = [ + {file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"}, + {file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"}, ] codecov = [ - {file = "codecov-2.1.11-py2.py3-none-any.whl", hash = "sha256:ba8553a82942ce37d4da92b70ffd6d54cf635fc1793ab0a7dc3fecd6ebfb3df8"}, - {file = "codecov-2.1.11-py3.8.egg", hash = "sha256:e95901d4350e99fc39c8353efa450050d2446c55bac91d90fcfd2354e19a6aef"}, - {file = "codecov-2.1.11.tar.gz", hash = "sha256:6cde272454009d27355f9434f4e49f238c0273b216beda8472a65dc4957f473b"}, + {file = "codecov-2.1.12-py2.py3-none-any.whl", hash = "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47"}, + {file = "codecov-2.1.12-py3.8.egg", hash = "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635"}, + {file = "codecov-2.1.12.tar.gz", hash = "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -840,8 +853,8 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] distlib = [ - {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, - {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, + {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, + {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, ] docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, @@ -852,20 +865,20 @@ filelock = [ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] identify = [ - {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, - {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, + {file = "identify-2.2.15-py2.py3-none-any.whl", hash = "sha256:de83a84d774921669774a2000bf87ebba46b4d1c04775f4a5d37deff0cf39f73"}, + {file = "identify-2.2.15.tar.gz", hash = "sha256:528a88021749035d5a39fe2ba67c0642b8341aaf71889da0e1ed669a429b87f0"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, - {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, + {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, + {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, ] jinja2 = [ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, @@ -915,32 +928,36 @@ mistune = [ {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] more-itertools = [ - {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, - {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, + {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"}, + {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"}, ] nodeenv = [ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, +] +platformdirs = [ + {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, + {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, ] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] pygments = [ - {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, - {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -954,10 +971,6 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] -pytest-azurepipelines = [ - {file = "pytest-azurepipelines-0.8.0.tar.gz", hash = "sha256:944ae2c0790b792d123aa7312fe307bc35214dd26531728923ae5085a1d1feab"}, - {file = "pytest_azurepipelines-0.8.0-py3-none-any.whl", hash = "sha256:38b841a90e88d1966715966d7ea35619ed710386138a6a0b8fb5954c991ca4f1"}, -] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, @@ -997,8 +1010,8 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -1056,36 +1069,35 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tox = [ - {file = "tox-3.23.1-py2.py3-none-any.whl", hash = "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b"}, - {file = "tox-3.23.1.tar.gz", hash = "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3"}, + {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, + {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] urllib3 = [ - {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, - {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, - {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, + {file = "virtualenv-20.8.0-py2.py3-none-any.whl", hash = "sha256:a4b987ec31c3c9996cf1bc865332f967fe4a0512c41b39652d6224f696e69da5"}, + {file = "virtualenv-20.8.0.tar.gz", hash = "sha256:4da4ac43888e97de9cf4fdd870f48ed864bbfd133d2c46cbdec941fed4a25aef"}, ] voluptuous = [ - {file = "voluptuous-0.12.1-py3-none-any.whl", hash = "sha256:8ace33fcf9e6b1f59406bfaf6b8ec7bcc44266a9f29080b4deb4fe6ff2492386"}, - {file = "voluptuous-0.12.1.tar.gz", hash = "sha256:663572419281ddfaf4b4197fd4942d181630120fb39b333e3adad70aeb56444b"}, + {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] xdoctest = [ - {file = "xdoctest-0.15.4-py2.py3-none-any.whl", hash = "sha256:4b91eb67e45e51a254ff9370adb72a0c82b08289844c95cfd3a1186d7ec4f694"}, - {file = "xdoctest-0.15.4-py3-none-any.whl", hash = "sha256:33d4a12cf70da245ca3f71be9ef03e0615fa862826bf6a08e8f025ce693e496d"}, - {file = "xdoctest-0.15.4.tar.gz", hash = "sha256:ef1f93d2147988d3cb6f35c026ec32aca971923f86945a775f61e2f8de8505d1"}, + {file = "xdoctest-0.15.8-py2.py3-none-any.whl", hash = "sha256:566e2bb2135e144e66ccd390affbe4504a2e96c25ef16260843b9680326cadc9"}, + {file = "xdoctest-0.15.8-py3-none-any.whl", hash = "sha256:80a57af2f8ca709ab9da111ab3b16ec474f11297b9efcc34709a2c3e56ed9ce6"}, + {file = "xdoctest-0.15.8.tar.gz", hash = "sha256:ddd128780593161a7398fcfefc49f5f6dfe4c2eb2816942cb53768d43bcab7b9"}, ] zipp = [ - {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, - {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, + {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, + {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, ] diff --git a/pyproject.toml b/pyproject.toml index f35605993..87ddba9d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.0.dev3" +version = "0.4.0.dev4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] @@ -26,7 +26,6 @@ sphinxcontrib-programoutput = { version = "^0", optional = true } [tool.poetry.dev-dependencies] pytest = "^5" -pytest-azurepipelines = "^0" pytest-cov = "^2" pytest-asyncio = "^0" pytest-sugar = "*" @@ -72,6 +71,11 @@ fail-under = 100 exclude = ['kasa/tests/*'] verbose = 2 +[tool.pytest.ini_options] +markers = [ + "requires_dummy: test requires dummy data to pass, skipped on real devices", +] + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api"