Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FlexMeasures Client basic functionality #7

Merged
merged 61 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
799e382
feat(auth): first draft api auth
Apr 19, 2023
393973d
Adding depedencies to run async tests and project dependencies such a…
victorgarcia98 Apr 19, 2023
b73d050
feat(auth): small changes and some test code
Apr 20, 2023
e6e7589
feat(auth): small changes and some test code
Apr 20, 2023
295dd15
Testing aioresponses mock.
victorgarcia98 Apr 20, 2023
3f1135a
feat(auth): trying to merge
Apr 20, 2023
47ae524
Merge remote-tracking branch 'origin/fm-api-client-dependencies' into…
Apr 20, 2023
c94ff6a
feat(measurements): post measurements test code
Apr 21, 2023
295119d
Remove skeleton files
Flix6x Apr 21, 2023
972b69a
Fix tests
Flix6x Apr 21, 2023
2dfa94c
Remove unused caplog
Flix6x Apr 21, 2023
8eec4b4
Allow closing the client, which closes the session
Flix6x Apr 21, 2023
87ec9bc
feat(scheduling): trigger and get schedule
Apr 21, 2023
9535dd8
Clean up call to post_measurements
Flix6x Apr 21, 2023
e06b670
Fix return values
Flix6x Apr 21, 2023
f7bcdaa
Move construction of headers into request function
Flix6x Apr 21, 2023
028da32
Add polling and test it
Flix6x Apr 24, 2023
f542112
Add polling timeout
Flix6x Apr 26, 2023
96daed1
Add max polling steps
Flix6x Apr 26, 2023
d1a0935
feat(client): added tests and review changes
Apr 26, 2023
ff35102
Test polling timeout
Flix6x Apr 26, 2023
58d427c
merge fm-api-client-scheduling-functions into add-polling
Flix6x Apr 26, 2023
6212792
get rid of hardcoded consumption price sensor
Flix6x Apr 26, 2023
0d19243
close client
Flix6x Apr 26, 2023
9fb8e87
Move ssl to __init__
Flix6x Apr 26, 2023
9196092
Rename argument
Flix6x Apr 26, 2023
96e8a20
Rename function and lambda params
Flix6x Apr 26, 2023
742ddcc
black
Flix6x Apr 26, 2023
609e335
flake8
Flix6x Apr 26, 2023
002f9d0
feat(client): add getting sensor and asset
May 1, 2023
429db48
chore(init): add init import path
May 2, 2023
e85cb30
Draft response handling logic
Flix6x May 2, 2023
5ad35e1
Merge pull request #5 from FlexMeasures/start-custom-response-handler
Flix6x May 2, 2023
234087d
refactor(request): break up request function in smaller parts
May 2, 2023
2f569d2
refactor(request): move check content-type and remove awaits
May 2, 2023
e275ede
refactor(client): check for status as handler
May 2, 2023
733df02
refactor(client): refactored request funtion
May 2, 2023
3000d72
refactor(client): automatic auth and fix referenced before assigned i…
May 2, 2023
d0c2247
refactor(client): url_build and constants added
May 3, 2023
dfe8108
feat(client): add prio functionality to post measurements
May 3, 2023
a142a69
chore(client): removed some outdated comments
May 4, 2023
b7a4621
chore(script_guus): removed testing script from pull request
May 4, 2023
40bcb84
refactor(client): change post_trigger_schedule to trigger_strorage_sc…
May 4, 2023
90be03f
refactor(client): import add noqa 401
May 4, 2023
c20a943
refactor(client): change float(10) to 10.0
May 4, 2023
b6e0a2e
fix: added test for init host/scheme/ssl and type checking in respons…
May 4, 2023
c4dd494
fix: added test for build url and refactored build url
May 4, 2023
419e366
chore(setup): set python version to 3.9 or higher
May 5, 2023
6aa837e
test(client): test check_response/raise_for_status
May 8, 2023
6b37763
refactor: remove status code returns where the function shouldn't ret…
May 8, 2023
99e16ff
refactor(constants): add constants.py and use constants for content t…
May 8, 2023
0eb27ec
refactor(init): since python 3.9 is required remove check for python …
May 8, 2023
a1d3e77
refactor(pre-commit): flake and isort changes locally executed
May 8, 2023
51881a2
refactor(pre-commit): flake and isort changes locally executed
May 8, 2023
123aafc
fix(CI): turned off coveralls
May 8, 2023
9d1c91f
fix(CI): turned off coveralls completely
May 8, 2023
d743e15
fix(CI): turned off coveralls try 3
May 8, 2023
2d1c8c5
fix(CI): revert coveralls turned off
May 8, 2023
30572ca
fix(CI): new coveralls turn off test
May 8, 2023
822f884
refactor(review): typo's comments, typing and small requested changes
May 8, 2023
5f2fea5
refactor(review): isort locally run
May 8, 2023
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 .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ exclude_lines =

# Don't complain about missing debug-only code:
def __repr__
if self\.debug
if self.debug

# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
Expand Down
30 changes: 15 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,25 @@ jobs:
pipx run --python '${{ steps.setup-python.outputs.python-path }}'
tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}'
-- -rFEx --durations 10 --color yes # pytest args
- name: Generate coverage report
run: pipx run coverage lcov -o coverage.lcov
- name: Upload partial coverage report
uses: coverallsapp/github-action@master
with:
path-to-lcov: coverage.lcov
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: ${{ matrix.platform }} - py${{ matrix.python }}
parallel: true
# - name: Generate coverage report
# run: pipx run coverage lcov -o coverage.lcov
# - name: Upload partial coverage report
# uses: coverallsapp/github-action@master
# with:
# path-to-lcov: coverage.lcov
# github-token: ${{ secrets.GITHUB_TOKEN }}
# flag-name: ${{ matrix.platform }} - py${{ matrix.python }}
# parallel: true

finalize:
needs: test
runs-on: ubuntu-latest
steps:
- name: Finalize coverage report
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel-finished: true
# steps:
# - name: Finalize coverage report
# uses: coverallsapp/github-action@master
# with:
# github-token: ${{ secrets.GITHUB_TOKEN }}
# parallel-finished: true

publish:
needs: finalize
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ MANIFEST
.venv*/
.conda*/
.python-version

