Skip to content

Commit e8862c9

Browse files
committed
Add 'supported_since' keywords to the gRPC property helpers
Allow marking gRPC properties as supported since a specific server version. The `grpc_data_property_read_only` is given a `supported_since` keyword, and `grpc_data_property` is given two separate keywords `readable_since` and `writable_since`. Other changes: - Change the `xfail_before` test fixture to `raises_before_version`, which explicitly checks that a `RuntimeError` is raised when run on an older server version. - Move the `supported_since` implementation to a separate file. - In the CI definition, reuse the `DOCKER_IMAGE_NAME` variable in more places.
1 parent 57ec18e commit e8862c9

File tree

8 files changed

+186
-65
lines changed

8 files changed

+186
-65
lines changed

.github/workflows/ci_cd.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ jobs:
170170
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
171171
env:
172172
LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }}
173-
IMAGE_NAME: "ghcr.io/ansys/acp${{ github.event.inputs.docker_image_suffix || ':latest' }}"
173+
IMAGE_NAME: ${{ env.DOCKER_IMAGE_NAME }}
174174

175175
- name: "Upload coverage to Codecov"
176176
uses: codecov/codecov-action@v4
@@ -279,7 +279,7 @@ jobs:
279279
run: >
280280
poetry run
281281
ansys-launcher configure ACP docker_compose
282-
--image_name_pyacp=ghcr.io/ansys/acp${{ github.event.inputs.docker_image_suffix || ':latest' }}
282+
--image_name_pyacp=${{ env.DOCKER_IMAGE_NAME }}
283283
--image_name_filetransfer=ghcr.io/ansys/tools-filetransfer:latest
284284
--license_server=1055@$LICENSE_SERVER
285285
--keep_volume=False

src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from ..._utils.property_protocols import ReadOnlyProperty, ReadWriteProperty
3939
from .polymorphic_from_pb import CreatableFromResourcePath, tree_object_from_resource_path
4040
from .protocols import Editable, GrpcObjectBase, ObjectInfo, Readable
41+
from .supported_since import supported_since as supported_since_decorator
4142

4243
# Note: The typing of the protobuf objects is fairly loose, maybe it could
4344
# be improved. The main challenge is that we do not encode the structure of
@@ -110,7 +111,10 @@ def inner(self: Readable) -> CreatableFromResourcePath | None:
110111

111112

112113
def grpc_data_getter(
113-
name: str, from_protobuf: _FROM_PROTOBUF_T[_GET_T], check_optional: bool = False
114+
name: str,
115+
from_protobuf: _FROM_PROTOBUF_T[_GET_T],
116+
check_optional: bool = False,
117+
supported_since: str | None = None,
114118
) -> Callable[[Readable], _GET_T]:
115119
"""Create a getter method which obtains the server object via the gRPC Get endpoint.
116120
@@ -125,6 +129,14 @@ def grpc_data_getter(
125129
will be used.
126130
"""
127131

132+
@supported_since_decorator(
133+
supported_since,
134+
# The default error message uses 'inner' as the method name, which is confusing
135+
err_msg_tpl=(
136+
f"The property '{name.split('.')[-1]}' is only readable since version {{required_version}} "
137+
f"of the ACP gRPC server. The current server version is {{server_version}}."
138+
),
139+
)
128140
def inner(self: Readable) -> Any:
129141
self._get_if_stored()
130142
pb_attribute = _get_data_attribute(self._pb_object, name, check_optional=check_optional)
@@ -149,26 +161,6 @@ def inner(self: Editable, value: Readable | None) -> None:
149161
return inner
150162

151163

152-
def grpc_data_setter(
153-
name: str, to_protobuf: _TO_PROTOBUF_T[_SET_T]
154-
) -> Callable[[Editable, _SET_T], None]:
155-
"""Create a setter method which updates the server object via the gRPC Put endpoint."""
156-
157-
def inner(self: Editable, value: _SET_T) -> None:
158-
self._get_if_stored()
159-
current_value = _get_data_attribute(self._pb_object, name)
160-
value_pb = to_protobuf(value)
161-
try:
162-
needs_updating = current_value != value_pb
163-
except TypeError:
164-
needs_updating = True
165-
if needs_updating:
166-
_set_data_attribute(self._pb_object, name, value_pb)
167-
self._put_if_stored()
168-
169-
return inner
170-
171-
172164
def _get_data_attribute(pb_obj: Message, name: str, check_optional: bool = False) -> _PROTOBUF_T:
173165
name_parts = name.split(".")
174166
if check_optional:
@@ -197,6 +189,37 @@ def _set_data_attribute(pb_obj: ObjectInfo, name: str, value: _PROTOBUF_T) -> No
197189
target_object.add().CopyFrom(item)
198190

199191

