dataloom-engine 0.4.1__tar.gz → 0.6.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 (29) hide show
  1. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/PKG-INFO +1 -1
  2. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/config.py +11 -6
  3. dataloom_engine-0.6.0/dataloom_engine/exceptions.py +41 -0
  4. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/hooks.py +5 -0
  5. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/loom.py +70 -6
  6. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/sinks.py +42 -9
  7. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/types.py +3 -2
  8. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/weaver.py +6 -2
  9. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine.egg-info/PKG-INFO +1 -1
  10. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/pyproject.toml +1 -1
  11. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/tests/test_config.py +12 -0
  12. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/tests/test_core.py +38 -1
  13. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/tests/test_loom.py +100 -0
  14. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/tests/test_optional.py +2 -1
  15. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/tests/test_sinks.py +53 -1
  16. dataloom_engine-0.4.1/dataloom_engine/exceptions.py +0 -25
  17. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/LICENSE +0 -0
  18. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/README.md +0 -0
  19. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/__init__.py +0 -0
  20. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/_optional.py +0 -0
  21. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/logs.py +0 -0
  22. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/processors.py +0 -0
  23. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/py.typed +0 -0
  24. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine/sources.py +0 -0
  25. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine.egg-info/SOURCES.txt +0 -0
  26. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine.egg-info/dependency_links.txt +0 -0
  27. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine.egg-info/requires.txt +0 -0
  28. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/dataloom_engine.egg-info/top_level.txt +0 -0
  29. {dataloom_engine-0.4.1 → dataloom_engine-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataloom-engine
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
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
@@ -18,10 +18,14 @@ class LoomConfig:
18
18
  DataLoom's main configuration object.
19
19
 
20
20
  Args:
21
- output_dir (Path): Base directory where the built-in Sinks save data.
22
- Strings are converted to Path automatically.
23
- batch_size (int): Number of items produced per processing cycle.
24
- interval_seconds (float): Time interval between task generations.
21
+ output_dir (Optional[Path]): Base directory for file-based Sinks.
22
+ Strings are converted to Path automatically. Optional:
23
+ pipelines that don't write local files (CallbackSink, custom
24
+ sinks) don't need it the engine core never reads it.
25
+ batch_size (int): Number of items produced per processing cycle
26
+ (used by the built-in demo Source).
27
+ interval_seconds (float): Time interval between task generations
28
+ (used by the built-in demo Source).
25
29
  queue_maxsize (Optional[int]): Maximum capacity of the task queue
26
30
  (backpressure). None uses the Loom default (num_weavers * 4);
27
31
  0 means an unbounded queue.
@@ -30,13 +34,14 @@ class LoomConfig:
30
34
  ConfigurationError: if any parameter is invalid.
31
35
  """
32
36
 
33
- output_dir: Union[str, Path]
37
+ output_dir: Optional[Union[str, Path]] = None
34
38
  batch_size: int = 10
35
39
  interval_seconds: float = 1.0
36
40
  queue_maxsize: Optional[int] = None
37
41
 
38
42
  def __post_init__(self) -> None:
39
- self.output_dir = Path(self.output_dir)
43
+ if self.output_dir is not None:
44
+ self.output_dir = Path(self.output_dir)
40
45
 
41
46
  if self.batch_size <= 0:
42
47
  raise ConfigurationError(
@@ -0,0 +1,41 @@
1
+ # dataloom_engine/exceptions.py
2
+
3
+ """
4
+ Custom exceptions for DataLoom.
5
+ Lets consumers catch library-specific errors without relying on
6
+ generic Python exceptions.
7
+ """
8
+
9
+ from typing import Any, Optional
10
+
11
+
12
+ class LoomError(Exception):
13
+ """Base exception for every DataLoom error."""
14
+
15
+ pass
16
+
17
+
18
+ class ConfigurationError(LoomError):
19
+ """Raised when LoomConfig validation fails."""
20
+
21
+ pass
22
+
23
+
24
+ class WeaverError(LoomError):
25
+ """
26
+ Raised when a Weaver fails to handle a batch.
27
+
28
+ Carries the failure context so hooks.on_error can implement retry,
29
+ quarantine or dead-letter logic without parsing error strings:
30
+
31
+ Attributes:
32
+ batch: The batch that failed, exactly as yielded by the Source.
33
+ stage: Where the failure happened: "process" (Processor.process)
34
+ or "send" (Sink.send). None when the error was constructed
35
+ without context.
36
+ """
37
+
38
+ def __init__(self, message: str, batch: Any = None, stage: Optional[str] = None):
39
+ super().__init__(message)
40
+ self.batch = batch
41
+ self.stage = stage
@@ -29,6 +29,11 @@ class LoomHooks:
29
29
  Called when an exception occurs in the Loom's main loop or while
30
30
  processing a batch inside a Weaver (WeaverError).
31
31
 
32
+ Batch-level failures arrive as WeaverError carrying the failed
33
+ batch (`error.batch`) and the stage where it broke
34
+ (`error.stage`: "process" or "send"), enabling retry, quarantine
35
+ or dead-letter handling without parsing error messages.
36
+
32
37
  Note: it may be invoked from multiple Weaver threads at the same
33
38
  time — implementations must be thread-safe.
34
39
  """
