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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ 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 |
|---|---|
| `client.devices` | `list_standard()`, `list_virtual()` |
| `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
Expand Down
12 changes: 8 additions & 4 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions contracts/cases/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions contracts/cases/token.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
]
}
22 changes: 21 additions & 1 deletion contracts/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"environments.createEntry",
"environments.deleteEntry",
"calculations.dailyValues",
"calculations.extrapolations"
"calculations.extrapolations",
"token.status"
]
},
"input": {
Expand Down Expand Up @@ -714,6 +715,25 @@
}
}
}
},
{
"if": {
"properties": {
"operation": {
"const": "token.status"
}
}
},
"then": {
"properties": {
"input": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
}
}
}
]
},
Expand Down
4 changes: 4 additions & 0 deletions energy_tracker_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
MeterReadingDto,
SortDirection,
TimestampDto,
TokenScope,
TokenStatusDto,
)

__version__ = version("energy-tracker-api")
Expand All @@ -54,6 +56,8 @@
"EnvironmentRecordDto",
"EnvironmentEntryDto",
"TimestampDto",
"TokenScope",
"TokenStatusDto",
# Exceptions
"EnergyTrackerAPIError",
"ValidationError",
Expand Down
2 changes: 2 additions & 0 deletions energy_tracker_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions energy_tracker_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
MeterReadingDto,
SortDirection,
)
from .token import TokenScope, TokenStatusDto

__all__ = [
"CalculationInterval",
Expand All @@ -36,4 +37,6 @@
"CreateEnvironmentEntryDto",
"EnvironmentRecordDto",
"EnvironmentEntryDto",
"TokenScope",
"TokenStatusDto",
]
66 changes: 66 additions & 0 deletions energy_tracker_api/models/token.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 2 additions & 0 deletions energy_tracker_api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Loading