Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}
},
"image": "mcr.microsoft.com/devcontainers/python:3.11-bookworm",
"image": "mcr.microsoft.com/devcontainers/python:3-3.14-bookworm",
"name": "A Python3, async interface to the Hilo API",
"postCreateCommand": "uv sync --group dev --group test && uv run pre-commit install"
}
16 changes: 10 additions & 6 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
pull_request:

env:
DEFAULT_PYTHON: 3.13
DEFAULT_PYTHON: "3.14"

jobs:
pre-commit:
Expand Down Expand Up @@ -36,21 +36,25 @@ jobs:

tests:
runs-on: "ubuntu-latest"
name: Tests
name: Tests (${{ matrix.python-version }})
strategy:
matrix:
# Mirrors the Hilo integration, which still supports Home Assistant releases on 3.13.
python-version: ["3.13", "3.14"]
steps:
- name: Check out the repository
uses: actions/checkout@v7

- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v7.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v10.2.0

- name: Install dependencies
run: uv sync --group test
run: uv sync --python ${{ matrix.python-version }} --group test

- name: Run tests
run: uv run pytest -v
run: uv run --python ${{ matrix.python-version }} pytest -v
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ repos:
hooks:
- id: ruff-format
args: [--quiet]
files: ^pyhilo/.+\.py$
files: ^(pyhilo|tests)/.+\.py$
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
files: ^pyhilo/.+\.py$
files: ^(pyhilo|tests)/.+\.py$
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This repository contains `pyhilo`, a standalone asynchronous Python client libra

