From ccefdbad40cd84e5b870a3a74afe903f45051028 Mon Sep 17 00:00:00 2001 From: Stefan Nebel Date: Thu, 8 Oct 2026 23:51:20 +0200 Subject: [PATCH] feat: add token status and prepare 3.1.0 Expose GET /v1/token/status as client.token.status(). It returns the token's display name, granted scopes and expiry, needs no scope and changes no data. Known scopes are TokenScope members; a scope added by a newer API stays a plain string instead of failing the response. Add shared contract cases for the new operation, including an unknown scope and the wrong-status case, and compare expiresAt as an instant. --- README.md | 3 +- contracts/README.md | 12 ++- contracts/cases/errors.json | 37 +++++++ contracts/cases/token.json | 123 +++++++++++++++++++++++ contracts/schema.json | 22 +++- energy_tracker_api/__init__.py | 4 + energy_tracker_api/client.py | 2 + energy_tracker_api/models/__init__.py | 3 + energy_tracker_api/models/token.py | 66 ++++++++++++ energy_tracker_api/resources/__init__.py | 2 + energy_tracker_api/resources/token.py | 31 ++++++ pyproject.toml | 2 +- tests/test_client.py | 11 ++ tests/test_contracts.py | 12 ++- tests/test_models.py | 82 +++++++++++++++ tests/test_resources_token.py | 92 +++++++++++++++++ 16 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 contracts/cases/token.json create mode 100644 energy_tracker_api/models/token.py create mode 100644 energy_tracker_api/resources/token.py create mode 100644 tests/test_resources_token.py diff --git a/README.md b/README.md index e0f8207..54e0e89 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ asyncio.run(main()) ## Resources -The client exposes four resource groups — all endpoints, parameters, and DTOs are documented in the [OpenAPI specification](https://github.com/energy-tracker/public-docs/blob/main/public-api/openapi.yml). +The client exposes five resource groups — all endpoints, parameters, and DTOs are documented in the [OpenAPI specification](https://github.com/energy-tracker/public-docs/blob/main/public-api/openapi.yml). | Resource | Methods | |---|---| @@ -45,6 +45,7 @@ The client exposes four resource groups — all endpoints, parameters, and DTOs | `client.meter_readings` | `list()`, `create()`, `delete()`, `export()` | | `client.environments` | `list()`, `get()`, `create()`, `delete()`, `create_entry()`, `delete_entry()` | | `client.calculations` | `daily_values()`, `extrapolations()` | +| `client.token` | `status()` | Starting with version 3.0.0, meter-reading CSV exports default to semicolon (`;`). To preserve the comma-separated output of version 2.x, explicitly set diff --git a/contracts/README.md b/contracts/README.md index 1896390..1a4d38f 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -31,13 +31,17 @@ test and release checks. No API token, live backend or sibling checkout is neede present, `retryAfter` in seconds. Exception class names and local wording are language-specific. Assert exactly one request, including on errors/redirects. -Compare `timestamp`, `date`, `lastUpdatedAt`, `from`, `to`, `updatedAfter` and -`updatedBefore` as offset-aware instants. `Z` and equivalent offsets/fractional -spellings are interchangeable. Do not round to calendar boundaries. Compare -all other values directly, preserving decimal precision and list order. +Compare `timestamp`, `date`, `lastUpdatedAt`, `expiresAt`, `from`, `to`, +`updatedAfter` and `updatedBefore` as offset-aware instants. `Z` and equivalent +offsets/fractional spellings are interchangeable. Do not round to calendar +boundaries. Compare all other values directly, preserving decimal precision and +list order. Token scopes compare as strings; scopes unknown to an SDK pass +through unchanged. The wire examples were checked against the Public API controllers and DTOs in [energy-tracker-core at fcc269a06184426caf64c882291d4209be57a723](https://github.com/StefaniOSApps/energy-tracker-core/tree/fcc269a06184426caf64c882291d4209be57a723/backend/src/app/public-api). +The token status examples were checked against +[energy-tracker-core at 969b1b722d3e56505c86ecdd098e1bcebaff90b3](https://github.com/StefaniOSApps/energy-tracker-core/tree/969b1b722d3e56505c86ecdd098e1bcebaff90b3/backend/src/app/public-api). Error/redirect/malformed-response cases additionally specify SDK behavior; their messages are illustrative, not fixed backend wording. These mocked responses do not verify server calculations, validation rules or authorization scopes. diff --git a/contracts/cases/errors.json b/contracts/cases/errors.json index 898462b..5bb4cb5 100644 --- a/contracts/cases/errors.json +++ b/contracts/cases/errors.json @@ -521,6 +521,43 @@ } } }, + { + "id": "token-status-wrong-status", + "operation": "token.status", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/token/status", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "displayName": "Home Assistant Zählerschrank", + "scopes": [ + "write:meter-reading", + "read:measuring-device" + ], + "expiresAt": "2027-01-01T00:00:00.000Z" + } + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, { "id": "readings-create-error-400", "operation": "meterReadings.create", diff --git a/contracts/cases/token.json b/contracts/cases/token.json new file mode 100644 index 0000000..e215f87 --- /dev/null +++ b/contracts/cases/token.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "token-status", + "operation": "token.status", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/token/status", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "displayName": "Home Assistant Zählerschrank", + "scopes": [ + "write:meter-reading", + "read:measuring-device" + ], + "expiresAt": "2027-01-01T00:00:00.000Z" + } + } + }, + "expected": { + "result": { + "displayName": "Home Assistant Zählerschrank", + "scopes": [ + "write:meter-reading", + "read:measuring-device" + ], + "expiresAt": "2027-01-01T00:00:00.000Z" + } + } + }, + { + "id": "token-status-no-expiry", + "operation": "token.status", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/token/status", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "displayName": "Write only", + "scopes": [ + "write:meter-reading" + ], + "expiresAt": null + } + } + }, + "expected": { + "result": { + "displayName": "Write only", + "scopes": [ + "write:meter-reading" + ], + "expiresAt": null + } + } + }, + { + "id": "token-status-unknown-scope", + "operation": "token.status", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/token/status", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "displayName": "Newer backend", + "scopes": [ + "meter-reading", + "read:future-resource" + ], + "expiresAt": null + } + } + }, + "expected": { + "result": { + "displayName": "Newer backend", + "scopes": [ + "meter-reading", + "read:future-resource" + ], + "expiresAt": null + } + } + } + ] +} diff --git a/contracts/schema.json b/contracts/schema.json index 9856bba..0672bb3 100644 --- a/contracts/schema.json +++ b/contracts/schema.json @@ -42,7 +42,8 @@ "environments.createEntry", "environments.deleteEntry", "calculations.dailyValues", - "calculations.extrapolations" + "calculations.extrapolations", + "token.status" ] }, "input": { @@ -714,6 +715,25 @@ } } } + }, + { + "if": { + "properties": { + "operation": { + "const": "token.status" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + } + } } ] }, diff --git a/energy_tracker_api/__init__.py b/energy_tracker_api/__init__.py index ba06baa..81e2d91 100644 --- a/energy_tracker_api/__init__.py +++ b/energy_tracker_api/__init__.py @@ -32,6 +32,8 @@ MeterReadingDto, SortDirection, TimestampDto, + TokenScope, + TokenStatusDto, ) __version__ = version("energy-tracker-api") @@ -54,6 +56,8 @@ "EnvironmentRecordDto", "EnvironmentEntryDto", "TimestampDto", + "TokenScope", + "TokenStatusDto", # Exceptions "EnergyTrackerAPIError", "ValidationError", diff --git a/energy_tracker_api/client.py b/energy_tracker_api/client.py index 6be83fd..5fdc695 100644 --- a/energy_tracker_api/client.py +++ b/energy_tracker_api/client.py @@ -70,12 +70,14 @@ def __init__( DeviceResource, EnvironmentResource, MeterReadingResource, + TokenResource, ) self.devices = DeviceResource(self) self.meter_readings = MeterReadingResource(self) self.environments = EnvironmentResource(self) self.calculations = CalculationResource(self) + self.token = TokenResource(self) async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: diff --git a/energy_tracker_api/models/__init__.py b/energy_tracker_api/models/__init__.py index 8c13141..a07d93b 100644 --- a/energy_tracker_api/models/__init__.py +++ b/energy_tracker_api/models/__init__.py @@ -18,6 +18,7 @@ MeterReadingDto, SortDirection, ) +from .token import TokenScope, TokenStatusDto __all__ = [ "CalculationInterval", @@ -36,4 +37,6 @@ "CreateEnvironmentEntryDto", "EnvironmentRecordDto", "EnvironmentEntryDto", + "TokenScope", + "TokenStatusDto", ] diff --git a/energy_tracker_api/models/token.py b/energy_tracker_api/models/token.py new file mode 100644 index 0000000..4c97fd0 --- /dev/null +++ b/energy_tracker_api/models/token.py @@ -0,0 +1,66 @@ +"""Access token data models for Energy Tracker API.""" + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + + +class TokenScope(StrEnum): + """Access scopes a personal access token can be granted.""" + + READ_DAILY_VALUES = "read:daily-values" + READ_EXTRAPOLATION = "read:extrapolation" + METER_READING = "meter-reading" + READ_METER_READING = "read:meter-reading" + WRITE_METER_READING = "write:meter-reading" + DELETE_METER_READING = "delete:meter-reading" + MEASURING_DEVICE = "measuring-device" + READ_MEASURING_DEVICE = "read:measuring-device" + WRITE_MEASURING_DEVICE = "write:measuring-device" + DELETE_MEASURING_DEVICE = "delete:measuring-device" + ENVIRONMENT_RECORD = "environment-record" + READ_ENVIRONMENT_RECORD = "read:environment-record" + WRITE_ENVIRONMENT_RECORD = "write:environment-record" + DELETE_ENVIRONMENT_RECORD = "delete:environment-record" + + +def _scope(value: str) -> TokenScope | str: + try: + return TokenScope(value) + except ValueError: + return value + + +@dataclass(frozen=True, slots=True) +class TokenStatusDto: + """Status of the personal access token used by the client. + + Attributes: + display_name: User-assigned name of the token. + scopes: Access scopes granted to the token. A scope introduced by a newer + API is kept as a plain string. + expires_at: Expiry timestamp, or None if the token does not expire. + """ + + display_name: str + scopes: list[TokenScope | str] + expires_at: datetime | None = None + + @classmethod + def _from_dict(cls, data: dict) -> TokenStatusDto: + scopes = data["scopes"] + is_string_list = isinstance(scopes, list) and all( + isinstance(scope, str) for scope in scopes + ) + if not is_string_list: + raise TypeError("scopes must be a list of strings") + + expires_at = None + if data.get("expiresAt"): + expires_at = datetime.fromisoformat(data["expiresAt"]) + + return cls( + display_name=data["displayName"], + scopes=[_scope(scope) for scope in scopes], + expires_at=expires_at, + ) diff --git a/energy_tracker_api/resources/__init__.py b/energy_tracker_api/resources/__init__.py index e161c99..4dee9fa 100644 --- a/energy_tracker_api/resources/__init__.py +++ b/energy_tracker_api/resources/__init__.py @@ -4,10 +4,12 @@ from .devices import DeviceResource from .environments import EnvironmentResource from .meter_readings import MeterReadingResource +from .token import TokenResource __all__ = [ "CalculationResource", "DeviceResource", "EnvironmentResource", "MeterReadingResource", + "TokenResource", ] diff --git a/energy_tracker_api/resources/token.py b/energy_tracker_api/resources/token.py new file mode 100644 index 0000000..9593b50 --- /dev/null +++ b/energy_tracker_api/resources/token.py @@ -0,0 +1,31 @@ +"""Access token resource handler for Energy Tracker API.""" + +from ..models import TokenStatusDto +from .base import BaseResource + + +class TokenResource(BaseResource): + """Handler for the personal access token used by the client.""" + + async def status(self) -> TokenStatusDto: + """Returns the name, scopes and expiry of the access token in use. + + Requires no scope, so it also works for write-only tokens, and does not + create or change data. The API limits this endpoint to 5 requests per + 5 minutes, including attempts with a missing or invalid token. Call it + when configuring an integration or checking credentials; do not poll it. + + Returns: + Status of the access token. + + Raises: + AuthenticationError: If the token is missing, invalid, expired or revoked. + ForbiddenError: If the account is suspended. + RateLimitError: If rate limit is exceeded. + EnergyTrackerAPIError: For other API errors. + """ + return await self._request_model( + response_type=TokenStatusDto, + method="GET", + endpoint="/v1/token/status", + ) diff --git a/pyproject.toml b/pyproject.toml index d07f16d..711b17a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "energy-tracker-api" -version = "3.0.0" +version = "3.1.0" description = "Async Python client for Energy Tracker API" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/test_client.py b/tests/test_client.py index 3ef064e..8d546b6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -126,6 +126,17 @@ def test_environments_resource_is_initialized(self): assert hasattr(client.environments, "list") assert hasattr(client.environments, "create") + def test_token_resource_is_initialized(self): + # Arrange + access_token = "test-token" + + # Act + client = EnergyTrackerClient(access_token=access_token) + + # Assert + assert client.token is not None + assert hasattr(client.token, "status") + class TestEnergyTrackerClientExtractApiMessage: """Tests for _extract_api_message method.""" diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 056acb5..7e387f1 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -59,8 +59,18 @@ "environments.deleteEntry": ("environments", "delete_entry"), "calculations.dailyValues": ("calculations", "daily_values"), "calculations.extrapolations": ("calculations", "extrapolations"), + "token.status": ("token", "status"), +} +TIMESTAMPS = { + "timestamp", + "date", + "lastUpdatedAt", + "expiresAt", + "from", + "to", + "updatedAfter", + "updatedBefore", } -TIMESTAMPS = {"timestamp", "date", "lastUpdatedAt", "from", "to", "updatedAfter", "updatedBefore"} def load_cases(): diff --git a/tests/test_models.py b/tests/test_models.py index 399291b..d92702c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -19,6 +19,8 @@ ExportMeterReadingsDto, MeterReadingDto, TimestampDto, + TokenScope, + TokenStatusDto, ) @@ -379,3 +381,83 @@ def test_to_dict_with_timestamp(self): "value": 23.5, "timestamp": "2024-01-15T10:30:45.123", } + + +class TestTokenStatusDto: + """Tests for TokenStatusDto.""" + + def test_from_dict_with_all_fields(self): + # Arrange + data = { + "displayName": "Home Assistant", + "scopes": ["write:meter-reading", "read:measuring-device"], + "expiresAt": "2027-01-01T00:00:00.000Z", + } + + # Act + result = TokenStatusDto._from_dict(data) + + # Assert + assert result.display_name == "Home Assistant" + assert result.scopes == [ + TokenScope.WRITE_METER_READING, + TokenScope.READ_MEASURING_DEVICE, + ] + assert all(isinstance(scope, TokenScope) for scope in result.scopes) + assert result.expires_at is not None + + def test_from_dict_without_expiry(self): + # Arrange + data = { + "displayName": "Home Assistant", + "scopes": ["write:meter-reading"], + "expiresAt": None, + } + + # Act + result = TokenStatusDto._from_dict(data) + + # Assert + assert result.display_name == "Home Assistant" + assert result.expires_at is None + + def test_from_dict_without_expiry_key(self): + # Arrange + data = { + "displayName": "Home Assistant", + "scopes": ["write:meter-reading"], + } + + # Act + result = TokenStatusDto._from_dict(data) + + # Assert + assert result.expires_at is None + + def test_from_dict_preserves_unknown_scopes(self): + # Arrange + data = { + "displayName": "Home Assistant", + "scopes": ["meter-reading", "read:future-resource"], + "expiresAt": None, + } + + # Act + result = TokenStatusDto._from_dict(data) + + # Assert + assert result.scopes == [TokenScope.METER_READING, "read:future-resource"] + assert not isinstance(result.scopes[1], TokenScope) + + @pytest.mark.parametrize("scopes", ["write:meter-reading", None, ["write:meter-reading", 1]]) + def test_from_dict_rejects_invalid_scopes(self, scopes): + # Arrange + data = { + "displayName": "Home Assistant", + "scopes": scopes, + "expiresAt": None, + } + + # Act & Assert + with pytest.raises(TypeError, match="scopes must be a list of strings"): + TokenStatusDto._from_dict(data) diff --git a/tests/test_resources_token.py b/tests/test_resources_token.py new file mode 100644 index 0000000..5240921 --- /dev/null +++ b/tests/test_resources_token.py @@ -0,0 +1,92 @@ +"""Tests for Energy Tracker API token resources.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +import pytest + +from energy_tracker_api.client import EnergyTrackerClient +from energy_tracker_api.exceptions import EnergyTrackerAPIError +from energy_tracker_api.resources.token import TokenResource + + +class TestTokenResourceInitialization: + """Tests for TokenResource initialization.""" + + def test_initialization(self): + # Arrange + client = Mock(spec=EnergyTrackerClient) + + # Act + resource = TokenResource(client) + + # Assert + assert resource._client == client + + +class TestTokenResourceStatus: + """Tests for TokenResource.status method.""" + + @pytest.mark.asyncio + async def test_status_with_expiry(self): + # Arrange + client = Mock(spec=EnergyTrackerClient) + client._make_request = AsyncMock( + return_value={ + "displayName": "Home Assistant", + "scopes": ["write:meter-reading", "read:measuring-device"], + "expiresAt": "2027-01-01T00:00:00.000Z", + } + ) + resource = TokenResource(client) + + # Act + result = await resource.status() + + # Assert + assert result.display_name == "Home Assistant" + assert result.scopes == ["write:meter-reading", "read:measuring-device"] + assert result.expires_at == datetime(2027, 1, 1, tzinfo=UTC) + client._make_request.assert_called_once_with( + method="GET", + endpoint="/v1/token/status", + ) + + @pytest.mark.asyncio + async def test_status_without_expiry(self): + # Arrange + client = Mock(spec=EnergyTrackerClient) + client._make_request = AsyncMock( + return_value={ + "displayName": "Write only", + "scopes": ["write:meter-reading"], + "expiresAt": None, + } + ) + resource = TokenResource(client) + + # Act + result = await resource.status() + + # Assert + assert result.display_name == "Write only" + assert result.expires_at is None + + @pytest.mark.asyncio + async def test_status_with_invalid_scopes_raises_api_error(self): + # Arrange + client = Mock(spec=EnergyTrackerClient) + client._make_request = AsyncMock( + return_value={ + "displayName": "Home Assistant", + "scopes": "write:meter-reading", + "expiresAt": None, + } + ) + resource = TokenResource(client) + + # Act & Assert + with pytest.raises(EnergyTrackerAPIError) as exc_info: + await resource.status() + + assert str(exc_info.value) == "Invalid TokenStatusDto response"