From c79e8b43c97c6575880bd8355c924a21207e579b Mon Sep 17 00:00:00 2001 From: Brett Date: Thu, 6 Aug 2026 09:33:22 +1000 Subject: [PATCH] ci: adopt aiopowerwall's lint/test/build job shape, add Ruff Splits typecheck into a lint job (ruff + mypy), makes the test job fail outright when tests/test_*.py matches nothing instead of silently skipping, and adds a build job (uv build + twine check + artifact upload). Python matrix is unchanged. --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++--- AGENTS.md | 4 +++- pyproject.toml | 10 ++++++++ teslemetry_stream/__init__.py | 42 ++++++++++++++++----------------- teslemetry_stream/const.py | 3 ++- teslemetry_stream/energysite.py | 4 +++- teslemetry_stream/stream.py | 13 +++++----- teslemetry_stream/vehicle.py | 4 +++- 8 files changed, 79 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee7d2ff..dd27504 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ concurrency: cancel-in-progress: true jobs: - typecheck: - name: Typecheck + lint: + name: Lint & type-check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,6 +21,8 @@ jobs: enable-cache: true - name: Install dependencies run: uv sync + - name: Ruff + run: uv run --with ruff ruff check teslemetry_stream - name: Mypy run: uv run --with mypy mypy teslemetry_stream @@ -43,7 +45,32 @@ jobs: run: uv sync --python ${{ matrix.python-version }} - name: Run tests run: | - for f in tests/test_*.py; do + shopt -s nullglob + files=(tests/test_*.py) + if [ ${#files[@]} -eq 0 ]; then + echo "No test files found under tests/ - failing." >&2 + exit 1 + fi + for f in "${files[@]}"; do echo "Running $f" uv run python "$f" done + + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Build + run: uv build + - name: Check build artifacts + run: uv run --with twine twine check dist/* + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* diff --git a/AGENTS.md b/AGENTS.md index cfc0f7c..f437e8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,9 @@ This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. - Add durable project-specific notes here as they are discovered through real work. -- CI runs on push/PR: `.github/workflows/ci.yml`. Uses `uv` (see `uv.lock`). +- CI runs on push/PR: `.github/workflows/ci.yml`. Uses `uv` (see `uv.lock`). Three jobs: `lint` (ruff + mypy), `test` (matrix), `build` (`uv build` + `twine check` + artifact upload). Ruff and twine, like mypy, aren't declared dev dependencies - CI installs them ephemerally via `uv run --with ...`, matching the existing mypy pattern. +- `[tool.ruff]` in `pyproject.toml` selects `E, F, W, I, B, UP, ASYNC, SIM, RUF` and ignores `RUF006` - the stream's background `listen`/refresh tasks (`stream.py`, `vehicle.py`) are intentionally untracked fire-and-forget `asyncio.create_task` calls, not a lint oversight. +- The `test` job's step fails outright (non-zero exit) if `tests/test_*.py` matches nothing, rather than skipping - do not reintroduce a silent-skip fallback there. - `tests/` files are plain scripts (`if __name__ == "__main__"`), not pytest-based - pytest would collect zero tests here. Run each directly, e.g. `uv run python tests/test_field_type_coercion.py`. - `pyproject.toml` has a `[tool.mypy]` config but no dev-dependency group declares mypy, so `uv sync` alone won't install it. CI installs it ephemerally via `uv run --with mypy mypy teslemetry_stream`. - `Signal` in `const.py` tracks ; the config route rejects names it does not know with `fst_err_validation`. Fields the API has retired are not rejected - it accepts the request and names them in a top-level `ignoredFields` list - so the library can lag the published list without breaking. diff --git a/pyproject.toml b/pyproject.toml index 3fb8388..af8c5ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,16 @@ include = ["teslemetry_stream*"] [tool.setuptools.package-data] teslemetry_stream = ["py.typed"] +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "ASYNC", "SIM", "RUF"] +# RUF006: the stream's background listen/refresh tasks are intentionally +# fire-and-forget for the lifetime of the connection, not tracked handles. +ignore = ["RUF006"] + [tool.mypy] python_version = "3.9" check_untyped_defs = true diff --git a/teslemetry_stream/__init__.py b/teslemetry_stream/__init__.py index 462b671..b1c7d5e 100644 --- a/teslemetry_stream/__init__.py +++ b/teslemetry_stream/__init__.py @@ -1,33 +1,33 @@ -from .stream import TeslemetryStream -from .vehicle import TeslemetryStreamVehicle +from .const import ( + SSE_ALL_TOPICS, + SSE_ENERGY_TOPICS, + SSE_VEHICLE_TOPICS, + Alert, + Signal, + SseTopic, +) from .energysite import TeslemetryStreamEnergySite from .exception import ( - TeslemetryStreamError, TeslemetryStreamConnectionError, + TeslemetryStreamEnded, + TeslemetryStreamError, TeslemetryStreamVehicleNotConfigured, - TeslemetryStreamEnded -) -from .const import ( - Signal, - Alert, - SseTopic, - SSE_VEHICLE_TOPICS, - SSE_ENERGY_TOPICS, - SSE_ALL_TOPICS, ) +from .stream import TeslemetryStream +from .vehicle import TeslemetryStreamVehicle __all__ = [ + "SSE_ALL_TOPICS", + "SSE_ENERGY_TOPICS", + "SSE_VEHICLE_TOPICS", + "Alert", + "Signal", + "SseTopic", "TeslemetryStream", - "TeslemetryStreamVehicle", + "TeslemetryStreamConnectionError", + "TeslemetryStreamEnded", "TeslemetryStreamEnergySite", "TeslemetryStreamError", - "TeslemetryStreamConnectionError", + "TeslemetryStreamVehicle", "TeslemetryStreamVehicleNotConfigured", - "TeslemetryStreamEnded", - "Signal", - "Alert", - "SseTopic", - "SSE_VEHICLE_TOPICS", - "SSE_ENERGY_TOPICS", - "SSE_ALL_TOPICS", ] diff --git a/teslemetry_stream/const.py b/teslemetry_stream/const.py index 15f0653..967744a 100644 --- a/teslemetry_stream/const.py +++ b/teslemetry_stream/const.py @@ -1,4 +1,5 @@ from __future__ import annotations + from dataclasses import dataclass from enum import Enum from functools import cached_property @@ -416,7 +417,7 @@ class EnergyHistoryTotals: total_grid_energy_exported: float | None @classmethod - def from_dict(cls, data: dict[str, float | None]) -> "EnergyHistoryTotals": + def from_dict(cls, data: dict[str, float | None]) -> EnergyHistoryTotals: """Build from the event's `totals` dict.""" return cls(**{field: data.get(field) for field in cls.__dataclass_fields__}) diff --git a/teslemetry_stream/energysite.py b/teslemetry_stream/energysite.py index d1099f5..85978e1 100644 --- a/teslemetry_stream/energysite.py +++ b/teslemetry_stream/energysite.py @@ -1,7 +1,9 @@ """Energy site class for handling streaming live_status and site_info updates.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any from .const import EnergyHistoryTotals, Key diff --git a/teslemetry_stream/stream.py b/teslemetry_stream/stream.py index 48aec65..3ba6988 100644 --- a/teslemetry_stream/stream.py +++ b/teslemetry_stream/stream.py @@ -1,15 +1,17 @@ from __future__ import annotations + import asyncio import json import logging +from collections.abc import Awaitable, Callable, Iterable from datetime import datetime, timezone -from typing import Any, Awaitable, Callable, Iterable, cast +from typing import Any, cast import aiohttp +from .energysite import TeslemetryStreamEnergySite from .exception import TeslemetryStreamEnded from .vehicle import TeslemetryStreamVehicle -from .energysite import TeslemetryStreamEnergySite LOGGER = logging.getLogger(__package__) @@ -305,7 +307,7 @@ async def __anext__(self) -> dict[str, Any]: return cast(dict[str, Any], data) raise TeslemetryStreamEnded() except StopAsyncIteration as e: - # Re-raise StopAsyncIteration explicitly to ensure it's not caught by the general Exception handler + # Re-raise explicitly so it isn't caught by the generic Exception handler below self.disconnect() raise e except TeslemetryStreamEnded: @@ -419,9 +421,8 @@ def recursive_match(dict1: dict[str, Any] | None, dict2: dict[str, Any]) -> bool for item1 in value1 ): return False - elif value1 is not None: + elif value1 is not None and value1 != value2: # Check the value matches - if value1 != value2: - return False + return False # No differences found return True diff --git a/teslemetry_stream/vehicle.py b/teslemetry_stream/vehicle.py index dbfbfc6..6bf9e7e 100644 --- a/teslemetry_stream/vehicle.py +++ b/teslemetry_stream/vehicle.py @@ -1,10 +1,12 @@ """Vehicle class for handling streaming field updates.""" from __future__ import annotations + import asyncio import logging +from collections.abc import Callable from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast import aiohttp