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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ asyncio.run(main())

## Resources

The client exposes three 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 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).

| 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()` |

## Configuration

Expand All @@ -52,6 +53,7 @@ client = EnergyTrackerClient(
access_token="your-token",
base_url="https://custom-api.example.com", # Optional
timeout=30, # Optional, default: 10s
calculation_timeout=60, # Optional, calculations only
)
```

Expand Down
8 changes: 8 additions & 0 deletions energy_tracker_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
NetworkError,
RateLimitError,
ResourceNotFoundError,
ServiceUnavailableError,
TimeoutError,
ValidationError,
)
from .models import (
CalculationInterval,
CalculationPointDto,
CreateEnvironmentEntryDto,
CreateEnvironmentRecordDto,
CreateMeterReadingDto,
Expand All @@ -25,6 +28,7 @@
EnvironmentRecordDto,
ExportColumn,
ExportMeterReadingsDto,
ExtrapolationMethod,
MeterReadingDto,
SortDirection,
TimestampDto,
Expand All @@ -34,6 +38,9 @@
__all__ = [
"EnergyTrackerClient",
# Models
"CalculationInterval",
"CalculationPointDto",
"ExtrapolationMethod",
"CreateMeterReadingDto",
"MeterReadingDto",
"ExportMeterReadingsDto",
Expand All @@ -55,6 +62,7 @@
"ResourceNotFoundError",
"ConflictError",
"RateLimitError",
"ServiceUnavailableError",
"NetworkError",
"TimeoutError",
]
70 changes: 59 additions & 11 deletions energy_tracker_api/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Energy Tracker API client implementation."""

import asyncio
import math
from http import HTTPStatus
from typing import Any, Literal
from urllib.parse import urljoin
Expand All @@ -15,6 +16,7 @@
NetworkError,
RateLimitError,
ResourceNotFoundError,
ServiceUnavailableError,
TimeoutError,
ValidationError,
)
Expand All @@ -28,33 +30,52 @@ class EnergyTrackerClient:
_base_url: str
_access_token: str
_timeout: aiohttp.ClientTimeout
_calculation_timeout: aiohttp.ClientTimeout
_session: aiohttp.ClientSession | None

def __init__(
self,
access_token: str,
base_url: str | None = None,
timeout: int = 10,
*,
calculation_timeout: float = 60,
):
"""Initialize the Energy Tracker API client.

Args:
access_token: Bearer token for authentication.
base_url: Base URL of the API (defaults to production API).
timeout: Request timeout in seconds (default: 10).
calculation_timeout: Positive, finite timeout in seconds for calculations
only (default: 60; the backend allows up to 45 seconds).
"""
if (
isinstance(calculation_timeout, bool)
or not isinstance(calculation_timeout, (int, float))
or not math.isfinite(calculation_timeout)
or calculation_timeout <= 0
):
raise ValueError("calculation_timeout must be a positive, finite number")
url = base_url or self._DEFAULT_BASE_URL

self._base_url = url.strip().rstrip("/")
self._access_token = access_token
self._timeout = aiohttp.ClientTimeout(total=timeout)
self._calculation_timeout = aiohttp.ClientTimeout(total=calculation_timeout)
self._session = None

from .resources import DeviceResource, EnvironmentResource, MeterReadingResource
from .resources import (
CalculationResource,
DeviceResource,
EnvironmentResource,
MeterReadingResource,
)

self.devices = DeviceResource(self)
self.meter_readings = MeterReadingResource(self)
self.environments = EnvironmentResource(self)
self.calculations = CalculationResource(self)

async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
Expand Down Expand Up @@ -111,10 +132,13 @@ async def _make_request(
try:
data = await response.json()
except (ValueError, aiohttp.ContentTypeError) as e:
raise EnergyTrackerAPIError("Expected a valid JSON response") from e
raise EnergyTrackerAPIError(
"Expected a valid JSON response", status_code=response.status
) from e
if not isinstance(data, (dict, list)):
raise EnergyTrackerAPIError(
f"Expected a JSON object or array, got {type(data).__name__}"
f"Expected a JSON object or array, got {type(data).__name__}",
status_code=response.status,
)
return data

Expand All @@ -135,19 +159,29 @@ async def _make_request(
message = "Bad Request"
if api_message:
message += f" ({'; '.join(api_message)})"
raise ValidationError(message, api_message=api_message)
raise ValidationError(
message, api_message=api_message, status_code=response.status
)
elif response.status == 401:
raise AuthenticationError(
"Unauthorized: Check your access token", api_message=api_message
"Unauthorized: Check your access token",
api_message=api_message,
status_code=response.status,
)
elif response.status == 403:
raise ForbiddenError(
"Forbidden: Insufficient permissions", api_message=api_message
"Forbidden: Insufficient permissions",
api_message=api_message,
status_code=response.status,
)
elif response.status == 404:
raise ResourceNotFoundError("Not Found", api_message=api_message)
raise ResourceNotFoundError(
"Not Found", api_message=api_message, status_code=response.status
)
elif response.status == 409:
raise ConflictError("Conflict", api_message=api_message)
raise ConflictError(
"Conflict", api_message=api_message, status_code=response.status
)
elif response.status == 429:
retry_after = response.headers.get("Retry-After")
retry_seconds = (
Expand All @@ -157,20 +191,34 @@ async def _make_request(
if retry_seconds:
message += f" - Retry after {retry_seconds} seconds"
raise RateLimitError(
message, api_message=api_message, retry_after=retry_seconds
message,
api_message=api_message,
retry_after=retry_seconds,
status_code=response.status,
)
elif response.status == HTTPStatus.SERVICE_UNAVAILABLE:
raise ServiceUnavailableError(
f"Server error: {response.status}",
api_message=api_message,
status_code=response.status,
)
elif response.status >= 500:
raise EnergyTrackerAPIError(
f"Server error: {response.status}", api_message=api_message
f"Server error: {response.status}",
api_message=api_message,
status_code=response.status,
)
elif response.status >= 400:
raise EnergyTrackerAPIError(
f"HTTP error: {response.status}", api_message=api_message
f"HTTP error: {response.status}",
api_message=api_message,
status_code=response.status,
)
else:
raise EnergyTrackerAPIError(
f"Unexpected HTTP status: {response.status} (expected {expected_status})",
api_message=api_message,
status_code=response.status,
)

except asyncio.TimeoutError as e:
Expand Down
23 changes: 20 additions & 3 deletions energy_tracker_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ class EnergyTrackerAPIError(Exception):

Attributes:
api_message: List of messages from the API response body.
status_code: HTTP response status, or None for local and transport errors.
"""

def __init__(self, message: str, api_message: list[str] | None = None):
def __init__(
self,
message: str,
api_message: list[str] | None = None,
*,
status_code: int | None = None,
):
super().__init__(message)
self.api_message = api_message if api_message is not None else []
self.status_code = status_code


class ValidationError(EnergyTrackerAPIError):
Expand Down Expand Up @@ -72,12 +80,21 @@ class RateLimitError(EnergyTrackerAPIError):
"""

def __init__(
self, message: str, api_message: list[str] | None = None, retry_after: int | None = None
self,
message: str,
api_message: list[str] | None = None,
retry_after: int | None = None,
*,
status_code: int | None = None,
):
super().__init__(message, api_message)
super().__init__(message, api_message, status_code=status_code)
self.retry_after = retry_after


class ServiceUnavailableError(EnergyTrackerAPIError):
"""Raised when the service is unavailable (HTTP 503), e.g. a calculation deadline expires."""


class NetworkError(EnergyTrackerAPIError):
"""Raised when a network error occurs (connection issues, DNS, etc.).

Expand Down
4 changes: 4 additions & 0 deletions energy_tracker_api/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Data models for Energy Tracker API."""

from .calculations import CalculationInterval, CalculationPointDto, ExtrapolationMethod
from .common import TimestampDto
from .devices import DeviceSummaryDto
from .environments import (
Expand All @@ -19,6 +20,9 @@
)

__all__ = [
"CalculationInterval",
"CalculationPointDto",
"ExtrapolationMethod",
"TimestampDto",
"DeviceSummaryDto",
"CreateMeterReadingDto",
Expand Down
65 changes: 65 additions & 0 deletions energy_tracker_api/models/calculations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Models for daily values and calendar interval extrapolations."""

import math
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum


class CalculationInterval(StrEnum):
"""Calendar interval for extrapolation; weeks begin on Monday."""

DAY = "day"
WEEK = "week"
MONTH = "month"
QUARTER = "quarter"
YEAR = "year"


class ExtrapolationMethod(StrEnum):
"""Available server-side extrapolation methods."""

STANDARD = "standard"


def _number(value: object, field: str, *, duration: bool = False) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{field} must be a JSON number")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{field} must be finite")
if duration and number < 0:
raise ValueError(f"{field} must not be negative")
return number


