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" }, ]