Files
ResInsight/GrpcInterface/Python/rips/pdmobject.py
T
Magne Sjaastad dc20a016f7 #13987 PDM: Migrate Surface and Polygon collections to PdmNestedCollection
Port RimSurfaceCollection, RimPolygonCollection and the polygon-in-view
side onto the new caf::PdmNestedCollection framework, replacing the
per-class folder plumbing (m_subCollections field, topmost-folder flag,
add/remove/find sub-collection, factory, "new folder" command) with the
shared base.

- RimSurfaceCollection and RimPolygonCollection now derive from
  caf::PdmNestedCollection. Each constructor uses the generic folder
  icon and exposes a static createTopmost() that marks the top instance
  and applies the branded domain icon; RimOilField calls the factory.
- RimPolygonInViewCollection uses the new RimNestedMirrorCollectionInView
  helper, and a new RimPolygonContainer carries the source-side polygon
  link.
- RicNewSurfaceCollectionFeature is removed; the generic
  RicNewNestedCollectionFeature drives the "Add Folder" action against
  both collections.
- Update callers (RimAnnotationCollection, RimEnsembleSurface, polygon
  command features) and the scripting wrapper (RimcSurfaceCollection),
  plus the Python add-folder binding and polygon test.
2026-05-13 14:53:00 +02:00

708 lines
26 KiB
Python

