clavem 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,94 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ test:
10
+ name: Python 3.13 / ${{ matrix.os }}
11
+ runs-on: ${{ matrix.os }}
12
+ timeout-minutes: 10
13
+ permissions:
14
+ contents: read
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, windows-latest, macos-latest]
19
+ steps:
20
+ - name: Check out repository
21
+ uses: actions/checkout@v4
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v8.2.0
24
+ - name: Set up Python
25
+ uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.13"
28
+ - name: Install locked dependencies
29
+ run: uv sync --frozen
30
+ - name: Run transport regressions
31
+ run: uv run --frozen pytest -q
32
+
33
+ build:
34
+ name: Build package
35
+ needs: test
36
+ runs-on: ubuntu-latest
37
+
38
+ permissions:
39
+ contents: read
40
+
41
+ steps:
42
+ - name: Check out repository
43
+ uses: actions/checkout@v4
44
+
45
+ - name: Install uv
46
+ uses: astral-sh/setup-uv@v8.2.0
47
+
48
+ - name: Set up Python
49
+ uses: actions/setup-python@v5
50
+ with:
51
+ python-version: "3.13"
52
+
53
+ - name: Build package
54
+ run: uv build --no-sources
55
+
56
+ - name: Check wheel import
57
+ run: uv run --with dist/*.whl --no-project -- python -I -c "from clavem.transport import connect, serve; import clavem; print(clavem.__file__)"
58
+
59
+ - name: Upload package distributions
60
+ uses: actions/upload-artifact@v4
61
+ with:
62
+ name: python-package-distributions
63
+ path: dist/
64
+
65
+ publish:
66
+ name: Publish package
67
+ needs: build
68
+ runs-on: ubuntu-latest
69
+ environment:
70
+ name: pypi
71
+ url: https://pypi.org/project/clavem/
72
+
73
+ permissions:
74
+ contents: write
75
+ id-token: write
76
+
77
+ steps:
78
+ - name: Download package distributions
79
+ uses: actions/download-artifact@v4
80
+ with:
81
+ name: python-package-distributions
82
+ path: dist/
83
+
84
+ - name: Publish package
85
+ uses: pypa/gh-action-pypi-publish@release/v1
86
+
87
+ - name: Create GitHub Release
88
+ run: >-
89
+ gh release create "$GITHUB_REF_NAME"
90
+ dist/*.whl dist/*.tar.gz
91
+ --repo "$GITHUB_REPOSITORY"
92
+ --verify-tag --generate-notes
93
+ env:
94
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,224 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ *.lcov
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ # Pipfile.lock
97
+
98
+ # uv
99
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # uv.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ # poetry.lock
110
+ # poetry.toml
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
115
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
116
+ # pdm.lock
117
+ # pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # pixi
122
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
123
+ # pixi.lock
124
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
125
+ # in the .venv directory. It is recommended not to include this directory in version control.
126
+ .pixi/*
127
+ !.pixi/config.toml
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule*
134
+ celerybeat.pid
135
+
136
+ # Redis
137
+ *.rdb
138
+ *.aof
139
+ *.pid
140
+
141
+ # RabbitMQ
142
+ mnesia/
143
+ rabbitmq/
144
+ rabbitmq-data/
145
+
146
+ # ActiveMQ
147
+ activemq-data/
148
+
149
+ # SageMath parsed files
150
+ *.sage.py
151
+
152
+ # Environments
153
+ .env
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs/Zensical documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
201
+ # can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer, you
203
+ # could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+ # Temporary file for partial code execution
206
+ tempCodeRunnerFile.py
207
+
208
+ # Ruff stuff:
209
+ .ruff_cache/
210
+
211
+ # PyPI configuration file
212
+ .pypirc
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
218
+
219
+ # Streamlit
220
+ .streamlit/secrets.toml
221
+
222
+ # Local pre-shared keys
223
+ *.psk
224
+ psk.bin
clavem-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cham
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.
clavem-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: clavem
3
+ Version: 0.1.0
4
+ Summary: Small async TLS-PSK message channels.
5
+ Project-URL: Repository, https://github.com/Chamkang6093/clavem
6
+ Author: Cham
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: asyncio,messaging,psk,tls
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+
18
+ # clavem
19
+
20
+ Small async TLS-PSK message channels. Python 3.13+, no third-party runtime dependencies.
21
+
22
+ Clavem authenticates peers with a shared random key and carries length-prefixed byte
23
+ messages over one TLS 1.3 connection. It does not reconnect, schedule heartbeats,
24
+ serialize application data, or configure logging.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install clavem
30
+ ```
31
+
32
+ For an unpublished checkout, use `uv sync` and `uv run` instead. Both endpoints
33
+ need a Python/OpenSSL build with PSK support:
34
+
35
+ ```bash
36
+ python -c "import ssl; print(ssl.OPENSSL_VERSION, ssl.HAS_PSK)"
37
+ ```
38
+
39
+ `HAS_PSK` must be `True`. Check this on the exact interpreter used for deployment
40
+ and on the interpreter bundled into a desktop executable.
41
+
42
+ ## Connect and exchange messages
43
+
44
+ Create one 32-byte random key and securely provision the same bytes on both peers.
45
+ Use a different key for unrelated services. The identity is public, not a password.
46
+
47
+ ```python
48
+ from pathlib import Path
49
+ import asyncio
50
+
51
+ from clavem.transport import connect
52
+
53
+
54
+ async def main() -> None:
55
+ key = Path("psk.bin").read_bytes()
56
+ async with await connect("127.0.0.1", 9443, identity="home", key=key) as channel:
57
+ await channel.send(b"hello")
58
+ async with asyncio.timeout(5):
59
+ reply = await channel.receive()
60
+ print(reply)
61
+
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ One task receives messages; multiple tasks may send, with complete frames serialized
67
+ by the channel. TCP_NODELAY is enabled on both peers: the client uses asyncio's
68
+ default, and the server explicitly enables it on accepted TLS sockets. Messages
69
+ can be empty and are limited to 65,536 bytes. The wire format is a four-byte
70
+ unsigned network-order length followed by that many bytes.
71
+
72
+ ## Serve
73
+
74
+ ```python
75
+ from pathlib import Path
76
+ import asyncio
77
+
78
+ from clavem.channel import Channel
79
+ from clavem.transport import serve
80
+
81
+
82
+ async def echo(channel: Channel) -> None:
83
+ try:
84
+ while True:
85
+ async with asyncio.timeout(30):
86
+ message = await channel.receive()
87
+ await channel.send(message)
88
+ except (asyncio.IncompleteReadError, ConnectionError, TimeoutError):
89
+ return
90
+
91
+
92
+ async def main() -> None:
93
+ key = Path("psk.bin").read_bytes()
94
+ async with serve(echo, "127.0.0.1", 9443, keys={"home": key}) as server:
95
+ await server.serve_forever()
96
+
97
+
98
+ asyncio.run(main())
99
+ ```
100
+
101
+ `serve()` owns accepted channels and handler tasks. Returning from a handler closes
102
+ its channel. Leaving the server context closes the listener and cancels and awaits
103
+ remaining handlers. Handle expected peer disconnects in your handler; unexpected
104
+ handler exceptions stop the server and propagate through its task group.
105
+
106
+ ## Contract
107
+
108
+ | API | Behavior |
109
+ | --- | --- |
110
+ | `connect(..., timeout=5)` | Bounds TCP connection and TLS authentication together. Returns a `Channel`. |
111
+ | `serve(..., handshake_timeout=5)` | Async context manager yielding an `asyncio.Server`. |
112
+ | `Channel.send(bytes)` | Checks the size before writing and applies stream backpressure. |
113
+ | `Channel.receive()` | Reads a complete frame; EOF raises `asyncio.IncompleteReadError`. |
114
+ | `Channel.close()` | Closes the stream; supported by `async with channel`. |
115
+ | `client_context()` / `server_context()` | TLS contexts for applications that need lower-level asyncio integration. |
116
+
117
+ An oversized incoming frame raises `clavem.channel.MessageTooLarge` and closes
118
+ the stream. An oversized outgoing message raises the same error before writing.
119
+ Network, TLS, and timeout exceptions retain their standard-library types. After
120
+ cancelling or timing out an in-progress receive or send, close the channel rather
121
+ than trying to reuse a potentially partial exchange.
122
+
123
+ TLS contexts require external PSK authentication. The client loads no trusted CAs,
124
+ the server loads no certificate and issues no session tickets, and the transport
125
+ checks that PSK authentication was negotiated. There is no plaintext fallback.
126
+ Clavem sets neither application idle timeouts nor connection-count limits; the
127
+ hosting application owns those policies.
128
+
129
+ ## Development and release
130
+
131
+ Use `uv sync` for the environment and committed `uv.lock`. Run `uv run pytest` for real
132
+ loopback TLS regressions: normal PSK exchange, rejection of untrusted and trusted
133
+ certificate fallback, and TCP_NODELAY checks on both peers. The publish workflow
134
+ runs these checks before building distributions.
135
+
136
+ Versions live in `pyproject.toml` and `uv.lock`. Release with a matching `vX.Y.Z`
137
+ tag after configuring the repository's `pypi` environment and the PyPI Trusted
138
+ Publisher. The workflow builds distributions, verifies an isolated wheel import,
139
+ publishes to PyPI, and attaches distributions to a GitHub Release.
clavem-0.1.0/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # clavem
2
+
3
+ Small async TLS-PSK message channels. Python 3.13+, no third-party runtime dependencies.
4
+
5
+ Clavem authenticates peers with a shared random key and carries length-prefixed byte
6
+ messages over one TLS 1.3 connection. It does not reconnect, schedule heartbeats,
7
+ serialize application data, or configure logging.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install clavem
13
+ ```
14
+
15
+ For an unpublished checkout, use `uv sync` and `uv run` instead. Both endpoints
16
+ need a Python/OpenSSL build with PSK support:
17
+
18
+ ```bash
19
+ python -c "import ssl; print(ssl.OPENSSL_VERSION, ssl.HAS_PSK)"
20
+ ```
21
+
22
+ `HAS_PSK` must be `True`. Check this on the exact interpreter used for deployment
23
+ and on the interpreter bundled into a desktop executable.
24
+
25
+ ## Connect and exchange messages
26
+
27
+ Create one 32-byte random key and securely provision the same bytes on both peers.
28
+ Use a different key for unrelated services. The identity is public, not a password.
29
+
30
+ ```python
31
+ from pathlib import Path
32
+ import asyncio
33
+
34
+ from clavem.transport import connect
35
+
36
+
37
+ async def main() -> None:
38
+ key = Path("psk.bin").read_bytes()
39
+ async with await connect("127.0.0.1", 9443, identity="home", key=key) as channel:
40
+ await channel.send(b"hello")
41
+ async with asyncio.timeout(5):
42
+ reply = await channel.receive()
43
+ print(reply)
44
+
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ One task receives messages; multiple tasks may send, with complete frames serialized
50
+ by the channel. TCP_NODELAY is enabled on both peers: the client uses asyncio's
51
+ default, and the server explicitly enables it on accepted TLS sockets. Messages
52
+ can be empty and are limited to 65,536 bytes. The wire format is a four-byte
53
+ unsigned network-order length followed by that many bytes.
54
+
55
+ ## Serve
56
+
57
+ ```python
58
+ from pathlib import Path
59
+ import asyncio
60
+
61
+ from clavem.channel import Channel
62
+ from clavem.transport import serve
63
+
64
+
65
+ async def echo(channel: Channel) -> None:
66
+ try:
67
+ while True:
68
+ async with asyncio.timeout(30):
69
+ message = await channel.receive()
70
+ await channel.send(message)
71
+ except (asyncio.IncompleteReadError, ConnectionError, TimeoutError):
72
+ return
73
+
74
+
75
+ async def main() -> None:
76
+ key = Path("psk.bin").read_bytes()
77
+ async with serve(echo, "127.0.0.1", 9443, keys={"home": key}) as server:
78
+ await server.serve_forever()
79
+
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ `serve()` owns accepted channels and handler tasks. Returning from a handler closes
85
+ its channel. Leaving the server context closes the listener and cancels and awaits
86
+ remaining handlers. Handle expected peer disconnects in your handler; unexpected
87
+ handler exceptions stop the server and propagate through its task group.
88
+
89
+ ## Contract
90
+
91
+ | API | Behavior |
92
+ | --- | --- |
93
+ | `connect(..., timeout=5)` | Bounds TCP connection and TLS authentication together. Returns a `Channel`. |
94
+ | `serve(..., handshake_timeout=5)` | Async context manager yielding an `asyncio.Server`. |
95
+ | `Channel.send(bytes)` | Checks the size before writing and applies stream backpressure. |
96
+ | `Channel.receive()` | Reads a complete frame; EOF raises `asyncio.IncompleteReadError`. |
97
+ | `Channel.close()` | Closes the stream; supported by `async with channel`. |
98
+ | `client_context()` / `server_context()` | TLS contexts for applications that need lower-level asyncio integration. |
99
+
100
+ An oversized incoming frame raises `clavem.channel.MessageTooLarge` and closes
101
+ the stream. An oversized outgoing message raises the same error before writing.
102
+ Network, TLS, and timeout exceptions retain their standard-library types. After
103
+ cancelling or timing out an in-progress receive or send, close the channel rather
104
+ than trying to reuse a potentially partial exchange.
105
+
106
+ TLS contexts require external PSK authentication. The client loads no trusted CAs,
107
+ the server loads no certificate and issues no session tickets, and the transport
108
+ checks that PSK authentication was negotiated. There is no plaintext fallback.
109
+ Clavem sets neither application idle timeouts nor connection-count limits; the
110
+ hosting application owns those policies.
111
+
112
+ ## Development and release
113
+
114
+ Use `uv sync` for the environment and committed `uv.lock`. Run `uv run pytest` for real
115
+ loopback TLS regressions: normal PSK exchange, rejection of untrusted and trusted
116
+ certificate fallback, and TCP_NODELAY checks on both peers. The publish workflow
117
+ runs these checks before building distributions.
118
+
119
+ Versions live in `pyproject.toml` and `uv.lock`. Release with a matching `vX.Y.Z`
120
+ tag after configuring the repository's `pypi` environment and the PyPI Trusted
121
+ Publisher. The workflow builds distributions, verifies an isolated wheel import,
122
+ publishes to PyPI, and attaches distributions to a GitHub Release.
File without changes
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ssl
5
+ import struct
6
+ from types import TracebackType
7
+
8
+ MAX_MESSAGE = 65_536
9
+ _HEADER = struct.Struct("!I")
10
+
11
+
12
+ class MessageTooLarge(ValueError):
13
+ """A message exceeds the channel's 64 KiB limit."""
14
+
15
+
16
+ class Channel:
17
+ """One reader and serialized writers on a length-prefixed byte stream."""
18
+
19
+ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
20
+ self._reader: asyncio.StreamReader = reader
21
+ self._writer: asyncio.StreamWriter = writer
22
+ self._send_lock: asyncio.Lock = asyncio.Lock()
23
+
24
+ async def send(self, message: bytes) -> None:
25
+ """Send one message; concurrent senders cannot interleave frames."""
26
+ if len(message) > MAX_MESSAGE:
27
+ raise MessageTooLarge("message exceeds 64 KiB")
28
+ async with self._send_lock:
29
+ self._writer.write(_HEADER.pack(len(message)) + message)
30
+ await self._writer.drain()
31
+
32
+ async def receive(self) -> bytes:
33
+ """Read one message. Only one task may receive at a time."""
34
+ header = await self._reader.readexactly(_HEADER.size)
35
+ (length,) = _HEADER.unpack(header)
36
+ if length > MAX_MESSAGE:
37
+ self._writer.close()
38
+ raise MessageTooLarge("peer message exceeds 64 KiB; channel closed")
39
+ return await self._reader.readexactly(length)
40
+
41
+ async def close(self) -> None:
42
+ """Close the stream, bounded by its configured TLS shutdown timeout."""
43
+ self._writer.close()
44
+ try:
45
+ await self._writer.wait_closed()
46
+ except (ConnectionError, ssl.SSLError, TimeoutError):
47
+ # A disconnected peer cannot acknowledge TLS shutdown.
48
+ pass
49
+
50
+ async def __aenter__(self) -> Channel:
51
+ return self
52
+
53
+ async def __aexit__(
54
+ self,
55
+ exc_type: type[BaseException] | None,
56
+ exc: BaseException | None,
57
+ traceback: TracebackType | None,
58
+ ) -> None:
59
+ await self.close()
File without changes
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import ssl
4
+ from collections.abc import Mapping
5
+
6
+
7
+ def _validate(identity: str, key: bytes) -> None:
8
+ if not identity or len(identity.encode("utf-8")) > 255 or "\0" in identity:
9
+ raise ValueError("identity must contain 1-255 UTF-8 bytes without NUL")
10
+ if len(key) != 32:
11
+ raise ValueError("key must contain 32 randomly generated bytes")
12
+
13
+
14
+ def _context(protocol: int) -> ssl.SSLContext:
15
+ if not ssl.HAS_PSK:
16
+ raise RuntimeError("this Python/OpenSSL build does not support TLS-PSK")
17
+ context = ssl.SSLContext(protocol)
18
+ context.minimum_version = ssl.TLSVersion.TLSv1_3
19
+ context.maximum_version = ssl.TLSVersion.TLSv1_3
20
+ return context
21
+
22
+
23
+ def client_context(identity: str, key: bytes) -> ssl.SSLContext:
24
+ """Authenticate with an external PSK; do not trust certificate fallback."""
25
+ _validate(identity, key)
26
+ context = _context(ssl.PROTOCOL_TLS_CLIENT)
27
+ context.check_hostname = False
28
+ # A successful external-PSK handshake needs no CA. Certificate fallback
29
+ # must still fail verification rather than authenticate an unrelated peer.
30
+ context.verify_mode = ssl.CERT_REQUIRED
31
+ context.set_psk_client_callback(lambda _hint: (identity, key))
32
+ return context
33
+
34
+
35
+ def server_context(keys: Mapping[str, bytes]) -> ssl.SSLContext:
36
+ """Accept only the supplied identities and their external PSKs."""
37
+ if not keys:
38
+ raise ValueError("at least one identity is required")
39
+ allowed = dict(keys)
40
+ for identity, key in allowed.items():
41
+ _validate(identity, key)
42
+ context = _context(ssl.PROTOCOL_TLS_SERVER)
43
+ context.num_tickets = 0
44
+
45
+ def lookup(identity: str | None) -> bytes:
46
+ if identity not in allowed:
47
+ return b"" # Decline unknown PSKs; no certificate fallback is configured.
48
+ return allowed[identity]
49
+
50
+ context.set_psk_server_callback(lookup)
51
+ return context
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+ import ssl
6
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
7
+ from contextlib import asynccontextmanager
8
+
9
+ from clavem.channel import MAX_MESSAGE, Channel
10
+ from clavem.tls import client_context, server_context
11
+
12
+ Handler = Callable[[Channel], Awaitable[None]]
13
+
14
+
15
+ async def connect(
16
+ host: str,
17
+ port: int,
18
+ *,
19
+ identity: str,
20
+ key: bytes,
21
+ timeout: float = 5, # noqa: ASYNC109 - also bounds the TLS handshake.
22
+ ) -> Channel:
23
+ """Establish a TLS 1.3 external-PSK channel within timeout seconds."""
24
+ if timeout <= 0:
25
+ raise ValueError("timeout must be positive")
26
+ context = client_context(identity, key)
27
+ async with asyncio.timeout(timeout):
28
+ reader, writer = await asyncio.open_connection(
29
+ host,
30
+ port,
31
+ ssl=context,
32
+ server_hostname="",
33
+ ssl_handshake_timeout=timeout,
34
+ ssl_shutdown_timeout=2,
35
+ limit=MAX_MESSAGE + 4,
36
+ )
37
+ channel = Channel(reader, writer)
38
+ if not writer.get_extra_info("ssl_object").session_reused:
39
+ await channel.close()
40
+ raise ssl.SSLError("peer did not authenticate with an external PSK")
41
+ return channel
42
+
43
+
44
+ @asynccontextmanager
45
+ async def serve(
46
+ handler: Handler,
47
+ host: str,
48
+ port: int,
49
+ *,
50
+ keys: Mapping[str, bytes],
51
+ handshake_timeout: float = 5,
52
+ ) -> AsyncIterator[asyncio.Server]:
53
+ """Own a listener and its handlers; cancel handlers when leaving the context.
54
+
55
+ A handler owns one authenticated channel for its lifetime. Unexpected
56
+ handler errors propagate through the task group and stop the listener.
57
+ Applications should handle expected disconnects in their handler.
58
+ """
59
+ if handshake_timeout <= 0:
60
+ raise ValueError("handshake_timeout must be positive")
61
+ context = server_context(keys)
62
+ tasks: set[asyncio.Task[None]] = set()
63
+
64
+ async def run(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
65
+ async with Channel(reader, writer) as channel:
66
+ if not writer.get_extra_info("ssl_object").session_reused:
67
+ raise ssl.SSLError("peer did not authenticate with an external PSK")
68
+ # Accepted sockets do not consistently inherit asyncio's TCP_NODELAY default.
69
+ writer.get_extra_info("socket").setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
70
+ await handler(channel)
71
+
72
+ async with asyncio.TaskGroup() as group:
73
+
74
+ def accept(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
75
+ task = group.create_task(run(reader, writer))
76
+ tasks.add(task)
77
+ task.add_done_callback(tasks.discard)
78
+
79
+ server = await asyncio.start_server(
80
+ accept,
81
+ host,
82
+ port,
83
+ ssl=context,
84
+ ssl_handshake_timeout=handshake_timeout,
85
+ ssl_shutdown_timeout=2,
86
+ limit=MAX_MESSAGE + 4,
87
+ )
88
+ try:
89
+ yield server
90
+ finally:
91
+ server.close()
92
+ for task in tasks:
93
+ task.cancel()
94
+ await server.wait_closed()
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build.targets.wheel]
6
+ packages = ["clavem"]
7
+
8
+ [project]
9
+ name = "clavem"
10
+ version = "0.1.0"
11
+ description = "Small async TLS-PSK message channels."
12
+ readme = "README.md"
13
+ requires-python = ">=3.13"
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+ authors = [{ name = "Cham" }]
17
+ keywords = ["asyncio", "tls", "psk", "messaging"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/Chamkang6093/clavem"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8.3", "pytest-asyncio>=1.0", "ruff>=0.11"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ asyncio_mode = "auto"
36
+ asyncio_default_fixture_loop_scope = "function"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py313"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B", "ASYNC", "PTH", "RUF"]
44
+
45
+ [tool.ruff.format]
46
+ docstring-code-format = true
47
+ quote-style = "double"
48
+ line-ending = "lf"
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import secrets
5
+ import ssl
6
+ from collections.abc import AsyncIterator
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ import pytest_asyncio
11
+
12
+
13
+ @pytest.fixture(scope="session", autouse=True)
14
+ def require_psk() -> None:
15
+ assert ssl.HAS_PSK, "transport regressions require a Python/OpenSSL build with PSK"
16
+
17
+
18
+ @pytest.fixture
19
+ def key() -> bytes:
20
+ return secrets.token_bytes(32)
21
+
22
+
23
+ @pytest.fixture
24
+ def certificate() -> Path:
25
+ return Path(__file__).parent / "data" / "server-cert.pem"
26
+
27
+
28
+ @pytest_asyncio.fixture
29
+ async def certificate_server(certificate: Path) -> AsyncIterator[tuple[int, list[bool]]]:
30
+ """A real TLS server that declines PSK and offers a test-only certificate."""
31
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
32
+ context.minimum_version = ssl.TLSVersion.TLSv1_3
33
+ context.maximum_version = ssl.TLSVersion.TLSv1_3
34
+ context.load_cert_chain(certificate, certificate.with_name("server-key.pem"))
35
+ context.set_psk_server_callback(lambda _identity: b"")
36
+ context.num_tickets = 0
37
+ accepted: list[bool] = []
38
+
39
+ def accept(_reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
40
+ accepted.append(writer.get_extra_info("ssl_object").session_reused)
41
+ writer.close()
42
+
43
+ server = await asyncio.start_server(
44
+ accept,
45
+ "127.0.0.1",
46
+ 0,
47
+ ssl=context,
48
+ ssl_handshake_timeout=2,
49
+ ssl_shutdown_timeout=1,
50
+ )
51
+ try:
52
+ yield server.sockets[0].getsockname()[1], accepted
53
+ finally:
54
+ server.close()
55
+ server.close_clients()
56
+ await server.wait_closed()
@@ -0,0 +1,4 @@
1
+ The certificate and private key in this directory are public, test-only fixtures
2
+ for a loopback server. They are not deployment credentials and must never be used
3
+ outside tests. The client trusts this certificate only in the explicit certificate
4
+ fallback regression; the system trust store is never modified.
@@ -0,0 +1,12 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIBsjCCAVegAwIBAgIUdZbNcJpxwDLIu8sRDJvdBlTy318wCgYIKoZIzj0EAwIw
3
+ HjEcMBoGA1UEAwwTY2xhdmVtLXRlc3QuaW52YWxpZDAeFw0yNjExMTExMDE2Mzda
4
+ Fw0zNjExMDgxMDE2MzdaMB4xHDAaBgNVBAMME2NsYXZlbS10ZXN0LmludmFsaWQw
5
+ WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASECx4F3R54DKWC3Mung5Ldkki2dUVK
6
+ O4u+swzHE4mthGPGEJxbWIJgdAFQrtajS5dZQtmUQ8uHkXqFdloZX3YXo3MwcTAd
7
+ BgNVHQ4EFgQUU24EwuFL7SGJYVU1IxhKRXb7rcowHwYDVR0jBBgwFoAUU24EwuFL
8
+ 7SGJYVU1IxhKRXb7rcowDwYDVR0TAQH/BAUwAwEB/zAeBgNVHREEFzAVghNjbGF2
9
+ ZW0tdGVzdC5pbnZhbGlkMAoGCCqGSM49BAMCA0kAMEYCIQCGi7jSR0VNUyYyoAFB
10
+ 8aTdM11WPjNb/btQkrOnimB2mgIhAMSE6eZeoyVKRuSUYf0lt0Syiyj7c7RA0jvH
11
+ ypg4hR3Q
12
+ -----END CERTIFICATE-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN PRIVATE KEY-----
2
+ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgOkroYoQ1yr0YPdBG
3
+ 24N92nZqeIT8TRMQo81t7FlZDyShRANCAASECx4F3R54DKWC3Mung5Ldkki2dUVK
4
+ O4u+swzHE4mthGPGEJxbWIJgdAFQrtajS5dZQtmUQ8uHkXqFdloZX3YX
5
+ -----END PRIVATE KEY-----
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+
6
+ from clavem.channel import Channel
7
+ from clavem.transport import connect, serve
8
+
9
+
10
+ async def test_tcp_nodelay_is_enabled_on_both_peers(key: bytes) -> None:
11
+ server_option: asyncio.Future[int] = asyncio.get_running_loop().create_future()
12
+
13
+ async def inspect(channel: Channel) -> None:
14
+ connection = channel._writer.get_extra_info("socket")
15
+ server_option.set_result(connection.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY))
16
+ # Keep the channel open until the server context cancels this handler.
17
+ # Avoid an unrelated EOF masking a failed socket-option assertion.
18
+ await asyncio.Event().wait()
19
+
20
+ async with asyncio.timeout(5):
21
+ async with serve(inspect, "127.0.0.1", 0, keys={"home": key}) as server:
22
+ port = server.sockets[0].getsockname()[1]
23
+ async with await connect("127.0.0.1", port, identity="home", key=key) as channel:
24
+ connection = channel._writer.get_extra_info("socket")
25
+ # Enabled is nonzero; Darwin exposes the flag bit rather than 1.
26
+ assert connection.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0
27
+ assert await server_option != 0
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ssl
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from clavem import transport
10
+ from clavem.channel import Channel
11
+ from clavem.tls import client_context
12
+
13
+
14
+ async def test_psk_channel_exchanges_messages(key: bytes) -> None:
15
+ async def echo(channel: Channel) -> None:
16
+ await channel.send(await channel.receive())
17
+
18
+ async with asyncio.timeout(5):
19
+ async with transport.serve(echo, "127.0.0.1", 0, keys={"home": key}) as server:
20
+ port = server.sockets[0].getsockname()[1]
21
+ async with await transport.connect(
22
+ "127.0.0.1", port, identity="home", key=key
23
+ ) as channel:
24
+ await channel.send(b"authenticated")
25
+ assert await channel.receive() == b"authenticated"
26
+
27
+
28
+ async def test_rejects_untrusted_certificate_fallback(
29
+ certificate_server: tuple[int, list[bool]], key: bytes
30
+ ) -> None:
31
+ port, _accepted = certificate_server
32
+ async with asyncio.timeout(5):
33
+ with pytest.raises(ssl.SSLCertVerificationError):
34
+ async with await transport.connect("127.0.0.1", port, identity="home", key=key):
35
+ pytest.fail("untrusted certificate fallback was accepted")
36
+
37
+
38
+ async def test_rejects_trusted_certificate_fallback(
39
+ certificate_server: tuple[int, list[bool]],
40
+ certificate: Path,
41
+ key: bytes,
42
+ monkeypatch: pytest.MonkeyPatch,
43
+ ) -> None:
44
+ port, accepted = certificate_server
45
+
46
+ def trusted_context(identity: str, secret: bytes) -> ssl.SSLContext:
47
+ context = client_context(identity, secret)
48
+ context.load_verify_locations(certificate)
49
+ return context
50
+
51
+ # First establish that the real certificate fallback succeeds when trusted.
52
+ # This rules out an unrelated TLS/certificate failure as the test's cause.
53
+ async with asyncio.timeout(5):
54
+ reader, writer = await asyncio.open_connection(
55
+ "127.0.0.1",
56
+ port,
57
+ ssl=trusted_context("home", key),
58
+ server_hostname="",
59
+ ssl_shutdown_timeout=1,
60
+ )
61
+ try:
62
+ assert not writer.get_extra_info("ssl_object").session_reused
63
+ assert await reader.read() == b""
64
+ finally:
65
+ writer.close()
66
+ await writer.wait_closed()
67
+ assert accepted == [False]
68
+
69
+ monkeypatch.setattr(transport, "client_context", trusted_context)
70
+ with pytest.raises(ssl.SSLError, match="external PSK"):
71
+ async with await transport.connect("127.0.0.1", port, identity="home", key=key):
72
+ pytest.fail("certificate-only authentication was accepted")
clavem-0.1.0/uv.lock ADDED
@@ -0,0 +1,122 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.13"
4
+
5
+ [[package]]
6
+ name = "clavem"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+
10
+ [package.dev-dependencies]
11
+ dev = [
12
+ { name = "pytest" },
13
+ { name = "pytest-asyncio" },
14
+ { name = "ruff" },
15
+ ]
16
+
17
+ [package.metadata]
18
+
19
+ [package.metadata.requires-dev]
20
+ dev = [
21
+ { name = "pytest", specifier = ">=8.3" },
22
+ { name = "pytest-asyncio", specifier = ">=1.0" },
23
+ { name = "ruff", specifier = ">=0.11" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "colorama"
28
+ version = "0.4.6"
29
+ source = { registry = "https://pypi.org/simple" }
30
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
33
+ ]
34
+
35
+ [[package]]
36
+ name = "iniconfig"
37
+ version = "2.3.1"
38
+ source = { registry = "https://pypi.org/simple" }
39
+ sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
40
+ wheels = [
41
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
42
+ ]
43
+
44
+ [[package]]
45
+ name = "packaging"
46
+ version = "26.3"
47
+ source = { registry = "https://pypi.org/simple" }
48
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
49
+ wheels = [
50
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
51
+ ]
52
+
53
+ [[package]]
54
+ name = "pluggy"
55
+ version = "1.7.0"
56
+ source = { registry = "https://pypi.org/simple" }
57
+ sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
58
+ wheels = [
59
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
60
+ ]
61
+
62
+ [[package]]
63
+ name = "pygments"
64
+ version = "2.21.0"
65
+ source = { registry = "https://pypi.org/simple" }
66
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
67
+ wheels = [
68
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
69
+ ]
70
+
71
+ [[package]]
72
+ name = "pytest"
73
+ version = "9.2.0"
74
+ source = { registry = "https://pypi.org/simple" }
75
+ dependencies = [
76
+ { name = "colorama", marker = "sys_platform == 'win32'" },
77
+ { name = "iniconfig" },
78
+ { name = "packaging" },
79
+ { name = "pluggy" },
80
+ { name = "pygments" },
81
+ ]
82
+ sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
83
+ wheels = [
84
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
85
+ ]
86
+
87
+ [[package]]
88
+ name = "pytest-asyncio"
89
+ version = "1.4.1.post1"
90
+ source = { registry = "https://pypi.org/simple" }
91
+ dependencies = [
92
+ { name = "pytest" },
93
+ ]
94
+ sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" }
95
+ wheels = [
96
+ { url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" },
97
+ ]
98
+
99
+ [[package]]
100
+ name = "ruff"
101
+ version = "0.17.4"
102
+ source = { registry = "https://pypi.org/simple" }
103
+ sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" }
104
+ wheels = [
105
+ { url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" },
106
+ { url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" },
107
+ { url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" },
108
+ { url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" },
109
+ { url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" },
110
+ { url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" },
111
+ { url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" },
112
+ { url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" },
113
+ { url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" },
114
+ { url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" },
115
+ { url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" },
116
+ { url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" },
117
+ { url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" },
118
+ { url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" },
119
+ { url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" },
120
+ { url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" },
121
+ { url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" },
122
+ ]