dataloom-engine 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/PKG-INFO +6 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/README.md +4 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/__init__.py +8 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/loom.py +85 -6
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/sinks.py +17 -12
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/types.py +3 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/PKG-INFO +6 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/pyproject.toml +2 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_core.py +26 -1
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_loom.py +159 -6
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_optional.py +6 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_sinks.py +34 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/LICENSE +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/_optional.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/config.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/exceptions.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/hooks.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/logs.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/processors.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/py.typed +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/sources.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/weaver.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/SOURCES.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/dependency_links.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/requires.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/top_level.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/setup.cfg +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_config.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataloom-engine
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: DataLoom:
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: DataLoom: a lightweight and efficient thread orchestration engine for data pipelines.
|
|
5
5
|
Author-email: Dioni Padilha <dionipdl@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/dionipadilha/dataloom
|
|
@@ -175,6 +175,10 @@ if __name__ == "__main__":
|
|
|
175
175
|
print("\n🛑 Loom stopped.")
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
+
More runnable, real-world examples — API enrichment with parallel speedup,
|
|
179
|
+
a resilient file-processing pipeline, a buffered sensor stream — live in the
|
|
180
|
+
[`examples/`](examples/) directory.
|
|
181
|
+
|
|
178
182
|
## 🏗️ Architecture
|
|
179
183
|
|
|
180
184
|
DataLoom is built around a weaving metaphor:
|
|
@@ -141,6 +141,10 @@ if __name__ == "__main__":
|
|
|
141
141
|
print("\n🛑 Loom stopped.")
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
More runnable, real-world examples — API enrichment with parallel speedup,
|
|
145
|
+
a resilient file-processing pipeline, a buffered sensor stream — live in the
|
|
146
|
+
[`examples/`](examples/) directory.
|
|
147
|
+
|
|
144
148
|
## 🏗️ Architecture
|
|
145
149
|
|
|
146
150
|
DataLoom is built around a weaving metaphor:
|
|
@@ -8,6 +8,8 @@ such as 'Weaver' are deliberately not exposed, keeping the usage surface
|
|
|
8
8
|
clean and safe for consumers.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
11
13
|
from dataloom_engine.config import LoomConfig
|
|
12
14
|
from dataloom_engine.exceptions import ConfigurationError, LoomError, WeaverError
|
|
13
15
|
from dataloom_engine.hooks import LoomHooks
|
|
@@ -24,7 +26,13 @@ from dataloom_engine.sinks import (
|
|
|
24
26
|
from dataloom_engine.sources import Source
|
|
25
27
|
from dataloom_engine.types import LoomState
|
|
26
28
|
|
|
29
|
+
try:
|
|
30
|
+
__version__ = version("dataloom-engine")
|
|
31
|
+
except PackageNotFoundError: # pragma: no cover -- running from an uninstalled source tree
|
|
32
|
+
__version__ = "0+unknown"
|
|
33
|
+
|
|
27
34
|
__all__ = [
|
|
35
|
+
"__version__",
|
|
28
36
|
"Loom",
|
|
29
37
|
"LoomConfig",
|
|
30
38
|
"LoomState",
|
|
@@ -6,11 +6,14 @@ Defines the main class responsible for managing the lifecycle of the
|
|
|
6
6
|
worker threads (Weavers) and the distribution of tasks.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import queue
|
|
10
11
|
import threading
|
|
12
|
+
import time
|
|
11
13
|
from typing import TYPE_CHECKING, Optional
|
|
12
14
|
|
|
13
15
|
from dataloom_engine.config import LoomConfig
|
|
16
|
+
from dataloom_engine.exceptions import ConfigurationError, LoomError
|
|
14
17
|
from dataloom_engine.hooks import LoomHooks
|
|
15
18
|
from dataloom_engine.processors import Processor
|
|
16
19
|
from dataloom_engine.sinks import Sink
|
|
@@ -20,6 +23,8 @@ from dataloom_engine.weaver import STOP_SENTINEL, Weaver
|
|
|
20
23
|
if TYPE_CHECKING:
|
|
21
24
|
from dataloom_engine.sources import Source
|
|
22
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
23
28
|
|
|
24
29
|
class Loom:
|
|
25
30
|
"""
|
|
@@ -45,6 +50,9 @@ class Loom:
|
|
|
45
50
|
hooks: Optional[LoomHooks] = None,
|
|
46
51
|
num_weavers: int = 2,
|
|
47
52
|
):
|
|
53
|
+
if num_weavers < 1:
|
|
54
|
+
raise ConfigurationError(f"num_weavers must be at least 1 (got: {num_weavers}).")
|
|
55
|
+
|
|
48
56
|
self.config = config
|
|
49
57
|
self.processor = processor
|
|
50
58
|
self.sink = sink
|
|
@@ -76,6 +84,8 @@ class Loom:
|
|
|
76
84
|
self.weavers: list[Weaver] = []
|
|
77
85
|
self._stop_lock = threading.Lock()
|
|
78
86
|
self._stopped = False
|
|
87
|
+
# Distinguishes COMPLETED (source exhausted) from STOPPED (interrupted)
|
|
88
|
+
self._source_exhausted = False
|
|
79
89
|
|
|
80
90
|
def __enter__(self) -> "Loom":
|
|
81
91
|
return self
|
|
@@ -90,7 +100,18 @@ class Loom:
|
|
|
90
100
|
"""
|
|
91
101
|
Starts the Weavers and begins the task production loop.
|
|
92
102
|
This method blocks until an error occurs or the loom is stopped.
|
|
103
|
+
|
|
104
|
+
A Loom instance is single-use: calling start() again after it has
|
|
105
|
+
run (or after stop()) raises LoomError.
|
|
93
106
|
"""
|
|
107
|
+
# Restarting a stopped instance would spawn Weavers that never
|
|
108
|
+
# receive a stop sentinel (stop() is idempotent), leaking threads.
|
|
109
|
+
if self.state is not LoomState.PENDING or self._stopped:
|
|
110
|
+
raise LoomError(
|
|
111
|
+
"This Loom has already been started or stopped; "
|
|
112
|
+
"create a new instance to run another pipeline."
|
|
113
|
+
)
|
|
114
|
+
|
|
94
115
|
self.state = LoomState.RUNNING
|
|
95
116
|
self.hooks.on_start()
|
|
96
117
|
|
|
@@ -112,6 +133,11 @@ class Loom:
|
|
|
112
133
|
if self.stop_event.is_set():
|
|
113
134
|
break
|
|
114
135
|
self._enqueue(batch)
|
|
136
|
+
else:
|
|
137
|
+
# The for/else only runs when the loop ended without a
|
|
138
|
+
# break: the source was exhausted naturally, so stop()
|
|
139
|
+
# may report COMPLETED instead of STOPPED.
|
|
140
|
+
self._source_exhausted = True
|
|
115
141
|
except Exception as e:
|
|
116
142
|
self.state = LoomState.FAILED
|
|
117
143
|
self.hooks.on_error(e)
|
|
@@ -131,13 +157,21 @@ class Loom:
|
|
|
131
157
|
except queue.Full:
|
|
132
158
|
continue
|
|
133
159
|
|
|
134
|
-
def stop(self) -> None:
|
|
160
|
+
def stop(self, timeout: Optional[float] = None) -> None:
|
|
135
161
|
"""
|
|
136
162
|
Signals every component to stop and waits for cleanup.
|
|
137
163
|
Safe to call multiple times or from finally blocks.
|
|
138
164
|
|
|
139
165
|
Items already queued are processed before shutdown: each Weaver
|
|
140
166
|
drains the queue until it finds its stop sentinel.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
timeout: Maximum time in seconds to wait for the Weavers to
|
|
170
|
+
finish. None (default) waits indefinitely. When the
|
|
171
|
+
deadline passes, still-running Weavers are reported via
|
|
172
|
+
hooks.on_error (as LoomError), the sink is closed anyway
|
|
173
|
+
and stop() returns; the leftover daemon threads do not
|
|
174
|
+
block process exit.
|
|
141
175
|
"""
|
|
142
176
|
with self._stop_lock:
|
|
143
177
|
if self._stopped:
|
|
@@ -146,17 +180,42 @@ class Loom:
|
|
|
146
180
|
|
|
147
181
|
self.stop_event.set()
|
|
148
182
|
|
|
183
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
184
|
+
|
|
149
185
|
# One sentinel per Weaver: each thread drains the queue and exits
|
|
150
186
|
# upon consuming its own. This replaces the queue join(), which
|
|
151
187
|
# could block forever if a Weaver died before emptying it.
|
|
152
188
|
for _ in self.weavers:
|
|
153
|
-
self.
|
|
154
|
-
|
|
155
|
-
weaver.join()
|
|
189
|
+
if not self._put_sentinel(deadline):
|
|
190
|
+
break
|
|
156
191
|
|
|
157
|
-
|
|
192
|
+
stuck = []
|
|
193
|
+
for weaver in self.weavers:
|
|
194
|
+
if deadline is None:
|
|
195
|
+
weaver.join()
|
|
196
|
+
else:
|
|
197
|
+
weaver.join(timeout=max(deadline - time.monotonic(), 0))
|
|
198
|
+
if weaver.is_alive():
|
|
199
|
+
stuck.append(weaver)
|
|
200
|
+
|
|
201
|
+
if stuck:
|
|
202
|
+
logger.warning(
|
|
203
|
+
"%d weaver(s) still running after the stop timeout; they are "
|
|
204
|
+
"daemon threads and will not block process exit.",
|
|
205
|
+
len(stuck),
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
self.hooks.on_error(
|
|
209
|
+
LoomError(f"{len(stuck)} weaver(s) did not finish within the stop timeout.")
|
|
210
|
+
)
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.exception("The on_error callback raised an exception.")
|
|
213
|
+
|
|
214
|
+
# Natural exhaustion of the source becomes COMPLETED; an external
|
|
215
|
+
# stop or interruption becomes STOPPED. A FAILED state set by
|
|
216
|
+
# start() on a source error is never overwritten.
|
|
158
217
|
if self.state is LoomState.RUNNING:
|
|
159
|
-
self.state = LoomState.COMPLETED
|
|
218
|
+
self.state = LoomState.COMPLETED if self._source_exhausted else LoomState.STOPPED
|
|
160
219
|
|
|
161
220
|
try:
|
|
162
221
|
self.sink.close()
|
|
@@ -165,3 +224,23 @@ class Loom:
|
|
|
165
224
|
self.hooks.on_error(e)
|
|
166
225
|
|
|
167
226
|
self.hooks.on_stop()
|
|
227
|
+
|
|
228
|
+
def _put_sentinel(self, deadline: Optional[float]) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Enqueues one stop sentinel, giving up when the deadline passes or
|
|
231
|
+
when no Weaver is alive to drain a full queue — a plain blocking
|
|
232
|
+
put() would hang stop() forever in that scenario.
|
|
233
|
+
"""
|
|
234
|
+
while True:
|
|
235
|
+
if deadline is None:
|
|
236
|
+
wait = 0.1
|
|
237
|
+
else:
|
|
238
|
+
wait = min(0.1, max(deadline - time.monotonic(), 0.0))
|
|
239
|
+
try:
|
|
240
|
+
self.task_queue.put(STOP_SENTINEL, timeout=wait)
|
|
241
|
+
return True
|
|
242
|
+
except queue.Full:
|
|
243
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
244
|
+
return False
|
|
245
|
+
if not any(weaver.is_alive() for weaver in self.weavers):
|
|
246
|
+
return False
|
|
@@ -44,17 +44,16 @@ class JsonFileSink(Sink):
|
|
|
44
44
|
Uses a threading.Lock to keep concurrent writes consistent.
|
|
45
45
|
"""
|
|
46
46
|
|
|
47
|
-
def __init__(self, output_dir: Path):
|
|
48
|
-
self.output_dir = output_dir
|
|
47
|
+
def __init__(self, output_dir: Path, filename: str = "results.json"):
|
|
48
|
+
self.output_dir = Path(output_dir)
|
|
49
49
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self._path = self.output_dir / filename
|
|
50
51
|
# The lock ensures only one Weaver writes to the file at a time
|
|
51
52
|
self._lock = threading.Lock()
|
|
52
53
|
|
|
53
54
|
def send(self, result: Dict[str, Any]) -> None:
|
|
54
|
-
filename = self.output_dir / "results.json"
|
|
55
|
-
|
|
56
55
|
with self._lock:
|
|
57
|
-
with open(
|
|
56
|
+
with open(self._path, "a") as f:
|
|
58
57
|
json.dump(result, f)
|
|
59
58
|
f.write("\n")
|
|
60
59
|
|
|
@@ -138,9 +137,14 @@ class ThreadedBufferedSink(Sink):
|
|
|
138
137
|
self.worker_thread.start()
|
|
139
138
|
|
|
140
139
|
def send(self, result: Dict[str, Any]) -> None:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
# The closed check and the put must be atomic with respect to
|
|
141
|
+
# close(): otherwise a send() racing with close() could pass the
|
|
142
|
+
# check, lose the CPU while close() drains the buffer, and then
|
|
143
|
+
# enqueue the item behind the stop sentinel — silently lost.
|
|
144
|
+
with self._close_lock:
|
|
145
|
+
if self._closed:
|
|
146
|
+
raise LoomError("ThreadedBufferedSink is already closed; send() is not allowed.")
|
|
147
|
+
self.queue.put(result)
|
|
144
148
|
|
|
145
149
|
def _worker(self) -> None:
|
|
146
150
|
while True:
|
|
@@ -157,15 +161,16 @@ class ThreadedBufferedSink(Sink):
|
|
|
157
161
|
self.queue.task_done()
|
|
158
162
|
|
|
159
163
|
def close(self) -> None:
|
|
160
|
-
# Idempotent: only the first call performs the shutdown
|
|
164
|
+
# Idempotent: only the first call performs the shutdown. The
|
|
165
|
+
# sentinel is enqueued under the same lock as send(), so it is
|
|
166
|
+
# guaranteed to land behind every accepted item — the worker
|
|
167
|
+
# drains all of them before exiting.
|
|
161
168
|
with self._close_lock:
|
|
162
169
|
if self._closed:
|
|
163
170
|
return
|
|
164
171
|
self._closed = True
|
|
172
|
+
self.queue.put(self._STOP)
|
|
165
173
|
|
|
166
|
-
# The sentinel goes in behind any pending items: the worker
|
|
167
|
-
# drains everything before exiting
|
|
168
|
-
self.queue.put(self._STOP)
|
|
169
174
|
self.worker_thread.join()
|
|
170
175
|
|
|
171
176
|
# Propagate the close
|
|
@@ -14,5 +14,6 @@ class LoomState(Enum):
|
|
|
14
14
|
|
|
15
15
|
PENDING = "pending"
|
|
16
16
|
RUNNING = "running"
|
|
17
|
-
COMPLETED = "completed"
|
|
18
|
-
|
|
17
|
+
COMPLETED = "completed" # the source was exhausted naturally
|
|
18
|
+
STOPPED = "stopped" # interrupted (external stop() or KeyboardInterrupt) before exhaustion
|
|
19
|
+
FAILED = "failed" # the source raised an error
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataloom-engine
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: DataLoom:
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: DataLoom: a lightweight and efficient thread orchestration engine for data pipelines.
|
|
5
5
|
Author-email: Dioni Padilha <dionipdl@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/dionipadilha/dataloom
|
|
@@ -175,6 +175,10 @@ if __name__ == "__main__":
|
|
|
175
175
|
print("\n🛑 Loom stopped.")
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
+
More runnable, real-world examples — API enrichment with parallel speedup,
|
|
179
|
+
a resilient file-processing pipeline, a buffered sensor stream — live in the
|
|
180
|
+
[`examples/`](examples/) directory.
|
|
181
|
+
|
|
178
182
|
## 🏗️ Architecture
|
|
179
183
|
|
|
180
184
|
DataLoom is built around a weaving metaphor:
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dataloom-engine"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "DataLoom:
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "DataLoom: a lightweight and efficient thread orchestration engine for data pipelines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = {text = "MIT"}
|
|
@@ -11,11 +11,12 @@ import threading
|
|
|
11
11
|
|
|
12
12
|
import numpy as np
|
|
13
13
|
|
|
14
|
-
from dataloom_engine import JsonFileSink, Processor, Sink
|
|
14
|
+
from dataloom_engine import JsonFileSink, LoomConfig, Processor, Sink
|
|
15
15
|
|
|
16
16
|
# Importing internal classes explicitly for testing
|
|
17
17
|
from dataloom_engine.exceptions import WeaverError
|
|
18
18
|
from dataloom_engine.processors import StatisticsProcessor
|
|
19
|
+
from dataloom_engine.sources import RandomNumPySource
|
|
19
20
|
from dataloom_engine.weaver import STOP_SENTINEL, Weaver
|
|
20
21
|
|
|
21
22
|
# --- Mocks and helpers ---
|
|
@@ -76,6 +77,30 @@ def test_json_sink_writes_file(tmp_path):
|
|
|
76
77
|
assert loaded_json == data
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def test_json_sink_accepts_custom_filename(tmp_path):
|
|
81
|
+
"""The output filename is configurable (parity with CsvFileSink)."""
|
|
82
|
+
sink = JsonFileSink(output_dir=tmp_path, filename="custom.jsonl")
|
|
83
|
+
sink.send({"id": 7})
|
|
84
|
+
|
|
85
|
+
assert (tmp_path / "custom.jsonl").exists()
|
|
86
|
+
assert not (tmp_path / "results.json").exists()
|
|
87
|
+
assert json.loads((tmp_path / "custom.jsonl").read_text()) == {"id": 7}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_random_numpy_source_respects_limit_and_batch_size():
|
|
91
|
+
"""The demo source yields exactly `limit` batches of `batch_size` values in [0, 1)."""
|
|
92
|
+
config = LoomConfig(output_dir=".", batch_size=5, interval_seconds=0)
|
|
93
|
+
source = RandomNumPySource(config, limit=3)
|
|
94
|
+
|
|
95
|
+
batches = list(source)
|
|
96
|
+
|
|
97
|
+
assert len(batches) == 3
|
|
98
|
+
for batch in batches:
|
|
99
|
+
assert len(batch) == 5
|
|
100
|
+
assert float(batch.min()) >= 0.0
|
|
101
|
+
assert float(batch.max()) < 1.0
|
|
102
|
+
|
|
103
|
+
|
|
79
104
|
# --- Integration tests (Weaver/flow) ---
|
|
80
105
|
|
|
81
106
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# tests/test_loom.py
|
|
2
2
|
|
|
3
3
|
import threading
|
|
4
|
+
import time
|
|
4
5
|
from typing import Any, Iterator
|
|
5
6
|
|
|
6
7
|
import numpy as np
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
9
10
|
from dataloom_engine import Loom, LoomConfig, LoomHooks, LoomState, Processor, Sink
|
|
10
|
-
from dataloom_engine.exceptions import WeaverError
|
|
11
|
+
from dataloom_engine.exceptions import ConfigurationError, LoomError, WeaverError
|
|
11
12
|
from dataloom_engine.sources import Source
|
|
12
13
|
|
|
13
14
|
# --- Mocks ---
|
|
@@ -61,11 +62,8 @@ def test_loom_uses_custom_source():
|
|
|
61
62
|
|
|
62
63
|
loom = Loom(config=config, processor=processor, sink=sink, source=source, num_weavers=2)
|
|
63
64
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
# Since our source is finite, it should finish naturally.
|
|
67
|
-
if hasattr(loom, "start"):
|
|
68
|
-
loom.start()
|
|
65
|
+
# start() blocks until the finite source is exhausted, then stops itself
|
|
66
|
+
loom.start()
|
|
69
67
|
|
|
70
68
|
# Verify results
|
|
71
69
|
assert len(sink.results) == 3
|
|
@@ -278,6 +276,161 @@ def test_loom_survives_broken_metrics_hook():
|
|
|
278
276
|
assert loom.state is LoomState.COMPLETED
|
|
279
277
|
|
|
280
278
|
|
|
279
|
+
def test_loom_external_stop_sets_stopped_state():
|
|
280
|
+
"""stop() before the source is exhausted must end as STOPPED, not COMPLETED."""
|
|
281
|
+
|
|
282
|
+
class InfiniteSource(Source):
|
|
283
|
+
def __iter__(self) -> Iterator[Any]:
|
|
284
|
+
i = 0
|
|
285
|
+
while True:
|
|
286
|
+
yield np.array([i])
|
|
287
|
+
i += 1
|
|
288
|
+
|
|
289
|
+
hooks = RecordingHooks()
|
|
290
|
+
sink = InMemorySink()
|
|
291
|
+
loom = _make_loom(InfiniteSource(), hooks=hooks, sink=sink)
|
|
292
|
+
|
|
293
|
+
runner = threading.Thread(target=loom.start, daemon=True)
|
|
294
|
+
runner.start()
|
|
295
|
+
# Wait until the pipeline is demonstrably flowing before stopping it
|
|
296
|
+
for _ in range(500):
|
|
297
|
+
with sink._lock:
|
|
298
|
+
if sink.results:
|
|
299
|
+
break
|
|
300
|
+
time.sleep(0.01)
|
|
301
|
+
assert sink.results, "pipeline never produced a result"
|
|
302
|
+
|
|
303
|
+
loom.stop()
|
|
304
|
+
runner.join(timeout=5)
|
|
305
|
+
|
|
306
|
+
assert not runner.is_alive(), "start() did not return after stop()"
|
|
307
|
+
assert loom.state is LoomState.STOPPED
|
|
308
|
+
assert hooks.stopped
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_loom_keyboard_interrupt_sets_stopped_state():
|
|
312
|
+
"""Ctrl+C is an interruption: the state must be STOPPED, not COMPLETED or FAILED."""
|
|
313
|
+
|
|
314
|
+
class InterruptingSource(Source):
|
|
315
|
+
def __iter__(self) -> Iterator[Any]:
|
|
316
|
+
yield np.array([1])
|
|
317
|
+
raise KeyboardInterrupt
|
|
318
|
+
|
|
319
|
+
hooks = RecordingHooks()
|
|
320
|
+
loom = _make_loom(InterruptingSource(), hooks=hooks)
|
|
321
|
+
|
|
322
|
+
with pytest.raises(KeyboardInterrupt):
|
|
323
|
+
loom.start()
|
|
324
|
+
|
|
325
|
+
assert loom.state is LoomState.STOPPED
|
|
326
|
+
assert hooks.stopped # cleanup still ran
|
|
327
|
+
assert hooks.errors == [] # an interruption is not an error
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_loom_stop_timeout_reports_stuck_weaver():
|
|
331
|
+
"""stop(timeout=...) returns even with a hung Processor and reports it via on_error."""
|
|
332
|
+
entered = threading.Event()
|
|
333
|
+
release = threading.Event()
|
|
334
|
+
|
|
335
|
+
class BlockingProcessor(Processor):
|
|
336
|
+
def process(self, batch):
|
|
337
|
+
entered.set()
|
|
338
|
+
release.wait(timeout=10)
|
|
339
|
+
return {"data": batch[0]}
|
|
340
|
+
|
|
341
|
+
class GatedSource(Source):
|
|
342
|
+
"""Yields one batch, then keeps the producer inside the loop until released."""
|
|
343
|
+
|
|
344
|
+
def __iter__(self) -> Iterator[Any]:
|
|
345
|
+
yield np.array([1])
|
|
346
|
+
release.wait(timeout=10)
|
|
347
|
+
|
|
348
|
+
hooks = RecordingHooks()
|
|
349
|
+
config = LoomConfig(output_dir=".", batch_size=1, interval_seconds=0)
|
|
350
|
+
loom = Loom(
|
|
351
|
+
config=config,
|
|
352
|
+
processor=BlockingProcessor(),
|
|
353
|
+
sink=InMemorySink(),
|
|
354
|
+
source=GatedSource(),
|
|
355
|
+
hooks=hooks,
|
|
356
|
+
num_weavers=1,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
runner = threading.Thread(target=loom.start, daemon=True)
|
|
360
|
+
runner.start()
|
|
361
|
+
assert entered.wait(timeout=5), "the weaver never picked up the batch"
|
|
362
|
+
|
|
363
|
+
started = time.monotonic()
|
|
364
|
+
loom.stop(timeout=0.2)
|
|
365
|
+
elapsed = time.monotonic() - started
|
|
366
|
+
|
|
367
|
+
assert elapsed < 5, "stop(timeout) blocked far beyond its deadline"
|
|
368
|
+
assert loom.state is LoomState.STOPPED
|
|
369
|
+
assert any("stop timeout" in str(e) for e in hooks.errors)
|
|
370
|
+
assert hooks.stopped # the sink was closed and on_stop fired despite the stuck weaver
|
|
371
|
+
|
|
372
|
+
# Cleanup: unblock everything so the test leaves no lingering threads
|
|
373
|
+
release.set()
|
|
374
|
+
runner.join(timeout=5)
|
|
375
|
+
assert not runner.is_alive()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@pytest.mark.parametrize("num_weavers", [0, -1])
|
|
379
|
+
def test_loom_rejects_non_positive_num_weavers(num_weavers):
|
|
380
|
+
"""
|
|
381
|
+
num_weavers < 1 must fail fast: 0 weavers would silently create an
|
|
382
|
+
unbounded queue (0 * 4 = 0 = no limit) and complete without
|
|
383
|
+
processing anything.
|
|
384
|
+
"""
|
|
385
|
+
config = LoomConfig(output_dir=".", batch_size=1, interval_seconds=0)
|
|
386
|
+
with pytest.raises(ConfigurationError):
|
|
387
|
+
Loom(
|
|
388
|
+
config=config,
|
|
389
|
+
processor=PassthroughProcessor(),
|
|
390
|
+
sink=InMemorySink(),
|
|
391
|
+
source=FiniteSource([1]),
|
|
392
|
+
num_weavers=num_weavers,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_loom_start_cannot_be_reused():
|
|
397
|
+
"""
|
|
398
|
+
A Loom instance is single-use: a second start() must raise instead of
|
|
399
|
+
spawning Weavers that never receive a stop sentinel (thread leak with
|
|
400
|
+
the state stuck in RUNNING).
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
class CountingHooks(LoomHooks):
|
|
404
|
+
def __init__(self):
|
|
405
|
+
self.start_calls = 0
|
|
406
|
+
|
|
407
|
+
def on_start(self):
|
|
408
|
+
self.start_calls += 1
|
|
409
|
+
|
|
410
|
+
hooks = CountingHooks()
|
|
411
|
+
loom = _make_loom(FiniteSource([1]), hooks=hooks)
|
|
412
|
+
loom.start()
|
|
413
|
+
weavers_after_first = len(loom.weavers)
|
|
414
|
+
|
|
415
|
+
with pytest.raises(LoomError):
|
|
416
|
+
loom.start()
|
|
417
|
+
|
|
418
|
+
assert len(loom.weavers) == weavers_after_first # no leaked threads
|
|
419
|
+
assert loom.state is LoomState.COMPLETED # not stuck in RUNNING
|
|
420
|
+
assert hooks.start_calls == 1 # the rejected start never fired hooks
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_loom_start_after_early_stop_raises():
|
|
424
|
+
"""stop() before start() must also make start() unusable (same leak scenario)."""
|
|
425
|
+
loom = _make_loom(FiniteSource([1]))
|
|
426
|
+
loom.stop()
|
|
427
|
+
|
|
428
|
+
with pytest.raises(LoomError):
|
|
429
|
+
loom.start()
|
|
430
|
+
|
|
431
|
+
assert loom.weavers == []
|
|
432
|
+
|
|
433
|
+
|
|
281
434
|
def test_loom_as_context_manager():
|
|
282
435
|
"""The with block yields the Loom itself and guarantees stop() on exit."""
|
|
283
436
|
hooks = RecordingHooks()
|
|
@@ -29,6 +29,12 @@ CORE_MODULES = [
|
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def test_package_exposes_version():
|
|
33
|
+
"""__version__ mirrors the installed distribution metadata."""
|
|
34
|
+
assert isinstance(dataloom_engine.__version__, str)
|
|
35
|
+
assert dataloom_engine.__version__
|
|
36
|
+
|
|
37
|
+
|
|
32
38
|
def test_core_modules_do_not_import_numpy():
|
|
33
39
|
"""
|
|
34
40
|
No core module may import numpy at module level. Lazy imports inside
|
|
@@ -166,6 +166,40 @@ def test_callback_sink_close_without_handler_is_noop():
|
|
|
166
166
|
sink.close() # must not raise
|
|
167
167
|
|
|
168
168
|
|
|
169
|
+
def test_threaded_sink_send_racing_close_never_drops_silently():
|
|
170
|
+
"""
|
|
171
|
+
Regression for the send()/close() race: send() used to check _closed
|
|
172
|
+
outside the close lock, so an item could be enqueued behind the stop
|
|
173
|
+
sentinel and silently lost — no delivery, no LoomError. Now the
|
|
174
|
+
check+put is atomic: every send() either delivers or raises.
|
|
175
|
+
"""
|
|
176
|
+
for _ in range(50): # repeat to give the race a chance
|
|
177
|
+
target = MockSink()
|
|
178
|
+
buffered_sink = ThreadedBufferedSink(target)
|
|
179
|
+
outcome = {}
|
|
180
|
+
barrier = threading.Barrier(2)
|
|
181
|
+
|
|
182
|
+
def producer(sink=buffered_sink, outcome=outcome, barrier=barrier):
|
|
183
|
+
barrier.wait() # line up with close() for maximum contention
|
|
184
|
+
try:
|
|
185
|
+
sink.send({"id": 1})
|
|
186
|
+
outcome["sent"] = True
|
|
187
|
+
except LoomError:
|
|
188
|
+
outcome["sent"] = False
|
|
189
|
+
|
|
190
|
+
thread = threading.Thread(target=producer)
|
|
191
|
+
thread.start()
|
|
192
|
+
barrier.wait()
|
|
193
|
+
buffered_sink.close()
|
|
194
|
+
thread.join(timeout=2)
|
|
195
|
+
assert not thread.is_alive()
|
|
196
|
+
|
|
197
|
+
if outcome["sent"]:
|
|
198
|
+
assert target.results == [{"id": 1}], "accepted item was dropped"
|
|
199
|
+
else:
|
|
200
|
+
assert target.results == []
|
|
201
|
+
|
|
202
|
+
|
|
169
203
|
def test_threaded_sink_no_data_loss_on_racy_close():
|
|
170
204
|
"""
|
|
171
205
|
Regression for the old worker race (stop_event + queue.empty()):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|