diff --git a/crates/re_sdk/src/log_sink.rs b/crates/re_sdk/src/log_sink.rs index e7314af4a82c..e6985ce65816 100644 --- a/crates/re_sdk/src/log_sink.rs +++ b/crates/re_sdk/src/log_sink.rs @@ -147,6 +147,12 @@ impl MemorySinkStorage { self.msgs.read() } + /// How many messages are currently written to this memory sink + #[inline] + pub fn num_msgs(&self) -> usize { + self.read().len() + } + /// Consumes and returns the inner array of [`LogMsg`]. /// /// This automatically takes care of flushing the underlying [`crate::RecordingStream`]. diff --git a/crates/re_types_builder/src/codegen/python.rs b/crates/re_types_builder/src/codegen/python.rs index 84fc1f0f47bd..7d6c77446537 100644 --- a/crates/re_types_builder/src/codegen/python.rs +++ b/crates/re_types_builder/src/codegen/python.rs @@ -324,6 +324,7 @@ impl PythonCodeGenerator { import pyarrow as pa import uuid + from {rerun_path}error_utils import catch_and_log_exceptions from {rerun_path}_baseclasses import ( Archetype, BaseExtensionType, @@ -547,7 +548,7 @@ fn code_for_struct( if field.is_nullable { format!("converter={typ_unwrapped}Batch._optional, # type: ignore[misc]\n") } else { - format!("converter={typ_unwrapped}Batch, # type: ignore[misc]\n") + format!("converter={typ_unwrapped}Batch._required, # type: ignore[misc]\n") } } else if !default_converter.is_empty() { code.push_text(&converter_function, 1, 0); @@ -618,6 +619,10 @@ fn code_for_struct( code.push_text(quote_init_method(obj, ext_class, objects), 2, 4); } + if obj.kind == ObjectKind::Archetype { + code.push_text(quote_clear_methods(obj), 2, 4); + } + if obj.is_delegating_component() { code.push_text( format!( @@ -1656,6 +1661,20 @@ fn quote_init_method(obj: &Object, ext_class: &ExtensionClass, objects: &Objects format!("self.__attrs_init__({})", attribute_init.join(", ")) }; + // Make sure Archetypes catch and log exceptions as a fallback + let forwarding_call = if obj.kind == ObjectKind::Archetype { + unindent::unindent(&format!( + r#" + with catch_and_log_exceptions(context=self.__class__.__name__): + {forwarding_call} + return + self.__attrs_clear__() + "# + )) + } else { + forwarding_call + }; + format!( "{head}\n{}", indent::indent_all_by( @@ -1665,6 +1684,33 @@ fn quote_init_method(obj: &Object, ext_class: &ExtensionClass, objects: &Objects ) } +fn quote_clear_methods(obj: &Object) -> String { + let param_nones = obj + .fields + .iter() + .map(|field| format!("{} = None, # type: ignore[arg-type]", field.name)) + .join("\n "); + + let classname = &obj.name; + + unindent::unindent(&format!( + r#" + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + {param_nones} + ) + + @classmethod + def _clear(cls) -> {classname}: + """Produce an empty {classname}, bypassing `__init__`""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + "# + )) +} + // --- Arrow registry code generators --- use arrow2::datatypes::{DataType, Field, UnionMode}; diff --git a/rerun_py/rerun_sdk/rerun/__init__.py b/rerun_py/rerun_sdk/rerun/__init__.py index 73692c983e20..1fb8c0389970 100644 --- a/rerun_py/rerun_sdk/rerun/__init__.py +++ b/rerun_py/rerun_sdk/rerun/__init__.py @@ -145,6 +145,7 @@ from .archetypes.boxes2d_ext import Box2DFormat from .components import MediaType, TextLogLevel from .datatypes import Quaternion, RotationAxisAngle, Scale3D, TranslationAndMat3x3, TranslationRotationScale3D +from .error_utils import set_strict_mode from .log_deprecated.annotation import AnnotationInfo, ClassDescription, log_annotation_context from .log_deprecated.arrow import log_arrow from .log_deprecated.bounding_box import log_obb, log_obbs @@ -199,7 +200,6 @@ "unregister_shutdown", "cleanup_if_forked_child", "shutdown_at_exit", - "strict_mode", "set_strict_mode", "start_web_viewer_server", ] @@ -224,11 +224,6 @@ def _init_recording_stream() -> None: _init_recording_stream() -# If `True`, we raise exceptions on use error (wrong parameter types, etc.). -# If `False` we catch all errors and log a warning instead. -_strict_mode = False - - def init( application_id: str, *, @@ -298,8 +293,7 @@ def init( random.seed(0) np.random.seed(0) - global _strict_mode - _strict_mode = strict + set_strict_mode(strict) # Always check whether we are a forked child when calling init. This should have happened # via `_register_on_fork` but it's worth being conservative. @@ -506,35 +500,6 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # --- -def strict_mode() -> bool: - """ - Strict mode enabled. - - In strict mode, incorrect use of the Rerun API (wrong parameter types etc.) - will result in exception being raised. - When strict mode is on, such problems are instead logged as warnings. - - The default is OFF. - """ - - return _strict_mode - - -def set_strict_mode(mode: bool) -> None: - """ - Turn strict mode on/off. - - In strict mode, incorrect use of the Rerun API (wrong parameter types etc.) - will result in exception being raised. - When strict mode is off, such problems are instead logged as warnings. - - The default is OFF. - """ - global _strict_mode - - _strict_mode = mode - - def start_web_viewer_server(port: int = 0) -> None: """ Start an HTTP server that hosts the rerun web viewer. diff --git a/rerun_py/rerun_sdk/rerun/_baseclasses.py b/rerun_py/rerun_sdk/rerun/_baseclasses.py index 7d691246578a..aeb3e40e8e25 100644 --- a/rerun_py/rerun_sdk/rerun/_baseclasses.py +++ b/rerun_py/rerun_sdk/rerun/_baseclasses.py @@ -5,6 +5,8 @@ import pyarrow as pa from attrs import define, fields +from .error_utils import catch_and_log_exceptions + T = TypeVar("T") @@ -152,7 +154,7 @@ class BaseBatch(Generic[T]): def __init__(self, data: T | None) -> None: """ - Primary method for creating Arrow arrays for required components. + Construct a new batch. This method must flexibly accept native data (which comply with type `T`). Subclasses must provide a type parameter specifying the type of the native data (this is automatically handled by the code generator). @@ -172,22 +174,30 @@ def __init__(self, data: T | None) -> None: ------- The Arrow array encapsulating the data. """ - - # If data is already an arrow array, use it - if isinstance(data, pa.Array): - if data.type == self._ARROW_TYPE: - self.pa_array = data - return - elif data.type == self._ARROW_TYPE.storage_type: - self.pa_array = self._ARROW_TYPE.wrap_array(data) + if data is not None: + with catch_and_log_exceptions(self.__class__.__name__): + # If data is already an arrow array, use it + if isinstance(data, pa.Array) and data.type == self._ARROW_TYPE: + self.pa_array = data + elif isinstance(data, pa.Array) and data.type == self._ARROW_TYPE.storage_type: + self.pa_array = self._ARROW_TYPE.wrap_array(data) + else: + self.pa_array = self._ARROW_TYPE.wrap_array( + self._native_to_pa_array(data, self._ARROW_TYPE.storage_type) + ) return - if data is None: - pa_array = _empty_pa_array(self._ARROW_TYPE.storage_type) - else: - pa_array = self._native_to_pa_array(data, self._ARROW_TYPE.storage_type) + # If we didn't return above, default to the empty array + self.pa_array = _empty_pa_array(self._ARROW_TYPE) - self.pa_array = self._ARROW_TYPE.wrap_array(pa_array) + @classmethod + def _required(cls, data: T | None) -> BaseBatch[T]: + """ + Primary method for creating Arrow arrays for optional components. + + Just calls through to __init__, but with clearer type annotations. + """ + return cls(data) @classmethod def _optional(cls, data: T | None) -> BaseBatch[T] | None: @@ -270,9 +280,10 @@ def component_name(self) -> str: return self._ARROW_TYPE._TYPE_NAME # type: ignore[attr-defined, no-any-return] +@catch_and_log_exceptions(context="creating empty array") def _empty_pa_array(type: pa.DataType) -> pa.Array: if isinstance(type, pa.ExtensionType): - return _empty_pa_array(type.storage_type) + return type.wrap_array(_empty_pa_array(type.storage_type)) # Creation of empty arrays of dense unions aren't implemented in pyarrow yet. if isinstance(type, pa.UnionType): diff --git a/rerun_py/rerun_sdk/rerun/_log.py b/rerun_py/rerun_sdk/rerun/_log.py index 22fed55ae672..8375ad68368f 100644 --- a/rerun_py/rerun_sdk/rerun/_log.py +++ b/rerun_py/rerun_sdk/rerun/_log.py @@ -9,7 +9,7 @@ from . import components as cmp from ._baseclasses import AsComponents, ComponentBatchLike -from .error_utils import _send_warning +from .error_utils import _send_warning, catch_and_log_exceptions from .recording_stream import RecordingStream __all__ = ["log", "IndicatorComponentBatch", "AsComponents"] @@ -102,6 +102,7 @@ def _splat() -> cmp.InstanceKeyBatch: return pa.array([_MAX_U64], type=cmp.InstanceKeyType().storage_type) # type: ignore[no-any-return] +@catch_and_log_exceptions() def log( entity_path: str, entity: AsComponents | Iterable[ComponentBatchLike], @@ -109,6 +110,7 @@ def log( ext: dict[str, Any] | None = None, timeless: bool = False, recording: RecordingStream | None = None, + strict: bool | None = None, ) -> None: """ Log an entity. @@ -127,7 +129,10 @@ def log( Specifies the [`rerun.RecordingStream`][] to use. If left unspecified, defaults to the current active data recording, if there is one. See also: [`rerun.init`][], [`rerun.set_global_data_recording`][]. - + strict: + If True, raise exceptions on non-loggable data. + If False, warn on non-loggable data. + if None, use the global default from `rerun.strict_mode()` """ # TODO(jleibs): Profile is_instance with runtime_checkable vs has_attr # Note from: https://docs.python.org/3/library/typing.html#typing.runtime_checkable @@ -139,7 +144,7 @@ def log( if hasattr(entity, "as_component_batches"): components = entity.as_component_batches() else: - components = entity + components = list(entity) if hasattr(entity, "num_instances"): num_instances = entity.num_instances() @@ -156,6 +161,7 @@ def log( ) +@catch_and_log_exceptions() def log_components( entity_path: str, components: Iterable[ComponentBatchLike], @@ -164,6 +170,7 @@ def log_components( ext: dict[str, Any] | None = None, timeless: bool = False, recording: RecordingStream | None = None, + strict: bool | None = None, ) -> None: """ Log an entity from a collection of `ComponentBatchLike` objects. @@ -190,7 +197,10 @@ def log_components( Specifies the [`rerun.RecordingStream`][] to use. If left unspecified, defaults to the current active data recording, if there is one. See also: [`rerun.init`][], [`rerun.set_global_data_recording`][]. - + strict: + If True, raise exceptions on non-loggable data. + If False, warn on non-loggable data. + if None, use the global default from `rerun.strict_mode()` """ instanced: dict[str, pa.Array] = {} splats: dict[str, pa.Array] = {} @@ -204,6 +214,11 @@ def log_components( num_instances = max(len(arr) for arr in arrow_arrays) for name, array in zip(names, arrow_arrays): + # Array could be None if there was an error producing the empty array + # Nothing we can do at this point other than ignore it. Some form of error + # should have been logged. + if array is None: + pass # Strip off the ExtensionArray if it's present. We will always log via component_name. # TODO(jleibs): Maybe warn if there is a name mismatch here. if isinstance(array, pa.ExtensionArray): diff --git a/rerun_py/rerun_sdk/rerun/_validators.py b/rerun_py/rerun_sdk/rerun/_validators.py index d766386f235d..960d3eb67475 100644 --- a/rerun_py/rerun_sdk/rerun/_validators.py +++ b/rerun_py/rerun_sdk/rerun/_validators.py @@ -62,7 +62,7 @@ def flat_np_float_array_from_array_like(data: Any, dimension: int) -> npt.NDArra if not valid: raise ValueError( - f"Expected either a flat array with a length a of {dimension} elements, or an array with shape (`num_elements`, {dimension}). Shape of passed array was {array.shape}." + f"Expected either a flat array with a length multiple of {dimension} elements, or an array with shape (`num_elements`, {dimension}). Shape of passed array was {array.shape}." ) return array.reshape((-1,)) diff --git a/rerun_py/rerun_sdk/rerun/archetypes/annotation_context.py b/rerun_py/rerun_sdk/rerun/archetypes/annotation_context.py index 71fc6a6529f4..05a64b9be4d5 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/annotation_context.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/annotation_context.py @@ -11,6 +11,7 @@ from .. import components from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["AnnotationContext"] @@ -128,11 +129,27 @@ def __init__(self: Any, context: components.AnnotationContextLike): """Create a new instance of the AnnotationContext archetype.""" # You can define your own __init__ function as a member of AnnotationContextExt in annotation_context_ext.py - self.__attrs_init__(context=context) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(context=context) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + context=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> AnnotationContext: + """Produce an empty AnnotationContext, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst context: components.AnnotationContextBatch = field( metadata={"component": "required"}, - converter=components.AnnotationContextBatch, # type: ignore[misc] + converter=components.AnnotationContextBatch._required, # type: ignore[misc] ) __str__ = Archetype.__str__ __repr__ = Archetype.__repr__ diff --git a/rerun_py/rerun_sdk/rerun/archetypes/arrows3d.py b/rerun_py/rerun_sdk/rerun/archetypes/arrows3d.py index b422336a8cbf..fed45982aac3 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/arrows3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/arrows3d.py @@ -48,9 +48,28 @@ class Arrows3D(Arrows3DExt, Archetype): # __init__ can be found in arrows3d_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + vectors=None, # type: ignore[arg-type] + origins=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Arrows3D: + """Produce an empty Arrows3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + vectors: components.Vector3DBatch = field( metadata={"component": "required"}, - converter=components.Vector3DBatch, # type: ignore[misc] + converter=components.Vector3DBatch._required, # type: ignore[misc] ) """ All the vectors for each arrow in the batch. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/arrows3d_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/arrows3d_ext.py index 105ac60404ea..440b1b670eb7 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/arrows3d_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/arrows3d_ext.py @@ -3,6 +3,7 @@ from typing import Any from .. import components, datatypes +from ..error_utils import catch_and_log_exceptions class Arrows3DExt: @@ -47,12 +48,15 @@ def __init__( # Custom constructor to remove positional arguments and force use of keyword arguments # while still making vectors required. - self.__attrs_init__( - vectors=vectors, - origins=origins, - radii=radii, - colors=colors, - labels=labels, - class_ids=class_ids, - instance_keys=instance_keys, - ) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + vectors=vectors, + origins=origins, + radii=radii, + colors=colors, + labels=labels, + class_ids=class_ids, + instance_keys=instance_keys, + ) + return + self.__attrs_clear__() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py b/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py index cdb76cb4ef2c..074489ece88c 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/asset3d.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions from .asset3d_ext import Asset3DExt __all__ = ["Asset3D"] @@ -103,11 +104,29 @@ def __init__( """ # You can define your own __init__ function as a member of Asset3DExt in asset3d_ext.py - self.__attrs_init__(blob=blob, media_type=media_type, transform=transform) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(blob=blob, media_type=media_type, transform=transform) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + blob=None, # type: ignore[arg-type] + media_type=None, # type: ignore[arg-type] + transform=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Asset3D: + """Produce an empty Asset3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst blob: components.BlobBatch = field( metadata={"component": "required"}, - converter=components.BlobBatch, # type: ignore[misc] + converter=components.BlobBatch._required, # type: ignore[misc] ) """ The asset's bytes. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py index c31ea02a2427..94caba355b77 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/asset3d_ext.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +from ..error_utils import catch_and_log_exceptions + if TYPE_CHECKING: from ..components import MediaType from . import Asset3D @@ -36,8 +38,10 @@ def from_file(path: str) -> Asset3D: """ from . import Asset3D - with open(path, "rb") as file: - return Asset3D.from_bytes(file.read(), guess_media_type(path)) + with catch_and_log_exceptions(context="Asset3D.from_file"): + with open(path, "rb") as file: + return Asset3D.from_bytes(file.read(), guess_media_type(path)) + return Asset3D._clear() @staticmethod def from_bytes(blob: bytes, media_type: MediaType | None) -> Asset3D: @@ -50,4 +54,6 @@ def from_bytes(blob: bytes, media_type: MediaType | None) -> Asset3D: from . import Asset3D # TODO(cmc): we could try and guess using magic bytes here, like rust does. - return Asset3D(blob=blob, media_type=media_type) + with catch_and_log_exceptions(context="Asset3D.from_file"): + return Asset3D(blob=blob, media_type=media_type) + return Asset3D._clear() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/bar_chart.py b/rerun_py/rerun_sdk/rerun/archetypes/bar_chart.py index 6d5b16845f65..a492a03cadf1 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/bar_chart.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/bar_chart.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions from .bar_chart_ext import BarChartExt __all__ = ["BarChart"] @@ -45,7 +46,23 @@ def __init__(self: Any, values: datatypes.TensorDataLike): """ # You can define your own __init__ function as a member of BarChartExt in bar_chart_ext.py - self.__attrs_init__(values=values) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(values=values) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + values=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> BarChart: + """Produce an empty BarChart, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst values: components.TensorDataBatch = field( metadata={"component": "required"}, diff --git a/rerun_py/rerun_sdk/rerun/archetypes/bar_chart_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/bar_chart_ext.py index 06e1f0d80d41..380e85401c56 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/bar_chart_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/bar_chart_ext.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from rerun.error_utils import _send_warning +from ..error_utils import _send_warning, catch_and_log_exceptions if TYPE_CHECKING: from ..components import TensorDataBatch @@ -11,6 +11,7 @@ class BarChartExt: @staticmethod + @catch_and_log_exceptions("BarChart converter") def values__field_converter_override(data: TensorDataArrayLike) -> TensorDataBatch: from ..components import TensorDataBatch diff --git a/rerun_py/rerun_sdk/rerun/archetypes/boxes2d.py b/rerun_py/rerun_sdk/rerun/archetypes/boxes2d.py index 830fcb475160..737bd9b009ce 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/boxes2d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/boxes2d.py @@ -36,9 +36,29 @@ class Boxes2D(Boxes2DExt, Archetype): # __init__ can be found in boxes2d_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + half_sizes=None, # type: ignore[arg-type] + centers=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + draw_order=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Boxes2D: + """Produce an empty Boxes2D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + half_sizes: components.HalfSizes2DBatch = field( metadata={"component": "required"}, - converter=components.HalfSizes2DBatch, # type: ignore[misc] + converter=components.HalfSizes2DBatch._required, # type: ignore[misc] ) """ All half-extents that make up the batch of boxes. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/boxes2d_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/boxes2d_ext.py index 5f72b8c31629..b98668252f1c 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/boxes2d_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/boxes2d_ext.py @@ -6,9 +6,8 @@ import numpy as np import numpy.typing as npt -from rerun.error_utils import _send_warning - from .. import components, datatypes +from ..error_utils import _send_warning, catch_and_log_exceptions class Box2DFormat(Enum): @@ -90,76 +89,81 @@ def __init__( instance_keys: Unique identifiers for each individual boxes in the batch. """ - if array is not None: - if half_sizes is not None: - _send_warning("Cannot specify both `array` and `half_sizes` at the same time.", 1) - if sizes is not None: - _send_warning("Cannot specify both `array` and `sizes` at the same time.", 1) - if mins is not None: - _send_warning("Cannot specify both `array` and `mins` at the same time.", 1) - if centers is not None: - _send_warning("Cannot specify both `array` and `centers` at the same time.", 1) - - if np.any(array): - array = np.asarray(array, dtype="float32") - if array.ndim == 1: - array = np.expand_dims(array, axis=0) - else: - array = np.zeros((0, 4), dtype="float32") - assert type(array) is np.ndarray - - if array_format == Box2DFormat.XYWH: - half_sizes = array[:, 2:4] / 2 - centers = array[:, 0:2] + half_sizes - elif array_format == Box2DFormat.YXHW: - half_sizes = np.flip(array[:, 2:4]) / 2 - centers = np.flip(array[:, 0:2]) + half_sizes - elif array_format == Box2DFormat.XYXY: - min = array[:, 0:2] - max = array[:, 2:4] - centers = (min + max) / 2 - half_sizes = max - centers - elif array_format == Box2DFormat.YXYX: - min = np.flip(array[:, 0:2]) - max = np.flip(array[:, 2:4]) - centers = (min + max) / 2 - half_sizes = max - centers - elif array_format == Box2DFormat.XCYCWH: - half_sizes = array[:, 2:4] / 2 - centers = array[:, 0:2] - elif array_format == Box2DFormat.XCYCW2H2: - half_sizes = array[:, 2:4] - centers = array[:, 0:2] - else: - raise ValueError(f"Unknown Box2D format {array_format}") - else: - if sizes is not None: - if half_sizes is not None: - _send_warning("Cannot specify both `sizes` and `half_sizes` at the same time.", 1) - sizes = np.asarray(sizes, dtype=np.float32) - half_sizes = sizes / 2.0 - - if mins is not None: + with catch_and_log_exceptions(context=self.__class__.__name__): + if array is not None: + if half_sizes is not None: + _send_warning("Cannot specify both `array` and `half_sizes` at the same time.", 1) + if sizes is not None: + _send_warning("Cannot specify both `array` and `sizes` at the same time.", 1) + if mins is not None: + _send_warning("Cannot specify both `array` and `mins` at the same time.", 1) if centers is not None: - _send_warning("Cannot specify both `mins` and `centers` at the same time.", 1) - - # already converted `sizes` to `half_sizes` - if half_sizes is None: - _send_warning("Cannot specify `mins` without `sizes` or `half_sizes`.", 1) - half_sizes = np.asarray([1, 1], dtype=np.float32) - - mins = np.asarray(mins, dtype=np.float32) - half_sizes = np.asarray(half_sizes, dtype=np.float32) - centers = mins + half_sizes - - self.__attrs_init__( - half_sizes=half_sizes, - centers=centers, - radii=radii, - colors=colors, - labels=labels, - draw_order=draw_order, - class_ids=class_ids, - instance_keys=instance_keys, - ) + _send_warning("Cannot specify both `array` and `centers` at the same time.", 1) + + if np.any(array): + array = np.asarray(array, dtype="float32") + if array.ndim == 1: + array = np.expand_dims(array, axis=0) + else: + array = np.zeros((0, 4), dtype="float32") + assert type(array) is np.ndarray + + if array_format == Box2DFormat.XYWH: + half_sizes = array[:, 2:4] / 2 + centers = array[:, 0:2] + half_sizes + elif array_format == Box2DFormat.YXHW: + half_sizes = np.flip(array[:, 2:4]) / 2 + centers = np.flip(array[:, 0:2]) + half_sizes + elif array_format == Box2DFormat.XYXY: + min = array[:, 0:2] + max = array[:, 2:4] + centers = (min + max) / 2 + half_sizes = max - centers + elif array_format == Box2DFormat.YXYX: + min = np.flip(array[:, 0:2]) + max = np.flip(array[:, 2:4]) + centers = (min + max) / 2 + half_sizes = max - centers + elif array_format == Box2DFormat.XCYCWH: + half_sizes = array[:, 2:4] / 2 + centers = array[:, 0:2] + elif array_format == Box2DFormat.XCYCW2H2: + half_sizes = array[:, 2:4] + centers = array[:, 0:2] + else: + raise ValueError(f"Unknown Box2D format {array_format}") + else: + if sizes is not None: + if half_sizes is not None: + _send_warning("Cannot specify both `sizes` and `half_sizes` at the same time.", 1) + + sizes = np.asarray(sizes, dtype=np.float32) + half_sizes = sizes / 2.0 + + if mins is not None: + if centers is not None: + _send_warning("Cannot specify both `mins` and `centers` at the same time.", 1) + + # already converted `sizes` to `half_sizes` + if half_sizes is None: + _send_warning("Cannot specify `mins` without `sizes` or `half_sizes`.", 1) + half_sizes = np.asarray([1, 1], dtype=np.float32) + + mins = np.asarray(mins, dtype=np.float32) + half_sizes = np.asarray(half_sizes, dtype=np.float32) + centers = mins + half_sizes + + self.__attrs_init__( + half_sizes=half_sizes, + centers=centers, + radii=radii, + colors=colors, + labels=labels, + draw_order=draw_order, + class_ids=class_ids, + instance_keys=instance_keys, + ) + return + + self.__attrs_clear__() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/boxes3d.py b/rerun_py/rerun_sdk/rerun/archetypes/boxes3d.py index cdfeed1b06ad..cdd9d01dd204 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/boxes3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/boxes3d.py @@ -71,9 +71,29 @@ class Boxes3D(Boxes3DExt, Archetype): # __init__ can be found in boxes3d_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + half_sizes=None, # type: ignore[arg-type] + centers=None, # type: ignore[arg-type] + rotations=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Boxes3D: + """Produce an empty Boxes3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + half_sizes: components.HalfSizes3DBatch = field( metadata={"component": "required"}, - converter=components.HalfSizes3DBatch, # type: ignore[misc] + converter=components.HalfSizes3DBatch._required, # type: ignore[misc] ) """ All half-extents that make up the batch of boxes. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/boxes3d_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/boxes3d_ext.py index 90245c2625d4..1ebf842bcb4e 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/boxes3d_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/boxes3d_ext.py @@ -4,9 +4,8 @@ import numpy as np -from rerun.error_utils import _send_warning - from .. import components, datatypes +from ..error_utils import _send_warning, catch_and_log_exceptions class Boxes3DExt: @@ -55,33 +54,37 @@ def __init__( Unique identifiers for each individual boxes in the batch. """ - if sizes is not None: - if half_sizes is not None: - _send_warning("Cannot specify both `sizes` and `half_sizes` at the same time.", 1) + with catch_and_log_exceptions(context=self.__class__.__name__): + if sizes is not None: + if half_sizes is not None: + _send_warning("Cannot specify both `sizes` and `half_sizes` at the same time.", 1) + + sizes = np.asarray(sizes, dtype=np.float32) + half_sizes = sizes / 2.0 - sizes = np.asarray(sizes, dtype=np.float32) - half_sizes = sizes / 2.0 + if mins is not None: + if centers is not None: + _send_warning("Cannot specify both `mins` and `centers` at the same time.", 1) - if mins is not None: - if centers is not None: - _send_warning("Cannot specify both `mins` and `centers` at the same time.", 1) + # already converted `sizes` to `half_sizes` + if half_sizes is None: + _send_warning("Cannot specify `mins` without `sizes` or `half_sizes`.", 1) + half_sizes = np.asarray([1, 1, 1], dtype=np.float32) - # already converted `sizes` to `half_sizes` - if half_sizes is None: - _send_warning("Cannot specify `mins` without `sizes` or `half_sizes`.", 1) - half_sizes = np.asarray([1, 1, 1], dtype=np.float32) + mins = np.asarray(mins, dtype=np.float32) + half_sizes = np.asarray(half_sizes, dtype=np.float32) + centers = mins + half_sizes - mins = np.asarray(mins, dtype=np.float32) - half_sizes = np.asarray(half_sizes, dtype=np.float32) - centers = mins + half_sizes + self.__attrs_init__( + half_sizes=half_sizes, + centers=centers, + rotations=rotations, + colors=colors, + radii=radii, + labels=labels, + class_ids=class_ids, + instance_keys=instance_keys, + ) + return - self.__attrs_init__( - half_sizes=half_sizes, - centers=centers, - rotations=rotations, - colors=colors, - radii=radii, - labels=labels, - class_ids=class_ids, - instance_keys=instance_keys, - ) + self.__attrs_clear__() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/clear.py b/rerun_py/rerun_sdk/rerun/archetypes/clear.py index c103bc9a0119..fce3d32e639f 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/clear.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/clear.py @@ -63,9 +63,22 @@ class Clear(ClearExt, Archetype): # __init__ can be found in clear_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + recursive=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Clear: + """Produce an empty Clear, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + recursive: components.ClearIsRecursiveBatch = field( metadata={"component": "required"}, - converter=components.ClearIsRecursiveBatch, # type: ignore[misc] + converter=components.ClearIsRecursiveBatch._required, # type: ignore[misc] ) __str__ = Archetype.__str__ __repr__ = Archetype.__repr__ diff --git a/rerun_py/rerun_sdk/rerun/archetypes/depth_image.py b/rerun_py/rerun_sdk/rerun/archetypes/depth_image.py index 59c7a4751f06..43a31fe315dc 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/depth_image.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/depth_image.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions from .depth_image_ext import DepthImageExt __all__ = ["DepthImage"] @@ -112,7 +113,25 @@ def __init__( """ # You can define your own __init__ function as a member of DepthImageExt in depth_image_ext.py - self.__attrs_init__(data=data, meter=meter, draw_order=draw_order) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(data=data, meter=meter, draw_order=draw_order) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + data=None, # type: ignore[arg-type] + meter=None, # type: ignore[arg-type] + draw_order=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> DepthImage: + """Produce an empty DepthImage, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst data: components.TensorDataBatch = field( metadata={"component": "required"}, diff --git a/rerun_py/rerun_sdk/rerun/archetypes/depth_image_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/depth_image_ext.py index 2be9139b072c..badf7dfa5267 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/depth_image_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/depth_image_ext.py @@ -5,9 +5,8 @@ import numpy as np import pyarrow as pa -from rerun.error_utils import _send_warning - from .._validators import find_non_empty_dim_indices +from ..error_utils import _send_warning, catch_and_log_exceptions if TYPE_CHECKING: from ..components import TensorDataBatch @@ -16,6 +15,7 @@ class DepthImageExt: @staticmethod + @catch_and_log_exceptions("DepthImage converter") def data__field_converter_override(data: TensorDataArrayLike) -> TensorDataBatch: from ..components import TensorDataBatch from ..datatypes import TensorDataType, TensorDimensionType diff --git a/rerun_py/rerun_sdk/rerun/archetypes/disconnected_space.py b/rerun_py/rerun_sdk/rerun/archetypes/disconnected_space.py index 581fee317e85..cfd285e95289 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/disconnected_space.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/disconnected_space.py @@ -11,6 +11,7 @@ from .. import components from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["DisconnectedSpace"] @@ -46,11 +47,27 @@ def __init__(self: Any, disconnected_space: components.DisconnectedSpaceLike): """Create a new instance of the DisconnectedSpace archetype.""" # You can define your own __init__ function as a member of DisconnectedSpaceExt in disconnected_space_ext.py - self.__attrs_init__(disconnected_space=disconnected_space) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(disconnected_space=disconnected_space) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + disconnected_space=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> DisconnectedSpace: + """Produce an empty DisconnectedSpace, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst disconnected_space: components.DisconnectedSpaceBatch = field( metadata={"component": "required"}, - converter=components.DisconnectedSpaceBatch, # type: ignore[misc] + converter=components.DisconnectedSpaceBatch._required, # type: ignore[misc] ) __str__ = Archetype.__str__ __repr__ = Archetype.__repr__ diff --git a/rerun_py/rerun_sdk/rerun/archetypes/image.py b/rerun_py/rerun_sdk/rerun/archetypes/image.py index 4712fde46810..8edaa1ca48f9 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/image.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/image.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions from .image_ext import ImageExt __all__ = ["Image"] @@ -68,7 +69,24 @@ def __init__(self: Any, data: datatypes.TensorDataLike, *, draw_order: component """ # You can define your own __init__ function as a member of ImageExt in image_ext.py - self.__attrs_init__(data=data, draw_order=draw_order) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(data=data, draw_order=draw_order) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + data=None, # type: ignore[arg-type] + draw_order=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Image: + """Produce an empty Image, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst data: components.TensorDataBatch = field( metadata={"component": "required"}, diff --git a/rerun_py/rerun_sdk/rerun/archetypes/image_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/image_ext.py index 14c932cec895..eb9f59e61498 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/image_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/image_ext.py @@ -5,9 +5,8 @@ import numpy as np import pyarrow as pa -from rerun.error_utils import _send_warning - from .._validators import find_non_empty_dim_indices +from ..error_utils import _send_warning, catch_and_log_exceptions if TYPE_CHECKING: from ..components import TensorDataBatch @@ -16,6 +15,7 @@ class ImageExt: @staticmethod + @catch_and_log_exceptions("Image converter") def data__field_converter_override(data: TensorDataArrayLike) -> TensorDataBatch: from ..components import TensorDataBatch from ..datatypes import TensorDataType, TensorDimensionType diff --git a/rerun_py/rerun_sdk/rerun/archetypes/line_strips2d.py b/rerun_py/rerun_sdk/rerun/archetypes/line_strips2d.py index e8ffd80d4e7b..531ed50ac8c9 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/line_strips2d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/line_strips2d.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["LineStrips2D"] @@ -131,19 +132,41 @@ def __init__( """ # You can define your own __init__ function as a member of LineStrips2DExt in line_strips2d_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + strips=strips, + radii=radii, + colors=colors, + labels=labels, + draw_order=draw_order, + class_ids=class_ids, + instance_keys=instance_keys, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - strips=strips, - radii=radii, - colors=colors, - labels=labels, - draw_order=draw_order, - class_ids=class_ids, - instance_keys=instance_keys, + strips=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + draw_order=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> LineStrips2D: + """Produce an empty LineStrips2D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + strips: components.LineStrip2DBatch = field( metadata={"component": "required"}, - converter=components.LineStrip2DBatch, # type: ignore[misc] + converter=components.LineStrip2DBatch._required, # type: ignore[misc] ) """ All the actual 2D line strips that make up the batch. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/line_strips3d.py b/rerun_py/rerun_sdk/rerun/archetypes/line_strips3d.py index cb963d15a517..3e1e4839312d 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/line_strips3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/line_strips3d.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["LineStrips3D"] @@ -153,13 +154,39 @@ def __init__( """ # You can define your own __init__ function as a member of LineStrips3DExt in line_strips3d_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + strips=strips, + radii=radii, + colors=colors, + labels=labels, + class_ids=class_ids, + instance_keys=instance_keys, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - strips=strips, radii=radii, colors=colors, labels=labels, class_ids=class_ids, instance_keys=instance_keys + strips=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> LineStrips3D: + """Produce an empty LineStrips3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + strips: components.LineStrip3DBatch = field( metadata={"component": "required"}, - converter=components.LineStrip3DBatch, # type: ignore[misc] + converter=components.LineStrip3DBatch._required, # type: ignore[misc] ) """ All the actual 3D line strips that make up the batch. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/mesh3d.py b/rerun_py/rerun_sdk/rerun/archetypes/mesh3d.py index 816d5e8e69b6..14019594f0dc 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/mesh3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/mesh3d.py @@ -71,9 +71,28 @@ class Mesh3D(Mesh3DExt, Archetype): # __init__ can be found in mesh3d_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + vertex_positions=None, # type: ignore[arg-type] + mesh_properties=None, # type: ignore[arg-type] + vertex_normals=None, # type: ignore[arg-type] + vertex_colors=None, # type: ignore[arg-type] + mesh_material=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Mesh3D: + """Produce an empty Mesh3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + vertex_positions: components.Position3DBatch = field( metadata={"component": "required"}, - converter=components.Position3DBatch, # type: ignore[misc] + converter=components.Position3DBatch._required, # type: ignore[misc] ) """ The positions of each vertex. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/mesh3d_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/mesh3d_ext.py index b202db443213..53453179d90f 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/mesh3d_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/mesh3d_ext.py @@ -5,6 +5,7 @@ import numpy.typing as npt from .. import components, datatypes +from ..error_utils import catch_and_log_exceptions class Mesh3DExt: @@ -48,18 +49,22 @@ def __init__( instance_keys: Unique identifiers for each individual vertex in the mesh. """ - if indices is not None: - if mesh_properties is not None: - raise ValueError("indices and mesh_properties are mutually exclusive") - mesh_properties = datatypes.MeshProperties(indices=indices) + with catch_and_log_exceptions(context=self.__class__.__name__): + if indices is not None: + if mesh_properties is not None: + raise ValueError("indices and mesh_properties are mutually exclusive") + mesh_properties = datatypes.MeshProperties(indices=indices) - # You can define your own __init__ function as a member of Mesh3DExt in mesh3d_ext.py - self.__attrs_init__( - vertex_positions=vertex_positions, - mesh_properties=mesh_properties, - vertex_normals=vertex_normals, - vertex_colors=vertex_colors, - mesh_material=mesh_material, - class_ids=class_ids, - instance_keys=instance_keys, - ) + # You can define your own __init__ function as a member of Mesh3DExt in mesh3d_ext.py + self.__attrs_init__( + vertex_positions=vertex_positions, + mesh_properties=mesh_properties, + vertex_normals=vertex_normals, + vertex_colors=vertex_colors, + mesh_material=mesh_material, + class_ids=class_ids, + instance_keys=instance_keys, + ) + return + + self.__attrs_clear__() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/pinhole.py b/rerun_py/rerun_sdk/rerun/archetypes/pinhole.py index fb315aa8dd67..1814db92ac99 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/pinhole.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/pinhole.py @@ -43,9 +43,24 @@ class Pinhole(PinholeExt, Archetype): # __init__ can be found in pinhole_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + image_from_camera=None, # type: ignore[arg-type] + resolution=None, # type: ignore[arg-type] + camera_xyz=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Pinhole: + """Produce an empty Pinhole, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + image_from_camera: components.PinholeProjectionBatch = field( metadata={"component": "required"}, - converter=components.PinholeProjectionBatch, # type: ignore[misc] + converter=components.PinholeProjectionBatch._required, # type: ignore[misc] ) """ Camera projection, from image coordinates to view coordinates. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/pinhole_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/pinhole_ext.py index 8ac1b8b1593d..55bf68f527d6 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/pinhole_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/pinhole_ext.py @@ -4,11 +4,10 @@ import numpy.typing as npt -from rerun.error_utils import _send_warning - from ..components import ViewCoordinatesLike from ..datatypes.mat3x3 import Mat3x3Like from ..datatypes.vec2d import Vec2D, Vec2DLike +from ..error_utils import _send_warning, catch_and_log_exceptions class PinholeExt: @@ -79,51 +78,55 @@ def __init__( Height of the image in pixels. """ - if resolution is None and width is not None and height is not None: - resolution = [width, height] - elif resolution is not None and (width is not None or height is not None): - _send_warning("Can't set both resolution and width/height", 1) - - # TODO(andreas): Use a union type for the Pinhole component instead ~Zof converting to a matrix here - if image_from_camera is None: - # Resolution is needed for various fallbacks/error cases below. - if resolution is None: - resolution = [1.0, 1.0] - resolution = Vec2D(resolution) - width = cast(float, resolution.xy[0]) - height = cast(float, resolution.xy[1]) - - if focal_length is None: - _send_warning("either image_from_camera or focal_length must be set", 1) - focal_length = (width * height) ** 0.5 # a reasonable default - if principal_point is None: - principal_point = [width / 2, height / 2] - if type(focal_length) in (int, float): - fl_x = focal_length - fl_y = focal_length - else: + with catch_and_log_exceptions(context=self.__class__.__name__): + if resolution is None and width is not None and height is not None: + resolution = [width, height] + elif resolution is not None and (width is not None or height is not None): + _send_warning("Can't set both resolution and width/height", 1) + + # TODO(andreas): Use a union type for the Pinhole component instead ~Zof converting to a matrix here + if image_from_camera is None: + # Resolution is needed for various fallbacks/error cases below. + if resolution is None: + resolution = [1.0, 1.0] + resolution = Vec2D(resolution) + width = cast(float, resolution.xy[0]) + height = cast(float, resolution.xy[1]) + + if focal_length is None: + _send_warning("either image_from_camera or focal_length must be set", 1) + focal_length = (width * height) ** 0.5 # a reasonable default + if principal_point is None: + principal_point = [width / 2, height / 2] + if type(focal_length) in (int, float): + fl_x = focal_length + fl_y = focal_length + else: + try: + # TODO(emilk): check that it is 2 elements long + fl_x = focal_length[0] # type: ignore[index] + fl_y = focal_length[1] # type: ignore[index] + except Exception: + _send_warning("Expected focal_length to be one or two floats", 1) + fl_x = width / 2 + fl_y = fl_x + try: - # TODO(emilk): check that it is 2 elements long - fl_x = focal_length[0] # type: ignore[index] - fl_y = focal_length[1] # type: ignore[index] + u_cen = principal_point[0] # type: ignore[index] + v_cen = principal_point[1] # type: ignore[index] except Exception: - _send_warning("Expected focal_length to be one or two floats", 1) - fl_x = width / 2 - fl_y = fl_x - - try: - u_cen = principal_point[0] # type: ignore[index] - v_cen = principal_point[1] # type: ignore[index] - except Exception: - _send_warning("Expected principal_point to be one or two floats", 1) - u_cen = width / 2 - v_cen = height / 2 - - image_from_camera = [[fl_x, 0, u_cen], [0, fl_y, v_cen], [0, 0, 1]] # type: ignore[assignment] - else: - if focal_length is not None: - _send_warning("Both image_from_camera and focal_length set", 1) - if principal_point is not None: - _send_warning("Both image_from_camera and principal_point set", 1) - - self.__attrs_init__(image_from_camera=image_from_camera, resolution=resolution, camera_xyz=camera_xyz) + _send_warning("Expected principal_point to be one or two floats", 1) + u_cen = width / 2 + v_cen = height / 2 + + image_from_camera = [[fl_x, 0, u_cen], [0, fl_y, v_cen], [0, 0, 1]] # type: ignore[assignment] + else: + if focal_length is not None: + _send_warning("Both image_from_camera and focal_length set", 1) + if principal_point is not None: + _send_warning("Both image_from_camera and principal_point set", 1) + + self.__attrs_init__(image_from_camera=image_from_camera, resolution=resolution, camera_xyz=camera_xyz) + return + + self.__attrs_clear__() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/points2d.py b/rerun_py/rerun_sdk/rerun/archetypes/points2d.py index b07582c04db3..523c0d9a1ec4 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/points2d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/points2d.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["Points2D"] @@ -114,20 +115,43 @@ def __init__( """ # You can define your own __init__ function as a member of Points2DExt in points2d_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + positions=positions, + radii=radii, + colors=colors, + labels=labels, + draw_order=draw_order, + class_ids=class_ids, + keypoint_ids=keypoint_ids, + instance_keys=instance_keys, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - positions=positions, - radii=radii, - colors=colors, - labels=labels, - draw_order=draw_order, - class_ids=class_ids, - keypoint_ids=keypoint_ids, - instance_keys=instance_keys, + positions=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + draw_order=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + keypoint_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> Points2D: + """Produce an empty Points2D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + positions: components.Position2DBatch = field( metadata={"component": "required"}, - converter=components.Position2DBatch, # type: ignore[misc] + converter=components.Position2DBatch._required, # type: ignore[misc] ) """ All the 2D positions at which the point cloud shows points. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/points3d.py b/rerun_py/rerun_sdk/rerun/archetypes/points3d.py index 324e0f1337b0..ac719c1cdbeb 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/points3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/points3d.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["Points3D"] @@ -104,19 +105,41 @@ def __init__( """ # You can define your own __init__ function as a member of Points3DExt in points3d_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + positions=positions, + radii=radii, + colors=colors, + labels=labels, + class_ids=class_ids, + keypoint_ids=keypoint_ids, + instance_keys=instance_keys, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - positions=positions, - radii=radii, - colors=colors, - labels=labels, - class_ids=class_ids, - keypoint_ids=keypoint_ids, - instance_keys=instance_keys, + positions=None, # type: ignore[arg-type] + radii=None, # type: ignore[arg-type] + colors=None, # type: ignore[arg-type] + labels=None, # type: ignore[arg-type] + class_ids=None, # type: ignore[arg-type] + keypoint_ids=None, # type: ignore[arg-type] + instance_keys=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> Points3D: + """Produce an empty Points3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + positions: components.Position3DBatch = field( metadata={"component": "required"}, - converter=components.Position3DBatch, # type: ignore[misc] + converter=components.Position3DBatch._required, # type: ignore[misc] ) """ All the 3D positions at which the point cloud shows points. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image.py b/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image.py index cde92b6c29c6..0b4e50b81b49 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions from .segmentation_image_ext import SegmentationImageExt __all__ = ["SegmentationImage"] @@ -69,7 +70,24 @@ def __init__(self: Any, data: datatypes.TensorDataLike, *, draw_order: component """ # You can define your own __init__ function as a member of SegmentationImageExt in segmentation_image_ext.py - self.__attrs_init__(data=data, draw_order=draw_order) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(data=data, draw_order=draw_order) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + data=None, # type: ignore[arg-type] + draw_order=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> SegmentationImage: + """Produce an empty SegmentationImage, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst data: components.TensorDataBatch = field( metadata={"component": "required"}, diff --git a/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image_ext.py index 2519c7dfeca1..4ee458f8400d 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/segmentation_image_ext.py @@ -5,9 +5,8 @@ import numpy as np import pyarrow as pa -from rerun.error_utils import _send_warning - from .._validators import find_non_empty_dim_indices +from ..error_utils import _send_warning, catch_and_log_exceptions if TYPE_CHECKING: from ..components import TensorDataBatch @@ -16,6 +15,7 @@ class SegmentationImageExt: @staticmethod + @catch_and_log_exceptions("SegmentationImage converter") def data__field_converter_override(data: TensorDataArrayLike) -> TensorDataBatch: from ..components import TensorDataBatch from ..datatypes import TensorDataType, TensorDimensionType diff --git a/rerun_py/rerun_sdk/rerun/archetypes/tensor.py b/rerun_py/rerun_sdk/rerun/archetypes/tensor.py index 9783a1cba823..50dd66d239ab 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/tensor.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/tensor.py @@ -70,9 +70,22 @@ class Tensor(TensorExt, Archetype): # __init__ can be found in tensor_ext.py + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + data=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Tensor: + """Produce an empty Tensor, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + data: components.TensorDataBatch = field( metadata={"component": "required"}, - converter=components.TensorDataBatch, # type: ignore[misc] + converter=components.TensorDataBatch._required, # type: ignore[misc] ) """ The tensor data diff --git a/rerun_py/rerun_sdk/rerun/archetypes/tensor_ext.py b/rerun_py/rerun_sdk/rerun/archetypes/tensor_ext.py index 1019e50e7ad2..c5de87791fff 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/tensor_ext.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/tensor_ext.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any, Sequence +from ..error_utils import catch_and_log_exceptions + if TYPE_CHECKING: from ..datatypes import TensorDataLike from ..datatypes.tensor_data_ext import TensorLike @@ -36,9 +38,13 @@ def __init__( """ from ..datatypes import TensorData - if not isinstance(data, TensorData): - data = TensorData(array=data, dim_names=dim_names) - elif dim_names is not None: - data = TensorData(buffer=data.buffer, dim_names=dim_names) + with catch_and_log_exceptions(context=self.__class__.__name__): + if not isinstance(data, TensorData): + data = TensorData(array=data, dim_names=dim_names) + elif dim_names is not None: + data = TensorData(buffer=data.buffer, dim_names=dim_names) + + self.__attrs_init__(data=data) + return - self.__attrs_init__(data=data) + self.__attrs_clear__() diff --git a/rerun_py/rerun_sdk/rerun/archetypes/text_document.py b/rerun_py/rerun_sdk/rerun/archetypes/text_document.py index eba8e0f2d706..1b46177562f8 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/text_document.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/text_document.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["TextDocument"] @@ -38,11 +39,28 @@ def __init__(self: Any, text: datatypes.Utf8Like, *, media_type: datatypes.Utf8L """ # You can define your own __init__ function as a member of TextDocumentExt in text_document_ext.py - self.__attrs_init__(text=text, media_type=media_type) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(text=text, media_type=media_type) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + text=None, # type: ignore[arg-type] + media_type=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> TextDocument: + """Produce an empty TextDocument, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst text: components.TextBatch = field( metadata={"component": "required"}, - converter=components.TextBatch, # type: ignore[misc] + converter=components.TextBatch._required, # type: ignore[misc] ) """ Contents of the text document. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/text_log.py b/rerun_py/rerun_sdk/rerun/archetypes/text_log.py index a37bc4e87c40..8cedf37885df 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/text_log.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/text_log.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["TextLog"] @@ -56,11 +57,29 @@ def __init__( """Create a new instance of the TextLog archetype.""" # You can define your own __init__ function as a member of TextLogExt in text_log_ext.py - self.__attrs_init__(text=text, level=level, color=color) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(text=text, level=level, color=color) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + text=None, # type: ignore[arg-type] + level=None, # type: ignore[arg-type] + color=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> TextLog: + """Produce an empty TextLog, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst text: components.TextBatch = field( metadata={"component": "required"}, - converter=components.TextBatch, # type: ignore[misc] + converter=components.TextBatch._required, # type: ignore[misc] ) level: components.TextLogLevelBatch | None = field( metadata={"component": "optional"}, diff --git a/rerun_py/rerun_sdk/rerun/archetypes/time_series_scalar.py b/rerun_py/rerun_sdk/rerun/archetypes/time_series_scalar.py index bd8b9423ae7d..c429d5e514cb 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/time_series_scalar.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/time_series_scalar.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["TimeSeriesScalar"] @@ -117,11 +118,31 @@ def __init__( """ # You can define your own __init__ function as a member of TimeSeriesScalarExt in time_series_scalar_ext.py - self.__attrs_init__(scalar=scalar, radius=radius, color=color, label=label, scattered=scattered) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(scalar=scalar, radius=radius, color=color, label=label, scattered=scattered) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + scalar=None, # type: ignore[arg-type] + radius=None, # type: ignore[arg-type] + color=None, # type: ignore[arg-type] + label=None, # type: ignore[arg-type] + scattered=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> TimeSeriesScalar: + """Produce an empty TimeSeriesScalar, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst scalar: components.ScalarBatch = field( metadata={"component": "required"}, - converter=components.ScalarBatch, # type: ignore[misc] + converter=components.ScalarBatch._required, # type: ignore[misc] ) """ The scalar value to log. diff --git a/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py b/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py index 6a57d6b17935..7a35feb2cf02 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py @@ -11,6 +11,7 @@ from .. import components, datatypes from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions __all__ = ["Transform3D"] @@ -64,11 +65,27 @@ def __init__(self: Any, transform: datatypes.Transform3DLike): """ # You can define your own __init__ function as a member of Transform3DExt in transform3d_ext.py - self.__attrs_init__(transform=transform) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(transform=transform) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + transform=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> Transform3D: + """Produce an empty Transform3D, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst transform: components.Transform3DBatch = field( metadata={"component": "required"}, - converter=components.Transform3DBatch, # type: ignore[misc] + converter=components.Transform3DBatch._required, # type: ignore[misc] ) """ The transform diff --git a/rerun_py/rerun_sdk/rerun/archetypes/view_coordinates.py b/rerun_py/rerun_sdk/rerun/archetypes/view_coordinates.py index f0add00961cf..59eed8705575 100644 --- a/rerun_py/rerun_sdk/rerun/archetypes/view_coordinates.py +++ b/rerun_py/rerun_sdk/rerun/archetypes/view_coordinates.py @@ -11,6 +11,7 @@ from .. import components from .._baseclasses import Archetype +from ..error_utils import catch_and_log_exceptions from .view_coordinates_ext import ViewCoordinatesExt __all__ = ["ViewCoordinates"] @@ -51,11 +52,27 @@ def __init__(self: Any, xyz: components.ViewCoordinatesLike): """Create a new instance of the ViewCoordinates archetype.""" # You can define your own __init__ function as a member of ViewCoordinatesExt in view_coordinates_ext.py - self.__attrs_init__(xyz=xyz) + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__(xyz=xyz) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" + self.__attrs_init__( + xyz=None, # type: ignore[arg-type] + ) + + @classmethod + def _clear(cls) -> ViewCoordinates: + """Produce an empty ViewCoordinates, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst xyz: components.ViewCoordinatesBatch = field( metadata={"component": "required"}, - converter=components.ViewCoordinatesBatch, # type: ignore[misc] + converter=components.ViewCoordinatesBatch._required, # type: ignore[misc] ) __str__ = Archetype.__str__ __repr__ = Archetype.__repr__ diff --git a/rerun_py/rerun_sdk/rerun/error_utils.py b/rerun_py/rerun_sdk/rerun/error_utils.py index d350f498accc..183d28a7b8e7 100644 --- a/rerun_py/rerun_sdk/rerun/error_utils.py +++ b/rerun_py/rerun_sdk/rerun/error_utils.py @@ -1,15 +1,64 @@ from __future__ import annotations +import functools import inspect -import logging +import threading +import warnings +from types import TracebackType +from typing import Any, Callable, TypeVar, cast -import rerun -from rerun.recording_stream import RecordingStream +from .recording_stream import RecordingStream __all__ = [ "_send_warning", ] +_TFunc = TypeVar("_TFunc", bound=Callable[..., Any]) + +# If `True`, we raise exceptions on use error (wrong parameter types, etc.). +# If `False` we catch all errors and log a warning instead. +_strict_mode = False + +_rerun_exception_ctx = threading.local() + + +def strict_mode() -> bool: + """ + Strict mode enabled. + + In strict mode, incorrect use of the Rerun API (wrong parameter types etc.) + will result in exception being raised. + When strict mode is on, such problems are instead logged as warnings. + + The default is OFF. + """ + # If strict was set explicitly, we are in struct mode + if getattr(_rerun_exception_ctx, "strict_mode", None) is not None: + return _rerun_exception_ctx.strict_mode # type: ignore[no-any-return] + else: + return _strict_mode + + +def set_strict_mode(mode: bool) -> None: + """ + Turn strict mode on/off. + + In strict mode, incorrect use of the Rerun API (wrong parameter types etc.) + will result in exception being raised. + When strict mode is off, such problems are instead logged as warnings. + + The default is OFF. + """ + global _strict_mode + + _strict_mode = mode + + +class RerunWarning(Warning): + """A custom warning class that we use to identify warnings that are emitted by the Rerun SDK itself.""" + + pass + def _build_warning_context_string(skip_first: int) -> str: """Builds a string describing the user context of a warning.""" @@ -32,11 +81,120 @@ def _send_warning( from rerun._log import log from rerun.archetypes import TextLog - if rerun.strict_mode(): + if strict_mode(): raise TypeError(message) - context_descriptor = _build_warning_context_string(skip_first=depth_to_user_code + 2) - warning = f"{message}\n{context_descriptor}" + # Send the warning to the user first + warnings.warn(message, category=RerunWarning, stacklevel=depth_to_user_code + 1) + + # Logging the warning to Rerun is a complex operation could produce another warning. Avoid recursion. + if not getattr(_rerun_exception_ctx, "sending_warning", False): + _rerun_exception_ctx.sending_warning = True + + # TODO(jleibs): Context/stack should be its own component. + context_descriptor = _build_warning_context_string(skip_first=depth_to_user_code + 1) + + log("rerun", TextLog(text=f"{message}\n{context_descriptor}", level="WARN"), recording=recording) + _rerun_exception_ctx.sending_warning = False + else: + warnings.warn( + "Encountered Error while sending warning", category=RerunWarning, stacklevel=depth_to_user_code + 1 + ) + + +class catch_and_log_exceptions: + """ + A hybrid decorator / context-manager. + + We can add this to any function or scope where we want to catch and log + exceptions. + + Warnings are attached to a thread-local context, and are sent out when + we leave the outer-most context object. This gives us a warning that + points to the user call-site rather than somewhere buried in Rerun code. + + For functions, this decorator checks for a strict kwarg and uses it to + override the global strict mode if provided. + + Parameters + ---------- + context: + A string describing the context of the exception. + If not provided, the function name will be used. + depth_to_user_code: + The number of frames to skip when building the warning context. + This should be the number of frames between the user code and the + context manager. + exception_return_value: + If an exception is caught, this value will be returned instead of + the function's return value. + """ + + def __init__( + self, context: str | None = None, depth_to_user_code: int = 1, exception_return_value: Any = None + ) -> None: + self.depth_to_user_code = depth_to_user_code + self.context = context + self.exception_return_value = exception_return_value + + def __enter__(self) -> catch_and_log_exceptions: + # Track the original strict_mode setting in case it's being + # overridden locally in this stack + self.original_strict = getattr(_rerun_exception_ctx, "strict_mode", None) + if getattr(_rerun_exception_ctx, "depth", None) is None: + _rerun_exception_ctx.depth = 1 + else: + _rerun_exception_ctx.depth += 1 + + return self + + def __call__(self, func: _TFunc) -> _TFunc: + if self.context is None: + self.context = func.__qualname__ + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with self: + if "strict" in kwargs: + _rerun_exception_ctx.strict_mode = kwargs["strict"] + return func(*args, **kwargs) + + # If there was an exception before returning from func + return self.exception_return_value + + return cast(_TFunc, wrapper) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + try: + if exc_type is not None and not strict_mode(): + if getattr(_rerun_exception_ctx, "pending_warnings", None) is None: + _rerun_exception_ctx.pending_warnings = [] + + context = f"{self.context}: " if self.context is not None else "" + + warning_message = f"{context}{exc_type.__name__}({exc_val})" + + _rerun_exception_ctx.pending_warnings.append(warning_message) + return True + else: + return False + finally: + if getattr(_rerun_exception_ctx, "depth", None) is not None: + _rerun_exception_ctx.depth -= 1 + if _rerun_exception_ctx.depth == 0: + pending_warnings = getattr(_rerun_exception_ctx, "pending_warnings", []) + _rerun_exception_ctx.pending_warnings = [] + _rerun_exception_ctx.depth = None + + for warning in pending_warnings: + _send_warning(warning, depth_to_user_code=self.depth_to_user_code + 2) + + # If we're back to the top of the stack, send out the pending warnings - log("rerun", TextLog(warning, level="WARN"), recording=recording) - logging.warning(warning) + # Return the local context to the prior value + _rerun_exception_ctx.strict_mode = self.original_strict diff --git a/rerun_py/rerun_sdk/rerun/log_deprecated/log_decorator.py b/rerun_py/rerun_sdk/rerun/log_deprecated/log_decorator.py index 14f0c83132f8..7cd02db7ac45 100644 --- a/rerun_py/rerun_sdk/rerun/log_deprecated/log_decorator.py +++ b/rerun_py/rerun_sdk/rerun/log_deprecated/log_decorator.py @@ -5,12 +5,13 @@ import warnings from typing import Any, Callable, TypeVar, cast -import rerun from rerun import bindings from rerun._log import log from rerun.archetypes import TextLog from rerun.recording_stream import RecordingStream +from ..error_utils import strict_mode + _TFunc = TypeVar("_TFunc", bound=Callable[..., Any]) @@ -44,7 +45,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: ) return - if rerun.strict_mode(): + if strict_mode(): # Pass on any exceptions to the caller return func(*args, **kwargs) else: diff --git a/rerun_py/rerun_sdk/rerun/recording.py b/rerun_py/rerun_sdk/rerun/recording.py index acfa19addad6..9c1efd8d97d1 100644 --- a/rerun_py/rerun_sdk/rerun/recording.py +++ b/rerun_py/rerun_sdk/rerun/recording.py @@ -26,6 +26,14 @@ def reset_blueprint(self, *, add_to_app_default_blueprint: bool = False) -> None """Reset the blueprint in the MemoryRecording.""" self.storage.reset_blueprint(add_to_app_default_blueprint) + def num_msgs(self) -> int: + """ + The number of pending messages in the MemoryRecording. + + Note: counting the messages will flush the batcher in order to get a deterministic count. + """ + return self.storage.num_msgs() # type: ignore[no-any-return] + def as_html( self, *, diff --git a/rerun_py/src/python_bridge.rs b/rerun_py/src/python_bridge.rs index a49a28be03b2..5b6d29882baa 100644 --- a/rerun_py/src/python_bridge.rs +++ b/rerun_py/src/python_bridge.rs @@ -531,7 +531,9 @@ struct PyMemorySinkStorage { #[pymethods] impl PyMemorySinkStorage { - /// This will do a blocking flush before returning! + /// Concatenate the contents of the [`MemorySinkStorage`] as byes. + /// + /// Note: This will do a blocking flush before returning! fn concat_as_bytes<'p>( &self, concat: Option<&PyMemorySinkStorage>, @@ -552,6 +554,18 @@ impl PyMemorySinkStorage { .map(|bytes| PyBytes::new(py, bytes.as_slice())) .map_err(|err| PyRuntimeError::new_err(err.to_string())) } + + /// Count the number of pending messages in the [`MemorySinkStorage`]. + /// + /// This will do a blocking flush before returning! + fn num_msgs(&self, py: Python<'_>) -> usize { + // Release the GIL in case any flushing behavior needs to cleanup a python object. + py.allow_threads(|| { + self.rec.flush_blocking(); + }); + + self.inner.num_msgs() + } } #[cfg(feature = "web_viewer")] diff --git a/rerun_py/tests/test_types/archetypes/affix_fuzzer1.py b/rerun_py/tests/test_types/archetypes/affix_fuzzer1.py index f9c81b436ad0..c1cb1a0c7668 100644 --- a/rerun_py/tests/test_types/archetypes/affix_fuzzer1.py +++ b/rerun_py/tests/test_types/archetypes/affix_fuzzer1.py @@ -9,6 +9,7 @@ from attrs import define, field from rerun._baseclasses import Archetype +from rerun.error_utils import catch_and_log_exceptions from .. import components, datatypes @@ -44,113 +45,149 @@ def __init__( """Create a new instance of the AffixFuzzer1 archetype.""" # You can define your own __init__ function as a member of AffixFuzzer1Ext in affix_fuzzer1_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + fuzz1001=fuzz1001, + fuzz1002=fuzz1002, + fuzz1003=fuzz1003, + fuzz1004=fuzz1004, + fuzz1005=fuzz1005, + fuzz1006=fuzz1006, + fuzz1007=fuzz1007, + fuzz1008=fuzz1008, + fuzz1009=fuzz1009, + fuzz1010=fuzz1010, + fuzz1011=fuzz1011, + fuzz1012=fuzz1012, + fuzz1013=fuzz1013, + fuzz1014=fuzz1014, + fuzz1015=fuzz1015, + fuzz1016=fuzz1016, + fuzz1017=fuzz1017, + fuzz1018=fuzz1018, + fuzz1019=fuzz1019, + fuzz1020=fuzz1020, + fuzz1021=fuzz1021, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - fuzz1001=fuzz1001, - fuzz1002=fuzz1002, - fuzz1003=fuzz1003, - fuzz1004=fuzz1004, - fuzz1005=fuzz1005, - fuzz1006=fuzz1006, - fuzz1007=fuzz1007, - fuzz1008=fuzz1008, - fuzz1009=fuzz1009, - fuzz1010=fuzz1010, - fuzz1011=fuzz1011, - fuzz1012=fuzz1012, - fuzz1013=fuzz1013, - fuzz1014=fuzz1014, - fuzz1015=fuzz1015, - fuzz1016=fuzz1016, - fuzz1017=fuzz1017, - fuzz1018=fuzz1018, - fuzz1019=fuzz1019, - fuzz1020=fuzz1020, - fuzz1021=fuzz1021, + fuzz1001=None, # type: ignore[arg-type] + fuzz1002=None, # type: ignore[arg-type] + fuzz1003=None, # type: ignore[arg-type] + fuzz1004=None, # type: ignore[arg-type] + fuzz1005=None, # type: ignore[arg-type] + fuzz1006=None, # type: ignore[arg-type] + fuzz1007=None, # type: ignore[arg-type] + fuzz1008=None, # type: ignore[arg-type] + fuzz1009=None, # type: ignore[arg-type] + fuzz1010=None, # type: ignore[arg-type] + fuzz1011=None, # type: ignore[arg-type] + fuzz1012=None, # type: ignore[arg-type] + fuzz1013=None, # type: ignore[arg-type] + fuzz1014=None, # type: ignore[arg-type] + fuzz1015=None, # type: ignore[arg-type] + fuzz1016=None, # type: ignore[arg-type] + fuzz1017=None, # type: ignore[arg-type] + fuzz1018=None, # type: ignore[arg-type] + fuzz1019=None, # type: ignore[arg-type] + fuzz1020=None, # type: ignore[arg-type] + fuzz1021=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> AffixFuzzer1: + """Produce an empty AffixFuzzer1, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + fuzz1001: components.AffixFuzzer1Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer1Batch, # type: ignore[misc] + converter=components.AffixFuzzer1Batch._required, # type: ignore[misc] ) fuzz1002: components.AffixFuzzer2Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer2Batch, # type: ignore[misc] + converter=components.AffixFuzzer2Batch._required, # type: ignore[misc] ) fuzz1003: components.AffixFuzzer3Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer3Batch, # type: ignore[misc] + converter=components.AffixFuzzer3Batch._required, # type: ignore[misc] ) fuzz1004: components.AffixFuzzer4Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer4Batch, # type: ignore[misc] + converter=components.AffixFuzzer4Batch._required, # type: ignore[misc] ) fuzz1005: components.AffixFuzzer5Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer5Batch, # type: ignore[misc] + converter=components.AffixFuzzer5Batch._required, # type: ignore[misc] ) fuzz1006: components.AffixFuzzer6Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer6Batch, # type: ignore[misc] + converter=components.AffixFuzzer6Batch._required, # type: ignore[misc] ) fuzz1007: components.AffixFuzzer7Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer7Batch, # type: ignore[misc] + converter=components.AffixFuzzer7Batch._required, # type: ignore[misc] ) fuzz1008: components.AffixFuzzer8Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer8Batch, # type: ignore[misc] + converter=components.AffixFuzzer8Batch._required, # type: ignore[misc] ) fuzz1009: components.AffixFuzzer9Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer9Batch, # type: ignore[misc] + converter=components.AffixFuzzer9Batch._required, # type: ignore[misc] ) fuzz1010: components.AffixFuzzer10Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer10Batch, # type: ignore[misc] + converter=components.AffixFuzzer10Batch._required, # type: ignore[misc] ) fuzz1011: components.AffixFuzzer11Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer11Batch, # type: ignore[misc] + converter=components.AffixFuzzer11Batch._required, # type: ignore[misc] ) fuzz1012: components.AffixFuzzer12Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer12Batch, # type: ignore[misc] + converter=components.AffixFuzzer12Batch._required, # type: ignore[misc] ) fuzz1013: components.AffixFuzzer13Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer13Batch, # type: ignore[misc] + converter=components.AffixFuzzer13Batch._required, # type: ignore[misc] ) fuzz1014: components.AffixFuzzer14Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer14Batch, # type: ignore[misc] + converter=components.AffixFuzzer14Batch._required, # type: ignore[misc] ) fuzz1015: components.AffixFuzzer15Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer15Batch, # type: ignore[misc] + converter=components.AffixFuzzer15Batch._required, # type: ignore[misc] ) fuzz1016: components.AffixFuzzer16Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer16Batch, # type: ignore[misc] + converter=components.AffixFuzzer16Batch._required, # type: ignore[misc] ) fuzz1017: components.AffixFuzzer17Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer17Batch, # type: ignore[misc] + converter=components.AffixFuzzer17Batch._required, # type: ignore[misc] ) fuzz1018: components.AffixFuzzer18Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer18Batch, # type: ignore[misc] + converter=components.AffixFuzzer18Batch._required, # type: ignore[misc] ) fuzz1019: components.AffixFuzzer19Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer19Batch, # type: ignore[misc] + converter=components.AffixFuzzer19Batch._required, # type: ignore[misc] ) fuzz1020: components.AffixFuzzer20Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer20Batch, # type: ignore[misc] + converter=components.AffixFuzzer20Batch._required, # type: ignore[misc] ) fuzz1021: components.AffixFuzzer21Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer21Batch, # type: ignore[misc] + converter=components.AffixFuzzer21Batch._required, # type: ignore[misc] ) __str__ = Archetype.__str__ __repr__ = Archetype.__repr__ diff --git a/rerun_py/tests/test_types/archetypes/affix_fuzzer2.py b/rerun_py/tests/test_types/archetypes/affix_fuzzer2.py index 5ade70ad6ff7..60189b03fa7c 100644 --- a/rerun_py/tests/test_types/archetypes/affix_fuzzer2.py +++ b/rerun_py/tests/test_types/archetypes/affix_fuzzer2.py @@ -9,6 +9,7 @@ from attrs import define, field from rerun._baseclasses import Archetype +from rerun.error_utils import catch_and_log_exceptions from .. import components, datatypes @@ -41,98 +42,131 @@ def __init__( """Create a new instance of the AffixFuzzer2 archetype.""" # You can define your own __init__ function as a member of AffixFuzzer2Ext in affix_fuzzer2_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + fuzz1101=fuzz1101, + fuzz1102=fuzz1102, + fuzz1103=fuzz1103, + fuzz1104=fuzz1104, + fuzz1105=fuzz1105, + fuzz1106=fuzz1106, + fuzz1107=fuzz1107, + fuzz1108=fuzz1108, + fuzz1109=fuzz1109, + fuzz1110=fuzz1110, + fuzz1111=fuzz1111, + fuzz1112=fuzz1112, + fuzz1113=fuzz1113, + fuzz1114=fuzz1114, + fuzz1115=fuzz1115, + fuzz1116=fuzz1116, + fuzz1117=fuzz1117, + fuzz1118=fuzz1118, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - fuzz1101=fuzz1101, - fuzz1102=fuzz1102, - fuzz1103=fuzz1103, - fuzz1104=fuzz1104, - fuzz1105=fuzz1105, - fuzz1106=fuzz1106, - fuzz1107=fuzz1107, - fuzz1108=fuzz1108, - fuzz1109=fuzz1109, - fuzz1110=fuzz1110, - fuzz1111=fuzz1111, - fuzz1112=fuzz1112, - fuzz1113=fuzz1113, - fuzz1114=fuzz1114, - fuzz1115=fuzz1115, - fuzz1116=fuzz1116, - fuzz1117=fuzz1117, - fuzz1118=fuzz1118, + fuzz1101=None, # type: ignore[arg-type] + fuzz1102=None, # type: ignore[arg-type] + fuzz1103=None, # type: ignore[arg-type] + fuzz1104=None, # type: ignore[arg-type] + fuzz1105=None, # type: ignore[arg-type] + fuzz1106=None, # type: ignore[arg-type] + fuzz1107=None, # type: ignore[arg-type] + fuzz1108=None, # type: ignore[arg-type] + fuzz1109=None, # type: ignore[arg-type] + fuzz1110=None, # type: ignore[arg-type] + fuzz1111=None, # type: ignore[arg-type] + fuzz1112=None, # type: ignore[arg-type] + fuzz1113=None, # type: ignore[arg-type] + fuzz1114=None, # type: ignore[arg-type] + fuzz1115=None, # type: ignore[arg-type] + fuzz1116=None, # type: ignore[arg-type] + fuzz1117=None, # type: ignore[arg-type] + fuzz1118=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> AffixFuzzer2: + """Produce an empty AffixFuzzer2, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + fuzz1101: components.AffixFuzzer1Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer1Batch, # type: ignore[misc] + converter=components.AffixFuzzer1Batch._required, # type: ignore[misc] ) fuzz1102: components.AffixFuzzer2Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer2Batch, # type: ignore[misc] + converter=components.AffixFuzzer2Batch._required, # type: ignore[misc] ) fuzz1103: components.AffixFuzzer3Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer3Batch, # type: ignore[misc] + converter=components.AffixFuzzer3Batch._required, # type: ignore[misc] ) fuzz1104: components.AffixFuzzer4Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer4Batch, # type: ignore[misc] + converter=components.AffixFuzzer4Batch._required, # type: ignore[misc] ) fuzz1105: components.AffixFuzzer5Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer5Batch, # type: ignore[misc] + converter=components.AffixFuzzer5Batch._required, # type: ignore[misc] ) fuzz1106: components.AffixFuzzer6Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer6Batch, # type: ignore[misc] + converter=components.AffixFuzzer6Batch._required, # type: ignore[misc] ) fuzz1107: components.AffixFuzzer7Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer7Batch, # type: ignore[misc] + converter=components.AffixFuzzer7Batch._required, # type: ignore[misc] ) fuzz1108: components.AffixFuzzer8Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer8Batch, # type: ignore[misc] + converter=components.AffixFuzzer8Batch._required, # type: ignore[misc] ) fuzz1109: components.AffixFuzzer9Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer9Batch, # type: ignore[misc] + converter=components.AffixFuzzer9Batch._required, # type: ignore[misc] ) fuzz1110: components.AffixFuzzer10Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer10Batch, # type: ignore[misc] + converter=components.AffixFuzzer10Batch._required, # type: ignore[misc] ) fuzz1111: components.AffixFuzzer11Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer11Batch, # type: ignore[misc] + converter=components.AffixFuzzer11Batch._required, # type: ignore[misc] ) fuzz1112: components.AffixFuzzer12Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer12Batch, # type: ignore[misc] + converter=components.AffixFuzzer12Batch._required, # type: ignore[misc] ) fuzz1113: components.AffixFuzzer13Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer13Batch, # type: ignore[misc] + converter=components.AffixFuzzer13Batch._required, # type: ignore[misc] ) fuzz1114: components.AffixFuzzer14Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer14Batch, # type: ignore[misc] + converter=components.AffixFuzzer14Batch._required, # type: ignore[misc] ) fuzz1115: components.AffixFuzzer15Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer15Batch, # type: ignore[misc] + converter=components.AffixFuzzer15Batch._required, # type: ignore[misc] ) fuzz1116: components.AffixFuzzer16Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer16Batch, # type: ignore[misc] + converter=components.AffixFuzzer16Batch._required, # type: ignore[misc] ) fuzz1117: components.AffixFuzzer17Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer17Batch, # type: ignore[misc] + converter=components.AffixFuzzer17Batch._required, # type: ignore[misc] ) fuzz1118: components.AffixFuzzer18Batch = field( metadata={"component": "required"}, - converter=components.AffixFuzzer18Batch, # type: ignore[misc] + converter=components.AffixFuzzer18Batch._required, # type: ignore[misc] ) __str__ = Archetype.__str__ __repr__ = Archetype.__repr__ diff --git a/rerun_py/tests/test_types/archetypes/affix_fuzzer3.py b/rerun_py/tests/test_types/archetypes/affix_fuzzer3.py index b3170480b7c2..f876f4006a17 100644 --- a/rerun_py/tests/test_types/archetypes/affix_fuzzer3.py +++ b/rerun_py/tests/test_types/archetypes/affix_fuzzer3.py @@ -9,6 +9,7 @@ from attrs import define, field from rerun._baseclasses import Archetype +from rerun.error_utils import catch_and_log_exceptions from .. import components, datatypes @@ -42,27 +43,60 @@ def __init__( """Create a new instance of the AffixFuzzer3 archetype.""" # You can define your own __init__ function as a member of AffixFuzzer3Ext in affix_fuzzer3_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + fuzz2001=fuzz2001, + fuzz2002=fuzz2002, + fuzz2003=fuzz2003, + fuzz2004=fuzz2004, + fuzz2005=fuzz2005, + fuzz2006=fuzz2006, + fuzz2007=fuzz2007, + fuzz2008=fuzz2008, + fuzz2009=fuzz2009, + fuzz2010=fuzz2010, + fuzz2011=fuzz2011, + fuzz2012=fuzz2012, + fuzz2013=fuzz2013, + fuzz2014=fuzz2014, + fuzz2015=fuzz2015, + fuzz2016=fuzz2016, + fuzz2017=fuzz2017, + fuzz2018=fuzz2018, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - fuzz2001=fuzz2001, - fuzz2002=fuzz2002, - fuzz2003=fuzz2003, - fuzz2004=fuzz2004, - fuzz2005=fuzz2005, - fuzz2006=fuzz2006, - fuzz2007=fuzz2007, - fuzz2008=fuzz2008, - fuzz2009=fuzz2009, - fuzz2010=fuzz2010, - fuzz2011=fuzz2011, - fuzz2012=fuzz2012, - fuzz2013=fuzz2013, - fuzz2014=fuzz2014, - fuzz2015=fuzz2015, - fuzz2016=fuzz2016, - fuzz2017=fuzz2017, - fuzz2018=fuzz2018, + fuzz2001=None, # type: ignore[arg-type] + fuzz2002=None, # type: ignore[arg-type] + fuzz2003=None, # type: ignore[arg-type] + fuzz2004=None, # type: ignore[arg-type] + fuzz2005=None, # type: ignore[arg-type] + fuzz2006=None, # type: ignore[arg-type] + fuzz2007=None, # type: ignore[arg-type] + fuzz2008=None, # type: ignore[arg-type] + fuzz2009=None, # type: ignore[arg-type] + fuzz2010=None, # type: ignore[arg-type] + fuzz2011=None, # type: ignore[arg-type] + fuzz2012=None, # type: ignore[arg-type] + fuzz2013=None, # type: ignore[arg-type] + fuzz2014=None, # type: ignore[arg-type] + fuzz2015=None, # type: ignore[arg-type] + fuzz2016=None, # type: ignore[arg-type] + fuzz2017=None, # type: ignore[arg-type] + fuzz2018=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> AffixFuzzer3: + """Produce an empty AffixFuzzer3, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + fuzz2001: components.AffixFuzzer1Batch | None = field( metadata={"component": "optional"}, default=None, diff --git a/rerun_py/tests/test_types/archetypes/affix_fuzzer4.py b/rerun_py/tests/test_types/archetypes/affix_fuzzer4.py index 7006598163a0..882ed32710d3 100644 --- a/rerun_py/tests/test_types/archetypes/affix_fuzzer4.py +++ b/rerun_py/tests/test_types/archetypes/affix_fuzzer4.py @@ -9,6 +9,7 @@ from attrs import define, field from rerun._baseclasses import Archetype +from rerun.error_utils import catch_and_log_exceptions from .. import components, datatypes @@ -42,27 +43,60 @@ def __init__( """Create a new instance of the AffixFuzzer4 archetype.""" # You can define your own __init__ function as a member of AffixFuzzer4Ext in affix_fuzzer4_ext.py + with catch_and_log_exceptions(context=self.__class__.__name__): + self.__attrs_init__( + fuzz2101=fuzz2101, + fuzz2102=fuzz2102, + fuzz2103=fuzz2103, + fuzz2104=fuzz2104, + fuzz2105=fuzz2105, + fuzz2106=fuzz2106, + fuzz2107=fuzz2107, + fuzz2108=fuzz2108, + fuzz2109=fuzz2109, + fuzz2110=fuzz2110, + fuzz2111=fuzz2111, + fuzz2112=fuzz2112, + fuzz2113=fuzz2113, + fuzz2114=fuzz2114, + fuzz2115=fuzz2115, + fuzz2116=fuzz2116, + fuzz2117=fuzz2117, + fuzz2118=fuzz2118, + ) + return + self.__attrs_clear__() + + def __attrs_clear__(self) -> None: + """Convenience method for calling `__attrs_init__` with all `None`s.""" self.__attrs_init__( - fuzz2101=fuzz2101, - fuzz2102=fuzz2102, - fuzz2103=fuzz2103, - fuzz2104=fuzz2104, - fuzz2105=fuzz2105, - fuzz2106=fuzz2106, - fuzz2107=fuzz2107, - fuzz2108=fuzz2108, - fuzz2109=fuzz2109, - fuzz2110=fuzz2110, - fuzz2111=fuzz2111, - fuzz2112=fuzz2112, - fuzz2113=fuzz2113, - fuzz2114=fuzz2114, - fuzz2115=fuzz2115, - fuzz2116=fuzz2116, - fuzz2117=fuzz2117, - fuzz2118=fuzz2118, + fuzz2101=None, # type: ignore[arg-type] + fuzz2102=None, # type: ignore[arg-type] + fuzz2103=None, # type: ignore[arg-type] + fuzz2104=None, # type: ignore[arg-type] + fuzz2105=None, # type: ignore[arg-type] + fuzz2106=None, # type: ignore[arg-type] + fuzz2107=None, # type: ignore[arg-type] + fuzz2108=None, # type: ignore[arg-type] + fuzz2109=None, # type: ignore[arg-type] + fuzz2110=None, # type: ignore[arg-type] + fuzz2111=None, # type: ignore[arg-type] + fuzz2112=None, # type: ignore[arg-type] + fuzz2113=None, # type: ignore[arg-type] + fuzz2114=None, # type: ignore[arg-type] + fuzz2115=None, # type: ignore[arg-type] + fuzz2116=None, # type: ignore[arg-type] + fuzz2117=None, # type: ignore[arg-type] + fuzz2118=None, # type: ignore[arg-type] ) + @classmethod + def _clear(cls) -> AffixFuzzer4: + """Produce an empty AffixFuzzer4, bypassing `__init__`.""" + inst = cls.__new__(cls) + inst.__attrs_clear__() + return inst + fuzz2101: components.AffixFuzzer1Batch | None = field( metadata={"component": "optional"}, default=None, diff --git a/rerun_py/tests/unit/test_exceptions.py b/rerun_py/tests/unit/test_exceptions.py new file mode 100644 index 000000000000..f49d84e6f555 --- /dev/null +++ b/rerun_py/tests/unit/test_exceptions.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import inspect +import os +import sys +from typing import Any + +import pytest +import rerun as rr +from rerun.error_utils import RerunWarning, catch_and_log_exceptions + + +@catch_and_log_exceptions() +def outer(strict: bool | None = None) -> int: + """Calls an inner decorated function.""" + inner(3) + + return 42 + + +@catch_and_log_exceptions() +def two_calls(strict: bool | None = None) -> None: + """Calls an inner decorated function twice.""" + inner(3) + inner(3) + + +@catch_and_log_exceptions(context="function context") +def inner(count: int) -> None: + """Calls itself recursively but ultimately raises an error.""" + if count < 0: + raise ValueError("some value error") + inner(count - 1) + + +@catch_and_log_exceptions() +def uses_context(strict: bool | None = None) -> None: + """Uses a context manager instead of a function.""" + with catch_and_log_exceptions("inner context"): + raise ValueError("some value error") + + +def get_line_number() -> int: + """Helper to get a line-number. Make sure our warnings point to the right place.""" + frame = inspect.currentframe().f_back # type: ignore[union-attr] + return frame.f_lineno # type: ignore[union-attr] + + +def expected_warnings(warnings: Any, mem: Any, starting_msgs: int, count: int, expected_line: int) -> None: + for w in warnings: + assert w.lineno == expected_line + assert w.filename == __file__ + assert "some value error" in str(w.message) + + +def test_stack_tracking() -> None: + # Force flushing so we can count the messages + os.environ["RERUN_FLUSH_NUM_ROWS"] = "0" + rr.init("test_enable_strict_mode", spawn=False) + + mem = rr.memory_recording() + with pytest.warns(RerunWarning) as warnings: + starting_msgs = mem.num_msgs() + + assert outer() == 42 + + expected_warnings(warnings, mem, starting_msgs, 1, get_line_number() - 2) + assert "function context" in str(warnings[0].message) + + with pytest.warns(RerunWarning) as warnings: + starting_msgs = mem.num_msgs() + + two_calls() + + expected_warnings(warnings, mem, starting_msgs, 2, get_line_number() - 2) + + with pytest.warns(RerunWarning) as warnings: + starting_msgs = mem.num_msgs() + + uses_context() + + expected_warnings(warnings, mem, starting_msgs, 1, get_line_number() - 2) + + with pytest.warns(RerunWarning) as warnings: + starting_msgs = mem.num_msgs() + + value = 0 + with catch_and_log_exceptions(depth_to_user_code=0): + uses_context() + value = 42 + + if sys.version_info < (3, 10): + expected_line = get_line_number() - 3 # the last line of the context block + else: + expected_line = get_line_number() - 7 # the first line of the context block + expected_warnings(warnings, mem, starting_msgs, 1, expected_line) + # value is changed because uses_context its own exception internally + assert value == 42 + assert "inner context" in str(warnings[0].message) + + with pytest.warns(RerunWarning) as warnings: + starting_msgs = mem.num_msgs() + + value = 0 + with catch_and_log_exceptions("some context", depth_to_user_code=0): + raise ValueError("some value error") + value = 42 + + if sys.version_info < (3, 10): + expected_line = get_line_number() - 3 # the last line of the context block + else: + expected_line = get_line_number() - 7 # the open of the context manager + expected_warnings(warnings, mem, starting_msgs, 1, expected_line) + # value wasn't changed because an exception was raised + assert value == 0 + assert "some context" in str(warnings[0].message) + + +def test_strict_mode() -> None: + # We can disable strict on just this function + with pytest.raises(ValueError): + outer(strict=True) + + with pytest.raises(ValueError): + uses_context(strict=True) + + # We can disable strict mode globally + rr.set_strict_mode(True) + with pytest.raises(ValueError): + outer() + # Clear the global strict mode again + rr.set_strict_mode(False) + + +def test_bad_components() -> None: + with pytest.warns(RerunWarning) as warnings: + points = rr.Points3D(positions=[1, 2, 3], colors="RED") + assert len(warnings) == 1 + assert len(points.positions) == 1 + assert len(points.colors) == 0 # type: ignore[arg-type] + + rr.set_strict_mode(True) + with pytest.raises(ValueError): + points = rr.Points3D(positions=[1, 2, 3], colors="RED") diff --git a/rerun_py/tests/unit/test_expected_warnings.py b/rerun_py/tests/unit/test_expected_warnings.py new file mode 100644 index 000000000000..66d266682502 --- /dev/null +++ b/rerun_py/tests/unit/test_expected_warnings.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import pytest +import rerun as rr +from rerun.error_utils import RerunWarning + +rr.init("exceptions", spawn=False) +# Make sure strict mode isn't leaking in from another context +mem = rr.memory_recording() + + +def test_expected_warnings() -> None: + # Always set strict mode to false in case it leaked from another test + rr.set_strict_mode(False) + with pytest.warns(RerunWarning) as warnings: + # Each of these calls will fail as they are executed and aggregate warnings into the single warnings recording. + # We then check that all the warnings were emitted in the expected order. + # If a log-call is expected to produce multiple warnings, add another tuple to the list that doesn't produce a warning, + # or else the offsets will get out of alignment. + expected_warnings = [ + ( + rr.log("points", rr.Points3D([1, 2, 3, 4, 5])), + "Expected either a flat array with a length multiple of 3 elements, or an array with shape (`num_elements`, 3). Shape of passed array was (5,).", + ), + ( + rr.log("points", rr.Points2D([1, 2, 3, 4, 5])), + "Expected either a flat array with a length multiple of 2 elements, or an array with shape (`num_elements`, 2). Shape of passed array was (5,).", + ), + ] + + assert len(warnings) == len(expected_warnings) + for warning, (_, expected) in zip(warnings, expected_warnings): + assert expected in str(warning)