zyncio 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.
zyncio/__init__.py ADDED
@@ -0,0 +1,243 @@
1
+ """Write dual sync/async interfaces with minimal duplication."""
2
+
3
+ from collections.abc import Callable, Coroutine
4
+ from enum import Enum
5
+ from typing import Any, Concatenate, Final, Generic, ParamSpec, TypeVar, overload
6
+ from typing_extensions import Self
7
+
8
+
9
+ __all__ = [
10
+ 'Mode',
11
+ 'SYNC',
12
+ 'ASYNC',
13
+ 'zfunc',
14
+ 'zmethod',
15
+ 'zclassmethod',
16
+ 'zproperty',
17
+ 'SyncMixin',
18
+ 'AsyncMixin',
19
+ ]
20
+
21
+
22
+ class Mode(Enum):
23
+ """`zyncio` execution mode."""
24
+
25
+ SYNC = 'sync'
26
+ ASYNC = 'async'
27
+
28
+
29
+ SYNC: Final = Mode.SYNC
30
+ ASYNC: Final = Mode.ASYNC
31
+
32
+
33
+ P = ParamSpec('P')
34
+ ReturnT = TypeVar('ReturnT')
35
+ SelfT = TypeVar('SelfT')
36
+
37
+
38
+ Zyncable = Callable[Concatenate[Mode, P], Coroutine[Any, Any, ReturnT]]
39
+ ZyncableMethod = Callable[Concatenate[SelfT, Mode, P], Coroutine[Any, Any, ReturnT]]
40
+
41
+
42
+ def _run_sync_coroutine(coro: Coroutine[Any, Any, ReturnT]) -> ReturnT:
43
+ try:
44
+ coro.send(None)
45
+ except StopIteration as e:
46
+ return e.value
47
+ else:
48
+ raise RuntimeError('zyncio functions must only await pure coroutines in sync mode')
49
+
50
+
51
+ class zfunc(Generic[P, ReturnT]):
52
+ """Wrap a function to run in both sync and async modes."""
53
+
54
+ def __init__(self, func: Zyncable[P, ReturnT]) -> None:
55
+ """..
56
+
57
+ :param func: The function to wrap.
58
+ """
59
+ self.func: Final[Zyncable[P, ReturnT]] = func
60
+ self.__name__: str = func.__name__
61
+ self.__qualname__: str = getattr(func, '__qualname__', func.__name__)
62
+ self.__doc__: str | None = getattr(func, '__doc__', None)
63
+
64
+ def __repr__(self) -> str:
65
+ return f'<{self.__module__}.{type(self).__name__} {self.__qualname__}>'
66
+
67
+ def run_sync(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT:
68
+ """Run the function in sync mode."""
69
+ return _run_sync_coroutine(self.func(SYNC, *args, **kwargs))
70
+
71
+ async def run_async(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT:
72
+ """Run the function in async mode."""
73
+ return await self.func(ASYNC, *args, **kwargs)
74
+
75
+
76
+ class SyncMixin:
77
+ """Mixin that makes `zyncio.zmethod`s into sync callables."""
78
+
79
+
80
+ class AsyncMixin:
81
+ """Mixin that makes `zyncio.zmethod`s into async callables."""
82
+
83
+
84
+ class zmethod(Generic[SelfT, P, ReturnT]):
85
+ """Wrap a method to run in both sync and async modes."""
86
+
87
+ def __init__(self, func: ZyncableMethod[SelfT, P, ReturnT]) -> None:
88
+ """..
89
+
90
+ :param func: The method to wrap.
91
+ """
92
+ self.func: Final[ZyncableMethod[SelfT, P, ReturnT]] = func
93
+ self.__name__: str = func.__name__
94
+ self.__qualname__: str = getattr(func, '__qualname__', func.__name__)
95
+ self.__doc__: str | None = getattr(func, '__doc__', None)
96
+
97
+ def __repr__(self) -> str:
98
+ return f'<{self.__module__}.{type(self).__name__} {self.__qualname__}>'
99
+
100
+ @overload
101
+ def __get__(self, instance: None, owner: type[SelfT]) -> Self: ...
102
+ @overload
103
+ def __get__(self, instance: SelfT, owner: type[SelfT] | None) -> 'BoundZyncMethod[SelfT, P, ReturnT]': ...
104
+ def __get__(self, instance: SelfT | None, owner: type[SelfT] | None) -> 'Self | BoundZyncMethod[SelfT, P, ReturnT]':
105
+ if instance is None:
106
+ return self
107
+ return BoundZyncMethod(self.func, instance)
108
+
109
+
110
+ class zclassmethod(Generic[SelfT, P, ReturnT]):
111
+ """Wrap a method to run in both sync and async modes."""
112
+
113
+ def __init__(self, func: ZyncableMethod[type[SelfT], P, ReturnT]) -> None:
114
+ """..
115
+
116
+ :param func: The method to wrap.
117
+ """
118
+ self.func: Final[ZyncableMethod[type[SelfT], P, ReturnT]] = func.__func__ if isinstance(func, classmethod) else func
119
+ self.__name__: str = func.__name__
120
+ self.__qualname__: str = getattr(func, '__qualname__', func.__name__)
121
+ self.__doc__: str | None = getattr(func, '__doc__', None)
122
+
123
+ def __repr__(self) -> str:
124
+ return f'<{self.__module__}.{type(self).__name__} {self.__qualname__}>'
125
+
126
+ def __get__(self, instance: SelfT | None, owner: type[SelfT]) -> 'BoundZyncClassMethod[SelfT, P, ReturnT]':
127
+ return BoundZyncClassMethod(self.func, owner)
128
+
129
+
130
+ SyncSelfT = TypeVar('SyncSelfT', bound=SyncMixin)
131
+ AsyncSelfT = TypeVar('AsyncSelfT', bound=AsyncMixin)
132
+
133
+
134
+ class BoundZyncMethod(Generic[SelfT, P, ReturnT]):
135
+ """A bound `zyncio.zmethod`."""
136
+
137
+ def __init__(self, func: ZyncableMethod[SelfT, P, ReturnT], instance: SelfT) -> None:
138
+ """..
139
+
140
+ :param func: The method to wrap.
141
+ :param instance: The instance to bind the method to.
142
+ """
143
+ self.func: Final[ZyncableMethod[SelfT, P, ReturnT]] = func
144
+ self.instance: Final[SelfT] = instance
145
+
146
+ def __repr__(self) -> str:
147
+ return f'<{self.__module__}.{type(self).__name__} {self.func.__qualname__} of {self.instance!r}>'
148
+
149
+ def run_zync(self, mode: Mode, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, ReturnT]:
150
+ """Run the method in the given mode."""
151
+ return self.func(self.instance, mode, *args, **kwargs)
152
+
153
+ def run_sync(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT:
154
+ """Run the method in sync mode."""
155
+ return _run_sync_coroutine(self.func(self.instance, SYNC, *args, **kwargs))
156
+
157
+ async def run_async(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT:
158
+ """Run the method in async mode."""
159
+ return await self.func(self.instance, ASYNC, *args, **kwargs)
160
+
161
+ @overload
162
+ def __call__(self: 'BoundZyncMethod[SyncSelfT, P, ReturnT]', *args: P.args, **kwargs: P.kwargs) -> ReturnT: ...
163
+ @overload
164
+ def __call__(self: 'BoundZyncMethod[AsyncSelfT, P, ReturnT]', *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, ReturnT]: ...
165
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT | Coroutine[Any, Any, ReturnT]: # noqa: D102
166
+ if isinstance(self.instance, SyncMixin):
167
+ return self.run_sync(*args, **kwargs)
168
+ elif isinstance(self.instance, AsyncMixin):
169
+ return self.run_async(*args, **kwargs)
170
+ else:
171
+ raise TypeError(f'{type(self).__name__} is only callable when bound to instances of SyncMixin or AsyncMixin')
172
+
173
+
174
+ class BoundZyncClassMethod(Generic[SelfT, P, ReturnT]):
175
+ """A bound `zyncio.zclassmethod`."""
176
+
177
+ def __init__(self, func: ZyncableMethod[type[SelfT], P, ReturnT], cls: type[SelfT]) -> None:
178
+ """..
179
+
180
+ :param func: The method to wrap.
181
+ :param cls: The class to bind the method to.
182
+ """
183
+ self.func: Final[ZyncableMethod[type[SelfT], P, ReturnT]] = func
184
+ self.cls: Final[type[SelfT]] = cls
185
+
186
+ def __repr__(self) -> str:
187
+ return f'<{self.__module__}.{type(self).__name__} {self.func.__qualname__} of {self.cls!r}>'
188
+
189
+ def run_zync(self, mode: Mode, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, ReturnT]:
190
+ """Run the method in the given mode."""
191
+ return self.func(self.cls, mode, *args, **kwargs)
192
+
193
+ def run_sync(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT:
194
+ """Run the method in sync mode."""
195
+ return _run_sync_coroutine(self.func(self.cls, SYNC, *args, **kwargs))
196
+
197
+ async def run_async(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT:
198
+ """Run the method in async mode."""
199
+ return await self.func(self.cls, ASYNC, *args, **kwargs)
200
+
201
+ @overload
202
+ def __call__(self: 'BoundZyncClassMethod[SyncSelfT, P, ReturnT]', *args: P.args, **kwargs: P.kwargs) -> ReturnT: ...
203
+ @overload
204
+ def __call__(self: 'BoundZyncClassMethod[AsyncSelfT, P, ReturnT]', *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, ReturnT]: ...
205
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ReturnT | Coroutine[Any, Any, ReturnT]: # noqa: D102
206
+ if issubclass(self.cls, SyncMixin):
207
+ return self.run_sync(*args, **kwargs)
208
+ elif issubclass(self.cls, AsyncMixin):
209
+ return self.run_async(*args, **kwargs)
210
+ else:
211
+ raise TypeError(f'{type(self).__name__} is only callable when bound to subclasses of SyncMixin or AsyncMixin')
212
+
213
+
214
+ class zproperty(Generic[SelfT, ReturnT]):
215
+ """Wrap a method to act as a property in sync mode, and as a coroutine in async mode."""
216
+
217
+ def __init__(self, func: ZyncableMethod[SelfT, [], ReturnT]) -> None:
218
+ """..
219
+
220
+ :param func: The method to wrap.
221
+ """
222
+ self.func: Final[ZyncableMethod[SelfT, [], ReturnT]] = func
223
+ self.__name__: str = func.__name__
224
+ self.__qualname__: str = getattr(func, '__qualname__', func.__name__)
225
+ self.__doc__: str | None = getattr(func, '__doc__', None)
226
+
227
+ def __repr__(self) -> str:
228
+ return f'<{self.__module__}.{type(self).__name__} {self.__qualname__}>'
229
+
230
+ @overload
231
+ def __get__(self, instance: None, owner: type[SelfT]) -> Self: ...
232
+ @overload
233
+ def __get__(self: 'zproperty[SyncSelfT, ReturnT]', instance: SelfT, owner: type[SelfT] | None) -> ReturnT: ...
234
+ @overload
235
+ def __get__(self: 'zproperty[AsyncSelfT, ReturnT]', instance: SelfT, owner: type[SelfT] | None) -> 'BoundZyncMethod[SelfT, [], ReturnT]': ...
236
+ def __get__(self, instance: SelfT | None, owner: type[SelfT] | None) -> 'Self | ReturnT | BoundZyncMethod[SelfT, [], ReturnT]':
237
+ if instance is None:
238
+ return self
239
+ elif isinstance(instance, SyncMixin):
240
+ return BoundZyncMethod(self.func, instance).run_sync()
241
+ elif isinstance(instance, AsyncMixin):
242
+ return BoundZyncMethod(self.func, instance)
243
+ raise TypeError(f'{type(self).__name__} can only be accessed on instances of SyncMixin or AsyncMixin')
zyncio/py.typed ADDED
File without changes
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: zyncio
3
+ Version: 0.1.0
4
+ Summary: Write dual sync/async interfaces with minimal duplication.
5
+ Project-URL: Documentation, https://github.com/BenjyWiener/zyncio#readme
6
+ Project-URL: Issues, https://github.com/BenjyWiener/zyncio/issues
7
+ Project-URL: Source, https://github.com/BenjyWiener/zyncio
8
+ Author-email: Benjy Wiener <benjywiener@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: Implementation :: CPython
19
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: typing-extensions~=4.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: coverage; extra == 'dev'
24
+ Requires-Dist: pytest; extra == 'dev'
25
+ Requires-Dist: pytest-cov; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # `zyncio`
29
+
30
+ ## Write dual sync/async interfaces with minimal duplication.
31
+
32
+ > If I had a nickel for every almost identical interface I had to write,
33
+ > I'd have two nickels... which isn't a lot, but it's weird that I had to
34
+ > write it twice.
35
+ >
36
+ > – Dr. Doofenshmirtz, before discovering zyncio.
37
+
38
+ # What is `zyncio`?
39
+
40
+ `zyncio` allows you to write interfaces that can be used synchronously and asynchronously,
41
+ while avoiding the code duplication this usually entails.
42
+
43
+ # How does it work?
44
+
45
+ `zyncio` works due to the fact that in Python you can actually run a coroutine **without an event loop**,
46
+ as long as your chain of `await`s consists exclusively of other coroutines (i.e. no `Future`s or `Task`s):
47
+
48
+ > The behavior of `await coroutine` is effectively the same as invoking a regular, synchronous Python function.
49
+ >
50
+ > – [A Conceptual Overview of `asyncio`](https://docs.python.org/3/howto/a-conceptual-overview-of-asyncio.html#await)
51
+
52
+ To run such a coroutine, we simply call `send(None)`, catch the `StopIteration`, and extract its `value`:
53
+
54
+ ```python
55
+ coro = pure_coroutine_func()
56
+ try:
57
+ coro.send(None)
58
+ except StopIteration as e:
59
+ ret = e.value
60
+ ```
61
+
62
+ This means that a single `async def` function can be made to run in both synchronous and asynchronous
63
+ contexts, as long as we have a way to determine which mode we're currently using:
64
+
65
+ ```python
66
+ async def zync_sleep(zync_mode: zyncio.Mode, secs: float) -> None:
67
+ if zync_mode is zyncio.SYNC:
68
+ time.sleep(secs)
69
+ else:
70
+ await asyncio.sleep(secs)
71
+ ```
72
+
73
+ But this isn't very convenient; you need to pass an additional parameter, and running in
74
+ sync mode is pretty clunky. That's where `zyncio.zfunc` comes in:
75
+
76
+ ```python
77
+ @zyncio.zfunc
78
+ async def zync_sleep(zync_mode: zyncio.Mode, secs: float) -> None:
79
+ ...
80
+
81
+ zync_sleep.run_sync(3)
82
+ asyncio.run(zync_sleep.run_async(3))
83
+
84
+ @zyncio.zfunc
85
+ async def sleep_3(zync_mode: zyncio.Mode) -> None:
86
+ await zync_sleep.run_zync(zync_mode, 3)
87
+ ```
88
+
89
+ ## The real magic: `SyncMixin`/`AsyncMixin`, `zyncio.zmethod`, and `zyncio.zproperty`
90
+
91
+ The real power of `zyncio` comes out when implementing client interfaces:
92
+
93
+ 1. Implement a single base client, using the `zyncio.zmethod` and `zyncio.zproperty`
94
+ decorators.
95
+
96
+ 2. Create two subclasses a sync client and an async client, adding the `zyncio.SyncMixin`
97
+ and `zyncio.AsyncMixin` mixins respectively.
98
+
99
+ 3. All of your `zyncio.zmethod`s magically become sync methods on the sync client and async
100
+ methods on the async client.
101
+
102
+ All of the `zyncio.zproperty`s magically become properties on the sync client, and async
103
+ methods on the async client.
104
+
105
+ ```python
106
+ class BaseClient:
107
+ def __init__(self, sock: socket.socket) -> None:
108
+ self.sock: socket.socket = sock
109
+
110
+ @zyncio.zmethod
111
+ async def send_msg(self, zync_mode: zyncio.Mode, data: bytes) -> None:
112
+ if zync_mode is zyncio.SYNC:
113
+ self.sock.sendall(data)
114
+ else:
115
+ loop = asyncio.get_running_loop()
116
+ await loop.sock_sendall(self.sock, data)
117
+
118
+ @zyncio.zmethod
119
+ async def recv_msg(self, zync_mode: zyncio.Mode, n: int) -> bytes:
120
+ buf = b''
121
+ if zync_mode is zyncio.SYNC:
122
+ while len(buf) < n:
123
+ buf += self.sock.recv(n)
124
+ else:
125
+ loop = asyncio.get_running_loop()
126
+ while len(buf) < n:
127
+ buf += await loop.sock_recv(self.sock, n)
128
+ return buf
129
+
130
+ @zyncio.zmethod
131
+ async def do_handshake(self, zync_mode: zyncio.Mode) -> None:
132
+ await self.send_msg.run_zync(zync_mode, HANDSHAKE_REQ)
133
+ response = await self.recv_msg.run_zync(zync_mode, len(HANDSHAKE_RESP))
134
+ if response != HANDSHAKE_RESP:
135
+ raise RuntimeError('Handshake failed')
136
+
137
+ @zyncio.zproperty
138
+ async def status(self, zync_mode: zyncio.Mode) -> str:
139
+ await self.send_msg.run_zync(zync_mode, STATUS_REQ)
140
+ return (await self.recv_msg.run_zync(zync_mode, STATUS_RESP_LEN)).decode()
141
+
142
+
143
+ class SyncClient(BaseClient, zyncio.SyncMixin):
144
+ pass
145
+
146
+
147
+ class AsyncClient(BaseClient, zyncio.AsyncMixin):
148
+ def __init__(self, sock: socket.socket) -> None:
149
+ super().__init__(sock)
150
+ self.sock.setblocking(False)
151
+
152
+
153
+ sync_client = SyncClient(sock)
154
+ sync_client.do_handshake() # Magically sync!
155
+ print('Status:', sync_client.status) # Sync property
156
+
157
+
158
+ async def use_async_client():
159
+ async_client = AsyncClient(sock)
160
+ await async_client.do_handshake() # Magically async!
161
+ print('Status:', await sync_client.status()) # Async func
162
+
163
+ asyncio.run(use_async_client())
164
+ ```
165
+
166
+ # Typing
167
+
168
+ `zyncio` is fully typed, and built specifically for typed projects. If you're getting
169
+ unexepcted type checking errors, please [open an issue](https://github.com/BenjyWiener/zyncio/issues).
@@ -0,0 +1,6 @@
1
+ zyncio/__init__.py,sha256=8n_oe7_cJxOQq8NF9JRakK8bw51yQkj0Pv1QSPIqddU,9504
2
+ zyncio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ zyncio-0.1.0.dist-info/METADATA,sha256=vVVl-g1XGniPrsI5qYqEVvvvdgjPqSvwtdUMg2MytO0,5877
4
+ zyncio-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ zyncio-0.1.0.dist-info/licenses/LICENSE,sha256=ZQlZ_loQOM5bqucZC4VC-ha3VmWsx39qjxnGF9yY9JU,1069
6
+ zyncio-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjy Wiener
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.