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: 2 additions & 2 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ jobs:
poetry run pytest -v --license-server=1055@$LICENSE_SERVER --no-server-log-files --docker-image=$IMAGE_NAME --cov=ansys.acp.core --cov-report=term --cov-report=xml --cov-report=html
env:
LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }}
IMAGE_NAME: "ghcr.io/ansys/acp${{ github.event.inputs.docker_image_suffix || ':latest' }}"
IMAGE_NAME: ${{ env.DOCKER_IMAGE_NAME }}

- name: "Upload coverage to Codecov"
uses: codecov/codecov-action@v4
Expand Down Expand Up @@ -279,7 +279,7 @@ jobs:
run: >
poetry run
ansys-launcher configure ACP docker_compose
--image_name_pyacp=ghcr.io/ansys/acp${{ github.event.inputs.docker_image_suffix || ':latest' }}
--image_name_pyacp=${{ env.DOCKER_IMAGE_NAME }}
--image_name_filetransfer=ghcr.io/ansys/tools-filetransfer:latest
--license_server=1055@$LICENSE_SERVER
--keep_volume=False
Expand Down
98 changes: 74 additions & 24 deletions src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from ..._utils.property_protocols import ReadOnlyProperty, ReadWriteProperty
from .polymorphic_from_pb import CreatableFromResourcePath, tree_object_from_resource_path
from .protocols import Editable, GrpcObjectBase, ObjectInfo, Readable
from .supported_since import supported_since as supported_since_decorator

# Note: The typing of the protobuf objects is fairly loose, maybe it could
# be improved. The main challenge is that we do not encode the structure of
Expand Down Expand Up @@ -110,7 +111,10 @@


def grpc_data_getter(
name: str, from_protobuf: _FROM_PROTOBUF_T[_GET_T], check_optional: bool = False
name: str,
from_protobuf: _FROM_PROTOBUF_T[_GET_T],
check_optional: bool = False,
supported_since: str | None = None,
) -> Callable[[Readable], _GET_T]:
"""Create a getter method which obtains the server object via the gRPC Get endpoint.

Expand All @@ -125,6 +129,14 @@
will be used.
"""

@supported_since_decorator(
supported_since,
# The default error message uses 'inner' as the method name, which is confusing
err_msg_tpl=(
f"The property '{name.split('.')[-1]}' is only readable since version {{required_version}} "
f"of the ACP gRPC server. The current server version is {{server_version}}."
),
)
def inner(self: Readable) -> Any:
self._get_if_stored()
pb_attribute = _get_data_attribute(self._pb_object, name, check_optional=check_optional)
Expand All @@ -149,26 +161,6 @@
return inner


def grpc_data_setter(
name: str, to_protobuf: _TO_PROTOBUF_T[_SET_T]
) -> Callable[[Editable, _SET_T], None]:
"""Create a setter method which updates the server object via the gRPC Put endpoint."""

def inner(self: Editable, value: _SET_T) -> None:
self._get_if_stored()
current_value = _get_data_attribute(self._pb_object, name)
value_pb = to_protobuf(value)
try:
needs_updating = current_value != value_pb
except TypeError:
needs_updating = True
if needs_updating:
_set_data_attribute(self._pb_object, name, value_pb)
self._put_if_stored()

return inner


def _get_data_attribute(pb_obj: Message, name: str, check_optional: bool = False) -> _PROTOBUF_T:
name_parts = name.split(".")
if check_optional:
Expand Down Expand Up @@ -197,6 +189,37 @@
target_object.add().CopyFrom(item)


def grpc_data_setter(
name: str,
to_protobuf: _TO_PROTOBUF_T[_SET_T],
setter_func: Callable[[ObjectInfo, str, _PROTOBUF_T], None] = _set_data_attribute,
supported_since: str | None = None,
) -> Callable[[Editable, _SET_T], None]:
"""Create a setter method which updates the server object via the gRPC Put endpoint."""

@supported_since_decorator(
supported_since,
# The default error message uses 'inner' as the method name, which is confusing
err_msg_tpl=(
f"The property '{name.split('.')[-1]}' is only editable since version {{required_version}} "
f"of the ACP gRPC server. The current server version is {{server_version}}."
),
)
def inner(self: Editable, value: _SET_T) -> None:
self._get_if_stored()
current_value = _get_data_attribute(self._pb_object, name)
value_pb = to_protobuf(value)
try:
needs_updating = current_value != value_pb
except TypeError:
needs_updating = True

