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

feat: update python api client #193

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Release Versions:

- [3.1.0](#300)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- [3.1.0](#300)
- [3.0.0](#300)

eeberhard marked this conversation as resolved.
Show resolved Hide resolved
- [2.1.0](#210)
- [2.0.0](#200)
- [1.2.1](#121)
Expand All @@ -11,6 +12,10 @@ Release Versions:
- [1.0.1](#101)
- [1.0.0](#100)

## 3.0.0

Version 3.0.0 of the AICA API client is compatible with the new AICA Core version 4.0.

## 2.1.0

- Support hardware and controller states and predicates in `wait_for` functions (#156)
Expand Down
21 changes: 11 additions & 10 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Python AICA API Client

The AICA API client module provides simple functions for interacting with the AICA API.
The AICA API client module provides simple functions for interacting with the API of the AICA Core.

```shell
pip install aica-api
Expand All @@ -22,7 +22,7 @@ aica.unload_component('my_component')
aica.stop_application()
```

To check the status of component predicates and conditions, the following blocking methods can be employed:
To check the status of predicates and conditions, the following blocking methods can be employed:

```python
from aica_api.client import AICA
Expand All @@ -34,28 +34,29 @@ if aica.wait_for_condition('timer_1_active', timeout=10.0):
else:
print('Timed out before condition was true')

if aica.wait_for_predicate('timer_1', 'is_timed_out', timeout=10.0):
if aica.wait_for_component_predicate('timer_1', 'is_timed_out', timeout=10.0):
print('Predicate is true!')
else:
print('Timed out before predicate was true')
```

## Compatability table

The latest version of the AICA API client will generally support the latest API server version in the AICA framework.
The latest version of the AICA API client will generally support the API server in the AICA Core version.
Major changes to the API client or server versions indicate breaking changes and are not backwards compatible. To
interact with older versions of the AICA framework, it may be necessary to install older versions of the client.
interact with older versions of the AICA Core, it may be necessary to install older versions of the client.
Use the following compatability table to determine which client version to use.

| API server version | Matching Python client version |
| AICA Core version | Matching Python client version |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| AICA Core version | Matching Python client version |
| AICA Core version | Matching Python client version |

|--------------------|---------------------------------|
| `4.x` | `>= 3.0.0` |
| `3.x` | `>= 2.0.0` |
| `2.x` | `1.2.0` |
| `<= 1.x` | Unsupported |

Between major version changes, minor updates to the API server version and Python client versions may introduce new
endpoints and functions respectively. If a function requires a feature that the detected API server version does not yet
support (as is the case when the Python client version is more up-to-date than the targeted API server), then calling
Between major version changes, minor updates to the AICA Core version and Python client versions may introduce new
endpoints and functions respectively. If a function requires a feature that the detected AICA Core version does not yet
support (as is the case when the Python client version is more up-to-date than the targeted AICA Core), then calling
that function will return None with a warning.

Recent client versions also support the following functions to check the client version and API compatability.
Expand All @@ -66,7 +67,7 @@ from aica_api.client import AICA
aica = AICA()

# get the current API server version
print(aica.api_version())
print(aica.core_version())
# get the current client version
print(aica.client_version())

Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "aica_api"
version = "2.1.0"
version = "3.0.0"
authors = [
{ name="Enrico Eberhard", email="[email protected]" },
]
Expand Down
170 changes: 123 additions & 47 deletions python/src/aica_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def __init__(self, url: str = 'localhost', port: Union[str, int] = '5000'):
self._address = f'http://{url}:{port}'

self._logger = getLogger(__name__)
self._api_version = None
self._core_version = None

def _endpoint(self, endpoint=''):
"""
Expand All @@ -52,13 +52,13 @@ def _endpoint(self, endpoint=''):
return f'{self._address}/v2/{endpoint}'

@staticmethod
def _requires_api_version(version):
def _requires_core_version(version):
"""
Decorator to mark a function with a specific API server version constraint.
Decorator to mark a function with a specific AICA Core version constraint.
Elides the function call and returns None with a warning if the version constraint is violated.

Example usage:
@_requires_api_version('>=3.2.1')
@_requires_core_version('>=3.2.1')
domire8 marked this conversation as resolved.
Show resolved Hide resolved
def my_new_endpoint()
...

Expand All @@ -67,32 +67,64 @@ def my_new_endpoint()
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if self._api_version is None and self.api_version() is None:
if self._core_version is None and self.api_version() is None:
return None
if not semver.match(self._api_version, version):
if not semver.match(self._core_version, version):
self._logger.warning(f'The function {func.__name__} requires API server version {version}, '
f'but the current API server version is {self._api_version}')
f'but the current API server version is {self._core_version}')
return None
return func(self, *args, **kwargs)

return wrapper

return decorator

@deprecated(deprecated_in='3.0.0', removed_in='4.0.0', current_version=CLIENT_VERSION,
details='Use the core_version function instead')
def api_version(self) -> Union[str, None]:
"""
Get the version of the AICA API server

:return: The version of the API server or None in case of
:return: The version of the API server or None in case of connection failure
"""
try:
self._api_version = requests.get(f'{self._address}/version').json()
self._logger.debug(f'API server version identified as {self._api_version}')
self._core_version = requests.get(f'{self._address}/version').json()
self._logger.debug(f'API server version identified as {self._core_version}')
except requests.exceptions.RequestException:
self._logger.error(f'Error connecting to the API server at {self._address}! '
f'Check that the AICA container is running and configured with the right address.')
self._api_version = None
return self._api_version
self._core_version = None
return self._core_version

def core_version(self) -> Union[str, None]:
"""
Get the version of the AICA Core

:return: The version of the AICA core or None in case of connection failure
"""
try:
self._core_version = requests.get(f'{self._address}/version').json()
self._logger.debug(f'AICA Core version identified as {self._core_version}')
except requests.exceptions.RequestException:
self._logger.error(f'Error connecting to the API server at {self._address}! '
f'Check that the AICA Core is running and configured with the right address.')
self._core_version = None
return self._core_version

def protocol(self) -> Union[str, None]:
"""
Get the version of the API protocol

:return: The version of the API protocol or None in case of connection failure
"""
try:
protocol = requests.get(f'{self._address}/protocol').json()
self._logger.debug(f'API protocol version identified as {protocol}')
return protocol
except requests.exceptions.RequestException:
self._logger.error(f'Error connecting to the API server at {self._address}! '
f'Check that the AICA Core is running and configured with the right address.')
return None

@staticmethod
def client_version() -> str:
Expand All @@ -105,30 +137,35 @@ def client_version() -> str:

def check(self) -> bool:
"""
Check if this API client is compatible with the detected API server version
Check if this API client is compatible with the detected AICA Core version

:return: True if the client is compatible with the API server version, False otherwise
:return: True if the client is compatible with the AICA Core version, False otherwise
"""
if self._api_version is None and self.api_version() is None:
if self._core_version is None and self.api_version() is None:
return False

version_info = semver.parse_version_info(self._api_version)
version_info = semver.parse_version_info(self._core_version)

if version_info.major == 3:
if version_info.major == 4:
return True
elif version_info.major > 3:
self._logger.error(f'The detected API version v{self._api_version} is newer than the maximum API version '
f'supported by this client (v{self.client_version()}). Please upgrade the Python API '
f'client version for newer API server versions.')
elif version_info.major > 4:
self._logger.error(f'The detected AICA Core version v{self._core_version} is newer than the maximum AICA '
f'Core version supported by this client (v{self.client_version()}). Please upgrade the '
f'Python API client version for newer API server versions.')
return False
elif version_info.major == 3:
self._logger.error(f'The detected AICA Core version v{self._core_version} is older than the minimum AICA '
f'Core version supported by this client (v{self.client_version()}). Please downgrade '
f'the Python API client to version v2.1.0 for API server versions v3.X.')
return False
elif version_info.major == 2:
self._logger.error(f'The detected API version v{self._api_version} is older than the minimum API version '
f'supported by this client (v{self.client_version()}). Please downgrade the Python API '
f'client to version v1.2.0 for API server versions v2.X.')
self._logger.error(f'The detected AICA Core version v{self._core_version} is older than the minimum AICA '
f'Core version supported by this client (v{self.client_version()}). Please downgrade '
f'the Python API client to version v1.2.0 for API server versions v2.X.')
return False
else:
self._logger.error(f'The detected API version v{self._api_version} is deprecated and not supported by '
f'this API client!')
self._logger.error(f'The detected AICA Core version v{self._core_version} is deprecated and not supported '
f'by this API client!')
return False

def component_descriptions(self) -> requests.Response:
Expand All @@ -143,6 +180,8 @@ def controller_descriptions(self) -> requests.Response:
"""
return requests.get(self._endpoint('controllers'))

@deprecated(deprecated_in='3.0.0', removed_in='4.0.0', current_version=CLIENT_VERSION,
details='Use the call_component_service function instead')
def call_service(self, component: str, service: str, payload: str) -> requests.Response:
"""
Call a service on a component.
Expand All @@ -155,6 +194,31 @@ def call_service(self, component: str, service: str, payload: str) -> requests.R
data = {"payload": payload}
return requests.put(self._endpoint(endpoint), json=data)

def call_component_service(self, component: str, service: str, payload: str) -> requests.Response:
domire8 marked this conversation as resolved.
Show resolved Hide resolved
"""
Call a service on a component.

:param component: The name of the component
:param service: The name of the service
:param payload: The service payload, formatted according to the respective service description
"""
endpoint = 'application/components/' + component + '/service/' + service
data = {"payload": payload}
return requests.put(self._endpoint(endpoint), json=data)

def call_controller_service(self, hardware: str, controller: str, service: str, payload: str) -> requests.Response:
"""
Call a service on a controller.

:param hardware: The name of the hardware interface
:param controller: The name of the controller
:param service: The name of the service
:param payload: The service payload, formatted according to the respective service description
"""
endpoint = 'application/hardware/' + hardware + '/controller/' + controller + '/service/' + service
data = {"payload": payload}
return requests.put(self._endpoint(endpoint), json=data)

def get_application_state(self) -> requests.Response:
"""
Get the application state
Expand Down Expand Up @@ -258,7 +322,8 @@ def set_controller_parameter(self, hardware: str, controller: str, parameter: st
def set_lifecycle_transition(self, component: str, transition: str) -> requests.Response:
"""
Trigger a lifecycle transition on a component. The transition label must be one of the following:
['configure', 'activate', 'deactivate', 'cleanup', 'shutdown']
['configure', 'activate', 'deactivate', 'cleanup', 'unconfigured_shutdown', 'inactive_shutdown',
'acitve_shutdown']

The transition will only be executed if the target is a lifecycle component and the current lifecycle state
allows the requested transition.
Expand Down Expand Up @@ -324,6 +389,18 @@ def get_application(self):
endpoint = "application"
return requests.get(self._endpoint(endpoint))

def manage_sequence(self, sequence_name: str, action: str):
"""
Manage a sequence. The action label must be one of the following: ['start', 'restart', 'abort']

The action will only be executed if the sequence exists and allows the requested action.

:param sequence_name: The name of the sequence
:param action: The sequence action label
"""
endpoint = f"application/sequences/{sequence_name}?action={action}"
return requests.put(self._endpoint(endpoint))

def wait_for_component(self, component: str, state: str, timeout: Union[None, int, float] = None) -> bool:
"""
Wait for a component to be in a particular state. Components can be in any of the following states:
Expand All @@ -337,7 +414,7 @@ def wait_for_component(self, component: str, state: str, timeout: Union[None, in
return read_until(lambda data: data[component]['state'] == state, url=self._address, namespace='/v2/components',
event='component_data', timeout=timeout) is not None

@_requires_api_version('>=3.1.0')
@_requires_core_version('>=3.1.0')
def wait_for_hardware(self, hardware: str, state: str, timeout: Union[None, int, float] = None) -> bool:
"""
Wait for a hardware interface to be in a particular state. Hardware can be in any of the following states:
Expand All @@ -351,12 +428,12 @@ def wait_for_hardware(self, hardware: str, state: str, timeout: Union[None, int,
return read_until(lambda data: data[hardware]['state'] == state, url=self._address, namespace='/v2/hardware',
event='hardware_data', timeout=timeout) is not None

@_requires_api_version('>=3.1.0')
@_requires_core_version('>=3.1.0')
def wait_for_controller(self, hardware: str, controller: str, state: str,
timeout: Union[None, int, float] = None) -> bool:
"""
Wait for a controller to be in a particular state. Controllers can be in any of the following states:
['unloaded', 'loaded', 'unconfigured', 'inactive', 'active', 'finalized']
['unloaded', 'loaded', 'active', 'finalized']

:param hardware: The name of the hardware interface responsible for the controller
:param controller: The name of the controller
Expand All @@ -367,21 +444,7 @@ def wait_for_controller(self, hardware: str, controller: str, state: str,
return read_until(lambda data: data[hardware]['controllers'][controller]['state'] == state, url=self._address,
namespace='/v2/hardware', event='hardware_data', timeout=timeout) is not None

@deprecated(deprecated_in='2.1.0', removed_in='3.0.0', current_version=CLIENT_VERSION,
details='Use the wait_for_component_predicate function instead')
def wait_for_predicate(self, component: str, predicate: str, timeout: Union[None, int, float] = None) -> bool:
"""
Wait until a component predicate is true.

:param component: The name of the component
:param predicate: The name of the predicate
:param timeout: Timeout duration in seconds. If set to None, block indefinitely
:return: True if the predicate is true before the timeout duration, False otherwise
"""
return read_until(lambda data: data[component]['predicates'][predicate], url=self._address,
namespace='/v2/components', event='component_data', timeout=timeout) is not None

@_requires_api_version('>=3.1.0')
@_requires_core_version('>=3.1.0')
def wait_for_component_predicate(self, component: str, predicate: str,
timeout: Union[None, int, float] = None) -> bool:
"""
Expand All @@ -395,7 +458,7 @@ def wait_for_component_predicate(self, component: str, predicate: str,
return read_until(lambda data: data[component]['predicates'][predicate], url=self._address,
namespace='/v2/components', event='component_data', timeout=timeout) is not None

@_requires_api_version('>=3.1.0')
@_requires_core_version('>=3.1.0')
def wait_for_controller_predicate(self, hardware: str, controller: str, predicate: str,
timeout: Union[None, int, float] = None) -> bool:
"""
Expand All @@ -411,7 +474,7 @@ def wait_for_controller_predicate(self, hardware: str, controller: str, predicat
url=self._address, namespace='/v2/hardware', event='hardware_data',
timeout=timeout) is not None

def wait_for_condition(self, condition, timeout=None) -> bool:
def wait_for_condition(self, condition: str, timeout=None) -> bool:
"""
Wait until a condition is true.

Expand All @@ -421,3 +484,16 @@ def wait_for_condition(self, condition, timeout=None) -> bool:
"""
return read_until(lambda data: data[condition], url=self._address, namespace='/v2/conditions',
event='conditions', timeout=timeout) is not None

def wait_for_sequence(self, sequence: str, state: str, timeout=None) -> bool:
"""
Wait for a sequence to be in a particular state. Sequences can be in any of the following states:
['active', 'inactive', 'aborted']

:param sequence: The name of the sequence
:param state: The state of the sequence to wait for
:param timeout: Timeout duration in seconds. If set to None, block indefinitely
:return: True if the condition is true before the timeout duration, False otherwise
"""
return read_until(lambda data: data[sequence]['state'] == state, url=self._address, namespace='/v2/sequences',
event='sequences', timeout=timeout) is not None