ioiocore
Real-time signal processing framework for Python
ioiocore is a real-time signal processing framework for Python. It provides an abstract, node-based data propagation
architecture that can be used in various contexts, such as biosignal processing.
Installation
ioiocore is available on PyPI:
pip install ioiocore
Documentation
Full documentation is available at GitHub Pages.
License
ioiocore is licensed under the g.tec Non-Commercial License (GNCL). See the LICENSE file for details.
Changelog
[5.1.1] - 2026-10-03
An application frozen with PyInstaller finds what ioiocore’s compiled modules import. ioiocore registers a PyInstaller hook, so a frozen application no longer depends on some other package importing the same modules.
[5.1.0] - 2026-10-01
ioiocore runs where no thread can be started, such as Pyodide in a browser. There, a node with several input ports runs once the node feeding it has finished its cycle, instead of on a thread of its own, so it sees everything that cycle pushed, as its thread would have; the pipeline monitor runs on the event loop, and the log file is written as entries arrive. Your nodes are called with the same data by the same rules, and a pipeline needs no log directory there. Nothing changes where threads exist.
Two functions are new:
ioc.threads_available()says which case you are in, andioc.call_later(delay, fn)schedulesfnon the running event loop, for code of your own that would otherwise start a timer thread.A wheel for Pyodide. ioiocore 5.1.0 is published for Pyodide 314 (Python 3.14, the
pyemscripten_2026_0_wasm32platform), compiled like every other wheel, somicropip.install("ioiocore")works in a browser.
[5.0.2] - 2026-09-26
A chain whose node failed to start is started again by the next
start(). A chain marked itself running before it started its internal nodes, so a node that raised instart()left the chain running, and the pipeline’s nextstart()returned without starting that node. A pipeline could then report that it was running with, for example, no device open.start()is now all or nothing for a chain, as it already was for a pipeline: the nodes that did start are stopped again, the chain stays stopped, and the exception propagates. A chain and a pipeline now also stop the element whosestart()raised, if it had already marked itself running.
[5.0.1] - 2026-09-24
An error handler is told even when stopping the pipeline fails. On a failure the pipeline publishes
ERROR, stops, and then calls the handlers registered withadd_error_handler(). An element whosestop()raised used to skip that last step, so no handler heard of the failure at all.The order is now documented. Whatever a handler records, a caller who reads it also sees
ERROR, and a stopped pipeline unless stopping it failed. The converse does not hold: a caller pollingget_condition()can seeERRORbefore any handler has run, and should readget_last_error(), which already has the entry.
[5.0.0] - 2026-09-14
Major release following a full reliability audit. It adds a composable chain layer and a pair of input-adaptation hooks, replaces node authorization with a neutral provider interface, hardens the real-time data path, threading and error handling for clinical use, and removes an unsafe serialization path. It is also the first ioiocore release to publish wheels for Linux and for Python 3.14. See the Migration Guide for how to upgrade downstream code.
A node whose inputs are all asynchronous no longer stalls on its first burst. A source firing immediately after
start(), while a silent sibling producer was still setting up, could leave the consumer asleep with its queue full. The first cycle waits until every input port has a context, and handing over that context did not wake the node – only an incoming frame did, and the burst was already over.It recovered as soon as more data arrived, so it stalled for good only where every input is asynchronous and the burst ends: an event port fed by a marker source that has gone quiet, or two epoch streams. Measured before the fix: eight pushes produced zero cycles in five runs out of eight.
Wheels for Linux, and for Python 3.14. A release now publishes 15 wheels – Windows, macOS and manylinux_2_28 x86_64, for CPython 3.10 through 3.14 – where it published 8 before, Windows and macOS only and 3.10 through 3.13. On Linux
pip install ioiocorepreviously had nothing to install: no wheel, and no source distribution on PyPI either.The interpreter and platform lists are now read from
[tool.ioiocore] python_versionsthroughscripts/ci_matrix.pyrather than repeated in the release workflow, so a version added there is built and not merely tested. The release also refuses to publish unless it holds every wheel that matrix calls for: a build that fails for one interpreter now stops the release instead of quietly shipping without it.Portis exported.ioiocore.Portbinds the base class thatIPortandOPortderive from, and whoseConfigurationdefines thename,typeandtimingkeys they inherit. It was documented in the manual but not importable from the package.Interfaceis no longer in the manual: it binds a class to its implementation and has no use in building a pipeline. It remains importable asioiocore.interface.Interface.An output port can be watched without being consumed.
OPortImp.add_tap(fn)/remove_tap(fn)invoke a plain callable with every frame the port pushes, before the fan-out copies, andremove_tapis safe while data is flowing. A port with no taps costs one attribute load and a branch, so nothing that does not use this pays for it.A callable rather than a node, because attaching a node to a running graph is not safe: a node with one input port gets no thread of its own, so it would execute inside the producer’s push – which for a device source is the driver’s own acquisition callback – and a node that was attached but never started fills an unbounded input queue until the pipeline’s backlog monitor stops the run. A tap has no queue, no thread, no setup and no lifecycle, so none of that applies. What it owes in exchange is stated in
add_tap: it runs on the producer’s thread and must return immediately, and it is handed the producer’s own buffer rather than a copy.A port’s connections are held in a tuple that is replaced, not a list that is mutated.
pushiterates that tuple, so connecting or disconnecting while data flows can no longer disturb it.This was a silent frame loss, and it fell on a bystander. With three consumers
[A, B, C], removingAwhilepush’s iterator is onAmakes the loop visitA, Cand skip B – a filter or a file writer, which asked for nothing and is told nothing. Demonstrated both ways;test/test_port_taps.pyreproduces it against a pipeline’s ports. Nothing in the library disconnected a port mid-run until something wanted to, which is why it had not surfaced.Costs nothing: measured over the push path, iterating a tuple attribute came out marginally faster than the list it replaces (54.0 against 59.1 ns per push).
An element now keeps the exception that stopped it, not only its message. Every catch site wrote
log(msg=e)and nothing more, which reduces an exception to its text and discards the traceback. A caller could be told that a node failed and never be shown where, because there was no object left to raisefrom– so no amount of checking in the layer above could give a user their own frames back.ProcessingElement.failure()returns it whole, alongside the log entry rather than instead of it: the log is what a running system writes down, and this is what a caller raises from. All four catch sites record it –NodeImp’s setup and cycle, andINodeImp’s direct handler and worker thread.The first failure is kept. A node whose
setup()raises returns from every later cycle without processing, and theERRORcondition it leaves behind makes the next cycle look like a fresh failure; overwriting would replace the cause with its consequence. Cleared byreset_run_state()with the counter and the condition, so a restarted run does not inherit the previous one’s verdict.raise_if_failed()now chains to the line that failed. It builds its message from the log entry, which carries the exception’s text and the node’s name and cannot carry a traceback – so a script was told that a node failed and had to go looking for the line itself, in its own node if it wrote one. It raisesfromthe kept exception now, so__cause__holds the original with its frames intact.Measured end to end: a node raising in
step()produces aRuntimeErrorwhose__cause__traceback ends instep, which is the user’s own method.fromonly when there is a cause.raise X from Nonesuppresses the chain, and an element that kept nothing – an older element, or a failure that reached only the log – must still raise with whatever context Python would have given it.Nothing about when an exception is caught changed. The batch path already propagated – all three catch sites on it honour
_batch– and the realtime path still catches, because an exception on a source thread nobody is waiting on would otherwise die silently. This adds what the realtime path was throwing away.An element now reports whether its
setup()raised, apart from its condition. Both asetup()and astep()that raise set the element’s condition toERROR, so a caller holding only the condition cannot tell a graph that was never viable from a run that died on its first frame. The distinction matters to exactly one caller:start(). A configuration that cannot work – a cutoff above Nyquist, a channel count that does not agree – is knowable before any data flows, and thestart()the caller is still inside can refuse it. Astep()that happens to fail on cycle 1 is an ordinary runtime failure, and makingstart()raise on that would turn every one of them into a start failure.setup_failed()onProcessingElementtherefore reports the first case only. It is cleared with the counter rather than with the condition, because the counter is what gatessetup(): a second run sets up again, and must not carry the previous run’s verdict on a setup that has not happened yet.get_condition()is exposed on the interface at the same time; it existed on the implementation and had no public reader.The engine no longer decimates on top of a node’s own answer.
_run_cyclegated the downstream push onis_decimation_step()in addition to whateverstep()returned.step()returningNonealready means “not this cycle”, and every node that decimates calls that helper itself and returnsNone— the four in g.Pype and this repository’s own test template — so the extra gate only ever agreed with them.Where it could not agree was the case it made impossible. A cycle carries
frame_sizesamples, so a cadence counted in cycles is the intended one only atframe_size == 1. A node whose decimation is semantic rather than paced — one that drops samples, like g.Pype’sDecimator— therefore could not accept any other frame size, and carried a written-down refusal saying exactly that.is_decimation_step()is unchanged and remains how a node paces itself; it is simply no longer applied a second time. Suite: 309 passed before and after.
Breaking changes
Removed source-code transport from (de)serialization.
Portable/Pipelinedeserialization now resolves node classes by import (module+class) only; anysourcefield in a payload is ignored. Custom nodes must be importable on the target host (ship them as a package).add_preinstalled_module()is now a no-op, kept only for compatibility.Removed
source_delay(theONode.source_delayproperty, its pipeline “equalization”, and theIONodestubs); it was never applied.The data path now copies on push:
OPorthands each connected input port its own deep copy, and consumers no longer copy on receive. Producers may safely reuse output buffers; fan-out consumers are isolated.Pipeline.start()now auto-resets after an error (previously the pipeline was permanently unstartable). Retry policy (backoff, limits) is the caller’s responsibility.The pipeline monitor now stops the pipeline on excessive input backlog (thresholds
ConstantsImp.BACKLOG_WARNING/BACKLOG_ERROR) instead of letting a slow consumer grow memory unbounded.Chains no longer silently discard explicitly supplied
input_ports/output_ports: a mismatch with the boundary node’s ports now raises at construction.Chain.get_counter()/Chain.get_context()now delegate to the chain’s last internal node (previously always0/None).
Breaking changes – node authorization.
Removed
NodeImp.add_authorization_key()and the module-levelAUTHORIZED_NODESset. A node no longer authorizes itself, so node authors have nothing to call: with no provider installed, every node starts. The old scheme was satisfied by calling it, which its own comment acknowledged, and it obliged every author to opt into their own authorization for no benefit.
Added – batch execution, for a consumer driving a whole recording.
Node.process(data)besideNode.step(data).step()takes one frame;process()takes everything the node will ever see, in one call, which is what a non-causal operation needs and what lets such a node hold no buffer of its own. A node declares which execution modes it supports by which of the two it overrides — derived from structure, so a node cannot claim a mode it has no body for. Implementing neither is refused when the node is constructed, naming the class.step()is therefore no longer abstract. A node implementing onlyprocess()is valid; callingstep()on it raises a message saying it runs in a batch pipeline only. The refusal for a node with neither method moved fromABCMetatoNode.__init__, because “one of these two” is a condition on a pair of methods thatabstractmethodcannot express.NodeImp.batch, set by the pipeline before start and never per cycle, since a run is wholly one mode. Two things depend on it: which handler runs, and whether an exception is caught.A batch run does not swallow exceptions.
NodeImp._cycle,NodeImp._setup_wrapperandINodeImp._direct_event_handlercatch and log on the realtime path — where the caller is a source thread nobody is watching and the log is the only channel — and re-raise on the batch path, where the driver is the caller. All three, not one: a graph with fan-in forbidden is a synchronous call stack, so the traceback arrives through the caller’s own line intact, and catching at any one of the three sites swallows it one frame further out, which looks fixed and is not. Most of the messages worth reading come fromsetup()rather thanstep()— a cutoff above Nyquist, a channel count that disagrees — which is why the setup path is included.
Added
Chain layer:
ProcessingElementbase plusChain,IChain,OChain,IOChain— reusable containers of internal nodes usable anywhere the equivalent node kind is expected.get_input_port()/get_output_port()with fixed direction on all node and chain types.A chain now derives which ports pair its internal nodes instead of assuming
out->in. Sequential wiring is unchanged; the pair is resolved by three rules, in order: a single output and a single input pair whatever they are called, equal name sets pair by name, and equal counts with different names pair in declaration order. Anything else is refused naming both sides, where it previously surfaced from insideconnectas a missing port and named only one. The old assumption was load-bearing and wrong for any boundary node with more than one port – a chain exposes its first internal node’s input ports as its own, so a transport bridge in front of a multi-port core could neither declare those ports nor be wired to them. More permissive than before rather than less: the previous form required the exact namesoutandin.Node.is_externally_fed()andNode.is_externally_drained(), which a node overrides to declare that something outside the pipeline supplies or consumes its data. A chain consults them to accept a transport bridge at its inlet or outlet, which it would otherwise refuse as a node whose ports nothing local feeds.Pipeline.close()and context-manager support (with Pipeline() as p:) to release logging resources;Pipeline.get_backlog()andPipeline.is_logging_persistent().Logger.stop(),Logger.is_persistent(), andLogger.get_incident()(a never-evicted last-error/last-warning channel).First-class optional configuration keys via
Configuration.OptionalKeys(validated if present, but may be absent).Linux support: the default log directory is resolved lazily (XDG path); importing the package no longer fails on unsupported platforms.
Added – input adaptation.
Node.pre_setup(data, port_context_in)andNode.pre_step(data), a symmetric pair of extension points called immediately beforesetup()andstep(). Both modify their argument in place and default to doing nothing.pre_stepmay also return True to withhold the cycle:stepis not called and nothing is emitted. A hook that holds frames back needs that – the alternative is fabricating a frame to keep the cycle going, which puts invented samples at the head of the stream and shifts every later one. The cycle counter still advances, sosetupis not repeated and any decimation phase stays aligned with the cycles the node was actually driven through. Together they let a consuming package present every node with uniform inputs without each node author knowing about it:pre_setupsettles what the inputs are – channels, rate, timing – andpre_stepsupplies frames that match. Adapting only the frames would be too late, because a node decides its own output shape insetup()from exactly those contexts. The engine holds no opinion about what “uniform” means; that belongs to the domain layer.
Added – a way for a program to learn that the run failed.
Pipeline.add_error_handler(handler)is called once when the run fails. A failure is detected asynchronously, afterstart()has returned, so there is nothing forstart()to raise – and until now the only report was printed to a console, which a windowed application may never show. The handler runs on the monitoring thread, so it should hand the news to whatever owns the user interface rather than doing work itself; an exception raised inside it is logged and swallowed, because that thread is the only channel that reports failures and must not die reporting one.Pipeline.raise_if_failed()for a script that would otherwise carry on past a dead pipeline. The message names the exception, the node and the source location.Pipeline.get_last_error()was annotatedstrbut returns aLogEntry. The annotation is corrected: it hid the node and location a reader needs.
Added – node authorization, replacing the above.
set_authorization_provider(provider)andauthorization_provider(). A consuming package that must gate execution installs any object withis_authorized(key) -> bool; it is asked once per nodestart(), never per cycle, and receives the node’s concrete type. Whatever secret the decision rests on stays in the consumer’s package – ioiocore holds no policy and no secret, which is why this is an interface rather than an implementation.Registration is one-way: a second call raises. Otherwise installing a permissive provider first would defeat a real one. A consumer must treat a failed registration as fatal rather than continuing ungated.
Added – serialization, so a document can be written by something other than ioiocore itself.
A serialized document carries
format_versionandwriter. A document written by a newer revision than the running build is refused outright rather than half-understood, and one carrying noformat_versionis read as the pre-5.0.0 revision; re-saving it stamps the current one.A connection may name its endpoints instead of carrying port ids:
"gen.out"for a named port, or"gen"for the node’s default one.serialize()still writes ids and ids are still resolved first, so no existing document changes meaning. The names are for a document written by hand or by an authoring tool, which cannot know the ids a future process will mint.A chain no longer embeds its internal nodes in a document; the loader rebuilds them from the chain’s own definition, so the process that loads a document decides a chain’s composition. Set
INTERNALS_ARE_DERIVED = Falseon a chain whose composition a document must pin instead.
Fixed
A generated node id is sixteen hexadecimal digits every time. One value in sixteen was padded with spaces rather than zeros and came out short, so a consumer matching
[0-9A-F]{16}– an authoring tool keying its boxes on the id – failed intermittently and for no visible reason.A document asking for a generated node id now gets one. The sentinel was compared by identity, and a document’s own copy of it arrives from
json.loadsas a different object, so it was taken for a real id and two such nodes collided.A node whose inputs include a port that never delivers data now runs. The first cycle waits for a context on every input port instead of data on every input port, so a producer that has set up but has nothing to emit no longer starves its consumer.
Port contexts are delivered when the producer sets up, not on its first data push, so a silent port still describes its stream.
A multi-input node with only asynchronous inputs drains its whole queue per wake instead of a single item, no longer stranding queued data. This completes the single-input fix listed below.
A node no longer runs
step()or pushes data after itssetup()fails.Error reporting no longer crashes on argument-less exceptions or non-string log messages; the log-writer thread survives a malformed entry.
Pipeline.start()is atomic: a node failing to start rolls back the already-started nodes and re-raises, instead of leaving unstoppable ones.Single-input node execution no longer races between
start()and the producer thread, and drains all queued items per event (no lost trailing samples);stop()no longer hangs behind an input backlog.The monitor thread is joined on
stop()(no duplicate monitors after a restart); the logger is flushed and closed onclose().The per-cycle deep copy is no longer performed while holding the input port locks.
An
IONodewith all-async outputs no longer spawns the pure-source setup thread (MRO hazard).A failed
connect()(or deserialization) no longer leaves a half-connected port that still receives data.Error detection uses a sticky incident channel, so a burst of log entries can no longer evict an error before the monitor sees it;
get_last_error()is likewise reliable.Portable’s id registry uses weak references (no longer keeps every node and port alive for the process lifetime); clearer id-conflict message.ConfigurationandContextnow blockdel(implement__delitem__).Chain deserialization rebinds subclass node handles to the restored nodes; chain port configuration is deep-copied (no shared mutable state).
Removed unconditional 10 ms sleep in node setup; removed the shared mutable default
data={}on the cycle entry points; removed dead code (PortImp.get_state, node authorization key fields).
Fixed – error reporting, which is the code path that reaches a user at the worst possible moment.
A logged exception lost its type.
str(e)alone was used whenever it produced any text, so aKeyErrorreached the user as*** 'sampling_rate' ***– the one word carrying the meaning was the word that got dropped. NowKeyError: 'sampling_rate'.The error banner did not say which node failed. It rebuilt a bare file path while the log entry already carried an unused
summaryreadingClass.function() [file:line]. That is now shown, together with the node’s instance name: a graph with threeBandpassnodes previously gave no way to tell which one stopped.ANSI colour codes were emitted unconditionally. Redirected to a file, captured by a test harness or displayed in a notebook, the most important message a user ever receives arrived wrapped in literal
[31m. Colour is now used only when stdout is a terminal, andNO_COLORandTERM=dumbare honoured.The banner joined the directory and filename with a hard-coded backslash, which is wrong on macOS.
A failed deserialization raised
TypeErrorwithout chaining, so a missing port id surfaced with no trace of the real cause. Now chained.When a log file could not be created the reason was discarded, in two separate handlers, and the banner then told the reader to consult it:
See log file for details: None. Both handlers now record the cause and the attempted path,Loggerexposes them, and the banner saysNo log file was written at <path> (<reason>). This message is all there is.The warning at construction names the cause as well, which is the moment a user can still act on it.A node that failed to start reported
TypeError: Argument 'msg' has incorrect typeinstead of why it failed.Pipeline.logtakes astrwhile the node loggers takestrorException, and the partial-start recovery path passed the exception object – so the logging call inside the handler raised, replacing the error being handled and pre-empting theraiseon the next line. A regression test for this had been failing.
Changed – the release chain, which is now g.Pype’s.
The version comes from the git tag via
setuptools_scm, read from CHANGELOG.md bynox -s tag_create, instead of from a hand-maintainedVERSIONtuple.src/ioiocore/__version__.pyis generated and no longer in the repository, so a source checkout needs its git history to report a version;ioiocore.__version__is unchanged for anyone installing a wheel.VERSIONitself is gone – nothing read it.pyproject.tomlnow carries the project metadata and the dependency extras that therequirements-*.txtfiles held, and declares which modules are compiled ([tool.ioiocore] cython_files).setup.pyis reduced to building the extensions it finds.Builds, tests, linting, cleaning and tagging run as nox sessions, shared with g.Pype file for file;
scripts/make_*.batare thin wrappers around them.setup.pyno longer renames the sources in place and restores them from anatexithook, so a build that fails can no longer leave the package as a directory of.pyxfiles with nothing to import.
Deprecated
Chain.get_port()— useget_input_port()/get_output_port().Portable.add_preinstalled_module()— no-op (source transport removed).
Security
Removed
exec()of serialized class source during deserialization (an arbitrary-code-execution surface reachable fromPipeline.deserialize()).
[4.0.7] - 2026-02-20
Fixed reference leakage in context propagation
Added direct execution policy in consecutive nodes
Removed pipeline load monitoring (not accurate)
Added support for macOS 14+
[4.0.6] - 2026-01-21
Made put/get at port level thread-safe
Fixed bug in data availability condition
[4.0.5] - 2026-01-14
Fixed some bugs in the de/serialization procedure
[4.0.4] - 2026-01-07
Asynchronous/synchronous data propagation decoupled in step() function
[4.0.3] - 2025-12-01
Asynchronous data propagation improved
[4.0.2] - 2025-07-11
Source delay handling implemented
[4.0.1] - 2025-07-11
Minor bugfixes
Improved logging & exception handling
[4.0.0] - 2025-07-10
Updated interface
Improved logging
[3.0.0] - 2025-07-03
Moved from metadata to contexts
Bugfixing
[2.3.1] - 2025-06-16
Added multirate support
Bugfixing
[2.3.0] - 2025-04-16
Increase unit test coverage
Added support for Python 3.8 and 3.9
[2.2.6] - 2025-02-24
Added Changelog to README.md
Added support for Python 3.10
[2.2.5] - 2025-02-24
Skipped
[2.2.4] - 2025-02-21
Updated documentation
Added support for macOS
Added support for Python 3.11
[2.2.3] - 2025-02-20
First public beta release