Skip to content

Commit

Permalink
[udf] Unlock Lua for user-defined functions
Browse files Browse the repository at this point in the history
  • Loading branch information
amotl committed Jan 5, 2024
1 parent 1905ec3 commit 7d2fc46
Show file tree
Hide file tree
Showing 9 changed files with 162 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mqttwarn changelog
in progress
===========
- [udf] Unlock JavaScript for user-defined functions. Thanks, @extremeheat.
- [udf] Unlock Lua for user-defined functions. Thanks, @scoder.


2023-10-15 0.35.0
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ RUN --mount=type=cache,id=pip,target=/root/.cache/pip \
true \
&& pip install --upgrade pip \
&& pip install --prefer-binary versioningit wheel \
&& pip install --use-pep517 --prefer-binary '/src[javascript]'
&& pip install --use-pep517 --prefer-binary '/src[javascript,lua]'

# Uninstall build prerequisites again.
RUN apt-get --yes remove --purge git && apt-get --yes autoremove
Expand Down
23 changes: 23 additions & 0 deletions docs/configure/transformation.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,19 @@ export its main entry point symbol, configure mqttwarn to use `functions = myclo
and adjust its settings to use your MQTT broker endpoint at the beginning of the data
pipeline, invoke mqttwarn, and turn off Kafka. It works!

On the next day, after investigating if you need to migrate any other system components,
you realize that there is an Nginx instance, which receives a certain share of telemetry
traffic using HTTP, and processes it using Lua. One quick `mosquitto_pub` later, you are
sure those telemetry messages are _also_ available on the MQTT bus already. Another set
of transformation rules written in Lua was quickly identified, and, after applying the
same procedure of inlining it into a single-file version, and configuring another mqttwarn
instance with `functions = mycloud.lua`, you are ready to turn off your whole cloud
infrastructure, and save valuable resources.

After a while, you are able to hire back half of your previous engineering team, and,
based on the new architecture, you will happily start contributing back to mqttwarn,
both in terms of maintenance, and by adding new features.

:::{note}
Rest assured we are overexaggerating a bit, and [Kafka] can only be compared to [MQTT]
if you are also willing to compare apples with oranges, but you will get the point that
Expand All @@ -467,6 +480,15 @@ available [OCI images](#using-oci-image).
You can find an example implementation for a `filter` function written in JavaScript
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).

#### Lua

For running user-defined functions code written in Lua, mqttwarn uses the excellent
[lupa] package. For adding JavaScript support to mqttwarn, install it using pip like
`pip install --upgrade 'mqttwarn[lua]'`, or use one of the available
[OCI images](#using-oci-image).

You can find an example implementation for a `filter` function written in Lua
at the [OwnTracks-to-ntfy example tutorial](#owntracks-ntfy-variants-udf).


## User-defined function examples
Expand Down Expand Up @@ -707,6 +729,7 @@ weather,topic=tasmota/temp/ds/1 temperature=19.7 1517525319000
[Jinja2 templates]: https://jinja.palletsprojects.com/templates/
[JSPyBridge]: https://pypi.org/project/javascript/
[Kafka]: https://en.wikipedia.org/wiki/Apache_Kafka
[lupa]: https://github.com/scoder/lupa
[MQTT]: https://en.wikipedia.org/wiki/MQTT
[Node.js]: https://en.wikipedia.org/wiki/Node.js
[OwnTracks]: https://owntracks.org
Expand Down
4 changes: 2 additions & 2 deletions docs/usage/pip.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ that.
pip install --upgrade mqttwarn
```

Add JavaScript support for user-defined functions.
Add JavaScript and Lua support for user-defined functions.
```bash
pip install --upgrade 'mqttwarn[javascript]'
pip install --upgrade 'mqttwarn[javascript,lua]'
```

You can also add support for a specific service plugin.
Expand Down
28 changes: 28 additions & 0 deletions examples/owntracks-ntfy/mqttwarn-owntracks.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
--[[
Forward OwnTracks low-battery warnings to ntfy.
https://mqttwarn.readthedocs.io/en/latest/examples/owntracks-battery/readme.html
--]]

-- mqttwarn filter function, returning true if the message should be ignored.
-- In this case, ignore all battery level telemetry values above a certain threshold.
function owntracks_batteryfilter(topic, message)
local ignore = true

-- Decode inbound message.
local data = json.decode(message)

-- Evaluate filtering rule.
if data ~= nil and data.batt ~= nil then
ignore = tonumber(data.batt) > 20
end

return ignore
end

-- Status message.
print("Loaded Lua module.")

-- Export symbols.
return {
owntracks_batteryfilter = owntracks_batteryfilter,
}
28 changes: 25 additions & 3 deletions examples/owntracks-ntfy/readme-variants.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ targets = {'testdrive': 'http://localhost:5555/testdrive'}

### JavaScript

