diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..bfc8182 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,26 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/mypy.ini + +[mypy] +python_version = 3.12 +platform = linux +show_error_codes = true +follow_imports = normal +local_partial_types = true +strict_equality = true +#strict_bytes = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +enable_error_code = ignore-without-code, redundant-self, truthy-iterable +disable_error_code = annotation-unchecked, import-not-found, import-untyped +extra_checks = false +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c31c093 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +*.md +.strict-typing +custom_components/*/translations/*.json \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e2e657b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,342 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +[MAIN] +# Specify the Python version +py-version=3.12 + +# 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 + +#init-hook = """\ +# from pathlib import Path; \ +# import sys; \ +# +# from pylint.config import find_default_config_files; \ +# +# sys.path.append( \ +# str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) +# ) \ +# """ + +load-plugins= + pylint.extensions.code_style, + pylint.extensions.typing +# hass_decorator, +# hass_enforce_class_module, +# hass_enforce_sorted_platforms, +# hass_enforce_super_call + +persistent = false +extension-pkg-allow-list = [ + "av.audio.stream", + "av.logging", + "av.stream", + "ciso8601", + "orjson", + "cv2", +] +fail-on = [ + "I", +] + +[BASIC] +class-const-naming-style = "any" + +["MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by ruff +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# possibly-used-before-assignment - too many errors / not necessarily issues +# --- +# Pylint CodeStyle plugin +# consider-using-namedtuple-or-dataclass - too opinionated +# consider-using-assignment-expr - decision to use := better left to devs +disable = [ + "format", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-boolean-expressions", + "too-many-positional-arguments", + "wrong-import-order", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", + "possibly-used-before-assignment", + + # Handled by ruff + # Ref: + "await-outside-async", # PLE1142 + "bad-str-strip-call", # PLE1310 + "bad-string-format-type", # PLE1307 + "bidirectional-unicode", # PLE2502 + "continue-in-finally", # PLE0116 + "duplicate-bases", # PLE0241 + "misplaced-bare-raise", # PLE0704 + "format-needs-mapping", # F502 + "function-redefined", # F811 + # Needed because ruff does not understand type of __all__ generated by a function + # "invalid-all-format", # PLE0605 + "invalid-all-object", # PLE0604 + "invalid-character-backspace", # PLE2510 + "invalid-character-esc", # PLE2513 + "invalid-character-nul", # PLE2514 + "invalid-character-sub", # PLE2512 + "invalid-character-zero-width-space", # PLE2515 + "logging-too-few-args", # PLE1206 + "logging-too-many-args", # PLE1205 + "missing-format-string-key", # F524 + "mixed-format-string", # F506 + "no-method-argument", # N805 + "no-self-argument", # N805 + "nonexistent-operator", # B002 + "nonlocal-without-binding", # PLE0117 + "not-in-loop", # F701, F702 + "notimplemented-raised", # F901 + "return-in-init", # PLE0101 + "return-outside-function", # F706 + "syntax-error", # E999 + "too-few-format-args", # F524 + "too-many-format-args", # F522 + "too-many-star-expressions", # F622 + "truncated-format-string", # F501 + "undefined-all-variable", # F822 + "undefined-variable", # F821 + "used-prior-global-declaration", # PLE0118 + "yield-inside-async-function", # PLE1700 + "yield-outside-function", # F704 + "anomalous-backslash-in-string", # W605 + "assert-on-string-literal", # PLW0129 + "assert-on-tuple", # F631 + "bad-format-string", # W1302, F + "bad-format-string-key", # W1300, F + "bare-except", # E722 + "binary-op-exception", # PLW0711 + "cell-var-from-loop", # B023 + # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work + "duplicate-except", # B014 + "duplicate-key", # F601 + "duplicate-string-formatting-argument", # F + "duplicate-value", # F + "eval-used", # S307 + "exec-used", # S102 + "expression-not-assigned", # B018 + "f-string-without-interpolation", # F541 + "forgotten-debug-statement", # T100 + "format-string-without-interpolation", # F + # "global-statement", # PLW0603, ruff catches new occurrences, needs more work + "global-variable-not-assigned", # PLW0602 + "implicit-str-concat", # ISC001 + "import-self", # PLW0406 + "inconsistent-quotes", # Q000 + "invalid-envvar-default", # PLW1508 + "keyword-arg-before-vararg", # B026 + "logging-format-interpolation", # G + "logging-fstring-interpolation", # G + "logging-not-lazy", # G + "misplaced-future", # F404 + "named-expr-without-context", # PLW0131 + "nested-min-max", # PLW3301 + "pointless-statement", # B018 + "raise-missing-from", # B904 + "redefined-builtin", # A001 + "try-except-raise", # TRY302 + "unused-argument", # ARG001, we don't use it + "unused-format-string-argument", #F507 + "unused-format-string-key", # F504 + "unused-import", # F401 + "unused-variable", # F841 + "useless-else-on-loop", # PLW0120 + "wildcard-import", # F403 + "bad-classmethod-argument", # N804 + "consider-iterating-dictionary", # SIM118 + "empty-docstring", # D419 + "invalid-name", # N815 + "line-too-long", # E501, disabled globally + "missing-class-docstring", # D101 + "missing-final-newline", # W292 + "missing-function-docstring", # D103 + "missing-module-docstring", # D100 + "multiple-imports", #E401 + "singleton-comparison", # E711, E712 + "subprocess-run-check", # PLW1510 + "superfluous-parens", # UP034 + "ungrouped-imports", # I001 + "unidiomatic-typecheck", # E721 + "unnecessary-direct-lambda-call", # PLC3002 + "unnecessary-lambda-assignment", # PLC3001 + "unnecessary-pass", # PIE790 + "unneeded-not", # SIM208 + "useless-import-alias", # PLC0414 + "wrong-import-order", # I001 + "wrong-import-position", # E402 + "comparison-of-constants", # PLR0133 + "comparison-with-itself", # PLR0124 + "consider-alternative-union-syntax", # UP007 + "consider-merging-isinstance", # PLR1701 + "consider-using-alias", # UP006 + "consider-using-dict-comprehension", # C402 + "consider-using-generator", # C417 + "consider-using-get", # SIM401 + "consider-using-set-comprehension", # C401 + "consider-using-sys-exit", # PLR1722 + "consider-using-ternary", # SIM108 + "literal-comparison", # F632 + "property-with-parameters", # PLR0206 + "super-with-arguments", # UP008 + "too-many-branches", # PLR0912 + "too-many-return-statements", # PLR0911 + "too-many-statements", # PLR0915 + "trailing-comma-tuple", # COM818 + "unnecessary-comprehension", # C416 + "use-a-generator", # C417 + "use-dict-literal", # C406 + "use-list-literal", # C405 + "useless-object-inheritance", # UP004 + "useless-return", # PLR1711 + "no-else-break", # RET508 + "no-else-continue", # RET507 + "no-else-raise", # RET506 + "no-else-return", # RET505 + "broad-except", # BLE001 + "protected-access", # SLF001 + "broad-exception-raised", # TRY002 + "consider-using-f-string", # PLC0209 + # "no-self-use", # PLR6301 # Optional plugin, not enabled + + # Handled by mypy + # Ref: + "abstract-class-instantiated", + "arguments-differ", + "assigning-non-slot", + "assignment-from-no-return", + "assignment-from-none", + "bad-exception-cause", + "bad-format-character", + "bad-reversed-sequence", + "bad-super-call", + "bad-thread-instantiation", + "catching-non-exception", + "comparison-with-callable", + "deprecated-class", + "dict-iter-missing-items", + "format-combined-specification", + "global-variable-undefined", + "import-error", + "inconsistent-mro", + "inherit-non-class", + "init-is-generator", + "invalid-class-object", + "invalid-enum-extension", + "invalid-envvar-value", + "invalid-format-returned", + "invalid-hash-returned", + "invalid-metaclass", + "invalid-overridden-method", + "invalid-repr-returned", + "invalid-sequence-index", + "invalid-slice-index", + "invalid-slots-object", + "invalid-slots", + "invalid-star-assignment-target", + "invalid-str-returned", + "invalid-unary-operand-type", + "invalid-unicode-codec", + "isinstance-second-argument-not-valid-type", + "method-hidden", + "misplaced-format-function", + "missing-format-argument-key", + "missing-format-attribute", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "non-iterator-returned", + "non-str-assignment-to-dunder-name", + "nonlocal-and-global", + "not-a-mapping", + "not-an-iterable", + "not-async-context-manager", + "not-callable", + "not-context-manager", + "overridden-final-method", + "raising-bad-type", + "raising-non-exception", + "redundant-keyword-arg", + "relative-beyond-top-level", + "self-cls-assignment", + "signature-differs", + "star-needs-assignment-target", + "subclassed-final-class", + "super-without-brackets", + "too-many-function-args", + "typevar-double-variance", + "typevar-name-mismatch", + "unbalanced-dict-unpacking", + "unbalanced-tuple-unpacking", + "unexpected-keyword-arg", + "unhashable-member", + "unpacking-non-sequence", + "unsubscriptable-object", + "unsupported-assignment-operation", + "unsupported-binary-operation", + "unsupported-delete-operation", + "unsupported-membership-test", + "used-before-assignment", + "using-final-decorator-in-unsupported-version", + "wrong-exception-operation", +] +enable = [ + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", +] +per-file-ignores = [ + # redefined-outer-name: Tests reference fixtures in the test function + # use-implicit-booleaness-not-comparison: Tests need to validate that a list + # or a dict is returned + "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", +] + +[REPORTS] +score = false + +[TYPECHECK] +ignored-classes = [ + "_CountingAttr", # for attrs +] +mixin-class-rgx = ".*[Mm]ix[Ii]n" + +[FORMAT] +expected-line-ending-format = "LF" + +[EXCEPTIONS] +overgeneral-exceptions = [ + "builtins.BaseException", + "builtins.Exception", + # "homeassistant.exceptions.HomeAssistantError", # too many issues +] + +[TYPING] +runtime-typing = false + +[CODE_STYLE] +max-line-length-suggestions = 72 diff --git a/.ruff.toml b/.ruff.toml index 378775c..7fa0b5e 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,6 +2,9 @@ target-version = "py312" +[tool.ruff] +required-version = ">=0.8.0" + [lint] select = [ "A001", # Variable {name} is shadowing a Python builtin @@ -31,6 +34,7 @@ select = [ "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake + "F541", # f-string without any placeholders "FLY", # flynt "FURB", # refurb "G", # flake8-logging-format @@ -47,6 +51,7 @@ select = [ "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style + "PTH", # flake8-pathlib "PYI", # flake8-pyi "RET", # flake8-return "RSE", # flake8-raise @@ -81,9 +86,12 @@ select = [ "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print - "TID251", # Banned imports + "TC", # flake8-type-checking + "TID", # Tidy imports "TRY", # tryceratops "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call "W", # pycodestyle ] @@ -102,7 +110,6 @@ ignore = [ "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "PT004", # Fixture {fixture} does not return anything, add leading underscore "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT018", # Assertion should be broken down into multiple parts "RUF001", # String contains ambiguous unicode character. @@ -113,6 +120,12 @@ ignore = [ "SIM103", # Return the condition {condition} directly "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files + + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 @@ -134,8 +147,22 @@ ignore = [ "PLE0605" ] +[lint.flake8-import-conventions.extend-aliases] +voluptuous = "vol" +"homeassistant.core.DOMAIN" = "HOMEASSISTANT_DOMAIN" +"homeassistant.helpers.area_registry" = "ar" +"homeassistant.helpers.category_registry" = "cr" +"homeassistant.helpers.config_validation" = "cv" +"homeassistant.helpers.device_registry" = "dr" +"homeassistant.helpers.entity_registry" = "er" +"homeassistant.helpers.floor_registry" = "fr" +"homeassistant.helpers.issue_registry" = "ir" +"homeassistant.helpers.label_registry" = "lr" +"homeassistant.util.dt" = "dt_util" + [lint.flake8-pytest-style] fixture-parentheses = false +mark-parentheses = false [lint.flake8-tidy-imports.banned-api] "async_timeout".msg = "use asyncio.timeout instead" @@ -145,12 +172,21 @@ fixture-parentheses = false force-sort-within-sections = true known-first-party = [ "homeassistant", + "pylint", ] combine-as-imports = true split-on-trailing-comma = false -[lint.pyupgrade] -keep-runtime-typing = true +[lint.per-file-ignores] +# Allow for main entry & scripts to write to stdout +"scripts/*" = ["T20"] + +# Allow relative imports within auth and within components +"custom_components/*/*/*" = ["TID252"] +"tests/custom_components/*/*/*" = ["TID252"] + +# Temporary +"tests/**" = ["PTH"] [lint.mccabe] -max-complexity = 25 \ No newline at end of file +max-complexity = 25 diff --git a/.strict-typing b/.strict-typing new file mode 100644 index 0000000..6ab102e --- /dev/null +++ b/.strict-typing @@ -0,0 +1 @@ +custom_components.* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 579699e..736cd25 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,6 @@ "python.testing.pytestEnabled": false, // https://code.visualstudio.com/docs/python/linting#_general-settings "pylint.importStrategy": "fromEnvironment", - "python.analysis.typeCheckingMode": "basic", + // "python.analysis.typeCheckingMode": "basic", "python.analysis.diagnosticMode": "workspace", } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bc5e249..28f7a83 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,12 +1,6 @@ { "version": "2.0.0", "tasks": [ - { - "label": "Run Home Assistant on port 8123", - "type": "shell", - "command": "scripts/develop", - "problemMatcher": [] - }, { "label": "Activate environment", "type": "shell", diff --git a/custom_components/volvo_cars/coordinator.py b/custom_components/volvo_cars/coordinator.py index 85d58bd..4aec49e 100644 --- a/custom_components/volvo_cars/coordinator.py +++ b/custom_components/volvo_cars/coordinator.py @@ -64,7 +64,7 @@ def __init__( update_interval=timedelta(minutes=2, seconds=15), ) - self.config_entry = entry + self.config_entry: VolvoCarsConfigEntry = entry self.api = api self._auth_api = auth_api diff --git a/custom_components/volvo_cars/entry_data.py b/custom_components/volvo_cars/entry_data.py index 70ea46d..4cb995f 100644 --- a/custom_components/volvo_cars/entry_data.py +++ b/custom_components/volvo_cars/entry_data.py @@ -26,6 +26,3 @@ class StoreData(TypedDict): class VolvoCarsStore(Store[StoreData]): """Volvo Cars storage.""" - - async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): - pass diff --git a/custom_components/volvo_cars/sensor.py b/custom_components/volvo_cars/sensor.py index 5569f89..58d8b26 100644 --- a/custom_components/volvo_cars/sensor.py +++ b/custom_components/volvo_cars/sensor.py @@ -3,14 +3,10 @@ from collections.abc import Callable from dataclasses import dataclass from decimal import Decimal -from typing import Any +from typing import Any, cast -from homeassistant.components.sensor import ( - SensorDeviceClass, # type: ignore # noqa: PGH003 - SensorEntity, - SensorEntityDescription, - SensorStateClass, # type: ignore # noqa: PGH003 -) +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,23 +35,26 @@ class VolvoCarsSensorDescription(VolvoCarsDescription, SensorEntityDescription): def _availability_status(field: VolvoCarsValue, _: VolvoCarsConfigEntry) -> str: - reason = field.get("unavailable_reason") - return reason if reason else field.value + reason = cast(str, field.get("unavailable_reason")) + return reason if reason else cast(str, field.value) def _calculate_time_to_service(field: VolvoCarsValue, _: VolvoCarsConfigEntry) -> int: + value = cast(int, field.value) + # Always express value in days if isinstance(field, VolvoCarsValueField) and field.unit == "months": - return field.value * 30 + return value * 30 - return field.value + return value def _calculate_engine_time_to_service( field: VolvoCarsValue, _: VolvoCarsConfigEntry ) -> int: # Express value in days instead of hours - return round(field.value / 24) + value = cast(int, field.value) + return round(value / 24) def _determine_fuel_consumption_unit(entry: VolvoCarsConfigEntry) -> str: @@ -70,15 +69,16 @@ def _determine_fuel_consumption_unit(entry: VolvoCarsConfigEntry) -> str: def _convert_fuel_consumption( field: VolvoCarsValue, entry: VolvoCarsConfigEntry ) -> Decimal: + value = cast(Decimal, field.value) unit_key = entry.options[OPT_FUEL_CONSUMPTION_UNIT] if unit_key == OPT_UNIT_MPG_UK: - return round(Decimal(282.481) / Decimal(field.value), 2) + return round(Decimal(282.481) / value, 2) if unit_key == OPT_UNIT_MPG_US: - return round(Decimal(235.215) / Decimal(field.value), 2) + return round(Decimal(235.215) / value, 2) - return field.value + return value # pylint: disable=unexpected-keyword-arg diff --git a/custom_components/volvo_cars/volvo/auth.py b/custom_components/volvo_cars/volvo/auth.py index d8380fb..67899a6 100644 --- a/custom_components/volvo_cars/volvo/auth.py +++ b/custom_components/volvo_cars/volvo/auth.py @@ -238,7 +238,9 @@ async def _async_refresh_token(self, refresh_token: str) -> TokenResponse | None response.raise_for_status() return TokenResponse.from_dict(json) - async def _handle_status_completed(self, data, status) -> AuthorizationModel: + async def _handle_status_completed( + self, data: dict, status: str + ) -> AuthorizationModel: code = data["authorizeResponse"]["code"] auth = await self._async_request_token(code) return AuthorizationModel(status, token=auth) diff --git a/custom_components/volvo_cars/volvo/models.py b/custom_components/volvo_cars/volvo/models.py index 9978145..de37b4c 100644 --- a/custom_components/volvo_cars/volvo/models.py +++ b/custom_components/volvo_cars/volvo/models.py @@ -112,10 +112,10 @@ class VolvoCarsValueField(VolvoCarsValue): timestamp: datetime unit: str | None = None - def __post_init__(self): + def __post_init__(self) -> None: """Post initialization.""" - if isinstance(self.timestamp, str): - self.timestamp = datetime.fromisoformat(self.timestamp) + if isinstance(self.timestamp, str): # type: ignore[unreachable] + self.timestamp = datetime.fromisoformat(self.timestamp) # type: ignore[unreachable] @dataclass @@ -132,10 +132,10 @@ class VolvoCarsLocationProperties(VolvoCarsApiBaseModel): heading: str timestamp: datetime - def __post_init__(self): + def __post_init__(self) -> None: """Post initialization.""" - if isinstance(self.timestamp, str): - self.timestamp = datetime.fromisoformat(self.timestamp) + if isinstance(self.timestamp, str): # type: ignore[unreachable] + self.timestamp = datetime.fromisoformat(self.timestamp) # type: ignore[unreachable] @dataclass