zyncio 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.
@@ -0,0 +1,55 @@
1
+ name: Make release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "*"
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ build:
11
+ name: Build source distribution and wheel
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Check out repository
15
+ uses: actions/checkout@v6
16
+ - name: Install Python 3.x
17
+ uses: actions/setup-python@v6
18
+ with:
19
+ python-version: 3.x
20
+ - name: Install build
21
+ run: pip install build
22
+ - name: Build sdist & wheel
23
+ run: python -m build
24
+ - name: Save sdist & wheel
25
+ uses: actions/upload-artifact@v5
26
+ with:
27
+ name: dist
28
+ path: |
29
+ dist/*.tar.gz
30
+ dist/*.whl
31
+
32
+ upload:
33
+ name: Upload
34
+ needs:
35
+ - build
36
+ runs-on: ubuntu-latest
37
+ # Don't release when running the workflow manually from GitHub's UI.
38
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
39
+ permissions:
40
+ id-token: write
41
+ attestations: write
42
+ contents: write
43
+ steps:
44
+ - name: Download artifacts
45
+ uses: actions/download-artifact@v4
46
+ with:
47
+ pattern: dist
48
+ merge-multiple: true
49
+ path: dist
50
+ - name: Attest provenance
51
+ uses: actions/attest-build-provenance@v2
52
+ with:
53
+ subject-path: dist/*
54
+ - name: Upload to PyPI
55
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,43 @@
1
+ name: Run static analyses
2
+
3
+ on:
4
+ push:
5
+ workflow_dispatch:
6
+
7
+ jobs:
8
+ lint:
9
+ name: Lint source with Ruff
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Check out repository
13
+ uses: actions/checkout@v6
14
+ - name: Run ruff check
15
+ uses: astral-sh/ruff-action@v3
16
+ with:
17
+ version: latest
18
+ - name: Run ruff format
19
+ run: ruff format --diff
20
+
21
+ type-check:
22
+ name: Type-check with Pyright
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ python-version:
28
+ - "3.10"
29
+ - "3.11"
30
+ - "3.12"
31
+ - "3.13"
32
+ - "3.14"
33
+ steps:
34
+ - name: Check out repository
35
+ uses: actions/checkout@v6
36
+ - name: Set up Python ${{ matrix.python-version }}
37
+ uses: actions/setup-python@v6
38
+ with:
39
+ python-version: ${{ matrix.python-version }}
40
+ - name: Install dependencies
41
+ run: pip install -e .[dev]
42
+ - name: Run Pyright
43
+ uses: jakebailey/pyright-action@v1
@@ -0,0 +1,75 @@
1
+ name: Run tests
2
+
3
+ on:
4
+ push:
5
+ workflow_dispatch:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ python-version:
14
+ - "3.10"
15
+ - "3.11"
16
+ - "3.12"
17
+ - "3.13"
18
+ - "3.14"
19
+ steps:
20
+ - name: Check out repository
21
+ uses: actions/checkout@v6
22
+ - name: Set up Python ${{ matrix.python-version }}
23
+ uses: actions/setup-python@v6
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+ - name: Install dependencies
27
+ run: pip install -e .[dev]
28
+ - name: Run tests
29
+ run: pytest --cov --cov-report=
30
+ env:
31
+ COVERAGE_FILE: coverage/coverage-${{ matrix.python-version }}
32
+ - name: Upload coverage files
33
+ uses: actions/upload-artifact@v5
34
+ with:
35
+ name: coverage-${{ matrix.python-version }}
36
+ path: coverage
37
+
38
+ coverage-merge:
39
+ needs: [test]
40
+ runs-on: ubuntu-latest
41
+ permissions:
42
+ statuses: write
43
+ steps:
44
+ - uses: actions/checkout@v6
45
+ - name: Set up Python 3.14
46
+ uses: actions/setup-python@v6
47
+ with:
48
+ python-version: "3.14"
49
+ - name: Install dependencies
50
+ run: pip install coverage
51
+ - name: Get coverage files
52
+ uses: actions/download-artifact@v6
53
+ with:
54
+ pattern: coverage-*
55
+ path: coverage
56
+ merge-multiple: true
57
+ - run: coverage combine coverage/*
58
+ - name: Create coverage status
59
+ run: |
60
+ coverage="$(coverage report --format=total)"
61
+ state="$([[ "$coverage" = "100.00" ]] && echo "success" || echo "failure")"
62
+
63
+ curl -sS --fail-with-body -X POST \
64
+ -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
65
+ -H "Accept: application/vnd.github+json" \
66
+ https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \
67
+ -d @- <<EOF
68
+ {
69
+ "state": "${state}",
70
+ "context": "coverage",
71
+ "description": "Coverage ${coverage}%",
72
+ "target_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
73
+ }
74
+ EOF
75
+ - run: coverage report --fail-under=100
@@ -0,0 +1 @@
1
+ __pycache__/
zyncio-0.1.0/LICENSE ADDED
@@ -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.
zyncio-0.1.0/PKG-INFO ADDED
@@ -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).
zyncio-0.1.0/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # `zyncio`
2
+
3
+ ## Write dual sync/async interfaces with minimal duplication.
4
+
5
+ > If I had a nickel for every almost identical interface I had to write,
6
+ > I'd have two nickels... which isn't a lot, but it's weird that I had to
7
+ > write it twice.
8
+ >
9
+ > – Dr. Doofenshmirtz, before discovering zyncio.
10
+
11
+ # What is `zyncio`?
12
+
13
+ `zyncio` allows you to write interfaces that can be used synchronously and asynchronously,
14
+ while avoiding the code duplication this usually entails.
15
+
16
+ # How does it work?
17
+
18
+ `zyncio` works due to the fact that in Python you can actually run a coroutine **without an event loop**,
19
+ as long as your chain of `await`s consists exclusively of other coroutines (i.e. no `Future`s or `Task`s):
20
+
21
+ > The behavior of `await coroutine` is effectively the same as invoking a regular, synchronous Python function.
22
+ >
23
+ > – [A Conceptual Overview of `asyncio`](https://docs.python.org/3/howto/a-conceptual-overview-of-asyncio.html#await)
24
+
25
+ To run such a coroutine, we simply call `send(None)`, catch the `StopIteration`, and extract its `value`:
26
+
27
+ ```python
28
+ coro = pure_coroutine_func()
29
+ try:
30
+ coro.send(None)
31
+ except StopIteration as e:
32
+ ret = e.value
33
+ ```
34
+
35
+ This means that a single `async def` function can be made to run in both synchronous and asynchronous
36
+ contexts, as long as we have a way to determine which mode we're currently using:
37
+
38
+ ```python
39
+ async def zync_sleep(zync_mode: zyncio.Mode, secs: float) -> None:
40
+ if zync_mode is zyncio.SYNC:
41
+ time.sleep(secs)
42
+ else:
43
+ await asyncio.sleep(secs)
44
+ ```
45
+
46
+ But this isn't very convenient; you need to pass an additional parameter, and running in
47
+ sync mode is pretty clunky. That's where `zyncio.zfunc` comes in:
48
+
49
+ ```python
50
+ @zyncio.zfunc
51
+ async def zync_sleep(zync_mode: zyncio.Mode, secs: float) -> None:
52
+ ...
53
+
54
+ zync_sleep.run_sync(3)
55
+ asyncio.run(zync_sleep.run_async(3))
56
+
57
+ @zyncio.zfunc
58
+ async def sleep_3(zync_mode: zyncio.Mode) -> None:
59
+ await zync_sleep.run_zync(zync_mode, 3)
60
+ ```
61
+
62
+ ## The real magic: `SyncMixin`/`AsyncMixin`, `zyncio.zmethod`, and `zyncio.zproperty`
63
+
64
+ The real power of `zyncio` comes out when implementing client interfaces:
65
+
66
+ 1. Implement a single base client, using the `zyncio.zmethod` and `zyncio.zproperty`
67
+ decorators.
68
+
69
+ 2. Create two subclasses a sync client and an async client, adding the `zyncio.SyncMixin`
70
+ and `zyncio.AsyncMixin` mixins respectively.
71
+
72
+ 3. All of your `zyncio.zmethod`s magically become sync methods on the sync client and async
73
+ methods on the async client.
74
+
75
+ All of the `zyncio.zproperty`s magically become properties on the sync client, and async
76
+ methods on the async client.
77
+
78
+ ```python
79
+ class BaseClient:
80
+ def __init__(self, sock: socket.socket) -> None:
81
+ self.sock: socket.socket = sock
82
+
83
+ @zyncio.zmethod
84
+ async def send_msg(self, zync_mode: zyncio.Mode, data: bytes) -> None:
85
+ if zync_mode is zyncio.SYNC:
86
+ self.sock.sendall(data)
87
+ else:
88
+ loop = asyncio.get_running_loop()
89
+ await loop.sock_sendall(self.sock, data)
90
+
91
+ @zyncio.zmethod
92
+ async def recv_msg(self, zync_mode: zyncio.Mode, n: int) -> bytes:
93
+ buf = b''
94
+ if zync_mode is zyncio.SYNC:
95
+ while len(buf) < n:
96
+ buf += self.sock.recv(n)
97
+ else:
98
+ loop = asyncio.get_running_loop()
99
+ while len(buf) < n:
100
+ buf += await loop.sock_recv(self.sock, n)
101
+ return buf
102
+
103
+ @zyncio.zmethod
104
+ async def do_handshake(self, zync_mode: zyncio.Mode) -> None:
105
+ await self.send_msg.run_zync(zync_mode, HANDSHAKE_REQ)
106
+ response = await self.recv_msg.run_zync(zync_mode, len(HANDSHAKE_RESP))
107
+ if response != HANDSHAKE_RESP:
108
+ raise RuntimeError('Handshake failed')
109
+
110
+ @zyncio.zproperty
111
+ async def status(self, zync_mode: zyncio.Mode) -> str:
112
+ await self.send_msg.run_zync(zync_mode, STATUS_REQ)
113
+ return (await self.recv_msg.run_zync(zync_mode, STATUS_RESP_LEN)).decode()
114
+
115
+
116
+ class SyncClient(BaseClient, zyncio.SyncMixin):
117
+ pass
118
+
119
+
120
+ class AsyncClient(BaseClient, zyncio.AsyncMixin):
121
+ def __init__(self, sock: socket.socket) -> None:
122
+ super().__init__(sock)
123
+ self.sock.setblocking(False)
124
+
125
+
126
+ sync_client = SyncClient(sock)
127
+ sync_client.do_handshake() # Magically sync!
128
+ print('Status:', sync_client.status) # Sync property
129
+
130
+
131
+ async def use_async_client():
132
+ async_client = AsyncClient(sock)
133
+ await async_client.do_handshake() # Magically async!
134
+ print('Status:', await sync_client.status()) # Async func
135
+
136
+ asyncio.run(use_async_client())
137
+ ```
138
+
139
+ # Typing
140
+
141
+ `zyncio` is fully typed, and built specifically for typed projects. If you're getting
142
+ unexepcted type checking errors, please [open an issue](https://github.com/BenjyWiener/zyncio/issues).
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "zyncio"
3
+ dynamic = ["version"]
4
+ description = "Write dual sync/async interfaces with minimal duplication."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ keywords = []
9
+ authors = [{ name = "Benjy Wiener", email = "benjywiener@gmail.com" }]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Programming Language :: Python",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: Implementation :: CPython",
19
+ "Programming Language :: Python :: Implementation :: PyPy",
20
+ ]
21
+ dependencies = ["typing_extensions~=4.0"]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["coverage", "pytest", "pytest-cov"]
25
+
26
+ [project.urls]
27
+ Documentation = "https://github.com/BenjyWiener/zyncio#readme"
28
+ Issues = "https://github.com/BenjyWiener/zyncio/issues"
29
+ Source = "https://github.com/BenjyWiener/zyncio"
30
+
31
+ [build-system]
32
+ requires = ["hatchling", "hatch-vcs"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.version]
36
+ source = "vcs"
37
+
38
+ [tool.ruff]
39
+ target-version = "py310"
40
+ line-length = 150
41
+
42
+ [tool.ruff.format]
43
+ quote-style = "single"
44
+
45
+ [tool.ruff.lint]
46
+ extend-select = ["D", "I"]
47
+ ignore = [
48
+ "D105", # undocumented-magic-method
49
+ ]
50
+
51
+ [tool.ruff.lint.pydocstyle]
52
+ convention = "pep257"
53
+
54
+ [tool.ruff.lint.isort]
55
+ force-sort-within-sections = true
56
+ lines-after-imports = 2
57
+ extra-standard-library = ["typing_extensions"]
58
+
59
+ [tool.pyright]
60
+ reportUnnecessaryTypeIgnoreComment = "error"
61
+
62
+ pythonVersion = "3.10"
63
+ [tool.coverage.run]
64
+ source_pkgs = ["zyncio", "tests"]
65
+ branch = true
66
+
67
+ [tool.coverage.paths]
68
+ zyncio = ["src/zyncio"]
69
+ tests = ["tests"]
70
+
71
+ [tool.coverage.report]
72
+ precision = 2
73
+ exclude_lines = ["# pragma: no cover", "def __repr__", "@overload"]
74
+ omit = ["tests/typing_tests.py"]
75
+ show_missing = true