This release is a pre-release and may not be stable for production use.
industream-eventhub-client
Python client for EventHub: publishes and subscribes to events stored in NATS JetStream, sends the commands that modify them to the EventHub backend, and reads their history from it.
It is a translation of the .NET client
(Industream.EventHub.Models and
Industream.EventHub.Client), as close as possible: same
models, same names, same behaviour. Only the Python conventions change (snake_case members, UPPER_CASE constants and
enum members, no Async suffix, Error instead of Exception). Its models follow the shared schemas and its tests
replay the shared contract cases (Shared/README.md in the EventHub repository).
Installation
pip install industream-eventhub-client
Python 3.12 or later. The client is asynchronous (asyncio) and works on the NATS connection the application opens with nats-py.
Building an event
from industream.eventhub.client import EventCategory, EventFactory, EventModel, EventSeverity
thermal_alarm = EventModel(
application="raceway", # type industream.raceway.thermalalarm
name="thermalalarm",
source="/industream/flowmaker/raceway-flow", # optional, default: /industream/raceway
severity=EventSeverity.ALARM,
category=EventCategory.PROCESS,
subject="tuyere/12",
attributes={"tuyere": 12},
data={"code": "THERMAL_OUT_OF_RANGE", "flameTemperature": 2412},
)
cloud_event = EventFactory.create(thermal_alarm)
EventFactory.create fills in:
id(UUID v7),specversion1.0,datacontenttypeapplication/json;type(industream.<application>.<name>) andsource(source, or/industream/<application>by default);timein UTC, to the millisecond (now when the model has notime);- the Severity extension (
severitytextandseveritynumber),category, andproject(applicationunless the model setsproject); rooteventwhen the model setsroot_event_id(secondary events);keephistory: falsewhen the model setskeep_in_history=False: the event goes through EventHub as usual, but the backend leaves it out of the database history (an ordinary event does not carry the extension);lasting: truewhen the model setslasting=True: the event lasts until it is ended, like an alarm (an instantaneous event does not carry the extension);- the publisher attributes, keeping their type:
str, 32-bitint,boolordatetime(written as a timestamp).
data is any value json can write (dict, list, str, numbers…); a dataclass is written with its field names in
camelCase (flame_temperature becomes flameTemperature), like the typed data of the .NET client, and a datetime
(ISO 8601) or a UUID as a string. Another type (an Enum, a Decimal…) raises a TypeError when the event is
written, at publish.
EventJson.encode(cloud_event) writes the CloudEvent as structured JSON (UTF-8 bytes), as it is published.
Invalid models
An invalid model is a programming error: EventFactory.create raises a ValueError whose message says what is wrong:
empty application, event or project name, unknown severity or category, empty subject, source that is not a URI
reference, invalid or reserved attribute name, unsupported attribute value, root event id that is not a UUID,
time (or datetime attribute) without time zone or out of the UTC range, string attribute (subject, names, project,
attribute value…) with a control character, a noncharacter or a lone surrogate.
Decoding a received event
received_event = EventDecoder.decode(message_body) # bytes; raises EventDecodeError if invalid
ReceivedEvent gives the CloudEvents fields, the severity, the category, the project, root_event_id (a UUID),
keep_in_history (True when keephistory is absent), lasting (False when lasting is absent), the publisher
attributes (read-only) and data (as parsed
JSON), plus the received cloud_event and, when a client read it from the stream, its stream_sequence and
nats_subject; a missing value is None. time is a datetime in UTC. The severity is read by OpenTelemetry range (severitynumber 13 to 16
is WARN, TRACE 1 to 4 is read as DEBUG), or from severitytext when there is no number: an event published
without the EventHub client may use any number of a level. Publisher attributes keep numbers and booleans typed;
timestamps and URIs, plain strings in JSON, come back as strings.
A message is rejected when it is not a valid CloudEvent in structured JSON (a body starting with a UTF-8 byte order
mark, a blank id, type or source, an
empty subject, a time that is not an RFC 3339 timestamp of a real day or has an offset beyond 14 hours, an invalid
datacontenttype or
dataschema, both data and data_base64, an extension name that is not lowercase letters and digits, a control
character or a noncharacter in a string attribute included),
has no time, severity, category or project, has a severitynumber that is not an integer, a keephistory or a lasting
that is not a boolean, a rootevent that is not an event id in its standard form, or an extension value outside the CloudEvents
type system (an object, a list, a number that is not a 32-bit integer). A null attribute is an absent one.
Secondary events
When the backend acknowledges or ends an event, it publishes a secondary event: its root_event_id is the id of
the main event, its type industream.eventhub.event.<change>. A subscriber receives it like any other event, with
modification set: what changed and the full state of the main event, read from its data. modification is None
for every other event.
if received_event.modification is not None:
if received_event.modification.change == EventChange.ACKNOWLEDGED:
...
end = received_event.modification.state.end # the full, cumulative state
change is a string: EventChange.ACKNOWLEDGED, EventChange.ENDED, or a change added later, kept as received. A
secondary event whose data is not { change, event, state } with the change of its type and a valid state is rejected
like any other invalid event. The main event stays in data, as it was published.
Reading the state of a modified event
EventStateDecoder.decode(json) reads the state the backend gives a modified event (Docs/Event-Model.md §8.3), from
parsed JSON: the state of a value of the eventhub-latest bucket, of a secondary event or of a command reply.
state = EventStateDecoder.decode(value["state"])
# state.acknowledgement: Acknowledgement(actor=Actor(type, id, proof), time, comment) or None
# state.end: EventEnd(actor, time) or None
The values are read in kebab-case ("hub-token" gives ActorProof.HUB_TOKEN), the times as RFC 3339 times
(datetime with their offset), and a field it does not know is ignored. An invalid state raises an EventDecodeError
that names the wrong field ('acknowledgement.actor.type' is unknown: 'robot').
A value of the eventhub-latest bucket (EventContract.LATEST_VERSION_BUCKET), the latest version of each modified
event, is read with LatestEventVersionDecoder.decode(json): event, the main event decoded as EventDecoder does
with its nats_subject, nats_subject and state. The pending secondary event the backend may keep there is
ignored.
bucket = await connection.jetstream().key_value(EventContract.LATEST_VERSION_BUCKET)
entry = await bucket.get(event_id)
latest_version = LatestEventVersionDecoder.decode(json.loads(entry.value))
NATS subject
The publisher chooses the NATS subject of its events; it only has to start with eventhub., the root the EventHub
stream captures (why: Docs/Event-Model.md §7.2).
from industream.eventhub.client import EventSubject
EventSubject.validate("eventhub.raceway.process.alarm.thermalalarm") # raises ValueError if invalid
A subject is rejected when it does not start with eventhub., has an empty token, contains whitespace or contains a
wildcard (*, >: subscriptions only). EventSubject.ALL (eventhub.>) covers every EventHub subject.
A subscription filter accepts wildcards as whole tokens, > only last:
EventSubject.validate_filter("eventhub.*.*.alarm.>")
Publishing an event
import nats
from industream.eventhub.client import EventFactory, EventPublisher
connection = await nats.connect("nats://localhost:4222")
publisher = EventPublisher(connection)
cloud_event = EventFactory.create(thermal_alarm)
published = await publisher.publish(cloud_event, "eventhub.raceway.process.alarm.thermalalarm")
# published.event_id, published.stream_sequence, published.duplicate
The application opens the NATS connection itself, with nats-py.
publish takes a CloudEvent built with EventFactory, then:
- checks the subject (
EventSubject.validate); - publishes the CloudEvent as structured JSON, with the
Content-Type: application/cloudevents+jsonheader and theNats-Msg-Idheader set to the eventid: JetStream ignores the same event published again within its duplicate window, andduplicateis thenTrue; - waits for the JetStream acknowledgement. When no stream stores the subject, or JetStream refuses the event, it
raises an
EventPublishError. Connection errors (a closed connection…) come as the nats-py errors.
To publish an event again after an error, publish the same CloudEvent: it keeps its id, so JetStream stores it
only once. Building it again from the EventModel would give it a new id, and the event would be stored twice.
Subscribing
import asyncio
import logging
from industream.eventhub.client import ConsumeOptions, EventSubscriber, HandlerFailure, ReceivedEvent
subscriber = EventSubscriber(connection)
async def handle(received_event: ReceivedEvent) -> None:
# react to received_event (decoded by EventDecoder)
print(received_event.id)
def report(failure: HandlerFailure) -> None:
logging.error("Event %s failed", failure.event.id, exc_info=failure.exception)
consumption = asyncio.create_task(
subscriber.consume(
"recipemaker-analyses",
"eventhub.materialcatalog.>",
handle,
ConsumeOptions(on_handler_failure=report),
)
)
# consumption.cancel() ends the consumption
The consumer name may not be empty nor contain whitespace, ., *, >, / or \, and max_deliveries must be
at least 1: consume raises a ValueError otherwise. consume runs until its task is cancelled, even before it has
started: it then hands no more event (those already received come back next time), and the handler is cancelled at
its next await. A cancelled handler is not a failure of the event. It uses a durable consumer: NATS remembers
where the consumer named consumer_name stopped, and the next consume with the same name resumes there, so an
application that was stopped receives the events it missed.
- An event is acknowledged once the handler succeeds. When the handler raises, the failure is passed to
on_handler_failureand the event comes back a second later, up tomax_deliveriesdeliveries (5 by default). After the last one (is_last_attempt), the consumer gives the event up; it stays in the stream. - Events are handed one at a time, and delivered at least once: an event comes back when the application stops
before acknowledging it, or when its handler runs longer than the acknowledgement wait of NATS (30 s). A handler
must accept an event it has already handled (the event
idtells). - A message that is not an EventHub event is passed to
on_invalid_message(anInvalidMessage: its NATS subject, stream sequence, body and error) and set aside for good, so that it does not come back in a loop. startonly matters the first time the consumer is created:SubscriptionStart.NEW_EVENTS(default) receives the events published from then on,SubscriptionStart.ALL_STORED_EVENTSalso receives the events still stored. A laterconsumewith the same name keeps the position of the consumer, and updates its filter andmax_deliveries.- A callback that raises (
on_invalid_message,on_handler_failure) endsconsumewith its exception, once the message is set aside or asked for again.
To read the past (replay), read_from returns, in order, the events stored from a start, then stops once caught up.
follow_from reads the same way, then goes on with the new events as they are published, until its task is
cancelled:
async for received_event in subscriber.read_from(ReadStart.at(since), "eventhub.raceway.>"):
...
async for received_event in subscriber.follow_from(ReadStart.after(last_sequence), "eventhub.raceway.>"):
last_sequence = received_event.stream_sequence
- The start is a date with a time zone (
ReadStart.at) or the stream sequence of an event already read (ReadStart.after): every received event carries its position in the stream,stream_sequence. NATS cannot look an event up by itsid. - Unlike
consume, they leave nothing behind in NATS: to resume later, the application keeps thestream_sequenceof the last event it handled. A loop left before its end (abreak) releases its NATS consumer when the generator is closed, which Python does later: wrap the generator incontextlib.aclosingto release it at once. Either way, NATS deletes a consumer nobody reads after a minute. - A read survives the loss of its NATS consumer: NATS deletes it when the application takes more than a minute on the events fetched ahead (up to 256), or on a restart. The read then goes on right after the last event read, each event once and in order.
- They skip messages that are not EventHub events, after passing them to
ReadOptions.on_invalid_message. - When
ReadStart.afterasks for events already removed by the stream retention,ReadOptions.on_missed_eventsgets the requested and the oldest stored sequences before the read goes on from the oldest stored event. A start at a date cannot tell: NATS does not keep the time of the removed events.
All three check the filter with EventSubject.validate_filter: wildcards as whole tokens, > only last. Like
EventSubject.validate_filter, they name it subject_filter, since filter is a Python built-in.
Modifying an event
Only the EventHub backend modifies an event: EventModifier sends it the commands (NATS Request-Reply, see the
backend README) and returns the event with its full new state.
from industream.eventhub.client import CommandActor, EventModifier
modifier = EventModifier(connection)
actor = CommandActor.application("raceway")
acknowledged = await modifier.acknowledge(received_event.id, actor, "Coal injection reduced.")
ended = await modifier.end(received_event.id, actor)
# ended.state.acknowledgement, ended.state.end: actor (type, id, proof), time, comment
-
acknowledgerecords that the actor has taken the event into account: any event, once, with an optional comment.endrecords that an event that lasts (lasting, an alarm) is over: once. They are independent: an acknowledged alarm stays active until it is ended. -
The actor decides the subject and the headers of the command, and the backend records it with the modification:
Actor Who Subject CommandActor.user(hub_token)A user, proven by their Industream Hub access token ( Authorizationheader)eventhubcmd.<command>.userCommandActor.declared_user(user_name)A user named without proof ( Eventhub-Actorheader), for a backend whose authentication is offeventhubcmd.<command>.userCommandActor.application(application_name)An application, proven by the NATS permissions of its NATS user eventhubcmd.<command>.application.<name>CommandActor.flow(flow_name)A FlowMaker flow ( Eventhub-Actorheader), sent by the FlowMaker boxeventhubcmd.<command>.flowAn empty token, a blank name, a name with a control character, or an application name that is not a single subject token (whitespace,
.,*,>) raises aValueError, as does an empty event id. -
When the backend refuses the command, an
EventModificationErrorcarries itsstatus(the HTTP meaning: 400 instantaneous event or invalid request, 401 Hub token missing or invalid, 404 unknown event, 409 already acknowledged or ended),titleanddetail. When no backend answers, or not within 10 seconds, or its reply cannot be read,statusisNone. After a timeout, the modification may have been recorded: sent again, it gets 409.
Reading the history
The stream keeps the events for its retention only (30 days by default), and NATS filters them by subject and date
only. The backend keeps every event in its database: EventHistoryReader reads them through its read queries (NATS
Request-Reply, see the backend README), with the state of each event.
from datetime import UTC, datetime, timedelta
from industream.eventhub.client import EventHistoryFilter, EventHistoryReader, EventSeverity
reader = EventHistoryReader(connection)
history_filter = EventHistoryFilter(
from_=datetime.now(UTC) - timedelta(days=7),
projects=["raceway"],
severities=[EventSeverity.ALARM],
in_progress=True,
attributes={"tuyere": 12},
)
page = await reader.read_history(history_filter, offset=0, limit=50)
# page.total; page.events: LatestEventVersion (event, nats_subject, state), most recent first
- Every filter is optional: the period
[from_, to[(times with a time zone), projects, severities, categories,acknowledged,in_progress(events that last and are not ended), publisher attributes (equality) andtext(contained in the type, the CloudEvents subject or an attribute value, whatever the case). - The caller chooses the page:
offsetandlimit(50 by default, 500 at most).totalcounts the events of the filter, all pages included. - Only the main events are listed, each with the full state its modifications gave it (
EventState, no acknowledgement and no end when never modified). - When the backend refuses the query (400: an invalid filter or page), an
EventQueryErrorcarries itsstatus,titleanddetail; when no backend answers, or not within 10 seconds, or its reply cannot be read,statusisNone. The queries are open: every NATS user that reads the stream may send them, without Hub token.
Statistics
read_statistics reads the numbers of the main events of a period, as the Overview of the UI shows them:
to = datetime.now(UTC)
statistics = await reader.read_statistics(to - timedelta(hours=24), to, timedelta(hours=1), "raceway", 10)
# statistics.by_severity, total: of the project (or of every project when None)
# statistics.by_project: every project of the period (by_severity, total, open_alarms)
# statistics.top_types: type, project, severity, count; most frequent first
# statistics.histogram: start, end, by_severity; every bucket of the period
- The caller chooses the width of the histogram buckets, in whole seconds: the backend refuses more than 500 buckets. The last bucket ends at the end of the period.
open_alarmscounts the alarms of the period that last and are not ended yet. The numbers per severity give every severity, 0 included.- Same errors as the history: an
EventQueryError.
Differences with the .NET client
What Python imposes, everything else being a translation:
EventSeverityandEventCategoryare enums withUPPER_CASEmembers, the Python convention:EventSeverity.ALARMisEventSeverity.Alarmin .NET. The value of anEventSeverityis its OpenTelemetry number, as in .NET; the value of anEventCategoryis the name used by the contract ("Process").- An invalid value raises a
ValueError, the Python counterpart ofArgumentException. timeis adatetimethat must have a time zone: a naivedatetimewould be read in the local time of the machine, a silent error.- A publisher attribute is a
str, a 32-bitint, aboolor adatetime: Python has no URI type, so a URI is given as astr. CloudEventis a type of this client, a read-only mapping of the structured JSON, not the class of the CloudEvents SDK as in .NET. The Python SDK (cloudevents2.x) checks almost none of the rules of the contract: it accepts any extension value, reads a time at24:00or written with a lowercasetorz, and writes the time to the microsecond (…42.318000Z) where the contract wants…42.318Z. Every rule would then have to be written around it anyway, in two places and with two more dependencies (cloudevents,python-dateutil). Instead,EventFactory,EventJsonandEventDecoderfollow the CloudEvents rules themselves, held by the shared contract cases, as the TypeScript client does.ReceivedEventgivesNonefor a missing value,root_event_idas aUUID(theGuidof .NET),timeas adatetimein UTC (to the microsecond, the precision ofdatetime), anddataas parsed JSON.EventDecoderchecks what the .NET SDK checks: the required attributes (id,source,type,specversion1.0, andtimefor EventHub), the types of the standard attributes (an RFC 3339time, a URI referencesource…), and extension values of the CloudEvents type system only. It also rejectsNaNandInfinity, which thejsonmodule reads although they are not JSON.- The client is asynchronous (asyncio), as nats-py is. Cancelling the task replaces the .NET
CancellationToken, the asyncio convention:consume,read_fromandfollow_fromtake no token, and the handler only gets the event. A cancelledconsumeorfollow_fromends withasyncio.CancelledError, as any cancelled coroutine, where .NET returns quietly; nothing else is raised, and a cancelled handler is not reported as a failure. To stop an application, cancel these tasks first, then close or drain the connection: once the connection is closed, nats-py ends what still waits for messages with aConnectionClosedError. - The error messages are those of .NET, in the Python words of the TypeScript client where the types differ
(
max_deliveries,None,int): the shared contract cases check the same parts of them. publishtakes no cancellation either: cancelling its task stops the wait for the acknowledgement. Nor doacknowledgeandend: cancelling their task stops the wait for the reply.EventHistoryFilter.from_is theFromof .NET:fromis a keyword of Python. A time of the filter without time zone raises aValueError, asReadStart.atdoes.ActorTypeandActorProofare enums withUPPER_CASEmembers (ActorProof.HUB_TOKEN), valued with the .NET names ("HubToken"); the times ofEventStatearedatetimes in UTC.HandlerFailure.exceptionis theExceptionthe handler raised; the callbacks ofConsumeOptionsandReadOptionsare plain functions, as theActioncallbacks of .NET.follow_fromreads with an ordered consumer of this client, where .NET uses the one of NATS.Net: an ephemeral pull consumer kept in memory, created again right after the last event read when it is lost (a NATS restart, a disconnection or a reader slower than a minute) or when a message is missing.read_fromreads with the same one, stopping once caught up, as .NET recreates its read consumer. The ordered consumer of nats-py is not used: when it creates its consumer again, it keeps the start date next to the new start sequence, which the server refuses (follow_fromwould then wait for good), and it starts from the beginning of the stream when no event was read yet.
Build and test
From this folder, with uv:
uv sync
uv run ruff check
uv run ruff format --check
uv run mypy
uv run pytest
uv run pytest tests/unit runs the unit tests only; uv run pytest tests/e2e runs the end-to-end tests, which start a
throwaway NATS server with Testcontainers: Docker must be running.
Metadata
Release files for industream-eventhub-client 1.0.0rc7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| industream_eventhub_client-1.0.0rc7.tar.gz | 109.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| industream_eventhub_client-1.0.0rc7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 174.5 kB
Release files / industream_eventhub_client-1.0.0rc7.tar.gz
| Download URL | industream_eventhub_client-1.0.0rc7.tar.gz |
|---|---|
| Size | 109.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ac3c32bcd172268a521db7f3d06e23991261d5a128dabd03b3126948a1623880
|
|
BLAKE2b-256 checksum How to use checksums |
a7ce58e5618ff5ea0dc924dcebf1bf1d3692c5f8e741896349bcfe9576238035
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / industream_eventhub_client-1.0.0rc7-py3-none-any.whl
| Download URL | industream_eventhub_client-1.0.0rc7-py3-none-any.whl |
|---|---|
| Size | 64.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3db8537a5213d74ee9a48f326e114c28f58a1601d287475f10b638e910947e73
|
|
BLAKE2b-256 checksum How to use checksums |
97707ef4234ab60e0dacbd41cb4258afba3d196dc807259dc6582ef6b5ba30d8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|