Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Merge pull request #52841 from YoannQDQ/qgisfunction
  • Loading branch information
m-kuhn committed Apr 30, 2023
2 parents bf93dc6 + ccb868f commit 0b898e0
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 132 deletions.
324 changes: 218 additions & 106 deletions python/core/additions/qgsfunction.py
@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-

"""
***************************************************************************
qgsfunction.py
Expand All @@ -21,153 +19,267 @@
import inspect
import string
import traceback
from builtins import str

from qgis.PyQt.QtCore import QCoreApplication
from qgis._core import QgsExpressionFunction, QgsExpression, QgsMessageLog, QgsFeatureRequest, Qgis


def register_function(function, arg_count, group, usesgeometry=False,
referenced_columns=[QgsFeatureRequest.ALL_ATTRIBUTES], handlesnull=False, **kwargs):
"""
Register a Python function to be used as a expression function.
class QgsPyExpressionFunction(QgsExpressionFunction):
"""Python expression function"""

def __init__(
self,
function,
name,
group,
helptext="",
usesgeometry=False,
referenced_columns=QgsFeatureRequest.ALL_ATTRIBUTES,
handlesnull=False,
params_as_list=False,
):
# Call the parent constructor
# -1 means that function can take any number of arguments
QgsExpressionFunction.__init__(self, name, -1, group, helptext)
self.function = function
self.params_as_list = params_as_list
self.usesgeometry = usesgeometry
self.referenced_columns = referenced_columns
self.handlesnull = handlesnull

def func(self, values, context, parent, node):
feature = None
if context:
feature = context.feature()
try:
# Inspect the inner function signature to get the list of parameters
parameters = inspect.signature(self.function).parameters
kwvalues = {}

# Handle special parameters
# those will not be inserted in the arguments list
# if they are present in the function signature
if "context" in parameters:
kwvalues["context"] = context
if "feature" in parameters:
kwvalues["feature"] = feature
if "parent" in parameters:
kwvalues["parent"] = parent

# In this context, values is a list of the parameters passed to the expression.
# If self.params_as_list is True, values is passed as is to the inner function.
if self.params_as_list:
return self.function(values, **kwvalues)
# Otherwise (default), the parameters are expanded
return self.function(*values, **kwvalues)

except Exception as ex:
tb = traceback.format_exception(None, ex, ex.__traceback__)
formatted_traceback = "".join(tb)
formatted_exception = f"{ex}:<pre>{formatted_traceback}</pre>"
parent.setEvalErrorString(formatted_exception)
return None

Functions should take (values, feature, parent) as args:
def usesGeometry(self, node):
return self.usesgeometry

Example:
def myfunc(values, feature, parent):
pass
def referencedColumns(self, node):
return self.referenced_columns

They can also shortcut naming feature and parent args by using *args
if they are not needed in the function.
def handlesNull(self):
return self.handlesnull

Example:
def myfunc(values, *args):
pass

Functions should return a value compatible with QVariant
def register_function(
function,
args="auto",
group="custom",
usesgeometry=False,
referenced_columns=[QgsFeatureRequest.ALL_ATTRIBUTES],
handlesnull=False,
params_as_list=None,
**kwargs,
):
"""
Register a Python function to be used as a expression function.
The function signature may contain special parameters (in any order at the end of the signature):
- feature: the QgsFeature related to the current evaluation
- parent: the QgsExpressionFunction parent
- context: the QgsExpressionContext related to the current evaluation
If those parameters are present in the function signature, they will be automatically passed to the function,
without the need to specify them in the expression.
Functions should return a value compatible with QVariant
Eval errors can be raised using parent.setEvalErrorString("Error message")
:param function:
:param arg_count:
:param group:
:param usesgeometry:
:param handlesnull: Needs to be set to True if this function does not always return NULL if any parameter is NULL. Default False.
:param function: the Python function to be used as an expression function
:param args: DEPRECATED since QGIS 3.32. Use ``params_as_list`` if you want to pass parameters as a list.
:param group: the expression group in which the function should be added
:param usesgeometry: Defines if this expression requires the geometry. By default False.
:param referenced_columns: An array of field names on which this expression works. By default ``[QgsFeatureRequest.ALL_ATTRIBUTES]``. Can be set to None for slightly faster evaluation.
:param handlesnull: Defines if this expression has custom handling for NULL values. If False, the result will always be NULL as soon as any parameter is NULL. False by default.
:param params_as_list: If True, the function will receive the expression parameters as a list. If False, the function will receive the parameters as individual arguments. False by default.
:Keyword Arguments:
* *register* (``bool``) --
Set to False to create the QgsPyExpressionFunction without registering it. Useful for testing puposes. By default True.
* *name* (``str``) --
If provided, replace the function name
* *helpText* (``str``) --
If provided, used in the help tooltip instead of the function docstring
:return:
"""

