diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd2b1dcf..9febc7974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This document follows the conventions laid out in [Keep a CHANGELOG][]. - Added function that sets Py_NoSiteFlag to 1. - Added support for Jetson Nano. - Added support for __len__ for .NET classes that implement ICollection +- Added `IPyArgumentConverter` interface and `PyArgConverter` attribute, that control custom argument marshaling from Python to .NET (#835) ### Changed diff --git a/src/embed_tests/TestCustomArgMarshal.cs b/src/embed_tests/TestCustomArgMarshal.cs new file mode 100644 index 000000000..d9f22ef9d --- /dev/null +++ b/src/embed_tests/TestCustomArgMarshal.cs @@ -0,0 +1,88 @@ +using System; +using NUnit.Framework; +using Python.Runtime; + +namespace Python.EmbeddingTest +{ + class TestCustomArgMarshal + { + [OneTimeSetUp] + public void SetUp() + { + PythonEngine.Initialize(); + } + + [OneTimeTearDown] + public void Dispose() + { + PythonEngine.Shutdown(); + } + + [Test] + public void CustomArgMarshaller() + { + var obj = new CustomArgMarshaling(); + using (Py.GIL()) { + dynamic callWithInt = PythonEngine.Eval("lambda o: o.CallWithInt('42')"); + callWithInt(obj.ToPython()); + } + Assert.AreEqual(expected: 42, actual: obj.LastArgument); + } + + [Test] + public void MarshallerOverride() { + var obj = new DerivedMarshaling(); + using (Py.GIL()) { + dynamic callWithInt = PythonEngine.Eval("lambda o: o.CallWithInt({ 'value': 42 })"); + callWithInt(obj.ToPython()); + } + Assert.AreEqual(expected: 42, actual: obj.LastArgument); + } + } + + [PyArgConverter(typeof(CustomArgConverter))] + class CustomArgMarshaling { + public object LastArgument { get; protected set; } + public virtual void CallWithInt(int value) => this.LastArgument = value; + } + + // this should override original custom marshaling behavior for any new methods + [PyArgConverter(typeof(CustomArgConverter2))] + class DerivedMarshaling : CustomArgMarshaling { + public override void CallWithInt(int value) { + base.CallWithInt(value); + } + } + + class CustomArgConverter : DefaultPyArgumentConverter { + public override bool TryConvertArgument(IntPtr pyarg, Type parameterType, bool needsResolution, + out object arg, out bool isOut) { + if (parameterType != typeof(int)) + return base.TryConvertArgument(pyarg, parameterType, needsResolution, out arg, out isOut); + + bool isString = base.TryConvertArgument(pyarg, typeof(string), needsResolution, + out arg, out isOut); + if (!isString) return false; + + int number; + if (!int.TryParse((string)arg, out number)) return false; + arg = number; + return true; + } + } + + class CustomArgConverter2 : DefaultPyArgumentConverter { + public override bool TryConvertArgument(IntPtr pyarg, Type parameterType, bool needsResolution, + out object arg, out bool isOut) { + if (parameterType != typeof(int)) + return base.TryConvertArgument(pyarg, parameterType, needsResolution, out arg, out isOut); + bool isPyObject = base.TryConvertArgument(pyarg, typeof(PyObject), needsResolution, + out arg, out isOut); + if (!isPyObject) return false; + var dict = new PyDict((PyObject)arg); + int number = (dynamic)dict["value"]; + arg = number; + return true; + } + } +} diff --git a/src/runtime/Python.Runtime.csproj b/src/runtime/Python.Runtime.csproj index 0c2f912de..4d959e6ab 100644 --- a/src/runtime/Python.Runtime.csproj +++ b/src/runtime/Python.Runtime.csproj @@ -94,6 +94,7 @@ + @@ -122,6 +123,7 @@ + diff --git a/src/runtime/defaultpyargconverter.cs b/src/runtime/defaultpyargconverter.cs new file mode 100644 index 000000000..b2dd5d236 --- /dev/null +++ b/src/runtime/defaultpyargconverter.cs @@ -0,0 +1,111 @@ +namespace Python.Runtime { + using System; + + /// + /// The implementation of used by default + /// + public class DefaultPyArgumentConverter: IPyArgumentConverter + { + /// + /// Gets the singleton instance. + /// + public static DefaultPyArgumentConverter Instance { get; } = new DefaultPyArgumentConverter(); + + /// + /// + /// Attempts to convert an argument passed by Python to the specified parameter type. + /// + /// Unmanaged pointer to the Python argument value + /// The expected type of the parameter + /// true if the method is overloaded + /// This parameter will receive the converted value, matching the specified type + /// This parameter will be set to true, + /// if the final type needs to be marshaled as an out argument. + /// true, if the object matches requested type, + /// and conversion was successful, otherwise false + public virtual bool TryConvertArgument( + IntPtr pyarg, Type parameterType, bool needsResolution, + out object arg, out bool isOut) + { + arg = null; + isOut = false; + Type clrType = TryComputeClrArgumentType(parameterType, pyarg, needsResolution: needsResolution); + if (clrType == null) + { + return false; + } + + if (!Converter.ToManaged(pyarg, clrType, out arg, false)) + { + Exceptions.Clear(); + return false; + } + + isOut = clrType.IsByRef; + return true; + } + + static Type TryComputeClrArgumentType(Type parameterType, IntPtr argument, bool needsResolution) + { + // this logic below handles cases when multiple overloading methods + // are ambiguous, hence comparison between Python and CLR types + // is necessary + Type clrType = null; + IntPtr pyArgType; + if (needsResolution) + { + // HACK: each overload should be weighted in some way instead + pyArgType = Runtime.PyObject_Type(argument); + Exceptions.Clear(); + if (pyArgType != IntPtr.Zero) + { + clrType = Converter.GetTypeByAlias(pyArgType); + } + Runtime.XDecref(pyArgType); + } + + if (clrType != null) + { + if ((parameterType != typeof(object)) && (parameterType != clrType)) + { + IntPtr pyParamType = Converter.GetPythonTypeByAlias(parameterType); + pyArgType = Runtime.PyObject_Type(argument); + Exceptions.Clear(); + + bool typeMatch = false; + if (pyArgType != IntPtr.Zero && pyParamType == pyArgType) + { + typeMatch = true; + clrType = parameterType; + } + if (!typeMatch) + { + // this takes care of enum values + TypeCode argTypeCode = Type.GetTypeCode(parameterType); + TypeCode paramTypeCode = Type.GetTypeCode(clrType); + if (argTypeCode == paramTypeCode) + { + typeMatch = true; + clrType = parameterType; + } + } + Runtime.XDecref(pyArgType); + if (!typeMatch) + { + return null; + } + } + else + { + clrType = parameterType; + } + } + else + { + clrType = parameterType; + } + + return clrType; + } + } +} diff --git a/src/runtime/methodbinder.cs b/src/runtime/methodbinder.cs index 8a7fc1930..ca69a2ba4 100644 --- a/src/runtime/methodbinder.cs +++ b/src/runtime/methodbinder.cs @@ -1,5 +1,9 @@ using System; using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Reflection; using System.Text; using System.Collections.Generic; @@ -19,15 +23,16 @@ internal class MethodBinder public MethodBase[] methods; public bool init = false; public bool allow_threads = true; + IPyArgumentConverter pyArgumentConverter; internal MethodBinder() { list = new ArrayList(); } - internal MethodBinder(MethodInfo mi) + internal MethodBinder(MethodInfo mi): this() { - list = new ArrayList { mi }; + this.AddMethod(mi); } public int Count @@ -37,6 +42,7 @@ public int Count internal void AddMethod(MethodBase m) { + Debug.Assert(!init); list.Add(m); } @@ -164,11 +170,50 @@ internal MethodBase[] GetMethods() // I'm sure this could be made more efficient. list.Sort(new MethodSorter()); methods = (MethodBase[])list.ToArray(typeof(MethodBase)); + pyArgumentConverter = GetArgumentConverter(this.methods); init = true; } return methods; } + static IPyArgumentConverter GetArgumentConverter(IEnumerable methods) { + IPyArgumentConverter converter = null; + Type converterType = null; + foreach (MethodBase method in methods) + { + PyArgConverterAttribute attribute = TryGetArgConverter(method.DeclaringType); + if (converterType == null) + { + if (attribute == null) continue; + + converterType = attribute.ConverterType; + converter = attribute.Converter; + } else if (converterType != attribute?.ConverterType) + { + throw new NotSupportedException("All methods must have the same IPyArgumentConverter"); + } + } + + return converter ?? DefaultPyArgumentConverter.Instance; + } + + static readonly ConcurrentDictionary ArgConverterCache = + new ConcurrentDictionary(); + static PyArgConverterAttribute TryGetArgConverter(Type type) { + if (type == null) return null; + + return ArgConverterCache.GetOrAdd(type, declaringType => + declaringType + .GetCustomAttributes(typeof(PyArgConverterAttribute), inherit: true) + .OfType() + .SingleOrDefault() + ?? declaringType.Assembly + .GetCustomAttributes(typeof(PyArgConverterAttribute), inherit: true) + .OfType() + .SingleOrDefault() + ); + } + /// /// Precedence algorithm largely lifted from Jython - the concerns are /// generally the same so we'll start with this and tweak as necessary. @@ -300,14 +345,17 @@ internal Binding Bind(IntPtr inst, IntPtr args, IntPtr kw, MethodBase info, Meth var pynargs = (int)Runtime.PyTuple_Size(args); var isGeneric = false; + IPyArgumentConverter argumentConverter; if (info != null) { _methods = new MethodBase[1]; _methods.SetValue(info, 0); + argumentConverter = GetArgumentConverter(_methods); } else { _methods = GetMethods(); + argumentConverter = this.pyArgumentConverter; } // TODO: Clean up @@ -326,7 +374,8 @@ internal Binding Bind(IntPtr inst, IntPtr args, IntPtr kw, MethodBase info, Meth continue; } var outs = 0; - var margs = TryConvertArguments(pi, paramsArray, args, pynargs, kwargDict, defaultArgList, + var margs = TryConvertArguments(pi, paramsArray, argumentConverter, + args, pynargs, kwargDict, defaultArgList, needsResolution: _methods.Length > 1, outs: out outs); @@ -383,6 +432,7 @@ internal Binding Bind(IntPtr inst, IntPtr args, IntPtr kw, MethodBase info, Meth /// Returns number of output parameters /// An array of .NET arguments, that can be passed to a method. static object[] TryConvertArguments(ParameterInfo[] pi, bool paramsArray, + IPyArgumentConverter argumentConverter, IntPtr args, int pyArgCount, Dictionary kwargDict, ArrayList defaultArgList, @@ -423,7 +473,9 @@ static object[] TryConvertArguments(ParameterInfo[] pi, bool paramsArray, } bool isOut; - if (!TryConvertArgument(op, parameter.ParameterType, needsResolution, out margs[paramIndex], out isOut)) + if (!argumentConverter.TryConvertArgument( + op, parameter.ParameterType, needsResolution, + out margs[paramIndex], out isOut)) { return null; } @@ -445,97 +497,6 @@ static object[] TryConvertArguments(ParameterInfo[] pi, bool paramsArray, return margs; } - static bool TryConvertArgument(IntPtr op, Type parameterType, bool needsResolution, - out object arg, out bool isOut) - { - arg = null; - isOut = false; - var clrtype = TryComputeClrArgumentType(parameterType, op, needsResolution: needsResolution); - if (clrtype == null) - { - return false; - } - - if (!Converter.ToManaged(op, clrtype, out arg, false)) - { - Exceptions.Clear(); - return false; - } - - isOut = clrtype.IsByRef; - return true; - } - - static Type TryComputeClrArgumentType(Type parameterType, IntPtr argument, bool needsResolution) - { - // this logic below handles cases when multiple overloading methods - // are ambiguous, hence comparison between Python and CLR types - // is necessary - Type clrtype = null; - IntPtr pyoptype; - if (needsResolution) - { - // HACK: each overload should be weighted in some way instead - pyoptype = Runtime.PyObject_Type(argument); - Exceptions.Clear(); - if (pyoptype != IntPtr.Zero) - { - clrtype = Converter.GetTypeByAlias(pyoptype); - } - Runtime.XDecref(pyoptype); - } - - if (clrtype != null) - { - var typematch = false; - if ((parameterType != typeof(object)) && (parameterType != clrtype)) - { - IntPtr pytype = Converter.GetPythonTypeByAlias(parameterType); - pyoptype = Runtime.PyObject_Type(argument); - Exceptions.Clear(); - if (pyoptype != IntPtr.Zero) - { - if (pytype != pyoptype) - { - typematch = false; - } - else - { - typematch = true; - clrtype = parameterType; - } - } - if (!typematch) - { - // this takes care of enum values - TypeCode argtypecode = Type.GetTypeCode(parameterType); - TypeCode paramtypecode = Type.GetTypeCode(clrtype); - if (argtypecode == paramtypecode) - { - typematch = true; - clrtype = parameterType; - } - } - Runtime.XDecref(pyoptype); - if (!typematch) - { - return null; - } - } - else - { - typematch = true; - clrtype = parameterType; - } - } - else - { - clrtype = parameterType; - } - - return clrtype; - } - static bool MatchesArgumentCount(int positionalArgumentCount, ParameterInfo[] parameters, Dictionary kwargDict, out bool paramsArray, diff --git a/src/runtime/pyargconverter.cs b/src/runtime/pyargconverter.cs new file mode 100644 index 000000000..cf6be7b6d --- /dev/null +++ b/src/runtime/pyargconverter.cs @@ -0,0 +1,59 @@ +namespace Python.Runtime { + using System; + + /// + /// Specifies how to convert Python objects, passed to .NET functions to the expected CLR types. + /// + public interface IPyArgumentConverter + { + /// + /// Attempts to convert an argument passed by Python to the specified parameter type. + /// + /// Unmanaged pointer to the Python argument value + /// The expected type of the parameter + /// true if the method is overloaded + /// This parameter will receive the converted value, matching the specified type + /// This parameter will be set to true, + /// if the final type needs to be marshaled as an out argument. + /// true, if the object matches requested type, + /// and conversion was successful, otherwise false + bool TryConvertArgument(IntPtr pyarg, Type parameterType, + bool needsResolution, out object arg, out bool isOut); + } + + /// + /// Specifies an argument converter to be used, when methods in this class/assembly are called from Python. + /// + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Struct)] + public class PyArgConverterAttribute : Attribute + { + static readonly Type[] EmptyArgTypeList = new Type[0]; + static readonly object[] EmptyArgList = new object[0]; + + /// + /// Gets the instance of the converter, that will be used when calling methods + /// of this class/assembly from Python + /// + public IPyArgumentConverter Converter { get; } + /// + /// Gets the type of the converter, that will be used when calling methods + /// of this class/assembly from Python + /// + public Type ConverterType { get; } + + /// + /// Specifies an argument converter to be used, when methods + /// in this class/assembly are called from Python. + /// + /// Type of the converter to use. + /// Must implement . + public PyArgConverterAttribute(Type converterType) + { + if (converterType == null) throw new ArgumentNullException(nameof(converterType)); + var ctor = converterType.GetConstructor(EmptyArgTypeList); + if (ctor == null) throw new ArgumentException("Specified converter must have public parameterless constructor"); + this.Converter = (IPyArgumentConverter)ctor.Invoke(EmptyArgList); + this.ConverterType = converterType; + } + } +}