In order to try that on the OwnTracks-to-ntfy example, use the alternative
`mqttwarn-owntracks.js` implementation by adjusting the `functions` setting within the
`[defaults]` section of your configuration file, and restart mqttwarn.
In order to explore JavaScript user-defined functions using the OwnTracks-to-ntfy recipe,
use the alternative `mqttwarn-owntracks.js` implementation by adjusting the `functions`
setting within the `[defaults]` section of your configuration file, and restart mqttwarn.
```ini
[defaults]
functions = mqttwarn-owntracks.js
Expand All @@ -69,3 +69,25 @@ previous one, which was written in Python.
The feature to run JavaScript code is currently considered to be experimental.
Please use it responsibly.
:::

### Lua

In order to explore Lua user-defined functions using the OwnTracks-to-ntfy recipe,
use the alternative `mqttwarn-owntracks.lua` implementation by adjusting the `functions`
setting within the `[defaults]` section of your configuration file, and restart mqttwarn.
```ini
[defaults]
functions = mqttwarn-owntracks.lua
```

The Lua function `owntracks_batteryfilter()` implements the same rule as the
previous ones, which was written in Python and JavaScript.

:::{literalinclude} mqttwarn-owntracks.lua
:language: lua
:::

:::{attention}
The feature to run Lua code is currently considered to be experimental.
Please use it responsibly.
:::
41 changes: 41 additions & 0 deletions mqttwarn/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import os
import re
import string
import sys
import threading
import types
import typing as t
Expand Down Expand Up @@ -150,6 +151,8 @@ def load_module_from_file(path: t.Union[str, Path]) -> types.ModuleType:
loader = importlib.machinery.SourcelessFileLoader(fullname=name, path=str(path))
elif path.suffix in [".js", ".javascript"]:
return load_source_js(name, str(path))
elif path.suffix == ".lua":
return load_source_lua(name, str(path))
else:
raise ImportError(f"Loading file type failed (only .py, .pyc, .js, .javascript): {path}")
spec = importlib.util.spec_from_loader(loader.name, loader)
Expand Down Expand Up @@ -317,3 +320,41 @@ def load_source_js(mod_name, filepath):
javascript.eval_js(js_code)
threading.Event().wait(0.01)
return module_factory(mod_name, module["exports"])


class LuaJsonAdapter:
"""
Support Lua as if it had its `json` module.
Wasn't able to make Lua's `json` module work, so this provides minimal functionality
instead. It will be injected into the Lua context's global `json` symbol.
"""

@staticmethod
def decode(data):
if data is None:
return None
return json.loads(data)

Check warning on line 337 in mqttwarn/util.py

View check run for this annotation

Codecov / codecov/patch

mqttwarn/util.py#L335-L337

Added lines #L335 - L337 were not covered by tests


def load_source_lua(mod_name, filepath):
"""
Load a Lua module, and import its exported symbols into a synthetic Python module.
"""
import lupa

lua = lupa.LuaRuntime(unpack_returned_tuples=True)

# Lua modules want to be loaded without suffix, but the interpreter would like to know about their path.
modfile = Path(filepath).with_suffix("").name
modpath = Path(filepath).parent
# Yeah, Windows.
if sys.platform == "win32":
modpath = str(modpath).replace("\\", "\\\\")

Check warning on line 353 in mqttwarn/util.py

View check run for this annotation

Codecov / codecov/patch

mqttwarn/util.py#L353

Added line #L353 was not covered by tests
lua.execute(rf'package.path = package.path .. ";{str(modpath)}/?.lua"')

logger.info(f"Loading Lua module {modfile} from path {modpath}")
module, filepath = lua.require(modfile)
# FIXME: Add support for common modules, as long as they are not available natively.
lua.globals()["json"] = LuaJsonAdapter
return module_factory(mod_name, module)
6 changes: 6 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
"javascript": [
"javascript==1!1.0.1; python_version>='3.7'",
],
"lua": [
"lupa<3",
],
"mysql": [
"mysql",
],
Expand Down Expand Up @@ -205,6 +208,9 @@
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Programming Language :: JavaScript",
"Programming Language :: Lua",
"Programming Language :: Other",
"Programming Language :: Other Scripting Engines",
"Programming Language :: Python",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
Expand Down
35 changes: 35 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,41 @@ def test_load_functions_javascript_runtime_failure(tmp_path):
assert ex.match("ReferenceError: bar is not defined")


def test_load_functions_lua_success(tmp_path):
"""
Verify that Lua module loading, including symbol exporting and invocation, works well.
"""
luafile = tmp_path / "test.lua"
luafile.write_text("return { forty_two = function() return 42 end }")
luamod = load_functions(luafile)
assert luamod.forty_two() == 42


def test_load_functions_lua_compile_failure(tmp_path):
"""
Verify that Lua module loading, including symbol exporting and invocation, works well.
"""
luafile = tmp_path / "test.lua"
luafile.write_text("Hotzenplotz")
with pytest.raises(Exception) as ex:
load_functions(luafile)
assert ex.typename == "LuaError"
assert ex.match("syntax error near <eof>")


def test_load_functions_lua_runtime_failure(tmp_path):
"""
Verify that Lua module loading, including symbol exporting and invocation, works well.
"""
luafile = tmp_path / "test.lua"
luafile.write_text("return { foo = function() bar() end }")
luamod = load_functions(luafile)
with pytest.raises(Exception) as ex:
luamod.foo()
assert ex.typename == "LuaError"
assert ex.match(re.escape("attempt to call a nil value (global 'bar')"))


def test_load_function():

# Load valid functions file
Expand Down

0 comments on commit 7d2fc46

Please sign in to comment.