sametime 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.
- sametime/__init__.py +215 -0
- sametime/py.typed +0 -0
- sametime-0.1.0.dist-info/METADATA +95 -0
- sametime-0.1.0.dist-info/RECORD +6 -0
- sametime-0.1.0.dist-info/WHEEL +4 -0
- sametime-0.1.0.dist-info/licenses/LICENSE +21 -0
sametime/__init__.py
ADDED
|
@@ -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)
|
sametime/py.typed
ADDED
|
File without changes
|
|
@@ -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,6 @@
|
|
|
1
|
+
sametime/__init__.py,sha256=eKoSbadypHrWveRWEJCm1fZ8rrVL5M_HujHLLiuizAE,7093
|
|
2
|
+
sametime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
sametime-0.1.0.dist-info/licenses/LICENSE,sha256=pAeG0B2X4uxLV9iLuLxEEk4YSQamiNrWf4E6P5vch1Q,1069
|
|
4
|
+
sametime-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
5
|
+
sametime-0.1.0.dist-info/METADATA,sha256=HOPxoHD22ZhWK-Y7EdaOU8ZOcQcXCZk2-QCRmkzpOJI,2826
|
|
6
|
+
sametime-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|