diff --git a/crates/re_log_types/src/path/mod.rs b/crates/re_log_types/src/path/mod.rs index 166e8413996f..0284354ca24b 100644 --- a/crates/re_log_types/src/path/mod.rs +++ b/crates/re_log_types/src/path/mod.rs @@ -25,7 +25,7 @@ pub use parse_path::PathParseError; /// Build a `Vec`: /// ``` /// # use re_log_types::*; -/// let parts: Vec = entity_path_vec!("foo", "bar"); +/// let parts: Vec = entity_path_vec!("foo", 42, "my image!"); /// ``` #[macro_export] macro_rules! entity_path_vec { @@ -34,7 +34,10 @@ macro_rules! entity_path_vec { ::std::vec::Vec::<$crate::EntityPathPart>::new() }; ($($part: expr),* $(,)?) => { - vec![ $($crate::EntityPathPart::from($part),)+ ] + vec![ $($crate::EntityPathPart::from( + #[allow(clippy::str_to_string, clippy::string_to_string)] + $part.to_string() + ),)+ ] }; } @@ -42,8 +45,8 @@ macro_rules! entity_path_vec { /// /// ``` /// # use re_log_types::*; -/// let path: EntityPath = entity_path!("world", "my image!"); -/// assert_eq!(path, EntityPath::parse_strict(r"world/my\ image\!").unwrap()); +/// let path: EntityPath = entity_path!("world", 42, "my image!"); +/// assert_eq!(path, EntityPath::parse_strict(r"world/42/my\ image\!").unwrap()); /// ``` #[macro_export] macro_rules! entity_path { diff --git a/crates/re_sdk/src/lib.rs b/crates/re_sdk/src/lib.rs index ab677ba59510..4010d71b00cb 100644 --- a/crates/re_sdk/src/lib.rs +++ b/crates/re_sdk/src/lib.rs @@ -31,7 +31,9 @@ pub use self::recording_stream::{ pub use re_sdk_comms::{default_flush_timeout, default_server_addr}; -pub use re_log_types::{entity_path, ApplicationId, EntityPath, StoreId, StoreKind}; +pub use re_log_types::{ + entity_path, ApplicationId, EntityPath, EntityPathPart, StoreId, StoreKind, +}; pub use global::cleanup_if_forked_child; diff --git a/crates/re_sdk/src/recording_stream.rs b/crates/re_sdk/src/recording_stream.rs index e8ed47a6c568..4e1c4743d4ab 100644 --- a/crates/re_sdk/src/recording_stream.rs +++ b/crates/re_sdk/src/recording_stream.rs @@ -719,16 +719,17 @@ impl RecordingStream { /// The data will be timestamped automatically based on the [`RecordingStream`]'s internal clock. /// See [`RecordingStream::set_time_sequence`] etc for more information. /// + /// The entity path can either be a string + /// (with special characters escaped, split on unescaped slashes) + /// or an [`EntityPath`] constructed with [`crate::entity_path`]. + /// See for more on entity paths. + /// /// See also: [`Self::log_timeless`] for logging timeless data. /// /// Internally, the stream will automatically micro-batch multiple log calls to optimize /// transport. /// See [SDK Micro Batching] for more information. /// - /// The entity path can either be a string - /// (with special characters escaped, split on unescaped slashes) - /// or an [`EntityPath`] constructed with [`crate::entity_path`]. - /// /// # Example: /// ```ignore /// # use rerun; @@ -791,6 +792,11 @@ impl RecordingStream { /// internal clock. /// See `RecordingStream::set_time_*` family of methods for more information. /// + /// The entity path can either be a string + /// (with special characters escaped, split on unescaped slashes) + /// or an [`EntityPath`] constructed with [`crate::entity_path`]. + /// See for more on entity paths. + /// /// Internally, the stream will automatically micro-batch multiple log calls to optimize /// transport. /// See [SDK Micro Batching] for more information. @@ -830,6 +836,11 @@ impl RecordingStream { /// All of the batches should have the same number of instances, or length 1 if the component is /// a splat, or 0 if the component is being cleared. /// + /// The entity path can either be a string + /// (with special characters escaped, split on unescaped slashes) + /// or an [`EntityPath`] constructed with [`crate::entity_path`]. + /// See for more on entity paths. + /// /// Internally, the stream will automatically micro-batch multiple log calls to optimize /// transport. /// See [SDK Micro Batching] for more information. diff --git a/crates/rerun_c/src/lib.rs b/crates/rerun_c/src/lib.rs index fcbc4b9e9734..124120841ad8 100644 --- a/crates/rerun_c/src/lib.rs +++ b/crates/rerun_c/src/lib.rs @@ -703,6 +703,39 @@ pub unsafe extern "C" fn rr_recording_stream_log( } } +// ---------------------------------------------------------------------------- +// Private functions + +#[allow(unsafe_code)] +#[no_mangle] +pub unsafe extern "C" fn _rr_escape_entity_path_part(part: CStringView) -> *const c_char { + let Ok(part) = part.as_str("entity_path_part") else { + return std::ptr::null(); + }; + + let part = re_sdk::EntityPathPart::from(part).to_string(); // escape the part + + let Ok(part) = CString::new(part) else { + return std::ptr::null(); + }; + + part.into_raw() +} + +#[allow(unsafe_code)] +#[no_mangle] +pub unsafe extern "C" fn _rr_free_string(str: *mut c_char) { + if str.is_null() { + return; + } + + // Free the string: + unsafe { + // SAFETY: `_rr_free_string` should only be called on strings allocated by `_rr_escape_entity_path_part`. + let _ = CString::from_raw(str); + } +} + // ---------------------------------------------------------------------------- // Helper functions: diff --git a/crates/rerun_c/src/rerun.h b/crates/rerun_c/src/rerun.h index 34f72b1d3840..b1c3a9c4d586 100644 --- a/crates/rerun_c/src/rerun.h +++ b/crates/rerun_c/src/rerun.h @@ -412,6 +412,23 @@ extern void rr_recording_stream_log( rr_recording_stream stream, rr_data_row data_row, bool inject_time, rr_error* error ); +// ---------------------------------------------------------------------------- +// Private functions + +/// PRIVATE FUNCTION: do not use. +/// +/// Escape a single part of an entity path, returning an new null-terminated string. +/// +/// The returned string must be freed with `_rr_free_string`. +/// +/// Returns `nullptr` on failure (e.g. invalid UTF8, ore null bytes in the string). +extern char* _rr_escape_entity_path_part(rr_string part); + +/// PRIVATE FUNCTION: do not use. +/// +/// Must only be called with the results from `_rr_escape_entity_path_part`. +extern void _rr_free_string(char* string); + // ---------------------------------------------------------------------------- #ifdef __cplusplus diff --git a/docs/code-examples/Cargo.toml b/docs/code-examples/Cargo.toml index 01ec3942f236..5307d953b889 100644 --- a/docs/code-examples/Cargo.toml +++ b/docs/code-examples/Cargo.toml @@ -71,6 +71,10 @@ path = "depth_image_simple.rs" name = "disconnected_space" path = "disconnected_space.rs" +[[bin]] +name = "entity_path" +path = "entity_path.rs" + [[bin]] name = "image_simple" path = "image_simple.rs" diff --git a/docs/code-examples/entity_path.cpp b/docs/code-examples/entity_path.cpp new file mode 100644 index 000000000000..c16377e5d5b9 --- /dev/null +++ b/docs/code-examples/entity_path.cpp @@ -0,0 +1,20 @@ +// Log a `TextDocument` + +#include + +int main() { + const auto rec = rerun::RecordingStream("rerun_example_text_document"); + rec.spawn().exit_on_failure(); + + rec.log( + R"(world/42/escaped\ string\!)", + rerun::TextDocument("This entity path was escaped manually") + ); + rec.log( + rerun::new_entity_path({"world", std::to_string(42), "unescaped string!"}), + rerun::TextDocument("This entity path was provided as a list of unescaped strings") + ); + + assert(rerun::escape_entity_path_part("my string!") == R"(my\ string\!)"); + assert(rerun::new_entity_path({"world", "42", "my string!"}) == R"(/world/42/my\ string\!)"); +} diff --git a/docs/code-examples/entity_path.py b/docs/code-examples/entity_path.py new file mode 100644 index 000000000000..233329ef45d3 --- /dev/null +++ b/docs/code-examples/entity_path.py @@ -0,0 +1,11 @@ +import rerun as rr + +rr.init("rerun_example_entity_path", spawn=True) + +rr.log(r"world/42/escaped\ string\!", rr.TextDocument("This entity path was escaped manually")) +rr.log( + ["world", 42, "unescaped string!"], rr.TextDocument("This entity path was provided as a list of unescaped strings") +) + +assert rr.escape_entity_path_part("my string!") == r"my\ string\!" +assert rr.new_entity_path(["world", 42, "my string!"]) == r"/world/42/my\ string\!" diff --git a/docs/code-examples/entity_path.rs b/docs/code-examples/entity_path.rs new file mode 100644 index 000000000000..49bde42e2bc3 --- /dev/null +++ b/docs/code-examples/entity_path.rs @@ -0,0 +1,16 @@ +//! Example of different ways of constructing an entity path. + +fn main() -> Result<(), Box> { + let rec = rerun::RecordingStreamBuilder::new("rerun_example_text_document").spawn()?; + + rec.log( + r"world/42/escaped\ string\!", + &rerun::TextDocument::new("This entity path was escaped manually"), + )?; + rec.log( + rerun::entity_path!["world", 42, "unescaped string!"], + &rerun::TextDocument::new("This entity path was provided as a list of unescaped strings"), + )?; + + Ok(()) +} diff --git a/rerun_cpp/src/rerun.hpp b/rerun_cpp/src/rerun.hpp index 7ebd2635ffbc..abd7a6fc091d 100644 --- a/rerun_cpp/src/rerun.hpp +++ b/rerun_cpp/src/rerun.hpp @@ -10,6 +10,7 @@ #include "rerun/collection_adapter.hpp" #include "rerun/collection_adapter_builtins.hpp" #include "rerun/config.hpp" +#include "rerun/entity_path.hpp" #include "rerun/error.hpp" #include "rerun/recording_stream.hpp" #include "rerun/result.hpp" diff --git a/rerun_cpp/src/rerun/c/rerun.h b/rerun_cpp/src/rerun/c/rerun.h index 93a072b9c3df..51d8aa79aeea 100644 --- a/rerun_cpp/src/rerun/c/rerun.h +++ b/rerun_cpp/src/rerun/c/rerun.h @@ -412,6 +412,23 @@ extern void rr_recording_stream_log( rr_recording_stream stream, rr_data_row data_row, bool inject_time, rr_error* error ); +// ---------------------------------------------------------------------------- +// Private functions + +/// PRIVATE FUNCTION: do not use. +/// +/// Escape a single part of an entity path, returning an new null-terminated string. +/// +/// The returned string must be freed with `_rr_free_string`. +/// +/// Returns `nullptr` on failure (e.g. invalid UTF8, ore null bytes in the string). +extern char* _rr_escape_entity_path_part(rr_string part); + +/// PRIVATE FUNCTION: do not use. +/// +/// Must only be called with the results from `_rr_escape_entity_path_part`. +extern void _rr_free_string(char* string); + // ---------------------------------------------------------------------------- #ifdef __cplusplus diff --git a/rerun_cpp/src/rerun/entity_path.cpp b/rerun_cpp/src/rerun/entity_path.cpp new file mode 100644 index 000000000000..bcf7add0c38d --- /dev/null +++ b/rerun_cpp/src/rerun/entity_path.cpp @@ -0,0 +1,36 @@ +#include "entity_path.hpp" + +#include "c/rerun.h" +#include "error.hpp" +#include "string_utils.hpp" + +namespace rerun { + std::string escape_entity_path_part(std::string_view unescaped) { + auto escaped_c_str = _rr_escape_entity_path_part(detail::to_rr_string(unescaped)); + + if (escaped_c_str == nullptr) { + Error(ErrorCode::InvalidStringArgument, "Failed to escape entity path part").handle(); + return std::string(unescaped); + } else { + std::string result = escaped_c_str; + _rr_free_string(escaped_c_str); + return result; + } + } + + std::string new_entity_path(const std::vector& path) { + if (path.empty()) { + return "/"; + } + + std::string result; + + for (const auto& part : path) { + result += "/"; + result += escape_entity_path_part(part); + } + + return result; + } + +} // namespace rerun diff --git a/rerun_cpp/src/rerun/entity_path.hpp b/rerun_cpp/src/rerun/entity_path.hpp new file mode 100644 index 000000000000..87eb633c4fe8 --- /dev/null +++ b/rerun_cpp/src/rerun/entity_path.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace rerun { + + /// Escape an individual part of an entity path. + /// + /// For instance, `escape_entity_path_path("my image!")` will return `"my\ image\!"`. + std::string escape_entity_path_part(std::string_view str); + + /// Construct an entity path by escaping each part of the path. + /// + /// For instance, `rerun::new_entity_path({"world", 42, "unescaped string!"})` will return + /// `"world/42/escaped\ string\!"`. + std::string new_entity_path(const std::vector& path); + +} // namespace rerun diff --git a/rerun_py/docs/gen_common_index.py b/rerun_py/docs/gen_common_index.py index fc406e00cf29..6b0b88f7d2e1 100755 --- a/rerun_py/docs/gen_common_index.py +++ b/rerun_py/docs/gen_common_index.py @@ -233,6 +233,8 @@ class Section: "set_global_data_recording", "set_thread_local_data_recording", "start_web_viewer_server", + "escape_entity_path_part", + "new_entity_path", ], class_list=["RecordingStream", "LoggingHandler", "MemoryRecording"], ), diff --git a/rerun_py/rerun_sdk/rerun/__init__.py b/rerun_py/rerun_sdk/rerun/__init__.py index c6244024af2e..458dd17b6495 100644 --- a/rerun_py/rerun_sdk/rerun/__init__.py +++ b/rerun_py/rerun_sdk/rerun/__init__.py @@ -65,6 +65,7 @@ "datatypes", "disable_timeline", "disconnect", + "escape_entity_path_part", "experimental", "get_application_id", "get_data_recording", @@ -72,9 +73,10 @@ "get_recording_id", "get_thread_local_data_recording", "is_enabled", - "log", "log_components", + "log", "memory_recording", + "new_entity_path", "reset_time", "save", "script_add_args", @@ -92,7 +94,15 @@ import rerun_bindings as bindings # type: ignore[attr-defined] from ._image import ImageEncoded, ImageFormat -from ._log import AsComponents, ComponentBatchLike, IndicatorComponentBatch, log, log_components +from ._log import ( + AsComponents, + ComponentBatchLike, + IndicatorComponentBatch, + escape_entity_path_part, + log, + log_components, + new_entity_path, +) from .any_value import AnyValues from .archetypes import ( AnnotationContext, diff --git a/rerun_py/rerun_sdk/rerun/_log.py b/rerun_py/rerun_sdk/rerun/_log.py index 5848c6f8401b..a637ed5ab9a0 100644 --- a/rerun_py/rerun_sdk/rerun/_log.py +++ b/rerun_py/rerun_sdk/rerun/_log.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable +from typing import Any, Iterable import pyarrow as pa import rerun_bindings as bindings @@ -54,14 +54,14 @@ def _splat() -> cmp.InstanceKeyBatch: @catch_and_log_exceptions() def log( - entity_path: str, + entity_path: str | list[str], entity: AsComponents | Iterable[ComponentBatchLike], *extra: AsComponents | Iterable[ComponentBatchLike], timeless: bool = False, recording: RecordingStream | None = None, strict: bool | None = None, ) -> None: - """ + r""" Log data to Rerun. This is the main entry point for logging data to rerun. It can be used to log anything @@ -98,6 +98,15 @@ def log( ---------- entity_path: Path to the entity in the space hierarchy. + + The entity path can either be a string + (with special characters escaped, split on unescaped slashes) + or a list of unescaped strings. + This means that logging to `"world/my\ image\!"` is the same as logging + to ["world", "my image!"]. + + See for more on entity paths. + entity: Anything that implements the [`rerun.AsComponents`][] interface, usually an archetype. *extra: @@ -163,7 +172,7 @@ def log( @catch_and_log_exceptions() def log_components( - entity_path: str, + entity_path: str | list[str], components: Iterable[ComponentBatchLike], *, num_instances: int | None = None, @@ -171,7 +180,7 @@ def log_components( recording: RecordingStream | None = None, strict: bool | None = None, ) -> None: - """ + r""" Log an entity from a collection of `ComponentBatchLike` objects. All of the batches should have the same length as the value of @@ -182,6 +191,15 @@ def log_components( ---------- entity_path: Path to the entity in the space hierarchy. + + The entity path can either be a string + (with special characters escaped, split on unescaped slashes) + or a list of unescaped strings. + This means that logging to `"world/my\ image\!"` is the same as logging + to ["world", "my image!"]. + + See for more on entity paths. + components: A collection of `ComponentBatchLike` objects that num_instances: @@ -211,6 +229,9 @@ def log_components( if num_instances is None: num_instances = max(len(arr) for arr in arrow_arrays) + if isinstance(entity_path, list): + entity_path = bindings.new_entity_path([str(part) for part in entity_path]) + added = set() for name, array in zip(names, arrow_arrays): @@ -256,3 +277,47 @@ def log_components( timeless=timeless, recording=recording, ) + + +def escape_entity_path_part(part: str) -> str: + r""" + Escape an individual part of an entity path. + + For instance, `escape_entity_path_path("my image!")` will return `"my\ image\!"`. + + See for more on entity paths. + + Parameters + ---------- + part: + An unescaped string + + Returns + ------- + str: + The escaped entity path. + """ + return str(bindings.escape_entity_path_part(part)) + + +def new_entity_path(entity_path: list[Any]) -> str: + r""" + Construct an entity path, defined by a list of (unescaped) parts. + + If any part if not a string, it will be converted to a string using `str()`. + + For instance, `new_entity_path(["world", 42, "my image!"])` will return `"world/42/my\ image\!"`. + + See for more on entity paths. + + Parameters + ---------- + entity_path: + A list of strings to escape and join with slash. + + Returns + ------- + str: + The escaped entity path. + """ + return str(bindings.new_entity_path([str(part) for part in entity_path])) diff --git a/rerun_py/src/python_bridge.rs b/rerun_py/src/python_bridge.rs index 5410c0382851..8a7021294338 100644 --- a/rerun_py/src/python_bridge.rs +++ b/rerun_py/src/python_bridge.rs @@ -15,7 +15,7 @@ use pyo3::{ //use re_viewport::{SpaceViewBlueprint, VIEWPORT_PATH}; use re_viewport::VIEWPORT_PATH; -use re_log_types::{DataRow, StoreKind}; +use re_log_types::{DataRow, EntityPathPart, StoreKind}; use rerun::{ log::RowId, sink::MemorySinkStorage, time::TimePoint, EntityPath, RecordingStream, RecordingStreamBuilder, StoreId, @@ -152,6 +152,8 @@ fn rerun_bindings(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(version, m)?)?; m.add_function(wrap_pyfunction!(get_app_url, m)?)?; m.add_function(wrap_pyfunction!(start_web_viewer_server, m)?)?; + m.add_function(wrap_pyfunction!(escape_entity_path_part, m)?)?; + m.add_function(wrap_pyfunction!(new_entity_path, m)?)?; // blueprint m.add_function(wrap_pyfunction!(set_panels, m)?)?; @@ -760,7 +762,7 @@ fn set_panel(entity_path: &str, is_expanded: bool, blueprint: Option<&PyRecordin use re_viewer::blueprint::components::PanelView; // TODO(jleibs): Validation this is a valid blueprint path? - let entity_path = parse_entity_path(entity_path); + let entity_path = EntityPath::parse_forgiving(entity_path); let panel_state = PanelView(is_expanded); @@ -867,7 +869,7 @@ fn log_arrow_msg( return Ok(()); }; - let entity_path = parse_entity_path(entity_path); + let entity_path = EntityPath::parse_forgiving(entity_path); // It's important that we don't hold the session lock while building our arrow component. // the API we call to back through pyarrow temporarily releases the GIL, which can cause @@ -938,6 +940,17 @@ fn start_web_viewer_server(port: u16) -> PyResult<()> { } } +#[pyfunction] +fn escape_entity_path_part(part: &str) -> String { + EntityPathPart::from(part).to_string() +} + +#[pyfunction] +fn new_entity_path(parts: Vec<&str>) -> String { + let path = EntityPath::from(parts.into_iter().map(EntityPathPart::from).collect_vec()); + path.to_string() +} + // --- Helpers --- fn python_version(py: Python<'_>) -> re_log_types::PythonVersion { @@ -1013,8 +1026,3 @@ authkey = multiprocessing.current_process().authkey }) .map(|authkey: &PyBytes| authkey.as_bytes().to_vec()) } - -fn parse_entity_path(entity_path: &str) -> EntityPath { - // We accept anything! - EntityPath::parse_forgiving(entity_path) -}