diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aed06819..cf3b74dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,6 +205,60 @@ jobs: name: wheels-linux-armv6 path: dist + build-debian: + needs: [tag, build-linux, build-linux-aarch64] + runs-on: ubuntu-latest + strategy: + matrix: + include: + - artifact: wheels-linux-x86_64 + arch: amd64 + target: x86_64-unknown-linux-gnu + - artifact: wheels-linux-i686 + arch: i386 + target: i686-unknown-linux-gnu + - artifact: wheels-linux-armv7 + arch: armhf + target: armv7-unknown-linux-gnueabihf + - artifact: wheels-linux-aarch64 + arch: arm64 + target: aarch64-unknown-linux-gnu + steps: + - name: Checkout this repository + uses: actions/checkout@v4 + with: + ref: ${{ needs.tag.outputs.branch }} + + - name: Download wheel + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist + + - name: Convert wheel to Debian package + run: | + shopt -s nullglob + wheels=(dist/eclipse_zenoh-*.whl) + if [[ ${#wheels[@]} -ne 1 ]]; then + echo "Expected exactly one wheel, found: ${#wheels[@]} (${wheels[*]})" >&2 + exit 1 + fi + bash ci/scripts/wheel-to-deb.sh \ + "${wheels[0]}" \ + python3-eclipse-zenoh \ + "${{ needs.tag.outputs.version }}" \ + "${{ matrix.arch }}" + + - name: Package Debian artifact as zip + run: | + zip "zenoh-python-${{ needs.tag.outputs.version }}-${{ matrix.target }}-debian.zip" *.deb + + - name: Upload Debian package artifact + uses: actions/upload-artifact@v4 + with: + name: zenoh-python-${{ needs.tag.outputs.version }}-${{ matrix.target }}-debian.zip + path: "zenoh-python-${{ needs.tag.outputs.version }}-${{ matrix.target }}-debian.zip" + publish-pypi: needs: [ @@ -253,3 +307,15 @@ jobs: branch: ${{ needs.tag.outputs.branch }} github-token: ${{ secrets.BOT_TOKEN_WORKFLOW }} archive-patterns: "^$" + + publish-debian: + needs: [tag, build-debian] + name: Publish Debian packages + uses: eclipse-zenoh/ci/.github/workflows/release-crates-debian.yml@main + with: + no-build: true + live-run: ${{ inputs.live-run || false }} + version: ${{ needs.tag.outputs.version }} + repo: ${{ github.repository }} + branch: ${{ needs.tag.outputs.branch }} + secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index be64bc53..1f56a101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3887,7 +3887,7 @@ dependencies = [ [[package]] name = "zenoh" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "ahash", "arc-swap", @@ -3938,7 +3938,7 @@ dependencies = [ [[package]] name = "zenoh-buffers" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "zenoh-collections", ] @@ -3946,7 +3946,7 @@ dependencies = [ [[package]] name = "zenoh-codec" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "tracing", "uhlc", @@ -3958,7 +3958,7 @@ dependencies = [ [[package]] name = "zenoh-collections" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "ahash", ] @@ -3966,7 +3966,7 @@ dependencies = [ [[package]] name = "zenoh-config" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "json5", "nonempty-collections", @@ -3991,7 +3991,7 @@ dependencies = [ [[package]] name = "zenoh-core" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "lazy_static", "tokio", @@ -4002,7 +4002,7 @@ dependencies = [ [[package]] name = "zenoh-crypto" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "aes", "hmac", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "zenoh-ext" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "bincode", @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "zenoh-keyexpr" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "getrandom 0.2.17", "hashbrown 0.16.1", @@ -4049,7 +4049,7 @@ dependencies = [ [[package]] name = "zenoh-link" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "zenoh-config", "zenoh-link-commons", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "zenoh-link-commons" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "base64", @@ -4103,7 +4103,7 @@ dependencies = [ [[package]] name = "zenoh-link-quic" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "rustls-webpki", @@ -4119,7 +4119,7 @@ dependencies = [ [[package]] name = "zenoh-link-quic_datagram" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "rustls-webpki", @@ -4135,7 +4135,7 @@ dependencies = [ [[package]] name = "zenoh-link-tcp" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "socket2 0.5.10", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "zenoh-link-tls" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "base64", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "zenoh-link-udp" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "libc", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "zenoh-link-unixsock_stream" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "nix", @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "zenoh-link-ws" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "futures-util", @@ -4241,7 +4241,7 @@ dependencies = [ [[package]] name = "zenoh-macros" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "proc-macro2", "quote", @@ -4252,7 +4252,7 @@ dependencies = [ [[package]] name = "zenoh-plugin-trait" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "git-version", "libloading", @@ -4269,7 +4269,7 @@ dependencies = [ [[package]] name = "zenoh-protocol" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "const_format", "rand 0.8.5", @@ -4294,7 +4294,7 @@ dependencies = [ [[package]] name = "zenoh-result" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "anyhow", ] @@ -4302,7 +4302,7 @@ dependencies = [ [[package]] name = "zenoh-runtime" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "lazy_static", "ron", @@ -4316,7 +4316,7 @@ dependencies = [ [[package]] name = "zenoh-shm" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "advisory-lock", "async-trait", @@ -4345,7 +4345,7 @@ dependencies = [ [[package]] name = "zenoh-stats" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "ahash", "prometheus-client", @@ -4358,7 +4358,7 @@ dependencies = [ [[package]] name = "zenoh-sync" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "arc-swap", "event-listener", @@ -4372,7 +4372,7 @@ dependencies = [ [[package]] name = "zenoh-task" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "futures", "tokio", @@ -4385,7 +4385,7 @@ dependencies = [ [[package]] name = "zenoh-transport" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "crossbeam-utils", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "zenoh-util" version = "1.9.0" -source = "git+https://github.com/eclipse-zenoh/zenoh.git?branch=main#90c06a28c7e1d396ea1a97b3a63a5e6a38afc6ee" +source = "git+https://github.com/YuanYuYuan/zenoh.git?branch=feat%2Frouting-timestamps#fff0190b41c414f7b5f11917d9c4ac60731fd43b" dependencies = [ "async-trait", "const_format", diff --git a/Cargo.toml b/Cargo.toml index ff161af4..ec312428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,10 +44,10 @@ maintenance = { status = "actively-developed" } [dependencies] paste = "1.0.14" pyo3 = { version = "0.25.1", features = ["abi3-py39", "extension-module"] } -zenoh = { version = "1.9.0", git = "https://github.com/eclipse-zenoh/zenoh.git", branch = "main", features = [ +zenoh = { version = "1.9.0", git = "https://github.com/YuanYuYuan/zenoh.git", branch = "feat/routing-timestamps", features = [ "internal", "unstable", ], default-features = false } -zenoh-ext = { version = "1.9.0", git = "https://github.com/eclipse-zenoh/zenoh.git", branch = "main", features = [ +zenoh-ext = { version = "1.9.0", git = "https://github.com/YuanYuYuan/zenoh.git", branch = "feat/routing-timestamps", features = [ "internal", ], optional = true } diff --git a/ci/scripts/wheel-to-deb.sh b/ci/scripts/wheel-to-deb.sh new file mode 100755 index 00000000..73dd12e5 --- /dev/null +++ b/ci/scripts/wheel-to-deb.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026 ZettaScale Technology +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +# which is available at https://www.apache.org/licenses/LICENSE-2.0. +# +# SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +# +# Contributors: +# ZettaScale Zenoh Team, +# +# Usage: wheel-to-deb.sh +# Example: +# wheel-to-deb.sh eclipse_zenoh-1.0.0-cp39-abi3-manylinux_2_17_x86_64.whl \ +# python3-eclipse-zenoh 1.0.0 amd64 + +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "Usage: $0 " >&2 + echo "Example: $0 eclipse_zenoh-1.0.0-cp39-abi3-manylinux_2_17_x86_64.whl python3-eclipse-zenoh 1.0.0 amd64" >&2 + exit 1 +fi + +WHEEL=$1 +PKG=$2 +VER=$3 +ARCH=$4 + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +unzip -q "$WHEEL" -d "$WORKDIR/contents" + +DIST_PKG="$WORKDIR/deb/usr/lib/python3/dist-packages" +mkdir -p "$DIST_PKG" + +cp -r "$WORKDIR/contents/zenoh" "$DIST_PKG/" + +# Copy dist-info for importlib.metadata and pip compatibility +mapfile -t DIST_INFO_DIRS < <(find "$WORKDIR/contents" -maxdepth 1 -name "*.dist-info" -type d) +if [[ ${#DIST_INFO_DIRS[@]} -gt 1 ]]; then + echo "Expected at most one dist-info directory, found: ${DIST_INFO_DIRS[*]}" >&2 + exit 1 +fi +if [[ ${#DIST_INFO_DIRS[@]} -eq 1 ]]; then + cp -r "${DIST_INFO_DIRS[0]}" "$DIST_PKG/" + # Mark as dpkg-managed so pip does not attempt to uninstall these files + echo "dpkg" > "$DIST_PKG/$(basename "${DIST_INFO_DIRS[0]}")/INSTALLER" +fi + +# Derive minimum glibc version from the manylinux tag in the wheel filename +# e.g. manylinux_2_17 -> libc6 (>= 2.17), manylinux_2_28 -> libc6 (>= 2.28) +LIBC6_DEP="libc6" +if [[ "$WHEEL" =~ manylinux_([0-9]+)_([0-9]+) ]]; then + LIBC6_DEP="libc6 (>= ${BASH_REMATCH[1]}.${BASH_REMATCH[2]})" +fi + +mkdir -p "$WORKDIR/deb/DEBIAN" +cat > "$WORKDIR/deb/DEBIAN/control" < +Depends: python3 (>= 3.9), $LIBC6_DEP +Section: python +Priority: optional +Homepage: https://zenoh.io +Description: Eclipse Zenoh Python bindings + Eclipse Zenoh: Zero Overhead Pub/sub, Store/Query and Compute. + . + This package provides the Python bindings for Eclipse Zenoh, enabling + pub/sub, queryable and geo-distributed storage in Python. + . + Built from manylinux wheels. +CTRL + +dpkg-deb --build --root-owner-group "$WORKDIR/deb" "${PKG}_${VER}_${ARCH}.deb" +echo "Built: ${PKG}_${VER}_${ARCH}.deb" diff --git a/examples/z_timestamp_instrumentation.py b/examples/z_timestamp_instrumentation.py new file mode 100644 index 00000000..ea6efc87 --- /dev/null +++ b/examples/z_timestamp_instrumentation.py @@ -0,0 +1,112 @@ +# +# Copyright (c) 2026 ZettaScale Technology +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +# which is available at https://www.apache.org/licenses/LICENSE-2.0. +# +# SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +# +# Contributors: +# ZettaScale Zenoh Team, +# +# Demonstrates opt-in end-to-end latency instrumentation. +# +# Run this example to see Send/Route/Receive timestamps on each message. +# The custom_callback variant shows how to inject your own clock bytes. +# +import time + +import zenoh +from zenoh import InterceptionPoint, TimestampInstrumentation + + +def print_stack(stack): + if stack is None: + print(" (no timestamp stack)") + return + for rec in stack.records: + ts = rec.as_timestamp() + if ts is not None: + print(f" {rec.point.name:8s} hlc={ts} custom={rec.is_custom}") + else: + print( + f" {rec.point.name:8s} raw={rec.timestamp().hex()} custom={rec.is_custom}" + ) + + +def example_put_subscribe(session): + print("\n── put/subscribe with send+receive instrumentation ─────────────────") + instr = TimestampInstrumentation(send=True, receive=True) + received = [] + with session.declare_subscriber("demo/ts/**", lambda s: received.append(s)): + time.sleep(0.05) + session.put("demo/ts/hello", b"world", timestamp_instrumentation=instr) + time.sleep(0.2) + if received: + print(f"Received sample on '{received[0].key_expr}':") + print_stack(received[0].timestamp_stack) + + +def example_publisher_default(session): + print("\n── publisher with per-put instrumentation ───────────────────────────") + instr = TimestampInstrumentation(send=True, receive=True) + received = [] + with session.declare_publisher("demo/ts/pub") as pub: + with session.declare_subscriber("demo/ts/pub", lambda s: received.append(s)): + time.sleep(0.05) + pub.put(b"message-1", timestamp_instrumentation=instr) + pub.put( + b"message-2", + timestamp_instrumentation=TimestampInstrumentation(send=True), + ) + time.sleep(0.2) + for s in received: + print(f"Received '{s.payload.to_string()}':") + print_stack(s.timestamp_stack) + + +def example_custom_callback(): + print("\n── session with custom timestamp callback ───────────────────────────") + import struct + import time as _t + + def my_clock(ctx): + # Return a simple 8-byte little-endian nanosecond timestamp. + ns = int(_t.time_ns()) + return struct.pack(", #[pyo3(from_py_with = ZBytes::from_py_opt)] attachment: Option, timestamp: Option, + timestamp_instrumentation: Option, ) -> PyResult<()> { let this = self.get_ref()?; - wait( - py, - build!(this.put(payload), encoding, attachment, timestamp), - ) + let mut builder = build!(this.put(payload), encoding, attachment, timestamp); + if let Some(instr) = timestamp_instrumentation { + builder = builder.timestamp_instrumentation(Some(instr.0)); + } + wait(py, builder) } - #[pyo3(signature = (*, attachment = None, timestamp = None))] + #[pyo3(signature = (*, attachment = None, timestamp = None, timestamp_instrumentation = None))] fn delete( &self, py: Python, #[pyo3(from_py_with = ZBytes::from_py_opt)] attachment: Option, timestamp: Option, + timestamp_instrumentation: Option, ) -> PyResult<()> { - wait(py, build!(self.get_ref()?.delete(), attachment, timestamp)) + let mut builder = build!(self.get_ref()?.delete(), attachment, timestamp); + if let Some(instr) = timestamp_instrumentation { + builder = builder.timestamp_instrumentation(Some(instr.0)); + } + wait(py, builder) } fn undeclare(&mut self, py: Python) -> PyResult<()> { diff --git a/src/lib.rs b/src/lib.rs index 09235cce..2e61ef7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ mod session; #[cfg(feature = "shared-memory")] mod shm; mod time; +mod timestamp_stack; mod utils; use pyo3::prelude::*; @@ -77,6 +78,10 @@ pub(crate) mod zenoh { Transport, TransportEvent, TransportEventsListener, }, time::{Timestamp, TimestampId, NTP64}, + timestamp_stack::{ + InterceptionPoint, TimestampInstrumentation, TimestampInstrumentationBuilder, + TimestampContext, TimestampStack, TimestampStackRecord, + }, ZError, }; diff --git a/src/pubsub.rs b/src/pubsub.rs index f5c46ef0..b1a5c41c 100644 --- a/src/pubsub.rs +++ b/src/pubsub.rs @@ -27,6 +27,7 @@ use crate::{ sample::{Sample, SourceInfo}, session::EntityGlobalId, time::Timestamp, + timestamp_stack::TimestampInstrumentation, utils::{generic, wait}, }; @@ -84,7 +85,8 @@ impl Publisher { Ok(wait(py, self.get_ref()?.matching_status())?.into()) } - #[pyo3(signature = (payload, *, encoding = None, attachment = None, timestamp = None, source_info = None))] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (payload, *, encoding = None, attachment = None, timestamp = None, source_info = None, timestamp_instrumentation = None))] fn put( &self, py: Python, @@ -93,27 +95,35 @@ impl Publisher { #[pyo3(from_py_with = ZBytes::from_py_opt)] attachment: Option, timestamp: Option, source_info: Option, + timestamp_instrumentation: Option, ) -> PyResult<()> { let this = self.get_ref()?; - let builder = build!( + let mut builder = build!( this.put(payload), encoding, attachment, timestamp, source_info ); + if let Some(instr) = timestamp_instrumentation { + builder = builder.timestamp_instrumentation(Some(instr.0)); + } wait(py, builder) } - #[pyo3(signature = (*, attachment = None, timestamp = None, source_info = None))] + #[pyo3(signature = (*, attachment = None, timestamp = None, source_info = None, timestamp_instrumentation = None))] fn delete( &self, py: Python, #[pyo3(from_py_with = ZBytes::from_py_opt)] attachment: Option, timestamp: Option, source_info: Option, + timestamp_instrumentation: Option, ) -> PyResult<()> { - let builder = build!(self.get_ref()?.delete(), attachment, timestamp, source_info); + let mut builder = build!(self.get_ref()?.delete(), attachment, timestamp, source_info); + if let Some(instr) = timestamp_instrumentation { + builder = builder.timestamp_instrumentation(Some(instr.0)); + } wait(py, builder) } diff --git a/src/query.rs b/src/query.rs index 17d5e674..ada7c859 100644 --- a/src/query.rs +++ b/src/query.rs @@ -30,6 +30,7 @@ use crate::{ sample::SourceInfo, session::EntityGlobalId, time::Timestamp, + timestamp_stack::{TimestampInstrumentation, TimestampStack}, utils::{generic, wait, IntoPyResult, IntoPython, IntoRust, MapInto}, }; @@ -230,6 +231,11 @@ impl Query { Ok(self.get_ref()?.source_info().cloned().map_into()) } + #[getter] + fn timestamp_stack(&self) -> PyResult> { + Ok(self.get_ref()?.timestamp_stack().cloned().map(TimestampStack)) + } + fn drop(&mut self) { Python::with_gil(|gil| gil.allow_threads(|| drop(self.0.take()))); } @@ -276,6 +282,14 @@ impl Reply { self.0.replier_id().map_into() } + #[getter] + fn timestamp_stack(&self) -> Option { + match self.0.result() { + Ok(sample) => sample.timestamp_stack().cloned().map(TimestampStack), + Err(err) => err.timestamp_stack().cloned().map(TimestampStack), + } + } + fn __repr__(&self) -> String { format!("{:?}", self.0) } @@ -295,6 +309,11 @@ impl ReplyError { self.0.encoding().clone().into() } + #[getter] + fn timestamp_stack(&self) -> Option { + self.0.timestamp_stack().cloned().map(TimestampStack) + } + fn __repr__(&self) -> String { format!("{:?}", self.0) } @@ -408,7 +427,7 @@ impl Querier { } #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (handler = None, *, parameters = None, payload = None, encoding = None, attachment = None, source_info = None, cancellation_token = None))] + #[pyo3(signature = (handler = None, *, parameters = None, payload = None, encoding = None, attachment = None, source_info = None, cancellation_token = None, timestamp_instrumentation = None))] fn get( &self, py: Python, @@ -419,10 +438,11 @@ impl Querier { #[pyo3(from_py_with = ZBytes::from_py_opt)] attachment: Option, source_info: Option, cancellation_token: Option, + timestamp_instrumentation: Option, ) -> PyResult> { let this = self.get_ref()?; let (handler, _) = into_handler(py, handler, cancellation_token.as_ref())?; - let builder = build!( + let mut builder = build!( this.get(), parameters, payload, @@ -431,6 +451,9 @@ impl Querier { source_info, cancellation_token ); + if let Some(instr) = timestamp_instrumentation { + builder = builder.timestamp_instrumentation(Some(instr.0)); + } wait(py, builder.with(handler)).map_into() } diff --git a/src/sample.rs b/src/sample.rs index dce32cf6..07aaea53 100644 --- a/src/sample.rs +++ b/src/sample.rs @@ -21,6 +21,7 @@ use crate::{ qos::{CongestionControl, Priority}, session::EntityGlobalId, time::Timestamp, + timestamp_stack::TimestampStack, utils::MapInto, }; @@ -95,6 +96,11 @@ impl Sample { self.0.source_info().cloned().map_into() } + #[getter] + fn timestamp_stack(&self) -> Option { + self.0.timestamp_stack().cloned().map(TimestampStack) + } + fn __repr__(&self) -> String { format!("{:?}", self.0) } diff --git a/src/session.rs b/src/session.rs index 92ae591e..be1e526c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -33,6 +33,7 @@ use crate::{ query::{Querier, QueryConsolidation, QueryTarget, Queryable, Reply, ReplyKeyExpr, Selector}, sample::{Locality, SampleKind, SourceInfo}, time::Timestamp, + timestamp_stack::{py_to_session_ts_callback, TimestampInstrumentation}, utils::{duration, wait, IntoPython, MapInto}, }; @@ -94,7 +95,7 @@ impl Session { } #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (key_expr, payload, *, encoding = None, congestion_control = None, priority = None, express = None, attachment = None, timestamp = None, allowed_destination = None, source_info = None))] + #[pyo3(signature = (key_expr, payload, *, encoding = None, congestion_control = None, priority = None, express = None, attachment = None, timestamp = None, allowed_destination = None, source_info = None, timestamp_instrumentation = None))] fn put( &self, py: Python, @@ -108,8 +109,9 @@ impl Session { timestamp: Option, allowed_destination: Option, source_info: Option, + timestamp_instrumentation: Option, ) -> PyResult<()> { - let build = build!( + let mut build = build!( self.0.put(key_expr, payload), encoding, congestion_control, @@ -120,11 +122,14 @@ impl Session { allowed_destination, source_info, ); + if let Some(instr) = timestamp_instrumentation { + build = build.timestamp_instrumentation(Some(instr.0)); + } wait(py, build) } #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (key_expr, *, congestion_control = None, priority = None, express = None, attachment = None, timestamp = None, allowed_destination = None, source_info = None))] + #[pyo3(signature = (key_expr, *, congestion_control = None, priority = None, express = None, attachment = None, timestamp = None, allowed_destination = None, source_info = None, timestamp_instrumentation = None))] fn delete( &self, py: Python, @@ -136,8 +141,9 @@ impl Session { timestamp: Option, allowed_destination: Option, source_info: Option, + timestamp_instrumentation: Option, ) -> PyResult<()> { - let build = build!( + let mut build = build!( self.0.delete(key_expr), congestion_control, priority, @@ -147,11 +153,14 @@ impl Session { allowed_destination, source_info ); + if let Some(instr) = timestamp_instrumentation { + build = build.timestamp_instrumentation(Some(instr.0)); + } wait(py, build) } #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (selector, handler = None, *, target = None, consolidation = None, accept_replies = None, timeout = None, congestion_control = None, priority = None, express = None, payload = None, encoding = None, attachment = None, allowed_destination = None, source_info = None, cancellation_token = None))] + #[pyo3(signature = (selector, handler = None, *, target = None, consolidation = None, accept_replies = None, timeout = None, congestion_control = None, priority = None, express = None, payload = None, encoding = None, attachment = None, allowed_destination = None, source_info = None, cancellation_token = None, timestamp_instrumentation = None))] fn get( &self, py: Python, @@ -172,9 +181,10 @@ impl Session { allowed_destination: Option, source_info: Option, cancellation_token: Option, + timestamp_instrumentation: Option, ) -> PyResult> { let (handler, _) = into_handler(py, handler, cancellation_token.as_ref())?; - let builder = build!( + let mut builder = build!( self.0.get(selector), target, consolidation, @@ -190,7 +200,9 @@ impl Session { source_info, cancellation_token ); - + if let Some(instr) = timestamp_instrumentation { + builder = builder.timestamp_instrumentation(Some(instr.0)); + } wait(py, builder.with(handler)).map_into() } @@ -306,8 +318,17 @@ impl Drop for Session { } #[pyfunction] -pub(crate) fn open(py: Python, config: Config) -> PyResult { - wait(py, zenoh::open(config)).map(Session) +#[pyo3(signature = (config, *, timestamp_callback = None))] +pub(crate) fn open( + py: Python, + config: Config, + timestamp_callback: Option, +) -> PyResult { + let mut builder = zenoh::open(config); + if let Some(cb) = timestamp_callback { + builder = builder.with_timestamp_callback(py_to_session_ts_callback(cb)); + } + wait(py, builder).map(Session) } wrapper!(zenoh::session::SessionInfo); diff --git a/src/timestamp_stack.rs b/src/timestamp_stack.rs new file mode 100644 index 00000000..238b2471 --- /dev/null +++ b/src/timestamp_stack.rs @@ -0,0 +1,264 @@ +// +// Copyright (c) 2026 ZettaScale Technology +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// +// Contributors: +// ZettaScale Zenoh Team, +// +use std::sync::Arc; + +use pyo3::{prelude::*, types::PyBytes}; +use zenoh::timestamp_stack::{ + InstrumentationTimestamp, InterceptionPoint as RustInterceptionPoint, SessionTimestampCallback, + TimestampInstrumentationBuilder as RustTimestampInstrumentationBuilder, + TsStackContext as RustTsStackContext, +}; + +use crate::{ + config::{WhatAmI, ZenohId}, + macros::wrapper, + time::Timestamp, + utils::IntoPyResult, +}; + +// ── TimestampInstrumentationBuilder ────────────────────────────────────────── + +#[pyclass] +pub(crate) struct TimestampInstrumentationBuilder { + send: bool, + route: bool, + receive: bool, +} + +#[pymethods] +impl TimestampInstrumentationBuilder { + #[new] + fn new() -> Self { + Self { + send: false, + route: false, + receive: false, + } + } + + fn set_send(mut self_: PyRefMut, send: bool) -> PyRefMut { + self_.send = send; + self_ + } + + fn set_route(mut self_: PyRefMut, route: bool) -> PyRefMut { + self_.route = route; + self_ + } + + fn set_receive(mut self_: PyRefMut, receive: bool) -> PyRefMut { + self_.receive = receive; + self_ + } + + fn build(&self) -> PyResult { + RustTimestampInstrumentationBuilder::new() + .set_send(self.send) + .set_route(self.route) + .set_receive(self.receive) + .build() + .map(TimestampInstrumentation) + .into_pyres() + } + + fn __repr__(&self) -> String { + format!( + "TimestampInstrumentationBuilder(send={}, route={}, receive={})", + self.send, self.route, self.receive + ) + } +} + +// InterceptionPoint is #[non_exhaustive] so we can't use enum_mapper! (it generates exhaustive +// From impls). Define it manually with a repr u8 for Python comparison, and a fallback variant. +#[pyo3::pyclass(eq)] +#[repr(u8)] +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum InterceptionPoint { + #[pyo3(name = "SEND")] + Send = 0, + #[pyo3(name = "ROUTE")] + Route = 1, + #[pyo3(name = "RECEIVE")] + Receive = 2, + /// Catch-all for future variants added by the Rust core. + #[pyo3(name = "UNKNOWN")] + Unknown = 255, +} + +impl From for InterceptionPoint { + fn from(v: RustInterceptionPoint) -> Self { + match v { + RustInterceptionPoint::Send => Self::Send, + RustInterceptionPoint::Route => Self::Route, + RustInterceptionPoint::Receive => Self::Receive, + } + } +} + +impl From for RustInterceptionPoint { + fn from(v: InterceptionPoint) -> Self { + match v { + InterceptionPoint::Send => RustInterceptionPoint::Send, + InterceptionPoint::Route => RustInterceptionPoint::Route, + InterceptionPoint::Receive | InterceptionPoint::Unknown => { + RustInterceptionPoint::Receive + } + } + } +} + +// Renamed from TsStackContext (OlivierHecart review: make the callback context name +// less implementation-specific, drop the "Stack" coupling). +#[pyclass] +#[derive(Clone)] +pub(crate) struct TimestampContext(pub(crate) RustTsStackContext); + +impl From for TimestampContext { + fn from(value: RustTsStackContext) -> Self { + Self(value) + } +} + +#[pymethods] +impl TimestampContext { + #[getter] + fn zid(&self) -> ZenohId { + self.0.zid.into() + } + + #[getter] + fn whatami(&self) -> WhatAmI { + self.0.whatami.into() + } + + #[getter] + fn interception_point(&self) -> InterceptionPoint { + self.0.interception_point.into() + } + + fn __repr__(&self) -> String { + format!("{:?}", self.0) + } +} + +wrapper!(zenoh::timestamp_stack::TimestampInstrumentation: Clone, Copy); + +#[pymethods] +impl TimestampInstrumentation { + #[new] + #[pyo3(signature = (*, send = false, route = false, receive = false))] + fn new(send: bool, route: bool, receive: bool) -> PyResult { + RustTimestampInstrumentationBuilder::new() + .set_send(send) + .set_route(route) + .set_receive(receive) + .build() + .map(Self) + .into_pyres() + } + + fn is_instrumented(&self, point: InterceptionPoint) -> bool { + self.0.is_instrumented(point.into()) + } + + fn __repr__(&self) -> String { + format!("{:?}", self.0) + } +} + +wrapper!(zenoh::timestamp_stack::TimestampStackRecord: Clone); + +#[pymethods] +impl TimestampStackRecord { + #[getter] + fn point(&self) -> InterceptionPoint { + self.0.point().into() + } + + #[getter] + fn is_custom(&self) -> bool { + self.0.is_custom() + } + + fn timestamp(&self, py: Python) -> PyResult { + match self.0.timestamp() { + InstrumentationTimestamp::UHLC(ts) => { + Ok(Timestamp(*ts).into_pyobject(py)?.into_any().unbind()) + } + InstrumentationTimestamp::Custom(bytes) => { + Ok(PyBytes::new(py, bytes).into_any().unbind()) + } + } + } + + fn as_timestamp(&self) -> Option { + match self.0.timestamp() { + InstrumentationTimestamp::UHLC(ts) => Some(Timestamp(*ts)), + InstrumentationTimestamp::Custom(_) => None, + } + } + + fn __repr__(&self) -> String { + format!("{:?}", self.0) + } +} + +wrapper!(zenoh::timestamp_stack::TimestampStack: Clone); + +#[pymethods] +impl TimestampStack { + #[getter] + fn instrumentation(&self) -> TimestampInstrumentation { + TimestampInstrumentation(self.0.instrumentation()) + } + + #[getter] + fn records(&self) -> Vec { + self.0 + .records() + .iter() + .cloned() + .map(TimestampStackRecord) + .collect() + } + + fn __repr__(&self) -> String { + format!("{:?}", self.0) + } +} + +/// Build a `SessionTimestampCallback` Arc from a Python callable. +pub(crate) fn py_to_session_ts_callback(py_cb: PyObject) -> SessionTimestampCallback { + Arc::new(move |ctx: RustTsStackContext| { + Python::with_gil(|py| { + let py_ctx = match Py::new(py, TimestampContext(ctx)) { + Ok(obj) => obj, + Err(_) => return Vec::new(), + }; + match py_cb.call1(py, (py_ctx,)) { + Ok(result) => result.extract::>(py).unwrap_or_default(), + Err(e) => { + if let Ok(logging) = py.import("logging") { + if let Ok(logger) = logging.call_method1("getLogger", ("zenoh",)) { + let _ = logger + .call_method1("error", (format!("Timestamp callback error: {e}"),)); + } + } + Vec::new() + } + } + }) + }) +} diff --git a/tests/test_timestamp_stack.py b/tests/test_timestamp_stack.py new file mode 100644 index 00000000..f447bd87 --- /dev/null +++ b/tests/test_timestamp_stack.py @@ -0,0 +1,345 @@ +# +# Copyright (c) 2026 ZettaScale Technology +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +# which is available at https://www.apache.org/licenses/LICENSE-2.0. +# +# SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +# +# Contributors: +# ZettaScale Zenoh Team, +# +import time +from typing import List, Optional + +import pytest + +import zenoh +from zenoh import InterceptionPoint, TimestampInstrumentation, TimestampStack, SampleKind + +SLEEP = 0.2 + + +def peer_config() -> zenoh.Config: + cfg = zenoh.Config() + cfg.insert_json5("scouting/multicast/enabled", "false") + return cfg + + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def collect_one(key: str, action, timeout: float = SLEEP) -> Optional[zenoh.Sample]: + received: List[zenoh.Sample] = [] + with zenoh.open(peer_config()) as session: + with session.declare_subscriber(key, lambda s: received.append(s)): + time.sleep(0.05) + action(session) + time.sleep(timeout) + return received[0] if received else None + + +# ── test_no_instrumentation ─────────────────────────────────────────────────── + + +def test_no_instrumentation(): + """Without instrumentation the stack should be None.""" + + def put(session): + session.put("test/ts/none", b"hello") + + sample = collect_one("test/ts/none", put) + assert sample is not None + assert sample.timestamp_stack is None + + +# ── test_put_subscribe_send_receive ────────────────────────────────────────── + + +def test_put_subscribe_send_receive(): + """A put with send+receive instrumentation produces SEND and RECEIVE records.""" + instr = TimestampInstrumentation(send=True, receive=True) + + def put(session): + session.put("test/ts/put", b"hello", timestamp_instrumentation=instr) + + sample = collect_one("test/ts/put", put) + assert sample is not None + stack = sample.timestamp_stack + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + assert InterceptionPoint.RECEIVE in points + + +# ── test_send_only ──────────────────────────────────────────────────────────── + + +def test_send_only(): + """send=True, receive=False → only SEND record.""" + instr = TimestampInstrumentation(send=True, receive=False) + + def put(session): + session.put("test/ts/send_only", b"x", timestamp_instrumentation=instr) + + sample = collect_one("test/ts/send_only", put) + assert sample is not None + stack = sample.timestamp_stack + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + assert InterceptionPoint.RECEIVE not in points + + +# ── test_receive_only ───────────────────────────────────────────────────────── + + +def test_receive_only(): + """receive=True, send=False → only RECEIVE record.""" + instr = TimestampInstrumentation(send=False, receive=True) + + def put(session): + session.put("test/ts/recv_only", b"x", timestamp_instrumentation=instr) + + sample = collect_one("test/ts/recv_only", put) + assert sample is not None + stack = sample.timestamp_stack + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.RECEIVE in points + assert InterceptionPoint.SEND not in points + + +# ── test_publisher_default ──────────────────────────────────────────────────── + + +def test_publisher_default(): + """Publisher put with instrumentation records SEND and RECEIVE points.""" + instr = TimestampInstrumentation(send=True, receive=True) + received: List[zenoh.Sample] = [] + + with zenoh.open(peer_config()) as session: + with session.declare_subscriber( + "test/ts/pub_default", lambda s: received.append(s) + ): + with session.declare_publisher("test/ts/pub_default") as pub: + time.sleep(0.05) + pub.put(b"data", timestamp_instrumentation=instr) + time.sleep(SLEEP) + + assert len(received) == 1 + stack = received[0].timestamp_stack + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + assert InterceptionPoint.RECEIVE in points + + +# ── test_publisher_per_put_override ────────────────────────────────────────── + + +def test_publisher_per_put_override(): + """Per-put instrumentation controls which points are recorded.""" + override_instr = TimestampInstrumentation(send=True, receive=False) + received: List[zenoh.Sample] = [] + + with zenoh.open(peer_config()) as session: + with session.declare_subscriber( + "test/ts/pub_override", lambda s: received.append(s) + ): + with session.declare_publisher("test/ts/pub_override") as pub: + time.sleep(0.05) + pub.put(b"data", timestamp_instrumentation=override_instr) + time.sleep(SLEEP) + + assert len(received) == 1 + points = [r.point for r in received[0].timestamp_stack.records] + assert InterceptionPoint.SEND in points + assert InterceptionPoint.RECEIVE not in points + + +# ── test_as_timestamp ───────────────────────────────────────────────────────── + + +def test_as_timestamp(): + """Standard HLC records decode via as_timestamp(); returns a Timestamp object.""" + instr = TimestampInstrumentation(send=True, receive=True) + + def put(session): + session.put("test/ts/as_ts", b"t", timestamp_instrumentation=instr) + + sample = collect_one("test/ts/as_ts", put) + assert sample is not None + for r in sample.timestamp_stack.records: + if not r.is_custom: + ts = r.as_timestamp() + assert ts is not None + + +# ── test_is_custom_false ────────────────────────────────────────────────────── + + +def test_is_custom_false(): + """Standard (non-callback) records have is_custom == False.""" + instr = TimestampInstrumentation(send=True, receive=True) + + def put(session): + session.put("test/ts/not_custom", b"x", timestamp_instrumentation=instr) + + sample = collect_one("test/ts/not_custom", put) + assert sample is not None + for r in sample.timestamp_stack.records: + assert not r.is_custom + + +# ── test_custom_callback ────────────────────────────────────────────────────── + + +def test_custom_callback(): + """A session-level timestamp callback produces custom records with the returned bytes.""" + MARKER = b"custom-ts-bytes" + + def my_callback(ctx): + return MARKER + + instr = TimestampInstrumentation(send=True, receive=True) + received: List[zenoh.Sample] = [] + + with zenoh.open(peer_config(), timestamp_callback=my_callback) as session: + with session.declare_subscriber( + "test/ts/custom_cb", lambda s: received.append(s) + ): + time.sleep(0.05) + session.put("test/ts/custom_cb", b"x", timestamp_instrumentation=instr) + time.sleep(SLEEP) + + assert len(received) == 1 + stack = received[0].timestamp_stack + assert stack is not None + custom_records = [r for r in stack.records if r.is_custom] + assert len(custom_records) > 0 + for r in custom_records: + assert r.timestamp() == MARKER + assert r.as_timestamp() is None # custom bytes don't decode as UHLC + + +# ── test_invalid_instrumentation ───────────────────────────────────────────── + + +def test_invalid_instrumentation(): + """All-false instrumentation should raise (at least one point required).""" + with pytest.raises(Exception): + TimestampInstrumentation(send=False, route=False, receive=False) + + +# ── test_session_delete_instrumentation ────────────────────────────────────── + + +def test_session_delete_instrumentation(): + """session.delete with instrumentation produces a stack on the DELETE sample.""" + instr = TimestampInstrumentation(send=True, receive=True) + received: List[zenoh.Sample] = [] + + with zenoh.open(peer_config()) as session: + with session.declare_subscriber("test/ts/del", lambda s: received.append(s)): + time.sleep(0.05) + session.delete("test/ts/del", timestamp_instrumentation=instr) + time.sleep(SLEEP) + + assert len(received) == 1 + assert received[0].kind == SampleKind.DELETE + stack = received[0].timestamp_stack + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + assert InterceptionPoint.RECEIVE in points + + +# ── test_publisher_delete_instrumentation ──────────────────────────────────── + + +def test_publisher_delete_instrumentation(): + """publisher.delete with instrumentation produces a stack on the DELETE sample.""" + instr = TimestampInstrumentation(send=True, receive=True) + received: List[zenoh.Sample] = [] + + with zenoh.open(peer_config()) as session: + with session.declare_subscriber( + "test/ts/pub_del", lambda s: received.append(s) + ): + with session.declare_publisher("test/ts/pub_del") as pub: + time.sleep(0.05) + pub.delete(timestamp_instrumentation=instr) + time.sleep(SLEEP) + + assert len(received) == 1 + assert received[0].kind == SampleKind.DELETE + stack = received[0].timestamp_stack + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + assert InterceptionPoint.RECEIVE in points + + +# ── test_query_timestamp_stack ──────────────────────────────────────────────── + + +def test_query_timestamp_stack(): + """Query.timestamp_stack carries the instrumentation from the get caller.""" + instr = TimestampInstrumentation(send=True, receive=True) + query_stacks: List[Optional[TimestampStack]] = [] + + with zenoh.open(peer_config()) as session: + + def on_query(q): + query_stacks.append(q.timestamp_stack) + q.reply(q.key_expr, b"answer") + + with session.declare_queryable("test/ts/q/**", on_query): + time.sleep(0.05) + replies = list( + session.get( + "test/ts/q/key", + timestamp_instrumentation=instr, + ) + ) + time.sleep(SLEEP) + + assert len(query_stacks) == 1 + stack = query_stacks[0] + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + + assert len(replies) >= 1 + + +# ── test_querier_get_instrumentation ───────────────────────────────────────── + + +def test_querier_get_instrumentation(): + """Querier.get with instrumentation produces a stack visible at the queryable.""" + instr = TimestampInstrumentation(send=True, receive=True) + query_stacks: List[Optional[TimestampStack]] = [] + + with zenoh.open(peer_config()) as session: + + def on_query(q): + query_stacks.append(q.timestamp_stack) + q.reply(q.key_expr, b"querier-answer") + + with session.declare_queryable("test/ts/qr/**", on_query): + with session.declare_querier("test/ts/qr/key") as querier: + time.sleep(0.05) + replies = list(querier.get(timestamp_instrumentation=instr)) + time.sleep(SLEEP) + + assert len(query_stacks) == 1 + stack = query_stacks[0] + assert stack is not None + points = [r.point for r in stack.records] + assert InterceptionPoint.SEND in points + + assert len(replies) >= 1 diff --git a/zenoh/__init__.pyi b/zenoh/__init__.pyi index 5fe200e5..032b5490 100644 --- a/zenoh/__init__.pyi +++ b/zenoh/__init__.pyi @@ -742,6 +742,7 @@ class Publisher: attachment: _IntoZBytes | None = None, timestamp: Timestamp | None = None, source_info: SourceInfo | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ): """Publish data to :class:`Subscriber` instances matching this publisher's key expression. @@ -755,6 +756,7 @@ class Publisher: attachment: _IntoZBytes | None = None, timestamp: Timestamp | None = None, source_info: SourceInfo | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ): """Declare that data associated with this publisher's key expression is deleted. @@ -895,6 +897,11 @@ class Query: def source_info(self) -> SourceInfo | None: """Gets info on the source of this Query.""" + @_unstable + @property + def timestamp_stack(self) -> TimestampStack | None: + """Gets the timestamp stack of this Query, if timestamp instrumentation was active.""" + def drop(self): """Drop the instance of a query. The query will only be finalized when all query instances (one per queryable @@ -990,6 +997,7 @@ class Querier: attachment: _IntoZBytes | None = None, source_info: SourceInfo | None = None, cancellation_token: CancellationToken | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ) -> Handler[Reply]: """Sends a query and returns a channel for processing replies. @@ -1006,6 +1014,7 @@ class Querier: attachment: _IntoZBytes | None = None, source_info: SourceInfo | None = None, cancellation_token: CancellationToken | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ) -> _H: """Sends a query and returns a channel for processing replies. @@ -1022,6 +1031,7 @@ class Querier: attachment: _IntoZBytes | None = None, source_info: SourceInfo | None = None, cancellation_token: CancellationToken | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ) -> None: """Sends a query and processes replies using the provided callback. @@ -1193,6 +1203,11 @@ class Reply: def replier_id(self) -> EntityGlobalId | None: """Returns the ID of the zenoh instance that answered this reply.""" + @_unstable + @property + def timestamp_stack(self) -> TimestampStack | None: + """Gets the timestamp stack of the inner Sample or ReplyError, if timestamp instrumentation was active.""" + @final class ReplyError: """An error reply received from a :class:`Queryable` and available in the :class:`Reply` structure.""" @@ -1205,6 +1220,11 @@ class ReplyError: def encoding(self) -> Encoding: """Gets the encoding of this `ReplyError`.""" + @_unstable + @property + def timestamp_stack(self) -> TimestampStack | None: + """Gets the timestamp stack of this ReplyError, if timestamp instrumentation was active.""" + @final class SampleKind(Enum): """The kind of a :class:`Sample`, indicating whether it contains data or indicates deletion.""" @@ -1266,6 +1286,11 @@ class Sample: def source_info(self) -> SourceInfo | None: """Gets info on the source of this Sample.""" + @_unstable + @property + def timestamp_stack(self) -> TimestampStack | None: + """Gets the timestamp stack of this Sample, if timestamp instrumentation was active.""" + @final class Scout(Generic[_H]): """A Scout object that yields :class:`zenoh.Hello` messages for discovered Zenoh nodes on the network. @@ -1440,6 +1465,7 @@ class Session: timestamp: Timestamp | None = None, allowed_destination: Locality | None = None, source_info: SourceInfo | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ): """Publish data directly from the session. @@ -1457,6 +1483,7 @@ class Session: timestamp: Timestamp | None = None, allowed_destination: Locality | None = None, source_info: SourceInfo | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ): """Publish a delete sample directly from the session. @@ -1482,6 +1509,7 @@ class Session: allowed_destination: Locality | None = None, source_info: SourceInfo | None = None, cancellation_token: CancellationToken | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ) -> Handler[Reply]: """Query data from the matching queryables in the system. @@ -1507,6 +1535,7 @@ class Session: allowed_destination: Locality | None = None, source_info: SourceInfo | None = None, cancellation_token: CancellationToken | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ) -> _H: """Query data from the matching queryables in the system. @@ -1532,6 +1561,7 @@ class Session: allowed_destination: Locality | None = None, source_info: SourceInfo | None = None, cancellation_token: CancellationToken | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ) -> None: """Query data from the matching queryables in the system. @@ -2128,6 +2158,142 @@ Used in :meth:`Timestamp.__new__` to accept various byte representations that can be converted to a :class:`TimestampId`. """ +@_unstable +@final +class InterceptionPoint(Enum): + """A point along a message's routing path where a timestamp is recorded.""" + + SEND = 0 + ROUTE = 1 + RECEIVE = 2 + UNKNOWN = 255 + +InterceptionPoint.SEND.__doc__ = """Timestamp recorded at the sending side (before transmission).""" +InterceptionPoint.ROUTE.__doc__ = """Timestamp recorded at the routing layer.""" +InterceptionPoint.RECEIVE.__doc__ = """Timestamp recorded at the receiving side (on delivery).""" +InterceptionPoint.UNKNOWN.__doc__ = """Catch-all for future variants added by the Rust core.""" + +@_unstable +@final +class TimestampContext: + """Context passed to a :class:`SessionTimestampCallback` when a timestamp is requested. + + Provides information about the session and the interception point where the timestamp is being collected. + """ + + @property + def zid(self) -> ZenohId: + """The ZenohId of the session that is generating the timestamp.""" + + @property + def whatami(self) -> WhatAmI: + """The mode (router/peer/client) of the session generating the timestamp.""" + + @property + def interception_point(self) -> InterceptionPoint: + """The routing stage at which this timestamp is being collected.""" + + def __repr__(self) -> str: ... + +SessionTimestampCallback = Callable[[TimestampContext], bytes] +"""A callable that receives a :class:`TimestampContext` and returns raw timestamp bytes. + +Used with :func:`open` to provide custom per-session timestamps at each interception point. +The returned bytes are stored verbatim in the :class:`TimestampStackRecord` and exposed via +:meth:`TimestampStackRecord.timestamp`. Use :meth:`TimestampStackRecord.as_timestamp` to +check whether the bytes decode as a UHLC :class:`Timestamp`. +""" + +@_unstable +@final +class TimestampInstrumentationBuilder: + """Builder for :class:`TimestampInstrumentation`. + + Construct one via :class:`TimestampInstrumentationBuilder()`, configure which interception + points to record, then call :meth:`build` to produce the final :class:`TimestampInstrumentation`. + """ + + def __new__(cls) -> Self: ... + def set_send(self, send: bool) -> Self: + """Enable or disable recording a timestamp at the SEND interception point.""" + + def set_route(self, route: bool) -> Self: + """Enable or disable recording a timestamp at the ROUTE interception point.""" + + def set_receive(self, receive: bool) -> Self: + """Enable or disable recording a timestamp at the RECEIVE interception point.""" + + def build(self) -> TimestampInstrumentation: + """Build the :class:`TimestampInstrumentation`. Raises :class:`ZError` if all flags are False.""" + + def __repr__(self) -> str: ... + +@_unstable +@final +class TimestampInstrumentation: + """Specifies which interception points should record timestamps for a message. + + Create one directly (keyword-only constructor) or via :class:`TimestampInstrumentationBuilder`: + + .. code-block:: python + + instr = zenoh.TimestampInstrumentation(send=True, receive=True) + instr = zenoh.TimestampInstrumentationBuilder().set_send(True).set_receive(True).build() + + Pass it to :meth:`Session.put`, :meth:`Session.delete`, :meth:`Publisher.put`, + :meth:`Publisher.delete`, :meth:`Session.get`, or :meth:`Querier.get`. + """ + + def __new__(cls, *, send: bool = False, route: bool = False, receive: bool = False) -> Self: ... + def is_instrumented(self, point: InterceptionPoint) -> bool: + """Returns True if the given interception point is enabled.""" + + def __repr__(self) -> str: ... + +@_unstable +@final +class TimestampStackRecord: + """A single timestamp entry in a :class:`TimestampStack`. + + Each record carries the interception point, whether the timestamp was generated by a + custom :data:`SessionTimestampCallback`, and the raw or UHLC timestamp value. + """ + + @property + def point(self) -> InterceptionPoint: + """The interception point at which this record was captured.""" + + @property + def is_custom(self) -> bool: + """True if the timestamp was generated by a custom :data:`SessionTimestampCallback`.""" + + def timestamp(self) -> Timestamp | bytes: + """Returns the timestamp as a :class:`Timestamp` (UHLC) or raw :class:`bytes` (custom).""" + + def as_timestamp(self) -> Timestamp | None: + """Returns the timestamp as a :class:`Timestamp`, or None if it is a custom bytes timestamp.""" + + def __repr__(self) -> str: ... + +@_unstable +@final +class TimestampStack: + """A stack of :class:`TimestampStackRecord` entries accumulated along a message's routing path. + + Accessible via :attr:`Sample.timestamp_stack`, :attr:`ReplyError.timestamp_stack`, + :attr:`Query.timestamp_stack`, and :attr:`Reply.timestamp_stack`. + """ + + @property + def instrumentation(self) -> TimestampInstrumentation: + """The instrumentation configuration that was active when this stack was created.""" + + @property + def records(self) -> list[TimestampStackRecord]: + """The list of timestamp records collected along the message's path.""" + + def __repr__(self) -> str: ... + @final class WhatAmI(Enum): """The type of the node in the Zenoh network. @@ -2260,7 +2426,9 @@ def init_log_from_env_or(level: str): For example, `RUST_LOG=debug` will set the log level to DEBUG. If `RUST_LOG` is not set, then logging is set to the provided level.""" -def open(config: Config) -> Session: +def open( + config: Config, *, timestamp_callback: SessionTimestampCallback | None = None +) -> Session: """Open a zenoh :class:`zenoh.Session`. For more information about sessions and configuration, see :ref:`session-and-config`. diff --git a/zenoh/ext.pyi b/zenoh/ext.pyi index 46676060..26e360aa 100644 --- a/zenoh/ext.pyi +++ b/zenoh/ext.pyi @@ -27,6 +27,7 @@ from zenoh import ( Session, Subscriber, Timestamp, + TimestampInstrumentation, ZBytes, handlers, ) @@ -164,6 +165,7 @@ class AdvancedPublisher: encoding: _IntoEncoding | None = None, attachment: _IntoZBytes | None = None, timestamp: Timestamp | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ): """Publish data to the key expression. See :meth:`zenoh.Publisher.put`.""" @@ -172,6 +174,7 @@ class AdvancedPublisher: *, attachment: _IntoZBytes | None = None, timestamp: Timestamp | None = None, + timestamp_instrumentation: TimestampInstrumentation | None = None, ): """Delete the value associated with the key expression. See :meth:`zenoh.Publisher.delete`."""