sametime 0.1.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.
sametime-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Outside Labs
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,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: sametime
3
+ Version: 0.1.0
4
+ Summary: Bounded synchronous thread concurrency for independent I/O operations
5
+ Author: ellainng
6
+ Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.14
16
+ Project-URL: Homepage, https://github.com/wispsy/sametime
17
+ Project-URL: Repository, https://github.com/wispsy/sametime
18
+ Project-URL: Issues, https://github.com/wispsy/sametime/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sametime
22
+
23
+ Bounded synchronous thread concurrency for independent I/O operations.
24
+
25
+ `sametime` provides structured thread management built directly on Python's standard library `concurrent.futures`. Requires Python 3.14+.
26
+
27
+ ## Highlights
28
+
29
+ - **Strict Resource Bounds**: Controls both worker count (`max_workers`) and queued futures (`max_pending`), preventing memory ballooning on large/infinite input iterables.
30
+ - **Context-Managed Lifecycle**: Automatic cancellation of pending tasks and graceful thread joins on context exit.
31
+ - **Ordered & Unordered Execution**: Preserve input order or stream results as soon as they complete (`ordered=False`).
32
+ - **Flexible Failure Strategies**: Choose between fail-fast behavior (`thread_map`, `thread_iter`) or indexed outcomes with error capture (`thread_outcomes`).
33
+ - **Consumer-Thread Callbacks**: `on_result` callbacks execute safely on the caller's thread before results are yielded.
34
+
35
+ ## Installation
36
+
37
+ ```sh
38
+ pip install sametime
39
+ ```
40
+
41
+ For development:
42
+
43
+ ```sh
44
+ git clone https://github.com/wispsy/sametime.git
45
+ cd sametime
46
+ uv sync
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ ### Basic Parallel Mapping
52
+
53
+ ```python
54
+ from sametime import thread_map
55
+
56
+ results = thread_map(len, ['hello', 'world', 'concurrency'], max_workers=3)
57
+ assert results == [5, 5, 11]
58
+ ```
59
+
60
+ ### Streaming Results As They Complete
61
+
62
+ ```python
63
+ from sametime import thread_iter
64
+
65
+ with thread_iter(fetch, urls, max_workers=4, ordered=False) as stream:
66
+ for item in stream:
67
+ handle(item)
68
+ ```
69
+
70
+ ### Tolerating Per-Item Errors with `Outcome`
71
+
72
+ ```python
73
+ from sametime import thread_outcomes
74
+
75
+ with thread_outcomes(int, ['10', 'invalid', '30'], max_pending=2) as results:
76
+ for outcome in results:
77
+ if outcome.ok:
78
+ print(f"Item #{outcome.index} = {outcome.value}")
79
+ else:
80
+ print(f"Item #{outcome.index} error: {outcome.error}")
81
+ ```
82
+
83
+ ## Documentation
84
+
85
+ See [docs/api.md](docs/api.md) for full API details, threading guarantees, and design considerations.
86
+
87
+ ## Running Tests
88
+
89
+ ```sh
90
+ python -m unittest discover -s tests -v
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,75 @@
1
+ # sametime
2
+
3
+ Bounded synchronous thread concurrency for independent I/O operations.
4
+
5
+ `sametime` provides structured thread management built directly on Python's standard library `concurrent.futures`. Requires Python 3.14+.
6
+
7
+ ## Highlights
8
+
9
+ - **Strict Resource Bounds**: Controls both worker count (`max_workers`) and queued futures (`max_pending`), preventing memory ballooning on large/infinite input iterables.
10
+ - **Context-Managed Lifecycle**: Automatic cancellation of pending tasks and graceful thread joins on context exit.
11
+ - **Ordered & Unordered Execution**: Preserve input order or stream results as soon as they complete (`ordered=False`).
12
+ - **Flexible Failure Strategies**: Choose between fail-fast behavior (`thread_map`, `thread_iter`) or indexed outcomes with error capture (`thread_outcomes`).
13
+ - **Consumer-Thread Callbacks**: `on_result` callbacks execute safely on the caller's thread before results are yielded.
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ pip install sametime
19
+ ```
20
+
21
+ For development:
22
+
23
+ ```sh
24
+ git clone https://github.com/wispsy/sametime.git
25
+ cd sametime
26
+ uv sync
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ### Basic Parallel Mapping
32
+
33
+ ```python
34
+ from sametime import thread_map
35
+
36
+ results = thread_map(len, ['hello', 'world', 'concurrency'], max_workers=3)
37
+ assert results == [5, 5, 11]
38
+ ```
39
+
40
+ ### Streaming Results As They Complete
41
+
42
+ ```python
43
+ from sametime import thread_iter
44
+
45
+ with thread_iter(fetch, urls, max_workers=4, ordered=False) as stream:
46
+ for item in stream:
47
+ handle(item)
48
+ ```
49
+
50
+ ### Tolerating Per-Item Errors with `Outcome`
51
+
52
+ ```python
53
+ from sametime import thread_outcomes
54
+
55
+ with thread_outcomes(int, ['10', 'invalid', '30'], max_pending=2) as results:
56
+ for outcome in results:
57
+ if outcome.ok:
58
+ print(f"Item #{outcome.index} = {outcome.value}")
59
+ else:
60
+ print(f"Item #{outcome.index} error: {outcome.error}")
61
+ ```
62
+
63
+ ## Documentation
64
+
65
+ See [docs/api.md](docs/api.md) for full API details, threading guarantees, and design considerations.
66
+
67
+ ## Running Tests
68
+
69
+ ```sh
70
+ python -m unittest discover -s tests -v
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,72 @@
1
+ # sametime API Reference
2
+
3
+ `sametime` provides bounded synchronous thread concurrency for independent I/O operations, built strictly on Python's standard library (`concurrent.futures.ThreadPoolExecutor`). Requires Python 3.14+.
4
+
5
+ ---
6
+
7
+ ## Design Principles
8
+
9
+ 1. **Explicit Bounds**: `max_workers` controls active threads (defaults to CPU count + 4, capped at 32). `max_pending` bounds the maximum number of submitted, unconsumed futures (defaults to `max_workers * 2`).
10
+ 2. **Context Manager Lifetime**: `thread_iter` and `thread_outcomes` are context managers. Exiting the `with` block cancels unstarted futures and joins active threads immediately.
11
+ 3. **Consumer-Thread Callbacks**: `on_result` callbacks execute strictly on the consuming thread as results are ready or consumed, avoiding threading races in user state.
12
+ 4. **Clean Error Handling**:
13
+ - `thread_map` / `thread_iter`: Fail fast, re-raising the first exception encountered.
14
+ - `thread_outcomes`: Captures worker exceptions inside `Outcome` records while letting keyboard interrupts and consumer errors propagate.
15
+
16
+ ---
17
+
18
+ ## API Reference
19
+
20
+ ### `Outcome[R]`
21
+
22
+ An indexed execution result:
23
+
24
+ ```python
25
+ @dataclass(frozen=True, slots=True)
26
+ class Outcome[R]:
27
+ index: int
28
+ value: R | None = None
29
+ error: Exception | None = field(default=None, repr=False)
30
+ ```
31
+
32
+ - `index: int`: The original 0-based position of the item in the input iterable.
33
+ - `ok: bool`: True if the call succeeded without exception.
34
+ - `result() -> R`: Returns `value` (including `None`), or re-raises `error`.
35
+
36
+ ---
37
+
38
+ ### `thread_map(function, iterable, *, max_workers=None, max_pending=None, ordered=True, on_result=None) -> list[R]`
39
+
40
+ Runs `function` concurrently over `iterable` using a bounded thread pool, collecting all results into a list.
41
+ - If `ordered=True` (default), preserves the input sequence order in the returned list.
42
+ - Fails fast on the first exception.
43
+
44
+ ---
45
+
46
+ ### `thread_iter(function, iterable, *, max_workers=None, max_pending=None, ordered=True, on_result=None) -> Iterator[Iterator[R]]`
47
+
48
+ Context manager yielding an iterator of results.
49
+ - `ordered=True` yields results in input order.
50
+ - `ordered=False` yields whichever result finishes first.
51
+ - Bounded input consumption: does not pull infinitely from lazy generators before downstream processing consumes results.
52
+
53
+ ```python
54
+ with thread_iter(fetch_data, urls, max_workers=8, ordered=False) as results:
55
+ for data in results:
56
+ process(data)
57
+ ```
58
+
59
+ ---
60
+
61
+ ### `thread_outcomes(function, iterable, *, max_workers=None, max_pending=None, ordered=True, on_result=None) -> Iterator[Iterator[Outcome[R]]]`
62
+
63
+ Context manager yielding `Outcome` objects for both successes and worker failures without interrupting processing of remaining items.
64
+
65
+ ```python
66
+ with thread_outcomes(process_record, records, max_pending=16) as outcomes:
67
+ for outcome in outcomes:
68
+ if outcome.ok:
69
+ print(f"Record {outcome.index} succeeded: {outcome.value}")
70
+ else:
71
+ print(f"Record {outcome.index} failed: {outcome.error}")
72
+ ```
@@ -0,0 +1,20 @@
1
+ """Bounded offline jobs; real I/O functions should set their own timeouts."""
2
+
3
+ from sametime import thread_iter, thread_map, thread_outcomes
4
+
5
+ assert thread_map(len, ['one', 'four'], max_workers=2, max_pending=2) == [3, 4]
6
+ with thread_iter(
7
+ str.upper, ['one', 'two', 'three'], max_workers=2, max_pending=2, ordered=False
8
+ ) as results:
9
+ assert sorted(results) == ['ONE', 'THREE', 'TWO']
10
+ with thread_iter(lambda value: value * 2, range(100), max_workers=2, max_pending=4) as results:
11
+ assert next(results) == 0
12
+ # Exiting early cancels queued work and joins running calls.
13
+ observed = []
14
+ with thread_outcomes(int, ['1', 'invalid', '3'], on_result=observed.append) as results:
15
+ outcomes = list(results)
16
+ assert [row.index for row in outcomes] == [0, 1, 2]
17
+ assert [row.ok for row in outcomes] == [True, False, True]
18
+ assert outcomes[0].result() == 1
19
+ assert len(observed) == 3
20
+ print('Bounded thread jobs passed')
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "sametime"
3
+ version = "0.1.0"
4
+ description = "Bounded synchronous thread concurrency for independent I/O operations"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ dependencies = []
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [[project.authors]]
20
+ name = "ellainng"
21
+ email = "299958882+ellainng@users.noreply.github.com"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/wispsy/sametime"
25
+ Repository = "https://github.com/wispsy/sametime"
26
+ Issues = "https://github.com/wispsy/sametime/issues"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.13.6,<0.14.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.uv.build-backend]
33
+ source-include = [
34
+ "tests/**",
35
+ "docs/**",
36
+ "examples/**",
37
+ "LICENSE",
38
+ "README.md",
39
+ ]
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "sametime"
3
+ version = "0.1.0"
4
+ description = "Bounded synchronous thread concurrency for independent I/O operations"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
10
+ ]
11
+ requires-python = ">=3.14"
12
+ dependencies = []
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/wispsy/sametime"
24
+ Repository = "https://github.com/wispsy/sametime"
25
+ Issues = "https://github.com/wispsy/sametime/issues"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.13.6,<0.14.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ source-include = ["tests/**", "docs/**", "examples/**", "LICENSE", "README.md"]
@@ -0,0 +1,215 @@
1
+ """Bounded synchronous thread concurrency for independent I/O operations."""
2
+
3
+ from collections.abc import Callable, Iterable, Iterator
4
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ import os
8
+ from typing import cast
9
+
10
+ __version__ = '0.1.0'
11
+
12
+ __all__ = ['Outcome', 'thread_iter', 'thread_map', 'thread_outcomes', '__version__']
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Outcome[R]:
17
+ """An indexed result without a separate reference to the input item.
18
+
19
+ Returned values and original exceptions may themselves retain input data.
20
+ Exception tracebacks are preserved; they are not a memory isolation boundary.
21
+ """
22
+
23
+ index: int
24
+ value: R | None = None
25
+ error: Exception | None = field(default=None, repr=False)
26
+
27
+ def __post_init__(self) -> None:
28
+ if isinstance(self.index, bool) or not isinstance(self.index, int):
29
+ raise TypeError('index must be a nonnegative integer')
30
+ if self.index < 0:
31
+ raise ValueError('index must be nonnegative')
32
+ if self.error is not None and not isinstance(self.error, Exception):
33
+ raise TypeError('error must be an Exception or None')
34
+ if self.error is not None and self.value is not None:
35
+ raise ValueError('an outcome cannot contain both a value and an error')
36
+
37
+ @property
38
+ def ok(self) -> bool:
39
+ return self.error is None
40
+
41
+ def result(self) -> R:
42
+ """Return the value (including None), or raise the original exception."""
43
+ if self.error is not None:
44
+ raise self.error
45
+ return cast(R, self.value)
46
+
47
+
48
+ def _positive(name: str, value: int) -> int:
49
+ if isinstance(value, bool) or not isinstance(value, int):
50
+ raise TypeError(f'{name} must be a positive integer')
51
+ if value <= 0:
52
+ raise ValueError(f'{name} must be a positive integer')
53
+ return value
54
+
55
+
56
+ def _results[T, R](
57
+ pool: ThreadPoolExecutor,
58
+ function: Callable[[T], R],
59
+ iterable: Iterable[T],
60
+ max_pending: int,
61
+ ordered: bool,
62
+ on_result: Callable[[Outcome[R]], None] | None,
63
+ ) -> Iterator[Outcome[R]]:
64
+ source = iter(iterable)
65
+ pending: dict[Future[R], int] = {}
66
+ exhausted = False
67
+ index = 0
68
+
69
+ def fill() -> None:
70
+ nonlocal exhausted, index
71
+ while not exhausted and len(pending) < max_pending:
72
+ try:
73
+ item = next(source)
74
+ except StopIteration:
75
+ exhausted = True
76
+ break
77
+ pending[pool.submit(function, item)] = index
78
+ index += 1
79
+
80
+ try:
81
+ fill()
82
+ while pending:
83
+ if ordered:
84
+ future = next(iter(pending))
85
+ else:
86
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
87
+ future = min(done, key=pending.__getitem__)
88
+ result_index = pending.pop(future)
89
+ try:
90
+ outcome = Outcome(result_index, value=future.result())
91
+ except Exception as error:
92
+ outcome = Outcome(result_index, error=error)
93
+ if on_result is not None:
94
+ on_result(outcome)
95
+ yield outcome
96
+ fill()
97
+ finally:
98
+ for future in pending:
99
+ future.cancel()
100
+
101
+
102
+ @contextmanager
103
+ def _thread_records[T, R](
104
+ function: Callable[[T], R],
105
+ iterable: Iterable[T],
106
+ *,
107
+ max_workers: int | None = None,
108
+ max_pending: int | None = None,
109
+ ordered: bool = True,
110
+ on_result: Callable[[Outcome[R]], None] | None = None,
111
+ ) -> Iterator[Iterator[Outcome[R]]]:
112
+ if not callable(function):
113
+ raise TypeError('function must be callable')
114
+ if not isinstance(ordered, bool):
115
+ raise TypeError('ordered must be a bool')
116
+ if on_result is not None and not callable(on_result):
117
+ raise TypeError('on_result must be callable or None')
118
+ workers = (
119
+ min(32, (os.process_cpu_count() or 1) + 4)
120
+ if max_workers is None
121
+ else _positive('max_workers', max_workers)
122
+ )
123
+ capacity = workers * 2 if max_pending is None else _positive('max_pending', max_pending)
124
+ pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix='sametime')
125
+ results = _results(pool, function, iterable, capacity, ordered, on_result)
126
+ try:
127
+ yield results
128
+ finally:
129
+ results.close()
130
+ pool.shutdown(wait=True, cancel_futures=True)
131
+
132
+
133
+ @contextmanager
134
+ def thread_iter[T, R](
135
+ function: Callable[[T], R],
136
+ iterable: Iterable[T],
137
+ *,
138
+ max_workers: int | None = None,
139
+ max_pending: int | None = None,
140
+ ordered: bool = True,
141
+ on_result: Callable[[Outcome[R]], None] | None = None,
142
+ ) -> Iterator[Iterator[R]]:
143
+ """Yield bounded values; always use as a context manager.
144
+
145
+ ordered=True preserves input order; false returns ready results first.
146
+ Worker and input exceptions propagate unchanged. on_result runs on the
147
+ consuming thread before yielding a value or raising its worker exception.
148
+ Exit cancels queued calls and joins running calls; I/O needs its own timeout.
149
+ The caller owns input resources. No input is consumed before iteration.
150
+ """
151
+ with _thread_records(
152
+ function,
153
+ iterable,
154
+ max_workers=max_workers,
155
+ max_pending=max_pending,
156
+ ordered=ordered,
157
+ on_result=on_result,
158
+ ) as records:
159
+ values = (record.result() for record in records)
160
+ try:
161
+ yield values
162
+ finally:
163
+ values.close()
164
+
165
+
166
+ @contextmanager
167
+ def thread_outcomes[T, R](
168
+ function: Callable[[T], R],
169
+ iterable: Iterable[T],
170
+ *,
171
+ max_workers: int | None = None,
172
+ max_pending: int | None = None,
173
+ ordered: bool = True,
174
+ on_result: Callable[[Outcome[R]], None] | None = None,
175
+ ) -> Iterator[Iterator[Outcome[R]]]:
176
+ """Yield indexed successes/failures with the same bounds and cleanup.
177
+
178
+ Worker Exception instances become outcomes. BaseException, producer failures
179
+ and callback failures propagate. Only consumed results invoke on_result;
180
+ unconsumed or cancelled work emits no outcome/callback during context exit.
181
+ """
182
+ with _thread_records(
183
+ function,
184
+ iterable,
185
+ max_workers=max_workers,
186
+ max_pending=max_pending,
187
+ ordered=ordered,
188
+ on_result=on_result,
189
+ ) as records:
190
+ yield records
191
+
192
+
193
+ def thread_map[T, R](
194
+ function: Callable[[T], R],
195
+ iterable: Iterable[T],
196
+ *,
197
+ max_workers: int | None = None,
198
+ max_pending: int | None = None,
199
+ ordered: bool = True,
200
+ on_result: Callable[[Outcome[R]], None] | None = None,
201
+ ) -> list[R]:
202
+ """Collect thread_iter results into a list; input must terminate.
203
+
204
+ Outstanding work is bounded, but the returned list grows with all results.
205
+ Use thread_iter to process large or infinite inputs incrementally.
206
+ """
207
+ with thread_iter(
208
+ function,
209
+ iterable,
210
+ max_workers=max_workers,
211
+ max_pending=max_pending,
212
+ ordered=ordered,
213
+ on_result=on_result,
214
+ ) as results:
215
+ return list(results)
File without changes
@@ -0,0 +1,162 @@
1
+ from concurrent.futures import ThreadPoolExecutor
2
+ import itertools
3
+ import threading
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ from sametime import thread_iter, thread_map
8
+
9
+
10
+ class ThreadTests(unittest.TestCase):
11
+ def test_empty_values_and_default_workers(self):
12
+ self.assertEqual(thread_map(len, []), [])
13
+ self.assertEqual(thread_map(len, ['a', 'abc', 'xy']), [1, 3, 2])
14
+ self.assertEqual(thread_map(lambda x: x, [None, False, 0]), [None, False, 0])
15
+ with patch('sametime.os.process_cpu_count', return_value=None):
16
+ self.assertEqual(thread_map(str, [1]), ['1'])
17
+
18
+ def test_actual_overlap_with_a_barrier(self):
19
+ barrier = threading.Barrier(3, timeout=5)
20
+ def work(value):
21
+ barrier.wait()
22
+ return value * 2
23
+ self.assertEqual(thread_map(work, [1, 2, 3], max_workers=3, max_pending=3), [2, 4, 6])
24
+
25
+ def test_ready_first_unblocks_before_first_input_finishes(self):
26
+ release = threading.Event()
27
+ first_started = threading.Event()
28
+ def work(value):
29
+ if value == 0:
30
+ first_started.set()
31
+ if not release.wait(5):
32
+ raise TimeoutError('test worker safety timeout')
33
+ else:
34
+ if not first_started.wait(5):
35
+ raise TimeoutError('first worker never started')
36
+ return value
37
+ try:
38
+ with thread_iter(work, [0, 1], max_workers=2, ordered=False) as results:
39
+ self.assertEqual(next(results), 1)
40
+ release.set()
41
+ self.assertEqual(list(results), [0])
42
+ finally:
43
+ release.set()
44
+
45
+ def test_ordered_waits_for_input_order(self):
46
+ second_done = threading.Event()
47
+ def work(value):
48
+ if value == 0:
49
+ if not second_done.wait(5):
50
+ raise TimeoutError('second worker never ran')
51
+ else:
52
+ second_done.set()
53
+ return value
54
+ self.assertEqual(thread_map(work, [0, 1], max_workers=2), [0, 1])
55
+
56
+ def test_lazy_bounded_input_including_completed_unconsumed_results(self):
57
+ seen = []
58
+ def source():
59
+ for value in itertools.count():
60
+ seen.append(value)
61
+ yield value
62
+ with thread_iter(lambda x: x, source(), max_workers=1, max_pending=3) as results:
63
+ self.assertEqual(seen, [])
64
+ self.assertEqual(next(results), 0)
65
+ self.assertEqual(seen, [0, 1, 2])
66
+ self.assertEqual(next(results), 1)
67
+ self.assertEqual(seen, [0, 1, 2, 3])
68
+ self.assertEqual(seen, [0, 1, 2, 3])
69
+ self.assertEqual(list(results), [])
70
+
71
+ def test_worker_exception_identity_and_cleanup(self):
72
+ error = LookupError('original')
73
+ threads = []
74
+ def work(value):
75
+ threads.append(threading.current_thread())
76
+ if value == 1:
77
+ raise error
78
+ return value
79
+ with self.assertRaises(LookupError) as raised:
80
+ thread_map(work, range(10), max_workers=1, max_pending=2)
81
+ self.assertIs(raised.exception, error)
82
+ self.assertTrue(all(not thread.is_alive() for thread in threads))
83
+
84
+ def test_input_exception_identity_and_cleanup(self):
85
+ error = OSError('input')
86
+ threads = []
87
+ def source():
88
+ yield 1
89
+ raise error
90
+ def work(value):
91
+ threads.append(threading.current_thread())
92
+ return value
93
+ with self.assertRaises(OSError) as raised:
94
+ thread_map(work, source(), max_workers=1, max_pending=3)
95
+ self.assertIs(raised.exception, error)
96
+ self.assertTrue(all(not thread.is_alive() for thread in threads))
97
+
98
+ def test_early_exit_cancels_queued_calls_and_joins_running_workers(self):
99
+ release = threading.Event()
100
+ started = threading.Event()
101
+ cancelled = threading.Event()
102
+ threads = []
103
+ class RecordingPool(ThreadPoolExecutor):
104
+ def submit(self, function, *args, **kwargs):
105
+ future = super().submit(function, *args, **kwargs)
106
+ future.add_done_callback(lambda done: cancelled.set() if done.cancelled() else None)
107
+ return future
108
+ def work(value):
109
+ threads.append(threading.current_thread())
110
+ if value == 0:
111
+ return value
112
+ if value > 1:
113
+ raise AssertionError('queued call should be cancelled')
114
+ started.set()
115
+ if not release.wait(5):
116
+ raise TimeoutError('test safety timeout')
117
+ return value
118
+ def release_after_cancellation():
119
+ cancelled.wait(5)
120
+ release.set()
121
+ releaser = threading.Thread(target=release_after_cancellation)
122
+ releaser.start()
123
+ try:
124
+ with patch('sametime.ThreadPoolExecutor', RecordingPool):
125
+ with thread_iter(work, range(10), max_workers=1, max_pending=10) as results:
126
+ self.assertEqual(next(results), 0)
127
+ self.assertTrue(started.wait(5))
128
+ self.assertTrue(cancelled.is_set())
129
+ self.assertEqual(list(results), [])
130
+ self.assertTrue(all(not thread.is_alive() for thread in threads))
131
+ finally:
132
+ release.set()
133
+ releaser.join(timeout=5)
134
+
135
+ def test_unused_context_and_caller_exception(self):
136
+ def bad_source():
137
+ raise AssertionError('must remain unconsumed')
138
+ yield
139
+ with thread_iter(str, bad_source()) as results:
140
+ pass
141
+ self.assertEqual(list(results), [])
142
+ with self.assertRaisesRegex(RuntimeError, 'caller'):
143
+ with thread_iter(str, range(100), max_pending=1) as results:
144
+ self.assertEqual(next(results), '0')
145
+ raise RuntimeError('caller')
146
+
147
+ def test_validation(self):
148
+ for keyword in ('max_workers', 'max_pending'):
149
+ for value in (True, 1.5, '2', 0, -1):
150
+ with self.subTest(keyword=keyword, value=value), self.assertRaises((ValueError, TypeError)):
151
+ thread_map(str, [], **{keyword: value})
152
+ for kwargs in ({'ordered': 1}, {'ordered': None}):
153
+ with self.assertRaises(TypeError):
154
+ thread_map(str, [], **kwargs)
155
+ with self.assertRaises(TypeError):
156
+ thread_map(None, [])
157
+ with self.assertRaises(TypeError):
158
+ thread_map(str, 1)
159
+
160
+
161
+ if __name__ == '__main__':
162
+ unittest.main()
@@ -0,0 +1,130 @@
1
+ import itertools
2
+ import threading
3
+ import unittest
4
+
5
+ from sametime import Outcome, thread_iter, thread_map, thread_outcomes
6
+
7
+
8
+ class OutcomeTests(unittest.TestCase):
9
+ def test_mixed_results_original_errors_and_none(self):
10
+ error = LookupError('original')
11
+ def work(value):
12
+ if value == 1:
13
+ raise error
14
+ return None if value == 0 else value * 2
15
+ with thread_outcomes(work, range(3), max_workers=2) as results:
16
+ rows = list(results)
17
+ self.assertEqual([row.index for row in rows], [0, 1, 2])
18
+ self.assertTrue(rows[0].ok)
19
+ self.assertIsNone(rows[0].result())
20
+ self.assertFalse(rows[1].ok)
21
+ self.assertIs(rows[1].error, error)
22
+ with self.assertRaises(LookupError) as raised:
23
+ rows[1].result()
24
+ self.assertIs(raised.exception, error)
25
+ self.assertEqual(rows[2].result(), 4)
26
+ self.assertFalse(hasattr(rows[0], 'input'))
27
+
28
+ def test_ready_results_keep_original_indices(self):
29
+ release = threading.Event()
30
+ def work(value):
31
+ if value == 0 and not release.wait(5):
32
+ raise TimeoutError('test safety timeout')
33
+ return value
34
+ try:
35
+ with thread_outcomes(work, [0, 1], max_workers=2, ordered=False) as results:
36
+ self.assertEqual(next(results).index, 1)
37
+ release.set()
38
+ self.assertEqual(next(results).index, 0)
39
+ finally:
40
+ release.set()
41
+
42
+ def test_callbacks_run_on_consumer_and_before_yield(self):
43
+ consumer = threading.get_ident()
44
+ seen = []
45
+ def observe(row):
46
+ self.assertEqual(threading.get_ident(), consumer)
47
+ seen.append(row.index)
48
+ with thread_outcomes(threading.get_ident, [None]) as results:
49
+ # Wrong call arity is captured as a normal worker failure.
50
+ self.assertIsInstance(next(results).error, TypeError)
51
+ with thread_iter(lambda value: threading.get_ident(), [1, 2], on_result=observe) as results:
52
+ self.assertNotEqual(next(results), consumer)
53
+ self.assertEqual(seen, [0])
54
+ self.assertEqual(seen, [0])
55
+
56
+ def test_fail_fast_behavior_and_error_callback(self):
57
+ error = ValueError('worker')
58
+ observed = []
59
+ def work(value):
60
+ raise error
61
+ with self.assertRaises(ValueError) as raised:
62
+ thread_map(work, [0, 1], max_pending=1, on_result=observed.append)
63
+ self.assertIs(raised.exception, error)
64
+ self.assertEqual(len(observed), 1)
65
+ self.assertIs(observed[0].error, error)
66
+
67
+ def test_callback_failures_propagate_and_join_workers(self):
68
+ error = RuntimeError('observer')
69
+ workers = []
70
+ def work(value):
71
+ workers.append(threading.current_thread())
72
+ return value
73
+ def observer(row):
74
+ raise error
75
+ with self.assertRaises(RuntimeError) as raised:
76
+ with thread_outcomes(work, range(10), max_workers=2, on_result=observer) as results:
77
+ next(results)
78
+ self.assertIs(raised.exception, error)
79
+ self.assertTrue(all(not worker.is_alive() for worker in workers))
80
+
81
+ def test_input_failures_and_base_exceptions_remain_failures(self):
82
+ def source():
83
+ yield 1
84
+ raise OSError('producer')
85
+ with self.assertRaises(OSError):
86
+ with thread_outcomes(str, source()) as results:
87
+ list(results)
88
+ def stop(value):
89
+ raise KeyboardInterrupt
90
+ with self.assertRaises(KeyboardInterrupt):
91
+ with thread_outcomes(stop, [0]) as results:
92
+ next(results)
93
+
94
+ def test_bounds_empty_inputs_and_unused_context(self):
95
+ seen = []
96
+ def source():
97
+ for value in itertools.count():
98
+ seen.append(value)
99
+ yield value
100
+ with thread_outcomes(str, source(), max_pending=2) as results:
101
+ self.assertEqual(seen, [])
102
+ self.assertEqual(next(results).index, 0)
103
+ self.assertEqual(seen, [0, 1])
104
+ self.assertEqual(list(results), [])
105
+ with thread_outcomes(str, []) as results:
106
+ self.assertEqual(list(results), [])
107
+ with thread_outcomes(str, source()) as results:
108
+ pass
109
+ self.assertEqual(seen, [0, 1])
110
+ for ordered in (True, False):
111
+ with thread_outcomes(str, range(9), max_pending=2, ordered=ordered) as results:
112
+ rows = sorted(results, key=lambda row: row.index)
113
+ self.assertEqual([row.index for row in rows], list(range(9)))
114
+ self.assertEqual([row.value for row in rows], list(map(str, range(9))))
115
+
116
+ def test_record_and_callback_validation(self):
117
+ for kwargs in ({'index': True}, {'index': -1}, {'index': 0, 'error': KeyboardInterrupt()},
118
+ {'index': 0, 'value': 1, 'error': ValueError()}):
119
+ with self.assertRaises((TypeError, ValueError)):
120
+ Outcome(**kwargs)
121
+ for api in (thread_iter, thread_outcomes):
122
+ with self.assertRaises(TypeError):
123
+ with api(str, [], on_result=1):
124
+ pass
125
+ with self.assertRaises(TypeError):
126
+ thread_map(str, [], on_result=1)
127
+
128
+
129
+ if __name__ == '__main__':
130
+ unittest.main()