From b827597dabca762fc6c368c70276ffcabe548992 Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Sat, 6 Apr 2024 14:13:10 +0200 Subject: [PATCH] Revert unique id for now, added dev files --- .github/workflows/ci.yml | 56 ++++++++++++++ .pre-commit-config.yaml | 89 ++++++++++++++++++++++ .prettierrc | 3 + .yamlllint | 59 ++++++++++++++ bandit.yaml | 20 +++++ custom_components/garmin_connect/sensor.py | 49 +++++------- mypy.ini | 19 +++++ pylintrc | 81 ++++++++++++++++++++ requirements.txt | 6 +- scripts/install/pip_packages | 9 +++ setup.cfg | 21 +++++ 11 files changed, 381 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .prettierrc create mode 100644 .yamlllint create mode 100644 bandit.yaml create mode 100644 mypy.ini create mode 100644 pylintrc create mode 100644 scripts/install/pip_packages create mode 100644 setup.cfg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0034ba8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: CI + +on: + push: + branches: + - main + pull_request: ~ + schedule: + - cron: "0 0 * * *" + +env: + DEFAULT_PYTHON: "3.11" + +jobs: + validate-hacs: + runs-on: "ubuntu-latest" + name: Validate with HACS + steps: + - uses: "actions/checkout@v4" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + validate-hassfest: + runs-on: "ubuntu-latest" + name: Validate with Hassfest + steps: + - uses: "actions/checkout@v4" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + code-quality: + runs-on: "ubuntu-latest" + name: Check code quality + steps: + - uses: "actions/checkout@v4" + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + id: python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache: "pip" + - name: Install dependencies + run: | + pip install -r requirements.txt + # Following steps cannot run by pre-commit.ci as repo = local + - name: Run mypy + run: mypy custom_components/ + - name: Pylint review + run: pylint custom_components/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a924023 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,89 @@ +ci: + skip: + - mypy + - pylint + +default_language_version: + python: python3.11 + +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + - repo: https://github.com/psf/black + rev: 23.12.1 + hooks: + - id: black + args: + - --safe + - --quiet + <<: &python-files-with-tests + files: ^((custom_components|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.6.0 + - pydocstyle==6.1.1 + <<: &python-files + files: ^(custom_components/.+)?[^/]+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.7.6 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=bandit.yaml + <<: *python-files-with-tests + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: detect-private-key + - id: no-commit-to-branch + - id: requirements-txt-fixer + - id: mixed-line-ending + args: + - --fix=lf + stages: [manual] + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + additional_dependencies: + - prettier@2.7.1 + - prettier-plugin-sort-json@0.0.3 + exclude_types: + - python + exclude: manifest\.json$ + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.33.0 + hooks: + - id: yamllint + - repo: local + hooks: + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy + name: Check with mypy + entry: scripts/run-in-env.sh mypy + language: script + types: [python] + <<: *python-files + - id: pylint + name: Check with pylint + entry: scripts/run-in-env.sh pylint + language: script + types: [python] + <<: *python-files \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a9ef17f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "jsonRecursiveSort": true +} \ No newline at end of file diff --git a/.yamlllint b/.yamlllint new file mode 100644 index 0000000..943780c --- /dev/null +++ b/.yamlllint @@ -0,0 +1,59 @@ +rules: + braces: + level: error + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + brackets: + level: error + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + colons: + level: error + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: error + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: + level: error + require-starting-space: true + min-spaces-from-content: 2 + comments-indentation: + level: error + document-end: + level: error + present: false + document-start: + level: error + present: false + empty-lines: + level: error + max: 1 + max-start: 0 + max-end: 1 + hyphens: + level: error + max-spaces-after: 1 + indentation: + level: error + spaces: 2 + indent-sequences: true + check-multi-line-strings: false + key-duplicates: + level: error + line-length: disable + new-line-at-end-of-file: + level: error + new-lines: + level: error + type: unix + trailing-spaces: + level: error + truthy: + disable \ No newline at end of file diff --git a/bandit.yaml b/bandit.yaml new file mode 100644 index 0000000..debcca5 --- /dev/null +++ b/bandit.yaml @@ -0,0 +1,20 @@ +# https://bandit.readthedocs.io/en/latest/config.html + +tests: + - B103 + - B108 + - B306 + - B307 + - B313 + - B314 + - B315 + - B316 + - B317 + - B318 + - B319 + - B320 + - B601 + - B602 + - B604 + - B608 + - B609 \ No newline at end of file diff --git a/custom_components/garmin_connect/sensor.py b/custom_components/garmin_connect/sensor.py index 81ce3b5..df1be72 100644 --- a/custom_components/garmin_connect/sensor.py +++ b/custom_components/garmin_connect/sensor.py @@ -9,7 +9,6 @@ import datetime from tzlocal import get_localzone from homeassistant.components.sensor import ( - ENTITY_ID_FORMAT, SensorEntity, SensorDeviceClass, SensorStateClass, @@ -20,11 +19,10 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ID, ) - -from homeassistant.const import CONF_ID, CONF_USERNAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity import DeviceInfo, generate_entity_id +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -50,14 +48,14 @@ async def async_setup_entry( coordinator: DataUpdateCoordinator = hass.data[GARMIN_DOMAIN][entry.entry_id][ DATA_COORDINATOR ] - device_id = entry.data[CONF_ID] - user_identifier = entry.data[CONF_USERNAME].split("@")[0] + unique_id = entry.data[CONF_ID] entities = [] for ( sensor_type, (name, unit, icon, device_class, state_class, enabled_by_default), ) in GARMIN_ENTITY_LIST.items(): + _LOGGER.debug( "Registering entity: %s, %s, %s, %s, %s, %s, %s", sensor_type, @@ -71,8 +69,7 @@ async def async_setup_entry( entities.append( GarminConnectSensor( coordinator, - device_id, - user_identifier, + unique_id, sensor_type, name, unit, @@ -87,8 +84,7 @@ async def async_setup_entry( entities.append( GarminConnectGearSensor( coordinator, - device_id, - user_identifier, + unique_id, gear_item[GEAR.UUID], gear_item["gearTypeName"], gear_item["displayName"], @@ -141,10 +137,9 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity): def __init__( self, coordinator, - device_id, - user_identifier, + unique_id, sensor_type, - sensor_name, + name, unit, icon, device_class, @@ -154,22 +149,17 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity): """Initialize a Garmin Connect sensor.""" super().__init__(coordinator) - self._device_id = device_id + self._unique_id = unique_id self._type = sensor_type self._device_class = device_class self._state_class = state_class self._enabled_default = enabled_default - self._attr_name = f"{user_identifier} {sensor_name}" - self.entity_id = generate_entity_id( - ENTITY_ID_FORMAT, - f"{GARMIN_DOMAIN} {self._attr_name}", - hass=coordinator.hass, - ) + self._attr_name = name self._attr_device_class = self._device_class self._attr_icon = icon self._attr_native_unit_of_measurement = unit - self._attr_unique_id = f"{GARMIN_DOMAIN}_{self._device_id}_{self._type}" + self._attr_unique_id = f"{self._unique_id}_{self._type}" self._attr_state_class = state_class @property @@ -233,7 +223,7 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity): def device_info(self) -> DeviceInfo: """Return device information.""" return { - "identifiers": {(GARMIN_DOMAIN, self._device_id)}, + "identifiers": {(GARMIN_DOMAIN, self._unique_id)}, "name": "Garmin Connect", "manufacturer": "Garmin Connect", } @@ -259,28 +249,27 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): def __init__( self, coordinator, - device_id, - user_identifier, + unique_id, uuid, sensor_type, - sensor_name, + name, device_class: None, enabled_default: bool = True, ): """Initialize a Garmin Connect sensor.""" super().__init__(coordinator) - self._device_id = device_id + self._unique_id = unique_id self._type = sensor_type self._uuid = uuid self._device_class = device_class self._enabled_default = enabled_default - self._attr_name = f"{user_identifier} {sensor_name}" + self._attr_name = name self._attr_device_class = self._device_class self._attr_icon = GEAR_ICONS[sensor_type] self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS - self._attr_unique_id = f"{GARMIN_DOMAIN}_{self._device_id}_{self._uuid}" + self._attr_unique_id = f"{self._unique_id}_{self._uuid}" self._attr_state_class = SensorStateClass.TOTAL self._attr_device_class = "garmin_gear" @@ -348,7 +337,7 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): def device_info(self) -> DeviceInfo: """Return device information.""" return { - "identifiers": {(GARMIN_DOMAIN, self._device_id)}, + "identifiers": {(GARMIN_DOMAIN, self._unique_id)}, "name": "Garmin Connect", "manufacturer": "Garmin Connect", } @@ -382,4 +371,4 @@ class GarminConnectGearSensor(CoordinatorEntity, SensorEntity): lambda d: d[GEAR.UUID] == self.uuid and d["defaultGear"] is True, self.coordinator.data["gear_defaults"], ) - ) + ) \ No newline at end of file diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..c563eb1 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,19 @@ +[mypy] +python_version = 3.11 +show_error_codes = true +follow_imports = silent +ignore_missing_imports = true +strict_equality = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true \ No newline at end of file diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..0a23b7b --- /dev/null +++ b/pylintrc @@ -0,0 +1,81 @@ +[MASTER] +ignore=tests +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs=2 + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + useless-suppression, + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +# load-plugins= + +# Pickle collected data for later comparisons. +persistent=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist=ciso8601, + cv2 + + +[BASIC] +good-names=i,j,k,ex,_,T,x,y,id + +[MESSAGES CONTROL] +# Reasons disabled: +# format - handled by black +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# too-many-* - are not enforced for the sake of readability +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# wrong-import-order - isort guards this +disable= + format, + abstract-class-little-used, + abstract-method, + cyclic-import, + duplicate-code, + inconsistent-return-statements, + too-many-instance-attributes, + wrong-import-order, + too-few-public-methods + +# enable useless-suppression temporarily every now and then to clean them up +enable= + useless-suppression, + use-symbolic-message-instead, + +[REPORTS] +score=no + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + +[FORMAT] +expected-line-ending-format=LF + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 92c046b..50e15ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ colorlog==6.8.2 homeassistant==2024.1.0 pip>=21.0,<24.1 -ruff==0.3.5 \ No newline at end of file +ruff==0.3.5 +mypy==1.8.0 +pre-commit==3.6.0 +pylint==3.0.3 +types-cachetools \ No newline at end of file diff --git a/scripts/install/pip_packages b/scripts/install/pip_packages new file mode 100644 index 0000000..45f5262 --- /dev/null +++ b/scripts/install/pip_packages @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e + +python3 -m pip \ + install \ + --upgrade \ + --disable-pip-version-check \ + "${@}" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0df93d1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# D107 Missing docstring in __init__ +ignore = + E501, + W503, + E203, + D202, + D107 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +profile = black \ No newline at end of file