@dataclass(frozen=True, slots=True)
class CalculationPointDto:
"""Consumption or production for a calendar interval, in the device unit.

Attributes:
date: Interval start in UTC.
actual_value: Value derived from recorded readings by interpolation.
actual_duration: Reading-backed duration in seconds, affected by DST.
expected_value: Estimated total for the interval; do not add actual_value.
expected_duration: Nominal duration in seconds, using 24 hours per calendar day.
"""

date: datetime
actual_value: float
actual_duration: float
expected_value: float
expected_duration: float

@classmethod
def _from_dict(cls, data: dict) -> CalculationPointDto:
date = datetime.fromisoformat(data["date"])
if date.utcoffset() is None:
raise ValueError("date must include a UTC offset")
return cls(
date=date.astimezone(UTC),
actual_value=_number(data["actualValue"], "actualValue"),
actual_duration=_number(data["actualDuration"], "actualDuration", duration=True),
expected_value=_number(data["expectedValue"], "expectedValue"),
expected_duration=_number(data["expectedDuration"], "expectedDuration", duration=True),
)
2 changes: 2 additions & 0 deletions energy_tracker_api/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Resource handlers for Energy Tracker API."""

from .calculations import CalculationResource
from .devices import DeviceResource
from .environments import EnvironmentResource
from .meter_readings import MeterReadingResource

__all__ = [
"CalculationResource",
"DeviceResource",
"EnvironmentResource",
"MeterReadingResource",
Expand Down
Loading
Loading