192+
def grpc_data_setter(
193+
name: str,
194+
to_protobuf: _TO_PROTOBUF_T[_SET_T],
195+
setter_func: Callable[[ObjectInfo, str, _PROTOBUF_T], None] = _set_data_attribute,
196+
supported_since: str | None = None,
197+
) -> Callable[[Editable, _SET_T], None]:
198+
"""Create a setter method which updates the server object via the gRPC Put endpoint."""
199+
200+
@supported_since_decorator(
201+
supported_since,
202+
# The default error message uses 'inner' as the method name, which is confusing
203+
err_msg_tpl=(
204+
f"The property '{name.split('.')[-1]}' is only editable since version {{required_version}} "
205+
f"of the ACP gRPC server. The current server version is {{server_version}}."
206+
),
207+
)
208+
def inner(self: Editable, value: _SET_T) -> None:
209+
self._get_if_stored()
210+
current_value = _get_data_attribute(self._pb_object, name)
211+
value_pb = to_protobuf(value)
212+
try:
213+
needs_updating = current_value != value_pb
214+
except TypeError:
215+
needs_updating = True
216+
if needs_updating:
217+
setter_func(self._pb_object, name, value_pb)
218+
self._put_if_stored()
219+
220+
return inner
221+
222+
200223
AnyT = TypeVar("AnyT")
201224

202225

@@ -212,6 +235,9 @@ def grpc_data_property(
212235
from_protobuf: _FROM_PROTOBUF_T[_GET_T] = lambda x: x,
213236
check_optional: bool = False,
214237
doc: str | None = None,
238+
setter_func: Callable[[ObjectInfo, str, _PROTOBUF_T], None] = _set_data_attribute,
239+
readable_since: str | None = None,
240+
writable_since: str | None = None,
215241
) -> ReadWriteProperty[_GET_T, _SET_T]:
216242
"""Define a property which is synchronized with the backend via gRPC.
217243
@@ -234,6 +260,10 @@ def grpc_data_property(
234260
will be used.
235261
doc :
236262
Docstring for the property.
263+
readable_since :
264+
Version since which the property is supported for reading.
265+
writable_since :
266+
Version since which the property is supported for setting.
237267
"""
238268
# Note jvonrick August 2023: We don't ensure with typechecks that the property returned here is
239269
# compatible with the class on which this property is created. For example:
@@ -244,8 +274,20 @@ def grpc_data_property(
244274
# https://github.com/python/typing/issues/985
245275
return _wrap_doc(
246276
_exposed_grpc_property(
247-
grpc_data_getter(name, from_protobuf=from_protobuf, check_optional=check_optional)
248-
).setter(grpc_data_setter(name, to_protobuf=to_protobuf)),
277+
grpc_data_getter(
278+
name,
279+
from_protobuf=from_protobuf,
280+
check_optional=check_optional,
281+
supported_since=readable_since,
282+
)
283+
).setter(
284+
grpc_data_setter(
285+
name,
286+
to_protobuf=to_protobuf,
287+
setter_func=setter_func,
288+
supported_since=writable_since,
289+
)
290+
),
249291
doc=doc,
250292
)
251293

@@ -255,6 +297,7 @@ def grpc_data_property_read_only(
255297
from_protobuf: _FROM_PROTOBUF_T[_GET_T] = lambda x: x,
256298
check_optional: bool = False,
257299
doc: str | None = None,
300+
supported_since: str | None = None,
258301
) -> ReadOnlyProperty[_GET_T]:
259302
"""Define a read-only property which is synchronized with the backend via gRPC.
260303
@@ -275,10 +318,17 @@ def grpc_data_property_read_only(
275318
will be used.
276319
doc :
277320
Docstring for the property.
321+
supported_since :
322+
Version since which the property is supported.
278323
"""
279324
return _wrap_doc(
280325
_exposed_grpc_property(
281-
grpc_data_getter(name, from_protobuf=from_protobuf, check_optional=check_optional)
326+
grpc_data_getter(
327+
name,
328+
from_protobuf=from_protobuf,
329+
check_optional=check_optional,
330+
supported_since=supported_since,
331+
)
282332
),
283333
doc=doc,
284334
)

src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from google.protobuf.message import Message
3131
import grpc
32+
from packaging.version import Version
3233

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

189190
_pb_object: Any
190191

192+
@property
193+
def _server_version(self) -> Version | None: ...
194+
191195

