dataloom-engine 0.4.0__tar.gz → 0.4.1__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.4.1}/PKG-INFO +6 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/README.md +4 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/__init__.py +8 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/loom.py +15 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/sinks.py +17 -12
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/PKG-INFO +6 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/pyproject.toml +2 -2
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_core.py +26 -1
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_loom.py +59 -6
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_optional.py +6 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_sinks.py +34 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/LICENSE +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/_optional.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/config.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/exceptions.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/hooks.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/logs.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/processors.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/py.typed +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/sources.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/types.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/weaver.py +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/SOURCES.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/dependency_links.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/requires.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/top_level.txt +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/setup.cfg +0 -0
- {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/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.
|
|
4
|
-
Summary: DataLoom:
|
|
3
|
+
Version: 0.4.1
|
|
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",
|
|
@@ -11,6 +11,7 @@ import threading
|
|
|
11
11
|
from typing import TYPE_CHECKING, Optional
|
|
12
12
|
|
|
13
13
|
from dataloom_engine.config import LoomConfig
|
|
14
|
+
from dataloom_engine.exceptions import ConfigurationError, LoomError
|
|
14
15
|
from dataloom_engine.hooks import LoomHooks
|
|
15
16
|
from dataloom_engine.processors import Processor
|
|
16
17
|
from dataloom_engine.sinks import Sink
|
|
@@ -45,6 +46,9 @@ class Loom:
|
|
|
45
46
|
hooks: Optional[LoomHooks] = None,
|
|
46
47
|
num_weavers: int = 2,
|
|
47
48
|
):
|
|
49
|
+
if num_weavers < 1:
|
|
50
|
+
raise ConfigurationError(f"num_weavers must be at least 1 (got: {num_weavers}).")
|
|
51
|
+
|
|
48
52
|
self.config = config
|
|
49
53
|
self.processor = processor
|
|
50
54
|
self.sink = sink
|
|
@@ -90,7 +94,18 @@ class Loom:
|
|
|
90
94
|
"""
|
|
91
95
|
Starts the Weavers and begins the task production loop.
|
|
92
96
|
This method blocks until an error occurs or the loom is stopped.
|
|
97
|
+
|
|
98
|
+
A Loom instance is single-use: calling start() again after it has
|
|
99
|
+
run (or after stop()) raises LoomError.
|
|
93
100
|
"""
|
|
101
|
+
# Restarting a stopped instance would spawn Weavers that never
|
|
102
|
+
# receive a stop sentinel (stop() is idempotent), leaking threads.
|
|
103
|
+
if self.state is not LoomState.PENDING or self._stopped:
|
|
104
|
+
raise LoomError(
|
|
105
|
+
"This Loom has already been started or stopped; "
|
|
106
|
+
"create a new instance to run another pipeline."
|
|
107
|
+
)
|
|
108
|
+
|
|
94
109
|
self.state = LoomState.RUNNING
|
|
95
110
|
self.hooks.on_start()
|
|
96
111
|
|
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataloom-engine
|
|
3
|
-
Version: 0.4.
|
|
4
|
-
Summary: DataLoom:
|
|
3
|
+
Version: 0.4.1
|
|
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.4.
|
|
8
|
-
description = "DataLoom:
|
|
7
|
+
version = "0.4.1"
|
|
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
|
|
|
@@ -7,7 +7,7 @@ import numpy as np
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from dataloom_engine import Loom, LoomConfig, LoomHooks, LoomState, Processor, Sink
|
|
10
|
-
from dataloom_engine.exceptions import WeaverError
|
|
10
|
+
from dataloom_engine.exceptions import ConfigurationError, LoomError, WeaverError
|
|
11
11
|
from dataloom_engine.sources import Source
|
|
12
12
|
|
|
13
13
|
# --- Mocks ---
|
|
@@ -61,11 +61,8 @@ def test_loom_uses_custom_source():
|
|
|
61
61
|
|
|
62
62
|
loom = Loom(config=config, processor=processor, sink=sink, source=source, num_weavers=2)
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
# Since our source is finite, it should finish naturally.
|
|
67
|
-
if hasattr(loom, "start"):
|
|
68
|
-
loom.start()
|
|
64
|
+
# start() blocks until the finite source is exhausted, then stops itself
|
|
65
|
+
loom.start()
|
|
69
66
|
|
|
70
67
|
# Verify results
|
|
71
68
|
assert len(sink.results) == 3
|
|
@@ -278,6 +275,62 @@ def test_loom_survives_broken_metrics_hook():
|
|
|
278
275
|
assert loom.state is LoomState.COMPLETED
|
|
279
276
|
|
|
280
277
|
|
|
278
|
+
@pytest.mark.parametrize("num_weavers", [0, -1])
|
|
279
|
+
def test_loom_rejects_non_positive_num_weavers(num_weavers):
|
|
280
|
+
"""
|
|
281
|
+
num_weavers < 1 must fail fast: 0 weavers would silently create an
|
|
282
|
+
unbounded queue (0 * 4 = 0 = no limit) and complete without
|
|
283
|
+
processing anything.
|
|
284
|
+
"""
|
|
285
|
+
config = LoomConfig(output_dir=".", batch_size=1, interval_seconds=0)
|
|
286
|
+
with pytest.raises(ConfigurationError):
|
|
287
|
+
Loom(
|
|
288
|
+
config=config,
|
|
289
|
+
processor=PassthroughProcessor(),
|
|
290
|
+
sink=InMemorySink(),
|
|
291
|
+
source=FiniteSource([1]),
|
|
292
|
+
num_weavers=num_weavers,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_loom_start_cannot_be_reused():
|
|
297
|
+
"""
|
|
298
|
+
A Loom instance is single-use: a second start() must raise instead of
|
|
299
|
+
spawning Weavers that never receive a stop sentinel (thread leak with
|
|
300
|
+
the state stuck in RUNNING).
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
class CountingHooks(LoomHooks):
|
|
304
|
+
def __init__(self):
|
|
305
|
+
self.start_calls = 0
|
|
306
|
+
|
|
307
|
+
def on_start(self):
|
|
308
|
+
self.start_calls += 1
|
|
309
|
+
|
|
310
|
+
hooks = CountingHooks()
|
|
311
|
+
loom = _make_loom(FiniteSource([1]), hooks=hooks)
|
|
312
|
+
loom.start()
|
|
313
|
+
weavers_after_first = len(loom.weavers)
|
|
314
|
+
|
|
315
|
+
with pytest.raises(LoomError):
|
|
316
|
+
loom.start()
|
|
317
|
+
|
|
318
|
+
assert len(loom.weavers) == weavers_after_first # no leaked threads
|
|
319
|
+
assert loom.state is LoomState.COMPLETED # not stuck in RUNNING
|
|
320
|
+
assert hooks.start_calls == 1 # the rejected start never fired hooks
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_loom_start_after_early_stop_raises():
|
|
324
|
+
"""stop() before start() must also make start() unusable (same leak scenario)."""
|
|
325
|
+
loom = _make_loom(FiniteSource([1]))
|
|
326
|
+
loom.stop()
|
|
327
|
+
|
|
328
|
+
with pytest.raises(LoomError):
|
|
329
|
+
loom.start()
|
|
330
|
+
|
|
331
|
+
assert loom.weavers == []
|
|
332
|
+
|
|
333
|
+
|
|
281
334
|
def test_loom_as_context_manager():
|
|
282
335
|
"""The with block yields the Loom itself and guarantees stop() on exit."""
|
|
283
336
|
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
|
|
File without changes
|
{dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|