diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 807758ea23..3db980efe9 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/doc/source/api/linked_object_definitions.rst b/doc/source/api/linked_object_definitions.rst index 6f2168eca1..1383ae6641 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 64c0660fbb..9c887ce28c 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 c254523b1b..2269aeb19f 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 852459051f..12597d8b53 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/edge_property_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py index 6e523ea66a..585bafb5c8 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/linked_object_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_list.py index 4bd4b5d7f2..2fdbff54e5 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/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index 50bac3c7e4..e73853959c 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 @@ -37,7 +38,8 @@ 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 # be improved. The main challenge is that we do not encode the structure of @@ -90,6 +92,23 @@ 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): + # 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\n{indent * ' '}*Added in ACP server version {cls._SUPPORTED_SINCE}.*\n" + ) return cls @@ -110,7 +129,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 +147,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 +179,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: @@ -178,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: @@ -197,6 +207,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[[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.""" + + @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 +253,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[[Message, 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 +278,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 +292,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 +315,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 +336,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 fdc2410dda..bed52792fb 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, @@ -150,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 = [] @@ -188,6 +190,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 0000000000..2b67cdf4b5 --- /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 : + The server version since which the method is supported. If ``None``, the + decorator does nothing. + err_msg_tpl : + 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 '{func.__name__}' method 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 758f993719..096b23be14 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 @@ -323,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) @@ -478,34 +491,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/butt_joint_sequence.py b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py new file mode 100644 index 0000000000..5376f0c105 --- /dev/null +++ b/src/ansys/acp/core/_tree_objects/butt_joint_sequence.py @@ -0,0 +1,153 @@ +# 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 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 ( + 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.property_helper import ( + 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"] + + +def _get_allowed_sequence_types() -> tuple[type, ...]: + from .modeling_group import ModelingGroup + + return (ModelingGroup, ModelingPly) + + +@mark_grpc_properties +class PrimaryPly(EdgePropertyTypeBase): + """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. + + """ + + __slots__: tuple[str, ...] = tuple() + + _PB_OBJECT_TYPE = butt_joint_sequence_pb2.PrimaryPly + _SUPPORTED_SINCE = "25.1" + + def __init__(self, sequence: ModelingGroup | ModelingPly | None = None, level: int = 1): + super().__init__() + self.sequence = sequence + self.level = level + + 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 +@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_sequence_types + ) diff --git a/src/ansys/acp/core/_tree_objects/model.py b/src/ansys/acp/core/_tree_objects/model.py index b4fec9c781..3efca801ba 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/src/ansys/acp/core/_tree_objects/modeling_group.py b/src/ansys/acp/core/_tree_objects/modeling_group.py index cfeb18d771..eaacaa8c04 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 @@ -62,7 +68,7 @@ class ModelingGroup(CreatableTreeObject, IdTreeObject): Parameters ---------- - name + name : Name of the modeling group. """ @@ -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/conftest.py b/tests/conftest.py index e09540c005..d6b22b2828 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_butt_joint_sequence.py b/tests/unittests/test_butt_joint_sequence.py new file mode 100644 index 0000000000..9608100dd4 --- /dev/null +++ b/tests/unittests/test_butt_joint_sequence.py @@ -0,0 +1,121 @@ +# 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 packaging.version import parse as parse_version +import pytest + +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: + 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 diff --git a/tests/unittests/test_model.py b/tests/unittests/test_model.py index eb86bff3d2..f0a4483005 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):