pulse-beacon 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inventzia/pulse/beacon/__init__.py +18 -0
- inventzia/pulse/beacon/_runtime/__init__.py +18 -0
- inventzia/pulse/beacon/_runtime/pulse-beacon-runtime.jar +0 -0
- inventzia/pulse/beacon/core/__init__.py +39 -0
- inventzia/pulse/beacon/core/actor.py +62 -0
- inventzia/pulse/beacon/core/channel.py +34 -0
- inventzia/pulse/beacon/core/crosslanguage/__init__.py +16 -0
- inventzia/pulse/beacon/core/crosslanguage/cross_language_streamer.py +95 -0
- inventzia/pulse/beacon/core/crosslanguage/jep_host.py +85 -0
- inventzia/pulse/beacon/core/crosslanguage/jpype_host.py +236 -0
- inventzia/pulse/beacon/core/dispatch.py +41 -0
- inventzia/pulse/beacon/core/examples/__init__.py +15 -0
- inventzia/pulse/beacon/core/examples/_data/cdf_bars.jsonl +4 -0
- inventzia/pulse/beacon/core/examples/_data/messages_one.jsonl +4 -0
- inventzia/pulse/beacon/core/examples/_data/messages_two.jsonl +3 -0
- inventzia/pulse/beacon/core/examples/echo_consumer.py +38 -0
- inventzia/pulse/beacon/core/examples/historic_run_jpype.py +260 -0
- inventzia/pulse/beacon/core/examples/message_feed_gateway.py +44 -0
- inventzia/pulse/beacon/core/examples/print_consumer.py +37 -0
- inventzia/pulse/beacon/core/examples/realtime_run_jpype.py +183 -0
- inventzia/pulse/beacon/core/examples/recording_print_consumer.py +35 -0
- inventzia/pulse/beacon/core/examples/vector_value_run_jpype.py +170 -0
- inventzia/pulse/beacon/core/gateway.py +61 -0
- inventzia/pulse/beacon/core/reporter.py +213 -0
- pulse_beacon-0.2.2.dist-info/METADATA +378 -0
- pulse_beacon-0.2.2.dist-info/RECORD +31 -0
- pulse_beacon-0.2.2.dist-info/WHEEL +5 -0
- pulse_beacon-0.2.2.dist-info/licenses/LICENSE-AGPL-3.0 +668 -0
- pulse_beacon-0.2.2.dist-info/licenses/LICENSE-COMMERCIAL.txt +22 -0
- pulse_beacon-0.2.2.dist-info/licenses/NOTICE +16 -0
- pulse_beacon-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""pulse-beacon: the event-driven engine core and in-process cross-language bridge.
|
|
11
|
+
|
|
12
|
+
See :mod:`inventzia.pulse.beacon.core` for the public engine API (actor / gateway
|
|
13
|
+
bases, channel, logging facade).
|
|
14
|
+
|
|
15
|
+
``inventzia`` and ``inventzia.pulse`` are PEP 420 namespace packages shared with
|
|
16
|
+
pulse-data; from here inward the packages are regular packages with deliberate
|
|
17
|
+
exports.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""Carrier package for the wheel-bundled Beacon runtime jar.
|
|
11
|
+
|
|
12
|
+
A *regular* package (unlike the PEP 420 namespace packages above it) so it can
|
|
13
|
+
hold ``pulse-beacon-runtime.jar`` as package data, discoverable at runtime via
|
|
14
|
+
``importlib.resources`` — see ``crosslanguage.jpype_host.bundled_runtime_jar``.
|
|
15
|
+
|
|
16
|
+
The jar is a build artifact produced by ``pulse-beacon/build-runtime-jar.sh``
|
|
17
|
+
(Maven ``-Pruntime-jar`` shade); it is not committed to source control.
|
|
18
|
+
"""
|
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""pulse-beacon engine core: actor / gateway bases, channel, and the logging facade.
|
|
11
|
+
|
|
12
|
+
from inventzia.pulse.beacon.core import BeaconActor, BeaconGateway
|
|
13
|
+
|
|
14
|
+
Deliberately import-light — none of these pull the JVM bridges. Import the
|
|
15
|
+
Python-host (``crosslanguage.jpype_host``) or Java-host (``crosslanguage.jep_host``)
|
|
16
|
+
machinery from its own module only when you need it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from inventzia.pulse.beacon.core.actor import BeaconActor
|
|
20
|
+
from inventzia.pulse.beacon.core.channel import BeaconChannel
|
|
21
|
+
from inventzia.pulse.beacon.core.gateway import BeaconGateway
|
|
22
|
+
from inventzia.pulse.beacon.core.reporter import (
|
|
23
|
+
CallbackReporter,
|
|
24
|
+
ComponentReporter,
|
|
25
|
+
LoggingReporter,
|
|
26
|
+
Reporter,
|
|
27
|
+
ReportLevel,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BeaconActor",
|
|
32
|
+
"BeaconGateway",
|
|
33
|
+
"BeaconChannel",
|
|
34
|
+
"Reporter",
|
|
35
|
+
"ReportLevel",
|
|
36
|
+
"LoggingReporter",
|
|
37
|
+
"CallbackReporter",
|
|
38
|
+
"ComponentReporter",
|
|
39
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
Base class for a consumer/strategy actor implemented in Python.
|
|
12
|
+
|
|
13
|
+
The Python mirror of the Java ``AbstractActor``: a reactive component that
|
|
14
|
+
receives events on the topics it subscribes to and may publish new events back
|
|
15
|
+
onto the bus. It is pure Python and JVM-agnostic — it never imports a JVM
|
|
16
|
+
bridge. The cross-language streamer drives ``on_startup`` / ``on_event`` /
|
|
17
|
+
``on_shutdown`` from the foreign side, and :meth:`publish` goes out through a bound
|
|
18
|
+
:class:`~inventzia.pulse.beacon.core.channel.BeaconChannel`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
|
|
23
|
+
from inventzia.pulse.beacon.core.reporter import ComponentReporter
|
|
24
|
+
from inventzia.pulse.data.datum.datum import Datum
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BeaconActor(ABC):
|
|
28
|
+
"""A reactive actor. Override :meth:`on_event` (and optionally the lifecycle hooks)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, name: str):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.log = ComponentReporter(name)
|
|
33
|
+
self._channel = None # bound at registration; see bind()
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Binding — the outbound bus, set before the run starts
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def bind(self, channel) -> None:
|
|
40
|
+
"""Bind the outbound :class:`BeaconChannel` this actor publishes through."""
|
|
41
|
+
self._channel = channel
|
|
42
|
+
|
|
43
|
+
def publish(self, topic_name: str, datum: Datum) -> None:
|
|
44
|
+
"""Publish a datum onto the bus via the bound channel."""
|
|
45
|
+
if self._channel is None:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
f"actor '{self.name}' published before being bound to a channel")
|
|
48
|
+
self._channel.publish(topic_name, datum)
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Lifecycle + inbound — override as needed
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def on_startup(self, time_millis: int) -> None:
|
|
55
|
+
"""Called once when the run begins, at the start time. Default: no-op."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def on_event(self, topic_name: str, datum: Datum) -> None:
|
|
59
|
+
"""Handle one event delivered on ``topic_name``."""
|
|
60
|
+
|
|
61
|
+
def on_shutdown(self, time_millis: int) -> None:
|
|
62
|
+
"""Called once when the run ends. Default: no-op."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
The outbound bus for a Python component.
|
|
12
|
+
|
|
13
|
+
The Python mirror of the bus an ``AbstractActor`` is bound to: it carries a
|
|
14
|
+
datum the component wants to emit back onto the engine. It wraps the Java
|
|
15
|
+
cross-language endpoint and calls its ``publishTagged(topicName, taggedJson)``,
|
|
16
|
+
serialising the datum to the self-describing tagged envelope first. The
|
|
17
|
+
component never sees the endpoint or any JSON — only :meth:`publish`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from inventzia.pulse.data.datum.codec import to_tagged_json
|
|
21
|
+
from inventzia.pulse.data.datum.datum import Datum
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BeaconChannel:
|
|
25
|
+
"""Publishes a datum onto the bus through the Java cross-language endpoint."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, endpoint):
|
|
28
|
+
# endpoint: the Java CrossLanguageActor (a JPype/JEP proxy) exposing
|
|
29
|
+
# publishTagged(str, str). Held opaquely so this class stays JVM-agnostic.
|
|
30
|
+
self._endpoint = endpoint
|
|
31
|
+
|
|
32
|
+
def publish(self, topic_name: str, datum: Datum) -> None:
|
|
33
|
+
"""Serialise ``datum`` and publish it on ``topic_name``."""
|
|
34
|
+
self._endpoint.publishTagged(topic_name, to_tagged_json(datum))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""In-process cross-language bridge: the JVM-agnostic streamer plus the JPype
|
|
11
|
+
(Python-host) and JEP (Java-host) bootstraps.
|
|
12
|
+
|
|
13
|
+
Kept import-light on purpose — the ``jpype_host`` / ``jep_host`` modules pull
|
|
14
|
+
heavy or native dependencies, so import the specific host you need rather than
|
|
15
|
+
this package.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
The one control loop that bridges a Python component to its Java counterpart.
|
|
12
|
+
|
|
13
|
+
This is the single piece of streaming logic, written once and used unchanged in
|
|
14
|
+
both embedding directions: under JPype it runs on a Python-owned thread, under
|
|
15
|
+
JEP on the thread the JVM created for it. It only *calls* the Java endpoint
|
|
16
|
+
(``takeNext`` / ``ackDone`` for a consumer, ``offerNext`` / ``finish`` for a
|
|
17
|
+
source) and the Python component (``produce`` / ``on_event`` / lifecycle), so
|
|
18
|
+
nothing here is JVM-specific.
|
|
19
|
+
|
|
20
|
+
Two loop flavours mirror the two roles:
|
|
21
|
+
|
|
22
|
+
* :meth:`run_consume` — engine → Python. Drains events the engine dispatched,
|
|
23
|
+
routes each to the component, and acks (which releases the engine's dispatch
|
|
24
|
+
thread, the determinism handshake).
|
|
25
|
+
* :meth:`run_produce` — Python → engine. Pulls the source's ``produce`` stream
|
|
26
|
+
and offers each event to the Java gateway, which blocks until it has been
|
|
27
|
+
emitted through the time-machine write permit.
|
|
28
|
+
|
|
29
|
+
A bidirectional gateway runs both loops on two threads — the modern form of the
|
|
30
|
+
old ``run_incoming_data`` / ``run_outgoing_data`` pair.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import traceback
|
|
34
|
+
|
|
35
|
+
from inventzia.pulse.beacon.core.dispatch import dispatch_consume, event_kind
|
|
36
|
+
from inventzia.pulse.beacon.core.reporter import ComponentReporter
|
|
37
|
+
from inventzia.pulse.data.datum.codec import to_tagged_json
|
|
38
|
+
|
|
39
|
+
_log = ComponentReporter("streamer")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CrossLanguageStreamer:
|
|
43
|
+
"""Drives one Python component against its Java cross-language endpoint."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, endpoint, component, channel=None):
|
|
46
|
+
# endpoint: the Java CrossLanguageActor / CrossLanguageGateway proxy.
|
|
47
|
+
self._endpoint = endpoint
|
|
48
|
+
self._component = component
|
|
49
|
+
# If a channel is supplied and the component can publish, bind it now so
|
|
50
|
+
# on_event handlers may publish during the consume loop.
|
|
51
|
+
if channel is not None and hasattr(component, "bind"):
|
|
52
|
+
component.bind(channel)
|
|
53
|
+
|
|
54
|
+
def run_consume(self) -> None:
|
|
55
|
+
"""Engine → Python: drain, route, ack — until an END event arrives."""
|
|
56
|
+
while True:
|
|
57
|
+
event = self._endpoint.takeNext() # blocks
|
|
58
|
+
if event_kind(event) == "END":
|
|
59
|
+
break
|
|
60
|
+
try:
|
|
61
|
+
dispatch_consume(self._component, event)
|
|
62
|
+
except Exception:
|
|
63
|
+
# A failing handler must not kill the streamer: the engine's
|
|
64
|
+
# dispatch thread is blocked waiting for our ack, so dying here
|
|
65
|
+
# would deadlock the run. Log and carry on.
|
|
66
|
+
_log.severe("error handling cross-language event\n" + traceback.format_exc())
|
|
67
|
+
finally:
|
|
68
|
+
self._endpoint.ackDone() # release the engine's dispatch thread
|
|
69
|
+
|
|
70
|
+
def run_produce(self, on_error=None) -> None:
|
|
71
|
+
"""Python → engine: offer each produced event; the gateway paces us via the permit.
|
|
72
|
+
|
|
73
|
+
``finish()`` runs in a ``finally`` so that a producer which raises (e.g. a
|
|
74
|
+
source that cannot open its input) or yields nothing still signals completion
|
|
75
|
+
to its Java gateway. Otherwise the engine's all-drivers barrier would wait on a
|
|
76
|
+
dead producer forever — a hang, not a fast failure.
|
|
77
|
+
|
|
78
|
+
``on_error`` (optional) is invoked with the exception *before* the
|
|
79
|
+
``finally`` runs, so a supervisor can react immediately: if the engine is
|
|
80
|
+
itself wedged (another driver also failed), ``finish()`` may block until the
|
|
81
|
+
engine is torn down, and waiting for it to return would defeat fast failure.
|
|
82
|
+
The exception still propagates after ``finish()`` for callers that rely on it.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
for topic_name, datum in self._component.produce():
|
|
86
|
+
self._endpoint.offerNext(topic_name, to_tagged_json(datum)) # blocks until accepted
|
|
87
|
+
except BaseException as exc: # noqa: BLE001 — notify, then let it propagate
|
|
88
|
+
if on_error is not None:
|
|
89
|
+
try:
|
|
90
|
+
on_error(exc)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
raise
|
|
94
|
+
finally:
|
|
95
|
+
self._endpoint.finish()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
Java-host (JEP) scaffold: a generic factory that runs a Python component's streamer.
|
|
12
|
+
|
|
13
|
+
The mirror of :mod:`jpype_host`, for the opposite embedding direction. Under JPype
|
|
14
|
+
a Python process boots the JVM; under JEP the **JVM is the host** and creates a
|
|
15
|
+
CPython interpreter (one per streamer thread) via JEP. The Java launcher drives
|
|
16
|
+
everything declaratively: it hands in the Java ``CrossLanguage*`` endpoint, the
|
|
17
|
+
*fully-qualified name* of the Python component type, and its constructor args, and
|
|
18
|
+
picks which streamer loop to run.
|
|
19
|
+
|
|
20
|
+
This module is a **factory, not an example** — it knows no component names and
|
|
21
|
+
imports nothing from the ``examples`` package. The two entry points are named
|
|
22
|
+
after the two streamer loops, which is what they actually run and is orthogonal to
|
|
23
|
+
component type:
|
|
24
|
+
|
|
25
|
+
* :func:`run_consume` — engine → Python. Used for an **Actor** (which consumes via
|
|
26
|
+
``on_event`` and may publish back) and for a **Gateway** in its **sink** role.
|
|
27
|
+
* :func:`run_produce` — Python → engine. Used for a **Gateway** in its **source**
|
|
28
|
+
role (which yields from ``produce``).
|
|
29
|
+
|
|
30
|
+
The component itself (any ``BeaconActor`` / ``BeaconGateway``, or a purpose-built
|
|
31
|
+
subclass such as a recording actor for parity) is constructed by fully-qualified
|
|
32
|
+
name — the example-specific choice of *which* types with *which* args lives on the
|
|
33
|
+
Java side, not here.
|
|
34
|
+
|
|
35
|
+
Everything below the factory — the components, ``dispatch``, and the streamer — is
|
|
36
|
+
JVM-agnostic and identical to the JPype path; only *who starts whom* differs. Each
|
|
37
|
+
``run_*`` blocks on the streamer loop until it ends (``END`` for consume, source
|
|
38
|
+
exhaustion for produce), so the calling Java thread *is* the streamer thread —
|
|
39
|
+
which is what JEP requires, since an interpreter is bound to its creating thread.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import importlib
|
|
43
|
+
|
|
44
|
+
from inventzia.pulse.beacon.core.channel import BeaconChannel
|
|
45
|
+
from inventzia.pulse.beacon.core.crosslanguage.cross_language_streamer import CrossLanguageStreamer
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _instantiate(type_fqn: str, args):
|
|
49
|
+
"""Import ``pkg.module.ClassName`` and construct it with ``args``.
|
|
50
|
+
|
|
51
|
+
``args`` is whatever the Java launcher handed in (typically a ``java.util.List``
|
|
52
|
+
of constructor arguments); it is materialised to a Python list so both plain
|
|
53
|
+
values and Java-object arguments (e.g. a parity sink) pass straight through.
|
|
54
|
+
"""
|
|
55
|
+
module_path, _, class_name = type_fqn.rpartition(".")
|
|
56
|
+
if not module_path:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"component type must be a fully-qualified 'module.Class' name, got {type_fqn!r}")
|
|
59
|
+
cls = getattr(importlib.import_module(module_path), class_name)
|
|
60
|
+
return cls(*list(args))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_consume(endpoint, type_fqn, args, publishes=False):
|
|
64
|
+
"""Build ``type_fqn(*args)`` and drive its consume loop (engine → Python) until END.
|
|
65
|
+
|
|
66
|
+
:param endpoint: the Java ``CrossLanguageActor`` / sink ``CrossLanguageGateway`` proxy.
|
|
67
|
+
:param type_fqn: fully-qualified name of the Python Actor / sink-Gateway class.
|
|
68
|
+
:param args: constructor arguments (a Java list; Java objects pass through).
|
|
69
|
+
:param publishes: if true, bind a :class:`BeaconChannel` so the component can
|
|
70
|
+
publish back onto the bus (an Actor with a ``Pub`` role, e.g. an echo actor).
|
|
71
|
+
"""
|
|
72
|
+
component = _instantiate(type_fqn, args)
|
|
73
|
+
channel = BeaconChannel(endpoint) if publishes else None
|
|
74
|
+
CrossLanguageStreamer(endpoint, component, channel).run_consume()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_produce(endpoint, type_fqn, args):
|
|
78
|
+
"""Build ``type_fqn(*args)`` and drive its produce loop (Python → engine) until exhausted.
|
|
79
|
+
|
|
80
|
+
:param endpoint: the Java source ``CrossLanguageGateway`` proxy.
|
|
81
|
+
:param type_fqn: fully-qualified name of the Python source-Gateway class.
|
|
82
|
+
:param args: constructor arguments (a Java list; Java objects pass through).
|
|
83
|
+
"""
|
|
84
|
+
component = _instantiate(type_fqn, args)
|
|
85
|
+
CrossLanguageStreamer(endpoint, component).run_produce()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
Python-host bootstrap: start an embedded JVM (via JPype) on the Beacon classpath.
|
|
12
|
+
|
|
13
|
+
This is the only direction-specific piece for the Python-host launcher. The
|
|
14
|
+
classpath is resolved in two ways, in order:
|
|
15
|
+
|
|
16
|
+
1. **Installed package (zero-config):** a single shaded *runtime jar* — Beacon
|
|
17
|
+
plus its ordinary Java dependencies — bundled inside the wheel at
|
|
18
|
+
``inventzia.pulse.beacon._runtime``. Located via ``importlib.resources``, so
|
|
19
|
+
``pip install pulse-beacon`` needs no staged jars, ``PYTHONPATH``, or Maven.
|
|
20
|
+
2. **Source checkout (dev):** the Maven-produced release jar set staged into
|
|
21
|
+
``<pulse-beacon>/core/java/jars/`` (the module jar plus runtime deps).
|
|
22
|
+
|
|
23
|
+
JPype finds the JVM itself from ``JAVA_HOME`` — a jar is not a JVM, so a JDK/JRE
|
|
24
|
+
17+ must be present (see PackagingAndPublishingRelease.md §3). Everything above
|
|
25
|
+
the bootstrap (actors, gateways, channel, dispatch, streamer) is JVM-agnostic
|
|
26
|
+
and unchanged from the Java-host (JEP) path.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import glob
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from inventzia.pulse.beacon.core.reporter import ComponentReporter
|
|
34
|
+
|
|
35
|
+
_log = ComponentReporter("jpype-host")
|
|
36
|
+
|
|
37
|
+
# The wheel-bundled shaded runtime jar (built by core/java `mvn -Pruntime-jar`
|
|
38
|
+
# and copied here — see pulse-beacon/build-runtime-jar.sh).
|
|
39
|
+
_RUNTIME_PACKAGE = "inventzia.pulse.beacon._runtime"
|
|
40
|
+
_RUNTIME_JAR = "pulse-beacon-runtime.jar"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bundled_runtime_jar() -> Path | None:
|
|
44
|
+
"""The shaded runtime jar bundled in the installed wheel, or ``None`` if absent.
|
|
45
|
+
|
|
46
|
+
Present in an installed/built package; absent in a bare source checkout that
|
|
47
|
+
hasn't run ``build-runtime-jar.sh`` (dev then falls back to the staged jars).
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
from importlib.resources import files
|
|
51
|
+
res = files(_RUNTIME_PACKAGE).joinpath(_RUNTIME_JAR)
|
|
52
|
+
path = Path(str(res))
|
|
53
|
+
return path if path.is_file() else None
|
|
54
|
+
except (ModuleNotFoundError, FileNotFoundError, TypeError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_jars_dir() -> Path:
|
|
59
|
+
"""The staged release jars directory: ``<pulse-beacon>/core/java/jars``.
|
|
60
|
+
|
|
61
|
+
Source-checkout convenience only; for an installed package this is superseded
|
|
62
|
+
by the bundled runtime-jar discovery (:func:`bundled_runtime_jar`).
|
|
63
|
+
"""
|
|
64
|
+
# .../pulse-beacon/src/inventzia/pulse/beacon/core/crosslanguage/jpype_host.py
|
|
65
|
+
# parents[6] is the pulse-beacon repo root.
|
|
66
|
+
beacon_root = Path(__file__).resolve().parents[6]
|
|
67
|
+
return beacon_root / "core" / "java" / "jars"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_classpath(jars_dir=None) -> tuple[list[str], str]:
|
|
71
|
+
"""Resolve the Beacon classpath: explicit dir, else bundled jar, else staged jars.
|
|
72
|
+
|
|
73
|
+
Returns ``(jars, source_description)``. Raises ``FileNotFoundError`` if none resolve.
|
|
74
|
+
"""
|
|
75
|
+
if jars_dir is not None:
|
|
76
|
+
jars = sorted(glob.glob(str(Path(jars_dir) / "*.jar")))
|
|
77
|
+
if jars:
|
|
78
|
+
return jars, f"{len(jars)} jars from {jars_dir}"
|
|
79
|
+
raise FileNotFoundError(f"no jars in {jars_dir}")
|
|
80
|
+
|
|
81
|
+
bundled = bundled_runtime_jar()
|
|
82
|
+
if bundled is not None:
|
|
83
|
+
return [str(bundled)], f"bundled runtime jar {bundled}"
|
|
84
|
+
|
|
85
|
+
staged = default_jars_dir()
|
|
86
|
+
jars = sorted(glob.glob(str(staged / "*.jar")))
|
|
87
|
+
if jars:
|
|
88
|
+
return jars, f"{len(jars)} staged jars from {staged}"
|
|
89
|
+
|
|
90
|
+
raise FileNotFoundError(
|
|
91
|
+
f"no Beacon classpath found. Either install the package (bundled runtime jar), "
|
|
92
|
+
f"or build the staged jars into {staged} with pulse-beacon/build-runtime-jar.sh "
|
|
93
|
+
f"(or: cd core/java && mvn package -DskipTests && "
|
|
94
|
+
f"mvn dependency:copy-dependencies -DoutputDirectory=jars -DincludeScope=runtime && "
|
|
95
|
+
f"cp target/pulse-beacon-core-*.jar jars/)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _normalize_classpath_entry(entry: str) -> str:
|
|
99
|
+
"""Canonicalize a classpath entry for comparison (absolute path, OS-normalized case/sep)."""
|
|
100
|
+
return os.path.normcase(os.path.abspath(str(entry)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _running_classpath_entries() -> set[str]:
|
|
104
|
+
"""The normalized entries on the already-running JVM's ``java.class.path``."""
|
|
105
|
+
from jpype import JClass
|
|
106
|
+
raw = JClass("java.lang.System").getProperty("java.class.path") or ""
|
|
107
|
+
return {_normalize_classpath_entry(e) for e in str(raw).split(os.pathsep) if e}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TypeUniverseMismatch(RuntimeError):
|
|
111
|
+
"""The Python and Java datum-type universes disagree; the bridge must not run.
|
|
112
|
+
|
|
113
|
+
Raised before any event flows, so a producer cannot emit an extension datum the
|
|
114
|
+
receiving runtime cannot decode (the SPI Phase 3 fail-fast gate).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def start_jvm(jars_dir=None, verify=True, extra_classpath=None) -> None:
|
|
119
|
+
"""Start the embedded JVM on the Beacon classpath, if not already running.
|
|
120
|
+
|
|
121
|
+
Classpath resolution order: explicit ``jars_dir`` → wheel-bundled runtime jar →
|
|
122
|
+
source-tree staged jars (see :func:`resolve_classpath`). ``extra_classpath`` (a list of
|
|
123
|
+
jar paths) is appended, so an extension jar contributing datum types via
|
|
124
|
+
``META-INF/services`` is discovered by the Java ``ServiceLoader``. The JVM is located via
|
|
125
|
+
``JAVA_HOME``; in the shared ``pulse`` conda env that is set automatically by the bundled
|
|
126
|
+
OpenJDK, so no manual export is needed; outside it, point ``JAVA_HOME`` at any JDK 17+.
|
|
127
|
+
Idempotent.
|
|
128
|
+
|
|
129
|
+
If ``verify`` (default), the Python and Java datum-type universes are compared once
|
|
130
|
+
the JVM is up, before any event flows, and a mismatch raises
|
|
131
|
+
:class:`TypeUniverseMismatch` (see :func:`verify_type_universe`).
|
|
132
|
+
|
|
133
|
+
**Reusing an existing JVM.** If a JVM is already running (a prior ``start_jvm``, a re-run
|
|
134
|
+
notebook cell, a retry after a failed verification, or a host application that booted the
|
|
135
|
+
JVM), this does not silently no-op: verification (when ``verify``) still runs, so a
|
|
136
|
+
mismatch present now is caught rather than slipping through just because the JVM started
|
|
137
|
+
earlier. And because a running JVM's classpath cannot be changed, any requested classpath
|
|
138
|
+
additions (``jars_dir`` / ``extra_classpath``) that are not already on it raise a
|
|
139
|
+
:class:`RuntimeError` rather than being silently ignored.
|
|
140
|
+
"""
|
|
141
|
+
import jpype
|
|
142
|
+
|
|
143
|
+
if jpype.isJVMStarted():
|
|
144
|
+
# A running JVM's classpath is fixed. If the caller asked to add jars that are not
|
|
145
|
+
# already on it, they cannot be honored — fail loudly instead of silently dropping them.
|
|
146
|
+
if jars_dir is not None or extra_classpath:
|
|
147
|
+
requested = list(resolve_classpath(jars_dir)[0]) if jars_dir is not None else []
|
|
148
|
+
requested += [str(p) for p in (extra_classpath or [])]
|
|
149
|
+
on_classpath = _running_classpath_entries()
|
|
150
|
+
missing = [j for j in requested if _normalize_classpath_entry(j) not in on_classpath]
|
|
151
|
+
if missing:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"JVM already running; its classpath cannot be changed, but start_jvm() was "
|
|
154
|
+
f"asked to add jars that are not on it: {missing}. Start the JVM once with the "
|
|
155
|
+
"full classpath (including every extension jar) before any other JPype use.")
|
|
156
|
+
_log.large_info("JVM already running; reusing it")
|
|
157
|
+
# Verification must still run on reuse (notebooks, retries, host-app JVMs): a mismatch
|
|
158
|
+
# now must not pass just because the JVM was started by an earlier call.
|
|
159
|
+
if verify:
|
|
160
|
+
verify_type_universe()
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
jars, source = resolve_classpath(jars_dir)
|
|
164
|
+
if extra_classpath:
|
|
165
|
+
jars = list(jars) + [str(p) for p in extra_classpath]
|
|
166
|
+
source += f" + {len(extra_classpath)} extension jar(s)"
|
|
167
|
+
|
|
168
|
+
if "JAVA_HOME" not in os.environ:
|
|
169
|
+
# A jar is not a JVM. In the `pulse` env JAVA_HOME is set automatically;
|
|
170
|
+
# outside it, set it explicitly.
|
|
171
|
+
raise EnvironmentError(
|
|
172
|
+
"JAVA_HOME is not set; run in the `pulse` conda env, or point it at a JDK 17+")
|
|
173
|
+
|
|
174
|
+
# convertStrings=True: Java String returns (topic names, tagged JSON) come
|
|
175
|
+
# back as native Python str, so json.loads / str ops work without wrapping.
|
|
176
|
+
jpype.startJVM(classpath=jars, convertStrings=True)
|
|
177
|
+
_log.info(f"JVM started ({source})")
|
|
178
|
+
if verify:
|
|
179
|
+
verify_type_universe()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _java_registry():
|
|
183
|
+
from jpype import JClass
|
|
184
|
+
return JClass("com.inventzia.pulse.data.datum.DatumTypeRegistry").defaultRegistry()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _py_type_map(registry) -> dict:
|
|
188
|
+
return {tid: (ver, fp) for pi in registry.providers() for (tid, ver, fp) in pi.entries}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _java_type_map(registry) -> dict:
|
|
192
|
+
return {str(e.typeId()): (int(e.typeVersion()), str(e.fingerprint()))
|
|
193
|
+
for pi in registry.providers() for e in pi.entries()}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def verify_type_universe(py_registry=None, java_registry=None) -> str:
|
|
197
|
+
"""Compare the Python and Java composite datum-type fingerprints; fail fast on mismatch.
|
|
198
|
+
|
|
199
|
+
Returns the shared fingerprint on success. Raises :class:`TypeUniverseMismatch` if the
|
|
200
|
+
two differ (naming the missing or incompatible types), or if either side is unverifiable
|
|
201
|
+
(a provider without a manifest), which is treated as fail-closed. Defaults to each
|
|
202
|
+
runtime's process-wide registry; explicit registries are accepted for testing.
|
|
203
|
+
"""
|
|
204
|
+
from inventzia.pulse.data.datum.registry import default_registry
|
|
205
|
+
|
|
206
|
+
py_reg = py_registry if py_registry is not None else default_registry()
|
|
207
|
+
java_reg = java_registry if java_registry is not None else _java_registry()
|
|
208
|
+
|
|
209
|
+
py_fp = py_reg.fingerprint()
|
|
210
|
+
jfp = java_reg.fingerprint()
|
|
211
|
+
java_fp = str(jfp) if jfp is not None else None
|
|
212
|
+
|
|
213
|
+
if py_fp is None or java_fp is None:
|
|
214
|
+
py_unv = list(py_reg.unverifiable_providers())
|
|
215
|
+
java_unv = [str(x) for x in java_reg.unverifiableProviders()]
|
|
216
|
+
raise TypeUniverseMismatch(
|
|
217
|
+
"datum-type universe is unverifiable (a provider has no manifest): "
|
|
218
|
+
f"python unverifiable={py_unv}, java unverifiable={java_unv}")
|
|
219
|
+
|
|
220
|
+
if py_fp != java_fp:
|
|
221
|
+
pym, jvm = _py_type_map(py_reg), _java_type_map(java_reg)
|
|
222
|
+
diffs = []
|
|
223
|
+
for tid in sorted(set(pym) | set(jvm)):
|
|
224
|
+
p, j = pym.get(tid), jvm.get(tid)
|
|
225
|
+
if p is None:
|
|
226
|
+
diffs.append(f" {tid}: only in Java")
|
|
227
|
+
elif j is None:
|
|
228
|
+
diffs.append(f" {tid}: only in Python")
|
|
229
|
+
elif p != j:
|
|
230
|
+
diffs.append(f" {tid}: differs (python v{p[0]}/{p[1][:8]}, java v{j[0]}/{j[1][:8]})")
|
|
231
|
+
raise TypeUniverseMismatch(
|
|
232
|
+
f"cross-language datum-type universe mismatch (python={py_fp[:12]}, java={java_fp[:12]}):\n"
|
|
233
|
+
+ "\n".join(diffs))
|
|
234
|
+
|
|
235
|
+
_log.large_info(lambda: f"type universe verified: {py_fp[:12]}")
|
|
236
|
+
return py_fp
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
Routing of one inbound cross-language event to a component's callbacks.
|
|
12
|
+
|
|
13
|
+
A ``CrossLanguageEvent`` crossing the boundary carries a transport-level
|
|
14
|
+
``kind`` (``DATA`` / ``START`` / ``STOP`` / ``END``). :func:`dispatch_consume`
|
|
15
|
+
maps a single event to the right component callback, decoding the
|
|
16
|
+
self-describing tagged JSON to a native pulse-data model for ``DATA`` events so
|
|
17
|
+
the component only ever sees a typed datum, never JSON. ``END`` is the
|
|
18
|
+
streamer loop's concern, not this function's.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from inventzia.pulse.data.datum.codec import from_tagged_json
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def event_kind(event) -> str:
|
|
25
|
+
"""The kind name of a cross-language event as a plain string (e.g. ``"DATA"``)."""
|
|
26
|
+
# event.kind() is a Java CrossLanguageEvent.Kind enum constant under the
|
|
27
|
+
# in-process bridge; .name() yields its string name.
|
|
28
|
+
return event.kind().name()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dispatch_consume(component, event) -> None:
|
|
32
|
+
"""Route one inbound event to ``component``'s lifecycle / on_event callbacks."""
|
|
33
|
+
kind = event_kind(event)
|
|
34
|
+
if kind == "DATA":
|
|
35
|
+
datum = from_tagged_json(event.taggedJson())
|
|
36
|
+
component.on_event(event.topicName(), datum)
|
|
37
|
+
elif kind == "START":
|
|
38
|
+
component.on_startup(event.time())
|
|
39
|
+
elif kind == "STOP":
|
|
40
|
+
component.on_shutdown(event.time())
|
|
41
|
+
# END: handled by the streamer loop (terminates it); nothing to dispatch.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-beacon.
|
|
5
|
+
#
|
|
6
|
+
# pulse-beacon is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""Runnable cross-language examples (Python-host JPype and Java-host JEP).
|
|
11
|
+
|
|
12
|
+
Import-light: each example module boots the JVM and imports the bridge itself,
|
|
13
|
+
so run a module directly (``python -m inventzia.pulse.beacon.core.examples.<name>``)
|
|
14
|
+
rather than importing this package.
|
|
15
|
+
"""
|