industream-flowmaker-sdk 1.0.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.
@@ -0,0 +1,39 @@
1
+ from .flowbox_init_params import *
2
+ from .flowbox import *
3
+ from .flowbox_core import *
4
+ from .flowbox_registration_info import *
5
+ from .flowbox_decorator import *
6
+ from .flowmaker_event import *
7
+ from .flowmaker_worker_options import *
8
+ from .flowmaker_worker import *
9
+ from .flowmaker_worker_builder import *
10
+
11
+ __all__ = [
12
+ "FlowBoxRaw",
13
+ "FlowBoxSource",
14
+ "FlowBoxSink",
15
+ "FlowBoxDestroy",
16
+ "FlowBoxCore",
17
+
18
+ "flowbox",
19
+ "FlowBoxRegistrationInfo",
20
+ "FlowBoxType",
21
+ "FlowBoxIOInterfaces",
22
+ "FlowBoxIO",
23
+ "SerializationFormat",
24
+ "FlowBoxUIConfig",
25
+
26
+ "FlowBoxInitParams",
27
+ "FlowRuntimeContext",
28
+
29
+ "FlowMakerWorkerBuilder",
30
+ "FlowMakerWorkerOptions",
31
+ "FlowMakerWorker"
32
+ ]
33
+
34
+
35
+ import asyncio
36
+ import sys
37
+
38
+ if sys.platform == "win32":
39
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
@@ -0,0 +1,24 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Callable
3
+ from . import FlowBoxInitParams
4
+
5
+ class FlowBoxRaw(ABC):
6
+ @abstractmethod
7
+ def __init__(self, init_params: FlowBoxInitParams) -> None: ...
8
+
9
+
10
+ class FlowBoxSource(ABC):
11
+ @abstractmethod
12
+ def on_output_ready(self, output_name: str, header: bytes,
13
+ fn_ready_for_next_item: Callable[[bytes, bytes], None]) -> None: ...
14
+
15
+
16
+ class FlowBoxSink(ABC):
17
+ @abstractmethod
18
+ def on_input_received(self, input_name: str, header: bytes, data: bytes,
19
+ fn_ready_for_next_item: Callable[[], None]) -> None: ...
20
+
21
+
22
+ class FlowBoxDestroy(ABC):
23
+ @abstractmethod
24
+ def on_destroy(self) -> None: ...
@@ -0,0 +1,59 @@
1
+ import asyncio
2
+ from abc import abstractmethod
3
+ from collections.abc import Callable
4
+ from reactivex import zip
5
+ from reactivex.subject import Subject
6
+ from . import FlowBoxRaw, FlowBoxSink, FlowBoxSource, FlowBoxInitParams
7
+
8
+ class FlowBoxCore(FlowBoxRaw, FlowBoxSink, FlowBoxSource):
9
+ def __init__(self, init_params: FlowBoxInitParams) -> None:
10
+ self._output_synchronizers: dict[str, OutputSynchronizer] = {}
11
+
12
+ @abstractmethod
13
+ async def on_input(self, input_name: str, header: bytes, data: bytes) -> None: ...
14
+
15
+ def on_input_received(self, input_name: str, header: bytes, data: bytes,
16
+ fn_ready_for_next_item: Callable[[], None]) -> None:
17
+ asyncio.create_task(self.on_input(input_name, header, data)) \
18
+ .add_done_callback(lambda _: fn_ready_for_next_item())
19
+
20
+
21
+ def on_output_ready(self, output_name: str, header: bytes,
22
+ fn_ready_for_next_item: Callable[[bytes, bytes], None]) -> None:
23
+ output_synchronizer = (self._output_synchronizers.get(output_name) or OutputSynchronizer())
24
+ self._output_synchronizers[output_name] = output_synchronizer
25
+
26
+ output_synchronizer.output_ready_subject.on_next(fn_ready_for_next_item)
27
+
28
+ async def push(self, output_name: str, header: bytes, data: bytes, output_buffer_size: int = 0):
29
+ future = asyncio.get_running_loop().create_future()
30
+
31
+ output_synchronizer = (self._output_synchronizers.get(output_name) or OutputSynchronizer())
32
+ self._output_synchronizers[output_name] = output_synchronizer
33
+
34
+ if output_synchronizer.msg_count_in_buffer < output_buffer_size:
35
+ future.set_result(None)
36
+
37
+ output_synchronizer.msg_count_in_buffer += 1
38
+ output_synchronizer.push_subject.on_next((header, data, lambda: future.set_result(None) if not future.done() else None))
39
+
40
+ await future
41
+
42
+
43
+ class OutputSynchronizer:
44
+ """Synchronize pushed data with the "on output ready" event."""
45
+
46
+ def __init__(self) -> None:
47
+ self.msg_count_in_buffer = 0
48
+ self.push_subject: Subject[tuple[bytes, bytes, Callable[[], None]]] = Subject()
49
+ self.output_ready_subject: Subject[Callable[[bytes, bytes], None]] = Subject()
50
+
51
+ zip(self.push_subject, self.output_ready_subject) \
52
+ .subscribe(lambda pair: self._on_next(*pair))
53
+
54
+ def _on_next(self, push_info: tuple, fn_ready_for_next_item: Callable):
55
+ data_to_push, header_to_push, complete_push = push_info
56
+
57
+ self.msg_count_in_buffer -= 1
58
+ fn_ready_for_next_item(data_to_push, header_to_push)
59
+ complete_push()
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ from . import FlowBoxRegistrationInfo, FlowBoxRaw
3
+
4
+ def flowbox(registration_info: FlowBoxRegistrationInfo):
5
+ def decorator(cls):
6
+ cls.registration_info = registration_info
7
+ return cls
8
+
9
+ return decorator
10
+
11
+
12
+ def get_registration_info(flowbox_type: type[FlowBoxRaw]) -> FlowBoxRegistrationInfo:
13
+ if not hasattr(flowbox_type, "registration_info"):
14
+ raise AttributeError(f"Missing registration info for the {flowbox_type.__name__} box. Please add a @flowbox() decorator to the {flowbox_type.__name__} class.")
15
+
16
+ if not isinstance(flowbox_type.registration_info, FlowBoxRegistrationInfo):
17
+ raise TypeError(f"Invalid registration info type for the {flowbox_type.__name__} box.")
18
+
19
+ return flowbox_type.registration_info
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ @dataclass
6
+ class FlowBoxInitParams:
7
+ runtime_context: FlowRuntimeContext
8
+ options: dict[str, Any]
9
+
10
+ @staticmethod
11
+ def from_dict(data: dict[str, Any]) -> FlowBoxInitParams:
12
+ return FlowBoxInitParams(
13
+ runtime_context=FlowRuntimeContext.from_dict(data["runtimeContext"]),
14
+ options=data["options"]
15
+ )
16
+
17
+
18
+ @dataclass
19
+ class FlowRuntimeContext:
20
+ flowbox_id: str
21
+ job_id: str
22
+ node_id: str
23
+ used_by: str | None
24
+
25
+ @staticmethod
26
+ def from_dict(data: dict[str, Any]) -> FlowRuntimeContext:
27
+ return FlowRuntimeContext(
28
+ flowbox_id=data["flowBoxId"],
29
+ job_id=data["jobId"],
30
+ node_id=data["nodeId"],
31
+ used_by=data.get("usedBy")
32
+ )
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from enum import IntEnum
4
+ from typing import Any
5
+
6
+ class FlowBoxType(IntEnum):
7
+ SOURCE = 1
8
+ SINK = 2
9
+ PIPE = 3
10
+
11
+
12
+ class SerializationFormat(IntEnum):
13
+ MSGPACK = 1
14
+ JSON = 2
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FlowBoxIOInterfaces:
19
+ inputs: list[FlowBoxIO] = field(default_factory=list)
20
+ outputs: list[FlowBoxIO] = field(default_factory=list)
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ return {
24
+ "inputs": [i.to_dict() for i in self.inputs],
25
+ "outputs": [o.to_dict() for o in self.outputs],
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class FlowBoxIO:
31
+ name: str
32
+ display_name: str
33
+ supported_formats: list[SerializationFormat] = field(default_factory=lambda: [SerializationFormat.MSGPACK])
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "name": self.name,
38
+ "displayName": self.display_name,
39
+ "supportedFormats": [f.name.lower() for f in self.supported_formats]
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FlowBoxUIConfig:
45
+ default_options: dict[str, Any] | None = None
46
+ implementation: str | None = None
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {
50
+ "defaultOptions": self.default_options,
51
+ "implementation": self.implementation
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FlowBoxRegistrationInfo:
57
+ id: str
58
+ display_name: str
59
+ current_version: str
60
+ type: FlowBoxType
61
+ icon: str
62
+ stateless: bool = False
63
+ io_interfaces: FlowBoxIOInterfaces = FlowBoxIOInterfaces()
64
+ ui_config: FlowBoxUIConfig = FlowBoxUIConfig()
65
+
66
+ def to_dict(self, agent: str, worker_id: str, worker_data_connection_string: str) -> dict[str, Any]:
67
+ return {
68
+ "id": self.id,
69
+ "displayName": self.display_name,
70
+ "currentVersion": self.current_version,
71
+ "type": self.type.name.lower(),
72
+ "icon": self.icon,
73
+ "stateless": self.stateless,
74
+ "agent": agent,
75
+ "workerId": worker_id,
76
+ "workerDataConnectionString": worker_data_connection_string,
77
+ "ioInterfaces": self.io_interfaces.to_dict(),
78
+ "uiConfig": self.ui_config.to_dict()
79
+ }
@@ -0,0 +1,9 @@
1
+ from enum import IntEnum
2
+
3
+ class FlowMakerEvent(IntEnum):
4
+ INIT_FLOW_BOX = 0x4026
5
+ CURRENT_EVENT = 0x8200
6
+ CAN_SEND_NEXT = 0x8001
7
+ DESTROY_EVENT = 0x82FF
8
+ HEARTBEAT_EVENT = 0x8210
9
+ SCHEDULER_RESTART = 0x8220
@@ -0,0 +1,147 @@
1
+ import asyncio
2
+ import httpx
3
+ import msgpack
4
+ import zmq
5
+ import zmq.asyncio
6
+ from typing import Any
7
+ from .flowbox_decorator import get_registration_info
8
+ from . import (
9
+ FlowBoxRaw,
10
+ FlowBoxSource,
11
+ FlowBoxSink,
12
+ FlowBoxDestroy,
13
+ FlowBoxInitParams,
14
+ FlowMakerEvent,
15
+ FlowMakerImmutableWorkerOptions
16
+ )
17
+
18
+ class FlowMakerWorker:
19
+ ACK_HEADER = (0x9000).to_bytes(8, byteorder="little")
20
+ ERROR_HEADER = (0xFFFF).to_bytes(8, byteorder="little")
21
+
22
+ def __init__(self, options: FlowMakerImmutableWorkerOptions, implementations: dict[str, type[FlowBoxRaw]]) -> None:
23
+ self._options = options
24
+ self._implementations = implementations
25
+ self._flowbox_instances: dict[str, FlowBoxRaw] = {}
26
+ self._socket = zmq.asyncio.Context().socket(zmq.SocketType.ROUTER)
27
+
28
+ async def run(self):
29
+ http_client = httpx.AsyncClient(base_url=self._options.runtime_http_address)
30
+
31
+ # Register all declared Flow boxes.
32
+ await self._register(http_client)
33
+
34
+ self._socket.bind(self._options.router_transport_address)
35
+ await self._listen_incoming_messages()
36
+
37
+
38
+ async def _register(self, http_client: httpx.AsyncClient) -> None:
39
+ registrations = [get_registration_info(impl) for impl in self._implementations.values()]
40
+ register_request = [
41
+ registration.to_dict(
42
+ "flowmaker-python-sdk/1.0.1;os=linux;tag=docker", # TODO: build this agent string using environment info
43
+ self._options.worker_id, self._options.worker_transport_adv_address)
44
+ for registration in registrations
45
+ ]
46
+ result = await http_client.post("/workers/register", json=register_request)
47
+ result.raise_for_status()
48
+
49
+ flowbow_ids = [f'"{r.id}"' for r in registrations]
50
+ print(f"Worker started with {', '.join(flowbow_ids)} registered.")
51
+
52
+
53
+ async def _listen_incoming_messages(self) -> None:
54
+ while True:
55
+ msg_parts = await self._socket.recv_multipart()
56
+
57
+ # Caution: keep the processing of each message in a separate function, to maintain
58
+ # a local scope of variables (since Python doesn't enforce local scope in while/for loops).
59
+ await self._process_incoming_message(msg_parts)
60
+
61
+ async def _process_incoming_message(self, msg_parts: list[bytes]) -> None:
62
+ if len(msg_parts) < 6:
63
+ print("Invalid message received: it must contain at least 6 parts.")
64
+ return
65
+
66
+ routing_id, token, encoded_node_ref, encoded_io_name, header, data, *_ = msg_parts
67
+
68
+ node_ref = encoded_node_ref.decode()
69
+ io_name = encoded_io_name.decode()
70
+ event_id = int.from_bytes(header[:4], byteorder="little")
71
+
72
+ if event_id == FlowMakerEvent.HEARTBEAT_EVENT:
73
+ # print("HEARTBEAT_EVENT received")
74
+ # Acknowledge to indicate that the worker is still alive.
75
+ await self._socket.send_multipart([routing_id, token, self.ACK_HEADER])
76
+
77
+ elif event_id == FlowMakerEvent.INIT_FLOW_BOX:
78
+ # print("INIT_FLOW_BOX received")
79
+ msg = msgpack.unpackb(data)
80
+ init_params = FlowBoxInitParams.from_dict(msg)
81
+
82
+ implementation = self._implementations[init_params.runtime_context.flowbox_id]
83
+ self._flowbox_instances[node_ref] = implementation(init_params)
84
+
85
+ print(f"Flow box {node_ref} initialized.")
86
+ await self._socket.send_multipart([routing_id, token, self.ACK_HEADER])
87
+
88
+ elif event_id == FlowMakerEvent.CURRENT_EVENT:
89
+ # print("CURRENT_EVENT received")
90
+ flowbox_instance = await self._get_flowbox_instance(routing_id, token, node_ref)
91
+ if flowbox_instance is None:
92
+ return
93
+
94
+ if isinstance(flowbox_instance, FlowBoxSink):
95
+ flowbox_instance.on_input_received(
96
+ io_name, header, data,
97
+ lambda: self._send_multipart_and_forget([routing_id, token, self.ACK_HEADER]))
98
+ else:
99
+ print(f"Flow box {node_ref} does not implement FlowBoxSink.")
100
+
101
+ elif event_id == FlowMakerEvent.CAN_SEND_NEXT:
102
+ # print("CAN_SEND_NEXT received")
103
+ flowbox_instance = await self._get_flowbox_instance(routing_id, token, node_ref)
104
+ if flowbox_instance is None:
105
+ return
106
+
107
+ if isinstance(flowbox_instance, FlowBoxSource):
108
+ flowbox_instance.on_output_ready(
109
+ io_name, header,
110
+ lambda header, data: self._send_multipart_and_forget([routing_id, token, header, data]))
111
+ else:
112
+ print(f"Flow box {node_ref} does not implement FlowBoxSource.")
113
+
114
+ elif event_id == FlowMakerEvent.DESTROY_EVENT:
115
+ # print("DESTROY_EVENT received")
116
+ flowbox_instance = await self._get_flowbox_instance(routing_id, token, node_ref)
117
+ if flowbox_instance is None:
118
+ return
119
+
120
+ if isinstance(flowbox_instance, FlowBoxDestroy):
121
+ flowbox_instance.on_destroy()
122
+ self._flowbox_instances.pop(node_ref)
123
+
124
+ print(f"Flow box {node_ref} destroyed.")
125
+ await self._socket.send_multipart([routing_id, token, self.ACK_HEADER])
126
+
127
+ elif event_id == FlowMakerEvent.SCHEDULER_RESTART:
128
+ # print("SCHEDULER_RESTART received")
129
+ for flowbox_instance in self._flowbox_instances.values():
130
+ if isinstance(flowbox_instance, FlowBoxDestroy):
131
+ flowbox_instance.on_destroy()
132
+ self._flowbox_instances.clear()
133
+
134
+ print("Scheduler restarted! All Flow box instances have just been destroyed.")
135
+ await self._socket.send_multipart([routing_id, token, self.ACK_HEADER])
136
+
137
+
138
+ async def _get_flowbox_instance(self, routing_id: bytes, token: bytes, node_ref: str) -> FlowBoxRaw | None:
139
+ flowbox_instance = self._flowbox_instances.get(node_ref)
140
+ if flowbox_instance is None:
141
+ await self._socket.send_multipart([routing_id, token, self.ERROR_HEADER])
142
+ print(f"Flow box {node_ref} not found or deleted.")
143
+
144
+ return flowbox_instance
145
+
146
+ def _send_multipart_and_forget(self, msg_parts: Any) -> None:
147
+ asyncio.ensure_future(self._socket.send_multipart(msg_parts)) # Fire and forget
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ from collections.abc import Callable
3
+ from . import FlowMakerWorkerOptions, FlowMakerWorker, FlowBoxRaw
4
+ from .flowbox_decorator import get_registration_info
5
+
6
+ class FlowMakerWorkerBuilder:
7
+ def __init__(self) -> None:
8
+ self._options = FlowMakerWorkerOptions()
9
+ self._implementations: dict[str, type[FlowBoxRaw]] = {}
10
+
11
+ def configure(self, configureOptions: Callable[[FlowMakerWorkerOptions], None]) -> FlowMakerWorkerBuilder:
12
+ configureOptions(self._options)
13
+ return self
14
+
15
+ def declare_flowbox(self, flowbox_type: type[FlowBoxRaw]) -> FlowMakerWorkerBuilder:
16
+ registration_info = get_registration_info(flowbox_type)
17
+ id = f"{registration_info.id}/{registration_info.current_version}"
18
+ self._implementations[id] = flowbox_type
19
+ return self
20
+
21
+ def build(self) -> FlowMakerWorker:
22
+ immutable_options = self._options.to_immutable()
23
+ return FlowMakerWorker(immutable_options, self._implementations)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ import os
4
+
5
+ class FlowMakerWorkerOptions:
6
+ def __init__(self) -> None:
7
+ self.worker_id = os.getenv("FM_WORKER_ID")
8
+ self.worker_transport_adv_address = os.getenv("FM_WORKER_TRANSPORT_ADV_ADDRESS")
9
+ self.router_transport_address = os.getenv("FM_ROUTER_TRANSPORT_ADDRESS")
10
+ self.runtime_http_address = os.getenv("FM_RUNTIME_HTTP_ADDRESS")
11
+
12
+ def to_immutable(self) -> FlowMakerImmutableWorkerOptions:
13
+ if not self.worker_id or self.worker_id.isspace():
14
+ raise ValueError("The 'FM_WORKER_ID' environment variable (or 'worker_id' option) is required.")
15
+
16
+ if not self.worker_transport_adv_address or self.worker_transport_adv_address.isspace():
17
+ raise ValueError("The 'FM_WORKER_TRANSPORT_ADV_ADDRESS' environment variable (or 'worker_transport_adv_address' option) is required.")
18
+
19
+ if not self.router_transport_address or self.router_transport_address.isspace():
20
+ raise ValueError("The 'FM_ROUTER_TRANSPORT_ADDRESS' environment variable (or 'router_transport_address' option) is required.")
21
+
22
+ if not self.runtime_http_address or self.runtime_http_address.isspace():
23
+ raise ValueError("The 'FM_RUNTIME_HTTP_ADDRESS' environment variable (or 'runtime_http_address' option) is required.")
24
+
25
+ return FlowMakerImmutableWorkerOptions(
26
+ self.worker_id,
27
+ self.worker_transport_adv_address,
28
+ self.router_transport_address,
29
+ self.runtime_http_address
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class FlowMakerImmutableWorkerOptions:
35
+ worker_id: str
36
+ worker_transport_adv_address: str
37
+ router_transport_address: str
38
+ runtime_http_address: str
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: industream-flowmaker-sdk
3
+ Version: 1.0.2
4
+ Summary: Industream FlowMaker SDK for Python
5
+ Project-URL: Homepage, https://github.com/industream/industream-flowmaker
6
+ Project-URL: Repository, https://github.com/industream/industream-flowmaker
7
+ Project-URL: Issues, https://github.com/industream/industream-flowmaker/issues
8
+ Author-email: Sylvain Bruyère <sylvain.bruyere@industream.com>
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: httpx>=0.28.1
17
+ Requires-Dist: msgpack-types>=0.5.0
18
+ Requires-Dist: msgpack>=1.1.0
19
+ Requires-Dist: pyzmq>=26.2.0
20
+ Requires-Dist: reactivex>=4.0.4
@@ -0,0 +1,13 @@
1
+ industream/flowmaker/sdk/__init__.py,sha256=aVhFHBW1cpN9VDPfLf0ZibzTkUA7JOkZMHvkPTP9JQA,884
2
+ industream/flowmaker/sdk/flowbox.py,sha256=1sJUpLn3zBZu6Fc9mZ1eb7GcPavtoNKQZITOJbLPycA,738
3
+ industream/flowmaker/sdk/flowbox_core.py,sha256=jw7JmkPfjzlaUxhCGndE0eAYo2uguVBf93RnQPvCqYI,2655
4
+ industream/flowmaker/sdk/flowbox_decorator.py,sha256=4AC-jI-0HZfT6t8LMmMqsJEj9SDfdb2BA0ReUAX2xU4,808
5
+ industream/flowmaker/sdk/flowbox_init_params.py,sha256=Um7__12cc-wnkebZje223pB_9tbE3ygvuQvEVSRVOUU,866
6
+ industream/flowmaker/sdk/flowbox_registration_info.py,sha256=u_LR6gS7NsWTB9SuK2wU0Vd-g01GR91ocHAiOOZHOvs,2334
7
+ industream/flowmaker/sdk/flowmaker_event.py,sha256=gajEZ3IKzh8AIYRKUVWtfudxv7xjUEpW__SjWSQzbIk,234
8
+ industream/flowmaker/sdk/flowmaker_worker.py,sha256=5coORY-nIAGYUq-hUC8Wgo-TcuZ8xnGclyzqqBUNe2Q,6471
9
+ industream/flowmaker/sdk/flowmaker_worker_builder.py,sha256=kiS04H7G3t8C04jF0eappq6zm7igNbXMRrO-VW4w5BI,1031
10
+ industream/flowmaker/sdk/flowmaker_worker_options.py,sha256=b_kwk8WW8eaeCfdQAE7pxFu6bkjw53ZY4mC9SCvC3No,1818
11
+ industream_flowmaker_sdk-1.0.2.dist-info/METADATA,sha256=cbi0iLvpnS82iYCABrHoA0EXlqsRJQ1xIhDLsjRLyPg,864
12
+ industream_flowmaker_sdk-1.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ industream_flowmaker_sdk-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any