Skip to content

Commit 8a9d2f0

Browse files
committed
[udf] Unlock Lua for user-defined functions
1 parent 93a61f5 commit 8a9d2f0

File tree

9 files changed

+162
-6
lines changed

9 files changed

+162
-6
lines changed

CHANGES.rst

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mqttwarn changelog
66
in progress
77
===========
88
- [udf] Unlock JavaScript for user-defined functions. Thanks, @extremeheat.
9+
- [udf] Unlock Lua for user-defined functions. Thanks, @scoder.
910

1011

1112
2023-10-15 0.35.0

Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ RUN --mount=type=cache,id=pip,target=/root/.cache/pip \
3434
true \
3535
&& pip install --upgrade pip \
3636
&& pip install --prefer-binary versioningit wheel \
37-
&& pip install --use-pep517 --prefer-binary '/src[javascript]'
37+
&& pip install --use-pep517 --prefer-binary '/src[javascript,lua]'
3838

3939
# Uninstall build prerequisites again.
4040
RUN apt-get --yes remove --purge git && apt-get --yes autoremove

docs/configure/transformation.md

+23
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,19 @@ export its main entry point symbol, configure mqttwarn to use `functions = myclo
447447
and adjust its settings to use your MQTT broker endpoint at the beginning of the data
448448
pipeline, invoke mqttwarn, and turn off Kafka. It works!
449449

450+
On the next day, after investigating if you need to migrate any other system components,
451+
you realize that there is an Nginx instance, which receives a certain share of telemetry
452+
traffic using HTTP, and processes it using Lua. One quick `mosquitto_pub` later, you are
453+
sure those telemetry messages are _also_ available on the MQTT bus already. Another set
454+
of transformation rules written in Lua was quickly identified, and, after applying the
455+
same procedure of inlining it into a single-file version, and configuring another mqttwarn
456+
instance with `functions = mycloud.lua`, you are ready to turn off your whole cloud
457+
infrastructure, and save valuable resources.
458+
459+
After a while, you are able to hire back half of your previous engineering team, and,
460+
based on the new architecture, you will happily start contributing back to mqttwarn,
461+
both in terms of maintenance, and by adding new features.
462+
450463
:::{note}
451464
Rest assured we are overexaggerating a bit, and [Kafka] can only be compared to [MQTT]
452465
if you are also willing to compare apples with oranges, but you will get the point that
@@ -467,6 +480,15 @@ available [OCI images](#using-oci-image).
467480
You can find an example implementation for a `filter` function written in JavaScript
468481
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).
469482

483+
#### Lua
484+
485+
For running user-defined functions code written in Lua, mqttwarn uses the excellent
486+
[lupa] package. For adding JavaScript support to mqttwarn, install it using pip like
487+
`pip install --upgrade 'mqttwarn[lua]'`, or use one of the available
488+
[OCI images](#using-oci-image).
489+
490+
You can find an example implementation for a `filter` function written in Lua
491+
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).
470492

471493

472494
## User-defined function examples
@@ -707,6 +729,7 @@ weather,topic=tasmota/temp/ds/1 temperature=19.7 1517525319000
707729
[Jinja2 templates]: https://jinja.palletsprojects.com/templates/
708730
[JSPyBridge]: https://pypi.org/project/javascript/
709731
[Kafka]: https://en.wikipedia.org/wiki/Apache_Kafka
732+
[lupa]: https://github.com/scoder/lupa
710733
[MQTT]: https://en.wikipedia.org/wiki/MQTT
711734
[Node.js]: https://en.wikipedia.org/wiki/Node.js
712735
[OwnTracks]: https://owntracks.org

docs/usage/pip.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ that.
1111
pip install --upgrade mqttwarn
1212
```
1313

14-
Add JavaScript support for user-defined functions.
14+
Add JavaScript and Lua support for user-defined functions.
1515
```bash
16-
pip install --upgrade 'mqttwarn[javascript]'
16+
pip install --upgrade 'mqttwarn[javascript,lua]'
1717
```
1818

1919
You can also add support for a specific service plugin.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--[[
2+
Forward OwnTracks low-battery warnings to ntfy.
3+
https://mqttwarn.readthedocs.io/en/latest/examples/owntracks-battery/readme.html
4+
--]]
5+
6+
-- mqttwarn filter function, returning true if the message should be ignored.
7+
-- In this case, ignore all battery level telemetry values above a certain threshold.
8+
function owntracks_batteryfilter(topic, message)
9+
local ignore = true
10+
11+
-- Decode inbound message.
12+
local data = json.decode(message)
13+
14+
-- Evaluate filtering rule.
15+
if data ~= nil and data.batt ~= nil then
16+
ignore = tonumber(data.batt) > 20
17+
end
18+
19+
return ignore
20+
end
21+
22+
-- Status message.
23+
print("Loaded Lua module.")
24+
25+
-- Export symbols.
26+
return {
27+
owntracks_batteryfilter = owntracks_batteryfilter,
28+
}

examples/owntracks-ntfy/readme-variants.md

+25-3
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ targets = {'testdrive': 'http://localhost:5555/testdrive'}
5050

5151
### JavaScript
5252