@@ -6,8 +6,10 @@ 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
@@ -21,6 +23,8 @@ from dataloom_engine.weaver import STOP_SENTINEL, Weaver
21
23
  if TYPE_CHECKING:
22
24
  from dataloom_engine.sources import Source
23
25
 
26
+ logger = logging.getLogger(__name__)
27
+
24
28
 
25
29
  class Loom:
26
30
  """
@@ -80,6 +84,8 @@ class Loom:
80
84
  self.weavers: list[Weaver] = []
81
85
  self._stop_lock = threading.Lock()
82
86
  self._stopped = False
87
+ # Distinguishes COMPLETED (source exhausted) from STOPPED (interrupted)
88
+ self._source_exhausted = False
83
89
 
84
90
  def __enter__(self) -> "Loom":
85
91
  return self
@@ -127,6 +133,11 @@ class Loom:
127
133
  if self.stop_event.is_set():
128
134
  break
129
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
130
141
  except Exception as e:
131
142
  self.state = LoomState.FAILED
132
143
  self.hooks.on_error(e)
@@ -146,13 +157,21 @@ class Loom:
146
157
  except queue.Full:
147
158
  continue
148
159
 
149
- def stop(self) -> None:
160
+ def stop(self, timeout: Optional[float] = None) -> None:
150
161
  """
151
162
  Signals every component to stop and waits for cleanup.
152
163
  Safe to call multiple times or from finally blocks.
153
164
 
154
165
  Items already queued are processed before shutdown: each Weaver
155
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.
156
175
  """
157
176
  with self._stop_lock:
158
177
  if self._stopped:
@@ -161,17 +180,42 @@ class Loom:
161
180
 
162
181
  self.stop_event.set()
163
182
 
183
+ deadline = None if timeout is None else time.monotonic() + timeout
184
+
164
185
  # One sentinel per Weaver: each thread drains the queue and exits
165
186
  # upon consuming its own. This replaces the queue join(), which
166
187
  # could block forever if a Weaver died before emptying it.
167
188
  for _ in self.weavers:
168
- self.task_queue.put(STOP_SENTINEL)
169
- for weaver in self.weavers:
170
- weaver.join()
189
+ if not self._put_sentinel(deadline):
190
+ break
171
191
 
172
- # 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.
173
217
  if self.state is LoomState.RUNNING:
174
- self.state = LoomState.COMPLETED
218
+ self.state = LoomState.COMPLETED if self._source_exhausted else LoomState.STOPPED
175
219
 
176
220
  try:
177
221
  self.sink.close()
@@ -180,3 +224,23 @@ class Loom:
180
224
  self.hooks.on_error(e)
181
225
 
182
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
@@ -12,7 +12,7 @@ import queue
12
12
  import threading
13
13
  from abc import ABC, abstractmethod
14
14
  from pathlib import Path
15
- from typing import Any, Callable, Dict, Optional
15
+ from typing import Any, Callable, Dict, Optional, TextIO
16
16
 
17
17
  from dataloom_engine.exceptions import LoomError
18
18
 
@@ -42,6 +42,11 @@ class JsonFileSink(Sink):
42
42
  """