- **Strict Decoupling from Home Assistant**:
- This library MUST NEVER import `homeassistant` or depend on Home Assistant Core modules, constants, or types.
- The library must be 100% installable, testable, and runnable independently in any standard Python 3.12+ environment.
- The library must be 100% installable, testable, and runnable independently in any standard Python 3.13+ environment.
- **Authentication Contract**:
- Expose an abstract base class `AbstractAuth` (`pyhilo.auth`):
```python
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Build Status][build-shield]][build]
[![Open in Dev Containers][devcontainer-shield]][devcontainer]

`python-hilo` (aka `pyhilo`) is a Python 3.11, `asyncio`-driven interface to the unofficial
`python-hilo` (aka `pyhilo`) is a Python 3.13+, `asyncio`-driven interface to the unofficial
Hilo API from Hydro Quebec. This is meant to be integrated into Home Assistant but can also
be used as a standalone library.

Expand Down Expand Up @@ -35,7 +35,7 @@ for formatting and linting.

You need at least:

- Python 3.11+
- Python 3.13+
- [uv][uv]

To install all packages, including all development requirements:
Expand Down
2 changes: 1 addition & 1 deletion pyhilo/device/graphql_value_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def _map_to_device_reading(

def _build_smart_meter(self, device: Dict[str, Any]) -> list[Dict[str, Any]]:
attributes = []
if device.get("zigbeeChannel") is not None:
if device.get("zigBeeChannel") is not None:
attributes.append(
self.build_attribute(
device["hiloId"], "ZigbeeChannel", device["zigBeeChannel"]
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "python-hilo"
version = "2026.10.1"
description = "A Python3, async interface to the Hilo API"
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.13"
license = "MIT"
authors = [
{ name = "David Vallee Delisle", email = "me@dvd.dev" },
Expand All @@ -17,7 +17,8 @@ classifiers = [
"Topic :: Home Automation",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
Expand Down Expand Up @@ -75,7 +76,7 @@ exclude_lines = [

[tool.ruff]
line-length = 88
target-version = "py311"
target-version = "py313"
exclude = [".git", "__pycache__", ".venv", "docs/source/conf.py", "old", "build", "dist"]

[tool.ruff.lint]
Expand All @@ -102,7 +103,7 @@ disallow_untyped_defs = true
follow_imports = "silent"
ignore_missing_imports = true
no_implicit_optional = true
python_version = "3.11"
python_version = "3.13"
show_error_codes = true
strict_equality = true
warn_incomplete_stub = true
Expand Down
29 changes: 20 additions & 9 deletions tests/test_device.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from datetime import datetime, timezone
from datetime import datetime
from unittest.mock import MagicMock

import pytest

from pyhilo.const import HILO_READING_TYPES
from pyhilo.device import DeviceAttribute, DeviceReading, HiloDevice, get_device_attributes
from pyhilo.device import (
DeviceAttribute,
DeviceReading,
HiloDevice,
get_device_attributes,
)


class TestDeviceAttribute:
Expand Down Expand Up @@ -97,7 +100,9 @@ def test_equality_by_attr_name(self):

def test_inequality_different_attr(self):
r1 = self._make_reading(device_attribute=DeviceAttribute("Power", "Watt"))
r2 = self._make_reading(device_attribute=DeviceAttribute("Intensity", "Percentage"))
r2 = self._make_reading(
device_attribute=DeviceAttribute("Intensity", "Percentage")
)
assert r1 != r2

def test_repr(self):
Expand Down Expand Up @@ -227,13 +232,19 @@ def test_update_readings_replaces_existing(self):
d = HiloDevice(api, id=1)
da = DeviceAttribute("Power", "Watt")
r1 = DeviceReading(
deviceId=1, hiloId="h-1", locationId=10,
timeStampUTC="2024-01-15T10:30:00Z", value=100.0,
deviceId=1,
hiloId="h-1",
locationId=10,
timeStampUTC="2024-01-15T10:30:00Z",
value=100.0,
device_attribute=da,
)
r2 = DeviceReading(
deviceId=1, hiloId="h-1", locationId=10,
timeStampUTC="2024-01-15T11:00:00Z", value=200.0,
deviceId=1,
hiloId="h-1",
locationId=10,
timeStampUTC="2024-01-15T11:00:00Z",
value=200.0,
device_attribute=da,
)
d.update_readings(r1)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_graphql_value_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Tests for mapping GraphQL subscription payloads to device attributes."""

import json
from pathlib import Path

from pyhilo.device.graphql_value_mapper import GraphqlValueMapper

PAYLOADS = Path(__file__).parent.parent / "doc" / "graphql"


def _device(filename: str) -> dict:
payload = json.loads((PAYLOADS / filename).read_text(encoding="utf-8"))
return payload["data"]["onAnyDeviceUpdated"]["device"]


def test_smart_meter_maps_zigbee_channel() -> None:
"""The meter's Zigbee channel is reported as the ZigbeeChannel attribute."""
attributes = GraphqlValueMapper().map_device_subscription_values(
_device("onAnyDeviceUpdated-meter.json")
)

channel = [a["value"] for a in attributes if a["attribute"] == "ZigbeeChannel"]
assert channel == [24]
50 changes: 41 additions & 9 deletions tests/test_util_state.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import os

import pytest

from pyhilo.util.state import (
AndroidDeviceDict,
FirebaseDict,
StateDict,
TokenDict,
FirebaseDict,
AndroidDeviceDict,
_get_defaults,
_write_state,
get_state,
Expand Down Expand Up @@ -107,22 +105,56 @@ async def test_non_dict_yaml_returns_defaults(self, tmp_path):
class TestSetState:
async def test_set_creates_file(self, tmp_path):
state_file = str(tmp_path / "state.yaml")
await set_state(state_file, "firebase", {"fid": "test-fid", "name": None, "token": {"access": None, "refresh": None, "expires_at": None}})
await set_state(
state_file,
"firebase",
{
"fid": "test-fid",
"name": None,
"token": {"access": None, "refresh": None, "expires_at": None},
},
)
result = await get_state(state_file)
assert result["firebase"]["fid"] == "test-fid"

async def test_set_merges_with_existing(self, tmp_path):
state_file = str(tmp_path / "state.yaml")
await set_state(state_file, "firebase", {"fid": "first", "name": None, "token": {"access": None, "refresh": None, "expires_at": None}})
await set_state(state_file, "android", {"token": "device-token", "device_id": 0})
await set_state(
state_file,
"firebase",
{
"fid": "first",
"name": None,
"token": {"access": None, "refresh": None, "expires_at": None},
},
)
await set_state(
state_file, "android", {"token": "device-token", "device_id": 0}
)
result = await get_state(state_file)
assert result["firebase"]["fid"] == "first"
assert result["android"]["token"] == "device-token"

async def test_set_merges_within_key(self, tmp_path):
state_file = str(tmp_path / "state.yaml")
await set_state(state_file, "firebase", {"fid": "first", "name": None, "token": {"access": None, "refresh": None, "expires_at": None}})
await set_state(state_file, "firebase", {"fid": "second", "name": "updated", "token": {"access": "tok", "refresh": None, "expires_at": None}})
await set_state(
state_file,
"firebase",
{
"fid": "first",
"name": None,
"token": {"access": None, "refresh": None, "expires_at": None},
},
)
await set_state(
state_file,
"firebase",
{
"fid": "second",
"name": "updated",
"token": {"access": "tok", "refresh": None, "expires_at": None},
},
)
result = await get_state(state_file)
assert result["firebase"]["fid"] == "second"
assert result["firebase"]["name"] == "updated"
Loading
Loading