53-
In order to try that on the OwnTracks-to-ntfy example, use the alternative
54-
`mqttwarn-owntracks.js` implementation by adjusting the `functions` setting within the
55-
`[defaults]` section of your configuration file, and restart mqttwarn.
53+
In order to explore JavaScript user-defined functions using the OwnTracks-to-ntfy recipe,
54+
use the alternative `mqttwarn-owntracks.js` implementation by adjusting the `functions`
55+
setting within the `[defaults]` section of your configuration file, and restart mqttwarn.
5656
```ini
5757
[defaults]
5858
functions = mqttwarn-owntracks.js
@@ -69,3 +69,25 @@ previous one, which was written in Python.
6969
The feature to run JavaScript code is currently considered to be experimental.
7070
Please use it responsibly.
7171
:::
72+
73+
### Lua
74+
75+
In order to explore Lua user-defined functions using the OwnTracks-to-ntfy recipe,
76+
use the alternative `mqttwarn-owntracks.lua` implementation by adjusting the `functions`
77+
setting within the `[defaults]` section of your configuration file, and restart mqttwarn.
78+
```ini
79+
[defaults]
80+
functions = mqttwarn-owntracks.lua
81+
```
82+
83+
The Lua function `owntracks_batteryfilter()` implements the same rule as the
84+
previous ones, which was written in Python and JavaScript.
85+
86+
:::{literalinclude} mqttwarn-owntracks.lua
87+
:language: lua
88+
:::
89+
90+
:::{attention}
91+
The feature to run Lua code is currently considered to be experimental.
92+
Please use it responsibly.
93+
:::

mqttwarn/util.py

+41
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import re
1515
import string
16+
import sys
1617
import threading
1718
import types
1819
import typing as t
@@ -150,6 +151,8 @@ def load_module_from_file(path: t.Union[str, Path]) -> types.ModuleType:
150151
loader = importlib.machinery.SourcelessFileLoader(fullname=name, path=str(path))
151152
elif path.suffix in [".js", ".javascript"]:
152153
return load_source_js(name, str(path))
154+
elif path.suffix == ".lua":
155+
return load_source_lua(name, str(path))
153156
else:
154157
raise ImportError(f"Loading file type failed (only .py, .pyc, .js, .javascript): {path}")
155158
spec = importlib.util.spec_from_loader(loader.name, loader)
@@ -317,3 +320,41 @@ def load_source_js(mod_name, filepath):
317320
javascript.eval_js(js_code)
318321
threading.Event().wait(0.01)
319322
return module_factory(mod_name, module["exports"])
323+
324+
325+
class LuaJsonAdapter:
326+
"""
327+
Support Lua as if it had its `json` module.
328+
329+
Wasn't able to make Lua's `json` module work, so this provides minimal functionality
330+
instead. It will be injected into the Lua context's global `json` symbol.
331+
"""
332+
333+
@staticmethod
334+
def decode(data):
335+
if data is None:
336+
return None
337+
return json.loads(data)
338+
339+
340+
def load_source_lua(mod_name, filepath):
341+
"""
342+
Load a Lua module, and import its exported symbols into a synthetic Python module.
343+
"""
344+
import lupa
345+
346+
lua = lupa.LuaRuntime(unpack_returned_tuples=True)
347+
348+
# Lua modules want to be loaded without suffix, but the interpreter would like to know about their path.
349+
modfile = Path(filepath).with_suffix("").name
350+
modpath = Path(filepath).parent
351+
# Yeah, Windows.
352+
if sys.platform == "win32":
353+
modpath = str(modpath).replace("\\", "\\\\")
354+
lua.execute(rf'package.path = package.path .. ";{str(modpath)}/?.lua"')
355+
356+
logger.info(f"Loading Lua module {modfile} from path {modpath}")
357+
module, filepath = lua.require(modfile)
358+
# FIXME: Add support for common modules, as long as they are not available natively.
359+
lua.globals()["json"] = LuaJsonAdapter
360+
return module_factory(mod_name, module)

setup.py

+6
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
"javascript": [
5757
"javascript==1!1.0.1; python_version>='3.7'",
5858
],
59+
"lua": [
60+
"lupa<3",
61+
],
5962
"mysql": [
6063
"mysql",
6164
],
@@ -205,6 +208,9 @@
205208
"Operating System :: MacOS",
206209
"Operating System :: Microsoft :: Windows",
207210
"Programming Language :: JavaScript",
211+
"Programming Language :: Lua",
212+
"Programming Language :: Other",
213+
"Programming Language :: Other Scripting Engines",
208214
"Programming Language :: Python",
209215
"Programming Language :: Python :: 3.6",
210216
"Programming Language :: Python :: 3.7",

tests/test_util.py

+35
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,41 @@ def test_load_functions_javascript_runtime_failure(tmp_path):
202202
assert ex.match("ReferenceError: bar is not defined")
203203

204204

205+
def test_load_functions_lua_success(tmp_path):
206+
"""
207+
Verify that Lua module loading, including symbol exporting and invocation, works well.
208+
"""
209+
luafile = tmp_path / "test.lua"
210+
luafile.write_text("return { forty_two = function() return 42 end }")
211+
luamod = load_functions(luafile)
212+
assert luamod.forty_two() == 42
213+
214+
215+
def test_load_functions_lua_compile_failure(tmp_path):
216+
"""
217+
Verify that Lua module loading, including symbol exporting and invocation, works well.
218+
"""
219+
luafile = tmp_path / "test.lua"
220+
luafile.write_text("Hotzenplotz")
221+
with pytest.raises(Exception) as ex:
222+
load_functions(luafile)
223+
assert ex.typename == "LuaError"
224+
assert ex.match("syntax error near <eof>")
225+
226+
227+
def test_load_functions_lua_runtime_failure(tmp_path):
228+
"""
229+
Verify that Lua module loading, including symbol exporting and invocation, works well.
230+
"""
231+
luafile = tmp_path / "test.lua"
232+
luafile.write_text("return { foo = function() bar() end }")
233+
luamod = load_functions(luafile)
234+
with pytest.raises(Exception) as ex:
235+
luamod.foo()
236+
assert ex.typename == "LuaError"
237+
assert ex.match(re.escape("attempt to call a nil value (global 'bar')"))
238+
239+
205240
def test_load_function():
206241

207242
# Load valid functions file

0 commit comments

Comments
 (0)