Repository: mitsuhiko/minijinja (language: rust)

Issue:
Support adding methods to objects
Hello all,

I am using `minijinja` to implement chat templating for LLMs. However, some templates call the `.strip()` method and I cannot find a way to inject this object. Is this possible to do? Thank you!


Discussion / hints:
It's quite intentional that methods of Python objects are not exposed. For the same reason `|items` is used rather than `.items()` and a filter has been added to Jinja2 to match the two engines. For `.strip()` there is a `|trim` filter which has the same behavior.

```jinja
{{ " foo "|trim }}
```

Closing this as out of scope.
See also https://github.com/pallets/jinja/issues/1561
Thank you!
On further reconsideration I do think it might be useful to add a callback to the environment so that someone could on their own fill in the blanks if they have a need.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f72dcd3..5db9ced5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,11 @@ All notable changes to MiniJinja are documented here.
 - Fixed an incorrect error case in `call_method`.  Now `UnknownMethod`
   is returned instead of `InvalidOperation` correctly. #439
 
+- Added `Environment::set_unknown_method_callback` which allows a user
+  to intercept method calls on primitives.  The motivation here is that
+  this can be used to implement python like methods to improve the
+  compatibility with Jinja2 Python templates.  #441
+
 ## 1.0.14
 
 - Fixed a bug with broken closure handling when working with nested
diff --git a/minijinja/src/environment.rs b/minijinja/src/environment.rs
index 97966775..c0353c68 100644
--- a/minijinja/src/environment.rs
+++ b/minijinja/src/environment.rs
@@ -20,6 +20,8 @@ use crate::{defaults, filters, functions, tests};
 
 type FormatterFunc = dyn Fn(&mut Output, &State, &Value) -> Result<(), Error> + Sync + Send;
 type PathJoinFunc = dyn for<'s> Fn(&'s str, &'s str) -> Cow<'s, str> + Sync + Send;
+type UnknownMethodFunc =
+    dyn Fn(&State, &Value, &str, &[Value]) -> Result<Value, Error> + Sync + Send;
 
 /// The maximum recursion in the VM.  Normally each stack frame
 /// adds one to this counter (eg: every time a frame is added).
@@ -50,6 +52,7 @@ pub struct Environment<'source> {
     tests: BTreeMap<Cow<'source, str>, tests::BoxedTest>,
     globals: BTreeMap<Cow<'source, str>, Value>,
     path_join_callback: Option<Arc<PathJoinFunc>>,
+    pub(crate) unknown_method_callback: Option<Arc<UnknownMethodFunc>>,
     undefined_behavior: UndefinedBehavior,
     formatter: Arc<FormatterFunc>,
     #[cfg(feature = "debug")]
