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.
Files changed (28) hide show
  1. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/PKG-INFO +6 -2
  2. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/README.md +4 -0
  3. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/__init__.py +8 -0
  4. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/loom.py +15 -0
  5. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/sinks.py +17 -12
  6. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/PKG-INFO +6 -2
  7. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/pyproject.toml +2 -2
  8. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_core.py +26 -1
  9. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_loom.py +59 -6
  10. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_optional.py +6 -0
  11. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/tests/test_sinks.py +34 -0
  12. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/LICENSE +0 -0
  13. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/_optional.py +0 -0
  14. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/config.py +0 -0
  15. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/exceptions.py +0 -0
  16. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/hooks.py +0 -0
  17. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/logs.py +0 -0
  18. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/processors.py +0 -0
  19. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/py.typed +0 -0
  20. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/sources.py +0 -0
  21. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/types.py +0 -0
  22. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine/weaver.py +0 -0
  23. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/SOURCES.txt +0 -0
  24. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/dependency_links.txt +0 -0
  25. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/requires.txt +0 -0
  26. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/dataloom_engine.egg-info/top_level.txt +0 -0
  27. {dataloom_engine-0.4.0 → dataloom_engine-0.4.1}/setup.cfg +0 -0
  28. {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.0
4
- Summary: DataLoom: Um orquestrador de threads leve e eficiente para dados.
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(filename, "a") as f:
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
- if self._closed:
142
- raise LoomError("ThreadedBufferedSink is already closed; send() is not allowed.")
143
- self.queue.put(result)
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.0
4
- Summary: DataLoom: Um orquestrador de threads leve e eficiente para dados.
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.0"
8
- description = "DataLoom: Um orquestrador de threads leve e eficiente para dados."
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
- # Run Loom (start blocks until source is exhausted or error)
65
- # But wait, Loom.start() blocks until source is exhausted AND then calls stop().
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