diff --git a/docs/concepts.rst b/docs/concepts.rst index 1dcf474d..c42172f3 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -115,6 +115,40 @@ Example: Declaring key expressions :start-after: [keyexpr_declare] :end-before: # [keyexpr_declare] +.. _path-parameters: + +Path Parameters +~~~~~~~~~~~~~~~ + +:class:`zenoh.KeFormat` lets you define key expression patterns with named path parameters, +similar to REST API path templates (e.g. ``/users/{id}``). You can build key expressions by +setting parameter values with a :class:`zenoh.KeFormatter`, or parse key expressions to extract +parameter values. + +The format syntax extends key expressions with specification chunks: ``${id:pattern}``, +``${id:pattern#default}``, and similar. For example, ``robot/${sensor_id:*}/reading`` defines +a format with a single parameter ``sensor_id`` that matches any single chunk (``*``). + +Example: Building and parsing key expressions with path parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. literalinclude:: examples/keyexpr_format.py + :language: python + :start-after: [keyexpr_format] + :end-before: # [keyexpr_format] + +Example: Using KeFormat with pub/sub +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In pub/sub, the publisher can build key expressions with :class:`zenoh.KeFormatter`, and the +subscriber can parse received :attr:`zenoh.Sample.key_expr` with :meth:`zenoh.KeFormat.parse` +to obtain the path parameter values: + +.. literalinclude:: examples/keyexpr_format_pubsub.py + :language: python + :start-after: [keyexpr_format_pubsub] + :end-before: # [keyexpr_format_pubsub] + .. _publish-subscribe: Publish/Subscribe diff --git a/docs/examples/keyexpr_format.py b/docs/examples/keyexpr_format.py new file mode 100644 index 00000000..5fec7303 --- /dev/null +++ b/docs/examples/keyexpr_format.py @@ -0,0 +1,14 @@ +import zenoh + +# [keyexpr_format] +fmt = zenoh.KeFormat("robot/${sensor_id:*}/reading") + +formatter = fmt.formatter() +formatter.set("sensor_id", "temperature") +key = formatter.build() +assert str(key) == "robot/temperature/reading" + +parsed = fmt.parse("robot/humidity/reading") +sensor_id = parsed.get("sensor_id") +assert sensor_id == "humidity" +# [keyexpr_format] diff --git a/docs/examples/keyexpr_format_pubsub.py b/docs/examples/keyexpr_format_pubsub.py new file mode 100644 index 00000000..beb7b42e --- /dev/null +++ b/docs/examples/keyexpr_format_pubsub.py @@ -0,0 +1,31 @@ +import threading +import time + +import zenoh + +# [keyexpr_format_pubsub] +fmt = zenoh.KeFormat("robot/${sensor_id:*}/reading") + +session = zenoh.open(zenoh.Config()) +received_sensor_ids = [] + + +def publisher_task(): + time.sleep(0.1) + for sensor_id in ("temperature", "humidity", "pressure"): + formatter = fmt.formatter() + formatter.set("sensor_id", sensor_id) + session.put(formatter.build(), f"reading from {sensor_id}") + + +subscriber = session.declare_subscriber("robot/*/reading") +threading.Thread(target=publisher_task, daemon=True).start() +for sample in subscriber: + parsed = fmt.parse(sample.key_expr) + received_sensor_ids.append(parsed.get("sensor_id")) + if len(received_sensor_ids) >= 3: + break + +session.close() +assert set(received_sensor_ids) == {"temperature", "humidity", "pressure"} +# [keyexpr_format_pubsub] diff --git a/src/key_expr_format.rs b/src/key_expr_format.rs new file mode 100644 index 00000000..8a9bcad4 --- /dev/null +++ b/src/key_expr_format.rs @@ -0,0 +1,133 @@ +// +// Copyright (c) 2024 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::collections::HashMap; + +use pyo3::prelude::*; + +use crate::{ + key_expr::KeyExpr, + utils::{IntoPyErr, IntoRust}, +}; + +#[pyclass] +pub(crate) struct KeFormat(pub(crate) String); + +#[pymethods] +impl KeFormat { + #[new] + pub(crate) fn new(format_spec: String) -> PyResult { + let format = zenoh::key_expr::format::KeFormat::new(&format_spec) + .map_err(IntoPyErr::into_pyerr)?; + // Validate format; we only need to know it parses. + drop(format); + Ok(Self(format_spec)) + } + + fn formatter(slf: PyRef) -> KeFormatter { + KeFormatter { + format_spec: slf.0.clone(), + values: HashMap::new(), + } + } + + fn parse(&self, key_expr: &Bound) -> PyResult { + let key_expr = KeyExpr::from_py(key_expr)?; + let key_expr_rust = key_expr.into_rust(); + let format = zenoh::key_expr::format::KeFormat::new(&self.0) + .map_err(IntoPyErr::into_pyerr)?; + let parsed = format + .parse(key_expr_rust.as_ref()) + .map_err(IntoPyErr::into_pyerr)?; + let values: HashMap = parsed + .iter() + .map(|(id, value)| { + ( + id.to_string(), + value.map(|v| v.to_string()).unwrap_or_default(), + ) + }) + .collect(); + Ok(Parsed { values }) + } + + fn __repr__(&self) -> String { + format!("KeFormat({:?})", self.0) + } + + fn __str__(&self) -> &str { + &self.0 + } +} + +#[pyclass] +pub(crate) struct Parsed { + pub(crate) values: HashMap, +} + +#[pymethods] +impl Parsed { + fn get(&self, id: &str) -> PyResult { + self.values + .get(id) + .cloned() + .ok_or_else(|| crate::ZError::new_err(format!("unknown parameter: {}", id))) + } + + fn __repr__(&self) -> String { + format!("Parsed({:?})", self.values) + } +} + +#[pyclass] +pub(crate) struct KeFormatter { + format_spec: String, + values: HashMap, +} + +#[pymethods] +impl KeFormatter { + #[pyo3(signature = (id, value))] + fn set(&mut self, id: &str, value: &str) -> PyResult<()> { + let format = zenoh::key_expr::format::KeFormat::new(&self.format_spec) + .map_err(IntoPyErr::into_pyerr)?; + let mut formatter = format.formatter(); + formatter.set(id, value).map_err(IntoPyErr::into_pyerr)?; + self.values.insert(id.to_string(), value.to_string()); + Ok(()) + } + + fn get(&self, id: &str) -> Option { + self.values.get(id).cloned() + } + + fn build(&self) -> PyResult { + let format = zenoh::key_expr::format::KeFormat::new(&self.format_spec) + .map_err(IntoPyErr::into_pyerr)?; + let mut formatter = format.formatter(); + for (id, value) in &self.values { + formatter.set(id, value).map_err(IntoPyErr::into_pyerr)?; + } + let key_expr = formatter.build().map_err(IntoPyErr::into_pyerr)?; + Ok(KeyExpr(key_expr.into())) + } + + fn clear(&mut self) -> () { + self.values.clear(); + } + + fn __repr__(&self) -> String { + format!("KeFormatter({:?})", self.format_spec) + } +} diff --git a/src/lib.rs b/src/lib.rs index 09235cce..5a228566 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ mod config; mod ext; mod handlers; mod key_expr; +mod key_expr_format; mod liveliness; mod macros; mod matching; @@ -62,6 +63,7 @@ pub(crate) mod zenoh { config::{Config, WhatAmI, WhatAmIMatcher, ZenohId}, handlers::Handler, key_expr::{KeyExpr, SetIntersectionLevel}, + key_expr_format::{KeFormat, KeFormatter, Parsed}, liveliness::{Liveliness, LivelinessToken}, matching::{MatchingListener, MatchingStatus}, pubsub::{Publisher, Subscriber}, diff --git a/zenoh/__init__.pyi b/zenoh/__init__.pyi index 5fe200e5..60885302 100644 --- a/zenoh/__init__.pyi +++ b/zenoh/__init__.pyi @@ -481,6 +481,66 @@ class KeyExpr: def __str__(self) -> str: ... + +@final +class KeFormat: + """Key expression format with path parameters (named chunks). + + Similar to REST API path templates (e.g. ``/users/{id}``), KeFormat lets you define + key expression patterns with named parameters. Use :meth:`formatter` to build key + expressions by setting parameter values, or :meth:`parse` to extract parameter + values from a key expression. + + Format syntax extends key expressions with specification chunks: + ``${id:pattern}``, ``${id:pattern#default}``, ``$#{id:pattern}#``, etc. + For example: ``robot/${sensor_id:*}/reading``. + """ + + def __new__(cls, format_spec: str) -> Self: + """Creates a new KeFormat from a format specification string. + Raises :exc:`ZError` if the format_spec is invalid. + """ + + def formatter(self) -> KeFormatter: + """Constructs a new formatter for building key expressions from this format.""" + + def parse(self, key_expr: _IntoKeyExpr) -> Parsed: + """Parses a key expression according to this format and returns the extracted parameter values. + Raises :exc:`ZError` if the key expression does not match the format.""" + + def __str__(self) -> str: ... + + +@final +class KeFormatter: + """Builder for key expressions from a :class:`KeFormat`. + Set parameter values with :meth:`set`, then call :meth:`build` to produce a :class:`KeyExpr`.""" + + def set(self, id: str, value: str) -> None: + """Sets the value for the given parameter id. + Raises :exc:`ZError` if the value does not match the parameter pattern.""" + + def get(self, id: str) -> str | None: + """Returns the current value for the given parameter id, or None if not set.""" + + def build(self) -> KeyExpr: + """Builds a KeyExpr from the format and the currently set parameter values. + Raises :exc:`ZError` if any required parameter is missing or a value is invalid.""" + + def clear(self) -> None: + """Clears all set parameter values from this formatter.""" + + +@final +class Parsed: + """Result of parsing a key expression with :meth:`KeFormat.parse`. + Holds the extracted parameter values.""" + + def get(self, id: str) -> str: + """Returns the value for the given parameter id. + Raises :exc:`ZError` if id is not a parameter in the format.""" + + _IntoKeyExpr = KeyExpr | str @final