@@ -92,6 +95,7 @@ impl<'source> Environment<'source> {
             tests: defaults::get_builtin_tests(),
             globals: defaults::get_globals(),
             path_join_callback: None,
+            unknown_method_callback: None,
             undefined_behavior: UndefinedBehavior::default(),
             formatter: Arc::new(defaults::escape_formatter),
             #[cfg(feature = "debug")]
@@ -113,6 +117,7 @@ impl<'source> Environment<'source> {
             tests: Default::default(),
             globals: Default::default(),
             path_join_callback: None,
+            unknown_method_callback: None,
             undefined_behavior: UndefinedBehavior::default(),
             formatter: Arc::new(defaults::escape_formatter),
             #[cfg(feature = "debug")]
@@ -257,6 +262,42 @@ impl<'source> Environment<'source> {
         self.path_join_callback = Some(Arc::new(f));
     }
 
+    /// Sets a callback invoked for unknown methods on objects.
+    ///
+    /// This registers a function with the environment that is invoked when invoking a method
+    /// on a value results in a [`UnknownMethod`](crate::ErrorKind::UnknownMethod) error.
+    /// In that case the callback is invoked with [`State`], the [`Value`], the name of
+    /// the method as `&str` as well as all arguments in a slice.
+    ///
+    /// This for instance implements a `.items()` method that invokes the `|items` filter:
+    ///
+    /// ```rust
+    /// use minijinja::value::{ValueKind, from_args};
+    /// use minijinja::{Error, ErrorKind};
+    /// # let mut env = minijinja::Environment::new();
+    ///
+    /// env.set_unknown_method_callback(|state, value, method, args| {
+    ///     if value.kind() == ValueKind::Map && method == "items" {
+    ///         let _: () = from_args(args)?;
+    ///         state.apply_filter("items", &[value.clone()])
+    ///     } else {
+    ///         Err(Error::new(
+    ///             ErrorKind::UnknownMethod,
+    ///             "object has no method named {name}",
+    ///         ))
+    ///     }
+    /// });
+    /// ```
+    ///
+    /// This can be used to increase the compatibility with Jinja2 templates that might
+    /// call Python methods on objects which are not available in minijinja.
+    pub fn set_unknown_method_callback<F>(&mut self, f: F)
+    where
+        F: Fn(&State, &Value, &str, &[Value]) -> Result<Value, Error> + Sync + Send + 'static,
+    {
+        self.unknown_method_callback = Some(Arc::new(f));
+    }
+
     /// Removes all stored templates.
     ///
     /// This method is mainly useful when combined with a loader as it causes
diff --git a/minijinja/src/value/mod.rs b/minijinja/src/value/mod.rs
index 3dab0e03..4f1b1126 100644
--- a/minijinja/src/value/mod.rs
+++ b/minijinja/src/value/mod.rs
@@ -1166,6 +1166,20 @@ impl Value {
     /// The name of the method is `name`, the arguments passed are in the `args`
     /// slice.
     pub fn call_method(&self, state: &State, name: &str, args: &[Value]) -> Result<Value, Error> {
+        match self._call_method(state, name, args) {
+            Ok(rv) => Ok(rv),
+            Err(err) => {
+                if err.kind() == ErrorKind::UnknownMethod {
+                    if let Some(ref callback) = state.env().unknown_method_callback {
+                        return callback(state, self, name, args);
+                    }
+                }
+                Err(err)
+            }
+        }
+    }
+
+    fn _call_method(&self, state: &State, name: &str, args: &[Value]) -> Result<Value, Error> {
         match self.0 {
             ValueRepr::Dynamic(ref dy) => return dy.call_method(state, name, args),
             ValueRepr::Map(ref map, _) => {
diff --git a/minijinja/src/value/object.rs b/minijinja/src/value/object.rs
index b8293eba..740f1674 100644
--- a/minijinja/src/value/object.rs
+++ b/minijinja/src/value/object.rs
@@ -46,7 +46,8 @@ pub trait Object: fmt::Display + fmt::Debug + Any + Sync + Send {
     /// Called when the engine tries to call a method on the object.
     ///
     /// It's the responsibility of the implementer to ensure that an
-    /// error is generated if an invalid method is invoked.
+    /// error is generated if an invalid method is invoked.  If the method
+    /// is not known an [`ErrorKind::UnknownMethod`] error must be returned.
     ///
     /// To convert the arguments into arguments use the
     /// [`from_args`](crate::value::from_args) function.


Test patch:
diff --git a/minijinja/tests/test_environment.rs b/minijinja/tests/test_environment.rs
index 15cee1fe..823dffeb 100644
--- a/minijinja/tests/test_environment.rs
+++ b/minijinja/tests/test_environment.rs
@@ -1,9 +1,10 @@
 use std::collections::BTreeMap;
 
+use insta::assert_snapshot;
 use similar_asserts::assert_eq;
 
-use minijinja::value::Value;
-use minijinja::Environment;
+use minijinja::value::{from_args, Value, ValueKind};
+use minijinja::{Environment, Error, ErrorKind};
 
 #[test]
 fn test_basic() {
@@ -149,3 +150,22 @@ fn test_keep_trailing_newlines() {
     );
     assert_eq!(env.render_str("blub\r\n", ()).unwrap(), "blub\r\n");
 }
+
+#[test]
+fn test_unknown_method_callback() {
+    let mut env = Environment::new();
+    env.set_unknown_method_callback(|_state, value, method, args| {
+        if value.kind() == ValueKind::Map && method == "items" {
+            let _: () = from_args(args)?;
+            minijinja::filters::items(value.clone())
+        } else {
+            Err(Error::new(
+                ErrorKind::UnknownMethod,
+                "object has no method named {name}",
+            ))
+        }
+    });
+
+    let rv = env.render_str("{{ {'x': 42}.items() }}", ()).unwrap();
+    assert_snapshot!(rv, @r###"[["x", 42]]"###);
+}