pipeline-toolkit 0.1.0__py3-none-any.whl

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.
pipeline/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """A small functional pipeline toolkit for Python.
2
+
3
+ This package provides a simple asynchronous pipeline executor along with supporting container and utility components.
4
+
5
+ The main components are:
6
+
7
+ - :class:`Pipeline`: Execute callable steps sequentially in a worker thread.
8
+ - :class:`Stack`: A simple LIFO container for storing values and errors.
9
+ - :func:`tap`: Apply a side effect to a deep copy of a value while returning the original value unchanged.
10
+
11
+ Example:
12
+ >>> from pipeline import Pipeline
13
+ >>> pipeline = Pipeline(
14
+ ... (lambda value: value + 1,),
15
+ ... (lambda value: value * 2,),
16
+ ... )
17
+ >>> pipeline.run(5).wait()
18
+ >>> pipeline.results.get()
19
+ 12
20
+ """
21
+ from .pipeline import Pipeline
22
+ __version__ = "0.1.0"
23
+ __author__ = "Hoàng Long"
24
+ __all__ = ["__version__", "__author__", "Pipeline"]
pipeline/pipeline.py ADDED
@@ -0,0 +1,317 @@
1
+ from .stack import Stack
2
+ from collections.abc import Mapping
3
+ import threading
4
+ class Pipeline:
5
+ """A simple asynchronous pipeline for executing callable steps sequentially.
6
+
7
+ Each step is represented by a tuple containing a callable and optional positional and keyword arguments:
8
+
9
+ (function,)
10
+ (function, args)
11
+ (function, kwargs)
12
+ (function, args, kwargs)
13
+
14
+ Where ``args`` must be a tuple or mapping, and ``kwargs`` must be a mapping.
15
+ The pipeline always passes the result of the previous step as the first positional argument to the next step.
16
+ The pipeline is snapshotted when execution starts, so modifications to the pipeline after ``run()`` has started do not affect the current execution.
17
+
18
+ Args:
19
+ iterable: An iterable of pipeline steps. Defaults to an empty pipeline.
20
+
21
+ Attributes:
22
+ stop_event: Event used to request that the worker stop execution.
23
+ skip_event: Event used to request that the next step be skipped.
24
+ pipeline: List containing the configured pipeline steps.
25
+ thread: The worker thread for the current execution, or ``None`` when no execution is active.
26
+ step: The one-based index of the currently executing step, or ``0`` when the pipeline is not running.
27
+ results: Stack containing the initial value and results produced by executed steps.
28
+ errors: Stack containing exceptions raised by executed steps.
29
+
30
+ Example:
31
+ >>> def add(value, amount):
32
+ ... return value + amount
33
+
34
+ >>> pipeline = Pipeline([
35
+ ... (add, (5,)),
36
+ ... (add, (10,)),
37
+ ... ])
38
+ >>> pipeline.run(0)
39
+ >>> pipeline.wait()
40
+ >>> pipeline.results.get()
41
+ 15
42
+ """
43
+ def __init__(self, iterable=()):
44
+ """Initialize a pipeline.
45
+
46
+ Args:
47
+ iterable: An iterable containing pipeline steps.
48
+
49
+ Raises:
50
+ TypeError: If any item in ``iterable`` is not a valid step.
51
+ """
52
+ iterable = tuple(iterable)
53
+ self.stop_event = threading.Event()
54
+ self.skip_event = threading.Event()
55
+ self.pipeline = []
56
+ self.thread = None
57
+ self.step = 0
58
+ self.results = Stack()
59
+ self.errors = Stack()
60
+ for step in iterable:
61
+ self._validate_step(step)
62
+ self.pipeline.append(step)
63
+ def _validate_step(self, step):
64
+ if not step or not isinstance(step, tuple):
65
+ raise TypeError("Invalid step format.")
66
+ if not callable(step[0]):
67
+ raise TypeError("Invalid step format.")
68
+ if len(step) > 3:
69
+ raise TypeError("Invalid step format.")
70
+ if len(step) >= 2 and not isinstance(step[1], (tuple, Mapping)):
71
+ raise TypeError("Invalid step format.")
72
+ if len(step) == 3 and not isinstance(step[1], tuple):
73
+ raise TypeError("Invalid step format.")
74
+ if len(step) == 3 and not isinstance(step[2], Mapping):
75
+ raise TypeError("Invalid step format.")
76
+ def _execute_step(self, step, default):
77
+ if len(step) == 1:
78
+ return step[0](default)
79
+ if isinstance(step[1], tuple):
80
+ return step[0](default, *step[1], **step[2]) if len(step) > 2 else step[0](default, *step[1])
81
+ return step[0](default, **step[1])
82
+ def run(self, default=None, delay=0, daemon=False, stop_on_error=True):
83
+ """Run the pipeline asynchronously in a worker thread.
84
+
85
+ The supplied ``default`` value is stored as the initial result.
86
+ Each step receives the result of the preceding step as its first positional argument.
87
+
88
+ The pipeline is snapshotted when execution starts, so modifications to ``self.pipeline`` do not affect the current execution.
89
+ Execution stops when all steps have completed, ``stop()`` is called, or an exception is raised while ``stop_on_error`` is enabled.
90
+
91
+ Args:
92
+ default: Initial value passed to the first step.
93
+ Defaults to ``None``.
94
+ delay: Delay in seconds between steps. Must be a non-negative integer or floating-point number.
95
+ Defaults to ``0``.
96
+ daemon: Whether the worker thread should be a daemon thread.
97
+ Defaults to ``False``.
98
+ stop_on_error: Whether execution should stop after the first exception.
99
+ Defaults to ``True``.
100
+
101
+ Returns:
102
+ This pipeline instance.
103
+
104
+ Raises:
105
+ TypeError: If ``delay`` is not an integer or floating-point number.
106
+ ValueError: If ``delay`` is negative.
107
+ RuntimeError: If the pipeline is already running.
108
+ """
109
+ if not isinstance(delay, (int, float)):
110
+ raise TypeError("Delay is not int or float.")
111
+ if delay < 0:
112
+ raise ValueError("Delay cannot be negative.")
113
+ if self.running:
114
+ raise RuntimeError("Pipeline is already running.")
115
+ self.stop_event.clear()
116
+ self.skip_event.clear()
117
+ pipeline = tuple(self.pipeline)
118
+ self.step = 0
119
+ self.results.clear()
120
+ self.results.push(default)
121
+ self.errors.clear()
122
+ def worker():
123
+ for index, step in enumerate(pipeline):
124
+ if self.stop_event.is_set():
125
+ break
126
+ self.step = index + 1
127
+ if self.skip_event.is_set():
128
+ self.skip_event.clear()
129
+ continue
130
+ try:
131
+ self.results.push(self._execute_step(step, self.results.get()))
132
+ except Exception as error:
133
+ self.errors.push(error)
134
+ if stop_on_error:
135
+ break
136
+ if delay:
137
+ self.stop_event.wait(delay)
138
+ if not self.stop_event.is_set():
139
+ self.step = 0
140
+ self.thread = None
141
+ self.thread = threading.Thread(target=worker, daemon=daemon)
142
+ self.thread.start()
143
+ return self
144
+ def run_step(self, step, default):
145
+ """Execute a single pipeline step synchronously.
146
+
147
+ This method does not create or modify the worker thread and does not store the result or exception in the pipeline's ``results`` or ``errors`` stacks.
148
+ Exceptions are allowed to propagate to the caller.
149
+
150
+ Args:
151
+ step: One-based index of the pipeline step to execute.
152
+ default: Value passed to the selected step as its first argument.
153
+
154
+ Returns:
155
+ The value returned by the selected step.
156
+
157
+ Raises:
158
+ TypeError: If ``step`` is not an integer.
159
+ ValueError: If ``step`` is less than ``1``.
160
+ IndexError: If ``step`` is outside the pipeline.
161
+ RuntimeError: If the pipeline is currently running.
162
+ """
163
+ if not isinstance(step, int):
164
+ raise TypeError("Step is not int.")
165
+ if step < 1:
166
+ raise ValueError("Step < 1.")
167
+ if not 1 <= step <= len(self.pipeline):
168
+ raise IndexError("Step out of pipeline.")
169
+ if self.running:
170
+ raise RuntimeError("Pipeline is already running.")
171
+ step = self.pipeline[step - 1]
172
+ return self._execute_step(step, default)
173
+ def stop(self):
174
+ """Request the running pipeline to stop and wait for termination.
175
+
176
+ Returns:
177
+ The one-based index of the step at which execution was stopped, or ``0`` if the pipeline was not running.
178
+ """
179
+ if self.thread is not None and self.running:
180
+ self.stop_event.set()
181
+ self.thread.join()
182
+ step = self.step
183
+ self.step = 0
184
+ return step
185
+ return 0
186
+ def skip(self):
187
+ """Request the currently running pipeline to skip its next step.
188
+
189
+ If the worker has not yet checked the skip event, the next step that reaches the worker's skip check will be skipped.
190
+
191
+ Returns:
192
+ This pipeline instance.
193
+ """
194
+ if self.thread is not None and self.running:
195
+ self.skip_event.set()
196
+ return self
197
+ def wait(self):
198
+ """Wait until the currently running pipeline finishes.
199
+
200
+ This method blocks only when the pipeline is running.
201
+
202
+ Returns:
203
+ This pipeline instance.
204
+ """
205
+ if self.thread is not None and self.running:
206
+ self.thread.join()
207
+ return self
208
+ def rerun(self, *args, **kwargs):
209
+ """Stop the current execution and start the pipeline again.
210
+
211
+ All arguments are passed directly to :meth:`run`.
212
+
213
+ Returns:
214
+ This pipeline instance.
215
+ """
216
+ self.stop()
217
+ self.run(*args, **kwargs)
218
+ return self
219
+ @property
220
+ def running(self):
221
+ """Whether the pipeline currently has a running worker thread."""
222
+ return self.thread is not None and self.thread.is_alive()
223
+ def add(self, step):
224
+ """Append a validated step to the pipeline.
225
+
226
+ Args:
227
+ step: The step to append.
228
+
229
+ Raises:
230
+ TypeError: If ``step`` has an invalid format.
231
+ """
232
+ self._validate_step(step)
233
+ self.pipeline.append(step)
234
+ def insert(self, index, step):
235
+ """Insert a validated step at the specified index.
236
+
237
+ Args:
238
+ index: One-based position at which to insert the step.
239
+ step: The step to insert.
240
+
241
+ Raises:
242
+ TypeError: If ``index`` is not an integer or ``step`` is invalid.
243
+ IndexError: If ``index`` is outside the pipeline.
244
+ """
245
+ if not isinstance(index, int):
246
+ raise TypeError(f"{index} is not int.")
247
+ if not 1 < index <= len(self.pipeline) + 1:
248
+ raise IndexError("Index out of range.")
249
+ self._validate_step(step)
250
+ self.pipeline.insert(index - 1, step)
251
+ def pop(self, index):
252
+ """Remove and return the step at the specified index.
253
+
254
+ Args:
255
+ index: One-based index of the step to remove.
256
+
257
+ Returns:
258
+ The removed pipeline step.
259
+
260
+ Raises:
261
+ TypeError: If ``index`` is not an integer.
262
+ IndexError: If ``index`` is outside the pipeline.
263
+ """
264
+ if not isinstance(index, int):
265
+ raise TypeError(f"{index} is not int.")
266
+ if not 1 <= index <= len(self.pipeline):
267
+ raise IndexError("Index out of range.")
268
+ return self.pipeline.pop(index - 1)
269
+ def clear(self):
270
+ """Remove all steps from the pipeline."""
271
+ self.pipeline.clear()
272
+ def _format_step(self, step):
273
+ func = step[0]
274
+ if len(step) >= 2:
275
+ args = step[1]
276
+ else:
277
+ args = ()
278
+ if isinstance(args, tuple):
279
+ parts = [repr(arg) for arg in args]
280
+ if len(step) == 3:
281
+ parts.extend(f"{key}={value!r}" for key, value in step[2].items())
282
+ else:
283
+ parts = [f"{key}={value!r}" for key, value in args.items()]
284
+ return f"{func.__name__}({', '.join(parts)})"
285
+ def __str__(self):
286
+ """Return a human-readable representation of the pipeline."""
287
+ return " | ".join(self._format_step(step) for step in self.pipeline)
288
+ def __repr__(self):
289
+ """Return the developer-oriented representation of the pipeline."""
290
+ return f"{type(self).__name__}(total_steps={len(self.pipeline)}, current_step={self.step}, running={self.running})"
291
+ def __bool__(self):
292
+ """Return whether the pipeline is currently running."""
293
+ return self.running
294
+ def __call__(self, *args, **kwargs):
295
+ """Run the pipeline.
296
+
297
+ Arguments are passed directly to :meth:`run`.
298
+
299
+ Returns:
300
+ This pipeline instance.
301
+ """
302
+ return self.run(*args, **kwargs)
303
+ def __contains__(self, item):
304
+ """Return whether a callable exists as a pipeline step.
305
+
306
+ Identity comparison is used, so the callable must be the exact same object as the callable stored in the step.
307
+
308
+ Args:
309
+ item: Callable to search for.
310
+
311
+ Returns:
312
+ ``True`` if the callable is present, otherwise ``False``.
313
+ """
314
+ return any(step[0] is item for step in self.pipeline)
315
+ def __len__(self):
316
+ """Return the number of configured pipeline steps."""
317
+ return len(self.pipeline)
pipeline/stack.py ADDED
@@ -0,0 +1,132 @@
1
+ class StackOverflowError(Exception):
2
+ """Raised when attempting to push an item onto a full stack."""
3
+ pass
4
+ class StackUnderflowError(Exception):
5
+ """Raised when attempting to access or remove an item from an empty stack."""
6
+ pass
7
+ class Stack:
8
+ """A simple LIFO stack container with optional maximum capacity.
9
+
10
+ The stack follows the Last-In, First-Out (LIFO) principle. The most recently pushed item is returned by :meth:`get` or removed by :meth:`pop`.
11
+
12
+ A maximum size can optionally be specified.
13
+ A ``maxsize`` of ``0`` means that the stack has no size limit.
14
+
15
+ Args:
16
+ maxsize: Maximum number of items allowed in the stack.
17
+ Defaults to ``0``, meaning unlimited capacity.
18
+
19
+ Raises:
20
+ TypeError: If ``maxsize`` is not an integer.
21
+ ValueError: If ``maxsize`` is negative.
22
+
23
+ Example:
24
+ >>> stack = Stack(3)
25
+ >>> stack.push("first")
26
+ >>> stack.push("second")
27
+ >>> stack.get()
28
+ 'second'
29
+ >>> stack.pop()
30
+ 'second'
31
+ >>> len(stack)
32
+ 1
33
+ """
34
+ def __init__(self, maxsize=0):
35
+ """Initialize an empty stack.
36
+
37
+ Args:
38
+ maxsize: Maximum number of items allowed in the stack.
39
+ ``0`` means unlimited capacity.
40
+
41
+ Raises:
42
+ TypeError: If ``maxsize`` is not an integer.
43
+ ValueError: If ``maxsize`` is negative.
44
+ """
45
+ if not isinstance(maxsize, int):
46
+ raise TypeError("maxsize is not int.")
47
+ if maxsize < 0:
48
+ raise ValueError("maxsize < 0.")
49
+ self._maxsize = maxsize
50
+ self._stack = []
51
+ def push(self, value):
52
+ """Push an item onto the top of the stack.
53
+
54
+ Args:
55
+ value: Value to push onto the stack.
56
+
57
+ Raises:
58
+ StackOverflowError: If the stack has reached its maximum capacity.
59
+ """
60
+ if self._maxsize and len(self._stack) >= self._maxsize:
61
+ raise StackOverflowError("stack overflow")
62
+ self._stack.append(value)
63
+ def get(self):
64
+ """Return the item at the top of the stack without removing it.
65
+
66
+ Returns:
67
+ The most recently pushed item.
68
+
69
+ Raises:
70
+ StackUnderflowError: If the stack is empty.
71
+ """
72
+ if not self._stack:
73
+ raise StackUnderflowError("stack underflow")
74
+ return self._stack[-1]
75
+ def pop(self):
76
+ """Remove and return the item at the top of the stack.
77
+
78
+ Returns:
79
+ The most recently pushed item.
80
+
81
+ Raises:
82
+ StackUnderflowError: If the stack is empty.
83
+ """
84
+ if not self._stack:
85
+ raise StackUnderflowError("stack underflow")
86
+ return self._stack.pop()
87
+ def clear(self):
88
+ """Remove all items from the stack."""
89
+ self._stack.clear()
90
+ def empty(self):
91
+ """Return whether the stack is empty.
92
+
93
+ Returns:
94
+ ``True`` if the stack contains no items, otherwise ``False``.
95
+ """
96
+ return len(self._stack) == 0
97
+ def full(self):
98
+ """Return whether the stack has reached its maximum capacity.
99
+
100
+ An unlimited stack is never considered full.
101
+
102
+ Returns:
103
+ ``True`` if the stack is full, otherwise ``False``.
104
+ """
105
+ return bool(self._maxsize and len(self._stack) >= self._maxsize)
106
+ def __len__(self):
107
+ """Return the number of items currently in the stack."""
108
+ return len(self._stack)
109
+ def __bool__(self):
110
+ """Return whether the stack contains at least one item."""
111
+ return bool(self._stack)
112
+ def __contains__(self, value):
113
+ """Return whether a value exists in the stack.
114
+
115
+ Args:
116
+ value: Value to search for.
117
+
118
+ Returns:
119
+ ``True`` if ``value`` is present, otherwise ``False``.
120
+ """
121
+ return value in self._stack
122
+ def __iter__(self):
123
+ """Iterate over the items in the stack from bottom to top.
124
+
125
+ Returns:
126
+ An iterator over the stack's items.
127
+ """
128
+ for item in self._stack:
129
+ yield item
130
+ def __repr__(self):
131
+ """Return the developer-oriented representation of the stack."""
132
+ return f"{type(self).__name__}({self._stack!r}, maxsize={self._maxsize})"
pipeline/tap.py ADDED
@@ -0,0 +1,43 @@
1
+ import copy
2
+ def tap(value, function, *args, **kwargs):
3
+ """Apply a function to a deep copy of a value and return the original value.
4
+
5
+ The function is called for its side effect.
6
+ A deep copy of ``value`` is passed to the function, so modifications made by the function do not affect the original value.
7
+
8
+ The return value of ``function`` is ignored.
9
+ This makes ``tap`` useful for inspecting, logging, debugging, or otherwise processing a value without interrupting a chain of operations.
10
+
11
+ Args:
12
+ value: Value to pass to ``function`` and return unchanged.
13
+ function: Callable to invoke with a deep copy of ``value``.
14
+ *args: Additional positional arguments passed to ``function``.
15
+ **kwargs: Additional keyword arguments passed to ``function``.
16
+
17
+ Returns:
18
+ The original ``value`` object.
19
+
20
+ Raises:
21
+ TypeError: If ``function`` is not callable.
22
+ Any exception raised by ``copy.deepcopy`` or ``function`` is propagated to the caller.
23
+
24
+ Example:
25
+ >>> data = {"items": [1, 2, 3]}
26
+ >>> tap(data, print)
27
+ {'items': [1, 2, 3]}
28
+
29
+ A function can modify its argument without modifying the original:
30
+
31
+ >>> def inspect(data):
32
+ ... data["items"].append(4)
33
+ >>> original = {"items": [1, 2, 3]}
34
+ >>> result = tap(original, inspect)
35
+ >>> result is original
36
+ True
37
+ >>> original
38
+ {'items': [1, 2, 3]}
39
+ """
40
+ if not callable(function):
41
+ raise TypeError("function is not callable.")
42
+ function(copy.deepcopy(value), *args, **kwargs)
43
+ return value
@@ -0,0 +1,477 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipeline-toolkit
3
+ Version: 0.1.0
4
+ Summary: A small functional pipeline toolkit for Python.
5
+ Author-email: Hoàng Long <hoanglongcodes@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Hoang-Long2012/pipeline-toolkit
8
+ Project-URL: Repository, https://github.com/Hoang-Long2012/pipeline-toolkit
9
+ Project-URL: Issues, https://github.com/Hoang-Long2012/pipeline-toolkit/issues
10
+ Project-URL: Changelog, https://github.com/Hoang-Long2012/pipeline-toolkit/blob/main/CHANGELOG.md
11
+ Keywords: pipeline,functional programming,functional pipeline,workflow,async,asynchronous,threading,utilities
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Python: >=3.8
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Dynamic: license-file
30
+
31
+ # Pipeline Toolkit
32
+
33
+ A small functional pipeline toolkit for Python.
34
+
35
+ `pipeline-toolkit` provides a simple way to build sequential pipelines from ordinary Python callables. Each step receives the result of the previous step, while the pipeline runs asynchronously in a worker thread.
36
+
37
+ ## Features
38
+
39
+ - Sequential functional pipeline execution
40
+ - Asynchronous execution using a worker thread
41
+ - Positional and keyword arguments for pipeline steps
42
+ - Stop, skip, wait, and rerun execution
43
+ - Manual synchronous step execution
44
+ - Result and error history using a stack
45
+ - Pipeline modification with `add()`, `insert()`, `pop()`, and `clear()`
46
+ - Small utility modules for functional workflows
47
+
48
+ ## Installation
49
+
50
+ Install from PyPI:
51
+
52
+ ```bash
53
+ pip install pipeline-toolkit
54
+ ```
55
+
56
+ Or install directly from GitHub:
57
+
58
+ ```bash
59
+ pip install git+https://github.com/Hoang-Long2012/pipeline-toolkit.git
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ A pipeline is created from an iterable of steps. Each step is a tuple whose first item is a callable.
65
+
66
+ ```python
67
+ from pipeline import Pipeline
68
+
69
+ def add(value, amount):
70
+ return value + amount
71
+
72
+ def multiply(value, factor):
73
+ return value * factor
74
+
75
+ pipeline = Pipeline([
76
+ (add, (5,)),
77
+ (multiply, (2,)),
78
+ ])
79
+
80
+ pipeline.run(10).wait()
81
+
82
+ print(pipeline.results.get())
83
+ ```
84
+
85
+ The execution flow is:
86
+
87
+ ```text
88
+ 10
89
+ ↓
90
+ add(10, 5)
91
+ ↓
92
+ 15
93
+ ↓
94
+ multiply(15, 2)
95
+ ↓
96
+ 30
97
+ ```
98
+
99
+ The final result is `30`.
100
+
101
+ ## Pipeline Steps
102
+
103
+ Each step can use one of four supported forms.
104
+
105
+ ### Callable only
106
+
107
+ ```python
108
+ (function,)
109
+ ```
110
+
111
+ The callable receives the previous result:
112
+
113
+ ```python
114
+ pipeline = Pipeline([
115
+ (str.upper,),
116
+ ])
117
+
118
+ pipeline.run("hello").wait()
119
+ ```
120
+
121
+ ### Positional arguments
122
+
123
+ ```python
124
+ (function, args)
125
+ ```
126
+
127
+ where `args` is a tuple:
128
+
129
+ ```python
130
+ pipeline = Pipeline([
131
+ (add, (5,)),
132
+ (multiply, (2,)),
133
+ ])
134
+ ```
135
+
136
+ A step such as:
137
+
138
+ ```python
139
+ (add, (5,))
140
+ ```
141
+
142
+ is executed as:
143
+
144
+ ```python
145
+ add(previous_result, 5)
146
+ ```
147
+
148
+ ### Keyword arguments
149
+
150
+ ```python
151
+ (function, kwargs)
152
+ ```
153
+
154
+ where `kwargs` is a mapping:
155
+
156
+ ```python
157
+ pipeline = Pipeline([
158
+ (pow, {"exp": 2}),
159
+ ])
160
+ ```
161
+
162
+ The step is executed as:
163
+
164
+ ```python
165
+ pow(previous_result, exp=2)
166
+ ```
167
+
168
+ ### Positional and keyword arguments
169
+
170
+ ```python
171
+ (function, args, kwargs)
172
+ ```
173
+
174
+ For example:
175
+
176
+ ```python
177
+ pipeline = Pipeline([
178
+ (my_function, (1, 2), {"option": True}),
179
+ ])
180
+ ```
181
+
182
+ The callable receives the previous result followed by the supplied positional and keyword arguments.
183
+
184
+ ## Execution
185
+
186
+ ### `run()`
187
+
188
+ Start the pipeline asynchronously.
189
+
190
+ ```python
191
+ pipeline.run(default=None, delay=0, daemon=False, stop_on_error=True)
192
+ ```
193
+
194
+ The `default` value becomes the initial result and is passed to the first step.
195
+
196
+ `delay` specifies the delay between steps.
197
+
198
+ `daemon` controls whether the worker thread is a daemon thread.
199
+
200
+ `stop_on_error` controls whether execution stops after the first exception.
201
+
202
+ `run()` returns the pipeline instance, allowing calls such as:
203
+
204
+ ```python
205
+ pipeline.run(10).wait()
206
+ ```
207
+
208
+ The pipeline is snapshotted when execution starts. Changes made to `pipeline.pipeline` after `run()` begins do not affect the current execution.
209
+
210
+ ### `wait()`
211
+
212
+ Wait for the current execution to finish.
213
+
214
+ ```python
215
+ pipeline.wait()
216
+ ```
217
+
218
+ It returns the pipeline instance.
219
+
220
+ ### `stop()`
221
+
222
+ Request the running pipeline to stop and wait for its worker thread to terminate.
223
+
224
+ ```python
225
+ step = pipeline.stop()
226
+ ```
227
+
228
+ The return value is the current one-based step index when execution is stopped, or `0` if the pipeline was not running.
229
+
230
+ ### `skip()`
231
+
232
+ Request the worker to skip the next step that reaches its skip check.
233
+
234
+ ```python
235
+ pipeline.skip()
236
+ ```
237
+
238
+ The method returns the pipeline instance.
239
+
240
+ ### `rerun()`
241
+
242
+ Stop the current execution and start the pipeline again.
243
+
244
+ ```python
245
+ pipeline.rerun(10)
246
+ ```
247
+
248
+ Arguments are passed directly to `run()`.
249
+
250
+ ## Manual Step Execution
251
+
252
+ `run_step()` executes one configured step synchronously.
253
+
254
+ ```python
255
+ result = pipeline.run_step(2, 10)
256
+ ```
257
+
258
+ Unlike `run()`, this method:
259
+
260
+ - does not create a worker thread
261
+ - does not modify the worker thread or pipeline execution state
262
+ - does not store the result in `results`
263
+ - does not store exceptions in `errors`
264
+ - allows exceptions to propagate to the caller
265
+
266
+ This makes it useful when a single pipeline step needs to be executed manually.
267
+
268
+ ## Results and Errors
269
+
270
+ The pipeline provides two `Stack` instances:
271
+
272
+ ```python
273
+ pipeline.results
274
+ pipeline.errors
275
+ ```
276
+
277
+ `results` contains the initial value and the results produced by executed steps.
278
+
279
+ For example:
280
+
281
+ ```python
282
+ pipeline.run(10).wait()
283
+
284
+ print(pipeline.results.get())
285
+ ```
286
+
287
+ `errors` contains exceptions raised by pipeline steps.
288
+
289
+ When `stop_on_error=True`, execution stops after the first exception.
290
+
291
+ When `stop_on_error=False`, the exception is stored in `errors` and execution continues with the previous result.
292
+
293
+ ## Managing Pipeline Steps
294
+
295
+ Pipeline steps can be modified before or between executions.
296
+
297
+ ### `add()`
298
+
299
+ Append a step:
300
+
301
+ ```python
302
+ pipeline.add((str.upper,))
303
+ ```
304
+
305
+ ### `insert()`
306
+
307
+ Insert a step at a one-based position:
308
+
309
+ ```python
310
+ pipeline.insert(2, (str.strip,))
311
+ ```
312
+
313
+ ### `pop()`
314
+
315
+ Remove and return a step:
316
+
317
+ ```python
318
+ step = pipeline.pop(1)
319
+ ```
320
+
321
+ Pipeline indexes are one-based.
322
+
323
+ ### `clear()`
324
+
325
+ Remove all configured steps:
326
+
327
+ ```python
328
+ pipeline.clear()
329
+ ```
330
+
331
+ ## Pipeline State
332
+
333
+ The `running` property indicates whether the worker thread is currently running:
334
+
335
+ ```python
336
+ if pipeline.running:
337
+ print("Pipeline is running")
338
+ ```
339
+
340
+ The `step` attribute contains the one-based index of the currently executing step. It is `0` when the pipeline is not running.
341
+
342
+ A `Pipeline` instance can also be used as a boolean:
343
+
344
+ ```python
345
+ if pipeline:
346
+ print("Pipeline is running")
347
+ ```
348
+
349
+ Calling a pipeline instance is equivalent to calling `run()`:
350
+
351
+ ```python
352
+ pipeline(10)
353
+ ```
354
+
355
+ is equivalent to:
356
+
357
+ ```python
358
+ pipeline.run(10)
359
+ ```
360
+
361
+ The length of a pipeline is the number of configured steps:
362
+
363
+ ```python
364
+ len(pipeline)
365
+ ```
366
+
367
+ A callable can be checked with the `in` operator:
368
+
369
+ ```python
370
+ if add in pipeline:
371
+ print("add is part of the pipeline")
372
+ ```
373
+
374
+ Callable membership uses identity comparison.
375
+
376
+ ## Utilities
377
+
378
+ ### `Stack`
379
+
380
+ `Stack` is a simple LIFO stack container with optional capacity limits.
381
+
382
+ It supports common stack operations such as pushing, retrieving, peeking, and removing items, with dedicated exceptions for overflow and underflow conditions.
383
+
384
+ Import it directly from its submodule:
385
+
386
+ ```python
387
+ from pipeline.stack import Stack
388
+ ```
389
+
390
+ `Stack` is also used internally by `Pipeline` for storing results and errors.
391
+
392
+ For example:
393
+
394
+ ```python
395
+ from pipeline.stack import Stack
396
+
397
+ stack = Stack()
398
+
399
+ stack.push("first")
400
+ stack.push("second")
401
+
402
+ print(stack.get())
403
+ ```
404
+
405
+ For detailed stack operations and behavior, see the `pipeline.stack` module.
406
+
407
+ ### `tap`
408
+
409
+ `tap` is a small functional utility for performing a side effect while keeping the pipeline value available for subsequent processing.
410
+
411
+ `tap` performs a side effect on a deep copy of the current value and returns the original value unchanged.
412
+
413
+ Import it directly from its submodule:
414
+
415
+ ```python
416
+ from pipeline.tap import tap
417
+ ```
418
+
419
+ For example:
420
+
421
+ ```python
422
+ from pipeline import Pipeline
423
+ from pipeline.tap import tap
424
+
425
+ def add(value, amount):
426
+ return value + amount
427
+
428
+ pipeline = Pipeline([
429
+ (add, (5,)),
430
+ (tap, (print,)),
431
+ (add, (10,)),
432
+ ])
433
+
434
+ pipeline.run(10).wait()
435
+ ```
436
+
437
+ Utilities are provided as separate submodules rather than being exported from the top-level `pipeline` package.
438
+
439
+ ## API Overview
440
+
441
+ ### `Pipeline`
442
+
443
+ | Member | Description |
444
+ | ------------ | ------------------------------------- |
445
+ | `run()` | Start asynchronous pipeline execution |
446
+ | `run_step()` | Execute one step synchronously |
447
+ | `stop()` | Stop the current execution |
448
+ | `skip()` | Request the next step to be skipped |
449
+ | `wait()` | Wait for the current execution |
450
+ | `rerun()` | Restart the pipeline |
451
+ | `add()` | Append a step |
452
+ | `insert()` | Insert a step |
453
+ | `pop()` | Remove and return a step |
454
+ | `clear()` | Remove all steps |
455
+ | `running` | Whether the worker is running |
456
+ | `step` | Current one-based step index |
457
+ | `results` | Stack of initial value and results |
458
+ | `errors` | Stack of raised exceptions |
459
+
460
+ ## Requirements
461
+
462
+ * Python 3.8 or newer
463
+
464
+ ## Changelog
465
+
466
+ See changelog from: [CHANGELOG.md](https://github.com/Hoang-Long2012/pipeline-toolkit/blob/main/CHANGELOG.md)
467
+
468
+ ## License
469
+
470
+ This project is licensed under the MIT License. See [LICENSE](https://github.com/Hoang-Long2012/pipeline-toolkit/blob/main/LICENSE) for details.
471
+
472
+ ## Contribution
473
+
474
+ If you'd like to contribute, feel free to submit a pull request.
475
+ If you'd like to report a bug or request a feature, please open an issue.
476
+
477
+ Copyright (C) 2026 Hoàng Long
@@ -0,0 +1,9 @@
1
+ pipeline/__init__.py,sha256=OqDA6wIp3_I4rPtf9fnN9YVUWHd_AOTduG02-F3tRbk,827
2
+ pipeline/pipeline.py,sha256=HGkfBTn-KU28k--FAVoQ13fKQSFhXYztplyG-3chPHM,10714
3
+ pipeline/stack.py,sha256=1Ng_Tb4B2vQaU61OJMIiC2Kfl_qqIn4c-A6g_ERSzxA,3742
4
+ pipeline/tap.py,sha256=rmcQWGk6ulSieJTvN_KabfEvaSmN4NM7n8X_hDbeYxE,1492
5
+ pipeline_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=c1qSpYLFRd3y_-AO7xnys7ntD8rcvVy7_E5Ue5-QIBo,1068
6
+ pipeline_toolkit-0.1.0.dist-info/METADATA,sha256=XAmz4Jucy3Yhx6fagBvJ6YWVB_929qUME1u0ImY9Wyo,10704
7
+ pipeline_toolkit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ pipeline_toolkit-0.1.0.dist-info/top_level.txt,sha256=Qdc1eKrvhKK_o9CPbdooOdDt7g3ZSXZDrNXHmUGl94Q,9
9
+ pipeline_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hoàng Long
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pipeline