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.
Files changed (28) hide show
  1. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/PKG-INFO +6 -2
  2. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/README.md +4 -0
  3. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/__init__.py +8 -0
  4. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/loom.py +85 -6
  5. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/sinks.py +17 -12
  6. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/types.py +3 -2
  7. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/PKG-INFO +6 -2
  8. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/pyproject.toml +2 -2
  9. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_core.py +26 -1
  10. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_loom.py +159 -6
  11. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_optional.py +6 -0
  12. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/tests/test_sinks.py +34 -0
  13. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/LICENSE +0 -0
  14. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/_optional.py +0 -0
  15. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/config.py +0 -0
  16. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/exceptions.py +0 -0
  17. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/hooks.py +0 -0
  18. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/logs.py +0 -0
  19. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/processors.py +0 -0
  20. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/py.typed +0 -0
  21. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/sources.py +0 -0
  22. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine/weaver.py +0 -0
  23. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/SOURCES.txt +0 -0
  24. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/dependency_links.txt +0 -0
  25. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/requires.txt +0 -0
  26. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/dataloom_engine.egg-info/top_level.txt +0 -0
  27. {dataloom_engine-0.4.0 → dataloom_engine-0.5.0}/setup.cfg +0 -0
  28. {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.0
4
- Summary: DataLoom: Um orquestrador de threads leve e eficiente para dados.
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.task_queue.put(STOP_SENTINEL)
154
- for weaver in self.weavers:
155
- weaver.join()
189
+ if not self._put_sentinel(deadline):
190
+ break
156
191
 
157
- # Never overwrite a FAILED state set by start() on error
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(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
@@ -14,5 +14,6 @@ class LoomState(Enum):
14
14
 
15
15
  PENDING = "pending"
16
16
  RUNNING = "running"
17
- COMPLETED = "completed"
18
- FAILED = "failed"
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.0
4
- Summary: DataLoom: Um orquestrador de threads leve e eficiente para dados.
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.4.0"
8
- description = "DataLoom: Um orquestrador de threads leve e eficiente para dados."
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
- # 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()
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