class QgsPyExpressionFunction(QgsExpressionFunction):

def __init__(self, func, name, args, group, helptext='', usesGeometry=True,
referencedColumns=QgsFeatureRequest.ALL_ATTRIBUTES, expandargs=False, handlesNull=False):
QgsExpressionFunction.__init__(self, name, args, group, helptext)
self.function = func
self.expandargs = expandargs
self.uses_geometry = usesGeometry
self.referenced_columns = referencedColumns
self.handles_null = handlesNull

def func(self, values, context, parent, node):
feature = None
if context:
feature = context.feature()

try:
if self.expandargs:
values.append(feature)
values.append(parent)
if inspect.getfullargspec(self.function).args[-1] == 'context':
values.append(context)
return self.function(*values)
else:
if inspect.getfullargspec(self.function).args[-1] == 'context':
self.function(values, feature, parent, context)
return self.function(values, feature, parent)
except Exception as ex:
tb = traceback.format_exception(None, ex, ex.__traceback__)
formatted_traceback = ''.join(tb)
formatted_exception = f"{ex}:<pre>{formatted_traceback}</pre>"
parent.setEvalErrorString(formatted_exception)
return None

def usesGeometry(self, node):
return self.uses_geometry

def referencedColumns(self, node):
return self.referenced_columns

def handlesNull(self):
return self.handles_null

helptemplate = string.Template("""<h3>$name function</h3><br>$doc""")
name = kwargs.get('name', function.__name__)
helptext = kwargs.get('helpText') or function.__doc__ or ''
# Format the help text
helptemplate = string.Template("<h3>$name function</h3><br>$doc")
name = kwargs.get("name", function.__name__)
helptext = kwargs.get("helpText") or function.__doc__ or ""
helptext = helptext.strip()
expandargs = False

if arg_count == "auto":
# Work out the number of args we need.
# Number of function args - 2. The last two args are always feature, parent.
args = inspect.getfullargspec(function).args
number = len(args)
arg_count = number - 2
if args[-1] == 'context':
arg_count -= 1
expandargs = True

register = kwargs.get('register', True)

register = kwargs.get("register", True)
if register and QgsExpression.isFunctionName(name):
if not QgsExpression.unregisterFunction(name):
msgtitle = QCoreApplication.translate("UserExpressions", "User expressions")
msg = QCoreApplication.translate("UserExpressions",
"The user expression {0} already exists and could not be unregistered.").format(
name)
msg = QCoreApplication.translate(
"UserExpressions", "The user expression {0} already exists and could not be unregistered."
).format(name)
QgsMessageLog.logMessage(msg + "\n", msgtitle, Qgis.Warning)
return None

function.__name__ = name
helptext = helptemplate.safe_substitute(name=name, doc=helptext)
f = QgsPyExpressionFunction(function, name, arg_count, group, helptext, usesgeometry, referenced_columns,
expandargs, handlesnull)

# This doesn't really make any sense here but does when used from a decorator context
# so it can stay.
# Legacy: if args was not 'auto', parameters were passed as a list
params_as_list = params_as_list or args != "auto"
f = QgsPyExpressionFunction(
function, name, group, helptext, usesgeometry, referenced_columns, handlesnull, params_as_list
)

if register:
QgsExpression.registerFunction(f)
return f


def qgsfunction(args='auto', group='custom', **kwargs):
def qgsfunction(args="auto", group="custom", **kwargs):
r"""
Decorator function used to define a user expression function.
:param args: Number of parameters, set to 'auto' to accept a variable length of parameters.
The decorated function signature may contain special parameters (in any order at the end of the signature):
- feature: the QgsFeature related to the current evaluation
- parent: the QgsExpressionFunction parent
- context: the QgsExpressionContext related to the current evaluation
If those parameters are present in the function signature, they will be automatically passed to the function,
without the need to specify them in the expression.
Functions should return a value compatible with QVariant
Eval errors can be raised using parent.setEvalErrorString("Error message")
:param args: DEPRECATED since QGIS 3.32. Use the "params_as_list" keyword argument if you want to pass parameters as a list.
:param group: The expression group to which this expression should be added.
:param \**kwargs:
See below
:Keyword Arguments:
* *referenced_columns* (``list``) --
An array of field names on which this expression works. Can be set to ``[QgsFeatureRequest.ALL_ATTRIBUTES]``. By default empty.
* *usesgeometry* (``bool``) --
Defines if this expression requires the geometry. By default False.
* *referenced_columns* (``list``) --
An array of field names on which this expression works. By default ``[QgsFeatureRequest.ALL_ATTRIBUTES]``. Can be set to None for slightly faster evaluation.
* *handlesnull* (``bool``) --
Defines if this expression has custom handling for NULL values. If False, the result will always be NULL as soon as any parameter is NULL. False by default.
* *params_as_list* (``bool``) \since QGIS 3.32 --
Defines if the parameters are passed to the function as a list. If set the False, they will be expanded. By default False.
* *register* (``bool``) --
Set to False to create the QgsPyExpressionFunction without registering it. Useful for testing puposes. By default True.
* *name* (``str``) --
If provided, replace the function name
* *helpText* (``str``) --
If provided, used in the help tooltip instead of the function docstring
**Example 1 (Basic function, with default parameters) **:
```
@qgsfunction(group="python")
def center(text, width, fillchar=" "):
return text.center(width, fillchar)
```
This registers a function called "center" in the "python" group.
This expression requires two parameters: text and width.
An optional third parameter can be provided: fillchar.
```
>>> center('Hello', 10)
' Hello '
>>> center('Hello', 15, '*')
'*****Hello*****'
```
**Example 2 (variable number of parameters) **:
```
@qgsfunction(group="custom")
def fibonnaci_numbers(*args):
def fibonnaci(n):
if n <= 1:
return n
else:
return fibonnaci(n - 1) + fibonnaci(n - 2)
return [fibonnaci(arg) for arg in args]
```
This registers a function called "fibonnaci_numbers" in the "custom" group.
This expression can be called with any number of parameters, and will return a list of the fibonnaci numbers
corresponding to the parameters.
```
>>> fibonnaci_numbers()
[]
>>> fibonnaci_numbers(1)
[1]
>>> fibonnaci_numbers(3)
[2]
>>> fibonnaci_numbers(1, 2, 3, 4, 5, 6, 7)
[ 1, 1, 2, 3, 5, 8, 13 ]
```
**Example 3 (feature and parent) **:
```
@qgsfunction(group="custom")
def display_field(fieldname, feature, parent):
try:
return f"<b>{fieldname}</b>: {feature[fieldname]}"
except KeyError:
parent.setEvalErrorString(f"Field {fieldname} not found")
```
This registers a function called "display_field" in the "custom" group.
This expression requires an unique parameter: fieldname. Feature is automatically passed to the function.
parent is the QgsExpressionFunction parent, and can be used to raise an error.
```
>>> display_field("altitude")
<b>altitude</b>: 164
>>> display_field("lat")
<b>lat</b>: 45.4
```
**Example 4 (context)**:
```
@qgsfunction(group="custom")
def title_layer_name(context):
return context.variable("layer_name").title()
```
This registers a function called "title_layer_name" in the "custom" group. It takes no parameters,
but extracts the layer name from the expression context and returns it as a title case string.
Example:
@qgsfunction(2, 'test'):
def add(values, feature, parent):
pass
Will create and register a function in QgsExpression called 'add' in the
'test' group that takes two arguments.
or not using feature and parent:
Example:
@qgsfunction(2, 'test'):
def add(values, *args):
pass
"""

def wrapper(func):
Expand Down
4 changes: 2 additions & 2 deletions python/user.py
Expand Up @@ -66,8 +66,8 @@ def load_user_expressions(path):
template = """from qgis.core import *
from qgis.gui import *
@qgsfunction(args='auto', group='Custom', referenced_columns=[])
def my_sum(value1, value2, feature, parent):
@qgsfunction(group='Custom', referenced_columns=[])
def my_sum(value1, value2):
\"\"\"
Calculates the sum of the two parameters value1 and value2.
<h2>Example usage:</h2>
Expand Down

0 comments on commit 0b898e0

Please sign in to comment.