diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index aa414dc..fe78760 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -3,13 +3,82 @@ name: "Publish release to PyPI"
on:
push:
tags:
- # Publish on version tags, e.g. v0.1.0
+ # Publish on version tags, e.g. v1.0.0
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+[ab][0-9]+"
jobs:
+ release-metadata:
+ name: Validate release metadata
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Verify tag matches package version
+ shell: bash
+ run: |
+ package_version="$(python3 - <<'PY'
+ import tomllib
+
+ with open("pyproject.toml", "rb") as project_file:
+ print(tomllib.load(project_file)["project"]["version"])
+ PY
+ )"
+ tag_version="${GITHUB_REF_NAME#v}"
+
+ if [[ "$tag_version" != "$package_version" ]]; then
+ echo "Release tag $GITHUB_REF_NAME does not match package version $package_version" >&2
+ exit 1
+ fi
+
+ verify:
+ name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
+ needs:
+ - release-metadata
+ runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu-latest
+ - ubuntu-24.04-arm
+ - windows-latest
+ - macos-latest
+ python-version:
+ - "3.11"
+ - "3.12"
+ - "3.13"
+ - "3.14"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: false
+
+ - name: Install dependencies
+ run: uv sync --locked --python ${{ matrix.python-version }}
+
+ - name: Run tests
+ run: >-
+ uv run --python ${{ matrix.python-version }}
+ python -m unittest discover -s tests -v
+
build:
+ needs:
+ - verify
runs-on: ubuntu-latest
permissions:
contents: read
@@ -24,20 +93,33 @@ jobs:
with:
enable-cache: false
+ - name: Install dependencies
+ run: uv sync --locked --python 3.11
+
+ - name: Check formatting
+ run: uv run --python 3.11 black --check src tests
+
+ - name: Check import order
+ run: uv run --python 3.11 isort --check-only src tests
+
+ - name: Lint
+ run: uv run --python 3.11 pylint src/ctmodbus
+
- name: Build
run: uv build
- # # Optional, but recommended: run smoke tests on distributions
- # - name: Smoke test (wheel)
- # run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
- # - name: Smoke test (source distribution)
- # run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
+ - name: Smoke test wheel
+ run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
+
+ - name: Smoke test source distribution
+ run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
- name: Upload distributions as artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
+ if-no-files-found: error
publish:
needs:
@@ -49,6 +131,11 @@ jobs:
contents: write
id-token: write
steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
@@ -60,6 +147,54 @@ jobs:
name: dist
path: dist/
+ - name: Prepare curated release notes
+ env:
+ TAG_VERSION: ${{ github.ref_name }}
+ run: |
+ python3 - <<'PY'
+ import os
+ import re
+ from pathlib import Path
+
+ tag_version = os.environ["TAG_VERSION"].removeprefix("v")
+ base_version = re.sub(r"(?:a|b|rc)\d+$", "", tag_version)
+ candidates = [tag_version]
+ if base_version != tag_version:
+ candidates.append(base_version)
+
+ lines = Path("CHANGELOG.md").read_text(encoding="utf-8").splitlines()
+ start = None
+ for candidate in candidates:
+ prefix = f"## [{candidate}]"
+ start = next(
+ (index + 1 for index, line in enumerate(lines)
+ if line.startswith(prefix)),
+ None,
+ )
+ if start is not None:
+ break
+
+ if start is None:
+ raise SystemExit(
+ f"CHANGELOG.md has no section for {tag_version} or {base_version}"
+ )
+
+ end = next(
+ (index for index in range(start, len(lines))
+ if lines[index].startswith("## [")),
+ len(lines),
+ )
+ notes = "\n".join(lines[start:end]).strip()
+ if base_version != tag_version:
+ notice = (
+ f"> **Prerelease:** {tag_version} is a release candidate for "
+ f"ctmodbus {base_version}. Test it before using it in production."
+ )
+ notes = f"{notice}\n\n{notes}"
+
+ Path("release-notes.md").write_text(notes + "\n", encoding="utf-8")
+ PY
+
- name: Generate PEP 740 attestations
uses: astral-sh/attest-action@f589a42a7efb6fe400b4f400de60b4bc90390027 # v0.0.6
@@ -70,8 +205,15 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- gh release create "$GITHUB_REF_NAME" \
+ release_options=()
+ if [[ "$GITHUB_REF_NAME" =~ (a|b|rc)[0-9]+$ ]]; then
+ release_options+=(--prerelease --latest=false)
+ fi
+
+ gh release create "$GITHUB_REF_NAME" dist/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--generate-notes \
+ --notes-file release-notes.md \
+ "${release_options[@]}" \
--title "$GITHUB_REF_NAME"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..5ee6b22
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,110 @@
+name: Test
+
+on:
+ push:
+ branches:
+ - "**"
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu-latest
+ - ubuntu-24.04-arm
+ - windows-latest
+ - macos-latest
+ python-version:
+ - "3.11"
+ - "3.12"
+ - "3.13"
+ - "3.14"
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: false
+
+ - name: Install Python
+ run: uv python install ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: uv sync --locked --python ${{ matrix.python-version }}
+
+ - name: Run tests
+ run: >-
+ uv run --python ${{ matrix.python-version }}
+ python -m unittest discover -s tests -v
+
+ quality:
+ name: Formatting and import order
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: false
+
+ - name: Install dependencies
+ run: uv sync --locked --python 3.11
+
+ - name: Check formatting
+ run: uv run --python 3.11 black --check src tests
+
+ - name: Check import order
+ run: uv run --python 3.11 isort --check-only src tests
+
+ - name: Lint
+ run: uv run --python 3.11 pylint src/ctmodbus
+
+ artifacts:
+ name: Build and smoke-test distributions
+ needs:
+ - tests
+ - quality
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: false
+
+ - name: Build distributions
+ run: uv build
+
+ - name: Smoke-test wheel
+ run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
+
+ - name: Smoke-test source distribution
+ run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
+
+ - name: Upload distributions
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: ctmodbus-distributions
+ path: dist/
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 023ef0b..21e8ee9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,7 @@
.mypy_cache/
*.pyc
__pycache__/
-src/ctserial/__pycache__/
dist/
ctmodbus.egg-info/
.venv/
-build/
\ No newline at end of file
+build/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a35f3cf
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,84 @@
+# ctmodbus instructions
+
+## Project context
+
+Read [docs/STATUS.md](docs/STATUS.md) for current work and validation gaps.
+Read [docs/DECISIONS.md](docs/DECISIONS.md) headings and entries relevant to the
+task; include D01–D08 and D11–D12 for cross-cutting architecture changes. Update
+these files alongside meaningful changes using the global continuity guidance.
+
+Use uv and the checked-in lockfile. Python 3.11 is the baseline; CI covers
+Python 3.11–3.14 on Linux x86-64/ARM64, Windows, and macOS.
+The 0.x compatibility waiver applies only to the migration (D01).
+
+Run ctmodbus and its tests with the locked, published `ctui==1.0.0` dependency.
+The temporary adjacent-checkout override has ended; release validation must
+omit `--with-editable ../ctui`.
+
+## Project constraints
+
+ctmodbus is an asynchronous Modbus device-testing tool using ctui and PyModbus.
+Preserve one application-owned connection, serialized device operations,
+validated responses, explicit uncertain-write reporting, and project isolation.
+Use native async device I/O; offload blocking discovery/certificate loading.
+Pure parsing and formatting can remain synchronous.
+
+Use `CtuiApp`, `@command`, and the same dispatch path for TUI and CLI. Use ctui
+configs for profiles and records for decoded operations; never persist live
+clients/tasks. Preserve the policies in the decision log when changing these
+boundaries. Expansion beyond the agreed feature set requires an actual task.
+
+Give every new positional `@command` argument a brief, meaningful
+`Argument.help` message. Keep this generic argument help out of repeated
+completion rows; when individual choices need distinct explanations, use the
+choices mapping or explicit `CompletionItem` help instead.
+
+Larger commands follow **component → action → transport or target**; use
+client/server/proxy groups for future component features. ctui-owned command
+names and configuration fields remain framework-owned.
+
+## Commands and verification
+
+```bash
+uv sync --locked --python 3.11
+uv run ctmodbus
+uv run --python 3.11 python -m unittest discover -s tests -v
+uv run black --check src tests
+uv run isort --check-only src tests
+uv run pylint src/ctmodbus
+uv lock --check
+git diff --check
+uv build
+```
+
+Packaging/release changes also need isolated wheel/sdist smoke tests via
+`tests/smoke_test.py`. Follow [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md).
+POSIX PTYs cover serial framing; physical adapters still require manual checks.
+TLS integration requires OpenSSL and otherwise skips. Record skipped checks.
+Publishing requires release approval; pushing a version tag triggers publication.
+
+## Code map
+
+- `src/ctmodbus/app.py`: lifecycle, dispatch arbitration, project guards,
+ records, progress, cancellation, and shutdown.
+- `src/ctmodbus/client_commands.py`: client lifecycle and selected configuration.
+- `src/ctmodbus/proxy_commands.py`: proxy lifecycle and logging controls.
+- `src/ctmodbus/result_popups.py`: standard management-result dialog policy.
+- `src/ctmodbus/component_help.py`: example-led component help popup adapter.
+- `src/ctmodbus/connection.py`: settings, client factory, serialization, timeouts.
+- `src/ctmodbus/operations.py`: typed reads/writes, chunking, response validation.
+- `src/ctmodbus/tag_deletion.py`: deletion preparation, approval and server-rule policy.
+- `src/ctmodbus/tags.py`: project tag storage, codecs, commands, and TOML exchange.
+- `src/ctmodbus/discovery.py`: advisory serial/local-service discovery.
+- `src/ctmodbus/formatting.py`: timestamps and safe result rendering.
+- `src/ctmodbus/commands.py`: application entry point.
+- `src/ctmodbus/server_config.py`: validated TOML server definitions.
+- `src/ctmodbus/simulation.py`: sparse emulation and transactional hook API.
+- `src/ctmodbus/server.py`: owned listeners, request routing and plain proxying.
+- `src/ctmodbus/server_commands.py`: CTUI configuration and server lifecycle.
+- `src/ctmodbus/data_state.py`: runtime read/write evidence views.
+- [README.md](README.md#server): server configuration, hooks and proxy usage.
+- `tests/`: unittest coverage, local server fixture, distribution smoke test.
+- `.github/workflows/`: platform tests and tag-triggered publishing.
+- [MIGRATION.md](MIGRATION.md), [CHANGELOG.md](CHANGELOG.md), and the release
+ checklist describe migration guidance, user-facing changes, and release gates.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6e40e1f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,74 @@
+# Changelog
+
+## [1.0.0] - 2026-10-24
+
+### Added
+
+- Web UI and scriptable CLI alongside the terminal UI, with shared commands,
+ completion, and generated help.
+- Verified Modbus TLS connections, private CA support, optional client
+ certificates, and an explicit insecure mode.
+- Named project tags with Boolean, integer, and floating-point types,
+ configurable byte/word order, typed reads/writes, and TOML import/export.
+- Confirmed bulk tag deletion, with an option to remove referenced server rules
+ in the same transaction and warnings for retained Python hooks.
+- Polling of tags or raw address ranges with interval, count, and duration
+ settings. Results append a row per cycle; busy ticks are skipped.
+- Independent TCP, UDP, TLS, RTU, and ASCII servers with sparse address maps,
+ static/random/sequence values, Python hooks, config validation, and reset.
+- Proxying between any supported client/server transports, with separate
+ upstream and downstream status views.
+- Correlated server/proxy request logs, independent quiet settings and live
+ logging switches, bounded write previews, and full payloads in records.
+- USB manufacturer and product details in serial-device completion.
+
+### Changed
+
+- Move device I/O to native async PyModbus 3.15 clients and use stable
+ `ctui==1.0.0`. Each app owns one client connection and serializes device work.
+- Organize lifecycle commands under `client`, `server`, and `proxy`, with
+ start/stop/status commands and short component overviews.
+- Save and load client settings independently of the connection. Loading selects
+ settings; `client start` connects. Invalid edits leave the selection intact.
+- Keep configs, tags, command history, and decoded operation records in projects.
+ Live clients, tasks, and runtime status remain separate from saved data.
+- Show management results in scrollable terminal/web popups while background
+ polling, serving, and request logging continue. CLI results stay plain text.
+- Keep CLI servers alive after a successful command sequence. Later commands
+ run before keepalive; errors, exit, and Ctrl-C clean up both endpoints.
+- Accept mixed decimal, hexadecimal, binary, and octal register-write lists,
+ including signed 16-bit values. Preserve unsigned values and single-request
+ write limits.
+- Use strict, versioned TOML for tags and server definitions, with clear errors
+ for unknown keys and unsupported formats.
+- Consolidate package metadata and align it with the existing GPL-3.0-or-later
+ source license.
+
+### Fixed
+
+- Validate response lengths and write acknowledgements. Preserve partial reads
+ and report uncertain writes instead of implying success or safe retry.
+- Handle multi-value writes and sparse device-identification responses correctly.
+- Reserve the connection across multi-tag reads. Keep completed results after
+ failure or cancellation and identify failed or unread tags.
+- Import the exact tag data approved during confirmation and apply all rows or
+ none. Guard project/tag changes while approval and import are in progress.
+- Keep protocol records in the project that started the operation. Report
+ recording failures separately from acknowledged device writes.
+
+### Compatibility
+
+- Require Python 3.11 or newer. Python 3.10 and earlier are no longer supported.
+- This release breaks compatibility with 0.x APIs, commands, and storage. Use
+ component lifecycle commands; old spellings aren't retained as aliases.
+ Unique command prefixes still work.
+- Raw reads/writes use plural table names, inclusive read ranges, comma-separated
+ write values, and separate host and `--port` arguments. `--max-count` limits
+ each read request, not the total address count.
+- Remove `debug eval` and server `--foreground` options. CLI serving now uses
+ automatic keepalive.
+- Older development TOML files need the current format/version markers and
+ `tick_interval_seconds` hook setting. There is no automatic format migration.
+
+See [README.md](README.md) for current usage and [MIGRATION.md](MIGRATION.md)
+for the transition from 0.x.
diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 0000000..ae9788d
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,25 @@
+# Migrating from ctmodbus 0.x
+
+The 1.0 migration deliberately breaks compatibility with 0.x commands and
+storage. The dependency baseline and architectural rationale are in
+[docs/DECISIONS.md](docs/DECISIONS.md).
+
+Use [README.md](README.md) for installation, command syntax, connection
+defaults, profiles, and tags. In particular:
+
+- Supply host and port separately; use `--port` instead of `host:port` syntax.
+- Use plural table names for raw reads/writes, inclusive read ranges, and
+ comma-separated write values. `tags create` uses singular table names.
+- Close the active connection before opening another or switching projects.
+- Use project-scoped profiles and decoded records; old storage has no
+ compatibility layer. The former `debug eval` command is removed.
+
+TLS and typed tags were added after the initial six migration milestones;
+their original deferral is superseded by D07 and D11 in the decision log.
+[CHANGELOG.md](CHANGELOG.md) records user-facing additions. Git history retains
+the completed milestone sequence.
+
+Current work and dated validation evidence belong in
+[docs/STATUS.md](docs/STATUS.md). Follow
+[RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for release procedures; historical
+local results do not establish current release readiness or publication.
diff --git a/README.md b/README.md
index e465425..27b59bc 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1,823 @@
# ControlThings Modbus
-A highly flexible Modbus tool made for penetration testers.
+An asynchronous Modbus tool for functional and cybersecurity protocol testing,
+built with [ctui](https://github.com/ControlThings-io/ctui) and
+[PyModbus](https://github.com/pymodbus-dev/pymodbus).
-# Installation:
+Features in 1.0.0:
-As long as you have git and Python 3.8 or later installed, all you should need to do is:
+- Terminal UI, web UI, and scriptable CLI
+- Clients and servers for TCP, UDP, TLS, serial RTU, and serial ASCII
+- Device identification, reads across all four Modbus tables, and
+ single/multiple coil and holding-register writes
+- Named tags with automatic Boolean, integer, and floating-point conversion,
+ configurable byte/word order, and tag import/export
+- Client reads using address lists and ranges, consecutive writes, and
+ polling of addresses or tags with interval, count, and duration settings
+- Server simulation with static, random, and sequence values, optional
+ Python hooks, and validated config import/export
+- Proxying between any combination of the supported transports
+- Runtime views of last read values and write outcomes, plus optional
+ server/proxy request logging
+- Saved projects with command history, configs, tags, operation records,
+ and project import/export
+
+## Installation
+
+Requires Python 3.11 or newer. Until 1.0.0 is published, use the
+[checkout setup](#development-and-validation) for the features described here.
+The PyPI commands below install the latest published release.
+
+Install from PyPI:
```bash
pip3 install ctmodbus
```
-Or better yet, if you have `uv` installed, install it in an isolated environment with:
+Or better yet, use `uv` to install it in an isolated environment:
```bash
uv tool install ctmodbus
```
-`uv` also lets you try the tool out without installing it by running:
+Just trying it out? Run it with `uvx`:
```bash
uvx ctmodbus
```
-## Examples of current user interface commands once you start ctmodbus:
+Run `ctmodbus` for the terminal UI or `ctmodbus --web` for the web UI.
+Web mode prints a local URL with a session token; open that URL in your browser.
+Use `ctmodbus --help` for startup options, including web port and TLS settings.
+
+## Interactive commands
+
+Both UIs offer completion, generated help, and unique command prefixes.
+Use `help` for the command list, or `help read` for a specific group.
+Enter `client`, `server`, or `proxy` for a short overview with examples.
+`help client`, `help server`, and `help proxy` give the full references.
+
+Connect to a device, then read or write its addresses:
+
+```text
+client start tcp 10.10.10.1 --port 502 --unit 1
+read id
+read discrete_inputs 1
+read coils 1,3,5,7
+read input_registers 5,10-30,90-99
+read holding_registers 50-58
+read holding_registers 0-500 --max-count 100
+write coils 128 0
+write coils 76 0,1,1,0,1,0,0,1
+write holding_registers 1000 14302,188,305
+write holding_registers 100 0x8000,0b0100110,0d800
+write holding_registers 100 -- -32768,-1,32767
+client status
+client stop
+```
+
+Replace the host, unit, and addresses with ones your device supports. Only one
+client connection can be open at a time. Other connection options include:
+
+```text
+client start udp 10.10.10.1 --port 10502
+client start tls plc.example.com --ca-file plant-ca.pem
+client start rtu /dev/ttyUSB0 --baudrate 9600 --parity E
+client start ascii COM2 --baudrate 9600
+```
+
+These are alternatives: run `client stop` before changing connections.
+`client discover` suggests serial ports and local services. Serial completion
+includes USB manufacturer/product details when available. You can still enter
+device paths and symlinks that discovery doesn't list. Opening UDP creates a
+local socket; it doesn't confirm that a remote device is responding.
+
+### Addresses and raw writes
+
+Addresses are **zero-based wire addresses**, from 0 through 65535. Convert
+4xxxx reference numbers to wire addresses before using them. Read ranges include
+both ends: `50-58` reads nine addresses. Lists preserve order and repetitions,
+with at most 65,536 addresses per read.
+
+`--max-count` limits the addresses in each request, up to 2,000 bits or 125
+registers. Larger reads split into requests automatically.
+
+Writes take a starting address and comma-separated values for consecutive
+addresses. Coils accept `0` or `1`; holding registers accept -32768–65535.
+Each value uses one 16-bit register. Negative values use two's complement:
+`-1` writes `0xFFFF`.
+
+Register values accept decimal and `0x`/`0b`/`0o`/`0d` prefixes, signs, and
+underscores. Mix prefixes freely: `0x8000,0b0100110,0d800` writes 32768, 38,
+and 800. Put `--` before a list starting with a negative value. Leave out spaces
+around commas or quote the whole list. Results and records show unsigned wire
+values; use [tags](#tags) for floats and values spanning multiple registers.
+
+One value uses a single-write function; multiple values use one multiple-write
+request. The limits are 1,968 coils or 123 registers, and writes never split.
+A matching acknowledgement confirms the write response, not an independent
+readback. Failed or interrupted writes may still have reached the device;
+ctmodbus reports an unconfirmed outcome explicitly.
+
+## Connection options and operation results
+
+Client defaults are:
+
+| Setting | Default | Option |
+| --- | --- | --- |
+| Unit ID | 1, supported range 1–247 | `--unit` |
+| Timeout | 3 seconds for network, 1 for serial | `--timeout` |
+| Retries | 0, supported range 0–10 | `--retries` |
+| Network port | 502 for TCP/UDP, 802 for TLS | `--port` |
+| Serial framing | 9600 baud, 8 data bits, no parity, 1 stop bit | `--baudrate`, `--bytesize`, `--parity`, `--stopbits` |
+
+Supply host and port separately, including for IPv6 addresses.
+
+TLS uses Modbus TLS framing and verifies the certificate and hostname by
+default. Use `--ca-file PATH` for a private CA, and
+`--cert-file PATH --key-file PATH` for client authentication. The certificate
+file can include its key; otherwise supply a separate unencrypted key file.
+`--insecure` disables server certificate and hostname verification.
+Saved configs retain paths and verification settings, so the referenced files
+must be available when reconnecting.
+
+Requests run asynchronously and share one serialized connection. The status bar
+shows connection state and read progress. `client stop` cancels outstanding
+work and closes the connection; reconnect explicitly before more device I/O.
+In the interactive UI, Ctrl-C clears the input line. Use `client stop` to stop
+an active device command.
+
+Read output includes UTC timestamps and compact address/value summaries.
+Registers have integer, hex, and character views. Each character represents
+one register's Unicode code point; it isn't a UTF-8 string decoder. If a
+chunked read fails, the error output retains values already read.
+
+## Tags
+
+Give addresses names and types so you can work with values directly.
+Tags belong to the project and use the active client connection and unit.
+A tag's type determines its width; supply only its first address.
+Connect the client before the read/write commands below.
+
+```text
+tags create pause_sw coil 0 bool
+tags create state holding_register 0 uint8
+tags create counter holding_register 1 uint16
+tags create timer holding_register 2 int32
+tags create energy input_register 10 float64 --word-order big
+tags list
+tags show timer
+read tags
+read tags pause_sw,state,counter,timer
+write tag timer 0d33_000
+write tag timer 0x8000_0000
+write tag timer -- -2_000_000_000
+write tag state 0b0111_0001
+write tag pause_sw on
+```
+
+`read tags` reads all tags in name order. A comma-separated list selects tags
+in your requested order, including duplicates. Each tag is read separately,
+with other device commands held until the whole command finishes. On failure
+or cancellation, completed rows remain visible and unread tags are reported.
+Device values can change between reads.
+Cancellation closes the connection; reconnect before continuing.
+
+Rename a tag with `tags rename timer duration`.
+
+### Types and values
+
+`tags create` takes a singular table name: `coil`, `discrete_input`,
+`input_register`, or `holding_register`. Coils and discrete inputs use `bool`;
+registers use signed/unsigned 8-, 16-, 32-, or 64-bit integers, or `float32` and
+`float64`. Only coils and holding registers can be written. Overlapping tags
+are allowed, with a warning, for alternative interpretations of the same data.
+
+Boolean input accepts `0`/`1`, `false`/`true`, or `off`/`on`. Integers accept
+decimal and `0b`, `0o`, `0d`, or `0x` prefixes, with optional underscores.
+Floats accept finite decimal or scientific notation; NaN and infinity are
+rejected.
+
+For signed tags, unsigned binary/octal/hex literals represent fixed-width bit
+patterns. For an `int32`, `0x8000_0000` means -2147483648 and `0xFFFF_FFFF`
+means -1. Decimal input must fit the numeric range. Use `--` before a negative
+positional value, as in `write tag timer -- -2000000000`.
+
+### Byte and word order
+
+Register tags default to big-endian bytes within each register and
+little-endian words across registers. Set `--byte-order little|big` and
+`--word-order little|big` to match your device.
+
+An 8-bit tag still uses a full register. Big byte order selects the low byte;
+little byte order selects the high byte. Writes zero the unused byte.
+Byte and word order don't apply to Boolean tags.
+
+### Import and export
+
+```text
+tags export my_tags
+tags import my_tags.toml
+tags import my_tags.toml --replace
+```
+
+Tag files use versioned TOML. Export adds `.toml` if needed and atomically
+replaces the destination. Import validates the whole file and applies all rows
+or none. Existing names need confirmation in the terminal/web UI, or
+`--replace` in CLI and command files. The exact validated contents are retained
+while you confirm; project changes and tag edits are blocked until it finishes.
+Both commands offer path completion.
+
+Exports assume sequential use. See the
+[concurrency limitations](docs/DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals)
+if you're embedding concurrent tag or project operations.
+
+### Removing tags
+
+Delete one tag with `tags delete NAME`, or every tag with `tags delete --all`.
+Stop the server and polling first. Bulk deletion needs Yes/No approval in the
+terminal/web UI or `--confirm` in CLI. An empty project needs no confirmation.
+A name and `--all` can't be combined.
+
+Saved server rules referencing a tag block deletion. Add
+`--remove-server-rules` to delete those entries in the same transaction:
+
+```text
+tags delete temperature
+tags delete temperature --remove-server-rules --confirm
+tags delete --all
+tags delete --all --remove-server-rules --confirm
+```
+
+These are alternatives; replace `temperature` with an existing tag name.
+Rule removal needs approval even for one tag. Other server settings and raw
+ranges stay intact. If hooks exist, approval also warns that Python references
+may need updating, even when deleting a single unreferenced tag. Companion
+scripts aren't changed.
+
+Declining approval changes nothing; a transaction failure rolls back both tags
+and server rules. Exported files stay untouched and can be imported again later.
+
+## Polling
+
+Run one poll at a time while keeping the terminal/web UI available:
+
+```text
+poll tags
+poll tags timer,state,pause_sw --interval 0.1 --count 100
+poll raw --holding-registers 0-2,10-11 --coils 0-7 --interval 1 --duration 60
+poll status
+poll stop
+```
+
+These are alternative polls; stop one before starting another. `poll tags`
+uses all tags in name order or the supplied comma-separated list. `poll raw`
+uses the same inclusive ranges as `read`, with a column per address/range.
+Table options, ranges, and repeated targets keep their supplied order.
+
+The default interval is one second. Interval and duration must be positive,
+finite seconds; count limits started cycles. With both count and duration,
+the first limit reached stops new cycles and the active cycle finishes.
+In CLI mode, polling streams results and waits before running the next command.
+Use limits in scripts; an unlimited CLI poll runs until interrupted.
+
+The first cycle starts immediately. Later cycles follow a fixed schedule
+without adding response time to the interval. Busy or overdue ticks are
+skipped, never queued for catch-up. All reads in a cycle share the client
+connection, so timing is best effort. `poll status` shows targets, limits,
+started/completed cycles, skipped ticks, failed columns, and last cycle duration.
+
+Each started cycle appends a row, including unchanged values:
+
+```text
+ # | timer | state | pause_sw
+ 1 | 33000 | 113 | True
+ 2 | ERR | 114 | False
+
+ # | holding_registers:0-2 | holding_registers:10-11 | coils:0-7
+ 1 | 0000 0071 80E8 | 000A 00FF | 10110001
+ 2 | 0000 0072 ERR | 000B 0100 | 10110011
+```
+
+Tags show decoded values; raw bits use `0`/`1` and raw registers use four-digit
+uppercase hex. Columns widen and repeat their header when needed. Float32/64
+values use up to 9/17 significant digits. `ERR` marks a failed tag or range;
+completed chunks remain visible. Other columns and cycles continue while the
+connection is usable. Records retain raw values and errors, and old values
+aren't shown as fresh results.
+
+Stop polling before editing tags or switching projects. `poll stop` waits for
+the active cycle and leaves the connection open. `client stop`, connection loss,
+or application shutdown also stop polling. Cancellation closes the connection;
+a poll never moves to a replacement connection automatically.
+
+## Server
+
+Run one server alongside the independent client. In a fresh project, the server
+exposes all four tables at addresses 0–65535, initially false/zero:
+
+```text
+server start tcp 127.0.0.1 --port 5020
+server status
+server stop
+```
+
+Other transports use the same server definition. These are alternatives;
+stop the running server before choosing another:
+
+```text
+server start udp 127.0.0.1 --port 5020
+server start tls 127.0.0.1 --port 8020 --cert-file server.pem --key-file server.key
+server start rtu /dev/ttyUSB0 --baudrate 9600
+server start ascii /dev/ttyUSB0 --baudrate 9600
+```
+
+TCP/UDP default to loopback port 5020; TLS defaults to loopback port 8020.
+Ports 502/802 also work when the OS permits binding them. Serial defaults match
+the client: 9600 baud, 8 data bits, no parity, and 1 stop bit. Client and server
+need different serial ports. Serial servers respond as devices, not passive
+taps; they ignore other unit IDs and broadcasts. Unit IDs are 1–247, default 1.
+
+TLS uses Modbus TLS framing and needs a certificate and unencrypted key.
+`--ca-file` additionally requires trusted client certificates. Server definitions
+and tags are saved per project; runtime values, tasks, and loaded Python
+functions aren't. Project exports don't bundle external hook or certificate
+files. Stopping the server leaves the client connected; exiting closes both.
+
+Configure raw ranges or typed tags with static, random, or sequence values,
+and add Python hooks for device behavior. Edit the definition with
+`server config` commands or import TOML. From a repository checkout, try the
+[pump/tank example](examples/device.toml) and its
+[companion hooks](examples/device_logic.py):
+
+```text
+server config import examples/device.toml
+server config validate
+server config show
+server config export device.toml
+server start tcp 127.0.0.1 --port 5020
+server stop
+```
+
+Stop the server before editing its definition or changing projects. Import
+replaces the server definition and merges its tags; conflicting tags need
+approval or `--replace`. Python hooks execute as trusted application code,
+including during validation.
+
+### Run a server from CLI
+
+From a repository checkout, start the pump/tank example with:
+
+```bash
+uv run ctmodbus \
+ -c "server config import examples/device.toml" \
+ -c "server start tcp 127.0.0.1 --port 5020"
+```
+
+The process stays alive after the commands finish. Ctrl-C shuts down both server
+and client. See [command-line execution](#command-line-execution) for command
+ordering and exit behavior.
+
+### Address maps and values
+
+Without a saved definition, all four tables cover addresses 0–65535, starting
+at false or zero. Values are stored sparsely. Requests still obey function
+quantity limits and cannot cross address 65535.
+
+Server TOML requires `format = "ctmodbus-server"` and integer `version = 1`.
+Tag-only TOML requires `format = "ctmodbus-tags"` and integer `version = 1`.
+Missing markers, unsupported versions, unknown keys, and the old
+`tick_seconds` field are rejected. Use `tick_interval_seconds` for periodic
+hooks. Older development files need explicit updates; there is no automatic
+format migration.
+
+Importing TOML replaces the server definition. Omitted tables are unavailable.
+Within a table, `unmapped = "illegal"` makes unlisted addresses return exception
+02 (Illegal Data Address); `unmapped = "default"` implements unlisted addresses
+with the configured initial `default`. Writes to writable default-backed
+addresses are retained for that server run.
+
+```toml
+format = "ctmodbus-server"
+version = 1
+unit = 1
+seed = 123
+
+[identity]
+vendor = "ControlThings"
+product = "Example device"
+revision = "1.0"
+
+[tables.coils]
+unmapped = "default"
+default = false
+
+[tables.holding_registers]
+unmapped = "illegal"
+
+[[tables.holding_registers.ranges]]
+start = 100
+end = 199
+mode = "static"
+value = 42
+
+[[tables.holding_registers.ranges]]
+start = 7
+end = 7
+mode = "sequence"
+values = [10, 20, 30]
+advance = "time"
+interval_seconds = 0.5
+repeat = true
+
+[tags.temperature]
+table = "input_registers"
+address = 10
+type = "float32"
+byte_order = "big"
+word_order = "little"
+mode = "random"
+min = 20.0
+max = 30.0
+```
+
+Raw ranges are inclusive and cannot overlap each other. Tags use the shared
+project tag codecs and derive their widths from type; numeric order defaults
+are big bytes/little words. Named tag rules overlay raw ranges, which overlay
+the table default. Server behavior tags cannot overlap each other. Existing
+project tags may still overlap for alternate client-side decoding.
+
+Each tag/raw range has one rule:
+
+- `static`: `value` is the initial scalar. Client writes subsequently change
+ writable values. Bits accept false/true or 0/1; raw registers accept 0–65535.
+- `random`: inclusive `min`/`max` bounds. Integer/Boolean rules use integer
+ samples; floating tags use floating samples. An optional top-level `seed`
+ makes fresh server runs and resets reproducible.
+- `sequence`: a nonempty `values` list; `advance = "read"` advances once per
+ successful request touching the address/tag. `advance = "time"` selects by
+ elapsed time since server start and requires positive `interval_seconds`.
+ `repeat = true` cycles; false holds the final value. Timed rules share a clock.
+
+Typed dynamic tags generate one complete encoded value per request, including
+partial reads. A single response cannot combine halves of different samples.
+Separate requests can observe different samples. Failed requests do not advance
+read sequences or the random generator. Random/sequence rules continue after
+writes; replace the rule with static behavior to stop them.
+
+### Build a server definition
+
+```text
+server config clear --confirm
+server config unit 1
+server config seed 123
+server config identity --vendor ControlThings --product "Tank and pump simulator" --revision 1.0
+server config table coils --unmapped illegal
+server config table discrete_inputs --unmapped illegal
+server config table input_registers --unmapped illegal
+server config table holding_registers --unmapped illegal
+
+tags create pump_enabled coil 0 bool
+tags create pump_running discrete_input 0 bool
+tags create tank_level input_register 10 float32
+server config set tag pump_enabled false
+server config set tag pump_running false
+server config set tag tank_level 100.0
+
+server config set holding_registers 100-199 42
+server config random holding_registers 7 --min 0 --max 100
+server config sequence tag tank_level 100,75,50,25 --advance time --interval 1.0
+server config sequence holding_registers 8 10,20,30 --advance read --repeat=false
+
+server hook write examples/device_logic.py on_write
+server hook tick examples/device_logic.py on_tick --interval 1.0
+server config validate
+server config export device.toml
+```
+
+`set`, `random`, and `sequence` replace a matching tag/exact raw-range rule.
+`server config remove tag NAME` removes a server tag rule without deleting the
+project tag. `server config remove TABLE RANGE` removes exact configured raw ranges;
+the table fallback still applies. `server hook clear` removes all hooks.
+Changing a referenced project tag while stopped requires updating/removing its
+server rule before validation/start; running definitions cannot be edited.
+
+`server config import FILE --replace` explicitly approves conflicting project tag
+definitions. Otherwise differing definitions require a collision-specific TUI
+confirmation. Import retains the exact validated file contents throughout
+confirmation, and atomically replaces the configuration and merges its tags.
+Unrelated project tags remain. Export includes initial definitions and referenced
+tags, not last observed or client-written runtime values.
+
+`server config validate` checks definitions, shared project tags and referenced
+hook callables without opening a listener. It does not prove hook behavior or
+port availability. `server reset --confirm` restores running local values,
+random state and sequence clocks from the initial definition. Reset is disabled
+in proxy mode. Reset preserves saved settings and timestamped observations,
+including earlier proxy evidence. Raw evidence and initial values remain distinct.
+
+To delete tags referenced by saved server rules, use the explicit rule-removal
+option described under [removing tags](#removing-tags).
+
+### Python hooks
+
+The [pump example](examples/device.toml) references a companion
+[Python module](examples/device_logic.py):
+
+```toml
+[hooks]
+module = "device_logic.py"
+on_write = "on_write"
+on_tick = "on_tick"
+tick_interval_seconds = 1.0
+```
+
+All hooks are optional and share one Python file. The module path resolves
+relative to the imported TOML file. Export writes a path relative to its
+new destination; it does not copy the Python file. Interface hook paths resolve
+relative to the current working directory. Hook modules are trusted application
+code and execute during validation/loading; this is not a Python sandbox.
+
+Hooks use these signatures. The pump example defines `on_write` and `on_tick`;
+add `on_read` to the module and config if you need it:
+
+```python
+def on_read(device, tag_names):
+ # Once per local read, including partial tags. Return value is ignored.
+ pass
+
+def on_write(device, tag_name, value):
+ # After applying a complete tag write, before acknowledging it.
+ if tag_name == "pump_enabled":
+ device.set("pump_running", bool(value))
+
+def on_tick(device, elapsed_seconds):
+ if device.get("pump_running"):
+ level = device.get("tank_level")
+ device.set("tank_level", max(0.0, level - elapsed_seconds))
+```
+
+Functions may be synchronous or async. Keep synchronous functions short; an
+async hook has a three-second execution bound. `get` returns a decoded local
+tag; `set` checks the scalar type and writes the complete tag without recursively
+triggering hooks. Hooks can update locally simulated input tables even though
+Modbus clients cannot write them. Full raw writes covering a named tag invoke
+`on_write`; partial tag writes are recorded as partial and do not invoke a
+complete-value hook.
+
+Request hooks run with pending changes under the local operation lock. A hook
+failure rolls back its value, sequence and random-state changes, returns
+exception 04 (Server Device Failure), and appears in `server status` and operation
+records. Failed ticks leave values unchanged and later ticks continue. Local
+hooks and rules are bypassed in proxy mode. Derived-expression syntax and MITM
+rule files are not implemented.
+
+### Server request output
+
+Listeners log incoming requests by default:
+
+```text
+2026-10-06T14:32:05.123+00:00 SERVER #42 peer=('127.0.0.1', 53120) unit=1 read holding_registers 10-12
+```
+
+Lines include UTC timestamps, endpoint, unit, function/table, and address range.
+Write previews show at most 16 values; records retain the full payload.
+An arrival line describes an attempt, not a successful response.
+
+Use `--quiet` on any `server start` command to suppress routine lines, or
+toggle logging while the listener runs:
+
+```text
+server logging off
+server logging on
+```
+
+Errors remain visible and recording continues. Each server start resets its
+logging setting from `--quiet`; the setting isn't saved. Request IDs increase
+across server restarts within the app. Display escapes control characters.
+Request logs append to output in terminal, web, and CLI modes.
+
+## Proxy
+
+To bridge transports, connect the client to the upstream device, start a server
+for downstream clients, then enable proxying:
+
+```text
+client start rtu /dev/ttyUSB0 --baudrate 9600 --retries 0
+server start tcp 127.0.0.1 --port 5020
+proxy start
+proxy status
+client status
+server status
+proxy stop
+server stop
+client stop
+```
+
+Any supported client/server transport combination works.
+
+Proxying requires both endpoints. `client stop` and `server stop` warn and
+ask for confirmation while the proxy is running; declining preserves both
+components. Approval stops proxy routing as part of stopping that component.
+CLI can approve explicitly with `--confirm`. The other component stays running.
+Unexpected upstream loss still produces a gateway error while proxy mode remains
+active; it never silently falls back to local simulation.
+
+The server's configured unit routes to the
+client connection's configured unit. One upstream connection is shared by proxy
+traffic and console operations; complete exchanges are serialized. Downstream
+transaction IDs are preserved, while PyModbus frames the upstream transport.
+Local address-map restrictions do not filter proxy requests. The existing
+four read tables, single/multiple coil/register writes, and device identification
+are supported. Other functions return exception 01 (Illegal Function).
+
+Upstream exceptions pass through. Unavailable/timed-out upstream exchanges
+return gateway exception 0B; there is no simulated fallback or automatic retry
+of uncertain writes. Proxy start requires `--retries 0`. Confirmed `client stop`
+cancels/drains active exchanges and stops proxy routing. Unexpected connection
+loss retains proxy mode until `proxy stop` or a confirmed component stop.
+Obvious loops back to the listener are rejected. Proxy stop routes new requests
+locally; already-routed exchanges finish using their original mode. Saved
+emulator configuration is preserved.
+
+### Runtime status
+
+`client status` works without a server, accumulating successful console
+reads and attempted/acknowledged/uncertain writes. It includes the last observed
+device identification. `server config show` shows saved initial rules;
+`server status` shows current local static tags and last downstream
+observations. Client and server views retain upstream and downstream values
+separately. Neither status command issues network requests.
+
+Unknown addresses remain unknown. Read/write times are UTC. Acknowledgement
+is separate from readback; the view labels whether a later read matches or
+differs from the last write. Complete typed values require one coherent read;
+partial/separate observations are labelled explicitly. State is runtime-only,
+cleared on a fresh connection/server run and project change, but durable decoded
+operation records remain. MITM rules are not implemented.
+
+### Proxy request output
+
+Proxying logs forwarding attempts using the same request ID as the
+[server arrival line](#server-request-output):
+
+```text
+2026-10-06T14:32:05.124+00:00 PROXY #42 upstream=TCP 192.168.1.20:502 unit=7 read holding_registers 10-12
+```
+
+Proxy logging is independent of server logging. Use `proxy start --quiet` to
+suppress routine forwarding lines, or toggle them while proxying:
+
+```text
+proxy logging off
+proxy logging on
+```
+
+Errors remain visible and recording continues. Each proxy start resets its
+logging setting from `--quiet`; the setting isn't saved.
+
+## Management result popups
+
+In the terminal/web UI, management commands show scrollable result popups.
+This includes start/stop, status, discovery, tag management, and config edits.
+The action finishes before the popup opens. Dismiss it to submit the next
+command; servers, proxying, polling, and request logs keep running meanwhile.
+Confirmations appear before actions that need approval.
+
+Read/write results, polling rows, and request logs stay in the main output.
+CLI management results are plain text. ctui's built-in commands use their own
+result presentation.
+
+## Command-line execution
+
+Use the same commands without opening a UI:
```bash
-ctmodbus> connect tcp 10.10.10.1 # start a client session
-ctmodbus> connect rtu /dev/serial # works with serial too
-ctmodbus> connect ascii COM2 # and and windows
-ctmodbus> connect udp 10.10.10.1:10502 # even udp with custom ports
-ctmodbus> read id # read device identifiers
-ctmodbus> read discrete_inputs 1 # read coils and registers
-ctmodbus> read coils 1,3,5,7 # with comma separated values
-ctmodbus> read input_register 5,10-30,90-99 # and ranges
-ctmodbus> read holding_register 50 9 # or start address and count
-ctmodbus> write coils 128 0 # write single values
+ctmodbus --help
+ctmodbus -c 'client start tcp 127.0.0.1 --port 5020' \
+ -c 'read holding_registers 0-9' -c 'client stop'
+ctmodbus -f commands.txt
+```
+
+Repeat `-c`/`--command` and `-f`/`--file` in the order you want them run.
+Quote each complete command at the shell. Files take one command per line;
+blank lines and lines beginning with `#` are ignored. Commands run sequentially
+and stop at the first error.
+
+A server start returns immediately so later commands can run. After a successful
+sequence, CLI stays alive if a server is still running. Ctrl-C shuts down server
+and client cleanly. Include `server stop` to finish a temporary-server sequence
+normally; errors or an explicit `exit confirm` also shut everything down.
+
+Exit codes are 0 for success, 2 for command/argument/protocol errors, 1 for
+unexpected command failures, and 130 for Ctrl-C.
+
+## Projects, profiles, and records
+
+Projects keep configs, tags, command history, and decoded operation records in
+ctui's user data directory under `io.controlthings.ctmodbus`. Save a client
+profile to reuse connection settings:
+
+```text
+project
+project configs list
+project configs show tcp-local
+client config load tcp-local
+client config show
+client config set --unit 7 --timeout 0.5
+client config save lab
+client start
+read holding_registers 0-9
+client stop
+project history export commands.txt
+project export lab.ctui-project
+project create another-lab
+project load default
+client config load lab
```
-## Planned UI commands once complete:
+This example assumes the default project and a device matching the saved
+settings. `tcp-local` and `udp-local` are built-in localhost profiles.
+Loading a config selects it without connecting; use `client start` to connect.
+`client config show` includes the saved source and any unsaved changes.
+Saving replaces the named profile with the selected settings, even while
+disconnected.
+
+To select settings from scratch, supply transport and target first:
+
+```text
+client config set --transport tcp --target localhost --port 502 --unit 1
+client config set --timeout 0.2 --retries 0
+client config save lab
+client start
+client status
+client stop
+client config load lab
+```
+
+Edit settings while the client is stopped. Only supplied fields change, and
+invalid edits leave the previous selection intact. Fields include network,
+serial, and TLS options. Clear certificate paths with `--clear-ca-file`,
+`--clear-cert-file`, and `--clear-key-file`. Use `--insecure false` to restore
+verification. Changing transports keeps omitted fields, so clear incompatible
+TLS settings in the same edit. Selection survives a client stop and clears
+when changing projects.
+
+Stop client, server, and polling before switching or resetting projects.
+ctui also provides config JSON and whole-project import/export. Live connections,
+tasks, runtime observations, and certificate/hook file contents aren't saved.
+
+Records link attempted requests, decoded responses, and errors to a connection
+session; they aren't packet captures. Recording failures are reported separately
+from device outcomes so an acknowledged write isn't mistaken for a failed one.
+Command history contains successful commands; protocol errors remain in
+operation records.
+
+## Development and validation
+
+From a repository checkout:
+
+```bash
+uv sync --locked --python 3.11
+uv run ctmodbus
+```
+
+Development and release checks use published `ctui==1.0.0` from the lockfile.
+Run the checks with:
+
+```bash
+uv run --python 3.11 python -m unittest discover -s tests -v
+uv run black --check src tests
+uv run isort --check-only src tests
+uv run pylint src/ctmodbus
+uv lock --check
+git diff --check
+uv build
+```
+
+Tests use temporary projects, local TCP/UDP/TLS servers, and terminal/web
+runtimes. POSIX PTYs cover RTU/ASCII framing; Windows skips those tests.
+TLS tests need OpenSSL and skip if it isn't available. Physical serial adapters
+still need hardware checks.
+
+For a manual TCP fixture, run:
```bash
-ctmodbus> write coils 76 01101001 # or multiple values
-ctmodbus> write holding_register 1000 14302 188 305 # registers support int
-ctmodbus> write holding_register 1000 "My name is Mud" # and strings
-ctmodbus> write holding_register 1400 DEADBEEF # or raw hex
-ctmodbus> poll holding_register 1-10,15-19 1 # poll registers every second
-ctmodbus> tags add input1 input_register 1 # define tag names
-ctmodbus> tags add config2 holding_register 50-69 # tags can define ranges
-ctmodbus> tags add config3 holding_register 70 20 # and work with start & count
-ctmodbus> read tags input1 config2 config3 # tags simplify reads & writes
-ctmodbus> tags group configs config1 config2 config3 # create tag groups
-ctmodbus> tags export saved.tags # export and share tags
-ctmodbus> tags import saved.tags # import other's tags
-ctmodbus> clone tcp:10.10.10.10 coils 1-100 # clone coils from a device
-ctmodbus> clone tcp:10.10.10.10 all 1-100 # or all types of values
-ctmodbus> simulate tcp:127.0.0.1:10502 # so you can later simulate
-ctmodbus> proxy tcp:10.10.10.1:10502 rtu:com4 # proxy requests to device
-ctmodbus> function 33 0000 DEADBEEF # send custom functions
-ctmodbus> function 8 [0000-FFFF] 0000 # brackets for enumeration
-ctmodbus> function 8 [0000-00FF] (0000)5 # parenths for random fuzzing
-ctmodbus> raw 1234 0001 06 01 0000 0010 # or full raw modbus payloads
-ctmodbus> tunnel listen tcp::6666 # setup modbus tunnel service
-ctmodbus> tunnel connect tcp:10.1.1.1:6666 # connect from another comp
-ctmodbus> tunnel send exfiltration.txt # send files through tunnel
-ctmodbus> tunnel shell # or open a terminal session
-ctmodbus> historian tcp:10.1.1.1:9300 # transactions to cthistorian
-```
-
-## This tool is built upon these to key library:
-
-- [Control Things User Interface](https://github.com/ControlThingsTools/ctui)
-- [PyModbus](https://github.com/bashwork/pymodbus)
-
-
-## Copyright 2025 Justin Searle
-
-This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License along with this program. If not, see .
+uv run python tests/server.py tcp --port 5020
+```
+
+It also supports `udp`, `rtu`, and `ascii`; serial fixtures take
+`--target DEVICE`. Use the [release checklist](RELEASE_CHECKLIST.md) for the
+platform matrix, isolated wheel/sdist checks, and publishing procedure.
+
+Moving from 0.x? See [MIGRATION.md](MIGRATION.md) for the command and storage
+changes. [CHANGELOG.md](CHANGELOG.md) covers user-facing changes;
+[project status](docs/STATUS.md) tracks current work and validation gaps, and
+[decisions](docs/DECISIONS.md) explains architecture and accepted choices.
+Device cloning, MITM rules, raw/fuzzy requests, tunneling, and historian
+integration remain deferred.
+
+## License
+
+Copyright Justin Searle. Licensed under the GNU General Public License,
+version 3 or later. See [LICENSE](LICENSE).
diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..8f1ed13
--- /dev/null
+++ b/RELEASE_CHECKLIST.md
@@ -0,0 +1,73 @@
+# Release checklist
+
+Target release tag: `v1.0.0`. Python package version: `1.0.0`.
+Merge the release-preparation PR from `dev-v1.0.0` into `main` before tagging.
+Record each check's revision in [docs/STATUS.md](docs/STATUS.md); repeat release
+gates on the intended `main` release revision after merging.
+
+## Automated gates
+
+Run every gate with published `ctui==1.0.0`, without the former
+`--with-editable ../ctui` override. Confirm package metadata and the lockfile
+both report ctmodbus `1.0.0` before starting.
+
+- [ ] `uv sync --locked --python 3.11`
+- [ ] `uv lock --check`
+- [ ] `uv run --locked --python 3.11 black --check src tests`
+- [ ] `uv run --locked --python 3.11 isort --check-only src tests`
+- [ ] `uv run --locked --python 3.11 pylint src/ctmodbus`
+- [ ] `uv run --locked --python 3.11 python -m unittest discover -s tests -v`
+- [ ] `git diff --check`
+- [ ] CI green on Linux x86-64, Linux ARM64, macOS, and Windows with Python 3.11–3.14.
+- [ ] Build wheel and sdist with `uv build`.
+- [ ] Smoke-test each distribution in an isolated environment:
+ `uv run --python 3.11 --isolated --no-project --with dist/ARTIFACT tests/smoke_test.py`.
+
+## Manual release checks
+
+- [ ] Run the TUI in a real terminal; check completion, output scrolling, progress,
+ error display, cancellation during slow I/O, and exit cleanup.
+- [ ] Run the web UI; check completion, dialogs, sustained polling/request output,
+ cancellation, and exit cleanup in a browser.
+- [ ] Test RTU and ASCII against physical serial adapters/devices. Automated PTY
+ checks verify framing but do not reproduce RS-485 timing or driver behavior.
+- [ ] Review README commands and changelog against the intended release.
+- [ ] Publish only after release approval; creating/pushing a version tag triggers
+ the existing trusted-PyPI publishing workflow.
+
+## Recording validation
+
+Record dated results and their revision in [docs/STATUS.md](docs/STATUS.md).
+Re-run these gates on the intended release revision; historical local results
+do not establish cross-platform CI, physical-device behavior, or publication.
+
+## GitHub and PyPI release setup
+
+- [ ] Commit and push the release-preparation changes when authorized, then open
+ a PR from `dev-v1.0.0` into `main` with the change summary and validation evidence.
+- [ ] Review and merge the PR after required CI passes. Confirm `main` contains
+ the intended 1.0.0 metadata, changelog and documentation, and validate that
+ final revision before tagging it.
+- [ ] Confirm the `pypi` GitHub environment and PyPI trusted publisher reference
+ this repository and `.github/workflows/publish.yml`.
+- [ ] Confirm the changelog has `## [1.0.0] - 2026-10-24`; update the date if the
+ approved publication date changes. Missing version sections fail before
+ publication.
+- [ ] Review the diff against the previous release and start with a clean checkout.
+- [ ] Inspect the wheel and sdist metadata, README, license, and dependencies.
+- [ ] After release approval, create and push the annotated tag on the validated
+ `main` release revision:
+
+ ```bash
+ git tag -a v1.0.0 -m "ctmodbus 1.0.0"
+ git push origin v1.0.0
+ ```
+
+- [ ] Watch metadata validation, all 16 OS/Python verification combinations,
+ quality checks, artifact smoke tests, attestation, and publication complete.
+- [ ] Confirm the GitHub release includes curated changelog notes, generated
+ notes, and distribution files; `v1.0.0` must be stable and marked latest.
+- [ ] Confirm both distributions and attestations appear on PyPI, then install
+ `ctmodbus==1.0.0` in a new environment and check help and a device workflow.
+- [ ] Remove README's temporary checkout-installation note after publication.
+- [ ] Never reuse an already published version; fix and increment it instead.
diff --git a/VERSION b/VERSION
deleted file mode 100644
index 2411653..0000000
--- a/VERSION
+++ /dev/null
@@ -1 +0,0 @@
-0.5.2
\ No newline at end of file
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
new file mode 100644
index 0000000..6b9b9b0
--- /dev/null
+++ b/docs/DECISIONS.md
@@ -0,0 +1,463 @@
+# Project decisions
+
+Recorded 2026-09-18 from the September 13 migration discussion, authorized plan,
+and implementation history through `426048a`; owner clarifications date from
+September 18. Dates below identify original decisions. “Accepted” means owner
+direction or an authorized plan; “Implemented policy” does not imply the owner
+specified every detail. Read entries relevant to the task, using their headings.
+
+## D01 — Migrate the existing feature set without 0.x compatibility
+
+Accepted, 2026-09-13; `c225522` through `7914437`.
+
+Prepare for 1.0 using ctui 1.0.0rc1, make I/O async where possible, and take
+advantage of the framework's typed commands, CLI, persistence, and lifecycle.
+The owner explicitly waived backward compatibility and prioritized migration
+over additional functionality. Preserve TCP/UDP/RTU/ASCII, discovery, device
+identification, all four read types, and coil/holding-register writes.
+
+Remove legacy command/storage compatibility and the development `debug eval`
+command. Repair existing broken multi-writes, unchecked replies, sparse device
+identification, and range ambiguity. Defer the expansion items in STATUS.
+This migration-specific waiver is not a future blanket compatibility policy.
+
+## D02 — Native async clients and application-owned state
+
+Accepted migration architecture, 2026-09-13; `39d0633`, `500949f`.
+
+Use native PyModbus async clients instead of wrapping synchronous device I/O.
+One ModbusApp owns its connection, settings, and tasks; replace import-time
+global application/unit state. Serialize whole device operations and connection
+changes; reject queued work from an obsolete connection generation.
+
+Offload serial enumeration, process inspection, and later TLS certificate loading
+with `asyncio.to_thread`; retain synchronous pure parsing/formatting. Bound
+requests and drain/cancel outstanding work at shutdown. Explicit `cancel`/`close`
+closes the transport, requiring reconnection; do not automatically reconnect.
+These boundaries keep the UI responsive and avoid stale replies or operations
+silently acting on a replacement connection. Ctrl-C clears input through ctui;
+it is not the device-work cancellation command.
+
+## D03 — One typed command path for TUI and CLI
+
+Accepted migration design; implemented policy, 2026-09-13; `b5dabf8`.
+
+Use CtuiApp, public `@command`/`Argument` APIs, `IntegerRanges`, typed lists,
+CommandError, and CommandResult. TUI and sequential `-c`/`-f` CLI share dispatch.
+Use plural snake_case names for raw reads/writes, comma-separated values, and
+explicit named options. Host and port are separate, including IPv6. Do not restore old
+aliases, host:port parsing, or space-separated multi-write values.
+
+Addresses are zero-based wire addresses 0–65535. Read ranges are inclusive,
+preserve order and repetitions, and accept at most 65,536 addresses. `--max-count`
+limits a request, not total addresses: 2,000 bits or 125 registers. Multi-writes
+are one request, never silently split (1,968 coils or 123 registers maximum).
+Validate the full requested range/value set before device I/O.
+
+Accepted extension, 2026-10-23: address-based holding-register writes accept
+comma-separated 16-bit integer literals with the same decimal/radix syntax as
+tags. Support signed int16 values and preserve unsigned 0–65535 input; negative
+values encode as two's complement. Each literal occupies one register, with no
+type inference or multi-register packing. Mixed `0x`/`0b`/`0o`/`0d` prefixes,
+signs, and underscores use one shared parser with tags. Parse every value before
+I/O; existing request limits, acknowledgements, and unsigned wire records apply.
+The earlier optional `--type` suggestion is not part of this change.
+
+## D04 — Report protocol evidence accurately
+
+Accepted correctness goals; implemented policy, 2026-09-13; `b5dabf8`, `500949f`.
+
+Validate response functions, lengths, values, and write echoes/counts before
+reporting success. A matching write acknowledgement is not independent readback.
+Failed/interrupted writes may have taken effect; explicitly distinguish an
+unconfirmed outcome from a write never sent. Default retries are zero.
+
+Keep completed values when a later read chunk fails. Handle sparse/continued
+identification and reject invalid continuation. Render UTC timestamps and
+compressed address/value summaries consistently in both interfaces; escape
+terminal controls. Register characters represent Unicode code points, not UTF-8
+decoding. Append output with CommandResult rather than stale output snapshots.
+
+## D05 — Project-scoped profiles and decoded records
+
+Accepted framework integration; implemented policy, 2026-09-13; `c6054e2`,
+`500949f`, `7914437`.
+
+Use ctui services under stable app ID `io.controlthings.ctmodbus`: configs for
+named connection profiles, records for attempted requests/decoded responses/
+errors grouped by connection session, and project command history. Live clients
+and tasks remain runtime state. Provide tcp-local/udp-local profile templates.
+
+Require closed connections and finished device work before changing/resetting
+projects, and finish recording sessions in their original project. This prevents
+cross-project records. Records are decoded operations, not raw packet capture.
+Storage warnings must not turn acknowledged writes into apparent protocol
+failures and prompt accidental retries. History follows ctui's successful-command
+policy; protocol errors remain available in records.
+
+## D06 — Packaging and dependency baseline
+
+Accepted migration plan; implemented policy, 2026-09-13; `c225522`.
+
+Require Python 3.11+ to match ctui. Pin `ctui==1.0.0rc1` for the migration and
+constrain PyModbus to the tested 3.15 minor series, locking 3.15.0. The recorded
+rationale for the minor bound is PyModbus API changes between minor releases.
+Do not expand it without compatibility validation.
+
+Accepted update, 2026-10-23: pin published stable `ctui==1.0.0` and end the
+temporary adjacent-checkout override. Local development had already exercised
+the ctui checkout leading to this release; validate the published distribution
+and its resolved dependencies before claiming release readiness. This
+supersedes the migration ctui pin; ctmodbus's own version remains unchanged.
+
+Use pyproject.toml as package/version metadata, uv_build, and uv.lock; remove
+obsolete setup.py and VERSION duplication. Align metadata with the existing
+GPL-3.0-or-later source/license; this was not a new relicensing decision.
+
+## D07 — TLS added by explicit follow-up
+
+Accepted, 2026-09-13; `ac4af8d` (formerly `504ffa6` before commit rewording).
+
+The owner requested `connect tls` after the baseline migration. Use native
+Modbus TLS framing with default port 802. Verify trust and hostname by default,
+support private CA roots and optional client identity, and require explicit
+`--insecure` to disable verification. Persist file paths/settings, never key or
+certificate contents; referenced files must exist on the reconnecting machine.
+Current key loading requires unencrypted keys. Integration tests use ephemeral
+OpenSSL certificates for verified I/O, mutual TLS, and rejection cases.
+
+## D08 — Release candidate first, ctui-aligned release workflows
+
+Accepted, 2026-09-13; `4fcc4f2`, `426048a`.
+
+The owner explicitly set the next intended release to `v1.0.0rc1`, with package
+version `1.0.0rc1`, rather than immediately publishing stable 1.0. The migration
+plan requires adopting tested stable ctui before final ctmodbus 1.0.
+
+At the owner's request, align build/test/publish workflows with ctui while
+retaining ctmodbus source paths and pylint. Test Python 3.11–3.14 on Linux
+x86-64/ARM64, Windows, and macOS. Build and smoke-test wheel and sdist, upload
+tested artifacts, validate tag/version equality, attest, and use trusted PyPI
+publishing. GitHub release notes combine curated changelog and generated notes;
+prereleases are marked prerelease and never latest.
+
+Follow the release checklist and obtain release approval before publication.
+Local passing tests do not prove remote CI, real-terminal usability, or physical
+serial timing/driver behavior. This decision does not assert publication occurred.
+
+Owner update, 2026-09-18: ctmodbus RC1 is not published; feature development is
+still ongoing. Release validation follows that work. The specific remaining
+features have not been listed, so do not invent commitments from deferred ideas.
+
+Accepted update, 2026-10-24: prepare stable ctmodbus 1.0.0 as the next release,
+superseding the earlier RC-first target. The changelog is dated October 24 at
+the owner's request. Stable ctui adoption is recorded in D06; package metadata,
+release gates and publication approval remain separate release steps.
+Merge release preparation from `dev-v1.0.0` into `main` through a reviewed PR,
+then tag the validated `main` release revision after publication approval.
+
+## D09 — Migration commit history
+
+Accepted, 2026-09-13.
+
+The owner authorized rewording this branch's migration commits to Conventional
+Commits. The hashes above identify the resulting history;
+`backup/pre-conventional-commits-504ffa6` preserves the earlier seven commits.
+This is not unfinished implementation or standing authorization to rewrite
+future shared history. General commit preferences live in global instructions.
+
+## D10 — Project memory and instruction scope
+
+Accepted, 2026-09-18; scope refined 2026-09-20 and 2026-09-21.
+
+Keep project constraints and context locations in AGENTS.md, current work and
+validation gaps in STATUS.md, and durable project rationale here. Track these
+files with the branch for laptop continuity. Personal development preferences
+belong in `~/.codex/AGENTS.md` and need separate synchronization between machines.
+
+Keep STATUS focused on actionable state; Git preserves completed milestones and
+prior evidence reviews. Read relevant decisions on demand rather than requiring
+the entire log for every task. Retain technical boundaries and rationale so
+context savings do not depend on rediscovering correctness constraints.
+
+The September 21 owner-approved documentation policy places local implementation
+contracts in docstrings, shared architecture and unresolved discrepancies here,
+current work/evidence in STATUS, usage in README, and release procedures in
+RELEASE_CHECKLIST. MIGRATION remains a short compatibility guide; CHANGELOG
+retains release-facing history. These complementary files link to authoritative
+locations instead of duplicating contracts or current validation claims.
+
+## D11 — Project-scoped typed tags
+
+Accepted and implemented, 2026-09-20.
+
+Tags map a project-local name to a Modbus table, first zero-based address, and
+type; the type derives the address count. They are independent of connection
+profiles, hosts, and unit IDs, and use whichever single connection is active.
+Support Boolean, signed/unsigned 8/16/32/64-bit integers, and IEEE 32/64-bit
+floats. Boolean tags use coils or discrete inputs; numeric tags use input or
+holding registers. Writes remain limited to coils and holding registers.
+
+Register tags default to big byte order within each 16-bit register and little
+word order across registers, with named options for both. Eight-bit values use
+one full register: big byte order puts the value in the low byte, little byte
+order in the high byte, and writes zero to the unused byte. This avoids an
+unsafe read-modify-write. Reject non-finite floats. Accept natural typed values
+and radix-prefixed integers without requiring ctui `HexBytes`. For signed types,
+interpret unsigned binary/octal/hex literals as fixed-width two's-complement bit
+patterns; signed and decimal literals retain their numeric meaning. Negative
+positional values use ctui's standard `--` end-of-options marker.
+
+Use comma-separated names for `read tags` to fit ctui's typed-list command
+model. Use singular table names in `tags create` because it accepts one starting
+address; persisted definitions retain canonical plural Modbus table names.
+With no names, read all tags in stable name order; otherwise preserve requested
+order and duplicate names. Allow overlapping tags with a creation warning.
+Export deterministic versioned TOML, adding `.toml` when absent; validate
+imports completely and apply them atomically. Existing names require a
+collision-specific TUI confirmation or explicit `--replace`. Keep file
+operations within the tag command group as `tags export` and `tags import`; both
+use ctui's shared path completer.
+
+Accepted naming update, 2026-10-06: rename the management command group from
+`tag` to `tags` at the owner's request. Keep `read tags`, `write tag`, and
+`serve data set tag` unchanged because they name their existing I/O operations.
+Do not register a separate singular management alias; ctui's ordinary command
+prefix matching still applies.
+
+Tagged I/O reuses the existing serialized read/write path, response checks,
+recording, cancellation, and uncertain-write reporting. Tag definitions are
+included in whole-project snapshots and cleared by a full project reset.
+
+Superseded alternatives: explicit end-address ranges became type-derived widths;
+the initial little-byte proposal became big bytes/little words; raw HexBytes
+input was dropped in favor of typed numeric parsing; whitespace tag lists became
+ctui comma-separated lists; creation table names became singular. No permanent
+unit/host association is stored, including for a possible future multi-connection
+model; any such association would be session-local and is not implemented.
+
+## D12 — Implementation discrepancies and ctui proposals
+
+Audit findings, 2026-09-21, against `4718ddd`; unresolved, not new accepted policy.
+
+- Resolved 2026-10-02: multi-tag reads hold one operation reservation and retain
+ completed decoded rows on later failure/cancellation. A shared reserved-read
+ helper preserves response checks and raw operation records. Adjacent-read
+ batching remains unimplemented; device values can change between requests.
+- Resolved 2026-10-02: import dispatch reserves the active project and tag edits
+ before confirmation, using ctui argument parsing (including command prefixes).
+ It retains validated content per task and applies that exact content. Concurrent
+ tag edits/project changes are rejected; cancellation releases the guard.
+- Import applies all rows in a short synchronous transaction on a separate SQLite
+ connection, with no cancellation checkpoints during the transaction and no
+ busy wait. This prevents ctui's shared-connection commits from exposing partial
+ imports. Cancellation before application leaves tags untouched; after commit
+ all imported rows remain. Metadata touch is still separate. Full reset still
+ clears the tag table separately after ctui's reset.
+- Tag file reads/writes run synchronously on the event loop. Export uses a fixed
+ `.tmp` sibling, so atomic destination replacement is not concurrent-export
+ safety. Import has no file-size limit.
+- Signed radix bit patterns and negative decimal writes were checked in focused
+ tests, but the history does not establish a full final-revision regression run
+ after all tag follow-ups. Artifact checks likewise preceded those follow-ups.
+
+Proposed ctui improvements, not dependency commitments: state-dependent
+confirmation callbacks; application-owned project table initialization/reset/
+statistics; extensible reset sections. The comma-separated tag list removed the
+need for variadic CLI parameters. Keep these proposals separate from accepted
+Modbus semantics. Contract docstrings describe current behavior; resolving the
+discrepancies requires implementation work and regression checks.
+
+## D13 — Independent servers, sparse emulation, proxy routing and evidence views
+
+Accepted, 2026-10-03, from the owner-directed design in this session.
+
+Add one independent server alongside the existing single client. Support TCP,
+UDP, native TLS, RTU and ASCII, with local bind defaults 5020/8020. Serial serving
+is an addressed device, not a tap. Use shared CTUI dispatch and an explicit
+foreground option for long-running CLI serving. Stop/drain before project changes.
+
+Persist versioned server definitions in project configs, merge referenced shared
+project tags, and keep runtime data/observations separate. Defaults expose all
+four 0–65535 tables, false/zero; imported omitted tables are unavailable. Each
+configured table chooses illegal-unmapped or default-backed addresses. Raw ranges
+and typed tags support static, random and read/time sequence rules. Keep sparse
+values, coherent typed request samples, reproducible seeds and reset behavior.
+
+Use optional trusted companion Python hooks on_read/on_write/on_tick with a
+small decoded-tag get/set API. Request hook failures roll back pending state and
+return exception 04; tick failures retain prior state. Confirm differing project
+tag definitions, apply the exact confirmed data, and atomically store definitions
+and tags. Configuration must be stopped for edits; no dynamic rule language yet.
+
+Proxy enable requires both endpoints and zero upstream retries. Capture routing
+mode per request, preserve downstream transaction/unit identities, translate
+framing through PyModbus, serialize full upstream exchanges with console I/O,
+and forward exceptions without simulated fallback. Server-unit requests map to
+connection unit; unsupported functions return 01. Keep separate upstream and
+presented observations, distinguishing read evidence, write acknowledgement and
+uncertainty. Observations reset on fresh sessions and are not persisted. MITM
+request/response seams exist, but rules and separate MITM TOML remain deferred.
+
+Server configuration, hooks and proxy usage are documented in
+[README.md](../README.md#server).
+
+## D14 — Fixed-cadence polling and appended evidence rows
+
+Accepted at the owner's request, 2026-10-06.
+
+One runtime poll belongs to each application. `poll tags` snapshots all or
+explicit ordered tags; `poll raw` accepts ordered table options and inclusive
+ranges. Keep protocol limits, response validation, project isolation, and one
+serialized connection. Reserve a whole cycle, then release between cycles so
+manual device commands can proceed. Never queue polling behind busy device work.
+
+Use a monotonic fixed cadence, starting immediately. Skip busy or obsolete ticks
+rather than overlap requests, cancel at each deadline, or accumulate catch-up
+work. Count means started cycles; duration limits cycle starts. Both limits work
+in terminal and browser sessions; CLI streams and waits for completion. Exact
+wire timing is not guaranteed by the event loop or transport.
+
+Append a header and one compact line per cycle to shared output. Preserve target
+order/repetitions and display typed tags, contiguous raw bits, and four-digit raw
+hex registers. Mark failed columns with ERR, retaining confirmed raw chunks and
+operation records. Continue after column errors while the transport is usable;
+stop on connection loss. A latest-values-only view was superseded by the owner's
+explicit preference for every row, including unchanged values.
+
+Keep stable project/tag/connection targets through preparation and execution;
+reject tag mutations during polling. `poll status` retains counters and reason
+after completion. `poll stop` drains gracefully; cancel/close/shutdown cancel
+polling and close the connection. Poll tasks and plans are runtime state only.
+
+Accepted display refinement: use `#` for the polling counter and ` | ` between
+all columns. Preallocate widths from labels, integer bounds, float precision,
+and full raw range lengths. Right-align counters/numeric tags and left-align
+booleans/raw sequences. Float32/64 use 9/17 significant digits to retain
+round-trip precision in a compact representation. Widen overflowing columns and
+repeat the header before the affected row; do not rewrite previous output.
+
+## D15 — Independent server and proxy request display
+
+Accepted at the owner's request, 2026-10-06.
+
+Append one incoming-request line per decoded server PDU and another when proxy
+forwarding starts, sharing an application-local monotonic request ID. Log server
+arrival before queuing and proxy dispatch after unit mapping. Show UTC timestamps,
+peer/upstream, unit, function/range, and bounded write previews; retain full writes
+in server records. These lines describe attempted activity, never success.
+
+Default both routine streams on. Each enabling command has `--quiet`, and live
+`serve logging` / `proxy logging` switches independently control them. Errors
+remain visible when quiet; record collection is unaffected. Settings are runtime
+state, reset by each enabling command. Use a shared synchronous output append
+helper for polling and logs so no await interleaves updates to existing output.
+Do not reinstate the reverted append default for all command results. MITM output
+remains future work, without rules or extra feature scope in this implementation.
+
+## D16 — Component commands, selected client settings, and strict TOML
+
+Accepted and implemented, 2026-10-06, by explicit owner direction. This
+supersedes the command spellings in D11, D13–D15; their I/O and evidence policies
+otherwise remain in force.
+
+Larger commands follow **component → action → transport or target**.
+Use client start tcp/udp/tls/rtu/ascii, client discover, client stop,
+and client status; use parallel server start/stop/status and proxy
+start/stop/status. Client stop replaces both close and cancel. Status combines
+lifecycle information and retained runtime evidence. Saved server definitions
+belong under server config, companion hooks under server hook, and runtime
+reset is server reset --confirm. Logging switches remain component-specific.
+Ordinary read/write/poll syntax stays short.
+
+Client settings are selected independently from live I/O. Client config
+provides show/set/save/load; loading requires a stopped client and explicit
+client start. Editing only supplied fields validates the full result before
+replacement. Initial selection requires transport/target; TLS starts with port
+802, serial with a one-second timeout. Show identifies saved source and unsaved
+changes. Explicit start settings replace selection; stop retains it and project
+transitions clear it. Certificate contents and live clients/tasks are never
+stored. TLS paths have explicit clear flags and insecure mode accepts true/false.
+ctui-owned config fields and built-in commands are unchanged.
+
+Proxy start requires both endpoints and zero upstream retries. Component stops
+require confirmation while proxy routing is active; rejection preserves both
+components, approval stops proxy routing with the chosen endpoint. CLI supplies
+--confirm; TUI/WUI use the requesting interface's confirmation dialog. Dispatch
+reserves stop approval across awaits to prevent competing lifecycle transitions.
+Unexpected upstream loss still fails without local fallback. Server runtime
+reset preserves saved configuration and timestamped historical observations.
+
+Bare client/server/proxy open concise example overviews in help popups; CLI
+prints the same text. Generated help COMPONENT remains available. ctui's
+internal help-result presentation protocol is isolated in component_help.py;
+release validation must exercise it against the intended published ctui build.
+
+No legacy aliases or TOML migrations are retained because these features are
+unreleased. Framework unique command prefixes remain available (serve can
+resolve as a prefix of server, rather than an explicitly registered alias).
+Server files require format ctmodbus-server and integer version 1; tag files
+require ctmodbus-tags and integer version 1. Unknown nested/top-level fields
+fail with actionable errors. Hook tick_seconds becomes tick_interval_seconds;
+server version stays 1 because the feature is unpublished. Existing server
+definitions need explicit format/key updates, or clear/reimport.
+
+Client and proxy command mixins now live in client_commands.py and
+proxy_commands.py; server commands remain in server_commands.py. App.py
+owns shared dispatch arbitration, projects, records, progress and shutdown.
+Transport validation/factories and request routing remain in their existing
+modules, preserving serialization and protocol evidence contracts.
+
+## D17 — Management result popups and CLI server keepalive
+
+Accepted and implemented, 2026-10-06, by explicit owner direction. This supersedes
+D13's explicit foreground option and ordinary management result presentation.
+
+Use standard ctui MessageDialog popups for owned start/stop/list/show/status,
+import/export, discovery, validation, configuration set/save/load, tag edits,
+server rule/hook edits, logging controls, reset, and clear. Descriptive titles,
+scrollbars and unwrapped terminal text support reference tables. Actions finish
+before presentation; modal dismissal gates later input without blocking async
+servers, proxy exchanges, polling or output appends. Concurrent already-running
+commands serialize their result dialogs; shutdown cancels pending presentations.
+Existing confirmation dialogs precede the action. Errors and component help
+retain their standard ctui presentation. Do not change ctui-owned commands.
+
+Read/write results, polling rows and request logs remain in the main output.
+CLI and headless dispatch preserve plain result text. The result policy and
+standard widget call are isolated in result_popups.py; no custom widgets or
+browser styles are introduced.
+
+Remove --foreground from all transports/interfaces. Start always returns after
+binding. CLI executes all supplied commands, then keeps a still-running server
+alive after a successful sequence. A sequence stopping the server exits normally.
+The first expected/unexpected command error, or explicit exit, skips keepalive
+and cleans up all services with existing exit status semantics. Cancellation
+also drains services and closes project storage. The application implements
+keepalive before shutdown through its lifecycle hook, retaining ctui's parsing,
+sequential execution, error formatting, and backend ownership. Console Ctrl-C
+finishes async cleanup and exits 130 without printing a traceback.
+
+## D18 — Confirmed bulk tag deletion and server reference integrity
+
+Accepted and implemented, 2026-10-07, by explicit owner direction.
+
+Extend tags delete with exclusive NAME/--all targets. Bulk deletion requires
+standard Yes/No approval in TUI/WUI or --confirm in CLI; no tags is a no-op
+without a prompt. Saved server tag entries prevent single or bulk deletion
+unless --remove-server-rules explicitly removes those entries too. Rule removal
+requires approval. Keep raw ranges/defaults, identity, unit/seed settings, hooks,
+files and other configs unchanged. Retained hooks trigger a pre-approval warning
+about Python references that cannot be reliably analyzed; single deletion with
+hooks also needs approval. Results use the standard management MessageDialog.
+The owner's initial "only MessageDialog" wording was clarified to permit the
+usual Yes/No confirmation dialog; no two-step review command is implemented.
+
+Reserve project/tag/server changes across target resolution, approval, and commit.
+Retain the exact confirmed plan and reject a concurrently changed saved server
+config. Delete tags and update the server entry in a short separate SQLite
+transaction, without cancellation checkpoints, so unrelated framework commits
+cannot expose partial work. SQL failure rolls back both tables. Project metadata
+touch follows the data commit, matching the existing import policy; it is not
+part of the data transaction. Polling and the server must be stopped. Exported
+TOML and companion hook scripts remain untouched; importing can recreate tags.
diff --git a/docs/STATUS.md b/docs/STATUS.md
new file mode 100644
index 0000000..cb30eed
--- /dev/null
+++ b/docs/STATUS.md
@@ -0,0 +1,451 @@
+# Project status
+
+Last reconciled: 2026-10-24, `dev-v1.0.0` at `b43c72e`, after verifying the
+complete passing GitHub Actions run. Only this status reconciliation is
+uncommitted; the branch is ready for a release-preparation PR into `main`.
+
+## Current state
+
+- The [latest CI run](https://github.com/ControlThings-io/ctmodbus/actions/runs/40074562350)
+ at `b43c72e` passes all 18 jobs: all 16 OS/Python test combinations, quality
+ checks, and wheel/sdist builds and smoke tests. Windows, macOS, Linux x86-64
+ and Linux ARM64 are green across Python 3.11–3.14.
+- The test portability fixes are committed and remotely validated. Paths are
+ quoted and canonicalized, polling uses event-based/virtual-clock regressions,
+ and the TLS fixture uses IPv4 with a matching certificate IP SAN and the
+ normal three-second timeout. Runtime code and workflow skips are unchanged.
+
+- CHANGELOG now targets 1.0.0, dated 2026-10-24 at the owner's request, with
+ Added/Changed/Fixed/Compatibility sections checked against current usage and
+ implementation. Package metadata, lockfile and release checklist now target
+ stable 1.0.0. Branch CI and artifact smoke tests pass; PR/main CI and manual
+ release checks remain outstanding. Neither the metadata nor dated changelog
+ establishes publication.
+- Release preparation must go through a reviewed PR from `dev-v1.0.0` into
+ `main`. No open PR for this branch/base exists at this review. Open the PR
+ with validation evidence, then verify the final `main` revision before
+ approving and pushing `v1.0.0`. The checklist records this sequence; this
+ review did not create a PR or perform a merge or publication.
+
+- README uses the owner's approved informal, concise technical style throughout.
+ The feature list distinguishes flexible reads from consecutive writes and
+ runtime status from saved project data. Development setup is consolidated;
+ separate server and proxy sections each include their own request logging
+ examples, before CLI/project usage. Detailed server configuration, address
+ maps, simulation rules, Python hooks and proxy routing are now in README;
+ the separate server guide is removed and its incoming links are updated.
+ Corrected project history export, stale polling deferral and ctui overrides.
+ PyPI still lists 0.6.0 on October 24, so installation directs readers to the
+ checkout for 1.0 features until publication.
+
+- `write holding_registers` accepts comma-separated mixed decimal and
+ `0x`/`0b`/`0o`/`0d` literals using the shared tag integer parser. Signed int16
+ values encode as two's complement, existing unsigned values remain supported,
+ and each literal occupies one register. Full-list validation precedes the
+ existing single-request write path. See [README usage](../README.md#interactive-commands)
+ and D03; optional typed address writes remain outside this change.
+
+- Async migration, TLS, project-scoped typed tags (`5eb04ee`), and USB identity
+ in serial completion (`4718ddd`) are implemented. Architecture and accepted
+ choices are in [DECISIONS.md](DECISIONS.md).
+- Version is `1.0.0`; dependencies are `ctui==1.0.0` and
+ `pymodbus[serial]>=3.15.0,<3.16` (locked to 3.15.0), Python >=3.11,<4.
+- Development and release validation now use the locked PyPI ctui distribution.
+ The temporary adjacent-checkout override has ended. `uv sync --locked`
+ replaces prior editable ctui installations in `.venv`; direct imports now
+ resolve to ctui 1.0.0 in site-packages. See the
+ [development setup](../README.md#development-and-validation) and D06.
+- Published ctui 1.0.0 fixes the earlier RC1 import incompatibility. The GitHub
+ `pypi` environment exists; PyPI trusted publisher configuration still needs
+ verification.
+- The September 21 audit updates global documentation ownership rules, expands
+ contracts throughout all 16 project Python files, and reconciles all seven
+ Markdown files. Global instructions need separate synchronization per machine.
+- Resolved and remaining tag lifecycle/concurrency discrepancies are recorded in
+ [D12](DECISIONS.md#d12--implementation-discrepancies-and-ctui-proposals).
+- Tag TOML commands are grouped with the other tag-management commands as
+ `tags export` and `tags import`; the unreleased `export tags` and `import tags`
+ spellings are not retained as aliases. Both path arguments use the shared
+ ctui path completer from the published stable dependency.
+ The management group is now `tags` (create/list/show/rename/delete/export/
+ import); dispatch guards, tests, and examples use that spelling. `write tag`
+ and server tag I/O retain their existing syntax. See D11 for the naming decision.
+
+- Server/emulator commands, all five transports, TOML and terminal definition
+ editing, typed/random/sequence rules, optional Python hooks, plain proxying,
+ and independent client/downstream evidence views are implemented locally.
+ See [README usage](../README.md#server), [D13](DECISIONS.md#d13--independent-servers-sparse-emulation-proxy-routing-and-evidence-views),
+ and the pump/tank example under `examples/`. MITM rules remain deferred.
+
+- Fixed-cadence `poll tags` and multi-table `poll raw` now run one poll per app,
+ with interval/count/duration options, retained status, and graceful stop.
+ Each cycle appends one aligned typed/raw row headed by `#`, with separators
+ between all columns and repeated headers on width expansion. Busy ticks are
+ skipped and partial reads remain recorded. CLI polling streams and waits.
+ Targets are stable and client stop/shutdown stop polling. See
+ [Polling](../README.md#polling) and [D14](DECISIONS.md#d14--fixed-cadence-polling-and-appended-evidence-rows).
+
+- Server request arrivals and proxy forwarding now append correlated UTC lines
+ by default. Every listener and `proxy start` support `--quiet`; independent
+ live `server logging` / `proxy logging` switches suppress routine lines while
+ preserving errors and operation records. Status shows logging state; write
+ previews are bounded and records retain full payloads. See
+ [request display controls](../README.md#server-request-output) and
+ [D15](DECISIONS.md#d15--independent-server-and-proxy-request-display).
+- The `aad20cd` revert removed the application-wide append default. Ordinary
+ management results now use TUI/WUI popups, while runtime request/polling
+ lines explicitly append. Read/write output and ctui built-ins retain their
+ existing presentation. See D17.
+
+- Component commands now follow client/server/proxy start/stop/status, with
+ client discover, selected client config show/set/save/load, server config/hook
+ editing, and server reset. Loading settings requires an explicit client start.
+ Stop confirmations protect active proxy routing; bare groups show example
+ help popups. Legacy aliases are removed; ctui-owned commands/fields remain
+ unchanged. Server reset retains timestamped observations. See
+ [D16](DECISIONS.md#d16--component-commands-selected-client-settings-and-strict-toml)
+ and [README usage](../README.md#projects-profiles-and-records).
+- Server/tag TOML now require exact format/version markers and reject unknown
+ nested/top-level keys. Server stays version 1 and uses `tick_interval_seconds`.
+ Existing unreleased server configs need explicit format/key updates or
+ clear/reimport. Client/proxy commands have dedicated modules; shared lifecycle
+ arbitration remains in app.py.
+
+- Owned management results now use standard scrollable ctui message dialogs
+ with descriptive titles. Actions finish first; background logging and polling
+ continue while dismissal gates new command submissions. UI output is preserved,
+ confirmations remain separate, and CLI/headless results stay plain text.
+- Every server start returns immediately; --foreground has been removed. CLI
+ executes the full sequence, then keeps a remaining listener alive on success.
+ Errors and explicit exit bypass keepalive; cancellation always cleans up both
+ endpoints. The CLI entry point handles Ctrl-C without a traceback (exit 130).
+ See [D17](DECISIONS.md#d17--management-result-popups-and-cli-server-keepalive)
+ and [result presentation](../README.md#management-result-popups).
+
+- `tags delete --all` now supports confirmed bulk deletion; NAME/--all are
+ exclusive. Saved server tag references refuse deletion unless explicitly
+ removed using --remove-server-rules. Both tables change in one transaction;
+ other config and external files are retained. Hook warnings appear before
+ approval. TUI/WUI use standard Yes/No confirmation and MessageDialog results;
+ CLI requires --confirm for bulk/rule deletion or hook warnings. Dispatch
+ reserves tag/project/server edits across preparation and approval. See
+ [tag removal](../README.md#removing-tags) and D18.
+
+## Next steps and validation gaps
+
+1. Multi-tag serialization/partial output and tag-import confirmation, atomic
+ application, and project isolation are fixed. Remaining D12 items include
+ reset integration and optional file-I/O/export improvements; adjacent-read
+ batching remains future work. These are not release feature commitments.
+ ctui enhancements listed there remain proposals, not dependency commitments.
+2. Manually exercise component overviews, option completion, client config
+ editing/loading, and proxy stop confirmation in a real terminal/browser.
+ Exercise tag completion, collision confirmation, and typed output in a real
+ terminal; verify USB metadata on physical serial ports.
+ Manually exercise sustained polling in a real terminal/browser and against
+ physical devices. Automated TUI/WUI and cadence checks pass; backend test
+ stalls were resolved by running outside the sandbox (see October 6 evidence).
+3. Open a PR from `dev-v1.0.0` into `main`, review and merge after required CI,
+ then run the [release checklist](../RELEASE_CHECKLIST.md) on the intended
+ `main` release revision. Passing branch CI below does not establish validation
+ of a future merge revision. Record actual evidence here.
+ Remove README's temporary checkout-installation note once 1.0 is published.
+4. Confirm the PyPI trusted publisher configuration and obtain release approval
+ before publication (D08).
+
+Device cloning, MITM rules, raw/fuzzy requests, tunneling, and
+historian integration remain deferred. Branch automation is green; release
+readiness still requires PR/main validation, manual checks and publisher setup.
+
+## Validation evidence
+
+- October 24 remote CI verification at `b43c72e`: GitHub Actions run 40074562350
+ completed successfully with all 18 jobs passing. Verified every Python
+ 3.11–3.14 combination on Windows, macOS, Linux x86-64 and Linux ARM64, quality
+ checks and distribution build/smoke jobs. No job was skipped in this run;
+ individual tests retain their documented platform skips. The branch has no
+ open PR into `main` at review. This read-only remote review and local status
+ update did not commit, push, merge, tag or publish anything.
+
+- October 24 Windows TLS follow-up on `85f59b4` plus test changes: reviewed
+ run 40074055336, confirming 12 passing Linux/macOS jobs and the same single
+ TLS connection failure on all four Windows Python versions. Injecting 0.75s
+ localhost-resolution latency reproduces the old 0.5s fixture failure; the
+ revised IPv4 fixture passes under the same injection. No exact Windows
+ resolver/handshake root cause is established without a Windows run.
+- All 131 tests pass with no skips on Linux/Python 3.11.16; all 21 server tests
+ pass with no skips on Python 3.14.9. Black/isort, local documentation links
+ and whitespace checks pass. No runtime/dependency changes, Windows execution,
+ remote rerun of the new fix, artifact rebuild, commit or push were performed.
+
+- October 24 CI investigation on `5b3720f` plus test changes: inspected failing
+ jobs and logs from runs 40071325659 and 40067319242. Each reported failure maps
+ to the three test assumptions above; no application defect was established.
+ All 131 tests pass with no skips on Linux x86-64 with Python 3.11.16 and
+ 3.14.9, using published ctui 1.0.0. All 15 focused polling tests also pass.
+- Windows backslash/space paths round-trip through the command parsers, and the
+ hook export regression passes through a symlinked temporary directory locally.
+ Server/tag suites now exercise paths containing spaces on every platform.
+ Black/isort, pylint (10.00/10), documentation links and whitespace checks pass.
+ No Windows/macOS/ARM64 execution, remote rerun of these changes, artifact
+ rebuild, commit, push or publication was performed by this investigation.
+
+- October 24 stable metadata preparation on `08de58b` plus the working tree,
+ Python 3.11.16/Linux x86-64 with published ctui 1.0.0: all 130 tests passed
+ with no skips. Black/isort, pylint (10.00/10), locked sync, lockfile and
+ whitespace checks passed. CLI help, installed app metadata, project metadata
+ and the lockfile all report ctmodbus 1.0.0. No dependency versions changed.
+- Fresh 1.0.0 wheel/sdist builds and both isolated installed-artifact smoke tests
+ passed without the editable ctui override. Inspected stable version, README,
+ GPL license and dependency metadata in both artifacts; imported ctui is the
+ published 1.0.0 distribution in site-packages. uv 0.13.5 emitted the existing
+ build-backend version warning; builds and smoke checks succeeded with the
+ declared uv_build range. Local documentation links/anchors and release
+ metadata were checked. No remote matrix CI, physical-adapter or sustained
+ manual UI checks were run; no commit, push, PR, merge, tag or publication was
+ performed by this work. Repeat release gates on the final `main` revision.
+
+- October 24 changelog preparation on `68d7aa3` plus documentation changes:
+ reconciled the release notes with README, migration guidance and implemented
+ behavior; checked local Markdown links, section/date formatting and whitespace.
+ This is a documentation-only change; no runtime tests, artifact builds,
+ remote CI or publication were performed for it.
+
+- October 24 server-guide consolidation on `96c3844` plus documentation changes:
+ all 119 fenced README command examples and inline rename/exit syntax parse
+ against installed ctui 1.0.0. All 22 server-definition commands plus four
+ remove/clear/validate checks ran successfully in a temporary project. The full
+ server TOML passes schema validation; the hook TOML references actual companion
+ functions and the Python example parses. Removed the nonexistent `on_read`
+ entry from the pump hook config example; the optional signature remains shown.
+ All 45 repository-local Markdown links/anchors, code fences and whitespace
+ checks passed. Updated guide references in instructions, decisions and status;
+ the separate guide is removed. Runtime code and dependencies are unchanged;
+ the prior full-suite result below was not rerun for this documentation move.
+
+- October 24 README/example audit on `1fda698` plus documentation changes,
+ Python 3.11.16/Linux x86-64 with published ctui 1.0.0: checked command syntax
+ for all 91 fenced command examples plus inline rename/exit examples against
+ the installed registry/parser, and executed 96 setup/workflow commands
+ in temporary projects against real local TCP endpoints. Covered raw/tag I/O,
+ import collision approval/replacement, tag deletion and saved server rules,
+ polling start/status/stop, pump/tank import/hook validation/export, actual proxy
+ forwarding, logging switches, saved profiles, history/project export and
+ project switches. Live checks substitute loopback ports, fixture unit 1 and
+ temporary paths; polling is stopped after its first cycle. Hardware and example
+ production endpoints are not contacted. CLI help and web startup defaults
+ match the documented options.
+- All 130 existing regression tests passed with no skips, including TCP/UDP,
+ TLS/mTLS, serial PTYs, terminal/browser behavior and CLI cleanup. This is
+ Linux/local evidence, not a remote matrix or physical-adapter check. Local
+ documentation checks passed for 36 local links/anchors, balanced Markdown
+ fences and whitespace. No runtime code,
+ dependency metadata or release version changed; no publication or new artifact
+ build was performed for this documentation-only work.
+
+- October 23 published ctui adoption on `9c15d08` plus the working tree,
+ Python 3.11.16/Linux x86-64: all 130 tests passed with no skips, including
+ actual TCP/UDP/TLS/mTLS/PTY transports, terminal/browser runtime, polling,
+ management dialogs, tags and lifecycle checks. No editable ctui override was
+ used; imported ctui reports 1.0.0 from site-packages with no direct URL.
+- Locked sync, Black/isort checks, pylint (10.00/10), lockfile and whitespace
+ checks passed. Wheel/sdist builds and both isolated installed-artifact smoke
+ tests passed using published dependencies; metadata in each artifact requires
+ `ctui==1.0.0`. uv 0.13.5 regenerated lock schema revision 5 and added ctui's
+ required browser dependencies; existing locked package versions other than
+ ctui are unchanged. uv emitted a build-backend version warning; both builds
+ succeeded with the existing declared uv_build range. No remote CI rerun,
+ physical-device checks, sustained manual UI checks or publication was done.
+
+- October 23 radix register writes on `5db3c8e` plus the working tree,
+ Python 3.11.16 with adjacent ctui at `755f5ac`: all 130 tests passed with no
+ skips, including tag codecs, mixed-radix/signed write dispatch and records,
+ single/multiple function selection, the 123-register/address boundary, and
+ rejection of malformed/out-of-range values before I/O. The focused command
+ suite also passed all 32 tests. The first full run exposed an incorrect records
+ API call in the new test; it was corrected before these passing runs.
+- Black/isort checks, pylint (10.00/10), lockfile and whitespace checks passed.
+ Tests and checks used the editable ctui override; sandbox cache access required
+ running uv checks outside the sandbox. Dependency metadata and lockfile are
+ unchanged. Physical devices, manual terminal/browser checks, remote CI and
+ distribution artifact checks were not run for this command-parsing change;
+ published-ctui release validation remains outstanding.
+
+- October 7 tag deletion on `f0cdb23` plus the working tree, Python 3.11.16
+ with adjacent ctui at `79579d0`: all 127 tests passed. New regressions cover
+ bulk approval/empty selection, exclusive and unknown targets, single/bulk saved
+ references, explicit rule removal, pre-approval counts/hook warnings, retained
+ unrelated configuration, approval cancellation, SQL rollback on either table,
+ changed-config rejection, in-flight edit protection, stopped server/polling
+ requirements, and actual WUI Yes/No confirmation with MessageDialog completion.
+ Existing transport, polling, popup, CLI, project and import regressions pass.
+ The adjacent ctui moved configs under project configs; README and test/smoke
+ references were reconciled with that inherited API, without modifying ctui.
+- Black/isort checks, pylint (10.00/10), lockfile, whitespace and local document
+ links passed. Wheel/sdist builds and isolated artifact smoke tests passed with
+ the adjacent ctui override. Dependency metadata and lockfile are unchanged.
+ IDE editable ctui imports and dependency compatibility were checked. Physical
+ hardware, remote platform CI and sustained manual UI checks were not performed;
+ published-ctui validation remains a release gate.
+
+
+- October 6 management popups/CLI keepalive on `ea96a46` plus the working tree,
+ Python 3.11.16 with adjacent ctui at `ec16fb1`: all 117 tests passed. New
+ coverage exercises standard TUI popup dismissal, WUI result presentation,
+ descriptive-title/owned-command scope, output preservation and live network
+ request logging while a startup popup awaits dismissal, command gating and
+ popup cancellation at shutdown. CLI regressions cover later commands before
+ keepalive, normal stop/exit, expected/unexpected errors, cancellation cleanup,
+ and a real POSIX subprocess SIGINT that exits 130 without traceback and releases
+ its listener. Existing TCP/UDP/TLS/mTLS/PTY transport, proxy, polling, record,
+ configuration and project isolation regressions pass.
+- Black/isort formatting, pylint (10.00/10), lockfile, local documentation links
+ and whitespace checks passed. Wheel/sdist builds and both isolated artifact
+ smoke tests passed with the development ctui override. Dependency metadata
+ and lockfile are unchanged. Remote platform CI, physical hardware and manual
+ sustained browser/terminal checks were not run; published-ctui validation
+ remains a release gate.
+
+
+- October 6 component refactor on `5ffde8c` plus the working tree, Python
+ 3.11.16 with adjacent ctui at `ec16fb1`: all 111 tests passed. Coverage includes
+ real TCP/UDP/TLS/mTLS and PTY RTU/ASCII clients/listeners, cross-transport
+ proxying, polling, records, cancellation, project isolation, and import guards.
+ A final focused run passed all 21 server/proxy tests after adding real-endpoint
+ declined/confirmed stop checks; the other endpoint and forwarding are retained
+ on rejection. New regressions cover selected config save/load/set without I/O, explicit
+ start, atomic invalid edits, TLS clearing/verification, source/dirty state,
+ completion/unique prefixes, concurrent config/stop reservations, declined and
+ accepted stop confirmation, bare-component terminal/browser help presentation,
+ generated references, removed aliases, strict TOML markers/nested keys, and
+ retained reset evidence. Existing tests use the new command hierarchy.
+- Black/isort checks and pylint (10.00/10), lockfile and whitespace checks passed.
+ Wheel/sdist builds and both isolated artifact smoke tests passed with the
+ development ctui override. All 22 documented server assembly/export commands
+ and the example TOML import/validate/export round trip passed. Local document
+ links were checked. Restored adjacent ctui editable in the IDE .venv; direct
+ imports and dependency compatibility checks passed. Dependency metadata and
+ lockfile are unchanged. Physical hardware, sustained manual terminal/browser
+ checks, published-ctui release validation, and remote platform CI were not run.
+
+
+- October 6 proxy display follow-up: proxy request lines now use `unit=`,
+ matching server lines while retaining the actual mapped upstream unit. The
+ focused proxy logging/quiet/error regression passed; README example updated.
+
+- October 6 request logging on `aad20cd` plus the working tree, Python 3.11.16
+ with adjacent ctui at `ec16fb1`: all 101 tests passed, including actual
+ TCP/UDP/TLS and PTY servers/proxying, real TUI/WUI output, and polling.
+ New checks cover default arrivals, shared server/proxy request IDs, unit
+ mapping, independent quiet/live switches, visible quiet-mode errors, UTC
+ timestamps, escaped controls, bounded previews/full recorded write payloads,
+ restart defaults, browser append preservation, and CLI stream selection.
+ Black/isort, pylint (10.00/10), lockfile and whitespace checks passed.
+ Dependency metadata is unchanged. Physical-device and sustained manual UI
+ checks, remote matrix CI, distribution artifacts, and publication were not
+ performed.
+
+- October 6 polling alignment refinement on `b8af8e4` plus the working tree:
+ all 14 focused polling tests and the real TUI test passed on Python 3.11
+ with adjacent ctui. Tests cover separator positions across mixed values and
+ errors, integer type bounds, full raw widths, counter/value overflow and
+ repeated headers, float precision, and existing polling/CLI/WUI behavior.
+ Changed-file Black/isort checks, pylint (10.00/10), and whitespace checks
+ passed. The full suite result below predates this display refinement;
+ unrelated transport and packaging checks were not repeated.
+
+- October 6 polling changes on `b8af8e4` plus the working tree, Python 3.11.16
+ with adjacent ctui at `216ef28`: full suite passed all 95 tests, including
+ real TUI polling and TCP/UDP/TLS/mTLS/PTY transport tests. All 12 focused
+ polling tests also passed. Coverage includes fixed cadence, skipped busy
+ ticks, count/duration,
+ ordered raw ranges/repeats, partial chunks, records, target-resolution guards,
+ immediate and in-flight cancellation, graceful stop, CLI interruption and
+ lifecycle/streaming,
+ shared layout appends, and an actual local WUI runtime.
+ The previous backend stalls disappear outside the filesystem sandbox; the
+ old TUI exit test was updated for current ctui's explicit confirmation.
+ Black, isort, pylint (10.00/10), lockfile and whitespace checks passed.
+ Dependency metadata and lockfile are unchanged. Manual sustained browser/
+ terminal and physical-device timing, remote CI, artifacts, and release
+ publication were not performed.
+
+- October 6 `tags` management rename on `759b8f7` plus the working tree:
+ command-registration checks and CLI help passed with adjacent editable ctui.
+ Four codec tests passed; the focused tag suite then stalled at its first
+ backend-dependent test and was interrupted, consistent with the existing
+ validation blocker. Black and isort checks of changed Python files passed
+ through their synchronous APIs; whitespace checks passed. Full dispatch,
+ import-guard and server regressions still need a completed test run.
+
+- October 5 development environment repair on `4cb75fd`: installed adjacent
+ ctui at `4833e58` editable into the project `.venv`, including its new aiohttp
+ dependency. Direct Python imports resolve ctui to the adjacent source tree;
+ `ctmodbus.app` imports, CLI `--help`, and `uv pip check` passed. The full
+ unittest run stalled on its first test, `test_all_transport_commands`, and
+ was interrupted; no suite success is claimed. Dependency metadata and
+ lockfile remain unchanged. README now documents the direct editable install
+ and development run overrides.
+
+- October 3 server/emulator and proxy changes on `2c9afc7` plus the working tree:
+ all 83 tests passed on Python 3.11.16 with adjacent ctui main at `fdda194`.
+ New regressions cover sparse maps, address 65535, typed/partial sequences,
+ random seeds, timed rules, hook rollback and ticks, import confirmation,
+ lifecycle guards, actual TCP/UDP/TLS/mTLS and PTY RTU/ASCII listeners,
+ cross-transport proxying and unit mapping, faithful exceptions, separate
+ evidence views, pipelined transaction IDs, invalid counts and bind failures.
+ CLI import/validate/export of `examples/device.toml` passed. Black, isort,
+ pylint (10.00/10), lockfile and whitespace checks passed; wheel and sdist
+ builds and isolated wheel/sdist smoke checks passed with the same ctui
+ override. All 21 documented CTUI assembly/export commands also passed.
+ Dependency metadata is unchanged. Physical hardware, remote
+ CI/platform checks and publication were not performed.
+
+- October 2 prepared tag-import changes on `920daa0` plus the working-tree diff:
+ all 66 tests passed on Python 3.11.16 with adjacent ctui main at `fdda194`.
+ Regressions cover changed files during confirmation, project/tag guards,
+ in-flight edits, cancellation before application, and all-row SQL rollback.
+ Black, isort, pylint (10.00/10), lockfile and whitespace checks passed; wheel
+ and sdist builds passed. Dependency metadata remains unchanged. Physical
+ serial hardware and remote platform CI were not checked.
+
+- October 2 multi-tag read changes on `6fd5c21` plus the working-tree diff:
+ all 61 tests passed on Python 3.11.16 with adjacent ctui main at `fdda194`.
+ Added regressions for whole-command serialization, partial failure,
+ cancellation, ordering/duplicates, and validation before I/O. Updated the
+ existing responsive-help test to request ctui's grouped `help read` page.
+ Black, isort, pylint (10.00/10), lockfile and whitespace checks passed.
+ Local TCP/UDP, PTY serial, TLS, and terminal tests ran; physical hardware and
+ remote platform CI were not checked. Dependency metadata remains unchanged.
+
+- Historical September 13 migration through `7914437`: 34 tests reported passing
+ on Python 3.11 and 3.14, local TCP/UDP and PTY serial, quality/lock checks,
+ wheel/sdist builds, and isolated installed-artifact smoke checks.
+- Historical TLS/workflow follow-up through `426048a`: prior session reported
+ 45 passing tests, quality/lock/build/workflow checks, and both artifact smoke
+ tests. These supersede the initial test count, not the manual/remote gates.
+- September 20 tag work based on `4e34098`: full suite reported 53 passing tests
+ on Python 3.11 and passing quality/build/artifact checks before final follow-ups.
+ Later singular syntax/read-all/signed-value updates had 10 focused tag tests.
+ Do not treat the earlier full-suite/artifact checks as tests of final `5eb04ee`.
+- September 20 serial completion based on `5eb04ee`, committed at `4718ddd`:
+ 30 discovery/command tests and formatting/import/lint/whitespace checks passed.
+- September 21 documentation audit on `4718ddd`: Python syntax and docstring
+ coverage checked; non-docstring ASTs match HEAD in all 16 Python files.
+ Black, isort, pylint (10.00/10), and whitespace checks passed.
+ All 40 focused command/tag/discovery tests passed; local Markdown links and
+ anchors checked. Full transport/matrix and artifact checks were not rerun for
+ this documentation-only change.
+- September 21 tag command rename and shared path-completer integration on
+ `cf3fb59`: all 11 focused tag tests passed on Python 3.11 using the adjacent
+ editable ctui checkout.
+
+## Audit source coverage
+
+Reviewed the three locally available ctmodbus Codex session files (including
+overlapping replayed history), current source/tests, all seven project Markdown
+files, and relevant Git history. Earlier September 13/18 evidence is retained
+from Git-tracked notes rather than claimed as newly rerun validation. Histories
+only on other machines or unavailable account sessions were not accessible.
+No additional project Markdown stash was found in the repository beyond those files;
+private transcripts and machine-specific logs are not copied into this repository.
diff --git a/examples/device.toml b/examples/device.toml
new file mode 100644
index 0000000..77dab77
--- /dev/null
+++ b/examples/device.toml
@@ -0,0 +1,55 @@
+# ctmodbus pump/tank server configuration.
+format = "ctmodbus-server"
+version = 1
+unit = 1
+
+[identity]
+vendor = "ControlThings"
+product = "Tank and pump simulator"
+revision = "1.0"
+
+# Only addresses declared below exist in these tables.
+[tables.coils]
+unmapped = "illegal"
+
+[tables.discrete_inputs]
+unmapped = "illegal"
+
+[tables.input_registers]
+unmapped = "illegal"
+
+[tables.holding_registers]
+unmapped = "illegal"
+
+# A client writes this tag to start or stop the pump.
+[tags.pump_enabled]
+table = "coils"
+address = 0
+type = "bool"
+value = false
+
+# The on_write hook updates this status signal.
+[tags.pump_running]
+table = "discrete_inputs"
+address = 0
+type = "bool"
+value = false
+
+# The on_tick hook lowers this value while the pump runs.
+# float32 occupies two registers: 10 and 11.
+[tags.tank_level]
+table = "input_registers"
+address = 10
+type = "float32"
+byte_order = "big"
+word_order = "little"
+value = 100.0
+
+# Python functions live in a companion file beside this TOML file.
+# on_write(device, tag, value): responds to a successful client tag write.
+# on_tick(device, elapsed_seconds): updates the simulation periodically.
+[hooks]
+module = "device_logic.py"
+on_write = "on_write"
+on_tick = "on_tick"
+tick_interval_seconds = 1.0
diff --git a/examples/device_logic.py b/examples/device_logic.py
new file mode 100644
index 0000000..4a3dac7
--- /dev/null
+++ b/examples/device_logic.py
@@ -0,0 +1,14 @@
+"""Pump/tank emulator hooks; loaded from the adjacent device.toml definition."""
+
+
+def on_write(device, tag, value):
+ """Follow complete writes to pump_enabled with the simulated running status."""
+ if tag == "pump_enabled":
+ device.set("pump_running", bool(value))
+
+
+def on_tick(device, elapsed_seconds):
+ """Drain one unit per second while pumping; clamp at zero."""
+ if device.get("pump_running"):
+ level = device.get("tank_level")
+ device.set("tank_level", max(0.0, level - elapsed_seconds))
diff --git a/pyproject.toml b/pyproject.toml
index b5f5fdb..dcea7de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,11 +1,12 @@
[project]
name = "ctmodbus"
-version = "0.6.0"
+version = "1.0.0"
description = "A highly flexible Modbus tool made for penetration testers"
authors = [{ name = "Justin Searle", email = "justin@controlthings.io" }]
-requires-python = ">=3.8,<4"
+requires-python = ">=3.11,<4"
readme = "README.md"
-license = "LGPL-3.0-or-later"
+license = "GPL-3.0-or-later"
+license-files = ["LICENSE"]
keywords = ["Modbus", "test", "ICS", "control", "ControlThings"]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,20 +20,17 @@ classifiers = [
"Operating System :: Microsoft :: Windows",
"Topic :: Utilities",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
- "ctui>=0.8.0,<0.9",
+ "ctui==1.0.0",
"pyserial>=3.5,<4",
- "pymodbus>=2.4.0,<3",
+ "pymodbus[serial]>=3.15.0,<3.16",
"tabulate>=0.8.9,<0.9",
- "psutil>=5.8.0,<6",
+ "psutil>=7,<8",
]
[project.urls]
@@ -49,8 +47,12 @@ dev = ["pylint", "black", "isort"]
requires = ["uv_build>=0.12.3,<0.13.0"]
build-backend = "uv_build"
+[tool.black]
+target-version = ["py311"]
+
[tool.pylint.messages_control]
-disable = "C0330, C0326"
+# Typed CLI parameters and explicit settings naturally exceed these heuristics.
+disable = ["too-many-arguments", "too-many-positional-arguments", "too-many-instance-attributes", "too-many-locals", "unidiomatic-typecheck"]
[tool.pylint.format]
max-line-length = "88"
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 9785ca3..0000000
--- a/setup.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# -*- encoding: utf-8 -*-
-from __future__ import absolute_import
-from __future__ import print_function
-
-import io
-import re
-from glob import glob
-from os.path import basename
-from os.path import dirname
-from os.path import join
-from os.path import splitext
-
-from setuptools import find_packages
-from setuptools import setup
-
-
-def read(*names, **kwargs):
- return io.open(
- join(dirname(__file__), *names),
- encoding=kwargs.get('encoding', 'utf8')
- ).read()
-
-setup(
- name='ctmodbus',
- version=open('VERSION').read(),
- license='GPLv3',
- description='a security professional\'s swiss army knife for interacting with Modbus devices',
- long_description=open('README.md').read(),
- long_description_content_type="text/markdown",
- author='Justin Searle',
- author_email='justin@meeas.com',
- url='https://github.com/ControlThingsTools/ctmodbus',
- packages=find_packages('src'),
- package_dir={'': 'src'},
- py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')],
- include_package_data=True,
- zip_safe=False,
- classifiers=[
- # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers
- 'Development Status :: 5 - Production/Stable',
- 'Intended Audience :: Developers',
- 'Intended Audience :: Information Technology',
- 'Intended Audience :: Manufacturing',
- 'Intended Audience :: Other Audience',
- 'Intended Audience :: Science/Research',
- 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
- 'Operating System :: Unix',
- 'Operating System :: POSIX',
- 'Operating System :: Microsoft :: Windows',
- 'Programming Language :: Python :: 3',
- 'Topic :: Utilities',
- ],
- keywords=[
- 'Modbus', 'pentest', 'ControlThingsTools', 'ControlThingsPlatform',
- ],
- python_requires='>=3.6,<4',
- install_requires=[
- 'ctui==0.7.*',
- 'pyserial',
- 'pymodbus',
- 'tabulate',
- 'psutil'
- ],
- extras_require={
- # eg:
- # 'rst': ['docutils>=0.11'],
- },
- entry_points={
- 'console_scripts': [
- 'ctmodbus = ctmodbus.commands:main',
- ]
- },
-)
diff --git a/src/ctmodbus/__init__.py b/src/ctmodbus/__init__.py
index e69de29..1da66e4 100644
--- a/src/ctmodbus/__init__.py
+++ b/src/ctmodbus/__init__.py
@@ -0,0 +1,6 @@
+"""Async Modbus device-testing package.
+
+Use the ctmodbus console script for TUI/CLI operation or ModbusApp for embedded
+use with an explicitly managed lifecycle. Importing this package opens no
+connection and creates no project storage.
+"""
diff --git a/src/ctmodbus/app.py b/src/ctmodbus/app.py
new file mode 100644
index 0000000..a85d8e3
--- /dev/null
+++ b/src/ctmodbus/app.py
@@ -0,0 +1,647 @@
+"""Application ownership, shared TUI/CLI dispatch, and ctui lifecycle integration.
+
+ModbusApp owns the connection, project services, recording session, and command
+tasks. Command methods return strings or CommandResult; expected failures use
+CommandError. ctui derives usage and completion from decorated signatures.
+"""
+
+import asyncio
+import shlex
+import sys
+from dataclasses import replace
+from importlib.metadata import version
+
+from ctui import CommandError, CommandResult, ConfirmationRequired, CtuiApp
+
+from ctmodbus.client_commands import ClientCommandMixin
+from ctmodbus.component_help import ComponentHelpMixin
+from ctmodbus.connection import Connection, ConnectionSettings, create_client
+from ctmodbus.data_state import DataState
+from ctmodbus.operations import ModbusCommandMixin
+from ctmodbus.polling import TABLE_ARGUMENTS, Poll, PollCommandMixin
+from ctmodbus.proxy_commands import ProxyCommandMixin
+from ctmodbus.result_popups import result_title, show_result
+from ctmodbus.server import Server
+from ctmodbus.server_commands import ServerCommandMixin
+from ctmodbus.tag_deletion import TagDeletionMixin
+from ctmodbus.tags import TagCommandMixin, TagStore, parse_tag_document
+
+
+class ModbusApp( # pylint: disable=too-many-public-methods,too-many-ancestors
+ ComponentHelpMixin,
+ ClientCommandMixin,
+ ProxyCommandMixin,
+ PollCommandMixin,
+ ServerCommandMixin,
+ TagDeletionMixin,
+ TagCommandMixin,
+ ModbusCommandMixin,
+ CtuiApp,
+):
+ """A Modbus client with one connection and project-scoped services.
+
+ Open the backend before direct dispatch, or use run/run_cli for managed
+ lifecycle. Always stop device work before closing the backend. Connection
+ commands validate ConnectionSettings and share open_connection's return
+ and failure contracts; decorators define CLI names and named options.
+ The application is intended for one event loop, not concurrent threads.
+ """
+
+ name = "ctmodbus"
+ version = version("ctmodbus")
+ description = "An asynchronous Modbus tool for device testing"
+ prompt = "ctmodbus> "
+ app_id = "io.controlthings.ctmodbus"
+ project_schema_version = 2
+ project_migrations = {1: (TagStore.CREATE,)}
+
+ def __init__(self, *, client_factory=create_client, **kwargs):
+ """Register project templates and runtime guards without opening I/O.
+
+ Forward kwargs to CtuiApp; inject a sync/async client factory for tests.
+ TagStore uses the configured backend, which must support project SQL.
+ """
+ super().__init__(**kwargs)
+ self.selected_client_settings = None
+ self.client_config_source = None
+ self.client_config_saved = None
+ self._client_config_task = None
+ self._component_stop_task = None
+ self.connection = Connection(client_factory)
+ self.tags = TagStore(self.backend)
+ self.server = Server(self)
+ self.poller = Poll(self)
+ self._poll_orders = {}
+ self._poll_start_task = None
+ self._poll_stdout = None
+ self._cli_failed = False
+ self._cli_exit_requested = False
+ self._result_popup_task = None
+ self._result_popup_tasks = set()
+ self._result_popup_lock = asyncio.Lock()
+ self.client_state = DataState()
+ self._server_edit_task = None
+ self._prepared_server_imports = {}
+ self.statusbar = self.connection_status
+ self._device_dispatches = set()
+ self._project_changing = False
+ self._prepared_tag_deletions = {}
+ self._tag_delete_task = None
+ self._tag_import_task = None
+ self._tag_mutations = set()
+ self._prepared_tag_imports = {}
+ self._closing = False
+ self._opening = False
+ self._stopping = False
+ self._close_lock = asyncio.Lock()
+ self._record_session = None
+ self._record_warnings = {}
+ self._progress = ""
+ self.on("modbus_progress", self.update_progress)
+ self.configs.register_template(
+ "tcp-local", ConnectionSettings("tcp", "127.0.0.1").as_dict()
+ )
+ self.configs.register_template(
+ "udp-local", ConnectionSettings("udp", "127.0.0.1").as_dict()
+ )
+
+ def append_output(self, text: str) -> None:
+ """Append runtime output synchronously to TUI/WUI or the active CLI stream.
+
+ Read the current widget at append time. No await permits competing tasks
+ to interleave a read/modify/write; callers supply safely rendered text.
+ """
+ if self._poll_stdout is not None:
+ print(text, file=self._poll_stdout, flush=True)
+ return
+ layout = getattr(self, "layout", None)
+ current = layout.output_field.text if layout else self.output_text
+ self.output_text = f"{current.rstrip()}\n{text}" if current else text
+ if layout:
+ layout.set_output(self.output_text)
+ runtime = getattr(self, "app", None)
+ if runtime:
+ runtime.invalidate()
+
+ def connection_status(self):
+ """Describe project, transport state, and active read progress."""
+ settings = self.connection.settings
+ state = (
+ f"{settings.label} "
+ f"({'connected' if self.connection.connected else 'disconnected'})"
+ if settings
+ else "Disconnected"
+ )
+ project = (
+ self.backend.current.name
+ if self.backend and self.backend.current
+ else "unopened"
+ )
+ return (
+ f"Project: {project} | {state}{self._progress} | "
+ f"Server: {self.server.label}"
+ )
+
+ def update_progress(self, completed, total):
+ """Set completed/total footer progress; total=0 clears it; return None.
+
+ Invalidate the TUI only if its runtime has been built; CLI is unaffected.
+ """
+ self._progress = f" | Read {completed}/{total}" if total else ""
+ if hasattr(self, "app"):
+ self.app.invalidate()
+
+ async def confirm_replacement(self, message, kwargs):
+ """Require explicit CLI approval or invoke the TUI confirmation callback."""
+ callback = kwargs.get("confirm_callback")
+ if callback is None:
+ raise ConfirmationRequired(message)
+ approved = callback(message)
+ if asyncio.iscoroutine(approved):
+ approved = await approved
+ return bool(approved)
+
+ async def prepare_tag_import(self, text, kwargs):
+ """Prepare exact import data under the dispatch project/tag guard.
+
+ Use ctui parsing, including unique command prefixes and end-of-options.
+ Confirm collisions against validated data, then retain that data for this
+ task. The import command consumes it without rereading the file.
+ """
+ item, arguments = self.commands.resolve(text)
+ values = item.parse_args(arguments)
+ imported = parse_tag_document(values["path"].expanduser())
+ replace_existing = values.get("replace", False)
+ collisions = sorted({tag.name for tag in imported} & await self.tags.names())
+ if collisions and not replace_existing:
+ message = (
+ "Tags already exist: "
+ + ", ".join(collisions)
+ + ". Overwrite them? Re-run with --replace to overwrite "
+ "without prompting."
+ )
+ if not await self.confirm_replacement(message, kwargs):
+ return CommandResult.rejected()
+ replace_existing = True
+ self._prepared_tag_imports[asyncio.current_task()] = (
+ imported,
+ replace_existing,
+ )
+ return None
+
+ async def dispatch(self, text, **kwargs):
+ """Execute shared dispatch, then present owned management results in UI.
+
+ Result dialogs preserve the output pane and run after lifecycle guards
+ release. Reject another submission until dismissed. CLI failures and
+ explicit exit requests disable server keepalive during normal teardown.
+ """
+ if self._result_popup_task is not None:
+ raise CommandError(
+ "Dismiss the result popup before starting another command"
+ )
+ try:
+ result = await self.dispatch_modbus(text, **kwargs)
+ except BaseException:
+ if self._poll_stdout is not None:
+ self._cli_failed = True
+ raise
+ if result.exit_requested:
+ self._cli_exit_requested = True
+ title = result_title(self.commands.resolve(text)[0].name)
+ if (
+ title
+ and result.accepted
+ and result.output is not None
+ and self._poll_stdout is None
+ and getattr(self, "app", None) is not None
+ ):
+ await self.present_result_popup(title, result.output)
+ return replace(result, output=None, append_output=False)
+ return result
+
+ async def present_result_popup(self, title: str, text: str) -> None:
+ """Serialize standard message dialogs and track their lifetime for shutdown."""
+ task = asyncio.current_task()
+ self._result_popup_tasks.add(task)
+ try:
+ async with self._result_popup_lock:
+ self._result_popup_task = task
+ try:
+ await show_result(title, text)
+ finally:
+ self._result_popup_task = None
+ finally:
+ self._result_popup_tasks.discard(task)
+
+ # Lifecycle arbitration is intentionally centralized around command dispatch.
+ async def dispatch_modbus( # pylint: disable=too-many-branches,too-many-statements
+ self, text, **kwargs
+ ):
+ """Return the ctui result for text while arbitrating device lifecycle.
+
+ Reject project transitions during tracked device work/open connections,
+ and device work during opening, closing, or stopping. Attach recording
+ warnings to success or CommandError without disguising acknowledged
+ writes. Cancellation inside execution becomes CommandError. shlex errors
+ precede that boundary. Imports reserve the project and tag mutations across
+ confirmation and application; other commands remain available.
+ """
+ tokens = shlex.split(text)
+
+ # Reserve project transitions before the first await. This also covers
+ # unique command prefixes, which are resolved to their canonical names.
+ item, _ = self.commands.resolve(text)
+ project_change = item.name in {
+ "project create",
+ "project load",
+ "project saveas",
+ "project import",
+ "project reset",
+ }
+ device_command = item.name.startswith(
+ ("client start", "client config ", "read ", "write ")
+ )
+ poll_start = item.name in {"poll tags", "poll raw"}
+ device_command = device_command or poll_start
+ server_import = item.name == "server config import"
+ server_start = item.name in {
+ "server start tcp",
+ "server start udp",
+ "server start tls",
+ "server start rtu",
+ "server start ascii",
+ }
+ server_edit = (
+ (
+ item.name.startswith("server config ")
+ and item.name
+ not in {
+ "server config show",
+ "server config export",
+ "server config validate",
+ "server reset",
+ }
+ )
+ or item.name.startswith("server hook ")
+ or server_start
+ )
+ task = asyncio.current_task()
+ if item.name == "proxy start" and (
+ self._opening
+ or self._closing
+ or self.server.lifecycle.locked()
+ or self.server.stopping
+ ):
+ raise CommandError(
+ "Finish client/server lifecycle changes before starting the proxy"
+ )
+ client_config = item.name.startswith("client config ")
+ if self._client_config_task is not None and (
+ client_config
+ or item.name.startswith("client start")
+ or project_change
+ or item.name == "client stop"
+ ):
+ raise CommandError(
+ "Client configuration is changing; retry when it finishes"
+ )
+ if (
+ item.name == "server stop"
+ and self._server_edit_task is not None
+ and self.server.listener is None
+ ):
+ raise CommandError(
+ "Server configuration is changing; retry when it finishes"
+ )
+ component_stop = item.name in {"client stop", "server stop"}
+ if self._component_stop_task is not None and (
+ project_change
+ or item.name.startswith(
+ (
+ "client start",
+ "client config ",
+ "server start",
+ "server config ",
+ "server hook ",
+ "server reset",
+ "proxy ",
+ )
+ )
+ or component_stop
+ ):
+ raise CommandError("A component is stopping; retry when it finishes")
+ if self._stopping:
+ raise CommandError("Application is stopping")
+ importing = item.name == "tags import"
+ tag_mutation = item.name in {
+ "tags create",
+ "tags rename",
+ "tags delete",
+ "tags import",
+ }
+ deleting = item.name == "tags delete"
+ if self._tag_delete_task is not None and (
+ project_change or tag_mutation or server_edit
+ ):
+ raise CommandError("Tag deletion is in progress; retry when it finishes")
+ if deleting and self._tag_mutations and self._tag_import_task is None:
+ raise CommandError("Tag edits are in progress; retry when they finish")
+ if poll_start and self._poll_start_task is not None:
+ raise CommandError("A poll is already starting")
+ if (self.poller.active or self._poll_start_task is not None) and tag_mutation:
+ raise CommandError("Stop polling before changing tag definitions")
+ if poll_start and (self._tag_mutations or self._tag_import_task is not None):
+ raise CommandError("Finish tag edits before starting polling")
+ if self.poller.active and project_change:
+ raise CommandError("Stop polling before changing projects")
+ if self._server_edit_task is not None and (
+ project_change or tag_mutation or server_edit
+ ):
+ raise CommandError(
+ "Server configuration is changing; retry when it finishes"
+ )
+ if self.server.stopping and (project_change or tag_mutation or server_edit):
+ raise CommandError("Server is stopping; retry when it finishes")
+ if self.server.listener is not None and (
+ project_change or tag_mutation or server_edit
+ ):
+ raise CommandError(
+ "Stop the server before changing its configuration, tags or project"
+ )
+ if server_edit and (self._tag_mutations or self._tag_import_task is not None):
+ raise CommandError(
+ "Finish tag edits before changing the server configuration"
+ )
+ if self._tag_import_task is not None and (
+ project_change or tag_mutation or server_edit
+ ):
+ raise CommandError("Tag import is in progress; retry when it finishes")
+ if importing and self._tag_mutations:
+ raise CommandError("Tag edits are in progress; retry when they finish")
+ if project_change:
+ if (
+ self._project_changing
+ or self._device_dispatches
+ or self._tag_mutations
+ or self.connection.client is not None
+ or self._closing
+ ):
+ raise CommandError(
+ "Close the connection and finish device work "
+ "before changing or resetting projects"
+ )
+ self._project_changing = True
+ elif self._project_changing:
+ raise CommandError(
+ "A project transition is in progress; retry when it finishes"
+ )
+ opening = item.name in {
+ "client start tcp",
+ "client start tls",
+ "client start udp",
+ "client start rtu",
+ "client start ascii",
+ "client start",
+ }
+ if device_command:
+ if self._opening:
+ raise CommandError("Connection is opening; retry when it finishes")
+ if opening:
+ self._opening = True
+ if self._closing:
+ self._opening = False
+ raise CommandError("Connection is closing; retry when it finishes")
+ self._device_dispatches.add(task)
+ if server_edit:
+ self._server_edit_task = task
+ if tag_mutation:
+ self._tag_mutations.add(task)
+ if deleting:
+ self._tag_delete_task = task
+ if importing:
+ self._tag_import_task = task
+ if poll_start:
+ self._poll_start_task = task
+ if item.name == "poll raw":
+ order = []
+ for token in tokens:
+ flag = token.split("=", 1)[0]
+ matches = [
+ name
+ for name, argument in TABLE_ARGUMENTS.items()
+ if flag.startswith("--") and argument.flags[0].startswith(flag)
+ ]
+ if len(matches) == 1 and matches[0] not in order:
+ order.append(matches[0])
+ self._poll_orders[task] = order
+ if client_config:
+ self._client_config_task = task
+ if component_stop:
+ self._component_stop_task = task
+ try:
+ if deleting:
+ rejected = await self.prepare_tag_deletion(text, kwargs)
+ if rejected is not None:
+ return rejected
+ if component_stop and self.server.proxy:
+ _, arguments = self.commands.resolve(text)
+ values = item.parse_args(arguments)
+ if not values.get("confirm", False):
+ if not await self.confirm_replacement(
+ "Proxy is running. Stop the proxy and this component? "
+ "Use --confirm to approve without prompting.",
+ kwargs,
+ ):
+ return CommandResult.rejected()
+ self.server.proxy = False
+ if server_import:
+ rejected = await self.prepare_server_import(text, kwargs)
+ if rejected is not None:
+ return rejected
+ if importing:
+ rejected = await self.prepare_tag_import(text, kwargs)
+ if rejected is not None:
+ return rejected
+ if project_change:
+ # External task cancellation can leave an ended transport's
+ # recording session open; finish it in its original project.
+ await self.finish_record_session()
+ result = await super().dispatch(text, **kwargs)
+ if project_change and result.accepted:
+ self.server.simulator = None
+ self.server.state = DataState("No server session")
+ self.client_state = DataState()
+ self.selected_client_settings = None
+ self.client_config_source = None
+ self.client_config_saved = None
+ if item.name == "project reset" and "all" in tokens and result.accepted:
+ await self.tags.clear()
+ warnings = self._record_warnings.get(task)
+ if warnings:
+ result = replace(
+ result,
+ output=(result.output or "")
+ + "\nRecording warning: "
+ + "; ".join(dict.fromkeys(warnings)),
+ )
+ return result
+ except asyncio.CancelledError as error:
+ raise CommandError("Command cancelled before completion") from error
+ except CommandError as error:
+ warnings = self._record_warnings.get(task)
+ if warnings:
+ raise CommandError(
+ f"{error}\nRecording warning: " + "; ".join(dict.fromkeys(warnings))
+ ) from error
+ raise
+ finally:
+ if client_config:
+ self._client_config_task = None
+ if component_stop:
+ self._component_stop_task = None
+ self._poll_orders.pop(task, None)
+ if poll_start:
+ self._poll_start_task = None
+ if server_edit:
+ self._server_edit_task = None
+ self._prepared_server_imports.pop(task, None)
+ if tag_mutation:
+ self._tag_mutations.discard(task)
+ if deleting:
+ self._tag_delete_task = None
+ self._prepared_tag_deletions.pop(task, None)
+ if importing:
+ self._prepared_tag_imports.pop(task, None)
+ self._tag_import_task = None
+ if project_change:
+ self._project_changing = False
+ if device_command:
+ self._device_dispatches.discard(task)
+ if opening:
+ self._opening = False
+ self._record_warnings.pop(task, None)
+
+ async def record_operation(self, direction, decoded):
+ """Append decoded data to the current Modbus record session; return None.
+
+ No session means no record. Catch ordinary storage errors as task-local
+ warnings so an acknowledged write never looks like a failed protocol
+ operation and invites a duplicate write. Cancellation still propagates.
+ """
+ self.client_state.record(direction, decoded)
+ if self._record_session is None:
+ return
+ try:
+ await self.records.append(
+ direction=direction,
+ protocol="modbus",
+ session=self._record_session,
+ decoded=decoded,
+ )
+ except Exception as error: # pylint: disable=broad-exception-caught
+ # A storage failure must not convert an acknowledged write into a
+ # reported protocol failure, nor cause the caller to retry a write.
+ self._record_warnings.setdefault(asyncio.current_task(), []).append(
+ str(error)
+ )
+
+ async def finish_record_session(self):
+ """Clear the active session ID and await optional end_session; return None.
+
+ Repeated calls are harmless; storage errors propagate after the ID is
+ cleared. Call before switching project storage to keep records isolated.
+ """
+ session, self._record_session = self._record_session, None
+ end_session = getattr(self.records, "end_session", None)
+ if session is not None and end_session is not None:
+ await end_session(session)
+
+ async def close_connection(self):
+ """Cancel tracked device dispatches and clear transport/session state.
+
+ Serialize close calls, wait up to five seconds for task draining, and
+ abort the transport even on drain failure. Return None; TimeoutError or
+ record-session cleanup errors can propagate. Always clear footer progress.
+ """
+ async with self._close_lock:
+ self._closing = True
+ try:
+ pending = (self._device_dispatches | self.connection.tasks) - {
+ asyncio.current_task()
+ }
+ for task in pending:
+ task.cancel()
+ try:
+ await asyncio.wait_for(self.poller.stop(abort=True), timeout=5)
+ if pending:
+ await asyncio.wait_for(
+ asyncio.gather(*pending, return_exceptions=True), timeout=5
+ )
+ finally:
+ self.connection.abort()
+ await self.finish_record_session()
+ finally:
+ self._closing = False
+ self.update_progress(0, 0)
+
+ async def run_cli(self, arguments, *, stdout=None, stderr=None, program=None):
+ """Run CLI commands, then keep a remaining server alive until stopped.
+
+ Stream runtime rows to stdout. ctui owns parsing, errors, and storage
+ lifetime. Successful sequences wait in on_stop; failures or explicit exit
+ clean up immediately. Cancellation/Ctrl-C always drains both endpoints.
+ """
+ self._poll_stdout = stdout or sys.stdout
+ self._cli_failed = False
+ self._cli_exit_requested = False
+ try:
+ return await super().run_cli(
+ arguments, stdout=stdout, stderr=stderr, program=program
+ )
+ finally:
+ self._poll_stdout = None
+
+ async def on_start(self):
+ """Allow a fresh runtime to use this application instance."""
+ self._stopping = False
+
+ async def on_stop(self):
+ """Keep successful CLI servers alive, then always drain runtime resources."""
+ try:
+ if (
+ self._poll_stdout is not None
+ and not self._cli_failed
+ and not self._cli_exit_requested
+ and self.server.listener is not None
+ ):
+ self._poll_stdout.flush()
+ await self.server.stop_event.wait()
+ finally:
+ await self.stop_runtime()
+
+ async def stop_runtime(self):
+ """Reject dispatch and cancel UI/management tasks before closing services."""
+ self._stopping = True
+ pending = (
+ self._tag_mutations
+ | self._result_popup_tasks
+ | {
+ task
+ for task in (
+ self._tag_import_task,
+ self._server_edit_task,
+ self._client_config_task,
+ self._component_stop_task,
+ self._result_popup_task,
+ )
+ if task is not None
+ }
+ )
+ pending.discard(asyncio.current_task())
+ for task in pending:
+ task.cancel()
+ if pending:
+ await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), 5)
+ await self.server.stop()
+ await self.close_connection()
diff --git a/src/ctmodbus/client_commands.py b/src/ctmodbus/client_commands.py
new file mode 100644
index 0000000..7bbb2b0
--- /dev/null
+++ b/src/ctmodbus/client_commands.py
@@ -0,0 +1,397 @@
+"""Client commands and selected project configuration, independent of live I/O."""
+
+import asyncio
+import json
+from typing import Literal
+
+from ctui import Argument, CommandError, CommandResult, CompletionItem, command
+
+from ctmodbus.connection import ConnectionSettings
+from ctmodbus.data_state import DataState
+from ctmodbus.discovery import complete_serial, suggestions
+from ctmodbus.server_config import CONFIG_NAME
+
+NETWORK_ARGUMENTS = {
+ name: Argument(flags=(f"--{name}",))
+ for name in ("port", "unit", "timeout", "retries")
+}
+NETWORK_ARGUMENTS["host"] = Argument(help="Device hostname or IP address")
+TLS_ARGUMENTS = {
+ **NETWORK_ARGUMENTS,
+ **{
+ name: Argument(flags=(f"--{name.replace('_', '-')}",))
+ for name in ("ca_file", "cert_file", "key_file", "insecure")
+ },
+}
+SERIAL_ARGUMENTS = {
+ name: Argument(flags=(f"--{name}",))
+ for name in (
+ "unit",
+ "timeout",
+ "retries",
+ "baudrate",
+ "bytesize",
+ "parity",
+ "stopbits",
+ )
+}
+SERIAL_ARGUMENTS["device"] = Argument(
+ help="Serial device path", completer=complete_serial
+)
+
+
+CONFIG_ARGUMENTS = {
+ name: Argument(flags=("--" + name.replace("_", "-"),))
+ for name in (
+ "transport",
+ "target",
+ "port",
+ "unit",
+ "timeout",
+ "retries",
+ "baudrate",
+ "bytesize",
+ "parity",
+ "stopbits",
+ "ca_file",
+ "cert_file",
+ "key_file",
+ "insecure",
+ "clear_ca_file",
+ "clear_cert_file",
+ "clear_key_file",
+ )
+}
+CONFIG_ARGUMENTS["transport"] = Argument(
+ flags=("--transport",),
+ choices={
+ "tcp": "TCP",
+ "udp": "UDP",
+ "tls": "Verified TLS",
+ "rtu": "Serial RTU",
+ "ascii": "Serial ASCII",
+ },
+)
+CONFIG_ARGUMENTS["parity"] = Argument(
+ flags=("--parity",), choices={"N": "None", "E": "Even", "O": "Odd"}
+)
+CONFIG_ARGUMENTS["insecure"] = Argument(
+ flags=("--insecure",),
+ completer=lambda context: [
+ CompletionItem("true", "Disable verification"),
+ CompletionItem("false", "Verify TLS certificates"),
+ ],
+)
+
+
+async def complete_profiles(context):
+ """Complete saved profile names from the active project."""
+ return [name for name in await context.app.configs.list() if name != CONFIG_NAME]
+
+
+class ClientCommandMixin:
+ """Client lifecycle and configuration commands sharing application guards."""
+
+ @command(name="client discover")
+ async def client_discover(self):
+ """List local serial devices and listening services."""
+ return await asyncio.to_thread(suggestions)
+
+ async def open_connection(self, settings):
+ """Return an appended OPENED result after connection and recording start.
+
+ Connection errors propagate as CommandError. If session setup fails or
+ is cancelled, abort the new connection and propagate the exception so
+ subsequent operations cannot run without their intended session.
+ """
+ if (
+ self.server.listener
+ and settings.transport in ("rtu", "ascii")
+ and self.server.serial_device == settings.target
+ ):
+ raise CommandError("Client and server cannot share the same serial port")
+ if self.connection.client is not None:
+ raise CommandError("Stop the client before starting another connection")
+ if settings != self.selected_client_settings:
+ self.client_config_source = None
+ self.client_config_saved = None
+ self.selected_client_settings = settings
+ await self.connection.connect(settings)
+ self.client_state = DataState(settings.label)
+ try:
+ await self.finish_record_session()
+ self._record_session = await self.records.start_session(
+ "modbus", metadata=settings.as_dict()
+ )
+ except BaseException:
+ self.connection.abort()
+ raise
+ return CommandResult.append(f"Session OPENED: {settings.label}")
+
+ @command(name="client start tcp", arguments=NETWORK_ARGUMENTS)
+ async def client_start_tcp(
+ self,
+ host: str,
+ port: int = 502,
+ unit: int = 1,
+ timeout: float = 3,
+ retries: int = 0,
+ ):
+ """Open a Modbus TCP session."""
+ return await self.open_connection(
+ ConnectionSettings(
+ "tcp", host, port=port, unit=unit, timeout=timeout, retries=retries
+ )
+ )
+
+ @command(name="client start tls", arguments=TLS_ARGUMENTS)
+ async def client_start_tls(
+ self,
+ host: str,
+ port: int = 802,
+ unit: int = 1,
+ timeout: float = 3,
+ retries: int = 0,
+ ca_file: str | None = None,
+ cert_file: str | None = None,
+ key_file: str | None = None,
+ insecure: bool = False,
+ ):
+ """Open Modbus TLS; verify the server unless --insecure is specified."""
+ return await self.open_connection(
+ ConnectionSettings(
+ "tls",
+ host,
+ port=port,
+ unit=unit,
+ timeout=timeout,
+ retries=retries,
+ ca_file=ca_file,
+ cert_file=cert_file,
+ key_file=key_file,
+ insecure=insecure,
+ )
+ )
+
+ @command(name="client start udp", arguments=NETWORK_ARGUMENTS)
+ async def client_start_udp(
+ self,
+ host: str,
+ port: int = 502,
+ unit: int = 1,
+ timeout: float = 3,
+ retries: int = 0,
+ ):
+ """Open a Modbus UDP session (does not verify a remote device)."""
+ return await self.open_connection(
+ ConnectionSettings(
+ "udp", host, port=port, unit=unit, timeout=timeout, retries=retries
+ )
+ )
+
+ @command(name="client start rtu", arguments=SERIAL_ARGUMENTS)
+ async def client_start_rtu(
+ self,
+ device: str,
+ unit: int = 1,
+ timeout: float = 1,
+ retries: int = 0,
+ baudrate: int = 9600,
+ bytesize: int = 8,
+ parity: Literal["N", "E", "O"] = "N",
+ stopbits: int = 1,
+ ):
+ """Open a Modbus RTU serial session."""
+ return await self.open_connection(
+ ConnectionSettings(
+ "rtu",
+ device,
+ unit=unit,
+ timeout=timeout,
+ retries=retries,
+ baudrate=baudrate,
+ bytesize=bytesize,
+ parity=parity,
+ stopbits=stopbits,
+ )
+ )
+
+ @command(name="client start ascii", arguments=SERIAL_ARGUMENTS)
+ async def client_start_ascii(
+ self,
+ device: str,
+ unit: int = 1,
+ timeout: float = 1,
+ retries: int = 0,
+ baudrate: int = 9600,
+ bytesize: int = 8,
+ parity: Literal["N", "E", "O"] = "N",
+ stopbits: int = 1,
+ ):
+ """Open a Modbus ASCII serial session."""
+ return await self.open_connection(
+ ConnectionSettings(
+ "ascii",
+ device,
+ unit=unit,
+ timeout=timeout,
+ retries=retries,
+ baudrate=baudrate,
+ bytesize=bytesize,
+ parity=parity,
+ stopbits=stopbits,
+ )
+ )
+
+ @command(name="client start")
+ async def client_start(self):
+ """Start the selected configuration; loading or editing never opens I/O."""
+ if self.selected_client_settings is None:
+ raise CommandError("Select settings with client config load or set first")
+ return await self.open_connection(self.selected_client_settings)
+
+ @command(name="client stop", arguments={"confirm": Argument(flags=("--confirm",))})
+ async def client_stop(
+ self, confirm: bool = False
+ ): # pylint: disable=unused-argument
+ """Cancel device work and close; dispatch confirms active proxy stops."""
+ await self.close_connection()
+ return CommandResult.append("Session CLOSED")
+
+ @command(name="client status")
+ async def client_status(self):
+ """Show connection state and retained read/write evidence without device I/O."""
+ heading = (
+ f"Connected: {self.connection.settings.label}"
+ if self.connection.connected
+ else "Disconnected; last session evidence retained"
+ )
+ return heading + "\n" + self.client_state.show(await self.tags.list())
+
+ def require_stopped_client(self):
+ """Reject configuration changes while a live client exists."""
+ if self.connection.client is not None:
+ raise CommandError("Stop the client before changing its configuration")
+
+ @command(name="client config show")
+ def client_config_show(self):
+ """Show selected settings, their saved source, and unsaved changes."""
+ if self.selected_client_settings is None:
+ return "No client configuration selected."
+ values = self.selected_client_settings.as_dict()
+ dirty = values != self.client_config_saved
+ return (
+ f"Source: {self.client_config_source or 'command settings'}\n"
+ f"Unsaved changes: {'yes' if dirty else 'no'}\n"
+ + json.dumps(values, indent=2)
+ )
+
+ @command(
+ name="client config save",
+ arguments={"name": Argument(help="Saved configuration name")},
+ )
+ async def client_config_save(self, name: str):
+ """Save selected settings, replacing NAME; store certificate paths."""
+ if name == CONFIG_NAME:
+ raise CommandError("That name is reserved for server configuration")
+ if self.selected_client_settings is None:
+ raise CommandError("Select client settings before saving a configuration")
+ values = self.selected_client_settings.as_dict()
+ await self.configs.save(name, values)
+ self.client_config_source = name
+ self.client_config_saved = values
+ return CommandResult.append(f"Saved client configuration {name!r}")
+
+ @command(
+ name="client config load",
+ arguments={
+ "name": Argument(
+ help="Saved configuration name", completer=complete_profiles
+ )
+ },
+ )
+ async def client_config_load(self, name: str):
+ """Select saved settings while stopped; require explicit client start."""
+ self.require_stopped_client()
+ if name == CONFIG_NAME:
+ raise CommandError("That name is reserved for server configuration")
+ values = await self.configs.get(name)
+ try:
+ settings = ConnectionSettings(**values)
+ except (TypeError, ValueError, CommandError) as error:
+ raise CommandError(
+ f"Invalid client configuration {name!r}: {error}"
+ ) from error
+ self.selected_client_settings = settings
+ self.client_config_source = name
+ self.client_config_saved = settings.as_dict()
+ return f"Loaded client configuration {name!r}. Use client start to connect."
+
+ @command(name="client config set", arguments=CONFIG_ARGUMENTS)
+ def client_config_set(
+ self,
+ transport: Literal["tcp", "udp", "tls", "rtu", "ascii"] | None = None,
+ target: str | None = None,
+ port: int | None = None,
+ unit: int | None = None,
+ timeout: float | None = None,
+ retries: int | None = None,
+ baudrate: int | None = None,
+ bytesize: int | None = None,
+ parity: Literal["N", "E", "O"] | None = None,
+ stopbits: int | None = None,
+ ca_file: str | None = None,
+ cert_file: str | None = None,
+ key_file: str | None = None,
+ insecure: bool | None = None,
+ clear_ca_file: bool = False,
+ clear_cert_file: bool = False,
+ clear_key_file: bool = False,
+ ):
+ """Atomically validate supplied fields against stopped selected settings.
+
+ First selection requires --transport and --target. Omitted fields retain
+ their values. Clear TLS paths explicitly; --insecure false restores
+ verification. Switching transport retains other fields, so clear any
+ incompatible TLS options in the same command.
+ """
+ self.require_stopped_client()
+ values = (
+ self.selected_client_settings.as_dict()
+ if self.selected_client_settings
+ else {}
+ )
+ supplied = {
+ "transport": transport,
+ "target": target,
+ "port": port,
+ "unit": unit,
+ "timeout": timeout,
+ "retries": retries,
+ "baudrate": baudrate,
+ "bytesize": bytesize,
+ "parity": parity,
+ "stopbits": stopbits,
+ "ca_file": ca_file,
+ "cert_file": cert_file,
+ "key_file": key_file,
+ "insecure": insecure,
+ }
+ updates = {name: value for name, value in supplied.items() if value is not None}
+ if not values and (transport is None or target is None):
+ raise CommandError("First selection requires --transport and --target")
+ if not values:
+ updates.setdefault("port", 802 if transport == "tls" else 502)
+ updates.setdefault("timeout", 1 if transport in ("rtu", "ascii") else 3)
+ for name, clear in (
+ ("ca_file", clear_ca_file),
+ ("cert_file", clear_cert_file),
+ ("key_file", clear_key_file),
+ ):
+ if clear:
+ if name in updates:
+ raise CommandError(f"Cannot set and clear {name} together")
+ updates[name] = None
+ settings = ConnectionSettings(**{**values, **updates})
+ self.selected_client_settings = settings
+ return "Client configuration updated. Use client start to connect."
diff --git a/src/ctmodbus/commands.py b/src/ctmodbus/commands.py
index 4cf241b..bbf096b 100644
--- a/src/ctmodbus/commands.py
+++ b/src/ctmodbus/commands.py
@@ -1,344 +1,24 @@
-"""
-Control Things Modbus, aka ctmodbus.py
+"""Console entry point for the ctmodbus script and module execution.
-# Copyright (C) 2019 Justin Searle
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or any later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details at .
+Delegate argument parsing and runtime ownership to ModbusApp/ctui; importing
+this module does not launch the application.
"""
-import socket
-from datetime import datetime
-from importlib.metadata import version
-
-from ctui import Ctui
-from ctui.dialogs import message_dialog
-from ctui.types import GreedyBin, GreedyInt
-from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient
-from pymodbus.mei_message import ReadDeviceInformationRequest
-
-from ctmodbus import common
-
-ctmodbus = Ctui()
-ctmodbus.name = "ctmodbus"
-ctmodbus.version = version("ctmodbus")
-ctmodbus.description = "A highly flexible Modbus tool made for penetration testers"
-ctmodbus.prompt = "ctmodbus> "
-
-ctmodbus.session = None
-unit_id = 1
-ctmodbus.statusbar = lambda: f"PROJECT: {ctmodbus.project_name} | Connection: {ctmodbus.session}"
-
-
-@ctmodbus.command
-def do_debug(cmd: str):
- """
- Run a python command for debugging
-
- :PARAM: cmd: Python command to run in debug session
- """
- message_dialog(title="Debug", text=str(eval(cmd)))
-
-
-@ctmodbus.command
-def do_connect():
- """Connect to modbus device/service or list suggestions"""
- output_text = "Connected Serial Devices\n"
- output_text += common.list_serial_devices()
- output_text += "\n\n\n"
- output_text += "Listening Services on Localhost\n"
- output_text += common.list_listening_ports()
- output_text += " \n"
- message_dialog(title="Suggestions", text=output_text)
-
-
-@ctmodbus.command
-def do_connect_ascii(device: str):
- """
- Connect to a Modbus ASCII serial device
-
- :PARAM: device: Serial device path or COM port
- """
- assert (
- ctmodbus.session == None
- ), "Session already open. Close first." # ToDo assert session type
- valid_device = common.validate_serial_device(device)
- s = ModbusSerialClient(method="ascii", port=valid_device, timeout=1)
- assert s.connect(), f"Could not connect to {valid_device}"
- ctmodbus.session = s
- date, time = str(datetime.today()).split()
- return ctmodbus.output_text + f"ASCII session OPENED with {valid_device}\n"
-
-
-@ctmodbus.command
-def do_connect_rtu(device: str):
- """
- Connect to a Modbus RTU serial device
-
- :PARAM: device: Serial device path or COM port
- """
- assert (
- ctmodbus.session == None
- ), "Session already open. Close first." # ToDo assert session type
- valid_device = common.validate_serial_device(device)
- s = ModbusSerialClient(method="rtu", port=valid_device, timeout=1)
- assert s.connect(), f"Could not connect to {valid_device}"
- ctmodbus.session = s
- date, time = str(datetime.today()).split()
- return (
- ctmodbus.output_text
- + f"RTU session OPENED with {valid_device} at {date} {time}\n"
- )
-
-
-@ctmodbus.command
-def do_connect_tcp(host_port: str):
- """
- Connect to a Modbus TCP device [:]
-
- :PARAM: host_port: [:]
- """
- assert (
- ctmodbus.session == None
- ), "Session already open. Close first." # ToDo assert session type
- host, port = common.parse_ip_port(host_port)
- s = ModbusTcpClient(host, port, timeout=3)
- assert s.connect(), f"Could not connect to {host}:{port}"
- ctmodbus.session = s
- date, time = str(datetime.today()).split()
- return (
- ctmodbus.output_text
- + f"TCP session OPENED with {host}:{port} at {date} {time}\n"
- )
-
-
-@ctmodbus.command
-def do_connect_udp(host_port: str):
- """
- Connect to a Modbus UDP device
-
- :PARAM: host_port: [:]
- """
- assert (
- ctmodbus.session == None
- ), "Session already open. Close first." # ToDo assert session type
- host, port = common.parse_ip_port(host_port)
- s = ModbusUdpClient(host, port, timeout=3)
- assert s.connect(), f"Could not connect to {host}:{port}"
- ctmodbus.session = s
- date, time = str(datetime.today()).split()
- return (
- ctmodbus.output_text
- + f"UDP session OPENED with {host}:{port} at {date} {time}\n"
- )
-
-
-@ctmodbus.command
-def do_close():
- """
- Close the open session
- """
- assert (
- ctmodbus.session
- ), "There is not an open session. Connect to one first." # ToDo assert session type
- ctmodbus.session.close()
- ctmodbus.session = None
- return ctmodbus.output_text + "Session CLOSED\n"
-
+from ctmodbus.app import ModbusApp
-@ctmodbus.command
-def do_read():
- """Various modbus read functions..."""
+def main():
+ """Run TUI without arguments or sequential -c/-f CLI commands from argv.
-@ctmodbus.command
-def do_read_id():
- """
- Read device identification data
- """
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- response = ctmodbus.session.execute(ReadDeviceInformationRequest(unit=1))
- assert not response.isError(), "Read DevID is not supported"
- date, time = str(datetime.today()).split()
- output_text = ctmodbus.output_text
- keys = [
- "VendorName",
- "ProductCode",
- "MajorMinorRevision",
- "VendorUrl",
- "ProductName",
- "ModelName",
- "UserApplicationName",
- ]
- for i in range(len(response.information)):
- if i < 7:
- output_text += f"{date} {time} - (43) Read DevID {keys[i]}: {str(response.information[i])}\n"
- else:
- output_text += f"{date} {time} - (43) Read DevID ObjectID {i}: {str(response.information[i])}\n"
- return output_text
-
-
-@ctmodbus.command
-def do_read_discreteInputs(csr: str, max: int = 2000):
- """
- Read discrete inputs (on/off) in format: 30,50,70-99,105
-
- :PARAM: csr: Comma separated ranges to read
- :PARAM: max: Optional max addresses to read per request (default 2000)
- """
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- desc = "(2) Read DisIn"
- results = {}
- output_text = ctmodbus.output_text
- for start, stop, count in common.csr_to_ranges(csr, max):
- response = ctmodbus.session.read_discrete_inputs(start, count, unit=unit_id)
- assert hasattr(response, "bits"), "No response received"
- for address, result in zip(range(start, stop), response.bits):
- results[address] = int(result)
- output_text += common.log_and_output_bits(desc, start, stop, results)
- ranges = csr.split()[0]
- message = f"{desc}: {ranges}\n\n"
- common.summarize_bit_responses(message, results)
- return output_text
-
-
-@ctmodbus.command
-def do_read_coils(csr: str, max: int = 2000):
- """
- Read coils (digital outputs and internal boolean tags) in format: 30,50,70-99,105
-
- :PARAM: csr: Comma separated ranges to read
- :PARAM: max: Optional max addresses to read per request (default 2000)
- """
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- desc = "(1) Read Coils"
- results = {}
- output_text = ctmodbus.output_text
- for start, stop, count in common.csr_to_ranges(csr, max):
- response = ctmodbus.session.read_coils(start, count, unit=unit_id)
- assert hasattr(response, "bits"), "No response received"
- for address, result in zip(range(start, stop), response.bits):
- results[address] = int(result)
- output_text += common.log_and_output_bits(desc, start, stop, results)
- ranges = csr.split()[0]
- message = f"{desc}: {ranges}\n\n"
- common.summarize_bit_responses(message, results)
- return output_text
-
-
-@ctmodbus.command
-def do_read_inputRegisters(csr: str, max: int = 125):
- """
- Read input registers (analog inputs) in format: 30,50,70-99,105
-
- :PARAM: csr: Comma separated ranges to read
- :PARAM: max: Optional max addresses to read per request (default 125)
- """
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- desc = "(4) Read InReg"
- results = {}
- output_text = ctmodbus.output_text
- for start, stop, count in common.csr_to_ranges(csr, max):
- response = ctmodbus.session.read_input_registers(start, count, unit=unit_id)
- assert hasattr(response, "registers"), "No response received"
- for address, result in zip(range(start, stop), response.registers):
- results[address] = result
- output_text += common.log_and_output_words(desc, start, stop, results)
- ranges = csr.split()[0]
- message = f"{desc}: {ranges}\n\n"
- common.summarize_word_responses(message, results)
- return output_text
-
-
-@ctmodbus.command
-def do_read_holdingRegisters(csr: str, max: int = 125):
- """
- Read holding registers (analog outputs and internal tags) in format: 30,50,70-99,105
-
- :PARAM: csr: Comma separated ranges to read
- :PARAM: max: Optional max addresses to read per request (default 125)
- """
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- desc = "(3) Read HoReg"
- results = {}
- output_text = ctmodbus.output_text
- for start, stop, count in common.csr_to_ranges(csr, max):
- response = ctmodbus.session.read_holding_registers(start, count, unit=unit_id)
- assert hasattr(response, "registers"), "No response received"
- for address, result in zip(range(start, stop), response.registers):
- results[address] = result
- output_text += common.log_and_output_words(desc, start, stop, results)
- ranges = csr.split()[0]
- message = f"{desc}: {ranges}\n\n"
- common.summarize_word_responses(message, results)
- return output_text
-
-
-@ctmodbus.command
-def do_write():
- """Various modbus write commands..."""
-
-
-@ctmodbus.command
-def do_write_register(address: int, values: GreedyInt):
- """
- Write single register in format:
-
- :PARAM: address: Modbus address to start writes
- :PARAM: values: Space separated integers to write
- """
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- if len(values) == 1:
- ctmodbus.session.write_register(address, values[0], unit=unit_id)
- desc = "Modbus Function 6, Write Single Register"
- else:
- # This is currently not working, as GreedyInt doesn't pass on a list
- ctmodbus.session.write_registers(address, values, unit=unit_id)
- desc = "Modbus Function 16, Write Multiple Registers"
- message_dialog(title="Success", text=f"Wrote {values} starting at {address}")
- results = {}
- stop = address + len(values)
- for i in range(len(values)):
- results[address + i] = values[i]
- output_text = ctmodbus.output_text
- output_text += common.log_and_output_words(desc, address, stop, results)
- return output_text
-
-
-@ctmodbus.command
-def do_write_coil(address: int, values: GreedyBin):
- """
- Write single coil in format: <0 or 1>
-
- :PARAM: address: Modbus address to start writes
- :PARAM: values: Space separated list of True or False to write
+ ctui manages backend startup/shutdown. CLI raises SystemExit with status
+ 0 for success, 2 for command errors, or 1 for unexpected failures; TUI
+ returns after exit. Ctrl-C completes async cleanup and exits 130 without a
+ traceback. No live application is retained globally.
"""
- assert ctmodbus.session, "There is not an open session. Connect to one first."
- if len(values) == 1:
- ctmodbus.session.write_coil(address, values[0], unit=unit_id)
- desc = "Modbus Function 5, Write Single Coil"
- else:
- # This is currently not working, as GreedyBin doesn't pass on a list
- ctmodbus.session.write_coils(address, values, unit=unit_id)
- desc = "Modbus Function 15, Write Multiple Coils"
- message_dialog(title="Success", text=f"Wrote {values} starting at {address}")
- results = {}
- stop = address + len(values)
- for i in range(len(values)):
- results[address + i] = int(values[i])
- output_text = ctmodbus.output_text
- output_text += common.log_and_output_bits(desc, address, stop, results)
- return output_text
-
-
-def main():
- ctmodbus.run()
+ try:
+ ModbusApp().run()
+ except KeyboardInterrupt:
+ raise SystemExit(130) from None
if __name__ == "__main__":
diff --git a/src/ctmodbus/common.py b/src/ctmodbus/common.py
deleted file mode 100644
index c639b4f..0000000
--- a/src/ctmodbus/common.py
+++ /dev/null
@@ -1,293 +0,0 @@
-"""
-Control Things Modbus, aka ctmodbus.py
-
-# Copyright (C) 2019 Justin Searle
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU General Public License as published by the Free Software
-# Foundation, either version 3 of the License, or any later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
-# details at .
-"""
-
-import operator
-import socket
-from datetime import datetime
-
-from ctui.dialogs import message_dialog
-from psutil import Process, net_connections
-from serial.tools.list_ports import comports
-from tabulate import tabulate
-
-
-class Loops(object):
- """Object that contains and calculates a list of range parameters"""
-
- from sys import maxsize
-
- def __init__(self, csr=None, minimum=-maxsize, maximum=maxsize):
- assert isinstance(csr, str) or csr == None, "csr must be a string"
- self.csr = csr or None
- self.loops = []
- self.length = 0
- self.enum_length = 0
- if csr:
- self._from_int_csr(csr, minimum, maximum)
-
- def __repr__(self):
- return repr(self.loops)
-
- def __len__(self):
- return self.length
-
- def __iter__(self):
- for loop in self.loops:
- yield loop
-
- def max_count(self, max_count):
- assert isinstance(max_count, int), "max_count must be int"
- for loop in self.loops:
- for i in range(0, loop["count"], max_count):
- start, stop, count = loop.values()
- yield {
- "start": start + i,
- "stop": min(start + max_count + i, stop),
- "count": min(max_count, count - i),
- }
-
- def append(self, start, stop=None, count=None):
- """Append a new loop"""
- assert isinstance(start, int), "start must be an int"
- assert stop or count, "must set stop or count"
- if stop and not count:
- count = stop - start + 1
- elif count and not stop:
- stop = start + count - 1
- assert count == stop - start + 1, "count must equal stop - start + 1"
- self.loops.append({"start": start, "stop": stop, "count": count})
- self.length += 1
- self.enum_length += count
-
- def enum(self, func=None):
- """Use ranges to enumerate every increment"""
- for loop in self.loops:
- for x in range(loop["start"], loop["stop"]):
- if func:
- yield func(x)
- else:
- yield x
-
- def _from_int_csr(self, csr, minimum, maximum):
- """Converts comma separated int ranges to range() functions"""
- assert isinstance(csr, str), 'csr must be a string like "1-5,10,13-15"'
- assert isinstance(minimum, int), "minimum must be int"
- self.minimum = minimum
- assert isinstance(maximum, int), "maximum must be int"
- self.maximum = maximum
- parts = csr.split(",")
- for single_or_range in parts:
- if single_or_range.isdigit():
- single = int(single_or_range)
- assert (
- minimum <= single <= maximum
- ), "{} is not between {} and {}".format(single, minimum, maximum)
- self.loops.append({"start": single, "stop": single + 1, "count": 1})
- self.length += 1
- self.enum_length += 1
- else:
- a_range = single_or_range.split("-")
- assert len(a_range) == 2, "{} is not a valid range".format(a_range)
- start, stop = a_range
- assert (
- start.isdigit() and stop.isdigit()
- ), "{} and {} must be integers".format(start, stop)
- start, stop = int(start), int(stop)
- assert (
- minimum <= start <= maximum
- ), "{} is not between {} and {}".format(start, minimum, maximum)
- assert minimum <= stop <= maximum, "{} is not between {} and {}".format(
- stop, minimum, maximum
- )
- assert start < stop, "{} must be less than {}".format(start, stop)
- count = stop - start + 1
- self.loops.append({"start": start, "stop": stop + 1, "count": count})
- self.length += 1
- self.enum_length += count
-
-
-def validate_serial_device(device):
- """
- Verify requested serial device is connected to the system
-
- :PARAM: device: A device file path or comm port
- """
- devices = [x.device for x in comports()]
- assert device in devices, "{} is not in: \n{} ".format(
- device, list_serial_devices()
- )
- return device
-
-
-def list_serial_devices():
- headers = ["DEVICE", "MANUFACTURER", "PRODUCT ID"]
- rows = []
- for dev in comports():
- columns = []
- columns.append(dev.device)
- columns.append(dev.manufacturer)
- columns.append(dev.product)
- rows.append(columns)
- table = sorted(rows, key=operator.itemgetter(0))
- return tabulate(table, headers=headers, tablefmt="fancy_grid")
-
-
-def list_listening_ports():
- conns = net_connections()
- headers = ["IP", "PORT", "PROCESS"]
- rows = []
- for conn in conns:
- if conn[5] == "LISTEN":
- collumns = []
- collumns.append(conn[3][0])
- collumns.append(conn[3][1])
- if conn[6]:
- process = Process(conn[6])
- collumns.append(process.name())
- else:
- collumns.append("")
- rows.append(collumns)
- table = sorted(rows, key=operator.itemgetter(1))
- return tabulate(table, headers=headers, tablefmt="fancy_grid")
-
-
-def parse_ip_port(ip_port):
- """
- Separate ip/host and port from unified address
-
- :PARAM: ip_port: Any address in ip or ip:port format
- """
- parts = ip_port.rsplit(":", 1)
- host = parts[0]
- port = 502
- if len(parts) == 2:
- port = int(parts[1])
- return host, port
-
-
-def log_and_output_bits(desc, start, stop, results):
- """
- Log in project database and output to screen
-
- :PARAM:
- """
- date, time = str(datetime.today()).split()
- output_text = "{} {} - {} {}-{}: ".format(date, time, desc, start, stop - 1)
- i = 0
- for address in range(start, stop):
- # Print next bit
- output_text += str(results[address])
- # Print spaces ever 4 bits
- i += 1
- if i % 5 == 0:
- output_text += " "
- # TODO: Add to self.storage
- output_text += "\n"
- return output_text
-
-
-def log_and_output_words(desc, start, stop, results):
- """
- Log in project database and output to screen
-
- :PARAM:
- """
- date, time = str(datetime.today()).split()
- output_text = "{} {} - {} {}-{}: ".format(date, time, desc, start, stop - 1)
- i = 0
- for address in range(start, stop):
- # Print spaces ever word
- output_text += "{:04x}".format(results[address]) + " "
- # Print extra spaces ever 5 words
- i += 1
- if i % 5 == 0:
- output_text += " "
- # TODO: Add to storage
- output_text += "\n"
- return output_text
-
-
-def summarize_bit_responses(message, results):
- """
- Summarize bit responses in message dialog
-
- :PARAM:
- """
- la, lr, fa = None, None, None # last_addres, last_result, first_address
- table = [["Addr", "Bit"]]
- for address, result in results.items():
- if address - 1 == la and result == lr:
- if fa == None:
- fa = la
- elif la != None:
- if fa == None:
- table.append([la, lr])
- fa = None
- else:
- s = "{0}-{1}".format(fa, la)
- table.append([s, lr])
- fa = None
- la, lr = address, result
- # Print final output from for loop
- if fa == None:
- table.append([la, lr])
- else:
- s = "{0}-{1}".format(fa, la)
- table.append([s, lr])
- message += tabulate(table, headers="firstrow", tablefmt="simple")
- message_dialog(title="Success", text=message)
-
-
-def summarize_word_responses(message, results):
- """
- Summarize word reseponses in message dialog
-
- :PARAM:
- """
- la, lr, fa = None, None, None # last_addres, last_result, first_address
- table = [["Addr", "Int", "HEX", "UTF-8"]]
- for address, result in results.items():
- if address - 1 == la and result == lr:
- if fa == None:
- fa = la
- elif la != None:
- if fa == None:
- table.append([la, lr, "{:04x}".format(lr), str(chr(lr))])
- fa = None
- else:
- s = "{0}-{1}".format(fa, la)
- table.append([s, lr, "{:04x}".format(lr), str(chr(lr))])
- fa = None
- la, lr = address, result
- # Print final output from for loop
- if fa == None:
- table.append([la, lr, "{:04x}".format(lr), str(chr(lr))])
- else:
- s = "{0}-{1}".format(fa, la)
- table.append([s, lr, "{:04x}".format(lr), str(chr(lr))])
- message += tabulate(table, headers="firstrow", tablefmt="simple")
- message_dialog(title="Success", text=message)
-
-
-def csr_to_ranges(csr, max):
- """
- Generator to convert csr to ranges
-
- :PARAM: csr: Comma separated list of values or ranges
- :PARAM: max: Maximum number a range can have
- """
- loops = Loops(csr, minimum=0, maximum=65535)
- for loop in loops.max_count(max):
- yield loop.values()
diff --git a/src/ctmodbus/component_help.py b/src/ctmodbus/component_help.py
new file mode 100644
index 0000000..0eb87e1
--- /dev/null
+++ b/src/ctmodbus/component_help.py
@@ -0,0 +1,79 @@
+"""Example-led component overview popups, separate from generated command help.
+
+ctui currently exposes popup help through its internal _HelpResult protocol.
+Keep that small adapter here; both terminal and browser use format_ui_help.
+Normal help targets continue through ctui's generated formatter unchanged.
+"""
+
+from ctui import command
+from ctui.commands import _HelpResult
+
+OVERVIEWS = {
+ "client": """Client — select settings, connect, inspect, and stop.
+
+client discover
+client start tcp localhost --port 502
+client config set --transport tls --target device --port 802
+client config save lab
+client config load lab
+client config show
+client start
+client status
+client stop [--confirm]
+
+Loading or editing settings requires a stopped client and never connects.
+Stopping also stops an active proxy after confirmation.
+Use help client for the full generated command reference.""",
+ "server": """Server — configure local behavior, then start a listener.
+
+server config import examples/device.toml
+server config show
+server config set holding_registers 0-3 42
+server config validate
+server start tcp 127.0.0.1 --port 5020
+server status
+server reset --confirm
+server logging off
+server stop [--confirm]
+
+Configuration and hooks are edited while stopped. Status includes runtime
+values and downstream evidence; reset restores local simulation state.
+Use help server for transports, config editing, export, and hook options.""",
+ "proxy": """Proxy — forward server requests through the connected client.
+
+client start tcp upstream --retries 0
+server start tcp 127.0.0.1 --port 5020
+proxy start [--quiet]
+proxy status
+proxy logging off
+proxy stop
+
+Both client and server must be running. New requests return to local
+simulation after proxy stop; already routed exchanges finish normally.
+Use help proxy for the full generated command reference.""",
+}
+
+
+class ComponentHelpMixin:
+ """Return overview help using ctui's shared TUI/WUI popup result protocol."""
+
+ def format_ui_help(self, target=None):
+ """Render custom overview sentinels; preserve generated help targets."""
+ if target and target.startswith("@overview:"):
+ return OVERVIEWS[target.removeprefix("@overview:")]
+ return super().format_ui_help(target)
+
+ @command(name="client")
+ def client_overview(self):
+ """Open concise client examples."""
+ return _HelpResult(output=OVERVIEWS["client"], target="@overview:client")
+
+ @command(name="server")
+ def server_overview(self):
+ """Open concise server examples."""
+ return _HelpResult(output=OVERVIEWS["server"], target="@overview:server")
+
+ @command(name="proxy")
+ def proxy_overview(self):
+ """Open concise proxy examples."""
+ return _HelpResult(output=OVERVIEWS["proxy"], target="@overview:proxy")
diff --git a/src/ctmodbus/connection.py b/src/ctmodbus/connection.py
new file mode 100644
index 0000000..16c3d61
--- /dev/null
+++ b/src/ctmodbus/connection.py
@@ -0,0 +1,290 @@
+"""Native async transports and serialized, cancellable connection ownership.
+
+Construct validated ConnectionSettings, await Connection.connect(settings), then
+use ``async with connection.operation()`` around related request calls. Clients
+and tasks are runtime state; only settings are serializable. Transport failures
+become CommandError. Protocol shape validation belongs to operations.py.
+"""
+
+import asyncio
+import inspect
+import math
+import ssl
+from contextlib import asynccontextmanager
+from dataclasses import asdict, dataclass
+from typing import Literal
+
+from ctui import CommandError
+from pymodbus import FramerType
+from pymodbus.client import (
+ AsyncModbusSerialClient,
+ AsyncModbusTcpClient,
+ AsyncModbusTlsClient,
+ AsyncModbusUdpClient,
+)
+from pymodbus.exceptions import ModbusException
+
+Transport = Literal["tcp", "udp", "tls", "rtu", "ascii"]
+
+
+@dataclass(frozen=True)
+class ConnectionSettings:
+ """Immutable, JSON-compatible endpoint and transport configuration.
+
+ Construction raises CommandError for invalid settings: unit 1–247, port
+ 1–65535, retries 0–10, finite timeout in (0, 300] seconds, baudrate
+ 1–4,000,000, 7/8 data bits, N/E/O parity, and 1/2 stop bits. TLS options
+ require TLS; a separate key requires a certificate. File existence and
+ certificate validity are checked when creating the client, not here.
+ Direct construction defaults to port 502; the TLS command supplies 802.
+ """
+
+ transport: Transport
+ target: str
+ port: int = 502
+ unit: int = 1
+ timeout: float = 3.0
+ retries: int = 0
+ baudrate: int = 9600
+ bytesize: int = 8
+ parity: Literal["N", "E", "O"] = "N"
+ stopbits: int = 1
+ ca_file: str | None = None
+ cert_file: str | None = None
+ key_file: str | None = None
+ insecure: bool = False
+
+ def __post_init__(self):
+ """Validate all fields immediately; raise CommandError before device I/O."""
+ if self.transport not in ("tcp", "udp", "tls", "rtu", "ascii"):
+ raise CommandError("Transport must be tcp, udp, tls, rtu, or ascii")
+ if not isinstance(self.target, str) or not self.target.strip():
+ raise CommandError("A host or serial device is required")
+ for name, low, high in (
+ ("port", 1, 65535),
+ ("unit", 1, 247),
+ ("retries", 0, 10),
+ ("baudrate", 1, 4_000_000),
+ ("bytesize", 7, 8),
+ ("stopbits", 1, 2),
+ ):
+ value = getattr(self, name)
+ if type(value) is not int or not low <= value <= high:
+ raise CommandError(
+ f"{name} must be an integer between {low} and {high}"
+ )
+ if (
+ type(self.timeout) not in (int, float)
+ or not math.isfinite(self.timeout)
+ or not 0 < self.timeout <= 300
+ ):
+ raise CommandError(
+ "timeout must be finite and between 0 (exclusive) and 300 seconds"
+ )
+ if self.parity not in ("N", "E", "O"):
+ raise CommandError("parity must be N, E, or O")
+
+ for name in ("ca_file", "cert_file", "key_file"):
+ value = getattr(self, name)
+ if value is not None and (not isinstance(value, str) or not value):
+ raise CommandError(f"{name} must be a nonempty file path")
+ if type(self.insecure) is not bool:
+ raise CommandError("insecure must be a boolean")
+ if self.key_file and not self.cert_file:
+ raise CommandError("key-file requires cert-file")
+ if self.transport != "tls" and any(
+ (self.ca_file, self.cert_file, self.key_file, self.insecure)
+ ):
+ raise CommandError("Certificate options require a TLS connection")
+
+ def as_dict(self):
+ """Return only JSON-compatible connection settings."""
+ return asdict(self)
+
+ @property
+ def label(self):
+ """Return an endpoint label shared by logs and the status bar."""
+ target = (
+ f"{self.target}:{self.port}"
+ if self.transport in ("tcp", "udp", "tls")
+ else self.target
+ )
+ return f"{self.transport.upper()} {target} unit {self.unit}"
+
+
+def tls_context(settings):
+ """Return an SSLContext with trust and hostname verification by default.
+
+ This synchronous function loads files; async callers must offload it.
+ Explicit insecure settings disable verification. An empty key password
+ prevents terminal prompts; encrypted keys are unsupported. SSL and file
+ errors propagate to the connection-opening error boundary.
+ """
+ context = ssl.create_default_context(cafile=settings.ca_file)
+ if settings.insecure:
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE
+ if settings.cert_file:
+ # An explicit empty password prevents OpenSSL prompting on stdin for an
+ # encrypted key. Password-bearing credentials are not stored in profiles.
+ context.load_cert_chain(settings.cert_file, settings.key_file, password="")
+ return context
+
+
+async def create_client(settings):
+ """Return an unopened native async client for validated settings.
+
+ Run in an event loop; certificate loading uses a worker thread. The caller
+ owns connection and cleanup. File/client-construction errors propagate.
+ Reconnect delay is zero so interrupted work requires explicit reconnection.
+ """
+ options = {
+ "timeout": settings.timeout,
+ "retries": settings.retries,
+ "reconnect_delay": 0,
+ }
+ if settings.transport == "tls":
+ context = await asyncio.to_thread(tls_context, settings)
+ return AsyncModbusTlsClient(
+ settings.target, port=settings.port, sslctx=context, **options
+ )
+ if settings.transport == "tcp":
+ return AsyncModbusTcpClient(settings.target, port=settings.port, **options)
+ if settings.transport == "udp":
+ return AsyncModbusUdpClient(settings.target, port=settings.port, **options)
+ return AsyncModbusSerialClient(
+ settings.target,
+ framer=FramerType.RTU if settings.transport == "rtu" else FramerType.ASCII,
+ baudrate=settings.baudrate,
+ bytesize=settings.bytesize,
+ parity=settings.parity,
+ stopbits=settings.stopbits,
+ **options,
+ )
+
+
+class Connection:
+ """Own one client and serialize operations within a single event loop.
+
+ A generation counter prevents queued operations using a replaced transport.
+ The injected factory may return a client or an awaitable client. Callers
+ must reserve operation() before request(); request() does not take the lock.
+ """
+
+ def __init__(self, client_factory=create_client):
+ """Initialize disconnected state without opening a socket or serial port."""
+ self.client_factory = client_factory
+ self.client = None
+ self.settings = None
+ self.generation = 0
+ self.lock = asyncio.Lock()
+ self.tasks = set()
+
+ @property
+ def connected(self):
+ """Read actual transport status rather than assuming an open socket."""
+ return self.client is not None and self.client.connected
+
+ async def connect(self, settings):
+ """Open settings under the lock and return None on success.
+
+ Reject an existing client or obsolete generation. Client.connect is
+ bounded by timeout + 1 seconds; factory creation precedes that bound.
+ Failed opens close the candidate. Transport errors and cancellation
+ become CommandError; other factory errors propagate.
+ """
+ task = asyncio.current_task()
+ generation = self.generation
+ self.tasks.add(task)
+ try:
+ async with self.lock:
+ if generation != self.generation:
+ raise CommandError("Connection attempt cancelled")
+ if self.client is not None:
+ raise CommandError("A session is already open; close it first")
+ client = self.client_factory(settings)
+ if inspect.isawaitable(client):
+ client = await client
+ try:
+ async with asyncio.timeout(settings.timeout + 1):
+ if not await client.connect():
+ raise CommandError(f"Could not open {settings.label}")
+ except BaseException:
+ client.close()
+ raise
+ self.client, self.settings = client, settings
+ except (OSError, ModbusException, TimeoutError) as error:
+ raise CommandError(f"Connection failed: {error}") from error
+ except asyncio.CancelledError as error:
+ raise CommandError("Connection attempt cancelled") from error
+ finally:
+ self.tasks.discard(task)
+
+ @asynccontextmanager
+ async def operation(self):
+ """Yield (client, settings) while exclusively reserving this session.
+
+ Raise CommandError for absent, disconnected, or replaced sessions.
+ Release the lock and task registration on any exit. This context does
+ not itself close the transport when its body fails or is cancelled.
+ """
+ client, generation = self.client, self.generation
+ if client is None:
+ raise CommandError("No open session; use client start first")
+ task = asyncio.current_task()
+ self.tasks.add(task)
+ try:
+ async with self.lock:
+ if generation != self.generation or client is not self.client:
+ raise CommandError("Session changed before the operation started")
+ if not client.connected:
+ raise CommandError("Session disconnected; close and reconnect")
+ yield client, self.settings
+ finally:
+ self.tasks.discard(task)
+
+ async def close(self):
+ """Invalidate queued work, cancel/drain other tasks, then clear state.
+
+ Return None; repeated calls are harmless. This low-level drain has no
+ timeout; the application supplies its own bounded shutdown path.
+ """
+ self.generation += 1
+ pending = self.tasks - {asyncio.current_task()}
+ for task in pending:
+ task.cancel()
+ if pending:
+ await asyncio.gather(*pending, return_exceptions=True)
+ async with self.lock:
+ if self.client is not None:
+ self.client.close()
+ self.client = self.settings = None
+
+ def abort(self):
+ """Synchronously close and clear transport/settings; increment generation.
+
+ Return None without awaiting or cancelling tasks. Generation checks
+ reject queued work, and callers remain responsible for draining tasks.
+ """
+ self.generation += 1
+ if self.client is not None:
+ self.client.close()
+ self.client = self.settings = None
+
+ async def request(self, method, **kwargs):
+ """Await method(device_id=unit, **kwargs) and return a non-error reply.
+
+ Requires an active operation() reservation. Bound execution by
+ (timeout + 1) * (retries + 1) seconds; map transport/timeout failures,
+ absent replies, and Modbus exception replies to CommandError.
+ Cancellation propagates; callers decide read/write uncertainty.
+ """
+ settings = self.settings
+ try:
+ async with asyncio.timeout((settings.timeout + 1) * (settings.retries + 1)):
+ response = await method(device_id=settings.unit, **kwargs)
+ except (OSError, ModbusException, TimeoutError) as error:
+ raise CommandError(f"Modbus request failed: {error}") from error
+ if response is None or response.isError():
+ raise CommandError(f"Modbus error response: {response}")
+ return response
diff --git a/src/ctmodbus/data_state.py b/src/ctmodbus/data_state.py
new file mode 100644
index 0000000..9a18ab5
--- /dev/null
+++ b/src/ctmodbus/data_state.py
@@ -0,0 +1,157 @@
+"""Transient evidence views for client observations and downstream responses.
+
+A write acknowledgement is not readback. Keep read and write evidence separately,
+with UTC timestamps and request identities, so partial tag reads cannot become
+fabricated complete typed values. No observations are persisted across sessions.
+"""
+
+from tabulate import tabulate
+
+from ctmodbus.formatting import timestamp
+from ctmodbus.tags import decode_tag_value
+
+READ_TABLES = {
+ "read_coils": "coils",
+ "read_discrete_inputs": "discrete_inputs",
+ "read_holding_registers": "holding_registers",
+ "read_input_registers": "input_registers",
+}
+WRITE_TABLES = {
+ "write_coil": "coils",
+ "write_coils": "coils",
+ "write_register": "holding_registers",
+ "write_registers": "holding_registers",
+}
+
+
+class DataState:
+ """Track last successful reads and attempted writes per raw table/address."""
+
+ def __init__(self, label="No session"):
+ self.label = label
+ self.rows = {}
+ self.serial = 0
+ self.identity = {}
+ self.identity_at = ""
+
+ def read(self, table, address, values):
+ """Record a complete validated response as one coherent observation."""
+ self.serial += 1
+ when = timestamp()
+ for offset, value in enumerate(values):
+ self.rows.setdefault((table, address + offset), {}).update(
+ read=int(value), read_at=when, request=self.serial
+ )
+
+ def write(self, table, address, values, outcome):
+ """Record attempted/acknowledged/uncertain writes without inventing reads."""
+ self.serial += 1
+ when = timestamp()
+ for offset, value in enumerate(values):
+ self.rows.setdefault((table, address + offset), {}).update(
+ write_request=self.serial,
+ write=int(value),
+ write_at=when,
+ outcome=outcome,
+ )
+
+ def identification(self, information):
+ """Keep the last observed identification objects, including continued pages."""
+ self.identity.update(
+ {int(key): repr(value) for key, value in information.items()}
+ )
+ self.identity_at = timestamp()
+
+ def record(self, direction, item):
+ """Consume existing decoded operation records for the client-only view."""
+ operation = item.get("operation")
+ if direction == "received" and operation == "read_device_information":
+ self.identification(item.get("information", {}))
+ if direction == "received" and operation in READ_TABLES and "values" in item:
+ self.read(READ_TABLES[operation], item["address"], item["values"])
+ if operation in WRITE_TABLES and "values" in item:
+ outcome = (
+ "acknowledged; no readback"
+ if item.get("acknowledged")
+ else (
+ ("uncertain" if item.get("sent", True) else "not sent")
+ if direction == "error"
+ else "attempted"
+ )
+ )
+ self.write(
+ WRITE_TABLES[operation], item["address"], item["values"], outcome
+ )
+
+ def show(self, tags):
+ """Render tags, partial accesses, raw evidence, and explicit unknown values."""
+ lines = [
+ self.label,
+ "Values are last observations, not a live device snapshot.",
+ ]
+ if self.identity:
+ lines.append(
+ f"Device identification observed at {self.identity_at}: {self.identity}"
+ )
+ tag_rows = []
+ for tag in tags:
+ items = [
+ self.rows.get((tag.table, address), {})
+ for address in range(tag.address, tag.stop)
+ ]
+ complete = (
+ all("read" in item for item in items)
+ and len({item.get("request") for item in items}) == 1
+ )
+ value = "unknown" if not any(items) else "partial / separate reads"
+ if complete:
+ try:
+ value = repr(
+ decode_tag_value(tag, [item["read"] for item in items])
+ )
+ except (ValueError, OverflowError):
+ value = "undecodable"
+ tag_rows.append(
+ (tag.name, tag.table, f"{tag.address}-{tag.stop - 1}", value)
+ )
+ if tag_rows:
+ lines.append(
+ tabulate(tag_rows, headers=("Tag", "Table", "Address", "Read value"))
+ )
+ raw = [
+ (
+ table,
+ address,
+ item.get("read", "unknown"),
+ item.get("read_at", "—"),
+ item.get("write", "—"),
+ item.get("outcome", "—"),
+ item.get("write_at", "—"),
+ (
+ ("matches" if item["read"] == item["write"] else "differs")
+ if "write" in item
+ and "read" in item
+ and item.get("request", 0) > item.get("write_request", 0)
+ else "not read back"
+ ),
+ )
+ for (table, address), item in sorted(self.rows.items())
+ ]
+ lines.append(
+ tabulate(
+ raw,
+ headers=(
+ "Table",
+ "Address",
+ "Last read",
+ "Read UTC",
+ "Last write",
+ "Write outcome",
+ "Write UTC",
+ "Readback",
+ ),
+ )
+ if raw
+ else "No observed addresses."
+ )
+ return "\n".join(lines)
diff --git a/src/ctmodbus/discovery.py b/src/ctmodbus/discovery.py
new file mode 100644
index 0000000..0391b1f
--- /dev/null
+++ b/src/ctmodbus/discovery.py
@@ -0,0 +1,78 @@
+"""Advisory local connection discovery and serial completion metadata.
+
+Enumeration and process inspection are blocking. Async callers offload these
+functions; discovered endpoints are suggestions, never an input allowlist.
+"""
+
+import asyncio
+
+import psutil
+from ctui import CompletionItem
+from serial.tools.list_ports import comports
+from tabulate import tabulate
+
+
+def serial_devices():
+ """Return pyserial port objects sorted by device path; OS errors propagate.
+
+ Manufacturer/product metadata may be absent for non-USB or unsupported ports.
+ This synchronous enumeration belongs on a worker thread for async callers.
+ """
+ return sorted(comports(), key=lambda item: item.device)
+
+
+async def complete_serial(_context):
+ """Return ctui CompletionItems after offloading port enumeration.
+
+ Ignore the completion context. Insert only the device path; show available
+ manufacturer/product as help, or a differing port description as fallback.
+ Return an empty list when no ports exist; enumeration errors propagate.
+ """
+ results = []
+ for item in await asyncio.to_thread(serial_devices):
+ details = [value for value in (item.manufacturer, item.product) if value]
+ if not details and item.description and item.description != item.device:
+ details.append(item.description)
+ results.append(CompletionItem(item.device, " — ".join(details)))
+ return results
+
+
+def suggestions():
+ """Return serial-device and local-listener tables as plain text.
+
+ Synchronous enumeration errors propagate. Process/network inspection is
+ best effort: inaccessible process names become unavailable and global
+ inspection errors become a diagnostic section rather than failing discovery.
+ """
+ devices = [
+ [item.device, item.manufacturer or "", item.product or ""]
+ for item in serial_devices()
+ ]
+ sections = [
+ "Connected serial devices",
+ tabulate(devices, headers=["Device", "Manufacturer", "Product"]),
+ ]
+ rows = []
+ try:
+ for conn in psutil.net_connections(kind="inet"):
+ if conn.status != psutil.CONN_LISTEN:
+ continue
+ process = ""
+ if conn.pid:
+ try:
+ process = psutil.Process(conn.pid).name()
+ except (psutil.Error, OSError):
+ process = "unavailable"
+ rows.append([conn.laddr.ip, conn.laddr.port, process])
+ sections.extend(
+ [
+ "Local listening services",
+ tabulate(
+ sorted(rows, key=lambda row: (row[1], row[0])),
+ headers=["IP", "Port", "Process"],
+ ),
+ ]
+ )
+ except (psutil.Error, OSError) as error:
+ sections.append(f"Local listening services unavailable: {error}")
+ return "\n\n".join(sections)
diff --git a/src/ctmodbus/formatting.py b/src/ctmodbus/formatting.py
new file mode 100644
index 0000000..0f6892b
--- /dev/null
+++ b/src/ctmodbus/formatting.py
@@ -0,0 +1,76 @@
+"""Pure formatting for identical TUI and command-line results.
+
+Return strings without changing device/project state. Callers provide validated
+wire values; rendering escapes control characters and uses explicit UTC times.
+"""
+
+from datetime import datetime, timezone
+from itertools import groupby
+
+from tabulate import tabulate
+
+
+def timestamp():
+ """Return an unambiguous UTC timestamp for protocol output."""
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
+
+
+def format_values(kind, values):
+ """Return a table of ordered (address, value) pairs for a canonical table.
+
+ Collapse adjacent equal values without hiding gaps, repeats, or input order.
+ Bit tables show 0/1; registers add hex and one Unicode code point (not UTF-8
+ decoding). Escape nonprintable characters. Caller validates wire ranges;
+ malformed pairs or values can raise ordinary Python conversion errors.
+ """
+ bits = kind in ("coils", "discrete_inputs")
+ rows = []
+ for _, group in groupby(
+ enumerate(values), key=lambda pair: (pair[1][0] - pair[0], pair[1][1])
+ ):
+ items = [item for _, item in group]
+ start, value = items[0]
+ stop = items[-1][0]
+ address = str(start) if start == stop else f"{start}-{stop}"
+ row = [address, int(value)]
+ if not bits:
+ # This is a code point view, not a claim that one register is UTF-8.
+ char = chr(value)
+ row.extend(
+ [f"{value:04X}", ascii(char)[1:-1] if not char.isprintable() else char]
+ )
+ rows.append(row)
+ return tabulate(
+ rows,
+ headers=(
+ ["Address", "Bit"] if bits else ["Address", "Integer", "Hex", "Character"]
+ ),
+ )
+
+
+def format_identification(information):
+ """Return an ID-sorted table from a validated object-ID/value mapping.
+
+ Label standard IDs 0–6 and retain sparse vendor IDs. Decode byte strings
+ using UTF-8 with escaped invalid bytes and repr all values to escape
+ terminal controls. Perform no protocol validation or I/O.
+ """
+ names = (
+ "VendorName",
+ "ProductCode",
+ "MajorMinorRevision",
+ "VendorUrl",
+ "ProductName",
+ "ModelName",
+ "UserApplicationName",
+ )
+ rows = []
+ for object_id, value in sorted(information.items()):
+ label = (
+ names[object_id] if 0 <= object_id < len(names) else f"ObjectID {object_id}"
+ )
+ if isinstance(value, bytes):
+ value = value.decode("utf-8", errors="backslashreplace")
+ # repr escapes device-provided terminal control sequences.
+ rows.append([object_id, label, repr(str(value))])
+ return tabulate(rows, headers=["ID", "Object", "Value"])
diff --git a/src/ctmodbus/integers.py b/src/ctmodbus/integers.py
new file mode 100644
index 0000000..b9de5c6
--- /dev/null
+++ b/src/ctmodbus/integers.py
@@ -0,0 +1,24 @@
+"""Shared integer literal syntax for tag values and register writes."""
+
+import re
+
+INTEGER_PATTERN = re.compile(
+ r"(?P[+-]?)(?:(?P0[bBoOdDxX])(?P[0-9A-Fa-f_]+)|"
+ r"(?P[0-9][0-9_]*))\Z"
+)
+
+
+def parse_integer(text: str) -> int:
+ """Parse decimal or 0b/0o/0d/0x text with an optional sign and underscores.
+
+ Malformed text or digits invalid for the radix raise ValueError. This parser
+ imposes no width or range; callers handle bounds and two's-complement bits.
+ """
+ match = INTEGER_PATTERN.fullmatch(text)
+ if not match:
+ raise ValueError("expected an integer or 0b/0o/0d/0x literal")
+ digits = (match.group("digits") or match.group("decimal")).replace("_", "")
+ prefix = (match.group("prefix") or "0d").lower()
+ base = {"0b": 2, "0o": 8, "0d": 10, "0x": 16}[prefix]
+ value = int(digits, base)
+ return -value if match.group("sign") == "-" else value
diff --git a/src/ctmodbus/operations.py b/src/ctmodbus/operations.py
new file mode 100644
index 0000000..d52dfaa
--- /dev/null
+++ b/src/ctmodbus/operations.py
@@ -0,0 +1,403 @@
+"""Typed Modbus commands, validation, and response handling.
+
+Wire addresses are zero-based and inclusive at the CLI. Reads retain completed
+chunks on error; writes are one request and an acknowledgement is not readback.
+The application supplies connection, records, and progress-event services.
+"""
+
+import asyncio
+
+from ctui import (
+ Argument,
+ CommandError,
+ CommandResult,
+ IntegerRanges,
+ IntegerSpan,
+ command,
+)
+
+from ctmodbus.formatting import format_identification, format_values, timestamp
+from ctmodbus.integers import parse_integer
+
+READ_LIMITS = {
+ "coils": 2000,
+ "discrete_inputs": 2000,
+ "input_registers": 125,
+ "holding_registers": 125,
+}
+READ_ARGUMENTS = {"max_count": Argument(flags=("--max-count",))}
+
+
+def read_chunks(addresses, max_count, limit):
+ """Yield IntegerSpan chunks after validating all inclusive input ranges.
+
+ max_count must be 1..limit; addresses must fit 0..65535 with at most 65536
+ entries including repeats. Preserve order and duplicates; do not merge
+ ranges. CommandError is raised when the generator is consumed, so callers
+ materialize it before sending any requests.
+ """
+ if not 1 <= max_count <= limit:
+ raise CommandError(f"max-count must be between 1 and {limit}")
+ if any(span.stop > 65536 for span in addresses):
+ raise CommandError("Addresses must be between 0 and 65535")
+ if addresses.count > 65536:
+ raise CommandError(
+ "A read may contain at most 65536 addresses, including repeats"
+ )
+ for span in addresses:
+ for start in range(span.start, span.stop, max_count):
+ yield IntegerSpan(start, min(max_count, span.stop - start))
+
+
+def validate_write(kind, address, values):
+ """Return None for a valid coil/register write, otherwise raise CommandError.
+
+ Caller supplies coils or holding_registers. Require exact integers, 0/1
+ coils or 0..65535 registers, a nonempty list limited to 1968 coils or 123
+ registers, and an address span entirely inside 0..65535. Never split writes.
+ """
+ limit = 1968 if kind == "coils" else 123
+ maximum = 1 if kind == "coils" else 65535
+ if not values or len(values) > limit:
+ raise CommandError(f"Provide between 1 and {limit} values")
+ if not 0 <= address <= 65535 or address + len(values) > 65536:
+ raise CommandError("Write addresses must be between 0 and 65535")
+ if any(type(value) is not int or not 0 <= value <= maximum for value in values):
+ raise CommandError(f"{kind} values must be integers between 0 and {maximum}")
+
+
+def parse_register_value(text: str) -> int:
+ """Return one wire word from signed int16 or unsigned uint16 literal text.
+
+ Accept the shared tag integer syntax, stripping surrounding whitespace for
+ comma-separated lists. Reject malformed literals and values outside
+ -32768..65535 with CommandError; encode negatives as 16-bit two's complement.
+ """
+ try:
+ value = parse_integer(text.strip())
+ except ValueError as error:
+ raise CommandError(f"Invalid register value {text!r}: {error}") from error
+ if not -32768 <= value <= 65535:
+ raise CommandError(f"Register value {text!r} must be between -32768 and 65535")
+ return value & 0xFFFF
+
+
+class ModbusCommandMixin:
+ """Commands mixed into ModbusApp; connection and services belong to the app.
+
+ Read wrappers accept ctui IntegerRanges (inclusive comma-separated CLI
+ spans) and return read_values' append result or propagate its CommandError.
+ Bit reads allow 2000 values/request, register reads 125. Write wrappers take
+ one start address and a comma-separated integer list; write_values defines
+ their limits, acknowledgement semantics, and failure contracts.
+ """
+
+ async def record_operation(self, direction, decoded):
+ """Return None by default; the application overrides this recording hook."""
+
+ async def read_values_data(self, kind, addresses, max_count):
+ """Return ordered (address, value) pairs for a canonical read table.
+
+ Validate read_chunks before I/O and reserve one connection across all
+ chunks. Check function, length, and value bounds; trim bit padding only.
+ Record requests/replies and emit per-chunk progress, reset on exit.
+ CommandError includes completed raw values; cancellation aborts the
+ transport and becomes CommandError with the same partial results.
+ """
+ chunks = list(read_chunks(addresses, max_count, READ_LIMITS[kind]))
+ try:
+ async with self.connection.operation() as (client, settings):
+ return await self.read_reserved_chunks(kind, chunks, client, settings)
+ except asyncio.CancelledError as error:
+ self.connection.abort()
+ raise CommandError("Read cancelled; connection closed") from error
+
+ async def read_reserved_chunks(self, kind, chunks, client, settings):
+ """Read validated chunks inside the caller's connection reservation.
+
+ Never acquire the connection lock here. Preserve completed raw values on
+ protocol failure; cancellation closes the transport. Record exchanges
+ and emit/reset chunk progress just as for ordinary range reads.
+ """
+ results = []
+ try:
+ for index, span in enumerate(chunks):
+ request = {
+ "operation": f"read_{kind}",
+ "address": span.start,
+ "count": span.count,
+ "unit": settings.unit,
+ }
+ await self.record_operation("sent", request)
+ response = await self.connection.request(
+ getattr(client, f"read_{kind}"),
+ address=span.start,
+ count=span.count,
+ )
+ expected_function = {
+ "coils": 1,
+ "discrete_inputs": 2,
+ "holding_registers": 3,
+ "input_registers": 4,
+ }[kind]
+ if getattr(response, "function_code", None) != expected_function:
+ raise CommandError("Read response has incorrect function")
+ attr = "bits" if kind in ("coils", "discrete_inputs") else "registers"
+ values = getattr(response, attr, None)
+ if (
+ values is None
+ or len(values) < span.count
+ or (attr == "registers" and len(values) != span.count)
+ ):
+ raise CommandError(
+ f"Invalid {kind} response length at address {span.start}"
+ )
+ values = values[: span.count] # Coil responses are padded to a byte.
+ if any(
+ not isinstance(value, (int, bool))
+ or not 0 <= value <= (1 if attr == "bits" else 65535)
+ for value in values
+ ):
+ raise CommandError(f"Invalid {kind} response values")
+ results.extend(zip(range(span.start, span.stop), values))
+ await self.record_operation(
+ "received", {**request, "values": list(values)}
+ )
+ await self.events.emit(
+ "modbus_progress", completed=index + 1, total=len(chunks)
+ )
+ except asyncio.CancelledError as error:
+ self.connection.abort()
+ await self.record_operation(
+ "error",
+ {
+ "operation": f"read_{kind}",
+ "error": "cancelled",
+ "completed_addresses": len(results),
+ },
+ )
+ raise CommandError(
+ self.partial_read(kind, results, "Read cancelled; connection closed")
+ ) from error
+ except CommandError as error:
+ await self.record_operation(
+ "error",
+ {
+ "operation": f"read_{kind}",
+ "error": str(error),
+ "completed_addresses": len(results),
+ },
+ )
+ raise CommandError(self.partial_read(kind, results, str(error))) from error
+ finally:
+ await self.events.emit("modbus_progress", completed=0, total=0)
+ return results
+
+ async def read_values(self, kind, addresses, max_count):
+ """Return an append CommandResult using read_values_data and UTC time.
+
+ Propagate its validation, protocol, and partial-read CommandError.
+ """
+ results = await self.read_values_data(kind, addresses, max_count)
+ return CommandResult.append(
+ f"{timestamp()} Read {kind}\n{format_values(kind, results)}"
+ )
+
+ @staticmethod
+ def partial_read(kind, results, message):
+ """Return message plus formatted completed pairs, or message if empty."""
+ if results:
+ return (
+ f"{message}\nPartial read: {len(results)} addresses completed\n"
+ f"{format_values(kind, results)}"
+ )
+ return message
+
+ @command(name="read coils", arguments=READ_ARGUMENTS)
+ async def read_coils(self, addresses: IntegerRanges, max_count: int = 2000):
+ """Read coils from inclusive comma-separated addresses/ranges."""
+ return await self.read_values("coils", addresses, max_count)
+
+ @command(name="read discrete_inputs", arguments=READ_ARGUMENTS)
+ async def read_discrete_inputs(
+ self, addresses: IntegerRanges, max_count: int = 2000
+ ):
+ """Read discrete inputs from inclusive addresses/ranges."""
+ return await self.read_values("discrete_inputs", addresses, max_count)
+
+ @command(name="read input_registers", arguments=READ_ARGUMENTS)
+ async def read_input_registers(
+ self, addresses: IntegerRanges, max_count: int = 125
+ ):
+ """Read input registers from inclusive addresses/ranges."""
+ return await self.read_values("input_registers", addresses, max_count)
+
+ @command(name="read holding_registers", arguments=READ_ARGUMENTS)
+ async def read_holding_registers(
+ self, addresses: IntegerRanges, max_count: int = 125
+ ):
+ """Read holding registers from inclusive addresses/ranges."""
+ return await self.read_values("holding_registers", addresses, max_count)
+
+ @command(name="read id")
+ async def read_id(self):
+ """Return appended identification text from ``read id``.
+
+ Hold one operation reservation across continued pages. Permit sparse
+ object IDs but reject empty/invalid pages and continuation cycles with
+ CommandError. Cancellation closes the connection. Unlike range reads,
+ failures do not render identification objects collected before failure.
+ """
+ information, seen, object_id = {}, set(), 0
+ try:
+ async with self.connection.operation() as (client, settings):
+ while True:
+ if object_id in seen or not 0 <= object_id <= 255:
+ raise CommandError("Invalid device identification continuation")
+ seen.add(object_id)
+ request = {
+ "operation": "read_device_information",
+ "object_id": object_id,
+ "unit": settings.unit,
+ }
+ await self.record_operation("sent", request)
+ response = await self.connection.request(
+ client.read_device_information, read_code=1, object_id=object_id
+ )
+ page = getattr(response, "information", None)
+ if not isinstance(page, dict) or not page:
+ raise CommandError("Device returned no identification objects")
+ if any(
+ type(key) is not int
+ or not 0 <= key <= 255
+ or not isinstance(value, (bytes, str))
+ for key, value in page.items()
+ ):
+ raise CommandError("Invalid device identification objects")
+ information.update(page)
+ await self.record_operation(
+ "received",
+ {
+ **request,
+ "information": {
+ str(key): repr(value) for key, value in page.items()
+ },
+ },
+ )
+ if not getattr(response, "more_follows", False):
+ break
+ object_id = response.next_object_id
+ except asyncio.CancelledError as error:
+ self.connection.abort()
+ await self.record_operation(
+ "error", {"operation": "read_device_information", "error": "cancelled"}
+ )
+ raise CommandError(
+ "Identification read cancelled; connection closed"
+ ) from error
+ except CommandError as error:
+ await self.record_operation(
+ "error", {"operation": "read_device_information", "error": str(error)}
+ )
+ raise
+ return CommandResult.append(
+ f"{timestamp()} Device identification\n{format_identification(information)}"
+ )
+
+ async def write_values(self, kind, address, values):
+ """Return an appended acknowledgement for one validated write request.
+
+ One value selects a single-write function; multiple values select one
+ multi-write. Validate function/address and echoed value or count.
+ CommandError after submission marks the outcome unconfirmed; a matching
+ reply is not readback. Cancellation aborts the transport and states
+ whether submission began. Record attempted and acknowledged values.
+ """
+ validate_write(kind, address, values)
+ multiple = len(values) > 1
+ suffix = "coils" if kind == "coils" else "registers"
+ method_name = f"write_{suffix if multiple else suffix[:-1]}"
+ request = {"operation": method_name, "address": address, "values": values}
+ sent = False
+ try:
+ async with self.connection.operation() as (client, settings):
+ request["unit"] = settings.unit
+ await self.record_operation("sent", request)
+ wire_values = (
+ [bool(value) for value in values] if kind == "coils" else values
+ )
+ args = (
+ {"values": wire_values} if multiple else {"value": wire_values[0]}
+ )
+ sent = True
+ response = await self.connection.request(
+ getattr(client, method_name), address=address, **args
+ )
+ expected_function = (
+ (15 if multiple else 5)
+ if kind == "coils"
+ else (16 if multiple else 6)
+ )
+ if (
+ getattr(response, "function_code", None) != expected_function
+ or getattr(response, "address", None) != address
+ ):
+ raise CommandError(
+ "Write acknowledgement has incorrect function or address"
+ )
+ if multiple:
+ if getattr(response, "count", None) != len(values):
+ raise CommandError("Write acknowledgement has incorrect count")
+ else:
+ echoed = getattr(
+ response, "bits" if kind == "coils" else "registers", None
+ )
+ if echoed != wire_values:
+ raise CommandError("Write acknowledgement has incorrect value")
+ await self.record_operation(
+ "received", {**request, "acknowledged": True}
+ )
+ except asyncio.CancelledError as error:
+ self.connection.abort()
+ message = "Write cancelled; connection closed. " + (
+ "The device may have applied the write; outcome unknown."
+ if sent
+ else "No write was sent."
+ )
+ await self.record_operation(
+ "error", {**request, "error": message, "sent": sent}
+ )
+ raise CommandError(message) from error
+ except CommandError as error:
+ message = str(error) + ("; write outcome unconfirmed" if sent else "")
+ await self.record_operation(
+ "error", {**request, "error": message, "sent": sent}
+ )
+ raise CommandError(message) from error
+ return CommandResult.append(
+ f"{timestamp()} Write acknowledged: {kind}\n"
+ f"{format_values(kind, list(enumerate(values, address)))}"
+ )
+
+ @command(name="write coils")
+ async def write_coils(self, address: int, values: list[int]):
+ """Write one or more coils as comma-separated 0/1 values."""
+ return await self.write_values("coils", address, values)
+
+ @command(
+ name="write holding_registers",
+ arguments={
+ "address": Argument(help="First zero-based register address"),
+ "values": Argument(help="Comma-separated 16-bit decimal or radix values"),
+ },
+ )
+ async def write_holding_registers(self, address: int, values: list[str]):
+ """Write 1–123 consecutive registers from decimal or 0b/0o/0d/0x values.
+
+ Each value occupies one register. Accept -32768..65535, encoding negative
+ values as two's complement; use ``--`` before a negative positional list.
+ Parse the entire list before the validated write path sends one request.
+ Acknowledgements and records contain the unsigned wire words.
+ """
+ encoded = [parse_register_value(value) for value in values]
+ return await self.write_values("holding_registers", address, encoded)
diff --git a/src/ctmodbus/polling.py b/src/ctmodbus/polling.py
new file mode 100644
index 0000000..c9f5161
--- /dev/null
+++ b/src/ctmodbus/polling.py
@@ -0,0 +1,426 @@
+"""Fixed-cadence, application-owned polling with serialized read evidence.
+
+Polls snapshot targets and the connection generation. Busy ticks are discarded,
+never queued; one cycle reserves the connection across its ordered columns.
+Runtime state is not persisted. Background output uses the shared ctui layout.
+"""
+
+import asyncio
+import math
+from dataclasses import dataclass
+
+from ctui import (
+ Argument,
+ CommandError,
+ CommandResult,
+ IntegerRanges,
+ IntegerSpan,
+ command,
+)
+
+from ctmodbus.operations import READ_LIMITS, read_chunks
+from ctmodbus.tags import Tag, complete_tags, decode_tag_value
+
+LIMIT_ARGUMENTS = {
+ name: Argument(flags=(f"--{name}",)) for name in ("interval", "count", "duration")
+}
+TABLE_ARGUMENTS = {
+ name: Argument(flags=(f"--{name.replace('_', '-')}",)) for name in READ_LIMITS
+}
+
+
+@dataclass(frozen=True)
+class PollColumn:
+ """Immutable display column with validated chunks and an optional tag codec."""
+
+ label: str
+ table: str
+ chunks: tuple[IntegerSpan, ...]
+ tag: Tag | None = None
+
+
+class PollTable:
+ """Format append-only rows with stable widths and a header on expansion.
+
+ Reserve integer type bounds, float precision, full raw sequences, and ERR.
+ Numeric tags/counters align right; booleans/raw sequences align left.
+ Floats use 9 significant digits for float32 and 17 for float64, retaining
+ round-trip precision with compact scientific notation when appropriate.
+ Unlimited counters start at six characters; bounded counters use at least
+ three. Overflow widens future rows and repeats the header without rewriting
+ previously appended output. Labels and values supplied here are safe ASCII.
+ """
+
+ def __init__(self, columns: tuple[PollColumn, ...], count: int | None):
+ """Calculate initial widths from the target plan and optional cycle limit."""
+ self.labels = ["#", *(column.label for column in columns)]
+ self.right = [
+ True,
+ *(
+ column.tag is not None and column.tag.type != "bool"
+ for column in columns
+ ),
+ ]
+ self.widths = [max(3, len(str(count))) if count is not None else 6]
+ for column in columns:
+ if column.tag is None:
+ size = sum(chunk.count for chunk in column.chunks)
+ width = (
+ size
+ if column.table in ("coils", "discrete_inputs")
+ else size * 5 - 1
+ )
+ elif column.tag.type == "bool":
+ width = 5
+ elif column.tag.type.startswith("float"):
+ width = 17 if column.tag.type == "float32" else 25
+ else:
+ signed = column.tag.type.startswith("int")
+ bits = int(column.tag.type.removeprefix("uint").removeprefix("int"))
+ width = max(
+ len(str(-(1 << (bits - 1)))) if signed else 0,
+ len(str((1 << (bits - int(signed))) - 1)),
+ )
+ self.widths.append(max(3, len(column.label), width))
+
+ def format(self, cells: list[str]) -> str:
+ """Return one padded line with a separator between every pair of columns."""
+ return " | ".join(
+ value.rjust(width) if right else value.ljust(width)
+ for value, width, right in zip(cells, self.widths, self.right)
+ ).rstrip()
+
+ def header(self) -> str:
+ """Return the current padded header, headed by #."""
+ return self.format(self.labels)
+
+ def row(self, number: int, values: list[str]) -> list[str]:
+ """Return a row, preceded by a new header if any cell requires expansion."""
+ cells = [str(number), *values]
+ widths = [max(width, len(value)) for width, value in zip(self.widths, cells)]
+ changed = widths != self.widths
+ self.widths = widths
+ return ([self.header()] if changed else []) + [self.format(cells)]
+
+ @staticmethod
+ def tag_value(tag: Tag, value: bool | int | float) -> str:
+ """Render a decoded scalar compactly without changing recorded raw evidence."""
+ if tag.type.startswith("float"):
+ text = format(value, ".9g" if tag.type == "float32" else ".17g")
+ if math.isfinite(value) and "." not in text and "e" not in text:
+ text += ".0"
+ return text
+ return repr(value)
+
+
+class Poll: # pylint: disable=protected-access
+ """Own a timer and at most one cycle; stop gracefully or abort during shutdown.
+
+ Deadlines use loop.time(), beginning immediately. Count limits started
+ cycles; duration bounds cycle starts, allowing an active cycle to finish.
+ Errors stay local to columns while the transport remains usable. Status is
+ retained after completion. All requests use existing validated read helpers.
+ """
+
+ def __init__(self, app):
+ """Initialize idle runtime state without creating tasks or device I/O."""
+ self.app = app
+ self.task = None
+ self.cycle = None
+ self.stop_event = asyncio.Event()
+ self.columns = ()
+ self.table = PollTable((), None)
+ self.interval = 1.0
+ self.count = self.duration = None
+ self.started = self.completed = self.skipped = self.failed_columns = 0
+ self.last_duration = 0.0
+ self.reason = "No poll started"
+ self.generation = None
+
+ @property
+ def active(self):
+ """Whether the timer or its final draining cycle is still running."""
+ return self.task is not None and not self.task.done()
+
+ def append(self, text):
+ """Append one safe line/header to TUI/WUI output or the CLI stream."""
+ self.app.append_output(text)
+
+ async def start(self, columns, interval, count, duration):
+ """Validate limits/session; reject replacement and empty targets.
+
+ CLI callers wait for completion and stream rows; TUI/WUI return at once.
+ No await occurs between validation and task ownership assignment.
+ """
+ if self.active:
+ raise CommandError("A poll is already running; use poll stop first")
+ if not columns:
+ raise CommandError("Provide polling targets; no tags or tables selected")
+ for name, value in (("interval", interval), ("duration", duration)):
+ if value is not None and (
+ isinstance(value, bool) or not math.isfinite(value) or value <= 0
+ ):
+ raise CommandError(f"{name} must be finite and greater than zero")
+ if count is not None and (type(count) is not int or count < 1):
+ raise CommandError("count must be a positive integer")
+ if not self.app.connection.connected:
+ raise CommandError("No connected session; use client start first")
+ self.columns = tuple(columns)
+ self.table = PollTable(self.columns, count)
+ self.interval, self.count, self.duration = interval, count, duration
+ self.generation = self.app.connection.generation
+ self.started = self.completed = self.skipped = self.failed_columns = 0
+ self.last_duration = 0.0
+ self.reason = "Running"
+ self.stop_event = asyncio.Event()
+ self.task = asyncio.create_task(self.run(), name="modbus-poll")
+ if self.app._poll_stdout is not None:
+ await self.task
+ return CommandResult.success()
+
+ async def run(self): # pylint: disable=too-many-branches,too-many-statements
+ """Schedule fixed deadlines, skip busy/late ticks, and drain the last cycle."""
+ loop = asyncio.get_running_loop()
+ origin = loop.time()
+ tick = 0
+ self.append(self.table.header())
+ try:
+ while not self.stop_event.is_set():
+ if self.count is not None and self.started >= self.count:
+ self.reason = "Count limit reached"
+ break
+ now = loop.time()
+ deadline = origin + tick * self.interval
+ if self.duration is not None and deadline >= origin + self.duration:
+ self.reason = "Duration limit reached"
+ break
+ delay = max(0, deadline - now)
+ try:
+ await asyncio.wait_for(self.stop_event.wait(), delay)
+ break
+ except TimeoutError:
+ pass
+ if self.stop_event.is_set():
+ break
+ now = loop.time()
+ # Event-loop delays discard obsolete deadlines without catch-up.
+ overdue = max(0, int((now - deadline) / self.interval))
+ self.skipped += overdue
+ tick += overdue
+ if self.duration is not None and now >= origin + self.duration:
+ self.reason = "Duration limit reached"
+ break
+ connection = self.app.connection
+ if connection.generation != self.generation or not connection.connected:
+ self.reason = "Connection lost or replaced"
+ break
+ if (
+ self.cycle is not None and not self.cycle.done()
+ ) or connection.lock.locked():
+ self.skipped += 1
+ else:
+ if self.cycle is not None:
+ await self.cycle
+ self.started += 1
+ self.cycle = asyncio.create_task(self.read_cycle(self.started))
+ # Let the cycle acquire or decline the reservation before
+ # evaluating a count limit. No device response is awaited.
+ await asyncio.sleep(0)
+ tick += 1
+ if self.cycle is not None:
+ await self.cycle
+ except asyncio.CancelledError:
+ self.reason = "Cancelled; connection closed"
+ if self.cycle is not None and not self.cycle.done():
+ self.cycle.cancel()
+ await asyncio.gather(self.cycle, return_exceptions=True)
+ raise
+ except Exception as error: # pylint: disable=broad-exception-caught
+ self.reason = f"Poll failed: {error}"
+ finally:
+ if self.cycle is not None and not self.cycle.done():
+ self.cycle.cancel()
+ await asyncio.gather(self.cycle, return_exceptions=True)
+ self.append(f"Poll stopped: {self.reason}")
+
+ async def read_cycle( # pylint: disable=too-many-branches,too-many-statements
+ self, number
+ ):
+ """Append one ordered row, retaining confirmed raw prefixes on column errors."""
+ # A manual operation may acquire the lock between timer and task turns.
+ # Decline that tick rather than enqueue behind it.
+ if self.app.connection.lock.locked():
+ self.started -= 1
+ self.skipped += 1
+ return
+ started = asyncio.get_running_loop().time()
+ values = []
+ try:
+ async with self.app.connection.operation() as (client, settings):
+ if self.app.connection.generation != self.generation:
+ raise CommandError("Polling session replaced")
+ for column in self.columns:
+ raw = []
+ rendered = "ERR"
+ failed = False
+ if not self.app.connection.connected:
+ failed = True
+ else:
+ try:
+ for chunk in column.chunks:
+ pairs = await self.app.read_reserved_chunks(
+ column.table, [chunk], client, settings
+ )
+ raw.extend(value for _, value in pairs)
+ if column.tag:
+ try:
+ rendered = self.table.tag_value(
+ column.tag, decode_tag_value(column.tag, raw)
+ )
+ except CommandError as error:
+ await self.app.record_operation(
+ "error",
+ {
+ "operation": "poll_decode",
+ "tag": column.tag.name,
+ "error": str(error),
+ "cycle": number,
+ },
+ )
+ raise
+ except CommandError:
+ failed = True
+ if column.tag:
+ value = "ERR" if failed else rendered
+ else:
+ value = (
+ "".join(str(int(item)) for item in raw)
+ if column.table in ("coils", "discrete_inputs")
+ else " ".join(f"{int(item):04X}" for item in raw)
+ )
+ if failed:
+ value = (value + " ERR").strip()
+ values.append(value)
+ self.failed_columns += int(failed)
+ if not self.app.connection.connected:
+ self.reason = "Connection lost"
+ self.stop_event.set()
+ except (CommandError, asyncio.CancelledError) as error:
+ missing = len(self.columns) - len(values)
+ values.extend(["ERR"] * missing)
+ self.failed_columns += missing
+ self.reason = "Connection lost or cancelled"
+ self.stop_event.set()
+ if isinstance(error, asyncio.CancelledError):
+ self.app.connection.abort()
+ finally:
+ self.last_duration = asyncio.get_running_loop().time() - started
+ self.completed += 1
+ for line in self.table.row(number, values):
+ self.append(line)
+ warnings = self.app._record_warnings.pop(asyncio.current_task(), [])
+ if warnings:
+ self.append("Recording warning: " + "; ".join(dict.fromkeys(warnings)))
+
+ async def stop(self, *, abort=False):
+ """Stop future ticks; optionally cancel active I/O for connection shutdown."""
+ if not self.active:
+ return
+ self.reason = "Stopped by user"
+ self.stop_event.set()
+ if abort:
+ self.task.cancel()
+ await asyncio.gather(self.task, return_exceptions=True)
+ else:
+ await self.task
+
+
+class PollCommandMixin:
+ """Typed polling commands shared by terminal, browser, and CLI dispatch."""
+
+ @command(
+ name="poll tags",
+ arguments={
+ **LIMIT_ARGUMENTS,
+ "names": Argument(
+ help="Comma-separated tag names; omit to poll all",
+ completer=complete_tags,
+ ),
+ },
+ )
+ async def poll_tags(
+ self,
+ names: list[str] | None = None,
+ interval: float = 1,
+ count: int | None = None,
+ duration: float | None = None,
+ ):
+ """Poll selected decoded tags in input order; default to all in name order."""
+ tags = (
+ [await self.tags.get(name) for name in names]
+ if names is not None
+ else await self.tags.list()
+ )
+ columns = [
+ PollColumn(tag.name, tag.table, (IntegerSpan(tag.address, tag.count),), tag)
+ for tag in tags
+ ]
+ return await self.poller.start(columns, interval, count, duration)
+
+ @command(name="poll raw", arguments={**LIMIT_ARGUMENTS, **TABLE_ARGUMENTS})
+ async def poll_raw(
+ self,
+ coils: IntegerRanges | None = None,
+ discrete_inputs: IntegerRanges | None = None,
+ input_registers: IntegerRanges | None = None,
+ holding_registers: IntegerRanges | None = None,
+ interval: float = 1,
+ count: int | None = None,
+ duration: float | None = None,
+ ):
+ """Poll each inclusive raw range as a column, respecting table-option order.
+
+ Validate the complete plan before starting; preserve overlaps/repetitions.
+ Direct Python callers use signature table order. Bit/register requests
+ split at their protocol limits and retain completed chunks on failure.
+ """
+ tables = {
+ "coils": coils,
+ "discrete_inputs": discrete_inputs,
+ "input_registers": input_registers,
+ "holding_registers": holding_registers,
+ }
+ order = self._poll_orders.get(asyncio.current_task(), list(tables))
+ columns = []
+ for table in order:
+ addresses = tables[table]
+ if addresses is None:
+ continue
+ limit = READ_LIMITS[table]
+ list(read_chunks(addresses, limit, limit))
+ for span in addresses:
+ chunks = tuple(read_chunks(IntegerRanges([span]), limit, limit))
+ columns.append(PollColumn(f"{table}:{span}", table, chunks))
+ return await self.poller.start(columns, interval, count, duration)
+
+ @command(name="poll status")
+ def poll_status(self):
+ """Show retained limits, targets, counts, errors, and last cycle duration."""
+ poll = self.poller
+ return CommandResult.append(
+ f"{'Running' if poll.active else 'Stopped'}: {poll.reason}\n"
+ f"interval={poll.interval:g}s, count={poll.count}, "
+ f"duration={poll.duration}\n"
+ f"started={poll.started}, completed={poll.completed}, "
+ f"skipped={poll.skipped}, "
+ f"failed_columns={poll.failed_columns}, "
+ f"last_duration={poll.last_duration:.3f}s\n"
+ + "Targets: "
+ + ", ".join(column.label for column in poll.columns)
+ )
+
+ @command(name="poll stop")
+ async def poll_stop(self):
+ """Stop scheduling, drain the current cycle, and retain the connection."""
+ await self.poller.stop()
+ return CommandResult.append("Polling is stopped.")
diff --git a/src/ctmodbus/proxy_commands.py b/src/ctmodbus/proxy_commands.py
new file mode 100644
index 0000000..45bdd6b
--- /dev/null
+++ b/src/ctmodbus/proxy_commands.py
@@ -0,0 +1,60 @@
+"""Plain proxy lifecycle and independent request display controls."""
+
+from typing import Literal
+
+from ctui import Argument, CommandError, CommandResult, command
+
+
+class ProxyCommandMixin:
+ """Route incoming server requests through the application-owned client."""
+
+ @command(name="proxy start", arguments={"quiet": Argument(flags=("--quiet",))})
+ async def proxy_start(self, quiet: bool = False):
+ """Forward server-unit requests to the connected unit; local rules are
+ bypassed.
+ """
+ if self.server.listener is None or not self.connection.connected:
+ raise CommandError("Proxy requires a running server and a connected client")
+ if self.connection.settings.retries:
+ raise CommandError("Proxy requires a client connection with --retries 0")
+ settings = self.connection.settings
+ if self.server.endpoint and settings.transport in ("tcp", "udp", "tls"):
+ target, port = self.server.endpoint
+ local_names = {"localhost", "127.0.0.1", "::1", "0.0.0.0", "::"}
+ if port == settings.port and (
+ target == settings.target
+ or (target in local_names and settings.target in local_names)
+ ):
+ raise CommandError("Proxy cannot forward to its own listener")
+ self.server.proxy = True
+ self.server.proxy_logging = not quiet
+ return "Proxy enabled. New requests use the upstream device."
+
+ @command(name="proxy stop")
+ async def proxy_stop(self):
+ """Return new requests to local behavior; already-routed exchanges finish
+ normally.
+ """
+ self.server.proxy = False
+ return "Proxy disabled. New requests use local simulation."
+
+ @command(name="proxy status")
+ async def proxy_status(self):
+ """Show forwarding mode and upstream connection readiness."""
+ mode = "enabled" if self.server.proxy else "disabled"
+ upstream = "connected" if self.connection.connected else "unavailable"
+ return (
+ f"Proxy: {mode}\nUpstream: {upstream}\n"
+ f"Logging: {'on' if self.server.proxy_logging else 'off'}"
+ )
+
+ @command(
+ name="proxy logging",
+ arguments={
+ "state": Argument(help="Enable or disable routine proxy forwarding lines")
+ },
+ )
+ def proxy_logging(self, state: Literal["on", "off"]):
+ """Change live forwarding verbosity independently of server arrival logging."""
+ self.server.proxy_logging = state == "on"
+ return CommandResult.append(f"Proxy request logging {state}.")
diff --git a/src/ctmodbus/result_popups.py b/src/ctmodbus/result_popups.py
new file mode 100644
index 0000000..51053eb
--- /dev/null
+++ b/src/ctmodbus/result_popups.py
@@ -0,0 +1,67 @@
+"""Standard ctui result dialogs for management commands, without output replacement.
+
+Actions complete before presentation. Await dismissal to sequence command input;
+background I/O and output appends continue. CLI and headless dispatch retain
+plain CommandResult output. Only application-owned commands are selected.
+"""
+
+from ctui.dialogs import MessageDialog, show_dialog
+
+ACTIONS = {
+ "start": "started",
+ "stop": "stopped",
+ "list": "list",
+ "show": "details",
+ "status": "status",
+ "import": "imported",
+ "export": "exported",
+ "discover": "discovery",
+ "validate": "validation",
+ "set": "updated",
+ "save": "saved",
+ "load": "loaded",
+ "create": "created",
+ "rename": "renamed",
+ "delete": "deleted",
+ "logging": "logging",
+ "reset": "reset",
+ "clear": "cleared",
+ "unit": "unit updated",
+ "seed": "seed updated",
+ "identity": "identity updated",
+ "table": "table updated",
+ "random": "random rule updated",
+ "sequence": "sequence rule updated",
+ "remove": "rule removed",
+ "read": "read hook updated",
+ "write": "write hook updated",
+ "tick": "tick hook updated",
+}
+
+
+def result_title(command_name: str) -> str | None:
+ """Return a descriptive title for an owned management command, else None."""
+ words = command_name.split()
+ if len(words) < 2:
+ return None
+ component = words[0]
+ if component not in {"client", "server", "proxy", "tags", "poll"}:
+ return None
+ if component == "poll" and words[1] not in {"status", "stop"}:
+ return None
+ subject = "Tags" if component == "tags" else component.capitalize()
+ action = words[1]
+ if action in {"config", "hook"}:
+ if len(words) < 3:
+ return None
+ subject += " configuration" if action == "config" else " hooks"
+ action = words[2]
+ description = ACTIONS.get(action)
+ return f"{subject} {description}" if description else None
+
+
+async def show_result(title: str, text: str) -> None:
+ """Await a standard scrollable dialog in the current terminal/browser view."""
+ await show_dialog(
+ MessageDialog(title=title, text=text, scrollbar=True, wrap_lines=False)
+ )
diff --git a/src/ctmodbus/server.py b/src/ctmodbus/server.py
new file mode 100644
index 0000000..56da076
--- /dev/null
+++ b/src/ctmodbus/server.py
@@ -0,0 +1,785 @@
+"""Owned async listeners and interchangeable emulator/proxy request routing.
+
+PyModbus owns decoding/framing. Our request handler captures each decoded PDU
+and downstream transaction before awaiting, avoiding mutable last-PDU state.
+Only the existing read/write/identification feature set is implemented; other
+functions return Illegal Function. No MITM rules are implemented here.
+"""
+
+import asyncio
+import copy
+import ssl
+import struct
+
+from ctui import CommandError
+from pymodbus import FramerType
+from pymodbus.constants import ExcCodes
+from pymodbus.exceptions import ModbusException, ModbusIOException
+from pymodbus.pdu import ExceptionResponse
+from pymodbus.pdu.bit_message import (
+ ReadCoilsRequest,
+ ReadCoilsResponse,
+ ReadDiscreteInputsRequest,
+ ReadDiscreteInputsResponse,
+ WriteMultipleCoilsRequest,
+ WriteMultipleCoilsResponse,
+ WriteSingleCoilRequest,
+ WriteSingleCoilResponse,
+)
+from pymodbus.pdu.mei_message import ReadDeviceInformationResponse
+from pymodbus.pdu.register_message import (
+ ReadHoldingRegistersRequest,
+ ReadHoldingRegistersResponse,
+ ReadInputRegistersRequest,
+ ReadInputRegistersResponse,
+ WriteMultipleRegistersResponse,
+ WriteSingleRegisterResponse,
+)
+from pymodbus.server import (
+ ModbusSerialServer,
+ ModbusTcpServer,
+ ModbusTlsServer,
+ ModbusUdpServer,
+)
+from pymodbus.server.requesthandler import ServerRequestHandler
+from pymodbus.simulator import DataType, SimData, SimDevice
+from pymodbus.transaction import TransactionManager
+
+from ctmodbus.data_state import DataState
+from ctmodbus.formatting import timestamp
+from ctmodbus.server_config import validate
+from ctmodbus.simulation import Simulator
+from ctmodbus.tags import decode_tag_value
+
+TABLES = {
+ 1: "coils",
+ 2: "discrete_inputs",
+ 3: "holding_registers",
+ 4: "input_registers",
+ 5: "coils",
+ 6: "holding_registers",
+ 15: "coils",
+ 16: "holding_registers",
+}
+RESPONSES = {
+ 1: ReadCoilsResponse,
+ 2: ReadDiscreteInputsResponse,
+ 3: ReadHoldingRegistersResponse,
+ 4: ReadInputRegistersResponse,
+ 5: WriteSingleCoilResponse,
+ 6: WriteSingleRegisterResponse,
+ 15: WriteMultipleCoilsResponse,
+ 16: WriteMultipleRegistersResponse,
+}
+LIMITS = {1: 2000, 2: 2000, 3: 125, 4: 125, 5: 1, 6: 1, 15: 1968, 16: 123}
+
+
+class CountValidation: # pylint: disable=too-few-public-methods
+ """Delay count checks until routing so invalid wire requests receive exception
+ 03.
+ """
+
+ def verifyCount(self, max_count, count=-1): # pylint: disable=invalid-name
+ """Decode first; Server.route applies function-specific limits."""
+
+
+class CoilRequest(WriteSingleCoilRequest):
+ """Retain the original coil word to reject nonstandard Boolean encodings."""
+
+ raw_coil = 0
+
+ def decode(self, data):
+ """Decode the PDU and retain its unnormalized Boolean word."""
+ super().decode(data)
+ self.raw_coil = struct.unpack(">HH", data[:4])[1]
+
+
+REQUEST_CLASSES = [
+ type(f"CountChecked{cls.__name__}", (CountValidation, cls), {})
+ for cls in (
+ ReadCoilsRequest,
+ ReadDiscreteInputsRequest,
+ ReadHoldingRegistersRequest,
+ ReadInputRegistersRequest,
+ WriteMultipleCoilsRequest,
+ )
+] + [CoilRequest]
+
+
+class OwnedHandler(ServerRequestHandler):
+ """Capture transaction state and track tasks for bounded shutdown."""
+
+ def callback_data(self, data, addr=None):
+ """Decode and capture every complete PDU, including pipelined TCP requests."""
+ total = 0
+ while total < len(data):
+ try:
+ used = TransactionManager.callback_data(self, data[total:], addr)
+ except ModbusIOException as error:
+ owner = self.server.owner
+ owner.request_counter += 1
+ owner.log_line(
+ "SERVER",
+ owner.request_counter,
+ f"peer={addr!r} unit={error.dev_id} "
+ f"function=0x{error.fcode or 0:02X} ERROR {error}",
+ )
+ self.server_send(
+ ExceptionResponse(
+ error.fcode or 0,
+ ExcCodes.ILLEGAL_FUNCTION,
+ device_id=error.dev_id,
+ transaction=error.transaction_id,
+ ),
+ addr,
+ )
+ return len(data)
+ if not used:
+ break
+ total += used
+ if self.last_pdu:
+ self.handle_later()
+ return total
+
+ def handle_later(self):
+ request, address = self.last_pdu, self.last_addr
+ self.last_pdu = None
+ if request is not None:
+ task = asyncio.create_task(self.dispatch_request(request, address))
+ self.server.owner.tasks.add(task)
+ task.add_done_callback(self.server.owner.tasks.discard)
+
+ async def dispatch_request(self, request, address):
+ """Return a response through the original framing and transaction ID."""
+ request_id = "?"
+ try:
+ peer = address or (
+ self.transport.get_extra_info("peername") if self.transport else None
+ )
+ request_id = self.server.owner.begin_request(request, peer)
+ if (
+ self.server.owner.serial_device
+ and request.dev_id != self.server.owner.config["unit"]
+ ):
+ return
+ response = await self.server.owner.handle(request, request_id=request_id)
+ response.transaction_id = request.transaction_id
+ response.dev_id = request.dev_id
+ self.server_send(response, address)
+ except asyncio.CancelledError:
+ pass
+ except Exception as error: # pylint: disable=broad-exception-caught
+ self.server.owner.last_error = str(error)
+ self.server.owner.log_line("SERVER", request_id, f"ERROR {error}")
+ self.server_send(
+ ExceptionResponse(
+ request.function_code,
+ ExcCodes.DEVICE_FAILURE,
+ device_id=request.dev_id,
+ transaction=request.transaction_id,
+ ),
+ address,
+ )
+
+
+class OwnedListener: # pylint: disable=too-few-public-methods
+ """Mixin coupling PyModbus listeners to application-owned request tasks."""
+
+ trace_packet = None
+ trace_pdu = None
+ trace_connect = None
+ owner = None
+
+ def callback_new_connection(self):
+ """Construct a handler that captures requests before awaiting."""
+ return OwnedHandler(self, self.trace_packet, self.trace_pdu, self.trace_connect)
+
+
+class TcpListener(OwnedListener, ModbusTcpServer):
+ """Owned Modbus TCP listener."""
+
+
+class UdpListener(OwnedListener, ModbusUdpServer):
+ """Owned Modbus UDP listener."""
+
+
+class TlsListener(OwnedListener, ModbusTlsServer):
+ """Owned native Modbus TLS listener."""
+
+
+class SerialListener(OwnedListener, ModbusSerialServer):
+ """Owned RTU/ASCII serial listener."""
+
+
+def server_tls(cert_file, key_file, ca_file):
+ """Load server identity off the event loop; optionally require client
+ certificates.
+ """
+ if not cert_file or not key_file:
+ raise CommandError("server start tls requires --cert-file and --key-file")
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(cert_file, key_file, password="")
+ if ca_file:
+ context.load_verify_locations(cafile=ca_file)
+ context.verify_mode = ssl.CERT_REQUIRED
+ return context
+
+
+class Server:
+ """One independent listener, sparse simulator, proxy mode and evidence view."""
+
+ def __init__(self, app):
+ self.app = app
+ self.listener = None
+ self.simulator = None
+ self.config = None
+ self.proxy = False
+ self.tasks = set()
+ self.tick_task = None
+ self.stop_event = asyncio.Event()
+ self.last_error = ""
+ self.session = None
+ self.state = DataState("No server session")
+ self.label = "Stopped"
+ self.endpoint = None
+ self.request_lock = asyncio.Lock()
+ self.lifecycle = asyncio.Lock()
+ self.serial_device = None
+ self.stopping = False
+ self.request_logging = True
+ self.proxy_logging = True
+ self.request_counter = 0
+
+ async def record(self, direction, item):
+ """Record server exchanges without turning storage errors into protocol
+ errors.
+ """
+ if self.session is not None:
+ try:
+ await self.app.records.append(
+ session=self.session,
+ protocol="modbus-server",
+ direction=direction,
+ decoded=item,
+ )
+ except Exception as error: # pylint: disable=broad-exception-caught
+ self.last_error = f"Recording warning: {error}"
+
+ async def start(
+ self, config, settings, cert_file=None, key_file=None, ca_file=None, quiet=False
+ ):
+ """Bind one listener; failure closes candidates and leaves the server
+ stopped.
+ """
+ async with self.lifecycle:
+ if self.listener is not None:
+ raise CommandError("Server already running; use server stop first")
+ config = validate(config)
+ if settings.transport in ("rtu", "ascii") and self.app.connection.settings:
+ connected = self.app.connection.settings
+ if (
+ connected.transport in ("rtu", "ascii")
+ and connected.target == settings.target
+ ):
+ raise CommandError(
+ "Client and server cannot share the same serial port"
+ )
+ hooks = await asyncio.to_thread(Simulator.load_hooks, config)
+ # A dummy register supplies the decoder context, not the address map.
+ context = SimDevice(
+ id=config["unit"],
+ simdata=[SimData(0, values=0, datatype=DataType.REGISTERS)],
+ )
+ options = {"ignore_missing_devices": False, "custom_pdu": REQUEST_CLASSES}
+ if settings.transport in ("tcp", "udp", "tls"):
+ cls = {"tcp": TcpListener, "udp": UdpListener, "tls": TlsListener}[
+ settings.transport
+ ]
+ options["address"] = (settings.target, settings.port)
+ if settings.transport == "tls":
+ options["sslctx"] = await asyncio.to_thread(
+ server_tls, cert_file, key_file, ca_file
+ )
+ else:
+ cls = SerialListener
+ options.update(
+ port=settings.target,
+ framer=(
+ FramerType.RTU
+ if settings.transport == "rtu"
+ else FramerType.ASCII
+ ),
+ baudrate=settings.baudrate,
+ bytesize=settings.bytesize,
+ parity=settings.parity,
+ stopbits=settings.stopbits,
+ reconnect_delay=0,
+ )
+ listener = cls(context, **options)
+ listener.owner = self
+ try:
+ self.simulator = Simulator(config, hooks)
+ self.config = config
+ self.stop_event = asyncio.Event()
+ self.proxy = False
+ self.request_logging = not quiet
+ self.proxy_logging = True
+ self.last_error = ""
+ self.state = DataState(f"Downstream {settings.label}")
+ self.session = await self.app.records.start_session(
+ "modbus-server", metadata=settings.as_dict()
+ )
+ self.serial_device = (
+ settings.target if settings.transport in ("rtu", "ascii") else None
+ )
+ self.listener = listener
+ await listener.serve_forever(background=True)
+ port = settings.port
+ if settings.transport in ("tcp", "tls"):
+ port = listener.transport.sockets[0].getsockname()[1]
+ elif settings.transport == "udp":
+ port = listener.transport.get_extra_info("sockname")[1]
+ self.label = f"{settings.transport.upper()} {settings.target}" + (
+ f":{port}" if settings.transport in ("tcp", "udp", "tls") else ""
+ )
+ self.endpoint = (
+ (settings.target, port)
+ if settings.transport in ("tcp", "udp", "tls")
+ else None
+ )
+ if "on_tick" in hooks:
+ self.tick_task = asyncio.create_task(self.ticks())
+ except BaseException:
+ await listener.shutdown()
+ self.listener = None
+ await self.finish_session()
+ raise
+
+ async def finish_session(self):
+ """End durable server records in their original project."""
+ session, self.session = self.session, None
+ if session is not None:
+ await self.app.records.end_session(session)
+
+ async def stop(self):
+ """Stop accepting requests, cancel/drain work, and close serial/network
+ resources.
+ """
+ async with self.lifecycle:
+ self.stopping = True
+ try:
+ listener, self.listener = self.listener, None
+ if listener is not None:
+ await listener.shutdown()
+ pending = self.tasks - {asyncio.current_task()}
+ if self.tick_task:
+ pending.add(self.tick_task)
+ for task in pending:
+ task.cancel()
+ if pending:
+ await asyncio.wait_for(
+ asyncio.gather(*pending, return_exceptions=True), 5
+ )
+ self.tick_task = None
+ self.proxy = False
+ self.stop_event.set()
+ await self.finish_session()
+ self.label = "Stopped"
+
+ finally:
+ self.stopping = False
+
+ async def ticks(self):
+ """Periodic local-only hooks; failures are reported and pending changes
+ rolled back.
+ """
+ previous = self.simulator.clock()
+ while True:
+ await asyncio.sleep(self.config["hooks"].get("tick_interval_seconds", 1))
+ now = self.simulator.clock()
+ elapsed, previous = now - previous, now
+ if self.proxy:
+ continue
+ try:
+ async with self.request_lock:
+ await self.simulator.tick(elapsed)
+ except Exception as error: # pylint: disable=broad-exception-caught
+ self.last_error = f"on_tick failed: {error}"
+ self.log_line("SERVER", "-", f"ERROR {self.last_error}")
+ await self.record(
+ "error", {"operation": "on_tick", "error": str(error)}
+ )
+
+ async def before_forward(self, request):
+ """Future MITM request seam; currently identity, with no rule loading."""
+ return request
+
+ async def after_response(self, response):
+ """Future MITM response seam; currently identity, with no rule loading."""
+ return response
+
+ @staticmethod
+ def values(request):
+ """Return payload values for supported write requests, trimming bit padding."""
+ if request.function_code in (5, 15):
+ return list(
+ request.bits[: 1 if request.function_code == 5 else request.count]
+ )
+ return list(request.registers)
+
+ def log_line(self, source, request_id, text):
+ """Append a UTC line with escaped controls; errors bypass quiet settings."""
+ safe = "".join(
+ char if char.isprintable() else ascii(char)[1:-1] for char in str(text)
+ )
+ self.app.append_output(f"{timestamp()} {source:<6} #{request_id} {safe}")
+
+ def request_summary(self, request):
+ """Describe functions with at most 16 write values; records retain full data."""
+ code = request.function_code
+ if code not in TABLES:
+ return "read id" if code == 43 else f"function=0x{code:02X}"
+ count = request.count if code in (1, 2, 3, 4, 15, 16) else 1
+ address = request.address
+ end = address + count - 1
+ selection = str(address) if count == 1 else f"{address}-{end}"
+ summary = f"{'read' if code < 5 else 'write'} {TABLES[code]} {selection}"
+ if code >= 5:
+ values = self.values(request)
+ preview = values[:16]
+ text = (
+ "".join(str(int(value)) for value in preview)
+ if code in (5, 15)
+ else " ".join(f"{int(value):04X}" for value in preview)
+ )
+ summary += f" values={text}"
+ if len(values) > len(preview):
+ summary += f" (+{len(values) - len(preview)} omitted)"
+ return summary
+
+ def begin_request(self, request, peer=None):
+ """Assign a monotonic app-local ID and log arrival before serialization."""
+ self.request_counter += 1
+ request_id = self.request_counter
+ if self.request_logging:
+ self.log_line(
+ "SERVER",
+ request_id,
+ f"peer={peer!r} unit={request.dev_id} {self.request_summary(request)}",
+ )
+ return request_id
+
+ async def handle(self, request, *, request_id=None):
+ """Log arrival, capture mode, serialize exchanges, and display failures."""
+ proxy = self.proxy
+ if request_id is None:
+ request_id = self.begin_request(request)
+ async with self.request_lock:
+ response = await self.route(request, proxy, request_id)
+ if response.isError():
+ uncertain = (
+ proxy
+ and request.function_code in (5, 6, 15, 16)
+ and response.exception_code == 11
+ )
+ self.log_line(
+ "SERVER",
+ request_id,
+ f"ERROR exception={response.exception_code}"
+ + ("; write outcome unconfirmed" if uncertain else ""),
+ )
+ return response
+
+ async def route(self, request, proxy, request_id=None):
+ # pylint: disable=too-many-return-statements,too-many-branches
+ """Route a captured request using its mode, preserving valid-address
+ semantics.
+ """
+ code = request.function_code
+ if request.dev_id != self.config["unit"]:
+ return ExceptionResponse(code, ExcCodes.GATEWAY_NO_RESPONSE)
+ if code not in TABLES and code != 43:
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_FUNCTION)
+ if code in TABLES:
+ count = request.count if code in (1, 2, 3, 4, 15, 16) else 1
+ if not 1 <= count <= LIMITS[code]:
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_VALUE)
+ if not 0 <= request.address or request.address + count > 65536:
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_ADDRESS)
+ if code == 5 and getattr(request, "raw_coil", 0) not in (0, 0xFF00):
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_VALUE)
+ if code in (15, 16):
+ expected = (count + 7) // 8 if code == 15 else count * 2
+ actual = getattr(
+ request,
+ "data_byte_count" if code == 15 else "_payload_byte_count",
+ expected,
+ )
+ if request.byte_count != expected or actual != expected:
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_VALUE)
+ else:
+ count = 0
+ await self.record(
+ "received",
+ {
+ "function": code,
+ "request_id": request_id,
+ "values": self.values(request) if code in (5, 6, 15, 16) else None,
+ "unit": request.dev_id,
+ "address": request.address,
+ "count": count,
+ "mode": "proxy" if proxy else "local",
+ },
+ )
+ response = (
+ await self.forward(request, request_id)
+ if proxy
+ else await self.local(request, count)
+ )
+ if code == 43 and not response.isError():
+ self.state.identification(response.information)
+ if code in TABLES:
+ table = TABLES[code]
+ if code < 5 and not response.isError():
+ values = response.bits[:count] if code < 3 else response.registers
+ self.state.read(table, request.address, values)
+ elif code >= 5:
+ self.state.write(
+ table,
+ request.address,
+ self.values(request),
+ (
+ "acknowledged; no readback"
+ if not response.isError()
+ else (
+ "possibly uncertain"
+ if proxy and response.exception_code == 11
+ else "rejected"
+ )
+ ),
+ )
+ await self.record(
+ "sent",
+ {
+ "function": response.function_code,
+ "request_id": request_id,
+ "exception": response.exception_code,
+ "registers": response.registers,
+ "bits": response.bits,
+ "tags": self.describe_tags(request, count, response),
+ },
+ )
+ return response
+
+ def describe_tags(self, request, count, response):
+ """Label complete and partial accesses without inventing complete values."""
+ table = TABLES.get(request.function_code)
+
+ result = []
+ for tag in self.simulator.tags.values():
+ if tag.table != table or not (
+ tag.address < request.address + count and request.address < tag.stop
+ ):
+ continue
+ partial = not (
+ request.address <= tag.address and tag.stop <= request.address + count
+ )
+ item = {"tag": tag.name, "partial": partial}
+ if not partial and not response.isError():
+ data = (
+ (
+ response.bits[:count]
+ if request.function_code < 3
+ else response.registers
+ )
+ if request.function_code < 5
+ else self.values(request)
+ )
+ raw = data[tag.address - request.address : tag.stop - request.address]
+ item["value"] = repr(decode_tag_value(tag, raw))
+ item["evidence"] = (
+ "read"
+ if request.function_code < 5
+ else "write acknowledgement, no readback"
+ )
+ result.append(item)
+ return result
+
+ async def local(self, request, count): # pylint: disable=too-many-return-statements
+ """Return local protocol responses; hook failures become Device Failure."""
+ code = request.function_code
+ if code == 43:
+ if request.read_code not in (1, 2, 3, 4):
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_VALUE)
+ identity = self.config["identity"]
+ objects = {
+ 0: identity["vendor"].encode(),
+ 1: identity["product"].encode(),
+ 2: identity["revision"].encode(),
+ }
+ objects = {
+ key: value
+ for key, value in objects.items()
+ if key == request.object_id
+ or (request.read_code != 4 and key >= request.object_id)
+ }
+ if not objects:
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_ADDRESS)
+ return ReadDeviceInformationResponse(
+ read_code=request.read_code, information=objects
+ )
+ table = TABLES[code]
+ if not all(
+ self.simulator.available(table, address)
+ for address in range(request.address, request.address + count)
+ ):
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_ADDRESS)
+ values = None if code < 5 else self.values(request)
+ if values is not None and len(values) != count:
+ return ExceptionResponse(code, ExcCodes.ILLEGAL_VALUE)
+ try:
+ result = await self.simulator.access(table, request.address, count, values)
+ except Exception as error: # pylint: disable=broad-exception-caught
+ self.last_error = f"Local hook failed: {error}"
+ await self.record("error", {"operation": "hook", "error": str(error)})
+ return ExceptionResponse(code, ExcCodes.DEVICE_FAILURE)
+ options = {"address": request.address, "count": count}
+ if code in (1, 2, 5):
+ options["bits"] = [bool(value) for value in result]
+ elif code in (3, 4, 6):
+ options["registers"] = result
+ return RESPONSES[code](**options)
+
+ @staticmethod
+ def validate_acknowledgement(code, request, response, values):
+ """Reject mismatched write echoes/counts before returning success."""
+ if response.address != request.address:
+ raise CommandError("Invalid upstream acknowledgement address")
+ if code in (15, 16) and response.count != len(values):
+ raise CommandError("Invalid upstream acknowledgement count")
+ if code == 5 and response.bits != values:
+ raise CommandError("Invalid upstream coil acknowledgement")
+ if code == 6 and response.registers != values:
+ raise CommandError("Invalid upstream register acknowledgement")
+
+ async def forward(
+ self, request, request_id=None
+ ): # pylint: disable=too-many-branches,too-many-statements
+ """Forward decoded PDUs under the client lock with zero retries and
+ faithful exceptions.
+ """
+ code = request.function_code
+ sent = False
+ try:
+ async with self.app.connection.operation() as (client, settings):
+ upstream = await self.before_forward(copy.deepcopy(request))
+ upstream.dev_id = settings.unit
+ if self.proxy_logging:
+ endpoint = settings.label.rsplit(" unit ", 1)[0]
+ self.log_line(
+ "PROXY",
+ request_id,
+ f"upstream={endpoint} unit={upstream.dev_id} "
+ f"{self.request_summary(upstream)}",
+ )
+ values = self.values(upstream) if code in (5, 6, 15, 16) else None
+ if values is not None:
+ self.app.client_state.write(
+ TABLES[code], upstream.address, values, "attempted"
+ )
+ retries = client.ctx.retries
+ client.ctx.retries = 0
+ try:
+ sent = True
+ async with asyncio.timeout(settings.timeout + 1):
+ response = await client.execute(False, upstream)
+ finally:
+ client.ctx.retries = retries
+ if response is None or response.function_code not in (
+ code,
+ code | 0x80,
+ ):
+ raise CommandError("Invalid upstream response function")
+ if not response.isError() and code in (1, 2, 3, 4):
+ data = (
+ response.bits[: request.count]
+ if code < 3
+ else response.registers
+ )
+ if len(data) != request.count or any(
+ not isinstance(v, (int, bool))
+ or not 0 <= v <= (1 if code < 3 else 65535)
+ for v in data
+ ):
+ raise CommandError("Invalid upstream read response")
+ self.app.client_state.read(TABLES[code], upstream.address, data)
+ if code == 43 and not response.isError():
+ self.app.client_state.identification(response.information)
+ if values is not None:
+ if not response.isError():
+ self.validate_acknowledgement(code, upstream, response, values)
+ self.app.client_state.write(
+ TABLES[code],
+ upstream.address,
+ values,
+ (
+ "rejected"
+ if response.isError()
+ else "acknowledged; no readback"
+ ),
+ )
+ response = await self.after_response(copy.deepcopy(response))
+ if response.isError():
+ self.log_line(
+ "PROXY",
+ request_id,
+ f"ERROR upstream exception={response.exception_code}",
+ )
+ return response
+ except asyncio.CancelledError:
+ self.log_line(
+ "PROXY",
+ request_id,
+ "ERROR forwarding cancelled"
+ + (
+ "; write outcome unconfirmed"
+ if sent and code in (5, 6, 15, 16)
+ else ""
+ ),
+ )
+ if sent:
+ self.app.connection.abort()
+ if code in (5, 6, 15, 16):
+ self.app.client_state.write(
+ TABLES[code],
+ request.address,
+ self.values(request),
+ "uncertain" if sent else "not sent",
+ )
+ if self.listener is not None and not self.stopping:
+ return ExceptionResponse(code, ExcCodes.GATEWAY_NO_RESPONSE)
+ raise
+ except (CommandError, OSError, ModbusException, TimeoutError) as error:
+ self.last_error = f"Proxy failed: {error}"
+ uncertain = sent and code in (5, 6, 15, 16)
+ self.log_line(
+ "PROXY",
+ request_id,
+ f"ERROR {error}" + ("; write outcome unconfirmed" if uncertain else ""),
+ )
+ if code in (5, 6, 15, 16):
+ self.app.client_state.write(
+ TABLES[code],
+ request.address,
+ self.values(request),
+ "uncertain" if sent else "not sent",
+ )
+ await self.record(
+ "error",
+ {
+ "operation": "proxy",
+ "error": str(error),
+ "write_uncertain": sent and code in (5, 6, 15, 16),
+ },
+ )
+ return ExceptionResponse(code, ExcCodes.GATEWAY_NO_RESPONSE)
diff --git a/src/ctmodbus/server_commands.py b/src/ctmodbus/server_commands.py
new file mode 100644
index 0000000..412f75f
--- /dev/null
+++ b/src/ctmodbus/server_commands.py
@@ -0,0 +1,616 @@
+"""Typed CTUI server-definition, listener, proxy and observation commands.
+
+Project configuration lives in configs, referenced tag definitions in TagStore.
+Imports replace server definitions and atomically merge referenced tags after
+collision-specific confirmation. Runtime data and hook functions are never saved.
+"""
+
+import asyncio
+import json
+import sqlite3
+from contextlib import closing
+from dataclasses import replace as replace_settings
+from pathlib import Path
+from typing import Literal
+
+from ctui import (
+ Argument,
+ CommandError,
+ CommandResult,
+ IntegerRanges,
+ PathCompleter,
+ command,
+)
+
+from ctmodbus import server_config
+from ctmodbus.connection import ConnectionSettings
+from ctmodbus.simulation import Simulator
+from ctmodbus.tags import decode_tag_value, encode_tag_value, parse_tag_value
+
+CONFIG_NAME = server_config.CONFIG_NAME
+PATH_ARG = {"path": Argument(help="Server TOML file", completer=PathCompleter())}
+TARGET_ARGS = {
+ "target": Argument(help="Modbus table name or tag"),
+ "selection": Argument(help="Tag name or inclusive address ranges"),
+}
+NETWORK_ARGS = {
+ "host": Argument(help="Local bind address"),
+ "port": Argument(flags=("--port",)),
+ "quiet": Argument(flags=("--quiet",)),
+}
+SERIAL_ARGS = {
+ "device": Argument(help="Local serial device path"),
+ **{
+ name: Argument(flags=("--" + name.replace("_", "-"),))
+ for name in (
+ "baudrate",
+ "bytesize",
+ "parity",
+ "stopbits",
+ "quiet",
+ )
+ },
+}
+
+
+class ServerCommandMixin: # pylint: disable=too-many-public-methods
+ """Commands share normal CTUI parsing, dispatch, history and project guards."""
+
+ async def server_definition(self):
+ """Load current project definition, defaulting to all-zero full address maps."""
+ configs = await self.configs.list()
+ return server_config.validate(
+ configs.get(CONFIG_NAME, server_config.definition())
+ )
+
+ async def save_server_definition(self, config):
+ """Validate a candidate before replacing its persistent project definition."""
+ config = server_config.validate(config)
+ await self.configs.save(CONFIG_NAME, config)
+
+ async def prepare_server_import(self, text, kwargs):
+ """Read/confirm once under dispatch guards; keep exact data until
+ application.
+ """
+ item, arguments = self.commands.resolve(text)
+ parsed = item.parse_args(arguments)
+ config = server_config.load(parsed["path"])
+ existing = {tag.name: tag for tag in await self.tags.list()}
+ conflicts = [
+ name
+ for name, values in config["tags"].items()
+ if name in existing
+ and existing[name] != server_config.tag_from(name, values)
+ ]
+ if conflicts and not parsed.get("replace", False):
+ message = (
+ "Server tag definitions differ: "
+ + ", ".join(sorted(conflicts))
+ + ". Replace them? Use --replace to approve without prompting."
+ )
+ if not await self.confirm_replacement(message, kwargs):
+ return CommandResult.rejected()
+ self._prepared_server_imports[asyncio.current_task()] = config
+ return None
+
+ @command(
+ name="server config import",
+ arguments={**PATH_ARG, "replace": Argument(flags=("--replace",))},
+ )
+ async def server_config_import(self, path: Path, replace: bool = False):
+ """Replace server definition and merge its tags after conflict confirmation."""
+ config = self._prepared_server_imports.get(asyncio.current_task())
+ if config is None:
+ config = server_config.load(path)
+ existing = {tag.name: tag for tag in await self.tags.list()}
+ if not replace and any(
+ name in existing
+ and existing[name] != server_config.tag_from(name, item)
+ for name, item in config["tags"].items()
+ ):
+ raise CommandError("Existing server tags differ; use --replace")
+ await self.tags.ensure()
+ cursor = await self.backend.connection.execute("PRAGMA database_list")
+ database = next(row[2] for row in await cursor.fetchall() if row[1] == "main")
+ tags = [
+ server_config.tag_from(name, item) for name, item in config["tags"].items()
+ ]
+ with closing(sqlite3.connect(database, timeout=0)) as connection:
+ with connection:
+ connection.executemany(
+ "INSERT OR REPLACE INTO tags VALUES (?, ?, ?, ?, ?, ?)",
+ [
+ (t.name, t.table, t.address, t.type, t.byte_order, t.word_order)
+ for t in tags
+ ],
+ )
+ connection.execute(
+ "INSERT INTO configs(name,value,template) VALUES (?,?,0) "
+ "ON CONFLICT(name) DO UPDATE SET value=excluded.value",
+ (CONFIG_NAME, json.dumps(config)),
+ )
+ await self.backend.touch()
+ return f"Imported server definition with {len(tags)} tags."
+
+ @command(name="server config export", arguments=PATH_ARG)
+ async def server_config_export(self, path: Path):
+ """Export definition and referenced tags, not live values or Python source."""
+ destination = server_config.export(await self.server_definition(), path)
+ return f"Exported server definition to {destination}."
+
+ @command(
+ name="server config clear",
+ arguments={"confirm": Argument(flags=("--confirm",))},
+ )
+ async def server_config_clear(self, confirm: bool = False):
+ """Replace server configuration with an empty address map; keep project tags."""
+ if not confirm:
+ raise CommandError("Use --confirm to clear the server definition")
+ await self.save_server_definition(server_config.definition(False))
+ return (
+ "Server definition cleared; no addresses available. Project tags retained."
+ )
+
+ @command(
+ name="server config unit",
+ arguments={"unit": Argument(help="Server unit ID, 1–247")},
+ )
+ async def server_config_unit(self, unit: int):
+ """Set the server unit ID independently of the connected client unit."""
+ config = await self.server_definition()
+ config["unit"] = unit
+ await self.save_server_definition(config)
+ return f"Server unit set to {unit}."
+
+ @command(
+ name="server config seed",
+ arguments={"seed": Argument(help="Reproducible random seed")},
+ )
+ async def server_config_seed(self, seed: int):
+ """Set the random seed used for each new local server run."""
+ config = await self.server_definition()
+ config["seed"] = seed
+ await self.save_server_definition(config)
+ return f"Server seed set to {seed}."
+
+ @command(
+ name="server config identity",
+ arguments={
+ name: Argument(flags=("--" + name,))
+ for name in ("vendor", "product", "revision")
+ },
+ )
+ async def server_config_identity(
+ self,
+ vendor: str = "ControlThings",
+ product: str = "ctmodbus simulator",
+ revision: str = "1.0",
+ ):
+ """Set the device-identification vendor, product, and revision."""
+ config = await self.server_definition()
+ config["identity"] = {
+ "vendor": vendor,
+ "product": product,
+ "revision": revision,
+ }
+ await self.save_server_definition(config)
+ return "Server identification updated."
+
+ @command(
+ name="server config table",
+ arguments={
+ "table": Argument(help="Modbus table"),
+ "unmapped": Argument(flags=("--unmapped",)),
+ "default": Argument(flags=("--default",)),
+ },
+ )
+ async def server_config_table(
+ self,
+ table: Literal[
+ "coils", "discrete_inputs", "input_registers", "holding_registers"
+ ],
+ unmapped: Literal["illegal", "default"] = "illegal",
+ default: str = "0",
+ ):
+ """Set availability and initial fallback value for unspecified addresses."""
+ config = await self.server_definition()
+ config["tables"][table].update(
+ unmapped=unmapped, default=self.raw_value(default)
+ )
+ await self.save_server_definition(config)
+ return f"{table}: unmapped={unmapped}, default={default}."
+
+ @staticmethod
+ def raw_value(text):
+ """Parse a Boolean or radix/decimal integer from command input."""
+ if text.lower() in ("true", "on", "false", "off"):
+ return text.lower() in ("true", "on")
+ try:
+ return (
+ int(text, 0)
+ if text.lower().lstrip("+-").startswith(("0x", "0b", "0o"))
+ else int(text)
+ )
+ except ValueError as error:
+ raise CommandError(f"Invalid raw value: {text}") from error
+
+ async def set_server_rule(self, target, selection, rule):
+ """Replace matching rules, reject ambiguous overlaps, and persist atomically."""
+ config = await self.server_definition()
+ if target == "tag":
+ tag = await self.tags.get(selection)
+ item = {**tag.as_dict(), "address": tag.address}
+ for key in ("value", "min", "max"):
+ if key in rule:
+ rule[key] = parse_tag_value(tag, rule[key])
+ if "values" in rule:
+ rule["values"] = [
+ parse_tag_value(tag, value) for value in rule["values"]
+ ]
+ config["tags"][selection] = {**item, **rule}
+ else:
+ if target not in config["tables"]:
+ raise CommandError("Target must be tag or a Modbus table")
+ ranges = IntegerRanges(selection)
+ if ranges.count > 65536 or any(
+ span.start < 0 or span.stop > 65536 for span in ranges
+ ):
+ raise CommandError("Addresses must fit 0–65535")
+ for key in ("value", "min", "max"):
+ if key in rule:
+ rule[key] = self.raw_value(rule[key])
+ if "values" in rule:
+ rule["values"] = [self.raw_value(value) for value in rule["values"]]
+ existing = config["tables"][target]["ranges"]
+ for span in ranges:
+ existing[:] = [
+ r
+ for r in existing
+ if (r["start"], r["end"]) != (span.start, span.stop - 1)
+ ]
+ existing.append({"start": span.start, "end": span.stop - 1, **rule})
+ await self.save_server_definition(config)
+ return f"Set {rule['mode']} behavior for {target} {selection}."
+
+ @command(
+ name="server config set",
+ arguments={**TARGET_ARGS, "value": Argument(help="Initial scalar value")},
+ )
+ async def server_config_set(self, target: str, selection: str, value: str):
+ """Set initial tag or raw range values, replacing matching dynamic rules."""
+ return await self.set_server_rule(
+ target, selection, {"mode": "static", "value": value}
+ )
+
+ @command(
+ name="server config random",
+ arguments={
+ **TARGET_ARGS,
+ "minimum": Argument(flags=("--min",)),
+ "maximum": Argument(flags=("--max",)),
+ },
+ )
+ async def server_config_random(
+ self, target: str, selection: str, minimum: str = "0", maximum: str = "1"
+ ):
+ """Generate bounded values on each successful read; writes do not disable
+ rules.
+ """
+ return await self.set_server_rule(
+ target, selection, {"mode": "random", "min": minimum, "max": maximum}
+ )
+
+ @command(
+ name="server config sequence",
+ arguments={
+ **TARGET_ARGS,
+ "values": Argument(help="Comma-separated sequence values"),
+ "advance": Argument(flags=("--advance",)),
+ "interval": Argument(flags=("--interval",)),
+ "repeat": Argument(flags=("--repeat",)),
+ },
+ )
+ async def server_config_sequence(
+ self,
+ target: str,
+ selection: str,
+ values: list[str],
+ advance: Literal["read", "time"] = "read",
+ interval: float | None = None,
+ repeat: bool = True,
+ ):
+ """Cycle values on reads or elapsed time; --repeat=false holds the final
+ value.
+ """
+ rule = {
+ "mode": "sequence",
+ "values": values,
+ "advance": advance,
+ "repeat": repeat,
+ }
+ if interval is not None:
+ rule["interval_seconds"] = interval
+ return await self.set_server_rule(target, selection, rule)
+
+ async def set_server_hook(self, name, module, function, interval=None):
+ """Attach a companion function; all hooks share one Python module."""
+ config = await self.server_definition()
+ path = str(module.expanduser().resolve())
+ if config["hooks"].get("module", path) != path:
+ raise CommandError(
+ "Hooks must share a module; clear hooks before switching modules"
+ )
+ config["hooks"].update(module=path, **{name: function})
+ if interval is not None:
+ config["hooks"]["tick_interval_seconds"] = interval
+ await self.save_server_definition(config)
+ return f"Configured {name}: {function}."
+
+ @command(
+ name="server hook read",
+ arguments={
+ "module": Argument(help="Companion Python file", completer=PathCompleter()),
+ "function": Argument(help="Read hook function name"),
+ },
+ )
+ async def server_hook_read(self, module: Path, function: str):
+ """Attach on_read(device, tag_names), once per local read request."""
+ return await self.set_server_hook("on_read", module, function)
+
+ @command(
+ name="server hook write",
+ arguments={
+ "module": Argument(help="Companion Python file", completer=PathCompleter()),
+ "function": Argument(help="Write hook function name"),
+ },
+ )
+ async def server_hook_write(self, module: Path, function: str):
+ """Attach on_write(device, tag_name, value) for complete successful tag
+ writes.
+ """
+ return await self.set_server_hook("on_write", module, function)
+
+ @command(
+ name="server hook tick",
+ arguments={
+ "module": Argument(help="Companion Python file", completer=PathCompleter()),
+ "function": Argument(help="Tick hook function name"),
+ "interval": Argument(flags=("--interval",)),
+ },
+ )
+ async def server_hook_tick(self, module: Path, function: str, interval: float = 1):
+ """Attach on_tick(device, elapsed_seconds) for periodic local updates."""
+ return await self.set_server_hook("on_tick", module, function, interval)
+
+ @command(name="server hook clear")
+ async def server_hook_clear(self):
+ """Remove all companion hooks from the stopped server definition."""
+ config = await self.server_definition()
+ config["hooks"] = {}
+ await self.save_server_definition(config)
+ return "Server hooks cleared."
+
+ async def validate_server_tags(self, config):
+ """Require server tags to match their shared project definitions."""
+ existing = {tag.name: tag for tag in await self.tags.list()}
+ for name, item in config["tags"].items():
+ if existing.get(name) != server_config.tag_from(name, item):
+ raise CommandError(
+ f"Server tag {name!r} differs from the project tag; "
+ "remove or reconfigure its rule"
+ )
+
+ @command(name="server config remove", arguments=TARGET_ARGS)
+ async def server_config_remove(self, target: str, selection: str):
+ """Remove a tag rule or raw ranges, retaining tags and table fallback."""
+ config = await self.server_definition()
+ if target == "tag":
+ if selection not in config["tags"]:
+ raise CommandError("No server rule exists for that tag")
+ del config["tags"][selection]
+ elif target in config["tables"]:
+ ranges = IntegerRanges(selection)
+ requested = {(span.start, span.stop - 1) for span in ranges}
+ existing = config["tables"][target]["ranges"]
+ if not requested <= {(rule["start"], rule["end"]) for rule in existing}:
+ raise CommandError("Remove requires exact configured ranges")
+ config["tables"][target]["ranges"] = [
+ rule
+ for rule in existing
+ if (rule["start"], rule["end"]) not in requested
+ ]
+ else:
+ raise CommandError("Target must be tag or a Modbus table")
+ await self.save_server_definition(config)
+ return "Server rules removed."
+
+ @command(name="server config validate")
+ async def server_config_validate(self):
+ """Check configuration and trusted hook callables without opening a listener."""
+ config = await self.server_definition()
+ await self.validate_server_tags(config)
+ await asyncio.to_thread(Simulator.load_hooks, config)
+ return (
+ "Server configuration valid. "
+ "Listener binding and hook behavior are not tested."
+ )
+
+ @command(name="server config show")
+ async def server_config_show(self):
+ """Show saved initial values and rules, separate from runtime evidence."""
+ return server_config.dumps(await self.server_definition())
+
+ async def show_server_data(self):
+ """Show local static tag values and retained downstream evidence without I/O."""
+ lines = []
+ if self.server.simulator is not None:
+ lines.append(
+ "Current local static tag values (separate from proxy observations):"
+ )
+ simulator = self.server.simulator
+ for name, tag in simulator.tags.items():
+ rule = simulator.config["tags"][name]
+ if rule.get("mode", "static") == "static":
+ initial = self.server.simulator.config["tags"][name].get("value", 0)
+ raw = [
+ simulator.values.get((tag.table, tag.address + offset), value)
+ for offset, value in enumerate(encode_tag_value(tag, initial))
+ ]
+ lines.append(f"{name} = {decode_tag_value(tag, raw)!r}")
+ lines.append(self.server.state.show(await self.tags.list()))
+ return "\n".join(lines)
+
+ async def start_server(self, settings, quiet=False, **tls):
+ """Bind the server and return immediately; CLI lifetime belongs to the app."""
+ try:
+ config = await self.server_definition()
+ await self.validate_server_tags(config)
+ settings = replace_settings(settings, unit=config["unit"])
+ await self.server.start(config, settings, quiet=quiet, **tls)
+ except (OSError, ValueError, RuntimeError) as error:
+ raise CommandError(f"Server start failed: {error}") from error
+ return CommandResult.append(f"Server {self.server.label}")
+
+ @command(name="server start tcp", arguments=NETWORK_ARGS)
+ async def server_start_tcp(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 5020,
+ quiet: bool = False,
+ ):
+ """Start a Modbus TCP listener without blocking later commands."""
+ return await self.start_server(
+ ConnectionSettings("tcp", host, port=port), quiet=quiet
+ )
+
+ @command(name="server start udp", arguments=NETWORK_ARGS)
+ async def server_start_udp(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 5020,
+ quiet: bool = False,
+ ):
+ """Serve Modbus UDP from the local definition."""
+ return await self.start_server(
+ ConnectionSettings("udp", host, port=port), quiet=quiet
+ )
+
+ @command(
+ name="server start tls",
+ arguments={
+ **NETWORK_ARGS,
+ **{
+ name: Argument(flags=("--" + name.replace("_", "-"),))
+ for name in ("cert_file", "key_file", "ca_file")
+ },
+ },
+ )
+ async def server_start_tls(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 8020,
+ cert_file: str | None = None,
+ key_file: str | None = None,
+ ca_file: str | None = None,
+ quiet: bool = False,
+ ):
+ """Serve native Modbus TLS; optional CA roots require client certificates."""
+ return await self.start_server(
+ ConnectionSettings("tls", host, port=port),
+ quiet=quiet,
+ cert_file=cert_file,
+ key_file=key_file,
+ ca_file=ca_file,
+ )
+
+ @command(name="server start rtu", arguments=SERIAL_ARGS)
+ async def server_start_rtu(
+ self,
+ device: str,
+ baudrate: int = 9600,
+ bytesize: int = 8,
+ parity: Literal["N", "E", "O"] = "N",
+ stopbits: int = 1,
+ quiet: bool = False,
+ ):
+ """Respond as an RTU device on a local serial port, not a traffic tap."""
+ return await self.start_server(
+ ConnectionSettings(
+ "rtu",
+ device,
+ baudrate=baudrate,
+ bytesize=bytesize,
+ parity=parity,
+ stopbits=stopbits,
+ ),
+ quiet=quiet,
+ )
+
+ @command(name="server start ascii", arguments=SERIAL_ARGS)
+ async def server_start_ascii(
+ self,
+ device: str,
+ baudrate: int = 9600,
+ bytesize: int = 8,
+ parity: Literal["N", "E", "O"] = "N",
+ stopbits: int = 1,
+ quiet: bool = False,
+ ):
+ """Respond as an ASCII device on a local serial port."""
+ return await self.start_server(
+ ConnectionSettings(
+ "ascii",
+ device,
+ baudrate=baudrate,
+ bytesize=bytesize,
+ parity=parity,
+ stopbits=stopbits,
+ ),
+ quiet=quiet,
+ )
+
+ @command(name="server stop", arguments={"confirm": Argument(flags=("--confirm",))})
+ async def server_stop(
+ self, confirm: bool = False
+ ): # pylint: disable=unused-argument
+ """Stop the listener; dispatch confirms proxy stops; retain the client."""
+ await self.server.stop()
+ return "Server stopped."
+
+ @command(name="server status")
+ async def server_status(self):
+ """Show listener, proxy mode and the last hook/transport/storage error."""
+ mode = "proxy" if self.server.proxy else "local"
+ async with self.server.request_lock:
+ data = await self.show_server_data()
+ return (
+ f"Server: {self.server.label}\nMode: {mode}\n"
+ f"Request logging: {'on' if self.server.request_logging else 'off'}\n"
+ f"Proxy logging: {'on' if self.server.proxy_logging else 'off'}\n"
+ f"Last error: {self.server.last_error or 'none'}\n{data}"
+ )
+
+ @command(name="server reset", arguments={"confirm": Argument(flags=("--confirm",))})
+ async def server_reset(self, confirm: bool = False):
+ """Reset local runtime values, random seed and sequences; leave proxy
+ evidence alone. Last observations stay timestamped historical evidence.
+ """
+ if not confirm:
+ raise CommandError("Use --confirm to reset local simulation state")
+ if self.server.listener is None or self.server.proxy:
+ raise CommandError("Reset requires a running local server")
+ async with self.server.request_lock:
+ current = self.server.simulator
+ self.server.simulator = Simulator(current.config, current.hooks)
+ return "Local simulation reset."
+
+ @command(
+ name="server logging",
+ arguments={
+ "state": Argument(help="Enable or disable routine server request lines")
+ },
+ )
+ def server_logging(self, state: Literal["on", "off"]):
+ """Change server verbosity; errors remain visible and records unchanged."""
+ self.server.request_logging = state == "on"
+ return CommandResult.append(f"Server request logging {state}.")
diff --git a/src/ctmodbus/server_config.py b/src/ctmodbus/server_config.py
new file mode 100644
index 0000000..dcfedd6
--- /dev/null
+++ b/src/ctmodbus/server_config.py
@@ -0,0 +1,364 @@
+"""Versioned, project-persisted server definitions; no live sockets or values.
+
+Tables have a fallback policy and nonoverlapping ranges. Named tag rules overlay
+raw ranges and derive widths through the existing codecs. Validate everything
+before changing project state. Files contain settings, never Python source.
+"""
+
+import copy
+import json
+import math
+import os
+import tempfile
+import tomllib
+from pathlib import Path
+
+from ctui import CommandError
+
+from ctmodbus.tags import TABLES, TAG_FIELDS, Tag, encode_tag_value
+
+CONFIG_NAME = "ctmodbus-server"
+
+IDENTITY = {
+ "vendor": "ControlThings",
+ "product": "ctmodbus simulator",
+ "revision": "1.0",
+}
+MODES = {"static", "random", "sequence"}
+
+
+def definition(full=True):
+ """Return independent default or empty maps with no expanded addresses."""
+ return {
+ "format": "ctmodbus-server",
+ "version": 1,
+ "unit": 1,
+ "identity": dict(IDENTITY),
+ "tags": {},
+ "hooks": {},
+ "tables": {
+ table: {
+ "unmapped": "default" if full else "illegal",
+ "default": False if table in TABLES[:2] else 0,
+ "ranges": [],
+ }
+ for table in TABLES
+ },
+ }
+
+
+def tag_from(name, item):
+ """Return a validated codec definition from a server tag entry."""
+ return Tag(
+ name,
+ item["table"],
+ item["address"],
+ item["type"],
+ item.get("byte_order", "big"),
+ item.get("word_order", "little"),
+ ).validate()
+
+
+def validate_value(value, table, tag=None):
+ """Validate exact raw integers/bits or a finite typed scalar before serving."""
+ if tag:
+ if tag.type == "bool" and type(value) not in (bool, int):
+ raise CommandError("Boolean values must be true/false or 0/1")
+ if tag.type == "bool" and value not in (0, 1):
+ raise CommandError("Boolean values must be true/false or 0/1")
+ if tag.type.startswith(("int", "uint")) and type(value) is not int:
+ raise CommandError("Integer tags require integer values")
+ if tag.type.startswith("float") and (
+ type(value) not in (int, float) or not math.isfinite(value)
+ ):
+ raise CommandError("Float tags require finite values")
+ encode_tag_value(tag, value)
+ elif type(value) not in (int, bool) or not 0 <= value <= (
+ 1 if table in TABLES[:2] else 65535
+ ):
+ raise CommandError(f"Invalid raw value for {table}: {value!r}")
+ return value
+
+
+def validate_rule(rule, table, tag=None):
+ """Check one static/random/sequence rule and reject contradictory options."""
+ mode = rule.get("mode", "static")
+ allowed = {"start", "end", "mode"} | {
+ "static": {"value"},
+ "random": {"min", "max"},
+ "sequence": {"values", "advance", "interval_seconds", "repeat"},
+ }.get(mode, set())
+ if mode not in MODES:
+ raise CommandError(
+ f"Unknown rule mode {mode!r}; use static, random, or sequence"
+ )
+ reject_unknown(rule, allowed, f"{table} {mode} rule")
+ if mode == "static":
+ validate_value(
+ rule.get("value", False if table in TABLES[:2] else 0), table, tag
+ )
+ elif mode == "random":
+ low, high = rule.get("min", 0), rule.get(
+ "max", 1 if table in TABLES[:2] else 65535
+ )
+ validate_value(low, table, tag)
+ validate_value(high, table, tag)
+ if low > high:
+ raise CommandError("Random minimum must not exceed maximum")
+ else:
+ values = rule.get("values")
+ if not isinstance(values, list) or not values:
+ raise CommandError("Sequences require a nonempty values list")
+ for value in values:
+ validate_value(value, table, tag)
+ if (
+ rule.get("advance", "read") not in ("read", "time")
+ or type(rule.get("repeat", True)) is not bool
+ ):
+ raise CommandError("Invalid sequence advance/repeat")
+ if rule.get("advance", "read") == "time":
+ interval = rule.get("interval_seconds")
+ if (
+ type(interval) not in (int, float)
+ or not math.isfinite(interval)
+ or interval <= 0
+ ):
+ raise CommandError(
+ "Timed sequences require a positive interval_seconds"
+ )
+ elif "interval_seconds" in rule:
+ raise CommandError("Read sequences do not accept interval_seconds")
+
+
+def reject_unknown(mapping, allowed, location):
+ """Report exact invalid keys and allowed replacements before normalization."""
+ if not isinstance(mapping, dict):
+ raise CommandError(f"{location} must be a TOML table")
+ unknown = set(mapping) - set(allowed)
+ if unknown:
+ obsolete = ""
+ if "tick_seconds" in unknown:
+ obsolete = "; tick_seconds is obsolete: use tick_interval_seconds"
+ raise CommandError(
+ f"Unknown keys in {location}: {', '.join(sorted(unknown))}{obsolete}. "
+ f"Allowed keys: {', '.join(sorted(allowed))}"
+ )
+
+
+def validate(config): # pylint: disable=too-many-branches,too-many-statements
+ """Require format/version markers; normalize validated sparse rules.
+
+ Unknown or obsolete keys fail with their location and accepted keys. This
+ applies equally to imported files and project-saved definitions; no legacy
+ migration occurs. Validation never imports hooks or opens device I/O.
+ """
+ try:
+ reject_unknown(
+ config,
+ {
+ "format",
+ "version",
+ "unit",
+ "identity",
+ "tables",
+ "tags",
+ "hooks",
+ "seed",
+ },
+ "server definition",
+ )
+ if config.get("format") != "ctmodbus-server":
+ raise CommandError('Server definition requires format = "ctmodbus-server"')
+ if type(config.get("version")) is not int or config["version"] != 1:
+ raise CommandError("Server definition requires integer version = 1")
+ if (
+ type(config.get("unit", 1)) is not int
+ or not 1 <= config.get("unit", 1) <= 247
+ ):
+ raise CommandError("Server unit ID must be an integer in 1–247")
+ result = definition(False)
+ result.update(copy.deepcopy(config))
+ if "seed" in result and type(result["seed"]) is not int:
+ raise CommandError("Random seed must be an integer")
+ reject_unknown(result["identity"], IDENTITY, "identity")
+ identity = {**IDENTITY, **result["identity"]}
+ if set(identity) - set(IDENTITY) or any(
+ not isinstance(v, str) or len(v.encode()) > 200 for v in identity.values()
+ ):
+ raise CommandError(
+ "Identity requires vendor/product/revision strings "
+ "up to 200 UTF-8 bytes"
+ )
+ result["identity"] = identity
+ reject_unknown(result["tables"], TABLES, "tables")
+ if not isinstance(result["tags"], dict):
+ raise CommandError("tags must be a TOML table")
+ for table, settings in result["tables"].items():
+ reject_unknown(
+ settings, {"unmapped", "default", "ranges"}, f"tables.{table}"
+ )
+ result["tables"] = {
+ table: {
+ "unmapped": "illegal",
+ "default": False if table in TABLES[:2] else 0,
+ "ranges": [],
+ **result["tables"].get(table, {}),
+ }
+ for table in TABLES
+ }
+ for table, settings in result["tables"].items():
+ if set(settings) - {"unmapped", "default", "ranges"} or settings[
+ "unmapped"
+ ] not in ("illegal", "default"):
+ raise CommandError(f"Invalid settings for {table}")
+ validate_value(settings["default"], table)
+ if not isinstance(settings["ranges"], list):
+ raise CommandError(f"tables.{table}.ranges must be an array of tables")
+ spans = []
+ for rule in settings["ranges"]:
+ if not isinstance(rule, dict):
+ raise CommandError(f"tables.{table}.ranges entries must be tables")
+ start, end = rule["start"], rule.get("end", rule["start"])
+ if (
+ type(start) is not int
+ or type(end) is not int
+ or not 0 <= start <= end <= 65535
+ ):
+ raise CommandError("Ranges must fit addresses 0–65535")
+ if any(
+ start <= other_end and other_start <= end
+ for other_start, other_end in spans
+ ):
+ raise CommandError(f"Overlapping ranges in {table}")
+ spans.append((start, end))
+ rule["end"] = end
+ validate_rule(rule, table)
+ spans = {table: [] for table in TABLES}
+ for name, item in result["tags"].items():
+ reject_unknown(
+ item,
+ TAG_FIELDS
+ | {
+ "mode",
+ "value",
+ "min",
+ "max",
+ "values",
+ "advance",
+ "interval_seconds",
+ "repeat",
+ },
+ f"tags.{name}",
+ )
+ tag = tag_from(name, item)
+ rule = {k: v for k, v in item.items() if k not in TAG_FIELDS}
+ if any(
+ tag.address < stop and start < tag.stop
+ for start, stop in spans[tag.table]
+ ):
+ raise CommandError("Server behavior tags must not overlap")
+ spans[tag.table].append((tag.address, tag.stop))
+ validate_rule(rule, tag.table, tag)
+ hooks = result["hooks"]
+ reject_unknown(
+ hooks,
+ {"module", "on_read", "on_write", "on_tick", "tick_interval_seconds"},
+ "hooks",
+ )
+ if "tick_interval_seconds" in hooks and "on_tick" not in hooks:
+ raise CommandError("tick_interval_seconds requires on_tick")
+ if any(
+ not isinstance(v, str) or not v
+ for k, v in hooks.items()
+ if k != "tick_interval_seconds"
+ ):
+ raise CommandError("Hook paths and function names must be nonempty strings")
+ if any(
+ k in hooks for k in ("on_read", "on_write", "on_tick")
+ ) and not hooks.get("module"):
+ raise CommandError("Hooks require a Python module path")
+ if "on_tick" in hooks:
+ interval = hooks.get("tick_interval_seconds", 1)
+ if (
+ type(interval) not in (int, float)
+ or not math.isfinite(interval)
+ or interval <= 0
+ ):
+ raise CommandError("tick_interval_seconds must be positive and finite")
+ return result
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
+ raise CommandError(f"Invalid server definition: {error}") from error
+
+
+def load(path):
+ """Read TOML once and resolve the companion hook path relative to its file."""
+ path = path.expanduser().resolve()
+ try:
+ config = tomllib.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as error:
+ raise CommandError(f"Cannot import server configuration: {error}") from error
+ config = validate(config)
+ if config["hooks"].get("module"):
+ config["hooks"]["module"] = str(
+ (path.parent / config["hooks"]["module"]).resolve()
+ )
+ return config
+
+
+def dumps(config):
+ """Serialize normalized definitions deterministically without another dependency."""
+ lines = []
+
+ def scalar(value):
+ if isinstance(value, bool):
+ return str(value).lower()
+ if isinstance(value, str):
+ return json.dumps(value, ensure_ascii=False)
+ if isinstance(value, list):
+ return "[" + ", ".join(scalar(v) for v in value) + "]"
+ return str(value)
+
+ def emit(mapping, prefix=()):
+ for key, value in mapping.items():
+ if not isinstance(value, dict) and not (
+ isinstance(value, list) and value and isinstance(value[0], dict)
+ ):
+ lines.append(f"{json.dumps(key)} = {scalar(value)}")
+ for key, value in mapping.items():
+ name = ".".join(json.dumps(part) for part in (*prefix, key))
+ if isinstance(value, dict):
+ lines.extend(("", f"[{name}]"))
+ emit(value, (*prefix, key))
+ elif isinstance(value, list) and value and isinstance(value[0], dict):
+ for item in value:
+ lines.extend(("", f"[[{name}]]"))
+ emit(item, (*prefix, key))
+
+ emit(validate(config))
+ return "\n".join(lines) + "\n"
+
+
+def export(config, path):
+ """Atomically export a definition with hook paths relative to the destination."""
+ path = path.expanduser().resolve()
+ if not path.suffix:
+ path = path.with_suffix(".toml")
+ config = copy.deepcopy(config)
+ if config["hooks"].get("module"):
+ config["hooks"]["module"] = os.path.relpath(
+ config["hooks"]["module"], path.parent
+ )
+ temporary = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w", encoding="utf-8", dir=path.parent, delete=False
+ ) as stream:
+ temporary = Path(stream.name)
+ stream.write(dumps(config))
+ temporary.replace(path)
+ except OSError as error:
+ raise CommandError(f"Cannot export server data: {error}") from error
+ finally:
+ if temporary and temporary.exists():
+ temporary.unlink()
+ return path
diff --git a/src/ctmodbus/simulation.py b/src/ctmodbus/simulation.py
new file mode 100644
index 0000000..756efd3
--- /dev/null
+++ b/src/ctmodbus/simulation.py
@@ -0,0 +1,249 @@
+"""Sparse local emulation with transactional hooks and coherent typed responses.
+
+Raw ranges provide values; named tags overlay them. Dynamic rules sample once
+per request, including partial tag accesses. A local request uses a transaction
+copy: failures leave values, sequence positions and RNG state unchanged.
+"""
+
+import asyncio
+import importlib.util
+import inspect
+import random
+import time
+from pathlib import Path
+
+from ctui import CommandError
+
+from ctmodbus.server_config import tag_from, validate, validate_value
+from ctmodbus.tags import decode_tag_value, encode_tag_value
+
+
+class HookDevice:
+ """Hook API over the pending local transaction; set never invokes hooks."""
+
+ def __init__(self, simulator):
+ self.simulator = simulator
+
+ def get(self, name):
+ """Decode a tag from the current request snapshot, sampling it once."""
+ tag = self.simulator.tags.get(name)
+ if tag is None:
+ raise CommandError(f"Unknown server tag: {name}")
+ return decode_tag_value(tag, self.simulator.tag_values(name))
+
+ def set(self, name, value):
+ """Set a complete typed tag in pending state, bypassing on_write."""
+ tag = self.simulator.tags.get(name)
+ if tag is None:
+ raise CommandError(f"Unknown server tag: {name}")
+ validate_value(value, tag.table, tag)
+ values = encode_tag_value(tag, value)
+ for offset, raw in enumerate(values):
+ self.simulator.values[tag.table, tag.address + offset] = raw
+ self.simulator.sampled[name] = values
+
+
+class Simulator:
+ """Own sparse runtime values, RNG and sequence indexes for one server run."""
+
+ def __init__(self, config, hooks=None, clock=time.monotonic):
+ self.config = validate(config)
+ self.tags = {
+ name: tag_from(name, item) for name, item in config["tags"].items()
+ }
+ self.hooks = hooks or {}
+ self.clock = clock
+ self.started = clock()
+ self.values = {}
+ self.indexes = {}
+ self.sampled = {}
+ self.rng = random.Random(config.get("seed"))
+ self.lock = asyncio.Lock()
+ self.sample_time = self.started
+ self.advance_reads = False
+
+ @staticmethod
+ def load_hooks(config):
+ """Load trusted companion code; validate named callables without invoking
+ hooks.
+ """
+ hooks = config["hooks"]
+ if not hooks.get("module"):
+ return {}
+ path = Path(hooks["module"])
+ try:
+ spec = importlib.util.spec_from_file_location("ctmodbus_device_hook", path)
+ if spec is None or spec.loader is None:
+ raise ValueError("Cannot load Python module")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ result = {}
+ for name in ("on_read", "on_write", "on_tick"):
+ if name in hooks:
+ function = getattr(module, hooks[name])
+ if not callable(function):
+ raise ValueError(f"{hooks[name]} is not callable")
+ result[name] = function
+ return result
+ except Exception as error:
+ raise CommandError(f"Cannot load server hooks: {error}") from error
+
+ def rule_value(self, key, rule, table, tag=None):
+ """Generate one bounded scalar; read sequences advance on transaction commit."""
+ mode = rule.get("mode", "static")
+ if mode == "static":
+ return rule.get(
+ "value", False if table in ("coils", "discrete_inputs") else 0
+ )
+ if mode == "random":
+ low, high = rule.get("min", 0), rule.get(
+ "max", 1 if table in ("coils", "discrete_inputs") else 65535
+ )
+ return (
+ self.rng.uniform(low, high)
+ if tag and tag.type.startswith("float")
+ else self.rng.randint(int(low), int(high))
+ )
+ values = rule["values"]
+ if rule.get("advance", "read") == "time":
+ index = int((self.sample_time - self.started) / rule["interval_seconds"])
+ else:
+ index = self.indexes.get(key, 0)
+ if self.advance_reads:
+ self.indexes[key] = index + 1
+ return values[
+ (
+ index % len(values)
+ if rule.get("repeat", True)
+ else min(index, len(values) - 1)
+ )
+ ]
+
+ def tag_values(self, name):
+ """Return one generated typed value per request; static tags honor writes."""
+ if name in self.sampled:
+ return self.sampled[name]
+ tag, rule = self.tags[name], self.config["tags"][name]
+ values = encode_tag_value(
+ tag, self.rule_value(("tag", name), rule, tag.table, tag)
+ )
+ if rule.get("mode", "static") == "static":
+ values = [
+ self.values.get((tag.table, tag.address + offset), value)
+ for offset, value in enumerate(values)
+ ]
+ self.sampled[name] = values
+ return values
+
+ def available(self, table, address):
+ """Check address availability without allocating its default value."""
+ settings = self.config["tables"][table]
+ return (
+ settings["unmapped"] == "default"
+ or any(
+ rule["start"] <= address <= rule["end"] for rule in settings["ranges"]
+ )
+ or any(
+ tag.table == table and tag.address <= address < tag.stop
+ for tag in self.tags.values()
+ )
+ )
+
+ def read(self, table, address, count):
+ """Produce sparse raw values, with full-width tag samples for partial reads."""
+ result = []
+ for current in range(address, address + count):
+ name = next(
+ (
+ name
+ for name, tag in self.tags.items()
+ if tag.table == table and tag.address <= current < tag.stop
+ ),
+ None,
+ )
+ if name:
+ result.append(self.tag_values(name)[current - self.tags[name].address])
+ continue
+ settings = self.config["tables"][table]
+ rule = next(
+ (
+ rule
+ for rule in settings["ranges"]
+ if rule["start"] <= current <= rule["end"]
+ ),
+ None,
+ )
+ if rule:
+ key = (table, current)
+ value = self.rule_value(key, rule, table)
+ if rule.get("mode", "static") == "static":
+ value = self.values.get(key, value)
+ else:
+ value = self.values.get((table, current), settings["default"])
+ result.append(int(value))
+ return result
+
+ async def hook(self, name, *args):
+ """Invoke optional synchronous/async trusted code against pending state."""
+ if name in self.hooks:
+ async with asyncio.timeout(3):
+ result = self.hooks[name](HookDevice(self), *args)
+ if inspect.isawaitable(result):
+ await result
+
+ async def transaction(self, callback, advance_reads=False):
+ """Serialize local operations and roll back all pending hook/rule changes."""
+ async with self.lock:
+ before = (dict(self.values), dict(self.indexes), self.rng.getstate())
+ self.sampled = {}
+ self.sample_time = self.clock()
+ self.advance_reads = advance_reads
+ try:
+ return await callback()
+ except BaseException:
+ self.values, self.indexes = before[:2]
+ self.rng.setstate(before[2])
+ raise
+ finally:
+ self.sampled = {}
+
+ async def access(self, table, address, count, values=None):
+ """Apply one request; validate its entire range before hooks or mutations."""
+ if not all(
+ self.available(table, current)
+ for current in range(address, address + count)
+ ):
+ raise CommandError("Illegal data address")
+
+ async def execute():
+ names = [
+ name
+ for name, tag in self.tags.items()
+ if tag.table == table
+ and tag.address < address + count
+ and address < tag.stop
+ ]
+ if values is None:
+ await self.hook("on_read", tuple(names))
+ return self.read(table, address, count)
+ for offset, value in enumerate(values):
+ self.values[table, address + offset] = int(value)
+ for name in names:
+ tag = self.tags[name]
+ if address <= tag.address and tag.stop <= address + count:
+ raw = values[tag.address - address : tag.stop - address]
+ self.sampled[name] = list(raw)
+ await self.hook("on_write", name, decode_tag_value(tag, raw))
+ return list(values)
+
+ return await self.transaction(execute, advance_reads=values is None)
+
+ async def tick(self, elapsed):
+ """Apply one periodic hook atomically; caller records errors and keeps
+ running.
+ """
+
+ async def execute():
+ await self.hook("on_tick", elapsed)
+
+ await self.transaction(execute)
diff --git a/src/ctmodbus/tag_deletion.py b/src/ctmodbus/tag_deletion.py
new file mode 100644
index 0000000..a6f8e3e
--- /dev/null
+++ b/src/ctmodbus/tag_deletion.py
@@ -0,0 +1,109 @@
+"""Confirmed tag deletion with atomic removal of saved server tag rules."""
+
+import asyncio
+
+from ctui import CommandError, CommandResult, ConfirmationRequired
+
+from ctmodbus import server_config
+
+
+class TagDeletionMixin:
+ """Prepare exact deletion targets under dispatch guards before confirmation."""
+
+ async def plan_tag_deletion(
+ self, name=None, all_tags=False, remove_server_rules=False, confirm=False
+ ):
+ """Validate exclusive NAME/--all selection and snapshot affected config.
+
+ Referenced rules require --remove-server-rules. Bulk deletion, rule
+ removal, or retained hooks require approval unless --confirm is supplied.
+ Never inspect or change companion Python files; warn about their references.
+ """
+ if (name is None) == (not all_tags):
+ raise CommandError("Specify a tag name or --all, never both")
+ selected = await self.tags.list() if all_tags else [await self.tags.get(name)]
+ names = tuple(tag.name for tag in selected)
+ if not names:
+ return {"names": names}
+ stored = (await self.configs.list()).get(server_config.CONFIG_NAME)
+ config = server_config.validate(stored) if stored is not None else None
+ references = sorted(set(names) & set(config["tags"])) if config else []
+ if references and not remove_server_rules:
+ raise CommandError(
+ "Saved server tag rules reference: "
+ + ", ".join(references)
+ + ". Use --remove-server-rules to delete these entries with the tags."
+ )
+ warning = (
+ "Warning: retained Python hooks may reference deleted tags; "
+ "update them manually."
+ if config and config["hooks"]
+ else ""
+ )
+ message = (
+ f"Delete {len(names)} project tags and remove "
+ f"{len(references)} server tag rules?"
+ )
+ if warning:
+ message += "\n" + warning
+ message += "\nUse --confirm to approve without prompting."
+ replacement = None
+ if references:
+ for target in references:
+ del config["tags"][target]
+ replacement = server_config.validate(config)
+ return {
+ "names": names,
+ "stored": stored,
+ "replacement": replacement,
+ "rule_count": len(references),
+ "warning": warning,
+ "message": message,
+ "needs_confirmation": bool(all_tags or references or warning)
+ and not confirm,
+ }
+
+ async def prepare_tag_deletion(self, text, kwargs):
+ """Confirm through ctui's requesting UI callback, retaining exact targets.
+
+ CLI without --confirm raises ConfirmationRequired. Decline or cancellation
+ changes nothing. Project/tag/server guards remain reserved during approval.
+ """
+ item, arguments = self.commands.resolve(text)
+ arguments = await item.expand_unique_arguments(arguments, self)
+ plan = await self.plan_tag_deletion(**item.parse_args(arguments))
+ if plan.get("needs_confirmation"):
+ if not await self.confirm_replacement(plan["message"], kwargs):
+ return CommandResult.rejected()
+ self._prepared_tag_deletions[asyncio.current_task()] = plan
+ return None
+
+ async def delete_tag_definitions(
+ self, name, all_tags, remove_server_rules, confirm
+ ):
+ """Commit the exact prepared deletion and server rule changes together.
+
+ Direct method calls still validate and require --confirm for destructive
+ selections. Dispatch handles normal UI approval and result MessageDialogs.
+ Raw rules, hooks, saved settings and external files remain unchanged.
+ """
+ plan = self._prepared_tag_deletions.get(asyncio.current_task())
+ if plan is None:
+ plan = await self.plan_tag_deletion(
+ name, all_tags, remove_server_rules, confirm
+ )
+ if plan.get("needs_confirmation"):
+ raise ConfirmationRequired(plan["message"])
+ if not plan["names"]:
+ return "No tags to delete."
+ await self.tags.delete_selected(
+ plan["names"],
+ server_config.CONFIG_NAME,
+ plan["stored"],
+ plan["replacement"],
+ )
+ result = (
+ f"Deleted {len(plan['names'])} project tags; "
+ f"removed {plan['rule_count']} server tag rules."
+ )
+ return result + ("\n" + plan["warning"] if plan["warning"] else "")
diff --git a/src/ctmodbus/tags.py b/src/ctmodbus/tags.py
new file mode 100644
index 0000000..19f8efa
--- /dev/null
+++ b/src/ctmodbus/tags.py
@@ -0,0 +1,799 @@
+"""Project-scoped typed Modbus tags and portable TOML files.
+
+Pure codecs operate on validated Tag definitions and raw Modbus values.
+TagStore persists definitions in the active ctui SQLite project; commands add
+CLI validation and shared protocol operations. Names are case-sensitive.
+Byte order defaults to big within a word and word order to little across words.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import math
+import os
+import re
+import sqlite3
+import struct
+import tomllib
+from contextlib import closing
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+from ctui import (
+ Argument,
+ CommandError,
+ CommandResult,
+ IntegerSpan,
+ PathCompleter,
+ command,
+)
+from tabulate import tabulate
+
+from ctmodbus.formatting import timestamp
+from ctmodbus.integers import INTEGER_PATTERN, parse_integer
+
+TAG_FIELDS = {"table", "address", "type", "byte_order", "word_order"}
+
+TABLES = ("coils", "discrete_inputs", "input_registers", "holding_registers")
+CREATE_TABLES = {
+ "coil": "coils",
+ "discrete_input": "discrete_inputs",
+ "input_register": "input_registers",
+ "holding_register": "holding_registers",
+}
+REGISTER_TABLES = ("input_registers", "holding_registers")
+WRITABLE_TABLES = ("coils", "holding_registers")
+TYPE_FORMATS = {
+ "uint8": (1, "B"),
+ "int8": (1, "b"),
+ "uint16": (2, "H"),
+ "int16": (2, "h"),
+ "uint32": (4, "I"),
+ "int32": (4, "i"),
+ "uint64": (8, "Q"),
+ "int64": (8, "q"),
+ "float32": (4, "f"),
+ "float64": (8, "d"),
+}
+TYPES = ("bool", *TYPE_FORMATS)
+NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_.-]*\Z")
+
+
+@dataclass(frozen=True)
+class Tag:
+ """Immutable definition of one bit or a scalar occupying 1–4 registers.
+
+ Construction alone does not validate; call validate() at input boundaries.
+ table uses plural protocol identifiers even though tags create takes singular
+ names. Addresses are zero-based; type derives width. No connection identity
+ or current value is stored. Eight-bit types still occupy a complete register.
+ """
+
+ name: str
+ table: str
+ address: int
+ type: str
+ byte_order: str = "big"
+ word_order: str = "little"
+
+ @property
+ def count(self):
+ """Return the number of Modbus addresses occupied by the tag."""
+ if self.type == "bool":
+ return 1
+ size, _ = TYPE_FORMATS[self.type]
+ return 1 if size == 1 else size // 2
+
+ @property
+ def stop(self):
+ """Return the exclusive end address."""
+ return self.address + self.count
+
+ def validate(self):
+ """Return self when valid; raise CommandError for invalid definitions.
+
+ Require NAME_PATTERN, supported table/type/order, and the entire derived
+ span inside 0..65535. Bool uses bit tables; numeric types use register
+ tables. Explicit order options on bool are rejected at command/import
+ boundaries, not by this internal representation.
+ """
+ if not isinstance(self.name, str) or not NAME_PATTERN.fullmatch(self.name):
+ raise CommandError(
+ "Tag names must start with a letter or underscore and contain only "
+ "letters, digits, underscores, dots, or hyphens"
+ )
+ if self.table not in TABLES:
+ raise CommandError(f"Tag table must be one of: {', '.join(TABLES)}")
+ if self.type not in TYPES:
+ raise CommandError(f"Tag type must be one of: {', '.join(TYPES)}")
+ if self.type == "bool" and self.table not in TABLES[:2]:
+ raise CommandError("bool tags require coils or discrete_inputs")
+ if self.type != "bool" and self.table not in REGISTER_TABLES:
+ raise CommandError(
+ "Numeric tags require input_registers or holding_registers"
+ )
+ if self.byte_order not in ("big", "little"):
+ raise CommandError("byte-order must be big or little")
+ if self.word_order not in ("big", "little"):
+ raise CommandError("word-order must be big or little")
+ if (
+ type(self.address) is not int
+ or not 0 <= self.address <= 65535
+ or self.stop > 65536
+ ):
+ raise CommandError("Tag addresses must be between 0 and 65535")
+ return self
+
+ def as_dict(self):
+ """Return a stable TOML-compatible representation."""
+ values = {"table": self.table, "address": self.address, "type": self.type}
+ if self.type != "bool":
+ values.update(
+ byte_order=self.byte_order,
+ word_order=self.word_order,
+ )
+ return values
+
+
+class TagStore:
+ """Persist tags using the backend's currently open SQLite connection.
+
+ No live values or transport identities are stored. Methods create the table
+ lazily, and mutations commit then touch project metadata. The backend owns
+ connection lifetime. Calls are not protected by a store lock; callers must
+ coordinate transactions and project changes. Unexpected SQL errors propagate.
+ """
+
+ CREATE = """CREATE TABLE IF NOT EXISTS tags(
+ name TEXT PRIMARY KEY, table_name TEXT NOT NULL, address INTEGER NOT NULL,
+ type_name TEXT NOT NULL, byte_order TEXT NOT NULL, word_order TEXT NOT NULL)"""
+
+ def __init__(self, backend):
+ """Retain the project backend without opening or creating a database."""
+ self.backend = backend
+
+ async def ensure(self):
+ """Create the table if absent and commit; return None.
+
+ ctui migrations cover older projects, but new-project initialization
+ does not execute those migrations, so reads also call this lazy hook.
+ It commits the shared connection and must not interleave another writer.
+ """
+ await self.backend.connection.execute(self.CREATE)
+ await self.backend.connection.commit()
+
+ @staticmethod
+ def _row(row):
+ """Return a validated Tag from the six-column stored tuple."""
+ return Tag(*row).validate()
+
+ async def list(self):
+ """Return all active-project tags ordered by name."""
+ await self.ensure()
+ cursor = await self.backend.connection.execute(
+ "SELECT name, table_name, address, type_name, byte_order, word_order "
+ "FROM tags ORDER BY name"
+ )
+ return [self._row(row) for row in await cursor.fetchall()]
+
+ async def get(self, name):
+ """Return the exact-name Tag; raise CommandError if absent or invalid."""
+ await self.ensure()
+ cursor = await self.backend.connection.execute(
+ "SELECT name, table_name, address, type_name, byte_order, word_order "
+ "FROM tags WHERE name = ?",
+ (name,),
+ )
+ row = await cursor.fetchone()
+ if row is None:
+ raise CommandError(f"Unknown tag: {name!r}")
+ return self._row(row)
+
+ async def names(self):
+ """Return a set of active-project names after validating stored definitions."""
+ return {tag.name for tag in await self.list()}
+
+ async def save(self, tag, *, replace=False):
+ """Validate and commit tag; return None, replacing only if requested.
+
+ Duplicate names become CommandError. Roll back SQL failures; other
+ exceptions propagate. Metadata touch happens after the data commit.
+ """
+ tag.validate()
+ await self.ensure()
+ sql = (
+ "INSERT OR REPLACE INTO tags VALUES (?, ?, ?, ?, ?, ?)"
+ if replace
+ else "INSERT INTO tags VALUES (?, ?, ?, ?, ?, ?)"
+ )
+ try:
+ await self.backend.connection.execute(
+ sql,
+ (
+ tag.name,
+ tag.table,
+ tag.address,
+ tag.type,
+ tag.byte_order,
+ tag.word_order,
+ ),
+ )
+ await self.backend.connection.commit()
+ except Exception as error:
+ await self.backend.connection.rollback()
+ if "UNIQUE constraint failed" in str(error):
+ raise CommandError(f"Tag {tag.name!r} already exists") from error
+ raise
+ await self.backend.touch()
+
+ async def delete(self, name):
+ """Delete NAME and return None; CommandError rejects absent/invalid tags."""
+ await self.get(name)
+ await self.backend.connection.execute(
+ "DELETE FROM tags WHERE name = ?", (name,)
+ )
+ await self.backend.connection.commit()
+ await self.backend.touch()
+
+ async def delete_selected(
+ self, names, config_name, expected_config, replacement=None
+ ):
+ """Atomically delete exact names and optionally replace their server config.
+
+ Caller validates the replacement and reserves tag/project/server edits.
+ A separate SQLite transaction prevents unrelated ctui commits from
+ exposing partial changes. Reject a changed config or missing selected
+ row before committing; database errors roll back both tables. There are
+ no cancellation checkpoints during the short synchronous transaction.
+ Project metadata touch follows the commit, as for imports.
+ """
+ await self.ensure()
+ cursor = await self.backend.connection.execute("PRAGMA database_list")
+ path = next(row[2] for row in await cursor.fetchall() if row[1] == "main")
+ try:
+ with closing(sqlite3.connect(path, timeout=0)) as connection:
+ with connection:
+ connection.execute("BEGIN IMMEDIATE")
+ stored = connection.execute(
+ "SELECT value FROM configs WHERE name = ?", (config_name,)
+ ).fetchone()
+ current = json.loads(stored[0]) if stored else None
+ if current != expected_config:
+ raise CommandError(
+ "Server configuration changed; retry tag deletion"
+ )
+ for name in names:
+ removed = connection.execute(
+ "DELETE FROM tags WHERE name = ?", (name,)
+ )
+ if removed.rowcount != 1:
+ raise CommandError(f"Tag {name!r} changed; retry deletion")
+ if replacement is not None:
+ connection.execute(
+ "UPDATE configs SET value = ? WHERE name = ?",
+ (json.dumps(replacement), config_name),
+ )
+ except sqlite3.Error as error:
+ raise CommandError(
+ f"Tag deletion failed; tags and server rules were rolled back: {error}"
+ ) from error
+ await self.backend.touch()
+
+ async def rename(self, name, new_name):
+ """Rename NAME and return None without changing its definition.
+
+ Invalid/missing names and duplicate destinations raise CommandError;
+ SQL failures roll back and propagate. Touch metadata after committing.
+ """
+ Tag(new_name, "coils", 0, "bool").validate()
+ await self.get(name)
+ try:
+ await self.backend.connection.execute(
+ "UPDATE tags SET name = ? WHERE name = ?", (new_name, name)
+ )
+ await self.backend.connection.commit()
+ except Exception as error:
+ await self.backend.connection.rollback()
+ if "UNIQUE constraint failed" in str(error):
+ raise CommandError(f"Tag {new_name!r} already exists") from error
+ raise
+ await self.backend.touch()
+
+ async def clear(self):
+ """Delete every tag in the active project."""
+ await self.ensure()
+ await self.backend.connection.execute("DELETE FROM tags")
+ await self.backend.connection.commit()
+ await self.backend.touch()
+
+ async def import_all(self, tags, *, replace=False):
+ """Commit prevalidated tags in one transaction and return None.
+
+ Use a separate SQLite connection so unrelated ctui commits cannot
+ commit partial imports. Apply a short synchronous transaction without
+ cancellation checkpoints; all rows commit or roll back together. Reject
+ a busy database immediately rather than blocking the event loop.
+ Caller guards project switching and confirms replacement policy.
+ Metadata touch follows the data commit.
+ """
+ for tag in tags:
+ tag.validate()
+ await self.ensure()
+ cursor = await self.backend.connection.execute("PRAGMA database_list")
+ databases = await cursor.fetchall()
+ path = next(row[2] for row in databases if row[1] == "main")
+ sql = (
+ "INSERT OR REPLACE INTO tags VALUES (?, ?, ?, ?, ?, ?)"
+ if replace
+ else "INSERT INTO tags VALUES (?, ?, ?, ?, ?, ?)"
+ )
+ with closing(sqlite3.connect(path, timeout=0)) as connection:
+ with connection:
+ connection.executemany(
+ sql,
+ [
+ (
+ tag.name,
+ tag.table,
+ tag.address,
+ tag.type,
+ tag.byte_order,
+ tag.word_order,
+ )
+ for tag in tags
+ ],
+ )
+ await self.backend.touch()
+
+
+def parse_tag_value(tag, text):
+ """Return bool, int, or float parsed for an already validated tag.
+
+ Bool accepts case-insensitive 0/1, false/true, off/on. Integer syntax follows
+ parse_integer; unsigned nondecimal literals fitting a signed tag's bit width
+ use two's complement, while explicit signs and decimal stay numeric. Floats
+ accept decimal/scientific text or integer literals converted numerically,
+ never raw IEEE bits. Reject non-finite input with CommandError. Integer and
+ float32 range validation is deferred to encode_tag_value.
+ """
+ if tag.type == "bool":
+ values = {
+ "0": False,
+ "false": False,
+ "off": False,
+ "1": True,
+ "true": True,
+ "on": True,
+ }
+ try:
+ return values[text.lower()]
+ except KeyError as error:
+ raise CommandError(
+ "Boolean tag values must be 0/1, false/true, or off/on"
+ ) from error
+ try:
+ if tag.type.startswith("float"):
+ try:
+ value = float(text.replace("_", ""))
+ except ValueError:
+ value = float(parse_integer(text))
+ if not math.isfinite(value):
+ raise ValueError("non-finite values are not supported")
+ return value
+ value = parse_integer(text)
+ if tag.type.startswith("int"):
+ match = INTEGER_PATTERN.fullmatch(text)
+ prefix = (match.group("prefix") or "0d").lower()
+ if not match.group("sign") and prefix != "0d":
+ size, _ = TYPE_FORMATS[tag.type]
+ bits = size * 8
+ if 2 ** (bits - 1) <= value < 2**bits:
+ value -= 2**bits
+ return value
+ except (ValueError, OverflowError) as error:
+ raise CommandError(f"Invalid {tag.type} value {text!r}: {error}") from error
+
+
+def encode_tag_value(tag, value):
+ """Return wire integers for a validated tag and parsed value.
+
+ Bool returns one 0/1 value. Numeric values use struct widths and the tag's
+ byte/word order; overflow becomes CommandError. Eight-bit values zero-pad
+ the unused byte (including negative int8), deliberately avoiding a
+ read-modify-write race. This function assumes parse_tag_value already
+ rejected non-finite floats; it is not a general input-validation boundary.
+ """
+ if tag.type == "bool":
+ return [int(value)]
+ size, format_code = TYPE_FORMATS[tag.type]
+ try:
+ packed = struct.pack(">" + format_code, value)
+ except (struct.error, OverflowError) as error:
+ raise CommandError(f"Value is outside the range for {tag.type}") from error
+ if size == 1:
+ packed = b"\x00" + packed
+ words = [packed[index : index + 2] for index in range(0, len(packed), 2)]
+ if tag.word_order == "little":
+ words.reverse()
+ if tag.byte_order == "little":
+ words = [word[::-1] for word in words]
+ return [int.from_bytes(word, "big") for word in words]
+
+
+def decode_tag_value(tag, values):
+ """Return a Python scalar from exactly tag.count validated wire values.
+
+ Reverse byte/word transforms and ignore the unused byte for 8-bit tags.
+ Raw floating-point NaN/infinity can be decoded even though write input
+ rejects them. Caller validates lengths and wire ranges; malformed direct
+ calls may raise IndexError, OverflowError, or struct.error.
+ """
+ if tag.type == "bool":
+ return bool(values[0])
+ words = [int(value).to_bytes(2, "big") for value in values]
+ if tag.byte_order == "little":
+ words = [word[::-1] for word in words]
+ if tag.word_order == "little":
+ words.reverse()
+ packed = b"".join(words)
+ size, format_code = TYPE_FORMATS[tag.type]
+ if size == 1:
+ packed = packed[1:]
+ return struct.unpack(">" + format_code, packed)[0]
+
+
+def parse_tag_document(path):
+ """Synchronously read PATH and return validated Tag objects in file order.
+
+ Require ctmodbus-tags format/version 1 and a tags table; reject unknown
+ per-tag fields and Boolean order options. Convert file, TOML, and definition
+ failures to CommandError. No project mutations occur here. Whole files are
+ read without a size limit; callers currently invoke this on the event loop.
+ """
+ try:
+ with path.open("rb") as stream:
+ document = tomllib.load(stream)
+ except (OSError, tomllib.TOMLDecodeError) as error:
+ raise CommandError(f"Cannot import tags: {error}") from error
+ unknown = set(document) - {"format", "version", "tags"}
+ if unknown:
+ raise CommandError(
+ f"Unknown tag export keys: {', '.join(sorted(unknown))}. "
+ "Allowed keys: format, version, tags"
+ )
+ if document.get("format") != "ctmodbus-tags":
+ raise CommandError('Tag export requires format = "ctmodbus-tags"')
+ if type(document.get("version")) is not int or document["version"] != 1:
+ raise CommandError("Tag export requires integer version = 1")
+ definitions = document.get("tags")
+ if not isinstance(definitions, dict):
+ raise CommandError("Tag export must contain a tags table")
+ tags = []
+ for name, values in definitions.items():
+ if not isinstance(values, dict):
+ raise CommandError(f"Tag {name!r} must be a TOML table")
+ unknown = set(values) - TAG_FIELDS
+ if unknown:
+ raise CommandError(
+ f"Tag {name!r} has unknown fields: {', '.join(sorted(unknown))}"
+ )
+ try:
+ if values.get("type") == "bool" and (
+ "byte_order" in values or "word_order" in values
+ ):
+ raise CommandError("Boolean tags do not accept byte or word order")
+ tag = Tag(
+ name=name,
+ table=values["table"],
+ address=values["address"],
+ type=values["type"],
+ byte_order=values.get("byte_order", "big"),
+ word_order=values.get("word_order", "little"),
+ ).validate()
+ except (KeyError, TypeError, CommandError) as error:
+ raise CommandError(f"Invalid tag {name!r}: {error}") from error
+ tags.append(tag)
+ return tags
+
+
+def export_tag_document(tags):
+ """Return versioned TOML text for validated tags in caller-supplied order.
+
+ Quote names, include numeric order settings, and emit an empty tags table
+ even with no definitions. Perform no file I/O or additional validation.
+ """
+ lines = ['format = "ctmodbus-tags"', "version = 1", "", "[tags]"]
+ for tag in tags:
+ lines.extend(("", f"[tags.{json.dumps(tag.name)}]"))
+ for key, value in tag.as_dict().items():
+ rendered = json.dumps(value) if isinstance(value, str) else str(value)
+ lines.append(f"{key} = {rendered}")
+ return "\n".join(lines) + "\n"
+
+
+async def complete_tags(context):
+ """Complete tag names from the active project."""
+ return sorted(await context.app.tags.names())
+
+
+TAG_ORDER_ARGUMENTS = {
+ "byte_order": Argument(flags=("--byte-order",)),
+ "word_order": Argument(flags=("--word-order",)),
+}
+
+
+class TagCommandMixin:
+ """Commands for typed tags using app-owned tags and protocol services.
+
+ ctui signatures/decorators define command syntax. Read/write/create/rename/
+ delete return append CommandResult; listing, inspection, and file commands
+ return text. Expected input/protocol errors use CommandError. Store and
+ unexpected file errors can propagate; no mixin-wide serialization is added.
+ """
+
+ @command(name="tags create", arguments=TAG_ORDER_ARGUMENTS)
+ async def tag_create(
+ self,
+ name: str,
+ table: Literal["coil", "discrete_input", "input_register", "holding_register"],
+ address: int,
+ type_name: Literal[
+ "bool",
+ "uint8",
+ "int8",
+ "uint16",
+ "int16",
+ "uint32",
+ "int32",
+ "uint64",
+ "int64",
+ "float32",
+ "float64",
+ ],
+ byte_order: Literal["big", "little"] | None = None,
+ word_order: Literal["little", "big"] | None = None,
+ ):
+ """Create NAME TABLE ADDRESS TYPE with optional byte/word order flags.
+
+ Accept singular tables and derive width from type; normalize storage to
+ plural tables. Defaults are big bytes/little words. Reject duplicate
+ names and Boolean order flags with CommandError. Overlap is legal and
+ produces a warning after save because alternate decodings are useful.
+ """
+ if type_name == "bool" and (byte_order is not None or word_order is not None):
+ raise CommandError("Boolean tags do not accept byte or word order")
+ stored_table = CREATE_TABLES[table]
+ tag = Tag(
+ name,
+ stored_table,
+ address,
+ type_name,
+ byte_order or "big",
+ word_order or "little",
+ ).validate()
+ overlaps = [
+ item.name
+ for item in await self.tags.list()
+ if item.table == tag.table
+ and item.address < tag.stop
+ and tag.address < item.stop
+ ]
+ await self.tags.save(tag)
+ output = (
+ f"Created tag {name!r}: {stored_table} "
+ f"{address}-{tag.stop - 1} {type_name}"
+ )
+ if overlaps:
+ output += "\nWarning: overlaps tags: " + ", ".join(overlaps)
+ return CommandResult.append(output)
+
+ @command(name="tags list")
+ async def tag_list(self):
+ """List tags in the active project."""
+ tags = await self.tags.list()
+ if not tags:
+ return "No tags."
+ return tabulate(
+ [
+ (
+ tag.name,
+ tag.table,
+ tag.address,
+ tag.count,
+ tag.type,
+ "-" if tag.type == "bool" else tag.byte_order,
+ "-" if tag.type == "bool" else tag.word_order,
+ )
+ for tag in tags
+ ],
+ headers=("Name", "Table", "Address", "Count", "Type", "Byte", "Word"),
+ )
+
+ @command(name="tags show", arguments={"name": Argument(completer=complete_tags)})
+ async def tag_show(self, name: str):
+ """Show one tag and its derived address range."""
+ tag = await self.tags.get(name)
+ values = tag.as_dict()
+ values["range"] = f"{tag.address}-{tag.stop - 1}"
+ return "\n".join(f"{key}: {value}" for key, value in values.items())
+
+ @command(name="tags rename", arguments={"name": Argument(completer=complete_tags)})
+ async def tag_rename(self, name: str, new_name: str):
+ """Rename a tag in the active project."""
+ await self.tags.rename(name, new_name)
+ return CommandResult.append(f"Renamed tag {name!r} to {new_name!r}")
+
+ @command(
+ name="tags delete",
+ arguments={
+ "name": Argument(help="Tag name; omit with --all", completer=complete_tags),
+ "all_tags": Argument(flags=("--all",)),
+ "remove_server_rules": Argument(flags=("--remove-server-rules",)),
+ "confirm": Argument(flags=("--confirm",)),
+ },
+ )
+ async def tag_delete(
+ self,
+ name: str | None = None,
+ all_tags: bool = False,
+ remove_server_rules: bool = False,
+ confirm: bool = False,
+ ):
+ """Delete NAME or --all, guarding saved server references and active work.
+
+ Bulk deletion and removal of referenced server rules require --confirm.
+ Dispatch obtains Yes/No approval in TUI/WUI and returns a MessageDialog
+ result. CLI requires --confirm. SQL changes commit together or roll back;
+ raw rules, hooks, configuration settings, and external files are retained.
+ """
+ return await self.delete_tag_definitions(
+ name, all_tags, remove_server_rules, confirm
+ )
+
+ @command(
+ name="read tags",
+ arguments={
+ "names": Argument(
+ help="Comma-separated tag names; omit to read all",
+ completer=complete_tags,
+ )
+ },
+ )
+ async def read_tags(self, names: list[str] | None = None):
+ """Read all tags, or comma-separated names in the requested order.
+
+ Resolve every name before I/O; None selects name-sorted project tags.
+ Empty selections and unknown names raise CommandError. Hold one
+ shared connection reservation across the complete selection. Preserve
+ explicit order and duplicates. On failure or cancellation, report the
+ failed tag and completed decoded rows; remaining tags are not attempted.
+ This serializes our commands but is not a device-wide atomic snapshot.
+ """
+ requested = names
+ if requested is None:
+ requested = [tag.name for tag in await self.tags.list()]
+ if not requested:
+ raise CommandError("No tags are defined in the active project")
+ tags = [await self.tags.get(name) for name in requested]
+ rows = []
+ tag = tags[0]
+ try:
+ async with self.connection.operation() as (client, settings):
+ for tag in tags:
+ # Scalar tag widths are within the protocol request limits.
+ result = await self.read_reserved_chunks(
+ tag.table,
+ [IntegerSpan(tag.address, tag.count)],
+ client,
+ settings,
+ )
+ raw = [value for _, value in result]
+ rows.append(
+ (
+ tag.name,
+ decode_tag_value(tag, raw),
+ tag.type,
+ ",".join(f"0x{int(value):04X}" for value in raw),
+ )
+ )
+ except (CommandError, asyncio.CancelledError) as error:
+ if isinstance(error, asyncio.CancelledError):
+ self.connection.abort()
+ message = "Read cancelled; connection closed"
+ else:
+ message = str(error)
+ output = f"Read tags failed at {tag.name!r}: {message}"
+ if rows:
+ output += f"\nPartial read: {len(rows)} tags completed\n" + tabulate(
+ rows, headers=("Tag", "Value", "Type", "Raw")
+ )
+ remaining = len(tags) - len(rows) - 1
+ if remaining:
+ output += f"\n{remaining} remaining tags were not read"
+ raise CommandError(output) from error
+ return CommandResult.append(
+ f"{timestamp()} Read tags\n"
+ + tabulate(rows, headers=("Tag", "Value", "Type", "Raw"))
+ )
+
+ @command(name="write tag", arguments={"name": Argument(completer=complete_tags)})
+ async def write_tag(self, name: str, value: str):
+ """Execute ``write tag NAME VALUE`` through the validated write path.
+
+ Use ``--`` before negative positional values. Reject read-only tables
+ and bad encodings before I/O; return decoded input and raw acknowledged
+ values. write_values owns serialization and uncertain-write errors.
+ Acknowledgement does not establish independent device readback.
+ """
+ tag = await self.tags.get(name)
+ if tag.table not in WRITABLE_TABLES:
+ raise CommandError(f"{tag.table} tags are read-only")
+ typed = parse_tag_value(tag, value)
+ encoded = encode_tag_value(tag, typed)
+ result = await self.write_values(tag.table, tag.address, encoded)
+ return CommandResult.append(
+ f"{timestamp()} Write tag acknowledged: {name} = {typed!r}\n"
+ f"Raw: {', '.join(f'0x{item:04X}' for item in encoded)}\n"
+ f"{result.output}"
+ )
+
+ @command(
+ name="tags export",
+ arguments={
+ "path": Argument(help="Destination TOML file", completer=PathCompleter())
+ },
+ )
+ async def tag_export(self, path: Path):
+ """Write name-sorted project tags to PATH and return destination text.
+
+ Append .toml unless the existing suffix matches case-insensitively.
+ Synchronously write PATH.tmp then os.replace the destination. The rename
+ is atomic, but the fixed temporary name is not safe for concurrent
+ exports to the same path. OSError becomes CommandError after cleanup.
+ """
+ if path.suffix.lower() != ".toml":
+ path = path.with_name(path.name + ".toml")
+ temporary = path.with_name(path.name + ".tmp")
+ try:
+ temporary.write_text(
+ export_tag_document(await self.tags.list()), encoding="utf-8"
+ )
+ os.replace(temporary, path)
+ except OSError as error:
+ try:
+ temporary.unlink()
+ except FileNotFoundError:
+ pass
+ raise CommandError(f"Cannot export tags: {error}") from error
+ return f"Exported tags to {path}."
+
+ @command(
+ name="tags import",
+ arguments={
+ "path": Argument(help="TOML tag file to import", completer=PathCompleter()),
+ "replace": Argument(flags=("--replace",)),
+ },
+ )
+ async def tag_import(self, path: Path, replace: bool = False):
+ """Validate PATH and merge tags, returning the imported count as text.
+
+ Dispatch supplies validated, confirmed data for the current task and
+ guards project/tag mutations. Direct calls validate the file and never
+ prompt; callers must coordinate project changes themselves.
+ """
+ prepared = self._prepared_tag_imports.get(asyncio.current_task())
+ if prepared is None:
+ tags = parse_tag_document(path.expanduser())
+ else:
+ tags, replace = prepared
+ collisions = sorted({tag.name for tag in tags} & await self.tags.names())
+ if collisions and not replace:
+ raise CommandError(
+ "Tags already exist: "
+ + ", ".join(collisions)
+ + ". Re-run with --replace to overwrite them."
+ )
+ await self.tags.import_all(tags, replace=replace)
+ return f"Imported {len(tags)} tags."
diff --git a/tests/server-ascii.py b/tests/server-ascii.py
deleted file mode 100644
index 0c65856..0000000
--- a/tests/server-ascii.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import logging
-
-from pymodbus.datastore import (
- ModbusSequentialDataBlock,
- ModbusServerContext,
- ModbusSlaveContext,
- ModbusSparseDataBlock,
-)
-from pymodbus.device import ModbusDeviceIdentification
-from pymodbus.server.sync import StartSerialServer
-from pymodbus.transaction import ModbusBinaryFramer, ModbusRtuFramer
-from pymodbus.version import version
-
-FORMAT = (
- "%(asctime)-15s %(threadName)-15s"
- " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s"
-)
-logging.basicConfig(format=FORMAT)
-log = logging.getLogger()
-log.setLevel(logging.DEBUG)
-
-
-def run_server():
- store = ModbusSlaveContext(
- di=ModbusSequentialDataBlock(0, [17] * 100),
- co=ModbusSequentialDataBlock(0, [17] * 100),
- hr=ModbusSequentialDataBlock(0, [17] * 100),
- ir=ModbusSequentialDataBlock(0, [17] * 100),
- )
-
- context = ModbusServerContext(slaves=store, single=True)
-
- identity = ModbusDeviceIdentification()
- identity.VendorName = "Pymodbus"
- identity.ProductCode = "PM"
- identity.VendorUrl = "http://github.com/riptideio/pymodbus/"
- identity.ProductName = "Pymodbus Server"
- identity.ModelName = "Pymodbus Server"
- identity.MajorMinorRevision = version.short()
-
- # socat -d -d PTY,link=/tmp/ptyp0,raw,echo=0,ispeed=9600 PTY,link=/tmp/ttyp0,raw,echo=0,ospeed=9600
-
- StartSerialServer(context, identity=identity, port="/dev/ttyp0", timeout=1)
-
-
-if __name__ == "__main__":
- run_server()
diff --git a/tests/server-rtu.py b/tests/server-rtu.py
deleted file mode 100644
index 11eea69..0000000
--- a/tests/server-rtu.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import logging
-
-from pymodbus.datastore import (
- ModbusSequentialDataBlock,
- ModbusServerContext,
- ModbusSlaveContext,
- ModbusSparseDataBlock,
-)
-from pymodbus.device import ModbusDeviceIdentification
-from pymodbus.server.sync import StartSerialServer
-from pymodbus.transaction import ModbusBinaryFramer, ModbusRtuFramer
-from pymodbus.version import version
-
-FORMAT = (
- "%(asctime)-15s %(threadName)-15s"
- " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s"
-)
-logging.basicConfig(format=FORMAT)
-log = logging.getLogger()
-log.setLevel(logging.DEBUG)
-
-
-def run_server():
- store = ModbusSlaveContext(
- di=ModbusSequentialDataBlock(0, [17] * 100),
- co=ModbusSequentialDataBlock(0, [17] * 100),
- hr=ModbusSequentialDataBlock(0, [17] * 100),
- ir=ModbusSequentialDataBlock(0, [17] * 100),
- )
-
- context = ModbusServerContext(slaves=store, single=True)
-
- identity = ModbusDeviceIdentification()
- identity.VendorName = "Pymodbus"
- identity.ProductCode = "PM"
- identity.VendorUrl = "http://github.com/riptideio/pymodbus/"
- identity.ProductName = "Pymodbus Server"
- identity.ModelName = "Pymodbus Server"
- identity.MajorMinorRevision = version.short()
-
- # socat -d -d PTY,link=/tmp/ptyp0,raw,echo=0,ispeed=9600 PTY,link=/tmp/ttyp0,raw,echo=0,ospeed=9600
-
- StartSerialServer(
- context,
- framer=ModbusRtuFramer,
- identity=identity,
- port="/tmp/ttyp0",
- timeout=0.005,
- baudrate=9600,
- )
-
-
-if __name__ == "__main__":
- run_server()
diff --git a/tests/server-tcp.py b/tests/server-tcp.py
deleted file mode 100644
index 42cf866..0000000
--- a/tests/server-tcp.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import logging
-
-from pymodbus.datastore import (
- ModbusSequentialDataBlock,
- ModbusServerContext,
- ModbusSlaveContext,
- ModbusSparseDataBlock,
-)
-from pymodbus.device import ModbusDeviceIdentification
-from pymodbus.server.sync import StartTcpServer
-from pymodbus.transaction import ModbusBinaryFramer, ModbusRtuFramer
-from pymodbus.version import version
-
-FORMAT = (
- "%(asctime)-15s %(threadName)-15s"
- " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s"
-)
-logging.basicConfig(format=FORMAT)
-log = logging.getLogger()
-log.setLevel(logging.DEBUG)
-
-
-def run_server():
- store = ModbusSlaveContext(
- di=ModbusSequentialDataBlock(0, [17] * 100),
- co=ModbusSequentialDataBlock(0, [17] * 100),
- hr=ModbusSequentialDataBlock(0, [17] * 100),
- ir=ModbusSequentialDataBlock(0, [17] * 100),
- )
-
- context = ModbusServerContext(slaves=store, single=True)
-
- identity = ModbusDeviceIdentification()
- identity.VendorName = "Pymodbus"
- identity.ProductCode = "PM"
- identity.VendorUrl = "http://github.com/riptideio/pymodbus/"
- identity.ProductName = "Pymodbus Server"
- identity.ModelName = "Pymodbus Server"
- identity.MajorMinorRevision = version.short()
-
- StartTcpServer(context, identity=identity, address=("127.0.0.1", 5020))
-
-
-if __name__ == "__main__":
- run_server()
diff --git a/tests/server-tls.py b/tests/server-tls.py
deleted file mode 100644
index 95ff848..0000000
--- a/tests/server-tls.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import logging
-
-from pymodbus.datastore import (
- ModbusSequentialDataBlock,
- ModbusServerContext,
- ModbusSlaveContext,
- ModbusSparseDataBlock,
-)
-from pymodbus.device import ModbusDeviceIdentification
-from pymodbus.server.sync import StartTlsServer
-from pymodbus.transaction import ModbusBinaryFramer, ModbusRtuFramer
-from pymodbus.version import version
-
-FORMAT = (
- "%(asctime)-15s %(threadName)-15s"
- " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s"
-)
-logging.basicConfig(format=FORMAT)
-log = logging.getLogger()
-log.setLevel(logging.DEBUG)
-
-
-def run_server():
- store = ModbusSlaveContext(
- di=ModbusSequentialDataBlock(0, [17] * 100),
- co=ModbusSequentialDataBlock(0, [17] * 100),
- hr=ModbusSequentialDataBlock(0, [17] * 100),
- ir=ModbusSequentialDataBlock(0, [17] * 100),
- )
-
- context = ModbusServerContext(slaves=store, single=True)
-
- identity = ModbusDeviceIdentification()
- identity.VendorName = "Pymodbus"
- identity.ProductCode = "PM"
- identity.VendorUrl = "http://github.com/riptideio/pymodbus/"
- identity.ProductName = "Pymodbus Server"
- identity.ModelName = "Pymodbus Server"
- identity.MajorMinorRevision = version.short()
-
- StartTlsServer(
- context,
- identity=identity,
- certfile="server.crt",
- keyfile="server.key",
- address=("127.0.0.1", 8020),
- )
-
-
-if __name__ == "__main__":
- run_server()
diff --git a/tests/server-udp.py b/tests/server-udp.py
deleted file mode 100644
index b8878a0..0000000
--- a/tests/server-udp.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import logging
-
-from pymodbus.datastore import (
- ModbusSequentialDataBlock,
- ModbusServerContext,
- ModbusSlaveContext,
- ModbusSparseDataBlock,
-)
-from pymodbus.device import ModbusDeviceIdentification
-from pymodbus.server.sync import StartUdpServer
-from pymodbus.transaction import ModbusBinaryFramer, ModbusRtuFramer
-from pymodbus.version import version
-
-FORMAT = (
- "%(asctime)-15s %(threadName)-15s"
- " %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s"
-)
-logging.basicConfig(format=FORMAT)
-log = logging.getLogger()
-log.setLevel(logging.DEBUG)
-
-
-def run_server():
- store = ModbusSlaveContext(
- di=ModbusSequentialDataBlock(0, [17] * 100),
- co=ModbusSequentialDataBlock(0, [17] * 100),
- hr=ModbusSequentialDataBlock(0, [17] * 100),
- ir=ModbusSequentialDataBlock(0, [17] * 100),
- )
-
- context = ModbusServerContext(slaves=store, single=True)
-
- identity = ModbusDeviceIdentification()
- identity.VendorName = "Pymodbus"
- identity.ProductCode = "PM"
- identity.VendorUrl = "http://github.com/riptideio/pymodbus/"
- identity.ProductName = "Pymodbus Server"
- identity.ModelName = "Pymodbus Server"
- identity.MajorMinorRevision = version.short()
-
- StartUdpServer(context, identity=identity, address=("127.0.0.1", 5020))
-
-
-if __name__ == "__main__":
- run_server()
diff --git a/tests/server.py b/tests/server.py
new file mode 100644
index 0000000..91d01f3
--- /dev/null
+++ b/tests/server.py
@@ -0,0 +1,76 @@
+"""Local fixtures for automated tests and manual device-client checks.
+
+Example: uv run python tests/server.py tcp --port 5020
+"""
+
+import argparse
+import asyncio
+
+from pymodbus import FramerType
+from pymodbus.pdu.device import ModbusDeviceIdentification
+from pymodbus.server import ModbusSerialServer, ModbusTcpServer, ModbusUdpServer
+from pymodbus.simulator import DataType, SimData, SimDevice
+
+
+def device():
+ """Return unit 1 with fixed identity, 4096 bits and 512 registers per table.
+
+ Initial table values are False/True for coils/inputs and 17/42 for
+ holding/input registers in PyModbus simulator table order.
+ """
+ identity = ModbusDeviceIdentification()
+ identity.VendorName = "ControlThings"
+ identity.ProductCode = "ctmodbus-test"
+ identity.MajorMinorRevision = "1.0"
+ return SimDevice(
+ id=1,
+ identity=identity,
+ simdata=(
+ [SimData(0, count=4096, values=False, datatype=DataType.BITS)],
+ [SimData(0, count=4096, values=True, datatype=DataType.BITS)],
+ [SimData(0, count=512, values=17, datatype=DataType.REGISTERS)],
+ [SimData(0, count=512, values=42, datatype=DataType.REGISTERS)],
+ ),
+ )
+
+
+def make_server(transport, target="127.0.0.1", port=0):
+ """Return an unstarted local TCP/UDP or RTU/ASCII test server.
+
+ Network port 0 requests an ephemeral port; serial target is a device path
+ at 9600 baud. Caller supplies a supported transport and owns serve/shutdown.
+ PyModbus construction errors propagate.
+ """
+ if transport in ("tcp", "udp"):
+ cls = ModbusTcpServer if transport == "tcp" else ModbusUdpServer
+ return cls(device(), address=(target, port))
+ return ModbusSerialServer(
+ device(),
+ port=target,
+ framer=FramerType.RTU if transport == "rtu" else FramerType.ASCII,
+ baudrate=9600,
+ )
+
+
+async def main():
+ """Parse fixture CLI options, serve until stopped, and always shut down.
+
+ argparse reports invalid syntax via SystemExit. Server errors propagate
+ after cleanup; successful completion returns None.
+ """
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("transport", choices=("tcp", "udp", "rtu", "ascii"))
+ parser.add_argument(
+ "--target", default="127.0.0.1", help="Bind address or serial device path"
+ )
+ parser.add_argument("--port", type=int, default=5020)
+ args = parser.parse_args()
+ server = make_server(args.transport, args.target, args.port)
+ try:
+ await server.serve_forever()
+ finally:
+ await server.shutdown()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/tests/smoke_test.py b/tests/smoke_test.py
new file mode 100644
index 0000000..9f21586
--- /dev/null
+++ b/tests/smoke_test.py
@@ -0,0 +1,59 @@
+"""Run against an installed wheel or sdist, without an editable source tree."""
+
+import asyncio
+import os
+import subprocess
+import tempfile
+from importlib.metadata import version
+
+from ctmodbus.app import ModbusApp
+
+
+def main():
+ """Verify installed metadata, CLI help, and isolated project startup.
+
+ Run with an installed artifact, not an editable checkout. Assert package
+ versions and expected commands; subprocess failures propagate. Temporary
+ project storage is removed on exit. Return None after printing success.
+ """
+ assert version("ctmodbus").startswith("1.")
+ assert version("ctui").startswith("1.")
+ result = subprocess.run(
+ ["ctmodbus", "--help"], check=True, capture_output=True, text=True
+ )
+ assert "read" in result.stdout
+ assert "client" in result.stdout
+ assert "server" in result.stdout
+ assert "proxy" in result.stdout
+ with tempfile.TemporaryDirectory() as directory:
+ groups = subprocess.run(
+ [
+ "ctmodbus",
+ "-c",
+ "help read",
+ "-c",
+ "help client",
+ "-c",
+ "help server",
+ "-c",
+ "help client start",
+ "-c",
+ "help server start",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ env={**os.environ, "XDG_DATA_HOME": directory},
+ )
+ for command in ("holding_registers", "start", "config", "status", "tcp", "rtu"):
+ assert command in groups.stdout
+ app = ModbusApp(data_dir=directory)
+ status = asyncio.run(
+ app.run_cli(["-c", "project", "-c", "project configs show tcp-local"])
+ )
+ assert status == 0
+ print("Installed distribution smoke test passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..6e68460
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,600 @@
+"""Command and lifecycle regression tests using an injected async client."""
+
+import asyncio
+import io
+import tempfile
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+from ctui import CommandError, IntegerRanges
+
+from ctmodbus.app import ModbusApp
+from ctmodbus.connection import ConnectionSettings
+from ctmodbus.formatting import format_identification, format_values
+from ctmodbus.operations import read_chunks
+
+
+def response(**kwargs):
+ """Return a successful fake PDU with caller-selected response fields."""
+ return SimpleNamespace(isError=lambda: False, **kwargs)
+
+
+class FakeClient:
+ """Async client double with recorded calls, injected replies, and I/O gates.
+
+ A gate can suspend requests for cancellation/concurrency tests; peak tracks
+ overlap. It performs no real I/O and returns deterministic default PDUs.
+ """
+
+ def __init__(self):
+ """Initialize a disconnected fake with empty call history and unset I/O gate."""
+ self.connected = False
+ self.calls = []
+ self.reply = None
+ self.gate = None
+ self.started = asyncio.Event()
+ self.active = 0
+ self.peak = 0
+
+ async def connect(self):
+ """Mark the fake connected and return True without opening a transport."""
+ self.connected = True
+ return True
+
+ def close(self):
+ """Mark the fake disconnected; return None without external I/O."""
+ self.connected = False
+
+ def __getattr__(self, name):
+ """Return an async request stub for the requested PyModbus method name."""
+
+ async def call(**kwargs):
+ """Record arguments, await an optional gate, and return the selected fake PDU.
+
+ Always decrement active calls on failure or cancellation; injected reply
+ errors propagate to the tested application boundary.
+ """
+ self.calls.append((name, kwargs))
+ self.active += 1
+ self.peak = max(self.peak, self.active)
+ self.started.set()
+ try:
+ if self.gate:
+ await self.gate.wait()
+ if self.reply:
+ return self.reply(name, kwargs)
+ if name == "read_device_information":
+ return response(
+ information={0: b"Vendor", 2: b"1.0", 128: b"custom"}
+ )
+ if name.startswith("read_"):
+ count = kwargs["count"]
+ if name in ("read_coils", "read_discrete_inputs"):
+ return response(
+ function_code=1 if name == "read_coils" else 2,
+ bits=[True] * (((count + 7) // 8) * 8),
+ )
+ return response(
+ function_code=3 if name == "read_holding_registers" else 4,
+ registers=list(
+ range(kwargs["address"], kwargs["address"] + count)
+ ),
+ )
+ codes = {
+ "write_coil": 5,
+ "write_coils": 15,
+ "write_register": 6,
+ "write_registers": 16,
+ }
+ values = kwargs.get("values", [kwargs.get("value")])
+ return response(
+ function_code=codes[name],
+ address=kwargs["address"],
+ count=len(values),
+ bits=values,
+ registers=values,
+ )
+ finally:
+ self.active -= 1
+
+ return call
+
+
+class CommandTests(unittest.IsolatedAsyncioTestCase):
+ """Exercise shared dispatch with a fresh temporary project and fake client."""
+
+ async def asyncSetUp(self):
+ """Open a fresh temporary project and inject a deterministic fake client."""
+ self.directory = tempfile.TemporaryDirectory()
+ self.client = FakeClient()
+ self.app = ModbusApp(
+ data_dir=self.directory.name, client_factory=lambda settings: self.client
+ )
+ await self.app.backend.open()
+
+ async def asyncTearDown(self):
+ """Stop application tasks, close project storage, and remove temporary data."""
+ await self.app.on_stop()
+ await self.app.backend.close()
+ self.directory.cleanup()
+
+ async def connect(self):
+ """Open the test TCP session using the injected fake client."""
+ await self.app.dispatch("client start tcp localhost --unit 7")
+
+ async def test_all_transport_commands(self):
+ """Verify each transport command stores settings and closes cleanly."""
+ for transport in ("tcp", "udp", "rtu", "ascii"):
+ await self.app.dispatch(
+ f"client start {transport} target --unit 7 --timeout 0.2"
+ )
+ self.assertEqual(self.app.connection.settings.transport, transport)
+ await self.app.dispatch("client stop")
+
+ async def test_range_chunking_and_unit(self):
+ """Assert chunk boundaries, input order, and configured unit on every request."""
+ await self.connect()
+ result = await self.app.dispatch(
+ "read holding_registers 0,124-126,65535 --max-count 2"
+ )
+ self.assertTrue(result.append_output)
+ self.assertEqual(
+ [(args["address"], args["count"]) for _, args in self.client.calls],
+ [(0, 1), (124, 2), (126, 1), (65535, 1)],
+ )
+ self.assertTrue(all(args["device_id"] == 7 for _, args in self.client.calls))
+
+ async def test_read_types_and_bit_padding(self):
+ """Ensure all read tables trim bit padding and record only requested values."""
+ await self.connect()
+ for kind in (
+ "coils",
+ "discrete_inputs",
+ "input_registers",
+ "holding_registers",
+ ):
+ result = await self.app.dispatch(f"read {kind} 0-2")
+ self.assertIn(f"Read {kind}", result.output)
+ records = await self.app.records.query(direction="received")
+ self.assertTrue(all(len(row.decoded["values"]) == 3 for row in records))
+
+ async def test_write_functions_and_acknowledgements(self):
+ """Verify single/multiple values select the corresponding write functions."""
+ await self.connect()
+ for kind, values in (
+ ("coils", "1"),
+ ("coils", "0,1,0"),
+ ("holding_registers", "65535"),
+ ("holding_registers", "0,123,65535"),
+ ):
+ result = await self.app.dispatch(f"write {kind} 20 {values}")
+ self.assertIn("Write acknowledged", result.output)
+ self.assertEqual(
+ [name for name, _ in self.client.calls],
+ ["write_coil", "write_coils", "write_register", "write_registers"],
+ )
+
+ async def test_register_radix_and_signed_writes(self):
+ """Encode mixed literals into one request and record unsigned wire words."""
+ await self.connect()
+ for literals, expected in (
+ ("0x8000,0b0100110,0d800", [32768, 38, 800]),
+ (
+ "-- -32768,-1,0,32767,32768,65535",
+ [32768, 65535, 0, 32767, 32768, 65535],
+ ),
+ (
+ "0XfFfF,0B1010,0o177777,0D8_00,+0x10,0008",
+ [65535, 10, 65535, 800, 16, 8],
+ ),
+ ("-- -0x8000,-0b1,-0o10,-0d800", [32768, 65535, 65528, 64736]),
+ ('"0x8000, 0b0100110, 0d800"', [32768, 38, 800]),
+ ):
+ with self.subTest(literals=literals):
+ before = len(self.client.calls)
+ result = await self.app.dispatch(
+ f"write holding_registers 100 {literals}"
+ )
+ self.assertIn("Write acknowledged", result.output)
+ self.assertEqual(len(self.client.calls), before + 1)
+ self.assertEqual(
+ self.client.calls[-1],
+ (
+ "write_registers",
+ {"address": 100, "values": expected, "device_id": 7},
+ ),
+ )
+ records = await self.app.records.query(direction="received")
+ self.assertEqual(len(records), 5)
+ self.assertIn(
+ [32768, 65535, 65528, 64736], [row.decoded["values"] for row in records]
+ )
+
+ async def test_register_radix_single_and_maximum_write(self):
+ """Retain single-write selection and accept exactly 123 encoded words."""
+ await self.connect()
+ await self.app.dispatch("write holding_registers 65535 -- -0b1")
+ self.assertEqual(
+ self.client.calls[-1],
+ ("write_register", {"address": 65535, "value": 65535, "device_id": 7}),
+ )
+ await self.app.dispatch(
+ "write holding_registers 65413 " + ",".join(["0xffff"] * 123)
+ )
+ self.assertEqual(len(self.client.calls), 2)
+ self.assertEqual(self.client.calls[-1][0], "write_registers")
+ self.assertEqual(self.client.calls[-1][1]["values"], [65535] * 123)
+
+ async def test_invalid_register_literals_do_not_send(self):
+ """Reject invalid later values and spans before any part of a write is sent."""
+ await self.connect()
+ for literals in (
+ "1,0x10000",
+ "1,0d65536",
+ "1,-32769",
+ "1,-0x8001",
+ "1,0b102",
+ "1,0o8",
+ "1,0x",
+ "1,1.5",
+ "1,nan",
+ "1,true",
+ "1,",
+ "1,,2",
+ ",".join(["0x0"] * 124),
+ ):
+ with self.subTest(literals=literals), self.assertRaises(CommandError):
+ await self.app.dispatch(f"write holding_registers 0 {literals}")
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("write holding_registers 65535 0x1,0x2")
+ self.assertEqual(self.client.calls, [])
+
+ async def test_invalid_arguments_do_not_send(self):
+ """Ensure rejected ranges and write values produce no client requests."""
+ await self.connect()
+ commands = [
+ "read coils 65536",
+ "read coils 0-2 --max-count 0",
+ "read coils 0 --max-count 2001",
+ "read input_registers 0 --max-count 126",
+ "write coils 0 2",
+ "write holding_registers 0 -1",
+ "write holding_registers 0 65536",
+ "write coils 65535 0,1",
+ "read coils 3-1",
+ "read coils nope",
+ "write holding_registers 0 " + ",".join(["0"] * 124),
+ ]
+ for text in commands:
+ with self.subTest(text=text), self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ self.assertEqual(self.client.calls, [])
+
+ async def test_response_errors_and_short_reads(self):
+ """Reject missing, oversized, invalid-value, and exception register replies."""
+ await self.connect()
+ for reply in (
+ SimpleNamespace(isError=lambda: True),
+ response(function_code=3, registers=[]),
+ response(function_code=3, registers=[1, 2]),
+ response(function_code=3, registers=[70000]),
+ ):
+ self.client.reply = lambda name, args: reply
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("read holding_registers 0")
+
+ async def test_partial_read(self):
+ """Retain completed addresses in the error when a later chunk fails."""
+ await self.connect()
+ self.client.reply = lambda name, args: (
+ response(function_code=3, registers=[10])
+ if args["address"] == 0
+ else response(function_code=3, registers=[])
+ )
+ with self.assertRaisesRegex(
+ CommandError, "Partial read: 1 addresses completed"
+ ):
+ await self.app.dispatch("read holding_registers 0-1 --max-count 1")
+
+ async def test_bad_write_acknowledgements(self):
+ """Mark mismatched function/address/value/count replies as unconfirmed writes."""
+ await self.connect()
+ for reply in (
+ response(function_code=6, address=99, registers=[1]),
+ response(function_code=6, address=0, registers=[2]),
+ response(function_code=16, address=0, count=1),
+ SimpleNamespace(isError=lambda: True),
+ ):
+ self.client.reply = lambda name, args: reply
+ values = "1,2" if getattr(reply, "function_code", 0) == 16 else "1"
+ with self.assertRaisesRegex(CommandError, "unconfirmed"):
+ await self.app.dispatch(f"write holding_registers 0 {values}")
+
+ async def test_sparse_identification_and_continuation(self):
+ """Accept sparse IDs across pages and escape device-supplied control bytes."""
+ await self.connect()
+ self.client.reply = lambda name, args: (
+ response(information={0: b"Vendor"}, more_follows=255, next_object_id=2)
+ if args["object_id"] == 0
+ else response(information={2: b"1.0", 128: b"custom\x1b"})
+ )
+ result = await self.app.dispatch("read id")
+ self.assertIn("MajorMinorRevision", result.output)
+ self.assertIn("ObjectID 128", result.output)
+ self.assertNotIn("\x1b", result.output)
+ self.assertEqual(len(self.client.calls), 2)
+
+ async def test_cyclic_identification(self):
+ """Reject an identification continuation that repeats a previous object ID."""
+ await self.connect()
+ self.client.reply = lambda name, args: response(
+ information={0: b"x"}, more_follows=255, next_object_id=0
+ )
+ with self.assertRaisesRegex(CommandError, "continuation"):
+ await self.app.dispatch("read id")
+
+ async def test_serialized_reads_and_responsive_help(self):
+ """Ensure blocked reads serialize while local help remains responsive."""
+ await self.connect()
+ self.client.gate = asyncio.Event()
+ first = asyncio.create_task(self.app.dispatch("read coils 0"))
+ await self.client.started.wait()
+ second = asyncio.create_task(self.app.dispatch("read coils 1"))
+ result = await asyncio.wait_for(self.app.dispatch("help read"), 1)
+ self.assertIn("coils", result.output)
+ self.client.gate.set()
+ await asyncio.gather(first, second)
+ self.assertEqual(self.client.peak, 1)
+
+ async def test_cancel_read_and_queued_work(self):
+ """Cancel active and queued reads, discard transport, and permit project change."""
+ await self.connect()
+ self.client.gate = asyncio.Event()
+ first = asyncio.create_task(self.app.dispatch("read coils 0"))
+ await self.client.started.wait()
+ second = asyncio.create_task(self.app.dispatch("read coils 1"))
+ await asyncio.sleep(0)
+ await asyncio.wait_for(self.app.dispatch("client stop"), 1)
+ outcomes = await asyncio.gather(first, second, return_exceptions=True)
+ self.assertTrue(all(isinstance(item, CommandError) for item in outcomes))
+ self.assertEqual(len(self.client.calls), 1)
+ self.assertIsNone(self.app.connection.client)
+ await self.app.dispatch("project create after-cancel")
+
+ async def test_cancel_write_reports_uncertainty(self):
+ """Report unknown outcome when closing during a submitted write."""
+ await self.connect()
+ self.client.gate = asyncio.Event()
+ task = asyncio.create_task(self.app.dispatch("write coils 0 1"))
+ await self.client.started.wait()
+ await self.app.dispatch("client stop")
+ with self.assertRaisesRegex(CommandError, "outcome unknown"):
+ await task
+
+ async def test_cancel_connect(self):
+ """Cancel an in-progress connection and remove its candidate transport."""
+ started = asyncio.Event()
+
+ async def blocked():
+ """Signal connection entry, then wait indefinitely for test cancellation."""
+ started.set()
+ await asyncio.Event().wait()
+
+ self.client.connect = blocked
+ task = asyncio.create_task(self.app.dispatch("client start tcp localhost"))
+ await started.wait()
+ await asyncio.wait_for(self.app.dispatch("client stop"), 1)
+ with self.assertRaises(CommandError):
+ await task
+ self.assertIsNone(self.app.connection.client)
+
+ async def test_shutdown_cancels_io(self):
+ """Drain pending device work and disconnect during application shutdown."""
+ await self.connect()
+ self.client.gate = asyncio.Event()
+ task = asyncio.create_task(self.app.dispatch("read coils 0"))
+ await self.client.started.wait()
+ await asyncio.wait_for(self.app.on_stop(), 1)
+ with self.assertRaises(CommandError):
+ await task
+ self.assertFalse(self.client.connected)
+
+ async def test_failed_connect_cleanup(self):
+ """Discard clients whose connect method returns failure."""
+ self.client.connect = AsyncMock(return_value=False)
+ with self.assertRaises(CommandError):
+ await self.connect()
+ self.assertIsNone(self.app.connection.client)
+ self.assertFalse(self.client.connected)
+
+ async def test_double_connect_rejected(self):
+ """Require explicit close before opening another session."""
+ await self.connect()
+ with self.assertRaisesRegex(CommandError, "Stop the client"):
+ await self.connect()
+
+ async def test_disconnected_session(self):
+ """Reject operations when an existing client reports a lost connection."""
+ await self.connect()
+ self.client.connected = False
+ with self.assertRaisesRegex(CommandError, "disconnected"):
+ await self.app.dispatch("read coils 0")
+
+ async def test_profiles_records_history_and_project_isolation(self):
+ """Keep profiles, records, and history local and guard active project changes."""
+ await self.connect()
+ await self.app.dispatch("client config save lab")
+ await self.app.dispatch("read coils 0")
+ self.assertEqual((await self.app.configs.get("lab"))["unit"], 7)
+ self.assertEqual(len(await self.app.records.query()), 2)
+ for text in (
+ "project create new",
+ "proj lo default",
+ "project reset records confirm",
+ ):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ await self.app.dispatch("client stop")
+ await self.app.dispatch("project create new")
+ self.assertNotIn("lab", await self.app.configs.list())
+ self.assertEqual(await self.app.records.query(), [])
+ await self.app.dispatch("project load default")
+ await self.app.dispatch("client config load lab")
+ await self.app.dispatch("client start")
+ self.assertEqual(self.app.connection.settings.unit, 7)
+ history = await self.app.history.all()
+ self.assertIn("read coils 0", [entry.command for entry in history])
+
+ async def test_no_io_during_recording_session_start(self):
+ """Block device I/O and project changes until recording startup completes."""
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = self.app.records.start_session
+
+ async def slow_start(*args, **kwargs):
+ """Pause recording startup until released, then call the original service."""
+ entered.set()
+ await release.wait()
+ return await original(*args, **kwargs)
+
+ with patch.object(self.app.records, "start_session", slow_start):
+ task = asyncio.create_task(self.app.dispatch("client start tcp localhost"))
+ await entered.wait()
+ with self.assertRaisesRegex(CommandError, "opening"):
+ await self.app.dispatch("read coils 0")
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("project create wrong")
+ release.set()
+ await task
+ await self.app.dispatch("read coils 0")
+ self.assertEqual(len(await self.app.records.query()), 2)
+
+ async def test_record_warning_preserved_on_protocol_error(self):
+ """Attach storage warnings without losing the underlying protocol error."""
+ await self.connect()
+ self.client.reply = lambda name, args: response(function_code=3, registers=[])
+ with patch.object(
+ self.app.records, "append", AsyncMock(side_effect=OSError("disk full"))
+ ):
+ with self.assertRaisesRegex(CommandError, "Recording warning: disk full"):
+ await self.app.dispatch("read holding_registers 0")
+
+ async def test_cli_command_file_and_argument_failure(self):
+ """Run command files in order and return status 2 for invalid arguments."""
+ from pathlib import Path
+
+ with tempfile.TemporaryDirectory() as path:
+ script = Path(path) / "commands.txt"
+ script.write_text(
+ "# local fixture\n\nclient start tcp localhost\nread coils 0\n",
+ encoding="utf-8",
+ )
+ app = ModbusApp(
+ data_dir=Path(path) / "data",
+ client_factory=lambda settings: self.client,
+ )
+ out, err = io.StringIO(), io.StringIO()
+ status = await app.run_cli(
+ ["-f", str(script), "-c", "write coils 0 1"], stdout=out, stderr=err
+ )
+ self.assertEqual(status, 0, err.getvalue())
+ self.assertIn("Write acknowledged", out.getvalue())
+ status = await app.run_cli(
+ ["-c", "client start tcp localhost", "-c", "write coils 0 nope"],
+ stdout=out,
+ stderr=err,
+ )
+ self.assertEqual(status, 2)
+ self.assertFalse(self.client.connected)
+
+ async def test_external_cancellation_then_project_change(self):
+ """Finish the original recording session after external read cancellation."""
+ await self.connect()
+ self.client.gate = asyncio.Event()
+ task = asyncio.create_task(self.app.dispatch("read coils 0"))
+ await self.client.started.wait()
+ task.cancel()
+ with self.assertRaises(CommandError):
+ await task
+ await self.app.dispatch("project create after-external-cancel")
+ self.assertIsNone(self.app._record_session)
+ self.assertEqual(await self.app.records.query(), [])
+
+ async def test_invalid_profile(self):
+ """Reject malformed saved connection settings before opening a transport."""
+ await self.app.configs.save(
+ "bad", {"transport": "tcp", "target": "localhost", "timeout": "oops"}
+ )
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("client config load bad")
+
+ async def test_record_failure_keeps_acknowledged_write(self):
+ """Retain acknowledged-write success when recording fails, with a warning."""
+ await self.connect()
+ with patch.object(
+ self.app.records, "append", AsyncMock(side_effect=OSError("disk full"))
+ ):
+ result = await self.app.dispatch("write coils 0 1")
+ self.assertIn("Write acknowledged", result.output)
+ self.assertIn("Recording warning: disk full", result.output)
+ self.assertEqual(len(self.client.calls), 1)
+
+ async def test_cli_sequence_and_cleanup(self):
+ # run_cli owns backend lifecycle; use another app and directory.
+ """Verify sequential CLI output, error status, and automatic disconnection."""
+ with tempfile.TemporaryDirectory() as path:
+ app = ModbusApp(data_dir=path, client_factory=lambda settings: self.client)
+ out, err = io.StringIO(), io.StringIO()
+ status = await app.run_cli(
+ [
+ "-c",
+ "client start tcp localhost",
+ "-c",
+ "read coils 0",
+ "-c",
+ "write coils 0 1",
+ ],
+ stdout=out,
+ stderr=err,
+ )
+ self.assertEqual(status, 0, err.getvalue())
+ self.assertIn("Write acknowledged", out.getvalue())
+ self.assertFalse(self.client.connected)
+ status = await app.run_cli(["-c", "read coils 0"], stdout=out, stderr=err)
+ self.assertEqual(status, 2)
+
+
+class ValidationTests(unittest.TestCase):
+ """Check pure range, settings, and rendering contracts without device I/O."""
+
+ def test_range_endpoints_and_order(self):
+ """Preserve inclusive endpoints, repeats, and out-of-order read spans."""
+ spans = list(read_chunks(IntegerRanges("65535,0-2,1"), 2, 125))
+ self.assertEqual(
+ [(x.start, x.count, x.stop) for x in spans],
+ [(65535, 1, 65536), (0, 2, 2), (2, 1, 3), (1, 1, 2)],
+ )
+
+ def test_invalid_connection_settings(self):
+ """Reject out-of-range, non-finite, and unsupported settings at construction."""
+ for kwargs in (
+ {"unit": 0},
+ {"unit": 248},
+ {"port": 0},
+ {"timeout": float("nan")},
+ {"timeout": float("inf")},
+ {"timeout": -1},
+ {"retries": -1},
+ {"parity": "X"},
+ {"baudrate": 0},
+ {"bytesize": 9},
+ {"stopbits": 3},
+ ):
+ with self.subTest(kwargs=kwargs), self.assertRaises(CommandError):
+ ConnectionSettings("tcp", "host", **kwargs)
+
+ def test_safe_formatting(self):
+ """Escape terminal controls while preserving compressed ranges and sparse IDs."""
+ self.assertNotIn("\x1b", format_values("holding_registers", [(0, 27)]))
+ self.assertIn("0-1", format_values("coils", [(0, 1), (1, 1), (3, 1)]))
+ self.assertIn("ObjectID 200", format_identification({200: b"\xff"}))
diff --git a/tests/test_components.py b/tests/test_components.py
new file mode 100644
index 0000000..c76e264
--- /dev/null
+++ b/tests/test_components.py
@@ -0,0 +1,368 @@
+"""Component command contracts, lifecycle approval, strict formats, and help."""
+
+import asyncio
+import copy
+import io
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import test_commands
+from ctui import CommandError, ConfirmationRequired
+from ctui.commands import _HelpResult
+from test_commands import FakeClient
+
+from ctmodbus.app import ModbusApp
+from ctmodbus.client_commands import complete_profiles
+from ctmodbus.component_help import OVERVIEWS
+from ctmodbus.server_config import definition, dumps, load, validate
+from ctmodbus.tags import parse_tag_document
+
+
+class ComponentTests(unittest.IsolatedAsyncioTestCase):
+ """Check selected settings and stop approval through shared dispatch."""
+
+ asyncSetUp = test_commands.CommandTests.asyncSetUp
+ asyncTearDown = test_commands.CommandTests.asyncTearDown
+
+ async def test_selection_save_load_start_and_project_isolation(self):
+ """Configuration editing and load issue no I/O; stop retains selection."""
+ await self.app.dispatch(
+ "client config set --transport tcp --target localhost --unit 7"
+ )
+ self.assertFalse(self.client.connected)
+ await self.app.dispatch("client config save lab")
+ self.assertIn(
+ "Unsaved changes: no",
+ (await self.app.dispatch("client config show")).output,
+ )
+ await self.app.dispatch("client config set --unit 8")
+ self.assertIn(
+ "Unsaved changes: yes",
+ (await self.app.dispatch("client config show")).output,
+ )
+ await self.app.dispatch("client config load lab")
+ self.assertIsNone(self.app.connection.client)
+ await self.app.dispatch("client start")
+ self.assertEqual(self.app.connection.settings.unit, 7)
+ for text in ("client config set --unit 9", "client config load lab"):
+ with self.assertRaisesRegex(CommandError, "Stop the client"):
+ await self.app.dispatch(text)
+ await self.app.dispatch("read coils 0")
+ await self.app.dispatch("client stop")
+ self.assertEqual(self.app.selected_client_settings.unit, 7)
+ self.assertIn(
+ "evidence retained", (await self.app.dispatch("client status")).output
+ )
+ await self.app.dispatch("project create other")
+ self.assertIsNone(self.app.selected_client_settings)
+ self.assertIsNone(self.app.client_config_source)
+
+ async def test_set_is_atomic_and_tls_fields_clear_explicitly(self):
+ """Invalid edits retain the full selection; TLS defaults and clearing are explicit."""
+ with self.assertRaisesRegex(CommandError, "transport"):
+ await self.app.dispatch("client config set --target localhost")
+ await self.app.dispatch(
+ "client config set --transport tls --target localhost --ca-file ca.pem --cert-file cert.pem --key-file key.pem --insecure true"
+ )
+ initial = self.app.selected_client_settings
+ self.assertEqual(initial.port, 802)
+ for text in (
+ "client config set --unit 0",
+ "client config set --transport tcp",
+ "client config set --clear-cert-file",
+ "client config set --ca-file next.pem --clear-ca-file",
+ ):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ self.assertEqual(self.app.selected_client_settings, initial)
+ await self.app.dispatch(
+ "client config set --transport tcp --clear-ca-file --clear-cert-file --clear-key-file --insecure false --port 502"
+ )
+ current = self.app.selected_client_settings
+ self.assertEqual(current.transport, "tcp")
+ self.assertIsNone(current.ca_file)
+ self.assertIsNone(current.cert_file)
+ self.assertIsNone(current.key_file)
+ self.assertFalse(current.insecure)
+
+ async def test_config_load_reserves_selection_and_project(self):
+ """Async loads block competing selection, start, stop, and project changes."""
+ await self.app.dispatch("client config set --transport tcp --target localhost")
+ await self.app.dispatch("client config save lab")
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = self.app.configs.get
+
+ async def delayed(name):
+ entered.set()
+ await release.wait()
+ return await original(name)
+
+ with patch.object(self.app.configs, "get", delayed):
+ task = asyncio.create_task(self.app.dispatch("cl conf lo lab"))
+ await entered.wait()
+ try:
+ for text in (
+ "client start",
+ "client stop",
+ "client config set --unit 8",
+ "project create next",
+ ):
+ with self.assertRaisesRegex(
+ CommandError, "configuration is changing"
+ ):
+ await self.app.dispatch(text)
+ finally:
+ release.set()
+ await task
+ self.assertIsNone(self.app._client_config_task)
+
+ async def test_proxy_stop_confirmation_and_reservation(self):
+ """Decline preserves both endpoints; approval stops only the selected endpoint."""
+ await self.app.dispatch("client start tcp localhost")
+ # A sentinel listener avoids sockets while exercising real dispatch approval.
+ self.app.server.listener = object()
+ self.app.server.proxy = True
+ with self.assertRaises(ConfirmationRequired):
+ await self.app.dispatch("client stop")
+ self.assertTrue(self.app.server.proxy)
+ self.assertTrue(self.client.connected)
+ result = await self.app.dispatch(
+ "client stop", confirm_callback=lambda message: False
+ )
+ self.assertFalse(result.accepted)
+ self.assertTrue(self.app.server.proxy)
+ entered, release = asyncio.Event(), asyncio.Event()
+
+ async def approve(message):
+ self.assertIn("Proxy", message)
+ entered.set()
+ await release.wait()
+ return True
+
+ task = asyncio.create_task(
+ self.app.dispatch("cl sto", confirm_callback=approve)
+ )
+ await entered.wait()
+ try:
+ for text in (
+ "proxy start",
+ "proxy stop",
+ "client start",
+ "server stop",
+ "project create next",
+ ):
+ with self.assertRaisesRegex(CommandError, "component is stopping"):
+ await self.app.dispatch(text)
+ finally:
+ release.set()
+ await task
+ self.app.server.listener = None
+ self.assertFalse(self.client.connected)
+ self.assertFalse(self.app.server.proxy)
+ await self.app.dispatch("client start")
+ self.app.server.proxy = True
+ await self.app.dispatch("server stop --confirm")
+ self.assertTrue(self.client.connected)
+ self.assertFalse(self.app.server.proxy)
+
+ async def test_overviews_generated_help_and_no_old_commands(self):
+ """Bare groups carry popup results; generated references and ctui configs remain."""
+ for component, overview in OVERVIEWS.items():
+ result = await self.app.dispatch(component)
+ self.assertIsInstance(result, _HelpResult)
+ self.assertEqual(self.app.format_ui_help(result.target), overview)
+ self.assertIn("start", result.output)
+ generated = await self.app.dispatch("help " + component)
+ self.assertNotEqual(generated.output, overview)
+ self.assertIn("status", generated.output)
+ for old in (
+ "connect",
+ "profile",
+ "close",
+ "cancel",
+ "proxy enable",
+ "proxy disable",
+ "client data show",
+ "server data show",
+ ):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(old)
+ self.assertEqual(self.app.commands.resolve("serve")[0].name, "server")
+ await self.app.dispatch("project configs show tcp-local")
+ names = await complete_profiles(type("Context", (), {"app": self.app})())
+ self.assertIn("tcp-local", names)
+ self.assertNotIn("ctmodbus-server", names)
+
+ async def test_completion_and_component_registration(self):
+ """Every moved subcommand stays registered; optional choices complete correctly."""
+ command, _ = self.app.commands.resolve("client config set")
+ choices = await command.complete("--transport", "", self.app)
+ self.assertEqual(
+ {item.value for item in choices}, {"tcp", "udp", "tls", "rtu", "ascii"}
+ )
+ parity = await command.complete("--parity", "", self.app)
+ self.assertEqual({item.value for item in parity}, {"N", "E", "O"})
+ await self.app.dispatch("client config set --transport tc --target localhost")
+ await self.app.dispatch("client config save lab")
+ command, _ = self.app.commands.resolve("client config load")
+ self.assertIn(
+ "lab", {item.value for item in await command.complete("", "", self.app)}
+ )
+ for component in ("client", "server"):
+ for transport in ("tcp", "udp", "tls", "rtu", "ascii"):
+ text = f"{component} start {transport}"
+ self.assertEqual(self.app.commands.resolve(text)[0].name, text)
+ for action in (
+ "import",
+ "export",
+ "clear",
+ "unit",
+ "seed",
+ "identity",
+ "table",
+ "set",
+ "random",
+ "sequence",
+ "remove",
+ "validate",
+ "show",
+ ):
+ text = "server config " + action
+ self.assertEqual(self.app.commands.resolve(text)[0].name, text)
+ for action in ("read", "write", "tick", "clear"):
+ text = "server hook " + action
+ self.assertEqual(self.app.commands.resolve(text)[0].name, text)
+
+ async def test_cli_configuration_and_overviews(self):
+ """Real CLI selects/loads without I/O and prints overview examples."""
+ with tempfile.TemporaryDirectory() as folder:
+ client = FakeClient()
+ app = ModbusApp(data_dir=folder, client_factory=lambda settings: client)
+ stdout, stderr = io.StringIO(), io.StringIO()
+ status = await app.run_cli(
+ [
+ "-c",
+ "client config load tcp-local",
+ "-c",
+ "client config show",
+ "-c",
+ "client",
+ "-c",
+ "server",
+ "-c",
+ "proxy",
+ ],
+ stdout=stdout,
+ stderr=stderr,
+ )
+ self.assertEqual(status, 0, stderr.getvalue())
+ self.assertFalse(client.connected)
+ for overview in OVERVIEWS.values():
+ self.assertIn(overview, stdout.getvalue())
+
+ async def test_browser_overviews_and_stop_confirmations(self):
+ """Browser presenter opens overview/confirmation dialogs and retains main output."""
+ from unittest.mock import AsyncMock
+
+ from ctui.web import WebClient, WebSession
+
+ session = WebSession(self.app, port=0, stdout=io.StringIO())
+ socket = type("Socket", (), {"closed": False, "send_json": AsyncMock()})()
+ view = WebClient(session, socket)
+ try:
+ await session.start()
+ self.app.layout.set_output("Existing output")
+ with patch.object(
+ view, "dialog", AsyncMock(return_value={"button": 0})
+ ) as dialog:
+ for component, overview in OVERVIEWS.items():
+ await view.command(component, component)
+ dialog.assert_awaited_with("Help", overview, ["OK"])
+ self.assertEqual(
+ self.app.layout.output_field.text, "Existing output"
+ )
+ with patch.object(view, "dialog", AsyncMock(return_value={"button": 0})):
+ await view.spawn(view.command("client start tcp localhost", "start"))
+ self.app.server.proxy = True
+ with patch.object(view, "dialog", AsyncMock(return_value={"button": 1})):
+ await view.spawn(view.command("client stop", "decline"))
+ self.assertTrue(self.client.connected)
+ self.assertTrue(self.app.server.proxy)
+ with patch.object(view, "dialog", AsyncMock(return_value={"button": 0})):
+ await view.spawn(view.command("client stop", "approve"))
+ self.assertFalse(self.client.connected)
+ self.assertFalse(self.app.server.proxy)
+ finally:
+ await session.close()
+
+
+class StrictFormatTests(unittest.TestCase):
+ """Reject malformed markers and nested obsolete/unknown schema fields."""
+
+ def test_server_markers_and_nested_unknown_keys(self):
+ """Every persisted definition requires exact markers and rejects unknown fields."""
+ for field, value in (
+ ("format", None),
+ ("format", "ctmodbus-tags"),
+ ("version", None),
+ ("version", True),
+ ("version", 1.0),
+ ("version", 2),
+ ):
+ config = definition()
+ if value is None:
+ del config[field]
+ else:
+ config[field] = value
+ with (
+ self.subTest(field=field, value=value),
+ self.assertRaisesRegex(CommandError, field),
+ ):
+ validate(config)
+ for path, key in (
+ ((), "typo"),
+ (("identity",), "company"),
+ (("tables",), "holding_register"),
+ (("tables", "coils"), "fallback"),
+ (("hooks",), "tick_seconds"),
+ ):
+ config = definition()
+ section = config
+ for part in path:
+ section = section[part]
+ section[key] = 1
+ with self.subTest(path=path), self.assertRaisesRegex(CommandError, key):
+ validate(config)
+ config = definition()
+ config["hooks"] = {
+ "module": "test.py",
+ "on_tick": "tick",
+ "tick_interval_seconds": 0.1,
+ }
+ with tempfile.TemporaryDirectory() as folder:
+ path = Path(folder) / "server.toml"
+ path.write_text(dumps(config))
+ loaded = load(path)
+ self.assertEqual(loaded["hooks"]["tick_interval_seconds"], 0.1)
+ for value in (False, 0, -1, float("inf")):
+ bad = copy.deepcopy(config)
+ bad["hooks"]["tick_interval_seconds"] = value
+ with self.assertRaisesRegex(CommandError, "tick_interval_seconds"):
+ validate(bad)
+
+ def test_tag_markers_and_top_level_unknown_keys(self):
+ """Tag version booleans/floats and unrelated tables cannot pass validation."""
+ with tempfile.TemporaryDirectory() as folder:
+ path = Path(folder) / "tags.toml"
+ for text in (
+ 'format = "ctmodbus-tags"\nversion = true\n[tags]\n',
+ 'format = "ctmodbus-tags"\nversion = 1.0\n[tags]\n',
+ "version = 1\n[tags]\n",
+ 'format = "ctmodbus-tags"\n[tags]\n',
+ 'format = "ctmodbus-tags"\nversion = 1\nextra = 2\n[tags]\n',
+ ):
+ path.write_text(text)
+ with self.subTest(text=text), self.assertRaises(CommandError):
+ parse_tag_document(path)
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
new file mode 100644
index 0000000..0a23c3d
--- /dev/null
+++ b/tests/test_discovery.py
@@ -0,0 +1,34 @@
+"""Local serial discovery and completion tests."""
+
+import unittest
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from ctmodbus.discovery import complete_serial
+
+
+class DiscoveryTests(unittest.IsolatedAsyncioTestCase):
+ """Verify completion insertion and metadata with mocked OS port enumeration."""
+
+ async def test_serial_completion_includes_usb_identity(self):
+ """Keep device paths insertable while showing USB identity or description."""
+ ports = [
+ SimpleNamespace(
+ device="/dev/ttyUSB0",
+ manufacturer="ControlThings",
+ product="Modbus Adapter",
+ description="USB serial device",
+ ),
+ SimpleNamespace(
+ device="COM3",
+ manufacturer=None,
+ product=None,
+ description="Built-in serial port",
+ ),
+ ]
+ with patch("ctmodbus.discovery.serial_devices", return_value=ports):
+ items = await complete_serial(None)
+
+ self.assertEqual([item.value for item in items], ["/dev/ttyUSB0", "COM3"])
+ self.assertEqual(items[0].help, "ControlThings — Modbus Adapter")
+ self.assertEqual(items[1].help, "Built-in serial port")
diff --git a/tests/test_integration.py b/tests/test_integration.py
new file mode 100644
index 0000000..14ed1ae
--- /dev/null
+++ b/tests/test_integration.py
@@ -0,0 +1,128 @@
+"""Actual TCP, UDP, RTU and ASCII exchanges against a local PyModbus server."""
+
+import asyncio
+import os
+import tempfile
+import unittest
+
+from ctui import CommandError
+from server import make_server
+
+from ctmodbus.app import ModbusApp
+
+
+class TransportTests(unittest.IsolatedAsyncioTestCase):
+ """Exercise real local transports; PTY serial cases require POSIX.
+
+ Fixtures clean up servers, projects, and descriptors. PTYs check framing,
+ not physical adapter timing or RS-485 driver behavior.
+ """
+
+ async def exercise(self, app):
+ """Assert reads, writes, identification, and invalid-address errors on an open app."""
+ for text in (
+ "read coils 0-9",
+ "read discrete_inputs 0-9",
+ "read input_registers 0-129",
+ "read holding_registers 0-129",
+ "write coils 1 1",
+ "write coils 2 0,1,0",
+ "write holding_registers 1 65535",
+ "write holding_registers 2 0,123,65535",
+ "read id",
+ ):
+ result = await app.dispatch(text)
+ self.assertTrue(result.output)
+ result = await app.dispatch("read holding_registers 1-4")
+ self.assertIn("65535", result.output)
+ result = await app.dispatch("read coils 1-4")
+ self.assertIn("Bit", result.output)
+ with self.assertRaises(CommandError):
+ await app.dispatch("read holding_registers 1000")
+
+ async def run_network(self, transport):
+ """Serve TCP/UDP on an ephemeral loopback port and always close both endpoints."""
+ server = make_server(transport)
+ await server.serve_forever(background=True)
+ if transport == "tcp":
+ port = server.transport.sockets[0].getsockname()[1]
+ else:
+ port = server.transport.get_extra_info("sockname")[1]
+ try:
+ with tempfile.TemporaryDirectory() as path:
+ app = ModbusApp(data_dir=path)
+ await app.backend.open()
+ try:
+ await app.dispatch(
+ f"client start {transport} 127.0.0.1 --port {port} --timeout 0.5"
+ )
+ await self.exercise(app)
+ finally:
+ await app.on_stop()
+ await app.backend.close()
+ finally:
+ await server.shutdown()
+
+ async def test_tcp(self):
+ """Exercise actual Modbus TCP requests against the local fixture."""
+ await self.run_network("tcp")
+
+ async def test_udp(self):
+ """Exercise actual Modbus UDP requests against the local fixture."""
+ await self.run_network("udp")
+
+ async def run_serial(self, transport):
+ # Two PTYs bridged at their masters exercise the real serial clients and
+ # server, including their framing, without requiring attached hardware.
+ """Bridge two raw PTYs for RTU/ASCII checks and close readers/descriptors on exit."""
+ import tty
+
+ master1, slave1 = os.openpty()
+ master2, slave2 = os.openpty()
+ loop = asyncio.get_running_loop()
+ tty.setraw(slave1)
+ tty.setraw(slave2)
+ for fd in (master1, master2):
+ os.set_blocking(fd, False)
+
+ def forward(source, destination):
+ """Relay available bytes between PTY masters; ignore temporary would-block errors."""
+ try:
+ data = os.read(source, 65536)
+ if data:
+ os.write(destination, data)
+ except BlockingIOError:
+ pass
+
+ loop.add_reader(master1, forward, master1, master2)
+ loop.add_reader(master2, forward, master2, master1)
+ server = make_server(transport, os.ttyname(slave1))
+ try:
+ await server.serve_forever(background=True)
+ with tempfile.TemporaryDirectory() as path:
+ app = ModbusApp(data_dir=path)
+ await app.backend.open()
+ try:
+ await app.dispatch(
+ f"client start {transport} {os.ttyname(slave2)} --timeout 1"
+ )
+ await self.exercise(app)
+ finally:
+ await app.on_stop()
+ await app.backend.close()
+ finally:
+ await server.shutdown()
+ loop.remove_reader(master1)
+ loop.remove_reader(master2)
+ for fd in (master1, slave1, master2, slave2):
+ os.close(fd)
+
+ @unittest.skipUnless(os.name == "posix", "PTY serial tests require POSIX")
+ async def test_rtu(self):
+ """Verify RTU framing over POSIX PTYs without physical hardware."""
+ await self.run_serial("rtu")
+
+ @unittest.skipUnless(os.name == "posix", "PTY serial tests require POSIX")
+ async def test_ascii(self):
+ """Verify ASCII framing over POSIX PTYs without physical hardware."""
+ await self.run_serial("ascii")
diff --git a/tests/test_polling.py b/tests/test_polling.py
new file mode 100644
index 0000000..98e2439
--- /dev/null
+++ b/tests/test_polling.py
@@ -0,0 +1,455 @@
+"""Polling cadence, output, lifecycle, limits, and read-evidence regressions."""
+
+import asyncio
+import io
+import tempfile
+import unittest
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from ctui import CommandError
+from test_commands import FakeClient, response
+
+from ctmodbus.app import ModbusApp
+
+
+def unpad(text):
+ """Normalize table padding for behavioral assertions independent of widths."""
+ return "\n".join(
+ " | ".join(cell.strip() for cell in line.split("|"))
+ for line in text.splitlines()
+ )
+
+
+class PollingTests(unittest.IsolatedAsyncioTestCase):
+ """Run shared dispatch with isolated project storage and controllable device I/O."""
+
+ async def asyncSetUp(self):
+ """Open project and fake connection; retain output without a UI runtime."""
+ self.directory = tempfile.TemporaryDirectory()
+ self.client = FakeClient()
+ self.app = ModbusApp(
+ data_dir=self.directory.name, client_factory=lambda settings: self.client
+ )
+ await self.app.backend.open()
+ await self.app.dispatch("client start tcp localhost")
+
+ async def asyncTearDown(self):
+ """Drain polling and device work before closing isolated project storage."""
+ await self.app.on_stop()
+ await self.app.backend.close()
+ self.directory.cleanup()
+
+ async def finish(self):
+ """Bound the background poll to avoid a hung regression run."""
+ await asyncio.wait_for(self.app.poller.task, 3)
+
+ async def test_raw_order_format_limits_and_records(self):
+ """Keep table/range order, duplicates, compact values, and shared records."""
+ self.app.output_text = "Existing output"
+ await self.app.dispatch(
+ "poll raw --holding-registers 2-3,1,2 --coils 0-2 --count 2 --interval 0.02"
+ )
+ await self.finish()
+ lines = unpad(self.app.output_text).splitlines()
+ self.assertEqual(lines[0], "Existing output")
+ self.assertEqual(
+ lines[1],
+ "# | holding_registers:2-3 | holding_registers:1 | holding_registers:2 | coils:0-2",
+ )
+ self.assertEqual(lines[2], "1 | 0002 0003 | 0001 | 0002 | 111")
+ self.assertEqual(lines[3], "2 | 0002 0003 | 0001 | 0002 | 111")
+ self.assertEqual(self.app.poller.completed, 2)
+ self.assertEqual(len(await self.app.records.query(direction="received")), 8)
+ self.assertEqual(self.client.peak, 1)
+
+ async def test_tags_all_explicit_order_types_and_guards(self):
+ """Snapshot all tags in name order or explicit order and reject mutations."""
+ await self.app.dispatch("tags create z coil 0 bool")
+ await self.app.dispatch("tags create a holding_register 1 int16")
+ self.client.gate = asyncio.Event()
+ await self.app.dispatch("poll tags --count 1")
+ await asyncio.wait_for(self.client.started.wait(), 1)
+ for text in (
+ "poll tags",
+ "tags delete a",
+ "tags create x coil 4 bool",
+ "tags import missing.toml",
+ "project load other",
+ ):
+ with self.subTest(command=text), self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ self.client.gate.set()
+ await self.finish()
+ self.assertIn("# | a | z\n1 | 1 | True", unpad(self.app.output_text))
+ await self.app.dispatch("poll tags z,a,z --count 1")
+ await self.finish()
+ self.assertIn("# | z | a | z\n1 | True | 1 | True", unpad(self.app.output_text))
+
+ async def test_skips_inflight_and_manual_work(self):
+ """Busy ticks never queue behind a gated response or a manual operation."""
+ self.client.gate = asyncio.Event()
+ await self.app.dispatch("poll raw --coils 0 --interval 0.01 --count 3")
+ await asyncio.wait_for(self.client.started.wait(), 1)
+
+ async def skipped_ticks():
+ """Wait for skipped ticks without asserting exact wall-clock timing."""
+ while self.app.poller.skipped < 2:
+ await asyncio.sleep(0.001)
+
+ await asyncio.wait_for(skipped_ticks(), 3)
+ self.assertEqual(self.app.poller.started, 1)
+ self.assertEqual(len(self.client.calls), 1)
+ self.client.gate.set()
+ await self.finish()
+ self.assertGreaterEqual(self.app.poller.skipped, 2)
+ self.assertEqual(self.app.poller.completed, 3)
+ self.assertEqual(self.client.peak, 1)
+ async with self.app.connection.operation():
+ await self.app.dispatch(
+ "poll raw --coils 0 --interval 0.01 --duration 0.04"
+ )
+ await self.finish()
+ self.assertEqual(self.app.poller.started, 0)
+ self.assertGreaterEqual(self.app.poller.skipped, 1)
+
+ async def test_partial_column_error_and_continue(self):
+ """Retain confirmed chunks and continue other columns and subsequent cycles."""
+
+ def replies(name, kwargs):
+ if kwargs["address"] == 125:
+ return response(function_code=3, registers=[])
+ return response(
+ function_code=3, registers=[kwargs["address"]] * kwargs["count"]
+ )
+
+ self.client.reply = replies
+ await self.app.dispatch(
+ "poll raw --holding-registers 0-125,7 --count 2 --interval 0.01"
+ )
+ await self.finish()
+ rows = [
+ line
+ for line in unpad(self.app.output_text).splitlines()
+ if line.startswith(("1 | ", "2 | "))
+ ]
+ self.assertEqual(len(rows), 2)
+ for row in rows:
+ self.assertTrue(row.endswith("ERR | 0007"))
+ self.assertEqual(row.count("0000"), 125)
+ self.assertEqual(self.app.poller.failed_columns, 2)
+ self.assertEqual(len(await self.app.records.query(direction="error")), 2)
+
+ async def test_graceful_stop_and_abort_idle_or_inflight(self):
+ """Stop drains without closing; cancel stops even an idle timer and closes I/O."""
+ self.client.gate = asyncio.Event()
+ await self.app.dispatch("poll raw --coils 0 --interval 0.01")
+ await asyncio.wait_for(self.client.started.wait(), 1)
+ stopping = asyncio.create_task(self.app.dispatch("poll stop"))
+ await asyncio.sleep(0.03)
+ self.assertFalse(stopping.done())
+ self.client.gate.set()
+ await asyncio.wait_for(stopping, 1)
+ self.assertEqual(self.app.poller.started, 1)
+ self.assertTrue(self.client.connected)
+ self.client.gate = None
+ await self.app.dispatch("poll raw --coils 0 --interval 60")
+ await asyncio.sleep(0.02)
+ await self.app.dispatch("client stop")
+ self.assertFalse(self.app.poller.active)
+ self.assertFalse(self.client.connected)
+ await self.app.dispatch("client start tcp localhost")
+ self.client.gate = asyncio.Event()
+ self.client.started.clear()
+ await self.app.dispatch("poll raw --coils 0")
+ await asyncio.wait_for(self.client.started.wait(), 1)
+ await asyncio.wait_for(self.app.dispatch("client stop"), 1)
+ self.assertFalse(self.app.poller.active)
+ self.assertIn("ERR", unpad(self.app.output_text))
+
+ async def test_duration_and_invalid_plans_before_io(self):
+ """Both limits apply and invalid selections/options do not start I/O."""
+ for text in (
+ "poll raw",
+ "poll tags",
+ "poll tags missing",
+ "poll raw --coils 65536",
+ "poll raw --coils 0 --interval 0",
+ "poll raw --coils 0 --interval nan",
+ "poll raw --coils 0 --duration -1",
+ "poll raw --coils 0 --count 0",
+ ):
+ with self.subTest(command=text), self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ self.assertEqual(self.client.calls, [])
+ await self.app.dispatch(
+ "poll raw --coils 0 --interval 0.01 --duration 0.035 --count 100"
+ )
+ await self.finish()
+ self.assertGreater(self.app.poller.completed, 0)
+ self.assertLess(self.app.poller.completed, 100)
+ result = await self.app.dispatch("poll status")
+ self.assertIn("Stopped", result.output)
+ self.assertIn("Duration limit reached", result.output)
+
+ async def test_connection_loss_stops_and_marks_remaining_columns(self):
+ """Disconnected transport produces error columns and ends the poll."""
+
+ def disconnect(_name, _kwargs):
+ self.client.connected = False
+ raise CommandError("Connection lost")
+
+ self.client.reply = disconnect
+ await self.app.dispatch("poll raw --coils 0,1 --interval 0.01")
+ await self.finish()
+ self.assertIn("1 | ERR | ERR", unpad(self.app.output_text))
+ self.assertEqual(self.app.poller.started, 1)
+
+ async def test_ui_output_and_cli_streaming(self):
+ """Shared layout output appends and invalidates; CLI limits stream in sequence."""
+ field = SimpleNamespace(text="Earlier output")
+ self.app.layout = SimpleNamespace(
+ output_field=field, set_output=lambda text: setattr(field, "text", text)
+ )
+ redraws = []
+ self.app.app = SimpleNamespace(invalidate=lambda: redraws.append(True))
+ await self.app.dispatch("poll raw --coils 0 --count 1")
+ await self.finish()
+ self.assertTrue(unpad(field.text).startswith("Earlier output\n#"))
+ self.assertIn("1 | 1", unpad(field.text))
+ self.assertGreaterEqual(len(redraws), 3)
+ del self.app.app
+ del self.app.layout
+ output = io.StringIO()
+ self.app._poll_stdout = output
+ await self.app.dispatch("poll raw --coils 0 --count 2 --interval 0.01")
+ self.app._poll_stdout = None
+ self.assertIn("2 | 1", unpad(output.getvalue()))
+ self.assertFalse(self.app.poller.active)
+
+ async def test_start_guard_and_immediate_close(self):
+ """Protect target resolution from tag edits; immediate close drains newborn tasks."""
+ await self.app.dispatch("tags create x coil 0 bool")
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = self.app.tags.list
+
+ async def paused():
+ entered.set()
+ await release.wait()
+ return await original()
+
+ with patch.object(self.app.tags, "list", paused):
+ starting = asyncio.create_task(self.app.dispatch("poll tags --count 1"))
+ await entered.wait()
+ for text in ("tags delete x", "poll tags"):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ release.set()
+ await starting
+ await self.app.dispatch("client stop")
+ self.assertFalse(self.client.connected)
+ self.assertFalse(self.app.poller.active)
+
+ async def test_cli_lifecycle_and_web_runtime(self):
+ """Real CLI lifecycle and browser layout retain appended background rows."""
+ from ctui.web import WebSession
+
+ with tempfile.TemporaryDirectory() as folder:
+ app = ModbusApp(
+ data_dir=folder, client_factory=lambda settings: FakeClient()
+ )
+ stdout, stderr = io.StringIO(), io.StringIO()
+ result = await app.run_cli(
+ [
+ "-c",
+ "client start tcp localhost",
+ "-c",
+ "poll raw --coils 0 --count 2 --interval 0.01",
+ "-c",
+ "poll status",
+ ],
+ stdout=stdout,
+ stderr=stderr,
+ )
+ self.assertEqual(result, 0, stderr.getvalue())
+ self.assertIn("2 | 1", unpad(stdout.getvalue()))
+ self.assertIn("completed=2", unpad(stdout.getvalue()))
+ self.assertFalse(app.connection.connected)
+ session = WebSession(self.app, port=0, stdout=io.StringIO())
+ try:
+ await session.start()
+ session.apply_result(
+ await self.app.dispatch(
+ "poll raw --coils 0 --duration 0.035 --interval 0.01"
+ )
+ )
+ await self.finish()
+ self.assertIn("# | coils:0", unpad(self.app.layout.output_field.text))
+ self.assertIn("1 | 1", unpad(self.app.layout.output_field.text))
+ self.assertEqual(self.app.app, session)
+ finally:
+ await session.close()
+
+ async def test_typed_values_and_tag_error_columns(self):
+ """Render signed/float codecs and exception columns without stopping later reads."""
+ from ctmodbus.tags import encode_tag_value
+
+ await self.app.dispatch("tags create signed holding_register 0 int16")
+ await self.app.dispatch("tags create energy input_register 5 float32")
+ await self.app.dispatch("tags create bad holding_register 9 uint16")
+ tag = await self.app.tags.get("energy")
+
+ def replies(name, kwargs):
+ if kwargs["address"] == 9:
+ return SimpleNamespace(isError=lambda: True)
+ return response(
+ function_code=4 if name == "read_input_registers" else 3,
+ registers=(
+ encode_tag_value(tag, 12.75) if kwargs["address"] == 5 else [65535]
+ ),
+ )
+
+ self.client.reply = replies
+ await self.app.dispatch("poll tags signed,bad,energy --count 2 --interval 0.01")
+ await self.finish()
+ self.assertIn("1 | -1 | ERR | 12.75", unpad(self.app.output_text))
+ self.assertIn("2 | -1 | ERR | 12.75", unpad(self.app.output_text))
+ self.assertEqual(self.app.poller.failed_columns, 2)
+
+ async def test_cli_interruption_propagates_to_dispatch(self):
+ """Cancellation becomes a command error so CLI dispatch stops its sequence."""
+ output = io.StringIO()
+ self.client.gate = asyncio.Event()
+ self.app._poll_stdout = output
+ task = asyncio.create_task(self.app.dispatch("poll raw --coils 0"))
+ await self.client.started.wait()
+ task.cancel()
+ with self.assertRaises(CommandError):
+ await task
+ self.app._poll_stdout = None
+ self.assertFalse(self.app.poller.active)
+ self.assertFalse(self.client.connected)
+
+
+class PollScheduleTests(unittest.IsolatedAsyncioTestCase):
+ """Check timer deadlines independently of filesystem and runner latency."""
+
+ async def test_fixed_cadence_after_slow_response(self):
+ """A 1.4-second response skips tick 1 and resumes at ticks 2 and 3."""
+ from ctui import IntegerSpan
+
+ from ctmodbus.polling import Poll, PollColumn
+
+ clock = SimpleNamespace(now=0.0)
+ clock.time = lambda: clock.now
+ release = asyncio.Event()
+ starts = []
+ connection = SimpleNamespace(connected=True, generation=1, lock=asyncio.Lock())
+ app = SimpleNamespace(
+ connection=connection, append_output=lambda _text: None, _poll_stdout=None
+ )
+ poll = Poll(app)
+
+ async def cycle(_number):
+ """Hold the operation reservation until the first response is ready."""
+ async with connection.lock:
+ starts.append(clock.now)
+ if len(starts) == 1:
+ await release.wait()
+ poll.completed += 1
+
+ async def wait_tick(waiter, delay):
+ """Advance a virtual timer, delivering the response between deadlines."""
+ waiter.close()
+ deadline = clock.now + delay
+ if not release.is_set() and deadline >= 1.4:
+ clock.now = 1.4
+ release.set()
+ await asyncio.sleep(0)
+ clock.now = deadline
+ raise TimeoutError
+
+ timing = SimpleNamespace(
+ get_running_loop=lambda: clock,
+ wait_for=wait_tick,
+ Event=asyncio.Event,
+ create_task=asyncio.create_task,
+ sleep=asyncio.sleep,
+ gather=asyncio.gather,
+ CancelledError=asyncio.CancelledError,
+ )
+ poll.read_cycle = cycle
+ with patch("ctmodbus.polling.asyncio", timing):
+ await poll.start(
+ [PollColumn("coils:0", "coils", (IntegerSpan(0, 1),))],
+ interval=1,
+ count=3,
+ duration=None,
+ )
+ await asyncio.wait_for(poll.task, 3)
+ self.assertEqual(starts, [0.0, 2.0, 3.0])
+ self.assertEqual(poll.completed, 3)
+ self.assertEqual(poll.skipped, 1)
+ self.assertEqual(poll.reason, "Count limit reached")
+
+
+class PollTableTests(unittest.TestCase):
+ """Check real separator positions, type bounds, and append-only expansion."""
+
+ def test_alignment_and_type_widths(self):
+ """Mixed values and ERR retain separator positions across every row."""
+ from ctui import IntegerSpan
+
+ from ctmodbus.polling import PollColumn, PollTable
+ from ctmodbus.tags import Tag
+
+ columns = (
+ PollColumn(
+ "n",
+ "holding_registers",
+ (IntegerSpan(0, 1),),
+ Tag("n", "holding_registers", 0, "int16"),
+ ),
+ PollColumn(
+ "b", "coils", (IntegerSpan(0, 1),), Tag("b", "coils", 0, "bool")
+ ),
+ PollColumn("raw", "holding_registers", (IntegerSpan(0, 3),)),
+ )
+ table = PollTable(columns, 100)
+ header = table.header()
+ self.assertEqual(header, " # | n | b | raw")
+ rows = [
+ table.row(1, ["1", "True", "0000 0001 0002"])[0],
+ table.row(100, ["-32768", "False", "0000 ERR"])[0],
+ ]
+ positions = lambda text: [i for i, char in enumerate(text) if char == "|"]
+ self.assertEqual(positions(header), [4, 13, 21])
+ for row in rows:
+ self.assertEqual(positions(row), positions(header))
+ self.assertTrue(rows[0].startswith(" 1 | 1 | True | "))
+ self.assertTrue(rows[1].startswith("100 | -32768 | False | "))
+ self.assertEqual(len(rows[0].split(" | ")[-1]), 14)
+
+ def test_expansion_and_float_precision(self):
+ """Counter/value overflow repeats a wider header; floats remain compact."""
+ from ctui import IntegerSpan
+
+ from ctmodbus.polling import PollColumn, PollTable
+ from ctmodbus.tags import Tag
+
+ tag = Tag("f", "holding_registers", 0, "float64")
+ table = PollTable(
+ (PollColumn("f", tag.table, (IntegerSpan(0, 4),), tag),), None
+ )
+ self.assertEqual(table.widths[0], 6)
+ old = table.row(1, ["1.0"])[0]
+ expanded = table.row(1000000, ["X" * 26])
+ self.assertEqual(len(expanded), 2)
+ self.assertEqual(expanded[0].split(" | ")[0], " #")
+ self.assertEqual(expanded[1], "1000000 | " + "X" * 26)
+ self.assertEqual(old.split(" | ")[0], " 1")
+ self.assertEqual(len(table.row(1000001, ["ERR"])), 1)
+ for value in (1.0, -1.2345678901234567, 1.7976931348623157e308):
+ self.assertEqual(float(table.tag_value(tag, value)), value)
+ self.assertEqual(table.tag_value(tag, 1.0), "1.0")
+ self.assertEqual(table.tag_value(tag, 1e100), "1e+100")
diff --git a/tests/test_result_popups.py b/tests/test_result_popups.py
new file mode 100644
index 0000000..af75834
--- /dev/null
+++ b/tests/test_result_popups.py
@@ -0,0 +1,256 @@
+"""Management result presentation and CLI server keepalive regression contracts."""
+
+import asyncio
+import io
+import os
+import signal
+import socket
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import test_commands
+from ctui import CommandError
+from pymodbus.client import AsyncModbusTcpClient
+
+from ctmodbus.app import ModbusApp
+from ctmodbus.result_popups import result_title
+
+
+def free_port():
+ """Return a loopback port candidate; actual binding still validates availability."""
+ with socket.socket() as candidate:
+ candidate.bind(("127.0.0.1", 0))
+ return candidate.getsockname()[1]
+
+
+class ResultPopupTests(unittest.IsolatedAsyncioTestCase):
+ """Management results preserve the output pane and await standard dialogs."""
+
+ asyncSetUp = test_commands.CommandTests.asyncSetUp
+ asyncTearDown = test_commands.CommandTests.asyncTearDown
+
+ async def test_start_popup_preserves_output_and_request_logging(self):
+ """A live server accepts and logs requests before its result popup is dismissed."""
+ self.app.app = type("Runtime", (), {"invalidate": lambda self: None})()
+ self.app.output_text = "Existing output"
+ opened, dismissed = asyncio.Event(), asyncio.Event()
+ port = free_port()
+
+ async def dialog(title, text):
+ self.assertEqual(title, "Server started")
+ self.assertIn("TCP", text)
+ self.assertIsNotNone(self.app.server.listener)
+ opened.set()
+ await dismissed.wait()
+
+ client = AsyncModbusTcpClient("127.0.0.1", port=port)
+ with patch("ctmodbus.app.show_result", dialog):
+ task = asyncio.create_task(
+ self.app.dispatch(f"server start tcp 127.0.0.1 --port {port}")
+ )
+ try:
+ await asyncio.wait_for(opened.wait(), 2)
+ self.assertFalse(task.done())
+ with self.assertRaisesRegex(CommandError, "Dismiss"):
+ await self.app.dispatch("server status")
+ self.assertTrue(await client.connect())
+ self.assertFalse(
+ (await client.read_coils(0, count=1, device_id=1)).isError()
+ )
+ self.assertTrue(self.app.output_text.startswith("Existing output\n"))
+ self.assertIn("SERVER #1", self.app.output_text)
+ finally:
+ dismissed.set()
+ client.close()
+ result = await asyncio.wait_for(task, 2)
+ self.assertIsNone(result.output)
+ self.assertIsNone(self.app._result_popup_task)
+
+ async def test_popup_shutdown_cancels_presentation(self):
+ """Shutdown releases an open popup without undoing a completed configuration edit."""
+ self.app.app = type("Runtime", (), {"invalidate": lambda self: None})()
+ opened = asyncio.Event()
+
+ async def dialog(title, text):
+ opened.set()
+ await asyncio.Event().wait()
+
+ with patch("ctmodbus.app.show_result", dialog):
+ task = asyncio.create_task(
+ self.app.dispatch(
+ "client config set --transport tcp --target localhost"
+ )
+ )
+ await opened.wait()
+ await self.app.on_stop()
+ self.assertTrue(task.cancelled())
+ self.assertIsNone(self.app._result_popup_task)
+ self.assertEqual(self.app.selected_client_settings.target, "localhost")
+
+ def test_scope_and_descriptive_titles(self):
+ """Only agreed owned management commands use result popups."""
+ for command in (
+ "client start tcp",
+ "client stop",
+ "client discover",
+ "client config set",
+ "client config save",
+ "client config load",
+ "client config show",
+ "server status",
+ "server config export",
+ "server hook tick",
+ "proxy logging",
+ "tags list",
+ "tags create",
+ "tags rename",
+ "tags delete",
+ "tags show",
+ "tags import",
+ "tags export",
+ "server reset",
+ "server config clear",
+ "poll status",
+ "poll stop",
+ ):
+ self.assertIsNotNone(result_title(command), command)
+ for command in (
+ "read coils",
+ "write tag",
+ "poll tags",
+ "poll raw",
+ "project configs show",
+ "history export",
+ "project import",
+ "client",
+ "server",
+ "proxy",
+ ):
+ self.assertIsNone(result_title(command), command)
+ self.assertEqual(result_title("tags import"), "Tags imported")
+ self.assertEqual(
+ result_title("client config show"), "Client configuration details"
+ )
+
+
+class CliKeepaliveTests(unittest.IsolatedAsyncioTestCase):
+ """CLI executes its complete sequence before retaining any remaining listener."""
+
+ async def test_sequence_then_keepalive_and_cancellation_cleanup(self):
+ """Later reads run before keepalive; cancellation closes client, server, and backend."""
+ with tempfile.TemporaryDirectory() as folder:
+ app = ModbusApp(data_dir=folder)
+ port = free_port()
+ stdout, stderr = io.StringIO(), io.StringIO()
+ ready = asyncio.Event()
+ keeping_alive = asyncio.Event()
+ original_stop = app.on_stop
+
+ async def observe_stop():
+ keeping_alive.set()
+ await original_stop()
+
+ app.on_stop = observe_stop
+ app.on(
+ "command_finished",
+ lambda command, result: (
+ ready.set() if command.name == "read coils" else None
+ ),
+ )
+ task = asyncio.create_task(
+ app.run_cli(
+ [
+ "-c",
+ f"server start tcp 127.0.0.1 --port {port}",
+ "-c",
+ f"client start tcp 127.0.0.1 --port {port}",
+ "-c",
+ "read coils 0",
+ "-c",
+ "client stop",
+ ],
+ stdout=stdout,
+ stderr=stderr,
+ )
+ )
+ try:
+ await asyncio.wait_for(ready.wait(), 3)
+ await asyncio.wait_for(keeping_alive.wait(), 3)
+ self.assertIsNone(app.connection.client)
+ self.assertFalse(task.done())
+ self.assertIsNotNone(app.server.listener)
+ self.assertIn("Read coils", stdout.getvalue())
+ self.assertEqual(stderr.getvalue(), "")
+ finally:
+ task.cancel()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+ self.assertIsNone(app.server.listener)
+ self.assertIsNone(app.connection.client)
+ self.assertIsNone(app._poll_stdout)
+
+ async def test_explicit_stop_error_and_exit_skip_keepalive(self):
+ """Explicit stop/exit and both expected/unexpected failures clean up promptly."""
+ for ending, status in (
+ ("server stop", 0),
+ ("invalid command", 2),
+ ("exit confirm", 0),
+ ("client discover", 1),
+ ):
+ with self.subTest(ending=ending), tempfile.TemporaryDirectory() as folder:
+ app = ModbusApp(data_dir=folder)
+ stdout, stderr = io.StringIO(), io.StringIO()
+ with patch(
+ "ctmodbus.client_commands.suggestions",
+ side_effect=RuntimeError("discovery failed"),
+ ):
+ result = await asyncio.wait_for(
+ app.run_cli(
+ [
+ "-c",
+ f"server start tcp 127.0.0.1 --port {free_port()}",
+ "-c",
+ ending,
+ ],
+ stdout=stdout,
+ stderr=stderr,
+ ),
+ 3,
+ )
+ self.assertEqual(result, status, stderr.getvalue())
+ self.assertIsNone(app.server.listener)
+ self.assertIsNone(app.connection.client)
+ self.assertIsNone(app._poll_stdout)
+
+ @unittest.skipUnless(os.name == "posix", "SIGINT subprocess check requires POSIX")
+ async def test_cli_ctrl_c_stops_server_without_traceback(self):
+ """The real entry point stays alive, then SIGINT releases its listening socket."""
+ with tempfile.TemporaryDirectory() as folder:
+ port = free_port()
+ process = await asyncio.create_subprocess_exec(
+ sys.executable,
+ "-m",
+ "ctmodbus.commands",
+ "-c",
+ f"server start tcp 127.0.0.1 --port {port}",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env={**os.environ, "XDG_DATA_HOME": folder},
+ )
+ try:
+ line = await asyncio.wait_for(process.stdout.readline(), 3)
+ self.assertIn(b"Server TCP", line)
+ self.assertIsNone(process.returncode)
+ process.send_signal(signal.SIGINT)
+ _, stderr = await asyncio.wait_for(process.communicate(), 3)
+ self.assertEqual(process.returncode, 130, stderr.decode())
+ self.assertNotIn(b"Traceback", stderr)
+ with socket.socket() as released:
+ released.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ released.bind(("127.0.0.1", port))
+ finally:
+ if process.returncode is None:
+ process.kill()
+ await process.wait()
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..3143137
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,780 @@
+"""Server configuration, sparse behavior, real listeners and proxy regressions."""
+
+import asyncio
+import copy
+import os
+import shlex
+import shutil
+import socket
+import ssl
+import struct
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+from ctui import CommandError, ConfirmationRequired
+from pymodbus import FramerType
+from pymodbus.client import (
+ AsyncModbusSerialClient,
+ AsyncModbusTcpClient,
+ AsyncModbusTlsClient,
+ AsyncModbusUdpClient,
+)
+
+from ctmodbus.app import ModbusApp
+from ctmodbus.server_config import definition, dumps, load, validate
+from ctmodbus.simulation import Simulator
+from ctmodbus.tags import Tag, decode_tag_value
+from tests.server import make_server
+
+
+def free_port(udp=False):
+ """Reserve a test port candidate; listener binding still checks failures."""
+ with socket.socket(
+ socket.AF_INET, socket.SOCK_DGRAM if udp else socket.SOCK_STREAM
+ ) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+class ConfigTests(unittest.TestCase):
+ """Validate maps and codecs independently of transport or project storage."""
+
+ def test_full_defaults_and_sparse_roundtrip(self):
+ config = definition()
+ self.assertEqual(validate(config), config)
+ config["tables"]["holding_registers"]["ranges"] = [
+ {
+ "start": 7,
+ "end": 10,
+ "mode": "sequence",
+ "values": [1, 2],
+ "advance": "time",
+ "interval_seconds": 0.5,
+ }
+ ]
+ with tempfile.TemporaryDirectory() as folder:
+ path = Path(folder) / "device.toml"
+ path.write_text(dumps(config))
+ self.assertEqual(load(path), config)
+ sparse = validate(
+ {
+ "format": "ctmodbus-server",
+ "version": 1,
+ "tables": {"coils": {"ranges": [{"start": 7, "value": True}]}},
+ }
+ )
+ self.assertEqual(sparse["tables"]["holding_registers"]["unmapped"], "illegal")
+
+ def test_invalid_spans_and_rules(self):
+ for rule in (
+ {"start": 65536, "value": 0},
+ {"start": 7, "mode": "random", "min": 8, "max": 4},
+ {"start": 7, "mode": "sequence", "values": []},
+ {
+ "start": 7,
+ "mode": "sequence",
+ "values": [1],
+ "advance": "time",
+ "interval_seconds": 0,
+ },
+ ):
+ with self.subTest(rule=rule), self.assertRaises(CommandError):
+ validate(
+ {
+ "format": "ctmodbus-server",
+ "version": 1,
+ "tables": {"holding_registers": {"ranges": [rule]}},
+ }
+ )
+ with self.assertRaises(CommandError):
+ validate(
+ {
+ "format": "ctmodbus-server",
+ "version": 1,
+ "tags": {
+ "bad": {
+ "table": "holding_registers",
+ "address": 65535,
+ "type": "float32",
+ "value": 1.0,
+ }
+ },
+ }
+ )
+
+
+class SimulationTests(unittest.IsolatedAsyncioTestCase):
+ """Exercise transaction rollback, coherent tags and dynamic state advancement."""
+
+ async def test_sparse_defaults_and_writes(self):
+ sim = Simulator(definition())
+ self.assertEqual(await sim.access("holding_registers", 65535, 1), [0])
+ self.assertEqual(sim.values, {})
+ await sim.access("holding_registers", 65535, 1, [42])
+ self.assertEqual(await sim.access("holding_registers", 65535, 1), [42])
+ self.assertEqual(len(sim.values), 1)
+
+ async def test_typed_sequences_and_partial_reads(self):
+ config = definition(False)
+ config["tags"]["level"] = dict(
+ table="input_registers",
+ address=10,
+ type="float32",
+ mode="sequence",
+ values=[12.5, 99.0],
+ )
+ sim = Simulator(config)
+ tag = Tag("level", "input_registers", 10, "float32")
+ self.assertEqual(
+ decode_tag_value(tag, await sim.access("input_registers", 10, 2)), 12.5
+ )
+ self.assertEqual(
+ decode_tag_value(tag, await sim.access("input_registers", 10, 2)), 99.0
+ )
+ self.assertEqual(len(await sim.access("input_registers", 11, 1)), 1)
+ with self.assertRaises(CommandError):
+ await sim.access("input_registers", 9, 2)
+
+ async def test_hooks_commit_and_roll_back(self):
+ config = definition(False)
+ config["tags"] = {
+ "enable": dict(table="coils", address=0, type="bool", value=False),
+ "running": dict(
+ table="discrete_inputs", address=0, type="bool", value=False
+ ),
+ }
+
+ def hook(device, name, value):
+ device.set("running", value)
+
+ sim = Simulator(config, {"on_write": hook})
+ await sim.access("coils", 0, 1, [True])
+ self.assertEqual(await sim.access("discrete_inputs", 0, 1), [1])
+
+ def failing(device, name, value):
+ device.set("running", value)
+ raise ValueError("broken hook")
+
+ sim.hooks["on_write"] = failing
+ with self.assertRaises(ValueError):
+ await sim.access("coils", 0, 1, [False])
+ self.assertEqual(await sim.access("coils", 0, 1), [1])
+ self.assertEqual(await sim.access("discrete_inputs", 0, 1), [1])
+
+ async def test_timed_sequences_and_seed(self):
+ now = [0.0]
+ config = definition(False)
+ config["tables"]["holding_registers"]["ranges"] = [
+ dict(
+ start=7,
+ end=7,
+ mode="sequence",
+ values=[10, 20, 30],
+ advance="time",
+ interval_seconds=0.5,
+ repeat=False,
+ )
+ ]
+ sim = Simulator(config, clock=lambda: now[0])
+ self.assertEqual(await sim.access("holding_registers", 7, 1), [10])
+ now[0] = 5
+ self.assertEqual(await sim.access("holding_registers", 7, 1), [30])
+ config["seed"] = 123
+ config["tables"]["holding_registers"]["ranges"] = [
+ dict(start=7, end=7, mode="random", min=1, max=100)
+ ]
+ first, second = Simulator(config), Simulator(config)
+ self.assertEqual(
+ [await first.access("holding_registers", 7, 1) for _ in range(5)],
+ [await second.access("holding_registers", 7, 1) for _ in range(5)],
+ )
+
+
+class ServerTests(unittest.IsolatedAsyncioTestCase):
+ """Use actual TCP/UDP clients and upstream servers; no external network traffic."""
+
+ async def asyncSetUp(self):
+ self.folder = tempfile.TemporaryDirectory(prefix="ctmodbus server ")
+ self.app = ModbusApp(data_dir=self.folder.name)
+ await self.app.backend.open()
+ self.clients = []
+
+ async def asyncTearDown(self):
+ for client in self.clients:
+ client.close()
+ await self.app.on_stop()
+ await self.app.backend.close()
+ self.folder.cleanup()
+
+ async def client(self, transport="tcp"):
+ port = free_port(transport == "udp")
+ await self.app.dispatch(f"server start {transport} 127.0.0.1 --port {port}")
+ cls = AsyncModbusTcpClient if transport == "tcp" else AsyncModbusUdpClient
+ client = cls("127.0.0.1", port=port, timeout=0.5, retries=0)
+ self.clients.append(client)
+ self.assertTrue(await client.connect())
+ return client
+
+ async def test_tcp_and_udp_boundaries_and_identity(self):
+ for transport in ("tcp", "udp"):
+ client = await self.client(transport)
+ for method in (
+ "read_coils",
+ "read_discrete_inputs",
+ "read_holding_registers",
+ "read_input_registers",
+ ):
+ result = await getattr(client, method)(65535, count=1, device_id=1)
+ self.assertFalse(result.isError())
+ result = await client.read_holding_registers(65535, count=2, device_id=1)
+ self.assertEqual(result.exception_code, 2)
+ result = await client.write_register(65535, 42, device_id=1)
+ self.assertFalse(result.isError())
+ self.assertEqual(
+ (
+ await client.read_holding_registers(65535, count=1, device_id=1)
+ ).registers,
+ [42],
+ )
+ result = await client.read_device_information(device_id=1)
+ self.assertEqual(result.information[0], b"ControlThings")
+ await self.app.dispatch("server stop --confirm")
+
+ async def test_ui_configuration_export_and_import_confirmation(self):
+ for text in (
+ "server config clear --confirm",
+ "tags create level input_register 10 float32",
+ "server config set tag level 12.5",
+ "server config set holding_registers 100-199 42",
+ "server config sequence holding_registers 7 10,20,30 --advance read",
+ "server config table coils --unmapped default --default true",
+ ):
+ await self.app.dispatch(text)
+ path = Path(self.folder.name) / "device.toml"
+ await self.app.dispatch(f"server config export {shlex.quote(str(path))}")
+ original = await self.app.server_definition()
+ await self.app.dispatch("server config clear --confirm")
+ await self.app.dispatch(f"server config import {shlex.quote(str(path))}")
+ self.assertEqual(await self.app.server_definition(), original)
+ changed = copy.deepcopy(original)
+ changed["tags"]["level"]["address"] = 20
+ path.write_text(dumps(changed))
+ with self.assertRaises(ConfirmationRequired):
+ await self.app.dispatch(f"server config import {shlex.quote(str(path))}")
+
+ async def approve(message):
+ path.write_text(dumps(definition(False)))
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("project create other")
+ return True
+
+ await self.app.dispatch(
+ f"server config import {shlex.quote(str(path))}", confirm_callback=approve
+ )
+ self.assertEqual((await self.app.tags.get("level")).address, 20)
+ self.assertEqual(
+ (await self.app.server_definition())["tags"]["level"]["address"], 20
+ )
+
+ async def test_sparse_map_dynamic_rules_and_lifecycle_guards(self):
+ await self.app.dispatch("server config clear --confirm")
+ await self.app.dispatch(
+ "server config sequence holding_registers 7 10,20 --advance read"
+ )
+ client = await self.client()
+ self.assertEqual(
+ (await client.read_holding_registers(7, count=1, device_id=1)).registers,
+ [10],
+ )
+ self.assertEqual(
+ (await client.read_holding_registers(7, count=1, device_id=1)).registers,
+ [20],
+ )
+ self.assertEqual(
+ (
+ await client.read_holding_registers(8, count=1, device_id=1)
+ ).exception_code,
+ 2,
+ )
+ for text in (
+ "project create other",
+ "server config clear --confirm",
+ "tags create x coil 0 bool",
+ ):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(text)
+ self.assertIn("7", (await self.app.dispatch("server status")).output)
+ retained = copy.deepcopy(self.app.server.state.rows)
+ saved = await self.app.server_definition()
+ await self.app.dispatch("server reset --confirm")
+ self.assertEqual(self.app.server.state.rows, retained)
+ self.assertEqual(await self.app.server_definition(), saved)
+ self.assertIn(
+ "sequence", (await self.app.dispatch("server config show")).output
+ )
+ self.assertNotIn(
+ "initial values / rules", (await self.app.dispatch("server status")).output
+ )
+ self.assertEqual(
+ (await client.read_holding_registers(7, count=1, device_id=1)).registers,
+ [10],
+ )
+
+ async def test_proxy_tcp_to_udp_and_client_observations(self):
+ upstream = make_server("udp")
+ await upstream.serve_forever(background=True)
+ port = upstream.transport.get_extra_info("sockname")[1]
+ try:
+ await self.app.dispatch(f"client start udp 127.0.0.1 --port {port}")
+ await self.app.dispatch("read holding_registers 0")
+ self.assertIn("17", (await self.app.dispatch("client status")).output)
+ await self.app.dispatch("server config clear --confirm")
+ await self.app.dispatch("server config unit 7")
+ client = await self.client()
+ await self.app.dispatch("proxy start")
+ for component in ("client", "server"):
+ with self.assertRaises(ConfirmationRequired):
+ await self.app.dispatch(f"{component} stop")
+ rejected = await self.app.dispatch(
+ f"{component} stop", confirm_callback=lambda message: False
+ )
+ self.assertFalse(rejected.accepted)
+ self.assertTrue(self.app.server.proxy)
+ self.assertIsNotNone(self.app.server.listener)
+ self.assertTrue(self.app.connection.connected)
+ self.assertEqual(
+ (
+ await client.read_holding_registers(0, count=1, device_id=7)
+ ).registers,
+ [17],
+ )
+ self.assertEqual(
+ (
+ await client.read_holding_registers(1000, count=1, device_id=7)
+ ).exception_code,
+ 2,
+ )
+ self.assertFalse(
+ (await client.write_register(0, 55, device_id=7)).isError()
+ )
+ self.assertEqual(
+ (
+ await client.read_holding_registers(0, count=1, device_id=7)
+ ).registers,
+ [55],
+ )
+ for text in ("server status", "client status"):
+ self.assertIn("55", (await self.app.dispatch(text)).output)
+ await self.app.dispatch("proxy stop")
+ self.assertEqual(
+ (
+ await client.read_holding_registers(0, count=1, device_id=7)
+ ).exception_code,
+ 2,
+ )
+ await self.app.dispatch("proxy start")
+ await self.app.dispatch("server stop --confirm")
+ self.assertFalse(self.app.server.proxy)
+ self.assertIsNone(self.app.server.listener)
+ self.assertTrue(self.app.connection.connected)
+ finally:
+ await upstream.shutdown()
+
+ async def test_proxy_failure_never_falls_back_and_state_views_differ(self):
+ upstream = make_server("tcp")
+ await upstream.serve_forever(background=True)
+ port = upstream.transport.sockets[0].getsockname()[1]
+ try:
+ await self.app.dispatch(f"client start tcp 127.0.0.1 --port {port}")
+ client = await self.client()
+ await self.app.dispatch("proxy start")
+
+ async def change(response):
+ response.registers = [80]
+ return response
+
+ self.app.server.after_response = change
+ result = await client.read_holding_registers(0, count=1, device_id=1)
+ self.assertEqual(result.registers, [80])
+ self.assertEqual(
+ self.app.client_state.rows["holding_registers", 0]["read"], 17
+ )
+ self.assertEqual(
+ self.app.server.state.rows["holding_registers", 0]["read"], 80
+ )
+ self.app.connection.abort()
+ self.assertEqual(
+ (
+ await client.read_holding_registers(0, count=1, device_id=1)
+ ).exception_code,
+ 11,
+ )
+ await self.app.dispatch("client stop --confirm")
+ self.assertFalse(self.app.server.proxy)
+ self.assertFalse(
+ (await client.read_holding_registers(0, count=1, device_id=1)).isError()
+ )
+ finally:
+ await upstream.shutdown()
+
+ async def test_hook_file_failure_rolls_back_and_tick_changes_values(self):
+ path = Path(self.folder.name) / "logic.py"
+ path.write_text(
+ "def on_write(device, tag, value):\n device.set('running', value)\n raise ValueError('intentional failure')\n\ndef on_tick(device, elapsed):\n device.set('running', True)\n"
+ )
+ await self.app.dispatch("server config clear --confirm")
+ await self.app.dispatch("tags create enable coil 0 bool")
+ await self.app.dispatch("tags create running discrete_input 0 bool")
+ await self.app.dispatch("server config set tag enable false")
+ await self.app.dispatch("server config set tag running false")
+ await self.app.dispatch(f"server hook write {shlex.quote(str(path))} on_write")
+ await self.app.dispatch(
+ f"server hook tick {shlex.quote(str(path))} on_tick --interval 0.02"
+ )
+ await self.app.dispatch("server config validate")
+ client = await self.client()
+ response = await client.write_coil(0, True, device_id=1)
+ self.assertEqual(response.exception_code, 4)
+ self.assertFalse((await client.read_coils(0, count=1, device_id=1)).bits[0])
+ self.assertIn(
+ "intentional failure", (await self.app.dispatch("server status")).output
+ )
+
+ async def tick_done():
+ while not self.app.server.simulator.values.get(
+ ("discrete_inputs", 0), False
+ ):
+ await asyncio.sleep(0.01)
+
+ await asyncio.wait_for(tick_done(), 1)
+ self.assertTrue(
+ (await client.read_discrete_inputs(0, count=1, device_id=1)).bits[0]
+ )
+ path_export = Path(self.folder.name) / "exported.toml"
+ await self.app.dispatch(f"server config export {shlex.quote(str(path_export))}")
+ self.assertEqual(load(path_export)["hooks"]["module"], str(path.resolve()))
+
+ async def test_raw_invalid_count_and_pipelined_transactions(self):
+ await self.client()
+ port = self.app.server.listener.transport.sockets[0].getsockname()[1]
+ reader, writer = await asyncio.open_connection("127.0.0.1", port)
+
+ def frame(transaction, count):
+ return struct.pack(">HHHBBHH", transaction, 0, 6, 1, 3, 0, count)
+
+ writer.write(frame(41, 0) + frame(42, 1))
+ await writer.drain()
+
+ async def packet():
+ header = await reader.readexactly(7)
+ transaction, _, length, _ = struct.unpack(">HHHB", header)
+ return transaction, await reader.readexactly(length - 1)
+
+ try:
+ first = await asyncio.wait_for(packet(), 1)
+ second = await asyncio.wait_for(packet(), 1)
+ self.assertEqual(first, (41, bytes([0x83, 3])))
+ self.assertEqual(second, (42, bytes([3, 2, 0, 0])))
+ finally:
+ writer.close()
+ await writer.wait_closed()
+
+ async def test_bind_failure_and_nonblocking_start(self):
+ port = free_port()
+ with socket.socket() as occupied:
+ occupied.bind(("127.0.0.1", port))
+ occupied.listen()
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(f"server start tcp 127.0.0.1 --port {port}")
+ self.assertIsNone(self.app.server.listener)
+ result = await asyncio.wait_for(
+ self.app.dispatch(f"server start tcp 127.0.0.1 --port {port}"), 1
+ )
+ self.assertIn("Server TCP", result.output)
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("server start tcp localhost --foreground")
+ await self.app.dispatch("server stop --confirm")
+ self.assertIsNone(self.app.server.listener)
+
+ @unittest.skipUnless(shutil.which("openssl"), "TLS certificates require OpenSSL")
+ async def test_native_tls_and_client_certificate_requirement(self):
+ """Verify TLS/mTLS on IPv4 with matching SANs and the normal 3s budget.
+
+ Avoid localhost address-family fallback and a subsecond TLS deadline on
+ Windows runners; certificate and hostname verification remain enabled.
+ """
+ cert, key = (
+ Path(self.folder.name) / "server.pem",
+ Path(self.folder.name) / "server.key",
+ )
+ subprocess.run(
+ [
+ "openssl",
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-keyout",
+ str(key),
+ "-out",
+ str(cert),
+ "-days",
+ "1",
+ "-subj",
+ "/CN=localhost",
+ "-addext",
+ "subjectAltName=DNS:localhost,IP:127.0.0.1",
+ ],
+ check=True,
+ capture_output=True,
+ )
+ port = free_port()
+ await self.app.dispatch(
+ f"server start tls 127.0.0.1 --port {port} --cert-file {shlex.quote(str(cert))} --key-file {shlex.quote(str(key))}"
+ )
+ context = ssl.create_default_context(cafile=str(cert))
+ client = AsyncModbusTlsClient(
+ "127.0.0.1", port=port, sslctx=context, timeout=3, retries=0
+ )
+ self.clients.append(client)
+ self.assertTrue(await client.connect())
+ self.assertEqual(
+ (await client.read_holding_registers(0, count=1, device_id=1)).registers,
+ [0],
+ )
+ client.close()
+ await self.app.dispatch("server stop --confirm")
+ await self.app.dispatch(
+ f"server start tls 127.0.0.1 --port {port} --cert-file {shlex.quote(str(cert))} --key-file {shlex.quote(str(key))} --ca-file {shlex.quote(str(cert))}"
+ )
+ context.load_cert_chain(str(cert), str(key))
+ client = AsyncModbusTlsClient(
+ "127.0.0.1", port=port, sslctx=context, timeout=3, retries=0
+ )
+ self.clients.append(client)
+ self.assertTrue(await client.connect())
+ self.assertFalse((await client.read_coils(0, count=1, device_id=1)).isError())
+
+ @unittest.skipUnless(os.name == "posix", "Serial PTYs require POSIX")
+ async def test_serial_listeners_and_tcp_to_rtu_proxy(self):
+ import tty
+
+ for transport in ("rtu", "ascii"):
+ first, a, second, b = (*os.openpty(), *os.openpty())
+ loop = asyncio.get_running_loop()
+ for fd in (a, b):
+ tty.setraw(fd)
+ for fd in (first, second):
+ os.set_blocking(fd, False)
+
+ def forward(source, destination):
+ try:
+ data = os.read(source, 65536)
+ if data:
+ os.write(destination, data)
+ except BlockingIOError:
+ pass
+
+ loop.add_reader(first, forward, first, second)
+ loop.add_reader(second, forward, second, first)
+ peer = None
+ try:
+ await self.app.dispatch(f"server start {transport} {os.ttyname(a)}")
+ peer = AsyncModbusSerialClient(
+ os.ttyname(b),
+ framer=FramerType.RTU if transport == "rtu" else FramerType.ASCII,
+ baudrate=9600,
+ timeout=1,
+ retries=0,
+ )
+ self.assertTrue(await peer.connect())
+ self.assertFalse(
+ (await peer.write_register(65535, 42, device_id=1)).isError()
+ )
+ self.assertEqual(
+ (
+ await peer.read_holding_registers(65535, count=1, device_id=1)
+ ).registers,
+ [42],
+ )
+ peer.close()
+ peer = None
+ # Turn the serial peer into an upstream device and proxy TCP to it.
+ await self.app.dispatch("server stop --confirm")
+ upstream = make_server(transport, os.ttyname(a))
+ await upstream.serve_forever(background=True)
+ try:
+ await self.app.dispatch(
+ f"client start {transport} {os.ttyname(b)} --timeout 1"
+ )
+ downstream = await self.client()
+ await self.app.dispatch("proxy start")
+ self.assertEqual(
+ (
+ await downstream.read_holding_registers(
+ 0, count=1, device_id=1
+ )
+ ).registers,
+ [17],
+ )
+ await self.app.dispatch("server stop --confirm")
+ await self.app.dispatch("client stop --confirm")
+ finally:
+ await upstream.shutdown()
+ finally:
+ if peer:
+ peer.close()
+ await self.app.server.stop()
+ loop.remove_reader(first)
+ loop.remove_reader(second)
+ for fd in (first, a, second, b):
+ os.close(fd)
+
+ async def test_client_state_session_reset_and_write_evidence(self):
+ client = await self.client()
+ port = self.app.server.listener.transport.sockets[0].getsockname()[1]
+ await self.app.dispatch(f"client start tcp 127.0.0.1 --port {port}")
+ await self.app.dispatch("write holding_registers 3 42")
+ row = self.app.client_state.rows["holding_registers", 3]
+ self.assertNotIn("read", row)
+ self.assertIn("acknowledged", row["outcome"])
+ await self.app.dispatch("read holding_registers 3")
+ self.assertEqual(row["read"], 42)
+ await self.app.dispatch("client stop --confirm")
+ await self.app.dispatch(f"client start tcp 127.0.0.1 --port {port}")
+ self.assertEqual(self.app.client_state.rows, {})
+ await self.app.dispatch("client stop --confirm")
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("write holding_registers 3 99")
+ self.assertEqual(
+ self.app.client_state.rows["holding_registers", 3]["outcome"], "not sent"
+ )
+ client.close()
+
+ async def test_request_lines_quiet_switches_and_records(self):
+ """Routine arrivals append; live quiet switches preserve records and errors."""
+ client = await self.client()
+ self.app.output_text = "Earlier output"
+ await client.read_holding_registers(0, count=2, device_id=1)
+ lines = self.app.output_text.splitlines()
+ self.assertEqual(lines[0], "Earlier output")
+ self.assertIn("SERVER #1 peer=", lines[1])
+ self.assertIn("unit=1 read holding_registers 0-1", lines[1])
+ self.assertRegex(lines[1], r"^\d{4}-\d\d-\d\dT.*\+00:00 SERVER")
+ await self.app.dispatch("server logging off")
+ self.app.output_text = "Quiet"
+ await client.read_coils(0, count=1, device_id=1)
+ self.assertEqual(self.app.output_text, "Quiet")
+ config = self.app.server.config
+ config["tables"]["holding_registers"]["unmapped"] = "illegal"
+ config["tables"]["holding_registers"]["ranges"] = []
+ # Replace the runtime simulator only for this error fixture.
+ self.app.server.simulator = Simulator(config)
+ response = await client.read_holding_registers(0, count=1, device_id=1)
+ self.assertTrue(response.isError())
+ self.assertIn("SERVER #3 ERROR exception=2", self.app.output_text)
+ records = await self.app.records.query(
+ direction="received", protocol="modbus-server"
+ )
+ self.assertEqual(len(records), 3)
+ self.assertEqual([row.decoded["request_id"] for row in records], [1, 2, 3])
+ await self.app.dispatch("server logging on")
+ self.assertIn(
+ "Request logging: on", (await self.app.dispatch("server status")).output
+ )
+
+ async def test_proxy_lines_independent_quiet_and_failure(self):
+ """Forwarding lines share IDs, show mapped units, and retain quiet-mode errors."""
+ upstream = make_server("tcp")
+ await upstream.serve_forever(background=True)
+ port = upstream.transport.sockets[0].getsockname()[1]
+ try:
+ await self.app.dispatch(f"client start tcp 127.0.0.1 --port {port}")
+ await self.app.dispatch("server config unit 7")
+ downstream = free_port()
+ await self.app.dispatch(
+ f"server start tcp 127.0.0.1 --port {downstream} --quiet"
+ )
+ client = AsyncModbusTcpClient(
+ "127.0.0.1", port=downstream, timeout=0.5, retries=0
+ )
+ self.clients.append(client)
+ self.assertTrue(await client.connect())
+ await self.app.dispatch("proxy start")
+ self.app.output_text = ""
+ await client.read_holding_registers(0, count=1, device_id=7)
+ self.assertNotIn("SERVER", self.app.output_text)
+ self.assertIn("PROXY #1 upstream=TCP", self.app.output_text)
+ self.assertIn("unit=1 read holding_registers 0", self.app.output_text)
+ await self.app.dispatch("server logging on")
+ await self.app.dispatch("proxy logging off")
+ self.app.output_text = ""
+ await client.read_holding_registers(0, count=1, device_id=7)
+ self.assertIn("SERVER #2", self.app.output_text)
+ self.assertNotIn("PROXY", self.app.output_text)
+ await self.app.dispatch("proxy logging on")
+ self.app.output_text = ""
+ await client.write_register(0, 113, device_id=7)
+ self.assertIn("SERVER #3", self.app.output_text)
+ self.assertIn("PROXY #3", self.app.output_text)
+ self.assertEqual(self.app.output_text.count("values=0071"), 2)
+ await self.app.dispatch("proxy start --quiet")
+ await self.app.dispatch("server logging off")
+ self.app.output_text = ""
+ await client.read_holding_registers(1000, count=1, device_id=7)
+ self.assertIn("PROXY #4 ERROR upstream exception=2", self.app.output_text)
+ self.assertIn("SERVER #4 ERROR exception=2", self.app.output_text)
+ self.assertIn(
+ "Logging: off", (await self.app.dispatch("proxy status")).output
+ )
+ finally:
+ await upstream.shutdown()
+
+ async def test_log_preview_limits_escaping_and_restart_defaults(self):
+ """Bound write previews, escape controls, and reset quiet policy on restart."""
+ client = await self.client()
+ await client.write_registers(0, list(range(20)), device_id=1)
+ self.assertIn("(+4 omitted)", self.app.output_text)
+ records = await self.app.records.query(
+ direction="received", protocol="modbus-server"
+ )
+ self.assertEqual(records[-1].decoded["values"], list(range(20)))
+ self.assertNotIn(
+ "values=0000 0001 0002 0003 0004 0005 0006 0007 0008 0009 000A 000B 000C 000D 000E 000F 0010",
+ self.app.output_text,
+ )
+ self.app.server.log_line("SERVER", 99, "ERROR bad\n\x1b[31m")
+ self.assertIn(r"ERROR bad\n\x1b[31m", self.app.output_text)
+ await self.app.dispatch("server logging off")
+ await self.app.dispatch("server stop --confirm")
+ await self.client()
+ self.assertTrue(self.app.server.request_logging)
+
+ async def test_request_logs_in_web_and_cli_output(self):
+ """Request lines use the real browser layout and selected CLI output stream."""
+ import io
+
+ from ctui.web import WebSession
+
+ client = await self.client()
+ session = WebSession(self.app, port=0, stdout=io.StringIO())
+ try:
+ await session.start()
+ self.app.layout.set_output("Existing browser output")
+ await client.read_coils(0, count=2, device_id=1)
+ text = self.app.layout.output_field.text
+ self.assertTrue(text.startswith("Existing browser output\n"))
+ self.assertIn("SERVER #1", text)
+ stream = io.StringIO()
+ self.app._poll_stdout = stream
+ try:
+ await client.read_coils(0, count=1, device_id=1)
+ finally:
+ self.app._poll_stdout = None
+ self.assertIn("SERVER #2", stream.getvalue())
+ self.assertNotIn("SERVER #2", self.app.layout.output_field.text)
+ finally:
+ await session.close()
diff --git a/tests/test_tag_deletion.py b/tests/test_tag_deletion.py
new file mode 100644
index 0000000..c53e156
--- /dev/null
+++ b/tests/test_tag_deletion.py
@@ -0,0 +1,256 @@
+"""Bulk and referenced tag deletion, approval guards, and transactional rollback."""
+
+import asyncio
+import copy
+import io
+import unittest
+from unittest.mock import AsyncMock, patch
+
+import test_tags
+from ctui import CommandError, ConfirmationRequired
+
+from ctmodbus.server_config import CONFIG_NAME, definition
+
+
+class TagDeletionTests(unittest.IsolatedAsyncioTestCase):
+ """Use a real temporary project database with shared tag command fixtures."""
+
+ asyncSetUp = test_tags.TagCommandTests.asyncSetUp
+ asyncTearDown = test_tags.TagCommandTests.asyncTearDown
+
+ async def seed(self, hooks=False):
+ """Create two project tags and one associated server tag rule."""
+ await self.app.dispatch("tags create timer holding_register 2 uint16")
+ await self.app.dispatch("tags create ready coil 0 bool")
+ config = definition()
+ config["seed"] = 9
+ config["tags"]["timer"] = {
+ "table": "holding_registers",
+ "address": 2,
+ "type": "uint16",
+ "mode": "sequence",
+ "values": [1, 2],
+ }
+ config["tables"]["holding_registers"]["ranges"] = [
+ {"start": 8, "end": 9, "value": 55}
+ ]
+ if hooks:
+ config["hooks"] = {"module": "unused.py", "on_read": "read"}
+ await self.app.save_server_definition(config)
+ return await self.app.server_definition()
+
+ async def test_bulk_requires_approval_and_empty_is_noop(self):
+ """Empty projects do not prompt; malformed and unknown selections fail safely."""
+ self.assertEqual(
+ (await self.app.dispatch("tags delete --all")).output, "No tags to delete."
+ )
+ await self.app.dispatch("tags create timer holding_register 2 uint16")
+ for command in (
+ "tags delete",
+ "tags delete timer --all",
+ "tags delete unknown",
+ ):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(command)
+ with self.assertRaisesRegex(ConfirmationRequired, "Delete 1 project tags"):
+ await self.app.dispatch("tags delete --all")
+ declined = await self.app.dispatch(
+ "tags delete --all", confirm_callback=lambda message: False
+ )
+ self.assertFalse(declined.accepted)
+ self.assertEqual(await self.app.tags.names(), {"timer"})
+ result = await self.app.dispatch("tags delete --all --confirm")
+ self.assertIn("Deleted 1", result.output)
+ self.assertEqual(await self.app.tags.names(), set())
+ self.assertNotIn(CONFIG_NAME, await self.app.configs.list())
+
+ async def test_references_require_opt_in_and_unrelated_state_is_retained(self):
+ """Single and bulk deletion refuse saved references without explicit rule removal."""
+ config = await self.seed()
+ for command in ("tags delete timer", "tags delete --all --confirm"):
+ with self.assertRaisesRegex(CommandError, "timer.*--remove-server-rules"):
+ await self.app.dispatch(command)
+ self.assertEqual(await self.app.server_definition(), config)
+ await self.app.dispatch("tags delete ready")
+ self.assertEqual(await self.app.tags.names(), {"timer"})
+ with self.assertRaises(ConfirmationRequired):
+ await self.app.dispatch("tags delete timer --remove-server-rules")
+ await self.app.dispatch("tags delete timer --remove-server-rules --confirm")
+ config["tags"] = {}
+ self.assertEqual(await self.app.server_definition(), config)
+ self.assertEqual(await self.app.tags.names(), set())
+
+ async def test_confirmed_exact_plan_and_hooks_warning(self):
+ """Show both counts and hook warning before approval, blocking competing edits."""
+ config = await self.seed(hooks=True)
+
+ async def approve(message):
+ self.assertIn(
+ "Delete 2 project tags and remove 1 server tag rules", message
+ )
+ self.assertIn("Python hooks", message)
+ for command in (
+ "tags create another coil 3 bool",
+ "tags rename timer renamed",
+ "tags delete ready",
+ "tags import missing.toml",
+ "project create other",
+ "server config unit 2",
+ "server start tcp localhost",
+ "poll tags",
+ ):
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(command)
+ await self.app.dispatch("help tags delete")
+ return True
+
+ result = await self.app.dispatch(
+ "tags del --all --remove-server-rules", confirm_callback=approve
+ )
+ self.assertIn("Python hooks", result.output)
+ config["tags"] = {}
+ self.assertEqual(await self.app.server_definition(), config)
+ self.assertEqual(await self.app.tags.names(), set())
+ self.assertIsNone(self.app._tag_delete_task)
+ self.assertEqual(self.app._prepared_tag_deletions, {})
+
+ async def test_cancellation_during_confirmation_preserves_everything(self):
+ """Cancelled approval releases guards without applying any part of deletion."""
+ config = await self.seed()
+ entered = asyncio.Event()
+
+ async def approve(message):
+ entered.set()
+ await asyncio.Event().wait()
+
+ task = asyncio.create_task(
+ self.app.dispatch(
+ "tags delete --all --remove-server-rules", confirm_callback=approve
+ )
+ )
+ await entered.wait()
+ task.cancel()
+ with self.assertRaisesRegex(CommandError, "cancelled"):
+ await task
+ self.assertEqual(await self.app.tags.names(), {"timer", "ready"})
+ self.assertEqual(await self.app.server_definition(), config)
+ self.assertIsNone(self.app._tag_delete_task)
+ self.assertEqual(self.app._prepared_tag_deletions, {})
+
+ async def test_sql_failure_rolls_back_tags_and_config(self):
+ """A failing config update rolls back all deletes on the separate connection."""
+ config = await self.seed()
+ await self.app.backend.connection.execute(
+ "CREATE TRIGGER reject_config BEFORE UPDATE ON configs BEGIN SELECT RAISE(ABORT, 'blocked'); END"
+ )
+ await self.app.backend.connection.commit()
+ with self.assertRaisesRegex(CommandError, "rolled back"):
+ await self.app.dispatch("tags delete --all --remove-server-rules --confirm")
+ self.assertEqual(await self.app.tags.names(), {"timer", "ready"})
+ self.assertEqual(await self.app.server_definition(), config)
+
+ async def test_later_delete_failure_rolls_back_earlier_deletes(self):
+ """A later tag SQL failure cannot expose a partial bulk deletion."""
+ await self.seed()
+ await self.app.backend.connection.execute(
+ "CREATE TRIGGER reject_tag BEFORE DELETE ON tags WHEN OLD.name = 'timer' BEGIN SELECT RAISE(ABORT, 'blocked'); END"
+ )
+ await self.app.backend.connection.commit()
+ before = await self.app.server_definition()
+ with self.assertRaisesRegex(CommandError, "rolled back"):
+ await self.app.dispatch("tags delete --all --remove-server-rules --confirm")
+ self.assertEqual(await self.app.tags.names(), {"timer", "ready"})
+ self.assertEqual(await self.app.server_definition(), before)
+
+ async def test_changed_saved_config_cannot_be_overwritten(self):
+ """Even an inherited config edit during approval is detected before mutation."""
+ config = await self.seed()
+ changed = copy.deepcopy(config)
+ changed["unit"] = 7
+
+ async def approve(message):
+ await self.app.configs.save(CONFIG_NAME, changed)
+ return True
+
+ with self.assertRaisesRegex(CommandError, "configuration changed"):
+ await self.app.dispatch(
+ "tags delete --all --remove-server-rules", confirm_callback=approve
+ )
+ self.assertEqual(await self.app.tags.names(), {"timer", "ready"})
+ self.assertEqual(await self.app.server_definition(), changed)
+
+ async def test_inflight_tag_edit_prevents_deletion(self):
+ """Do not prepare deletion while an earlier create is unfinished."""
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = self.app.tags.save
+
+ async def paused(tag):
+ entered.set()
+ await release.wait()
+ await original(tag)
+
+ with patch.object(self.app.tags, "save", paused):
+ task = asyncio.create_task(
+ self.app.dispatch("tags create ready coil 0 bool")
+ )
+ await entered.wait()
+ try:
+ with self.assertRaisesRegex(CommandError, "Tag edits"):
+ await self.app.dispatch("tags delete --all --confirm")
+ finally:
+ release.set()
+ await task
+ self.assertEqual(await self.app.tags.names(), {"ready"})
+
+ async def test_server_and_polling_must_be_stopped(self):
+ """Existing lifecycle guards also cover confirmed bulk deletion."""
+ await self.seed()
+ with patch.object(self.app.server, "listener", object()):
+ with self.assertRaisesRegex(CommandError, "Stop the server"):
+ await self.app.dispatch(
+ "tags delete --all --remove-server-rules --confirm"
+ )
+ await self.app.dispatch("client start tcp localhost")
+ await self.app.dispatch("poll raw --coils 0 --interval 0.01")
+ try:
+ with self.assertRaisesRegex(CommandError, "Stop polling"):
+ await self.app.dispatch(
+ "tags delete --all --remove-server-rules --confirm"
+ )
+ finally:
+ await self.app.dispatch("poll stop")
+ self.assertEqual(await self.app.tags.names(), {"timer", "ready"})
+
+ async def test_browser_confirmation_then_message_result(self):
+ """The real WUI presenter asks Yes/No and presents completion as a message."""
+ from ctui.web import WebClient, WebSession
+
+ await self.seed()
+ session = WebSession(self.app, port=0, stdout=io.StringIO())
+ socket = type("Socket", (), {"closed": False, "send_json": AsyncMock()})()
+ view = WebClient(session, socket)
+ try:
+ await session.start()
+ self.app.layout.set_output("Existing output")
+ with patch.object(
+ view, "dialog", AsyncMock(return_value={"button": 1})
+ ) as dialog:
+ await view.spawn(
+ view.command("tags delete --all --remove-server-rules", "decline")
+ )
+ self.assertEqual(dialog.await_args.args[0], "Confirm")
+ self.assertEqual(dialog.await_args.args[2], ["Yes", "No"])
+ self.assertEqual(await self.app.tags.names(), {"timer", "ready"})
+ with patch.object(
+ view, "dialog", AsyncMock(return_value={"button": 0})
+ ) as dialog:
+ await view.spawn(
+ view.command("tags delete --all --remove-server-rules", "approve")
+ )
+ self.assertEqual(dialog.await_count, 2)
+ self.assertEqual(dialog.await_args.args[0], "Tags deleted")
+ self.assertEqual(len(dialog.await_args.args[2]), 1)
+ self.assertEqual(await self.app.tags.names(), set())
+ self.assertEqual(self.app.layout.output_field.text, "Existing output")
+ finally:
+ await session.close()
diff --git a/tests/test_tags.py b/tests/test_tags.py
new file mode 100644
index 0000000..3ecdfd1
--- /dev/null
+++ b/tests/test_tags.py
@@ -0,0 +1,461 @@
+"""Typed tag persistence, conversion, I/O, and interchange tests."""
+
+import asyncio
+import shlex
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+from ctui import CommandError, ConfirmationRequired, PathCompleter
+
+from ctmodbus.app import ModbusApp
+from ctmodbus.tags import (
+ Tag,
+ decode_tag_value,
+ encode_tag_value,
+ export_tag_document,
+ parse_tag_value,
+)
+from tests.test_commands import FakeClient, response
+
+
+class TagCodecTests(unittest.TestCase):
+ """Verify scalar encodings and parser boundaries independently of device I/O."""
+
+ def test_integer_radices_and_byte_word_order(self):
+ """Check radix parsing, signed bit patterns, and default/swapped register bytes."""
+ tag = Tag("timer", "holding_registers", 2, "int32")
+ self.assertEqual(parse_tag_value(tag, "0d33_000"), 33000)
+ self.assertEqual(parse_tag_value(tag, "0b1000_0001"), 129)
+ self.assertEqual(parse_tag_value(tag, "-0x10"), -16)
+ self.assertEqual(parse_tag_value(tag, "0x8000_0000"), -(2**31))
+ self.assertEqual(parse_tag_value(tag, "0xFFFF_FFFF"), -1)
+ self.assertEqual(
+ encode_tag_value(Tag("x", "holding_registers", 0, "uint32"), 0x01020304),
+ [0x0304, 0x0102],
+ )
+ self.assertEqual(
+ encode_tag_value(
+ Tag(
+ "x",
+ "holding_registers",
+ 0,
+ "uint32",
+ byte_order="little",
+ ),
+ 0x01020304,
+ ),
+ [0x0403, 0x0201],
+ )
+
+ def test_eight_bit_padding_and_round_trips(self):
+ """Verify zero padding and signed, wide-integer, and floating codec round trips."""
+ big = Tag("x", "holding_registers", 0, "uint8")
+ little = Tag("x", "holding_registers", 0, "uint8", byte_order="little")
+ self.assertEqual(encode_tag_value(big, 0x12), [0x0012])
+ self.assertEqual(encode_tag_value(little, 0x12), [0x1200])
+ for tag, value in (
+ (Tag("x", "holding_registers", 0, "int8"), -2),
+ (Tag("x", "holding_registers", 0, "int16"), -1234),
+ (Tag("x", "holding_registers", 0, "uint64"), 2**60),
+ (Tag("x", "holding_registers", 0, "float32"), 12.5),
+ (Tag("x", "holding_registers", 0, "float64"), -3.25e20),
+ ):
+ with self.subTest(type=tag.type):
+ encoded = encode_tag_value(tag, value)
+ self.assertEqual(decode_tag_value(tag, encoded), value)
+
+ def test_value_and_definition_validation(self):
+ """Reject incompatible tables, invalid spans/names, and non-finite float input."""
+ with self.assertRaises(CommandError):
+ Tag("bad name", "coils", 0, "bool").validate()
+ with self.assertRaises(CommandError):
+ Tag("bad", "coils", 0, "uint16").validate()
+ with self.assertRaises(CommandError):
+ Tag("bad", "holding_registers", 65535, "uint32").validate()
+ float_tag = Tag("x", "holding_registers", 0, "float32")
+ for value in ("nan", "inf", "-inf"):
+ with self.subTest(value=value), self.assertRaises(CommandError):
+ parse_tag_value(float_tag, value)
+
+ def test_empty_export_has_an_importable_tags_table(self):
+ """Ensure an empty export includes the required tags table header."""
+ self.assertIn("[tags]", export_tag_document([]))
+
+
+class TagCommandTests(unittest.IsolatedAsyncioTestCase):
+ """Exercise tag persistence and commands with isolated storage and fake I/O."""
+
+ async def asyncSetUp(self):
+ """Open a fresh temporary project and inject a deterministic fake client."""
+ self.directory = tempfile.TemporaryDirectory(prefix="ctmodbus tags ")
+ self.client = FakeClient()
+ self.app = ModbusApp(
+ data_dir=self.directory.name, client_factory=lambda settings: self.client
+ )
+ await self.app.backend.open()
+
+ async def asyncTearDown(self):
+ """Stop application tasks, close project storage, and remove temporary data."""
+ await self.app.on_stop()
+ await self.app.backend.close()
+ self.directory.cleanup()
+
+ async def connect(self):
+ """Open the test TCP session using the injected fake client."""
+ await self.app.dispatch("client start tcp localhost")
+
+ async def test_create_list_rename_delete_and_project_scope(self):
+ """Verify tag CRUD, project switching, and full-reset removal."""
+ await self.app.dispatch("tags create timer holding_register 2 int32")
+ result = await self.app.dispatch("tags list")
+ self.assertIn("timer", result.output)
+ self.assertIn("little", result.output)
+ result = await self.app.dispatch("tags show timer")
+ self.assertIn("range: 2-3", result.output)
+ await self.app.dispatch("tags rename timer duration")
+ self.assertEqual((await self.app.tags.get("duration")).address, 2)
+ await self.app.dispatch("tags delete duration")
+ self.assertEqual(await self.app.tags.list(), [])
+
+ await self.app.dispatch("tags create local coil 0 bool")
+ await self.app.dispatch("project create other")
+ self.assertEqual(await self.app.tags.list(), [])
+ await self.app.dispatch("project load default")
+ self.assertEqual((await self.app.tags.get("local")).table, "coils")
+ await self.app.dispatch("project reset all confirm")
+ self.assertEqual(await self.app.tags.list(), [])
+
+ async def test_overlap_warning_and_duplicate_rejection(self):
+ """Permit overlaps but reject duplicate names and invalid creation syntax."""
+ await self.app.dispatch("tags create first holding_register 0 uint32")
+ result = await self.app.dispatch("tags create second holding_register 1 uint16")
+ self.assertIn("overlaps tags: first", result.output)
+ with self.assertRaisesRegex(CommandError, "already exists"):
+ await self.app.dispatch("tags create first holding_register 3 uint16")
+ with self.assertRaisesRegex(CommandError, "do not accept"):
+ await self.app.dispatch("tags create bit coil 0 bool --byte-order little")
+ with self.assertRaisesRegex(CommandError, "must be one of"):
+ await self.app.dispatch("tags create old coils 0 bool")
+
+ async def test_tag_reads_and_writes(self):
+ """Verify raw encodings, signed writes, read-only rejection, and read-all dispatch."""
+ await self.app.dispatch("tags create switch coil 0 bool")
+ await self.app.dispatch("tags create state holding_register 5 uint8")
+ await self.app.dispatch("tags create timer holding_register 6 int32")
+ await self.app.dispatch("tags create sensed discrete_input 4 bool")
+ await self.connect()
+
+ await self.app.dispatch("write tag switch on")
+ await self.app.dispatch("write tag state 0b0111_0001")
+ await self.app.dispatch("write tag timer 0d33_000")
+ self.assertEqual(
+ [
+ (name, values.get("value", values.get("values")))
+ for name, values in self.client.calls[-3:]
+ ],
+ [
+ ("write_coil", True),
+ ("write_register", 113),
+ ("write_registers", [33000, 0]),
+ ],
+ )
+ with self.assertRaisesRegex(CommandError, "read-only"):
+ await self.app.dispatch("write tag sensed 1")
+ result = await self.app.dispatch("write tag timer 0x8000_0000")
+ self.assertIn("-2147483648", result.output)
+ self.assertEqual(self.client.calls[-1][1]["values"], [0, 0x8000])
+ result = await self.app.dispatch("write tag timer -- -2_000_000_000")
+ self.assertIn("-2000000000", result.output)
+ with self.assertRaisesRegex(CommandError, "outside the range"):
+ await self.app.dispatch("write tag timer -- -4_000_000_000")
+
+ self.client.calls.clear()
+ self.client.reply = lambda name, args: (
+ response(function_code=1, bits=[True] * 8)
+ if name == "read_coils"
+ else (
+ response(function_code=2, bits=[False] * 8)
+ if name == "read_discrete_inputs"
+ else response(
+ function_code=3,
+ registers=[113] if args["address"] == 5 else [33000, 0],
+ )
+ )
+ )
+ result = await self.app.dispatch("read tags switch,state,timer")
+ self.assertIn("switch", result.output)
+ self.assertIn("33000", result.output)
+ self.assertEqual(len(self.client.calls), 3)
+
+ self.client.calls.clear()
+ result = await self.app.dispatch("read tags")
+ for name in ("sensed", "state", "switch", "timer"):
+ self.assertIn(name, result.output)
+ self.assertEqual(len(self.client.calls), 4)
+
+ async def test_multi_tag_order_duplicates_and_prevalidation(self):
+ """Preserve explicit order/repeats and reject unknown tags before I/O."""
+ await self.app.dispatch("tags create first holding_register 1 uint16")
+ await self.app.dispatch("tags create second holding_register 2 uint16")
+ await self.connect()
+ await self.app.dispatch("read tags second,first,second")
+ self.assertEqual([args["address"] for _, args in self.client.calls], [2, 1, 2])
+ self.client.calls.clear()
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("read tags first,missing")
+ self.assertEqual(self.client.calls, [])
+
+ async def test_multi_tag_failure_preserves_completed_rows(self):
+ """Keep decoded rows and stop before later tags on a malformed reply."""
+ for name, address in (("first", 1), ("second", 2), ("third", 3)):
+ await self.app.dispatch(
+ f"tags create {name} holding_register {address} uint16"
+ )
+ await self.connect()
+ self.client.reply = lambda name, args: response(
+ function_code=3, registers=[42] if args["address"] == 1 else []
+ )
+ with self.assertRaises(CommandError) as caught:
+ await self.app.dispatch("read tags first,second,third")
+ output = str(caught.exception)
+ for text in (
+ "failed at 'second'",
+ "1 tags completed",
+ "first",
+ "42",
+ "1 remaining tags",
+ ):
+ self.assertIn(text, output)
+ self.assertEqual(len(self.client.calls), 2)
+ self.assertFalse(self.app.connection.lock.locked())
+ self.assertTrue(self.app.connection.connected)
+
+ async def test_multi_tag_read_holds_reservation(self):
+ """A queued raw read cannot execute between two tag requests."""
+ for name, address in (("first", 1), ("second", 2)):
+ await self.app.dispatch(
+ f"tags create {name} holding_register {address} uint16"
+ )
+ await self.connect()
+ self.client.gate = asyncio.Event()
+ tags = asyncio.create_task(self.app.dispatch("read tags first,second"))
+ await asyncio.wait_for(self.client.started.wait(), 1)
+ competitor = asyncio.create_task(self.app.dispatch("read holding_registers 9"))
+ await asyncio.sleep(0)
+ self.client.gate.set()
+ await asyncio.wait_for(asyncio.gather(tags, competitor), 2)
+ self.assertEqual([args["address"] for _, args in self.client.calls], [1, 2, 9])
+ self.assertEqual(self.client.peak, 1)
+
+ async def test_multi_tag_cancellation_preserves_completed_rows(self):
+ """Cancellation during the second tag retains the first and closes I/O."""
+ for name, address in (("first", 1), ("second", 2), ("third", 3)):
+ await self.app.dispatch(
+ f"tags create {name} holding_register {address} uint16"
+ )
+ await self.connect()
+ second_started = asyncio.Event()
+
+ def reply(name, args):
+ """Suspend the next request after the first tag succeeds."""
+ self.client.gate = asyncio.Event()
+ self.client.started = second_started
+ return response(function_code=3, registers=[42])
+
+ self.client.reply = reply
+ task = asyncio.create_task(self.app.dispatch("read tags first,second,third"))
+ await asyncio.wait_for(second_started.wait(), 1)
+ task.cancel()
+ with self.assertRaises(CommandError) as caught:
+ await asyncio.wait_for(task, 2)
+ output = str(caught.exception)
+ for text in (
+ "cancelled",
+ "failed at 'second'",
+ "first",
+ "42",
+ "1 tags completed",
+ ):
+ self.assertIn(text, output)
+ self.assertFalse(self.app.connection.connected)
+ self.assertFalse(self.app.connection.lock.locked())
+ self.assertEqual(len(self.client.calls), 2)
+
+ async def test_read_all_requires_at_least_one_defined_tag(self):
+ """Report an empty tag project before attempting any device read."""
+ with self.assertRaisesRegex(CommandError, "No tags are defined"):
+ await self.app.dispatch("read tags")
+
+ async def test_export_import_collision_confirmation_and_replace(self):
+ """Round-trip TOML and verify collision rejection, declined prompts, and replace."""
+ await self.app.dispatch("tags create timer holding_register 2 int32")
+ path = Path(self.directory.name) / "my_tags"
+ result = await self.app.dispatch(f"tags export {shlex.quote(str(path))}")
+ exported = path.with_suffix(".toml")
+ self.assertTrue(exported.is_file())
+ self.assertIn('word_order = "little"', exported.read_text(encoding="utf-8"))
+ await self.app.dispatch("tags delete timer")
+ await self.app.dispatch(f"tags import {shlex.quote(str(exported))}")
+ self.assertEqual((await self.app.tags.get("timer")).count, 2)
+
+ with self.assertRaisesRegex(ConfirmationRequired, "timer"):
+ await self.app.dispatch(f"tags import {shlex.quote(str(exported))}")
+ messages = []
+
+ async def decline(message):
+ """Capture the confirmation message and return False without changing tags."""
+ messages.append(message)
+ return False
+
+ result = await self.app.dispatch(
+ f"tags import {shlex.quote(str(exported))}", confirm_callback=decline
+ )
+ self.assertFalse(result.accepted)
+ self.assertIn("--replace", messages[0])
+ result = await self.app.dispatch(
+ f"tags import {shlex.quote(str(exported))} --replace"
+ )
+ self.assertIn("Imported 1 tags", result.output)
+
+ async def test_import_uses_confirmed_data_and_guards_project(self):
+ """A changed file cannot change approved data; prefixes share the guard."""
+ await self.app.dispatch("tags create timer holding_register 2 uint16")
+ path = Path(self.directory.name) / "confirmed.toml"
+ path.write_text(
+ export_tag_document([Tag("timer", "holding_registers", 9, "uint16")])
+ )
+
+ async def approve(message):
+ """Try competing mutations and replace the file before approving."""
+ self.assertIn("timer", message)
+ for command in (
+ "project create other",
+ "tags delete timer",
+ "tags import missing.toml",
+ ):
+ with self.assertRaisesRegex(CommandError, "import is in progress"):
+ await self.app.dispatch(command)
+ path.write_text(
+ export_tag_document([Tag("unexpected", "coils", 0, "bool")])
+ )
+ await self.app.dispatch("help tags import")
+ return True
+
+ await self.app.dispatch(
+ f"tags imp {shlex.quote(str(path))}", confirm_callback=approve
+ )
+ self.assertEqual((await self.app.tags.get("timer")).address, 9)
+ self.assertEqual(await self.app.tags.names(), {"timer"})
+ self.assertIsNone(self.app._tag_import_task)
+ self.assertEqual(self.app._prepared_tag_imports, {})
+ await self.app.dispatch("project create other")
+ self.assertEqual(await self.app.tags.names(), set())
+
+ async def test_import_rejects_an_inflight_tag_edit(self):
+ """Do not confirm against tags while an earlier edit is unfinished."""
+ started, release = asyncio.Event(), asyncio.Event()
+ save = self.app.tags.save
+
+ async def paused_save(tag):
+ """Suspend an existing tag creation before saving its row."""
+ started.set()
+ await release.wait()
+ await save(tag)
+
+ with patch.object(self.app.tags, "save", paused_save):
+ task = asyncio.create_task(self.app.dispatch("tags create new coil 0 bool"))
+ await asyncio.wait_for(started.wait(), 1)
+ try:
+ with self.assertRaisesRegex(CommandError, "Tag edits are in progress"):
+ await self.app.dispatch("tags import missing.toml")
+ with self.assertRaises(CommandError):
+ await self.app.dispatch("project create other")
+ finally:
+ release.set()
+ await task
+ self.assertEqual(await self.app.tags.names(), {"new"})
+
+ async def test_cancelled_import_confirmation_leaves_tags_unchanged(self):
+ """Cancellation releases the project guard without applying any data."""
+ await self.app.dispatch("tags create timer holding_register 2 uint16")
+ path = Path(self.directory.name) / "cancelled.toml"
+ path.write_text(
+ export_tag_document([Tag("timer", "holding_registers", 9, "uint16")])
+ )
+ started = asyncio.Event()
+
+ async def approve(message):
+ """Wait until the caller cancels the pending confirmation."""
+ started.set()
+ await asyncio.Event().wait()
+
+ task = asyncio.create_task(
+ self.app.dispatch(
+ f"tags import {shlex.quote(str(path))}", confirm_callback=approve
+ )
+ )
+ await asyncio.wait_for(started.wait(), 1)
+ task.cancel()
+ with self.assertRaisesRegex(CommandError, "cancelled"):
+ await task
+ self.assertEqual((await self.app.tags.get("timer")).address, 2)
+ self.assertIsNone(self.app._tag_import_task)
+ await self.app.dispatch("tags delete timer")
+
+ async def test_import_transaction_rolls_back_every_row(self):
+ """A later SQL constraint failure cannot leave earlier inserts committed."""
+ await self.app.dispatch("tags create existing coil 0 bool")
+ with self.assertRaises(sqlite3.IntegrityError):
+ await self.app.tags.import_all(
+ [Tag("new", "coils", 1, "bool"), Tag("existing", "coils", 2, "bool")]
+ )
+ # A framework commit afterward must not expose a partial import.
+ await self.app.backend.touch()
+ self.assertEqual(await self.app.tags.names(), {"existing"})
+ self.assertEqual((await self.app.tags.get("existing")).address, 0)
+
+ async def test_cancel_before_import_transaction_writes_nothing(self):
+ """Cancelling before the short transaction starts leaves no imported rows."""
+ await self.app.tags.ensure()
+ started = asyncio.Event()
+
+ async def wait_before_transaction():
+ """Expose a cancellation point before any data mutation."""
+ started.set()
+ await asyncio.Event().wait()
+
+ with patch.object(
+ self.app.tags, "ensure", AsyncMock(side_effect=wait_before_transaction)
+ ):
+ task = asyncio.create_task(
+ self.app.tags.import_all([Tag("new", "coils", 0, "bool")])
+ )
+ await asyncio.wait_for(started.wait(), 1)
+ task.cancel()
+ with self.assertRaises(asyncio.CancelledError):
+ await task
+ self.assertEqual(await self.app.tags.names(), set())
+
+ def test_import_and_export_use_path_completion(self):
+ """Expose ctui filesystem suggestions on both tag-file path arguments."""
+ for name in ("tags import", "tags export"):
+ with self.subTest(command=name):
+ completer = self.app.commands[name].arguments["path"].completer
+ self.assertIsInstance(completer, PathCompleter)
+
+ async def test_import_is_validated_before_changes(self):
+ """Ensure one malformed definition prevents every imported tag from being saved."""
+ path = Path(self.directory.name) / "bad.toml"
+ path.write_text(
+ 'format = "ctmodbus-tags"\nversion = 1\n'
+ '[tags.good]\ntable = "coils"\naddress = 0\ntype = "bool"\n'
+ '[tags.bad]\ntable = "holding_registers"\naddress = 65535\n'
+ 'type = "float64"\n',
+ encoding="utf-8",
+ )
+ with self.assertRaisesRegex(CommandError, "Invalid tag 'bad'"):
+ await self.app.dispatch(f"tags import {shlex.quote(str(path))}")
+ self.assertEqual(await self.app.tags.list(), [])
diff --git a/tests/test_tls.py b/tests/test_tls.py
new file mode 100644
index 0000000..52292d4
--- /dev/null
+++ b/tests/test_tls.py
@@ -0,0 +1,209 @@
+"""TLS command, trust validation, client identity, and protocol integration."""
+
+import asyncio
+import shutil
+import ssl
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import test_commands
+import test_integration
+from ctui import CommandError
+from pymodbus.server import ModbusTlsServer
+from server import device
+
+from ctmodbus.app import ModbusApp
+from ctmodbus.connection import ConnectionSettings, tls_context
+
+
+class TlsOptionsTests(unittest.IsolatedAsyncioTestCase):
+ """Check TLS command/profile settings with the shared fake-client fixture."""
+
+ asyncSetUp = test_commands.CommandTests.asyncSetUp
+ asyncTearDown = test_commands.CommandTests.asyncTearDown
+
+ async def test_defaults_and_profile_roundtrip(self):
+ """Preserve TLS options across config save/load and explicit start."""
+ await self.app.dispatch("client start tls example.com")
+ self.assertEqual(self.app.connection.settings.port, 802)
+ self.assertFalse(self.app.connection.settings.insecure)
+ await self.app.dispatch("client stop")
+ await self.app.dispatch(
+ 'client start tls example.com --port 1802 --ca-file "root ca.pem" --cert-file client.pem --key-file client.key --insecure'
+ )
+ await self.app.dispatch("client config save secure")
+ settings = self.app.connection.settings
+ await self.app.dispatch("client stop")
+ await self.app.dispatch("client config load secure")
+ await self.app.dispatch("client start")
+ self.assertEqual(settings, self.app.connection.settings)
+ self.assertIn("TLS example.com:1802", self.app.connection_status())
+
+ async def test_invalid_key_option(self):
+ """Reject a client key supplied without a certificate."""
+ with self.assertRaisesRegex(CommandError, "requires cert-file"):
+ await self.app.dispatch(
+ "client start tls example.com --key-file client.key"
+ )
+
+ async def test_tls_opening_guard(self):
+ """Prevent device I/O until the TLS recording session has started."""
+ entered, release = asyncio.Event(), asyncio.Event()
+ original = self.app.records.start_session
+
+ async def delayed(*args, **kwargs):
+ """Signal and suspend session startup until the test releases its gate."""
+ entered.set()
+ await release.wait()
+ return await original(*args, **kwargs)
+
+ with patch.object(self.app.records, "start_session", delayed):
+ task = asyncio.create_task(
+ self.app.dispatch("client start tls example.com")
+ )
+ await entered.wait()
+ with self.assertRaisesRegex(CommandError, "opening"):
+ await self.app.dispatch("read coils 0")
+ release.set()
+ await task
+
+
+@unittest.skipUnless(shutil.which("openssl"), "TLS integration requires openssl")
+class TlsIntegrationTests(unittest.IsolatedAsyncioTestCase):
+ """Exercise real TLS with ephemeral certificates; skip if OpenSSL is unavailable.
+
+ Each case opens temporary storage and a loopback server. These tests cover
+ trust and identity, not production certificate provisioning.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ """Generate a temporary self-signed localhost certificate; OpenSSL errors fail setup."""
+ cls.certificates = tempfile.TemporaryDirectory()
+ cls.cert = Path(cls.certificates.name) / "server.pem"
+ cls.key = Path(cls.certificates.name) / "server.key"
+ subprocess.run(
+ [
+ "openssl",
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-keyout",
+ str(cls.key),
+ "-out",
+ str(cls.cert),
+ "-days",
+ "2",
+ "-subj",
+ "/CN=localhost",
+ "-addext",
+ "subjectAltName=DNS:localhost",
+ ],
+ check=True,
+ capture_output=True,
+ )
+
+ @classmethod
+ def tearDownClass(cls):
+ """Remove the temporary test certificate and private key."""
+ cls.certificates.cleanup()
+
+ async def asyncSetUp(self):
+ """Open an isolated project and trusted loopback TLS server on an ephemeral port."""
+ self.directory = tempfile.TemporaryDirectory()
+ self.app = ModbusApp(data_dir=self.directory.name)
+ await self.app.backend.open()
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(self.cert, self.key)
+ self.server = ModbusTlsServer(
+ device(), address=("127.0.0.1", 0), sslctx=context
+ )
+ await self.server.serve_forever(background=True)
+ self.port = self.server.transport.sockets[0].getsockname()[1]
+
+ async def asyncTearDown(self):
+ """Stop the app and TLS server, close storage, and remove temporary project data."""
+ await self.app.on_stop()
+ await self.app.backend.close()
+ await self.server.shutdown()
+ self.directory.cleanup()
+
+ async def test_verified_tls_operations(self):
+ """Verify the shared protocol workflow over a trusted TLS connection."""
+ await self.app.dispatch(
+ f'client start tls localhost --port {self.port} --ca-file "{self.cert}"'
+ )
+ await test_integration.TransportTests.exercise(self, self.app)
+
+ async def test_untrusted_certificate_rejected(self):
+ """Reject an untrusted server and clear the failed connection."""
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(
+ f"client start tls localhost --port {self.port} --timeout 0.5"
+ )
+ self.assertIsNone(self.app.connection.client)
+
+ async def test_hostname_mismatch_rejected(self):
+ """Reject a trusted certificate whose hostname does not match the target."""
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(
+ f'client start tls 127.0.0.1 --port {self.port} --ca-file "{self.cert}" --timeout 0.5'
+ )
+ self.assertIsNone(self.app.connection.client)
+
+ async def test_explicit_insecure_connection(self):
+ """Allow protocol I/O only after explicitly disabling certificate verification."""
+ await self.app.dispatch(
+ f"client start tls 127.0.0.1 --port {self.port} --insecure"
+ )
+ result = await self.app.dispatch("read coils 0")
+ self.assertIn("Read coils", result.output)
+
+ async def test_missing_certificate_file(self):
+ """Reject a missing trust file and leave no open client."""
+ with self.assertRaises(CommandError):
+ await self.app.dispatch(
+ f'client start tls localhost --port {self.port} --ca-file "{self.cert}.missing"'
+ )
+ self.assertIsNone(self.app.connection.client)
+
+ async def test_client_certificate(self):
+ # Require a client identity, using the ephemeral test certificate as
+ # both the trust anchor and the client identity for this local fixture.
+ """Require and exercise mutual TLS using the local ephemeral identity."""
+ await self.server.shutdown()
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(self.cert, self.key)
+ context.load_verify_locations(self.cert)
+ context.verify_mode = ssl.CERT_REQUIRED
+ self.server = ModbusTlsServer(
+ device(), address=("127.0.0.1", 0), sslctx=context
+ )
+ await self.server.serve_forever(background=True)
+ port = self.server.transport.sockets[0].getsockname()[1]
+ await self.app.dispatch(
+ f'client start tls localhost --port {port} --ca-file "{self.cert}" --cert-file "{self.cert}" --key-file "{self.key}"'
+ )
+ result = await self.app.dispatch("read coils 0")
+ self.assertIn("Read coils", result.output)
+
+
+class TlsContextTests(unittest.TestCase):
+ """Check TLS context defaults and reject invalid profile option combinations."""
+
+ def test_secure_defaults(self):
+ """Require certificate trust and hostname checks in a default TLS context."""
+ context = tls_context(ConnectionSettings("tls", "localhost", port=802))
+ self.assertTrue(context.check_hostname)
+ self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
+
+ def test_invalid_profile_options(self):
+ """Reject malformed TLS option types and key-only profile settings."""
+ for values in ({"insecure": "false"}, {"ca_file": 42}, {"key_file": "key"}):
+ with self.subTest(values=values), self.assertRaises(CommandError):
+ ConnectionSettings("tls", "localhost", **values)
diff --git a/tests/test_tui.py b/tests/test_tui.py
new file mode 100644
index 0000000..de3dac6
--- /dev/null
+++ b/tests/test_tui.py
@@ -0,0 +1,94 @@
+"""Exercise the actual ctui terminal runtime with an injected terminal."""
+
+import asyncio
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from prompt_toolkit.application import create_app_session
+from prompt_toolkit.input import create_pipe_input
+from prompt_toolkit.output import DummyOutput
+from test_commands import FakeClient
+
+from ctmodbus.app import ModbusApp
+
+
+class TerminalTests(unittest.IsolatedAsyncioTestCase):
+ """Drive the real TUI with pipe input and dummy output, without a physical terminal."""
+
+ async def test_terminal_commands_and_shutdown(self):
+ """Await command completion events and verify exit closes the fake connection."""
+ ready = asyncio.Event()
+ finished = asyncio.Queue()
+
+ class TestApp(ModbusApp):
+ """Expose terminal readiness to the surrounding asynchronous test."""
+
+ async def on_ready(self):
+ """Signal that input may be injected; return None."""
+ ready.set()
+
+ with tempfile.TemporaryDirectory() as path, create_pipe_input() as pipe:
+ client = FakeClient()
+ app = TestApp(data_dir=path, client_factory=lambda settings: client)
+ app.on(
+ "command_finished",
+ lambda command, result: finished.put_nowait(command.name),
+ )
+ with create_app_session(input=pipe, output=DummyOutput()):
+ running = asyncio.create_task(app.run_async())
+ try:
+ await asyncio.wait_for(ready.wait(), 3)
+ for text, name in (
+ ("client start tcp localhost", "client start tcp"),
+ ("read coils 0-2", "read coils"),
+ ("write coils 0 1", "write coils"),
+ ):
+ pipe.send_text(text + "\n")
+ self.assertEqual(
+ await asyncio.wait_for(finished.get(), 3), name
+ )
+ if name == "client start tcp":
+ while app._result_popup_task is None:
+ await asyncio.sleep(0)
+ pipe.send_text("\r")
+ while app._result_popup_task is not None:
+ await asyncio.sleep(0)
+ dialogs = asyncio.Queue()
+
+ async def show_overview(dialog):
+ dialogs.put_nowait(dialog)
+ return None
+
+ previous_output = app.layout.output_field.text
+ with patch("ctui.keybindings.show_dialog", show_overview):
+ for component in ("client", "server", "proxy"):
+ pipe.send_text(component + "\n")
+ self.assertEqual(
+ await asyncio.wait_for(finished.get(), 3), component
+ )
+ await asyncio.wait_for(dialogs.get(), 3)
+ self.assertEqual(
+ app.layout.output_field.text, previous_output
+ )
+ pipe.send_text("poll raw --coils 0-2 --interval 0.01 --count 2\n")
+ self.assertEqual(
+ await asyncio.wait_for(finished.get(), 3), "poll raw"
+ )
+ await asyncio.wait_for(app.poller.task, 3)
+ self.assertIn(
+ "2 | 111",
+ " | ".join(
+ cell.strip()
+ for cell in app.layout.output_field.text.splitlines()[
+ -2
+ ].split("|")
+ ),
+ )
+ pipe.send_text("exit confirm\n")
+ await asyncio.wait_for(running, 3)
+ self.assertFalse(client.connected)
+ finally:
+ if not running.done():
+ app.exit()
+ await running
diff --git a/uv.lock b/uv.lock
index f3aaffd..30e0d65 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,174 +1,234 @@
version = 1
-revision = 3
-requires-python = ">=3.8, <4"
+revision = 5
+requires-python = ">=3.11, <4"
resolution-markers = [
"python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
+ "python_full_version < '3.12'",
]
[[package]]
-name = "astroid"
-version = "3.2.4"
+name = "aiohappyeyeballs"
+version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-dependencies = [
- { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9e/53/1067e1113ecaf58312357f2cd93063674924119d80d173adc3f6f2387aa2/astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", size = 397576, upload-time = "2024-07-20T12:57:43.26Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
]
[[package]]
-name = "astroid"
-version = "3.3.11"
+name = "aiohttp"
+version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
-]
dependencies = [
- { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" }
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" },
+ { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" },
+ { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" },
+ { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" },
+ { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" },
+ { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" },
+ { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" },
+ { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" },
+ { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" },
+ { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" },
+ { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" },
+ { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" },
+ { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" },
+ { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" },
+ { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" },
+ { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" },
+ { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" },
+ { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" },
+ { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" },
+ { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" },
+ { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" },
+ { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" },
+ { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" },
+ { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" },
+ { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" },
+ { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" },
+ { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" },
+ { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1c/06d89f58b2db3ee92dd377217659d587e06f973e57bf0a97a0d8a4586c0c/aiohttp-3.14.5-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da", size = 540674, upload-time = "2026-10-11T01:03:17.483Z" },
+ { url = "https://files.pythonhosted.org/packages/18/39/5e822e038f496f0540ada91e27099d48f9e7f919b6c6deb4cf6db36cc706/aiohttp-3.14.5-cp315-cp315-android_24_x86_64.whl", hash = "sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29", size = 554183, upload-time = "2026-10-11T01:03:19.71Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ff/0cf2619d902b5b160762422a7e5e02091295766a7fe4fcf5b9a655e386c1/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833", size = 523160, upload-time = "2026-10-11T01:03:22.193Z" },
+ { url = "https://files.pythonhosted.org/packages/86/99/3553abfc53a40849dbaacc3f54c730ec58410809ffa2d05885ae56108f2d/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf", size = 530311, upload-time = "2026-10-11T01:03:24.435Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/a4/5d25f73754bc1e8f983ba704e86d641aea290c967f195f2aeebdaed2bd84/aiohttp-3.14.5-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789", size = 540274, upload-time = "2026-10-11T01:03:26.698Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a4/07eda5db2e3ee017d9590f36c12a94ec6f1f50516e8df78672373dfc7185/aiohttp-3.14.5-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f", size = 819239, upload-time = "2026-10-11T01:03:29.481Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/aa/a8723dd987a696dd48d4cf2f0088e589ce77caebff0b96f2a78c20424380/aiohttp-3.14.5-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c", size = 549832, upload-time = "2026-10-11T01:03:32.02Z" },
+ { url = "https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14", size = 549234, upload-time = "2026-10-11T01:03:34.469Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/8e3e07fd8a0d33d06955ff4e54a1cb92f4bce347ff55e441dc3e25a7b5e5/aiohttp-3.14.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11", size = 1891314, upload-time = "2026-10-11T01:03:36.899Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b3/05a79ce2e25f024e93de30c94f39dc6aa6e2bc1e9c531a5c4b18dc61b7a4/aiohttp-3.14.5-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946", size = 1884372, upload-time = "2026-10-11T01:03:39.334Z" },
+ { url = "https://files.pythonhosted.org/packages/94/52/0fd8af0717db109eea258191b326b5cb5847fb88928bfa6c862fd78b9ad3/aiohttp-3.14.5-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1", size = 1930957, upload-time = "2026-10-11T01:03:42.172Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b3/fa78733da88812bf9fb193913fb0ce1548f8b6912047633fdf88a758ff8c/aiohttp-3.14.5-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8", size = 2038886, upload-time = "2026-10-11T01:03:44.646Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c", size = 1894470, upload-time = "2026-10-11T01:03:47.304Z" },
+ { url = "https://files.pythonhosted.org/packages/62/2a/f87feb42abe8e6a7c03814dbcb711540849a1e90aa392055f3183c643610/aiohttp-3.14.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016", size = 1716273, upload-time = "2026-10-11T01:03:50.121Z" },
+ { url = "https://files.pythonhosted.org/packages/81/b2/adf1f960dd977722ed1347d33da512a1624807114f57a3b91f5cc828e081/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd", size = 1857513, upload-time = "2026-10-11T01:03:52.959Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/fd/ef8d910e641de4160026a513ace5888b7f92826bbc3fd90ced05d55a828e/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f", size = 1887704, upload-time = "2026-10-11T01:03:55.641Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/db/6c9142f941cba8d35be8e1fae6ea2bd390e754fc14076b8147aee1a4592f/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412", size = 1895684, upload-time = "2026-10-11T01:03:58.18Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/7c/6a6bd9a72e576d376c668333b00c51c6147aba4fb863ed6c94996d497eca/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4", size = 1706299, upload-time = "2026-10-11T01:04:00.8Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ec/d2cc494242f8c4d3d1cd70baf591742818a74386dc3b84af3195c5c4fced/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee", size = 1916645, upload-time = "2026-10-11T01:04:03.892Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/6e/e852c53647e815db09a1b6b5ab634f54bd736412397e11940feef4d2c89a/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d", size = 1875408, upload-time = "2026-10-11T01:04:06.922Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/f6/72ab6ef20c332399be593bac543d2c24a6d241e39d3540ce9f95a63e4bd2/aiohttp-3.14.5-cp315-cp315-win32.whl", hash = "sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534", size = 485590, upload-time = "2026-10-11T01:04:09.626Z" },
+ { url = "https://files.pythonhosted.org/packages/06/eb/e9de75b8c6d2170c42c08ff303abf857ea8a6d9d9b6e99b5aba40f15e962/aiohttp-3.14.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d", size = 511740, upload-time = "2026-10-11T01:04:12.277Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/25/455f3c2785eb0d50748cffd0abd07500815f419a9495b14610b7622d8d2d/aiohttp-3.14.5-cp315-cp315-win_arm64.whl", hash = "sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a", size = 495171, upload-time = "2026-10-11T01:04:14.667Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/6c/497f0a98782eebfcf0f02a7fbdef5428148bd426027140cbad494cf842b5/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4", size = 855638, upload-time = "2026-10-11T01:04:17.154Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/c5/55c0cef2572af9b1ee81608f7c0a1bf74e6c9151a73b04915d933f192335/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51", size = 567455, upload-time = "2026-10-11T01:04:19.702Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/47/e1a0e39f4a2b881f6071225afaf94a6547001b5aefc1566734d73c685934/aiohttp-3.14.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905", size = 567836, upload-time = "2026-10-11T01:04:22.235Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/1d/817d85836f52b687160064a326e62e037dc42f1ad5b6b5188a70e5c134b5/aiohttp-3.14.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59", size = 2051409, upload-time = "2026-10-11T01:04:24.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/25/e8ea6fc212a9aabce982917346ce8ecab0929c74b60232a056dd11c99da0/aiohttp-3.14.5-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2", size = 1936493, upload-time = "2026-10-11T01:04:27.456Z" },
+ { url = "https://files.pythonhosted.org/packages/24/33/de0517f71f1a19feea4aff78a2ec4ec2d98634129ec30ead78fce2f8f81b/aiohttp-3.14.5-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef", size = 2021405, upload-time = "2026-10-11T01:04:30.276Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/11/ddaf2e7930543e9f0cad3a1c54e1af0c1bd2fae3973d568c4263d3b010e9/aiohttp-3.14.5-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e", size = 2142017, upload-time = "2026-10-11T01:04:33.022Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/7b/58784353c06de8adc20f426daad3d85dd86fd55331c713a3f4b638c94573/aiohttp-3.14.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84", size = 2002482, upload-time = "2026-10-11T01:04:35.94Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/f0/417d9535caa9e165ffe6a53e2347a78107e7c87dcb0e10aca315c3af0344/aiohttp-3.14.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0", size = 1791774, upload-time = "2026-10-11T01:04:38.733Z" },
+ { url = "https://files.pythonhosted.org/packages/98/01/25e49c2e8a01b9f0e19ca0a8448ad50aa2bdf96c8cd41e92bd45af044784/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3", size = 1970358, upload-time = "2026-10-11T01:04:41.66Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/fc/c132fd3465b6c7e4ce0193154f602c3e6c46b680e4da7eb3bd0d8a40d1c7/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc", size = 1969192, upload-time = "2026-10-11T01:04:44.66Z" },
+ { url = "https://files.pythonhosted.org/packages/95/4e/d58b45e7dba4eb607eca11fc0a4aa77ae0f39c11afa804635a61ce18cff4/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57", size = 1963618, upload-time = "2026-10-11T01:04:47.505Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/d9/f6ac50946efb3490428ef52b56e62c1c6b7f9c6ff3ec6083535526c83e60/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9", size = 1784817, upload-time = "2026-10-11T01:04:50.287Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/2f/f255eb63da788cd8a452fe350869c03266ccad7d884925f6963c87808f24/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2", size = 1995956, upload-time = "2026-10-11T01:04:53.213Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9b/241aa3393eaafda0034470add1625f82a4c252901108723b283a9b0b32ca/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79", size = 1967794, upload-time = "2026-10-11T01:04:55.958Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/7f/a68e689288c9e4bfcf8d0979f2b12b774bf01861985420df6150eba5448e/aiohttp-3.14.5-cp315-cp315t-win32.whl", hash = "sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e", size = 503876, upload-time = "2026-10-11T01:04:58.978Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/78/49b0299da6d54de19fc6fdc6889d50233ac47d192a461624f4fc010fde83/aiohttp-3.14.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb", size = 530957, upload-time = "2026-10-11T01:05:02.23Z" },
+ { url = "https://files.pythonhosted.org/packages/21/d4/b0afc936aeb6d2f93157e3408b069ec5d7934429ae7d023de5e0953b1887/aiohttp-3.14.5-cp315-cp315t-win_arm64.whl", hash = "sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b", size = 510408, upload-time = "2026-10-11T01:05:05.362Z" },
+ { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
-name = "astroid"
-version = "4.0.4"
+name = "aiosignal"
+version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
dependencies = [
- { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
-name = "black"
-version = "24.8.0"
+name = "aiosqlite"
+version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-dependencies = [
- { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } },
- { name = "mypy-extensions" },
- { name = "packaging", version = "26.2", source = { registry = "https://pypi.org/simple" } },
- { name = "pathspec", version = "0.12.1", source = { registry = "https://pypi.org/simple" } },
- { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" } },
- { name = "tomli" },
- { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/04/b0/46fb0d4e00372f4a86a6f8efa3cb193c9f64863615e39010b1477e010578/black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f", size = 644810, upload-time = "2024-08-02T17:43:18.405Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/6e/74e29edf1fba3887ed7066930a87f698ffdcd52c5dbc263eabb06061672d/black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6", size = 1632092, upload-time = "2024-08-02T17:47:26.911Z" },
- { url = "https://files.pythonhosted.org/packages/ab/49/575cb6c3faee690b05c9d11ee2e8dba8fbd6d6c134496e644c1feb1b47da/black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb", size = 1457529, upload-time = "2024-08-02T17:47:29.109Z" },
- { url = "https://files.pythonhosted.org/packages/7a/b4/d34099e95c437b53d01c4aa37cf93944b233066eb034ccf7897fa4e5f286/black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42", size = 1757443, upload-time = "2024-08-02T17:46:20.306Z" },
- { url = "https://files.pythonhosted.org/packages/87/a0/6d2e4175ef364b8c4b64f8441ba041ed65c63ea1db2720d61494ac711c15/black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a", size = 1418012, upload-time = "2024-08-02T17:47:20.33Z" },
- { url = "https://files.pythonhosted.org/packages/08/a6/0a3aa89de9c283556146dc6dbda20cd63a9c94160a6fbdebaf0918e4a3e1/black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1", size = 1615080, upload-time = "2024-08-02T17:48:05.467Z" },
- { url = "https://files.pythonhosted.org/packages/db/94/b803d810e14588bb297e565821a947c108390a079e21dbdcb9ab6956cd7a/black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af", size = 1438143, upload-time = "2024-08-02T17:47:30.247Z" },
- { url = "https://files.pythonhosted.org/packages/a5/b5/f485e1bbe31f768e2e5210f52ea3f432256201289fd1a3c0afda693776b0/black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4", size = 1738774, upload-time = "2024-08-02T17:46:17.837Z" },
- { url = "https://files.pythonhosted.org/packages/a8/69/a000fc3736f89d1bdc7f4a879f8aaf516fb03613bb51a0154070383d95d9/black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af", size = 1427503, upload-time = "2024-08-02T17:46:22.654Z" },
- { url = "https://files.pythonhosted.org/packages/a2/a8/05fb14195cfef32b7c8d4585a44b7499c2a4b205e1662c427b941ed87054/black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368", size = 1646132, upload-time = "2024-08-02T17:49:52.843Z" },
- { url = "https://files.pythonhosted.org/packages/41/77/8d9ce42673e5cb9988f6df73c1c5c1d4e9e788053cccd7f5fb14ef100982/black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed", size = 1448665, upload-time = "2024-08-02T17:47:54.479Z" },
- { url = "https://files.pythonhosted.org/packages/cc/94/eff1ddad2ce1d3cc26c162b3693043c6b6b575f538f602f26fe846dfdc75/black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018", size = 1762458, upload-time = "2024-08-02T17:46:19.384Z" },
- { url = "https://files.pythonhosted.org/packages/28/ea/18b8d86a9ca19a6942e4e16759b2fa5fc02bbc0eb33c1b866fcd387640ab/black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2", size = 1436109, upload-time = "2024-08-02T17:46:52.97Z" },
- { url = "https://files.pythonhosted.org/packages/9f/d4/ae03761ddecc1a37d7e743b89cccbcf3317479ff4b88cfd8818079f890d0/black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd", size = 1617322, upload-time = "2024-08-02T17:51:20.203Z" },
- { url = "https://files.pythonhosted.org/packages/14/4b/4dfe67eed7f9b1ddca2ec8e4418ea74f0d1dc84d36ea874d618ffa1af7d4/black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2", size = 1442108, upload-time = "2024-08-02T17:50:40.824Z" },
- { url = "https://files.pythonhosted.org/packages/97/14/95b3f91f857034686cae0e73006b8391d76a8142d339b42970eaaf0416ea/black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e", size = 1745786, upload-time = "2024-08-02T17:46:02.939Z" },
- { url = "https://files.pythonhosted.org/packages/95/54/68b8883c8aa258a6dde958cd5bdfada8382bec47c5162f4a01e66d839af1/black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920", size = 1426754, upload-time = "2024-08-02T17:46:38.603Z" },
- { url = "https://files.pythonhosted.org/packages/13/b2/b3f24fdbb46f0e7ef6238e131f13572ee8279b70f237f221dd168a9dba1a/black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c", size = 1631706, upload-time = "2024-08-02T17:49:57.606Z" },
- { url = "https://files.pythonhosted.org/packages/d9/35/31010981e4a05202a84a3116423970fd1a59d2eda4ac0b3570fbb7029ddc/black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e", size = 1457429, upload-time = "2024-08-02T17:49:12.764Z" },
- { url = "https://files.pythonhosted.org/packages/27/25/3f706b4f044dd569a20a4835c3b733dedea38d83d2ee0beb8178a6d44945/black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47", size = 1756488, upload-time = "2024-08-02T17:46:08.067Z" },
- { url = "https://files.pythonhosted.org/packages/63/72/79375cd8277cbf1c5670914e6bd4c1b15dea2c8f8e906dc21c448d0535f0/black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb", size = 1417721, upload-time = "2024-08-02T17:46:42.637Z" },
- { url = "https://files.pythonhosted.org/packages/27/1e/83fa8a787180e1632c3d831f7e58994d7aaf23a0961320d21e84f922f919/black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed", size = 206504, upload-time = "2024-08-02T17:43:15.747Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
-name = "black"
-version = "25.11.0"
+name = "astroid"
+version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
+sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
-dependencies = [
- { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } },
- { name = "mypy-extensions" },
- { name = "packaging", version = "26.3", source = { registry = "https://pypi.org/simple" } },
- { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" } },
- { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" } },
- { name = "pytokens" },
- { name = "tomli" },
- { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" },
- { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" },
- { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" },
- { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" },
- { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" },
- { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" },
- { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" },
- { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" },
- { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" },
- { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" },
- { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" },
- { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" },
- { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" },
- { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" },
- { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" },
- { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" },
- { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" },
- { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" },
- { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" },
- { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" },
- { url = "https://files.pythonhosted.org/packages/d5/9a/5b2c0e3215fe748fcf515c2dd34658973a1210bf610e24de5ba887e4f1c8/black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06", size = 1743063, upload-time = "2025-11-10T02:02:43.175Z" },
- { url = "https://files.pythonhosted.org/packages/a1/20/245164c6efc27333409c62ba54dcbfbe866c6d1957c9a6c0647786e950da/black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2", size = 1596867, upload-time = "2025-11-10T02:00:17.157Z" },
- { url = "https://files.pythonhosted.org/packages/ca/6f/1a3859a7da205f3d50cf3a8bec6bdc551a91c33ae77a045bb24c1f46ab54/black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc", size = 1655678, upload-time = "2025-11-10T01:57:09.028Z" },
- { url = "https://files.pythonhosted.org/packages/56/1a/6dec1aeb7be90753d4fcc273e69bc18bfd34b353223ed191da33f7519410/black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc", size = 1347452, upload-time = "2025-11-10T01:57:01.871Z" },
- { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" },
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "black"
version = "26.5.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
dependencies = [
- { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" } },
+ { name = "click" },
{ name = "mypy-extensions" },
- { name = "packaging", version = "26.3", source = { registry = "https://pypi.org/simple" } },
- { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" } },
- { name = "platformdirs", version = "4.11.3", source = { registry = "https://pypi.org/simple" } },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
{ name = "pytokens" },
- { name = "tomli", marker = "python_full_version < '3.11'" },
- { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" },
- { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" },
- { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" },
- { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" },
- { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" },
{ url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" },
{ url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" },
@@ -192,31 +252,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
]
-[[package]]
-name = "click"
-version = "8.1.8"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
-]
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
-]
-
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
@@ -236,34 +275,28 @@ wheels = [
[[package]]
name = "ctmodbus"
-version = "0.6.0"
+version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "ctui" },
{ name = "psutil" },
- { name = "pymodbus" },
+ { name = "pymodbus", extra = ["serial"] },
{ name = "pyserial" },
{ name = "tabulate" },
]
[package.dev-dependencies]
dev = [
- { name = "black", version = "24.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
- { name = "black", version = "25.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
- { name = "black", version = "26.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "isort", version = "5.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
- { name = "isort", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
- { name = "isort", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "pylint", version = "3.2.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
- { name = "pylint", version = "3.3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
- { name = "pylint", version = "4.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "black" },
+ { name = "isort" },
+ { name = "pylint" },
]
[package.metadata]
requires-dist = [
- { name = "ctui", specifier = ">=0.8.0,<0.9" },
- { name = "psutil", specifier = ">=5.8.0,<6" },
- { name = "pymodbus", specifier = ">=2.4.0,<3" },
+ { name = "ctui", specifier = "==1.0.0" },
+ { name = "psutil", specifier = ">=7,<8" },
+ { name = "pymodbus", extras = ["serial"], specifier = ">=3.15.0,<3.16" },
{ name = "pyserial", specifier = ">=3.5,<4" },
{ name = "tabulate", specifier = ">=0.8.9,<0.9" },
]
@@ -277,98 +310,147 @@ dev = [
[[package]]
name = "ctui"
-version = "0.8.1"
+version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "prompt-toolkit", version = "3.0.52", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "prompt-toolkit", version = "3.0.53", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
- { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
- { name = "pygments", version = "2.21.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
- { name = "six" },
- { name = "tabulate" },
- { name = "tinydb", version = "4.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
- { name = "tinydb", version = "4.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "aiohttp" },
+ { name = "aiosqlite" },
+ { name = "platformdirs" },
+ { name = "prompt-toolkit" },
+ { name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/26/f5/d0e70e7c78e5e497765409e75df779afd6769d1f274ea66bcde5d3b6d975/ctui-0.8.1.tar.gz", hash = "sha256:13b0028cf8031ea6e521a0ebf6441e261eee9201a883cb8782fa6446f900a62f", size = 15385, upload-time = "2026-08-20T23:14:30.353Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/41/c54aa3902e9ed664903e320a9ef5597a513b5c28874ec147760618a6b7cb/ctui-1.0.0.tar.gz", hash = "sha256:58e219eb58a59cd09ee599beb50111447690b526753ead8282e421217e351fc7", size = 129389, upload-time = "2026-10-24T05:29:52.683Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/e1/5adab45e6fd1b4e14c47ce88a7dd7902e594175fad28bd4a3501ccb9ae46/ctui-0.8.1-py3-none-any.whl", hash = "sha256:ead92d7b480d0efac4c6dd39288c53022137a94b970f505bb825c3fe4fc3bba7", size = 22263, upload-time = "2026-08-20T23:14:29.261Z" },
-]
-
-[[package]]
-name = "dill"
-version = "0.4.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/d339c54fc59c6511c8b8502fd71b3386bfaed5d7cf6e76ec02c85deb9002/ctui-1.0.0-py3-none-any.whl", hash = "sha256:3299fa235d79f5ad7807f3a217d53832ea29b9a67cf35957ecf862434c931eef", size = 126372, upload-time = "2026-10-24T05:29:51.159Z" },
]
[[package]]
name = "dill"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
-]
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
-name = "importlib-metadata"
-version = "8.7.1"
+name = "frozenlist"
+version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "zipp" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
-name = "isort"
-version = "5.13.2"
+name = "idna"
+version = "3.20"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" },
-]
-
-[[package]]
-name = "isort"
-version = "6.1.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
-]
-dependencies = [
- { name = "importlib-metadata" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
@@ -384,131 +466,203 @@ wheels = [
]
[[package]]
-name = "mypy-extensions"
-version = "1.1.0"
+name = "multidict"
+version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341, upload-time = "2026-10-09T20:31:38.279Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/c68c86c078a3b3cfa254bce218fbeaf1f6617d4ace811558154ae63d2c20/multidict-7.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41ff3202cc23c800507777df5a4805b402f262b31008c60fdc652aeb6db2f278", size = 165442, upload-time = "2026-10-09T13:59:19.146Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/988c71ec3a6fe143b2971ac8d57cd5aca7cc6da970bccd1e4c2254997a09/multidict-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e50f7775b66c7802f4cb697e986c5acf30ec07301efee95b396c08114e890d67", size = 95611, upload-time = "2026-10-09T13:59:20.591Z" },
+ { url = "https://files.pythonhosted.org/packages/30/5e/7d18eb5a75cf4bacb6b3ee689929dcb27d7ed8ca2d245e80e9b8056fcbf5/multidict-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85cb3ced4fa84949cee12bfe78208b6ece7baf3cbd242b26dcaf773efff8d206", size = 98743, upload-time = "2026-10-09T13:59:21.841Z" },
+ { url = "https://files.pythonhosted.org/packages/72/38/2aeff3de6ddf235094aad6f833e3df14d1b3175e328748508443cb2b5458/multidict-7.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2128f3358335e0c83688ecb40c19d9d6606cd60784dfbf2e24e980ac2ba87b0d", size = 452832, upload-time = "2026-10-09T13:59:23.131Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1f/94d8b7fc7d47bb43be1fc041bccc2784383d352258d4d0dd90ef55d1a2e5/multidict-7.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2e718fa9d1d900decbc240a533d5d0baf0947ef464c78a8cd4fa32b4e8f590c", size = 475378, upload-time = "2026-10-09T13:59:24.661Z" },
+ { url = "https://files.pythonhosted.org/packages/93/86/78f431ef2735fae7a773261ea26cff11e37cbbb4520bf3caed56ece006f1/multidict-7.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ecc68f5e47bc6f6f889bbed5bc657b22bb2237ad9ccab8229cb5a0d64f4cb536", size = 442847, upload-time = "2026-10-09T13:59:26.276Z" },
+ { url = "https://files.pythonhosted.org/packages/45/d3/1c5af82fe42f677f7c4dcaf487449511d754b84fc29f11a6d190cfb01836/multidict-7.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5f21fda91bd6c34455bd5c312e42aa1334da46cdafb4c533ecd01e0f7f19250b", size = 493652, upload-time = "2026-10-09T13:59:28.162Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bc/414b4a83a12811329c046a7c1954d6f08a5eda63f8466939a6cb612da22b/multidict-7.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a90453a79423cd7145cc08fc92322dcd7aca4862258f533e03f473226d4b835", size = 506991, upload-time = "2026-10-09T13:59:29.701Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ac/a91709d7153489a3b53661b62af371112b96fb083d10443e22d54665a1e1/multidict-7.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c54ae1b89e582aa25f213cd8b5eac0bda1724e79299f486baeb3f562bbf82ca5", size = 480392, upload-time = "2026-10-09T13:59:31.179Z" },
+ { url = "https://files.pythonhosted.org/packages/72/63/a4fc53b6da419876f363fc6860f9fbaa48eb8066f458e907eb2989e9510b/multidict-7.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ff8dd079e7b5f3438332499233a2a5acfca0741fd0eb3d4ddba0c2d9bc04d19", size = 431248, upload-time = "2026-10-09T13:59:32.899Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/78/08af4a624b3b9d2f48af730c173394135d14afbf4b3de5ae6c80e0900fd1/multidict-7.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4ef15d0a29fc2da67fe8ba2301ecabd6f8733696cc2bf0a0cf96a144a20328c", size = 463657, upload-time = "2026-10-09T13:59:34.721Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ef/06e5c75b88506972c880d228fe96702cf0fa058ea31f157f5fcd5efcf006/multidict-7.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5fa296f14068538fced53c6eec86520a2ef3d3d27a0fb134640d03e067986d5f", size = 436120, upload-time = "2026-10-09T13:59:36.135Z" },
+ { url = "https://files.pythonhosted.org/packages/23/8d/a917004a1e8ceb325c4cf0583e418a40b7ff9519cbfe759bc6982cd3b4c1/multidict-7.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ab323f0c5490abaf35a78563e1043c7a772eb86d93f359ecc0fd286d1cd3807", size = 453859, upload-time = "2026-10-09T13:59:37.878Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/0b/d1cc417355625152b611790aa1f0bc780983ed8c0752f44087fc7dbe66c9/multidict-7.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:88ec4d16e9f58071c9896ea01c4da97cce9d01418fe844ff06eebb00e0a1386a", size = 481768, upload-time = "2026-10-09T13:59:39.39Z" },
+ { url = "https://files.pythonhosted.org/packages/94/0b/8e79adf65cf5430497d1020504b7d86ebe90e3289a95bb6b252a630d761f/multidict-7.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9161eb81b8062da824426d3700d4b0d287f0cb0b05923713adfe3bd25e7937ac", size = 426973, upload-time = "2026-10-09T13:59:40.932Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/4c/e84d6e05943600b16cd2745587e65a407f89b3a4a1f77081d456c5a07011/multidict-7.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c564d0758748f38aec56a6b98c6801a427b3a63f39b7cac538b2b2d18ca32740", size = 489681, upload-time = "2026-10-09T13:59:42.663Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/98/01951236a01b3e15c037a4a796f329b5099374e46365d0bc65bb4a7b325d/multidict-7.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c5e4a362a95b85301d262ef6bed06cc8e4a144ac7e2be874cb4c3c46ae89d754", size = 475655, upload-time = "2026-10-09T13:59:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/72/52be318cdb46732da8eb118c2e4212fe570724eaef817901f8b702af2016/multidict-7.1.0-cp311-cp311-win32.whl", hash = "sha256:5d19bb1ec12e385c09215d5d53a243c060c7e8a0aacdba16d933e22902ee380d", size = 81719, upload-time = "2026-10-09T13:59:46.158Z" },
+ { url = "https://files.pythonhosted.org/packages/37/fd/1ed7b7d206ef7a6918ac3de515543ca4b49bc50131325ca1814c005daf3f/multidict-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:396ba9917fe489ec3a5942ae3e29e91324c8b9956f371f7e124c971c71379e7a", size = 93586, upload-time = "2026-10-09T13:59:47.618Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/1e/837877d218bfd2b656ca899ec9dd83e6eb82d8e1ef16e896d5abf3cb60cd/multidict-7.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5ed78742502b8d90ff2816688d407a097c8b5cc6af4343fc5ad7a98df53a7cd", size = 92446, upload-time = "2026-10-09T13:59:48.996Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e1/215e7df354e2907f3af9d910c8136653cfec94796012a776359bc802a326/multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21", size = 165426, upload-time = "2026-10-09T13:59:50.564Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ec/461ba588b308ada2cd16907d6ea425ca4d417596b88c12814ad9a0bb7325/multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4", size = 95943, upload-time = "2026-10-09T13:59:52.381Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/84/31444ef07ec13a33c42c2772d986129e137e69c4beb89ca64f2138988145/multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d", size = 98294, upload-time = "2026-10-09T13:59:54.066Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/10/aca13806d73d88b5b01e35e828e23a346cb1abad710a49dff0507702efc9/multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88", size = 453301, upload-time = "2026-10-09T13:59:55.397Z" },
+ { url = "https://files.pythonhosted.org/packages/96/8c/382d771bfb3a9282d98332d0e1f27fd1f8b4ae0e7175bd7e008ebf934900/multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698", size = 478570, upload-time = "2026-10-09T13:59:56.899Z" },
+ { url = "https://files.pythonhosted.org/packages/52/9c/e81b0c92449da3a1950a575c520d957d7be618777d170717a71e00558d7f/multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711", size = 443699, upload-time = "2026-10-09T13:59:58.465Z" },
+ { url = "https://files.pythonhosted.org/packages/63/72/f8f5fee6960d1b580a7b046c7c5abccf67aa26fa5647927a91c9c835c2f8/multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412", size = 497332, upload-time = "2026-10-09T14:08:01.88Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/92/a25d7db3ca451b588e743e067ee80f2b475edf6467a56908454faf6a714c/multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda", size = 512457, upload-time = "2026-10-09T14:08:25.465Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f3/374c0ab122bb98b1a62a8742b1e3f59563e4d609942005a4041861a0df67/multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01", size = 483000, upload-time = "2026-10-09T14:08:27.132Z" },
+ { url = "https://files.pythonhosted.org/packages/46/1f/01c8522859771dc3cee840d5852233fe84c91aa982a1cd8ad594306d25cc/multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c", size = 430746, upload-time = "2026-10-09T14:08:28.715Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f3/af90affc8cca6b4ee59b2ec354a20839005e829d14d155451009810ef1ce/multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593", size = 466859, upload-time = "2026-10-09T20:27:24.139Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/97/1b6762f37f6331449e164af9d5500f0abb0f23670e81ba543441e0a6be1d/multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c", size = 441952, upload-time = "2026-10-09T20:27:26.559Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/d2/4908177fbf22438799c04ba11a2853a99c69d028fccefe61f19e68caba0c/multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c", size = 452810, upload-time = "2026-10-09T20:27:28.347Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/05/5031f44f680ec54fc182c4d71c9ab7c07216983946aa64ce6fdd56a52692/multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a", size = 483934, upload-time = "2026-10-09T20:27:30.14Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3b/9b21d107dbe96fa7e6966ff9e5e10b9ac0d2d1c2cf1633098e4c700f865e/multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be", size = 427042, upload-time = "2026-10-09T20:27:32.433Z" },
+ { url = "https://files.pythonhosted.org/packages/53/ce/5b01b1041580072866b30e39c6380bff269e72fb5ca41a7f2fb828ede943/multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf", size = 492543, upload-time = "2026-10-09T20:27:34.459Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/6f/6508a23fcc7b1122e4f18409d7900ffeb3cd020cd080fe98aba3eabf9486/multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de", size = 475796, upload-time = "2026-10-09T20:27:36.289Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b6/ebb6433f4aa55ac1fea4bf11e860e99611640d07cc92d21cb3f35608de22/multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb", size = 81250, upload-time = "2026-10-09T20:27:38.229Z" },
+ { url = "https://files.pythonhosted.org/packages/61/0a/11240e5e7d2e986e288f4a6090f569ad00225b7c2c513d4096b36643f9ab/multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085", size = 93817, upload-time = "2026-10-09T20:27:39.739Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9b/a04ffd76db7cca2a60d347e839315e1ada63017c6c343fa90a23a5a55433/multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d", size = 92312, upload-time = "2026-10-09T20:27:41.285Z" },
+ { url = "https://files.pythonhosted.org/packages/60/34/22ee5f60d704e8edfe6e0a37ca1e2d1efe276560dc8f6f8caee35ea7a4f5/multidict-7.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:51d7f33be9a4a1a2801430846d72841deea0894eae8381a07e7d90e0f71b3c4b", size = 101454, upload-time = "2026-10-09T20:27:43.102Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/6d/7652943397171fd624de163d25fc6d2807852931c40c24776cfee3f857cc/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2a964dfeb2aba3663f0536c809aa1ff385f065e89fae57e883fb7edfb4067c2f", size = 95823, upload-time = "2026-10-09T20:27:44.912Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/57/717d1aa4e901f58a7e93b945ed741c860d950f456b75be47123fef507ac2/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d02cd23b5af182a49d635ee72be38053767711987a9fd82625b16b93828a0d8c", size = 97841, upload-time = "2026-10-09T20:27:46.616Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/74/d2d22d306225f3c53ea5a682abb8a43bc30057d6c78b5c94a74035450bfd/multidict-7.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0179698c3c913eb64f32397083747fad20ed0f0a2b7469a08cd1a8a95d14d90e", size = 164661, upload-time = "2026-10-09T20:27:48.495Z" },
+ { url = "https://files.pythonhosted.org/packages/55/f4/1e63fea41ba86768e18dbc1743e6780f75ad32d7a45e418740c6fb64589e/multidict-7.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ccf98ee859fe29f874ddd8e637f14ba59108a333492b521acb885a9095244a9c", size = 95489, upload-time = "2026-10-09T20:27:50.055Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/55/477c351b21b34fe948ffffa8b64cd0c246efd998fed3de922b217e632e59/multidict-7.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5fa1484f74d011addf2e5f5a0378ec41521989839a05d6051d8067d8ce732423", size = 97931, upload-time = "2026-10-09T20:27:51.598Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/dd/288508d7deb9489dd7c8e0b172d62dc1da8f3cb24877293ead42e6cc2047/multidict-7.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fed3d721f75c25a9fcdd0e362af53f4b20acbcdc63081112f85419ba0ce3444", size = 453816, upload-time = "2026-10-09T20:27:53.217Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ed/172447dd09f06111f69d2cf22a018020b34a93a39ddf72cddfdb48323449/multidict-7.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df03e392cae1e05462918abbae06d6100f1e53f67db971ff0ac6c07d9edf7321", size = 477779, upload-time = "2026-10-09T20:27:54.972Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/7d/e5b7755e84611ee846f0dc830cb1363ce10764b29247a01b857722fda653/multidict-7.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:379f477b98a1e9a77ddc3ccaa8c709d3fb4a288ff54b96e171e637b55b4adbae", size = 441299, upload-time = "2026-10-09T20:27:56.901Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/d6/e9c93da1644491610f09258349283421a279fdb3b383929b4d963698691c/multidict-7.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f586bc8a084a3671ddcae9e5fbd3228a596bfb63d9f0380f153f9a65b69f08", size = 496193, upload-time = "2026-10-09T20:27:58.788Z" },
+ { url = "https://files.pythonhosted.org/packages/22/49/7fe19efed1b1c73e2f6ae65eba4e6528eeb26e10c970dc0bb0fc009a2aae/multidict-7.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e7f70d912a589e30290ed926f90ddbc3160998359cbad7c9ede1bcee481748c", size = 511469, upload-time = "2026-10-09T20:28:00.689Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/2e/7b708a72001323dc6b2930834d63e355870ea5b1ee8d1450e0fe1a24752b/multidict-7.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5129cc1f5fec6888e2db0be936dab67242e32c738811c8769aeea93aab4257a8", size = 481377, upload-time = "2026-10-09T20:28:03.011Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/60/3d23e7d2ebc7e7e6b1b205de0d5c2ce0652a9b2edef65eea3528758b5699/multidict-7.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1401caec21fd7f002e79ab6806bbfd1f54bb3de6d5e12bd91c6685dce16ad2be", size = 429992, upload-time = "2026-10-09T20:28:04.846Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/e6/8e3bef78ea1929a3f2c395aa292799d4effd6ce1b5cfce42c1986a33e2b5/multidict-7.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c81062e947f4b5a624135a843f6ac4b3c7fe6508300c9fb27347f022ba0c513d", size = 465635, upload-time = "2026-10-09T20:28:06.527Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6a/55fe5dd90c0e9ce60f5c4fa8ace27eeb1190d9cbb151929b04412f0c673a/multidict-7.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c53be0dd676484a660acc56e4f1cd0dd74bc1255d12fa285e86a3fa9d5f22bf9", size = 441693, upload-time = "2026-10-09T20:28:08.443Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a9/628913f71537dce7dbd6c4ee1ae5019976264427c55354ecc593b4b921b9/multidict-7.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:41e0c3350d08994ee8640c39884e16514e282f70ba40f5b2299582509a327774", size = 453808, upload-time = "2026-10-09T20:28:10.871Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d0/3ae3af653b5776d045f460582006d0b4d7dd41b078b3ab2e874bf4d1e22f/multidict-7.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abeec7a89d698aa1c9b4c36bd5e3c746faef0867076e6a2ca27fa5077c4ece26", size = 483160, upload-time = "2026-10-09T20:28:12.892Z" },
+ { url = "https://files.pythonhosted.org/packages/56/3e/2c13e111b9f4c6216aee0ac57ac39c53db7f97e42bb8490e60a06eaca352/multidict-7.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23f6d325241b0db006ca2841309ed17622137e134930a740a8f1331ec4404791", size = 425896, upload-time = "2026-10-09T20:28:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/45/13/15b4d614cf0d6838a7d89eb14f4820fdecfd1b7f529061d172a674912d41/multidict-7.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:034b0dc1b7fb8279599c5d8563f86abb4d2454735b06544ecab23c54572ad2bd", size = 491674, upload-time = "2026-10-09T20:28:16.508Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/97/3a6f8c75f12a607ff047e3db9a45dff6a558b1e31b89b2f0f27da0fedb6f/multidict-7.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c8074ad4d67067c87bd0663dfda654f786336078c8fd7d2f6c1aa41de8494cc", size = 473989, upload-time = "2026-10-09T20:28:18.284Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/20/5e7726631e50b6afd26324a234851a06104b8ee30f01bc209140788b76c6/multidict-7.1.0-cp313-cp313-win32.whl", hash = "sha256:7b25c335fc53acf29d4d21dbc19fe39d2824201cdda0448623152cc5917bd259", size = 80952, upload-time = "2026-10-09T20:28:20.044Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/19/b26bdbef5bf5071ea5a3ba37c963af187fddb8b0a925bd39c0c5dd0ad04a/multidict-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de54b49e6da811b0321e412d14efdaa1ee0c0b6609296ea5b9022bc5b2bd843", size = 93554, upload-time = "2026-10-09T20:28:21.657Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d5/e9a8600d3868e3fdc4a4be1d15e594048508511c0398423df0ed18c11b5a/multidict-7.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:aeba2c750102051aa51e087c2ccbc79f2724a41c94168f8731e36f54c453551a", size = 92039, upload-time = "2026-10-09T20:28:23.464Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/a7/c9f5c08348a6f903143b631546e22becf1b704a1304b17f7e1b9850308d2/multidict-7.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:128ea4142f81a79d430f3d0eb55206093e5eda03a12abbc7b03c34748ff6116b", size = 100435, upload-time = "2026-10-09T20:28:26.111Z" },
+ { url = "https://files.pythonhosted.org/packages/82/60/92fe617008c74cc019483b1c59202c48738c6f637ad5b12b36f01e21db43/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:439a19f7fbbff232ce96682c57e27030b8ac3a4b8121484c94f04bf99d08bfff", size = 95267, upload-time = "2026-10-09T20:28:27.856Z" },
+ { url = "https://files.pythonhosted.org/packages/17/78/82182f311d673f17de15bc7f7465c7ea5cbd2987ca3a6bf6546fa721e9d8/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8090c35199d6b7bc6426bb8bdaf341e64f295cc2624a1fda7860c0837f1acc03", size = 97233, upload-time = "2026-10-09T20:28:29.817Z" },
+ { url = "https://files.pythonhosted.org/packages/63/25/af4e482d053fd73b4b1d60de3ff5578c6cb1d319d39d3f2a454d65409835/multidict-7.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b7cc5333fcbfb27327d12612ed72322f221b61c2b69deb1155078c964f86e1a1", size = 163070, upload-time = "2026-10-09T20:28:31.495Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d5/eaed52e199451dac445305fb2631d3f4e7ac9536aeef01f23622a1d93f90/multidict-7.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b0e0040b0d8dd89bd0af9ab18901981e344ffba68bb30b8eabb4eab6c303279b", size = 94582, upload-time = "2026-10-09T20:28:33.263Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/e3/ff58ecad5161baa98dec05716e2745449446f2424a9c727464bf452f7fd1/multidict-7.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33376418ab2846b931a72b36cfa16810befc4f49485d0b3f4dc054a4d6d00038", size = 97406, upload-time = "2026-10-09T20:28:34.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/a2/ae4eadf02d4bc035baa7cadf5548ca4fa441517193f76ce1aeb5ed276ae3/multidict-7.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1101aea5c3eb1d26e090b931c693488af0db9f3d52e68be8d4cdd807dad9841d", size = 449908, upload-time = "2026-10-09T20:28:36.659Z" },
+ { url = "https://files.pythonhosted.org/packages/91/55/bd5101ef760d1af4c3f246330b29b48ee22bfdd5a83150b713dd93b431e2/multidict-7.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f996b19ac89e0dae65821ce65f788619e4286f78c62d005ecd3b75b5d9c0892b", size = 471605, upload-time = "2026-10-09T20:28:38.561Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/8f/3dea8a28b0416ab71d47c8ce274bb3cacedde2d9838647ac67cdaa3d48e6/multidict-7.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e0d91a4bcb59ac0d7af0d8e0da737332e1b7fe6831e53e47819b1b5349d431b2", size = 433523, upload-time = "2026-10-09T20:28:40.913Z" },
+ { url = "https://files.pythonhosted.org/packages/24/97/805d2aa4f746cc43cf4b206f1b1bfe2566add95bbb145abd3d9cf61858bf/multidict-7.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e96d67914ddbf5466e4476a1cd7ff30a332cbab85ed895207acc3e58c979b6a7", size = 488277, upload-time = "2026-10-09T20:28:42.851Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/7b/eac247b7e76f6010071c7c2401da5255eeffd1b2ac981925e533413d21ec/multidict-7.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:170ba61761f59ab92afcc86ce5534a3f3d0b07c38b339b950a83213f22dd86ec", size = 501888, upload-time = "2026-10-09T20:28:44.75Z" },
+ { url = "https://files.pythonhosted.org/packages/87/58/de62e27b09dd15756265765945f68f1c0d1e23eaba09e2e109fbb4112425/multidict-7.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33389fe084e5426d9fd85d7d9ca91a29cd0d88a83c7c96e411aca49a3f9967bc", size = 474883, upload-time = "2026-10-09T20:28:46.704Z" },
+ { url = "https://files.pythonhosted.org/packages/21/fd/afb4e50ce3b44707b5ee39c6b5fa1573bdb50c4be660bb35b040362b5170/multidict-7.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77024596b9046572c4e90b34c1ff212346756dc48933f90c53cf6e233660788d", size = 424327, upload-time = "2026-10-09T20:28:48.681Z" },
+ { url = "https://files.pythonhosted.org/packages/23/70/96c9abf933c4edae53b8a1c8d3f038120a3a60d363f16c6d94eee9b04851/multidict-7.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ff15531a376dc6f35984443fd1429e4b150c36ce27633e7cc52a9e5318546e20", size = 459255, upload-time = "2026-10-09T20:28:50.562Z" },
+ { url = "https://files.pythonhosted.org/packages/24/37/dbc1dba26dc1c9e42aa07ee01908131a67fee0e79ebe5c1d0e7fc00aad55/multidict-7.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:544f2642a456fa264614e975d921540ee8c3b368b04d5aa1ddbec33241b13e08", size = 432476, upload-time = "2026-10-09T20:28:52.361Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/0a/eac70cc1461668bad8253a2da727670a56293eaba112aeb4079af8cd37d9/multidict-7.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08834fb8b20e1a985c70e8380a10940234b4162de62694458727330376e58b33", size = 450030, upload-time = "2026-10-09T20:28:54.252Z" },
+ { url = "https://files.pythonhosted.org/packages/23/2d/eb40652ea74f96df859c7c5d38f9997f420b9dc404300dda0c5745327a6f/multidict-7.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b4908e17867930b7ac77f89a18dc67308c67c511f037d8580489be86fb585912", size = 476169, upload-time = "2026-10-09T20:28:56.819Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d0/7454ab8335bc4ec9f8abcc4c83a314bab060f22db6da8436b396e07ed21a/multidict-7.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b24e1f93b9b586ec03bc7bea1bf021ec90bf2528c729195028a3ca1c266b3f9", size = 420459, upload-time = "2026-10-09T20:28:58.744Z" },
+ { url = "https://files.pythonhosted.org/packages/60/7a/cedb46b287b720a2c4eda2448faf162612d0b718beb32581e35ccd9219b3/multidict-7.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5c93473d0d7cd9bbb370973a9679a62f381c7050d7dff4ad6aaa92e8650f5a79", size = 483071, upload-time = "2026-10-09T20:29:01.114Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/11/e7ded22b008546dd30ae8c979aa5ac3282cad98876f7d5bbe93c1f2409a0/multidict-7.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf14cfcc30b097583d698a6e2b8b68c9bcffab277c485d481881958360c2938d", size = 467470, upload-time = "2026-10-09T20:29:03.388Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/f3/0136144cb0b1731fd93475cea75d974a55009358d3bf61dd55e2709a3dca/multidict-7.1.0-cp314-cp314-win32.whl", hash = "sha256:86bc779a0896e59e4be30a5be5cd6eeffd0b40b6f0e75e730218736b7bfc6f5c", size = 80205, upload-time = "2026-10-09T20:29:05.736Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d9/a62a690d780febc171026d3e3c5700fc8387aa3e5f77cd04ada33d23d3f4/multidict-7.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c9fd50f636a8fa9cb6324cd3eac962fec2bc5bb432452a3b583583a1059acfc", size = 92923, upload-time = "2026-10-09T20:29:07.337Z" },
+ { url = "https://files.pythonhosted.org/packages/84/08/eb7a1c34dc37c5f2c6aef52e6861e6f1cdfc6c685dcb72581eb218c00953/multidict-7.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:e6906aa4bc62cde2c8aeb8a99a7b4401b241e274ae7b11df67d863d61ab3d5de", size = 92851, upload-time = "2026-10-09T20:29:09.15Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/3a/019abd746e8fbed9edd74b81259f20b278c011cef918868506d9b5bd6566/multidict-7.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fabfdd4cf97db033196b51af46b8a681d4785c2a66347f2a5af1b4bbb1182629", size = 177429, upload-time = "2026-10-09T20:29:10.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/41/c8dda935231305d9b83c4a4cc5b5b9e323615328d4704f00ba2cf6d89916/multidict-7.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a177a0ee5cf19931dcaeb3f662bc562754cfa4f4ace2351d9da24a954ef7db94", size = 102003, upload-time = "2026-10-09T20:29:12.78Z" },
+ { url = "https://files.pythonhosted.org/packages/af/23/3c3e79a222eaaa59a32648cec34709d848657be5339f96876075731e7f8e/multidict-7.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ae91de396d5c4ac97cb24dbada3d5c91a51454781e0a70476b008f4e879e4f0", size = 104861, upload-time = "2026-10-09T20:29:14.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5b/04637e7cea5729466fb89048520fde167c5404df1beedcc86f18aeabed7c/multidict-7.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:160bdb3520fdadcaa21e1b98aab2e011265070814ecab3804eb61674becbd400", size = 463221, upload-time = "2026-10-09T20:29:16.697Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/42/b121767de213a9774399c200cb877b19c2e1e5a0b0a6bc8407c7325fa37e/multidict-7.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2144785e42527404bbd5cfd11981fee4abe59a22aded0e498eb711a831d3f3", size = 502241, upload-time = "2026-10-09T20:29:18.657Z" },
+ { url = "https://files.pythonhosted.org/packages/47/0a/1cccd17ebf39776df7d8a6c99d066fc3ac1823d044cfe86a22ebbf42b841/multidict-7.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d0b4fec6a8d02d7e95de5cfa913261820f1ce04bd4c0381924de0da523179b8", size = 457128, upload-time = "2026-10-09T20:29:20.589Z" },
+ { url = "https://files.pythonhosted.org/packages/19/a9/0616d20dee16255f8737d9017288d9304dabf3695ac0071baca25d00c713/multidict-7.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:943a9bce22180ad0f4d32d1b402a0949a4ecfe5a1257b47f54a1b51981d81b86", size = 511842, upload-time = "2026-10-09T20:29:22.569Z" },
+ { url = "https://files.pythonhosted.org/packages/91/68/5a406b82ecfeee50c097c05fd519c836a61815d81c4a9a8c523d90fb4b35/multidict-7.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f76ceb623f7ff50df46ac57e1587c479d87a5766319c4f43d0c0a5158896afab", size = 519852, upload-time = "2026-10-09T20:29:24.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/da/d42234f9d4f0e33885c70149112bf62aa4436045090c15cec0810ada0b2f/multidict-7.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98beff85392ce435b28a0971ec21cade61ce8be8b632c9d855475a28ef92d31a", size = 500322, upload-time = "2026-10-09T20:29:26.569Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/c6/323e921a9812a6ea82978d4e1d9713e0b709023be037e6e4c14efe5472eb/multidict-7.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:71196ebb8d523148e5975396a444de02367f204b53b14e26794c96b2be0ed742", size = 444070, upload-time = "2026-10-09T20:29:28.52Z" },
+ { url = "https://files.pythonhosted.org/packages/93/87/109b7170de2168294f3e562f9d10caaaf946628d614b5a57246279acc1a6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35534b366410a36bb3d6f788691e37a76e4d1da48326b0ada3e5032580dd76af", size = 484862, upload-time = "2026-10-09T20:29:30.504Z" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/7f1f63c9cf13f47036c0c64c607e6eee87c94076e3c6876abb6990ee62ad/multidict-7.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4674b12701c3fcbdf7f88b9e4479701c93bec5da9eb576140d5fcc0092990af", size = 457314, upload-time = "2026-10-09T20:29:32.6Z" },
+ { url = "https://files.pythonhosted.org/packages/93/ce/7de8b4ee6a847cc951915c279fc388127ec32a7c14aa7a01c4ad692be575/multidict-7.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ead852a5e906a43fcb6784eeac480f6a67919a51d480c1f80d32ddf9d615475", size = 462672, upload-time = "2026-10-09T20:29:34.776Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/02dd089475983b1f5b8729319caa0b02fa3a60b527f27c86b2d53c24ca11/multidict-7.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:afe36ca503c2ffe30fb6df82b20389fa3c4035b5d65888a61310921cf3ae91c5", size = 498109, upload-time = "2026-10-09T20:29:36.763Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/b9bdc59aa8e3eaf1f9e5d2460bbe0c428a01250d44417f8ccf965a8f6fa6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a5f0bebb10aae010d3c9ee3abaf83ab2069c718457aea09c15532355dd7e061f", size = 442260, upload-time = "2026-10-09T20:29:38.872Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/83/21b044885ab81bf5c03ec5c4c16aa449977e428c5c122e38969481cec1df/multidict-7.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b9d9b7d72975521434368fe8aed3f6b522060bf271adabaa5ca6c87c0c08e168", size = 498916, upload-time = "2026-10-09T20:29:41.091Z" },
+ { url = "https://files.pythonhosted.org/packages/95/8b/31686730092e349ee2255fa630945ca344fc62e76f0b18d65b0f3dbb0ecf/multidict-7.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:274023bf952f849e0d05eba28a4c1f65f9796430d2b09ec16539386c0f76554c", size = 493525, upload-time = "2026-10-09T20:29:43.192Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/d0/c9fddcd7ac42b70a46ac9cf15e21eb527874164e14d418fcb50ce7190a4b/multidict-7.1.0-cp314-cp314t-win32.whl", hash = "sha256:7e0bfa161df365ba3c88899ee3b7c94755200967284bdedef8c1b8b43e2c0f2b", size = 86481, upload-time = "2026-10-09T20:29:45.188Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ab/ce727c06680e72b5d381caa8587f561cb6fea1bfab6ba20c877019768a6f/multidict-7.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34a35be8fb82d37087e8176aba907b9459f03d0e293c80f574c6337a436f4eaa", size = 99676, upload-time = "2026-10-09T20:29:47.441Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e7/d116d7ce514d04d9bf63774ed55e8033e2ce15ab5655a597bb947c53dfb8/multidict-7.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d7dd46a8fcd7653c09ebe67eae9d4cb6636c7a905d9cbaf587dabcbd4eca6013", size = 98726, upload-time = "2026-10-09T20:29:49.365Z" },
+ { url = "https://files.pythonhosted.org/packages/28/42/963e2e38ffd79487b24775841cfe2abb85aa1fa08ee152afc4a9b64e1cdd/multidict-7.1.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:852c921217f330b3e81a822647ebadeae7e42cf503ec1992d0bfbc90121c09fb", size = 100450, upload-time = "2026-10-09T20:29:51.486Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/6e/fbc4721aa0eaea2dff3274ff44e94f60a89841bbb4cdaecd9faa4eb64aa7/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cbec738d2ad551c6f70955d7eec95e339380ee1564e2afe86bfee05fed52ceec", size = 95440, upload-time = "2026-10-09T20:29:53.929Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/045aee749b599560d5348bb1bee5c9d61d8b12f31d16e0594778ef3472de/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a5f721a2437390ab69c10c6df5c142478d399af8dfb02e6d823cf2358e8a4748", size = 97496, upload-time = "2026-10-09T20:29:55.808Z" },
+ { url = "https://files.pythonhosted.org/packages/97/79/f2916b81324d629eba363f760fba26cafef5cb437489c24969ada979508d/multidict-7.1.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:99cf27791129d37e191ff013bfc29bf6631c29edb21680c00978567b91fc5d6b", size = 163241, upload-time = "2026-10-09T20:29:57.813Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f6/2aab2b2d7bf2d69204bde0cdabfefe4df3d1954f3f156bfc53714917e757/multidict-7.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2ba6611fc93c4b169d0e0ea376ebf4b8a529933d1f5f2c2ec7d8f8b93ef58ec2", size = 94586, upload-time = "2026-10-09T20:30:00.201Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f", size = 97573, upload-time = "2026-10-09T20:30:02.675Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2e/951c1412c9803e8f87c8673305be1e448761c940cd867ccfdd3e6a551dfd/multidict-7.1.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf606cfe3f67984b4064ac605d71e1eba12515fbabf5bd5a34a8952b8800dc66", size = 453476, upload-time = "2026-10-09T20:30:04.622Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/55/12f9f95fc65470bf481ad3d1c0e0fc20094720d0af1985119656f2b4be25/multidict-7.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fdfcb03be719d9573597b095b1175d2e9d0b30d065791dfd9fca727c499442", size = 473078, upload-time = "2026-10-09T20:30:06.986Z" },
+ { url = "https://files.pythonhosted.org/packages/87/31/ebe216194a68934de7053d582cde62c839bef4dbc4ae67e9bbccf80a61d2/multidict-7.1.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8bba9d1f6db4ef2a6ebfc937a65d36e80e3aada00b382eaf56fea8f639322d5", size = 435633, upload-time = "2026-10-09T20:30:09.021Z" },
+ { url = "https://files.pythonhosted.org/packages/46/e5/4aea764cd6a1d326d91f2bb92b1c8c39c3f5a8da624e7b20b88179dffb72/multidict-7.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46d4af0afc6eb9867b3ae50605787c80b868e2f52eac3801246034925fe578b8", size = 488656, upload-time = "2026-10-09T20:30:11.119Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f8/b1e5935233fa4b504b1a58b584fcdf03526635e4b5f268f716be9108a1a4/multidict-7.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5cc58ebb731200ddb64d55f1b345630fb5f7a8138cdbd242af9dce964a7cb03d", size = 502311, upload-time = "2026-10-09T20:30:13.396Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc", size = 477470, upload-time = "2026-10-09T20:30:15.894Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a1/11ac1880eacddcf698aa227ba9387cda60128e38034eda182cf4585109ee/multidict-7.1.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f7e5dd83a615636b80182bdf446ece57ed61d5d51854acc5d9840631136d4e", size = 430088, upload-time = "2026-10-09T20:30:18.972Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5a/09926de0ec910704507a21c4f1ad76026aab9a5657ced868c57bbbe9324b/multidict-7.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9bc5e7f843d14a167cdc26fe2d22f6f3aa2feb57919cf3ff034262a57d8d95d0", size = 460632, upload-time = "2026-10-09T20:30:21.45Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/36/cc51eb3ffb09f475326bbf868c6af2805fbbe9175122ce6d7a65aa06d6c1/multidict-7.1.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0631eb5f49f67de10bbdc3f64141326dbc62e8d319900966648381ce0845d8ca", size = 434056, upload-time = "2026-10-09T20:30:23.679Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f4/9e0e4dce595573eba1555495ac9ca6b5f7f6e14cf6c3a5cd87ce5bd33d6f/multidict-7.1.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:d1b1b32f3c32f734dde8f36ac1df8e275e768a7b333241cd637cb2538628a4b4", size = 453014, upload-time = "2026-10-09T20:30:26.192Z" },
+ { url = "https://files.pythonhosted.org/packages/96/14/993a7ac9f3592f5628a12f6bc2495f5cce5814b883170e06f487dede6969/multidict-7.1.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:159976f9c40f96e3fe0952b708846a43a76bacb114e9cc828816f5080bddd5ec", size = 476343, upload-time = "2026-10-09T20:30:28.532Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/da/c2147c1e225a676ee0c97f97bb935fb8a70821bc12df240491bf9f140801/multidict-7.1.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:81a0e08c64dfdad27dab687b96f572b23bafa1999a39d1b6f70b3ddbb73e8bd0", size = 426813, upload-time = "2026-10-09T20:30:31.015Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/fa/8858b260a6662035670759e7b3a7da9decc227296491749073e76202de93/multidict-7.1.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4e11e7299079718c78f8147e7206c22fe35bab4466d38992420795288a0b8096", size = 483882, upload-time = "2026-10-09T20:30:33.509Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/43/d42dc515d56d506f437e8a19c4f604719a306f8d2b60f70a14be34ac9231/multidict-7.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:34d2ee98e15d5cfe782a431bc913fce3b58cf3fdb34fcb437aeb275cdf9007ab", size = 469953, upload-time = "2026-10-09T20:30:35.996Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/bd/1b6fb52f7e082b4d49b069d362169a2e6dbc43800f152f33c8f0e8933117/multidict-7.1.0-cp315-cp315-win32.whl", hash = "sha256:f376224572d1f5da1c871f969ab04765727f180e70d012d93e07bfc08442c64b", size = 80202, upload-time = "2026-10-09T20:30:38.121Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/5dec513dec950825529037703da89a597dc0b157e7b1b8d0304dd9e672cc/multidict-7.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:67fcf28db77b385820881521db7435e9f1c607cfaf07db6eb78aa9d1146bde86", size = 92921, upload-time = "2026-10-09T20:30:40.507Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/eb/fd62025f8cd3dad6f936df8bd7ff2642e2fe3fe0166ef2fc75699ee57cac/multidict-7.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:c7aafa4dd2f702ee2198005d6cba4309c1e25ed1c201d77beddefa47411bead8", size = 92848, upload-time = "2026-10-09T20:30:43.026Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f4/2dcd45731d2f57b87211a1dab1c29a72c6a4a527f370851c3e4cef5c4445/multidict-7.1.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1348ddc076251cd542f4a99ccda4b7c1f8444e8ab489d3541a978ca5901c7c1f", size = 177634, upload-time = "2026-10-09T20:30:45.125Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d6/d57b12a5bb19396a99ae66ec48ff69f764cc2c83e83bd9a003bd5ed5802a/multidict-7.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9267bf8261a779abb2a6eab5f107f5db85b2d1745f2494081c731aaf28738ce3", size = 101954, upload-time = "2026-10-09T20:30:47.416Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8b/374e3f6e4581b8f8425712214cad854ff9370019483aaa49e6207c160adf/multidict-7.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8a844b8b1685f38a2e8b2f3213b286e2a7abfe67508381780a0d4599ac337c1c", size = 105056, upload-time = "2026-10-09T20:30:49.618Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/0a/6c89e2179a84eaa78eec658b9a05f10d3e22654708d7b2e3ebdf0faa0614/multidict-7.1.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18a447d46a3a2f1e61b365cbf5627db7030fdb707dad70c4f2760e5144166ecc", size = 467374, upload-time = "2026-10-09T20:30:51.933Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/62/431ed831b5e9b94c75ed0a410786d07176e879603601570eb25594039200/multidict-7.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88811f890db240a1c82bf0bcd52973763707a552c8113ac3fcebca183afb2fa8", size = 504024, upload-time = "2026-10-09T20:30:54.701Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/dc/52e4204538a7824e407e531b742ff6f7a2ff614dd40382d29a62755699cb/multidict-7.1.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a60b720c329c0007feae692b7bf91cf17b3f9bd3727be96cc6f9a3336651041b", size = 460139, upload-time = "2026-10-09T20:30:57.193Z" },
+ { url = "https://files.pythonhosted.org/packages/22/4c/0228d61a8fee69a7ac4d8d0426aa1810b9c36b679a8c51844de701230073/multidict-7.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b117ed1cd1a23df0902461c38093408b95971833dcee629112acda25b603c8d0", size = 513162, upload-time = "2026-10-09T20:30:59.662Z" },
+ { url = "https://files.pythonhosted.org/packages/56/50/a0b28bf4f036897bbd44c8761fbb384c014c34f621687799d07345b62820/multidict-7.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bbcae7a54050b7ad7bc7bf425ba63dea7d2cd31a92246ba787a2ce69a9b98dbc", size = 520312, upload-time = "2026-10-09T20:31:02.515Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/1d/ba96f77c24174eff9f6521cc7c83958f27598b924a9f7b6aca5d11c73f35/multidict-7.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cba2b0b9235fe10e12301d6b4cfba0f353fa668d635f6e988b03623c2cd42ba", size = 502953, upload-time = "2026-10-09T20:31:05.187Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f2/14c3ffb649cf45a629ec38ca757a493f0caf729e3f3580816dc7779e4caa/multidict-7.1.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:535173fbcc3933d84f9929d49d7a59a0faec259ee07d07c34c7d2a980b4e3683", size = 451497, upload-time = "2026-10-09T20:31:07.597Z" },
+ { url = "https://files.pythonhosted.org/packages/55/df/fa4f8f6ee2314bdd3e4bc830c25f1d8a737188198abeaf105c73454f0b95/multidict-7.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ea027bdeca1d7e498237634ee4e3a852e2723eef39996dec0ff0f77dff8a2336", size = 486212, upload-time = "2026-10-09T20:31:10.178Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5e/3227762b04a8ff1a90359ee2c04af9ed095fe3892e101705fa4233b51514/multidict-7.1.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:507151e1e3dee95e9e8159e329aed4f75aa5205ecd6505a4f6be546890eafbe1", size = 459864, upload-time = "2026-10-09T20:31:13.044Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/04/58524c7e82176438a48704a687fb383cc943dd7075c67b5bea288043b504/multidict-7.1.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9c10791e9f5ef132effc8fdce2009482c1cfb26618c5fc1b7952a47dd5eb632e", size = 465861, upload-time = "2026-10-09T20:31:15.503Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d6/28e549b6ec0c829647de4c75cf32e4c7ea0d0e87b91dfb399292f630c365/multidict-7.1.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f16ac8af2804855d3cae5fc3c5ab609c9fd0fc8ecacd92579c05ed3c173396fd", size = 498865, upload-time = "2026-10-09T20:31:18.548Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ac/3006640586b4d33442c53989b78fc290c8eae5fab5b62a31ac7a4c220e69/multidict-7.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b78de22bae456a976f33df34d598dfd16edc9a03df8f4cc8b7c17bdba4c97b4a", size = 449885, upload-time = "2026-10-09T20:31:21.03Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7e/1ece407cee9f9aff9ace9e44379ed937bccb790936ae448feb79a82c362e/multidict-7.1.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:db77888081431aaa69f3fd3480891746ddce6c2a571f6201869a24e2f06cf423", size = 500237, upload-time = "2026-10-09T20:31:23.611Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4c/c052b700ffcf689454848ee81ba87bedfdc10caff592c0d2a327c43939a5/multidict-7.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c39dfcaa0bf23443474c0cb58d8d8aea9529c1841d99654cb38e4dada7b1948a", size = 495143, upload-time = "2026-10-09T20:31:26.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ef/9ddad94c32fb0bdfc5ad0942d06ae6699bd30d261b0ff59b94810a133238/multidict-7.1.0-cp315-cp315t-win32.whl", hash = "sha256:16b21164797bde6f417066d02775975cc2e15ab8abf80efa55fe85e0b4894020", size = 86526, upload-time = "2026-10-09T20:31:28.734Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ce/2efa1f32a07adfe041d48c6d04c08b6f9db66b8512c765983172399b2855/multidict-7.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:55392202cb374dd1a1f89a8ce1586644870d9e936752059d053e576acc50bc89", size = 99671, upload-time = "2026-10-09T20:31:31.282Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/b3/a44c301fe13716c5f2c08afd89a481018988cde4b337b36e28a2b0fcf3a3/multidict-7.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f979a077d1c0a9a36dd4fab0d3a36b8de7b593bf935e13df85a380395b2c11ad", size = 98893, upload-time = "2026-10-09T20:31:33.62Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087, upload-time = "2026-10-09T20:31:35.945Z" },
]
[[package]]
-name = "packaging"
-version = "26.2"
+name = "mypy-extensions"
+version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
-]
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" }
wheels = [
{ 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" },
]
-[[package]]
-name = "pathspec"
-version = "0.12.1"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
-]
-
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
-]
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
-[[package]]
-name = "platformdirs"
-version = "4.3.6"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" },
-]
-
-[[package]]
-name = "platformdirs"
-version = "4.4.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
-]
-
[[package]]
name = "platformdirs"
version = "4.11.3"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" },
]
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.52"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
-]
-dependencies = [
- { name = "wcwidth" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
-]
-
[[package]]
name = "prompt-toolkit"
version = "3.0.53"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
dependencies = [
{ name = "wcwidth" },
]
@@ -518,110 +672,182 @@ wheels = [
]
[[package]]
-name = "psutil"
-version = "5.9.8"
+name = "propcache"
+version = "0.5.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/90/c7/6dc0a455d111f68ee43f27793971cf03fe29b6ef972042549db29eec39a2/psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c", size = 503247, upload-time = "2024-01-19T20:47:09.517Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/e3/07ae864a636d70a8a6f58da27cb1179192f1140d5d1da10886ade9405797/psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81", size = 248702, upload-time = "2024-01-19T20:47:36.303Z" },
- { url = "https://files.pythonhosted.org/packages/b3/bd/28c5f553667116b2598b9cc55908ec435cb7f77a34f2bff3e3ca765b0f78/psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421", size = 285242, upload-time = "2024-01-19T20:47:39.65Z" },
- { url = "https://files.pythonhosted.org/packages/c5/4f/0e22aaa246f96d6ac87fe5ebb9c5a693fbe8877f537a1022527c47ca43c5/psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4", size = 288191, upload-time = "2024-01-19T20:47:43.078Z" },
- { url = "https://files.pythonhosted.org/packages/6e/f5/2aa3a4acdc1e5940b59d421742356f133185667dd190b166dbcfcf5d7b43/psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0", size = 251252, upload-time = "2024-01-19T20:47:52.88Z" },
- { url = "https://files.pythonhosted.org/packages/93/52/3e39d26feae7df0aa0fd510b14012c3678b36ed068f7d78b8d8784d61f0e/psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf", size = 255090, upload-time = "2024-01-19T20:47:56.019Z" },
- { url = "https://files.pythonhosted.org/packages/05/33/2d74d588408caedd065c2497bdb5ef83ce6082db01289a1e1147f6639802/psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8", size = 249898, upload-time = "2024-01-19T20:47:59.238Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" },
+ { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" },
+ { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" },
+ { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" },
+ { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" },
+ { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" },
+ { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" },
+ { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" },
+ { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" },
+ { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" },
+ { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" },
+ { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" },
+ { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" },
+ { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" },
+ { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" },
+ { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" },
+ { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" },
+ { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" },
+ { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" },
+ { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" },
+ { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" },
+ { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" },
+ { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" },
+ { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" },
+ { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" },
+ { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" },
+ { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" },
+ { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" },
+ { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" },
+ { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" },
+ { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" },
+ { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" },
+ { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" },
+ { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" },
+ { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" },
+ { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" },
+ { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" },
+ { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" },
+ { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" },
+ { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" },
+ { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
]
[[package]]
-name = "pygments"
-version = "2.19.2"
+name = "psutil"
+version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
-]
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" }
wheels = [
{ 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" },
]
-[[package]]
-name = "pylint"
-version = "3.2.7"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-dependencies = [
- { name = "astroid", version = "3.2.4", source = { registry = "https://pypi.org/simple" } },
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "dill", version = "0.4.0", source = { registry = "https://pypi.org/simple" } },
- { name = "isort", version = "5.13.2", source = { registry = "https://pypi.org/simple" } },
- { name = "mccabe" },
- { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" } },
- { name = "tomli" },
- { name = "tomlkit", version = "0.13.3", source = { registry = "https://pypi.org/simple" } },
- { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/cf/e8/d59ce8e54884c9475ed6510685ef4311a10001674c28703b23da30f3b24d/pylint-3.2.7.tar.gz", hash = "sha256:1b7a721b575eaeaa7d39db076b6e7743c993ea44f57979127c517c6c572c803e", size = 1511922, upload-time = "2024-08-31T14:26:26.851Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/42/4d/c73bc0fca447b918611985c325cd7017fb762050eb9c6ac6fa7d9ac6fbe4/pylint-3.2.7-py3-none-any.whl", hash = "sha256:02f4aedeac91be69fb3b4bea997ce580a4ac68ce58b89eaefeaf06749df73f4b", size = 519906, upload-time = "2024-08-31T14:26:24.933Z" },
-]
-
-[[package]]
-name = "pylint"
-version = "3.3.9"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
-]
-dependencies = [
- { name = "astroid", version = "3.3.11", source = { registry = "https://pypi.org/simple" } },
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "dill", version = "0.4.1", source = { registry = "https://pypi.org/simple" } },
- { name = "isort", version = "6.1.0", source = { registry = "https://pypi.org/simple" } },
- { name = "mccabe" },
- { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" } },
- { name = "tomli" },
- { name = "tomlkit", version = "0.15.1", source = { registry = "https://pypi.org/simple" } },
- { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" },
-]
-
[[package]]
name = "pylint"
version = "4.0.7"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
dependencies = [
- { name = "astroid", version = "4.0.4", source = { registry = "https://pypi.org/simple" } },
+ { name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "dill", version = "0.4.1", source = { registry = "https://pypi.org/simple" } },
- { name = "isort", version = "8.0.1", source = { registry = "https://pypi.org/simple" } },
+ { name = "dill" },
+ { name = "isort" },
{ name = "mccabe" },
- { name = "platformdirs", version = "4.11.3", source = { registry = "https://pypi.org/simple" } },
- { name = "tomli", marker = "python_full_version < '3.11'" },
- { name = "tomlkit", version = "0.15.1", source = { registry = "https://pypi.org/simple" } },
+ { name = "platformdirs" },
+ { name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/92/98dace02f2d11b88160354c53944f77ea7327aa78bce1c75971e7aaa4347/pylint-4.0.7.tar.gz", hash = "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52", size = 1594770, upload-time = "2026-08-09T19:13:23.289Z" }
wheels = [
@@ -630,15 +856,16 @@ wheels = [
[[package]]
name = "pymodbus"
-version = "2.5.3"
+version = "3.15.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyserial" },
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/fe/a9/f98aadfcb8d0a34326be68a06a72cc60e04139e8f4fd17633972f8af3b4c/pymodbus-2.5.3.tar.gz", hash = "sha256:5ef68c1a109bdb467c830ef003ef2db6494349a5248e4af946fe21c9eefe7e74", size = 165383, upload-time = "2021-10-17T07:17:24.752Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/b8/03dace16e0e5d1c3eb16e8b9bcce9885f5e8a38db34345ec375cd70ed2e2/pymodbus-3.15.0.tar.gz", hash = "sha256:4c5f715128bfeba59f4c9fb3542b0c32a8afd4b90081111e39c12f7af0c89aae", size = 168238, upload-time = "2026-08-13T09:33:33.049Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/0f/28190d06bbc3b78834d384cb04bb15e5dcdea4f5d370094dbf5debc3af10/pymodbus-2.5.3-py2.py3-none-any.whl", hash = "sha256:6f91403294a73b5a1616a11309f93c0b782115c09379438ef7ebc5bdf86182e9", size = 154549, upload-time = "2021-10-17T07:17:20.313Z" },
+ { url = "https://files.pythonhosted.org/packages/27/7b/fa75ca74270ad3786234c6573f3c6c524441d3eb686882e280c717ce6d04/pymodbus-3.15.0-py3-none-any.whl", hash = "sha256:74d8dee961f23845f18a8481253183504dca05c4fcd102890cd28f4d7ce09267", size = 167602, upload-time = "2026-08-13T09:33:31.474Z" },
+]
+
+[package.optional-dependencies]
+serial = [
+ { name = "pyserial" },
]
[[package]]
@@ -656,11 +883,6 @@ version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" },
- { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" },
- { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" },
- { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" },
- { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" },
{ url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" },
@@ -686,28 +908,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
- { url = "https://files.pythonhosted.org/packages/4a/08/968c22e06ab6570788964e2d5a702db9a3816e20ffde380b2b1385541d64/pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc", size = 154847, upload-time = "2026-01-30T01:03:32.268Z" },
- { url = "https://files.pythonhosted.org/packages/09/2b/2061bb4b300e6921f7968724b185237627a8a3dc4f311e34079dfadf9b65/pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009", size = 238610, upload-time = "2026-01-30T01:03:33.809Z" },
- { url = "https://files.pythonhosted.org/packages/1e/64/abf6e43523ea9b4aea69bfe22788a518806741107238674e5c0fb6fc8dc1/pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1", size = 252493, upload-time = "2026-01-30T01:03:35.715Z" },
- { url = "https://files.pythonhosted.org/packages/dc/fb/bcb6784c87d1de182afb284f37b07bc172eebec91ddc20e83aec767e4963/pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6", size = 255651, upload-time = "2026-01-30T01:03:36.961Z" },
- { url = "https://files.pythonhosted.org/packages/1a/0c/0c33752be2209498661903f6f240779aea5c9adbd85d22336ce3f7718e81/pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037", size = 104346, upload-time = "2026-01-30T01:03:38.069Z" },
- { url = "https://files.pythonhosted.org/packages/51/2a/f125667ce48105bf1f4e50e03cfa7b24b8c4f47684d7f1cf4dcb6f6b1c15/pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3", size = 161464, upload-time = "2026-01-30T01:03:39.11Z" },
- { url = "https://files.pythonhosted.org/packages/40/df/065a30790a7ca6bb48ad9018dd44668ed9135610ebf56a2a4cb8e513fd5c/pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1", size = 246159, upload-time = "2026-01-30T01:03:40.131Z" },
- { url = "https://files.pythonhosted.org/packages/a5/1c/fd09976a7e04960dabc07ab0e0072c7813d566ec67d5490a4c600683c158/pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db", size = 259120, upload-time = "2026-01-30T01:03:41.233Z" },
- { url = "https://files.pythonhosted.org/packages/52/49/59fdc6fc5a390ae9f308eadeb97dfc70fc2d804ffc49dd39fc97604622ec/pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1", size = 262196, upload-time = "2026-01-30T01:03:42.696Z" },
- { url = "https://files.pythonhosted.org/packages/3d/e7/d6734dccf0080e3dc00a55b0827ab5af30c886f8bc127bbc04bc3445daec/pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a", size = 103510, upload-time = "2026-01-30T01:03:43.915Z" },
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
-]
-
[[package]]
name = "tabulate"
version = "0.8.10"
@@ -717,134 +920,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/4e/e5a13fdb3e6f81ce11893523ff289870c87c8f1f289a7369fb0e9840c3bb/tabulate-0.8.10-py3-none-any.whl", hash = "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc", size = 29068, upload-time = "2022-06-21T16:26:37.943Z" },
]
-[[package]]
-name = "tinydb"
-version = "4.8.2"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.9.*'",
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a0/79/4af51e2bb214b6ea58f857c51183d92beba85b23f7ba61c983ab3de56c33/tinydb-4.8.2.tar.gz", hash = "sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d", size = 32566, upload-time = "2024-10-12T15:24:01.13Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/78/17/853354204e1ca022d6b7d011ca7f3206c4f8faa3cc743e92609b49c1d83f/tinydb-4.8.2-py3-none-any.whl", hash = "sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3", size = 24888, upload-time = "2024-10-12T15:23:59.833Z" },
-]
-
-[[package]]
-name = "tinydb"
-version = "4.9.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/01/e9/d30dd8b1b88a75ec3e464eb81feee514ccf97f50bd306d198959e6733210/tinydb-4.9.0.tar.gz", hash = "sha256:6928b1fa785186bda7952a0ba05aaeedc883ede565ca9c7d608de44e5e75de70", size = 31987, upload-time = "2026-08-06T15:03:44.78Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/ef/321cb77b49bc26f36ed1c85d55cd8e80f8f81e5e183b769abf67386c257a/tinydb-4.9.0-py3-none-any.whl", hash = "sha256:111f1f680978a1b7c534d698dd1d76739ff31c74c27fd26a111140b51d8b35d6", size = 25731, upload-time = "2026-08-06T15:03:43.582Z" },
-]
-
-[[package]]
-name = "tomli"
-version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
- { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
- { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
- { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
- { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
- { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
- { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
- { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
- { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
- { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
- { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
- { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
- { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
- { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
- { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
- { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
- { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
- { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
- { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
- { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
- { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
- { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
- { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
- { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
- { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
- { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
- { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
- { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
- { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
- { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
- { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
- { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
- { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
- { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
- { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
- { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
- { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
- { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
- { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
- { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
- { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
- { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
- { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
- { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
-]
-
-[[package]]
-name = "tomlkit"
-version = "0.13.3"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
-]
-
[[package]]
name = "tomlkit"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
-]
sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
]
-[[package]]
-name = "typing-extensions"
-version = "4.13.2"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version < '3.9'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
-]
-
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
- "python_full_version == '3.10.*'",
- "python_full_version == '3.9.*'",
-]
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
@@ -860,10 +948,134 @@ wheels = [
]
[[package]]
-name = "zipp"
-version = "3.23.1"
+name = "yarl"
+version = "1.25.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" },
+ { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
+ { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
+ { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
+ { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
+ { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
+ { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
+ { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
+ { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
+ { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" },
+ { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" },
+ { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" },
+ { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" },
+ { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" },
+ { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" },
+ { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" },
+ { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" },
+ { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" },
+ { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" },
+ { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" },
+ { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" },
+ { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" },
+ { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" },
+ { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" },
+ { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" },
+ { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" },
+ { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" },
+ { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" },
+ { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" },
+ { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" },
+ { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" },
+ { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" },
+ { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" },
+ { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
]