43
43
  Default sink that appends results to a local JSON-lines file.
44
44
  Uses a threading.Lock to keep concurrent writes consistent.
45
+
46
+ The file is opened lazily on the first send() and kept open until
47
+ close(), avoiding an open/close syscall pair per result. Every line
48
+ is flushed, so results are visible to readers immediately. A send()
49
+ after close() transparently reopens the file in append mode.
45
50
  """
46
51
 
47
52
  def __init__(self, output_dir: Path, filename: str = "results.json"):
@@ -50,12 +55,21 @@ class JsonFileSink(Sink):
50
55
  self._path = self.output_dir / filename
51
56
  # The lock ensures only one Weaver writes to the file at a time
52
57
  self._lock = threading.Lock()
58
+ self._file: Optional[TextIO] = None
53
59
 
54
60
  def send(self, result: Dict[str, Any]) -> None:
55
61
  with self._lock:
56
- with open(self._path, "a") as f:
57
- json.dump(result, f)
58
- f.write("\n")
62
+ if self._file is None:
63
+ self._file = open(self._path, "a")
64
+ json.dump(result, self._file)
65
+ self._file.write("\n")
66
+ self._file.flush()
67
+
68
+ def close(self) -> None:
69
+ with self._lock:
70
+ if self._file is not None:
71
+ self._file.close()
72
+ self._file = None
59
73
 
60
74
 
61
75
  class CsvFileSink(Sink):
@@ -66,6 +80,12 @@ class CsvFileSink(Sink):
66
80
  In subsequent results, extra keys are ignored and missing keys are
67
81
  left empty. Uses a threading.Lock to keep concurrent writes
68
82
  consistent.
83
+
84
+ The file is opened lazily on the first send() and kept open until
85
+ close(), avoiding an open/close syscall pair per row. Every row is
86
+ flushed, so results are visible to readers immediately. A send()
87
+ after close() transparently reopens the file in append mode (the
88
+ header is not repeated).
69
89
  """
70
90
 
71
91
  def __init__(self, output_dir: Path, filename: str = "results.csv"):
@@ -74,6 +94,8 @@ class CsvFileSink(Sink):
74
94
  self._path = self.output_dir / filename
75
95
  self._lock = threading.Lock()
76
96
  self._fieldnames: Optional[list] = None
97
+ self._file: Optional[TextIO] = None
98
+ self._writer: Optional[csv.DictWriter] = None
77
99
 
78
100
  def send(self, result: Dict[str, Any]) -> None:
79
101
  with self._lock:
@@ -82,11 +104,22 @@ class CsvFileSink(Sink):
82
104
  if fieldnames is None:
83
105
  fieldnames = list(result.keys())
84
106
  self._fieldnames = fieldnames
85
- with open(self._path, "a", newline="") as f:
86
- writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
87
- if write_header:
88
- writer.writeheader()
89
- writer.writerow(result)
107
+ writer = self._writer
108
+ if self._file is None or writer is None:
109
+ self._file = open(self._path, "a", newline="")
110
+ writer = csv.DictWriter(self._file, fieldnames=fieldnames, extrasaction="ignore")
111
+ self._writer = writer
112
+ if write_header:
113
+ writer.writeheader()
114
+ writer.writerow(result)
115
+ self._file.flush()
116
+
117
+ def close(self) -> None:
118
+ with self._lock:
119
+ if self._file is not None:
120
+ self._file.close()
121
+ self._file = None
122
+ self._writer = None
90
123
 
91
124
 
92
125
  class CallbackSink(Sink):
@@ -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
@@ -61,15 +61,19 @@ class Weaver(threading.Thread):
61
61
  self.task_queue.task_done()
62
62
 
63
63
  def _process_batch(self, batch: Any) -> None:
64
+ stage = "process"
64
65
  try:
65
66
  started = time.monotonic()
66
67
  result = self.processor.process(batch)
68
+ stage = "send"
67
69
  self.sink.send(result)
68
70
  duration = time.monotonic() - started
69
71
  except Exception as exc:
70
- logger.exception("Weaver failed to process a batch; the batch was dropped.")
72
+ logger.exception("Weaver failed to %s a batch; the batch was dropped.", stage)
71
73
  if self.on_error is not None:
72
- error = WeaverError(f"Failed to process batch: {exc}")
74
+ # The failed batch and the stage travel with the error so
75
+ # hooks can retry or quarantine without parsing strings
76
+ error = WeaverError(f"Failed to {stage} batch: {exc}", batch=batch, stage=stage)
73
77
  error.__cause__ = exc
74
78
  try:
75
79
  self.on_error(error)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataloom-engine
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dataloom-engine"
7
- version = "0.4.1"
7
+ version = "0.6.0"
8
8
  description = "DataLoom: a lightweight and efficient thread orchestration engine for data pipelines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -19,6 +19,18 @@ def test_config_coerces_output_dir_to_path():
19
19
  assert isinstance(config.output_dir, Path)
20
20
 
21
21
 
22
+ def test_config_output_dir_is_optional():
23
+ """Pipelines without file-based sinks don't need to invent a directory."""
24
+ config = LoomConfig()
25
+ assert config.output_dir is None
26
+ assert config.batch_size == 10 # remaining defaults still apply
27
+
28
+
29
+ def test_config_validation_still_runs_without_output_dir():
30
+ with pytest.raises(ConfigurationError):
31
+ LoomConfig(batch_size=0)
32
+
33
+
22
34
  def test_config_accepts_float_interval():
23
35
  config = LoomConfig(output_dir=".", interval_seconds=0.25)
24
36
  assert config.interval_seconds == 0.25
@@ -181,7 +181,44 @@ def test_weaver_handles_processor_error():
181
181
  # The valid items were processed despite the error in the middle
182
182
  assert sorted(r["sum"] for r in mock_sink.results) == [1.0, 3.0]
183
183
 
184
- # The error was typed and reported
184
+ # The error was typed and reported, with full failure context
185
185
  assert len(errors) == 1
186
186
  assert isinstance(errors[0], WeaverError)
187
187
  assert isinstance(errors[0].__cause__, ValueError)
188
+ assert errors[0].stage == "process"
189
+ assert list(errors[0].batch) == [2] # the exact batch that failed
190
+
191
+
192
+ def test_weaver_reports_sink_failure_with_send_stage():
193
+ """A Sink failure is reported as WeaverError with stage='send' and the original batch."""
194
+
195
+ class BrokenSink(Sink):
196
+ def send(self, result):
197
+ raise IOError("disk full!")
198
+
199
+ task_queue = queue.Queue()
200
+ errors = []
201
+ errors_lock = threading.Lock()
202
+
203
+ def on_error(exc):
204
+ with errors_lock:
205
+ errors.append(exc)
206
+
207
+ task_queue.put(np.array([7]))
208
+ task_queue.put(STOP_SENTINEL)
209
+
210
+ weaver = Weaver(
211
+ task_queue=task_queue,
212
+ processor=SimpleProcessor(),
213
+ sink=BrokenSink(),
214
+ on_error=on_error,
215
+ )
216
+ weaver.start()
217
+ weaver.join(timeout=2)
218
+ assert not weaver.is_alive()
219
+
220
+ assert len(errors) == 1
221
+ assert isinstance(errors[0], WeaverError)
222
+ assert errors[0].stage == "send"
223
+ assert list(errors[0].batch) == [7]
224
+ assert isinstance(errors[0].__cause__, IOError)
@@ -1,6 +1,7 @@
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
@@ -275,6 +276,105 @@ def test_loom_survives_broken_metrics_hook():
275
276
  assert loom.state is LoomState.COMPLETED
276
277
 
277
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
+
278
378
  @pytest.mark.parametrize("num_weavers", [0, -1])
279
379
  def test_loom_rejects_non_positive_num_weavers(num_weavers):
