Merge pull request #675 from programmerjake/add-text-signature
add #[text_signature = "..."] attribute
This commit is contained in:
commit
352707c29f
|
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
* Support for `#[text_signature]` attribute. [#675](https://github.com/PyO3/pyo3/pull/675)
|
||||
|
||||
## [0.8.3]
|
||||
|
||||
### Fixed
|
||||
|
|
|
@ -74,15 +74,72 @@ fn module_with_functions(py: Python, m: &PyModule) -> PyResult<()> {
|
|||
# fn main() {}
|
||||
```
|
||||
|
||||
### Making the function signature available to Python
|
||||
## Making the function signature available to Python
|
||||
|
||||
In order to make the function signature available to Python to be retrieved via
|
||||
`inspect.signature`, simply make sure the first line of your docstring is
|
||||
formatted like in the example below. Please note that the newline after the
|
||||
`inspect.signature`, use the `#[text_signature]` annotation as in the example
|
||||
below. The `/` signifies the end of positional-only arguments.
|
||||
|
||||
```rust
|
||||
use pyo3::prelude::*;
|
||||
|
||||
/// This function adds two unsigned 64-bit integers.
|
||||
#[pyfunction]
|
||||
#[text_signature = "(a, b, /)"]
|
||||
fn add(a: u64, b: u64) -> u64 {
|
||||
a + b
|
||||
}
|
||||
```
|
||||
|
||||
This also works for classes and methods:
|
||||
|
||||
```rust
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyType;
|
||||
|
||||
// it works even if the item is not documented:
|
||||
|
||||
#[pyclass]
|
||||
#[text_signature = "(c, d, /)"]
|
||||
struct MyClass {}
|
||||
|
||||
#[pymethods]
|
||||
impl MyClass {
|
||||
// the signature for the constructor is attached
|
||||
// to the struct definition instead.
|
||||
#[new]
|
||||
fn new(obj: &PyRawObject, c: i32, d: &str) {
|
||||
obj.init(Self {});
|
||||
}
|
||||
// the self argument should be written $self
|
||||
#[text_signature = "($self, e, f)"]
|
||||
fn my_method(&self, e: i32, f: i32) -> i32 {
|
||||
e + f
|
||||
}
|
||||
#[classmethod]
|
||||
#[text_signature = "(cls, e, f)"]
|
||||
fn my_class_method(cls: &PyType, e: i32, f: i32) -> i32 {
|
||||
e + f
|
||||
}
|
||||
#[staticmethod]
|
||||
#[text_signature = "(e, f)"]
|
||||
fn my_static_method(e: i32, f: i32) -> i32 {
|
||||
e + f
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Making the function signature available to Python (old method)
|
||||
|
||||
Alternatively, simply make sure the first line of your docstring is
|
||||
formatted like in the following example. Please note that the newline after the
|
||||
`--` is mandatory. The `/` signifies the end of positional-only arguments. This
|
||||
is not a feature of this library in particular, but the general format used by
|
||||
CPython for annotating signatures of built-in functions.
|
||||
|
||||
`#[text_signature]` should be preferred, since it will override automatically
|
||||
generated signatures when those are added in a future version of PyO3.
|
||||
|
||||
```rust
|
||||
use pyo3::prelude::*;
|
||||
|
||||
|
@ -94,6 +151,17 @@ use pyo3::prelude::*;
|
|||
fn add(a: u64, b: u64) -> u64 {
|
||||
a + b
|
||||
}
|
||||
|
||||
// a function with a signature but without docs. Both blank lines after the `--` are mandatory.
|
||||
|
||||
/// sub(a, b, /)
|
||||
/// --
|
||||
///
|
||||
///
|
||||
#[pyfunction]
|
||||
fn sub(a: u64, b: u64) -> u64 {
|
||||
a - b
|
||||
}
|
||||
```
|
||||
|
||||
When annotated like this, signatures are also correctly displayed in IPython.
|
||||
|
|
|
@ -50,7 +50,7 @@ impl<'a> FnSpec<'a> {
|
|||
pub fn parse(
|
||||
name: &'a syn::Ident,
|
||||
sig: &'a syn::Signature,
|
||||
meth_attrs: &'a mut Vec<syn::Attribute>,
|
||||
meth_attrs: &mut Vec<syn::Attribute>,
|
||||
) -> syn::Result<FnSpec<'a>> {
|
||||
let (mut fn_type, fn_attrs) = parse_attributes(meth_attrs)?;
|
||||
|
||||
|
|
|
@ -133,7 +133,7 @@ fn function_wrapper_ident(name: &Ident) -> Ident {
|
|||
/// Generates python wrapper over a function that allows adding it to a python module as a python
|
||||
/// function
|
||||
pub fn add_fn_to_module(
|
||||
func: &syn::ItemFn,
|
||||
func: &mut syn::ItemFn,
|
||||
python_name: &Ident,
|
||||
pyfn_attrs: Vec<pyfunction::Argument>,
|
||||
) -> TokenStream {
|
||||
|
@ -157,7 +157,14 @@ pub fn add_fn_to_module(
|
|||
let function_wrapper_ident = function_wrapper_ident(&func.sig.ident);
|
||||
|
||||
let wrapper = function_c_wrapper(&func.sig.ident, &spec);
|
||||
let doc = utils::get_doc(&func.attrs, true);
|
||||
let text_signature = match utils::parse_text_signature_attrs(&mut func.attrs, python_name) {
|
||||
Ok(text_signature) => text_signature,
|
||||
Err(err) => return err.to_compile_error(),
|
||||
};
|
||||
let doc = match utils::get_doc(&func.attrs, text_signature, true) {
|
||||
Ok(doc) => doc,
|
||||
Err(err) => return err.to_compile_error(),
|
||||
};
|
||||
|
||||
let tokens = quote! {
|
||||
fn #function_wrapper_ident(py: pyo3::Python) -> pyo3::PyObject {
|
||||
|
|
|
@ -157,7 +157,11 @@ impl PyClassArgs {
|
|||
}
|
||||
|
||||
pub fn build_py_class(class: &mut syn::ItemStruct, attr: &PyClassArgs) -> syn::Result<TokenStream> {
|
||||
let doc = utils::get_doc(&class.attrs, true);
|
||||
let text_signature = utils::parse_text_signature_attrs(
|
||||
&mut class.attrs,
|
||||
&get_class_python_name(&class.ident, attr),
|
||||
)?;
|
||||
let doc = utils::get_doc(&class.attrs, text_signature, true)?;
|
||||
let mut descriptors = Vec::new();
|
||||
|
||||
check_generics(class)?;
|
||||
|
@ -245,16 +249,20 @@ fn impl_inventory(cls: &syn::Ident) -> TokenStream {
|
|||
}
|
||||
}
|
||||
|
||||
fn get_class_python_name(cls: &syn::Ident, attr: &PyClassArgs) -> TokenStream {
|
||||
match &attr.name {
|
||||
Some(name) => quote! { #name },
|
||||
None => quote! { #cls },
|
||||
}
|
||||
}
|
||||
|
||||
fn impl_class(
|
||||
cls: &syn::Ident,
|
||||
attr: &PyClassArgs,
|
||||
doc: syn::Lit,
|
||||
descriptors: Vec<(syn::Field, Vec<FnType>)>,
|
||||
) -> TokenStream {
|
||||
let cls_name = match &attr.name {
|
||||
Some(name) => quote! { #name }.to_string(),
|
||||
None => cls.to_string(),
|
||||
};
|
||||
let cls_name = get_class_python_name(cls, attr).to_string();
|
||||
|
||||
let extra = {
|
||||
if let Some(freelist) = &attr.freelist {
|
||||
|
|
|
@ -12,8 +12,50 @@ pub fn gen_py_method(
|
|||
) -> syn::Result<TokenStream> {
|
||||
check_generic(name, sig)?;
|
||||
|
||||
let doc = utils::get_doc(&meth_attrs, true);
|
||||
let spec = FnSpec::parse(name, sig, meth_attrs)?;
|
||||
let spec = FnSpec::parse(name, sig, &mut *meth_attrs)?;
|
||||
|
||||
let mut parse_erroneous_text_signature = |alt_name: Option<&str>, error_msg: &str| {
|
||||
let python_name;
|
||||
let python_name = match alt_name {
|
||||
None => name,
|
||||
Some(alt_name) => {
|
||||
python_name = syn::Ident::new(alt_name, name.span());
|
||||
&python_name
|
||||
}
|
||||
};
|
||||
// try to parse anyway to give better error messages
|
||||
if let Some(text_signature) =
|
||||
utils::parse_text_signature_attrs(&mut *meth_attrs, python_name)?
|
||||
{
|
||||
Err(syn::Error::new_spanned(text_signature, error_msg))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
|
||||
let text_signature = match &spec.tp {
|
||||
FnType::Fn | FnType::PySelf(_) | FnType::FnClass | FnType::FnStatic => {
|
||||
utils::parse_text_signature_attrs(&mut *meth_attrs, name)?
|
||||
}
|
||||
FnType::FnNew => parse_erroneous_text_signature(
|
||||
Some("__new__"),
|
||||
"text_signature not allowed on __new__; if you want to add a signature on \
|
||||
__new__, put it on the struct definition instead",
|
||||
)?,
|
||||
FnType::FnCall => parse_erroneous_text_signature(
|
||||
Some("__call__"),
|
||||
"text_signature not allowed on __call__",
|
||||
)?,
|
||||
FnType::Getter(getter_name) => parse_erroneous_text_signature(
|
||||
getter_name.as_ref().map(|v| &**v),
|
||||
"text_signature not allowed on getter",
|
||||
)?,
|
||||
FnType::Setter(setter_name) => parse_erroneous_text_signature(
|
||||
setter_name.as_ref().map(|v| &**v),
|
||||
"text_signature not allowed on setter",
|
||||
)?,
|
||||
};
|
||||
let doc = utils::get_doc(&meth_attrs, text_signature, true)?;
|
||||
|
||||
Ok(match spec.tp {
|
||||
FnType::Fn => impl_py_method_def(name, doc, &spec, &impl_wrap(cls, name, &spec, true)),
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use proc_macro2::Span;
|
||||
use proc_macro2::TokenStream;
|
||||
use std::fmt::Display;
|
||||
|
||||
pub fn print_err(msg: String, t: TokenStream) {
|
||||
println!("Error: {} in '{}'", msg, t.to_string());
|
||||
|
@ -20,35 +21,123 @@ pub fn if_type_is_python(ty: &syn::Type) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
// FIXME(althonos): not sure the docstring formatting is on par here.
|
||||
pub fn get_doc(attrs: &[syn::Attribute], null_terminated: bool) -> syn::Lit {
|
||||
let mut doc = Vec::new();
|
||||
pub fn is_text_signature_attr(attr: &syn::Attribute) -> bool {
|
||||
attr.path.is_ident("text_signature")
|
||||
}
|
||||
|
||||
// TODO(althonos): set span on produced doc str literal
|
||||
// let mut span = None;
|
||||
fn parse_text_signature_attr<T: Display + quote::ToTokens + ?Sized>(
|
||||
attr: &syn::Attribute,
|
||||
python_name: &T,
|
||||
) -> syn::Result<Option<syn::LitStr>> {
|
||||
if !is_text_signature_attr(attr) {
|
||||
return Ok(None);
|
||||
}
|
||||
let python_name_str = python_name.to_string();
|
||||
let python_name_str = python_name_str
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| {
|
||||
syn::Error::new_spanned(
|
||||
&python_name,
|
||||
format!("failed to parse python name: {}", python_name),
|
||||
)
|
||||
})?;
|
||||
match attr.parse_meta()? {
|
||||
syn::Meta::NameValue(syn::MetaNameValue {
|
||||
lit: syn::Lit::Str(lit),
|
||||
..
|
||||
}) => {
|
||||
let value = lit.value();
|
||||
if value.starts_with('(') && value.ends_with(')') {
|
||||
Ok(Some(syn::LitStr::new(
|
||||
&(python_name_str.to_owned() + &value),
|
||||
lit.span(),
|
||||
)))
|
||||
} else {
|
||||
Err(syn::Error::new_spanned(
|
||||
lit,
|
||||
"text_signature must start with \"(\" and end with \")\"",
|
||||
))
|
||||
}
|
||||
}
|
||||
meta => Err(syn::Error::new_spanned(
|
||||
meta,
|
||||
"text_signature must be of the form #[text_signature = \"\"]",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_text_signature_attrs<T: Display + quote::ToTokens + ?Sized>(
|
||||
attrs: &mut Vec<syn::Attribute>,
|
||||
python_name: &T,
|
||||
) -> syn::Result<Option<syn::LitStr>> {
|
||||
let mut text_signature = None;
|
||||
let mut attrs_out = Vec::with_capacity(attrs.len());
|
||||
for attr in attrs.drain(..) {
|
||||
if let Some(value) = parse_text_signature_attr(&attr, python_name)? {
|
||||
if text_signature.is_some() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
"text_signature attribute already specified previously",
|
||||
));
|
||||
} else {
|
||||
text_signature = Some(value);
|
||||
}
|
||||
} else {
|
||||
attrs_out.push(attr);
|
||||
}
|
||||
}
|
||||
*attrs = attrs_out;
|
||||
Ok(text_signature)
|
||||
}
|
||||
|
||||
// FIXME(althonos): not sure the docstring formatting is on par here.
|
||||
pub fn get_doc(
|
||||
attrs: &[syn::Attribute],
|
||||
text_signature: Option<syn::LitStr>,
|
||||
null_terminated: bool,
|
||||
) -> syn::Result<syn::Lit> {
|
||||
let mut doc = String::new();
|
||||
let mut span = Span::call_site();
|
||||
|
||||
if let Some(text_signature) = text_signature {
|
||||
// create special doc string lines to set `__text_signature__`
|
||||
span = text_signature.span();
|
||||
doc.push_str(&text_signature.value());
|
||||
doc.push_str("\n--\n\n");
|
||||
}
|
||||
|
||||
let mut separator = "";
|
||||
let mut first = true;
|
||||
|
||||
for attr in attrs.iter() {
|
||||
if let Ok(syn::Meta::NameValue(ref metanv)) = attr.parse_meta() {
|
||||
if metanv.path.is_ident("doc") {
|
||||
// span = Some(metanv.span());
|
||||
if let syn::Lit::Str(ref litstr) = metanv.lit {
|
||||
if first {
|
||||
first = false;
|
||||
span = litstr.span();
|
||||
}
|
||||
let d = litstr.value();
|
||||
doc.push(if d.starts_with(' ') {
|
||||
d[1..d.len()].to_string()
|
||||
doc.push_str(separator);
|
||||
if d.starts_with(' ') {
|
||||
doc.push_str(&d[1..d.len()]);
|
||||
} else {
|
||||
d
|
||||
});
|
||||
doc.push_str(&d);
|
||||
};
|
||||
separator = "\n";
|
||||
} else {
|
||||
panic!("Invalid doc comment");
|
||||
return Err(syn::Error::new_spanned(metanv, "Invalid doc comment"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut docstr = doc.join("\n");
|
||||
if null_terminated {
|
||||
docstr.push('\0');
|
||||
doc.push('\0');
|
||||
}
|
||||
|
||||
syn::Lit::Str(syn::LitStr::new(&docstr, Span::call_site()))
|
||||
Ok(syn::Lit::Str(syn::LitStr::new(&doc, span)))
|
||||
}
|
||||
|
|
|
@ -27,7 +27,12 @@ pub fn pymodule(attr: TokenStream, input: TokenStream) -> TokenStream {
|
|||
|
||||
process_functions_in_module(&mut ast);
|
||||
|
||||
let expanded = py_init(&ast.sig.ident, &modname, get_doc(&ast.attrs, false));
|
||||
let doc = match get_doc(&ast.attrs, None, false) {
|
||||
Ok(doc) => doc,
|
||||
Err(err) => return err.to_compile_error().into(),
|
||||
};
|
||||
|
||||
let expanded = py_init(&ast.sig.ident, &modname, doc);
|
||||
|
||||
quote!(
|
||||
#ast
|
||||
|
@ -75,11 +80,11 @@ pub fn pymethods(_: TokenStream, input: TokenStream) -> TokenStream {
|
|||
|
||||
#[proc_macro_attribute]
|
||||
pub fn pyfunction(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||
let ast = parse_macro_input!(input as syn::ItemFn);
|
||||
let mut ast = parse_macro_input!(input as syn::ItemFn);
|
||||
let args = parse_macro_input!(attr as PyFunctionAttr);
|
||||
|
||||
let python_name = syn::Ident::new(&ast.sig.ident.unraw().to_string(), Span::call_site());
|
||||
let expanded = add_fn_to_module(&ast, &python_name, args.arguments);
|
||||
let expanded = add_fn_to_module(&mut ast, &python_name, args.arguments);
|
||||
|
||||
quote!(
|
||||
#ast
|
||||
|
|
186
tests/test_text_signature.rs
Normal file
186
tests/test_text_signature.rs
Normal file
|
@ -0,0 +1,186 @@
|
|||
use pyo3::prelude::*;
|
||||
use pyo3::{types::PyType, wrap_pyfunction, wrap_pymodule};
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn class_without_docs_or_signature() {
|
||||
#[pyclass]
|
||||
struct MyClass {}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let typeobj = py.get_type::<MyClass>();
|
||||
|
||||
py_assert!(py, typeobj, "typeobj.__doc__ is None");
|
||||
py_assert!(py, typeobj, "typeobj.__text_signature__ is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_with_docs() {
|
||||
/// docs line1
|
||||
#[pyclass]
|
||||
/// docs line2
|
||||
struct MyClass {}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let typeobj = py.get_type::<MyClass>();
|
||||
|
||||
py_assert!(py, typeobj, "typeobj.__doc__ == 'docs line1\\ndocs line2'");
|
||||
py_assert!(py, typeobj, "typeobj.__text_signature__ is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_with_docs_and_signature() {
|
||||
/// docs line1
|
||||
#[pyclass]
|
||||
/// docs line2
|
||||
#[text_signature = "(a, b=None, *, c=42)"]
|
||||
/// docs line3
|
||||
struct MyClass {}
|
||||
|
||||
#[pymethods]
|
||||
impl MyClass {
|
||||
#[new]
|
||||
#[args(a, b = "None", "*", c = 42)]
|
||||
fn __new__(obj: &PyRawObject, a: i32, b: Option<i32>, c: i32) {
|
||||
let _ = (a, b, c);
|
||||
obj.init(Self {});
|
||||
}
|
||||
}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let typeobj = py.get_type::<MyClass>();
|
||||
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.__doc__ == 'docs line1\\ndocs line2\\ndocs line3'"
|
||||
);
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.__text_signature__ == '(a, b=None, *, c=42)'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_with_signature() {
|
||||
#[pyclass]
|
||||
#[text_signature = "(a, b=None, *, c=42)"]
|
||||
struct MyClass {}
|
||||
|
||||
#[pymethods]
|
||||
impl MyClass {
|
||||
#[new]
|
||||
#[args(a, b = "None", "*", c = 42)]
|
||||
fn __new__(obj: &PyRawObject, a: i32, b: Option<i32>, c: i32) {
|
||||
let _ = (a, b, c);
|
||||
obj.init(Self {});
|
||||
}
|
||||
}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let typeobj = py.get_type::<MyClass>();
|
||||
|
||||
py_assert!(py, typeobj, "typeobj.__doc__ is None");
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.__text_signature__ == '(a, b=None, *, c=42)'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_function() {
|
||||
#[pyfunction(a, b = "None", "*", c = 42)]
|
||||
#[text_signature = "(a, b=None, *, c=42)"]
|
||||
fn my_function(a: i32, b: Option<i32>, c: i32) {
|
||||
let _ = (a, b, c);
|
||||
}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let f = wrap_pyfunction!(my_function)(py);
|
||||
|
||||
py_assert!(py, f, "f.__text_signature__ == '(a, b=None, *, c=42)'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pyfn() {
|
||||
#[pymodule]
|
||||
fn my_module(_py: Python, m: &PyModule) -> PyResult<()> {
|
||||
#[pyfn(m, "my_function", a, b = "None", "*", c = 42)]
|
||||
#[text_signature = "(a, b=None, *, c=42)"]
|
||||
fn my_function(a: i32, b: Option<i32>, c: i32) {
|
||||
let _ = (a, b, c);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let m = wrap_pymodule!(my_module)(py);
|
||||
|
||||
py_assert!(
|
||||
py,
|
||||
m,
|
||||
"m.my_function.__text_signature__ == '(a, b=None, *, c=42)'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_methods() {
|
||||
#[pyclass]
|
||||
struct MyClass {}
|
||||
|
||||
#[pymethods]
|
||||
impl MyClass {
|
||||
#[text_signature = "($self, a)"]
|
||||
fn method(&self, a: i32) {
|
||||
let _ = a;
|
||||
}
|
||||
#[text_signature = "($self, b)"]
|
||||
fn pyself_method(_this: PyRef<Self>, b: i32) {
|
||||
let _ = b;
|
||||
}
|
||||
#[classmethod]
|
||||
#[text_signature = "($cls, c)"]
|
||||
fn class_method(_cls: &PyType, c: i32) {
|
||||
let _ = c;
|
||||
}
|
||||
#[staticmethod]
|
||||
#[text_signature = "(d)"]
|
||||
fn static_method(d: i32) {
|
||||
let _ = d;
|
||||
}
|
||||
}
|
||||
|
||||
let gil = Python::acquire_gil();
|
||||
let py = gil.python();
|
||||
let typeobj = py.get_type::<MyClass>();
|
||||
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.method.__text_signature__ == '($self, a)'"
|
||||
);
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.pyself_method.__text_signature__ == '($self, b)'"
|
||||
);
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.class_method.__text_signature__ == '($cls, c)'"
|
||||
);
|
||||
py_assert!(
|
||||
py,
|
||||
typeobj,
|
||||
"typeobj.static_method.__text_signature__ == '(d)'"
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue