Skip to content

Commit 6ccaed4

Browse files
committed
Add support for custom functions
Closes #100.
1 parent 7b1a08f commit 6ccaed4

File tree

5 files changed

+139
-16
lines changed

5 files changed

+139
-16
lines changed

README.rst

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,85 @@ of your dict keys. To do this you can use either of these options:
9696
... jmespath.Options(dict_cls=collections.OrderedDict))
9797
9898
99+
Custom Functions
100+
~~~~~~~~~~~~~~~~
101+
102+
The JMESPath language has numerous
103+
`built-in functions
104+
<http://jmespath.org/specification.html#built-in-functions>`__, but it is
105+
also possible to add your own custom functions. Keep in mind that
106+
custom function support in jmespath.py is experimental and the API may
107+
change based on feedback.
108+
109+
**If you have a custom function that you've found useful, consider submitting
110+
it to jmespath.site and propose that it be added to the JMESPath language.**
111+
You can submit proposals
112+
`here <https://github.com/jmespath/jmespath.site/issues>`__.
113+
114+
To create custom functions:
115+
116+
* Create a subclass of ``jmespath.functions.Functions``.
117+
* Create a method with the name ``_func_<your function name>``.
118+
* Apply the ``jmespath.functions.signature`` decorator that indicates
119+
the expected types of the function arguments.
120+
* Provide an instance of your subclass in a ``jmespath.Options`` object.
121+
122+
Below are a few examples:
123+
124+
.. code:: python
125+
126+
import jmespath
127+
from jmespath import functions
128+
129+
# 1. Create a subclass of functions.Functions.
130+
# The function.Functions base class has logic
131+
# that introspects all of its methods and automatically
132+
# registers your custom functions in its function table.
133+
class CustomFunctions(functions.Functions):
134+
135+
# 2 and 3. Create a function that starts with _func_
136+
# and decorate it with @signature which indicates its
137+
# expected types.
138+
# In this example, we're creating a jmespath function
139+
# called "unique_letters" that accepts a single argument
140+
# with an expected type "string".
141+
@functions.signature({'types': ['string']})
142+
def _func_unique_letters(self, s):
143+
# Given a string s, return a sorted
144+
# string of unique letters: 'ccbbadd' -> 'abcd'
145+
return ''.join(sorted(set(s)))
146+
147+
# Here's another example. This is creating
148+
# a jmespath function called "my_add" that expects
149+
# two arguments, both of which should be of type number.
150+
@functions.signature({'types': ['number']}, {'types': ['number']})
151+
def _func_my_add(self, x, y):
152+
return x + y
153+
154+
# 4. Provide an instance of your subclass in a Options object.
155+
options = jmespath.Options(custom_functions=CustomFunctions())
156+
157+
# Provide this value to jmespath.search:
158+
# This will print 3
159+
print(
160+
jmespath.search(
161+
'my_add(`1`, `2`)', {}, options=options)
162+
)
163+
164+
# This will print "abcd"
165+
print(
166+
jmespath.search(
167+
'foo.bar | unique_letters(@)',
168+
{'foo': {'bar': 'ccbbadd'}},
169+
options=options)
170+
)
171+
172+
Again, if you come up with useful functions that you think make
173+
sense in the JMESPath language (and make sense to implement in all
174+
JMESPath libraries, not just python), please let us know at
175+
`jmespath.site <https://github.com/jmespath/jmespath.site/issues>`__.
176+
177+
99178
Specification
100179
=============
101180

jmespath/compat.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33

44
PY2 = sys.version_info[0] == 2
55

6+
7+
def with_metaclass(meta, *bases):
8+
# Taken from flask/six.
9+
class metaclass(meta):
10+
def __new__(cls, name, this_bases, d):
11+
return meta(name, bases, d)
12+
return type.__new__(metaclass, 'temporary_class', (), {})
13+
14+
615
if PY2:
716
text_type = unicode
817
string_type = basestring

jmespath/functions.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from jmespath import exceptions
55
from jmespath.compat import string_type as STRING_TYPE
6-
from jmespath.compat import get_methods
6+
from jmespath.compat import get_methods, with_metaclass
77

88

99
# python types -> jmespath types
@@ -34,27 +34,36 @@
3434
}
3535

3636

37-
def populate_function_table(cls):
38-
func_table = cls.FUNCTION_TABLE
39-
for name, method in get_methods(cls):
40-
signature = getattr(method, 'signature', None)
41-
if signature is not None:
42-
func_table[name[6:]] = {"function": method,
43-
"signature": signature}
44-
return cls
45-
46-
4737
def signature(*arguments):
4838
def _record_signature(func):
4939
func.signature = arguments
5040
return func
5141
return _record_signature
5242

5343

54-
@populate_function_table
55-
class RuntimeFunctions(object):
56-
# The built in functions are automatically populated in the FUNCTION_TABLE
57-
# using the @signature decorator on methods defined in this class.
44+
class FunctionRegistry(type):
45+
def __init__(cls, name, bases, attrs):
46+
cls._populate_function_table()
47+
super(FunctionRegistry, cls).__init__(name, bases, attrs)
48+
49+
def _populate_function_table(cls):
50+
function_table = getattr(cls, 'FUNCTION_TABLE', {})
51+
# Any method with a @signature decorator that also
52+
# starts with "_func_" is registered as a function.
53+
# _func_max_by -> max_by function.
54+
for name, method in get_methods(cls):
55+
if not name.startswith('_func_'):
56+
continue
57+
signature = getattr(method, 'signature', None)
58+
if signature is not None:
59+
function_table[name[6:]] = {
60+
'function': method,
61+
'signature': signature,
62+
}
63+
cls.FUNCTION_TABLE = function_table
64+
65+
66+
class Functions(with_metaclass(FunctionRegistry, object)):
5867

5968
FUNCTION_TABLE = {
6069
}

jmespath/visitor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def __init__(self, options=None):
9797
if options.custom_functions is not None:
9898
self._functions = self._options.custom_functions
9999
else:
100-
self._functions = functions.RuntimeFunctions()
100+
self._functions = functions.Functions()
101101

102102
def default_visit(self, node, *args, **kwargs):
103103
raise NotImplementedError(node['type'])

tests/test_search.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from tests import unittest, OrderedDict
22

33
import jmespath
4+
import jmespath.functions
45

56

67
class TestSearchOptions(unittest.TestCase):
@@ -10,3 +11,28 @@ def test_can_provide_dict_cls(self):
1011
{'c': 'c', 'b': 'b', 'a': 'a', 'd': 'd'},
1112
options=jmespath.Options(dict_cls=OrderedDict))
1213
self.assertEqual(result, ['a', 'b', 'c'])
14+
15+
def test_can_provide_custom_functions(self):
16+
class CustomFunctions(jmespath.functions.Functions):
17+
@jmespath.functions.signature(
18+
{'types': ['number']},
19+
{'types': ['number']})
20+
def _func_custom_add(self, x, y):
21+
return x + y
22+
23+
@jmespath.functions.signature(
24+
{'types': ['number']},
25+
{'types': ['number']})
26+
def _func_my_subtract(self, x, y):
27+
return x - y
28+
29+
30+
options = jmespath.Options(custom_functions=CustomFunctions())
31+
self.assertEqual(
32+
jmespath.search('custom_add(`1`, `2`)', {}, options=options),
33+
3
34+
)
35+
self.assertEqual(
36+
jmespath.search('my_subtract(`10`, `3`)', {}, options=options),
37+
7
38+
)

0 commit comments

Comments
 (0)