signallang
A small, safe scripting language for publishing synthetic signals —
structured data that changes over time, on a schedule you control. No
eval/exec, no user-defined functions, no way to reach a name,
attribute, or module that wasn't explicitly handed in.
It was designed to drive a ROS2 "fake publisher" (make a topic emit synthetic sensor/status data so you can test a subscriber in isolation, without real hardware), but nothing in the language itself is ROS-specific. It's a general way to describe "this value, but ramping from 20 to 30 over 10 seconds, sent at 2Hz, for 10 seconds" — useful anywhere that shape shows up: mocking a sensor feed, driving a UI demo, load-testing a message consumer, feeding a simulator.
from signallang import compile_script
compiled = compile_script("""
temperature = linear(20, 30, 10s);
send hz 2 dur inf;
""")
run = compiled.new_run()
run.step() # StepResult(value={'temperature': 20.0}, hz=2.0)
run.step() # StepResult(value={'temperature': 20.5}, hz=2.0)
Why not just a Python loop?
You can write the loop by hand — while True: msg.temp += 0.5; pub.publish(msg); sleep(0.5)
— and for a one-off script that's often the right call. signallang earns
its keep once you want any of these on top:
- Ramps, holds, and repeats composed declaratively —
linear(...),if/repeat/for, multiplesendphases in sequence — without hand rolling tick-counting and phase-transition bookkeeping every time. - A script as data, not code — safe to accept from a config file, a
web form, or a REST body (this is exactly how the ROS2 dashboard uses
it: users type a script into a browser textarea and a backend compiles
and runs it, with no
evalin sight). - Decoupled from whoever owns the clock. A script never sleeps or
blocks; it's driven one logical tick at a time by whatever real timer
the host already has (a
whileloop, anrclpy.Timer, anasyncioevent loop) — see Two layers below.
Install
pip install signallang
git clone https://github.com/KhiemGOM/signallang && cd signallang
pip install -e ".[test]" && pytest
Two layers: compile time vs. real time
A script compiles to a small, flat instruction tape (SetVar / SetField
/ Send / Jump / JumpIfFalse / CreateTimer / ResetTimer) and runs
as a stepped VM. ScriptRun.step() executes instructions until it hits
the next Send and returns immediately — no thread, no clock, no
sleep() anywhere in compiler.py or vm.py. Time inside the VM is
counted, not waited for.
Pacing those step() calls in real time is the caller's job:
run = compiled.new_run()
while (result := run.step()) is not None:
publish(result.value)
sleep(1.0 / result.hz)
run_realtime() ships a minimal version of exactly that loop as an
opt-in convenience — the only place in the package that imports time:
from signallang import run_realtime
run_realtime(compiled, on_send=lambda msg: print(msg)) # blocks, real time
Anything that already owns a timer loop — ROS's rclpy.Timer, a
game/simulator tick, an asyncio task — skips run_realtime() and calls
step() directly from its own loop instead. examples/ has
two small, non-ROS proofs of this: stdout_signal.py
pipes NDJSON to stdout (consumable by any language, C++ included), and
websocket_signal.py broadcasts to
WebSocket clients from an asyncio event loop instead of a blocking
thread.
Language tour
Fields and values
data = 5; # a static field, holds until reassigned
frame_id = "map"; # string literal
linear.x = 1.5; # dotted path into a nested field
header = default; # ask the schema provider for this field's zero value
send [default, 20.0, 0.1]; # positional array fill against the schema's field order
Expressions
Loosest to tightest binding: or → and → not → comparison
(< > <= >= == !=, non-chaining — write a > 1 and a < 5, not
1 < a < 5) → + - → * / % → unary + - → atom.
| Numbers | 20, 0.5, -3.2 |
| Constants | true, false, pi, e |
| Variables | t, i, _t, or anything else in scope |
| Functions | sin cos abs sqrt floor ceil min max random |
terop(cond, then, else) |
inline choice, both branches evaluate eagerly |
| Duration literals | 10s, 3m, 500ms, 10t (ticks) — normalized to seconds at parse time |
.s / .m / .ms |
postfix unit view — _t.s reads a timer back out in seconds |
Only names you declare with var, or that the language defines (t,
_t, i inside a for), are ever in scope — there's no ambient global
namespace to reach into.
Control flow
if battery < 20 {
status = "low";
} else if battery < 50 {
status = "ok";
} else {
status = "full";
}
repeat 3 { send hz 1 dur 1t; } # fixed count
repeat { send hz 1 dur 1t; } # forever
for i in 0..7 { data = i * 30; send hz 1 dur 1t; } # bounded range, i in scope
for i in 0..inf { data = i; send hz 1 dur 1t; } # unbounded — no eager unrolling
if/repeat/for lower to real jump-based control flow at compile
time, so a repeat/for body can contain any number of sends, and an
unbounded loop costs nothing extra per step() — it's just an ordinary
instruction pointer moving through the tape, the same as a bounded one.
send — the one instruction that spends time
send; # send the current msg once, hz defaults to 50 (MAX_HZ), dur inf
send hz 2 dur inf; # 2Hz forever
send hz 10 dur 4.5s; # 10Hz for 4.5 wall-clock seconds
send hz 1 dur 10t; # exactly 10 ticks at 1Hz, no trailing wait
send 5; # value sugar: send this scalar directly, no prior field assign
send [1, 2, 3]; # value sugar: send this array directly
hz, dur, and a bare value can appear in any order on the same send
statement. hz is clamped to a 50Hz safety ceiling (MAX_HZ), enforced
by the compiler.
live blocks and timers
A live { ... } block re-evaluates on every tick instead of freezing
after its first assignment — this is how ramps and anything
time-dependent stay moving:
temperature = live { return 20 + sin(t); }; # re-evaluated every tick
# linear(from, to, duration) is sugar for a live block driven by a fresh
# per-binding timer (_t) — the ramp restarts cleanly every time this
# assignment statement re-executes, e.g. once per lap of an enclosing repeat.
linear.x = linear(0, 1.0, 3s);
var mt = timer(); # an explicit named timer
mt.reset(); # zero it immediately, mid-script
data = mt.s; # read it back out in seconds
t is the VM's running tick counter. _t is a latching_timer()
implicitly created per live binding: unstarted until first read, and a
fresh one is created each time its assignment statement (re-)executes —
so a ramp inside a repeat restarts from zero on every lap, for free.
Full worked example
repeat {
linear.x = linear(0, 1.0, 3s);
send hz 10 dur 3s;
linear.x = 1.0;
send hz 10 dur 5s;
linear.x = linear(1.0, 0, 3s);
send hz 10 dur 3s;
linear.x = 0.0;
send hz 10 dur 2s;
}
Ramp linear.x from 0 to 1 over 3s, hold at 1 for 5s, ramp back down to
0 over 3s, hold at 0 for 2s, repeat forever — a 13-second accelerate/
cruise/decelerate/stop cycle, e.g. for faking a geometry_msgs/Twist on
a mobile-base test rig.
Safety model
- No
eval/exec, no attribute access, no imports, no user-defined functions — every callable is one of nine fixed math functions. - The only names in scope are
t/i/_t, whatevervardeclares, and fields on the message being built. There's no way to reference anything outside the value currently being computed. hzis clamped to a 50Hz ceiling at compile time;hz <= 0is a compile error, not a runtime crash.- Malformed scripts (bad field names, wrong array shape, mismatched
positional fill) are supposed to fail at
compile_script()/ the firststep()— fail fast, before anything is ever sent.
Why I made this
The ROS2 dashboard's fake-publisher feature needed to let someone type
"ramp this topic's temperature from 20 to 30 over 10 seconds, at 2Hz"
into a browser textarea and have a backend run it safely — no eval,
nothing able to reach outside the value it's computing.
Nothing off-the-shelf fit that shape. Faker/Mimesis generate one-shot
fake values, not a schedule of them over time. JSON-Schema-driven
fuzzers produce structurally valid data but nothing intentionally
time-varying. General sandboxed interpreters (RestrictedPython,
asteval, embedded Lua) are safe but have no built-in notion of ticks,
ramps, or send hz/dur — reaching for one would still mean building this
exact scheduling layer on top by hand. So this became its own small
thing: an expression sandbox plus six statement primitives, compiled to a
flat instruction tape and driven one tick at a time by whoever owns the
real clock.
Development
pip install -e ".[test]"
pytest -v
compiler.py/vm.py never import time — the whole VM is timing-free
by construction, and CI enforces this with a grep, alongside a check that
src/signallang never references rclpy/ros2 (this package has zero
ROS dependency; a ROS2 adapter is expected to live in the consuming
project instead, wrapping SchemaProvider around real message reflection
and driving step() from an rclpy.Timer).
Type-checking: pip install -e ".[dev]" && mypy src/signallang.
License
Apache-2.0
Metadata
Release files for signallang 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| signallang-0.1.1.tar.gz | 35.5 kB | With provenance | Details for signallang-0.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| signallang-0.1.1-py3-none-any.whl | Python 3 | none | any | Details for signallang-0.1.1-py3-none-any.whl |
Total release size: 63.3 kB
Release files / signallang-0.1.1.tar.gz
| Download URL | signallang-0.1.1.tar.gz |
|---|---|
| Size | 35.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
feecaaaad5e8a047e7f9d5961dfbbe0587da65a037d99f7879555cbf1190e78d
|
|
BLAKE2b-256 checksum How to use checksums |
f7ca4b973ffa70bd0532a2a296f9d449474e4e041e57b3eddc86dfc7ab7c4cba
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency logRelease files / signallang-0.1.1-py3-none-any.whl
| Download URL | signallang-0.1.1-py3-none-any.whl |
|---|---|
| Size | 27.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
eed2bc81330343012d54add2f5acf890f2469dbf3865e3ed5e3a81c077c8ff7b
|
|
BLAKE2b-256 checksum How to use checksums |
10685c954baf4facf7d38d4e1e2e181c508b9982433ccd69b029b2c153fd51d7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency logSecurity for signallang 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for signallang-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 16, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.