# pylint: disable=no-self-use
"""
ResInsight caf::PdmObject connection module
"""
from enum import Enum
from functools import wraps
import grpc
import re
import inspect
import PdmObject_pb2
import PdmObject_pb2_grpc
import Commands_pb2
import Commands_pb2_grpc
from .exception import RipsError
from typing import Any, Callable, TypeVar, Union, List, Optional, Type
from typing_extensions import ParamSpec
# TypeVar for generic return types in PdmObjectBase methods
# Using string bound for forward reference since PdmObjectBase is defined later
PdmObjectT = TypeVar("PdmObjectT", bound="PdmObjectBase")
def camel_to_snake(name: str) -> str:
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def snake_to_camel(name: str) -> str:
return "".join(word.title() for word in name.split("_"))
F = TypeVar("F", bound=Callable[..., Any])
C = TypeVar("C")
def _is_closed_channel_error(exc: BaseException) -> bool:
# gRPC raises ValueError("Cannot invoke RPC on closed channel!") when an
# RPC is attempted after the channel has been closed (e.g. by the
# heartbeat after detecting a dead server). Translate to RipsError so
# callers see a typed exception instead of a bare ValueError.
return isinstance(exc, ValueError) and "closed channel" in str(exc)
def add_method(cls: C) -> Callable[[F], F]:
def decorator(func: F) -> F:
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except grpc.RpcError as e:
raise RipsError(
e.details(), code=e.code(), details=e.details()
) from None
except ValueError as e:
if _is_closed_channel_error(e):
raise RipsError(
"ResInsight gRPC channel is closed "
"(server may have crashed or been shut down)"
) from e
raise
# Explicitly preserve signature for Sphinx documentation
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
wrapper.__signature__ = inspect.signature(func) # type: ignore
wrapper.__annotations__ = getattr(func, "__annotations__", {})
# Add the wrapped function to the class
setattr(cls, func.__name__, wrapper)
return func # returning func means func can still be used normally
return decorator
P = ParamSpec("P")
T = TypeVar("T")
def add_static_method(cls: C) -> Callable[[Callable[P, T]], Callable[P, T]]:
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
return func(*args, **kwargs)
setattr(cls, func.__name__, wrapper)
# Note we are not binding func, but wrapper which accepts self but does exactly the same as func
return func # returning func means func can still be used normally
return decorator
class PdmObjectBase:
"""
The ResInsight base class for the Project Data Model
"""
__custom_init__ = None
def _execute_command(self, **command_params) -> Any:
self.__warnings = []
response, call = self._commands.Execute.with_call(
Commands_pb2.CommandParams(**command_params)
)
for key, value in call.trailing_metadata():
value = value.replace(";;", "\n")
if key == "warning":
self.__warnings.append(value)
return response
def __init__(
self,
pb2_object: Optional[PdmObject_pb2.PdmObject],
channel: Optional[grpc.Channel],
) -> None:
self.__warnings = []
self.__chunk_size = 8160
self._channel = channel
# Create stubs
if self._channel:
self._pdm_object_stub = PdmObject_pb2_grpc.PdmObjectServiceStub(
self._channel
)
self._commands = Commands_pb2_grpc.CommandsStub(self._channel)
if pb2_object is not None:
# Copy parameters from ResInsight
assert isinstance(pb2_object, PdmObject_pb2.PdmObject)
self._pb2_object = pb2_object
for camel_keyword in self._pb2_object.parameters:
snake_keyword = camel_to_snake(camel_keyword)
setattr(self, snake_keyword, self.__get_grpc_value(camel_keyword))
else:
# Copy parameters from PdmObject defaults
self._pb2_object = PdmObject_pb2.PdmObject(
class_keyword=self.__class__.__name__
)
self.__copy_to_pb2()
def copy_from(self, obj: object) -> None:
"""Copy attribute values from object to self"""
for attribute in dir(obj):
if not attribute.startswith("__"):
value = getattr(obj, attribute)
# This is crucial to avoid overwriting methods
if not callable(value):
setattr(self, attribute, value)
if self.__custom_init__ is not None:
self.__custom_init__(self._pb2_object, self._channel)
self.update()
def warnings(self) -> List[str]:
return self.__warnings
def has_warnings(self) -> bool:
return len(self.__warnings) > 0
def __copy_to_pb2(self) -> None:
if self._pb2_object is not None:
for snake_kw in dir(self):
if not snake_kw.startswith("_"):
value = getattr(self, snake_kw)
# This is crucial to avoid overwriting methods
if not callable(value):
camel_kw = snake_to_camel(snake_kw)
self.__set_grpc_value(camel_kw, value)
def pb2_object(self) -> PdmObject_pb2.PdmObject:
"""Private method"""
return self._pb2_object
def channel(self) -> grpc.Channel:
"""Private method"""
return self._channel
def address(self) -> Any:
"""Get the unique address of the PdmObject
Returns:
A 64-bit unsigned integer address
"""
return self._pb2_object.address
def set_visible(self, visible: bool) -> None:
"""Set the visibility of the object in the ResInsight project tree"""
self._pb2_object.visible = visible
def visible(self) -> bool:
"""Get the visibility of the object in the ResInsight project tree"""
return bool(self._pb2_object.visible)
def print_object_info(self) -> None:
"""Print the structure and data content of the PdmObject"""
print("=========== " + self.__class__.__name__ + " =================")
print("Object Attributes: ")
for snake_kw in dir(self):
if not snake_kw.startswith("_") and not callable(getattr(self, snake_kw)):
print(
" "
+ snake_kw
+ " ["
+ type(getattr(self, snake_kw)).__name__
+ "]: "
+ str(getattr(self, snake_kw))
)
print("Object Methods:")
for snake_kw in dir(self):
if not snake_kw.startswith("_") and callable(getattr(self, snake_kw)):
print(" " + snake_kw)
Value = Union[bool, str, float, int, "ValueArray"]
ValueArray = List[Value]
def __convert_from_grpc_value(self, value: str) -> Value:
if value.lower() == "false":
return False
if value.lower() == "true":
return True
try:
int_val = int(value)
return int_val
except ValueError:
try:
float_val = float(value)
return float_val
except ValueError:
# We may have a string. Remove the outer pair of quotes
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
value = value[1:-1]
if self.__islist(value):
return self.__makelist(value)
if self.__istuple(value):
return self.__maketuple(value)
return self.__unescape_string(value)
def __convert_to_grpc_value(self, value: Any) -> str:
if isinstance(value, bool):
if value:
return "true"
return "false"
if isinstance(value, Enum):
return str(value.value)
if isinstance(value, PdmObjectBase):
return value.__class__.__name__ + ":" + str(value.address())
if isinstance(value, list):
list_of_values = []
for val in value:
list_of_values.append(self.__convert_to_grpc_value(val))
return "[" + ", ".join(list_of_values) + "]"
if isinstance(value, tuple):
list_of_values = []
for val in value:
list_of_values.append(self.__convert_to_grpc_value(val))
return "(" + ", ".join(list_of_values) + ")"
return str(value)
def __get_grpc_value(self, camel_keyword: str) -> Value:
return self.__convert_from_grpc_value(
self._pb2_object.parameters[camel_keyword]
)
def __set_grpc_value(self, camel_keyword: str, value: str) -> None:
self._pb2_object.parameters[camel_keyword] = self.__convert_to_grpc_value(value)
def set_value(self, snake_keyword: str, value: object) -> None:
"""Set the value associated with the provided keyword and updates ResInsight
Arguments:
keyword(str): A string containing the parameter keyword
value(varying): A value matching the type of the parameter.
See keyword documentation and/or print_object_info() to find
the correct data type.
"""
setattr(self, snake_keyword, value)
self.update()
def __istuple(self, value) -> bool:
return value.startswith("(") and value.endswith(")")
def __islist(self, value: str) -> bool:
return value.startswith("[") and value.endswith("]")
def __maketuple(self, tuple_string: str) -> Value:
tuple_string = tuple_string.removeprefix("(")
tuple_string = tuple_string.removesuffix(")")
if not tuple_string:
return ()
if "), (" in tuple_string:
subtuples = re.split(r"\), \(", tuple_string)
return [self.__makelist(subtuple) for subtuple in subtuples]
else:
strings = tuple_string.split(", ")
if len(strings) == 2:
return (
self.__convert_from_grpc_value(strings[0]),
self.__convert_from_grpc_value(strings[1]),
)
return ()
def __unescape_string(self, value: str) -> str:
result = []
i = 0
while i < len(value):
if value[i] == "\\" and i + 1 < len(value):
next_ch = value[i + 1]
if next_ch == '"' or next_ch == "\\":
result.append(next_ch)
i += 2
continue
result.append(value[i])
i += 1
return "".join(result)
def __makelist(self, list_string: str) -> Value:
list_string = list_string.removeprefix("[")
list_string = list_string.removesuffix("]")
if not list_string:
return []
# Quote-aware split: track quote state, escape state, and bracket depth
# so commas inside "..." or [...] are not treated as separators.
items = []
current = []
in_quotes = False
escape_next = False
bracket_depth = 0
for ch in list_string:
if escape_next:
current.append(ch)
escape_next = False
continue
if ch == "\\" and in_quotes:
current.append(ch)
escape_next = True
continue
if ch == '"':
in_quotes = not in_quotes
current.append(ch)
continue
if not in_quotes:
if ch == "[":
bracket_depth += 1
elif ch == "]":
bracket_depth -= 1
elif ch == "," and bracket_depth == 0:
# Separator: expect ", " so skip the following space
items.append("".join(current))
current = []
continue
elif ch == " " and not current:
# Skip space after comma separator
continue
current.append(ch)
if current:
items.append("".join(current))
values = []
for item in items:
values.append(self.__convert_from_grpc_value(item))
return values
def __from_pb2_to_resinsight_classes(
self,
pb2_object_list: List[PdmObject_pb2.PdmObject],
super_class_definition: Type[PdmObjectT],
) -> List[PdmObjectT]:
pdm_object_list = []
from .generated.generated_classes import class_from_keyword
for pb2_object in pb2_object_list:
child_class_definition = class_from_keyword(pb2_object.class_keyword)
if child_class_definition is None:
child_class_definition = super_class_definition
assert child_class_definition is not None
pdm_object = child_class_definition(
pb2_object=pb2_object, channel=self.channel()
)
pdm_object_list.append(pdm_object)
return pdm_object_list
def descendants(self, class_definition: Type[PdmObjectT]) -> List[PdmObjectT]:
"""Get a list of all project tree descendants matching the class keyword
Arguments:
class_definition[class]: A class definition matching the type of class wanted
Returns:
A list of PdmObjects matching the class_definition
"""
assert inspect.isclass(class_definition)
class_keyword = class_definition.__name__
try:
request = PdmObject_pb2.PdmDescendantObjectRequest(
object=self._pb2_object, child_keyword=class_keyword
)
object_list = self._pdm_object_stub.GetDescendantPdmObjects(request).objects
return self.__from_pb2_to_resinsight_classes(object_list, class_definition)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
return [] # Valid empty result
raise e
def children(
self, child_field: str, class_definition: Type[PdmObjectT]
) -> List[PdmObjectT]:
"""Get a list of all direct project tree children inside the provided child_field
Arguments:
child_field[str]: A field name
Returns:
A list of PdmObjects inside the child_field
"""
request = PdmObject_pb2.PdmChildObjectRequest(
object=self._pb2_object, child_field=child_field
)
try:
object_list = self._pdm_object_stub.GetChildPdmObjects(request).objects
return self.__from_pb2_to_resinsight_classes(object_list, class_definition)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
return []
raise e
def add_new_object(
self, class_definition: Type[PdmObjectT], child_field: str = ""
) -> Optional[PdmObjectT]:
"""Create and add an object to the specified child field
Arguments:
class_definition[class]: Class definition of the object to create
child_field[str]: The keyword for the field to create a new object in. If empty, the first child array field is used.
Returns:
The created PdmObject inside the child_field
"""
from .generated.generated_classes import class_from_keyword
assert inspect.isclass(class_definition)
class_keyword = class_definition.__name__
request = PdmObject_pb2.CreatePdmChildObjectRequest(
object=self._pb2_object,
child_field=child_field,
class_keyword=class_keyword,
)
try:
pb2_object = self._pdm_object_stub.CreateChildPdmObject(request)
child_class_definition = class_from_keyword(pb2_object.class_keyword)
if child_class_definition is None:
child_class_definition = class_definition
assert child_class_definition.__name__ == class_definition.__name__
pdm_object = class_definition(pb2_object=pb2_object, channel=self.channel())
return pdm_object
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
return None
raise e
def ancestor(self, class_definition: Type[PdmObjectT]) -> Optional[PdmObjectT]:
"""Find the first ancestor that matches the provided class_keyword
Arguments:
class_definition[class]: A class definition matching the type of class wanted
"""
assert inspect.isclass(class_definition)
class_keyword = class_definition.__name__
request = PdmObject_pb2.PdmParentObjectRequest(
object=self._pb2_object, parent_keyword=class_keyword
)
try:
pb2_object = self._pdm_object_stub.GetAncestorPdmObject(request)
pdm_object = class_definition(pb2_object=pb2_object, channel=self.channel())
return pdm_object
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
return None
raise e
def _call_get_method_async(self, method_name: str):
request = PdmObject_pb2.PdmObjectGetterRequest(
object=self._pb2_object, method=method_name
)
for chunk in self._pdm_object_stub.CallPdmObjectGetter(request):
yield chunk
def _call_get_method(self, method_name: str):
all_values = []
generator = self._call_get_method_async(method_name)
for chunk in generator:
data = getattr(chunk, chunk.WhichOneof("data"))
for value in data.data:
all_values.append(value)
return all_values
def __generate_set_method_chunks(self, array, method_request):
index = -1
while index < len(array):
chunk = PdmObject_pb2.PdmObjectSetterChunk()
if index == -1:
chunk.set_request.CopyFrom(
PdmObject_pb2.PdmObjectSetterRequest(
request=method_request, data_count=len(array)
)
)
index += 1
else:
actual_chunk_size = min(len(array) - index + 1, self.__chunk_size)
if isinstance(array[0], float):
chunk.CopyFrom(
PdmObject_pb2.PdmObjectSetterChunk(
doubles=PdmObject_pb2.DoubleArray(
data=array[index : index + actual_chunk_size]
)
)
)
elif isinstance(array[0], int):
chunk.CopyFrom(
PdmObject_pb2.PdmObjectSetterChunk(
ints=PdmObject_pb2.IntArray(
data=array[index : index + actual_chunk_size]
)
)
)
elif isinstance(array[0], str):
chunk.CopyFrom(
PdmObject_pb2.PdmObjectSetterChunk(
strings=PdmObject_pb2.StringArray(
data=array[index : index + actual_chunk_size]
)
)
)
else:
raise Exception("Wrong data type for set method")
index += actual_chunk_size
yield chunk
# Final empty message to signal completion
chunk = PdmObject_pb2.PdmObjectSetterChunk()
yield chunk
def _call_set_method(self, method_name: str, values) -> None:
method_request = PdmObject_pb2.PdmObjectGetterRequest(
object=self._pb2_object, method=method_name
)
request_iterator = self.__generate_set_method_chunks(values, method_request)
reply = self._pdm_object_stub.CallPdmObjectSetter(request_iterator)
if reply.accepted_value_count < len(values):
raise IndexError
def _call_pdm_method_void(self, method_name: str, **kwargs: Any) -> None:
pb2_params = PdmObject_pb2.PdmObject(class_keyword=method_name)
for key, value in kwargs.items():
pb2_params.parameters[snake_to_camel(key)] = self.__convert_to_grpc_value(
value
)
request = PdmObject_pb2.PdmObjectMethodRequest(
object=self._pb2_object, method=method_name, params=pb2_params
)
try:
self._pdm_object_stub.CallPdmObjectMethod(request)
except grpc.RpcError as exc:
raise RipsError(
"%s" % exc.details(), code=exc.code(), details=exc.details()
) from None
except ValueError as exc:
if _is_closed_channel_error(exc):
raise RipsError(
"ResInsight gRPC channel is closed "
"(server may have crashed or been shut down)"
) from exc
raise
def _call_pdm_method_return_value(
self, method_name: str, class_definition: Type[PdmObjectT], **kwargs: Any
) -> PdmObjectT:
pb2_params = PdmObject_pb2.PdmObject(class_keyword=method_name)
for key, value in kwargs.items():
pb2_params.parameters[snake_to_camel(key)] = self.__convert_to_grpc_value(
value
)
request = PdmObject_pb2.PdmObjectMethodRequest(
object=self._pb2_object, method=method_name, params=pb2_params
)
try:
pb2_object = self._pdm_object_stub.CallPdmObjectMethod(request)
# Prefer the actual class the server returned over the statically declared one
# so that generic methods (e.g. AddFolder registered on a shared base) yield
# the most-derived Python proxy and inherited accessors work as expected.
from .generated.generated_classes import class_from_keyword
actual_class = class_from_keyword(pb2_object.class_keyword)
if actual_class is not None and issubclass(actual_class, class_definition):
return actual_class(pb2_object=pb2_object, channel=self.channel())
return class_definition(pb2_object=pb2_object, channel=self.channel())
except grpc.RpcError as exc:
raise RipsError(
"%s" % exc.details(), code=exc.code(), details=exc.details()
) from None
except ValueError as exc:
if _is_closed_channel_error(exc):
raise RipsError(
"ResInsight gRPC channel is closed "
"(server may have crashed or been shut down)"
) from exc
raise
def _call_pdm_method_return_optional_value(
self, method_name: str, class_definition: Type[PdmObjectT], **kwargs: Any
) -> Optional[PdmObjectT]:
pb2_params = PdmObject_pb2.PdmObject(class_keyword=method_name)
for key, value in kwargs.items():
pb2_params.parameters[snake_to_camel(key)] = self.__convert_to_grpc_value(
value
)
request = PdmObject_pb2.PdmObjectMethodRequest(
object=self._pb2_object, method=method_name, params=pb2_params
)
try:
pb2_object = self._pdm_object_stub.CallPdmObjectMethod(request)
from .generated.generated_classes import class_from_keyword
child_class_definition = class_from_keyword(pb2_object.class_keyword)
if child_class_definition is None:
return None
assert issubclass(child_class_definition, class_definition)
pdm_object = child_class_definition(
pb2_object=pb2_object, channel=self.channel()
)
return pdm_object
except grpc.RpcError as exc:
raise RipsError(
"%s" % exc.details(), code=exc.code(), details=exc.details()
) from None
except ValueError as exc:
if _is_closed_channel_error(exc):
raise RipsError(
"ResInsight gRPC channel is closed "
"(server may have crashed or been shut down)"
) from exc
raise
def update(self) -> None:
"""Sync all fields from the Python Object to ResInsight
If validation fails on the server, all local changes are rolled back
to the last known valid state, and the exception is re-raised.
"""
if self._pdm_object_stub is None:
raise Exception(
"Object is not connected to GRPC service so cannot update ResInsight"
)
# Store a snapshot of the last known valid protobuf state
pb2_snapshot = PdmObject_pb2.PdmObject()
pb2_snapshot.CopyFrom(self._pb2_object)
try:
self.__copy_to_pb2()
self._pdm_object_stub.UpdateExistingPdmObject(self._pb2_object)
except Exception:
# Rollback: restore protobuf object to previous state
self._pb2_object.CopyFrom(pb2_snapshot)
# Restore Python attributes from the protobuf snapshot
for camel_keyword in pb2_snapshot.parameters:
snake_keyword = camel_to_snake(camel_keyword)
value = self.__convert_from_grpc_value(
pb2_snapshot.parameters[camel_keyword]
)
setattr(self, snake_keyword, value)
# Re-raise the exception so the caller knows update failed
raise
def delete(self) -> None:
"""Delete object in ResInsight"""
self.__copy_to_pb2()
if self._pdm_object_stub is not None:
self._pdm_object_stub.DeleteExistingPdmObject(self._pb2_object)
else:
raise Exception(
"Object is not connected to GRPC service so cannot update ResInsight"
)