venv/*
22 changes: 13 additions & 9 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,16 @@ package_dir =
=src

# Require a min/specific Python version (comma-separated conditions)
# python_requires = >=3.8
python_requires >= 3.9

# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0.
# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in
# new major versions. This works if the required packages follow Semantic Versioning.
# For more information, check out https://semver.org/.
install_requires =
importlib-metadata; python_version<"3.8"

aiohttp
pandas

[options.packages.find]
where = src
Expand All @@ -66,6 +67,9 @@ testing =
setuptools
pytest
pytest-cov
pytest-asyncio
pytest-mock
aioresponses

[options.entry_points]
# Add here console scripts like:
Expand All @@ -84,13 +88,13 @@ testing =
# in order to write a coverage file that can be read by Jenkins.
# CAUTION: --cov flags may prohibit setting breakpoints while debugging.
# Comment those flags to avoid this pytest issue.
addopts =
--cov flexmeasures_client --cov-report term-missing
--verbose
norecursedirs =
dist
build
.tox
# addopts =
# --cov flexmeasures_client --cov-report term-missing
# --verbose
# norecursedirs =
# dist
# build
# .tox
testpaths = tests
# Use pytest markers to select/deselect specific tests
# markers =
Expand Down
8 changes: 1 addition & 7 deletions src/flexmeasures_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import sys

if sys.version_info[:2] >= (3, 8):
# TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
from importlib.metadata import PackageNotFoundError, version # pragma: no cover
else:
from importlib_metadata import PackageNotFoundError, version # pragma: no cover
from importlib.metadata import PackageNotFoundError, version # pragma: no cover

try:
# Change here if project is renamed and does not equal the package name
Expand Down
280 changes: 280 additions & 0 deletions src/flexmeasures_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
from __future__ import annotations

import asyncio
import socket
from dataclasses import dataclass
from typing import Any, cast

import async_timeout
import pandas as pd
from aiohttp.client import ClientError, ClientSession
from yarl import URL

from flexmeasures_client.constants import API_VERSIOM, CONTENT_TYPE_HEADERS
from flexmeasures_client.response_handling import (
check_content_type,
check_for_status,
check_response,
)

MAX_POLLING_STEPS: int = 10 # seconds
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
POLLING_TIMEOUT = 200.0 # seconds
REQUEST_TIMEOUT = 20.0 # seconds
POLLING_INTERVAL = 10.0 # seconds


@dataclass
class FlexMeasuresClient:
"""Main class for connecting to the FlexMeasures API"""

password: str
email: str
access_token: str = None
host: str = "localhost:5000"
scheme: str = ""
ssl: bool | None = None
api_version: str = API_VERSIOM
path: str = f"/api/{api_version}/"
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
consumption_price_sensor: int = (
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
3 # TODO find sensor and use sensor through API or set in config
)
reauth_once: bool = True

polling_step: int = 0
max_polling_steps: int = MAX_POLLING_STEPS
polling_timeout: float = POLLING_TIMEOUT # seconds
request_timeout: float = REQUEST_TIMEOUT # seconds
polling_interval: float = POLLING_INTERVAL # seconds
session: ClientSession | None = None

def __post_init__(self):
if not self.scheme:
self.scheme: str = "http" if "localhost" in self.host else "https"
if self.ssl is None:
self.ssl: bool = False if "localhost" in self.host else True

async def close(self):
await self.session.close()

async def request(
self,
uri: str,
*,
json: dict | None = None,
method: str = "POST",
path: str = path,
params: dict[str, Any] | None = None,
include_auth: bool = True,
) -> tuple[dict, int]:
"""Send a request to FlexMeasures.

Retries if:
- the client request timed out (as indicated by the client's self.request_timeout)
- the server response indicates a 408 (Request Timeout) status
- the server response indicates a 503 (Service Unavailable) status with a Retry-After response header

Fails if:
- the server response indicated a status code of 400 or higher
- the client polling timed out (as indicated by the client's self.polling_timeout)
""" # noqa: E501
url = self.build_url(uri, path=path)
print(url)

headers = await self.get_headers(include_auth=include_auth)
self.start_session()

self.polling_step = 0
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
self.reauth_once = True # reset this counter once when starting polling
try:
async with async_timeout.timeout(self.polling_timeout):
while self.polling_step < self.max_polling_steps:
try:
async with async_timeout.timeout(self.request_timeout):
response = await self.request_once(
method=method,
url=url,
params=params,
headers=headers,
json=json,
)
break
except asyncio.TimeoutError:
print(
f"Client request timeout occurred while connecting to the API. Retrying in {self.polling_interval} seconds..." # noqa: E501
)
self.polling_step += 1
await asyncio.sleep(self.polling_interval)
except (ClientError, socket.gaierror) as exception:
raise ConnectionError(
"Error occurred while communicating with the API."
) from exception
except asyncio.TimeoutError as exception:
raise ConnectionError(
"Client polling timeout while connection to the API."
) from exception

check_content_type(response)

return cast(dict[str, Any], await response.json()), response.status

async def request_once(
self,
method: str,
url: str,
params: dict[str, Any] | None = None,
headers: dict | None = None,
json: dict | None = None,
):
"""Sends a single request to FlexMeasures and checks the response"""
response = await self.session.request(
method=method,
url=url,
params=params,
headers=headers,
json=json,
ssl=self.ssl,
)
await check_response(
self,
response,
)
print(response.headers)
return response

def start_session(self):
"""If there is no session, start one"""
if self.session is None:
self.session = ClientSession()

async def get_headers(self, include_auth: bool) -> dict:
"""If the request needs to be authenticated check if there is a access_token or request one. Then create the headers dict""" # noqa: E501
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
headers = CONTENT_TYPE_HEADERS
if include_auth:
if self.access_token is None:
await self.get_access_token()
headers |= {"Authorization": self.access_token}
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
print(headers)
return headers

def build_url(self, uri: str, path: str = path):
url = URL.build(scheme=self.scheme, host=self.host, path=path).join(
URL(uri),
)
return url

async def get_access_token(self):
"""Get access token and store it on the FlexMeasuresClient."""
response, _status = await self.request(
uri="requestAuthToken",
path="/api/",
victorgarcia98 marked this conversation as resolved.
Show resolved Hide resolved
json={
"email": self.email,
"password": self.password,
},
include_auth=False,
)
print(response, _status)
self.access_token = response["auth_token"]

async def post_measurements(
self,
sensor_id: int,
start: str,
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
duration: str,
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
values: list[float],
unit: str,
entity_address: str,
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
prior: str | None = None,
):
"""Post sensor data for the given time range."""
json = dict(
sensor=f"{entity_address}.{sensor_id}",
start=pd.Timestamp(
start
).isoformat(), # for example: 2021-10-13T00:00+02:00
duration=pd.Timedelta(duration).isoformat(), # for example: PT1H
values=values,
unit=unit,
)
if prior:
json["prior"] = prior

response, status = await self.request(
uri="sensors/data",
json=json,
)
check_for_status(status, 200)
print("Sensor data sent successfully.")

async def trigger_storage_schedule(
self,
sensor_id: int,
start: str,
soc_unit: str,
soc_at_start: float,
soc_targets: list,
consumption_price_sensor: int | None = None,
production_price_sensor: int | None = None,
inflexible_device_sensors: list[int] | None = None,
):
"""Post schedule trigger with initial and target states of charge (soc)."""
message = {
"start": pd.Timestamp(
start
).isoformat(), # for example: 2021-10-13T00:00+02:00
"flex-model": {
"soc-unit": soc_unit,
"soc-at-start": soc_at_start,
"soc-targets": soc_targets,
},
"flex-context": {},
}

# Set optional flex context
if consumption_price_sensor is not None:
message["flex-context"][
"consumption-price-sensor"
] = consumption_price_sensor
if production_price_sensor is not None:
message["flex-context"]["production-price-sensor"] = production_price_sensor
if inflexible_device_sensors is not None:
message["flex-context"][
"inflexible-device-sensors"
] = inflexible_device_sensors

response, status = await self.request(
uri=f"sensors/{sensor_id}/schedules/trigger",
json=message,
)
check_for_status(status, 200)
print("Schedule triggered successfully.")

async def get_schedule(
self,
sensor_id: int,
schedule_id: str,
duration: str,
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
):
"""Get schedule with given ID."""
response, status = await self.request(
uri=f"sensors/{sensor_id}/schedules/{schedule_id}",
method="GET",
json={
"duration": pd.Timedelta(duration).isoformat(), # for example: PT1H
},
)
check_for_status(status, 200)
GustaafL marked this conversation as resolved.
Show resolved Hide resolved

return response

async def get_assets(self):
"""Get all the assets available to the current user"""
response, status = await self.request(uri="assets", method="GET")
check_for_status(status, 200)
return response

async def get_sensors(self):
GustaafL marked this conversation as resolved.
Show resolved Hide resolved
"""Get all the sensors available to the current user"""
response, status = await self.request(uri="sensors", method="GET")
check_for_status(status, 200)
return response
Loading