Check warning on line 215 in src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py#L214-L215

Added lines #L214 - L215 were not covered by tests
if needs_updating:
setter_func(self._pb_object, name, value_pb)
self._put_if_stored()

return inner


AnyT = TypeVar("AnyT")


Expand All @@ -212,6 +235,9 @@
from_protobuf: _FROM_PROTOBUF_T[_GET_T] = lambda x: x,
check_optional: bool = False,
doc: str | None = None,
setter_func: Callable[[ObjectInfo, str, _PROTOBUF_T], None] = _set_data_attribute,
readable_since: str | None = None,
writable_since: str | None = None,
) -> ReadWriteProperty[_GET_T, _SET_T]:
"""Define a property which is synchronized with the backend via gRPC.

Expand All @@ -234,6 +260,10 @@
will be used.
doc :
Docstring for the property.
readable_since :
Version since which the property is supported for reading.
writable_since :
Version since which the property is supported for setting.
"""
# Note jvonrick August 2023: We don't ensure with typechecks that the property returned here is
# compatible with the class on which this property is created. For example:
Expand All @@ -244,8 +274,20 @@
# https://github.com/python/typing/issues/985
return _wrap_doc(
_exposed_grpc_property(
grpc_data_getter(name, from_protobuf=from_protobuf, check_optional=check_optional)
).setter(grpc_data_setter(name, to_protobuf=to_protobuf)),
grpc_data_getter(
name,
from_protobuf=from_protobuf,
check_optional=check_optional,
supported_since=readable_since,
)
).setter(
grpc_data_setter(
name,
to_protobuf=to_protobuf,
setter_func=setter_func,
supported_since=writable_since,
)
),
doc=doc,
)

Expand All @@ -255,6 +297,7 @@
from_protobuf: _FROM_PROTOBUF_T[_GET_T] = lambda x: x,
check_optional: bool = False,
doc: str | None = None,
supported_since: str | None = None,
) -> ReadOnlyProperty[_GET_T]:
"""Define a read-only property which is synchronized with the backend via gRPC.

Expand All @@ -275,10 +318,17 @@
will be used.
doc :
Docstring for the property.
supported_since :
Version since which the property is supported.
"""
return _wrap_doc(
_exposed_grpc_property(
grpc_data_getter(name, from_protobuf=from_protobuf, check_optional=check_optional)
grpc_data_getter(
name,
from_protobuf=from_protobuf,
check_optional=check_optional,
supported_since=supported_since,
)
),
doc=doc,
)
Expand Down
4 changes: 4 additions & 0 deletions src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from google.protobuf.message import Message
import grpc
from packaging.version import Version

