From e8862c9329d302cff2407bda6ac115dbb4e7689e Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 09:08:24 +0200 Subject: [PATCH 1/8] 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. --- .github/workflows/ci_cd.yml | 4 +- .../_grpc_helpers/property_helper.py | 98 ++++++++++++++----- .../_tree_objects/_grpc_helpers/protocols.py | 4 + .../_grpc_helpers/supported_since.py | 84 ++++++++++++++++ src/ansys/acp/core/_tree_objects/base.py | 39 ++------ src/ansys/acp/core/_tree_objects/model.py | 3 +- tests/conftest.py | 8 +- tests/unittests/test_model.py | 11 ++- 8 files changed, 186 insertions(+), 65 deletions(-) create mode 100644 src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 807758ea2..3db980efe 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -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 @@ -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 diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index 50bac3c7e..9f3ae9073 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -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 @@ -110,7 +111,10 @@ def inner(self: Readable) -> CreatableFromResourcePath | None: 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. @@ -125,6 +129,14 @@ def grpc_data_getter( 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) @@ -149,26 +161,6 @@ def inner(self: Editable, value: Readable | None) -> None: 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: @@ -197,6 +189,37 @@ def _set_data_attribute(pb_obj: ObjectInfo, name: str, value: _PROTOBUF_T) -> No 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 + if needs_updating: + setter_func(self._pb_object, name, value_pb) + self._put_if_stored() + + return inner + + AnyT = TypeVar("AnyT") @@ -212,6 +235,9 @@ def grpc_data_property( 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. @@ -234,6 +260,10 @@ def grpc_data_property( 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: @@ -244,8 +274,20 @@ def grpc_data_property( # 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, ) @@ -255,6 +297,7 @@ def grpc_data_property_read_only( 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. @@ -275,10 +318,17 @@ def grpc_data_property_read_only( 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, ) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py index fdc2410dd..35bfdea39 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py @@ -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, @@ -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.""" diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py new file mode 100644 index 000000000..e27b63ff5 --- /dev/null +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py @@ -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( + required_version=required_version, server_version=server_version + ) + raise RuntimeError(err_msg) + return func(self, *args, **kwargs) + + return inner + + return decorator diff --git a/src/ansys/acp/core/_tree_objects/base.py b/src/ansys/acp/core/_tree_objects/base.py index 758f99371..8e0a11133 100644 --- a/src/ansys/acp/core/_tree_objects/base.py +++ b/src/ansys/acp/core/_tree_objects/base.py @@ -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 @@ -147,6 +146,12 @@ def _server_wrapper(self) -> ServerWrapper: 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 + return self._server_wrapper.version + @property def _is_stored(self) -> bool: return self._server_wrapper_store is not None @@ -478,34 +483,6 @@ def _put_if_stored(self) -> None: 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) diff --git a/src/ansys/acp/core/_tree_objects/model.py b/src/ansys/acp/core/_tree_objects/model.py index b4fec9c78..3efca801b 100644 --- a/src/ansys/acp/core/_tree_objects/model.py +++ b/src/ansys/acp/core/_tree_objects/model.py @@ -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, @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index e09540c00..d6b22b282 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/unittests/test_model.py b/tests/unittests/test_model.py index eb86bff3d..f0a448300 100644 --- a/tests/unittests/test_model.py +++ b/tests/unittests/test_model.py @@ -251,12 +251,11 @@ def test_regression_454(minimal_complete_model): assert not hasattr(minimal_complete_model, "store") -def test_modeling_ply_export(acp_instance, minimal_complete_model, xfail_before): +def test_modeling_ply_export(acp_instance, minimal_complete_model, raises_before_version): """ Test that the 'export_modeling_ply_geometries' method produces a file. The contents of the file are not checked. """ - xfail_before("25.1") out_filename = "modeling_ply_export.step" with tempfile.TemporaryDirectory() as tmp_dir: @@ -265,9 +264,11 @@ def test_modeling_ply_export(acp_instance, minimal_complete_model, xfail_before) out_file_path = pathlib.Path(out_filename) else: out_file_path = local_file_path - minimal_complete_model.export_modeling_ply_geometries(out_file_path) - acp_instance.download_file(out_file_path, local_file_path) - assert local_file_path.exists() + + with raises_before_version("25.1"): + minimal_complete_model.export_modeling_ply_geometries(out_file_path) + acp_instance.download_file(out_file_path, local_file_path) + assert local_file_path.exists() def test_parent_access_raises(minimal_complete_model): From 0b4b7c5e8091a62d902d2318cfbd0884586c16f6 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 09:53:24 +0200 Subject: [PATCH 2/8] Allow marking 'supported_since' on class level Add a '_SUPPORTED_SINCE' class attribute to the 'GrpcObjectBase' class, which has two effects: - upon storing an object, check the server version and raise an appropriate exception if the server version is too low - in the 'mark_grpc_properties' class decorator, add a line at the end of the class docstring indicating the server version at which the class was introduced --- .../core/_tree_objects/_grpc_helpers/property_helper.py | 3 +++ .../acp/core/_tree_objects/_grpc_helpers/protocols.py | 1 + .../core/_tree_objects/_grpc_helpers/supported_since.py | 6 +++--- src/ansys/acp/core/_tree_objects/base.py | 8 ++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index 9f3ae9073..06e65fda7 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -91,6 +91,9 @@ def mark_grpc_properties(cls: T) -> T: if name not in props_unique: props_unique.append(name) cls._GRPC_PROPERTIES = tuple(props_unique) + if cls._SUPPORTED_SINCE is not None: + if isinstance(cls.__doc__, str): + cls.__doc__ += f"\n\nSupported since ACP gRPC server version {cls._SUPPORTED_SINCE}." return cls diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py index 35bfdea39..bed52792f 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/protocols.py @@ -151,6 +151,7 @@ class GrpcObjectBase(Protocol): __slots__: Iterable[str] = tuple() _GRPC_PROPERTIES: tuple[str, ...] = tuple() + _SUPPORTED_SINCE: str | None = None def __str__(self) -> str: string_items = [] diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py index e27b63ff5..2b67cdf4b 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/supported_since.py @@ -45,10 +45,10 @@ def supported_since( Parameters ---------- - version : Optional[str] + version : The server version since which the method is supported. If ``None``, the decorator does nothing. - err_msg_tpl : Optional[str] + err_msg_tpl : A custom error message template. If ``None``, a default error message is used. """ if version is None: @@ -69,7 +69,7 @@ def inner(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R: 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"The '{func.__name__}' method is only supported since version {version} " f"of the ACP gRPC server. The current server version is {server_version}." ) else: diff --git a/src/ansys/acp/core/_tree_objects/base.py b/src/ansys/acp/core/_tree_objects/base.py index 8e0a11133..096b23be1 100644 --- a/src/ansys/acp/core/_tree_objects/base.py +++ b/src/ansys/acp/core/_tree_objects/base.py @@ -328,6 +328,14 @@ def store(self: Self, parent: TreeObject) -> None: Parent object to store the object under. """ self._server_wrapper_store = parent._server_wrapper + if self._SUPPORTED_SINCE is not None: + assert self._server_version is not None + if self._server_version < parse_version(self._SUPPORTED_SINCE): + raise RuntimeError( + f"The '{type(self).__name__}' object is only supported since version " + f"{self._SUPPORTED_SINCE} of the ACP gRPC server. The current server version is " + f"{self._server_version}." + ) collection_path = CollectionPath( value=_rp_join(parent._resource_path.value, self._COLLECTION_LABEL) From 85f30713408f52db3b824ae4e3011448506a3d4b Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 14:10:36 +0200 Subject: [PATCH 3/8] Add trailing newlines to edited docstring --- .../acp/core/_tree_objects/_grpc_helpers/property_helper.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index 06e65fda7..a8376b58a 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -93,7 +93,9 @@ def mark_grpc_properties(cls: T) -> T: cls._GRPC_PROPERTIES = tuple(props_unique) if cls._SUPPORTED_SINCE is not None: if isinstance(cls.__doc__, str): - cls.__doc__ += f"\n\nSupported since ACP gRPC server version {cls._SUPPORTED_SINCE}." + cls.__doc__ += ( + f"\n\nSupported since ACP gRPC server version {cls._SUPPORTED_SINCE}.\n\n" + ) return cls From b1927c02c3547a8e31ec6defbfb702d17d1a4396 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 14:31:42 +0200 Subject: [PATCH 4/8] Fix docstring indentation --- .../_grpc_helpers/property_helper.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index a8376b58a..aafa3f669 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -29,6 +29,7 @@ from collections.abc import Callable from functools import reduce +import sys from typing import TYPE_CHECKING, Any, TypeVar from google.protobuf.message import Message @@ -93,8 +94,20 @@ def mark_grpc_properties(cls: T) -> T: cls._GRPC_PROPERTIES = tuple(props_unique) if cls._SUPPORTED_SINCE is not None: if isinstance(cls.__doc__, str): + # When adding to the docstring, we need to match the existing + # indentation of the docstring (except the first line). + # See PEP 257 'Handling Docstring Indentation'. + # Alternatively, we could strip the common indentation from the + # docstring. + indent = sys.maxsize + for line in cls.__doc__.splitlines()[1:]: + stripped = line.lstrip() + if stripped: # ignore empty lines + indent = min(indent, len(line) - len(stripped)) + if indent == sys.maxsize: + indent = 0 cls.__doc__ += ( - f"\n\nSupported since ACP gRPC server version {cls._SUPPORTED_SINCE}.\n\n" + f"\n\n{indent * ' '}*Added in ACP server version {cls._SUPPORTED_SINCE}.*\n" ) return cls From 4b270808cb21e2f60108c4f9bee17bf014f99f65 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 13:18:19 +0200 Subject: [PATCH 5/8] Implement ButtJointSequence objects --- doc/source/api/linked_object_definitions.rst | 5 +- doc/source/api/tree_objects.rst | 1 + src/ansys/acp/core/__init__.py | 4 + src/ansys/acp/core/_tree_objects/__init__.py | 3 + .../_grpc_helpers/linked_object_list.py | 12 +- .../core/_tree_objects/butt_joint_sequence.py | 226 ++++++++++++++++++ .../acp/core/_tree_objects/modeling_group.py | 18 +- tests/unittests/test_butt_joint_sequence.py | 112 +++++++++ 8 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 src/ansys/acp/core/_tree_objects/butt_joint_sequence.py create mode 100644 tests/unittests/test_butt_joint_sequence.py diff --git a/doc/source/api/linked_object_definitions.rst b/doc/source/api/linked_object_definitions.rst index 6f2168eca..1383ae664 100644 --- a/doc/source/api/linked_object_definitions.rst +++ b/doc/source/api/linked_object_definitions.rst @@ -7,7 +7,8 @@ Linked object definitions :toctree: _autosummary FabricWithAngle + Lamina LinkedSelectionRule - TaperEdge + PrimaryPly SubShape - Lamina + TaperEdge diff --git a/doc/source/api/tree_objects.rst b/doc/source/api/tree_objects.rst index 64c0660fb..9c887ce28 100644 --- a/doc/source/api/tree_objects.rst +++ b/doc/source/api/tree_objects.rst @@ -8,6 +8,7 @@ ACP objects AnalysisPly BooleanSelectionRule + ButtJointSequence CADComponent CADGeometry CutoffSelectionRule diff --git a/src/ansys/acp/core/__init__.py b/src/ansys/acp/core/__init__.py index c254523b1..2269aeb19 100644 --- a/src/ansys/acp/core/__init__.py +++ b/src/ansys/acp/core/__init__.py @@ -48,6 +48,7 @@ BooleanSelectionRule, BooleanSelectionRuleElementalData, BooleanSelectionRuleNodalData, + ButtJointSequence, CADComponent, CADGeometry, CutoffMaterialType, @@ -104,6 +105,7 @@ PlyCutoffType, PlyGeometryExportFormat, PlyType, + PrimaryPly, ProductionPly, ProductionPlyElementalData, ProductionPlyNodalData, @@ -153,6 +155,7 @@ "BooleanSelectionRule", "BooleanSelectionRuleElementalData", "BooleanSelectionRuleNodalData", + "ButtJointSequence", "CADComponent", "CADGeometry", "ConnectLaunchConfig", @@ -221,6 +224,7 @@ "PlyCutoffType", "PlyGeometryExportFormat", "PlyType", + "PrimaryPly", "print_model", "ProductionPly", "ProductionPlyElementalData", diff --git a/src/ansys/acp/core/_tree_objects/__init__.py b/src/ansys/acp/core/_tree_objects/__init__.py index 852459051..12597d8b5 100644 --- a/src/ansys/acp/core/_tree_objects/__init__.py +++ b/src/ansys/acp/core/_tree_objects/__init__.py @@ -27,6 +27,7 @@ BooleanSelectionRuleElementalData, BooleanSelectionRuleNodalData, ) +from .butt_joint_sequence import ButtJointSequence, PrimaryPly from .cad_component import CADComponent from .cad_geometry import CADGeometry, TriangleMesh from .cutoff_selection_rule import ( @@ -126,6 +127,7 @@ "BooleanSelectionRule", "BooleanSelectionRuleElementalData", "BooleanSelectionRuleNodalData", + "ButtJointSequence", "CADComponent", "CADGeometry", "CutoffMaterialType", @@ -184,6 +186,7 @@ "PlyCutoffType", "PlyGeometryExportFormat", "PlyType", + "PrimaryPly", "ProductionPly", "ProductionPlyElementalData", "ProductionPlyNodalData", diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py index 4bd4b5d7f..2fdbff54e 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py @@ -41,7 +41,7 @@ ValueT = TypeVar("ValueT", bound=CreatableTreeObject) -__all__ = ["LinkedObjectList", "define_linked_object_list"] +__all__ = ["LinkedObjectList", "define_linked_object_list", "define_polymorphic_linked_object_list"] class LinkedObjectList(ObjectCacheMixin, MutableSequence[ValueT]): @@ -302,11 +302,19 @@ def setter(self: ValueT, value: list[ChildT]) -> None: def define_polymorphic_linked_object_list( - attribute_name: str, allowed_types: tuple[Any, ...] + attribute_name: str, + allowed_types: tuple[Any, ...] | None = None, + allowed_types_getter: Callable[[], tuple[Any, ...]] | None = None, ) -> Any: """Define a list of linked tree objects with polymorphic types.""" + if allowed_types is None != allowed_types_getter is None: + raise ValueError("Exactly one of allowed_types and allowed_types_getter must be provided.") def getter(self: ValueT) -> LinkedObjectList[Any]: + nonlocal allowed_types + if allowed_types_getter is not None: + allowed_types = allowed_types_getter() + return LinkedObjectList( _parent_object=self, _attribute_name=attribute_name, diff --git a/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py new file mode 100644 index 000000000..9f6e56cb4 --- /dev/null +++ b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py @@ -0,0 +1,226 @@ +# 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 __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Any, Union, cast + +from typing_extensions import Self + +from ansys.api.acp.v0 import butt_joint_sequence_pb2, butt_joint_sequence_pb2_grpc + +from .._utils.property_protocols import ReadWriteProperty +from ._grpc_helpers.edge_property_list import ( + GenericEdgePropertyType, + define_add_method, + define_edge_property_list, +) +from ._grpc_helpers.linked_object_list import define_polymorphic_linked_object_list +from ._grpc_helpers.polymorphic_from_pb import tree_object_from_resource_path +from ._grpc_helpers.property_helper import ( + _exposed_grpc_property, + grpc_data_property, + grpc_data_property_read_only, + mark_grpc_properties, +) +from .base import CreatableTreeObject, IdTreeObject +from .enums import status_type_from_pb +from .modeling_ply import ModelingPly +from .object_registry import register + +if TYPE_CHECKING: + # Creates a circular import if imported at the top-level, since the ButtJointSequence + # is a direct child of the ModelingGroup. + from .modeling_group import ModelingGroup + +__all__ = ["ButtJointSequence", "PrimaryPly"] + + +@mark_grpc_properties +class PrimaryPly(GenericEdgePropertyType): + """Defines a primary ply of a butt joint sequence. + + Parameters + ---------- + sequence : + Modeling group or modeling ply defining the primary ply. + level : + Level of the primary ply. Plies with a higher level inherit the thickness + from adjacent plies with a lower level. + + """ + + _SUPPORTED_SINCE = "25.1" + + def __init__(self, sequence: ModelingGroup | ModelingPly, level: int = 1): + self._callback_apply_changes: Callable[[], None] | None = None + self.sequence = sequence + self.level = level + + @_exposed_grpc_property + def sequence(self) -> ModelingGroup | ModelingPly: + """Linked sequence.""" + return self._sequence + + @sequence.setter + def sequence(self, value: ModelingGroup | ModelingPly) -> None: + from .modeling_group import ModelingGroup + + if not isinstance(value, (ModelingGroup, ModelingPly)): + raise TypeError(f"Expected a ModelingGroup or ModelingPly, got {type(value)}") + self._sequence = value + if self._callback_apply_changes: + self._callback_apply_changes() + + @_exposed_grpc_property + def level(self) -> int: + """Level of the primary ply. + + Plies with a higher level inherit the thickness from adjacent plies with a lower level. + """ + return self._level + + @level.setter + def level(self, value: int) -> None: + self._level = value + if self._callback_apply_changes: + self._callback_apply_changes() + + def _set_callback_apply_changes(self, callback_apply_changes: Callable[[], None]) -> None: + self._callback_apply_changes = callback_apply_changes + + @classmethod + def _from_pb_object( + cls, + parent_object: CreatableTreeObject, + message: butt_joint_sequence_pb2.PrimaryPly, + apply_changes: Callable[[], None], + ) -> Self: + from .modeling_group import ModelingGroup # imported here to avoid circular import + + new_obj = cls( + sequence=cast( + Union["ModelingGroup", ModelingPly], + tree_object_from_resource_path( + message.sequence, + server_wrapper=parent_object._server_wrapper, + allowed_types=(ModelingGroup, ModelingPly), + ), + ), + level=message.level, + ) + new_obj._set_callback_apply_changes(apply_changes) + return new_obj + + def _to_pb_object(self) -> butt_joint_sequence_pb2.PrimaryPly: + return butt_joint_sequence_pb2.PrimaryPly( + sequence=self.sequence._resource_path, level=self.level + ) + + def _check(self) -> bool: + # Check for empty resource paths + return bool(self.sequence._resource_path.value) + + def __eq__(self, other: Any) -> bool: + if isinstance(other, self.__class__): + return ( + self.sequence._resource_path == other.sequence._resource_path + and self.level == other.level + ) + + return False + + def __repr__(self) -> str: + return f"PrimaryPly(sequence={self.sequence.__repr__()}, level={self.level})" + + def clone(self) -> Self: + """Create a new unstored PrimaryPly with the same properties.""" + return type(self)(sequence=self.sequence, level=self.level) + + +def _get_allowed_secondary_ply_types() -> tuple[type, ...]: + from .modeling_group import ModelingGroup + + return (ModelingGroup, ModelingPly) + + +@mark_grpc_properties +@register +class ButtJointSequence(CreatableTreeObject, IdTreeObject): + """Instantiate a ButtJointSequence. + + Parameters + ---------- + name : + Name of the butt joint sequence. + primary_plies : + Primary plies are the source of a butt joint and they pass the thickness to + adjacent plies. Plies with a higher level inherit the thickness from those + with a lower level. + secondary_plies : + Secondary plies are butt-joined to adjacent primary plies and they inherit + the thickness. + + """ + + __slots__: Iterable[str] = tuple() + + _COLLECTION_LABEL = "butt_joint_sequences" + _OBJECT_INFO_TYPE = butt_joint_sequence_pb2.ObjectInfo + _CREATE_REQUEST_TYPE = butt_joint_sequence_pb2.CreateRequest + _SUPPORTED_SINCE = "25.1" + + def __init__( + self, + *, + name: str = "ButtJointSequence", + active: bool = True, + global_ply_nr: int = 0, + primary_plies: Sequence[PrimaryPly] = (), + secondary_plies: Sequence[ModelingGroup | ModelingPly] = (), + ): + super().__init__(name=name) + self.active = active + self.global_ply_nr = global_ply_nr + self.primary_plies = primary_plies + self.secondary_plies = secondary_plies + + def _create_stub(self) -> butt_joint_sequence_pb2_grpc.ObjectServiceStub: + return butt_joint_sequence_pb2_grpc.ObjectServiceStub(self._channel) + + status = grpc_data_property_read_only("properties.status", from_protobuf=status_type_from_pb) + active: ReadWriteProperty[bool, bool] = grpc_data_property("properties.active") + global_ply_nr: ReadWriteProperty[int, int] = grpc_data_property("properties.global_ply_nr") + + primary_plies = define_edge_property_list("properties.primary_plies", PrimaryPly) + add_primary_ply = define_add_method( + PrimaryPly, + attribute_name="primary_plies", + func_name="add_primary_ply", + parent_class_name="ButtJointSequence", + module_name=__module__, + ) + + secondary_plies = define_polymorphic_linked_object_list( + "properties.secondary_plies", allowed_types_getter=_get_allowed_secondary_ply_types + ) diff --git a/src/ansys/acp/core/_tree_objects/modeling_group.py b/src/ansys/acp/core/_tree_objects/modeling_group.py index cfeb18d77..e604fd28f 100644 --- a/src/ansys/acp/core/_tree_objects/modeling_group.py +++ b/src/ansys/acp/core/_tree_objects/modeling_group.py @@ -25,7 +25,12 @@ from collections.abc import Iterable import dataclasses -from ansys.api.acp.v0 import modeling_group_pb2, modeling_group_pb2_grpc, modeling_ply_pb2_grpc +from ansys.api.acp.v0 import ( + butt_joint_sequence_pb2_grpc, + modeling_group_pb2, + modeling_group_pb2_grpc, + modeling_ply_pb2_grpc, +) from ._grpc_helpers.mapping import define_create_method, define_mutable_mapping from ._grpc_helpers.property_helper import mark_grpc_properties @@ -37,6 +42,7 @@ nodal_data_property, ) from .base import CreatableTreeObject, IdTreeObject +from .butt_joint_sequence import ButtJointSequence from .modeling_ply import ModelingPly from .object_registry import register @@ -86,5 +92,15 @@ def _create_stub(self) -> modeling_group_pb2_grpc.ObjectServiceStub: ) modeling_plies = define_mutable_mapping(ModelingPly, modeling_ply_pb2_grpc.ObjectServiceStub) + create_butt_joint_sequence = define_create_method( + ButtJointSequence, + func_name="create_butt_joint_sequence", + parent_class_name="ModelingGroup", + module_name=__module__, + ) + butt_joint_sequences = define_mutable_mapping( + ButtJointSequence, butt_joint_sequence_pb2_grpc.ObjectServiceStub + ) + elemental_data = elemental_data_property(ModelingGroupElementalData) nodal_data = nodal_data_property(ModelingGroupNodalData) diff --git a/tests/unittests/test_butt_joint_sequence.py b/tests/unittests/test_butt_joint_sequence.py new file mode 100644 index 000000000..1be13b5c5 --- /dev/null +++ b/tests/unittests/test_butt_joint_sequence.py @@ -0,0 +1,112 @@ +# 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. + +import pytest + +from ansys.acp.core import PrimaryPly + +from .common.tree_object_tester import NoLockedMixin, ObjectPropertiesToTest, TreeObjectTester +from .common.utils import AnyThing + + +@pytest.fixture +def parent_model(load_model_from_tempfile): + with load_model_from_tempfile() as model: + yield model + + +@pytest.fixture +def parent_object(parent_model): + return parent_model.modeling_groups["ModelingGroup.1"] + + +@pytest.fixture +def tree_object(parent_object): + return parent_object.create_butt_joint_sequence() + + +class TestButtJointSequence(NoLockedMixin, TreeObjectTester): + COLLECTION_NAME = "butt_joint_sequences" + + @staticmethod + @pytest.fixture + def default_properties(): + return { + "status": "NOTUPTODATE", + "active": True, + "global_ply_nr": AnyThing(), + "primary_plies": [], + "secondary_plies": [], + } + + CREATE_METHOD_NAME = "create_butt_joint_sequence" + + @staticmethod + @pytest.fixture + def object_properties(parent_model): + mg1 = parent_model.create_modeling_group() + mg2 = parent_model.create_modeling_group() + mp1 = mg1.create_modeling_ply() + mp2 = mg1.create_modeling_ply() + return ObjectPropertiesToTest( + read_write=[ + ("name", "ButtJointSequence name"), + ("active", False), + ("global_ply_nr", 3), + ( + "primary_plies", + [ + PrimaryPly(sequence=mg1, level=1), + PrimaryPly(sequence=mp2, level=3), + ], + ), + ("secondary_plies", [mg2, mp1]), + ], + read_only=[ + ("id", "some_id"), + ("status", "UPTODATE"), + ], + ) + + +def test_wrong_primary_ply_type_error_message(tree_object, parent_model): + butt_joint_sequence = tree_object + fabric = parent_model.create_fabric() + with pytest.raises(TypeError) as exc: + butt_joint_sequence.primary_plies = [fabric] + assert "PrimaryPly" in str(exc.value) + assert "Fabric" in str(exc.value) + + +def test_add_primary_ply(parent_object): + """Verify add method for primary plies.""" + modeling_ply_1 = parent_object.create_modeling_ply() + + butt_joint_sequence = parent_object.create_butt_joint_sequence() + butt_joint_sequence.add_primary_ply(modeling_ply_1) + assert butt_joint_sequence.primary_plies[-1].sequence == modeling_ply_1 + assert butt_joint_sequence.primary_plies[-1].level == 1 + modeling_ply_2 = modeling_ply_1.clone() + modeling_ply_2.store(parent=parent_object) + butt_joint_sequence.add_primary_ply(modeling_ply_2, level=3) + assert butt_joint_sequence.primary_plies[-1].sequence == modeling_ply_2 + assert butt_joint_sequence.primary_plies[-1].level == 3 From 074d7527cd7d0fd5f6ad1d43d51fe35cdbc8b4aa Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 14:07:29 +0200 Subject: [PATCH 6/8] Skip ButtJointSequence tests on 242 server --- tests/unittests/test_butt_joint_sequence.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unittests/test_butt_joint_sequence.py b/tests/unittests/test_butt_joint_sequence.py index 1be13b5c5..9608100dd 100644 --- a/tests/unittests/test_butt_joint_sequence.py +++ b/tests/unittests/test_butt_joint_sequence.py @@ -20,14 +20,23 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from packaging.version import parse as parse_version import pytest -from ansys.acp.core import PrimaryPly +from ansys.acp.core import ButtJointSequence, PrimaryPly from .common.tree_object_tester import NoLockedMixin, ObjectPropertiesToTest, TreeObjectTester from .common.utils import AnyThing +@pytest.fixture(autouse=True) +def skip_if_unsupported_version(acp_instance): + if parse_version(acp_instance.server_version) < parse_version( + ButtJointSequence._SUPPORTED_SINCE + ): + pytest.skip("ButtJointSequence is not supported on this version of the server.") + + @pytest.fixture def parent_model(load_model_from_tempfile): with load_model_from_tempfile() as model: From 98533848d15f504ef12922e33b40162f07d97f42 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 14:09:08 +0200 Subject: [PATCH 7/8] Tweak docstring --- src/ansys/acp/core/_tree_objects/butt_joint_sequence.py | 1 - src/ansys/acp/core/_tree_objects/modeling_group.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py index 9f6e56cb4..97321e94b 100644 --- a/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py +++ b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py @@ -180,7 +180,6 @@ class ButtJointSequence(CreatableTreeObject, IdTreeObject): secondary_plies : Secondary plies are butt-joined to adjacent primary plies and they inherit the thickness. - """ __slots__: Iterable[str] = tuple() diff --git a/src/ansys/acp/core/_tree_objects/modeling_group.py b/src/ansys/acp/core/_tree_objects/modeling_group.py index e604fd28f..eaacaa8c0 100644 --- a/src/ansys/acp/core/_tree_objects/modeling_group.py +++ b/src/ansys/acp/core/_tree_objects/modeling_group.py @@ -68,7 +68,7 @@ class ModelingGroup(CreatableTreeObject, IdTreeObject): Parameters ---------- - name + name : Name of the modeling group. """ From 9d8abd2b583c4860546b8d8c6775aa1a1d78e98e Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 3 Oct 2024 16:14:52 +0200 Subject: [PATCH 8/8] Create common implementation for edge property types --- .../_grpc_helpers/edge_property_list.py | 137 +++++++++++++++++- .../_grpc_helpers/property_helper.py | 8 +- .../core/_tree_objects/butt_joint_sequence.py | 120 +++------------ 3 files changed, 162 insertions(+), 103 deletions(-) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py index 6e523ea66..585bafb5c 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py @@ -23,6 +23,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable, Iterator, MutableSequence +import functools import sys import textwrap from typing import Any, Concatenate, Protocol, TypeVar, cast, overload @@ -30,9 +31,20 @@ from google.protobuf.message import Message from typing_extensions import ParamSpec, Self +from ansys.api.acp.v0 import base_pb2 + +from ..._utils.property_protocols import ReadWriteProperty from .._object_cache import ObjectCacheMixin, constructor_with_cache -from ..base import CreatableTreeObject -from .property_helper import _exposed_grpc_property, _wrap_doc, grpc_data_getter, grpc_data_setter +from ..base import CreatableTreeObject, ServerWrapper +from .polymorphic_from_pb import tree_object_from_resource_path +from .property_helper import ( + _exposed_grpc_property, + _get_data_attribute, + _set_data_attribute, + _wrap_doc, + grpc_data_getter, + grpc_data_setter, +) from .protocols import GrpcObjectBase __all__ = [ @@ -67,6 +79,125 @@ def clone(self) -> Self: raise NotImplementedError +class EdgePropertyTypeBase(GenericEdgePropertyType): + """Common implementation of the GenericEdgePropertyType protocol.""" + + __slots__ = ("_pb_object", "_callback_apply_changes", "_server_wrapper") + _PB_OBJECT_TYPE: type[Message] + + def __init__(self) -> None: + self._pb_object = self._PB_OBJECT_TYPE() + self._callback_apply_changes: Callable[[], None] | None = None + self._server_wrapper: ServerWrapper | None = None + + def _set_callback_apply_changes(self, callback_apply_changes: Callable[[], None]) -> None: + self._callback_apply_changes = callback_apply_changes + + @classmethod + def _from_pb_object( + cls, + parent_object: CreatableTreeObject, + message: Message, + callback_apply_changes: Callable[[], None], + ) -> Self: + new_obj = cls() + new_obj._pb_object = message + new_obj._set_callback_apply_changes(callback_apply_changes) + new_obj._server_wrapper = parent_object._server_wrapper + return new_obj + + def _to_pb_object(self) -> Message: + return self._pb_object + + def _check(self) -> bool: + return self._server_wrapper is not None + + def __eq__(self, other: Any) -> bool: + if isinstance(other, self.__class__): + return cast(bool, self._pb_object == other._pb_object) + return False + + def clone(self) -> Self: + """Create a new unstored object with the same properties.""" + obj = self.__class__() + obj._pb_object.CopyFrom(self._pb_object) + obj._server_wrapper = self._server_wrapper + # do not copy the callback, as the new object's parent is not yet defined + return obj + + def __repr__(self) -> str: + args = ", ".join(f"{k}={getattr(self, k)!r}" for k in self._GRPC_PROPERTIES) + return f"{self.__class__.__name__}({args})" + + +def edge_property_type_linked_object( + name_in_pb: str, + allowed_types: type[CreatableTreeObject] | tuple[type[CreatableTreeObject], ...] | None = None, + allowed_types_getter: ( + Callable[[], type[CreatableTreeObject] | tuple[type[CreatableTreeObject], ...]] | None + ) = None, +) -> Any: + """Define the linked object property for the edge property type.""" + if allowed_types is None == allowed_types_getter is None: + raise ValueError("Exactly one of 'allowed_types' and 'allowed_types_getter' must be given.") + + @functools.cache + def _allowed_types() -> tuple[type[CreatableTreeObject], ...]: + if allowed_types_getter is not None: + allowed_types = allowed_types_getter() + if not isinstance(allowed_types, tuple): + allowed_types = (allowed_types,) + return allowed_types + + def getter(self: EdgePropertyTypeBase) -> Any: + resource_path = _get_data_attribute(self._pb_object, name_in_pb) + if self._server_wrapper is None: + return None + return tree_object_from_resource_path( + resource_path=resource_path, + server_wrapper=self._server_wrapper, + allowed_types=_allowed_types(), + ) + + def setter(self: EdgePropertyTypeBase, value: Any) -> None: + if value is None: + server_wrapper = None + resource_path = base_pb2.ResourcePath(value="") + else: + if not isinstance(value, _allowed_types()): + raise TypeError( + f"Expected object of type {allowed_types}, got type {type(value)} instead." + ) + server_wrapper = value._server_wrapper + resource_path = value._resource_path + + self._server_wrapper = server_wrapper + _set_data_attribute(self._pb_object, name_in_pb, resource_path) + if self._callback_apply_changes: + self._callback_apply_changes() + + return _exposed_grpc_property(getter, setter) + + +T = TypeVar("T") + + +def edge_property_type_attribute( + name_in_pb: str, + from_protobuf: Callable[[Any], T] = lambda x: x, + to_protobuf: Callable[[T], Any] = lambda x: x, +) -> ReadWriteProperty[T, T]: + def getter(self: EdgePropertyTypeBase) -> Any: + return from_protobuf(_get_data_attribute(self._pb_object, name_in_pb)) + + def setter(self: EdgePropertyTypeBase, value: Any) -> None: + _set_data_attribute(self._pb_object, name_in_pb, to_protobuf(value)) + if self._callback_apply_changes: + self._callback_apply_changes() + + return _exposed_grpc_property(getter, setter) + + ValueT = TypeVar("ValueT", bound=GenericEdgePropertyType) @@ -364,7 +495,7 @@ def reverse(self) -> None: def _apply_changes(self) -> None: """Apply changes to the list. - Use to support in-place modification. + Used to support in-place modification. This function applies the changes if someone edits one entry of the list. """ self._set_object_list(self._object_list) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index aafa3f669..e73853959 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -38,7 +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 .protocols import Editable, GrpcObjectBase, Readable from .supported_since import supported_since as supported_since_decorator # Note: The typing of the protobuf objects is fairly loose, maybe it could @@ -188,7 +188,7 @@ def _get_data_attribute(pb_obj: Message, name: str, check_optional: bool = False return reduce(getattr, name_parts, pb_obj) -def _set_data_attribute(pb_obj: ObjectInfo, name: str, value: _PROTOBUF_T) -> None: +def _set_data_attribute(pb_obj: Message, name: str, value: _PROTOBUF_T) -> None: name_parts = name.split(".") try: @@ -210,7 +210,7 @@ def _set_data_attribute(pb_obj: ObjectInfo, name: str, value: _PROTOBUF_T) -> No def grpc_data_setter( name: str, to_protobuf: _TO_PROTOBUF_T[_SET_T], - setter_func: Callable[[ObjectInfo, str, _PROTOBUF_T], None] = _set_data_attribute, + setter_func: Callable[[Message, 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.""" @@ -253,7 +253,7 @@ def grpc_data_property( 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, + setter_func: Callable[[Message, str, _PROTOBUF_T], None] = _set_data_attribute, readable_since: str | None = None, writable_since: str | None = None, ) -> ReadWriteProperty[_GET_T, _SET_T]: diff --git a/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py index 97321e94b..5376f0c10 100644 --- a/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py +++ b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py @@ -22,23 +22,21 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Sequence -from typing import TYPE_CHECKING, Any, Union, cast - -from typing_extensions import Self +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING from ansys.api.acp.v0 import butt_joint_sequence_pb2, butt_joint_sequence_pb2_grpc from .._utils.property_protocols import ReadWriteProperty from ._grpc_helpers.edge_property_list import ( - GenericEdgePropertyType, + EdgePropertyTypeBase, define_add_method, define_edge_property_list, + edge_property_type_attribute, + edge_property_type_linked_object, ) from ._grpc_helpers.linked_object_list import define_polymorphic_linked_object_list -from ._grpc_helpers.polymorphic_from_pb import tree_object_from_resource_path from ._grpc_helpers.property_helper import ( - _exposed_grpc_property, grpc_data_property, grpc_data_property_read_only, mark_grpc_properties, @@ -56,8 +54,14 @@ __all__ = ["ButtJointSequence", "PrimaryPly"] +def _get_allowed_sequence_types() -> tuple[type, ...]: + from .modeling_group import ModelingGroup + + return (ModelingGroup, ModelingPly) + + @mark_grpc_properties -class PrimaryPly(GenericEdgePropertyType): +class PrimaryPly(EdgePropertyTypeBase): """Defines a primary ply of a butt joint sequence. Parameters @@ -70,98 +74,22 @@ class PrimaryPly(GenericEdgePropertyType): """ + __slots__: tuple[str, ...] = tuple() + + _PB_OBJECT_TYPE = butt_joint_sequence_pb2.PrimaryPly _SUPPORTED_SINCE = "25.1" - def __init__(self, sequence: ModelingGroup | ModelingPly, level: int = 1): - self._callback_apply_changes: Callable[[], None] | None = None + def __init__(self, sequence: ModelingGroup | ModelingPly | None = None, level: int = 1): + super().__init__() self.sequence = sequence self.level = level - @_exposed_grpc_property - def sequence(self) -> ModelingGroup | ModelingPly: - """Linked sequence.""" - return self._sequence - - @sequence.setter - def sequence(self, value: ModelingGroup | ModelingPly) -> None: - from .modeling_group import ModelingGroup - - if not isinstance(value, (ModelingGroup, ModelingPly)): - raise TypeError(f"Expected a ModelingGroup or ModelingPly, got {type(value)}") - self._sequence = value - if self._callback_apply_changes: - self._callback_apply_changes() - - @_exposed_grpc_property - def level(self) -> int: - """Level of the primary ply. - - Plies with a higher level inherit the thickness from adjacent plies with a lower level. - """ - return self._level - - @level.setter - def level(self, value: int) -> None: - self._level = value - if self._callback_apply_changes: - self._callback_apply_changes() - - def _set_callback_apply_changes(self, callback_apply_changes: Callable[[], None]) -> None: - self._callback_apply_changes = callback_apply_changes - - @classmethod - def _from_pb_object( - cls, - parent_object: CreatableTreeObject, - message: butt_joint_sequence_pb2.PrimaryPly, - apply_changes: Callable[[], None], - ) -> Self: - from .modeling_group import ModelingGroup # imported here to avoid circular import - - new_obj = cls( - sequence=cast( - Union["ModelingGroup", ModelingPly], - tree_object_from_resource_path( - message.sequence, - server_wrapper=parent_object._server_wrapper, - allowed_types=(ModelingGroup, ModelingPly), - ), - ), - level=message.level, - ) - new_obj._set_callback_apply_changes(apply_changes) - return new_obj - - def _to_pb_object(self) -> butt_joint_sequence_pb2.PrimaryPly: - return butt_joint_sequence_pb2.PrimaryPly( - sequence=self.sequence._resource_path, level=self.level - ) - - def _check(self) -> bool: - # Check for empty resource paths - return bool(self.sequence._resource_path.value) - - def __eq__(self, other: Any) -> bool: - if isinstance(other, self.__class__): - return ( - self.sequence._resource_path == other.sequence._resource_path - and self.level == other.level - ) - - return False - - def __repr__(self) -> str: - return f"PrimaryPly(sequence={self.sequence.__repr__()}, level={self.level})" - - def clone(self) -> Self: - """Create a new unstored PrimaryPly with the same properties.""" - return type(self)(sequence=self.sequence, level=self.level) - - -def _get_allowed_secondary_ply_types() -> tuple[type, ...]: - from .modeling_group import ModelingGroup - - return (ModelingGroup, ModelingPly) + sequence: ReadWriteProperty[ + ModelingGroup | ModelingPly | None, ModelingGroup | ModelingPly | None + ] = edge_property_type_linked_object( + "sequence", allowed_types_getter=_get_allowed_sequence_types + ) + level: ReadWriteProperty[int, int] = edge_property_type_attribute("level") @mark_grpc_properties @@ -221,5 +149,5 @@ def _create_stub(self) -> butt_joint_sequence_pb2_grpc.ObjectServiceStub: ) secondary_plies = define_polymorphic_linked_object_list( - "properties.secondary_plies", allowed_types_getter=_get_allowed_secondary_ply_types + "properties.secondary_plies", allowed_types_getter=_get_allowed_sequence_types )