280
380
  """
@@ -68,7 +68,8 @@ def test_pipeline_runs_without_numpy():
68
68
  self.results.append(result)
69
69
 
70
70
  sink = CollectSink()
71
- config = LoomConfig(output_dir=".", batch_size=1, interval_seconds=0)
71
+ # No file sink involved: output_dir can simply be omitted
72
+ config = LoomConfig(batch_size=1, interval_seconds=0)
72
73
  with Loom(config=config, processor=SumProcessor(), sink=sink, source=ListSource()) as loom:
73
74
  loom.start()
74
75
 
@@ -1,12 +1,19 @@
1
1
  # tests/test_sinks.py
2
2
 
3
3
  import csv
4
+ import json
4
5
  import threading
5
6
 
6
7
  import pytest
7
8
 
8
9
  from dataloom_engine.exceptions import LoomError
9
- from dataloom_engine.sinks import CallbackSink, CsvFileSink, Sink, ThreadedBufferedSink
10
+ from dataloom_engine.sinks import (
11
+ CallbackSink,
12
+ CsvFileSink,
13
+ JsonFileSink,
14
+ Sink,
15
+ ThreadedBufferedSink,
16
+ )
10
17
 
11
18
 
12
19
  class MockSink(Sink):
@@ -148,6 +155,51 @@ def test_csv_sink_concurrent_writes(tmp_path):
148
155
  assert ids == list(range(100))
149
156
 
150
157
 
158
+ def test_file_sinks_flush_results_before_close(tmp_path):
159
+ """With persistent handles, every result must still be readable immediately."""
160
+ json_sink = JsonFileSink(output_dir=tmp_path)
161
+ json_sink.send({"id": 1})
162
+ # No close() yet: the line must already be on disk
163
+ assert json.loads((tmp_path / "results.json").read_text()) == {"id": 1}
164
+
165
+ csv_sink = CsvFileSink(output_dir=tmp_path)
166
+ csv_sink.send({"id": 1})
167
+ with open(tmp_path / "results.csv", newline="") as f:
168
+ assert list(csv.DictReader(f)) == [{"id": "1"}]
169
+
170
+ json_sink.close()
171
+ csv_sink.close()
172
+
173
+
174
+ def test_file_sinks_reopen_after_close(tmp_path):
175
+ """send() after close() appends transparently; the CSV header is not repeated."""
176
+ sink = CsvFileSink(output_dir=tmp_path)
177
+ sink.send({"id": 1})
178
+ sink.close()
179
+ sink.send({"id": 2})
180
+ sink.close()
181
+
182
+ lines = (tmp_path / "results.csv").read_text().strip().splitlines()
183
+ assert lines == ["id", "1", "2"] # a single header, both rows
184
+
185
+ json_sink = JsonFileSink(output_dir=tmp_path)
186
+ json_sink.send({"id": 1})
187
+ json_sink.close()
188
+ json_sink.send({"id": 2})
189
+ json_sink.close()
190
+
191
+ rows = [json.loads(line) for line in (tmp_path / "results.json").read_text().splitlines()]
192
+ assert rows == [{"id": 1}, {"id": 2}]
193
+
194
+
195
+ def test_file_sink_close_before_any_send_is_noop(tmp_path):
196
+ """close() without any send() must not fail nor create files."""
197
+ JsonFileSink(output_dir=tmp_path, filename="a.json").close()
198
+ CsvFileSink(output_dir=tmp_path, filename="a.csv").close()
199
+ assert not (tmp_path / "a.json").exists()
200
+ assert not (tmp_path / "a.csv").exists()
201
+
202
+
151
203
  def test_callback_sink_delegates_send_and_close():
152
204
  received = []
153
205
  closed = []
@@ -1,25 +0,0 @@
1
- # dataloom_engine/exceptions.py
2
-
3
- """
4
- Custom exceptions for DataLoom.
5
- Lets consumers catch library-specific errors without relying on
6
- generic Python exceptions.
7
- """
8
-
9
-
10
- class LoomError(Exception):
11
- """Base exception for every DataLoom error."""
12
-
13
- pass
14
-
15
-
16
- class ConfigurationError(LoomError):
17
- """Raised when LoomConfig validation fails."""
18
-
19
- pass
20
-
21
-
22
- class WeaverError(LoomError):
23
- """Raised when a Weaver fails to process a batch."""
24
-
25
- pass
File without changes