from ansys.api.acp.v0.base_pb2 import (
BasicInfo,
Expand Down Expand Up @@ -188,6 +189,9 @@ def _resource_path(self) -> ResourcePath: ...

_pb_object: Any

@property
def _server_version(self) -> Version | None: ...


class Editable(Readable, Protocol):
"""Interface definition for editable objects."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from collections.abc import Callable
from functools import wraps
from typing import Concatenate, TypeAlias, TypeVar

from packaging.version import parse as parse_version
from typing_extensions import ParamSpec

from .protocols import Readable

T = TypeVar("T", bound=Readable)
P = ParamSpec("P")
R = TypeVar("R")
_WRAPPED_T: TypeAlias = Callable[Concatenate[T, P], R]


def supported_since(
version: str | None, err_msg_tpl: str | None = None
) -> Callable[[_WRAPPED_T[T, P, R]], _WRAPPED_T[T, P, R]]:
"""Mark a TreeObjectBase method as supported since a specific server version.

Raises an exception if the current server version does not match the required version.
If either the given `version` or the server version is `None`, the decorator does nothing.

Parameters
----------
version : Optional[str]
The server version since which the method is supported. If ``None``, the
decorator does nothing.
err_msg_tpl : Optional[str]
A custom error message template. If ``None``, a default error message is used.
"""
if version is None:
# return a trivial decorator if no version is specified
def trivial_decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]:
return func

return trivial_decorator

required_version = parse_version(version)

def decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]:
@wraps(func)
def inner(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
server_version = self._server_version
# If the object is not stored, we cannot check the server version.
if server_version is not None:
if server_version < required_version:
if err_msg_tpl is None:
err_msg = (
f"The method '{func.__name__}' is only supported since version {version} "
f"of the ACP gRPC server. The current server version is {server_version}."
)
else:
err_msg = err_msg_tpl.format(

Check warning on line 76 in src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py#L76

Added line #L76 was not covered by tests
required_version=required_version, server_version=server_version
)
raise RuntimeError(err_msg)
return func(self, *args, **kwargs)

return inner

return decorator
39 changes: 8 additions & 31 deletions src/ansys/acp/core/_tree_objects/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,13 @@
from abc import abstractmethod
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from functools import wraps
import typing
from typing import Any, Concatenate, Generic, TypeAlias, TypeVar, cast
from typing import Any, Generic, TypeVar, cast

from grpc import Channel
from packaging.version import Version
from packaging.version import parse as parse_version
from typing_extensions import ParamSpec, Self
from typing_extensions import Self

from ansys.api.acp.v0.base_pb2 import CollectionPath, DeleteRequest, GetRequest, ResourcePath

Expand Down Expand Up @@ -147,6 +146,12 @@
assert self._server_wrapper_store is not None
return self._server_wrapper_store

@property
def _server_version(self) -> Version | None:
if not self._is_stored:
return None

Check warning on line 152 in src/ansys/acp/core/_tree_objects/base.py

View check run for this annotation

Codecov / codecov/patch

src/ansys/acp/core/_tree_objects/base.py#L152

Added line #L152 was not covered by tests
return self._server_wrapper.version

@property
def _is_stored(self) -> bool:
return self._server_wrapper_store is not None
Expand Down Expand Up @@ -478,34 +483,6 @@
self._put()


T = TypeVar("T", bound=TreeObjectBase)
P = ParamSpec("P")
R = TypeVar("R")
_WRAPPED_T: TypeAlias = Callable[Concatenate[T, P], R]


def supported_since(version: str) -> Callable[[_WRAPPED_T[T, P, R]], _WRAPPED_T[T, P, R]]:
"""Mark a TreeObjectBase method as supported since a specific server version.

Raises an exception if the current server version does not match the required version.
"""
required_version = parse_version(version)

def decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]:
@wraps(func)
def inner(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
if self._server_wrapper.version < required_version:
raise RuntimeError(
f"The method '{func.__name__}' is only supported since version {version} of the ACP "
f"gRPC server. The current server version is {self._server_wrapper.version}."
)
return func(self, *args, **kwargs)

return inner

return decorator


if typing.TYPE_CHECKING: # pragma: no cover
# Ensure that the ReadOnlyTreeObject satisfies the Gettable interface
_x: Readable = typing.cast(ReadOnlyTreeObject, None)
Expand Down
3 changes: 2 additions & 1 deletion src/ansys/acp/core/_tree_objects/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
grpc_data_property_read_only,
mark_grpc_properties,
)
from ._grpc_helpers.supported_since import supported_since
from ._mesh_data import (
ElementalData,
NodalData,
Expand All @@ -87,7 +88,7 @@
elemental_data_property,
nodal_data_property,
)
from .base import ServerWrapper, TreeObject, supported_since
from .base import ServerWrapper, TreeObject
from .boolean_selection_rule import BooleanSelectionRule
from .cad_geometry import CADGeometry
from .cutoff_selection_rule import CutoffSelectionRule
Expand Down
8 changes: 6 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,15 @@ def inner(model, relative_file_path="square_and_solid.stp"):


@pytest.fixture
def xfail_before(acp_instance):
def raises_before_version(acp_instance):
"""Mark a test as expected to fail before a certain server version."""

@contextmanager
def inner(version: str):
if parse_version(acp_instance.server_version) < parse_version(version):
pytest.xfail(f"Expected to fail until server version {version!r}")
with pytest.raises(RuntimeError):
yield
else:
yield

return inner
Loading