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 +21 -0
- sametime-0.1.0/PKG-INFO +95 -0
- sametime-0.1.0/README.md +75 -0
- sametime-0.1.0/docs/api.md +72 -0
- sametime-0.1.0/examples/thread_jobs.py +20 -0
- sametime-0.1.0/pyproject.toml +39 -0
- sametime-0.1.0/pyproject.toml.orig +32 -0
- sametime-0.1.0/src/sametime/__init__.py +215 -0
- sametime-0.1.0/src/sametime/py.typed +0 -0
- sametime-0.1.0/tests/test_sametime.py +162 -0
- sametime-0.1.0/tests/test_thread_outcomes.py +130 -0
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.
|
sametime-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
sametime-0.1.0/README.md
ADDED
|
@@ -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()
|