192196
class Editable(Readable, Protocol):
193197
"""Interface definition for editable objects."""
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates.
2+
# SPDX-License-Identifier: MIT
3+
#
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
23+
from collections.abc import Callable
24+
from functools import wraps
25+
from typing import Concatenate, TypeAlias, TypeVar
26+
27+
from packaging.version import parse as parse_version
28+
from typing_extensions import ParamSpec
29+
30+
from .protocols import Readable
31+
32+
T = TypeVar("T", bound=Readable)
33+
P = ParamSpec("P")
34+
R = TypeVar("R")
35+
_WRAPPED_T: TypeAlias = Callable[Concatenate[T, P], R]
36+
37+
38+
def supported_since(
39+
version: str | None, err_msg_tpl: str | None = None
40+
) -> Callable[[_WRAPPED_T[T, P, R]], _WRAPPED_T[T, P, R]]:
41+
"""Mark a TreeObjectBase method as supported since a specific server version.
42+
43+
Raises an exception if the current server version does not match the required version.
44+
If either the given `version` or the server version is `None`, the decorator does nothing.
45+
46+
Parameters
47+
----------
48+
version : Optional[str]
49+
The server version since which the method is supported. If ``None``, the
50+
decorator does nothing.
51+
err_msg_tpl : Optional[str]
52+
A custom error message template. If ``None``, a default error message is used.
53+
"""
54+
if version is None:
55+
# return a trivial decorator if no version is specified
56+
def trivial_decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]:
57+
return func
58+
59+
return trivial_decorator
60+
61+
required_version = parse_version(version)
62+
63+
def decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]:
64+
@wraps(func)
65+
def inner(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
66+
server_version = self._server_version
67+
# If the object is not stored, we cannot check the server version.
68+
if server_version is not None:
69+
if server_version < required_version:
70+
if err_msg_tpl is None:
71+
err_msg = (
72+
f"The method '{func.__name__}' is only supported since version {version} "
73+
f"of the ACP gRPC server. The current server version is {server_version}."
74+
)
75+
else:
76+
err_msg = err_msg_tpl.format(
77+
required_version=required_version, server_version=server_version
78+
)
79+
raise RuntimeError(err_msg)
80+
return func(self, *args, **kwargs)
81+
82+
return inner
83+
84+
return decorator

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

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,13 @@
2626
from abc import abstractmethod
2727
from collections.abc import Callable, Iterable
2828
from dataclasses import dataclass
29-
from functools import wraps
3029
import typing
31-
from typing import Any, Concatenate, Generic, TypeAlias, TypeVar, cast
30+
from typing import Any, Generic, TypeVar, cast
3231

3332
from grpc import Channel
3433
from packaging.version import Version
3534
from packaging.version import parse as parse_version
36-
from typing_extensions import ParamSpec, Self
35+
from typing_extensions import Self
3736

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

@@ -147,6 +146,12 @@ def _server_wrapper(self) -> ServerWrapper:
147146
assert self._server_wrapper_store is not None
148147
return self._server_wrapper_store
149148

149+
@property
150+
def _server_version(self) -> Version | None:
151+
if not self._is_stored:
152+
return None
153+
return self._server_wrapper.version
154+
150155
@property
151156
def _is_stored(self) -> bool:
152157
return self._server_wrapper_store is not None
@@ -478,34 +483,6 @@ def _put_if_stored(self) -> None:
478483
self._put()
479484

480485

481-
T = TypeVar("T", bound=TreeObjectBase)
482-
P = ParamSpec("P")
483-
R = TypeVar("R")
484-
_WRAPPED_T: TypeAlias = Callable[Concatenate[T, P], R]
485-
486-
487-
def supported_since(version: str) -> Callable[[_WRAPPED_T[T, P, R]], _WRAPPED_T[T, P, R]]:
488-
"""Mark a TreeObjectBase method as supported since a specific server version.
489-
490-
Raises an exception if the current server version does not match the required version.
491-
"""
492-
required_version = parse_version(version)
493-
494-
def decorator(func: _WRAPPED_T[T, P, R]) -> _WRAPPED_T[T, P, R]:
495-
@wraps(func)
496-
def inner(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
497-
if self._server_wrapper.version < required_version:
498-
raise RuntimeError(
499-
f"The method '{func.__name__}' is only supported since version {version} of the ACP "
500-
f"gRPC server. The current server version is {self._server_wrapper.version}."
501-
)
502-
return func(self, *args, **kwargs)
503-
504-
return inner
505-
506-
return decorator
507-
508-
509486
if typing.TYPE_CHECKING: # pragma: no cover
510487
# Ensure that the ReadOnlyTreeObject satisfies the Gettable interface
511488
_x: Readable = typing.cast(ReadOnlyTreeObject, None)

src/ansys/acp/core/_tree_objects/model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
grpc_data_property_read_only,
8080
mark_grpc_properties,
8181
)
82+
from ._grpc_helpers.supported_since import supported_since
8283
from ._mesh_data import (
8384
ElementalData,
8485
NodalData,
@@ -87,7 +88,7 @@
8788
elemental_data_property,
8889
nodal_data_property,
8990
)
90-
from .base import ServerWrapper, TreeObject, supported_since
91+
from .base import ServerWrapper, TreeObject
9192
from .boolean_selection_rule import BooleanSelectionRule
9293
from .cad_geometry import CADGeometry
9394
from .cutoff_selection_rule import CutoffSelectionRule

tests/conftest.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,15 @@ def inner(model, relative_file_path="square_and_solid.stp"):
264264

265265

266266
@pytest.fixture
267-
def xfail_before(acp_instance):
267+
def raises_before_version(acp_instance):
268268
"""Mark a test as expected to fail before a certain server version."""
269269

270+
@contextmanager
270271
def inner(version: str):
271272
if parse_version(acp_instance.server_version) < parse_version(version):
272-
pytest.xfail(f"Expected to fail until server version {version!r}")
273+
with pytest.raises(RuntimeError):
274+
yield
275+
else:
276+
yield
273277

274278
return inner

0 commit comments

Comments
 (0)