Repository: extendr/extendr
Pull request #861 (2024-12-13 21:54:06)

Hotfix: Mishandling `NULL` pointer
In #853, type-checking was added to `ExternalPtr<T>`. Previously, there were issues with processing `NULL` pointers, that we covered through a test. This test resided in `test-classes.R` of `extendrtests`. While the test still logically made sense after #853, the test would _panic_. For some reason, the `panic!` (due to an `unwrap()`) would not fail the test. We believe that this was unnoticed, because there was no signal of failure, outside the print log. The print log of R tests are not visible in the CI.

Now, the case of `NULL` pointer is handled in the type-checking code. It was an oversight, made possible by me being overly naive. But now, it is handled properly.

This is not a critical issue, but many exotic extendr code relies on passing of `NULL` pointers, so in a sense, it is critical to advanced use-cases. 



JosiahParry: This looks good. Any idea why the cargo msrv check is failing?
CGMossa: I don't know what is going on with CI. It works locally. The new version of `cargo-msrv` have changed their print

```sh
extendr-main on  nightly_crash [$] is 📦 v0.7.1 via 🦀 v1.83.0 took 1m45s 
❯ cargo extendr msrv             
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/xtask msrv`
$ cargo-msrv --path extendr-api verify
  [Meta]   cargo-msrv 0.17.1                                                                                                       

Compatibility Check #1: Rust 1.65.0
  [OK]     Is compatible 

extendr-main on  nightly_crash [$] is 📦 v0.7.1 via 🦀 v1.83.0 took 2s 
❯ echo $?
0

```

The local version that still worked is
```sh

extendr-main on  nightly_crash [$] is 📦 v0.7.1 via 🦀 v1.83.0 took 8s 
❯ cargo msrv --version
cargo-msrv 0.15.1
```


CGMossa: @JosiahParry I've updated the top post here. I think this should be merged regardless of the MSRV CI failure. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65564f45a..ae61f4581 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 - `ExternalPtr<T>` and `&ExternalPtr<T>` can now be used as function arguments with appropriate type checking <https://github.com/extendr/extendr/pull/853>
 - `TryFrom<Robj>` is now implemented for `&ExternalPtr<T>` and `&mut ExternalPtr<T>` <https://github.com/extendr/extendr/pull/833>
 - `Option<T>` can now be returned from `#[extendr]` annotated functions for extendr_api types. `None` is translated into `NULL` value in R [[#802]](https://github.com/extendr/extendr/pull/802)
