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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.