ea-singleflight 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.
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: ea-singleflight
3
+ Version: 0.1.0
4
+ Summary: An asynchronous request coalescing utility for Python.
5
+ Author: Emil
6
+ Author-email: Emil <emilupx@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.13
14
+ Project-URL: Homepage, https://github.com/EmilAyupov/ea-singleflight
15
+ Project-URL: Repository, https://github.com/EmilAyupov/ea-singleflight
16
+ Project-URL: Issues, https://github.com/EmilAyupov/ea-singleflight/issues
17
+ Description-Content-Type: text/markdown
18
+
19
+ # SingleFlight
20
+
21
+ **SingleFlight** is a lightweight asynchronous Python utility that prevents duplicate concurrent operations.
22
+
23
+ When multiple callers request the same operation using the same key, only one operation is executed. All callers share the result of that operation.
24
+
25
+ ## Features
26
+
27
+ - Deduplicates concurrent operations by key.
28
+ - Shares results between multiple callers.
29
+ - Propagates exceptions to all waiting callers.
30
+ - Protects shared operations from individual caller cancellation.
31
+ - Supports independent operations for different keys.
32
+ - Automatically cleans up completed operations.
33
+ - Built with Python's standard `asyncio` library.
34
+
35
+ ## Requirements
36
+
37
+ - Python 3.13+
38
+
39
+ ## Installation
40
+
41
+ The package is distributed as `ea-singleflight` and imported as `singleflight`.
42
+
43
+ Once published on PyPI, install it with:
44
+
45
+ ```bash
46
+ pip install ea-singleflight
47
+ ```
48
+
49
+ Until then, you can install it directly from GitHub:
50
+
51
+ ```bash
52
+ pip install git+https://github.com/EmilAyupov/ea-singleflight.git
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ import asyncio
59
+
60
+ from singleflight import SingleFlight
61
+
62
+
63
+ async def fetch_user(user_id: int) -> dict:
64
+ print(f"Fetching user {user_id}...")
65
+
66
+ await asyncio.sleep(1)
67
+
68
+ return {
69
+ "id": user_id,
70
+ "name": "Alice",
71
+ }
72
+
73
+
74
+ async def main() -> None:
75
+ group = SingleFlight()
76
+
77
+ results = await asyncio.gather(
78
+ group.do("user:123", lambda: fetch_user(123)),
79
+ group.do("user:123", lambda: fetch_user(123)),
80
+ group.do("user:123", lambda: fetch_user(123)),
81
+ )
82
+
83
+ print(results)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ asyncio.run(main())
88
+ ```
89
+
90
+ Although three callers request the same operation, `fetch_user()` is executed only once.
91
+
92
+ All three callers receive the same result.
93
+
94
+ ## How It Works
95
+
96
+ SingleFlight maintains a collection of in-flight operations indexed by string keys.
97
+
98
+ When `do()` is called:
99
+
100
+ 1. If no operation exists for the given key, a new asynchronous task is created.
101
+ 2. If an operation already exists, the caller joins the existing task.
102
+ 3. All callers await the same task.
103
+ 4. Once the operation completes and all waiting callers finish, the corresponding entry is removed.
104
+
105
+ Operations with different keys run independently.
106
+
107
+ ## Cancellation Behavior
108
+
109
+ SingleFlight uses `asyncio.shield()` to protect shared tasks from cancellation initiated by individual callers.
110
+
111
+ If one caller is cancelled:
112
+
113
+ - That caller receives `asyncio.CancelledError`.
114
+ - The shared operation continues running.
115
+ - Other callers can still receive its result.
116
+
117
+ If all callers are cancelled, the shared operation continues running and can be joined by a new caller using the same key.
118
+
119
+ ## Exception Handling
120
+
121
+ If the shared operation raises an exception, all waiting callers receive that exception.
122
+
123
+ Once the operation has completed and its callers have finished, the corresponding entry is removed.
124
+
125
+ A subsequent call using the same key starts a new operation.
126
+
127
+ ## API
128
+
129
+ ### `SingleFlight()`
130
+
131
+ Creates a new SingleFlight instance.
132
+
133
+ ```python
134
+ group = SingleFlight()
135
+ ```
136
+
137
+ ### `await group.do(key, fn)`
138
+
139
+ Executes or joins an asynchronous operation associated with the given key.
140
+
141
+ **Parameters:**
142
+
143
+ | Parameter | Type | Description |
144
+ | --------- | ---------- | -------------------------------------------- |
145
+ | `key` | `str` | Identifier of the operation |
146
+ | `fn` | `Callable` | Zero-argument function returning a coroutine |
147
+
148
+ **Returns:**
149
+
150
+ The result of the shared asynchronous operation.
151
+
152
+ **Raises:**
153
+
154
+ - `TypeError` if `fn` does not return a coroutine.
155
+ - Any exception raised by the shared operation.
156
+ - `asyncio.CancelledError` if the individual caller is cancelled or the shared task is cancelled.
157
+
158
+ ## Limitations
159
+
160
+ - Keys must be strings.
161
+ - Only asynchronous functions returning coroutines are supported.
162
+ - Callers using the same key must expect compatible results.
163
+ - The first caller's function is used; subsequent functions for the same key are not executed.
164
+ - A SingleFlight instance is intended for use within a single asyncio event loop.
165
+ - The implementation is not thread-safe.
166
+
167
+ ## Development
168
+
169
+ Clone the repository:
170
+
171
+ ```bash
172
+ git clone https://github.com/EmilAyupov/ea-singleflight.git
173
+ cd ea-singleflight
174
+ ```
175
+
176
+ Install dependencies:
177
+
178
+ ```bash
179
+ uv sync --all-groups
180
+ ```
181
+
182
+ Run checks:
183
+
184
+ ```bash
185
+ ./scripts/check.sh
186
+ ```
187
+
188
+ The project uses:
189
+
190
+ - [Python](https://www.python.org/)
191
+ - [asyncio](https://docs.python.org/3/library/asyncio.html)
192
+ - [pytest](https://pytest.org/)
193
+ - [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
194
+ - [Ruff](https://docs.astral.sh/ruff/)
195
+ - [Pyrefly](https://pyrefly.org/)
196
+ - [uv](https://docs.astral.sh/uv/)
197
+
198
+ ## License
199
+
200
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,6 @@
1
+ singleflight/__init__.py,sha256=3tG3dW1Cy6fHIcIdhhUHA65xUQMdNIOeweV5YFlx3_k,59
2
+ singleflight/main.py,sha256=mlW1lZqWkso97gZ493A0D-XrX55o1zyfbnYbFNe7FOw,1601
3
+ singleflight/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ ea_singleflight-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
5
+ ea_singleflight-0.1.0.dist-info/METADATA,sha256=3zFs6rfcuh1sfFDOpOExrBWpFZdTjotrovM3xNSKsJ8,5424
6
+ ea_singleflight-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,3 @@
1
+ from .main import SingleFlight
2
+
3
+ __all__ = ["SingleFlight"]
singleflight/main.py ADDED
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+ from collections.abc import Callable, Coroutine
3
+ from dataclasses import dataclass
4
+ from typing import Any, TypeVar, cast
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ @dataclass
10
+ class Flight[T]:
11
+ task: asyncio.Task[T]
12
+ waiters: int = 0
13
+
14
+
15
+ class SingleFlight:
16
+ def __init__(self) -> None:
17
+ self._flights: dict[str, Flight[Any]] = {}
18
+ self._lock = asyncio.Lock()
19
+
20
+ async def do(
21
+ self,
22
+ key: str,
23
+ fn: Callable[[], Coroutine[Any, Any, T]],
24
+ ) -> T:
25
+ async with self._lock:
26
+ flight = self._flights.get(key)
27
+
28
+ if flight is None:
29
+ coro = fn()
30
+
31
+ if not asyncio.iscoroutine(coro):
32
+ raise TypeError("fn must return a coroutine")
33
+
34
+ task = asyncio.create_task(coro)
35
+ flight = Flight(task=task)
36
+ self._flights[key] = flight
37
+
38
+ task.add_done_callback(lambda _: self._on_task_done(key, flight))
39
+
40
+ flight.waiters += 1
41
+
42
+ try:
43
+ result = await asyncio.shield(flight.task)
44
+ return cast(T, result)
45
+ finally:
46
+ flight.waiters -= 1
47
+ self._cleanup(key, flight)
48
+
49
+ def _cleanup(self, key: str, flight: Flight[Any]) -> None:
50
+ if (
51
+ self._flights.get(key) is flight
52
+ and flight.task.done()
53
+ and flight.waiters == 0
54
+ ):
55
+ del self._flights[key]
56
+
57
+ def _on_task_done(self, key: str, flight: Flight[Any]) -> None:
58
+ if not flight.task.cancelled():
59
+ flight.task.exception()
60
+
61
+ self._cleanup(key, flight)
singleflight/py.typed ADDED
File without changes