+- Conversion of a `Robj` that contains a `list()`/`List` to a compatible tuple `(T0, ..., Tn)`, where `n` is atmost 12 [[#857]](https://github.com/extendr/extendr/pull/857)
 
 ### Changed
 
diff --git a/extendr-api/src/error.rs b/extendr-api/src/error.rs
index 38d5a2f0f..2b6d0770c 100644
--- a/extendr-api/src/error.rs
+++ b/extendr-api/src/error.rs
@@ -73,6 +73,7 @@ pub enum Error {
     MustNotBeNA(Robj),
     ExpectedWholeNumber(Robj, ConversionError),
     ExpectedNonZeroLength(Robj),
+    ExpectedLength(usize),
     OutOfLimits(Robj),
     TypeMismatch(Robj),
     NamespaceNotFound(Robj),
@@ -150,6 +151,7 @@ impl std::fmt::Display for Error {
             Error::OutOfRange(_robj) => write!(f, "Out of range."),
             Error::MustNotBeNA(_robj) => write!(f, "Must not be NA."),
             Error::ExpectedNonZeroLength(_robj) => write!(f, "Expected non zero length"),
+            Error::ExpectedLength(len) => write!(f, "Expected length: {len}"),
             Error::OutOfLimits(robj) => write!(f, "The value is too big: {:?}", robj),
             Error::TypeMismatch(_robj) => write!(f, "Type mismatch"),
 
diff --git a/extendr-api/src/robj/try_from_robj.rs b/extendr-api/src/robj/try_from_robj.rs
index 15ba6f242..420ca73dd 100644
--- a/extendr-api/src/robj/try_from_robj.rs
+++ b/extendr-api/src/robj/try_from_robj.rs
@@ -579,3 +579,5 @@ impl TryFrom<&Robj> for HashMap<&str, Robj> {
             .collect::<HashMap<&str, Robj>>())
     }
 }
+// Choosing arity 12.. As the Rust compiler did for these [Tuple to array conversion](https://doc.rust-lang.org/stable/std/primitive.tuple.html#trait-implementations-1)
+impl_try_from_robj_tuples!((1, 12));
diff --git a/extendr-api/src/wrapper/externalptr.rs b/extendr-api/src/wrapper/externalptr.rs
index 1785dbc3f..28865b68e 100644
--- a/extendr-api/src/wrapper/externalptr.rs
+++ b/extendr-api/src/wrapper/externalptr.rs
@@ -247,7 +247,7 @@ impl<T: 'static> TryFrom<&Robj> for &ExternalPtr<T> {
                 .external_ptr_addr::<Box<dyn Any>>()
                 .cast_const()
                 .as_ref()
-                .unwrap()
+                .ok_or_else(|| Error::ExpectedExternalNonNullPtr(value.clone()))?
         };
 
         if boxed_ptr.downcast_ref::<T>().is_none() {
@@ -275,7 +275,7 @@ impl<T: 'static> TryFrom<&mut Robj> for &mut ExternalPtr<T> {
                 .external_ptr_addr::<Box<dyn Any>>()
                 .cast_const()
                 .as_ref()
-                .unwrap()
+                .ok_or_else(|| Error::ExpectedExternalNonNullPtr(value.clone()))?
         };
 
         if boxed_ptr.downcast_ref::<T>().is_none() {
diff --git a/extendr-macros/src/lib.rs b/extendr-macros/src/lib.rs
index ad9a064cc..8815d54ee 100644
--- a/extendr-macros/src/lib.rs
+++ b/extendr-macros/src/lib.rs
@@ -299,3 +299,94 @@ pub fn derive_into_robj(item: TokenStream) -> TokenStream {
 pub fn derive_into_dataframe(item: TokenStream) -> TokenStream {
     dataframe::derive_into_dataframe(item)
 }
+
+#[proc_macro]
+pub fn impl_try_from_robj_tuples(input: TokenStream) -> TokenStream {
+    let range = parse_macro_input!(input as syn::ExprTuple);
+    let start = match &range.elems[0] {
+        syn::Expr::Lit(syn::ExprLit {
+            lit: syn::Lit::Int(lit),
+            ..
+        }) => lit.base10_parse::<usize>().unwrap(),
+        _ => {
+            return TokenStream::from(quote!(compile_error!(
+                "Expected integer literal for `start`"
+            )))
+        }
+    };
+    let end = match &range.elems[1] {
+        syn::Expr::Lit(syn::ExprLit {
+            lit: syn::Lit::Int(lit),
+            ..
+        }) => lit.base10_parse::<usize>().unwrap(),
+        _ => {
+            return TokenStream::from(quote!(compile_error!("Expected integer literal for `end`")))
+        }
+    };
+
+    TokenStream::from_iter((start..=end).map(|n| {
+        let types: Vec<_> = (0..n).map(|i| quote::format_ident!("T{}", i)).collect();
+        let indices = 0..n;
+        let element_extraction = indices.map(|idx| {
+            quote! {
+                (&list.elt(#idx)?).try_into()?
+            }
+        });
+
+        TokenStream::from(quote! {
+            impl<#(#types),*> TryFrom<&Robj> for (#(#types,)*)
+            where
+                #(#types: for<'a> TryFrom<&'a Robj, Error = crate::error::Error>),*
+            {
+                type Error = Error;
+
+                fn try_from(robj: &Robj) -> Result<Self> {
+                    let list: List = robj.try_into()?;
+                    if list.len() != #n {
+                        return Err(Error::ExpectedLength(#n));
+                    }
+                    Ok((
+                        #(#element_extraction),*
+                    ))
+                }
+            }
+
+            // TODO: the following impls are borrowed from `impl_try_from_robj`
+            // find a way to reuse that code, possibly
+
+            impl<#(#types),*> TryFrom<Robj> for (#(#types,)*)
+            where
+                #(#types: for<'a> TryFrom<&'a Robj, Error = crate::error::Error>),* {
+                type Error = Error;
+
+                fn try_from(robj: Robj) -> Result<Self> {
+                    Self::try_from(&robj)
+                }
+            }
+
+            impl<#(#types),*> TryFrom<&Robj> for Option<(#(#types,)*)>
+            where
+            #(#types: for<'a> TryFrom<&'a Robj, Error = crate::error::Error>),*{
+                type Error = Error;
+
+                fn try_from(robj: &Robj) -> Result<Self> {
+                    if robj.is_null() || robj.is_na() {
+                        Ok(None)
+                    } else {
+                        Ok(Some(<(#(#types,)*)>::try_from(robj)?))
+                    }
+                }
+            }
+
+            impl<#(#types),*> TryFrom<Robj> for Option<(#(#types,)*)>
+            where
+            #(#types: for<'a> TryFrom<&'a Robj, Error = crate::error::Error>),*{
+                type Error = Error;
+
+                fn try_from(robj: Robj) -> Result<Self> {
+                    Self::try_from(&robj)
+                }
+            }
+        })
+    }))
+}


Test patch:
diff --git a/tests/extendrtests/DESCRIPTION b/tests/extendrtests/DESCRIPTION
index 579f90aed..083e15b2f 100644
--- a/tests/extendrtests/DESCRIPTION
+++ b/tests/extendrtests/DESCRIPTION
@@ -28,4 +28,4 @@ Suggests:
     lobstr,
     rprojroot
 Config/testthat/edition: 3
-Config/rextendr/version: 0.3.1.9000
+Config/rextendr/version: 0.3.1.9001
diff --git a/tests/extendrtests/R/extendr-wrappers.R b/tests/extendrtests/R/extendr-wrappers.R
index 61ba086fb..ddd434a04 100644
--- a/tests/extendrtests/R/extendr-wrappers.R
+++ b/tests/extendrtests/R/extendr-wrappers.R
@@ -145,6 +145,16 @@ false <- function(type) .Call(wrap__false, type)
 #' @export
 hello_submodule <- function() .Call(wrap__hello_submodule)
 
+sum_triplet_ints <- function(x) .Call(wrap__sum_triplet_ints, x)
+
+sum_points <- function(x) .Call(wrap__sum_points, x)
+
+middle_zero <- function(integers) invisible(.Call(wrap__middle_zero, integers))
+
+logicals_sum <- function(logicals) .Call(wrap__logicals_sum, logicals)
+
+floats_mean <- function(x) .Call(wrap__floats_mean, x)
+
 create_numeric_externalptr <- function(x) .Call(wrap__create_numeric_externalptr, x)
 
 sum_integer_externalptr <- function(x) .Call(wrap__sum_integer_externalptr, x)
@@ -157,12 +167,6 @@ fetch_rownames <- function(x) .Call(wrap__fetch_rownames, x)
 
 change_dimnames <- function(x) .Call(wrap__change_dimnames, x)
 
-middle_zero <- function(integers) invisible(.Call(wrap__middle_zero, integers))
-
-logicals_sum <- function(logicals) .Call(wrap__logicals_sum, logicals)
-
-floats_mean <- function(x) .Call(wrap__floats_mean, x)
-
 to_unique_rstr <- function(r_char_vec) .Call(wrap__to_unique_rstr, r_char_vec)
 
 to_unique_str <- function(r_char_vec) .Call(wrap__to_unique_str, r_char_vec)
diff --git a/tests/extendrtests/src/rust/src/lib.rs b/tests/extendrtests/src/rust/src/lib.rs
index 6d7a65e9e..882089ed7 100644
--- a/tests/extendrtests/src/rust/src/lib.rs
+++ b/tests/extendrtests/src/rust/src/lib.rs
@@ -13,6 +13,7 @@ mod optional_ndarray;
 mod raw_identifiers;
 mod submodule;
 mod to_unique_character;
+mod tuple_conversions;
 mod typedsliceargs;
 
 // Return string `"Hello world!"` to R.
@@ -354,8 +355,9 @@ extendr_module! {
     use optional_faer;
     use raw_identifiers;
     use submodule;
+    use tuple_conversions;
+    use typedsliceargs;
     use externalptr;
     use matrix;
-    use typedsliceargs;
     use to_unique_character;
 }
diff --git a/tests/extendrtests/src/rust/src/tuple_conversions.rs b/tests/extendrtests/src/rust/src/tuple_conversions.rs
new file mode 100644
index 000000000..54c10871c
--- /dev/null
+++ b/tests/extendrtests/src/rust/src/tuple_conversions.rs
@@ -0,0 +1,42 @@
+use extendr_api::prelude::*;
+
+#[extendr]
+fn sum_triplet_ints(x: (i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32)) -> Rint {
+    Rint::from(x.0 + x.1)
+}
+
+#[derive(Debug, Clone)]
+pub struct Point {
+    x: f64,
+    y: f64,
+}
+
+impl TryFrom<Robj> for Point {
+    type Error = extendr_api::Error;
+    fn try_from(value: Robj) -> std::result::Result<Self, Self::Error> {
+        let dbl_vec = Doubles::try_from(value)?;
+        let x = dbl_vec[0].inner();
+        let y = dbl_vec[1].inner();
+        Ok(Point { x, y })
+    }
+}
+
+impl TryFrom<&Robj> for Point {
+    type Error = extendr_api::Error;
+    fn try_from(value: &Robj) -> std::result::Result<Self, Self::Error> {
+        value.clone().try_into()
+    }
+}
+
+#[extendr]
+fn sum_points(x: (Point, Point)) -> Doubles {
+    let Point { x: x1, y: y1 } = x.0;
+    let Point { x: x2, y: y2 } = x.1;
+    Doubles::from_values([x1 + x2, y1 + y2])
+}
+
+extendr_module! {
+    mod tuple_conversions;
+    fn sum_triplet_ints;
+    fn sum_points;
+}
diff --git a/tests/extendrtests/tests/testthat/_snaps/macro-snapshot.md b/tests/extendrtests/tests/testthat/_snaps/macro-snapshot.md
index c614db185..db0bae70b 100644
--- a/tests/extendrtests/tests/testthat/_snaps/macro-snapshot.md
+++ b/tests/extendrtests/tests/testthat/_snaps/macro-snapshot.md
@@ -6269,6 +6269,309 @@
               };
           }
       }
+      mod tuple_conversions {
+          use extendr_api::prelude::*;
+          fn sum_triplet_ints(
+              x: (i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32),
+          ) -> Rint {
+              Rint::from(x.0 + x.1)
+          }
+          #[no_mangle]
+          #[allow(non_snake_case, clippy::not_unsafe_ptr_arg_deref)]
+          pub extern "C" fn wrap__sum_triplet_ints(x: extendr_api::SEXP) -> extendr_api::SEXP {
+              use extendr_api::robj::*;
+              let wrap_result_state: std::result::Result<
+                  std::result::Result<extendr_api::Robj, extendr_api::Error>,
+                  Box<dyn std::any::Any + Send>,
+              > = unsafe {
+                  std::panic::catch_unwind(
+                      std::panic::AssertUnwindSafe(move || -> std::result::Result<
+                          extendr_api::Robj,
+                          extendr_api::Error,
+                      > {
+                          let _x_robj = extendr_api::robj::Robj::from_sexp(x);
+                          Ok(extendr_api::Robj::from(sum_triplet_ints(_x_robj.try_into()?)))
+                      }),
+                  )
+              };
+              match wrap_result_state {
+                  Ok(Ok(zz)) => {
+                      return unsafe { zz.get() };
+                  }
+                  Ok(Err(conversion_err)) => {
+                      let err_string = conversion_err.to_string();
+                      drop(conversion_err);
+                      extendr_api::throw_r_error(&err_string);
+                  }
+                  Err(unwind_err) => {
+                      drop(unwind_err);
+                      let err_string = ::alloc::__export::must_use({
+                          let res = ::alloc::fmt::format(
+                              format_args!("User function panicked: {0}", "sum_triplet_ints"),
+                          );
+                          res
+                      });
+                      extendr_api::handle_panic(
+                          err_string.as_str(),
+                          || {
+                              #[cold]
+                              #[track_caller]
+                              #[inline(never)]
+                              const fn panic_cold_explicit() -> ! {
+                                  ::core::panicking::panic_explicit()
+                              }
+                              panic_cold_explicit();
+                          },
+                      );
+                  }
+              }
+              {
+                  ::core::panicking::panic_fmt(
+                      format_args!(
+                          "internal error: entered unreachable code: {0}",
+                          format_args!("internal extendr error, this should never happen."),
+                      ),
+                  );
+              }
+          }
+          #[allow(non_snake_case)]
+          fn meta__sum_triplet_ints(metadata: &mut Vec<extendr_api::metadata::Func>) {
+              let args = <[_]>::into_vec(
+                  #[rustc_box]
+                  ::alloc::boxed::Box::new([
+                      extendr_api::metadata::Arg {
+                          name: "x",
+                          arg_type: "_28i32_2ci32_2ci32_2ci32_2ci32_2ci32_2ci32_2ci32_2ci32_2ci32_2ci32_2ci32_29",
+                          default: None,
+                      },
+                  ]),
+              );
+              metadata
+                  .push(extendr_api::metadata::Func {
+                      doc: "",
+                      rust_name: "sum_triplet_ints",
+                      r_name: "sum_triplet_ints",
+                      mod_name: "sum_triplet_ints",
+                      args: args,
+                      return_type: "Rint",
+                      func_ptr: wrap__sum_triplet_ints as *const u8,
+                      hidden: false,
+                  })
+          }
+          pub struct Point {
+              x: f64,
+              y: f64,
+          }
+          #[automatically_derived]
+          impl ::core::fmt::Debug for Point {
+              #[inline]
+              fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
+                  ::core::fmt::Formatter::debug_struct_field2_finish(
+                      f,
+                      "Point",
+                      "x",
+                      &self.x,
+                      "y",
+                      &&self.y,
+                  )
+              }
+          }
+          #[automatically_derived]
+          impl ::core::clone::Clone for Point {
+              #[inline]
+              fn clone(&self) -> Point {
+                  Point {
+                      x: ::core::clone::Clone::clone(&self.x),
+                      y: ::core::clone::Clone::clone(&self.y),
+                  }
+              }
+          }
+          impl TryFrom<Robj> for Point {
+              type Error = extendr_api::Error;
+              fn try_from(value: Robj) -> std::result::Result<Self, Self::Error> {
+                  let dbl_vec = Doubles::try_from(value)?;
+                  let x = dbl_vec[0].inner();
+                  let y = dbl_vec[1].inner();
+                  Ok(Point { x, y })
+              }
+          }
+          impl TryFrom<&Robj> for Point {
+              type Error = extendr_api::Error;
+              fn try_from(value: &Robj) -> std::result::Result<Self, Self::Error> {
+                  value.clone().try_into()
+              }
+          }
+          fn sum_points(x: (Point, Point)) -> Doubles {
+              let Point { x: x1, y: y1 } = x.0;
+              let Point { x: x2, y: y2 } = x.1;
+              Doubles::from_values([x1 + x2, y1 + y2])
+          }
+          #[no_mangle]
+          #[allow(non_snake_case, clippy::not_unsafe_ptr_arg_deref)]
+          pub extern "C" fn wrap__sum_points(x: extendr_api::SEXP) -> extendr_api::SEXP {
+              use extendr_api::robj::*;
+              let wrap_result_state: std::result::Result<
+                  std::result::Result<extendr_api::Robj, extendr_api::Error>,
+                  Box<dyn std::any::Any + Send>,
+              > = unsafe {
+                  std::panic::catch_unwind(
+                      std::panic::AssertUnwindSafe(move || -> std::result::Result<
+                          extendr_api::Robj,
+                          extendr_api::Error,
+                      > {
+                          let _x_robj = extendr_api::robj::Robj::from_sexp(x);
+                          Ok(extendr_api::Robj::from(sum_points(_x_robj.try_into()?)))
+                      }),
+                  )
+              };
+              match wrap_result_state {
+                  Ok(Ok(zz)) => {
+                      return unsafe { zz.get() };
+                  }
+                  Ok(Err(conversion_err)) => {
+                      let err_string = conversion_err.to_string();
+                      drop(conversion_err);
+                      extendr_api::throw_r_error(&err_string);
+                  }
+                  Err(unwind_err) => {
+                      drop(unwind_err);
+                      let err_string = ::alloc::__export::must_use({
+                          let res = ::alloc::fmt::format(
+                              format_args!("User function panicked: {0}", "sum_points"),
+                          );
+                          res
+                      });
+                      extendr_api::handle_panic(
+                          err_string.as_str(),
+                          || {
+                              #[cold]
+                              #[track_caller]
+                              #[inline(never)]
+                              const fn panic_cold_explicit() -> ! {
+                                  ::core::panicking::panic_explicit()
+                              }
+                              panic_cold_explicit();
+                          },
+                      );
+                  }
+              }
+              {
+                  ::core::panicking::panic_fmt(
+                      format_args!(
+                          "internal error: entered unreachable code: {0}",
+                          format_args!("internal extendr error, this should never happen."),
+                      ),
+                  );
+              }
+          }
+          #[allow(non_snake_case)]
+          fn meta__sum_points(metadata: &mut Vec<extendr_api::metadata::Func>) {
+              let args = <[_]>::into_vec(
+                  #[rustc_box]
+                  ::alloc::boxed::Box::new([
+                      extendr_api::metadata::Arg {
+                          name: "x",
+                          arg_type: "_28Point_2cPoint_29",
+                          default: None,
+                      },
+                  ]),
+              );
+              metadata
+                  .push(extendr_api::metadata::Func {
+                      doc: "",
+                      rust_name: "sum_points",
+                      r_name: "sum_points",
+                      mod_name: "sum_points",
+                      args: args,
+                      return_type: "Doubles",
+                      func_ptr: wrap__sum_points as *const u8,
+                      hidden: false,
+                  })
+          }
+          #[no_mangle]
+          #[allow(non_snake_case)]
+          pub fn get_tuple_conversions_metadata() -> extendr_api::metadata::Metadata {
+              let mut functions = Vec::new();
+              let mut impls = Vec::new();
+              meta__sum_triplet_ints(&mut functions);
+              meta__sum_points(&mut functions);
+              functions
+                  .push(extendr_api::metadata::Func {
+                      doc: "Metadata access function.",
+                      rust_name: "get_tuple_conversions_metadata",
+                      mod_name: "get_tuple_conversions_metadata",
+                      r_name: "get_tuple_conversions_metadata",
+                      args: Vec::new(),
+                      return_type: "Metadata",
+                      func_ptr: wrap__get_tuple_conversions_metadata as *const u8,
+                      hidden: true,
+                  });
+              functions
+                  .push(extendr_api::metadata::Func {
+                      doc: "Wrapper generator.",
+                      rust_name: "make_tuple_conversions_wrappers",
+                      mod_name: "make_tuple_conversions_wrappers",
+                      r_name: "make_tuple_conversions_wrappers",
+                      args: <[_]>::into_vec(
+                          #[rustc_box]
+                          ::alloc::boxed::Box::new([
+                              extendr_api::metadata::Arg {
+                                  name: "use_symbols",
+                                  arg_type: "bool",
+                                  default: None,
+                              },
+                              extendr_api::metadata::Arg {
+                                  name: "package_name",
+                                  arg_type: "&str",
+                                  default: None,
+                              },
+                          ]),
+                      ),
+                      return_type: "String",
+                      func_ptr: wrap__make_tuple_conversions_wrappers as *const u8,
+                      hidden: true,
+                  });
+              extendr_api::metadata::Metadata {
+                  name: "tuple_conversions",
+                  functions,
+                  impls,
+              }
+          }
+          #[no_mangle]
+          #[allow(non_snake_case)]
+          pub extern "C" fn wrap__get_tuple_conversions_metadata() -> extendr_api::SEXP {
+              use extendr_api::GetSexp;
+              unsafe { extendr_api::Robj::from(get_tuple_conversions_metadata()).get() }
+          }
+          #[no_mangle]
+          #[allow(non_snake_case, clippy::not_unsafe_ptr_arg_deref)]
+          pub extern "C" fn wrap__make_tuple_conversions_wrappers(
+              use_symbols_sexp: extendr_api::SEXP,
+              package_name_sexp: extendr_api::SEXP,
+          ) -> extendr_api::SEXP {
+              unsafe {
+                  use extendr_api::robj::*;
+                  use extendr_api::GetSexp;
+                  let robj = Robj::from_sexp(use_symbols_sexp);
+                  let use_symbols: bool = <bool>::try_from(&robj).unwrap();
+                  let robj = Robj::from_sexp(package_name_sexp);
+                  let package_name: &str = <&str>::try_from(&robj).unwrap();
+                  extendr_api::Robj::from(
+                          get_tuple_conversions_metadata()
+                              .make_r_wrappers(use_symbols, package_name)
+                              .unwrap(),
+                      )
+                      .get()
+              }
+          }
+          #[no_mangle]
+          #[allow(non_snake_case, clippy::not_unsafe_ptr_arg_deref)]
+          pub extern "C" fn R_init_tuple_conversions_extendr(info: *mut extendr_api::DllInfo) {
+              unsafe {
+                  extendr_api::register_call_methods(info, get_tuple_conversions_metadata())
+              };
+          }
+      }
       mod typedsliceargs {
           use extendr_api::prelude::*;
           fn middle_zero(integers: &mut [Rint]) {
@@ -10034,9 +10337,10 @@
           functions.extend(optional_faer::get_optional_faer_metadata().functions);
           functions.extend(raw_identifiers::get_raw_identifiers_metadata().functions);
           functions.extend(submodule::get_submodule_metadata().functions);
+          functions.extend(tuple_conversions::get_tuple_conversions_metadata().functions);
+          functions.extend(typedsliceargs::get_typedsliceargs_metadata().functions);
           functions.extend(externalptr::get_externalptr_metadata().functions);
           functions.extend(matrix::get_matrix_metadata().functions);
-          functions.extend(typedsliceargs::get_typedsliceargs_metadata().functions);
           functions.extend(to_unique_character::get_to_unique_character_metadata().functions);
           impls.extend(altrep::get_altrep_metadata().impls);
           impls.extend(attributes::get_attributes_metadata().impls);
@@ -10047,9 +10351,10 @@
           impls.extend(optional_faer::get_optional_faer_metadata().impls);
           impls.extend(raw_identifiers::get_raw_identifiers_metadata().impls);
           impls.extend(submodule::get_submodule_metadata().impls);
+          impls.extend(tuple_conversions::get_tuple_conversions_metadata().impls);
+          impls.extend(typedsliceargs::get_typedsliceargs_metadata().impls);
           impls.extend(externalptr::get_externalptr_metadata().impls);
           impls.extend(matrix::get_matrix_metadata().impls);
-          impls.extend(typedsliceargs::get_typedsliceargs_metadata().impls);
           impls.extend(to_unique_character::get_to_unique_character_metadata().impls);
           functions
               .push(extendr_api::metadata::Func {
diff --git a/tests/extendrtests/tests/testthat/test-tuples.R b/tests/extendrtests/tests/testthat/test-tuples.R
new file mode 100644
index 000000000..d2cb3bcd1
--- /dev/null
+++ b/tests/extendrtests/tests/testthat/test-tuples.R
@@ -0,0 +1,13 @@
+test_that("tuple conversions work", {
+  # normal types
+  expect_identical(sum_triplet_ints(as.list(1:12)), 3L)
+
+  # using try from implementation
+  expect_identical(
+    sum_points(list(c(0.25, 1.3), c(10.25, 11.2))),
+    c(10.5, 12.5)
+  )
+
+  # error works as anticipated
+  expect_error(sum_triplet_ints(as.list(1:3)), "Expected length")
+})